From 2744c70c5c52be969e7ed8e23cf5b45b95d3c27c Mon Sep 17 00:00:00 2001 From: Ember Moth Date: Sun, 5 Jul 2026 20:27:58 +0800 Subject: [PATCH] Initial --- .gitignore | 1 + Cargo.lock | 5535 +++++++++++++++++ Cargo.toml | 47 + crates/email/Cargo.toml | 16 + crates/email/src/lib.rs | 11 + crates/email/src/manager.rs | 121 + crates/email/src/platform.rs | 54 + crates/email/src/sender.rs | 36 + crates/email/src/smtp.rs | 97 + crates/email/src/template.rs | 175 + crates/email/src/worker.rs | 263 + crates/ip/Cargo.toml | 12 + crates/ip/src/lib.rs | 116 + crates/jwt/Cargo.toml | 8 + crates/jwt/src/lib.rs | 58 + crates/oauth/Cargo.toml | 13 + crates/oauth/src/config.rs | 24 + crates/oauth/src/error.rs | 26 + crates/oauth/src/lib.rs | 98 + crates/oauth/src/telegram.rs | 103 + crates/password/Cargo.toml | 12 + crates/password/src/lib.rs | 231 + crates/payment/Cargo.toml | 16 + crates/payment/src/alipay.rs | 162 + crates/payment/src/epay.rs | 146 + crates/payment/src/error.rs | 28 + crates/payment/src/lib.rs | 10 + crates/payment/src/platform.rs | 146 + crates/payment/src/stripe.rs | 171 + crates/payment/src/types.rs | 105 + crates/result/Cargo.toml | 13 + crates/result/src/code_error.rs | 120 + crates/result/src/error_code.rs | 230 + crates/result/src/http_result.rs | 236 + crates/result/src/lib.rs | 9 + crates/sms/Cargo.toml | 18 + crates/sms/src/config.rs | 38 + crates/sms/src/factory.rs | 13 + crates/sms/src/lib.rs | 10 + crates/sms/src/platform.rs | 33 + crates/sms/src/providers/abosend.rs | 108 + crates/sms/src/providers/alibabacloud.rs | 179 + crates/sms/src/providers/mod.rs | 4 + crates/sms/src/providers/smsbao.rs | 86 + crates/sms/src/providers/twilio.rs | 82 + crates/sms/src/sender.rs | 4 + migrations/mysql/00001_init_schema.sql | 594 ++ migrations/mysql/00002_init_basic_data.sql | 150 + migrations/mysql/02003_update_payment.sql | 146 + migrations/mysql/02004_rebuild_rule.sql | 28 + .../mysql/02005_device_online_record.sql | 124 + .../mysql/02006_reset_subscribe_record.sql | 26 + migrations/mysql/02007_adapte_rule.sql | 9 + migrations/mysql/02100_task.sql | 27 + .../mysql/02101_subscribe_application.sql | 31 + migrations/mysql/02102_subscribe_config.sql | 7 + migrations/mysql/02103_delete_application.sql | 6 + migrations/mysql/02104_system_log.sql | 127 + migrations/mysql/02105_node.sql | 34 + migrations/mysql/02106_subscribe.sql | 16 + migrations/mysql/02107_log_setting.sql | 7 + migrations/mysql/02108_user_referral.sql | 13 + migrations/mysql/02109_node_sort.sql | 7 + migrations/mysql/02110_traffic_log_index.sql | 6 + migrations/mysql/02111_clear_table.sql | 6 + migrations/mysql/02112_subscribe.sql | 10 + migrations/mysql/02113_task.sql | 17 + migrations/mysql/02114_node_config.sql | 11 + migrations/mysql/02115_ads.sql | 24 + migrations/mysql/02116_user_algo.sql | 42 + migrations/mysql/02117_site_custom_data.sql | 18 + migrations/mysql/02118_traffic_log_idx.sql | 6 + .../mysql/02119_user_subscribe_note.sql | 10 + migrations/mysql/02120_user_rules.sql | 10 + migrations/mysql/02121_user_withdrawal.sql | 24 + migrations/mysql/02122_server.sql | 31 + migrations/mysql/02123_subscribe_original.sql | 8 + .../mysql/02124_server_group_delete.sql | 4 + migrations/mysql/02125_subscribe_stock.sql | 11 + migrations/mysql/02126_system_log_idx.sql | 6 + migrations/mysql/02127_search_indexes.sql | 266 + .../mysql/02128_server_config_override.sql | 20 + migrations/mysql/02129_payment_sort.sql | 42 + migrations/mysql/02130_subscribe_tutorial.sql | 10 + .../02131_timestamptz_last_reported_at.sql | 9 + migrations/postgres/00001_init_schema.sql | 494 ++ migrations/postgres/00002_init_basic_data.sql | 135 + migrations/postgres/02003_update_payment.sql | 15 + migrations/postgres/02004_rebuild_rule.sql | 27 + .../postgres/02005_device_online_record.sql | 23 + .../postgres/02006_reset_subscribe_record.sql | 24 + migrations/postgres/02007_adapte_rule.sql | 10 + migrations/postgres/02100_task.sql | 26 + .../postgres/02101_subscribe_application.sql | 37 + .../postgres/02102_subscribe_config.sql | 9 + .../postgres/02103_delete_application.sql | 7 + migrations/postgres/02104_system_log.sql | 108 + migrations/postgres/02105_node.sql | 33 + migrations/postgres/02106_subscribe.sql | 15 + migrations/postgres/02107_log_setting.sql | 8 + migrations/postgres/02108_user_referral.sql | 10 + migrations/postgres/02109_node_sort.sql | 8 + .../postgres/02110_traffic_log_index.sql | 6 + migrations/postgres/02111_clear_table.sql | 8 + migrations/postgres/02112_subscribe.sql | 8 + migrations/postgres/02113_task.sql | 18 + migrations/postgres/02114_node_config.sql | 10 + migrations/postgres/02115_ads.sql | 5 + migrations/postgres/02116_user_algo.sql | 8 + .../postgres/02117_site_custom_data.sql | 18 + migrations/postgres/02118_traffic_log_idx.sql | 6 + .../postgres/02119_user_subscribe_note.sql | 8 + migrations/postgres/02120_user_rules.sql | 8 + migrations/postgres/02121_user_withdrawal.sql | 23 + migrations/postgres/02122_server.sql | 30 + .../postgres/02123_subscribe_original.sql | 8 + .../postgres/02124_server_group_delete.sql | 5 + migrations/postgres/02125_subscribe_stock.sql | 12 + migrations/postgres/02126_system_log_idx.sql | 6 + migrations/postgres/02127_search_indexes.sql | 64 + .../postgres/02128_server_config_override.sql | 18 + migrations/postgres/02129_payment_sort.sql | 12 + .../postgres/02130_subscribe_tutorial.sql | 10 + .../02131_timestamptz_last_reported_at.sql | 10 + src/adapter/mod.rs | 512 ++ src/cache.rs | 109 + src/config/cache_key.rs | 29 + src/config/mod.rs | 715 +++ src/config/protocol.rs | 34 + src/db.rs | 89 + src/handler/admin/ads/create_ads_handler.rs | 17 + src/handler/admin/ads/delete_ads_handler.rs | 17 + .../admin/ads/get_ads_detail_handler.rs | 16 + src/handler/admin/ads/get_ads_list_handler.rs | 16 + src/handler/admin/ads/mod.rs | 10 + src/handler/admin/ads/update_ads_handler.rs | 17 + .../create_announcement_handler.rs | 17 + .../delete_announcement_handler.rs | 17 + .../announcement/get_announcement_handler.rs | 16 + .../get_announcement_list_handler.rs | 16 + src/handler/admin/announcement/mod.rs | 10 + .../update_announcement_handler.rs | 17 + .../create_subscribe_application_handler.rs | 22 + .../delete_subscribe_application_handler.rs | 22 + .../get_subscribe_application_list_handler.rs | 21 + src/handler/admin/application/mod.rs | 10 + .../preview_subscribe_template_handler.rs | 21 + .../update_subscribe_application_handler.rs | 22 + .../get_auth_method_config_handler.rs | 15 + .../get_auth_method_list_handler.rs | 13 + .../auth_method/get_email_platform_handler.rs | 13 + .../auth_method/get_sms_platform_handler.rs | 13 + src/handler/admin/auth_method/mod.rs | 14 + .../auth_method/test_email_send_handler.rs | 21 + .../auth_method/test_sms_send_handler.rs | 21 + .../update_auth_method_config_handler.rs | 16 + src/handler/admin/console/mod.rs | 8 + .../query_revenue_statistics_handler.rs | 14 + .../query_server_total_data_handler.rs | 12 + .../query_ticket_wait_reply_handler.rs | 14 + .../console/query_user_statistics_handler.rs | 17 + .../coupon/batch_delete_coupon_handler.rs | 17 + .../admin/coupon/create_coupon_handler.rs | 17 + .../admin/coupon/delete_coupon_handler.rs | 17 + .../admin/coupon/get_coupon_list_handler.rs | 16 + src/handler/admin/coupon/mod.rs | 10 + .../admin/coupon/update_coupon_handler.rs | 17 + .../document/batch_delete_document_handler.rs | 19 + .../admin/document/create_document_handler.rs | 17 + .../admin/document/delete_document_handler.rs | 17 + .../document/get_document_detail_handler.rs | 18 + .../document/get_document_list_handler.rs | 16 + src/handler/admin/document/mod.rs | 12 + .../admin/document/update_document_handler.rs | 17 + .../admin/log/filter_balance_log_handler.rs | 16 + .../log/filter_commission_log_handler.rs | 16 + .../admin/log/filter_email_log_handler.rs | 14 + .../admin/log/filter_gift_log_handler.rs | 16 + .../admin/log/filter_login_log_handler.rs | 16 + .../admin/log/filter_mobile_log_handler.rs | 14 + .../admin/log/filter_register_log_handler.rs | 16 + .../log/filter_reset_subscribe_log_handler.rs | 16 + .../log/filter_server_traffic_log_handler.rs | 16 + .../admin/log/filter_subscribe_log_handler.rs | 16 + .../log/filter_traffic_log_details_handler.rs | 15 + ...lter_user_subscribe_traffic_log_handler.rs | 15 + .../admin/log/get_log_setting_handler.rs | 13 + .../admin/log/get_message_log_list_handler.rs | 15 + src/handler/admin/log/mod.rs | 30 + .../admin/log/update_log_setting_handler.rs | 16 + .../create_batch_send_email_task_handler.rs | 23 + .../marketing/create_quota_task_handler.rs | 17 + .../get_batch_send_email_task_list_handler.rs | 21 + ...et_batch_send_email_task_status_handler.rs | 22 + .../get_pre_send_email_count_handler.rs | 22 + src/handler/admin/marketing/mod.rs | 18 + .../query_quota_task_list_handler.rs | 16 + .../query_quota_task_pre_count_handler.rs | 22 + .../query_quota_task_status_handler.rs | 19 + .../stop_batch_send_email_task_handler.rs | 22 + src/handler/admin/mod.rs | 18 + .../admin/order/create_order_handler.rs | 17 + .../admin/order/get_order_list_handler.rs | 16 + src/handler/admin/order/mod.rs | 6 + .../order/update_order_status_handler.rs | 17 + .../payment/create_payment_method_handler.rs | 16 + .../payment/delete_payment_method_handler.rs | 16 + .../get_payment_method_list_handler.rs | 15 + .../payment/get_payment_platform_handler.rs | 13 + src/handler/admin/payment/mod.rs | 10 + .../payment/update_payment_method_handler.rs | 16 + src/handler/admin/plugin/api.rs | 1 + src/handler/admin/plugin/detail.rs | 7 + src/handler/admin/plugin/disable.rs | 7 + src/handler/admin/plugin/enable.rs | 7 + src/handler/admin/plugin/list.rs | 7 + src/handler/admin/plugin/mod.rs | 11 + src/handler/admin/plugin/reload.rs | 7 + .../admin/server/create_node_handler.rs | 17 + .../admin/server/create_server_handler.rs | 17 + .../admin/server/delete_node_handler.rs | 17 + .../admin/server/delete_server_handler.rs | 17 + .../admin/server/filter_node_list_handler.rs | 15 + .../server/filter_server_list_handler.rs | 16 + .../server/get_server_node_config_handler.rs | 16 + .../server/get_server_protocols_handler.rs | 16 + src/handler/admin/server/mod.rs | 30 + .../admin/server/query_node_tag_handler.rs | 14 + .../server/reset_sort_with_node_handler.rs | 17 + .../server/reset_sort_with_server_handler.rs | 17 + .../server/toggle_node_status_handler.rs | 17 + .../admin/server/update_node_handler.rs | 17 + .../admin/server/update_server_handler.rs | 17 + .../update_server_node_config_handler.rs | 19 + .../batch_delete_subscribe_group_handler.rs | 17 + .../batch_delete_subscribe_handler.rs | 17 + .../create_subscribe_group_handler.rs | 17 + .../subscribe/create_subscribe_handler.rs | 17 + .../delete_subscribe_group_handler.rs | 17 + .../subscribe/delete_subscribe_handler.rs | 17 + .../get_subscribe_details_handler.rs | 16 + .../get_subscribe_group_list_handler.rs | 14 + .../subscribe/get_subscribe_list_handler.rs | 16 + src/handler/admin/subscribe/mod.rs | 26 + .../reset_all_subscribe_token_handler.rs | 14 + .../admin/subscribe/subscribe_sort_handler.rs | 17 + .../update_subscribe_group_handler.rs | 17 + .../subscribe/update_subscribe_handler.rs | 17 + .../system/get_currency_config_handler.rs | 14 + .../admin/system/get_invite_config_handler.rs | 14 + .../admin/system/get_module_config_handler.rs | 9 + .../admin/system/get_node_config_handler.rs | 14 + .../system/get_node_multiplier_handler.rs | 14 + .../get_privacy_policy_config_handler.rs | 14 + .../system/get_register_config_handler.rs | 14 + .../admin/system/get_site_config_handler.rs | 14 + .../system/get_subscribe_config_handler.rs | 14 + .../admin/system/get_tos_config_handler.rs | 14 + .../system/get_verify_code_config_handler.rs | 14 + .../admin/system/get_verify_config_handler.rs | 14 + src/handler/admin/system/mod.rs | 50 + .../pre_view_node_multiplier_handler.rs | 14 + .../system/set_node_multiplier_handler.rs | 17 + .../system/setting_telegram_bot_handler.rs | 11 + .../system/update_currency_config_handler.rs | 17 + .../system/update_invite_config_handler.rs | 17 + .../system/update_node_config_handler.rs | 17 + .../update_privacy_policy_config_handler.rs | 17 + .../system/update_register_config_handler.rs | 17 + .../system/update_site_config_handler.rs | 17 + .../system/update_subscribe_config_handler.rs | 17 + .../admin/system/update_tos_config_handler.rs | 17 + .../update_verify_code_config_handler.rs | 17 + .../system/update_verify_config_handler.rs | 17 + .../ticket/create_ticket_follow_handler.rs | 23 + .../admin/ticket/get_ticket_handler.rs | 15 + .../admin/ticket/get_ticket_list_handler.rs | 15 + src/handler/admin/ticket/mod.rs | 8 + .../ticket/update_ticket_status_handler.rs | 16 + .../admin/tool/get_system_log_handler.rs | 9 + src/handler/admin/tool/get_version_handler.rs | 9 + src/handler/admin/tool/mod.rs | 8 + .../admin/tool/query_ip_location_handler.rs | 14 + .../admin/tool/restart_system_handler.rs | 9 + .../admin/user/batch_delete_user_handler.rs | 17 + .../user/create_user_auth_method_handler.rs | 17 + src/handler/admin/user/create_user_handler.rs | 17 + .../user/create_user_subscribe_handler.rs | 25 + .../admin/user/current_user_handler.rs | 17 + .../user/delete_user_auth_method_handler.rs | 17 + .../admin/user/delete_user_device_handler.rs | 17 + src/handler/admin/user/delete_user_handler.rs | 17 + .../user/delete_user_subscribe_handler.rs | 17 + .../user/get_user_auth_method_handler.rs | 17 + .../admin/user/get_user_detail_handler.rs | 16 + .../admin/user/get_user_list_handler.rs | 16 + .../admin/user/get_user_login_logs_handler.rs | 16 + .../user/get_user_subscribe_by_id_handler.rs | 16 + .../get_user_subscribe_devices_handler.rs | 16 + .../admin/user/get_user_subscribe_handler.rs | 21 + .../user/get_user_subscribe_logs_handler.rs | 23 + ...er_subscribe_reset_traffic_logs_handler.rs | 23 + ...get_user_subscribe_traffic_logs_handler.rs | 23 + .../kick_offline_by_user_device_handler.rs | 23 + src/handler/admin/user/mod.rs | 56 + .../reset_user_subscribe_token_handler.rs | 22 + .../reset_user_subscribe_traffic_handler.rs | 22 + .../toggle_user_subscribe_status_handler.rs | 22 + .../user/update_user_auth_method_handler.rs | 17 + .../user/update_user_basic_info_handler.rs | 17 + .../admin/user/update_user_device_handler.rs | 29 + .../update_user_notify_setting_handler.rs | 17 + .../user/update_user_subscribe_handler.rs | 36 + src/handler/auth/check_user_handler.rs | 17 + .../auth/check_user_telephone_handler.rs | 17 + src/handler/auth/device_login_handler.rs | 23 + src/handler/auth/mod.rs | 19 + .../oauth/apple_login_callback_handler.rs | 26 + src/handler/auth/oauth/mod.rs | 6 + .../oauth/o_auth_login_get_token_handler.rs | 21 + .../auth/oauth/o_auth_login_handler.rs | 19 + src/handler/auth/reset_password_handler.rs | 24 + src/handler/auth/telephone_login_handler.rs | 24 + .../auth/telephone_reset_password_handler.rs | 28 + .../auth/telephone_user_register_handler.rs | 28 + src/handler/auth/user_login_handler.rs | 25 + src/handler/auth/user_register_handler.rs | 24 + .../common/check_verification_code_handler.rs | 17 + src/handler/common/get_ads_handler.rs | 16 + src/handler/common/get_client_handler.rs | 13 + .../common/get_global_config_handler.rs | 13 + .../common/get_privacy_policy_handler.rs | 13 + src/handler/common/get_stat_handler.rs | 13 + src/handler/common/get_tos_handler.rs | 13 + src/handler/common/heartbeat_handler.rs | 10 + src/handler/common/mod.rs | 20 + src/handler/common/send_email_code_handler.rs | 17 + src/handler/common/send_sms_code_handler.rs | 17 + src/handler/mod.rs | 29 + src/handler/notify/mod.rs | 1 + src/handler/notify/payment_notify_handler.rs | 70 + src/handler/public/announcement/mod.rs | 2 + .../query_announcement_handler.rs | 13 + src/handler/public/document/mod.rs | 4 + .../document/query_document_detail_handler.rs | 20 + .../document/query_document_list_handler.rs | 16 + src/handler/public/mod.rs | 8 + .../public/order/close_order_handler.rs | 21 + src/handler/public/order/mod.rs | 16 + .../public/order/pre_create_order_handler.rs | 21 + src/handler/public/order/purchase_handler.rs | 21 + .../order/query_order_detail_handler.rs | 20 + .../public/order/query_order_list_handler.rs | 20 + src/handler/public/order/recharge_handler.rs | 21 + src/handler/public/order/renewal_handler.rs | 21 + .../public/order/reset_traffic_handler.rs | 21 + .../get_available_payment_methods_handler.rs | 12 + src/handler/public/payment/mod.rs | 2 + .../get_available_payment_methods_handler.rs | 15 + .../public/portal/get_subscription_handler.rs | 17 + src/handler/public/portal/mod.rs | 12 + .../portal/pre_purchase_order_handler.rs | 18 + .../portal/purchase_checkout_handler.rs | 18 + src/handler/public/portal/purchase_handler.rs | 27 + .../portal/query_purchase_order_handler.rs | 20 + src/handler/public/subscribe/mod.rs | 6 + .../query_subscribe_group_list_handler.rs | 12 + .../subscribe/query_subscribe_list_handler.rs | 17 + .../query_user_subscribe_node_list_handler.rs | 18 + .../create_user_ticket_follow_handler.rs | 21 + .../ticket/create_user_ticket_handler.rs | 21 + .../ticket/get_user_ticket_details_handler.rs | 20 + .../ticket/get_user_ticket_list_handler.rs | 20 + src/handler/public/ticket/mod.rs | 10 + .../update_user_ticket_status_handler.rs | 21 + .../user/bind_o_auth_callback_handler.rs | 21 + .../public/user/bind_o_auth_handler.rs | 21 + .../public/user/bind_telegram_handler.rs | 18 + .../user/commission_withdraw_handler.rs | 21 + .../public/user/get_device_list_handler.rs | 18 + .../public/user/get_login_log_handler.rs | 20 + .../public/user/get_o_auth_methods_handler.rs | 18 + .../public/user/get_subscribe_log_handler.rs | 20 + src/handler/public/user/mod.rs | 56 + .../public/user/pre_unsubscribe_handler.rs | 21 + .../user/query_user_affiliate_handler.rs | 18 + .../user/query_user_affiliate_list_handler.rs | 20 + .../user/query_user_balance_log_handler.rs | 20 + .../user/query_user_commission_log_handler.rs | 20 + .../public/user/query_user_info_handler.rs | 18 + .../user/query_user_subscribe_handler.rs | 18 + .../user/query_withdrawal_log_handler.rs | 20 + .../reset_user_subscribe_token_handler.rs | 21 + .../public/user/unbind_device_handler.rs | 21 + .../public/user/unbind_o_auth_handler.rs | 21 + .../public/user/unbind_telegram_handler.rs | 18 + .../public/user/unsubscribe_handler.rs | 21 + .../public/user/update_bind_email_handler.rs | 21 + .../public/user/update_bind_mobile_handler.rs | 21 + .../public/user/update_user_notify_handler.rs | 21 + .../user/update_user_password_handler.rs | 21 + .../public/user/update_user_rules_handler.rs | 21 + .../update_user_subscribe_note_handler.rs | 21 + .../public/user/verify_email_handler.rs | 21 + src/handler/routes.rs | 345 + .../server/get_server_config_handler.rs | 71 + .../server/get_server_user_list_handler.rs | 46 + src/handler/server/helpers.rs | 8 + src/handler/server/mod.rs | 14 + .../server/push_online_users_handler.rs | 35 + .../query_server_protocol_config_handler.rs | 35 + .../server/server_push_status_handler.rs | 35 + .../server_push_user_traffic_handler.rs | 34 + src/handler/subscribe.rs | 63 + src/handler/telegram.rs | 28 + src/main.rs | 198 + src/middleware/auth_middleware.rs | 81 + src/middleware/cors_middleware.rs | 55 + src/middleware/device_middleware.rs | 59 + src/middleware/logger_middleware.rs | 73 + src/middleware/mod.rs | 8 + src/middleware/notify_middleware.rs | 43 + src/middleware/pan_domain_middleware.rs | 111 + src/middleware/server_middleware.rs | 34 + src/middleware/trace_middleware.rs | 12 + src/migration.rs | 194 + src/model/dto/ads.rs | 102 + src/model/dto/announcement.rs | 103 + src/model/dto/application.rs | 130 + src/model/dto/auth.rs | 371 ++ src/model/dto/common.rs | 54 + src/model/dto/coupon.rs | 102 + src/model/dto/document.rs | 106 + src/model/dto/log.rs | 445 ++ src/model/dto/marketing.rs | 185 + src/model/dto/misc.rs | 79 + src/model/dto/mod.rs | 42 + src/model/dto/node.rs | 221 + src/model/dto/order.rs | 345 + src/model/dto/payment.rs | 171 + src/model/dto/protocol.rs | 254 + src/model/dto/server.rs | 314 + src/model/dto/subscribe.rs | 610 ++ src/model/dto/system.rs | 167 + src/model/dto/ticket.rs | 128 + src/model/dto/user.rs | 434 ++ src/model/entity/ads.rs | 17 + src/model/entity/announcement.rs | 13 + src/model/entity/auth.rs | 124 + src/model/entity/client.rs | 33 + src/model/entity/coupon.rs | 20 + src/model/entity/document.rs | 12 + src/model/entity/log.rs | 197 + src/model/entity/mod.rs | 22 + src/model/entity/node.rs | 152 + src/model/entity/order.rs | 42 + src/model/entity/payment.rs | 58 + src/model/entity/subscribe.rs | 48 + src/model/entity/system.rs | 15 + src/model/entity/task.rs | 98 + src/model/entity/ticket.rs | 34 + src/model/entity/traffic.rs | 39 + src/model/entity/user.rs | 105 + src/model/mod.rs | 8 + src/queue/client.rs | 80 + src/queue/handler/email.rs | 22 + src/queue/handler/mod.rs | 88 + src/queue/handler/order.rs | 31 + src/queue/handler/sms.rs | 15 + src/queue/handler/subscription.rs | 20 + src/queue/handler/task.rs | 15 + src/queue/handler/traffic.rs | 22 + src/queue/mod.rs | 56 + src/queue/service/email.rs | 274 + src/queue/service/mod.rs | 7 + src/queue/service/order.rs | 285 + src/queue/service/sms.rs | 99 + src/queue/service/subscription.rs | 173 + src/queue/service/task.rs | 252 + src/queue/service/traffic.rs | 266 + src/queue/types.rs | 30 + src/repository/ads/mod.rs | 19 + src/repository/ads/mysql.rs | 131 + src/repository/ads/pg.rs | 124 + src/repository/announcement/mod.rs | 21 + src/repository/announcement/mysql.rs | 152 + src/repository/announcement/pg.rs | 147 + src/repository/auth/mod.rs | 15 + src/repository/auth/mysql.rs | 88 + src/repository/auth/pg.rs | 78 + src/repository/client/mod.rs | 13 + src/repository/client/mysql.rs | 87 + src/repository/client/pg.rs | 77 + src/repository/coupon/mod.rs | 22 + src/repository/coupon/mysql.rs | 169 + src/repository/coupon/pg.rs | 161 + src/repository/document/mod.rs | 21 + src/repository/document/mysql.rs | 137 + src/repository/document/pg.rs | 129 + src/repository/log/mod.rs | 21 + src/repository/log/mysql.rs | 142 + src/repository/log/pg.rs | 145 + src/repository/mod.rs | 202 + src/repository/node/mod.rs | 65 + src/repository/node/mysql.rs | 442 ++ src/repository/node/pg.rs | 438 ++ src/repository/order/mod.rs | 73 + src/repository/order/mysql.rs | 420 ++ src/repository/order/pg.rs | 414 ++ src/repository/payment/mod.rs | 28 + src/repository/payment/mysql.rs | 174 + src/repository/payment/pg.rs | 170 + src/repository/subscribe/mod.rs | 52 + src/repository/subscribe/mysql.rs | 362 ++ src/repository/subscribe/pg.rs | 375 ++ src/repository/system/mod.rs | 69 + src/repository/system/mysql.rs | 111 + src/repository/system/pg.rs | 101 + src/repository/task/mod.rs | 27 + src/repository/task/mysql.rs | 179 + src/repository/task/pg.rs | 174 + src/repository/ticket/mod.rs | 41 + src/repository/ticket/mysql.rs | 208 + src/repository/ticket/pg.rs | 195 + src/repository/traffic/mod.rs | 73 + src/repository/traffic/mysql.rs | 357 ++ src/repository/traffic/pg.rs | 361 ++ src/repository/user/mod.rs | 279 + src/repository/user/mysql.rs | 1317 ++++ src/repository/user/pg.rs | 1307 ++++ src/scheduler/mod.rs | 94 + src/service/admin/ads/create_ads_service.rs | 48 + src/service/admin/ads/delete_ads_service.rs | 25 + .../admin/ads/get_ads_detail_service.rs | 32 + src/service/admin/ads/get_ads_list_service.rs | 41 + src/service/admin/ads/mod.rs | 5 + src/service/admin/ads/update_ads_service.rs | 36 + .../create_announcement_service.rs | 42 + .../delete_announcement_service.rs | 25 + .../get_announcement_list_service.rs | 40 + .../announcement/get_announcement_service.rs | 29 + src/service/admin/announcement/mod.rs | 5 + .../update_announcement_service.rs | 42 + .../create_subscribe_application_service.rs | 63 + .../delete_subscribe_application_service.rs | 25 + .../get_subscribe_application_list_service.rs | 45 + src/service/admin/application/mod.rs | 5 + .../preview_subscribe_template_service.rs | 21 + .../update_subscribe_application_service.rs | 67 + .../get_auth_method_config_service.rs | 20 + .../get_auth_method_list_service.rs | 95 + .../auth_method/get_email_platform_service.rs | 6 + .../auth_method/get_sms_platform_service.rs | 10 + src/service/admin/auth_method/mod.rs | 7 + .../auth_method/test_email_send_service.rs | 28 + .../auth_method/test_sms_send_service.rs | 24 + .../update_auth_method_config_service.rs | 1 + src/service/admin/console/mod.rs | 4 + .../query_revenue_statistics_service.rs | 77 + .../query_server_total_data_service.rs | 24 + .../query_ticket_wait_reply_service.rs | 14 + .../console/query_user_statistics_service.rs | 95 + .../coupon/batch_delete_coupon_service.rs | 19 + .../admin/coupon/create_coupon_service.rs | 103 + .../admin/coupon/delete_coupon_service.rs | 27 + .../admin/coupon/get_coupon_list_service.rs | 58 + src/service/admin/coupon/mod.rs | 5 + .../admin/coupon/update_coupon_service.rs | 64 + .../document/batch_delete_document_service.rs | 23 + .../admin/document/create_document_service.rs | 58 + .../admin/document/delete_document_service.rs | 27 + .../document/get_document_detail_service.rs | 40 + .../document/get_document_list_service.rs | 50 + src/service/admin/document/mod.rs | 6 + .../admin/document/update_document_service.rs | 43 + .../admin/log/filter_balance_log_service.rs | 89 + .../log/filter_commission_log_service.rs | 50 + .../admin/log/filter_email_log_service.rs | 70 + .../admin/log/filter_gift_log_service.rs | 56 + .../admin/log/filter_login_log_service.rs | 52 + .../admin/log/filter_mobile_log_service.rs | 65 + .../admin/log/filter_register_log_service.rs | 52 + .../log/filter_reset_subscribe_log_service.rs | 50 + .../log/filter_server_traffic_log_service.rs | 52 + .../admin/log/filter_subscribe_log_service.rs | 52 + .../log/filter_traffic_log_details_service.rs | 64 + ...lter_user_subscribe_traffic_log_service.rs | 55 + .../admin/log/get_log_setting_service.rs | 9 + .../admin/log/get_message_log_list_service.rs | 79 + src/service/admin/log/mod.rs | 15 + .../admin/log/update_log_setting_service.rs | 23 + .../create_batch_send_email_task_service.rs | 80 + .../marketing/create_quota_task_service.rs | 52 + .../get_batch_send_email_task_list_service.rs | 76 + ...et_batch_send_email_task_status_service.rs | 28 + .../get_pre_send_email_count_service.rs | 24 + src/service/admin/marketing/mod.rs | 9 + .../query_quota_task_list_service.rs | 74 + .../query_quota_task_pre_count_service.rs | 28 + .../query_quota_task_status_service.rs | 28 + .../stop_batch_send_email_task_service.rs | 31 + src/service/admin/mod.rs | 17 + .../admin/order/create_order_service.rs | 64 + .../admin/order/get_order_list_service.rs | 68 + src/service/admin/order/mod.rs | 3 + .../order/update_order_status_service.rs | 47 + .../payment/create_payment_method_service.rs | 32 + .../payment/delete_payment_method_service.rs | 12 + .../get_payment_method_list_service.rs | 85 + .../payment/get_payment_platform_service.rs | 6 + src/service/admin/payment/mod.rs | 5 + .../payment/update_payment_method_service.rs | 32 + src/service/admin/server/constant.rs | 20 + .../admin/server/create_node_service.rs | 34 + .../admin/server/create_server_service.rs | 37 + .../admin/server/delete_node_service.rs | 25 + .../admin/server/delete_server_service.rs | 25 + .../admin/server/filter_node_list_service.rs | 53 + .../server/filter_server_list_service.rs | 59 + .../server/get_server_node_config_service.rs | 18 + .../server/get_server_protocols_service.rs | 31 + src/service/admin/server/mod.rs | 16 + .../admin/server/query_node_tag_service.rs | 12 + .../server/reset_sort_with_node_service.rs | 19 + .../server/reset_sort_with_server_service.rs | 19 + .../server/toggle_node_status_service.rs | 27 + .../admin/server/update_node_service.rs | 51 + .../update_server_node_config_service.rs | 31 + .../admin/server/update_server_service.rs | 43 + .../batch_delete_subscribe_group_service.rs | 16 + .../batch_delete_subscribe_service.rs | 24 + .../create_subscribe_group_service.rs | 27 + .../subscribe/create_subscribe_service.rs | 58 + .../delete_subscribe_group_service.rs | 25 + .../subscribe/delete_subscribe_service.rs | 25 + .../get_subscribe_details_service.rs | 64 + .../get_subscribe_group_list_service.rs | 42 + .../subscribe/get_subscribe_list_service.rs | 38 + src/service/admin/subscribe/mod.rs | 13 + .../reset_all_subscribe_token_service.rs | 19 + .../admin/subscribe/subscribe_sort_service.rs | 58 + .../update_subscribe_group_service.rs | 42 + .../subscribe/update_subscribe_service.rs | 64 + .../system/get_currency_config_service.rs | 35 + .../admin/system/get_invite_config_service.rs | 39 + .../admin/system/get_module_config_service.rs | 16 + .../admin/system/get_node_config_service.rs | 74 + .../system/get_node_multiplier_service.rs | 36 + .../get_privacy_policy_config_service.rs | 32 + .../system/get_register_config_service.rs | 63 + .../admin/system/get_site_config_service.rs | 43 + .../system/get_subscribe_config_service.rs | 43 + .../admin/system/get_tos_config_service.rs | 30 + .../system/get_verify_code_config_service.rs | 47 + .../admin/system/get_verify_config_service.rs | 45 + src/service/admin/system/mod.rs | 26 + .../pre_view_node_multiplier_service.rs | 16 + .../system/set_node_multiplier_service.rs | 32 + .../system/setting_telegram_bot_service.rs | 45 + src/service/admin/system/update_config.rs | 47 + .../system/update_currency_config_service.rs | 16 + .../system/update_invite_config_service.rs | 28 + .../system/update_node_config_service.rs | 63 + .../update_privacy_policy_config_service.rs | 15 + .../system/update_register_config_service.rs | 45 + .../system/update_site_config_service.rs | 20 + .../system/update_subscribe_config_service.rs | 26 + .../admin/system/update_tos_config_service.rs | 14 + .../update_verify_code_config_service.rs | 34 + .../system/update_verify_config_service.rs | 36 + src/service/admin/ticket/constant.rs | 15 + src/service/admin/ticket/constant_service.rs | 1 + .../ticket/create_ticket_follow_service.rs | 37 + .../admin/ticket/get_ticket_list_service.rs | 76 + .../admin/ticket/get_ticket_service.rs | 35 + src/service/admin/ticket/mod.rs | 5 + .../ticket/update_ticket_status_service.rs | 30 + .../admin/tool/get_system_log_service.rs | 10 + src/service/admin/tool/get_version_service.rs | 9 + src/service/admin/tool/mod.rs | 4 + .../admin/tool/query_ip_location_service.rs | 16 + .../admin/tool/restart_system_service.rs | 6 + .../admin/user/batch_delete_user_service.rs | 25 + .../user/create_user_auth_method_service.rs | 36 + src/service/admin/user/create_user_service.rs | 50 + .../user/create_user_subscribe_service.rs | 54 + .../admin/user/current_user_service.rs | 18 + .../user/delete_user_auth_method_service.rs | 22 + .../admin/user/delete_user_device_service.rs | 18 + src/service/admin/user/delete_user_service.rs | 17 + .../user/delete_user_subscribe_service.rs | 21 + .../user/get_user_auth_method_service.rs | 34 + .../admin/user/get_user_detail_service.rs | 18 + .../admin/user/get_user_list_service.rs | 60 + .../admin/user/get_user_login_logs_service.rs | 31 + .../user/get_user_subscribe_by_id_service.rs | 22 + .../get_user_subscribe_devices_service.rs | 43 + .../user/get_user_subscribe_logs_service.rs | 32 + ...er_subscribe_reset_traffic_logs_service.rs | 32 + .../admin/user/get_user_subscribe_service.rs | 22 + ...get_user_subscribe_traffic_logs_service.rs | 32 + .../kick_offline_by_user_device_service.rs | 42 + src/service/admin/user/mod.rs | 28 + .../reset_user_subscribe_token_service.rs | 38 + .../reset_user_subscribe_traffic_service.rs | 43 + .../toggle_user_subscribe_status_service.rs | 35 + .../user/update_user_auth_method_service.rs | 44 + .../user/update_user_basic_info_service.rs | 55 + .../admin/user/update_user_device_service.rs | 21 + .../update_user_notify_setting_service.rs | 38 + .../user/update_user_subscribe_service.rs | 24 + src/service/auth/bind_device_service.rs | 247 + src/service/auth/check_user_service.rs | 36 + .../auth/check_user_telephone_service.rs | 41 + src/service/auth/device_login_service.rs | 139 + src/service/auth/mod.rs | 12 + .../oauth/apple_login_callback_service.rs | 56 + src/service/auth/oauth/mod.rs | 4 + .../oauth/o_auth_login_get_token_service.rs | 375 ++ .../auth/oauth/o_auth_login_service.rs | 134 + src/service/auth/oauth/trial_cache.rs | 6 + src/service/auth/reset_password_service.rs | 113 + src/service/auth/telephone_login_service.rs | 90 + .../auth/telephone_reset_password_service.rs | 80 + .../auth/telephone_user_register_service.rs | 171 + src/service/auth/trial_cache.rs | 5 + src/service/auth/user_login_service.rs | 71 + src/service/auth/user_register_service.rs | 190 + .../common/check_verification_code_service.rs | 42 + src/service/common/get_ads_service.rs | 32 + src/service/common/get_client_service.rs | 36 + .../common/get_global_config_service.rs | 109 + .../common/get_privacy_policy_service.rs | 21 + src/service/common/get_stat_service.rs | 46 + src/service/common/get_tos_service.rs | 21 + src/service/common/heartbeat_service.rs | 11 + src/service/common/mod.rs | 10 + src/service/common/send_email_code_service.rs | 135 + src/service/common/send_sms_code_service.rs | 126 + src/service/mod.rs | 11 + src/service/nodeconfig/mod.rs | 2 + src/service/nodeconfig/override.rs | 237 + src/service/nodeconfig/override_test.rs | 1 + src/service/notify/alipay_notify_service.rs | 74 + src/service/notify/e_pay_notify_service.rs | 82 + src/service/notify/mod.rs | 3 + src/service/notify/stripe_notify_service.rs | 88 + src/service/public/announcement/mod.rs | 1 + .../query_announcement_service.rs | 27 + src/service/public/document/mod.rs | 2 + .../document/query_document_detail_service.rs | 29 + .../document/query_document_list_service.rs | 31 + src/service/public/mod.rs | 9 + src/service/public/order/calculate_coupon.rs | 97 + src/service/public/order/calculate_fee.rs | 95 + .../public/order/close_order_service.rs | 128 + src/service/public/order/constant.rs | 55 + src/service/public/order/get_discount.rs | 109 + src/service/public/order/mod.rs | 12 + .../public/order/pre_create_order_service.rs | 205 + src/service/public/order/purchase_service.rs | 374 ++ .../order/query_order_detail_service.rs | 106 + .../public/order/query_order_list_service.rs | 126 + src/service/public/order/recharge_service.rs | 135 + src/service/public/order/renewal_service.rs | 270 + .../public/order/reset_traffic_service.rs | 177 + .../get_available_payment_methods_service.rs | 26 + src/service/public/payment/mod.rs | 1 + .../get_available_payment_methods_service.rs | 49 + .../public/portal/get_subscription_service.rs | 95 + src/service/public/portal/mod.rs | 7 + .../portal/pre_purchase_order_service.rs | 101 + .../portal/purchase_checkout_service.rs | 91 + src/service/public/portal/purchase_service.rs | 234 + .../portal/query_purchase_order_service.rs | 102 + src/service/public/portal/tool.rs | 11 + src/service/public/subscribe/mod.rs | 3 + .../query_subscribe_group_list_service.rs | 26 + .../subscribe/query_subscribe_list_service.rs | 34 + .../query_user_subscribe_node_list_service.rs | 75 + src/service/public/ticket/constant.rs | 10 + .../create_user_ticket_follow_service.rs | 89 + .../ticket/create_user_ticket_service.rs | 64 + .../ticket/get_user_ticket_details_service.rs | 72 + .../ticket/get_user_ticket_list_service.rs | 54 + src/service/public/ticket/mod.rs | 6 + .../update_user_ticket_status_service.rs | 61 + .../user/bind_o_auth_callback_service.rs | 58 + .../public/user/bind_o_auth_service.rs | 37 + .../public/user/bind_telegram_service.rs | 40 + .../public/user/calculate_remaining_amount.rs | 23 + .../user/commission_withdraw_service.rs | 65 + .../public/user/get_device_list_service.rs | 51 + .../public/user/get_login_log_service.rs | 67 + .../public/user/get_o_auth_methods_service.rs | 46 + .../public/user/get_subscribe_log_service.rs | 65 + src/service/public/user/mod.rs | 30 + .../public/user/pre_unsubscribe_service.rs | 42 + .../user/query_user_affiliate_list_service.rs | 51 + .../user/query_user_affiliate_service.rs | 47 + .../user/query_user_balance_log_service.rs | 65 + .../user/query_user_commission_log_service.rs | 64 + .../public/user/query_user_info_service.rs | 100 + .../user/query_user_subscribe_logic_test.rs | 1 + .../user/query_user_subscribe_service.rs | 87 + .../user/query_withdrawal_log_service.rs | 45 + .../reset_user_subscribe_token_service.rs | 50 + .../public/user/unbind_device_service.rs | 37 + .../public/user/unbind_o_auth_service.rs | 37 + .../public/user/unbind_telegram_service.rs | 44 + .../public/user/unsubscribe_service.rs | 50 + .../public/user/update_bind_email_service.rs | 49 + .../public/user/update_bind_mobile_service.rs | 54 + .../public/user/update_user_notify_service.rs | 60 + .../user/update_user_password_service.rs | 68 + .../public/user/update_user_rules_service.rs | 49 + .../update_user_subscribe_note_service.rs | 49 + .../public/user/verify_email_service.rs | 51 + src/service/server/constant.rs | 15 + .../server/get_server_config_service.rs | 136 + .../server/get_server_user_list_logic_test.rs | 1 + .../server/get_server_user_list_service.rs | 160 + src/service/server/meta.rs | 26 + src/service/server/mod.rs | 9 + .../server/push_online_users_service.rs | 18 + .../query_server_protocol_config_service.rs | 177 + .../server/server_push_status_service.rs | 19 + .../server_push_user_traffic_service.rs | 76 + src/service/subscribe/mod.rs | 2 + src/service/subscribe/subscribe_service.rs | 196 + src/service/subscribe/user_agent.rs | 74 + src/service/telegram/bot.rs | 125 + src/service/telegram/mod.rs | 3 + src/service/telegram/telegram_service.rs | 182 + src/service/telegram/template.rs | 100 + src/service/telemetry.rs | 373 ++ src/tracing_otel.rs | 118 + 837 files changed, 53059 insertions(+) create mode 100644 .gitignore create mode 100644 Cargo.lock create mode 100644 Cargo.toml create mode 100644 crates/email/Cargo.toml create mode 100644 crates/email/src/lib.rs create mode 100644 crates/email/src/manager.rs create mode 100644 crates/email/src/platform.rs create mode 100644 crates/email/src/sender.rs create mode 100644 crates/email/src/smtp.rs create mode 100644 crates/email/src/template.rs create mode 100644 crates/email/src/worker.rs create mode 100644 crates/ip/Cargo.toml create mode 100644 crates/ip/src/lib.rs create mode 100644 crates/jwt/Cargo.toml create mode 100644 crates/jwt/src/lib.rs create mode 100644 crates/oauth/Cargo.toml create mode 100644 crates/oauth/src/config.rs create mode 100644 crates/oauth/src/error.rs create mode 100644 crates/oauth/src/lib.rs create mode 100644 crates/oauth/src/telegram.rs create mode 100644 crates/password/Cargo.toml create mode 100644 crates/password/src/lib.rs create mode 100644 crates/payment/Cargo.toml create mode 100644 crates/payment/src/alipay.rs create mode 100644 crates/payment/src/epay.rs create mode 100644 crates/payment/src/error.rs create mode 100644 crates/payment/src/lib.rs create mode 100644 crates/payment/src/platform.rs create mode 100644 crates/payment/src/stripe.rs create mode 100644 crates/payment/src/types.rs create mode 100644 crates/result/Cargo.toml create mode 100644 crates/result/src/code_error.rs create mode 100644 crates/result/src/error_code.rs create mode 100644 crates/result/src/http_result.rs create mode 100644 crates/result/src/lib.rs create mode 100644 crates/sms/Cargo.toml create mode 100644 crates/sms/src/config.rs create mode 100644 crates/sms/src/factory.rs create mode 100644 crates/sms/src/lib.rs create mode 100644 crates/sms/src/platform.rs create mode 100644 crates/sms/src/providers/abosend.rs create mode 100644 crates/sms/src/providers/alibabacloud.rs create mode 100644 crates/sms/src/providers/mod.rs create mode 100644 crates/sms/src/providers/smsbao.rs create mode 100644 crates/sms/src/providers/twilio.rs create mode 100644 crates/sms/src/sender.rs create mode 100644 migrations/mysql/00001_init_schema.sql create mode 100644 migrations/mysql/00002_init_basic_data.sql create mode 100644 migrations/mysql/02003_update_payment.sql create mode 100644 migrations/mysql/02004_rebuild_rule.sql create mode 100644 migrations/mysql/02005_device_online_record.sql create mode 100644 migrations/mysql/02006_reset_subscribe_record.sql create mode 100644 migrations/mysql/02007_adapte_rule.sql create mode 100644 migrations/mysql/02100_task.sql create mode 100644 migrations/mysql/02101_subscribe_application.sql create mode 100644 migrations/mysql/02102_subscribe_config.sql create mode 100644 migrations/mysql/02103_delete_application.sql create mode 100644 migrations/mysql/02104_system_log.sql create mode 100644 migrations/mysql/02105_node.sql create mode 100644 migrations/mysql/02106_subscribe.sql create mode 100644 migrations/mysql/02107_log_setting.sql create mode 100644 migrations/mysql/02108_user_referral.sql create mode 100644 migrations/mysql/02109_node_sort.sql create mode 100644 migrations/mysql/02110_traffic_log_index.sql create mode 100644 migrations/mysql/02111_clear_table.sql create mode 100644 migrations/mysql/02112_subscribe.sql create mode 100644 migrations/mysql/02113_task.sql create mode 100644 migrations/mysql/02114_node_config.sql create mode 100644 migrations/mysql/02115_ads.sql create mode 100644 migrations/mysql/02116_user_algo.sql create mode 100644 migrations/mysql/02117_site_custom_data.sql create mode 100644 migrations/mysql/02118_traffic_log_idx.sql create mode 100644 migrations/mysql/02119_user_subscribe_note.sql create mode 100644 migrations/mysql/02120_user_rules.sql create mode 100644 migrations/mysql/02121_user_withdrawal.sql create mode 100644 migrations/mysql/02122_server.sql create mode 100644 migrations/mysql/02123_subscribe_original.sql create mode 100644 migrations/mysql/02124_server_group_delete.sql create mode 100644 migrations/mysql/02125_subscribe_stock.sql create mode 100644 migrations/mysql/02126_system_log_idx.sql create mode 100644 migrations/mysql/02127_search_indexes.sql create mode 100644 migrations/mysql/02128_server_config_override.sql create mode 100644 migrations/mysql/02129_payment_sort.sql create mode 100644 migrations/mysql/02130_subscribe_tutorial.sql create mode 100644 migrations/mysql/02131_timestamptz_last_reported_at.sql create mode 100644 migrations/postgres/00001_init_schema.sql create mode 100644 migrations/postgres/00002_init_basic_data.sql create mode 100644 migrations/postgres/02003_update_payment.sql create mode 100644 migrations/postgres/02004_rebuild_rule.sql create mode 100644 migrations/postgres/02005_device_online_record.sql create mode 100644 migrations/postgres/02006_reset_subscribe_record.sql create mode 100644 migrations/postgres/02007_adapte_rule.sql create mode 100644 migrations/postgres/02100_task.sql create mode 100644 migrations/postgres/02101_subscribe_application.sql create mode 100644 migrations/postgres/02102_subscribe_config.sql create mode 100644 migrations/postgres/02103_delete_application.sql create mode 100644 migrations/postgres/02104_system_log.sql create mode 100644 migrations/postgres/02105_node.sql create mode 100644 migrations/postgres/02106_subscribe.sql create mode 100644 migrations/postgres/02107_log_setting.sql create mode 100644 migrations/postgres/02108_user_referral.sql create mode 100644 migrations/postgres/02109_node_sort.sql create mode 100644 migrations/postgres/02110_traffic_log_index.sql create mode 100644 migrations/postgres/02111_clear_table.sql create mode 100644 migrations/postgres/02112_subscribe.sql create mode 100644 migrations/postgres/02113_task.sql create mode 100644 migrations/postgres/02114_node_config.sql create mode 100644 migrations/postgres/02115_ads.sql create mode 100644 migrations/postgres/02116_user_algo.sql create mode 100644 migrations/postgres/02117_site_custom_data.sql create mode 100644 migrations/postgres/02118_traffic_log_idx.sql create mode 100644 migrations/postgres/02119_user_subscribe_note.sql create mode 100644 migrations/postgres/02120_user_rules.sql create mode 100644 migrations/postgres/02121_user_withdrawal.sql create mode 100644 migrations/postgres/02122_server.sql create mode 100644 migrations/postgres/02123_subscribe_original.sql create mode 100644 migrations/postgres/02124_server_group_delete.sql create mode 100644 migrations/postgres/02125_subscribe_stock.sql create mode 100644 migrations/postgres/02126_system_log_idx.sql create mode 100644 migrations/postgres/02127_search_indexes.sql create mode 100644 migrations/postgres/02128_server_config_override.sql create mode 100644 migrations/postgres/02129_payment_sort.sql create mode 100644 migrations/postgres/02130_subscribe_tutorial.sql create mode 100644 migrations/postgres/02131_timestamptz_last_reported_at.sql create mode 100644 src/adapter/mod.rs create mode 100644 src/cache.rs create mode 100644 src/config/cache_key.rs create mode 100644 src/config/mod.rs create mode 100644 src/config/protocol.rs create mode 100644 src/db.rs create mode 100644 src/handler/admin/ads/create_ads_handler.rs create mode 100644 src/handler/admin/ads/delete_ads_handler.rs create mode 100644 src/handler/admin/ads/get_ads_detail_handler.rs create mode 100644 src/handler/admin/ads/get_ads_list_handler.rs create mode 100644 src/handler/admin/ads/mod.rs create mode 100644 src/handler/admin/ads/update_ads_handler.rs create mode 100644 src/handler/admin/announcement/create_announcement_handler.rs create mode 100644 src/handler/admin/announcement/delete_announcement_handler.rs create mode 100644 src/handler/admin/announcement/get_announcement_handler.rs create mode 100644 src/handler/admin/announcement/get_announcement_list_handler.rs create mode 100644 src/handler/admin/announcement/mod.rs create mode 100644 src/handler/admin/announcement/update_announcement_handler.rs create mode 100644 src/handler/admin/application/create_subscribe_application_handler.rs create mode 100644 src/handler/admin/application/delete_subscribe_application_handler.rs create mode 100644 src/handler/admin/application/get_subscribe_application_list_handler.rs create mode 100644 src/handler/admin/application/mod.rs create mode 100644 src/handler/admin/application/preview_subscribe_template_handler.rs create mode 100644 src/handler/admin/application/update_subscribe_application_handler.rs create mode 100644 src/handler/admin/auth_method/get_auth_method_config_handler.rs create mode 100644 src/handler/admin/auth_method/get_auth_method_list_handler.rs create mode 100644 src/handler/admin/auth_method/get_email_platform_handler.rs create mode 100644 src/handler/admin/auth_method/get_sms_platform_handler.rs create mode 100644 src/handler/admin/auth_method/mod.rs create mode 100644 src/handler/admin/auth_method/test_email_send_handler.rs create mode 100644 src/handler/admin/auth_method/test_sms_send_handler.rs create mode 100644 src/handler/admin/auth_method/update_auth_method_config_handler.rs create mode 100644 src/handler/admin/console/mod.rs create mode 100644 src/handler/admin/console/query_revenue_statistics_handler.rs create mode 100644 src/handler/admin/console/query_server_total_data_handler.rs create mode 100644 src/handler/admin/console/query_ticket_wait_reply_handler.rs create mode 100644 src/handler/admin/console/query_user_statistics_handler.rs create mode 100644 src/handler/admin/coupon/batch_delete_coupon_handler.rs create mode 100644 src/handler/admin/coupon/create_coupon_handler.rs create mode 100644 src/handler/admin/coupon/delete_coupon_handler.rs create mode 100644 src/handler/admin/coupon/get_coupon_list_handler.rs create mode 100644 src/handler/admin/coupon/mod.rs create mode 100644 src/handler/admin/coupon/update_coupon_handler.rs create mode 100644 src/handler/admin/document/batch_delete_document_handler.rs create mode 100644 src/handler/admin/document/create_document_handler.rs create mode 100644 src/handler/admin/document/delete_document_handler.rs create mode 100644 src/handler/admin/document/get_document_detail_handler.rs create mode 100644 src/handler/admin/document/get_document_list_handler.rs create mode 100644 src/handler/admin/document/mod.rs create mode 100644 src/handler/admin/document/update_document_handler.rs create mode 100644 src/handler/admin/log/filter_balance_log_handler.rs create mode 100644 src/handler/admin/log/filter_commission_log_handler.rs create mode 100644 src/handler/admin/log/filter_email_log_handler.rs create mode 100644 src/handler/admin/log/filter_gift_log_handler.rs create mode 100644 src/handler/admin/log/filter_login_log_handler.rs create mode 100644 src/handler/admin/log/filter_mobile_log_handler.rs create mode 100644 src/handler/admin/log/filter_register_log_handler.rs create mode 100644 src/handler/admin/log/filter_reset_subscribe_log_handler.rs create mode 100644 src/handler/admin/log/filter_server_traffic_log_handler.rs create mode 100644 src/handler/admin/log/filter_subscribe_log_handler.rs create mode 100644 src/handler/admin/log/filter_traffic_log_details_handler.rs create mode 100644 src/handler/admin/log/filter_user_subscribe_traffic_log_handler.rs create mode 100644 src/handler/admin/log/get_log_setting_handler.rs create mode 100644 src/handler/admin/log/get_message_log_list_handler.rs create mode 100644 src/handler/admin/log/mod.rs create mode 100644 src/handler/admin/log/update_log_setting_handler.rs create mode 100644 src/handler/admin/marketing/create_batch_send_email_task_handler.rs create mode 100644 src/handler/admin/marketing/create_quota_task_handler.rs create mode 100644 src/handler/admin/marketing/get_batch_send_email_task_list_handler.rs create mode 100644 src/handler/admin/marketing/get_batch_send_email_task_status_handler.rs create mode 100644 src/handler/admin/marketing/get_pre_send_email_count_handler.rs create mode 100644 src/handler/admin/marketing/mod.rs create mode 100644 src/handler/admin/marketing/query_quota_task_list_handler.rs create mode 100644 src/handler/admin/marketing/query_quota_task_pre_count_handler.rs create mode 100644 src/handler/admin/marketing/query_quota_task_status_handler.rs create mode 100644 src/handler/admin/marketing/stop_batch_send_email_task_handler.rs create mode 100644 src/handler/admin/mod.rs create mode 100644 src/handler/admin/order/create_order_handler.rs create mode 100644 src/handler/admin/order/get_order_list_handler.rs create mode 100644 src/handler/admin/order/mod.rs create mode 100644 src/handler/admin/order/update_order_status_handler.rs create mode 100644 src/handler/admin/payment/create_payment_method_handler.rs create mode 100644 src/handler/admin/payment/delete_payment_method_handler.rs create mode 100644 src/handler/admin/payment/get_payment_method_list_handler.rs create mode 100644 src/handler/admin/payment/get_payment_platform_handler.rs create mode 100644 src/handler/admin/payment/mod.rs create mode 100644 src/handler/admin/payment/update_payment_method_handler.rs create mode 100644 src/handler/admin/plugin/api.rs create mode 100644 src/handler/admin/plugin/detail.rs create mode 100644 src/handler/admin/plugin/disable.rs create mode 100644 src/handler/admin/plugin/enable.rs create mode 100644 src/handler/admin/plugin/list.rs create mode 100644 src/handler/admin/plugin/mod.rs create mode 100644 src/handler/admin/plugin/reload.rs create mode 100644 src/handler/admin/server/create_node_handler.rs create mode 100644 src/handler/admin/server/create_server_handler.rs create mode 100644 src/handler/admin/server/delete_node_handler.rs create mode 100644 src/handler/admin/server/delete_server_handler.rs create mode 100644 src/handler/admin/server/filter_node_list_handler.rs create mode 100644 src/handler/admin/server/filter_server_list_handler.rs create mode 100644 src/handler/admin/server/get_server_node_config_handler.rs create mode 100644 src/handler/admin/server/get_server_protocols_handler.rs create mode 100644 src/handler/admin/server/mod.rs create mode 100644 src/handler/admin/server/query_node_tag_handler.rs create mode 100644 src/handler/admin/server/reset_sort_with_node_handler.rs create mode 100644 src/handler/admin/server/reset_sort_with_server_handler.rs create mode 100644 src/handler/admin/server/toggle_node_status_handler.rs create mode 100644 src/handler/admin/server/update_node_handler.rs create mode 100644 src/handler/admin/server/update_server_handler.rs create mode 100644 src/handler/admin/server/update_server_node_config_handler.rs create mode 100644 src/handler/admin/subscribe/batch_delete_subscribe_group_handler.rs create mode 100644 src/handler/admin/subscribe/batch_delete_subscribe_handler.rs create mode 100644 src/handler/admin/subscribe/create_subscribe_group_handler.rs create mode 100644 src/handler/admin/subscribe/create_subscribe_handler.rs create mode 100644 src/handler/admin/subscribe/delete_subscribe_group_handler.rs create mode 100644 src/handler/admin/subscribe/delete_subscribe_handler.rs create mode 100644 src/handler/admin/subscribe/get_subscribe_details_handler.rs create mode 100644 src/handler/admin/subscribe/get_subscribe_group_list_handler.rs create mode 100644 src/handler/admin/subscribe/get_subscribe_list_handler.rs create mode 100644 src/handler/admin/subscribe/mod.rs create mode 100644 src/handler/admin/subscribe/reset_all_subscribe_token_handler.rs create mode 100644 src/handler/admin/subscribe/subscribe_sort_handler.rs create mode 100644 src/handler/admin/subscribe/update_subscribe_group_handler.rs create mode 100644 src/handler/admin/subscribe/update_subscribe_handler.rs create mode 100644 src/handler/admin/system/get_currency_config_handler.rs create mode 100644 src/handler/admin/system/get_invite_config_handler.rs create mode 100644 src/handler/admin/system/get_module_config_handler.rs create mode 100644 src/handler/admin/system/get_node_config_handler.rs create mode 100644 src/handler/admin/system/get_node_multiplier_handler.rs create mode 100644 src/handler/admin/system/get_privacy_policy_config_handler.rs create mode 100644 src/handler/admin/system/get_register_config_handler.rs create mode 100644 src/handler/admin/system/get_site_config_handler.rs create mode 100644 src/handler/admin/system/get_subscribe_config_handler.rs create mode 100644 src/handler/admin/system/get_tos_config_handler.rs create mode 100644 src/handler/admin/system/get_verify_code_config_handler.rs create mode 100644 src/handler/admin/system/get_verify_config_handler.rs create mode 100644 src/handler/admin/system/mod.rs create mode 100644 src/handler/admin/system/pre_view_node_multiplier_handler.rs create mode 100644 src/handler/admin/system/set_node_multiplier_handler.rs create mode 100644 src/handler/admin/system/setting_telegram_bot_handler.rs create mode 100644 src/handler/admin/system/update_currency_config_handler.rs create mode 100644 src/handler/admin/system/update_invite_config_handler.rs create mode 100644 src/handler/admin/system/update_node_config_handler.rs create mode 100644 src/handler/admin/system/update_privacy_policy_config_handler.rs create mode 100644 src/handler/admin/system/update_register_config_handler.rs create mode 100644 src/handler/admin/system/update_site_config_handler.rs create mode 100644 src/handler/admin/system/update_subscribe_config_handler.rs create mode 100644 src/handler/admin/system/update_tos_config_handler.rs create mode 100644 src/handler/admin/system/update_verify_code_config_handler.rs create mode 100644 src/handler/admin/system/update_verify_config_handler.rs create mode 100644 src/handler/admin/ticket/create_ticket_follow_handler.rs create mode 100644 src/handler/admin/ticket/get_ticket_handler.rs create mode 100644 src/handler/admin/ticket/get_ticket_list_handler.rs create mode 100644 src/handler/admin/ticket/mod.rs create mode 100644 src/handler/admin/ticket/update_ticket_status_handler.rs create mode 100644 src/handler/admin/tool/get_system_log_handler.rs create mode 100644 src/handler/admin/tool/get_version_handler.rs create mode 100644 src/handler/admin/tool/mod.rs create mode 100644 src/handler/admin/tool/query_ip_location_handler.rs create mode 100644 src/handler/admin/tool/restart_system_handler.rs create mode 100644 src/handler/admin/user/batch_delete_user_handler.rs create mode 100644 src/handler/admin/user/create_user_auth_method_handler.rs create mode 100644 src/handler/admin/user/create_user_handler.rs create mode 100644 src/handler/admin/user/create_user_subscribe_handler.rs create mode 100644 src/handler/admin/user/current_user_handler.rs create mode 100644 src/handler/admin/user/delete_user_auth_method_handler.rs create mode 100644 src/handler/admin/user/delete_user_device_handler.rs create mode 100644 src/handler/admin/user/delete_user_handler.rs create mode 100644 src/handler/admin/user/delete_user_subscribe_handler.rs create mode 100644 src/handler/admin/user/get_user_auth_method_handler.rs create mode 100644 src/handler/admin/user/get_user_detail_handler.rs create mode 100644 src/handler/admin/user/get_user_list_handler.rs create mode 100644 src/handler/admin/user/get_user_login_logs_handler.rs create mode 100644 src/handler/admin/user/get_user_subscribe_by_id_handler.rs create mode 100644 src/handler/admin/user/get_user_subscribe_devices_handler.rs create mode 100644 src/handler/admin/user/get_user_subscribe_handler.rs create mode 100644 src/handler/admin/user/get_user_subscribe_logs_handler.rs create mode 100644 src/handler/admin/user/get_user_subscribe_reset_traffic_logs_handler.rs create mode 100644 src/handler/admin/user/get_user_subscribe_traffic_logs_handler.rs create mode 100644 src/handler/admin/user/kick_offline_by_user_device_handler.rs create mode 100644 src/handler/admin/user/mod.rs create mode 100644 src/handler/admin/user/reset_user_subscribe_token_handler.rs create mode 100644 src/handler/admin/user/reset_user_subscribe_traffic_handler.rs create mode 100644 src/handler/admin/user/toggle_user_subscribe_status_handler.rs create mode 100644 src/handler/admin/user/update_user_auth_method_handler.rs create mode 100644 src/handler/admin/user/update_user_basic_info_handler.rs create mode 100644 src/handler/admin/user/update_user_device_handler.rs create mode 100644 src/handler/admin/user/update_user_notify_setting_handler.rs create mode 100644 src/handler/admin/user/update_user_subscribe_handler.rs create mode 100644 src/handler/auth/check_user_handler.rs create mode 100644 src/handler/auth/check_user_telephone_handler.rs create mode 100644 src/handler/auth/device_login_handler.rs create mode 100644 src/handler/auth/mod.rs create mode 100644 src/handler/auth/oauth/apple_login_callback_handler.rs create mode 100644 src/handler/auth/oauth/mod.rs create mode 100644 src/handler/auth/oauth/o_auth_login_get_token_handler.rs create mode 100644 src/handler/auth/oauth/o_auth_login_handler.rs create mode 100644 src/handler/auth/reset_password_handler.rs create mode 100644 src/handler/auth/telephone_login_handler.rs create mode 100644 src/handler/auth/telephone_reset_password_handler.rs create mode 100644 src/handler/auth/telephone_user_register_handler.rs create mode 100644 src/handler/auth/user_login_handler.rs create mode 100644 src/handler/auth/user_register_handler.rs create mode 100644 src/handler/common/check_verification_code_handler.rs create mode 100644 src/handler/common/get_ads_handler.rs create mode 100644 src/handler/common/get_client_handler.rs create mode 100644 src/handler/common/get_global_config_handler.rs create mode 100644 src/handler/common/get_privacy_policy_handler.rs create mode 100644 src/handler/common/get_stat_handler.rs create mode 100644 src/handler/common/get_tos_handler.rs create mode 100644 src/handler/common/heartbeat_handler.rs create mode 100644 src/handler/common/mod.rs create mode 100644 src/handler/common/send_email_code_handler.rs create mode 100644 src/handler/common/send_sms_code_handler.rs create mode 100644 src/handler/mod.rs create mode 100644 src/handler/notify/mod.rs create mode 100644 src/handler/notify/payment_notify_handler.rs create mode 100644 src/handler/public/announcement/mod.rs create mode 100644 src/handler/public/announcement/query_announcement_handler.rs create mode 100644 src/handler/public/document/mod.rs create mode 100644 src/handler/public/document/query_document_detail_handler.rs create mode 100644 src/handler/public/document/query_document_list_handler.rs create mode 100644 src/handler/public/mod.rs create mode 100644 src/handler/public/order/close_order_handler.rs create mode 100644 src/handler/public/order/mod.rs create mode 100644 src/handler/public/order/pre_create_order_handler.rs create mode 100644 src/handler/public/order/purchase_handler.rs create mode 100644 src/handler/public/order/query_order_detail_handler.rs create mode 100644 src/handler/public/order/query_order_list_handler.rs create mode 100644 src/handler/public/order/recharge_handler.rs create mode 100644 src/handler/public/order/renewal_handler.rs create mode 100644 src/handler/public/order/reset_traffic_handler.rs create mode 100644 src/handler/public/payment/get_available_payment_methods_handler.rs create mode 100644 src/handler/public/payment/mod.rs create mode 100644 src/handler/public/portal/get_available_payment_methods_handler.rs create mode 100644 src/handler/public/portal/get_subscription_handler.rs create mode 100644 src/handler/public/portal/mod.rs create mode 100644 src/handler/public/portal/pre_purchase_order_handler.rs create mode 100644 src/handler/public/portal/purchase_checkout_handler.rs create mode 100644 src/handler/public/portal/purchase_handler.rs create mode 100644 src/handler/public/portal/query_purchase_order_handler.rs create mode 100644 src/handler/public/subscribe/mod.rs create mode 100644 src/handler/public/subscribe/query_subscribe_group_list_handler.rs create mode 100644 src/handler/public/subscribe/query_subscribe_list_handler.rs create mode 100644 src/handler/public/subscribe/query_user_subscribe_node_list_handler.rs create mode 100644 src/handler/public/ticket/create_user_ticket_follow_handler.rs create mode 100644 src/handler/public/ticket/create_user_ticket_handler.rs create mode 100644 src/handler/public/ticket/get_user_ticket_details_handler.rs create mode 100644 src/handler/public/ticket/get_user_ticket_list_handler.rs create mode 100644 src/handler/public/ticket/mod.rs create mode 100644 src/handler/public/ticket/update_user_ticket_status_handler.rs create mode 100644 src/handler/public/user/bind_o_auth_callback_handler.rs create mode 100644 src/handler/public/user/bind_o_auth_handler.rs create mode 100644 src/handler/public/user/bind_telegram_handler.rs create mode 100644 src/handler/public/user/commission_withdraw_handler.rs create mode 100644 src/handler/public/user/get_device_list_handler.rs create mode 100644 src/handler/public/user/get_login_log_handler.rs create mode 100644 src/handler/public/user/get_o_auth_methods_handler.rs create mode 100644 src/handler/public/user/get_subscribe_log_handler.rs create mode 100644 src/handler/public/user/mod.rs create mode 100644 src/handler/public/user/pre_unsubscribe_handler.rs create mode 100644 src/handler/public/user/query_user_affiliate_handler.rs create mode 100644 src/handler/public/user/query_user_affiliate_list_handler.rs create mode 100644 src/handler/public/user/query_user_balance_log_handler.rs create mode 100644 src/handler/public/user/query_user_commission_log_handler.rs create mode 100644 src/handler/public/user/query_user_info_handler.rs create mode 100644 src/handler/public/user/query_user_subscribe_handler.rs create mode 100644 src/handler/public/user/query_withdrawal_log_handler.rs create mode 100644 src/handler/public/user/reset_user_subscribe_token_handler.rs create mode 100644 src/handler/public/user/unbind_device_handler.rs create mode 100644 src/handler/public/user/unbind_o_auth_handler.rs create mode 100644 src/handler/public/user/unbind_telegram_handler.rs create mode 100644 src/handler/public/user/unsubscribe_handler.rs create mode 100644 src/handler/public/user/update_bind_email_handler.rs create mode 100644 src/handler/public/user/update_bind_mobile_handler.rs create mode 100644 src/handler/public/user/update_user_notify_handler.rs create mode 100644 src/handler/public/user/update_user_password_handler.rs create mode 100644 src/handler/public/user/update_user_rules_handler.rs create mode 100644 src/handler/public/user/update_user_subscribe_note_handler.rs create mode 100644 src/handler/public/user/verify_email_handler.rs create mode 100644 src/handler/routes.rs create mode 100644 src/handler/server/get_server_config_handler.rs create mode 100644 src/handler/server/get_server_user_list_handler.rs create mode 100644 src/handler/server/helpers.rs create mode 100644 src/handler/server/mod.rs create mode 100644 src/handler/server/push_online_users_handler.rs create mode 100644 src/handler/server/query_server_protocol_config_handler.rs create mode 100644 src/handler/server/server_push_status_handler.rs create mode 100644 src/handler/server/server_push_user_traffic_handler.rs create mode 100644 src/handler/subscribe.rs create mode 100644 src/handler/telegram.rs create mode 100644 src/main.rs create mode 100644 src/middleware/auth_middleware.rs create mode 100644 src/middleware/cors_middleware.rs create mode 100644 src/middleware/device_middleware.rs create mode 100644 src/middleware/logger_middleware.rs create mode 100644 src/middleware/mod.rs create mode 100644 src/middleware/notify_middleware.rs create mode 100644 src/middleware/pan_domain_middleware.rs create mode 100644 src/middleware/server_middleware.rs create mode 100644 src/middleware/trace_middleware.rs create mode 100644 src/migration.rs create mode 100644 src/model/dto/ads.rs create mode 100644 src/model/dto/announcement.rs create mode 100644 src/model/dto/application.rs create mode 100644 src/model/dto/auth.rs create mode 100644 src/model/dto/common.rs create mode 100644 src/model/dto/coupon.rs create mode 100644 src/model/dto/document.rs create mode 100644 src/model/dto/log.rs create mode 100644 src/model/dto/marketing.rs create mode 100644 src/model/dto/misc.rs create mode 100644 src/model/dto/mod.rs create mode 100644 src/model/dto/node.rs create mode 100644 src/model/dto/order.rs create mode 100644 src/model/dto/payment.rs create mode 100644 src/model/dto/protocol.rs create mode 100644 src/model/dto/server.rs create mode 100644 src/model/dto/subscribe.rs create mode 100644 src/model/dto/system.rs create mode 100644 src/model/dto/ticket.rs create mode 100644 src/model/dto/user.rs create mode 100644 src/model/entity/ads.rs create mode 100644 src/model/entity/announcement.rs create mode 100644 src/model/entity/auth.rs create mode 100644 src/model/entity/client.rs create mode 100644 src/model/entity/coupon.rs create mode 100644 src/model/entity/document.rs create mode 100644 src/model/entity/log.rs create mode 100644 src/model/entity/mod.rs create mode 100644 src/model/entity/node.rs create mode 100644 src/model/entity/order.rs create mode 100644 src/model/entity/payment.rs create mode 100644 src/model/entity/subscribe.rs create mode 100644 src/model/entity/system.rs create mode 100644 src/model/entity/task.rs create mode 100644 src/model/entity/ticket.rs create mode 100644 src/model/entity/traffic.rs create mode 100644 src/model/entity/user.rs create mode 100644 src/model/mod.rs create mode 100644 src/queue/client.rs create mode 100644 src/queue/handler/email.rs create mode 100644 src/queue/handler/mod.rs create mode 100644 src/queue/handler/order.rs create mode 100644 src/queue/handler/sms.rs create mode 100644 src/queue/handler/subscription.rs create mode 100644 src/queue/handler/task.rs create mode 100644 src/queue/handler/traffic.rs create mode 100644 src/queue/mod.rs create mode 100644 src/queue/service/email.rs create mode 100644 src/queue/service/mod.rs create mode 100644 src/queue/service/order.rs create mode 100644 src/queue/service/sms.rs create mode 100644 src/queue/service/subscription.rs create mode 100644 src/queue/service/task.rs create mode 100644 src/queue/service/traffic.rs create mode 100644 src/queue/types.rs create mode 100644 src/repository/ads/mod.rs create mode 100644 src/repository/ads/mysql.rs create mode 100644 src/repository/ads/pg.rs create mode 100644 src/repository/announcement/mod.rs create mode 100644 src/repository/announcement/mysql.rs create mode 100644 src/repository/announcement/pg.rs create mode 100644 src/repository/auth/mod.rs create mode 100644 src/repository/auth/mysql.rs create mode 100644 src/repository/auth/pg.rs create mode 100644 src/repository/client/mod.rs create mode 100644 src/repository/client/mysql.rs create mode 100644 src/repository/client/pg.rs create mode 100644 src/repository/coupon/mod.rs create mode 100644 src/repository/coupon/mysql.rs create mode 100644 src/repository/coupon/pg.rs create mode 100644 src/repository/document/mod.rs create mode 100644 src/repository/document/mysql.rs create mode 100644 src/repository/document/pg.rs create mode 100644 src/repository/log/mod.rs create mode 100644 src/repository/log/mysql.rs create mode 100644 src/repository/log/pg.rs create mode 100644 src/repository/mod.rs create mode 100644 src/repository/node/mod.rs create mode 100644 src/repository/node/mysql.rs create mode 100644 src/repository/node/pg.rs create mode 100644 src/repository/order/mod.rs create mode 100644 src/repository/order/mysql.rs create mode 100644 src/repository/order/pg.rs create mode 100644 src/repository/payment/mod.rs create mode 100644 src/repository/payment/mysql.rs create mode 100644 src/repository/payment/pg.rs create mode 100644 src/repository/subscribe/mod.rs create mode 100644 src/repository/subscribe/mysql.rs create mode 100644 src/repository/subscribe/pg.rs create mode 100644 src/repository/system/mod.rs create mode 100644 src/repository/system/mysql.rs create mode 100644 src/repository/system/pg.rs create mode 100644 src/repository/task/mod.rs create mode 100644 src/repository/task/mysql.rs create mode 100644 src/repository/task/pg.rs create mode 100644 src/repository/ticket/mod.rs create mode 100644 src/repository/ticket/mysql.rs create mode 100644 src/repository/ticket/pg.rs create mode 100644 src/repository/traffic/mod.rs create mode 100644 src/repository/traffic/mysql.rs create mode 100644 src/repository/traffic/pg.rs create mode 100644 src/repository/user/mod.rs create mode 100644 src/repository/user/mysql.rs create mode 100644 src/repository/user/pg.rs create mode 100644 src/scheduler/mod.rs create mode 100644 src/service/admin/ads/create_ads_service.rs create mode 100644 src/service/admin/ads/delete_ads_service.rs create mode 100644 src/service/admin/ads/get_ads_detail_service.rs create mode 100644 src/service/admin/ads/get_ads_list_service.rs create mode 100644 src/service/admin/ads/mod.rs create mode 100644 src/service/admin/ads/update_ads_service.rs create mode 100644 src/service/admin/announcement/create_announcement_service.rs create mode 100644 src/service/admin/announcement/delete_announcement_service.rs create mode 100644 src/service/admin/announcement/get_announcement_list_service.rs create mode 100644 src/service/admin/announcement/get_announcement_service.rs create mode 100644 src/service/admin/announcement/mod.rs create mode 100644 src/service/admin/announcement/update_announcement_service.rs create mode 100644 src/service/admin/application/create_subscribe_application_service.rs create mode 100644 src/service/admin/application/delete_subscribe_application_service.rs create mode 100644 src/service/admin/application/get_subscribe_application_list_service.rs create mode 100644 src/service/admin/application/mod.rs create mode 100644 src/service/admin/application/preview_subscribe_template_service.rs create mode 100644 src/service/admin/application/update_subscribe_application_service.rs create mode 100644 src/service/admin/auth_method/get_auth_method_config_service.rs create mode 100644 src/service/admin/auth_method/get_auth_method_list_service.rs create mode 100644 src/service/admin/auth_method/get_email_platform_service.rs create mode 100644 src/service/admin/auth_method/get_sms_platform_service.rs create mode 100644 src/service/admin/auth_method/mod.rs create mode 100644 src/service/admin/auth_method/test_email_send_service.rs create mode 100644 src/service/admin/auth_method/test_sms_send_service.rs create mode 100644 src/service/admin/auth_method/update_auth_method_config_service.rs create mode 100644 src/service/admin/console/mod.rs create mode 100644 src/service/admin/console/query_revenue_statistics_service.rs create mode 100644 src/service/admin/console/query_server_total_data_service.rs create mode 100644 src/service/admin/console/query_ticket_wait_reply_service.rs create mode 100644 src/service/admin/console/query_user_statistics_service.rs create mode 100644 src/service/admin/coupon/batch_delete_coupon_service.rs create mode 100644 src/service/admin/coupon/create_coupon_service.rs create mode 100644 src/service/admin/coupon/delete_coupon_service.rs create mode 100644 src/service/admin/coupon/get_coupon_list_service.rs create mode 100644 src/service/admin/coupon/mod.rs create mode 100644 src/service/admin/coupon/update_coupon_service.rs create mode 100644 src/service/admin/document/batch_delete_document_service.rs create mode 100644 src/service/admin/document/create_document_service.rs create mode 100644 src/service/admin/document/delete_document_service.rs create mode 100644 src/service/admin/document/get_document_detail_service.rs create mode 100644 src/service/admin/document/get_document_list_service.rs create mode 100644 src/service/admin/document/mod.rs create mode 100644 src/service/admin/document/update_document_service.rs create mode 100644 src/service/admin/log/filter_balance_log_service.rs create mode 100644 src/service/admin/log/filter_commission_log_service.rs create mode 100644 src/service/admin/log/filter_email_log_service.rs create mode 100644 src/service/admin/log/filter_gift_log_service.rs create mode 100644 src/service/admin/log/filter_login_log_service.rs create mode 100644 src/service/admin/log/filter_mobile_log_service.rs create mode 100644 src/service/admin/log/filter_register_log_service.rs create mode 100644 src/service/admin/log/filter_reset_subscribe_log_service.rs create mode 100644 src/service/admin/log/filter_server_traffic_log_service.rs create mode 100644 src/service/admin/log/filter_subscribe_log_service.rs create mode 100644 src/service/admin/log/filter_traffic_log_details_service.rs create mode 100644 src/service/admin/log/filter_user_subscribe_traffic_log_service.rs create mode 100644 src/service/admin/log/get_log_setting_service.rs create mode 100644 src/service/admin/log/get_message_log_list_service.rs create mode 100644 src/service/admin/log/mod.rs create mode 100644 src/service/admin/log/update_log_setting_service.rs create mode 100644 src/service/admin/marketing/create_batch_send_email_task_service.rs create mode 100644 src/service/admin/marketing/create_quota_task_service.rs create mode 100644 src/service/admin/marketing/get_batch_send_email_task_list_service.rs create mode 100644 src/service/admin/marketing/get_batch_send_email_task_status_service.rs create mode 100644 src/service/admin/marketing/get_pre_send_email_count_service.rs create mode 100644 src/service/admin/marketing/mod.rs create mode 100644 src/service/admin/marketing/query_quota_task_list_service.rs create mode 100644 src/service/admin/marketing/query_quota_task_pre_count_service.rs create mode 100644 src/service/admin/marketing/query_quota_task_status_service.rs create mode 100644 src/service/admin/marketing/stop_batch_send_email_task_service.rs create mode 100644 src/service/admin/mod.rs create mode 100644 src/service/admin/order/create_order_service.rs create mode 100644 src/service/admin/order/get_order_list_service.rs create mode 100644 src/service/admin/order/mod.rs create mode 100644 src/service/admin/order/update_order_status_service.rs create mode 100644 src/service/admin/payment/create_payment_method_service.rs create mode 100644 src/service/admin/payment/delete_payment_method_service.rs create mode 100644 src/service/admin/payment/get_payment_method_list_service.rs create mode 100644 src/service/admin/payment/get_payment_platform_service.rs create mode 100644 src/service/admin/payment/mod.rs create mode 100644 src/service/admin/payment/update_payment_method_service.rs create mode 100644 src/service/admin/server/constant.rs create mode 100644 src/service/admin/server/create_node_service.rs create mode 100644 src/service/admin/server/create_server_service.rs create mode 100644 src/service/admin/server/delete_node_service.rs create mode 100644 src/service/admin/server/delete_server_service.rs create mode 100644 src/service/admin/server/filter_node_list_service.rs create mode 100644 src/service/admin/server/filter_server_list_service.rs create mode 100644 src/service/admin/server/get_server_node_config_service.rs create mode 100644 src/service/admin/server/get_server_protocols_service.rs create mode 100644 src/service/admin/server/mod.rs create mode 100644 src/service/admin/server/query_node_tag_service.rs create mode 100644 src/service/admin/server/reset_sort_with_node_service.rs create mode 100644 src/service/admin/server/reset_sort_with_server_service.rs create mode 100644 src/service/admin/server/toggle_node_status_service.rs create mode 100644 src/service/admin/server/update_node_service.rs create mode 100644 src/service/admin/server/update_server_node_config_service.rs create mode 100644 src/service/admin/server/update_server_service.rs create mode 100644 src/service/admin/subscribe/batch_delete_subscribe_group_service.rs create mode 100644 src/service/admin/subscribe/batch_delete_subscribe_service.rs create mode 100644 src/service/admin/subscribe/create_subscribe_group_service.rs create mode 100644 src/service/admin/subscribe/create_subscribe_service.rs create mode 100644 src/service/admin/subscribe/delete_subscribe_group_service.rs create mode 100644 src/service/admin/subscribe/delete_subscribe_service.rs create mode 100644 src/service/admin/subscribe/get_subscribe_details_service.rs create mode 100644 src/service/admin/subscribe/get_subscribe_group_list_service.rs create mode 100644 src/service/admin/subscribe/get_subscribe_list_service.rs create mode 100644 src/service/admin/subscribe/mod.rs create mode 100644 src/service/admin/subscribe/reset_all_subscribe_token_service.rs create mode 100644 src/service/admin/subscribe/subscribe_sort_service.rs create mode 100644 src/service/admin/subscribe/update_subscribe_group_service.rs create mode 100644 src/service/admin/subscribe/update_subscribe_service.rs create mode 100644 src/service/admin/system/get_currency_config_service.rs create mode 100644 src/service/admin/system/get_invite_config_service.rs create mode 100644 src/service/admin/system/get_module_config_service.rs create mode 100644 src/service/admin/system/get_node_config_service.rs create mode 100644 src/service/admin/system/get_node_multiplier_service.rs create mode 100644 src/service/admin/system/get_privacy_policy_config_service.rs create mode 100644 src/service/admin/system/get_register_config_service.rs create mode 100644 src/service/admin/system/get_site_config_service.rs create mode 100644 src/service/admin/system/get_subscribe_config_service.rs create mode 100644 src/service/admin/system/get_tos_config_service.rs create mode 100644 src/service/admin/system/get_verify_code_config_service.rs create mode 100644 src/service/admin/system/get_verify_config_service.rs create mode 100644 src/service/admin/system/mod.rs create mode 100644 src/service/admin/system/pre_view_node_multiplier_service.rs create mode 100644 src/service/admin/system/set_node_multiplier_service.rs create mode 100644 src/service/admin/system/setting_telegram_bot_service.rs create mode 100644 src/service/admin/system/update_config.rs create mode 100644 src/service/admin/system/update_currency_config_service.rs create mode 100644 src/service/admin/system/update_invite_config_service.rs create mode 100644 src/service/admin/system/update_node_config_service.rs create mode 100644 src/service/admin/system/update_privacy_policy_config_service.rs create mode 100644 src/service/admin/system/update_register_config_service.rs create mode 100644 src/service/admin/system/update_site_config_service.rs create mode 100644 src/service/admin/system/update_subscribe_config_service.rs create mode 100644 src/service/admin/system/update_tos_config_service.rs create mode 100644 src/service/admin/system/update_verify_code_config_service.rs create mode 100644 src/service/admin/system/update_verify_config_service.rs create mode 100644 src/service/admin/ticket/constant.rs create mode 100644 src/service/admin/ticket/constant_service.rs create mode 100644 src/service/admin/ticket/create_ticket_follow_service.rs create mode 100644 src/service/admin/ticket/get_ticket_list_service.rs create mode 100644 src/service/admin/ticket/get_ticket_service.rs create mode 100644 src/service/admin/ticket/mod.rs create mode 100644 src/service/admin/ticket/update_ticket_status_service.rs create mode 100644 src/service/admin/tool/get_system_log_service.rs create mode 100644 src/service/admin/tool/get_version_service.rs create mode 100644 src/service/admin/tool/mod.rs create mode 100644 src/service/admin/tool/query_ip_location_service.rs create mode 100644 src/service/admin/tool/restart_system_service.rs create mode 100644 src/service/admin/user/batch_delete_user_service.rs create mode 100644 src/service/admin/user/create_user_auth_method_service.rs create mode 100644 src/service/admin/user/create_user_service.rs create mode 100644 src/service/admin/user/create_user_subscribe_service.rs create mode 100644 src/service/admin/user/current_user_service.rs create mode 100644 src/service/admin/user/delete_user_auth_method_service.rs create mode 100644 src/service/admin/user/delete_user_device_service.rs create mode 100644 src/service/admin/user/delete_user_service.rs create mode 100644 src/service/admin/user/delete_user_subscribe_service.rs create mode 100644 src/service/admin/user/get_user_auth_method_service.rs create mode 100644 src/service/admin/user/get_user_detail_service.rs create mode 100644 src/service/admin/user/get_user_list_service.rs create mode 100644 src/service/admin/user/get_user_login_logs_service.rs create mode 100644 src/service/admin/user/get_user_subscribe_by_id_service.rs create mode 100644 src/service/admin/user/get_user_subscribe_devices_service.rs create mode 100644 src/service/admin/user/get_user_subscribe_logs_service.rs create mode 100644 src/service/admin/user/get_user_subscribe_reset_traffic_logs_service.rs create mode 100644 src/service/admin/user/get_user_subscribe_service.rs create mode 100644 src/service/admin/user/get_user_subscribe_traffic_logs_service.rs create mode 100644 src/service/admin/user/kick_offline_by_user_device_service.rs create mode 100644 src/service/admin/user/mod.rs create mode 100644 src/service/admin/user/reset_user_subscribe_token_service.rs create mode 100644 src/service/admin/user/reset_user_subscribe_traffic_service.rs create mode 100644 src/service/admin/user/toggle_user_subscribe_status_service.rs create mode 100644 src/service/admin/user/update_user_auth_method_service.rs create mode 100644 src/service/admin/user/update_user_basic_info_service.rs create mode 100644 src/service/admin/user/update_user_device_service.rs create mode 100644 src/service/admin/user/update_user_notify_setting_service.rs create mode 100644 src/service/admin/user/update_user_subscribe_service.rs create mode 100644 src/service/auth/bind_device_service.rs create mode 100644 src/service/auth/check_user_service.rs create mode 100644 src/service/auth/check_user_telephone_service.rs create mode 100644 src/service/auth/device_login_service.rs create mode 100644 src/service/auth/mod.rs create mode 100644 src/service/auth/oauth/apple_login_callback_service.rs create mode 100644 src/service/auth/oauth/mod.rs create mode 100644 src/service/auth/oauth/o_auth_login_get_token_service.rs create mode 100644 src/service/auth/oauth/o_auth_login_service.rs create mode 100644 src/service/auth/oauth/trial_cache.rs create mode 100644 src/service/auth/reset_password_service.rs create mode 100644 src/service/auth/telephone_login_service.rs create mode 100644 src/service/auth/telephone_reset_password_service.rs create mode 100644 src/service/auth/telephone_user_register_service.rs create mode 100644 src/service/auth/trial_cache.rs create mode 100644 src/service/auth/user_login_service.rs create mode 100644 src/service/auth/user_register_service.rs create mode 100644 src/service/common/check_verification_code_service.rs create mode 100644 src/service/common/get_ads_service.rs create mode 100644 src/service/common/get_client_service.rs create mode 100644 src/service/common/get_global_config_service.rs create mode 100644 src/service/common/get_privacy_policy_service.rs create mode 100644 src/service/common/get_stat_service.rs create mode 100644 src/service/common/get_tos_service.rs create mode 100644 src/service/common/heartbeat_service.rs create mode 100644 src/service/common/mod.rs create mode 100644 src/service/common/send_email_code_service.rs create mode 100644 src/service/common/send_sms_code_service.rs create mode 100644 src/service/mod.rs create mode 100644 src/service/nodeconfig/mod.rs create mode 100644 src/service/nodeconfig/override.rs create mode 100644 src/service/nodeconfig/override_test.rs create mode 100644 src/service/notify/alipay_notify_service.rs create mode 100644 src/service/notify/e_pay_notify_service.rs create mode 100644 src/service/notify/mod.rs create mode 100644 src/service/notify/stripe_notify_service.rs create mode 100644 src/service/public/announcement/mod.rs create mode 100644 src/service/public/announcement/query_announcement_service.rs create mode 100644 src/service/public/document/mod.rs create mode 100644 src/service/public/document/query_document_detail_service.rs create mode 100644 src/service/public/document/query_document_list_service.rs create mode 100644 src/service/public/mod.rs create mode 100644 src/service/public/order/calculate_coupon.rs create mode 100644 src/service/public/order/calculate_fee.rs create mode 100644 src/service/public/order/close_order_service.rs create mode 100644 src/service/public/order/constant.rs create mode 100644 src/service/public/order/get_discount.rs create mode 100644 src/service/public/order/mod.rs create mode 100644 src/service/public/order/pre_create_order_service.rs create mode 100644 src/service/public/order/purchase_service.rs create mode 100644 src/service/public/order/query_order_detail_service.rs create mode 100644 src/service/public/order/query_order_list_service.rs create mode 100644 src/service/public/order/recharge_service.rs create mode 100644 src/service/public/order/renewal_service.rs create mode 100644 src/service/public/order/reset_traffic_service.rs create mode 100644 src/service/public/payment/get_available_payment_methods_service.rs create mode 100644 src/service/public/payment/mod.rs create mode 100644 src/service/public/portal/get_available_payment_methods_service.rs create mode 100644 src/service/public/portal/get_subscription_service.rs create mode 100644 src/service/public/portal/mod.rs create mode 100644 src/service/public/portal/pre_purchase_order_service.rs create mode 100644 src/service/public/portal/purchase_checkout_service.rs create mode 100644 src/service/public/portal/purchase_service.rs create mode 100644 src/service/public/portal/query_purchase_order_service.rs create mode 100644 src/service/public/portal/tool.rs create mode 100644 src/service/public/subscribe/mod.rs create mode 100644 src/service/public/subscribe/query_subscribe_group_list_service.rs create mode 100644 src/service/public/subscribe/query_subscribe_list_service.rs create mode 100644 src/service/public/subscribe/query_user_subscribe_node_list_service.rs create mode 100644 src/service/public/ticket/constant.rs create mode 100644 src/service/public/ticket/create_user_ticket_follow_service.rs create mode 100644 src/service/public/ticket/create_user_ticket_service.rs create mode 100644 src/service/public/ticket/get_user_ticket_details_service.rs create mode 100644 src/service/public/ticket/get_user_ticket_list_service.rs create mode 100644 src/service/public/ticket/mod.rs create mode 100644 src/service/public/ticket/update_user_ticket_status_service.rs create mode 100644 src/service/public/user/bind_o_auth_callback_service.rs create mode 100644 src/service/public/user/bind_o_auth_service.rs create mode 100644 src/service/public/user/bind_telegram_service.rs create mode 100644 src/service/public/user/calculate_remaining_amount.rs create mode 100644 src/service/public/user/commission_withdraw_service.rs create mode 100644 src/service/public/user/get_device_list_service.rs create mode 100644 src/service/public/user/get_login_log_service.rs create mode 100644 src/service/public/user/get_o_auth_methods_service.rs create mode 100644 src/service/public/user/get_subscribe_log_service.rs create mode 100644 src/service/public/user/mod.rs create mode 100644 src/service/public/user/pre_unsubscribe_service.rs create mode 100644 src/service/public/user/query_user_affiliate_list_service.rs create mode 100644 src/service/public/user/query_user_affiliate_service.rs create mode 100644 src/service/public/user/query_user_balance_log_service.rs create mode 100644 src/service/public/user/query_user_commission_log_service.rs create mode 100644 src/service/public/user/query_user_info_service.rs create mode 100644 src/service/public/user/query_user_subscribe_logic_test.rs create mode 100644 src/service/public/user/query_user_subscribe_service.rs create mode 100644 src/service/public/user/query_withdrawal_log_service.rs create mode 100644 src/service/public/user/reset_user_subscribe_token_service.rs create mode 100644 src/service/public/user/unbind_device_service.rs create mode 100644 src/service/public/user/unbind_o_auth_service.rs create mode 100644 src/service/public/user/unbind_telegram_service.rs create mode 100644 src/service/public/user/unsubscribe_service.rs create mode 100644 src/service/public/user/update_bind_email_service.rs create mode 100644 src/service/public/user/update_bind_mobile_service.rs create mode 100644 src/service/public/user/update_user_notify_service.rs create mode 100644 src/service/public/user/update_user_password_service.rs create mode 100644 src/service/public/user/update_user_rules_service.rs create mode 100644 src/service/public/user/update_user_subscribe_note_service.rs create mode 100644 src/service/public/user/verify_email_service.rs create mode 100644 src/service/server/constant.rs create mode 100644 src/service/server/get_server_config_service.rs create mode 100644 src/service/server/get_server_user_list_logic_test.rs create mode 100644 src/service/server/get_server_user_list_service.rs create mode 100644 src/service/server/meta.rs create mode 100644 src/service/server/mod.rs create mode 100644 src/service/server/push_online_users_service.rs create mode 100644 src/service/server/query_server_protocol_config_service.rs create mode 100644 src/service/server/server_push_status_service.rs create mode 100644 src/service/server/server_push_user_traffic_service.rs create mode 100644 src/service/subscribe/mod.rs create mode 100644 src/service/subscribe/subscribe_service.rs create mode 100644 src/service/subscribe/user_agent.rs create mode 100644 src/service/telegram/bot.rs create mode 100644 src/service/telegram/mod.rs create mode 100644 src/service/telegram/telegram_service.rs create mode 100644 src/service/telegram/template.rs create mode 100644 src/service/telemetry.rs create mode 100644 src/tracing_otel.rs diff --git a/.gitignore b/.gitignore new file mode 100644 index 00000000..ea8c4bf7 --- /dev/null +++ b/.gitignore @@ -0,0 +1 @@ +/target diff --git a/Cargo.lock b/Cargo.lock new file mode 100644 index 00000000..7464c8eb --- /dev/null +++ b/Cargo.lock @@ -0,0 +1,5535 @@ +# This file is automatically @generated by Cargo. +# It is not intended for manual editing. +version = 4 + +[[package]] +name = "adler2" +version = "2.0.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "320119579fcad9c21884f5c4861d16174d0e06250625266f50fe6898340abefa" + +[[package]] +name = "aho-corasick" +version = "1.1.4" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "ddd31a130427c27518df266943a5308ed92d4b226cc639f5a8f1002816174301" +dependencies = [ + "memchr", +] + +[[package]] +name = "alipay_sdk_rust" +version = "1.0.16" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "48947e91a28a10b12e232726fbf38e5e3b7432e7b2e416ec7fb456d676680b25" +dependencies = [ + "anyhow", + "base64 0.22.1", + "gostd", + "jsonmap", + "log", + "md5 0.7.0", + "rsa", + "serde", + "serde_json", + "serde_with", + "sha2 0.10.9", + "thiserror 2.0.18", + "uuid 1.23.4", + "x509-parser", +] + +[[package]] +name = "alloc-no-stdlib" +version = "2.0.4" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "cc7bb162ec39d46ab1ca8c77bf72e890535becd1751bb45f64c597edb4c8c6b3" + +[[package]] +name = "alloc-stdlib" +version = "0.2.4" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "0e76a019e91224d279006ff972f1e984179a6e9feb050adba6ce8274aef23195" +dependencies = [ + "alloc-no-stdlib", +] + +[[package]] +name = "allocator-api2" +version = "0.2.21" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "683d7910e743518b0e34f1186f92494becacb047c7b6bf616c96772180fef923" + +[[package]] +name = "android_system_properties" +version = "0.1.5" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "819e7219dbd41043ac279b19830f2efc897156490d7fd6ea916720117ee66311" +dependencies = [ + "libc", +] + +[[package]] +name = "anyhow" +version = "1.0.103" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "2a4385e2e34eb35d6b3efe798b9eb88096925d87726c0798709bf56d9ed84af3" + +[[package]] +name = "arc-swap" +version = "1.9.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "c049c0be4daef0b145cb3555416b3b8ef5b7888a38aea1a3a155801fe7b0810b" +dependencies = [ + "rustversion", +] + +[[package]] +name = "arcstr" +version = "1.2.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "03918c3dbd7701a85c6b9887732e2921175f26c350b4563841d0958c21d57e6d" + +[[package]] +name = "arctic-oauth" +version = "0.3.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "ebc13b23bbf7fc9d255b965d20c9fc1eaa97e91b0e0c5416c954089977a64360" +dependencies = [ + "base64 0.22.1", + "ecdsa", + "http 1.4.2", + "p256", + "rand 0.9.4", + "reqwest 0.13.4", + "serde", + "serde_json", + "sha2 0.10.9", + "thiserror 2.0.18", + "url", +] + +[[package]] +name = "asn1-rs" +version = "0.7.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "b7f43a50ac4fdca5df8e885c21b835997f0a1cdee65494a6847694a98652d9d8" +dependencies = [ + "asn1-rs-derive", + "asn1-rs-impl", + "displaydoc", + "nom 7.1.3", + "num-traits", + "rusticata-macros", + "thiserror 2.0.18", + "time", +] + +[[package]] +name = "asn1-rs-derive" +version = "0.6.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "3109e49b1e4909e9db6515a30c633684d68cdeaa252f215214cb4fa1a5bfee2c" +dependencies = [ + "proc-macro2", + "quote", + "syn 2.0.118", + "synstructure", +] + +[[package]] +name = "asn1-rs-impl" +version = "0.2.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "7b18050c2cd6fe86c3a76584ef5e0baf286d038cda203eb6223df2cc413565f7" +dependencies = [ + "proc-macro2", + "quote", + "syn 2.0.118", +] + +[[package]] +name = "async-channel" +version = "1.9.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "81953c529336010edd6d8e358f886d9581267795c61b19475b71314bffa46d35" +dependencies = [ + "concurrent-queue", + "event-listener 2.5.3", + "futures-core", +] + +[[package]] +name = "async-compression" +version = "0.4.42" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "e79b3f8a79cccc2898f31920fc69f304859b3bd567490f75ebf51ae1c792a9ac" +dependencies = [ + "compression-codecs", + "compression-core", + "pin-project-lite", + "tokio", +] + +[[package]] +name = "async-lock" +version = "3.4.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "290f7f2596bd5b78a9fec8088ccd89180d7f9f55b94b0576823bbbdc72ee8311" +dependencies = [ + "event-listener 5.4.1", + "event-listener-strategy", + "pin-project-lite", +] + +[[package]] +name = "async-stream" +version = "0.3.6" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "0b5a71a6f37880a80d1d7f19efd781e4b5de42c88f0722cc13bcb6cc2cfe8476" +dependencies = [ + "async-stream-impl", + "futures-core", + "pin-project-lite", +] + +[[package]] +name = "async-stream-impl" +version = "0.3.6" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "c7c24de15d275a1ecfd47a380fb4d5ec9bfe0933f309ed5e705b775596a3574d" +dependencies = [ + "proc-macro2", + "quote", + "syn 2.0.118", +] + +[[package]] +name = "async-stripe" +version = "0.40.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "bdab4ef191d8dec8c6937ccbccb15fa8fe21c56c487bd6c18475b188806bcfa7" +dependencies = [ + "chrono", + "futures-util", + "hex", + "hmac 0.12.1", + "http-types", + "hyper 0.14.32", + "hyper-tls 0.5.0", + "serde", + "serde_json", + "serde_path_to_error", + "serde_qs 0.10.1", + "sha2 0.10.9", + "smart-default", + "smol_str", + "thiserror 1.0.69", + "tokio", + "uuid 0.8.2", +] + +[[package]] +name = "async-trait" +version = "0.1.89" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "9035ad2d096bed7955a320ee7e2230574d28fd3c3a0f186cbea1ff3c7eed5dbb" +dependencies = [ + "proc-macro2", + "quote", + "syn 2.0.118", +] + +[[package]] +name = "asynq" +version = "0.1.8" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "eebe7d891667073c659c0e27b43cd07defa5a15d53c713c4fbe4e6b710395fdd" +dependencies = [ + "anyhow", + "async-trait", + "chrono", + "cron", + "futures", + "futures-util", + "hostname", + "http 1.4.2", + "http-serde", + "md5 0.8.0", + "num_cpus", + "phf 0.13.1", + "prost 0.14.4", + "prost-build", + "prost-types", + "rand 0.10.1", + "redis 1.3.0", + "serde", + "serde_json", + "thiserror 2.0.18", + "tokio", + "tokio-util", + "tracing", + "uuid 1.23.4", +] + +[[package]] +name = "atoi" +version = "2.0.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "f28d99ec8bfea296261ca1af174f24225171fea9664ba9003cbebee704810528" +dependencies = [ + "num-traits", +] + +[[package]] +name = "atomic-waker" +version = "1.1.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "1505bd5d3d116872e7271a6d4e16d81d0c8570876c8de68093a09ac269d8aac0" + +[[package]] +name = "autocfg" +version = "1.5.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "f2032f911046de80f0a198e0901378627c33f59ea0ac00e363d481118bd70a53" + +[[package]] +name = "aws-lc-rs" +version = "1.17.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "4342d8937fc7e5dd9b1c60292261c0670c882a2cd1719cfc11b1af41731e32ad" +dependencies = [ + "aws-lc-sys", + "zeroize", +] + +[[package]] +name = "aws-lc-sys" +version = "0.42.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "6d9ceb1da931507a12f4fccea479dccd00da1943e1b4ae72d8e502d707361444" +dependencies = [ + "cc", + "cmake", + "dunce", + "fs_extra", + "pkg-config", +] + +[[package]] +name = "axum" +version = "0.7.9" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "edca88bc138befd0323b20752846e6587272d3b03b0343c8ea28a6f819e6e71f" +dependencies = [ + "async-trait", + "axum-core 0.4.5", + "bytes", + "futures-util", + "http 1.4.2", + "http-body 1.0.1", + "http-body-util", + "itoa", + "matchit 0.7.3", + "memchr", + "mime", + "percent-encoding", + "pin-project-lite", + "rustversion", + "serde", + "sync_wrapper", + "tower 0.5.3", + "tower-layer", + "tower-service", +] + +[[package]] +name = "axum" +version = "0.8.9" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "31b698c5f9a010f6573133b09e0de5408834d0c82f8d7475a89fc1867a71cd90" +dependencies = [ + "axum-core 0.5.6", + "bytes", + "form_urlencoded", + "futures-util", + "http 1.4.2", + "http-body 1.0.1", + "http-body-util", + "hyper 1.10.1", + "hyper-util", + "itoa", + "matchit 0.8.4", + "memchr", + "mime", + "percent-encoding", + "pin-project-lite", + "serde_core", + "serde_json", + "serde_path_to_error", + "serde_urlencoded", + "sync_wrapper", + "tokio", + "tower 0.5.3", + "tower-layer", + "tower-service", + "tracing", +] + +[[package]] +name = "axum-core" +version = "0.4.5" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "09f2bd6146b97ae3359fa0cc6d6b376d9539582c7b4220f041a33ec24c226199" +dependencies = [ + "async-trait", + "bytes", + "futures-util", + "http 1.4.2", + "http-body 1.0.1", + "http-body-util", + "mime", + "pin-project-lite", + "rustversion", + "sync_wrapper", + "tower-layer", + "tower-service", +] + +[[package]] +name = "axum-core" +version = "0.5.6" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "08c78f31d7b1291f7ee735c1c6780ccde7785daae9a9206026862dab7d8792d1" +dependencies = [ + "bytes", + "futures-core", + "http 1.4.2", + "http-body 1.0.1", + "http-body-util", + "mime", + "pin-project-lite", + "sync_wrapper", + "tower-layer", + "tower-service", + "tracing", +] + +[[package]] +name = "backon" +version = "1.6.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "cffb0e931875b666fc4fcb20fee52e9bbd1ef836fd9e9e04ec21555f9f85f7ef" +dependencies = [ + "fastrand 2.4.1", +] + +[[package]] +name = "base16ct" +version = "0.2.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "4c7f02d4ea65f2c1853089ffd8d2787bdbc63de2f0d29dedbcf8ccdfa0ccd4cf" + +[[package]] +name = "base64" +version = "0.13.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "9e1b586273c5702936fe7b7d6896644d8be71e6314cfe09d3167c95f712589e8" + +[[package]] +name = "base64" +version = "0.22.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "72b3254f16251a8381aa12e40e3c4d2f0199f8c6508fbecb9d91f575e0fbb8c6" + +[[package]] +name = "base64ct" +version = "1.8.3" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "2af50177e190e07a26ab74f8b1efbfe2ef87da2116221318cb1c2e82baf7de06" + +[[package]] +name = "bcrypt" +version = "0.15.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "e65938ed058ef47d92cf8b346cc76ef48984572ade631927e9937b5ffc7662c7" +dependencies = [ + "base64 0.22.1", + "blowfish", + "getrandom 0.2.17", + "subtle", + "zeroize", +] + +[[package]] +name = "bitflags" +version = "2.13.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "b4388bee8683e3d04af747c73422af53102d2bd24d9eadb6cbc100baef4b43f8" +dependencies = [ + "serde_core", +] + +[[package]] +name = "block-buffer" +version = "0.10.4" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "3078c7629b62d3f0439517fa394996acacc5cbc91c5a20d8c658e77abd503a71" +dependencies = [ + "generic-array", +] + +[[package]] +name = "block-buffer" +version = "0.12.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "d2f6c7dbe95a6ed67ad9f18e57daf93a2f034c524b99fd2b76d18fdfeb6660aa" +dependencies = [ + "hybrid-array", +] + +[[package]] +name = "blowfish" +version = "0.9.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "e412e2cd0f2b2d93e02543ceae7917b3c70331573df19ee046bcbc35e45e87d7" +dependencies = [ + "byteorder", + "cipher", +] + +[[package]] +name = "brotli" +version = "8.0.4" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "5cc91aac060a7a1e25823bdccbfb6af1875b88f17c6daac97894eed8207166b3" +dependencies = [ + "alloc-no-stdlib", + "alloc-stdlib", + "brotli-decompressor", +] + +[[package]] +name = "brotli-decompressor" +version = "5.0.3" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "3a32acac15fe1967bc3986b2a6347dffc965602354ea6f450ad07e8bfd253583" +dependencies = [ + "alloc-no-stdlib", + "alloc-stdlib", +] + +[[package]] +name = "bs58" +version = "0.5.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "bf88ba1141d185c399bee5288d850d63b8369520c1eafc32a0430b5b6c287bf4" +dependencies = [ + "tinyvec", +] + +[[package]] +name = "bumpalo" +version = "3.20.3" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "72f5acc6cb2ba439de613abc23857ec3d78374d8ed5ac84e9d11336e87da8649" + +[[package]] +name = "byteorder" +version = "1.5.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "1fd0f2584146f6f2ef48085050886acf353beff7305ebd1ae69500e27c67f64b" + +[[package]] +name = "bytes" +version = "1.12.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "8ae3f5d315924270530207e2a68396c3cc547f6dca3fbdca317cfb1a51edb593" + +[[package]] +name = "cc" +version = "1.2.65" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "e228eec9be7c17ccb640b59b36a5cd805ea2a564a4c5e162c2f659fea30d3b96" +dependencies = [ + "find-msvc-tools", + "jobserver", + "libc", + "shlex", +] + +[[package]] +name = "cfg-if" +version = "1.0.4" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "9330f8b2ff13f34540b44e946ef35111825727b38d33286ef986142615121801" + +[[package]] +name = "cfg_aliases" +version = "0.2.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "613afe47fcd5fac7ccf1db93babcb082c5994d996f20b8b159f2ad1658eb5724" + +[[package]] +name = "chacha20" +version = "0.10.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "d524456ba66e72eb8b115ff89e01e497f8e6d11d78b70b1aa13c0fbd97540a81" +dependencies = [ + "cfg-if", + "cpufeatures 0.3.0", + "rand_core 0.10.1", +] + +[[package]] +name = "chrono" +version = "0.4.45" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "1aa79e62e7697b8e29b513a68abacf485adcd1fe8284a4316c5ae868e6633327" +dependencies = [ + "iana-time-zone", + "js-sys", + "num-traits", + "serde", + "wasm-bindgen", + "windows-link", +] + +[[package]] +name = "cipher" +version = "0.4.4" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "773f3b9af64447d2ce9850330c473515014aa235e6a783b02db81ff39e4a3dad" +dependencies = [ + "crypto-common 0.1.7", + "inout", +] + +[[package]] +name = "cmake" +version = "0.1.58" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "c0f78a02292a74a88ac736019ab962ece0bc380e3f977bf72e376c5d78ff0678" +dependencies = [ + "cc", +] + +[[package]] +name = "cmov" +version = "0.5.4" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "0c9ea0ac24bc397ab3c98583a3c9ba74fa56b09a4449bbe172b9b1ddb016027a" + +[[package]] +name = "combine" +version = "4.6.7" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "ba5a308b75df32fe02788e748662718f03fde005016435c444eea572398219fd" +dependencies = [ + "bytes", + "futures-core", + "memchr", + "pin-project-lite", + "tokio", + "tokio-util", +] + +[[package]] +name = "compression-codecs" +version = "0.4.38" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "ce2548391e9c1929c21bf6aa2680af86fe4c1b33e6cea9ac1cfeec0bd11218cf" +dependencies = [ + "brotli", + "compression-core", + "flate2", + "memchr", +] + +[[package]] +name = "compression-core" +version = "0.4.32" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "cc14f565cf027a105f7a44ccf9e5b424348421a1d8952a8fc9d499d313107789" + +[[package]] +name = "concurrent-queue" +version = "2.5.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "4ca0197aee26d1ae37445ee532fefce43251d24cc7c166799f4d46817f1d3973" +dependencies = [ + "crossbeam-utils", +] + +[[package]] +name = "const-oid" +version = "0.7.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "e4c78c047431fee22c1a7bb92e00ad095a02a983affe4d8a72e2a2c62c1b94f3" + +[[package]] +name = "const-oid" +version = "0.9.6" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "c2459377285ad874054d797f3ccebf984978aa39129f6eafde5cdc8315b612f8" + +[[package]] +name = "core-foundation" +version = "0.9.4" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "91e195e091a93c46f7102ec7818a2aa394e1e1771c3ab4825963fa03e45afb8f" +dependencies = [ + "core-foundation-sys", + "libc", +] + +[[package]] +name = "core-foundation" +version = "0.10.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "b2a6cd9ae233e7f62ba4e9353e81a88df7fc8a5987b8d445b4d90c879bd156f6" +dependencies = [ + "core-foundation-sys", + "libc", +] + +[[package]] +name = "core-foundation-sys" +version = "0.8.7" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "773648b94d0e5d620f64f280777445740e61fe701025087ec8b57f45c791888b" + +[[package]] +name = "cpufeatures" +version = "0.2.17" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "59ed5838eebb26a2bb2e58f6d5b5316989ae9d08bab10e0e6d103e656d1b0280" +dependencies = [ + "libc", +] + +[[package]] +name = "cpufeatures" +version = "0.3.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "8b2a41393f66f16b0823bb79094d54ac5fbd34ab292ddafb9a0456ac9f87d201" +dependencies = [ + "libc", +] + +[[package]] +name = "crc" +version = "3.4.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "5eb8a2a1cd12ab0d987a5d5e825195d372001a4094a0376319d5a0ad71c1ba0d" +dependencies = [ + "crc-catalog", +] + +[[package]] +name = "crc-catalog" +version = "2.5.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "217698eaf96b4a3f0bc4f3662aaa55bdf913cd54d7204591faa790070c6d0853" + +[[package]] +name = "crc32fast" +version = "1.5.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "9481c1c90cbf2ac953f07c8d4a58aa3945c425b7185c9154d67a65e4230da511" +dependencies = [ + "cfg-if", +] + +[[package]] +name = "cron" +version = "0.16.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "089df96cf6a25253b4b6b6744d86f91150a3d4df546f31a95def47976b8cba97" +dependencies = [ + "chrono", + "once_cell", + "phf 0.11.3", + "winnow", +] + +[[package]] +name = "crossbeam-channel" +version = "0.5.15" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "82b8f8f868b36967f9606790d1903570de9ceaf870a7bf9fbbd3016d636a2cb2" +dependencies = [ + "crossbeam-utils", +] + +[[package]] +name = "crossbeam-queue" +version = "0.3.12" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "0f58bbc28f91df819d0aa2a2c00cd19754769c2fad90579b3592b1c9ba7a3115" +dependencies = [ + "crossbeam-utils", +] + +[[package]] +name = "crossbeam-utils" +version = "0.8.21" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "d0a5c400df2834b80a4c3327b3aad3a4c4cd4de0629063962b03235697506a28" + +[[package]] +name = "crypto-bigint" +version = "0.3.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "03c6a1d5fa1de37e071642dfa44ec552ca5b299adb128fab16138e24b548fd21" +dependencies = [ + "generic-array", + "subtle", +] + +[[package]] +name = "crypto-bigint" +version = "0.5.5" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "0dc92fb57ca44df6db8059111ab3af99a63d5d0f8375d9972e319a379c6bab76" +dependencies = [ + "generic-array", + "rand_core 0.6.4", + "subtle", + "zeroize", +] + +[[package]] +name = "crypto-common" +version = "0.1.7" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "78c8292055d1c1df0cce5d180393dc8cce0abec0a7102adb6c7b1eef6016d60a" +dependencies = [ + "generic-array", + "typenum", +] + +[[package]] +name = "crypto-common" +version = "0.2.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "ce6e4c961d6cd6c9a86db418387425e8bdeaf05b3c8bc1411e6dca4c252f1453" +dependencies = [ + "hybrid-array", +] + +[[package]] +name = "ctutils" +version = "0.4.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "7d5515a3834141de9eafb9717ad39eea8247b5674e6066c404e8c4b365d2a29e" +dependencies = [ + "cmov", +] + +[[package]] +name = "cvt" +version = "0.1.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "d2ae9bf77fbf2d39ef573205d554d87e86c12f1994e9ea335b0651b9b278bcf1" +dependencies = [ + "cfg-if", +] + +[[package]] +name = "darling" +version = "0.23.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "25ae13da2f202d56bd7f91c25fba009e7717a1e4a1cc98a76d844b65ae912e9d" +dependencies = [ + "darling_core", + "darling_macro", +] + +[[package]] +name = "darling_core" +version = "0.23.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "9865a50f7c335f53564bb694ef660825eb8610e0a53d3e11bf1b0d3df31e03b0" +dependencies = [ + "ident_case", + "proc-macro2", + "quote", + "strsim", + "syn 2.0.118", +] + +[[package]] +name = "darling_macro" +version = "0.23.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "ac3984ec7bd6cfa798e62b4a642426a5be0e68f9401cfc2a01e3fa9ea2fcdb8d" +dependencies = [ + "darling_core", + "quote", + "syn 2.0.118", +] + +[[package]] +name = "data-encoding" +version = "2.11.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "a4ae5f15dda3c708c0ade84bfee31ccab44a3da4f88015ed22f63732abe300c8" + +[[package]] +name = "der" +version = "0.5.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "6919815d73839e7ad218de758883aae3a257ba6759ce7a9992501efbb53d705c" +dependencies = [ + "const-oid 0.7.1", + "crypto-bigint 0.3.2", + "pem-rfc7468 0.3.1", +] + +[[package]] +name = "der" +version = "0.7.10" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "e7c1832837b905bbfb5101e07cc24c8deddf52f93225eee6ead5f4d63d53ddcb" +dependencies = [ + "const-oid 0.9.6", + "pem-rfc7468 0.7.0", + "zeroize", +] + +[[package]] +name = "der-parser" +version = "10.0.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "07da5016415d5a3c4dd39b11ed26f915f52fc4e0dc197d87908bc916e51bc1a6" +dependencies = [ + "asn1-rs", + "displaydoc", + "nom 7.1.3", + "num-bigint", + "num-traits", + "rusticata-macros", +] + +[[package]] +name = "deranged" +version = "0.5.8" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "7cd812cc2bc1d69d4764bd80df88b4317eaef9e773c75226407d9bc0876b211c" +dependencies = [ + "serde_core", +] + +[[package]] +name = "digest" +version = "0.10.7" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "9ed9a281f7bc9b7576e61468ba615a66a5c8cfdff42420a70aa82701a3b1e292" +dependencies = [ + "block-buffer 0.10.4", + "const-oid 0.9.6", + "crypto-common 0.1.7", + "subtle", +] + +[[package]] +name = "digest" +version = "0.11.3" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "f1dd6dbb5841937940781866fa1281a1ff7bd3bf827091440879f9994983d5c2" +dependencies = [ + "block-buffer 0.12.1", + "crypto-common 0.2.2", + "ctutils", +] + +[[package]] +name = "displaydoc" +version = "0.2.6" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "1ac70aa55017e108007fbaf5aa0f54b021c98f92ff8af59d42eda9da96e3dd4f" +dependencies = [ + "proc-macro2", + "quote", + "syn 2.0.118", +] + +[[package]] +name = "dotenvy" +version = "0.15.7" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "1aaf95b3e5c8f23aa320147307562d361db0ae0d51242340f558153b4eb2439b" + +[[package]] +name = "dunce" +version = "1.0.5" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "92773504d58c093f6de2459af4af33faa518c13451eb8f2b5698ed3d36e7c813" + +[[package]] +name = "dyn-clone" +version = "1.0.20" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "d0881ea181b1df73ff77ffaaf9c7544ecc11e82fba9b5f27b262a3c73a332555" + +[[package]] +name = "ecdsa" +version = "0.16.9" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "ee27f32b5c5292967d2d4a9d7f1e0b0aed2c15daded5a60300e4abb9d8020bca" +dependencies = [ + "der 0.7.10", + "digest 0.10.7", + "elliptic-curve", + "rfc6979", + "signature", + "spki 0.7.3", +] + +[[package]] +name = "either" +version = "1.16.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "91622ff5e7162018101f2fea40d6ebf4a78bbe5a49736a2020649edf9693679e" +dependencies = [ + "serde", +] + +[[package]] +name = "elliptic-curve" +version = "0.13.8" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "b5e6043086bf7973472e0c7dff2142ea0b680d30e18d9cc40f267efbf222bd47" +dependencies = [ + "base16ct", + "crypto-bigint 0.5.5", + "digest 0.10.7", + "ff", + "generic-array", + "group", + "pem-rfc7468 0.7.0", + "pkcs8 0.10.2", + "rand_core 0.6.4", + "sec1", + "subtle", + "zeroize", +] + +[[package]] +name = "email" +version = "0.1.0" +dependencies = [ + "anyhow", + "async-trait", + "lettre", + "serde", + "serde_json", + "thiserror 2.0.18", + "tokio", + "tracing", +] + +[[package]] +name = "email-encoding" +version = "0.4.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "9298e6504d9b9e780ed3f7dfd43a61be8cd0e09eb07f7706a945b0072b6670b6" +dependencies = [ + "base64 0.22.1", + "memchr", +] + +[[package]] +name = "email_address" +version = "0.2.9" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "e079f19b08ca6239f47f8ba8509c11cf3ea30095831f7fed61441475edd8c449" + +[[package]] +name = "encoding_rs" +version = "0.8.35" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "75030f3c4f45dafd7586dd6780965a8c7e8e285a5ecb86713e63a79c5b2766f3" +dependencies = [ + "cfg-if", +] + +[[package]] +name = "equivalent" +version = "1.0.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "877a4ace8713b0bcf2a4e7eec82529c029f1d0619886d18145fea96c3ffe5c0f" + +[[package]] +name = "errno" +version = "0.3.14" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "39cab71617ae0d63f51a36d69f866391735b51691dbda63cf6f96d042b63efeb" +dependencies = [ + "libc", + "windows-sys 0.61.2", +] + +[[package]] +name = "etcetera" +version = "0.11.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "de48cc4d1c1d97a20fd819def54b890cadde72ed3ad0c614822a0a433361be96" +dependencies = [ + "cfg-if", + "windows-sys 0.61.2", +] + +[[package]] +name = "event-listener" +version = "2.5.3" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "0206175f82b8d6bf6652ff7d71a1e27fd2e4efde587fd368662814d6ec1d9ce0" + +[[package]] +name = "event-listener" +version = "5.4.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "e13b66accf52311f30a0db42147dadea9850cb48cd070028831ae5f5d4b856ab" +dependencies = [ + "concurrent-queue", + "parking", + "pin-project-lite", +] + +[[package]] +name = "event-listener-strategy" +version = "0.5.4" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "8be9f3dfaaffdae2972880079a491a1a8bb7cbed0b8dd7a347f668b4150a3b93" +dependencies = [ + "event-listener 5.4.1", + "pin-project-lite", +] + +[[package]] +name = "fastrand" +version = "1.9.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "e51093e27b0797c359783294ca4f0a911c270184cb10f85783b118614a1501be" +dependencies = [ + "instant", +] + +[[package]] +name = "fastrand" +version = "2.4.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "9f1f227452a390804cdb637b74a86990f2a7d7ba4b7d5693aac9b4dd6defd8d6" + +[[package]] +name = "ff" +version = "0.13.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "c0b50bfb653653f9ca9095b427bed08ab8d75a137839d9ad64eb11810d5b6393" +dependencies = [ + "rand_core 0.6.4", + "subtle", +] + +[[package]] +name = "find-msvc-tools" +version = "0.1.9" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "5baebc0774151f905a1a2cc41989300b1e6fbb29aff0ceffa1064fdd3088d582" + +[[package]] +name = "fixedbitset" +version = "0.5.7" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "1d674e81391d1e1ab681a28d99df07927c6d4aa5b027d7da16ba32d1d21ecd99" + +[[package]] +name = "flate2" +version = "1.1.9" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "843fba2746e448b37e26a819579957415c8cef339bf08564fe8b7ddbd959573c" +dependencies = [ + "crc32fast", + "miniz_oxide", +] + +[[package]] +name = "flume" +version = "0.12.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "5e139bc46ca777eb5efaf62df0ab8cc5fd400866427e56c68b22e414e53bd3be" +dependencies = [ + "futures-core", + "futures-sink", + "spin", +] + +[[package]] +name = "fnv" +version = "1.0.7" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "3f9eec918d3f24069decb9af1554cad7c880e2da24a9afd88aca000531ab82c1" + +[[package]] +name = "foldhash" +version = "0.1.5" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "d9c4f5dac5e15c24eb999c26181a6ca40b39fe946cbe4c263c7209467bc83af2" + +[[package]] +name = "foldhash" +version = "0.2.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "77ce24cb58228fbb8aa041425bb1050850ac19177686ea6e0f41a70416f56fdb" + +[[package]] +name = "foreign-types" +version = "0.3.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "f6f339eb8adc052cd2ca78910fda869aefa38d22d5cb648e6485e4d3fc06f3b1" +dependencies = [ + "foreign-types-shared", +] + +[[package]] +name = "foreign-types-shared" +version = "0.1.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "00b0228411908ca8685dba7fc2cdd70ec9990a6e753e89b6ac91a84c40fbaf4b" + +[[package]] +name = "form_urlencoded" +version = "1.2.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "cb4cb245038516f5f85277875cdaa4f7d2c9a0fa0468de06ed190163b1581fcf" +dependencies = [ + "percent-encoding", +] + +[[package]] +name = "fs_extra" +version = "1.3.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "42703706b716c37f96a77aea830392ad231f44c9e9a67872fa5548707e11b11c" + +[[package]] +name = "futures" +version = "0.3.32" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "8b147ee9d1f6d097cef9ce628cd2ee62288d963e16fb287bd9286455b241382d" +dependencies = [ + "futures-channel", + "futures-core", + "futures-executor", + "futures-io", + "futures-sink", + "futures-task", + "futures-util", +] + +[[package]] +name = "futures-channel" +version = "0.3.32" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "07bbe89c50d7a535e539b8c17bc0b49bdb77747034daa8087407d655f3f7cc1d" +dependencies = [ + "futures-core", + "futures-sink", +] + +[[package]] +name = "futures-core" +version = "0.3.32" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "7e3450815272ef58cec6d564423f6e755e25379b217b0bc688e295ba24df6b1d" + +[[package]] +name = "futures-executor" +version = "0.3.32" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "baf29c38818342a3b26b5b923639e7b1f4a61fc5e76102d4b1981c6dc7a7579d" +dependencies = [ + "futures-core", + "futures-task", + "futures-util", +] + +[[package]] +name = "futures-intrusive" +version = "0.5.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "1d930c203dd0b6ff06e0201a4a2fe9149b43c684fd4420555b26d21b1a02956f" +dependencies = [ + "futures-core", + "lock_api", + "parking_lot", +] + +[[package]] +name = "futures-io" +version = "0.3.32" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "cecba35d7ad927e23624b22ad55235f2239cfa44fd10428eecbeba6d6a717718" + +[[package]] +name = "futures-lite" +version = "1.13.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "49a9d51ce47660b1e808d3c990b4709f2f415d928835a17dfd16991515c46bce" +dependencies = [ + "fastrand 1.9.0", + "futures-core", + "futures-io", + "memchr", + "parking", + "pin-project-lite", + "waker-fn", +] + +[[package]] +name = "futures-macro" +version = "0.3.32" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "e835b70203e41293343137df5c0664546da5745f82ec9b84d40be8336958447b" +dependencies = [ + "proc-macro2", + "quote", + "syn 2.0.118", +] + +[[package]] +name = "futures-sink" +version = "0.3.32" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "c39754e157331b013978ec91992bde1ac089843443c49cbc7f46150b0fad0893" + +[[package]] +name = "futures-task" +version = "0.3.32" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "037711b3d59c33004d3856fbdc83b99d4ff37a24768fa1be9ce3538a1cde4393" + +[[package]] +name = "futures-util" +version = "0.3.32" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "389ca41296e6190b48053de0321d02a77f32f8a5d2461dd38762c0593805c6d6" +dependencies = [ + "futures-channel", + "futures-core", + "futures-io", + "futures-macro", + "futures-sink", + "futures-task", + "memchr", + "pin-project-lite", + "slab", +] + +[[package]] +name = "generic-array" +version = "0.14.7" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "85649ca51fd72272d7821adaf274ad91c288277713d9c18820d8499a7ff69e9a" +dependencies = [ + "typenum", + "version_check", + "zeroize", +] + +[[package]] +name = "getrandom" +version = "0.1.16" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "8fc3cb4d91f53b50155bdcfd23f6a4c39ae1969c2ae85982b135750cccaf5fce" +dependencies = [ + "cfg-if", + "libc", + "wasi 0.9.0+wasi-snapshot-preview1", +] + +[[package]] +name = "getrandom" +version = "0.2.17" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "ff2abc00be7fca6ebc474524697ae276ad847ad0a6b3faa4bcb027e9a4614ad0" +dependencies = [ + "cfg-if", + "js-sys", + "libc", + "wasi 0.11.1+wasi-snapshot-preview1", + "wasm-bindgen", +] + +[[package]] +name = "getrandom" +version = "0.3.4" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "899def5c37c4fd7b2664648c28120ecec138e4d395b459e5ca34f9cce2dd77fd" +dependencies = [ + "cfg-if", + "js-sys", + "libc", + "r-efi 5.3.0", + "wasip2", + "wasm-bindgen", +] + +[[package]] +name = "getrandom" +version = "0.4.3" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "300e883d756b2e4ec94e02791f39b04b522276138852cfc41d9fb7e904106099" +dependencies = [ + "cfg-if", + "libc", + "r-efi 6.0.0", + "rand_core 0.10.1", +] + +[[package]] +name = "glob" +version = "0.3.3" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "0cc23270f6e1808e30a928bdc84dea0b9b4136a8bc82338574f23baf47bbd280" + +[[package]] +name = "gostd" +version = "0.4.5" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "216bf4b77632bfc7215b110090548e6fb1d5a815060ec14d64cd126eefd9ebc5" +dependencies = [ + "gostd_builtin", + "gostd_bytes", + "gostd_derive", + "gostd_http", + "gostd_io", + "gostd_strings", + "gostd_time", + "gostd_unicode", + "gostd_url", + "rand 0.8.6", +] + +[[package]] +name = "gostd_builtin" +version = "1.0.4" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "22d3e205fe025bf1a1e8ab95e45c6bfa00aa42aa71d31cdb479b275808266a77" + +[[package]] +name = "gostd_bytes" +version = "1.1.3" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "dbe54093976ddc16648aac7bba089f70373c1dabd06592b49aa161069e7abc4b" +dependencies = [ + "gostd_builtin", + "gostd_derive", + "gostd_io", + "gostd_strings", + "gostd_time", + "gostd_unicode", +] + +[[package]] +name = "gostd_derive" +version = "0.0.3" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "7ed1713fbbe002f018944a47c31ded81705b30a5b20251b7f559acef2c31e209" +dependencies = [ + "quote", + "syn 1.0.109", +] + +[[package]] +name = "gostd_http" +version = "1.1.6" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "b8106215331346ad5f0996a499f5992b850059b69d63e8ccfa470cf6119b00a0" +dependencies = [ + "anyhow", + "bytes", + "gostd_builtin", + "gostd_io", + "gostd_strings", + "gostd_time", + "gostd_url", + "rustls", + "thiserror 2.0.18", + "webpki-roots", +] + +[[package]] +name = "gostd_io" +version = "1.1.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "b902db5525db4802a1fb0a16b35d3fced9019ee74dd707105d960b359a94bebf" +dependencies = [ + "gostd_builtin", +] + +[[package]] +name = "gostd_strings" +version = "1.1.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "2f63171985f0774861e8503581845edf998840c0fad5a4dd2ad874927cd3e867" +dependencies = [ + "gostd_builtin", + "gostd_derive", + "gostd_io", + "gostd_unicode", +] + +[[package]] +name = "gostd_time" +version = "1.1.3" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "c1cdeb936b0d208b019ed78a3a57ab4a6a85394d52cbb4b2b59e61d4c96c1b04" +dependencies = [ + "cvt", + "gostd_builtin", + "gostd_derive", + "lazy_static", + "libc", + "winapi", +] + +[[package]] +name = "gostd_unicode" +version = "1.1.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "d32e9b0cdb699a0fb2332629a00d5dacb0b4cad2087f48eee02f8e21ee49c840" +dependencies = [ + "gostd_builtin", + "lazy_static", +] + +[[package]] +name = "gostd_url" +version = "1.1.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "956154d00527ac4e2b327e1cc91c85031d03fb574c5e18823ba7bb429947b85a" +dependencies = [ + "gostd_builtin", + "gostd_io", + "gostd_strings", +] + +[[package]] +name = "group" +version = "0.13.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "f0f9ef7462f7c099f518d754361858f86d8a07af53ba9af0fe635bbccb151a63" +dependencies = [ + "ff", + "rand_core 0.6.4", + "subtle", +] + +[[package]] +name = "gtmpl" +version = "0.7.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "9b924c0ea1149cd5cc9b208b33a4acb036ffb4b39c10048569de20616988a2b4" +dependencies = [ + "anyhow", + "gtmpl_value", + "lazy_static", + "percent-encoding", + "thiserror 1.0.69", +] + +[[package]] +name = "gtmpl_value" +version = "0.5.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "1d61bf6605eabca491ef6db6ed194e0aa82900ccc8bdea4c56ac277102152dd5" +dependencies = [ + "anyhow", + "thiserror 1.0.69", +] + +[[package]] +name = "h2" +version = "0.3.27" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "0beca50380b1fc32983fc1cb4587bfa4bb9e78fc259aad4a0032d2080309222d" +dependencies = [ + "bytes", + "fnv", + "futures-core", + "futures-sink", + "futures-util", + "http 0.2.12", + "indexmap 2.14.0", + "slab", + "tokio", + "tokio-util", + "tracing", +] + +[[package]] +name = "h2" +version = "0.4.15" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "6cb093c84e8bd9b188d4c4a8cb6579fc016968d14c99882163cd3ff402a4f155" +dependencies = [ + "atomic-waker", + "bytes", + "fnv", + "futures-core", + "futures-sink", + "http 1.4.2", + "indexmap 2.14.0", + "slab", + "tokio", + "tokio-util", + "tracing", +] + +[[package]] +name = "hashbrown" +version = "0.12.3" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "8a9ee70c43aaf417c914396645a0fa852624801b24ebb7ae78fe8272889ac888" + +[[package]] +name = "hashbrown" +version = "0.15.5" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "9229cfe53dfd69f0609a49f65461bd93001ea1ef889cd5529dd176593f5338a1" +dependencies = [ + "foldhash 0.1.5", +] + +[[package]] +name = "hashbrown" +version = "0.16.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "841d1cc9bed7f9236f321df977030373f4a4163ae1a7dbfe1a51a2c1a51d9100" +dependencies = [ + "allocator-api2", + "equivalent", + "foldhash 0.2.0", +] + +[[package]] +name = "hashbrown" +version = "0.17.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "ed5909b6e89a2db4456e54cd5f673791d7eca6732202bbf2a9cc504fe2f9b84a" + +[[package]] +name = "hashlink" +version = "0.11.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "824e001ac4f3012dd16a264bec811403a67ca9deb6c102fc5049b32c4574b35f" +dependencies = [ + "hashbrown 0.16.1", +] + +[[package]] +name = "heck" +version = "0.5.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "2304e00983f87ffb38b55b444b5e3b60a884b5d30c0fca7d82fe33449bbe55ea" + +[[package]] +name = "hermit-abi" +version = "0.5.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "fc0fef456e4baa96da950455cd02c081ca953b141298e41db3fc7e36b1da849c" + +[[package]] +name = "hex" +version = "0.4.3" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "7f24254aa9a54b5c858eaee2f5bccdb46aaf0e486a595ed5fd8f86ba55232a70" + +[[package]] +name = "hkdf" +version = "0.13.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "4aaa26c720c68b866f2c96ef5c1264b3e6f473fe5d4ce61cd44bbe913e553018" +dependencies = [ + "hmac 0.13.0", +] + +[[package]] +name = "hmac" +version = "0.12.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "6c49c37c09c17a53d937dfbb742eb3a961d65a994e6bcdcf37e7399d0cc8ab5e" +dependencies = [ + "digest 0.10.7", +] + +[[package]] +name = "hmac" +version = "0.13.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "6303bc9732ae41b04cb554b844a762b4115a61bfaa81e3e83050991eeb56863f" +dependencies = [ + "digest 0.11.3", +] + +[[package]] +name = "hostname" +version = "0.4.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "617aaa3557aef3810a6369d0a99fac8a080891b68bd9f9812a1eeda0c0730cbd" +dependencies = [ + "cfg-if", + "libc", + "windows-link", +] + +[[package]] +name = "http" +version = "0.2.12" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "601cbb57e577e2f5ef5be8e7b83f0f63994f25aa94d673e54a92d5c516d101f1" +dependencies = [ + "bytes", + "fnv", + "itoa", +] + +[[package]] +name = "http" +version = "1.4.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "6970f50e31d6fc17d3fa27329444bfa74e196cf62e95052a3f6fee181dba6425" +dependencies = [ + "bytes", + "itoa", +] + +[[package]] +name = "http-body" +version = "0.4.6" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "7ceab25649e9960c0311ea418d17bee82c0dcec1bd053b5f9a66e265a693bed2" +dependencies = [ + "bytes", + "http 0.2.12", + "pin-project-lite", +] + +[[package]] +name = "http-body" +version = "1.0.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "1efedce1fb8e6913f23e0c92de8e62cd5b772a67e7b3946df930a62566c93184" +dependencies = [ + "bytes", + "http 1.4.2", +] + +[[package]] +name = "http-body-util" +version = "0.1.3" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "b021d93e26becf5dc7e1b75b1bed1fd93124b374ceb73f43d4d4eafec896a64a" +dependencies = [ + "bytes", + "futures-core", + "http 1.4.2", + "http-body 1.0.1", + "pin-project-lite", +] + +[[package]] +name = "http-serde" +version = "2.1.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "0f056c8559e3757392c8d091e796416e4649d8e49e88b8d76df6c002f05027fd" +dependencies = [ + "http 1.4.2", + "serde", +] + +[[package]] +name = "http-types" +version = "2.12.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "6e9b187a72d63adbfba487f48095306ac823049cb504ee195541e91c7775f5ad" +dependencies = [ + "anyhow", + "async-channel", + "base64 0.13.1", + "futures-lite", + "http 0.2.12", + "infer", + "pin-project-lite", + "rand 0.7.3", + "serde", + "serde_json", + "serde_qs 0.8.5", + "serde_urlencoded", + "url", +] + +[[package]] +name = "httparse" +version = "1.10.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "6dbf3de79e51f3d586ab4cb9d5c3e2c14aa28ed23d180cf89b4df0454a69cc87" + +[[package]] +name = "httpdate" +version = "1.0.3" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "df3b46402a9d5adb4c86a0cf463f42e19994e3ee891101b1841f30a545cb49a9" + +[[package]] +name = "hybrid-array" +version = "0.4.13" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "818356c5132c1fede50f837ca96afbe78ff42413047f4abb886217845e1b6c8c" +dependencies = [ + "typenum", +] + +[[package]] +name = "hyper" +version = "0.14.32" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "41dfc780fdec9373c01bae43289ea34c972e40ee3c9f6b3c8801a35f35586ce7" +dependencies = [ + "bytes", + "futures-channel", + "futures-core", + "futures-util", + "h2 0.3.27", + "http 0.2.12", + "http-body 0.4.6", + "httparse", + "httpdate", + "itoa", + "pin-project-lite", + "socket2 0.5.10", + "tokio", + "tower-service", + "tracing", + "want", +] + +[[package]] +name = "hyper" +version = "1.10.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "55281c53a1894c864990125767da440a4e630446785086f52523b20033b74498" +dependencies = [ + "atomic-waker", + "bytes", + "futures-channel", + "futures-core", + "h2 0.4.15", + "http 1.4.2", + "http-body 1.0.1", + "httparse", + "httpdate", + "itoa", + "pin-project-lite", + "smallvec", + "tokio", + "want", +] + +[[package]] +name = "hyper-rustls" +version = "0.27.9" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "33ca68d021ef39cf6463ab54c1d0f5daf03377b70561305bb89a8f83aab66e0f" +dependencies = [ + "http 1.4.2", + "hyper 1.10.1", + "hyper-util", + "rustls", + "tokio", + "tokio-rustls", + "tower-service", +] + +[[package]] +name = "hyper-timeout" +version = "0.5.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "2b90d566bffbce6a75bd8b09a05aa8c2cb1fabb6cb348f8840c9e4c90a0d83b0" +dependencies = [ + "hyper 1.10.1", + "hyper-util", + "pin-project-lite", + "tokio", + "tower-service", +] + +[[package]] +name = "hyper-tls" +version = "0.5.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "d6183ddfa99b85da61a140bea0efc93fdf56ceaa041b37d553518030827f9905" +dependencies = [ + "bytes", + "hyper 0.14.32", + "native-tls", + "tokio", + "tokio-native-tls", +] + +[[package]] +name = "hyper-tls" +version = "0.6.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "70206fc6890eaca9fde8a0bf71caa2ddfc9fe045ac9e5c70df101a7dbde866e0" +dependencies = [ + "bytes", + "http-body-util", + "hyper 1.10.1", + "hyper-util", + "native-tls", + "tokio", + "tokio-native-tls", + "tower-service", +] + +[[package]] +name = "hyper-util" +version = "0.1.20" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "96547c2556ec9d12fb1578c4eaf448b04993e7fb79cbaad930a656880a6bdfa0" +dependencies = [ + "base64 0.22.1", + "bytes", + "futures-channel", + "futures-util", + "http 1.4.2", + "http-body 1.0.1", + "hyper 1.10.1", + "ipnet", + "libc", + "percent-encoding", + "pin-project-lite", + "socket2 0.6.4", + "system-configuration", + "tokio", + "tower-service", + "tracing", + "windows-registry", +] + +[[package]] +name = "iana-time-zone" +version = "0.1.65" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "e31bc9ad994ba00e440a8aa5c9ef0ec67d5cb5e5cb0cc7f8b744a35b389cc470" +dependencies = [ + "android_system_properties", + "core-foundation-sys", + "iana-time-zone-haiku", + "js-sys", + "log", + "wasm-bindgen", + "windows-core", +] + +[[package]] +name = "iana-time-zone-haiku" +version = "0.1.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "f31827a206f56af32e590ba56d5d2d085f558508192593743f16b2306495269f" +dependencies = [ + "cc", +] + +[[package]] +name = "icu_collections" +version = "2.2.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "2984d1cd16c883d7935b9e07e44071dca8d917fd52ecc02c04d5fa0b5a3f191c" +dependencies = [ + "displaydoc", + "potential_utf", + "utf8_iter", + "yoke", + "zerofrom", + "zerovec", +] + +[[package]] +name = "icu_locale_core" +version = "2.2.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "92219b62b3e2b4d88ac5119f8904c10f8f61bf7e95b640d25ba3075e6cac2c29" +dependencies = [ + "displaydoc", + "litemap", + "tinystr", + "writeable", + "zerovec", +] + +[[package]] +name = "icu_normalizer" +version = "2.2.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "c56e5ee99d6e3d33bd91c5d85458b6005a22140021cc324cea84dd0e72cff3b4" +dependencies = [ + "icu_collections", + "icu_normalizer_data", + "icu_properties", + "icu_provider", + "smallvec", + "zerovec", +] + +[[package]] +name = "icu_normalizer_data" +version = "2.2.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "da3be0ae77ea334f4da67c12f149704f19f81d1adf7c51cf482943e84a2bad38" + +[[package]] +name = "icu_properties" +version = "2.2.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "bee3b67d0ea5c2cca5003417989af8996f8604e34fb9ddf96208a033901e70de" +dependencies = [ + "icu_collections", + "icu_locale_core", + "icu_properties_data", + "icu_provider", + "zerotrie", + "zerovec", +] + +[[package]] +name = "icu_properties_data" +version = "2.2.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "8e2bbb201e0c04f7b4b3e14382af113e17ba4f63e2c9d2ee626b720cbce54a14" + +[[package]] +name = "icu_provider" +version = "2.2.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "139c4cf31c8b5f33d7e199446eff9c1e02decfc2f0eec2c8d71f65befa45b421" +dependencies = [ + "displaydoc", + "icu_locale_core", + "writeable", + "yoke", + "zerofrom", + "zerotrie", + "zerovec", +] + +[[package]] +name = "ident_case" +version = "1.0.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "b9e0384b61958566e926dc50660321d12159025e767c18e043daf26b70104c39" + +[[package]] +name = "idna" +version = "1.1.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "3b0875f23caa03898994f6ddc501886a45c7d3d62d04d2d90788d47be1b1e4de" +dependencies = [ + "idna_adapter", + "smallvec", + "utf8_iter", +] + +[[package]] +name = "idna_adapter" +version = "1.2.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "cb68373c0d6620ef8105e855e7745e18b0d00d3bdb07fb532e434244cdb9a714" +dependencies = [ + "icu_normalizer", + "icu_properties", +] + +[[package]] +name = "indexmap" +version = "1.9.3" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "bd070e393353796e801d209ad339e89596eb4c8d430d18ede6a1cced8fafbd99" +dependencies = [ + "autocfg", + "hashbrown 0.12.3", + "serde", +] + +[[package]] +name = "indexmap" +version = "2.14.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "d466e9454f08e4a911e14806c24e16fba1b4c121d1ea474396f396069cf949d9" +dependencies = [ + "equivalent", + "hashbrown 0.17.1", + "serde", + "serde_core", +] + +[[package]] +name = "infer" +version = "0.2.3" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "64e9829a50b42bb782c1df523f78d332fe371b10c661e78b7a3c34b0198e9fac" + +[[package]] +name = "inout" +version = "0.1.4" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "879f10e63c20629ecabbb64a8010319738c66a5cd0c29b02d63d272b03751d01" +dependencies = [ + "generic-array", +] + +[[package]] +name = "instant" +version = "0.1.13" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "e0242819d153cba4b4b05a5a8f2a7e9bbf97b6055b2a002b395c96b5ff3c0222" +dependencies = [ + "cfg-if", +] + +[[package]] +name = "ip" +version = "0.1.0" +dependencies = [ + "reqwest 0.12.28", + "serde", + "serde_json", + "thiserror 2.0.18", + "tokio", + "tracing", +] + +[[package]] +name = "ipnet" +version = "2.12.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "d98f6fed1fde3f8c21bc40a1abb88dd75e67924f9cffc3ef95607bad8017f8e2" + +[[package]] +name = "itertools" +version = "0.13.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "413ee7dfc52ee1a4949ceeb7dbc8a33f2d6c088194d9f922fb8318faf1f01186" +dependencies = [ + "either", +] + +[[package]] +name = "itertools" +version = "0.14.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "2b192c782037fadd9cfa75548310488aabdbf3d2da73885b31bd0abd03351285" +dependencies = [ + "either", +] + +[[package]] +name = "itoa" +version = "1.0.18" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "8f42a60cbdf9a97f5d2305f08a87dc4e09308d1276d28c869c684d7777685682" + +[[package]] +name = "jni" +version = "0.22.4" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "5efd9a482cf3a427f00d6b35f14332adc7902ce91efb778580e180ff90fa3498" +dependencies = [ + "cfg-if", + "combine", + "jni-macros", + "jni-sys", + "log", + "simd_cesu8", + "thiserror 2.0.18", + "walkdir", + "windows-link", +] + +[[package]] +name = "jni-macros" +version = "0.22.4" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "a00109accc170f0bdb141fed3e393c565b6f5e072365c3bd58f5b062591560a3" +dependencies = [ + "proc-macro2", + "quote", + "rustc_version", + "simd_cesu8", + "syn 2.0.118", +] + +[[package]] +name = "jni-sys" +version = "0.4.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "c6377a88cb3910bee9b0fa88d4f42e1d2da8e79915598f65fb0c7ee14c878af2" +dependencies = [ + "jni-sys-macros", +] + +[[package]] +name = "jni-sys-macros" +version = "0.4.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "38c0b942f458fe50cdac086d2f946512305e5631e720728f2a61aabcd47a6264" +dependencies = [ + "quote", + "syn 2.0.118", +] + +[[package]] +name = "jobserver" +version = "0.1.34" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "9afb3de4395d6b3e67a780b6de64b51c978ecf11cb9a462c66be7d4ca9039d33" +dependencies = [ + "getrandom 0.3.4", + "libc", +] + +[[package]] +name = "js-sys" +version = "0.3.103" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "53b44bfcdb3f8d5837a46dae1ca9660a837176eee74a28b229bc626816589102" +dependencies = [ + "cfg-if", + "futures-util", + "wasm-bindgen", +] + +[[package]] +name = "jsonmap" +version = "0.1.3" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "50904ac1efde7c2547499ee215843514cccc8a7b6f55fcf8f41f0106cdfc9927" +dependencies = [ + "serde", +] + +[[package]] +name = "jsonwebtoken" +version = "9.3.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "5a87cc7a48537badeae96744432de36f4be2b4a34a05a5ef32e9dd8a1c169dde" +dependencies = [ + "base64 0.22.1", + "js-sys", + "pem", + "ring", + "serde", + "serde_json", + "simple_asn1", +] + +[[package]] +name = "jwt" +version = "0.1.0" +dependencies = [ + "jsonwebtoken", + "serde", +] + +[[package]] +name = "lazy_static" +version = "1.5.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "bbd2bcb4c963f2ddae06a2efc7e9f3591312473c50c6685e1f298068316e66fe" +dependencies = [ + "spin", +] + +[[package]] +name = "lettre" +version = "0.11.22" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "0da65617f6cb926332d039cb578aad56178da86e128db6a1b09f4c94fa5b3349" +dependencies = [ + "async-trait", + "base64 0.22.1", + "email-encoding", + "email_address", + "fastrand 2.4.1", + "futures-io", + "futures-util", + "httpdate", + "idna", + "mime", + "native-tls", + "nom 8.0.0", + "percent-encoding", + "quoted_printable", + "socket2 0.6.4", + "tokio", + "tokio-native-tls", + "url", +] + +[[package]] +name = "libc" +version = "0.2.186" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "68ab91017fe16c622486840e4c83c9a37afeff978bd239b5293d61ece587de66" + +[[package]] +name = "libm" +version = "0.2.16" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "b6d2cec3eae94f9f509c767b45932f1ada8350c4bdb85af2fcab4a3c14807981" + +[[package]] +name = "libsqlite3-sys" +version = "0.30.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "2e99fb7a497b1e3339bc746195567ed8d3e24945ecd636e3619d20b9de9e9149" +dependencies = [ + "pkg-config", + "vcpkg", +] + +[[package]] +name = "linux-raw-sys" +version = "0.12.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "32a66949e030da00e8c7d4434b251670a91556f4144941d37452769c25d58a53" + +[[package]] +name = "litemap" +version = "0.8.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "92daf443525c4cce67b150400bc2316076100ce0b3686209eb8cf3c31612e6f0" + +[[package]] +name = "lock_api" +version = "0.4.14" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "224399e74b87b5f3557511d98dff8b14089b3dadafcab6bb93eab67d3aace965" +dependencies = [ + "scopeguard", +] + +[[package]] +name = "log" +version = "0.4.33" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "0ceec5bc11778974d1bcb055b18002eba7f4b3518b6a0081b3af5f21666da9ad" + +[[package]] +name = "lru-slab" +version = "0.1.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "112b39cec0b298b6c1999fee3e31427f74f676e4cb9879ed1a121b43661a4154" + +[[package]] +name = "matchers" +version = "0.2.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "d1525a2a28c7f4fa0fc98bb91ae755d1e2d1505079e05539e35bc876b5d65ae9" +dependencies = [ + "regex-automata", +] + +[[package]] +name = "matchit" +version = "0.7.3" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "0e7465ac9959cc2b1404e8e2367b43684a6d13790fe23056cc8c6c5a6b7bcb94" + +[[package]] +name = "matchit" +version = "0.8.4" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "47e1ffaa40ddd1f3ed91f717a33c8c0ee23fff369e3aa8772b9605cc1d22f4c3" + +[[package]] +name = "md-5" +version = "0.11.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "69b6441f590336821bb897fb28fc622898ccceb1d6cea3fde5ea86b090c4de98" +dependencies = [ + "cfg-if", + "digest 0.11.3", +] + +[[package]] +name = "md5" +version = "0.7.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "490cc448043f947bae3cbee9c203358d62dbee0db12107a74be5c30ccfd09771" + +[[package]] +name = "md5" +version = "0.8.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "ae960838283323069879657ca3de837e9f7bbb4c7bf6ea7f1b290d5e9476d2e0" + +[[package]] +name = "memchr" +version = "2.8.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "88904434abc2901f197fe8cc55f0445e7ded921dba5911dad2e2b39b48e663c4" + +[[package]] +name = "mime" +version = "0.3.17" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "6877bb514081ee2a7ff5ef9de3281f14a4dd4bceac4c09388074a6b5df8a139a" + +[[package]] +name = "minimal-lexical" +version = "0.2.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "68354c5c6bd36d73ff3feceb05efa59b6acb7626617f4962be322a825e61f79a" + +[[package]] +name = "miniz_oxide" +version = "0.8.9" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "1fa76a2c86f704bdb222d66965fb3d63269ce38518b83cb0575fca855ebb6316" +dependencies = [ + "adler2", + "simd-adler32", +] + +[[package]] +name = "mio" +version = "1.2.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "02bd0af71c67b473010cbbc60715ee815645a4dc942899111f494b4b737d6fda" +dependencies = [ + "libc", + "wasi 0.11.1+wasi-snapshot-preview1", + "windows-sys 0.61.2", +] + +[[package]] +name = "multimap" +version = "0.10.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "1d87ecb2933e8aeadb3e3a02b828fed80a7528047e68b4f424523a0981a3a084" + +[[package]] +name = "native-tls" +version = "0.2.18" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "465500e14ea162429d264d44189adc38b199b62b1c21eea9f69e4b73cb03bbf2" +dependencies = [ + "libc", + "log", + "openssl", + "openssl-probe", + "openssl-sys", + "schannel", + "security-framework", + "security-framework-sys", + "tempfile", +] + +[[package]] +name = "nom" +version = "7.1.3" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "d273983c5a657a70a3e8f2a01329822f3b8c8172b73826411a55751e404a0a4a" +dependencies = [ + "memchr", + "minimal-lexical", +] + +[[package]] +name = "nom" +version = "8.0.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "df9761775871bdef83bee530e60050f7e54b1105350d6884eb0fb4f46c2f9405" +dependencies = [ + "memchr", +] + +[[package]] +name = "nu-ansi-term" +version = "0.50.3" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "7957b9740744892f114936ab4a57b3f487491bbeafaf8083688b16841a4240e5" +dependencies = [ + "windows-sys 0.61.2", +] + +[[package]] +name = "num-bigint" +version = "0.4.7" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "c863e9ab5e7bf9c99ba75e1050f1e4d624ae87ed3532d6238ffbdc7b585dbbe6" +dependencies = [ + "num-integer", + "num-traits", +] + +[[package]] +name = "num-bigint-dig" +version = "0.8.6" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "e661dda6640fad38e827a6d4a310ff4763082116fe217f279885c97f511bb0b7" +dependencies = [ + "lazy_static", + "libm", + "num-integer", + "num-iter", + "num-traits", + "rand 0.8.6", + "smallvec", + "zeroize", +] + +[[package]] +name = "num-conv" +version = "0.2.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "521739c6d2bac4aa25192232afe6841231376b2b26d4d9fae5ecf8ca5772e441" + +[[package]] +name = "num-integer" +version = "0.1.46" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "7969661fd2958a5cb096e56c8e1ad0444ac2bbcd0061bd28660485a44879858f" +dependencies = [ + "num-traits", +] + +[[package]] +name = "num-iter" +version = "0.1.45" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "1429034a0490724d0075ebb2bc9e875d6503c3cf69e235a8941aa757d83ef5bf" +dependencies = [ + "autocfg", + "num-integer", + "num-traits", +] + +[[package]] +name = "num-traits" +version = "0.2.19" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "071dfc062690e90b734c0b2273ce72ad0ffa95f0c74596bc250dcfd960262841" +dependencies = [ + "autocfg", + "libm", +] + +[[package]] +name = "num_cpus" +version = "1.17.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "91df4bbde75afed763b708b7eee1e8e7651e02d97f6d5dd763e89367e957b23b" +dependencies = [ + "hermit-abi", + "libc", +] + +[[package]] +name = "oauth" +version = "0.1.0" +dependencies = [ + "arctic-oauth", + "base64 0.22.1", + "hex", + "hmac 0.12.1", + "serde", + "serde_json", + "sha2 0.10.9", +] + +[[package]] +name = "oid-registry" +version = "0.8.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "12f40cff3dde1b6087cc5d5f5d4d65712f34016a03ed60e9c08dcc392736b5b7" +dependencies = [ + "asn1-rs", +] + +[[package]] +name = "once_cell" +version = "1.21.4" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "9f7c3e4beb33f85d45ae3e3a1792185706c8e16d043238c593331cc7cd313b50" + +[[package]] +name = "openssl" +version = "0.10.81" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "77823a27f0babb03091cb9ed9ef80af3b39dbc82f97e8fa530374b7dafd87a45" +dependencies = [ + "bitflags", + "cfg-if", + "foreign-types", + "libc", + "openssl-macros", + "openssl-sys", +] + +[[package]] +name = "openssl-macros" +version = "0.1.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "a948666b637a0f465e8564c73e89d4dde00d72d4d473cc972f390fc3dcee7d9c" +dependencies = [ + "proc-macro2", + "quote", + "syn 2.0.118", +] + +[[package]] +name = "openssl-probe" +version = "0.2.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "7c87def4c32ab89d880effc9e097653c8da5d6ef28e6b539d313baaacfbafcbe" + +[[package]] +name = "openssl-sys" +version = "0.9.117" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "b47e7e6bb2c38cd930d25a23b40fa52e068c10e85f3e03a7f5ba5aaca5713695" +dependencies = [ + "cc", + "libc", + "pkg-config", + "vcpkg", +] + +[[package]] +name = "opentelemetry" +version = "0.27.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "ab70038c28ed37b97d8ed414b6429d343a8bbf44c9f79ec854f3a643029ba6d7" +dependencies = [ + "futures-core", + "futures-sink", + "js-sys", + "pin-project-lite", + "thiserror 1.0.69", + "tracing", +] + +[[package]] +name = "opentelemetry-http" +version = "0.27.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "10a8a7f5f6ba7c1b286c2fbca0454eaba116f63bbe69ed250b642d36fbb04d80" +dependencies = [ + "async-trait", + "bytes", + "http 1.4.2", + "opentelemetry", +] + +[[package]] +name = "opentelemetry-otlp" +version = "0.27.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "91cf61a1868dacc576bf2b2a1c3e9ab150af7272909e80085c3173384fe11f76" +dependencies = [ + "async-trait", + "futures-core", + "http 1.4.2", + "opentelemetry", + "opentelemetry-http", + "opentelemetry-proto", + "opentelemetry_sdk", + "prost 0.13.5", + "thiserror 1.0.69", + "tokio", + "tonic", + "tracing", +] + +[[package]] +name = "opentelemetry-proto" +version = "0.27.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "a6e05acbfada5ec79023c85368af14abd0b307c015e9064d249b2a950ef459a6" +dependencies = [ + "opentelemetry", + "opentelemetry_sdk", + "prost 0.13.5", + "tonic", +] + +[[package]] +name = "opentelemetry-semantic-conventions" +version = "0.27.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "bc1b6902ff63b32ef6c489e8048c5e253e2e4a803ea3ea7e783914536eb15c52" + +[[package]] +name = "opentelemetry-stdout" +version = "0.27.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "bc8a298402aa5c260be90d10dc54b5a7d4e1025c354848f8e2c976d761351049" +dependencies = [ + "async-trait", + "chrono", + "futures-util", + "opentelemetry", + "opentelemetry_sdk", + "ordered-float", + "serde", + "serde_json", + "thiserror 1.0.69", +] + +[[package]] +name = "opentelemetry_sdk" +version = "0.27.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "231e9d6ceef9b0b2546ddf52335785ce41252bc7474ee8ba05bfad277be13ab8" +dependencies = [ + "async-trait", + "futures-channel", + "futures-executor", + "futures-util", + "glob", + "opentelemetry", + "percent-encoding", + "rand 0.8.6", + "serde_json", + "thiserror 1.0.69", + "tokio", + "tokio-stream", + "tracing", +] + +[[package]] +name = "ordered-float" +version = "4.6.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "7bb71e1b3fa6ca1c61f383464aaf2bb0e2f8e772a1f01d486832464de363b951" +dependencies = [ + "num-traits", +] + +[[package]] +name = "p256" +version = "0.13.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "c9863ad85fa8f4460f9c48cb909d38a0d689dba1f6f6988a5e3e0d31071bcd4b" +dependencies = [ + "ecdsa", + "elliptic-curve", + "primeorder", + "sha2 0.10.9", +] + +[[package]] +name = "parking" +version = "2.2.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "f38d5652c16fde515bb1ecef450ab0f6a219d619a7274976324d5e377f7dceba" + +[[package]] +name = "parking_lot" +version = "0.12.5" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "93857453250e3077bd71ff98b6a65ea6621a19bb0f559a85248955ac12c45a1a" +dependencies = [ + "lock_api", + "parking_lot_core", +] + +[[package]] +name = "parking_lot_core" +version = "0.9.12" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "2621685985a2ebf1c516881c026032ac7deafcda1a2c9b7850dc81e3dfcb64c1" +dependencies = [ + "cfg-if", + "libc", + "redox_syscall", + "smallvec", + "windows-link", +] + +[[package]] +name = "password" +version = "0.1.0" +dependencies = [ + "bcrypt", + "hex", + "md5 0.7.0", + "pbkdf2", + "rand 0.8.6", + "sha2 0.10.9", +] + +[[package]] +name = "payment" +version = "0.1.0" +dependencies = [ + "alipay_sdk_rust", + "async-stripe", + "md5 0.7.0", + "reqwest 0.12.28", + "serde", + "serde_json", + "serde_urlencoded", + "thiserror 2.0.18", + "tracing", + "url", +] + +[[package]] +name = "pbkdf2" +version = "0.12.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "f8ed6a7761f76e3b9f92dfb0a60a6a6477c61024b775147ff0973a02653abaf2" +dependencies = [ + "digest 0.10.7", + "hmac 0.12.1", +] + +[[package]] +name = "pem" +version = "3.0.6" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "1d30c53c26bc5b31a98cd02d20f25a7c8567146caf63ed593a9d87b2775291be" +dependencies = [ + "base64 0.22.1", + "serde_core", +] + +[[package]] +name = "pem-rfc7468" +version = "0.3.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "01de5d978f34aa4b2296576379fcc416034702fd94117c56ffd8a1a767cefb30" +dependencies = [ + "base64ct", +] + +[[package]] +name = "pem-rfc7468" +version = "0.7.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "88b39c9bfcfc231068454382784bb460aae594343fb030d46e9f50a645418412" +dependencies = [ + "base64ct", +] + +[[package]] +name = "percent-encoding" +version = "2.3.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "9b4f627cb1b25917193a259e49bdad08f671f8d9708acfd5fe0a8c1455d87220" + +[[package]] +name = "petgraph" +version = "0.8.3" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "8701b58ea97060d5e5b155d383a69952a60943f0e6dfe30b04c287beb0b27455" +dependencies = [ + "fixedbitset", + "hashbrown 0.15.5", + "indexmap 2.14.0", +] + +[[package]] +name = "phf" +version = "0.11.3" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "1fd6780a80ae0c52cc120a26a1a42c1ae51b247a253e4e06113d23d2c2edd078" +dependencies = [ + "phf_macros 0.11.3", + "phf_shared 0.11.3", +] + +[[package]] +name = "phf" +version = "0.13.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "c1562dc717473dbaa4c1f85a36410e03c047b2e7df7f45ee938fbef64ae7fadf" +dependencies = [ + "phf_macros 0.13.1", + "phf_shared 0.13.1", + "serde", +] + +[[package]] +name = "phf_generator" +version = "0.11.3" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "3c80231409c20246a13fddb31776fb942c38553c51e871f8cbd687a4cfb5843d" +dependencies = [ + "phf_shared 0.11.3", + "rand 0.8.6", +] + +[[package]] +name = "phf_generator" +version = "0.13.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "135ace3a761e564ec88c03a77317a7c6b80bb7f7135ef2544dbe054243b89737" +dependencies = [ + "fastrand 2.4.1", + "phf_shared 0.13.1", +] + +[[package]] +name = "phf_macros" +version = "0.11.3" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "f84ac04429c13a7ff43785d75ad27569f2951ce0ffd30a3321230db2fc727216" +dependencies = [ + "phf_generator 0.11.3", + "phf_shared 0.11.3", + "proc-macro2", + "quote", + "syn 2.0.118", +] + +[[package]] +name = "phf_macros" +version = "0.13.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "812f032b54b1e759ccd5f8b6677695d5268c588701effba24601f6932f8269ef" +dependencies = [ + "phf_generator 0.13.1", + "phf_shared 0.13.1", + "proc-macro2", + "quote", + "syn 2.0.118", +] + +[[package]] +name = "phf_shared" +version = "0.11.3" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "67eabc2ef2a60eb7faa00097bd1ffdb5bd28e62bf39990626a582201b7a754e5" +dependencies = [ + "siphasher", +] + +[[package]] +name = "phf_shared" +version = "0.13.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "e57fef6bc5981e38c2ce2d63bfa546861309f875b8a75f092d1d54ae2d64f266" +dependencies = [ + "siphasher", +] + +[[package]] +name = "pin-project" +version = "1.1.13" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "2466b2336ed02bcdca6b294417127b90ec92038d1d5c4fbeac971a922e0e0924" +dependencies = [ + "pin-project-internal", +] + +[[package]] +name = "pin-project-internal" +version = "1.1.13" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "c96395f0a926bc13b1c17622aaddda1ecb55d49c8f1bf9777e4d877800a43f8b" +dependencies = [ + "proc-macro2", + "quote", + "syn 2.0.118", +] + +[[package]] +name = "pin-project-lite" +version = "0.2.17" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "a89322df9ebe1c1578d689c92318e070967d1042b512afbe49518723f4e6d5cd" + +[[package]] +name = "pkcs1" +version = "0.3.3" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "a78f66c04ccc83dd4486fd46c33896f4e17b24a7a3a6400dedc48ed0ddd72320" +dependencies = [ + "der 0.5.1", + "pkcs8 0.8.0", + "zeroize", +] + +[[package]] +name = "pkcs8" +version = "0.8.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "7cabda3fb821068a9a4fab19a683eac3af12edf0f34b94a8be53c4972b8149d0" +dependencies = [ + "der 0.5.1", + "spki 0.5.4", + "zeroize", +] + +[[package]] +name = "pkcs8" +version = "0.10.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "f950b2377845cebe5cf8b5165cb3cc1a5e0fa5cfa3e1f7f55707d8fd82e0a7b7" +dependencies = [ + "der 0.7.10", + "spki 0.7.3", +] + +[[package]] +name = "pkg-config" +version = "0.3.33" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "19f132c84eca552bf34cab8ec81f1c1dcc229b811638f9d283dceabe58c5569e" + +[[package]] +name = "potential_utf" +version = "0.1.5" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "0103b1cef7ec0cf76490e969665504990193874ea05c85ff9bab8b911d0a0564" +dependencies = [ + "zerovec", +] + +[[package]] +name = "powerfmt" +version = "0.2.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "439ee305def115ba05938db6eb1644ff94165c5ab5e9420d1c1bcedbba909391" + +[[package]] +name = "ppanel-backend" +version = "0.1.0" +dependencies = [ + "anyhow", + "async-trait", + "asynq", + "axum 0.8.9", + "base64 0.22.1", + "chrono", + "email", + "gtmpl", + "gtmpl_value", + "hex", + "jwt", + "md5 0.7.0", + "oauth", + "opentelemetry", + "opentelemetry-otlp", + "opentelemetry-semantic-conventions", + "opentelemetry-stdout", + "opentelemetry_sdk", + "password", + "payment", + "rand 0.8.6", + "redis 0.27.6", + "reqwest 0.12.28", + "result", + "serde", + "serde_json", + "serde_urlencoded", + "serde_yaml", + "sms", + "sqlx", + "tokio", + "tower-http", + "tracing", + "tracing-appender", + "tracing-opentelemetry", + "tracing-subscriber", + "urlencoding", + "uuid 1.23.4", +] + +[[package]] +name = "ppv-lite86" +version = "0.2.21" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "85eae3c4ed2f50dcfe72643da4befc30deadb458a9b590d720cde2f2b1e97da9" +dependencies = [ + "zerocopy", +] + +[[package]] +name = "prettyplease" +version = "0.2.37" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "479ca8adacdd7ce8f1fb39ce9ecccbfe93a3f1344b3d0d97f20bc0196208f62b" +dependencies = [ + "proc-macro2", + "syn 2.0.118", +] + +[[package]] +name = "primeorder" +version = "0.13.6" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "353e1ca18966c16d9deb1c69278edbc5f194139612772bd9537af60ac231e1e6" +dependencies = [ + "elliptic-curve", +] + +[[package]] +name = "proc-macro2" +version = "1.0.106" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "8fd00f0bb2e90d81d1044c2b32617f68fcb9fa3bb7640c23e9c748e53fb30934" +dependencies = [ + "unicode-ident", +] + +[[package]] +name = "prost" +version = "0.13.5" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "2796faa41db3ec313a31f7624d9286acf277b52de526150b7e69f3debf891ee5" +dependencies = [ + "bytes", + "prost-derive 0.13.5", +] + +[[package]] +name = "prost" +version = "0.14.4" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "528ac67416ff8646872a3c02cad9cc4ee5dc9f9540c9b10771855c95cb2e5ae1" +dependencies = [ + "bytes", + "prost-derive 0.14.4", +] + +[[package]] +name = "prost-build" +version = "0.14.4" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "03da047801ff44bb6a4d407d4860c05fd70bb81714e6b2f3812603d5b145b042" +dependencies = [ + "heck", + "itertools 0.14.0", + "log", + "multimap", + "petgraph", + "prettyplease", + "prost 0.14.4", + "prost-types", + "regex", + "syn 2.0.118", + "tempfile", +] + +[[package]] +name = "prost-derive" +version = "0.13.5" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "8a56d757972c98b346a9b766e3f02746cde6dd1cd1d1d563472929fdd74bec4d" +dependencies = [ + "anyhow", + "itertools 0.14.0", + "proc-macro2", + "quote", + "syn 2.0.118", +] + +[[package]] +name = "prost-derive" +version = "0.14.4" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "b570b25f7617e43d59005d0990ccb79e950a423952cea19671b7a876da390adf" +dependencies = [ + "anyhow", + "itertools 0.14.0", + "proc-macro2", + "quote", + "syn 2.0.118", +] + +[[package]] +name = "prost-types" +version = "0.14.4" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "f94967dc7688f3054c7fac87473ffae4cc4c3904800e2d9f5b857246d8963b0a" +dependencies = [ + "prost 0.14.4", +] + +[[package]] +name = "quinn" +version = "0.11.11" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "0c1a41e437b6bbd489372cd4971de128e85c855f56c57f283d20ff016cf7c0a8" +dependencies = [ + "bytes", + "cfg_aliases", + "pin-project-lite", + "quinn-proto", + "quinn-udp", + "rustc-hash", + "rustls", + "socket2 0.6.4", + "thiserror 2.0.18", + "tokio", + "tracing", + "web-time", +] + +[[package]] +name = "quinn-proto" +version = "0.11.15" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "4fcb935c5bec503c2f0e306bdd3e58bb9029dcb14fa8d9ac76e3a5256ac0763e" +dependencies = [ + "aws-lc-rs", + "bytes", + "getrandom 0.3.4", + "lru-slab", + "rand 0.9.4", + "ring", + "rustc-hash", + "rustls", + "rustls-pki-types", + "slab", + "thiserror 2.0.18", + "tinyvec", + "tracing", + "web-time", +] + +[[package]] +name = "quinn-udp" +version = "0.5.14" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "addec6a0dcad8a8d96a771f815f0eaf55f9d1805756410b39f5fa81332574cbd" +dependencies = [ + "cfg_aliases", + "libc", + "once_cell", + "socket2 0.6.4", + "tracing", + "windows-sys 0.60.2", +] + +[[package]] +name = "quote" +version = "1.0.46" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "dfbc457d0c7a0759a614551b11a6409e5951f6c7537be1f1b7682b9ae9230368" +dependencies = [ + "proc-macro2", +] + +[[package]] +name = "quoted_printable" +version = "0.5.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "478e0585659a122aa407eb7e3c0e1fa51b1d8a870038bd29f0cf4a8551eea972" + +[[package]] +name = "r-efi" +version = "5.3.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "69cdb34c158ceb288df11e18b4bd39de994f6657d83847bdffdbd7f346754b0f" + +[[package]] +name = "r-efi" +version = "6.0.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "f8dcc9c7d52a811697d2151c701e0d08956f92b0e24136cf4cf27b57a6a0d9bf" + +[[package]] +name = "rand" +version = "0.7.3" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "6a6b1679d49b24bbfe0c803429aa1874472f50d9b363131f0e89fc356b544d03" +dependencies = [ + "getrandom 0.1.16", + "libc", + "rand_chacha 0.2.2", + "rand_core 0.5.1", + "rand_hc", +] + +[[package]] +name = "rand" +version = "0.8.6" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "5ca0ecfa931c29007047d1bc58e623ab12e5590e8c7cc53200d5202b69266d8a" +dependencies = [ + "libc", + "rand_chacha 0.3.1", + "rand_core 0.6.4", +] + +[[package]] +name = "rand" +version = "0.9.4" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "44c5af06bb1b7d3216d91932aed5265164bf384dc89cd6ba05cf59a35f5f76ea" +dependencies = [ + "rand_chacha 0.9.0", + "rand_core 0.9.5", +] + +[[package]] +name = "rand" +version = "0.10.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "d2e8e8bcc7961af1fdac401278c6a831614941f6164ee3bf4ce61b7edb162207" +dependencies = [ + "chacha20", + "getrandom 0.4.3", + "rand_core 0.10.1", +] + +[[package]] +name = "rand_chacha" +version = "0.2.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "f4c8ed856279c9737206bf725bf36935d8666ead7aa69b52be55af369d193402" +dependencies = [ + "ppv-lite86", + "rand_core 0.5.1", +] + +[[package]] +name = "rand_chacha" +version = "0.3.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "e6c10a63a0fa32252be49d21e7709d4d4baf8d231c2dbce1eaa8141b9b127d88" +dependencies = [ + "ppv-lite86", + "rand_core 0.6.4", +] + +[[package]] +name = "rand_chacha" +version = "0.9.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "d3022b5f1df60f26e1ffddd6c66e8aa15de382ae63b3a0c1bfc0e4d3e3f325cb" +dependencies = [ + "ppv-lite86", + "rand_core 0.9.5", +] + +[[package]] +name = "rand_core" +version = "0.5.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "90bde5296fc891b0cef12a6d03ddccc162ce7b2aff54160af9338f8d40df6d19" +dependencies = [ + "getrandom 0.1.16", +] + +[[package]] +name = "rand_core" +version = "0.6.4" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "ec0be4795e2f6a28069bec0b5ff3e2ac9bafc99e6a9a7dc3547996c5c816922c" +dependencies = [ + "getrandom 0.2.17", +] + +[[package]] +name = "rand_core" +version = "0.9.5" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "76afc826de14238e6e8c374ddcc1fa19e374fd8dd986b0d2af0d02377261d83c" +dependencies = [ + "getrandom 0.3.4", +] + +[[package]] +name = "rand_core" +version = "0.10.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "63b8176103e19a2643978565ca18b50549f6101881c443590420e4dc998a3c69" + +[[package]] +name = "rand_hc" +version = "0.2.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "ca3129af7b92a17112d59ad498c6f81eaf463253766b90396d39ea7a39d6613c" +dependencies = [ + "rand_core 0.5.1", +] + +[[package]] +name = "redis" +version = "0.27.6" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "09d8f99a4090c89cc489a94833c901ead69bfbf3877b4867d5482e321ee875bc" +dependencies = [ + "arc-swap", + "async-trait", + "backon", + "bytes", + "combine", + "futures", + "futures-util", + "itertools 0.13.0", + "itoa", + "num-bigint", + "percent-encoding", + "pin-project-lite", + "ryu", + "sha1_smol", + "socket2 0.5.10", + "tokio", + "tokio-util", + "url", +] + +[[package]] +name = "redis" +version = "1.3.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "2fa6f8e4b491d7a8ef3a9550a4d71969bd0064f46e32b8dbbcc7fc60dad94fed" +dependencies = [ + "arc-swap", + "arcstr", + "async-lock", + "backon", + "bytes", + "cfg-if", + "combine", + "futures-channel", + "futures-util", + "itoa", + "num-bigint", + "percent-encoding", + "pin-project-lite", + "ryu", + "sha1_smol", + "socket2 0.6.4", + "tokio", + "tokio-util", + "url", + "xxhash-rust", +] + +[[package]] +name = "redox_syscall" +version = "0.5.18" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "ed2bf2547551a7053d6fdfafda3f938979645c44812fbfcda098faae3f1a362d" +dependencies = [ + "bitflags", +] + +[[package]] +name = "ref-cast" +version = "1.0.25" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "f354300ae66f76f1c85c5f84693f0ce81d747e2c3f21a45fef496d89c960bf7d" +dependencies = [ + "ref-cast-impl", +] + +[[package]] +name = "ref-cast-impl" +version = "1.0.25" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "b7186006dcb21920990093f30e3dea63b7d6e977bf1256be20c3563a5db070da" +dependencies = [ + "proc-macro2", + "quote", + "syn 2.0.118", +] + +[[package]] +name = "regex" +version = "1.12.4" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "f1292b7759ae1cb9ec195452d1390a074f0cd8541ab7a5a8c31cd6db45d4a6ba" +dependencies = [ + "aho-corasick", + "memchr", + "regex-automata", + "regex-syntax", +] + +[[package]] +name = "regex-automata" +version = "0.4.14" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "6e1dd4122fc1595e8162618945476892eefca7b88c52820e74af6262213cae8f" +dependencies = [ + "aho-corasick", + "memchr", + "regex-syntax", +] + +[[package]] +name = "regex-syntax" +version = "0.8.11" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "d6f6ff9a378485b298a5286656da665ba74413d36db0979633275d2e708145d4" + +[[package]] +name = "reqwest" +version = "0.12.28" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "eddd3ca559203180a307f12d114c268abf583f59b03cb906fd0b3ff8646c1147" +dependencies = [ + "base64 0.22.1", + "bytes", + "encoding_rs", + "futures-core", + "h2 0.4.15", + "http 1.4.2", + "http-body 1.0.1", + "http-body-util", + "hyper 1.10.1", + "hyper-rustls", + "hyper-tls 0.6.0", + "hyper-util", + "js-sys", + "log", + "mime", + "native-tls", + "percent-encoding", + "pin-project-lite", + "rustls-pki-types", + "serde", + "serde_json", + "serde_urlencoded", + "sync_wrapper", + "tokio", + "tokio-native-tls", + "tower 0.5.3", + "tower-http", + "tower-service", + "url", + "wasm-bindgen", + "wasm-bindgen-futures", + "web-sys", +] + +[[package]] +name = "reqwest" +version = "0.13.4" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "219c5811de6525e5416c7d5d53bb656d3afdbc6c5af816e0802bcfa42dbdc1c3" +dependencies = [ + "base64 0.22.1", + "bytes", + "encoding_rs", + "futures-core", + "h2 0.4.15", + "http 1.4.2", + "http-body 1.0.1", + "http-body-util", + "hyper 1.10.1", + "hyper-rustls", + "hyper-util", + "js-sys", + "log", + "mime", + "percent-encoding", + "pin-project-lite", + "quinn", + "rustls", + "rustls-pki-types", + "rustls-platform-verifier", + "serde", + "serde_json", + "sync_wrapper", + "tokio", + "tokio-rustls", + "tower 0.5.3", + "tower-http", + "tower-service", + "url", + "wasm-bindgen", + "wasm-bindgen-futures", + "web-sys", +] + +[[package]] +name = "result" +version = "0.1.0" +dependencies = [ + "anyhow", + "axum 0.8.9", + "serde", + "serde_json", + "tokio", +] + +[[package]] +name = "rfc6979" +version = "0.4.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "f8dd2a808d456c4a54e300a23e9f5a67e122c3024119acbfd73e3bf664491cb2" +dependencies = [ + "hmac 0.12.1", + "subtle", +] + +[[package]] +name = "ring" +version = "0.17.14" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "a4689e6c2294d81e88dc6261c768b63bc4fcdb852be6d1352498b114f61383b7" +dependencies = [ + "cc", + "cfg-if", + "getrandom 0.2.17", + "libc", + "untrusted", + "windows-sys 0.52.0", +] + +[[package]] +name = "rsa" +version = "0.6.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "4cf22754c49613d2b3b119f0e5d46e34a2c628a937e3024b8762de4e7d8c710b" +dependencies = [ + "byteorder", + "digest 0.10.7", + "num-bigint-dig", + "num-integer", + "num-iter", + "num-traits", + "pkcs1", + "pkcs8 0.8.0", + "rand_core 0.6.4", + "smallvec", + "subtle", + "zeroize", +] + +[[package]] +name = "rustc-hash" +version = "2.1.3" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "6b1e7f9a428571be2dc5bc0505c13fb6bf936822b894ec87abf8a08a4e51742d" + +[[package]] +name = "rustc_version" +version = "0.4.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "cfcb3a22ef46e85b45de6ee7e79d063319ebb6594faafcf1c225ea92ab6e9b92" +dependencies = [ + "semver", +] + +[[package]] +name = "rusticata-macros" +version = "4.1.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "faf0c4a6ece9950b9abdb62b1cfcf2a68b3b67a10ba445b3bb85be2a293d0632" +dependencies = [ + "nom 7.1.3", +] + +[[package]] +name = "rustix" +version = "1.1.4" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "b6fe4565b9518b83ef4f91bb47ce29620ca828bd32cb7e408f0062e9930ba190" +dependencies = [ + "bitflags", + "errno", + "libc", + "linux-raw-sys", + "windows-sys 0.61.2", +] + +[[package]] +name = "rustls" +version = "0.23.41" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "6b92b125634d9b795e7beca796cc790df15a7fb38323bf3196fda83292d06b1f" +dependencies = [ + "aws-lc-rs", + "log", + "once_cell", + "rustls-pki-types", + "rustls-webpki", + "subtle", + "zeroize", +] + +[[package]] +name = "rustls-native-certs" +version = "0.8.4" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "dab5152771c58876a2146916e53e35057e1a4dfa2b9df0f0305b07f611fdea4d" +dependencies = [ + "openssl-probe", + "rustls-pki-types", + "schannel", + "security-framework", +] + +[[package]] +name = "rustls-pki-types" +version = "1.15.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "764899a24af3980067ee14bc143654f297b22eaebfe3c7b6b211920a5a59b046" +dependencies = [ + "web-time", + "zeroize", +] + +[[package]] +name = "rustls-platform-verifier" +version = "0.7.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "26d1e2536ce4f35f4846aa13bff16bd0ff40157cdb14cc056c7b14ba41233ba0" +dependencies = [ + "core-foundation 0.10.1", + "core-foundation-sys", + "jni", + "log", + "once_cell", + "rustls", + "rustls-native-certs", + "rustls-platform-verifier-android", + "rustls-webpki", + "security-framework", + "security-framework-sys", + "webpki-root-certs", + "windows-sys 0.61.2", +] + +[[package]] +name = "rustls-platform-verifier-android" +version = "0.1.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "f87165f0995f63a9fbeea62b64d10b4d9d8e78ec6d7d51fb2125fda7bb36788f" + +[[package]] +name = "rustls-webpki" +version = "0.103.13" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "61c429a8649f110dddef65e2a5ad240f747e85f7758a6bccc7e5777bd33f756e" +dependencies = [ + "aws-lc-rs", + "ring", + "rustls-pki-types", + "untrusted", +] + +[[package]] +name = "rustversion" +version = "1.0.22" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "b39cdef0fa800fc44525c84ccb54a029961a8215f9619753635a9c0d2538d46d" + +[[package]] +name = "ryu" +version = "1.0.23" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "9774ba4a74de5f7b1c1451ed6cd5285a32eddb5cccb8cc655a4e50009e06477f" + +[[package]] +name = "same-file" +version = "1.0.6" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "93fc1dc3aaa9bfed95e02e6eadabb4baf7e3078b0bd1b4d7b6b0b68378900502" +dependencies = [ + "winapi-util", +] + +[[package]] +name = "schannel" +version = "0.1.29" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "91c1b7e4904c873ef0710c1f407dde2e6287de2bebc1bbbf7d430bb7cbffd939" +dependencies = [ + "windows-sys 0.61.2", +] + +[[package]] +name = "schemars" +version = "0.9.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "4cd191f9397d57d581cddd31014772520aa448f65ef991055d7f61582c65165f" +dependencies = [ + "dyn-clone", + "ref-cast", + "serde", + "serde_json", +] + +[[package]] +name = "schemars" +version = "1.2.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "a2b42f36aa1cd011945615b92222f6bf73c599a102a300334cd7f8dbeec726cc" +dependencies = [ + "dyn-clone", + "ref-cast", + "serde", + "serde_json", +] + +[[package]] +name = "scopeguard" +version = "1.2.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "94143f37725109f92c262ed2cf5e59bce7498c01bcc1502d7b9afe439a4e9f49" + +[[package]] +name = "sec1" +version = "0.7.3" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "d3e97a565f76233a6003f9f5c54be1d9c5bdfa3eccfb189469f11ec4901c47dc" +dependencies = [ + "base16ct", + "der 0.7.10", + "generic-array", + "pkcs8 0.10.2", + "subtle", + "zeroize", +] + +[[package]] +name = "security-framework" +version = "3.7.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "b7f4bc775c73d9a02cde8bf7b2ec4c9d12743edf609006c7facc23998404cd1d" +dependencies = [ + "bitflags", + "core-foundation 0.10.1", + "core-foundation-sys", + "libc", + "security-framework-sys", +] + +[[package]] +name = "security-framework-sys" +version = "2.17.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "6ce2691df843ecc5d231c0b14ece2acc3efb62c0a398c7e1d875f3983ce020e3" +dependencies = [ + "core-foundation-sys", + "libc", +] + +[[package]] +name = "semver" +version = "1.0.28" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "8a7852d02fc848982e0c167ef163aaff9cd91dc640ba85e263cb1ce46fae51cd" + +[[package]] +name = "serde" +version = "1.0.228" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "9a8e94ea7f378bd32cbbd37198a4a91436180c5bb472411e48b5ec2e2124ae9e" +dependencies = [ + "serde_core", + "serde_derive", +] + +[[package]] +name = "serde_core" +version = "1.0.228" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "41d385c7d4ca58e59fc732af25c3983b67ac852c1a25000afe1175de458b67ad" +dependencies = [ + "serde_derive", +] + +[[package]] +name = "serde_derive" +version = "1.0.228" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "d540f220d3187173da220f885ab66608367b6574e925011a9353e4badda91d79" +dependencies = [ + "proc-macro2", + "quote", + "syn 2.0.118", +] + +[[package]] +name = "serde_json" +version = "1.0.150" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "e8014e44b4736ed0538adeecded0fce2a272f22dc9578a7eb6b2d9993c74cfb9" +dependencies = [ + "itoa", + "memchr", + "serde", + "serde_core", + "zmij", +] + +[[package]] +name = "serde_path_to_error" +version = "0.1.20" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "10a9ff822e371bb5403e391ecd83e182e0e77ba7f6fe0160b795797109d1b457" +dependencies = [ + "itoa", + "serde", + "serde_core", +] + +[[package]] +name = "serde_qs" +version = "0.8.5" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "c7715380eec75f029a4ef7de39a9200e0a63823176b759d055b613f5a87df6a6" +dependencies = [ + "percent-encoding", + "serde", + "thiserror 1.0.69", +] + +[[package]] +name = "serde_qs" +version = "0.10.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "8cac3f1e2ca2fe333923a1ae72caca910b98ed0630bb35ef6f8c8517d6e81afa" +dependencies = [ + "percent-encoding", + "serde", + "thiserror 1.0.69", +] + +[[package]] +name = "serde_urlencoded" +version = "0.7.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "d3491c14715ca2294c4d6a88f15e84739788c1d030eed8c110436aafdaa2f3fd" +dependencies = [ + "form_urlencoded", + "itoa", + "ryu", + "serde", +] + +[[package]] +name = "serde_with" +version = "3.21.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "76a5c54c7310e7b8b9577c286d7e399ddd876c3e12b3ed917a8aabc4b96e9e8c" +dependencies = [ + "base64 0.22.1", + "bs58", + "chrono", + "hex", + "indexmap 1.9.3", + "indexmap 2.14.0", + "schemars 0.9.0", + "schemars 1.2.1", + "serde_core", + "serde_json", + "serde_with_macros", + "time", +] + +[[package]] +name = "serde_with_macros" +version = "3.21.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "84d57bc0c8b9a17920c178daa6bb924850d54a9c97ab45194bb8c17ad66bb660" +dependencies = [ + "darling", + "proc-macro2", + "quote", + "syn 2.0.118", +] + +[[package]] +name = "serde_yaml" +version = "0.9.34+deprecated" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "6a8b1a1a2ebf674015cc02edccce75287f1a0130d394307b36743c2f5d504b47" +dependencies = [ + "indexmap 2.14.0", + "itoa", + "ryu", + "serde", + "unsafe-libyaml", +] + +[[package]] +name = "sha1" +version = "0.10.6" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "e3bf829a2d51ab4a5ddf1352d8470c140cadc8301b2ae1789db023f01cedd6ba" +dependencies = [ + "cfg-if", + "cpufeatures 0.2.17", + "digest 0.10.7", +] + +[[package]] +name = "sha1" +version = "0.11.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "aacc4cc499359472b4abe1bf11d0b12e688af9a805fa5e3016f9a386dc2d0214" +dependencies = [ + "cfg-if", + "cpufeatures 0.3.0", + "digest 0.11.3", +] + +[[package]] +name = "sha1_smol" +version = "1.0.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "bbfa15b3dddfee50a0fff136974b3e1bde555604ba463834a7eb7deb6417705d" + +[[package]] +name = "sha2" +version = "0.10.9" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "a7507d819769d01a365ab707794a4084392c824f54a7a6a7862f8c3d0892b283" +dependencies = [ + "cfg-if", + "cpufeatures 0.2.17", + "digest 0.10.7", +] + +[[package]] +name = "sha2" +version = "0.11.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "446ba717509524cb3f22f17ecc096f10f4822d76ab5c0b9822c5f9c284e825f4" +dependencies = [ + "cfg-if", + "cpufeatures 0.3.0", + "digest 0.11.3", +] + +[[package]] +name = "sharded-slab" +version = "0.1.7" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "f40ca3c46823713e0d4209592e8d6e826aa57e928f09752619fc696c499637f6" +dependencies = [ + "lazy_static", +] + +[[package]] +name = "shlex" +version = "2.0.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "f8fadd59c855ef2080decdef8ff161eb6661b86933c9d82e5ba29dc602a55aba" + +[[package]] +name = "signal-hook-registry" +version = "1.4.8" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "c4db69cba1110affc0e9f7bcd48bbf87b3f4fc7c61fc9155afd4c469eb3d6c1b" +dependencies = [ + "errno", + "libc", +] + +[[package]] +name = "signature" +version = "2.2.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "77549399552de45a898a580c1b41d445bf730df867cc44e6c0233bbc4b8329de" +dependencies = [ + "digest 0.10.7", + "rand_core 0.6.4", +] + +[[package]] +name = "simd-adler32" +version = "0.3.9" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "703d5c7ef118737c72f1af64ad2f6f8c5e1921f818cdcb97b8fe6fc69bf66214" + +[[package]] +name = "simd_cesu8" +version = "1.1.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "94f90157bb87cddf702797c5dadfa0be7d266cdf49e22da2fcaa32eff75b2c33" +dependencies = [ + "rustc_version", + "simdutf8", +] + +[[package]] +name = "simdutf8" +version = "0.1.5" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "e3a9fe34e3e7a50316060351f37187a3f546bce95496156754b601a5fa71b76e" + +[[package]] +name = "simple_asn1" +version = "0.6.4" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "0d585997b0ac10be3c5ee635f1bab02d512760d14b7c468801ac8a01d9ae5f1d" +dependencies = [ + "num-bigint", + "num-traits", + "thiserror 2.0.18", + "time", +] + +[[package]] +name = "siphasher" +version = "1.0.3" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "8ee5873ec9cce0195efcb7a4e9507a04cd49aec9c83d0389df45b1ef7ba2e649" + +[[package]] +name = "slab" +version = "0.4.12" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "0c790de23124f9ab44544d7ac05d60440adc586479ce501c1d6d7da3cd8c9cf5" + +[[package]] +name = "smallvec" +version = "1.15.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "8ed6a63f02c8539c91a8685a86f4099661ba3da017932f6ebbea6de3f0fa7c90" +dependencies = [ + "serde", +] + +[[package]] +name = "smart-default" +version = "0.6.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "133659a15339456eeeb07572eb02a91c91e9815e9cbc89566944d2c8d3efdbf6" +dependencies = [ + "proc-macro2", + "quote", + "syn 1.0.109", +] + +[[package]] +name = "smol_str" +version = "0.1.24" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "fad6c857cbab2627dcf01ec85a623ca4e7dcb5691cbaa3d7fb7653671f0d09c9" +dependencies = [ + "serde", +] + +[[package]] +name = "sms" +version = "0.1.0" +dependencies = [ + "anyhow", + "async-trait", + "base64 0.22.1", + "chrono", + "hmac 0.12.1", + "md5 0.7.0", + "reqwest 0.12.28", + "serde", + "serde_json", + "sha1 0.10.6", + "sha2 0.10.9", + "tokio", +] + +[[package]] +name = "socket2" +version = "0.5.10" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "e22376abed350d73dd1cd119b57ffccad95b4e585a7cda43e286245ce23c0678" +dependencies = [ + "libc", + "windows-sys 0.52.0", +] + +[[package]] +name = "socket2" +version = "0.6.4" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "52d1cfed4120b4d927bf7c0f86d2087a4a7d6027c906d9f9d525a80573b9be51" +dependencies = [ + "libc", + "windows-sys 0.61.2", +] + +[[package]] +name = "spin" +version = "0.9.8" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "6980e8d7511241f8acf4aebddbb1ff938df5eebe98691418c4468d0b72a96a67" +dependencies = [ + "lock_api", +] + +[[package]] +name = "spki" +version = "0.5.4" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "44d01ac02a6ccf3e07db148d2be087da624fea0221a16152ed01f0496a6b0a27" +dependencies = [ + "base64ct", + "der 0.5.1", +] + +[[package]] +name = "spki" +version = "0.7.3" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "d91ed6c858b01f942cd56b37a94b3e0a1798290327d1236e4d9cf4eaca44d29d" +dependencies = [ + "base64ct", + "der 0.7.10", +] + +[[package]] +name = "sqlx" +version = "0.9.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "378620ccc25c62c89d8be1c819e76a88d59bdcc3304733330788948e619bfd71" +dependencies = [ + "sqlx-core", + "sqlx-macros", + "sqlx-mysql", + "sqlx-postgres", + "sqlx-sqlite", +] + +[[package]] +name = "sqlx-core" +version = "0.9.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "05b44e85bf579a8eeb4ceaa77a3a523baf2bf0e9bac7e40f405d537b5d2d5ccb" +dependencies = [ + "base64 0.22.1", + "bytes", + "cfg-if", + "chrono", + "crc", + "crossbeam-queue", + "either", + "event-listener 5.4.1", + "futures-core", + "futures-intrusive", + "futures-io", + "futures-util", + "hashbrown 0.16.1", + "hashlink", + "indexmap 2.14.0", + "log", + "memchr", + "percent-encoding", + "serde", + "serde_json", + "sha2 0.10.9", + "smallvec", + "thiserror 2.0.18", + "tokio", + "tokio-stream", + "tracing", + "url", + "uuid 1.23.4", +] + +[[package]] +name = "sqlx-macros" +version = "0.9.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "bd2b84f2bc39a5705ef27ec785a11c934a41bbd4a24941e257927cddc26b60bf" +dependencies = [ + "proc-macro2", + "quote", + "sqlx-core", + "sqlx-macros-core", + "syn 2.0.118", +] + +[[package]] +name = "sqlx-macros-core" +version = "0.9.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "fb8d96de5fdc85a5c4ec813432b523ec637e80ba98f046555f75f7908ddac7c3" +dependencies = [ + "cfg-if", + "dotenvy", + "either", + "heck", + "hex", + "proc-macro2", + "quote", + "serde", + "serde_json", + "sha2 0.10.9", + "sqlx-core", + "sqlx-mysql", + "sqlx-postgres", + "sqlx-sqlite", + "syn 2.0.118", + "thiserror 2.0.18", + "tokio", + "url", +] + +[[package]] +name = "sqlx-mysql" +version = "0.9.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "90b8020fe17c5f2c245bfa2505d7ef59c5604839527c740266ad2214acebea27" +dependencies = [ + "bitflags", + "byteorder", + "bytes", + "chrono", + "crc", + "digest 0.11.3", + "dotenvy", + "either", + "futures-core", + "futures-util", + "generic-array", + "log", + "percent-encoding", + "serde", + "sha1 0.11.0", + "sha2 0.11.0", + "sqlx-core", + "thiserror 2.0.18", + "tracing", + "uuid 1.23.4", +] + +[[package]] +name = "sqlx-postgres" +version = "0.9.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "87a2bdd6e83f6b3ea525ca9fee568030508b58355a43d0b2c1674d5f79dcd65e" +dependencies = [ + "atoi", + "base64 0.22.1", + "bitflags", + "byteorder", + "chrono", + "crc", + "dotenvy", + "etcetera", + "futures-channel", + "futures-core", + "futures-util", + "hex", + "hkdf", + "hmac 0.13.0", + "itoa", + "log", + "md-5", + "memchr", + "rand 0.10.1", + "serde", + "serde_json", + "sha2 0.11.0", + "smallvec", + "sqlx-core", + "stringprep", + "thiserror 2.0.18", + "tracing", + "uuid 1.23.4", + "whoami", +] + +[[package]] +name = "sqlx-sqlite" +version = "0.9.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "488e99c397a62007e4229aec669a179816339afc6d2620ca6fa420dbee2e982c" +dependencies = [ + "atoi", + "chrono", + "flume", + "form_urlencoded", + "futures-channel", + "futures-core", + "futures-executor", + "futures-intrusive", + "futures-util", + "libsqlite3-sys", + "log", + "percent-encoding", + "serde", + "sqlx-core", + "thiserror 2.0.18", + "tracing", + "url", + "uuid 1.23.4", +] + +[[package]] +name = "stable_deref_trait" +version = "1.2.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "6ce2be8dc25455e1f91df71bfa12ad37d7af1092ae736f3a6cd0e37bc7810596" + +[[package]] +name = "stringprep" +version = "0.1.5" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "7b4df3d392d81bd458a8a621b8bffbd2302a12ffe288a9d931670948749463b1" +dependencies = [ + "unicode-bidi", + "unicode-normalization", + "unicode-properties", +] + +[[package]] +name = "strsim" +version = "0.11.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "7da8b5736845d9f2fcb837ea5d9e2628564b3b043a70948a3f0b778838c5fb4f" + +[[package]] +name = "subtle" +version = "2.6.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "13c2bddecc57b384dee18652358fb23172facb8a2c51ccc10d74c157bdea3292" + +[[package]] +name = "symlink" +version = "0.1.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "a7973cce6668464ea31f176d85b13c7ab3bba2cb3b77a2ed26abd7801688010a" + +[[package]] +name = "syn" +version = "1.0.109" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "72b64191b275b66ffe2469e8af2c1cfe3bafa67b529ead792a6d0160888b4237" +dependencies = [ + "proc-macro2", + "quote", + "unicode-ident", +] + +[[package]] +name = "syn" +version = "2.0.118" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "1b9ae57f904213ebb649ce6895b8a66c66f0203b9319718f69a5612a065b1422" +dependencies = [ + "proc-macro2", + "quote", + "unicode-ident", +] + +[[package]] +name = "sync_wrapper" +version = "1.0.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "0bf256ce5efdfa370213c1dabab5935a12e49f2c58d15e9eac2870d3b4f27263" +dependencies = [ + "futures-core", +] + +[[package]] +name = "synstructure" +version = "0.13.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "728a70f3dbaf5bab7f0c4b1ac8d7ae5ea60a4b5549c8a5914361c99147a709d2" +dependencies = [ + "proc-macro2", + "quote", + "syn 2.0.118", +] + +[[package]] +name = "system-configuration" +version = "0.7.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "a13f3d0daba03132c0aa9767f98351b3488edc2c100cda2d2ec2b04f3d8d3c8b" +dependencies = [ + "bitflags", + "core-foundation 0.9.4", + "system-configuration-sys", +] + +[[package]] +name = "system-configuration-sys" +version = "0.6.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "8e1d1b10ced5ca923a1fcb8d03e96b8d3268065d724548c0211415ff6ac6bac4" +dependencies = [ + "core-foundation-sys", + "libc", +] + +[[package]] +name = "tempfile" +version = "3.27.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "32497e9a4c7b38532efcdebeef879707aa9f794296a4f0244f6f69e9bc8574bd" +dependencies = [ + "fastrand 2.4.1", + "getrandom 0.4.3", + "once_cell", + "rustix", + "windows-sys 0.61.2", +] + +[[package]] +name = "thiserror" +version = "1.0.69" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "b6aaf5339b578ea85b50e080feb250a3e8ae8cfcdff9a461c9ec2904bc923f52" +dependencies = [ + "thiserror-impl 1.0.69", +] + +[[package]] +name = "thiserror" +version = "2.0.18" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "4288b5bcbc7920c07a1149a35cf9590a2aa808e0bc1eafaade0b80947865fbc4" +dependencies = [ + "thiserror-impl 2.0.18", +] + +[[package]] +name = "thiserror-impl" +version = "1.0.69" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "4fee6c4efc90059e10f81e6d42c60a18f76588c3d74cb83a0b242a2b6c7504c1" +dependencies = [ + "proc-macro2", + "quote", + "syn 2.0.118", +] + +[[package]] +name = "thiserror-impl" +version = "2.0.18" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "ebc4ee7f67670e9b64d05fa4253e753e016c6c95ff35b89b7941d6b856dec1d5" +dependencies = [ + "proc-macro2", + "quote", + "syn 2.0.118", +] + +[[package]] +name = "thread_local" +version = "1.1.9" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "f60246a4944f24f6e018aa17cdeffb7818b76356965d03b07d6a9886e8962185" +dependencies = [ + "cfg-if", +] + +[[package]] +name = "time" +version = "0.3.53" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "18dfaaeddcb932337b5e7866ee7d0ce9b76d2fd092997146f187ec09b4558a50" +dependencies = [ + "deranged", + "num-conv", + "powerfmt", + "serde_core", + "time-core", + "time-macros", +] + +[[package]] +name = "time-core" +version = "0.1.9" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "9e1c906769ad99c88eaa54e728060edef082f8e358ff32030cb7c7d315e81109" + +[[package]] +name = "time-macros" +version = "0.2.31" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "c431b87111666e491a90baa837f914fb45cd5dc3c268591b0220ff5057f2085f" +dependencies = [ + "num-conv", + "time-core", +] + +[[package]] +name = "tinystr" +version = "0.8.3" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "c8323304221c2a851516f22236c5722a72eaa19749016521d6dff0824447d96d" +dependencies = [ + "displaydoc", + "zerovec", +] + +[[package]] +name = "tinyvec" +version = "1.11.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "3e61e67053d25a4e82c844e8424039d9745781b3fc4f32b8d55ed50f5f667ef3" +dependencies = [ + "tinyvec_macros", +] + +[[package]] +name = "tinyvec_macros" +version = "0.1.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "1f3ccbac311fea05f86f61904b462b55fb3df8837a366dfc601a0161d0532f20" + +[[package]] +name = "tokio" +version = "1.52.3" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "8fc7f01b389ac15039e4dc9531aa973a135d7a4135281b12d7c1bc79fd57fffe" +dependencies = [ + "bytes", + "libc", + "mio", + "parking_lot", + "pin-project-lite", + "signal-hook-registry", + "socket2 0.6.4", + "tokio-macros", + "windows-sys 0.61.2", +] + +[[package]] +name = "tokio-macros" +version = "2.7.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "385a6cb71ab9ab790c5fe8d67f1645e6c450a7ce006a33de03daa956cf70a496" +dependencies = [ + "proc-macro2", + "quote", + "syn 2.0.118", +] + +[[package]] +name = "tokio-native-tls" +version = "0.3.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "bbae76ab933c85776efabc971569dd6119c580d8f5d448769dec1764bf796ef2" +dependencies = [ + "native-tls", + "tokio", +] + +[[package]] +name = "tokio-rustls" +version = "0.26.4" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "1729aa945f29d91ba541258c8df89027d5792d85a8841fb65e8bf0f4ede4ef61" +dependencies = [ + "rustls", + "tokio", +] + +[[package]] +name = "tokio-stream" +version = "0.1.18" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "32da49809aab5c3bc678af03902d4ccddea2a87d028d86392a4b1560c6906c70" +dependencies = [ + "futures-core", + "pin-project-lite", + "tokio", +] + +[[package]] +name = "tokio-util" +version = "0.7.18" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "9ae9cec805b01e8fc3fd2fe289f89149a9b66dd16786abd8b19cfa7b48cb0098" +dependencies = [ + "bytes", + "futures-core", + "futures-sink", + "pin-project-lite", + "tokio", +] + +[[package]] +name = "tonic" +version = "0.12.3" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "877c5b330756d856ffcc4553ab34a5684481ade925ecc54bcd1bf02b1d0d4d52" +dependencies = [ + "async-stream", + "async-trait", + "axum 0.7.9", + "base64 0.22.1", + "bytes", + "h2 0.4.15", + "http 1.4.2", + "http-body 1.0.1", + "http-body-util", + "hyper 1.10.1", + "hyper-timeout", + "hyper-util", + "percent-encoding", + "pin-project", + "prost 0.13.5", + "socket2 0.5.10", + "tokio", + "tokio-stream", + "tower 0.4.13", + "tower-layer", + "tower-service", + "tracing", +] + +[[package]] +name = "tower" +version = "0.4.13" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "b8fa9be0de6cf49e536ce1851f987bd21a43b771b09473c3549a6c853db37c1c" +dependencies = [ + "futures-core", + "futures-util", + "indexmap 1.9.3", + "pin-project", + "pin-project-lite", + "rand 0.8.6", + "slab", + "tokio", + "tokio-util", + "tower-layer", + "tower-service", + "tracing", +] + +[[package]] +name = "tower" +version = "0.5.3" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "ebe5ef63511595f1344e2d5cfa636d973292adc0eec1f0ad45fae9f0851ab1d4" +dependencies = [ + "futures-core", + "futures-util", + "pin-project-lite", + "sync_wrapper", + "tokio", + "tower-layer", + "tower-service", + "tracing", +] + +[[package]] +name = "tower-http" +version = "0.6.11" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "4cfcf7e2740e6fc6d4d688b4ef00650406bb94adf4731e43c096c3a19fe40840" +dependencies = [ + "async-compression", + "bitflags", + "bytes", + "futures-core", + "futures-util", + "http 1.4.2", + "http-body 1.0.1", + "http-body-util", + "pin-project-lite", + "tokio", + "tokio-util", + "tower 0.5.3", + "tower-layer", + "tower-service", + "tracing", + "url", +] + +[[package]] +name = "tower-layer" +version = "0.3.3" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "121c2a6cda46980bb0fcd1647ffaf6cd3fc79a013de288782836f6df9c48780e" + +[[package]] +name = "tower-service" +version = "0.3.3" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "8df9b6e13f2d32c91b9bd719c00d1958837bc7dec474d94952798cc8e69eeec3" + +[[package]] +name = "tracing" +version = "0.1.44" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "63e71662fa4b2a2c3a26f570f037eb95bb1f85397f3cd8076caed2f026a6d100" +dependencies = [ + "log", + "pin-project-lite", + "tracing-attributes", + "tracing-core", +] + +[[package]] +name = "tracing-appender" +version = "0.2.5" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "050686193eb999b4bb3bc2acfa891a13da00f79734704c4b8b4ef1a10b368a3c" +dependencies = [ + "crossbeam-channel", + "symlink", + "thiserror 2.0.18", + "time", + "tracing-subscriber", +] + +[[package]] +name = "tracing-attributes" +version = "0.1.31" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "7490cfa5ec963746568740651ac6781f701c9c5ea257c58e057f3ba8cf69e8da" +dependencies = [ + "proc-macro2", + "quote", + "syn 2.0.118", +] + +[[package]] +name = "tracing-core" +version = "0.1.36" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "db97caf9d906fbde555dd62fa95ddba9eecfd14cb388e4f491a66d74cd5fb79a" +dependencies = [ + "once_cell", + "valuable", +] + +[[package]] +name = "tracing-log" +version = "0.2.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "ee855f1f400bd0e5c02d150ae5de3840039a3f54b025156404e34c23c03f47c3" +dependencies = [ + "log", + "once_cell", + "tracing-core", +] + +[[package]] +name = "tracing-opentelemetry" +version = "0.28.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "97a971f6058498b5c0f1affa23e7ea202057a7301dbff68e968b2d578bcbd053" +dependencies = [ + "js-sys", + "once_cell", + "opentelemetry", + "opentelemetry_sdk", + "smallvec", + "tracing", + "tracing-core", + "tracing-log", + "tracing-subscriber", + "web-time", +] + +[[package]] +name = "tracing-serde" +version = "0.2.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "704b1aeb7be0d0a84fc9828cae51dab5970fee5088f83d1dd7ee6f6246fc6ff1" +dependencies = [ + "serde", + "tracing-core", +] + +[[package]] +name = "tracing-subscriber" +version = "0.3.23" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "cb7f578e5945fb242538965c2d0b04418d38ec25c79d160cd279bf0731c8d319" +dependencies = [ + "matchers", + "nu-ansi-term", + "once_cell", + "regex-automata", + "serde", + "serde_json", + "sharded-slab", + "smallvec", + "thread_local", + "tracing", + "tracing-core", + "tracing-log", + "tracing-serde", +] + +[[package]] +name = "try-lock" +version = "0.2.5" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "e421abadd41a4225275504ea4d6566923418b7f05506fbc9c0fe86ba7396114b" + +[[package]] +name = "typenum" +version = "1.20.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "b6f5e870be6c3b371b77fe0ee0bafb859fa4964b4404c27de1d380043c4dda20" + +[[package]] +name = "unicode-bidi" +version = "0.3.18" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "5c1cb5db39152898a79168971543b1cb5020dff7fe43c8dc468b0885f5e29df5" + +[[package]] +name = "unicode-ident" +version = "1.0.24" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "e6e4313cd5fcd3dad5cafa179702e2b244f760991f45397d14d4ebf38247da75" + +[[package]] +name = "unicode-normalization" +version = "0.1.25" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "5fd4f6878c9cb28d874b009da9e8d183b5abc80117c40bbd187a1fde336be6e8" +dependencies = [ + "tinyvec", +] + +[[package]] +name = "unicode-properties" +version = "0.1.4" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "7df058c713841ad818f1dc5d3fd88063241cc61f49f5fbea4b951e8cf5a8d71d" + +[[package]] +name = "unsafe-libyaml" +version = "0.2.11" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "673aac59facbab8a9007c7f6108d11f63b603f7cabff99fabf650fea5c32b861" + +[[package]] +name = "untrusted" +version = "0.9.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "8ecb6da28b8a351d773b68d5825ac39017e680750f980f3a1a85cd8dd28a47c1" + +[[package]] +name = "url" +version = "2.5.8" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "ff67a8a4397373c3ef660812acab3268222035010ab8680ec4215f38ba3d0eed" +dependencies = [ + "form_urlencoded", + "idna", + "percent-encoding", + "serde", + "serde_derive", +] + +[[package]] +name = "urlencoding" +version = "2.1.3" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "daf8dba3b7eb870caf1ddeed7bc9d2a049f3cfdfae7cb521b087cc33ae4c49da" + +[[package]] +name = "utf8_iter" +version = "1.0.4" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "b6c140620e7ffbb22c2dee59cafe6084a59b5ffc27a8859a5f0d494b5d52b6be" + +[[package]] +name = "uuid" +version = "0.8.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "bc5cf98d8186244414c848017f0e2676b3fcb46807f6668a97dfe67359a3c4b7" +dependencies = [ + "getrandom 0.2.17", +] + +[[package]] +name = "uuid" +version = "1.23.4" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "bf80a72845275afea99e7f2b434723d3bc7e38470fcd1c7ed39a599c73319a53" +dependencies = [ + "getrandom 0.4.3", + "js-sys", + "rand 0.10.1", + "serde_core", + "sha1_smol", + "wasm-bindgen", +] + +[[package]] +name = "valuable" +version = "0.1.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "ba73ea9cf16a25df0c8caa16c51acb937d5712a8429db78a3ee29d5dcacd3a65" + +[[package]] +name = "vcpkg" +version = "0.2.15" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "accd4ea62f7bb7a82fe23066fb0957d48ef677f6eeb8215f372f52e48bb32426" + +[[package]] +name = "version_check" +version = "0.9.5" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "0b928f33d975fc6ad9f86c8f283853ad26bdd5b10b7f1542aa2fa15e2289105a" + +[[package]] +name = "waker-fn" +version = "1.2.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "317211a0dc0ceedd78fb2ca9a44aed3d7b9b26f81870d485c07122b4350673b7" + +[[package]] +name = "walkdir" +version = "2.5.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "29790946404f91d9c5d06f9874efddea1dc06c5efe94541a7d6863108e3a5e4b" +dependencies = [ + "same-file", + "winapi-util", +] + +[[package]] +name = "want" +version = "0.3.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "bfa7760aed19e106de2c7c0b581b509f2f25d3dacaf737cb82ac61bc6d760b0e" +dependencies = [ + "try-lock", +] + +[[package]] +name = "wasi" +version = "0.9.0+wasi-snapshot-preview1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "cccddf32554fecc6acb585f82a32a72e28b48f8c4c1883ddfeeeaa96f7d8e519" + +[[package]] +name = "wasi" +version = "0.11.1+wasi-snapshot-preview1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "ccf3ec651a847eb01de73ccad15eb7d99f80485de043efb2f370cd654f4ea44b" + +[[package]] +name = "wasip2" +version = "1.0.4+wasi-0.2.12" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "b67efb37e106e55ce722a510d6b5f9c17f083e5fc79afc2badeb12cc313d9487" +dependencies = [ + "wit-bindgen", +] + +[[package]] +name = "wasm-bindgen" +version = "0.2.126" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "4b067c0c11094aef6b7a801c1e34a26affafdf3d051dba08456b868789aaf9a4" +dependencies = [ + "cfg-if", + "once_cell", + "rustversion", + "wasm-bindgen-macro", + "wasm-bindgen-shared", +] + +[[package]] +name = "wasm-bindgen-futures" +version = "0.4.76" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "c62df1340f32221cb9c54d6a27b030e3dba64361d4a95bed55f9aacb44da291d" +dependencies = [ + "js-sys", + "wasm-bindgen", +] + +[[package]] +name = "wasm-bindgen-macro" +version = "0.2.126" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "167ce5e579f6bcf889c4f7175a8a5a585de84e8ff93976ce393efa5f2837aab1" +dependencies = [ + "quote", + "wasm-bindgen-macro-support", +] + +[[package]] +name = "wasm-bindgen-macro-support" +version = "0.2.126" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "f3997c7839262f4ef12cf90b818d6340c18e80f263f1a94bf157d0ec4420380e" +dependencies = [ + "bumpalo", + "proc-macro2", + "quote", + "syn 2.0.118", + "wasm-bindgen-shared", +] + +[[package]] +name = "wasm-bindgen-shared" +version = "0.2.126" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "dc1b4cb0cc549fcf58d7dfc081778139b3d283a081644e833e84682ad71cea24" +dependencies = [ + "unicode-ident", +] + +[[package]] +name = "web-sys" +version = "0.3.103" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "8622dcb61c0bcc9fffa6938bed81210af2da9a7e4a1a834b2e37a59b6dfb6141" +dependencies = [ + "js-sys", + "wasm-bindgen", +] + +[[package]] +name = "web-time" +version = "1.1.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "5a6580f308b1fad9207618087a65c04e7a10bc77e02c8e84e9b00dd4b12fa0bb" +dependencies = [ + "js-sys", + "wasm-bindgen", +] + +[[package]] +name = "webpki-root-certs" +version = "1.0.8" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "0d46a5a140e6f7afeccd8eae97eff335163939eac8b929834875168b29b3d267" +dependencies = [ + "rustls-pki-types", +] + +[[package]] +name = "webpki-roots" +version = "1.0.8" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "bf85cb06032201fa7c6f829d7db5a7e5aa45bcc0655327713065f6f0576731bf" +dependencies = [ + "rustls-pki-types", +] + +[[package]] +name = "whoami" +version = "2.1.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "998767ef88740d1f5b0682a9c53c24431453923962269c2db68ee43788c5a40d" + +[[package]] +name = "winapi" +version = "0.3.9" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "5c839a674fcd7a98952e593242ea400abe93992746761e38641405d28b00f419" +dependencies = [ + "winapi-i686-pc-windows-gnu", + "winapi-x86_64-pc-windows-gnu", +] + +[[package]] +name = "winapi-i686-pc-windows-gnu" +version = "0.4.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "ac3b87c63620426dd9b991e5ce0329eff545bccbbb34f3be09ff6fb6ab51b7b6" + +[[package]] +name = "winapi-util" +version = "0.1.11" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "c2a7b1c03c876122aa43f3020e6c3c3ee5c05081c9a00739faf7503aeba10d22" +dependencies = [ + "windows-sys 0.61.2", +] + +[[package]] +name = "winapi-x86_64-pc-windows-gnu" +version = "0.4.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "712e227841d057c1ee1cd2fb22fa7e5a5461ae8e48fa2ca79ec42cfc1931183f" + +[[package]] +name = "windows-core" +version = "0.62.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "b8e83a14d34d0623b51dce9581199302a221863196a1dde71a7663a4c2be9deb" +dependencies = [ + "windows-implement", + "windows-interface", + "windows-link", + "windows-result", + "windows-strings", +] + +[[package]] +name = "windows-implement" +version = "0.60.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "053e2e040ab57b9dc951b72c264860db7eb3b0200ba345b4e4c3b14f67855ddf" +dependencies = [ + "proc-macro2", + "quote", + "syn 2.0.118", +] + +[[package]] +name = "windows-interface" +version = "0.59.3" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "3f316c4a2570ba26bbec722032c4099d8c8bc095efccdc15688708623367e358" +dependencies = [ + "proc-macro2", + "quote", + "syn 2.0.118", +] + +[[package]] +name = "windows-link" +version = "0.2.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "f0805222e57f7521d6a62e36fa9163bc891acd422f971defe97d64e70d0a4fe5" + +[[package]] +name = "windows-registry" +version = "0.6.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "02752bf7fbdcce7f2a27a742f798510f3e5ad88dbe84871e5168e2120c3d5720" +dependencies = [ + "windows-link", + "windows-result", + "windows-strings", +] + +[[package]] +name = "windows-result" +version = "0.4.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "7781fa89eaf60850ac3d2da7af8e5242a5ea78d1a11c49bf2910bb5a73853eb5" +dependencies = [ + "windows-link", +] + +[[package]] +name = "windows-strings" +version = "0.5.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "7837d08f69c77cf6b07689544538e017c1bfcf57e34b4c0ff58e6c2cd3b37091" +dependencies = [ + "windows-link", +] + +[[package]] +name = "windows-sys" +version = "0.52.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "282be5f36a8ce781fad8c8ae18fa3f9beff57ec1b52cb3de0789201425d9a33d" +dependencies = [ + "windows-targets 0.52.6", +] + +[[package]] +name = "windows-sys" +version = "0.60.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "f2f500e4d28234f72040990ec9d39e3a6b950f9f22d3dba18416c35882612bcb" +dependencies = [ + "windows-targets 0.53.5", +] + +[[package]] +name = "windows-sys" +version = "0.61.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "ae137229bcbd6cdf0f7b80a31df61766145077ddf49416a728b02cb3921ff3fc" +dependencies = [ + "windows-link", +] + +[[package]] +name = "windows-targets" +version = "0.52.6" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "9b724f72796e036ab90c1021d4780d4d3d648aca59e491e6b98e725b84e99973" +dependencies = [ + "windows_aarch64_gnullvm 0.52.6", + "windows_aarch64_msvc 0.52.6", + "windows_i686_gnu 0.52.6", + "windows_i686_gnullvm 0.52.6", + "windows_i686_msvc 0.52.6", + "windows_x86_64_gnu 0.52.6", + "windows_x86_64_gnullvm 0.52.6", + "windows_x86_64_msvc 0.52.6", +] + +[[package]] +name = "windows-targets" +version = "0.53.5" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "4945f9f551b88e0d65f3db0bc25c33b8acea4d9e41163edf90dcd0b19f9069f3" +dependencies = [ + "windows-link", + "windows_aarch64_gnullvm 0.53.1", + "windows_aarch64_msvc 0.53.1", + "windows_i686_gnu 0.53.1", + "windows_i686_gnullvm 0.53.1", + "windows_i686_msvc 0.53.1", + "windows_x86_64_gnu 0.53.1", + "windows_x86_64_gnullvm 0.53.1", + "windows_x86_64_msvc 0.53.1", +] + +[[package]] +name = "windows_aarch64_gnullvm" +version = "0.52.6" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "32a4622180e7a0ec044bb555404c800bc9fd9ec262ec147edd5989ccd0c02cd3" + +[[package]] +name = "windows_aarch64_gnullvm" +version = "0.53.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "a9d8416fa8b42f5c947f8482c43e7d89e73a173cead56d044f6a56104a6d1b53" + +[[package]] +name = "windows_aarch64_msvc" +version = "0.52.6" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "09ec2a7bb152e2252b53fa7803150007879548bc709c039df7627cabbd05d469" + +[[package]] +name = "windows_aarch64_msvc" +version = "0.53.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "b9d782e804c2f632e395708e99a94275910eb9100b2114651e04744e9b125006" + +[[package]] +name = "windows_i686_gnu" +version = "0.52.6" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "8e9b5ad5ab802e97eb8e295ac6720e509ee4c243f69d781394014ebfe8bbfa0b" + +[[package]] +name = "windows_i686_gnu" +version = "0.53.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "960e6da069d81e09becb0ca57a65220ddff016ff2d6af6a223cf372a506593a3" + +[[package]] +name = "windows_i686_gnullvm" +version = "0.52.6" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "0eee52d38c090b3caa76c563b86c3a4bd71ef1a819287c19d586d7334ae8ed66" + +[[package]] +name = "windows_i686_gnullvm" +version = "0.53.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "fa7359d10048f68ab8b09fa71c3daccfb0e9b559aed648a8f95469c27057180c" + +[[package]] +name = "windows_i686_msvc" +version = "0.52.6" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "240948bc05c5e7c6dabba28bf89d89ffce3e303022809e73deaefe4f6ec56c66" + +[[package]] +name = "windows_i686_msvc" +version = "0.53.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "1e7ac75179f18232fe9c285163565a57ef8d3c89254a30685b57d83a38d326c2" + +[[package]] +name = "windows_x86_64_gnu" +version = "0.52.6" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "147a5c80aabfbf0c7d901cb5895d1de30ef2907eb21fbbab29ca94c5b08b1a78" + +[[package]] +name = "windows_x86_64_gnu" +version = "0.53.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "9c3842cdd74a865a8066ab39c8a7a473c0778a3f29370b5fd6b4b9aa7df4a499" + +[[package]] +name = "windows_x86_64_gnullvm" +version = "0.52.6" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "24d5b23dc417412679681396f2b49f3de8c1473deb516bd34410872eff51ed0d" + +[[package]] +name = "windows_x86_64_gnullvm" +version = "0.53.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "0ffa179e2d07eee8ad8f57493436566c7cc30ac536a3379fdf008f47f6bb7ae1" + +[[package]] +name = "windows_x86_64_msvc" +version = "0.52.6" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "589f6da84c646204747d1270a2a5661ea66ed1cced2631d546fdfb155959f9ec" + +[[package]] +name = "windows_x86_64_msvc" +version = "0.53.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "d6bbff5f0aada427a1e5a6da5f1f98158182f26556f345ac9e04d36d0ebed650" + +[[package]] +name = "winnow" +version = "0.7.15" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "df79d97927682d2fd8adb29682d1140b343be4ac0f08fd68b7765d9c059d3945" +dependencies = [ + "memchr", +] + +[[package]] +name = "wit-bindgen" +version = "0.57.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "1ebf944e87a7c253233ad6766e082e3cd714b5d03812acc24c318f549614536e" + +[[package]] +name = "writeable" +version = "0.6.3" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "1ffae5123b2d3fc086436f8834ae3ab053a283cfac8fe0a0b8eaae044768a4c4" + +[[package]] +name = "x509-parser" +version = "0.17.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "4569f339c0c402346d4a75a9e39cf8dad310e287eef1ff56d4c68e5067f53460" +dependencies = [ + "asn1-rs", + "data-encoding", + "der-parser", + "lazy_static", + "nom 7.1.3", + "oid-registry", + "rusticata-macros", + "thiserror 2.0.18", + "time", +] + +[[package]] +name = "xxhash-rust" +version = "0.8.16" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "4d93c89cdc2d3a63c3ec48ffe926931bdc069eafa8e4402fe6d8f790c9d1e576" + +[[package]] +name = "yoke" +version = "0.8.3" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "709fe23a0424b6a435d82152b1bd3fdfb0833487d5fa90d05d42762a9891fef5" +dependencies = [ + "stable_deref_trait", + "yoke-derive", + "zerofrom", +] + +[[package]] +name = "yoke-derive" +version = "0.8.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "de844c262c8848816172cef550288e7dc6c7b7814b4ee56b3e1553f275f1858e" +dependencies = [ + "proc-macro2", + "quote", + "syn 2.0.118", + "synstructure", +] + +[[package]] +name = "zerocopy" +version = "0.8.52" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "ce1022995ff5ff5d841ad7d994facc23098cd40152f2c1d11cd607c6f530653f" +dependencies = [ + "zerocopy-derive", +] + +[[package]] +name = "zerocopy-derive" +version = "0.8.52" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "1ae7f38b72ec2a254e2b87ef277cf2cd4fb97cbebf944faa6f33354da0867930" +dependencies = [ + "proc-macro2", + "quote", + "syn 2.0.118", +] + +[[package]] +name = "zerofrom" +version = "0.1.8" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "0ec05a11813ea801ff6d75110ad09cd0824ddba17dfe17128ea0d5f68e6c5272" +dependencies = [ + "zerofrom-derive", +] + +[[package]] +name = "zerofrom-derive" +version = "0.1.7" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "11532158c46691caf0f2593ea8358fed6bbf68a0315e80aae9bd41fbade684a1" +dependencies = [ + "proc-macro2", + "quote", + "syn 2.0.118", + "synstructure", +] + +[[package]] +name = "zeroize" +version = "1.9.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "e13c156562582aa81c60cb29407084cdb54c4164760106ab78e6c5b0858cf64e" + +[[package]] +name = "zerotrie" +version = "0.2.4" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "0f9152d31db0792fa83f70fb2f83148effb5c1f5b8c7686c3459e361d9bc20bf" +dependencies = [ + "displaydoc", + "yoke", + "zerofrom", +] + +[[package]] +name = "zerovec" +version = "0.11.6" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "90f911cbc359ab6af17377d242225f4d75119aec87ea711a880987b18cd7b239" +dependencies = [ + "yoke", + "zerofrom", + "zerovec-derive", +] + +[[package]] +name = "zerovec-derive" +version = "0.11.3" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "625dc425cab0dca6dc3c3319506e6593dcb08a9f387ea3b284dbd52a92c40555" +dependencies = [ + "proc-macro2", + "quote", + "syn 2.0.118", +] + +[[package]] +name = "zmij" +version = "1.0.21" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "b8848ee67ecc8aedbaf3e4122217aff892639231befc6a1b58d29fff4c2cabaa" diff --git a/Cargo.toml b/Cargo.toml new file mode 100644 index 00000000..1f2902e0 --- /dev/null +++ b/Cargo.toml @@ -0,0 +1,47 @@ +[workspace] +members = ["crates/*"] + +[package] +name = "ppanel-backend" +version = "0.1.0" +edition = "2021" + +[dependencies] +asynq = { version = "0.1", features = ["json"] } +anyhow = "1" +async-trait = "0.1" +axum = "0.8" +chrono = "0.4" +base64 = "0.22" +hex = "0.4" +jwt = { path = "crates/jwt" } +oauth = { path = "crates/oauth" } +password = { path = "crates/password" } +rand = "0.8" +redis = { version = "0.27", features = ["tokio-comp", "connection-manager"] } +result = { path = "crates/result" } +payment = { path = "crates/payment" } +serde = { version = "1", features = ["derive"] } +serde_json = "1" +serde_urlencoded = "0.7" +serde_yaml = "0.9" +sqlx = { version = "0.9", features = ["runtime-tokio", "postgres", "mysql", "any", "chrono", "uuid", "migrate", "derive", "macros"] } +tokio = { version = "1", features = ["full"] } +tower-http = { version = "0.6", features = ["trace"] } +tracing = "0.1" +tracing-appender = "0.2" +tracing-subscriber = { version = "0.3", features = ["env-filter", "json"] } +tracing-opentelemetry = "0.28" +opentelemetry = { version = "0.27", features = ["trace"] } +opentelemetry_sdk = { version = "0.27", features = ["rt-tokio", "trace"] } +opentelemetry-otlp = { version = "0.27", features = ["grpc-tonic", "http-proto", "trace"] } +opentelemetry-stdout = { version = "0.27", features = ["trace"] } +opentelemetry-semantic-conventions = "0.27" +uuid = { version = "1", features = ["v4", "v5", "serde"] } +md5 = "0.7" +urlencoding = "2" +gtmpl = "0.7" +gtmpl_value = "0.5" +sms = { path = "crates/sms" } +email = { path = "crates/email" } +reqwest = { version = "0.12", features = ["json"] } diff --git a/crates/email/Cargo.toml b/crates/email/Cargo.toml new file mode 100644 index 00000000..66cbb78b --- /dev/null +++ b/crates/email/Cargo.toml @@ -0,0 +1,16 @@ +[package] +name = "email" +version = "0.1.0" +edition = "2021" + +[dependencies] +lettre = { version = "0.11", default-features = false, features = ["tokio1", "builder", "smtp-transport", "tokio1-native-tls"] } +serde = { version = "1", features = ["derive"] } +serde_json = "1" +tokio = { version = "1", features = ["full"] } +tracing = "0.1" +thiserror = "2" +async-trait = "0.1" +anyhow = "1" + +[dev-dependencies] diff --git a/crates/email/src/lib.rs b/crates/email/src/lib.rs new file mode 100644 index 00000000..7db3beee --- /dev/null +++ b/crates/email/src/lib.rs @@ -0,0 +1,11 @@ +pub mod manager; +pub mod platform; +pub mod sender; +pub mod smtp; +pub mod template; +pub mod worker; + +pub use manager::{get_global_manager, set_global_manager, WorkerManager}; +pub use platform::{get_supported_platforms, Platform, PlatformInfo}; +pub use sender::{new_sender, EmailError, Sender}; +pub use worker::{ErrorInfo, Worker, WorkerStatus}; diff --git a/crates/email/src/manager.rs b/crates/email/src/manager.rs new file mode 100644 index 00000000..20639f33 --- /dev/null +++ b/crates/email/src/manager.rs @@ -0,0 +1,121 @@ +use std::collections::HashMap; +use std::sync::Arc; + +use tokio::sync::RwLock; +use tokio::time::{sleep, Duration}; + +use crate::sender::Sender; +use crate::worker::{TaskInfo, Worker}; + +#[async_trait::async_trait] +pub trait TaskRepo: Send + Sync { + async fn find_one(&self, id: i64) -> Result; + async fn update(&self, data: &TaskInfo) -> Result<(), anyhow::Error>; + async fn update_status(&self, id: i64, status: i16) -> Result<(), anyhow::Error>; + fn is_cancelled(&self, id: i64) -> bool; +} + +pub struct WorkerManager { + repo: Arc, + sender: Arc, + workers: RwLock>, +} + +struct WorkerHandle { + worker: Arc, +} + +impl WorkerManager { + pub fn new(repo: Arc, sender: Arc) -> Arc { + let manager = Arc::new(WorkerManager { + repo, + sender, + workers: RwLock::new(HashMap::new()), + }); + + let mgr = manager.clone(); + tokio::spawn(async move { + loop { + sleep(Duration::from_secs(60)).await; + mgr.check_workers().await; + } + }); + + manager + } + + pub async fn add_worker(&self, id: i64) { + let mut workers = self.workers.write().await; + if workers.contains_key(&id) { + tracing::info!( + "Batch Send Email: Worker already exists, task_id={}", + id + ); + return; + } + + let worker = Arc::new(Worker::new(id, self.repo.clone(), self.sender.clone())); + let handle = WorkerHandle { + worker: worker.clone(), + }; + workers.insert(id, handle); + + tracing::info!( + "Batch Send Email: Added new worker, task_id={}", + id + ); + + tokio::spawn(async move { + worker.start().await; + }); + } + + pub async fn get_worker(&self, id: i64) -> Option> { + let workers = self.workers.read().await; + workers.get(&id).map(|h| h.worker.clone()) + } + + pub async fn remove_worker(&self, id: i64) { + let mut workers = self.workers.write().await; + if workers.remove(&id).is_some() { + tracing::info!( + "Batch Send Email: Removed worker, task_id={}", + id + ); + } else { + tracing::error!( + "Batch Send Email: Worker not found for removal, task_id={}", + id + ); + } + } + + async fn check_workers(&self) { + let mut workers = self.workers.write().await; + let mut to_remove = Vec::new(); + + for (&id, handle) in workers.iter() { + if handle.worker.is_running().await as i16 == 2 { + to_remove.push(id); + } + } + + for id in to_remove { + workers.remove(&id); + tracing::info!( + "Batch Send Email: Removed completed worker, task_id={}", + id + ); + } + } +} + +static MANAGER: std::sync::OnceLock> = std::sync::OnceLock::new(); + +pub fn set_global_manager(manager: Arc) -> Result<(), Arc> { + MANAGER.set(manager) +} + +pub fn get_global_manager() -> Option<&'static Arc> { + MANAGER.get() +} diff --git a/crates/email/src/platform.rs b/crates/email/src/platform.rs new file mode 100644 index 00000000..e2977b77 --- /dev/null +++ b/crates/email/src/platform.rs @@ -0,0 +1,54 @@ +use std::collections::HashMap; +use std::str::FromStr; + +use serde::Serialize; + +#[derive(Debug, Clone, Copy, PartialEq, Eq)] +pub enum Platform { + Smtp, + Unsupported, +} + +impl FromStr for Platform { + type Err = (); + + fn from_str(s: &str) -> Result { + match s { + "smtp" => Ok(Platform::Smtp), + _ => Ok(Platform::Unsupported), + } + } +} + +impl Platform { + pub fn as_str(&self) -> &'static str { + match self { + Platform::Smtp => "smtp", + Platform::Unsupported => "unsupported", + } + } +} + +#[derive(Debug, Clone, Serialize)] +pub struct PlatformInfo { + pub platform: String, + pub platform_url: String, + pub platform_field_description: HashMap, +} + +pub fn get_supported_platforms() -> Vec { + let mut desc = HashMap::new(); + desc.insert("host".into(), "host".into()); + desc.insert("port".into(), "port".into()); + desc.insert("user".into(), "user".into()); + desc.insert("pass".into(), "pass".into()); + desc.insert("from".into(), "from".into()); + desc.insert("reply_to".into(), "reply_to".into()); + desc.insert("ssl".into(), "ssl".into()); + + vec![PlatformInfo { + platform: "smtp".into(), + platform_url: String::new(), + platform_field_description: desc, + }] +} diff --git a/crates/email/src/sender.rs b/crates/email/src/sender.rs new file mode 100644 index 00000000..2ee10304 --- /dev/null +++ b/crates/email/src/sender.rs @@ -0,0 +1,36 @@ +use std::str::FromStr; + +use crate::platform::Platform; +use crate::smtp::{SmtpClient, SmtpConfig}; + +#[derive(Debug, thiserror::Error)] +pub enum EmailError { + #[error("SMTP transport error: {0}")] + SmtpTransport(#[from] lettre::transport::smtp::Error), + #[error("Message build error: {0}")] + MessageBuild(String), + #[error("Unsupported platform: {0}")] + UnsupportedPlatform(String), + #[error("Config parse error: {0}")] + ConfigParse(#[from] serde_json::Error), +} + +#[async_trait::async_trait] +pub trait Sender: Send + Sync { + async fn send(&self, to: &[String], subject: &str, body: &str) -> Result<(), EmailError>; +} + +pub fn new_sender( + platform: &str, + config: &str, + site_name: &str, +) -> Result, EmailError> { + match Platform::from_str(platform).unwrap_or(Platform::Unsupported) { + Platform::Smtp => { + let mut cfg: SmtpConfig = serde_json::from_str(config)?; + cfg.site_name = site_name.to_string(); + Ok(Box::new(SmtpClient::new(cfg))) + } + _ => Err(EmailError::UnsupportedPlatform(platform.to_string())), + } +} diff --git a/crates/email/src/smtp.rs b/crates/email/src/smtp.rs new file mode 100644 index 00000000..35075c6e --- /dev/null +++ b/crates/email/src/smtp.rs @@ -0,0 +1,97 @@ +use lettre::message::header::ContentType; +use lettre::transport::smtp::authentication::Credentials; +use lettre::transport::smtp::client::{Tls, TlsParameters}; +use lettre::{AsyncSmtpTransport, AsyncTransport, Message, Tokio1Executor}; +use serde::Deserialize; + +use crate::sender::EmailError; + +#[derive(Debug, Clone, Deserialize)] +pub struct SmtpConfig { + pub host: String, + pub port: u16, + pub user: String, + pub pass: String, + pub from: String, + pub reply_to: Option, + pub ssl: bool, + #[serde(default)] + pub site_name: String, +} + +pub struct SmtpClient { + config: SmtpConfig, + mailer: AsyncSmtpTransport, +} + +impl SmtpClient { + pub fn new(config: SmtpConfig) -> Self { + let creds = Credentials::new(config.user.clone(), config.pass.clone()); + + let tls_params = TlsParameters::new(config.host.clone()) + .expect("failed to build TLS parameters"); + + let tls = if config.ssl { + Tls::Wrapper(tls_params) + } else { + Tls::Required(tls_params) + }; + + let mailer = AsyncSmtpTransport::::relay(&config.host) + .expect("failed to build SMTP relay") + .port(config.port) + .credentials(creds) + .tls(tls) + .build(); + + SmtpClient { config, mailer } + } +} + +#[async_trait::async_trait] +impl crate::sender::Sender for SmtpClient { + async fn send(&self, to: &[String], subject: &str, body: &str) -> Result<(), EmailError> { + let site_name = if self.config.site_name.is_empty() { + self.config.from.clone() + } else { + self.config.site_name.clone() + }; + + let from_header = format!("{} <{}>", site_name, self.config.from); + + let from_addr: lettre::message::Mailbox = from_header + .parse() + .map_err(|e: lettre::address::AddressError| { + EmailError::MessageBuild(e.to_string()) + })?; + + let mut builder = Message::builder().from(from_addr); + + if let Some(ref reply_to) = self.config.reply_to { + let reply_addr: lettre::message::Mailbox = reply_to + .parse() + .map_err(|e: lettre::address::AddressError| { + EmailError::MessageBuild(e.to_string()) + })?; + builder = builder.reply_to(reply_addr); + } + + for addr in to { + let to_addr: lettre::message::Mailbox = addr + .parse() + .map_err(|e: lettre::address::AddressError| { + EmailError::MessageBuild(e.to_string()) + })?; + builder = builder.to(to_addr); + } + + let message = builder + .subject(subject) + .header(ContentType::TEXT_HTML) + .body(body.to_string()) + .map_err(|e| EmailError::MessageBuild(e.to_string()))?; + + self.mailer.send(message).await?; + Ok(()) + } +} diff --git a/crates/email/src/template.rs b/crates/email/src/template.rs new file mode 100644 index 00000000..8e3ec975 --- /dev/null +++ b/crates/email/src/template.rs @@ -0,0 +1,175 @@ +pub const DEFAULT_EMAIL_VERIFY_TEMPLATE: &str = r#" + + + + + + {{if eq .Type 1}}注册验证码 / Registration Verification Code{{else}}重置密码验证码 / Password + Reset Verification Code{{end}} + + + + +
+
+ +

{{.SiteName}}

+
+
+

Hi, 尊敬的用户 / Dear User

+

+ {{if eq .Type 1}} 感谢您注册!您的验证码是(请于{{.Expire}}分钟内使用): +
+ Thank you for registering! Your verification code is (please use it within {{.Expire}} minutes): {{else}} + 您正在重置密码。您的验证码是(请于{{.Expire}}分钟内使用): +
+ You are resetting your password. Your verification code is (please use it within {{.Expire}} minutes): {{end}} +

+
+ {{.Code}} +
+

+ 如果您未请求此验证码,请忽略此邮件。
If you did not request this code, please ignore this email. +

+
+ +
+ +"#; + +pub const DEFAULT_MAINTENANCE_EMAIL_TEMPLATE: &str = r#" + + + + + 系统维护通知 / System Maintenance Notice + + + +
+
+ +

{{.SiteName}}

+
+
+

Hi, 尊敬的用户 / Dear User

+

+ 我们计划在{{.MaintenanceDate}}进行系统维护,预计维护时间为{{.MaintenanceTime}}。在此期间,您可能会遇到服务中断或无法访问的情况。 +
+ We will be performing system maintenance on {{.MaintenanceDate}}, and the expected maintenance period is {{.MaintenanceTime}}. During this time, you may experience service interruptions or unavailability. +

+

+ 维护完成后,系统将自动恢复。如果您有任何问题,请随时联系我们的支持团队。 +
+ The system will resume automatically once the maintenance is completed. If you have any questions, please feel free to contact our support team. +

+
+ +
+ +"#; + +pub const DEFAULT_EXPIRATION_EMAIL_TEMPLATE: &str = r#" + + + + + 服务到期通知 / Service Expiration Notice + + + +
+
+ +

{{.SiteName}}

+
+
+

Hi, 尊敬的用户 / Dear User

+

+ 您的服务即将在{{.ExpireDate}}到期,请及时续费以保证服务不间断。 +
+ Your service is set to expire on {{.ExpireDate}}. Please renew your subscription to avoid service interruptions. +

+

+ 如需帮助,请联系客服团队。感谢您的支持! +
+ If you need assistance, please contact our support team. Thank you for your continued support! +

+
+ +
+ +"#; + +pub const DEFAULT_TRAFFIC_EXCEED_EMAIL_TEMPLATE: &str = r#" + + + + + 流量用尽通知 / Traffic Exhausted Notice + + + +
+
+ +

{{.SiteName}}

+
+
+

Hi, 尊敬的用户 / Dear User

+

+ 您的流量已经用尽,请及时购买流量以继续使用我们的服务。 +
+ Your traffic has been exhausted. Please purchase additional traffic to continue using our service. +

+

+ 如需帮助,请联系客服团队。感谢您的支持! +
+ If you need assistance, please contact our support team. Thank you for your continued support! +

+
+ +
+ +"#; diff --git a/crates/email/src/worker.rs b/crates/email/src/worker.rs new file mode 100644 index 00000000..5bb3cb0e --- /dev/null +++ b/crates/email/src/worker.rs @@ -0,0 +1,263 @@ +use std::sync::Arc; +use std::time::Duration; + +use serde::{Deserialize, Serialize}; +use tokio::sync::Mutex; +use tokio::time::sleep; + +use crate::sender::Sender; + +#[derive(Debug, Clone, Serialize, Deserialize)] +pub struct ErrorInfo { + pub error: String, + pub email: String, + pub time: i64, +} + +#[derive(Debug, Clone, Copy, PartialEq, Eq)] +pub enum WorkerStatus { + Idle = 0, + Running = 1, + Completed = 2, +} + +#[derive(Debug, Clone, Deserialize)] +pub struct EmailScope { + #[serde(rename = "type")] + pub type_: i16, + #[serde(default)] + pub register_start_time: i64, + #[serde(default)] + pub register_end_time: i64, + #[serde(default)] + pub recipients: Vec, + #[serde(default)] + pub additional: Vec, + #[serde(default)] + pub scheduled: i64, + #[serde(default)] + pub interval: i16, + #[serde(default)] + pub limit: i64, +} + +#[derive(Debug, Clone, Deserialize)] +pub struct EmailContent { + pub subject: String, + pub content: String, +} + +#[derive(Debug, Clone)] +pub struct TaskInfo { + pub id: i64, + pub type_: i16, + pub scope: String, + pub content: String, + pub status: i16, + pub errors: String, + pub total: i64, + pub current: i64, +} + +pub struct Worker { + id: i64, + repo: Arc, + sender: Arc, + status: Arc>, +} + +impl Worker { + pub fn new( + id: i64, + repo: Arc, + sender: Arc, + ) -> Self { + Worker { + id, + repo, + sender, + status: Arc::new(Mutex::new(WorkerStatus::Idle)), + } + } + + pub fn id(&self) -> i64 { + self.id + } + + pub async fn is_running(&self) -> WorkerStatus { + *self.status.lock().await + } + + pub async fn start(&self) { + let task_info = match self.repo.find_one(self.id).await { + Ok(t) => t, + Err(e) => { + tracing::error!( + "Batch Send Email: Failed to find task, task_id={}, error={}", + self.id, + e + ); + return; + } + }; + + if task_info.status != 0 { + tracing::error!( + "Batch Send Email: Task already completed or in progress, task_id={}", + self.id + ); + return; + } + + let scope: EmailScope = match serde_json::from_str(&task_info.scope) { + Ok(s) => s, + Err(e) => { + tracing::error!( + "Batch Send Email: Failed to parse task scope, task_id={}, error={}", + self.id, + e + ); + return; + } + }; + + if scope.recipients.is_empty() && scope.additional.is_empty() { + tracing::error!( + "Batch Send Email: No recipients or additional emails provided, task_id={}", + self.id + ); + return; + } + + let content: EmailContent = match serde_json::from_str(&task_info.content) { + Ok(c) => c, + Err(e) => { + tracing::error!( + "Batch Send Email: Failed to parse task content, task_id={}, error={}", + self.id, + e + ); + return; + } + }; + + { + let mut status = self.status.lock().await; + *status = WorkerStatus::Running; + } + + let mut recipients = scope.recipients.clone(); + recipients.extend(scope.additional.clone()); + remove_duplicates_and_empty(&mut recipients); + + if recipients.is_empty() { + tracing::error!( + "Batch Send Email: No valid recipients found, task_id={}", + self.id + ); + let mut status = self.status.lock().await; + *status = WorkerStatus::Completed; + return; + } + + let interval = if scope.interval == 0 { + Duration::from_secs(1) + } else { + Duration::from_secs(scope.interval as u64) + }; + + let mut errors: Vec = Vec::new(); + let mut count: i64 = 0; + + for recipient in &recipients { + if self.repo.is_cancelled(self.id) { + tracing::info!( + "Batch Send Email: Worker stopped by cancellation, task_id={}", + self.id + ); + return; + } + + if task_info.status == 0 { + // mark as in-progress via repo + let _ = self.repo.update_status(self.id, 1).await; + } + + if let Err(e) = self + .sender + .send(std::slice::from_ref(recipient), &content.subject, &content.content) + .await + { + tracing::error!( + "Batch Send Email: Failed to send email, task_id={}, recipient={}, error={}", + self.id, + recipient, + e + ); + errors.push(ErrorInfo { + error: e.to_string(), + email: recipient.clone(), + time: std::time::SystemTime::now() + .duration_since(std::time::UNIX_EPOCH) + .unwrap() + .as_secs() as i64, + }); + } + + count += 1; + + let mut updated = task_info.clone(); + updated.current = count; + updated.errors = serde_json::to_string(&errors).unwrap_or_default(); + + if let Err(e) = self.repo.update(&updated).await { + tracing::error!( + "Batch Send Email: Failed to update task progress, task_id={}, error={}", + self.id, + e + ); + let mut status = self.status.lock().await; + *status = WorkerStatus::Completed; + } + + sleep(interval).await; + } + + let mut status = self.status.lock().await; + *status = WorkerStatus::Completed; + + let mut finalized = task_info.clone(); + finalized.status = 2; + finalized.current = count; + finalized.errors = serde_json::to_string(&errors).unwrap_or_default(); + + match self.repo.update(&finalized).await { + Ok(_) => { + tracing::info!( + "Batch Send Email: Task completed successfully, task_id={}, total_sent={}", + self.id, + count + ); + } + Err(e) => { + tracing::error!( + "Batch Send Email: Failed to finalize task, task_id={}, error={}", + self.id, + e + ); + } + } + } +} + +fn remove_duplicates_and_empty(items: &mut Vec) { + let mut seen = std::collections::HashSet::new(); + items.retain(|item| { + if item.is_empty() || seen.contains(item) { + false + } else { + seen.insert(item.clone()); + true + } + }); +} diff --git a/crates/ip/Cargo.toml b/crates/ip/Cargo.toml new file mode 100644 index 00000000..b5a4d4de --- /dev/null +++ b/crates/ip/Cargo.toml @@ -0,0 +1,12 @@ +[package] +name = "ip" +version = "0.1.0" +edition = "2021" + +[dependencies] +reqwest = { version = "0.12", features = ["json", "brotli", "gzip"] } +serde = { version = "1", features = ["derive"] } +serde_json = "1" +thiserror = "2" +tracing = "0.1" +tokio = { version = "1", features = ["net"] } diff --git a/crates/ip/src/lib.rs b/crates/ip/src/lib.rs new file mode 100644 index 00000000..2db1ba9a --- /dev/null +++ b/crates/ip/src/lib.rs @@ -0,0 +1,116 @@ +use std::net::IpAddr; +use std::time::Duration; + +use serde::Deserialize; + +const IPINFO: &str = "ipinfo.io"; +const IPAPI: &str = "ipapi.co"; +const IPBASE: &str = "api.ipbase.com"; +const IPWHOIS: &str = "ipwhois.app"; + +const SERVICES: &[&str] = &[IPBASE, IPAPI, IPWHOIS, IPINFO]; + +#[derive(Debug, thiserror::Error)] +pub enum IpError { + #[error("DNS resolution failed for {0}")] + DnsResolutionFailed(String), + #[error("HTTP request failed: {0}")] + Http(#[from] reqwest::Error), + #[error("JSON parsing failed: {0}")] + Json(#[from] serde_json::Error), + #[error("all geolocation services failed")] + AllServicesFailed, +} + +pub async fn resolve_ip(input: &str) -> Result, IpError> { + if let Ok(ip) = input.parse::() { + return Ok(vec![ip.to_string()]); + } + + let addrs = tokio::net::lookup_host(input).await.map_err(|_| { + IpError::DnsResolutionFailed(input.to_string()) + })?; + + let ips: Vec = addrs.map(|sa| sa.ip().to_string()).collect(); + if ips.is_empty() { + return Err(IpError::DnsResolutionFailed(input.to_string())); + } + Ok(ips) +} + +pub async fn get_region_by_ip(ip: &str) -> Result { + let client = new_http_client(); + + for service in SERVICES { + match fetch_geolocation(&client, service, ip).await { + Ok(resp) => return Ok(resp), + Err(e) => { + tracing::error!("Failed to fetch geolocation from {}: {:?}", service, e); + continue; + } + } + } + + Err(IpError::AllServicesFailed) +} + +fn new_http_client() -> reqwest::Client { + reqwest::Client::builder() + .timeout(Duration::from_secs(10)) + .user_agent("Mozilla/5.0 (X11; Linux x86_64; rv:134.0) Gecko/20100101 Firefox/134.0") + .build() + .expect("Failed to build reqwest::Client") +} + +async fn fetch_geolocation( + client: &reqwest::Client, + service: &str, + ip: &str, +) -> Result { + let api_url = match service { + IPINFO => format!("https://ipinfo.io/{}/json", ip), + IPAPI => format!("https://ipapi.co/{}/json", ip), + IPBASE => format!("https://api.ipbase.com/v1/json/{}", ip), + IPWHOIS => format!("https://ipwhois.app/json/{}", ip), + _ => unreachable!(), + }; + + let resp = client + .get(&api_url) + .header("Host", service) + .header("Accept", "application/json, text/html, application/xhtml+xml, */*;q=0.8") + .header("Accept-Language", "en-US,en;q=0.5") + .header("Accept-Encoding", "gzip, deflate, br, zstd") + .header("Connection", "keep-alive") + .header("Upgrade-Insecure-Requests", "1") + .send() + .await?; + + let bytes = resp.bytes().await?; + let mut location: GeoLocationResponse = serde_json::from_slice(&bytes)?; + + if location.country.is_empty() { + location.country = location.country_name.clone(); + } + + if !location.loc.is_empty() { + if let Some((lat, lon)) = location.loc.split_once(',') { + location.latitude = lat.trim().to_string(); + location.longitude = lon.trim().to_string(); + } + } + + Ok(location) +} + +#[derive(Debug, Default, Clone, Deserialize)] +#[serde(default)] +pub struct GeoLocationResponse { + pub country: String, + pub country_name: String, + pub region: String, + pub city: String, + pub latitude: String, + pub longitude: String, + pub loc: String, +} diff --git a/crates/jwt/Cargo.toml b/crates/jwt/Cargo.toml new file mode 100644 index 00000000..c95b9587 --- /dev/null +++ b/crates/jwt/Cargo.toml @@ -0,0 +1,8 @@ +[package] +name = "jwt" +version = "0.1.0" +edition = "2021" + +[dependencies] +jsonwebtoken = "9" +serde = { version = "1", features = ["derive"] } diff --git a/crates/jwt/src/lib.rs b/crates/jwt/src/lib.rs new file mode 100644 index 00000000..463b7792 --- /dev/null +++ b/crates/jwt/src/lib.rs @@ -0,0 +1,58 @@ +use jsonwebtoken::{decode, encode, DecodingKey, EncodingKey, Header, Validation}; +use serde::{Deserialize, Serialize}; + +#[derive(Debug, Clone, Serialize, Deserialize)] +pub struct Claims { + pub exp: i64, + pub iat: i64, + #[serde(rename = "UserId")] + pub user_id: i64, + #[serde(rename = "SessionId")] + pub session_id: String, + #[serde(rename = "LoginType")] + pub login_type: String, +} + +impl Claims { + pub fn new(user_id: i64, session_id: String, login_type: String) -> (Self, i64) { + let now = chrono_now(); + let seconds = 604800; + ( + Self { + iat: now, + exp: now + seconds, + user_id, + session_id, + login_type, + }, + seconds, + ) + } +} + +fn chrono_now() -> i64 { + std::time::SystemTime::now() + .duration_since(std::time::UNIX_EPOCH) + .unwrap() + .as_secs() as i64 +} + +pub fn generate_token(claims: &Claims, secret: &str) -> Result { + encode( + &Header::default(), + claims, + &EncodingKey::from_secret(secret.as_bytes()), + ) +} + +pub fn validate_token( + token: &str, + secret: &str, +) -> Result { + let token_data = decode::( + token, + &DecodingKey::from_secret(secret.as_bytes()), + &Validation::default(), + )?; + Ok(token_data.claims) +} diff --git a/crates/oauth/Cargo.toml b/crates/oauth/Cargo.toml new file mode 100644 index 00000000..333a1af1 --- /dev/null +++ b/crates/oauth/Cargo.toml @@ -0,0 +1,13 @@ +[package] +name = "oauth" +version = "0.1.0" +edition = "2021" + +[dependencies] +arctic-oauth = { version = "0.3.0", features = ["apple", "google"] } +base64 = "0.22" +hex = "0.4" +hmac = "0.12" +serde = { version = "1", features = ["derive"] } +serde_json = "1" +sha2 = "0.10" diff --git a/crates/oauth/src/config.rs b/crates/oauth/src/config.rs new file mode 100644 index 00000000..39b135a2 --- /dev/null +++ b/crates/oauth/src/config.rs @@ -0,0 +1,24 @@ +use serde::Deserialize; + +#[derive(Debug, Clone, Deserialize)] +pub struct GoogleConfig { + pub client_id: String, + pub client_secret: String, + pub redirect_url: String, +} + +#[derive(Debug, Clone, Deserialize)] +pub struct AppleConfig { + pub team_id: String, + pub key_id: String, + pub client_id: String, + pub client_secret: String, + pub redirect_url: String, +} + +#[derive(Debug, Clone, Deserialize)] +pub struct TelegramConfig { + pub bot_token: String, + pub enable_notify: Option, + pub webhook_domain: Option, +} diff --git a/crates/oauth/src/error.rs b/crates/oauth/src/error.rs new file mode 100644 index 00000000..7bbed6a4 --- /dev/null +++ b/crates/oauth/src/error.rs @@ -0,0 +1,26 @@ +use std::fmt; + +#[derive(Debug)] +pub enum OAuthError { + Config(String), + Telegram(String), + Arctic(arctic_oauth::Error), +} + +impl fmt::Display for OAuthError { + fn fmt(&self, f: &mut fmt::Formatter<'_>) -> fmt::Result { + match self { + OAuthError::Config(msg) => write!(f, "OAuth config error: {}", msg), + OAuthError::Telegram(msg) => write!(f, "Telegram OAuth error: {}", msg), + OAuthError::Arctic(e) => write!(f, "OAuth error: {}", e), + } + } +} + +impl std::error::Error for OAuthError {} + +impl From for OAuthError { + fn from(e: arctic_oauth::Error) -> Self { + OAuthError::Arctic(e) + } +} diff --git a/crates/oauth/src/lib.rs b/crates/oauth/src/lib.rs new file mode 100644 index 00000000..538c9b14 --- /dev/null +++ b/crates/oauth/src/lib.rs @@ -0,0 +1,98 @@ +//! OAuth 2.0 authentication library wrapping `arctic-oauth` with +//! config types matching the database schema, plus Telegram Login support. +//! +//! # Providers +//! +//! | Provider | Standard OAuth 2.0 | PKCE | Tokens | +//! |-----------|-------------------|----------|---------------| +//! | Google | ✅ (arctic-oauth) | Required | access + refresh + id_token | +//! | Apple | ✅ (arctic-oauth) | None | access + id_token | +//! | Telegram | ⚠️ Custom HMAC | N/A | N/A (stateless) | + +pub mod config; +pub mod error; +pub mod telegram; + +// Re-export arctic-oauth core types + providers +pub use arctic_oauth::{ + create_code_challenge, decode_id_token, generate_code_verifier, generate_state, + Apple, AppleOptions, Google, GoogleOptions, OAuth2Tokens, +}; +pub use arctic_oauth::{CodeChallengeMethod, Error as ArcticError}; +pub use config::{AppleConfig, GoogleConfig, TelegramConfig}; +pub use error::OAuthError; +pub use telegram::{parse_and_validate_auth_data, parse_base64_and_validate, validate_auth_data, AuthData}; + +/// Unified user info extracted from any OAuth provider. +#[derive(Debug, Clone)] +pub struct OAuthUserInfo { + pub open_id: String, + pub email: Option, + pub name: Option, + pub picture: Option, +} + +impl OAuthUserInfo { + pub fn from_google(tokens: &OAuth2Tokens) -> Result { + let id_token = tokens.id_token()?; + let claims = decode_id_token(id_token)?; + let open_id = claims + .get("sub") + .and_then(|v| v.as_str()) + .unwrap_or("") + .to_string(); + let email = claims.get("email").and_then(|v| v.as_str()).map(String::from); + let name = claims.get("name").and_then(|v| v.as_str()).map(String::from); + let picture = claims + .get("picture") + .and_then(|v| v.as_str()) + .map(String::from); + Ok(Self { + open_id, + email, + name, + picture, + }) + } + + pub fn from_apple(tokens: &OAuth2Tokens) -> Result { + let id_token = tokens.id_token()?; + let claims = decode_id_token(id_token)?; + let open_id = claims + .get("sub") + .and_then(|v| v.as_str()) + .unwrap_or("") + .to_string(); + let email = claims.get("email").and_then(|v| v.as_str()).map(String::from); + let name = None; + let picture = None; + Ok(Self { + open_id, + email, + name, + picture, + }) + } + + pub fn from_telegram(data: &AuthData) -> Self { + let open_id = data.id.to_string(); + let name = Some( + [data.first_name.as_deref(), data.last_name.as_deref()] + .into_iter() + .flatten() + .collect::>() + .join(" "), + ) + .filter(|s| !s.is_empty()); + Self { + open_id, + email: None, + name, + picture: data.photo_url.clone(), + } + } + + pub fn open_id(&self) -> &str { + &self.open_id + } +} diff --git a/crates/oauth/src/telegram.rs b/crates/oauth/src/telegram.rs new file mode 100644 index 00000000..c06a1746 --- /dev/null +++ b/crates/oauth/src/telegram.rs @@ -0,0 +1,103 @@ +use base64::Engine; +use hmac::{Hmac, Mac}; +use serde::Deserialize; +use sha2::{Digest, Sha256}; + +use crate::error::OAuthError; + +const AUTH_DATE_TTL_SECS: i64 = 86400; + +#[derive(Debug, Clone, Deserialize)] +pub struct AuthData { + pub id: i64, + pub first_name: Option, + pub last_name: Option, + pub username: Option, + pub photo_url: Option, + pub auth_date: i64, + pub hash: String, +} + +pub type TelegramUserInfo = AuthData; + +fn check_string(data: &AuthData) -> String { + let mut pairs: Vec<(String, String)> = Vec::new(); + + pairs.push(("id".to_string(), data.id.to_string())); + if let Some(v) = &data.first_name { + pairs.push(("first_name".to_string(), v.clone())); + } + if let Some(v) = &data.last_name { + pairs.push(("last_name".to_string(), v.clone())); + } + if let Some(v) = &data.username { + pairs.push(("username".to_string(), v.clone())); + } + if let Some(v) = &data.photo_url { + pairs.push(("photo_url".to_string(), v.clone())); + } + pairs.push(("auth_date".to_string(), data.auth_date.to_string())); + + pairs.sort_by(|a, b| a.0.cmp(&b.0)); + + pairs + .iter() + .map(|(k, v)| format!("{}={}", k, v)) + .collect::>() + .join("\n") +} + +pub fn validate_auth_data(data: &AuthData, bot_token: &[u8]) -> Result<(), OAuthError> { + if bot_token.is_empty() { + return Err(OAuthError::Telegram( + "telegram bot token is not provided".into(), + )); + } + + let now = std::time::SystemTime::now() + .duration_since(std::time::UNIX_EPOCH) + .map_err(|e| OAuthError::Telegram(format!("system clock error: {}", e)))? + .as_secs() as i64; + + if now - data.auth_date > AUTH_DATE_TTL_SECS { + return Err(OAuthError::Telegram("auth date is expired".into())); + } + + let check_str = check_string(data); + + let key = sha2::Sha256::digest(bot_token); + let mut mac = Hmac::::new_from_slice(&key) + .map_err(|e| OAuthError::Telegram(format!("HMAC key error: {}", e)))?; + mac.update(check_str.as_bytes()); + let computed = hex::encode(mac.finalize().into_bytes()); + + if data.hash != computed { + return Err(OAuthError::Telegram("hash is not valid".into())); + } + + Ok(()) +} + +pub fn parse_and_validate_auth_data( + json_bytes: &[u8], + bot_token: &[u8], +) -> Result { + let data: AuthData = serde_json::from_slice(json_bytes) + .map_err(|e| OAuthError::Telegram(format!("json parse error: {}", e)))?; + validate_auth_data(&data, bot_token)?; + Ok(data) +} + +pub fn parse_base64_and_validate( + base64_str: &str, + bot_token: &[u8], +) -> Result { + let decoded = base64::engine::general_purpose::STANDARD + .decode(base64_str.as_bytes()) + .or_else(|_| { + base64::engine::general_purpose::URL_SAFE_NO_PAD.decode(base64_str.as_bytes()) + }) + .map_err(|e| OAuthError::Telegram(format!("base64 decode error: {}", e)))?; + + parse_and_validate_auth_data(&decoded, bot_token) +} diff --git a/crates/password/Cargo.toml b/crates/password/Cargo.toml new file mode 100644 index 00000000..87510496 --- /dev/null +++ b/crates/password/Cargo.toml @@ -0,0 +1,12 @@ +[package] +name = "password" +version = "0.1.0" +edition = "2021" + +[dependencies] +pbkdf2 = "0.12" +sha2 = "0.10" +md5 = "0.7" +bcrypt = "0.15" +hex = "0.4" +rand = "0.8" diff --git a/crates/password/src/lib.rs b/crates/password/src/lib.rs new file mode 100644 index 00000000..51a6a533 --- /dev/null +++ b/crates/password/src/lib.rs @@ -0,0 +1,231 @@ +//! 密码加密与验证工具。 +//! +//! 从 `/root/project-moth/server/pkg/tool/encryption.go` 迁移而来。 +//! 提供密码哈希(PBKDF2-SHA512)、MD5 编码以及多算法验证。 + +use pbkdf2::pbkdf2_hmac; +use sha2::{Digest, Sha256, Sha512}; + +const PBKDF2_SALT_LEN: usize = 16; +const PBKDF2_ITERATIONS: u32 = 100; +const PBKDF2_KEY_LEN: usize = 32; + +#[derive(Debug)] +pub enum PasswordError { + HashError(String), + ParseError(String), +} + +impl std::fmt::Display for PasswordError { + fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result { + match self { + PasswordError::HashError(msg) => write!(f, "Hash error: {}", msg), + PasswordError::ParseError(msg) => write!(f, "Parse error: {}", msg), + } + } +} + +impl std::error::Error for PasswordError {} + +/// 使用 PBKDF2-SHA512 对密码进行加盐编码。 +/// +/// 返回格式化字符串:`$pbkdf2-sha512$$`。 +/// +/// # 示例 +/// ``` +/// let encoded = password::encode_password("mypassword").unwrap(); +/// assert!(encoded.starts_with("$pbkdf2-sha512$")); +/// ``` +pub fn encode_password(password: &str) -> Result { + use rand::Rng; + + // 生成随机盐值 + let mut rng = rand::thread_rng(); + let salt: Vec = (0..PBKDF2_SALT_LEN).map(|_| rng.gen()).collect(); + + // 使用 PBKDF2-HMAC-SHA512 对密码进行哈希 + let mut key = vec![0u8; PBKDF2_KEY_LEN]; + pbkdf2_hmac::(password.as_bytes(), &salt, PBKDF2_ITERATIONS, &mut key); + + // 将盐值和密钥编码为十六进制 + let salt_hex = hex::encode(&salt); + let key_hex = hex::encode(&key); + + Ok(format!("$pbkdf2-sha512${}${}", salt_hex, key_hex)) +} + +/// 验证密码与编码后的密码哈希是否匹配。 +/// +/// 预期格式:`$pbkdf2-sha512$$`。 +/// +/// # 示例 +/// ``` +/// let encoded = password::encode_password("mypassword").unwrap(); +/// assert!(password::verify_password("mypassword", &encoded)); +/// assert!(!password::verify_password("wrongpass", &encoded)); +/// ``` +pub fn verify_password(password: &str, encoded: &str) -> bool { + let parts: Vec<&str> = encoded.split('$').collect(); + if parts.len() < 4 || parts[1] != "pbkdf2-sha512" { + return false; + } + + let salt_hex = parts[2]; + let expected_hash_hex = parts[3]; + + // 从十六进制解码盐值 + let salt = match hex::decode(salt_hex) { + Ok(s) => s, + Err(_) => return false, + }; + + // 计算哈希值 + let mut key = vec![0u8; PBKDF2_KEY_LEN]; + pbkdf2_hmac::(password.as_bytes(), &salt, PBKDF2_ITERATIONS, &mut key); + + // 与期望的哈希值进行比较 + let computed_hash_hex = hex::encode(&key); + computed_hash_hex == expected_hash_hex +} + +/// 计算输入字符串的 MD5 哈希值。 +/// +/// # 参数 +/// * `s` - 输入字符串 +/// * `uppercase` - 若为 true 则返回大写十六进制,否则返回小写 +/// +/// # 示例 +/// ``` +/// let hash = password::md5_encode("hello", false); +/// assert_eq!(hash, "5d41402abc4b2a76b9719d911017c592"); +/// ``` +pub fn md5_encode(s: &str, uppercase: bool) -> String { + let digest = md5::compute(s.as_bytes()); + let result = format!("{:x}", digest); + if uppercase { + result.to_uppercase() + } else { + result + } +} + +/// 使用多种算法验证密码。 +/// +/// 支持的算法: +/// - `"md5"`:简单 MD5 哈希 +/// - `"sha256"`:简单 SHA-256 哈希 +/// - `"md5salt"`:MD5(密码 + 盐值) +/// - `"sha256salt"`:SHA-256(密码 + 盐值),由 SSPanel 使用 +/// - `"default"`:PBKDF2-SHA512(PPanel 默认) +/// - `"bcrypt"`:Bcrypt 哈希 +/// +/// # 参数 +/// * `algo` - 算法名称 +/// * `salt` - 盐值字符串(用于 `*salt` 算法) +/// * `password` - 明文密码 +/// * `hash` - 期望的哈希值 +/// +/// # 示例 +/// ``` +/// let result = password::multi_password_verify("md5", "", "hello", "5d41402abc4b2a76b9719d911017c592"); +/// assert!(result); +/// ``` +pub fn multi_password_verify(algo: &str, salt: &str, password: &str, hash: &str) -> bool { + match algo { + "md5" => { + let digest = md5::compute(password.as_bytes()); + let computed = format!("{:x}", digest); + computed == hash + } + "sha256" => { + let mut hasher = Sha256::new(); + hasher.update(password.as_bytes()); + let computed = hex::encode(hasher.finalize()); + computed == hash + } + "md5salt" => { + let input = format!("{}{}", password, salt); + let digest = md5::compute(input.as_bytes()); + let computed = format!("{:x}", digest); + computed == hash + } + "sha256salt" => { + let input = format!("{}{}", password, salt); + let mut hasher = Sha256::new(); + hasher.update(input.as_bytes()); + let computed = hex::encode(hasher.finalize()); + computed == hash + } + "default" => verify_password(password, hash), + "bcrypt" => bcrypt::verify(password, hash).unwrap_or(false), + _ => false, + } +} + +#[cfg(test)] +mod tests { + use super::*; + + #[test] + fn test_encode_and_verify_password() { + let password = "test_password_123"; + let encoded = encode_password(password).unwrap(); + + eprintln!("Encoded password: {}", encoded); + + assert!(encoded.starts_with("$pbkdf2-sha512$")); + assert!(verify_password(password, &encoded)); + assert!(!verify_password("wrong_password", &encoded)); + } + + #[test] + fn test_md5_encode() { + let input = "hello"; + let lowercase = md5_encode(input, false); + let uppercase = md5_encode(input, true); + + assert_eq!(lowercase, "5d41402abc4b2a76b9719d911017c592"); + assert_eq!(uppercase, "5D41402ABC4B2A76B9719D911017C592"); + } + + #[test] + fn test_multi_password_verify_md5() { + let password = "hello"; + let hash = "5d41402abc4b2a76b9719d911017c592"; + assert!(multi_password_verify("md5", "", password, hash)); + assert!(!multi_password_verify("md5", "", "wrong", hash)); + } + + #[test] + fn test_multi_password_verify_sha256() { + let password = "hello"; + let hash = "2cf24dba5fb0a30e26e83b2ac5b9e29e1b161e5c1fa7425e73043362938b9824"; + assert!(multi_password_verify("sha256", "", password, hash)); + } + + #[test] + fn test_multi_password_verify_md5salt() { + let password = "hello"; + let salt = "world"; + // MD5("helloworld") = fc5e038d38a57032085441e7fe7010b0 + let hash = "fc5e038d38a57032085441e7fe7010b0"; + assert!(multi_password_verify("md5salt", salt, password, hash)); + } + + #[test] + fn test_multi_password_verify_bcrypt() { + let password = "test123"; + // 预先为 "test123" 生成的 bcrypt 哈希值 + let hash = bcrypt::hash(password, 4).unwrap(); + assert!(multi_password_verify("bcrypt", "", password, &hash)); + assert!(!multi_password_verify("bcrypt", "", "wrong", &hash)); + } + + #[test] + fn test_multi_password_verify_default() { + let password = "test123"; + let encoded = encode_password(password).unwrap(); + assert!(multi_password_verify("default", "", password, &encoded)); + assert!(!multi_password_verify("default", "", "wrong", &encoded)); + } +} diff --git a/crates/payment/Cargo.toml b/crates/payment/Cargo.toml new file mode 100644 index 00000000..d46376da --- /dev/null +++ b/crates/payment/Cargo.toml @@ -0,0 +1,16 @@ +[package] +name = "payment" +version = "0.1.0" +edition = "2021" + +[dependencies] +alipay_sdk_rust = "1" +stripe = { version = "0.40", package = "async-stripe", features = ["runtime-tokio-hyper"] } +md5 = "0.7" +reqwest = { version = "0.12", features = ["json"] } +serde = { version = "1", features = ["derive"] } +serde_json = "1" +serde_urlencoded = "0.7" +thiserror = "2" +tracing = "0.1" +url = "2" diff --git a/crates/payment/src/alipay.rs b/crates/payment/src/alipay.rs new file mode 100644 index 00000000..755a4d81 --- /dev/null +++ b/crates/payment/src/alipay.rs @@ -0,0 +1,162 @@ +use alipay_sdk_rust::{ + biz::{BizContenter, TradePrecreateBiz, TradeQueryBiz}, + pay::{Payer, PayClient}, +}; + +use crate::error::PaymentError; +use crate::types::Cents; + +#[derive(Debug, Clone)] +pub struct Config { + pub app_id: String, + pub private_key: String, + pub public_key: String, + pub invoice_name: String, + pub notify_url: String, + pub sandbox: bool, +} + +#[derive(Debug, Clone, PartialEq, Eq)] +pub enum OrderStatus { + Success, + Pending, + Closed, + Finished, + Error(String), +} + +pub struct Notification { + pub order_no: String, + pub amount: Cents, + pub status: OrderStatus, +} + +pub struct Provider { + client: Box, + config: Config, +} + +impl Provider { + pub fn new(config: Config) -> Result { + let api_url = if config.sandbox { + "https://openapi-sandbox.dl.alipaydev.com/gateway.do" + } else { + "https://openapi.dl.alipaydev.com/gateway.do" + }; + + let app_id = config.app_id.clone(); + let private_key = config.private_key.clone(); + let public_key = config.public_key.clone(); + + let client: Box = Box::new( + PayClient::builder() + .api_url(api_url) + .app_id(&app_id) + .private_key(&private_key) + .public_key(&public_key) + .sign_type_rsa2() + .charset_utf8() + .format_json() + .version_1_0() + .build() + .map_err(|e| PaymentError::Config(format!("Alipay config error: {e}")))?, + ); + + Ok(Provider { client, config }) + } + + pub fn pre_create_trade(&self, order_no: &str, amount: Cents) -> Result { + let mut biz = TradePrecreateBiz::new(); + biz.set_out_trade_no(order_no.to_string().into()); + biz.set_total_amount(amount.to_yuan_string().into()); + biz.set_subject(self.config.invoice_name.clone().into()); + biz.set("notify_url", self.config.notify_url.clone().into()); + + let resp = self + .client + .trade_precreate(&biz) + .map_err(|e| PaymentError::Alipay(e.to_string()))?; + + if resp.response.code.as_deref() != Some("10000") { + return Err(PaymentError::Alipay( + resp.response + .sub_msg + .unwrap_or_else(|| "unknown alipay error".into()), + )); + } + + resp.response + .qr_code + .ok_or_else(|| PaymentError::Alipay("QR code not returned".into())) + } + + pub fn query_trade(&self, order_no: &str) -> Result { + let mut biz = TradeQueryBiz::new(); + biz.set_out_trade_no(order_no.to_string().into()); + + let resp = self + .client + .trade_query(&biz) + .map_err(|e| PaymentError::Alipay(e.to_string()))?; + + match resp.response.trade_status.as_deref() { + Some("TRADE_SUCCESS") | Some("TRADE_FINISHED") => Ok(OrderStatus::Success), + Some("WAIT_BUYER_PAY") => Ok(OrderStatus::Pending), + Some("TRADE_CLOSED") => Ok(OrderStatus::Closed), + Some(s) => Ok(OrderStatus::Error(s.into())), + None => Ok(OrderStatus::Error("no trade status".into())), + } + } + + pub fn decode_notification(&self, body: &[u8]) -> Result { + let verified = self + .client + .async_verify_sign(body) + .map_err(|e| PaymentError::Alipay(format!("notification verify failed: {e}")))?; + + if !verified { + return Err(PaymentError::Alipay( + "notification sign verification failed".into(), + )); + } + + let parsed: serde_json::Value = serde_json::from_slice(body) + .map_err(|e| PaymentError::Alipay(format!("invalid notify body: {e}")))?; + + let response = parsed + .as_object() + .and_then(|m| m.values().next()) + .and_then(|v| v.as_object()) + .ok_or_else(|| PaymentError::Alipay("cannot parse notification".into()))?; + + let trade_status = response + .get("trade_status") + .and_then(|v| v.as_str()) + .unwrap_or(""); + let out_trade_no = response + .get("out_trade_no") + .and_then(|v| v.as_str()) + .unwrap_or(""); + let total_amount = response + .get("total_amount") + .and_then(|v| v.as_str()) + .unwrap_or("0"); + + let status = match trade_status { + "TRADE_SUCCESS" => OrderStatus::Success, + "WAIT_BUYER_PAY" => OrderStatus::Pending, + "TRADE_CLOSED" => OrderStatus::Closed, + "TRADE_FINISHED" => OrderStatus::Finished, + s => OrderStatus::Error(s.into()), + }; + + let amount = Cents::from_yuan(total_amount) + .map_err(|e| PaymentError::Alipay(format!("Invalid amount: {e}")))?; + + Ok(Notification { + order_no: out_trade_no.into(), + amount, + status, + }) + } +} diff --git a/crates/payment/src/epay.rs b/crates/payment/src/epay.rs new file mode 100644 index 00000000..532a019a --- /dev/null +++ b/crates/payment/src/epay.rs @@ -0,0 +1,146 @@ +use std::collections::BTreeMap; + +use crate::error::PaymentError; +use crate::types::Cents; + +#[derive(Debug, Clone)] +pub struct Config { + pub pid: String, + pub url: String, + pub key: String, + pub pay_type: String, +} + +pub struct Order { + pub name: String, + pub order_no: String, + pub amount: Cents, + pub sign_type: String, + pub notify_url: String, + pub return_url: String, +} + +pub struct Provider { + pub pid: String, + pub url: String, + pub key: String, + pub pay_type: String, +} + +impl Provider { + pub fn new(config: Config) -> Self { + Provider { + pid: config.pid, + url: config.url, + key: config.key, + pay_type: config.pay_type, + } + } + + fn params_map(&self, order: &Order) -> BTreeMap<&str, String> { + let mut m = BTreeMap::new(); + m.insert("pid", self.pid.clone()); + m.insert("type", self.pay_type.clone()); + m.insert("out_trade_no", order.order_no.clone()); + m.insert("money", order.amount.to_yuan_string()); + m.insert("name", order.name.clone()); + m.insert("notify_url", order.notify_url.clone()); + m.insert("return_url", order.return_url.clone()); + m + } + + fn create_sign(&self, params: &BTreeMap<&str, String>) -> String { + let query: String = params + .iter() + .filter(|(k, v)| !v.is_empty() && **k != "sign" && **k != "sign_type") + .map(|(k, v)| format!("{k}={v}")) + .collect::>() + .join("&"); + let text = format!("{}{}", query, self.key); + format!("{:x}", md5::compute(text)) + } + + pub fn create_pay_url(&self, order: &Order) -> Result { + let params = self.params_map(order); + let sign = self.create_sign(¶ms); + + let mut base_url = url::Url::parse(&self.url) + .map_err(|_| PaymentError::Config("invalid EPay URL".into()))?; + base_url = base_url + .join("/submit.php") + .map_err(|_| PaymentError::Config("invalid EPay path".into()))?; + + { + let mut pairs = base_url.query_pairs_mut(); + for (k, v) in ¶ms { + pairs.append_pair(k, v); + } + pairs.append_pair("sign", &sign); + pairs.append_pair("sign_type", "MD5"); + } + + Ok(base_url.to_string()) + } + + pub fn verify_sign(&self, params: &std::collections::HashMap) -> bool { + let mut sorted = BTreeMap::new(); + for (k, v) in params { + sorted.insert(k.as_str(), v.clone()); + } + let expected = params.get("sign").cloned().unwrap_or_default(); + self.create_sign(&sorted) == expected + } + + pub async fn query_order_status(&self, order_no: &str) -> Result { + let query_url = format!( + "{}/api.php?act=order&pid={}&out_trade_no={}", + self.url, self.pid, order_no + ); + let resp = reqwest::get(&query_url).await?; + let body: serde_json::Value = resp.json().await?; + let status = body.get("status").and_then(|v| v.as_i64()).unwrap_or(0); + Ok(status == 1) + } +} + +#[cfg(test)] +mod tests { + use super::*; + + #[test] + fn test_epay_sign() { + let provider = Provider::new(Config { + pid: "1654".into(), + url: "http://127.0.0.1".into(), + key: "LbTabbB580zWyhXhyyww7wwvy5u8k0wl".into(), + pay_type: "alipay".into(), + }); + + let order = Order { + name: "product".into(), + order_no: "202412152115078262977262254".into(), + amount: Cents(1000), + sign_type: "MD5".into(), + notify_url: "".into(), + return_url: "".into(), + }; + + let url = provider.create_pay_url(&order).unwrap(); + assert!(url.contains("sign=")); + assert!(url.contains("sign_type=MD5")); + + // Verify sign from callback params (matches Go test data) + let params = std::collections::HashMap::from([ + ("pid".into(), "1654".into()), + ("trade_no".into(), "2024121521150860990".into()), + ("out_trade_no".into(), "202412152115078262977262254".into()), + ("type".into(), "alipay".into()), + ("name".into(), "product".into()), + ("money".into(), "10".into()), + ("trade_status".into(), "TRADE_SUCCESS".into()), + ("sign".into(), "d3181f18ebdf9821f0ab6ee93faa82d1".into()), + ("sign_type".into(), "MD5".into()), + ]); + assert!(provider.verify_sign(¶ms)); + } +} diff --git a/crates/payment/src/error.rs b/crates/payment/src/error.rs new file mode 100644 index 00000000..bfb33f34 --- /dev/null +++ b/crates/payment/src/error.rs @@ -0,0 +1,28 @@ +use thiserror::Error; + +#[derive(Error, Debug)] +pub enum PaymentError { + #[error("Stripe error: {0}")] + Stripe(#[from] stripe::StripeError), + + #[error("Stripe webhook error: {0}")] + StripeWebhook(String), + + #[error("Alipay error: {0}")] + Alipay(String), + + #[error("EPay error: {0}")] + EPay(String), + + #[error("HTTP request failed: {0}")] + Http(#[from] reqwest::Error), + + #[error("URL parse error: {0}")] + Url(#[from] url::ParseError), + + #[error("Serialization error: {0}")] + Serde(#[from] serde_json::Error), + + #[error("Invalid configuration: {0}")] + Config(String), +} diff --git a/crates/payment/src/lib.rs b/crates/payment/src/lib.rs new file mode 100644 index 00000000..a72f8370 --- /dev/null +++ b/crates/payment/src/lib.rs @@ -0,0 +1,10 @@ +pub mod alipay; +pub mod epay; +pub mod error; +pub mod platform; +pub mod stripe; +pub mod types; + +pub use error::PaymentError; +pub use platform::{get_supported_platforms, Platform, PlatformInfo}; +pub use types::{Cents, Notification, Order, PaymentSheet, User}; diff --git a/crates/payment/src/platform.rs b/crates/payment/src/platform.rs new file mode 100644 index 00000000..17e97ed7 --- /dev/null +++ b/crates/payment/src/platform.rs @@ -0,0 +1,146 @@ +use serde::Serialize; + +#[derive(Debug, Clone, Copy, PartialEq, Eq)] +pub enum Platform { + Stripe, + AlipayF2F, + EPay, + Balance, + CryptoSaaS, + Unsupported, +} + +const PLATFORM_NAMES: &[(&str, Platform)] = &[ + ("CryptoSaaS", Platform::CryptoSaaS), + ("Stripe", Platform::Stripe), + ("AlipayF2F", Platform::AlipayF2F), + ("EPay", Platform::EPay), + ("balance", Platform::Balance), +]; + +impl Platform { + pub fn from_str(s: &str) -> Self { + for &(name, platform) in PLATFORM_NAMES { + if name == s { + return platform; + } + } + Platform::Unsupported + } + + pub fn as_str(&self) -> &'static str { + for &(name, platform) in PLATFORM_NAMES { + if platform == *self { + return name; + } + } + "unsupported" + } +} + +impl fmt::Display for Platform { + fn fmt(&self, f: &mut fmt::Formatter<'_>) -> fmt::Result { + write!(f, "{}", self.as_str()) + } +} + +impl std::str::FromStr for Platform { + type Err = (); + fn from_str(s: &str) -> Result { + match Self::from_str(s) { + Platform::Unsupported => Err(()), + p => Ok(p), + } + } +} + +#[derive(Debug, Clone, Serialize)] +pub struct PlatformInfo { + pub platform: String, + pub platform_url: String, + pub platform_field_description: std::collections::HashMap, +} + +pub fn get_supported_platforms() -> Vec { + vec![ + PlatformInfo { + platform: "Stripe".into(), + platform_url: "https://stripe.com".into(), + platform_field_description: { + let mut m = std::collections::HashMap::new(); + m.insert("public_key".into(), "Publishable key".into()); + m.insert("secret_key".into(), "Secret key".into()); + m.insert("webhook_secret".into(), "Webhook secret".into()); + m.insert("payment".into(), "Payment Method, only supported card/alipay/wechat_pay".into()); + m + }, + }, + PlatformInfo { + platform: "AlipayF2F".into(), + platform_url: "https://alipay.com".into(), + platform_field_description: { + let mut m = std::collections::HashMap::new(); + m.insert("app_id".into(), "App ID".into()); + m.insert("private_key".into(), "Private Key".into()); + m.insert("public_key".into(), "Public Key".into()); + m.insert("invoice_name".into(), "Invoice Name".into()); + m.insert("sandbox".into(), "Sandbox Mode".into()); + m + }, + }, + PlatformInfo { + platform: "EPay".into(), + platform_url: String::new(), + platform_field_description: { + let mut m = std::collections::HashMap::new(); + m.insert("pid".into(), "PID".into()); + m.insert("url".into(), "URL".into()); + m.insert("key".into(), "Key".into()); + m.insert("type".into(), "Type".into()); + m + }, + }, + PlatformInfo { + platform: "CryptoSaaS".into(), + platform_url: "https://t.me/CryptoSaaSBot".into(), + platform_field_description: { + let mut m = std::collections::HashMap::new(); + m.insert("endpoint".into(), "API Endpoint".into()); + m.insert("account_id".into(), "Account ID".into()); + m.insert("secret_key".into(), "Secret Key".into()); + m + }, + }, + ] +} + +use std::fmt; + +#[cfg(test)] +mod tests { + use super::*; + + #[test] + fn test_platform_parse() { + assert_eq!(Platform::from_str("Stripe"), Platform::Stripe); + assert_eq!(Platform::from_str("AlipayF2F"), Platform::AlipayF2F); + assert_eq!(Platform::from_str("EPay"), Platform::EPay); + assert_eq!(Platform::from_str("balance"), Platform::Balance); + assert_eq!(Platform::from_str("CryptoSaaS"), Platform::CryptoSaaS); + assert_eq!(Platform::from_str("unknown"), Platform::Unsupported); + } + + #[test] + fn test_platform_display() { + assert_eq!(Platform::Stripe.to_string(), "Stripe"); + assert_eq!(Platform::Unsupported.to_string(), "unsupported"); + } + + #[test] + fn test_get_supported_platforms() { + let platforms = get_supported_platforms(); + assert_eq!(platforms.len(), 4); + assert!(platforms.iter().any(|p| p.platform == "Stripe")); + assert!(platforms.iter().any(|p| p.platform == "EPay")); + } +} diff --git a/crates/payment/src/stripe.rs b/crates/payment/src/stripe.rs new file mode 100644 index 00000000..05513955 --- /dev/null +++ b/crates/payment/src/stripe.rs @@ -0,0 +1,171 @@ +use std::collections::HashMap; +use std::str::FromStr; + +use stripe::{ + Client as StripeClient, CreateCustomer, CreateEphemeralKey, CreatePaymentIntent, + CreateWebhookEndpoint, Customer, CustomerSearchParams, EphemeralKey, EventFilter, + EventObject, Expandable, PaymentIntent, PaymentIntentId, PaymentIntentStatus, + PaymentMethod, PaymentMethodId, Webhook, WebhookEndpoint, +}; + +use crate::error::PaymentError; +use crate::types::{Cents, Notification, Order, PaymentSheet, User}; + +pub const API_VERSION: &str = "2024-04-10"; + +#[derive(Debug, Clone)] +pub struct Config { + pub public_key: String, + pub secret_key: String, + pub webhook_secret: String, +} + +pub struct Provider { + client: StripeClient, + config: Config, +} + +impl Provider { + pub fn new(config: Config) -> Self { + let client = StripeClient::new(&config.secret_key); + Provider { client, config } + } + + pub async fn create_payment_sheet( + &self, + order: &Order, + user: &User, + ) -> Result { + let customer = self.find_or_create_customer(user).await?; + + let mut ek_params = CreateEphemeralKey::new(); + ek_params.customer = Some(customer.id.clone()); + let ek = EphemeralKey::create(&self.client, ek_params).await?; + + let mut metadata = HashMap::new(); + metadata.insert("order_no".to_string(), order.order_no.clone()); + metadata.insert("user_id".to_string(), user.user_id.to_string()); + metadata.insert("subscribe".to_string(), order.subscribe.clone()); + + let currency = stripe::Currency::from_str(&order.currency) + .map_err(|e| PaymentError::Config(format!("invalid currency: {e}")))?; + + let mut pi_params = CreatePaymentIntent::new(order.amount.0, currency); + pi_params.customer = Some(customer.id.clone()); + pi_params.payment_method_types = Some(vec![order.payment.clone()]); + pi_params.metadata = Some(metadata); + + let pi = PaymentIntent::create(&self.client, pi_params).await?; + + Ok(PaymentSheet { + client_secret: pi.client_secret.unwrap_or_default(), + ephemeral_key: ek.secret.unwrap_or_default(), + customer: customer.id.to_string(), + publishable_key: self.config.public_key.clone(), + trade_no: pi.id.to_string(), + }) + } + + pub async fn find_or_create_customer(&self, user: &User) -> Result { + if let Some(customer) = self.search_customer(user).await? { + return Ok(customer); + } + self.create_customer(user).await + } + + pub async fn search_customer(&self, user: &User) -> Result, PaymentError> { + let query = if !user.email.is_empty() { + format!("email:'{}'", user.email) + } else { + format!("metadata['user_id']:'{}'", user.user_id) + }; + + let mut params = CustomerSearchParams::new(); + params.query = query; + + let result = Customer::search(&self.client, params).await?; + Ok(result.data.into_iter().next()) + } + + pub async fn create_customer(&self, user: &User) -> Result { + let mut params = CreateCustomer::new(); + if !user.email.is_empty() { + params.email = Some(&user.email); + } + + let mut metadata = HashMap::new(); + metadata.insert("user_id".to_string(), user.user_id.to_string()); + params.metadata = Some(metadata); + + Ok(Customer::create(&self.client, params).await?) + } + + pub async fn query_order_status(&self, trade_no: &str) -> Result { + let id = PaymentIntentId::from_str(trade_no) + .map_err(|_| PaymentError::Config("invalid PaymentIntent ID".into()))?; + let intent = PaymentIntent::retrieve(&self.client, &id, &[]).await?; + Ok(intent.status == PaymentIntentStatus::Succeeded) + } + + pub fn parse_notify( + &self, + payload: &[u8], + signature: &str, + ) -> Result { + let payload_str = + std::str::from_utf8(payload).map_err(|e| PaymentError::StripeWebhook(e.to_string()))?; + + let event = Webhook::construct_event(payload_str, signature, &self.config.webhook_secret) + .map_err(|e| PaymentError::StripeWebhook(e.to_string()))?; + + let pi = match event.data.object { + EventObject::PaymentIntent(pi) => pi, + _ => { + return Err(PaymentError::StripeWebhook( + "unexpected event object type".into(), + )) + } + }; + + let order_no = pi.metadata.get("order_no").cloned().unwrap_or_default(); + let user_id: i64 = pi + .metadata + .get("user_id") + .and_then(|v| v.parse().ok()) + .unwrap_or(0); + + let method = match pi.payment_method { + Some(Expandable::Object(ref pm)) => Some(pm.type_.to_string()), + _ => None, + }; + + Ok(Notification { + event_type: event.type_.to_string(), + order_no, + trade_no: pi.id.to_string(), + user_id, + amount: Cents(pi.amount), + method, + }) + } + + pub async fn retrieve_payment_method( + &self, + id: &str, + ) -> Result { + let pm_id = PaymentMethodId::from_str(id) + .map_err(|_| PaymentError::Config("invalid PaymentMethod ID".into()))?; + Ok(PaymentMethod::retrieve(&self.client, &pm_id, &[]).await?) + } + + pub async fn create_webhook_endpoint( + &self, + url: &str, + ) -> Result { + let params = CreateWebhookEndpoint::new( + vec![EventFilter::PaymentIntentSucceeded, EventFilter::PaymentIntentPaymentFailed], + url, + ); + Ok(WebhookEndpoint::create(&self.client, params).await?) + } +} diff --git a/crates/payment/src/types.rs b/crates/payment/src/types.rs new file mode 100644 index 00000000..0e991377 --- /dev/null +++ b/crates/payment/src/types.rs @@ -0,0 +1,105 @@ +use serde::{Deserialize, Serialize}; +use std::fmt; +use std::ops::{Div, Mul}; + +#[derive(Debug, Clone, Copy, PartialEq, Eq, Serialize, Deserialize)] +#[serde(transparent)] +pub struct Cents(pub i64); + +impl Cents { + pub fn from_yuan(s: &str) -> Result { + let yuan: f64 = s.parse()?; + Ok(Cents((yuan * 100.0).round() as i64)) + } + + pub fn to_yuan_f64(&self) -> f64 { + self.0 as f64 / 100.0 + } + + pub fn to_yuan_string(&self) -> String { + format!("{:.2}", self.to_yuan_f64()) + } +} + +impl fmt::Display for Cents { + fn fmt(&self, f: &mut fmt::Formatter<'_>) -> fmt::Result { + write!(f, "{}", self.0) + } +} + +impl Mul for Cents { + type Output = Cents; + fn mul(self, rhs: i64) -> Cents { + Cents(self.0 * rhs) + } +} + +impl Div for Cents { + type Output = Cents; + fn div(self, rhs: i64) -> Cents { + Cents(self.0 / rhs) + } +} + +#[cfg(test)] +mod tests { + use super::*; + + #[test] + fn test_cents_from_yuan() { + let c = Cents::from_yuan("10.00").unwrap(); + assert_eq!(c.0, 1000); + assert_eq!(c.to_yuan_string(), "10.00"); + } + + #[test] + fn test_cents_from_yuan_rounding() { + let c = Cents::from_yuan("9.99").unwrap(); + assert_eq!(c.0, 999); + assert_eq!(c.to_yuan_string(), "9.99"); + } + + #[test] + fn test_cents_display() { + let c = Cents(100); + assert_eq!(c.to_string(), "100"); + assert_eq!(c.to_yuan_f64(), 1.0); + } +} + +#[derive(Debug, Clone, Serialize, Deserialize)] +pub struct Order { + pub order_no: String, + pub amount: Cents, + pub currency: String, + pub payment: String, + pub subscribe: String, + pub name: String, + pub notify_url: String, + pub return_url: String, +} + +#[derive(Debug, Clone)] +pub struct User { + pub user_id: i64, + pub email: String, +} + +#[derive(Debug, Clone)] +pub struct Notification { + pub event_type: String, + pub order_no: String, + pub trade_no: String, + pub user_id: i64, + pub amount: Cents, + pub method: Option, +} + +#[derive(Debug, Clone, Serialize)] +pub struct PaymentSheet { + pub client_secret: String, + pub ephemeral_key: String, + pub customer: String, + pub publishable_key: String, + pub trade_no: String, +} diff --git a/crates/result/Cargo.toml b/crates/result/Cargo.toml new file mode 100644 index 00000000..9d6007ef --- /dev/null +++ b/crates/result/Cargo.toml @@ -0,0 +1,13 @@ +[package] +name = "result" +version = "0.1.0" +edition = "2024" + +[dependencies] +anyhow = "1" +serde = { version = "1", features = ["derive"] } +serde_json = "1" +axum = "0.8" + +[dev-dependencies] +tokio = { version = "1", features = ["macros", "rt"] } diff --git a/crates/result/src/code_error.rs b/crates/result/src/code_error.rs new file mode 100644 index 00000000..6db69bab --- /dev/null +++ b/crates/result/src/code_error.rs @@ -0,0 +1,120 @@ +// Code-carrying error type. +// +// Ported from the Go package `xerr` (errors.go). A `CodeError` carries a +// numeric error code (shown to the front end) and a human-readable message, +// and can travel through an error chain so handlers can recover the code via +// [`find_code_error`]. + +use std::fmt; +use std::sync::LazyLock; + +use crate::error_code::{map_err_msg, ERROR}; + +/// An error that carries a machine-readable code and a message. +#[derive(Debug, Clone)] +pub struct CodeError { + err_code: u32, + err_msg: String, +} + +impl CodeError { + /// Creates a `CodeError` whose message is looked up from [`map_err_msg`]. + /// + /// Mirrors Go `xerr.NewErrCode`. + pub fn new_err_code(err_code: u32) -> Self { + Self { + err_code, + err_msg: map_err_msg(err_code).to_string(), + } + } + + /// Creates a `CodeError` with an explicit code and message. + /// + /// Mirrors Go `xerr.NewErrCodeMsg`. + pub fn new_err_code_msg(err_code: u32, err_msg: impl Into) -> Self { + Self { + err_code, + err_msg: err_msg.into(), + } + } + + /// Creates a `CodeError` for an unspecified failure (`ERROR` code). + /// + /// Mirrors Go `xerr.NewErrMsg`. + pub fn new_err_msg(err_msg: impl Into) -> Self { + Self { + err_code: ERROR, + err_msg: err_msg.into(), + } + } + + /// Returns the error code shown to the front end. + pub fn get_err_code(&self) -> u32 { + self.err_code + } + + /// Returns the error message shown to the front end. + pub fn get_err_msg(&self) -> &str { + &self.err_msg + } +} + +impl fmt::Display for CodeError { + fn fmt(&self, f: &mut fmt::Formatter<'_>) -> fmt::Result { + // Preserve the original Go format (note the full-width comma). + write!(f, "ErrCode:{},ErrMsg:{}", self.err_code, self.err_msg) + } +} + +impl std::error::Error for CodeError {} + +/// Sentinel error for "304 Not Modified". +/// +/// Mirrors Go `xerr.StatusNotModified`. +pub static STATUS_NOT_MODIFIED: LazyLock = + LazyLock::new(|| SimpleError("304 Not Modified".to_string())); + +/// A plain string-backed error, used for sentinel values such as +/// [`STATUS_NOT_MODIFIED`]. +#[derive(Debug, Clone)] +pub struct SimpleError(pub String); + +impl fmt::Display for SimpleError { + fn fmt(&self, f: &mut fmt::Formatter<'_>) -> fmt::Result { + f.write_str(&self.0) + } +} + +impl std::error::Error for SimpleError {} + +#[cfg(test)] +mod tests { + use super::*; + + #[test] + fn new_err_code_maps_message() { + let e = CodeError::new_err_code(crate::error_code::INVALID_PARAMS); + assert_eq!(e.get_err_code(), 400); + assert_eq!(e.get_err_msg(), "Param Error"); + } + + #[test] + fn new_err_code_msg_is_explicit() { + let e = CodeError::new_err_code_msg(123, "custom"); + assert_eq!(e.get_err_code(), 123); + assert_eq!(e.get_err_msg(), "custom"); + } + + #[test] + fn new_err_msg_uses_error_code() { + let e = CodeError::new_err_msg("boom"); + assert_eq!(e.get_err_code(), ERROR); + assert_eq!(e.get_err_msg(), "boom"); + } + + #[test] + fn display_preserves_go_format() { + let e = CodeError::new_err_code_msg(400, "Param Error"); + assert_eq!(e.to_string(), "ErrCode:400,ErrMsg:Param Error"); + } +} diff --git a/crates/result/src/error_code.rs b/crates/result/src/error_code.rs new file mode 100644 index 00000000..2c528d31 --- /dev/null +++ b/crates/result/src/error_code.rs @@ -0,0 +1,230 @@ +// Error codes and their human-readable messages. +// +// Ported from the Go package `xerr` (errCode.go + errMsg.go). The first three +// digits identify the business area; the last three identify the specific +// error within that area. + +use std::collections::HashMap; +use std::sync::LazyLock; + +/// General error codes. +pub const SUCCESS: u32 = 200; +pub const ERROR: u32 = 500; + +/// Database errors. +pub const DATABASE_QUERY_ERROR: u32 = 10001; +pub const DATABASE_UPDATE_ERROR: u32 = 10002; +pub const DATABASE_INSERT_ERROR: u32 = 10003; +pub const DATABASE_DELETED_ERROR: u32 = 10004; + +/// User errors. +pub const USER_EXIST: u32 = 20001; +pub const USER_NOT_EXIST: u32 = 20002; +pub const USER_PASSWORD_ERROR: u32 = 20003; +pub const USER_DISABLED: u32 = 20004; +pub const INSUFFICIENT_BALANCE: u32 = 20005; +pub const STOP_REGISTER: u32 = 20006; +pub const TELEGRAM_NOT_BOUND: u32 = 20007; +pub const USER_NOT_BIND_OAUTH: u32 = 20008; +pub const INVITE_CODE_ERROR: u32 = 20009; +pub const USER_COMMISSION_NOT_ENOUGH: u32 = 20010; + +/// Node errors. +pub const NODE_EXIST: u32 = 30001; +pub const NODE_NOT_EXIST: u32 = 30002; +pub const NODE_GROUP_EXIST: u32 = 30003; +pub const NODE_GROUP_NOT_EXIST: u32 = 30004; +pub const NODE_GROUP_NOT_EMPTY: u32 = 30005; + +/// Request errors. +pub const INVALID_PARAMS: u32 = 400; +pub const TOO_MANY_REQUESTS: u32 = 401; +pub const ERROR_TOKEN_EMPTY: u32 = 40002; +pub const ERROR_TOKEN_INVALID: u32 = 40003; +pub const ERROR_TOKEN_EXPIRE: u32 = 40004; +pub const INVALID_ACCESS: u32 = 40005; +pub const INVALID_CIPHERTEXT: u32 = 40006; +pub const SECRET_IS_EMPTY: u32 = 40007; + +/// Coupon errors. +pub const COUPON_NOT_EXIST: u32 = 50001; +pub const COUPON_ALREADY_USED: u32 = 50002; +pub const COUPON_NOT_APPLICABLE: u32 = 50003; +pub const COUPON_INSUFFICIENT_USAGE: u32 = 50004; +pub const COUPON_EXPIRED: u32 = 50005; +pub const COUPON_DISABLED: u32 = 50006; + +/// Subscribe errors. +pub const SUBSCRIBE_EXPIRED: u32 = 60001; +pub const SUBSCRIBE_NOT_AVAILABLE: u32 = 60002; +pub const USER_SUBSCRIBE_EXIST: u32 = 60003; +pub const SUBSCRIBE_IS_USED_ERROR: u32 = 60004; +pub const SINGLE_SUBSCRIBE_MODE_EXCEEDS_LIMIT: u32 = 60005; +pub const SUBSCRIBE_QUOTA_LIMIT: u32 = 60006; +pub const SUBSCRIBE_OUT_OF_STOCK: u32 = 60007; + +/// Order errors. +pub const ORDER_NOT_EXIST: u32 = 61001; +pub const PAYMENT_METHOD_NOT_FOUND: u32 = 61002; +pub const ORDER_STATUS_ERROR: u32 = 61003; +pub const INSUFFICIENT_OF_PERIOD: u32 = 61004; +pub const EXIST_AVAILABLE_TRAFFIC: u32 = 61005; + +/// Auth errors. +pub const VERIFY_CODE_ERROR: u32 = 70001; + +/// Equipment errors. +pub const QUEUE_ENQUEUE_ERROR: u32 = 80001; + +/// System errors. +pub const DEBUG_MODE_ERROR: u32 = 90001; +pub const SEND_SMS_ERROR: u32 = 90002; +pub const SMS_NOT_ENABLED: u32 = 90003; +pub const EMAIL_NOT_ENABLED: u32 = 90004; +pub const GET_AUTHENTICATOR_ERROR: u32 = 90005; +pub const AUTHENTICATOR_NOT_SUPPORTED_ERROR: u32 = 90006; +pub const TELEPHONE_AREA_CODE_IS_EMPTY: u32 = 90007; +pub const TODAY_SEND_COUNT_EXCEEDS_LIMIT: u32 = 90015; +pub const PASSWORD_IS_EMPTY: u32 = 90008; +pub const AREA_CODE_IS_EMPTY: u32 = 90009; +pub const PASSWORD_OR_VERIFICATION_CODE_REQUIRED: u32 = 90010; +pub const EMAIL_EXIST: u32 = 90011; +pub const TELEPHONE_EXIST: u32 = 90012; +pub const DEVICE_EXIST: u32 = 90013; +pub const TELEPHONE_ERROR: u32 = 90014; +pub const DEVICE_NOT_EXIST: u32 = 90017; +pub const USERID_NOT_MATCH: u32 = 90018; + +/// Mapping of error code -> default message. +static MESSAGES: LazyLock> = LazyLock::new(|| { + let mut m = HashMap::new(); + // General + m.insert(SUCCESS, "Success"); + m.insert(ERROR, "Internal Server Error"); + // Request / parameter + m.insert(TOO_MANY_REQUESTS, "Too Many Requests"); + m.insert(INVALID_PARAMS, "Param Error"); + m.insert(ERROR_TOKEN_EMPTY, "User token is empty"); + m.insert(ERROR_TOKEN_INVALID, "User token is invalid"); + m.insert(ERROR_TOKEN_EXPIRE, "User token is expired"); + m.insert(SECRET_IS_EMPTY, "Secret is empty"); + m.insert(INVALID_ACCESS, "Invalid access"); + m.insert(INVALID_CIPHERTEXT, "Invalid ciphertext"); + // Database + m.insert(DATABASE_QUERY_ERROR, "Database query error"); + m.insert(DATABASE_UPDATE_ERROR, "Database update error"); + m.insert(DATABASE_INSERT_ERROR, "Database insert error"); + m.insert(DATABASE_DELETED_ERROR, "Database deleted error"); + // User + m.insert(USER_EXIST, "User already exists"); + m.insert(USER_NOT_EXIST, "User does not exist"); + m.insert(USER_PASSWORD_ERROR, "User password error"); + m.insert(USER_DISABLED, "User disabled"); + m.insert(INSUFFICIENT_BALANCE, "Insufficient balance"); + m.insert(STOP_REGISTER, "Stop register"); + m.insert(TELEGRAM_NOT_BOUND, "Telegram not bound "); + m.insert(USER_NOT_BIND_OAUTH, "User not bind oauth method"); + m.insert(INVITE_CODE_ERROR, "Invite code error"); + // Node + m.insert(NODE_EXIST, "Node already exists"); + m.insert(NODE_NOT_EXIST, "Node does not exist"); + m.insert(NODE_GROUP_EXIST, "Node group already exists"); + m.insert(NODE_GROUP_NOT_EXIST, "Node group does not exist"); + m.insert(NODE_GROUP_NOT_EMPTY, "Node group is not empty"); + // Coupon + m.insert(COUPON_NOT_EXIST, "Coupon does not exist"); + m.insert(COUPON_ALREADY_USED, "Coupon has already been used"); + m.insert(COUPON_NOT_APPLICABLE, "Coupon does not match the order or conditions"); + m.insert(COUPON_INSUFFICIENT_USAGE, "Coupon has insufficient remaining uses"); + m.insert(COUPON_EXPIRED, "Coupon is expired"); + m.insert(COUPON_DISABLED, "Coupon is disabled"); + // Subscribe + m.insert(SUBSCRIBE_EXPIRED, "Subscribe is expired"); + m.insert(SUBSCRIBE_NOT_AVAILABLE, "Subscribe is not available"); + m.insert(USER_SUBSCRIBE_EXIST, "User has subscription"); + m.insert(SUBSCRIBE_IS_USED_ERROR, "Subscribe is used"); + m.insert( + SINGLE_SUBSCRIBE_MODE_EXCEEDS_LIMIT, + "Single subscribe mode exceeds limit", + ); + m.insert(SUBSCRIBE_QUOTA_LIMIT, "Subscribe quota limit"); + m.insert(SUBSCRIBE_OUT_OF_STOCK, "Subscribe out of stock"); + // Auth + m.insert(VERIFY_CODE_ERROR, "Verify code error"); + // Equipment + m.insert(QUEUE_ENQUEUE_ERROR, " Queue enqueue error"); + // System + m.insert(DEBUG_MODE_ERROR, "Debug mode is enabled"); + m.insert(GET_AUTHENTICATOR_ERROR, "Unsupported login method"); + m.insert( + AUTHENTICATOR_NOT_SUPPORTED_ERROR, + "The authenticator does not support this method", + ); + m.insert(TELEPHONE_AREA_CODE_IS_EMPTY, "Telephone area code is empty"); + m.insert( + TODAY_SEND_COUNT_EXCEEDS_LIMIT, + "This account has reached the limit of sending times today", + ); + m.insert(SMS_NOT_ENABLED, "Telephone login is not enabled"); + m.insert(EMAIL_NOT_ENABLED, "Email function is not enabled yet"); + m.insert( + PASSWORD_OR_VERIFICATION_CODE_REQUIRED, + "Password or verification code required", + ); + m.insert(EMAIL_EXIST, "Email already exists"); + m.insert(TELEPHONE_EXIST, "Telephone already exists"); + m.insert(DEVICE_EXIST, "device exists"); + m.insert(PASSWORD_IS_EMPTY, "password is empty"); + m.insert(TELEPHONE_ERROR, "telephone number error"); + m.insert(DEVICE_NOT_EXIST, "Device does not exist"); + m.insert(USERID_NOT_MATCH, "Userid not match"); + // Order + m.insert(ORDER_NOT_EXIST, "Order does not exist"); + m.insert(PAYMENT_METHOD_NOT_FOUND, "Payment method not found"); + m.insert(ORDER_STATUS_ERROR, "Order status error"); + m.insert(INSUFFICIENT_OF_PERIOD, "Insufficient number of period"); + m +}); + +/// Returns the default message for an error code. +/// +/// Mirrors Go `xerr.MapErrMsg`: falls back to `"Internal Server Error"` when the +/// code is unknown. +pub fn map_err_msg(err_code: u32) -> &'static str { + MESSAGES + .get(&err_code) + .copied() + .unwrap_or("Internal Server Error") +} + +/// Returns `true` when the code is a known, registered error code. +/// +/// Mirrors Go `xerr.IsCodeErr`. +pub fn is_code_err(err_code: u32) -> bool { + MESSAGES.contains_key(&err_code) +} + +#[cfg(test)] +mod tests { + use super::*; + + #[test] + fn maps_known_codes() { + assert_eq!(map_err_msg(SUCCESS), "Success"); + assert_eq!(map_err_msg(ERROR), "Internal Server Error"); + assert_eq!(map_err_msg(INVALID_PARAMS), "Param Error"); + assert_eq!(map_err_msg(ORDER_NOT_EXIST), "Order does not exist"); + } + + #[test] + fn unknown_code_falls_back() { + assert_eq!(map_err_msg(999_999), "Internal Server Error"); + } + + #[test] + fn is_code_err_recognizes_registered_codes() { + assert!(is_code_err(SUCCESS)); + assert!(is_code_err(INVALID_PARAMS)); + assert!(!is_code_err(999_999)); + } +} diff --git a/crates/result/src/http_result.rs b/crates/result/src/http_result.rs new file mode 100644 index 00000000..4d087be9 --- /dev/null +++ b/crates/result/src/http_result.rs @@ -0,0 +1,236 @@ +// Response envelopes and HTTP result construction. +// +// Ported from the Go package `result` (responseBean.go + httpResult.go). The Go +// package wraps responses in `ResponseSuccessBean` / `ResponseErrorBean` and +// writes them through a Hertz `*Context`; in axum the equivalent is producing a +// type that implements `IntoResponse`, so those beans are exposed directly. + +use axum::http::StatusCode; +use axum::response::{IntoResponse, Response}; +use axum::Json; +use serde::Serialize; +use std::error::Error as StdError; + +use crate::code_error::CodeError; +use crate::error_code; + +/// Envelope returned for a successful request. +/// +/// Mirrors Go `result.ResponseSuccessBean`. `data` is omitted from the JSON +/// payload when `None` (the `omitempty`-style `skip_serializing_if`). +#[derive(Debug, Serialize)] +pub struct ResponseSuccessBean { + pub code: u32, + pub msg: String, + #[serde(skip_serializing_if = "Option::is_none")] + pub data: Option, +} + +impl ResponseSuccessBean { + pub fn new(data: Option) -> Self { + Self { + code: 200, + msg: "success".to_string(), + data, + } + } +} + +/// Marker for an empty (null) data payload, kept for parity with the Go +/// `NullJson` type. +pub struct NullJson; + +/// Envelope returned for a failed request. +/// +/// Mirrors Go `result.ResponseErrorBean`. +#[derive(Debug, Serialize)] +pub struct ResponseErrorBean { + pub code: u32, + pub msg: String, +} + +/// Builds a success envelope wrapping `data`. +/// +/// Mirrors Go `result.Success`. +pub fn success(data: T) -> ResponseSuccessBean { + ResponseSuccessBean::new(Some(data)) +} + +/// Builds an error envelope for the given code and message. +/// +/// Mirrors Go `result.Error`. +pub fn error(err_code: u32, err_msg: impl Into) -> ResponseErrorBean { + ResponseErrorBean { + code: err_code, + msg: err_msg.into(), + } +} + +/// A structured HTTP response, identical in intent to the Go `HTTPResult`: +/// an HTTP status code paired with the serialized body. Axum's `Response` +/// subsumes the original `(StatusCode, body)` pair. +#[derive(Debug)] +pub struct HttpResult { + pub status_code: StatusCode, + pub body: Response, +} + +/// Constructs an `HttpResult` from a fallible response, normalizing the body +/// into a success or error envelope. +/// +/// Mirrors Go `result.BuildHTTPResult`: +/// - On success: HTTP 200 with a `ResponseSuccessBean` body. +/// - On error: HTTP 200 with a `ResponseErrorBean` body, the code/message +/// recovered from any nested `CodeError`, defaulting to `ERROR` / +/// `"Internal Server Error"` for plain errors. +pub fn build_http_result(resp: Option, err: Option) -> HttpResult +where + T: Serialize, +{ + if let Some(err) = err { + let (code, msg) = recover_code_and_msg(&err); + return HttpResult { + status_code: StatusCode::OK, + body: error(code, msg).into_response_body(), + }; + } + + HttpResult { + status_code: StatusCode::OK, + body: Json(ResponseSuccessBean::new(resp)).into_response_body(), + } +} + +/// Constructs an `HttpResult` for a parameter-validation failure. +/// +/// Mirrors Go `result.BuildParamErrorResult`: HTTP 200 with a `ResponseErrorBean` +/// whose code is `INVALID_PARAMS` and whose message is the raw error text. +pub fn build_param_error_result(err: &dyn StdError) -> HttpResult { + HttpResult { + status_code: StatusCode::OK, + body: error(error_code::INVALID_PARAMS, err.to_string()).into_response_body(), + } +} + +/// Emits an HTTP response built from a fallible result. +/// +/// Mirrors Go `result.HttpResult(ctx, resp, err)` (which wrote the body via the +/// Hertz context). Here it simply renders the constructed `HttpResult`. +pub fn http_result(resp: Option, err: Option) -> Response +where + T: Serialize, +{ + build_http_result(resp, err).into_response() +} + +/// Emits an HTTP response for a parameter-validation failure. +/// +/// Mirrors Go `result.ParamErrorResult(ctx, err)`. The Go version also logged the +/// error onto the Hertz error chain (`ctx.Error`); in axum that side effect is +/// the caller's responsibility (e.g. a tracing call), so only the response is +/// produced here. +pub fn param_error_result(err: &dyn StdError) -> Response { + build_param_error_result(err).into_response() +} + +impl IntoResponse for HttpResult { + fn into_response(self) -> Response { + self.body + } +} + +// ---- helpers -------------------------------------------------------------- + +/// Re-implementation of Go's `errors.As(errors.Cause(err), &e)` chain walk: the +/// first `CodeError` found while unwrapping `anyhow::Error` wins. +fn recover_code_and_msg(err: &anyhow::Error) -> (u32, String) { + for cause in err.chain() { + if let Some(code_err) = cause.downcast_ref::() { + return (code_err.get_err_code(), code_err.get_err_msg().to_string()); + } + } + (error_code::ERROR, "Internal Server Error".to_string()) +} + +/// Extension so the `(StatusCode, Json)` rendering can be captured as the +/// inner `Response` body of an `HttpResult`. +trait IntoResponseBody { + fn into_response_body(self) -> Response; +} + +impl IntoResponseBody for Json { + fn into_response_body(self) -> Response { + (StatusCode::OK, self).into_response() + } +} + +impl IntoResponseBody for ResponseErrorBean { + fn into_response_body(self) -> Response { + (StatusCode::OK, Json(self)).into_response() + } +} + +#[cfg(test)] +mod tests { + use super::*; + use axum::body::to_bytes; + use crate::code_error::CodeError; + use crate::error_code; + + #[tokio::test] + async fn build_http_result_success() { + let result = build_http_result(Some("ok"), None); + assert_eq!(result.status_code, StatusCode::OK); + let bytes = to_bytes(result.body.into_body(), usize::MAX).await.unwrap(); + let v: serde_json::Value = serde_json::from_slice(&bytes).unwrap(); + assert_eq!(v["code"], 200); + assert_eq!(v["msg"], "success"); + assert_eq!(v["data"], "ok"); + } + + #[tokio::test] + async fn build_http_result_code_error() { + let err = anyhow::Error::new(CodeError::new_err_code(error_code::INVALID_PARAMS)); + let result = build_http_result::<()>(None, Some(err)); + let bytes = to_bytes(result.body.into_body(), usize::MAX).await.unwrap(); + let v: serde_json::Value = serde_json::from_slice(&bytes).unwrap(); + assert_eq!(v["code"], 400); + assert_eq!(v["msg"], "Param Error"); + assert!(v.get("data").is_none()); + } + + #[tokio::test] + async fn build_http_result_generic_error() { + let err = anyhow::Error::msg("boom"); + let result = build_http_result::<()>(None, Some(err)); + let bytes = to_bytes(result.body.into_body(), usize::MAX).await.unwrap(); + let v: serde_json::Value = serde_json::from_slice(&bytes).unwrap(); + assert_eq!(v["code"], 500); + assert_eq!(v["msg"], "Internal Server Error"); + } + + #[tokio::test] + async fn build_param_error_result_works() { + let err = anyhow::Error::msg("bad param"); + let result = build_param_error_result(err.as_ref()); + let bytes = to_bytes(result.body.into_body(), usize::MAX).await.unwrap(); + let v: serde_json::Value = serde_json::from_slice(&bytes).unwrap(); + assert_eq!(v["code"], 400); + assert_eq!(v["msg"], "bad param"); + } + + #[test] + fn success_envelope_shape() { + let bean = success(serde_json::json!({"x": 1})); + assert_eq!(bean.code, 200); + assert_eq!(bean.msg, "success"); + assert_eq!(bean.data.as_ref().unwrap()["x"], 1); + } + + #[test] + fn error_envelope_shape() { + let bean = error(401, "Too Many Requests"); + assert_eq!(bean.code, 401); + assert_eq!(bean.msg, "Too Many Requests"); + } +} diff --git a/crates/result/src/lib.rs b/crates/result/src/lib.rs new file mode 100644 index 00000000..e7b359db --- /dev/null +++ b/crates/result/src/lib.rs @@ -0,0 +1,9 @@ +// Response envelopes and HTTP result construction. +// +// Ported from the Go package `result` together with its `xerr` dependency: +// error codes live in [`error_code`], the code-carrying error in +// [`code_error`], and the response beans / `HttpResult` in [`http_result`]. + +pub mod code_error; +pub mod error_code; +pub mod http_result; diff --git a/crates/sms/Cargo.toml b/crates/sms/Cargo.toml new file mode 100644 index 00000000..aab0f670 --- /dev/null +++ b/crates/sms/Cargo.toml @@ -0,0 +1,18 @@ +[package] +name = "sms" +version = "0.1.0" +edition = "2021" + +[dependencies] +async-trait = "0.1" +anyhow = "1" +serde = { version = "1", features = ["derive"] } +serde_json = "1" +base64 = "0.22" +tokio = { version = "1", features = ["full"] } +reqwest = { version = "0.12", features = ["json"] } +hmac = "0.12" +sha1 = "0.10" +sha2 = "0.10" +md5 = "0.7" +chrono = "0.4" diff --git a/crates/sms/src/config.rs b/crates/sms/src/config.rs new file mode 100644 index 00000000..a9f2ce42 --- /dev/null +++ b/crates/sms/src/config.rs @@ -0,0 +1,38 @@ +use serde::{Deserialize, Serialize}; + +/// Unified config struct covering all 4 providers. +/// Fields not used by a given provider are simply ignored. +#[derive(Debug, Clone, Serialize, Deserialize, Default)] +pub struct SmsConfig { + // ── AlibabaCloud ──────────────────────────────────────────────── + /// AccessKeyId + #[serde(default)] + pub access: String, + /// AccessKeySecret / MD5Key / AuthToken / Password + #[serde(default)] + pub secret: String, + /// AlibabaCloud: SignName + #[serde(default)] + pub sign_name: String, + /// AlibabaCloud: Endpoint (default: dysmsapi.ap-southeast-1.aliyuncs.com) + #[serde(default)] + pub endpoint: String, + /// AlibabaCloud: TemplateCode + #[serde(default)] + pub template_code: String, + + // ── Smsbao / Abosend / Twilio ──────────────────────────────────── + /// Go template string, e.g. "Your code is {{.code}}" + #[serde(default)] + pub template: String, + + // ── Abosend ───────────────────────────────────────────────────── + /// Override API base URL (default: https://smsapi.abosend.com) + #[serde(default)] + pub api_domain: String, + + // ── Twilio ─────────────────────────────────────────────────────── + /// Sending phone number (e.g. "+12015551234") + #[serde(default)] + pub phone_number: String, +} diff --git a/crates/sms/src/factory.rs b/crates/sms/src/factory.rs new file mode 100644 index 00000000..adff7387 --- /dev/null +++ b/crates/sms/src/factory.rs @@ -0,0 +1,13 @@ +use crate::config::SmsConfig; +use crate::platform::Platform; +use crate::providers::{abosend::AbosendSender, alibabacloud::AlibabaCloudSender, smsbao::SmsbaoSender, twilio::TwilioSender}; +use crate::sender::Sender; + +pub fn create_sender(platform: Platform, config: SmsConfig) -> Box { + match platform { + Platform::AlibabaCloud => Box::new(AlibabaCloudSender::new(config)), + Platform::Smsbao => Box::new(SmsbaoSender::new(config)), + Platform::Abosend => Box::new(AbosendSender::new(config)), + Platform::Twilio => Box::new(TwilioSender::new(config)), + } +} diff --git a/crates/sms/src/lib.rs b/crates/sms/src/lib.rs new file mode 100644 index 00000000..94a16c3c --- /dev/null +++ b/crates/sms/src/lib.rs @@ -0,0 +1,10 @@ +pub mod config; +pub mod factory; +pub mod platform; +pub mod providers; +pub mod sender; + +pub use config::SmsConfig; +pub use factory::create_sender; +pub use platform::Platform; +pub use sender::Sender; diff --git a/crates/sms/src/platform.rs b/crates/sms/src/platform.rs new file mode 100644 index 00000000..9ffc419c --- /dev/null +++ b/crates/sms/src/platform.rs @@ -0,0 +1,33 @@ +use serde::{Deserialize, Serialize}; + +#[derive(Debug, Clone, Serialize, Deserialize)] +pub enum Platform { + AlibabaCloud, + Smsbao, + Abosend, + Twilio, +} + +impl Platform { + pub fn from_str(s: &str) -> Option { + match s { + "AlibabaCloud" => Some(Platform::AlibabaCloud), + "smsbao" => Some(Platform::Smsbao), + "abosend" => Some(Platform::Abosend), + "twilio" => Some(Platform::Twilio), + _ => None, + } + } +} + +impl std::fmt::Display for Platform { + fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result { + let s = match self { + Platform::AlibabaCloud => "AlibabaCloud", + Platform::Smsbao => "smsbao", + Platform::Abosend => "abosend", + Platform::Twilio => "twilio", + }; + write!(f, "{}", s) + } +} diff --git a/crates/sms/src/providers/abosend.rs b/crates/sms/src/providers/abosend.rs new file mode 100644 index 00000000..f032becd --- /dev/null +++ b/crates/sms/src/providers/abosend.rs @@ -0,0 +1,108 @@ +use anyhow::Context; +use reqwest::Client; +use serde::Deserialize; + +use crate::config::SmsConfig; +use crate::sender::Sender; + +const BASE_URL: &str = "https://smsapi.abosend.com"; + +pub struct AbosendSender { + config: SmsConfig, + client: Client, + base_url: String, +} + +impl AbosendSender { + pub fn new(config: SmsConfig) -> Self { + let base_url = if config.api_domain.is_empty() { + BASE_URL.to_string() + } else { + config.api_domain.clone() + }; + Self { + config, + client: Client::new(), + base_url, + } + } +} + +#[derive(Debug, Deserialize)] +struct AbosendResponse { + code: i32, + message: String, +} + +/// Render a Go-style template string: replace `{{.code}}` with `code`. +fn render_template(template: &str, code: &str) -> String { + template.replace("{{.code}}", code) +} + +/// Compute MD5 hex string (lowercase). +fn md5_hex(input: &str) -> String { + let digest = md5::compute(input.as_bytes()); + format!("{:x}", digest) +} + +#[async_trait::async_trait] +impl Sender for AbosendSender { + async fn send(&self, area: &str, phone: &str, code: &str, _expire: u32) -> anyhow::Result<()> { + let content = render_template(&self.config.template, code); + + // rand is a 6-digit numeric string; we derive it from current time nanos + let rand_num = format!( + "{:06}", + std::time::SystemTime::now() + .duration_since(std::time::UNIX_EPOCH) + .unwrap_or_default() + .subsec_nanos() + % 1_000_000 + ); + + // sign = md5(orgCode + content + rand + md5key), uppercase + let sign_input = format!( + "{}{}{}{}", + self.config.access, content, rand_num, self.config.secret + ); + let sign = md5_hex(&sign_input).to_uppercase(); + + let body = serde_json::json!({ + "orgCode": self.config.access, + "mobileArea": format!("+{}", area), + "mobiles": format!("{}{}", area, phone), + "content": content, + "rand": rand_num, + "sign": sign, + }); + + let url = format!("{}/v2/api/sendSMS", self.base_url); + let resp = self + .client + .post(&url) + .json(&body) + .send() + .await + .context("Abosend: HTTP request failed")?; + + let status = resp.status(); + if !status.is_success() { + anyhow::bail!("Abosend: HTTP {}", status); + } + + let result: AbosendResponse = resp + .json() + .await + .context("Abosend: failed to parse response")?; + + if result.code != 200 { + anyhow::bail!( + "Abosend: send failed, code={}, message={}", + result.code, + result.message + ); + } + + Ok(()) + } +} diff --git a/crates/sms/src/providers/alibabacloud.rs b/crates/sms/src/providers/alibabacloud.rs new file mode 100644 index 00000000..bf8e51bb --- /dev/null +++ b/crates/sms/src/providers/alibabacloud.rs @@ -0,0 +1,179 @@ +use anyhow::Context; +use reqwest::Client; +use serde::Deserialize; +use serde_json::json; + +use crate::config::SmsConfig; +use crate::sender::Sender; + +const DEFAULT_ENDPOINT: &str = "dysmsapi.ap-southeast-1.aliyuncs.com"; + +pub struct AlibabaCloudSender { + config: SmsConfig, + client: Client, +} + +impl AlibabaCloudSender { + pub fn new(config: SmsConfig) -> Self { + Self { + config, + client: Client::new(), + } + } +} + +/// Minimal response body from Dysmsapi SendSms +#[derive(Debug, Deserialize)] +struct SendSmsResponse { + #[serde(rename = "Code")] + code: String, + #[serde(rename = "Message")] + message: String, +} + +/// Build the canonical query string and HMAC-SHA1 signature required by +/// Alibaba Cloud's Dysmsapi (RPC-style, API version 2017-05-25). +/// +/// Reference: +/// https://help.aliyun.com/document_detail/101341.html +fn sign_request( + access_key_id: &str, + access_key_secret: &str, + params: &mut Vec<(String, String)>, +) -> anyhow::Result { + use base64::{engine::general_purpose::STANDARD, Engine}; + use hmac::{Hmac, Mac}; + use sha1::Sha1; + #[allow(unused_imports)] + use sha2::Sha256; + + // Common system parameters + let nonce = uuid_v4_simple(); + let timestamp = chrono::Utc::now().format("%Y-%m-%dT%H:%M:%SZ").to_string(); + + params.push(("AccessKeyId".to_string(), access_key_id.to_string())); + params.push(("Format".to_string(), "JSON".to_string())); + params.push(("SignatureMethod".to_string(), "HMAC-SHA1".to_string())); + params.push(("SignatureNonce".to_string(), nonce)); + params.push(("SignatureVersion".to_string(), "1.0".to_string())); + params.push(("Timestamp".to_string(), timestamp)); + params.push(("Version".to_string(), "2017-05-25".to_string())); + + // Sort by key + params.sort_by(|a, b| a.0.cmp(&b.0)); + + // Percent-encode each key=value pair then join with & + let canonical = params + .iter() + .map(|(k, v)| { + format!( + "{}={}", + percent_encode(k), + percent_encode(v) + ) + }) + .collect::>() + .join("&"); + + let string_to_sign = format!( + "GET&{}&{}", + percent_encode("/"), + percent_encode(&canonical) + ); + + // Sign with HMAC-SHA1 using "&" as key + let signing_key = format!("{}&", access_key_secret); + let mut mac = Hmac::::new_from_slice(signing_key.as_bytes()) + .context("HMAC-SHA1 init failed")?; + mac.update(string_to_sign.as_bytes()); + let signature = STANDARD.encode(mac.finalize().into_bytes()); + + params.push(("Signature".to_string(), signature.clone())); + + // Rebuild final query + let query = params + .iter() + .map(|(k, v)| format!("{}={}", percent_encode(k), percent_encode(v))) + .collect::>() + .join("&"); + + Ok(query) +} + +fn percent_encode(s: &str) -> String { + let mut encoded = String::new(); + for b in s.bytes() { + match b { + b'A'..=b'Z' | b'a'..=b'z' | b'0'..=b'9' + | b'-' | b'_' | b'.' | b'~' => { + encoded.push(b as char); + } + _ => { + encoded.push_str(&format!("%{:02X}", b)); + } + } + } + encoded +} + +fn uuid_v4_simple() -> String { + // Generate a UUID-like random string without external dep + use std::time::{SystemTime, UNIX_EPOCH}; + let t = SystemTime::now() + .duration_since(UNIX_EPOCH) + .unwrap_or_default() + .subsec_nanos(); + format!("{:08x}-{:04x}-4{:03x}", t, t >> 16, t & 0xfff) +} + +#[async_trait::async_trait] +impl Sender for AlibabaCloudSender { + async fn send(&self, area: &str, phone: &str, code: &str, _expire: u32) -> anyhow::Result<()> { + let endpoint = if self.config.endpoint.is_empty() { + DEFAULT_ENDPOINT + } else { + &self.config.endpoint + }; + + let template_param = json!({ "code": code }).to_string(); + let phone_number = format!("{}{}", area, phone); + + let mut params: Vec<(String, String)> = vec![ + ("Action".to_string(), "SendSms".to_string()), + ("PhoneNumbers".to_string(), phone_number), + ("SignName".to_string(), self.config.sign_name.clone()), + ("TemplateCode".to_string(), self.config.template_code.clone()), + ("TemplateParam".to_string(), template_param), + ]; + + let query = sign_request(&self.config.access, &self.config.secret, &mut params)?; + let url = format!("https://{}/{}", endpoint, query); + + let resp = self + .client + .get(&url) + .send() + .await + .context("AlibabaCloud: HTTP request failed")?; + + let status = resp.status(); + let body = resp.text().await.context("AlibabaCloud: failed to read body")?; + + if !status.is_success() { + anyhow::bail!("AlibabaCloud: HTTP {} — {}", status, body); + } + + let result: SendSmsResponse = + serde_json::from_str(&body).context("AlibabaCloud: failed to parse response")?; + + if result.code != "OK" { + anyhow::bail!( + "AlibabaCloud: SendSms failed, code={}, message={}", + result.code, + result.message + ); + } + + Ok(()) + } +} diff --git a/crates/sms/src/providers/mod.rs b/crates/sms/src/providers/mod.rs new file mode 100644 index 00000000..85bb77d4 --- /dev/null +++ b/crates/sms/src/providers/mod.rs @@ -0,0 +1,4 @@ +pub mod alibabacloud; +pub mod abosend; +pub mod smsbao; +pub mod twilio; diff --git a/crates/sms/src/providers/smsbao.rs b/crates/sms/src/providers/smsbao.rs new file mode 100644 index 00000000..67ed9a45 --- /dev/null +++ b/crates/sms/src/providers/smsbao.rs @@ -0,0 +1,86 @@ +use anyhow::Context; +use reqwest::Client; + +use crate::config::SmsConfig; +use crate::sender::Sender; + +const BASE_URL: &str = "https://api.smsbao.com"; + +pub struct SmsbaoSender { + config: SmsConfig, + client: Client, +} + +impl SmsbaoSender { + pub fn new(config: SmsConfig) -> Self { + Self { + config, + client: Client::new(), + } + } +} + +/// Render a Go-style template: replace `{{.code}}` with `code`. +fn render_template(template: &str, code: &str) -> String { + template.replace("{{.code}}", code) +} + +/// MD5 hex (lowercase). +fn md5_hex(input: &str) -> String { + let digest = md5::compute(input.as_bytes()); + format!("{:x}", digest) +} + +/// Map SMSBao numeric response body to an error description. +fn parse_smsbao_error(body: &str) -> anyhow::Result<()> { + match body.trim() { + "0" => Ok(()), + "30" => anyhow::bail!("SMSBao: password error"), + "40" => anyhow::bail!("SMSBao: account not found"), + "41" => anyhow::bail!("SMSBao: insufficient balance"), + "43" => anyhow::bail!("SMSBao: IP address restrictions"), + "50" => anyhow::bail!("SMSBao: content contains sensitive words"), + "51" => anyhow::bail!("SMSBao: mobile number is incorrect"), + other => anyhow::bail!("SMSBao: unknown error code: {}", other), + } +} + +#[async_trait::async_trait] +impl Sender for SmsbaoSender { + async fn send(&self, area: &str, phone: &str, code: &str, _expire: u32) -> anyhow::Result<()> { + let content = render_template(&self.config.template, code); + let password_md5 = md5_hex(&self.config.secret); + + // Domestic (China, area == "86") → /sms, just mobile number + // International → /wsms, prepend +area to number + let (api_path, mobile) = if area == "86" { + ("/sms", phone.to_string()) + } else { + ("/wsms", format!("+{}{}", area, phone)) + }; + + let url = format!("{}{}", BASE_URL, api_path); + let resp = self + .client + .get(&url) + .query(&[ + ("u", self.config.access.as_str()), + ("p", &password_md5), + ("m", &mobile), + ("c", &content), + ]) + .send() + .await + .context("SMSBao: HTTP request failed")?; + + let status = resp.status(); + let body = resp.text().await.context("SMSBao: failed to read body")?; + + if !status.is_success() { + anyhow::bail!("SMSBao: HTTP {} — {}", status, body); + } + + parse_smsbao_error(&body)?; + Ok(()) + } +} diff --git a/crates/sms/src/providers/twilio.rs b/crates/sms/src/providers/twilio.rs new file mode 100644 index 00000000..4ca99921 --- /dev/null +++ b/crates/sms/src/providers/twilio.rs @@ -0,0 +1,82 @@ +use anyhow::Context; +use base64::{engine::general_purpose::STANDARD, Engine}; +use reqwest::Client; +use serde::Deserialize; + +use crate::config::SmsConfig; +use crate::sender::Sender; + +pub struct TwilioSender { + config: SmsConfig, + client: Client, +} + +impl TwilioSender { + pub fn new(config: SmsConfig) -> Self { + Self { + config, + client: Client::new(), + } + } +} + +/// Render Go-style template: replace `{{.code}}` with `code`. +fn render_template(template: &str, code: &str) -> String { + template.replace("{{.code}}", code) +} + +/// Twilio Messages API response (partial — only error fields matter) +#[derive(Debug, Deserialize)] +struct TwilioMessageResponse { + error_code: Option, + error_message: Option, +} + +#[async_trait::async_trait] +impl Sender for TwilioSender { + async fn send(&self, area: &str, phone: &str, code: &str, _expire: u32) -> anyhow::Result<()> { + let to = format!("+{}{}", area, phone); + let body_text = render_template(&self.config.template, code); + + // Twilio REST API: POST /2010-04-01/Accounts/{AccountSid}/Messages.json + // Auth: HTTP Basic (AccountSid : AuthToken) + let account_sid = &self.config.access; + let auth_token = &self.config.secret; + let url = format!( + "https://api.twilio.com/2010-04-01/Accounts/{}/Messages.json", + account_sid + ); + + let credentials = STANDARD.encode(format!("{}:{}", account_sid, auth_token)); + + let resp = self + .client + .post(&url) + .header("Authorization", format!("Basic {}", credentials)) + .form(&[ + ("To", to.as_str()), + ("From", self.config.phone_number.as_str()), + ("Body", body_text.as_str()), + ]) + .send() + .await + .context("Twilio: HTTP request failed")?; + + let status = resp.status(); + let raw = resp.text().await.context("Twilio: failed to read body")?; + + if !status.is_success() { + anyhow::bail!("Twilio: HTTP {} — {}", status, raw); + } + + let result: TwilioMessageResponse = + serde_json::from_str(&raw).context("Twilio: failed to parse response")?; + + if let Some(err_code) = result.error_code { + let msg = result.error_message.unwrap_or_default(); + anyhow::bail!("Twilio: send failed, error_code={}, message={}", err_code, msg); + } + + Ok(()) + } +} diff --git a/crates/sms/src/sender.rs b/crates/sms/src/sender.rs new file mode 100644 index 00000000..cb2feecc --- /dev/null +++ b/crates/sms/src/sender.rs @@ -0,0 +1,4 @@ +#[async_trait::async_trait] +pub trait Sender: Send + Sync { + async fn send(&self, area: &str, phone: &str, code: &str, expire: u32) -> anyhow::Result<()>; +} diff --git a/migrations/mysql/00001_init_schema.sql b/migrations/mysql/00001_init_schema.sql new file mode 100644 index 00000000..a4a3eab5 --- /dev/null +++ b/migrations/mysql/00001_init_schema.sql @@ -0,0 +1,594 @@ +-- migrate:up +-- 000001_init_schema.up.sql +SET FOREIGN_KEY_CHECKS = 0; + +CREATE TABLE IF NOT EXISTS `ads` +( + `id` bigint NOT NULL AUTO_INCREMENT, + `title` varchar(255) CHARACTER SET utf8mb4 COLLATE utf8mb4_general_ci NOT NULL DEFAULT '' COMMENT 'Ads title', + `type` varchar(255) CHARACTER SET utf8mb4 COLLATE utf8mb4_general_ci NOT NULL DEFAULT '' COMMENT 'Ads type', + `content` text CHARACTER SET utf8mb4 COLLATE utf8mb4_general_ci COMMENT 'Ads content', + `target_url` varchar(512) CHARACTER SET utf8mb4 COLLATE utf8mb4_general_ci DEFAULT '' COMMENT 'Ads target url', + `start_time` datetime DEFAULT NULL COMMENT 'Ads start time', + `end_time` datetime DEFAULT NULL COMMENT 'Ads end time', + `status` tinyint(1) DEFAULT '0' COMMENT 'Ads status,0 disable,1 enable', + `created_at` datetime(3) DEFAULT NULL COMMENT 'Create Time', + `updated_at` datetime(3) DEFAULT NULL COMMENT 'Update Time', + PRIMARY KEY (`id`) +) ENGINE = InnoDB + DEFAULT CHARSET = utf8mb4 + COLLATE = utf8mb4_general_ci; + +CREATE TABLE IF NOT EXISTS `announcement` +( + `id` bigint NOT NULL AUTO_INCREMENT, + `title` varchar(255) CHARACTER SET utf8mb4 COLLATE utf8mb4_general_ci NOT NULL DEFAULT '' COMMENT 'Title', + `content` text CHARACTER SET utf8mb4 COLLATE utf8mb4_general_ci COMMENT 'Content', + `show` tinyint(1) NOT NULL DEFAULT '0' COMMENT 'Show', + `pinned` tinyint(1) NOT NULL DEFAULT '0' COMMENT 'Pinned', + `popup` tinyint(1) NOT NULL DEFAULT '0' COMMENT 'Popup', + `created_at` datetime(3) DEFAULT NULL COMMENT 'Create Time', + `updated_at` datetime(3) DEFAULT NULL COMMENT 'Update Time', + PRIMARY KEY (`id`) +) ENGINE = InnoDB + DEFAULT CHARSET = utf8mb4 + COLLATE = utf8mb4_general_ci; + +CREATE TABLE IF NOT EXISTS `application` +( + `id` bigint NOT NULL AUTO_INCREMENT, + `name` varchar(255) CHARACTER SET utf8mb4 COLLATE utf8mb4_general_ci NOT NULL DEFAULT '' COMMENT '应用名称', + `icon` text CHARACTER SET utf8mb4 COLLATE utf8mb4_general_ci NOT NULL COMMENT '应用图标', + `description` text CHARACTER SET utf8mb4 COLLATE utf8mb4_general_ci COMMENT '更新描述', + `subscribe_type` varchar(50) CHARACTER SET utf8mb4 COLLATE utf8mb4_general_ci NOT NULL DEFAULT '' COMMENT '订阅类型', + `created_at` datetime(3) DEFAULT NULL COMMENT '创建时间', + `updated_at` datetime(3) DEFAULT NULL COMMENT '更新时间', + PRIMARY KEY (`id`) +) ENGINE = InnoDB + DEFAULT CHARSET = utf8mb4 + COLLATE = utf8mb4_general_ci; + +CREATE TABLE IF NOT EXISTS `application_config` +( + `id` bigint NOT NULL AUTO_INCREMENT, + `app_id` bigint NOT NULL DEFAULT '0' COMMENT 'App id', + `encryption_key` text CHARACTER SET utf8mb4 COLLATE utf8mb4_general_ci COMMENT 'Encryption Key', + `encryption_method` varchar(255) CHARACTER SET utf8mb4 COLLATE utf8mb4_general_ci DEFAULT NULL COMMENT 'Encryption Method', + `domains` text CHARACTER SET utf8mb4 COLLATE utf8mb4_general_ci, + `startup_picture` text CHARACTER SET utf8mb4 COLLATE utf8mb4_general_ci, + `startup_picture_skip_time` bigint NOT NULL DEFAULT '0' COMMENT 'Startup Picture Skip Time', + `created_at` datetime(3) DEFAULT NULL COMMENT 'Create Time', + `updated_at` datetime(3) DEFAULT NULL COMMENT 'Update Time', + PRIMARY KEY (`id`) +) ENGINE = InnoDB + DEFAULT CHARSET = utf8mb4 + COLLATE = utf8mb4_general_ci; + +CREATE TABLE IF NOT EXISTS `application_version` +( + `id` bigint NOT NULL AUTO_INCREMENT, + `url` varchar(255) CHARACTER SET utf8mb4 COLLATE utf8mb4_general_ci NOT NULL DEFAULT '' COMMENT '应用地址', + `version` varchar(255) CHARACTER SET utf8mb4 COLLATE utf8mb4_general_ci NOT NULL DEFAULT '' COMMENT '应用版本', + `platform` varchar(50) CHARACTER SET utf8mb4 COLLATE utf8mb4_general_ci NOT NULL DEFAULT '' COMMENT '应用平台', + `is_default` tinyint(1) NOT NULL DEFAULT '0' COMMENT '默认版本', + `description` text CHARACTER SET utf8mb4 COLLATE utf8mb4_general_ci COMMENT '更新描述', + `application_id` bigint DEFAULT NULL COMMENT '所属应用', + `created_at` datetime(3) DEFAULT NULL COMMENT '创建时间', + `updated_at` datetime(3) DEFAULT NULL COMMENT '更新时间', + PRIMARY KEY (`id`), + KEY `fk_application_application_versions` (`application_id`) +) ENGINE = InnoDB + DEFAULT CHARSET = utf8mb4 + COLLATE = utf8mb4_general_ci; + +CREATE TABLE IF NOT EXISTS `auth_method` +( + `id` bigint NOT NULL AUTO_INCREMENT, + `method` varchar(255) CHARACTER SET utf8mb4 COLLATE utf8mb4_general_ci NOT NULL DEFAULT '' COMMENT 'method', + `config` text CHARACTER SET utf8mb4 COLLATE utf8mb4_general_ci NOT NULL COMMENT 'OAuth Configuration', + `enabled` tinyint(1) NOT NULL DEFAULT '0' COMMENT 'Is Enabled', + `created_at` datetime(3) DEFAULT NULL COMMENT 'Create Time', + `updated_at` datetime(3) DEFAULT NULL COMMENT 'Update Time', + PRIMARY KEY (`id`), + UNIQUE KEY `uni_auth_method` (`method`) +) ENGINE = InnoDB + DEFAULT CHARSET = utf8mb4 + COLLATE = utf8mb4_general_ci; + +CREATE TABLE IF NOT EXISTS `coupon` +( + `id` bigint NOT NULL AUTO_INCREMENT, + `name` varchar(255) CHARACTER SET utf8mb4 COLLATE utf8mb4_general_ci NOT NULL DEFAULT '' COMMENT 'Coupon Name', + `code` varchar(255) CHARACTER SET utf8mb4 COLLATE utf8mb4_general_ci NOT NULL DEFAULT '' COMMENT 'Coupon Code', + `count` bigint NOT NULL DEFAULT '0' COMMENT 'Count Limit', + `type` tinyint(1) NOT NULL DEFAULT '1' COMMENT 'Coupon Type: 1: Percentage 2: Fixed Amount', + `discount` bigint NOT NULL DEFAULT '0' COMMENT 'Coupon Discount', + `start_time` bigint NOT NULL DEFAULT '0' COMMENT 'Start Time', + `expire_time` bigint NOT NULL DEFAULT '0' COMMENT 'Expire Time', + `user_limit` bigint NOT NULL DEFAULT '0' COMMENT 'User Limit', + `subscribe` varchar(255) CHARACTER SET utf8mb4 COLLATE utf8mb4_general_ci NOT NULL DEFAULT '' COMMENT 'Subscribe Limit', + `used_count` bigint NOT NULL DEFAULT '0' COMMENT 'Used Count', + `enable` tinyint(1) NOT NULL DEFAULT '1' COMMENT 'Enable', + `created_at` datetime(3) DEFAULT NULL COMMENT 'Create Time', + `updated_at` datetime(3) DEFAULT NULL COMMENT 'Update Time', + PRIMARY KEY (`id`), + UNIQUE KEY `uni_coupon_code` (`code`) +) ENGINE = InnoDB + DEFAULT CHARSET = utf8mb4 + COLLATE = utf8mb4_general_ci; + +CREATE TABLE IF NOT EXISTS `document` +( + `id` bigint NOT NULL AUTO_INCREMENT, + `title` varchar(255) CHARACTER SET utf8mb4 COLLATE utf8mb4_general_ci NOT NULL DEFAULT '' COMMENT 'Document Title', + `content` text CHARACTER SET utf8mb4 COLLATE utf8mb4_general_ci COMMENT 'Document Content', + `tags` varchar(255) CHARACTER SET utf8mb4 COLLATE utf8mb4_general_ci NOT NULL DEFAULT '' COMMENT 'Document Tags', + `show` tinyint(1) NOT NULL DEFAULT '1' COMMENT 'Show', + `created_at` datetime(3) DEFAULT NULL COMMENT 'Create Time', + `updated_at` datetime(3) DEFAULT NULL COMMENT 'Update Time', + PRIMARY KEY (`id`) +) ENGINE = InnoDB + DEFAULT CHARSET = utf8mb4 + COLLATE = utf8mb4_general_ci; + +CREATE TABLE IF NOT EXISTS `message_log` +( + `id` bigint NOT NULL AUTO_INCREMENT, + `type` varchar(50) CHARACTER SET utf8mb4 COLLATE utf8mb4_general_ci NOT NULL DEFAULT 'email' COMMENT 'Message Type', + `platform` varchar(50) CHARACTER SET utf8mb4 COLLATE utf8mb4_general_ci NOT NULL DEFAULT 'smtp' COMMENT 'Platform', + `to` text CHARACTER SET utf8mb4 COLLATE utf8mb4_general_ci NOT NULL COMMENT 'To', + `subject` varchar(255) CHARACTER SET utf8mb4 COLLATE utf8mb4_general_ci NOT NULL DEFAULT '' COMMENT 'Subject', + `content` text CHARACTER SET utf8mb4 COLLATE utf8mb4_general_ci COMMENT 'Content', + `status` tinyint(1) NOT NULL DEFAULT '0' COMMENT 'Status', + `created_at` datetime(3) DEFAULT NULL COMMENT 'Create Time', + `updated_at` datetime(3) DEFAULT NULL COMMENT 'Update Time', + PRIMARY KEY (`id`) +) ENGINE = InnoDB + DEFAULT CHARSET = utf8mb4 + COLLATE = utf8mb4_general_ci; + +CREATE TABLE IF NOT EXISTS `order` +( + `id` bigint NOT NULL AUTO_INCREMENT, + `parent_id` bigint DEFAULT NULL COMMENT 'Parent Order Id', + `user_id` bigint NOT NULL DEFAULT '0' COMMENT 'User Id', + `order_no` varchar(255) CHARACTER SET utf8mb4 COLLATE utf8mb4_general_ci NOT NULL DEFAULT '' COMMENT 'Order No', + `type` tinyint(1) NOT NULL DEFAULT '1' COMMENT 'Order Type: 1: Subscribe, 2: Renewal, 3: ResetTraffic, 4: Recharge', + `quantity` bigint NOT NULL DEFAULT '1' COMMENT 'Quantity', + `price` bigint NOT NULL DEFAULT '0' COMMENT 'Original price', + `amount` bigint NOT NULL DEFAULT '0' COMMENT 'Order Amount', + `gift_amount` bigint NOT NULL DEFAULT '0' COMMENT 'User Gift Amount', + `discount` bigint NOT NULL DEFAULT '0' COMMENT 'Discount Amount', + `coupon` varchar(255) CHARACTER SET utf8mb4 COLLATE utf8mb4_general_ci DEFAULT NULL COMMENT 'Coupon', + `coupon_discount` bigint NOT NULL DEFAULT '0' COMMENT 'Coupon Discount Amount', + `commission` bigint NOT NULL DEFAULT '0' COMMENT 'Order Commission', + `payment_id` bigint NOT NULL DEFAULT '-1' COMMENT 'Payment Id', + `method` varchar(255) CHARACTER SET utf8mb4 COLLATE utf8mb4_general_ci NOT NULL DEFAULT '' COMMENT 'Payment Method', + `fee_amount` bigint NOT NULL DEFAULT '0' COMMENT 'Fee Amount', + `trade_no` varchar(255) CHARACTER SET utf8mb4 COLLATE utf8mb4_general_ci DEFAULT NULL COMMENT 'Trade No', + `status` tinyint(1) NOT NULL DEFAULT '1' COMMENT 'Order Status: 1: Pending, 2: Paid, 3:Close, 4: Failed, 5:Finished', + `subscribe_id` bigint NOT NULL DEFAULT '0' COMMENT 'Subscribe Id', + `subscribe_token` varchar(255) CHARACTER SET utf8mb4 COLLATE utf8mb4_general_ci DEFAULT NULL COMMENT 'Renewal Subscribe Token', + `is_new` tinyint(1) NOT NULL DEFAULT '0' COMMENT 'Is New Order', + `created_at` datetime(3) DEFAULT NULL COMMENT 'Create Time', + `updated_at` datetime(3) DEFAULT NULL COMMENT 'Update Time', + PRIMARY KEY (`id`), + UNIQUE KEY `uni_order_order_no` (`order_no`) +) ENGINE = InnoDB + DEFAULT CHARSET = utf8mb4 + COLLATE = utf8mb4_general_ci; + +CREATE TABLE IF NOT EXISTS `payment` +( + `id` bigint NOT NULL AUTO_INCREMENT, + `name` varchar(100) CHARACTER SET utf8mb4 COLLATE utf8mb4_general_ci NOT NULL DEFAULT '' COMMENT 'Payment Name', + `platform` varchar(100) CHARACTER SET utf8mb4 COLLATE utf8mb4_general_ci NOT NULL COMMENT 'Payment Platform', + `description` text CHARACTER SET utf8mb4 COLLATE utf8mb4_general_ci COMMENT 'Payment Description', + `icon` varchar(255) CHARACTER SET utf8mb4 COLLATE utf8mb4_general_ci DEFAULT '' COMMENT 'Payment Icon', + `domain` varchar(255) CHARACTER SET utf8mb4 COLLATE utf8mb4_general_ci DEFAULT '' COMMENT 'Notification Domain', + `config` text CHARACTER SET utf8mb4 COLLATE utf8mb4_general_ci NOT NULL COMMENT 'Payment Configuration', + `fee_mode` tinyint(1) NOT NULL DEFAULT '0' COMMENT 'Fee Mode: 0: No Fee 1: Percentage 2: Fixed Amount 3: Percentage + Fixed Amount', + `fee_percent` bigint DEFAULT '0' COMMENT 'Fee Percentage', + `fee_amount` bigint DEFAULT '0' COMMENT 'Fixed Fee Amount', + `enable` tinyint(1) NOT NULL DEFAULT '0' COMMENT 'Is Enabled', + `token` varchar(255) CHARACTER SET utf8mb4 COLLATE utf8mb4_general_ci DEFAULT NULL COMMENT 'Payment Token', + PRIMARY KEY (`id`), + UNIQUE KEY `uni_payment_token` (`token`) +) ENGINE = InnoDB + DEFAULT CHARSET = utf8mb4 + COLLATE = utf8mb4_general_ci; + +CREATE TABLE IF NOT EXISTS `server` +( + `id` bigint NOT NULL AUTO_INCREMENT, + `name` varchar(100) CHARACTER SET utf8mb4 COLLATE utf8mb4_general_ci NOT NULL DEFAULT '' COMMENT 'Node Name', + `tags` varchar(128) CHARACTER SET utf8mb4 COLLATE utf8mb4_general_ci NOT NULL DEFAULT '' COMMENT 'Tags', + `country` varchar(128) CHARACTER SET utf8mb4 COLLATE utf8mb4_general_ci NOT NULL DEFAULT '' COMMENT 'Country', + `city` varchar(128) CHARACTER SET utf8mb4 COLLATE utf8mb4_general_ci NOT NULL DEFAULT '' COMMENT 'City', + `latitude` varchar(128) CHARACTER SET utf8mb4 COLLATE utf8mb4_general_ci NOT NULL DEFAULT '' COMMENT 'latitude', + `longitude` varchar(128) CHARACTER SET utf8mb4 COLLATE utf8mb4_general_ci NOT NULL DEFAULT '' COMMENT 'longitude', + `server_addr` varchar(100) CHARACTER SET utf8mb4 COLLATE utf8mb4_general_ci NOT NULL DEFAULT '' COMMENT 'Server Address', + `relay_mode` varchar(20) CHARACTER SET utf8mb4 COLLATE utf8mb4_general_ci NOT NULL DEFAULT 'none' COMMENT 'Relay Mode', + `relay_node` text CHARACTER SET utf8mb4 COLLATE utf8mb4_general_ci COMMENT 'Relay Node', + `speed_limit` bigint NOT NULL DEFAULT '0' COMMENT 'Speed Limit', + `traffic_ratio` decimal(4, 2) NOT NULL DEFAULT '0.00' COMMENT 'Traffic Ratio', + `group_id` bigint DEFAULT NULL COMMENT 'Group ID', + `protocol` varchar(20) CHARACTER SET utf8mb4 COLLATE utf8mb4_general_ci NOT NULL DEFAULT '' COMMENT 'Protocol', + `config` text CHARACTER SET utf8mb4 COLLATE utf8mb4_general_ci COMMENT 'Config', + `enable` tinyint(1) NOT NULL DEFAULT '1' COMMENT 'Enabled', + `sort` bigint NOT NULL DEFAULT '0' COMMENT 'Sort', + `last_reported_at` datetime(3) DEFAULT NULL COMMENT 'Last Reported Time', + `created_at` datetime(3) DEFAULT NULL COMMENT 'Creation Time', + `updated_at` datetime(3) DEFAULT NULL COMMENT 'Update Time', + PRIMARY KEY (`id`), + KEY `idx_group_id` (`group_id`) +) ENGINE = InnoDB + DEFAULT CHARSET = utf8mb4 + COLLATE = utf8mb4_general_ci; + +CREATE TABLE IF NOT EXISTS `server_group` +( + `id` bigint NOT NULL AUTO_INCREMENT, + `name` varchar(100) CHARACTER SET utf8mb4 COLLATE utf8mb4_general_ci NOT NULL DEFAULT '' COMMENT 'Group Name', + `description` varchar(255) CHARACTER SET utf8mb4 COLLATE utf8mb4_general_ci DEFAULT '' COMMENT 'Group Description', + `created_at` datetime(3) DEFAULT NULL COMMENT 'Creation Time', + `updated_at` datetime(3) DEFAULT NULL COMMENT 'Update Time', + PRIMARY KEY (`id`) +) ENGINE = InnoDB + DEFAULT CHARSET = utf8mb4 + COLLATE = utf8mb4_general_ci; + +-- if `sms` not exist, create it +CREATE TABLE IF NOT EXISTS `sms` +( + `id` bigint NOT NULL AUTO_INCREMENT, + `content` text CHARACTER SET utf8mb4 COLLATE utf8mb4_general_ci, + `platform` varchar(64) CHARACTER SET utf8mb4 COLLATE utf8mb4_general_ci DEFAULT NULL, + `area_code` varchar(64) CHARACTER SET utf8mb4 COLLATE utf8mb4_general_ci DEFAULT NULL, + `telephone` varchar(64) CHARACTER SET utf8mb4 COLLATE utf8mb4_general_ci DEFAULT NULL, + `status` tinyint(1) DEFAULT '1', + `created_at` timestamp NULL DEFAULT CURRENT_TIMESTAMP, + PRIMARY KEY (`id`) +) ENGINE = InnoDB + DEFAULT CHARSET = utf8mb4 + COLLATE = utf8mb4_general_ci; + +CREATE TABLE IF NOT EXISTS `subscribe` +( + `id` bigint NOT NULL AUTO_INCREMENT, + `name` varchar(255) CHARACTER SET utf8mb4 COLLATE utf8mb4_general_ci NOT NULL DEFAULT '' COMMENT 'Subscribe Name', + `description` text CHARACTER SET utf8mb4 COLLATE utf8mb4_general_ci COMMENT 'Subscribe Description', + `unit_price` bigint NOT NULL DEFAULT '0' COMMENT 'Unit Price', + `unit_time` varchar(255) CHARACTER SET utf8mb4 COLLATE utf8mb4_general_ci NOT NULL DEFAULT '' COMMENT 'Unit Time', + `discount` text CHARACTER SET utf8mb4 COLLATE utf8mb4_general_ci COMMENT 'Discount', + `replacement` bigint NOT NULL DEFAULT '0' COMMENT 'Replacement', + `inventory` bigint NOT NULL DEFAULT '0' COMMENT 'Inventory', + `traffic` bigint NOT NULL DEFAULT '0' COMMENT 'Traffic', + `speed_limit` bigint NOT NULL DEFAULT '0' COMMENT 'Speed Limit', + `device_limit` bigint NOT NULL DEFAULT '0' COMMENT 'Device Limit', + `quota` bigint NOT NULL DEFAULT '0' COMMENT 'Quota', + `group_id` bigint DEFAULT NULL COMMENT 'Group Id', + `server_group` varchar(255) CHARACTER SET utf8mb4 COLLATE utf8mb4_general_ci DEFAULT NULL COMMENT 'Server Group', + `server` varchar(255) CHARACTER SET utf8mb4 COLLATE utf8mb4_general_ci DEFAULT NULL COMMENT 'Server', + `show` tinyint(1) NOT NULL DEFAULT '0' COMMENT 'Show portal page', + `sell` tinyint(1) NOT NULL DEFAULT '0' COMMENT 'Sell', + `sort` bigint NOT NULL DEFAULT '0' COMMENT 'Sort', + `deduction_ratio` bigint DEFAULT '0' COMMENT 'Deduction Ratio', + `allow_deduction` tinyint(1) DEFAULT '1' COMMENT 'Allow deduction', + `reset_cycle` bigint DEFAULT '0' COMMENT 'Reset Cycle: 0: No Reset, 1: 1st, 2: Monthly, 3: Yearly', + `renewal_reset` tinyint(1) DEFAULT '0' COMMENT 'Renew Reset', + `created_at` datetime(3) DEFAULT NULL COMMENT 'Create Time', + `updated_at` datetime(3) DEFAULT NULL COMMENT 'Update Time', + PRIMARY KEY (`id`) +) ENGINE = InnoDB + DEFAULT CHARSET = utf8mb4 + COLLATE = utf8mb4_general_ci; + +CREATE TABLE IF NOT EXISTS `subscribe_group` +( + `id` bigint NOT NULL AUTO_INCREMENT, + `name` varchar(255) CHARACTER SET utf8mb4 COLLATE utf8mb4_general_ci NOT NULL DEFAULT '' COMMENT 'Group Name', + `description` text CHARACTER SET utf8mb4 COLLATE utf8mb4_general_ci COMMENT 'Group Description', + `created_at` datetime(3) DEFAULT NULL COMMENT 'Create Time', + `updated_at` datetime(3) DEFAULT NULL COMMENT 'Update Time', + PRIMARY KEY (`id`) +) ENGINE = InnoDB + DEFAULT CHARSET = utf8mb4 + COLLATE = utf8mb4_general_ci; + +CREATE TABLE IF NOT EXISTS `subscribe_type` +( + `id` bigint NOT NULL AUTO_INCREMENT, + `name` varchar(50) CHARACTER SET utf8mb4 COLLATE utf8mb4_general_ci NOT NULL DEFAULT '' COMMENT '订阅类型', + `mark` varchar(255) CHARACTER SET utf8mb4 COLLATE utf8mb4_general_ci NOT NULL DEFAULT '' COMMENT '订阅标识', + `created_at` datetime(3) DEFAULT NULL COMMENT '创建时间', + `updated_at` datetime(3) DEFAULT NULL COMMENT '更新时间', + PRIMARY KEY (`id`) +) ENGINE = InnoDB + DEFAULT CHARSET = utf8mb4 + COLLATE = utf8mb4_general_ci; + +CREATE TABLE IF NOT EXISTS `system` +( + `id` bigint NOT NULL AUTO_INCREMENT, + `category` varchar(100) CHARACTER SET utf8mb4 COLLATE utf8mb4_general_ci NOT NULL DEFAULT '' COMMENT 'Category', + `key` varchar(100) CHARACTER SET utf8mb4 COLLATE utf8mb4_general_ci NOT NULL DEFAULT '' COMMENT 'Key Name', + `value` text CHARACTER SET utf8mb4 COLLATE utf8mb4_general_ci NOT NULL COMMENT 'Key Value', + `type` varchar(50) CHARACTER SET utf8mb4 COLLATE utf8mb4_general_ci NOT NULL DEFAULT '' COMMENT 'Type', + `desc` text CHARACTER SET utf8mb4 COLLATE utf8mb4_general_ci NOT NULL COMMENT 'Description', + `created_at` datetime(3) DEFAULT NULL COMMENT 'Creation Time', + `updated_at` datetime(3) DEFAULT NULL COMMENT 'Update Time', + PRIMARY KEY (`id`), + UNIQUE KEY `uni_system_key` (`key`), + KEY `index_key` (`key`) +) ENGINE = InnoDB + DEFAULT CHARSET = utf8mb4 + COLLATE = utf8mb4_general_ci; + +CREATE TABLE IF NOT EXISTS `ticket` +( + `id` bigint NOT NULL AUTO_INCREMENT, + `title` varchar(255) CHARACTER SET utf8mb4 COLLATE utf8mb4_general_ci NOT NULL DEFAULT '' COMMENT 'Title', + `description` text CHARACTER SET utf8mb4 COLLATE utf8mb4_general_ci COMMENT 'Description', + `user_id` bigint NOT NULL DEFAULT '0' COMMENT 'UserId', + `status` tinyint(1) NOT NULL DEFAULT '1' COMMENT 'Status', + `created_at` datetime(3) DEFAULT NULL COMMENT 'Create Time', + `updated_at` datetime(3) DEFAULT NULL COMMENT 'Update Time', + PRIMARY KEY (`id`) +) ENGINE = InnoDB + DEFAULT CHARSET = utf8mb4 + COLLATE = utf8mb4_general_ci; + +CREATE TABLE IF NOT EXISTS `ticket_follow` +( + `id` bigint NOT NULL AUTO_INCREMENT, + `ticket_id` bigint NOT NULL DEFAULT '0' COMMENT 'TicketId', + `from` varchar(255) CHARACTER SET utf8mb4 COLLATE utf8mb4_general_ci NOT NULL DEFAULT '' COMMENT 'From', + `type` tinyint(1) NOT NULL DEFAULT '1' COMMENT 'Type: 1 text, 2 image', + `content` text CHARACTER SET utf8mb4 COLLATE utf8mb4_general_ci COMMENT 'Content', + `created_at` datetime(3) DEFAULT NULL COMMENT 'Create Time', + PRIMARY KEY (`id`) +) ENGINE = InnoDB + DEFAULT CHARSET = utf8mb4 + COLLATE = utf8mb4_general_ci; + +CREATE TABLE IF NOT EXISTS `traffic_log` +( + `id` bigint NOT NULL AUTO_INCREMENT, + `server_id` bigint NOT NULL COMMENT 'Server ID', + `user_id` bigint NOT NULL COMMENT 'User ID', + `subscribe_id` bigint NOT NULL COMMENT 'Subscription ID', + `download` bigint DEFAULT '0' COMMENT 'Download Traffic', + `upload` bigint DEFAULT '0' COMMENT 'Upload Traffic', + `timestamp` datetime(3) NOT NULL DEFAULT CURRENT_TIMESTAMP(3) COMMENT 'Traffic Log Time', + PRIMARY KEY (`id`), + KEY `idx_subscribe_id` (`subscribe_id`), + KEY `idx_server_id` (`server_id`), + KEY `idx_user_id` (`user_id`) +) ENGINE = InnoDB + DEFAULT CHARSET = utf8mb4 + COLLATE = utf8mb4_general_ci; + +CREATE TABLE IF NOT EXISTS `user` +( + `id` bigint NOT NULL AUTO_INCREMENT, + `password` varchar(100) CHARACTER SET utf8mb4 COLLATE utf8mb4_general_ci NOT NULL COMMENT 'User Password', + `avatar` text CHARACTER SET utf8mb4 COLLATE utf8mb4_general_ci COMMENT 'User Avatar', + `balance` bigint DEFAULT '0' COMMENT 'User Balance', + `telegram` bigint DEFAULT NULL COMMENT 'Telegram Account', + `refer_code` varchar(20) CHARACTER SET utf8mb4 COLLATE utf8mb4_general_ci DEFAULT '' COMMENT 'Referral Code', + `referer_id` bigint DEFAULT NULL COMMENT 'Referrer ID', + `commission` bigint DEFAULT '0' COMMENT 'Commission', + `gift_amount` bigint DEFAULT '0' COMMENT 'User Gift Amount', + `enable` tinyint(1) NOT NULL DEFAULT '1' COMMENT 'Is Account Enabled', + `is_admin` tinyint(1) NOT NULL DEFAULT '0' COMMENT 'Is Admin', + `valid_email` tinyint(1) NOT NULL DEFAULT '0' COMMENT 'Is Email Verified', + `enable_email_notify` tinyint(1) NOT NULL DEFAULT '0' COMMENT 'Enable Email Notifications', + `enable_telegram_notify` tinyint(1) NOT NULL DEFAULT '0' COMMENT 'Enable Telegram Notifications', + `enable_balance_notify` tinyint(1) NOT NULL DEFAULT '0' COMMENT 'Enable Balance Change Notifications', + `enable_login_notify` tinyint(1) NOT NULL DEFAULT '0' COMMENT 'Enable Login Notifications', + `enable_subscribe_notify` tinyint(1) NOT NULL DEFAULT '0' COMMENT 'Enable Subscription Notifications', + `enable_trade_notify` tinyint(1) NOT NULL DEFAULT '0' COMMENT 'Enable Trade Notifications', + `created_at` datetime(3) DEFAULT NULL COMMENT 'Creation Time', + `updated_at` datetime(3) DEFAULT NULL COMMENT 'Update Time', + `deleted_at` datetime(3) DEFAULT NULL COMMENT 'Deletion Time', + `is_del` bigint unsigned DEFAULT NULL COMMENT '1: Normal 0: Deleted', + PRIMARY KEY (`id`), + KEY `idx_referer` (`referer_id`) +) ENGINE = InnoDB + DEFAULT CHARSET = utf8mb4 + COLLATE = utf8mb4_general_ci; + +CREATE TABLE IF NOT EXISTS `user_auth_methods` +( + `id` bigint NOT NULL AUTO_INCREMENT, + `user_id` bigint NOT NULL COMMENT 'User ID', + `auth_type` varchar(255) CHARACTER SET utf8mb4 COLLATE utf8mb4_general_ci NOT NULL COMMENT 'Auth Type 1: apple 2: google 3: github 4: facebook 5: telegram 6: email 7: phone', + `auth_identifier` varchar(255) CHARACTER SET utf8mb4 COLLATE utf8mb4_general_ci NOT NULL COMMENT 'Auth Identifier', + `verified` tinyint(1) NOT NULL DEFAULT '0' COMMENT 'Is Verified', + `created_at` datetime(3) DEFAULT NULL COMMENT 'Creation Time', + `updated_at` datetime(3) DEFAULT NULL COMMENT 'Update Time', + PRIMARY KEY (`id`), + UNIQUE KEY `idx_auth_identifier` (`auth_identifier`), + KEY `idx_user_id` (`user_id`) +) ENGINE = InnoDB + DEFAULT CHARSET = utf8mb4 + COLLATE = utf8mb4_general_ci; + +CREATE TABLE IF NOT EXISTS `user_balance_log` +( + `id` bigint NOT NULL AUTO_INCREMENT, + `user_id` bigint NOT NULL COMMENT 'User ID', + `amount` bigint NOT NULL COMMENT 'Amount', + `type` tinyint(1) NOT NULL COMMENT 'Type: 1: Recharge 2: Withdraw 3: Payment 4: Refund 5: Reward', + `order_id` bigint DEFAULT NULL COMMENT 'Order ID', + `balance` bigint NOT NULL COMMENT 'Balance', + `created_at` datetime(3) DEFAULT NULL COMMENT 'Creation Time', + PRIMARY KEY (`id`), + KEY `idx_user_id` (`user_id`) +) ENGINE = InnoDB + DEFAULT CHARSET = utf8mb4 + COLLATE = utf8mb4_general_ci; + +CREATE TABLE IF NOT EXISTS `user_commission_log` +( + `id` bigint NOT NULL AUTO_INCREMENT, + `user_id` bigint NOT NULL COMMENT 'User ID', + `order_no` varchar(191) CHARACTER SET utf8mb4 COLLATE utf8mb4_general_ci DEFAULT NULL COMMENT 'Order No.', + `amount` bigint NOT NULL COMMENT 'Amount', + `created_at` datetime(3) DEFAULT NULL COMMENT 'Creation Time', + PRIMARY KEY (`id`), + KEY `idx_user_id` (`user_id`) +) ENGINE = InnoDB + DEFAULT CHARSET = utf8mb4 + COLLATE = utf8mb4_general_ci; + +CREATE TABLE IF NOT EXISTS `user_device` +( + `id` bigint NOT NULL AUTO_INCREMENT, + `user_id` bigint NOT NULL COMMENT 'User ID', + `subscribe_id` bigint DEFAULT NULL COMMENT 'Subscribe ID', + `ip` varchar(191) CHARACTER SET utf8mb4 COLLATE utf8mb4_general_ci DEFAULT NULL COMMENT 'Device Ip.', + `Identifier` varchar(191) CHARACTER SET utf8mb4 COLLATE utf8mb4_general_ci DEFAULT NULL COMMENT 'Device Identifier.', + `user_agent` varchar(64) CHARACTER SET utf8mb4 COLLATE utf8mb4_general_ci DEFAULT NULL COMMENT 'Device User Agent.', + `online` tinyint(1) NOT NULL DEFAULT '0' COMMENT 'Online', + `enabled` tinyint(1) NOT NULL DEFAULT '1' COMMENT 'EnableDeviceNumber', + `created_at` datetime(3) DEFAULT NULL COMMENT 'Creation Time', + `updated_at` datetime(3) DEFAULT NULL COMMENT 'Update Time', + PRIMARY KEY (`id`), + KEY `idx_user_id` (`user_id`) +) ENGINE = InnoDB + DEFAULT CHARSET = utf8mb4 + COLLATE = utf8mb4_general_ci; + +CREATE TABLE IF NOT EXISTS `user_gift_amount_log` +( + `id` bigint NOT NULL AUTO_INCREMENT, + `user_id` bigint NOT NULL COMMENT 'User ID', + `user_subscribe_id` bigint DEFAULT NULL COMMENT 'Deduction User Subscribe ID', + `order_no` varchar(191) CHARACTER SET utf8mb4 COLLATE utf8mb4_general_ci DEFAULT NULL COMMENT 'Order No.', + `type` tinyint(1) NOT NULL COMMENT 'Type: 1: Increase 2: Reduce', + `amount` bigint NOT NULL COMMENT 'Amount', + `balance` bigint NOT NULL COMMENT 'Balance', + `remark` varchar(255) CHARACTER SET utf8mb4 COLLATE utf8mb4_general_ci DEFAULT '' COMMENT 'Remark', + `created_at` datetime(3) DEFAULT NULL COMMENT 'Creation Time', + PRIMARY KEY (`id`), + KEY `idx_user_id` (`user_id`) +) ENGINE = InnoDB + DEFAULT CHARSET = utf8mb4 + COLLATE = utf8mb4_general_ci; + +CREATE TABLE IF NOT EXISTS `user_login_log` +( + `id` bigint NOT NULL AUTO_INCREMENT, + `user_id` bigint NOT NULL COMMENT 'User ID', + `login_ip` varchar(255) CHARACTER SET utf8mb4 COLLATE utf8mb4_general_ci NOT NULL COMMENT 'Login IP', + `user_agent` text CHARACTER SET utf8mb4 COLLATE utf8mb4_general_ci NOT NULL COMMENT 'UserAgent', + `success` tinyint(1) NOT NULL DEFAULT '0' COMMENT 'Login Success', + `created_at` datetime(3) DEFAULT NULL COMMENT 'Creation Time', + PRIMARY KEY (`id`), + KEY `idx_user_id` (`user_id`) +) ENGINE = InnoDB + DEFAULT CHARSET = utf8mb4 + COLLATE = utf8mb4_general_ci; + +CREATE TABLE IF NOT EXISTS `user_subscribe` +( + `id` bigint NOT NULL AUTO_INCREMENT, + `user_id` bigint NOT NULL COMMENT 'User ID', + `order_id` bigint NOT NULL COMMENT 'Order ID', + `subscribe_id` bigint NOT NULL COMMENT 'Subscription ID', + `start_time` datetime(3) NOT NULL DEFAULT CURRENT_TIMESTAMP(3) COMMENT 'Subscription Start Time', + `expire_time` datetime(3) DEFAULT NULL COMMENT 'Subscription Expire Time', + `traffic` bigint DEFAULT '0' COMMENT 'Traffic', + `download` bigint DEFAULT '0' COMMENT 'Download Traffic', + `upload` bigint DEFAULT '0' COMMENT 'Upload Traffic', + `token` varchar(255) CHARACTER SET utf8mb4 COLLATE utf8mb4_general_ci DEFAULT '' COMMENT 'Token', + `uuid` varchar(255) CHARACTER SET utf8mb4 COLLATE utf8mb4_general_ci DEFAULT '' COMMENT 'UUID', + `status` tinyint(1) DEFAULT '0' COMMENT 'Subscription Status: 0: Pending 1: Active 2: Finished 3: Expired 4: Deducted', + `created_at` datetime(3) DEFAULT NULL COMMENT 'Creation Time', + `updated_at` datetime(3) DEFAULT NULL COMMENT 'Update Time', + PRIMARY KEY (`id`), + UNIQUE KEY `uni_user_subscribe_token` (`token`), + UNIQUE KEY `uni_user_subscribe_uuid` (`uuid`), + KEY `idx_user_id` (`user_id`), + KEY `idx_order_id` (`order_id`), + KEY `idx_subscribe_id` (`subscribe_id`), + KEY `idx_token` (`token`), + KEY `idx_uuid` (`uuid`) +) ENGINE = InnoDB + DEFAULT CHARSET = utf8mb4 + COLLATE = utf8mb4_general_ci; + +CREATE TABLE IF NOT EXISTS `user_subscribe_log` +( + `id` bigint NOT NULL AUTO_INCREMENT, + `user_id` bigint NOT NULL COMMENT 'User ID', + `user_subscribe_id` bigint NOT NULL COMMENT 'User Subscribe ID', + `token` varchar(255) CHARACTER SET utf8mb4 COLLATE utf8mb4_general_ci NOT NULL COMMENT 'Token', + `ip` varchar(255) CHARACTER SET utf8mb4 COLLATE utf8mb4_general_ci NOT NULL COMMENT 'IP', + `user_agent` text CHARACTER SET utf8mb4 COLLATE utf8mb4_general_ci NOT NULL COMMENT 'UserAgent', + `created_at` datetime(3) DEFAULT NULL COMMENT 'Creation Time', + PRIMARY KEY (`id`), + KEY `idx_user_id` (`user_id`), + KEY `idx_user_subscribe_id` (`user_subscribe_id`) +) ENGINE = InnoDB + DEFAULT CHARSET = utf8mb4 + COLLATE = utf8mb4_general_ci; + +CREATE TABLE IF NOT EXISTS `server_rule_group` +( + `id` bigint NOT NULL AUTO_INCREMENT, + `name` varchar(100) CHARACTER SET utf8mb4 COLLATE utf8mb4_general_ci NOT NULL DEFAULT '' COMMENT 'Rule Group Name', + `icon` text CHARACTER SET utf8mb4 COLLATE utf8mb4_general_ci COMMENT 'Rule Group Icon', + `tags` text CHARACTER SET utf8mb4 COLLATE utf8mb4_general_ci COMMENT 'Selected Node Tags', + `description` varchar(255) CHARACTER SET utf8mb4 COLLATE utf8mb4_general_ci DEFAULT '' COMMENT 'Rule Group Description', + `enable` tinyint(1) NOT NULL DEFAULT '1' COMMENT 'Rule Group Enable', + `created_at` datetime(3) DEFAULT NULL COMMENT 'Creation Time', + `updated_at` datetime(3) DEFAULT NULL COMMENT 'Update Time', + PRIMARY KEY (`id`), + UNIQUE KEY `unique_name` (`name`) +) ENGINE = InnoDB + DEFAULT CHARSET = utf8mb4 + COLLATE = utf8mb4_general_ci; + + +SET FOREIGN_KEY_CHECKS = 1; + +-- migrate:down +-- 000001_init_schema.down.sql +SET FOREIGN_KEY_CHECKS = 0; + +DROP TABLE IF EXISTS `user_subscribe_log`; +DROP TABLE IF EXISTS `user_subscribe`; +DROP TABLE IF EXISTS `user_login_log`; +DROP TABLE IF EXISTS `user_gift_amount_log`; +DROP TABLE IF EXISTS `user_device`; +DROP TABLE IF EXISTS `user_commission_log`; +DROP TABLE IF EXISTS `user_balance_log`; +DROP TABLE IF EXISTS `user_auth_methods`; +DROP TABLE IF EXISTS `user`; +DROP TABLE IF EXISTS `traffic_log`; +DROP TABLE IF EXISTS `ticket_follow`; +DROP TABLE IF EXISTS `ticket`; +DROP TABLE IF EXISTS `system`; +DROP TABLE IF EXISTS `subscribe_type`; +DROP TABLE IF EXISTS `subscribe_group`; +DROP TABLE IF EXISTS `subscribe`; +DROP TABLE IF EXISTS `sms`; +DROP TABLE IF EXISTS `server_rule_group`; +DROP TABLE IF EXISTS `server_group`; +DROP TABLE IF EXISTS `server`; +DROP TABLE IF EXISTS `payment`; +DROP TABLE IF EXISTS `order`; +DROP TABLE IF EXISTS `message_log`; +DROP TABLE IF EXISTS `document`; +DROP TABLE IF EXISTS `coupon`; +DROP TABLE IF EXISTS `auth_method`; +DROP TABLE IF EXISTS `application_version`; +DROP TABLE IF EXISTS `application_config`; +DROP TABLE IF EXISTS `application`; +DROP TABLE IF EXISTS `announcement`; +DROP TABLE IF EXISTS `ads`; + +SET FOREIGN_KEY_CHECKS = 1; diff --git a/migrations/mysql/00002_init_basic_data.sql b/migrations/mysql/00002_init_basic_data.sql new file mode 100644 index 00000000..5f267716 --- /dev/null +++ b/migrations/mysql/00002_init_basic_data.sql @@ -0,0 +1,150 @@ +-- migrate:up +-- 000002_init_data.up.sql +SET FOREIGN_KEY_CHECKS = 0; + +-- auth_method +INSERT IGNORE INTO `auth_method` (`id`, `method`, `config`, `enabled`, `created_at`, `updated_at`) +VALUES (1, 'email', + '{"platform":"smtp","platform_config":{"host":"","port":0,"user":"","pass":"","from":"","ssl":false},"enable_verify":false,"enable_notify":false,"enable_domain_suffix":false,"domain_suffix_list":"","verify_email_template":"","expiration_email_template":"","maintenance_email_template":"","traffic_exceed_email_template":""}', + 1, '2025-04-22 14:25:16.642', '2025-04-22 14:25:16.642'), + (2, 'mobile', + '{"platform":"AlibabaCloud","platform_config":{"access":"","secret":"","sign_name":"","endpoint":"","template_code":""},"enable_whitelist":false,"whitelist":[]}', + 0, '2025-04-22 14:25:16.642', '2025-04-22 14:25:16.642'), + (3, 'apple', '{"team_id":"","key_id":"","client_id":"","client_secret":"","redirect_url":""}', 0, + '2025-04-22 14:25:16.642', '2025-04-22 14:25:16.642'), + (4, 'google', '{"client_id":"","client_secret":"","redirect_url":""}', 0, '2025-04-22 14:25:16.642', + '2025-04-22 14:25:16.642'), + (5, 'github', '{"client_id":"","client_secret":"","redirect_url":""}', 0, '2025-04-22 14:25:16.642', + '2025-04-22 14:25:16.642'), + (6, 'facebook', '{"client_id":"","client_secret":"","redirect_url":""}', 0, '2025-04-22 14:25:16.642', + '2025-04-22 14:25:16.642'), + (7, 'telegram', '{"bot_token":"","enable_notify":false,"webhook_domain":""}', 0, '2025-04-22 14:25:16.642', + '2025-04-22 14:25:16.642'), + (8, 'device', '{"show_ads":false,"only_real_device":false,"enable_security":false,"security_secret":""}', 0, + '2025-04-22 14:25:16.642', '2025-04-22 14:25:16.642'); + +-- payment +INSERT IGNORE INTO `payment` (`id`, `name`, `platform`, `description`, `icon`, `domain`, `config`, `fee_mode`, + `fee_percent`, `fee_amount`, `enable`, `token`) +VALUES (-1, 'Balance', 'balance', '', '', '', '', 0, 0, 0, 1, ''); + +-- subscribe_type +INSERT IGNORE INTO `subscribe_type` (`id`, `name`, `mark`, `created_at`, `updated_at`) +VALUES (1, 'Clash', 'Clash', '2025-04-22 14:25:16.648', '2025-04-22 14:25:16.648'), + (2, 'Hiddify', 'Hiddify', '2025-04-22 14:25:16.648', '2025-04-22 14:25:16.648'), + (3, 'Loon', 'Loon', '2025-04-22 14:25:16.648', '2025-04-22 14:25:16.648'), + (4, 'NekoBox', 'NekoBox', '2025-04-22 14:25:16.648', '2025-04-22 14:25:16.648'), + (5, 'NekoRay', 'NekoRay', '2025-04-22 14:25:16.648', '2025-04-22 14:25:16.648'), + (6, 'Netch', 'Netch', '2025-04-22 14:25:16.648', '2025-04-22 14:25:16.648'), + (7, 'Quantumult', 'Quantumult', '2025-04-22 14:25:16.648', '2025-04-22 14:25:16.648'), + (8, 'Shadowrocket', 'Shadowrocket', '2025-04-22 14:25:16.648', '2025-04-22 14:25:16.648'), + (9, 'SingBox', ' SingBox', '2025-04-22 14:25:16.648', '2025-04-22 14:25:16.648'), + (10, 'Surfboard', 'Surfboard', '2025-04-22 14:25:16.648', '2025-04-22 14:25:16.648'), + (11, 'Surge', 'Surge', '2025-04-22 14:25:16.648', '2025-04-22 14:25:16.648'), + (12, 'V2box', 'V2box', '2025-04-22 14:25:16.648', '2025-04-22 14:25:16.648'), + (13, 'V2rayN', 'V2rayN', '2025-04-22 14:25:16.648', '2025-04-22 14:25:16.648'), + (14, 'V2rayNg', 'V2rayNg', '2025-04-22 14:25:16.648', '2025-04-22 14:25:16.648'); + +-- system +INSERT IGNORE INTO `system` (`id`, `category`, `key`, `value`, `type`, `desc`, `created_at`, `updated_at`) +VALUES (1, 'site', 'SiteLogo', '/favicon.svg', 'string', 'Site Logo', '2025-04-22 14:25:16.637', + '2025-04-22 14:25:16.637'), + (2, 'site', 'SiteName', 'Perfect Panel', 'string', 'Site Name', '2025-04-22 14:25:16.637', + '2025-04-22 14:25:16.637'), + (3, 'site', 'SiteDesc', + 'PPanel is a pure, professional, and perfect open-source proxy panel tool, designed to be your ideal choice for learning and practical use.', + 'string', 'Site Description', '2025-04-22 14:25:16.637', '2025-04-22 14:25:16.637'), + (4, 'site', 'Host', '', 'string', 'Site Host', '2025-04-22 14:25:16.637', '2025-04-22 14:25:16.637'), + (5, 'site', 'Keywords', 'Perfect Panel,PPanel', 'string', 'Site Keywords', '2025-04-22 14:25:16.637', + '2025-04-22 14:25:16.637'), + (6, 'site', 'CustomHTML', '', 'string', 'Custom HTML', '2025-04-22 14:25:16.637', '2025-04-22 14:25:16.637'), + (7, 'tos', 'TosContent', 'Welcome to use Perfect Panel', 'string', 'Terms of Service', '2025-04-22 14:25:16.637', + '2025-04-22 14:25:16.637'), + (8, 'tos', 'PrivacyPolicy', '', 'string', 'PrivacyPolicy', '2025-04-22 14:25:16.637', '2025-04-22 14:25:16.637'), + (9, 'ad', 'WebAD', 'false', 'bool', 'Display ad on the web', '2025-04-22 14:25:16.637', + '2025-04-22 14:25:16.637'), + (10, 'subscribe', 'SingleModel', 'false', 'bool', '是否单订阅模式', '2025-04-22 14:25:16.639', + '2025-04-22 14:25:16.639'), + (11, 'subscribe', 'SubscribePath', '/api/subscribe', 'string', '订阅路径', '2025-04-22 14:25:16.639', + '2025-04-22 14:25:16.639'), + (12, 'subscribe', 'SubscribeDomain', '', 'string', '订阅域名', '2025-04-22 14:25:16.639', + '2025-04-22 14:25:16.639'), + (13, 'subscribe', 'PanDomain', 'false', 'bool', '是否使用泛域名', '2025-04-22 14:25:16.639', + '2025-04-22 14:25:16.639'), + (14, 'verify', 'TurnstileSiteKey', '', 'string', 'TurnstileSiteKey', '2025-04-22 14:25:16.639', + '2025-04-22 14:25:16.639'), + (15, 'verify', 'TurnstileSecret', '', 'string', 'TurnstileSecret', '2025-04-22 14:25:16.639', + '2025-04-22 14:25:16.639'), + (16, 'verify', 'EnableLoginVerify', 'false', 'bool', 'is enable login verify', '2025-04-22 14:25:16.639', + '2025-04-22 14:25:16.639'), + (17, 'verify', 'EnableRegisterVerify', 'false', 'bool', 'is enable register verify', '2025-04-22 14:25:16.639', + '2025-04-22 14:25:16.639'), + (18, 'verify', 'EnableResetPasswordVerify', 'false', 'bool', 'is enable reset password verify', + '2025-04-22 14:25:16.639', '2025-04-22 14:25:16.639'), + (19, 'server', 'NodeSecret', '12345678', 'string', 'node secret', '2025-04-22 14:25:16.640', + '2025-04-22 14:25:16.640'), + (20, 'server', 'NodePullInterval', '10', 'int', 'node pull interval', '2025-04-22 14:25:16.640', + '2025-04-22 14:25:16.640'), + (21, 'server', 'NodePushInterval', '60', 'int', 'node push interval', '2025-04-22 14:25:16.640', + '2025-04-22 14:25:16.640'), + (22, 'server', 'NodeMultiplierConfig', '[]', 'string', 'node multiplier config', '2025-04-22 14:25:16.640', + '2025-04-22 14:25:16.640'), + (23, 'invite', 'ForcedInvite', 'false', 'bool', 'Forced invite', '2025-04-22 14:25:16.640', + '2025-04-22 14:25:16.640'), + (24, 'invite', 'ReferralPercentage', '20', 'int', 'Referral percentage', '2025-04-22 14:25:16.640', + '2025-04-22 14:25:16.640'), + (25, 'invite', 'OnlyFirstPurchase', 'false', 'bool', 'Only first purchase', '2025-04-22 14:25:16.640', + '2025-04-22 14:25:16.640'), + (26, 'register', 'StopRegister', 'false', 'bool', 'is stop register', '2025-04-22 14:25:16.640', + '2025-04-22 14:25:16.640'), + (27, 'register', 'EnableTrial', 'false', 'bool', 'is enable trial', '2025-04-22 14:25:16.640', + '2025-04-22 14:25:16.640'), + (28, 'register', 'TrialSubscribe', '', 'int', 'Trial subscription', '2025-04-22 14:25:16.640', + '2025-04-22 14:25:16.640'), + (29, 'register', 'TrialTime', '24', 'int', 'Trial time', '2025-04-22 14:25:16.640', '2025-04-22 14:25:16.640'), + (30, 'register', 'TrialTimeUnit', 'Hour', 'string', 'Trial time unit', '2025-04-22 14:25:16.640', + '2025-04-22 14:25:16.640'), + (31, 'register', 'EnableIpRegisterLimit', 'false', 'bool', 'is enable IP register limit', + '2025-04-22 14:25:16.640', '2025-04-22 14:25:16.640'), + (32, 'register', 'IpRegisterLimit', '3', 'int', 'IP Register Limit', '2025-04-22 14:25:16.640', + '2025-04-22 14:25:16.640'), + (33, 'register', 'IpRegisterLimitDuration', '64', 'int', 'IP Register Limit Duration (minutes)', + '2025-04-22 14:25:16.640', '2025-04-22 14:25:16.640'), + (34, 'currency', 'Currency', 'USD', 'string', 'Currency', '2025-04-22 14:25:16.641', '2025-04-22 14:25:16.641'), + (35, 'currency', 'CurrencySymbol', '$', 'string', 'Currency Symbol', '2025-04-22 14:25:16.641', + '2025-04-22 14:25:16.641'), + (36, 'currency', 'CurrencyUnit', 'USD', 'string', 'Currency Unit', '2025-04-22 14:25:16.641', + '2025-04-22 14:25:16.641'), + (37, 'currency', 'AccessKey', '', 'string', 'Exchangerate Access Key', '2025-04-22 14:25:16.641', + '2025-04-22 14:25:16.641'), + (38, 'verify_code', 'VerifyCodeExpireTime', '300', 'int', 'Verify code expire time', '2025-04-22 14:25:16.641', + '2025-04-22 14:25:16.641'), + (39, 'verify_code', 'VerifyCodeLimit', '15', 'int', 'limits of verify code', '2025-04-22 14:25:16.641', + '2025-04-22 14:25:16.641'), + (40, 'verify_code', 'VerifyCodeInterval', '60', 'int', 'Interval of verify code', '2025-04-22 14:25:16.641', + '2025-04-22 14:25:16.641'), + (41, 'system', 'Version', '0.2.0(02002)', 'string', 'System Version', '2025-04-22 14:25:16.642', + '2025-04-22 14:25:16.642'); +SET FOREIGN_KEY_CHECKS = 1; +-- migrate:down +-- 000002_init_data.down.sql +SET +FOREIGN_KEY_CHECKS = 0; + +DELETE +FROM `auth_method` +WHERE `id` IN (1, 2, 3, 4, 5, 6, 7, 8); +DELETE +FROM `payment` +WHERE `id` = -1; +DELETE +FROM `subscribe_type` +WHERE `id` IN (1, 2, 3, 4, 5, 6, 7, 8, 9, 10, 11, 12, 13, 14); +DELETE +FROM `system` +WHERE `id` IN + (1, 2, 3, 4, 5, 6, 7, 8, 9, 10, 11, 12, 13, 14, 15, 16, 17, 18, 19, 20, 21, 22, 23, 24, 25, 26, 27, 28, 29, 30, + 31, 32, 33, 34, 35, 36, 37, 38, 39, 40, 41); + +SET +FOREIGN_KEY_CHECKS = 1; diff --git a/migrations/mysql/02003_update_payment.sql b/migrations/mysql/02003_update_payment.sql new file mode 100644 index 00000000..c81b2aa0 --- /dev/null +++ b/migrations/mysql/02003_update_payment.sql @@ -0,0 +1,146 @@ +-- migrate:up +-- 2025-04-22 16:16:00 +-- Purpose: Update payment table +-- Author: PPanel Team, 2025-04-21 + +SET FOREIGN_KEY_CHECKS = 0; + +-- Alter the order table to add a payment_id column (if not exists) +SET @column_exists = (SELECT COUNT(*) + FROM INFORMATION_SCHEMA.COLUMNS + WHERE TABLE_SCHEMA = DATABASE() + AND TABLE_NAME = 'order' + AND COLUMN_NAME = 'payment_id'); +SET @sql = IF(@column_exists = 0, + 'ALTER TABLE `order` ADD COLUMN `payment_id` bigint NOT NULL DEFAULT \'-1\' COMMENT \'Payment Id\' AFTER `commission`', + 'SELECT 1'); +PREPARE stmt FROM @sql; +EXECUTE stmt; +DEALLOCATE PREPARE stmt; + +-- Alter the payment table to add a platform column (if not exists) +SET @column_exists = (SELECT COUNT(*) + FROM INFORMATION_SCHEMA.COLUMNS + WHERE TABLE_SCHEMA = DATABASE() + AND TABLE_NAME = 'payment' + AND COLUMN_NAME = 'platform'); +SET @sql = IF(@column_exists = 0, + 'ALTER TABLE `payment` ADD COLUMN `platform` varchar(100) CHARACTER SET utf8mb4 COLLATE utf8mb4_general_ci NOT NULL COMMENT \'Payment Platform\' AFTER `name`', + 'SELECT 1'); +PREPARE stmt FROM @sql; +EXECUTE stmt; +DEALLOCATE PREPARE stmt; + +-- Drop the mark column from the payment table (only if exists) +SET @column_exists = (SELECT COUNT(*) + FROM INFORMATION_SCHEMA.COLUMNS + WHERE TABLE_SCHEMA = DATABASE() + AND TABLE_NAME = 'payment' + AND COLUMN_NAME = 'mark'); +SET @sql = IF(@column_exists > 0, + 'ALTER TABLE `payment` DROP COLUMN `mark`', + 'SELECT 1'); +PREPARE stmt FROM @sql; +EXECUTE stmt; +DEALLOCATE PREPARE stmt; + +-- Alter the payment table to add a description column (if not exists) +SET @column_exists = (SELECT COUNT(*) + FROM INFORMATION_SCHEMA.COLUMNS + WHERE TABLE_SCHEMA = DATABASE() + AND TABLE_NAME = 'payment' + AND COLUMN_NAME = 'description'); +SET @sql = IF(@column_exists = 0, + 'ALTER TABLE `payment` ADD COLUMN `description` text CHARACTER SET utf8mb4 COLLATE utf8mb4_general_ci COMMENT \'Payment Description\' AFTER `platform`', + 'SELECT 1'); +PREPARE stmt FROM @sql; +EXECUTE stmt; +DEALLOCATE PREPARE stmt; + +-- Alter the payment table to add a token column (if not exists) +SET @column_exists = (SELECT COUNT(*) + FROM INFORMATION_SCHEMA.COLUMNS + WHERE TABLE_SCHEMA = DATABASE() + AND TABLE_NAME = 'payment' + AND COLUMN_NAME = 'token'); +SET @sql = IF(@column_exists = 0, + 'ALTER TABLE `payment` ADD COLUMN `token` varchar(255) CHARACTER SET utf8mb4 COLLATE utf8mb4_general_ci DEFAULT NULL COMMENT \'Payment Token\' AFTER `description`', + 'SELECT 1'); +PREPARE stmt FROM @sql; +EXECUTE stmt; +DEALLOCATE PREPARE stmt; + +SET FOREIGN_KEY_CHECKS = 1; +-- migrate:down +-- migrations/02003_update_payment.down.sql +-- Purpose: Revert updates to payment and order tables +-- Author: PPanel Team, 2025-04-21 + +SET FOREIGN_KEY_CHECKS = 0; + +-- Drop payment_id column from order table (if exists) +SET @column_exists = (SELECT COUNT(*) + FROM INFORMATION_SCHEMA.COLUMNS + WHERE TABLE_SCHEMA = DATABASE() + AND TABLE_NAME = 'order' + AND COLUMN_NAME = 'payment_id'); +SET @sql = IF(@column_exists > 0, + 'ALTER TABLE `order` DROP COLUMN `payment_id`', + 'SELECT 1'); +PREPARE stmt FROM @sql; +EXECUTE stmt; +DEALLOCATE PREPARE stmt; + +-- Drop platform column from payment table (if exists) +SET @column_exists = (SELECT COUNT(*) + FROM INFORMATION_SCHEMA.COLUMNS + WHERE TABLE_SCHEMA = DATABASE() + AND TABLE_NAME = 'payment' + AND COLUMN_NAME = 'platform'); +SET @sql = IF(@column_exists > 0, + 'ALTER TABLE `payment` DROP COLUMN `platform`', + 'SELECT 1'); +PREPARE stmt FROM @sql; +EXECUTE stmt; +DEALLOCATE PREPARE stmt; + +-- Drop description column from payment table (if exists) +SET @column_exists = (SELECT COUNT(*) + FROM INFORMATION_SCHEMA.COLUMNS + WHERE TABLE_SCHEMA = DATABASE() + AND TABLE_NAME = 'payment' + AND COLUMN_NAME = 'description'); +SET @sql = IF(@column_exists > 0, + 'ALTER TABLE `payment` DROP COLUMN `description`', + 'SELECT 1'); +PREPARE stmt FROM @sql; +EXECUTE stmt; +DEALLOCATE PREPARE stmt; + +-- Drop token column from payment table (if exists) +SET @column_exists = (SELECT COUNT(*) + FROM INFORMATION_SCHEMA.COLUMNS + WHERE TABLE_SCHEMA = DATABASE() + AND TABLE_NAME = 'payment' + AND COLUMN_NAME = 'token'); +SET @sql = IF(@column_exists > 0, + 'ALTER TABLE `payment` DROP COLUMN `token`', + 'SELECT 1'); +PREPARE stmt FROM @sql; +EXECUTE stmt; +DEALLOCATE PREPARE stmt; + +-- Optionally restore mark column (if needed, adjust definition as per original schema) +SET @column_exists = (SELECT COUNT(*) + FROM INFORMATION_SCHEMA.COLUMNS + WHERE TABLE_SCHEMA = DATABASE() + AND TABLE_NAME = 'payment' + AND COLUMN_NAME = 'mark'); +SET @sql = IF(@column_exists = 0, + 'ALTER TABLE `payment` ADD COLUMN `mark` varchar(255) CHARACTER SET utf8mb4 COLLATE utf8mb4_general_ci DEFAULT NULL COMMENT \'Payment Mark\' AFTER `name`', + 'SELECT 1'); +PREPARE stmt FROM @sql; +EXECUTE stmt; +DEALLOCATE PREPARE stmt; + +SET FOREIGN_KEY_CHECKS = 1; diff --git a/migrations/mysql/02004_rebuild_rule.sql b/migrations/mysql/02004_rebuild_rule.sql new file mode 100644 index 00000000..2d2c5d45 --- /dev/null +++ b/migrations/mysql/02004_rebuild_rule.sql @@ -0,0 +1,28 @@ +-- migrate:up +-- migrations/02003_rebuild_rule.up.sql +-- Purpose: rebuilding server rule table +-- Author: PPanel Team, 2025-04-21 + +DROP TABLE IF EXISTS `server_rule_group`; + +CREATE TABLE `server_rule_group` +( + `id` BIGINT UNSIGNED NOT NULL AUTO_INCREMENT, + `name` VARCHAR(64) CHARACTER SET utf8mb4 COLLATE utf8mb4_general_ci NOT NULL DEFAULT '' COMMENT 'Rule Group Name', + `icon` VARCHAR(255) CHARACTER SET utf8mb4 COLLATE utf8mb4_general_ci COMMENT 'Rule Group Icon', + `tags` TEXT CHARACTER SET utf8mb4 COLLATE utf8mb4_general_ci COMMENT 'Selected Node Tags', + `rules` MEDIUMTEXT CHARACTER SET utf8mb4 COLLATE utf8mb4_general_ci COMMENT 'Rules', + `enable` TINYINT(1) NOT NULL DEFAULT 1 COMMENT 'Rule Group Enable', + `created_at` DATETIME(3) COMMENT 'Creation Time', + `updated_at` DATETIME(3) COMMENT 'Update Time', + PRIMARY KEY (`id`), + UNIQUE KEY `uni_server_rule_group_name` (`name`), + INDEX `idx_enable` (`enable`) +) ENGINE = InnoDB + DEFAULT CHARSET = utf8mb4 + COLLATE = utf8mb4_general_ci; +-- migrate:down +-- migrations/02003_rebuild_rule.up.sql +-- Purpose: Back rebuilding server rule table +-- Author: PPanel Team, 2025-04-21 +DROP TABLE IF EXISTS server_rule_group; diff --git a/migrations/mysql/02005_device_online_record.sql b/migrations/mysql/02005_device_online_record.sql new file mode 100644 index 00000000..2e2d61f3 --- /dev/null +++ b/migrations/mysql/02005_device_online_record.sql @@ -0,0 +1,124 @@ +-- migrate:up +-- migrations/02005_create_user_device_online_record.up.sql +-- Purpose: Create table for tracking user device online records +-- Author: PPanel Team, 2025-04-22 + +CREATE TABLE IF NOT EXISTS `user_device_online_record` +( + `id` BIGINT NOT NULL AUTO_INCREMENT PRIMARY KEY, + `user_id` BIGINT NOT NULL COMMENT 'User ID', + `identifier` VARCHAR(255) NOT NULL COMMENT 'Device Identifier', + `online_time` DATETIME COMMENT 'Online Time', + `offline_time` DATETIME COMMENT 'Offline Time', + `online_seconds` BIGINT COMMENT 'Offline Seconds', + `duration_days` BIGINT COMMENT 'Duration Days', + `created_at` DATETIME COMMENT 'Creation Time' +) ENGINE = InnoDB + DEFAULT CHARSET = utf8mb4 + COLLATE = utf8mb4_general_ci; + + +-- User subscribe table migration for adding finished_at column + +SET @column_exists = (SELECT COUNT(*) + FROM INFORMATION_SCHEMA.COLUMNS + WHERE TABLE_SCHEMA = DATABASE() + AND TABLE_NAME = 'user_subscribe' + AND COLUMN_NAME = 'finished_at'); + +SET @sql = IF(@column_exists = 0, + 'ALTER TABLE `user_subscribe` ADD COLUMN `finished_at` DATETIME NULL COMMENT ''Subscribe Finished Time'' AFTER `expire_time`', + 'SELECT 1' + ); + +PREPARE stmt FROM @sql; +EXECUTE stmt; +DEALLOCATE PREPARE stmt; + + +-- Application config table migration for adding Link column + +SET @column_exists = (SELECT COUNT(*) + FROM INFORMATION_SCHEMA.COLUMNS + WHERE TABLE_SCHEMA = DATABASE() + AND TABLE_NAME = 'application_config' + AND COLUMN_NAME = 'invitation_link'); + +SET @sql = IF(@column_exists = 0, + 'ALTER TABLE `application_config` ADD COLUMN `invitation_link` TEXT NULL DEFAULT NULL COMMENT ''Invitation Link'' AFTER `startup_picture_skip_time`', + 'SELECT 1' + ); + +PREPARE stmt FROM @sql; +EXECUTE stmt; +DEALLOCATE PREPARE stmt; + +-- Application config table migration for adding kr_website_id column +SET @column_exists = (SELECT COUNT(*) + FROM INFORMATION_SCHEMA.COLUMNS + WHERE TABLE_SCHEMA = DATABASE() + AND TABLE_NAME = 'application_config' + AND COLUMN_NAME = 'kr_website_id'); + +SET @sql = IF(@column_exists = 0, + 'ALTER TABLE `application_config` ADD COLUMN `kr_website_id` VARCHAR(255) NULL DEFAULT NULL COMMENT ''KR Website ID'' AFTER `invitation_link`', + 'SELECT 1' + ); + +PREPARE stmt FROM @sql; +EXECUTE stmt; +DEALLOCATE PREPARE stmt; + +-- migrate:down +-- migrations/02004_create_user_device_online_record.down.sql +-- Purpose: Drop user device online record table +-- Author: PPanel Team, 2025-04-22 + +DROP TABLE IF EXISTS `user_device_online_record`; + +-- User subscribe table migration for removing finished_at column +SET @column_exists = (SELECT COUNT(*) + FROM INFORMATION_SCHEMA.COLUMNS + WHERE TABLE_SCHEMA = DATABASE() + AND TABLE_NAME = 'user_subscribe' + AND COLUMN_NAME = 'finished_at'); +SET @sql = IF(@column_exists > 0, + 'ALTER TABLE `user_subscribe` DROP COLUMN `finished_at`', + 'SELECT 1' + ); +PREPARE stmt FROM @sql; +EXECUTE stmt; +DEALLOCATE PREPARE stmt; + +-- Application config table migration for removing invitation_link column + +SET @column_exists = (SELECT COUNT(*) + FROM INFORMATION_SCHEMA.COLUMNS + WHERE TABLE_SCHEMA = DATABASE() + AND TABLE_NAME = 'application_config' + AND COLUMN_NAME = 'invitation_link'); + +SET @sql = IF(@column_exists > 0, + 'ALTER TABLE `application_config` DROP COLUMN `invitation_link`', + 'SELECT 1' + ); + +PREPARE stmt FROM @sql; +EXECUTE stmt; +DEALLOCATE PREPARE stmt; + +-- Application config table migration for removing kr_website_id column +SET @column_exists = (SELECT COUNT(*) + FROM INFORMATION_SCHEMA.COLUMNS + WHERE TABLE_SCHEMA = DATABASE() + AND TABLE_NAME = 'application_config' + AND COLUMN_NAME = 'kr_website_id'); + +SET @sql = IF(@column_exists > 0, + 'ALTER TABLE `application_config` DROP COLUMN `kr_website_id`', + 'SELECT 1' + ); + +PREPARE stmt FROM @sql; +EXECUTE stmt; +DEALLOCATE PREPARE stmt; diff --git a/migrations/mysql/02006_reset_subscribe_record.sql b/migrations/mysql/02006_reset_subscribe_record.sql new file mode 100644 index 00000000..cbf89311 --- /dev/null +++ b/migrations/mysql/02006_reset_subscribe_record.sql @@ -0,0 +1,26 @@ +-- migrate:up +-- migrations/02008_create_user_reset_subscribe_log.up.sql +-- Purpose: Create user_reset_subscribe_log table +-- Author: PPanel Team, 2025-04-22 + +CREATE TABLE IF NOT EXISTS `user_reset_subscribe_log` +( + `id` BIGINT NOT NULL AUTO_INCREMENT PRIMARY KEY, + `user_id` BIGINT NOT NULL COMMENT 'User ID', + `type` TINYINT(1) NOT NULL COMMENT 'Type: 1: Auto 2: Advance 3: Paid', + `order_no` VARCHAR(255) DEFAULT NULL COMMENT 'Order No.', + `user_subscribe_id` BIGINT NOT NULL COMMENT 'User Subscribe ID', + `created_at` DATETIME NOT NULL DEFAULT CURRENT_TIMESTAMP COMMENT 'Creation Time', + INDEX `idx_user_id` (`user_id`), + INDEX `idx_user_subscribe_id` (`user_subscribe_id`) +) ENGINE = InnoDB + DEFAULT CHARSET = utf8mb4 + COLLATE = utf8mb4_general_ci; + +-- migrate:down +-- migrations/02008_create_user_reset_subscribe_log.down.sql +-- Purpose: Drop user_reset_subscribe_log table +-- Author: PPanel Team, 2025-04-22 + +DROP TABLE IF EXISTS `user_reset_subscribe_log`; + diff --git a/migrations/mysql/02007_adapte_rule.sql b/migrations/mysql/02007_adapte_rule.sql new file mode 100644 index 00000000..32e22700 --- /dev/null +++ b/migrations/mysql/02007_adapte_rule.sql @@ -0,0 +1,9 @@ +-- migrate:up +ALTER TABLE `server_rule_group` +ADD COLUMN `default` TINYINT(1) NOT NULL DEFAULT 0 COMMENT 'Is Default Group', +ADD COLUMN `type` VARCHAR(100) NOT NULL DEFAULT '' COMMENT 'Rule Group Type'; +-- migrate:down +ALTER TABLE `server_rule_group` +DROP COLUMN `default`, +DROP COLUMN `type`; + diff --git a/migrations/mysql/02100_task.sql b/migrations/mysql/02100_task.sql new file mode 100644 index 00000000..6a89211b --- /dev/null +++ b/migrations/mysql/02100_task.sql @@ -0,0 +1,27 @@ +-- migrate:up +DROP TABLE IF EXISTS `email_task`; +CREATE TABLE `email_task` ( + `id` bigint NOT NULL AUTO_INCREMENT COMMENT 'ID', + `subject` varchar(255) COLLATE utf8mb4_general_ci NOT NULL COMMENT 'Email Subject', + `content` text COLLATE utf8mb4_general_ci NOT NULL COMMENT 'Email Content', + `recipient` text COLLATE utf8mb4_general_ci NOT NULL COMMENT 'Email Recipient', + `scope` varchar(50) COLLATE utf8mb4_general_ci NOT NULL COMMENT 'Email Scope', + `register_start_time` datetime(3) DEFAULT NULL COMMENT 'Register Start Time', + `register_end_time` datetime(3) DEFAULT NULL COMMENT 'Register End Time', + `additional` text COLLATE utf8mb4_general_ci COMMENT 'Additional Information', + `scheduled` datetime(3) NOT NULL COMMENT 'Scheduled Time', + `interval` tinyint unsigned NOT NULL COMMENT 'Interval in Seconds', + `limit` bigint unsigned NOT NULL COMMENT 'Daily send limit', + `status` tinyint unsigned NOT NULL COMMENT 'Daily Status', + `errors` text COLLATE utf8mb4_general_ci NOT NULL COMMENT 'Errors', + `total` bigint unsigned NOT NULL DEFAULT '0' COMMENT 'Total Number', + `current` bigint unsigned NOT NULL DEFAULT '0' COMMENT 'Current Number', + `created_at` datetime(3) DEFAULT NULL COMMENT 'Creation Time', + `updated_at` datetime(3) DEFAULT NULL COMMENT 'Update Time', + PRIMARY KEY (`id`) +) ENGINE=InnoDB DEFAULT CHARSET=utf8mb4 COLLATE=utf8mb4_general_ci; + +SET FOREIGN_KEY_CHECKS = 1; + +-- migrate:down +DROP TABLE IF EXISTS `email_task`; diff --git a/migrations/mysql/02101_subscribe_application.sql b/migrations/mysql/02101_subscribe_application.sql new file mode 100644 index 00000000..be8c4d5a --- /dev/null +++ b/migrations/mysql/02101_subscribe_application.sql @@ -0,0 +1,31 @@ +-- migrate:up +DROP TABLE IF EXISTS `subscribe_application`; +CREATE TABLE IF NOT EXISTS `subscribe_application` ( + `id` bigint NOT NULL AUTO_INCREMENT, + `name` varchar(255) CHARACTER SET utf8mb4 COLLATE utf8mb4_general_ci NOT NULL DEFAULT '' COMMENT 'Application Name', + `icon` mediumtext CHARACTER SET utf8mb4 COLLATE utf8mb4_general_ci COMMENT 'Application Icon', + `description` varchar(255) CHARACTER SET utf8mb4 COLLATE utf8mb4_general_ci DEFAULT NULL COMMENT 'Application Description', + `scheme` varchar(255) CHARACTER SET utf8mb4 COLLATE utf8mb4_general_ci NOT NULL DEFAULT '' COMMENT 'Application Scheme', + `user_agent` varchar(255) CHARACTER SET utf8mb4 COLLATE utf8mb4_general_ci NOT NULL DEFAULT '' COMMENT 'User Agent', + `is_default` tinyint(1) NOT NULL DEFAULT '0' COMMENT 'Is Default Application', + `subscribe_template` mediumtext CHARACTER SET utf8mb4 COLLATE utf8mb4_general_ci COMMENT 'Subscribe Template', + `output_format` varchar(50) CHARACTER SET utf8mb4 COLLATE utf8mb4_general_ci NOT NULL DEFAULT 'yaml' COMMENT 'Output Format', + `download_link` text CHARACTER SET utf8mb4 COLLATE utf8mb4_general_ci NOT NULL COMMENT 'Download Link', + `created_at` datetime(3) DEFAULT NULL COMMENT 'Create Time', + `updated_at` datetime(3) DEFAULT NULL COMMENT 'Update Time', + PRIMARY KEY (`id`) +) ENGINE=InnoDB AUTO_INCREMENT=7 DEFAULT CHARSET=utf8mb4 COLLATE=utf8mb4_general_ci; + +-- ---------------------------- +-- Records of subscribe_application +-- ---------------------------- +BEGIN; +INSERT INTO `subscribe_application` (`id`, `name`, `icon`, `description`, `scheme`, `user_agent`, `is_default`, `subscribe_template`, `output_format`, `download_link`, `created_at`, `updated_at`) VALUES (1, 'Default', '', '', '', 'default', 1, '{{- $GiB := 1073741824.0 -}}\n{{- $used := printf \"%.2f\" (divf (add (.UserInfo.Download | default 0 | float64) (.UserInfo.Upload | default 0 | float64)) $GiB) -}}\n{{- $traffic := (.UserInfo.Traffic | default 0 | float64) -}}\n{{- $total := printf \"%.2f\" (divf $traffic $GiB) -}}\n\n{{- $ExpiredAt := \"\" -}}\n{{- $expStr := printf \"%v\" .UserInfo.ExpiredAt -}}\n{{- if regexMatch `^[0-9]+$` $expStr -}}\n {{- $ts := $expStr | float64 -}}\n {{- $sec := ternary (divf $ts 1000.0) $ts (ge (len $expStr) 13) -}}\n {{- $ExpiredAt = (date \"2006-01-02 15:04:05\" (unixEpoch ($sec | int64))) -}}\n{{- else -}}\n {{- $ExpiredAt = $expStr -}}\n{{- end -}}\n\n{{- $sortFields := list \"Sort\" \"Port\" \"Name\" -}}\n{{- $sortConfig := dict \"Sort\" \"asc\" \"Port\" \"asc\" \"Name\" \"asc\" -}}\n{{- $byKey := dict -}}\n{{- range $p := .Proxies -}}\n {{- $keyParts := list -}}\n {{- range $field := $sortFields -}}\n {{- $order := index $sortConfig $field -}}\n {{- $val := default \"\" (printf \"%v\" (index $p $field)) -}}\n {{- if or (eq $field \"Sort\") (eq $field \"Port\") -}}\n {{- $val = printf \"%08d\" (int (default 0 (index $p $field))) -}}\n {{- end -}}\n {{- if eq $order \"desc\" -}}\n {{- $val = printf \"~%s\" $val -}}\n {{- end -}}\n {{- $keyParts = append $keyParts $val -}}\n {{- end -}}\n {{- $_ := set $byKey (join \"|\" $keyParts) $p -}}\n{{- end -}}\n{{- $sorted := list -}}\n{{- range $k := sortAlpha (keys $byKey) -}}\n {{- $sorted = append $sorted (index $byKey $k) -}}\n{{- end -}}\n\n{{- $supportSet := dict \"shadowsocks\" true \"vmess\" true \"vless\" true \"trojan\" true \"hysteria2\" true \"hysteria\" true \"tuic\" true \"anytls\" true -}}\n{{- $supportedProxies := list -}}\n{{- range $proxy := $sorted -}}\n {{- if hasKey $supportSet $proxy.Type -}}\n {{- $supportedProxies = append $supportedProxies $proxy -}}\n {{- end -}}\n{{- end -}}\n\nREMARKS={{ .SiteName }}-{{ .SubscribeName }}\nSTATUS=Traffic: {{ $used }} GiB/{{ $total }} GiB | Expires: {{ $ExpiredAt }}\n# Generated at: {{ now | date \"2006-01-02 15:04:05\\n\" }}\n\n{{- range $proxy := $supportedProxies }}\n {{- $common := \"udp=1&tfo=1\" -}}\n\n {{- $server := $proxy.Server -}}\n {{- if and (contains $server \":\") (not (hasPrefix \"[\" $server)) -}}\n {{- $server = printf \"[%s]\" $server -}}\n {{- end -}}\n\n {{- $password := $.UserInfo.Password -}}\n {{- if and (eq $proxy.Type \"shadowsocks\") (ne (default \"\" $proxy.ServerKey) \"\") -}}\n {{- $method := $proxy.Method -}}\n {{- if or (hasPrefix \"2022-blake3-\" $method) (eq $method \"2022-blake3-aes-128-gcm\") (eq $method \"2022-blake3-aes-256-gcm\") -}}\n {{- $userKeyLen := ternary 16 32 (hasSuffix \"128-gcm\" $method) -}}\n {{- $pwdStr := printf \"%s\" $password -}}\n {{- $userKey := ternary $pwdStr (trunc $userKeyLen $pwdStr) (le (len $pwdStr) $userKeyLen) -}}\n {{- $serverB64 := b64enc $proxy.ServerKey -}}\n {{- $userB64 := b64enc $userKey -}}\n {{- $password = printf \"%s:%s\" $serverB64 $userB64 -}}\n {{- end -}}\n {{- end -}}\n\n {{- $SkipVerify := $proxy.AllowInsecure -}}\n\n {{- /* 公共传输层配置函数 */ -}}\n {{- $buildTransportParams := dict -}}\n {{- $transport := default \"tcp\" $proxy.Transport -}}\n {{- if ne $transport \"\" -}}\n {{- $_ := set $buildTransportParams \"type\" (ternary \"ws\" $transport (eq $transport \"websocket\")) -}}\n {{- end -}}\n {{- /* TCP 传输类型配置 */ -}}\n {{- if eq $transport \"tcp\" -}}\n {{- $headerType := default \"none\" $proxy.HeaderType -}}\n {{- if ne $headerType \"none\" -}}\n {{- $_ := set $buildTransportParams \"headerType\" $headerType -}}\n {{- end -}}\n {{- if and (eq $headerType \"http\") (ne (default \"\" $proxy.Host) \"\") -}}\n {{- $_ := set $buildTransportParams \"host\" $proxy.Host -}}\n {{- end -}}\n {{- if and (eq $headerType \"http\") (ne (default \"\" $proxy.Path) \"\") -}}\n {{- $_ := set $buildTransportParams \"path\" ($proxy.Path | urlquery) -}}\n {{- end -}}\n {{- end -}}\n {{- /* WebSocket/xhttp/httpupgrade 传输类型配置 */ -}}\n {{- if and (or (eq $transport \"ws\") (eq $transport \"websocket\") (eq $transport \"xhttp\") (eq $transport \"httpupgrade\")) (ne (default \"\" $proxy.Host) \"\") -}}\n {{- $_ := set $buildTransportParams \"host\" $proxy.Host -}}\n {{- end -}}\n {{- if and (or (eq $transport \"ws\") (eq $transport \"websocket\") (eq $transport \"xhttp\") (eq $transport \"httpupgrade\")) (ne (default \"\" $proxy.Path) \"\") -}}\n {{- $_ := set $buildTransportParams \"path\" ($proxy.Path | urlquery) -}}\n {{- end -}}\n {{- /* gRPC 传输类型配置 */ -}}\n {{- if and (eq $transport \"grpc\") (ne (default \"\" $proxy.ServiceName) \"\") -}}\n {{- $_ := set $buildTransportParams \"serviceName\" $proxy.ServiceName -}}\n {{- end -}}\n {{- /* xhttp 特有配置 */ -}}\n {{- if and (eq $transport \"xhttp\") (ne (default \"\" $proxy.XhttpMode) \"\") -}}\n {{- $_ := set $buildTransportParams \"mode\" $proxy.XhttpMode -}}\n {{- end -}}\n {{- if and (eq $transport \"xhttp\") (ne (default \"\" $proxy.XhttpExtra) \"\") -}}\n {{- $_ := set $buildTransportParams \"extra\" (urlquery $proxy.XhttpExtra) -}}\n {{- end -}}\n\n {{- /* 公共安全层配置 */ -}}\n {{- $buildSecurityParams := dict -}}\n {{- if or (eq $proxy.Security \"tls\") (eq $proxy.Security \"reality\") -}}\n {{- $_ := set $buildSecurityParams \"security\" $proxy.Security -}}\n {{- end -}}\n {{- if ne (default \"\" $proxy.SNI) \"\" -}}\n {{- $_ := set $buildSecurityParams \"sni\" $proxy.SNI -}}\n {{- end -}}\n {{- if $SkipVerify -}}\n {{- $_ := set $buildSecurityParams \"allowInsecure\" \"1\" -}}\n {{- end -}}\n {{- if ne (default \"\" $proxy.Fingerprint) \"\" -}}\n {{- $_ := set $buildSecurityParams \"fp\" $proxy.Fingerprint -}}\n {{- end -}}\n {{- if and (eq $proxy.Security \"reality\") (ne (default \"\" $proxy.RealityPublicKey) \"\") -}}\n {{- $_ := set $buildSecurityParams \"pbk\" $proxy.RealityPublicKey -}}\n {{- end -}}\n {{- if and (eq $proxy.Security \"reality\") (ne (default \"\" $proxy.RealityShortId) \"\") -}}\n {{- $_ := set $buildSecurityParams \"sid\" $proxy.RealityShortId -}}\n {{- end -}}\n {{- if $proxy.EchEnable -}}\n {{- $_ := set $buildSecurityParams \"ech\" (printf \"%s+udp://1.1.1.1\" (default \"\" $proxy.EchServerName) | urlquery) -}}\n {{- end -}}\n\n {{- if eq $proxy.Type \"shadowsocks\" }}\n {{- $params := list -}}\n {{- /* Shadowsocks 特有的 obfs 插件参数 */ -}}\n {{- if ne (default \"\" $proxy.Obfs) \"\" -}}\n {{- $params = append $params (printf \"obfs=%s\" $proxy.Obfs) -}}\n {{- end -}}\n {{- if ne (default \"\" $proxy.ObfsHost) \"\" -}}\n {{- $params = append $params (printf \"obfs-host=%s\" $proxy.ObfsHost) -}}\n {{- end -}}\n {{- if ne (default \"\" $proxy.ObfsPath) \"\" -}}\n {{- $params = append $params (printf \"obfs-uri=%s\" ($proxy.ObfsPath | urlquery)) -}}\n {{- end -}}\n {{- /* 使用公共传输层配置 */ -}}\n {{- range $key, $val := $buildTransportParams -}}\n {{- $params = append $params (printf \"%s=%s\" $key $val) -}}\n {{- end -}}\n {{- /* 使用公共安全层配置 */ -}}\n {{- range $key, $val := $buildSecurityParams -}}\n {{- $params = append $params (printf \"%s=%s\" $key $val) -}}\n {{- end -}}\n {{- /* 添加公共参数 */ -}}\n {{- $params = append $params $common }}\nss://{{ printf \"%s:%s\" (default \"aes-128-gcm\" $proxy.Method) $password | b64enc }}@{{ $server }}:{{ $proxy.Port }}?{{ join \"&\" $params }}#{{ $proxy.Name }}\n {{- else if eq $proxy.Type \"vmess\" }}\n {{- $vmessDict := dict \"v\" \"2\" \"ps\" $proxy.Name \"add\" $proxy.Server \"port\" (printf \"%d\" $proxy.Port) \"id\" $password \"aid\" \"0\" \"net\" \"tcp\" \"type\" \"none\" -}}\n {{- if hasKey $buildTransportParams \"type\" -}}\n {{- $_ := set $vmessDict \"net\" (index $buildTransportParams \"type\") -}}\n {{- end -}}\n {{- if hasKey $buildTransportParams \"host\" -}}\n {{- $_ := set $vmessDict \"host\" (index $buildTransportParams \"host\") -}}\n {{- end -}}\n {{- if hasKey $buildTransportParams \"path\" -}}\n {{- $_ := set $vmessDict \"path\" (index $buildTransportParams \"path\") -}}\n {{- end -}}\n {{- if and (eq $transport \"grpc\") (hasKey $buildTransportParams \"serviceName\") -}}\n {{- $_ := set $vmessDict \"path\" (index $buildTransportParams \"serviceName\") -}}\n {{- end -}}\n {{- if hasKey $buildTransportParams \"mode\" -}}\n {{- $_ := set $vmessDict \"xhttpMode\" (index $buildTransportParams \"mode\") -}}\n {{- end -}}\n {{- if hasKey $buildTransportParams \"extra\" -}}\n {{- $_ := set $vmessDict \"xhttpExtra\" (index $buildTransportParams \"extra\") -}}\n {{- end -}}\n {{- if hasKey $buildSecurityParams \"security\" -}}\n {{- $_ := set $vmessDict \"tls\" (index $buildSecurityParams \"security\") -}}\n {{- end -}}\n {{- if hasKey $buildSecurityParams \"sni\" -}}\n {{- $_ := set $vmessDict \"sni\" (index $buildSecurityParams \"sni\") -}}\n {{- end -}}\n {{- if hasKey $buildSecurityParams \"fp\" -}}\n {{- $_ := set $vmessDict \"fp\" (index $buildSecurityParams \"fp\") -}}\n {{- end -}}\n {{- if hasKey $buildSecurityParams \"allowInsecure\" -}}\n {{- $_ := set $vmessDict \"skip-cert-verify\" true -}}\n {{- end -}}\n {{- if hasKey $buildSecurityParams \"ech\" -}}\n {{- $_ := set $vmessDict \"ech\" (index $buildSecurityParams \"ech\") -}}\n {{- end }}\nvmess://{{ $vmessDict | toJson | b64enc }}\n {{- else if eq $proxy.Type \"vless\" }}\n {{- $params := list -}}\n {{- /* 1. Encryption 加密参数 */ -}}\n {{- $encryption := default \"none\" $proxy.Encryption -}}\n {{- if eq $encryption \"none\" -}}\n {{- $params = append $params \"encryption=none\" -}}\n {{- else -}}\n {{- $encParts := list -}}\n {{- $encParts = append $encParts $encryption -}}\n {{- if ne (default \"\" $proxy.EncryptionMode) \"\" -}}\n {{- $encParts = append $encParts $proxy.EncryptionMode -}}\n {{- end -}}\n {{- if ne (default \"\" $proxy.EncryptionRtt) \"\" -}}\n {{- $encParts = append $encParts $proxy.EncryptionRtt -}}\n {{- end -}}\n {{- if ne (default \"\" $proxy.EncryptionClientPadding) \"\" -}}\n {{- $encParts = append $encParts $proxy.EncryptionClientPadding -}}\n {{- end -}}\n {{- if ne (default \"\" $proxy.EncryptionPassword) \"\" -}}\n {{- $encParts = append $encParts $proxy.EncryptionPassword -}}\n {{- end -}}\n {{- $params = append $params (printf \"encryption=%s\" (join \".\" $encParts)) -}}\n {{- end -}}\n {{- /* 2. Flow 流控参数 */ -}}\n {{- if and (ne (default \"\" $proxy.Flow) \"\") (ne $proxy.Flow \"none\") -}}\n {{- $params = append $params (printf \"flow=%s\" $proxy.Flow) -}}\n {{- end -}}\n {{- /* 3. Security 安全参数 */ -}}\n {{- if hasKey $buildSecurityParams \"security\" -}}\n {{- $params = append $params (printf \"security=%s\" (index $buildSecurityParams \"security\")) -}}\n {{- end -}}\n {{- if hasKey $buildSecurityParams \"sni\" -}}\n {{- $params = append $params (printf \"sni=%s\" (index $buildSecurityParams \"sni\")) -}}\n {{- end -}}\n {{- if hasKey $buildSecurityParams \"fp\" -}}\n {{- $params = append $params (printf \"fp=%s\" (index $buildSecurityParams \"fp\")) -}}\n {{- end -}}\n {{- if hasKey $buildSecurityParams \"allowInsecure\" -}}\n {{- $params = append $params \"allowInsecure=1\" -}}\n {{- end -}}\n {{- if hasKey $buildSecurityParams \"pbk\" -}}\n {{- $params = append $params (printf \"pbk=%s\" (index $buildSecurityParams \"pbk\")) -}}\n {{- end -}}\n {{- if hasKey $buildSecurityParams \"sid\" -}}\n {{- $params = append $params (printf \"sid=%s\" (index $buildSecurityParams \"sid\")) -}}\n {{- end -}}\n {{- if hasKey $buildSecurityParams \"ech\" -}}\n {{- $params = append $params (printf \"ech=%s\" (index $buildSecurityParams \"ech\")) -}}\n {{- end -}}\n {{- /* 4. Transport 传输层参数 */ -}}\n {{- if hasKey $buildTransportParams \"type\" -}}\n {{- $params = append $params (printf \"type=%s\" (index $buildTransportParams \"type\")) -}}\n {{- end -}}\n {{- if hasKey $buildTransportParams \"host\" -}}\n {{- $params = append $params (printf \"host=%s\" (index $buildTransportParams \"host\")) -}}\n {{- end -}}\n {{- if hasKey $buildTransportParams \"path\" -}}\n {{- $params = append $params (printf \"path=%s\" (index $buildTransportParams \"path\")) -}}\n {{- end -}}\n {{- if hasKey $buildTransportParams \"serviceName\" -}}\n {{- $params = append $params (printf \"serviceName=%s\" (index $buildTransportParams \"serviceName\")) -}}\n {{- end -}}\n {{- if hasKey $buildTransportParams \"mode\" -}}\n {{- $params = append $params (printf \"mode=%s\" (index $buildTransportParams \"mode\")) -}}\n {{- end -}}\n {{- if hasKey $buildTransportParams \"extra\" -}}\n {{- $params = append $params (printf \"extra=%s\" (index $buildTransportParams \"extra\")) -}}\n {{- end -}}\n {{- /* 5. Common 通用参数 */ -}}\n {{- $params = append $params $common }}\nvless://{{ $password }}@{{ $server }}:{{ $proxy.Port }}?{{ join \"&\" $params }}#{{ $proxy.Name }}\n {{- else if eq $proxy.Type \"trojan\" }}\n {{- $params := list -}}\n {{- range $key, $val := $buildTransportParams -}}\n {{- $params = append $params (printf \"%s=%s\" $key $val) -}}\n {{- end -}}\n {{- range $key, $val := $buildSecurityParams -}}\n {{- $params = append $params (printf \"%s=%s\" $key $val) -}}\n {{- end -}}\n {{- $params = append $params $common }}\ntrojan://{{ $password }}@{{ $server }}:{{ $proxy.Port }}?{{ join \"&\" $params }}#{{ $proxy.Name }}\n {{- else if or (eq $proxy.Type \"hysteria2\") (eq $proxy.Type \"hysteria\") }}\n {{- $params := list -}}\n {{- if ne (default \"\" $proxy.SNI) \"\" -}}\n {{- $params = append $params (printf \"sni=%s\" $proxy.SNI) -}}\n {{- end -}}\n {{- if $proxy.AllowInsecure -}}\n {{- $params = append $params \"insecure=1\" -}}\n {{- end -}}\n {{- if ne (default \"\" $proxy.ObfsPassword) \"\" -}}\n {{- $params = append $params (printf \"obfs=salamander&obfs-password=%s\" $proxy.ObfsPassword) -}}\n {{- end -}}\n {{- if ne (default \"\" $proxy.HopPorts) \"\" -}}\n {{- $params = append $params (printf \"mport=%s\" $proxy.HopPorts) -}}\n {{- end }}\nhysteria2://{{- if ne $password \"\" -}}{{ $password }}@{{- end -}}{{ $server }}:{{ $proxy.Port }}?{{ join \"&\" (append $params $common) }}#{{ $proxy.Name | urlquery }}\n {{- else if eq $proxy.Type \"tuic\" }}\n {{- $params := list -}}\n {{- if ne (default \"\" $proxy.CongestionController) \"\" -}}\n {{- $params = append $params (printf \"congestion_controller=%s\" $proxy.CongestionController) -}}\n {{- end -}}\n {{- if ne (default \"\" $proxy.UDPRelayMode) \"\" -}}\n {{- $params = append $params (printf \"udp_relay_mode=%s\" $proxy.UDPRelayMode) -}}\n {{- end -}}\n {{- if $proxy.ReduceRtt -}}\n {{- $params = append $params \"reduce_rtt=1\" -}}\n {{- end -}}\n {{- if $proxy.DisableSNI -}}\n {{- $params = append $params \"disable_sni=1\" -}}\n {{- end -}}\n {{- if ne (default \"\" $proxy.SNI) \"\" -}}\n {{- $params = append $params (printf \"sni=%s\" $proxy.SNI) -}}\n {{- end -}}\n {{- if $proxy.AllowInsecure -}}\n {{- $params = append $params \"allow_insecure=1\" -}}\n {{- end -}}\n {{- $params = append $params $common }}\ntuic://{{ default \"\" $proxy.ServerKey }}:{{ $password }}@{{ $server }}:{{ $proxy.Port }}?{{ join \"&\" $params }}#{{ $proxy.Name }}\n {{- else if eq $proxy.Type \"anytls\" }}\n {{- $params := list -}}\n {{- /* 使用公共传输层配置 */ -}}\n {{- range $key, $val := $buildTransportParams -}}\n {{- $params = append $params (printf \"%s=%s\" $key $val) -}}\n {{- end -}}\n {{- /* 使用公共安全层配置 */ -}}\n {{- range $key, $val := $buildSecurityParams -}}\n {{- $params = append $params (printf \"%s=%s\" $key $val) -}}\n {{- end -}}\n {{- $params = append $params $common }}\nanytls://{{ $password }}@{{ $server }}:{{ $proxy.Port }}?{{ join \"&\" $params }}#{{ $proxy.Name }}\n {{- else if or (eq $proxy.Type \"http\") (eq $proxy.Type \"https\") }}\n {{- $user := default $password $proxy.Username }}\nhttp{{- if eq $proxy.Type \"https\" -}}s{{- end -}}://{{- if or (ne (default \"\" $user) \"\") (ne (default \"\" $password) \"\") -}}{{ $user }}:{{ $password }}@{{- end -}}{{ $server }}:{{ $proxy.Port }}#{{ $proxy.Name }}\n {{- else if or (eq $proxy.Type \"socks\") (eq $proxy.Type \"socks5\") (eq $proxy.Type \"socks5-tls\") }}\n {{- $user := default $password $proxy.Username }}\nsocks5://{{- if or (ne (default \"\" $user) \"\") (ne (default \"\" $password) \"\") -}}{{ $user }}:{{ $password }}@{{- end -}}{{ $server }}:{{ $proxy.Port }}{{- if eq $proxy.Type \"socks5-tls\" }}?tls=1{{- end }}#{{ $proxy.Name }}\n {{- end }}\n{{- end }}\n', 'base64', '{}', '2025-08-12 22:57:56.711', '2025-08-15 21:45:20.181'); +INSERT INTO `subscribe_application` (`id`, `name`, `icon`, `description`, `scheme`, `user_agent`, `is_default`, `subscribe_template`, `output_format`, `download_link`, `created_at`, `updated_at`) VALUES (2, 'Shadowrocket', '', '', 'shadowrocket://add/sub://${window.btoa(url)}?remark=${encodeURIComponent(name)}', 'Shadowrocket', 0, '{{- $GiB := 1073741824.0 -}}\n{{- $used := printf \"%.2f\" (divf (add (.UserInfo.Download | default 0 | float64) (.UserInfo.Upload | default 0 | float64)) $GiB) -}}\n{{- $traffic := (.UserInfo.Traffic | default 0 | float64) -}}\n{{- $total := printf \"%.2f\" (divf $traffic $GiB) -}}\n\n{{- $ExpiredAt := \"\" -}}\n{{- $expStr := printf \"%v\" .UserInfo.ExpiredAt -}}\n{{- if regexMatch `^[0-9]+$` $expStr -}}\n {{- $ts := $expStr | float64 -}}\n {{- $sec := ternary (divf $ts 1000.0) $ts (ge (len $expStr) 13) -}}\n {{- $ExpiredAt = (date \"2006-01-02 15:04:05\" (unixEpoch ($sec | int64))) -}}\n{{- else -}}\n {{- $ExpiredAt = $expStr -}}\n{{- end -}}\n\n{{- $sortFields := list \"Sort\" \"Port\" \"Name\" -}}\n{{- $sortConfig := dict \"Sort\" \"asc\" \"Port\" \"asc\" \"Name\" \"asc\" -}}\n{{- $byKey := dict -}}\n{{- range $p := .Proxies -}}\n {{- $keyParts := list -}}\n {{- range $field := $sortFields -}}\n {{- $order := index $sortConfig $field -}}\n {{- $val := default \"\" (printf \"%v\" (index $p $field)) -}}\n {{- if or (eq $field \"Sort\") (eq $field \"Port\") -}}\n {{- $val = printf \"%08d\" (int (default 0 (index $p $field))) -}}\n {{- end -}}\n {{- if eq $order \"desc\" -}}\n {{- $val = printf \"~%s\" $val -}}\n {{- end -}}\n {{- $keyParts = append $keyParts $val -}}\n {{- end -}}\n {{- $_ := set $byKey (join \"|\" $keyParts) $p -}}\n{{- end -}}\n{{- $sorted := list -}}\n{{- range $k := sortAlpha (keys $byKey) -}}\n {{- $sorted = append $sorted (index $byKey $k) -}}\n{{- end -}}\n\n{{- $supportSet := dict \"shadowsocks\" true \"vmess\" true \"vless\" true \"trojan\" true \"hysteria2\" true \"hysteria\" true \"tuic\" true \"anytls\" true -}}\n{{- $supportedProxies := list -}}\n{{- range $proxy := $sorted -}}\n {{- if hasKey $supportSet $proxy.Type -}}\n {{- $supportedProxies = append $supportedProxies $proxy -}}\n {{- end -}}\n{{- end -}}\n\nREMARKS={{ .SiteName }}-{{ .SubscribeName }}\nSTATUS=Traffic: {{ $used }} GiB/{{ $total }} GiB | Expires: {{ $ExpiredAt }}\n# Generated at: {{ now | date \"2006-01-02 15:04:05\\n\" }}\n\n{{- range $proxy := $supportedProxies }}\n {{- $common := \"udp=1&tfo=1\" -}}\n\n {{- $server := $proxy.Server -}}\n {{- if and (contains $server \":\") (not (hasPrefix \"[\" $server)) -}}\n {{- $server = printf \"[%s]\" $server -}}\n {{- end -}}\n\n {{- $password := $.UserInfo.Password -}}\n {{- if and (eq $proxy.Type \"shadowsocks\") (ne (default \"\" $proxy.ServerKey) \"\") -}}\n {{- $method := $proxy.Method -}}\n {{- if or (hasPrefix \"2022-blake3-\" $method) (eq $method \"2022-blake3-aes-128-gcm\") (eq $method \"2022-blake3-aes-256-gcm\") -}}\n {{- $userKeyLen := ternary 16 32 (hasSuffix \"128-gcm\" $method) -}}\n {{- $pwdStr := printf \"%s\" $password -}}\n {{- $userKey := ternary $pwdStr (trunc $userKeyLen $pwdStr) (le (len $pwdStr) $userKeyLen) -}}\n {{- $serverB64 := b64enc $proxy.ServerKey -}}\n {{- $userB64 := b64enc $userKey -}}\n {{- $password = printf \"%s:%s\" $serverB64 $userB64 -}}\n {{- end -}}\n {{- end -}}\n\n {{- $SkipVerify := $proxy.AllowInsecure -}}\n\n {{- /* 公共传输层配置函数 */ -}}\n {{- $buildTransportParams := dict -}}\n {{- $transport := default \"tcp\" $proxy.Transport -}}\n {{- if ne $transport \"\" -}}\n {{- $_ := set $buildTransportParams \"type\" (ternary \"ws\" $transport (eq $transport \"websocket\")) -}}\n {{- end -}}\n {{- /* TCP 传输类型配置 */ -}}\n {{- if eq $transport \"tcp\" -}}\n {{- $headerType := default \"none\" $proxy.HeaderType -}}\n {{- if ne $headerType \"none\" -}}\n {{- $_ := set $buildTransportParams \"headerType\" $headerType -}}\n {{- end -}}\n {{- if and (eq $headerType \"http\") (ne (default \"\" $proxy.Host) \"\") -}}\n {{- $_ := set $buildTransportParams \"host\" $proxy.Host -}}\n {{- end -}}\n {{- if and (eq $headerType \"http\") (ne (default \"\" $proxy.Path) \"\") -}}\n {{- $_ := set $buildTransportParams \"path\" ($proxy.Path | urlquery) -}}\n {{- end -}}\n {{- end -}}\n {{- /* WebSocket/xhttp/httpupgrade 传输类型配置 */ -}}\n {{- if and (or (eq $transport \"ws\") (eq $transport \"websocket\") (eq $transport \"xhttp\") (eq $transport \"httpupgrade\")) (ne (default \"\" $proxy.Host) \"\") -}}\n {{- $_ := set $buildTransportParams \"host\" $proxy.Host -}}\n {{- end -}}\n {{- if and (or (eq $transport \"ws\") (eq $transport \"websocket\") (eq $transport \"xhttp\") (eq $transport \"httpupgrade\")) (ne (default \"\" $proxy.Path) \"\") -}}\n {{- $_ := set $buildTransportParams \"path\" ($proxy.Path | urlquery) -}}\n {{- end -}}\n {{- /* gRPC 传输类型配置 */ -}}\n {{- if and (eq $transport \"grpc\") (ne (default \"\" $proxy.ServiceName) \"\") -}}\n {{- $_ := set $buildTransportParams \"serviceName\" $proxy.ServiceName -}}\n {{- end -}}\n {{- /* xhttp 特有配置 */ -}}\n {{- if and (eq $transport \"xhttp\") (ne (default \"\" $proxy.XhttpMode) \"\") -}}\n {{- $_ := set $buildTransportParams \"mode\" $proxy.XhttpMode -}}\n {{- end -}}\n {{- if and (eq $transport \"xhttp\") (ne (default \"\" $proxy.XhttpExtra) \"\") -}}\n {{- $_ := set $buildTransportParams \"extra\" (urlquery $proxy.XhttpExtra) -}}\n {{- end -}}\n\n {{- /* 公共安全层配置 */ -}}\n {{- $buildSecurityParams := dict -}}\n {{- if or (eq $proxy.Security \"tls\") (eq $proxy.Security \"reality\") -}}\n {{- $_ := set $buildSecurityParams \"security\" $proxy.Security -}}\n {{- end -}}\n {{- if ne (default \"\" $proxy.SNI) \"\" -}}\n {{- $_ := set $buildSecurityParams \"sni\" $proxy.SNI -}}\n {{- end -}}\n {{- if $SkipVerify -}}\n {{- $_ := set $buildSecurityParams \"allowInsecure\" \"1\" -}}\n {{- end -}}\n {{- if ne (default \"\" $proxy.Fingerprint) \"\" -}}\n {{- $_ := set $buildSecurityParams \"fp\" $proxy.Fingerprint -}}\n {{- end -}}\n {{- if and (eq $proxy.Security \"reality\") (ne (default \"\" $proxy.RealityPublicKey) \"\") -}}\n {{- $_ := set $buildSecurityParams \"pbk\" $proxy.RealityPublicKey -}}\n {{- end -}}\n {{- if and (eq $proxy.Security \"reality\") (ne (default \"\" $proxy.RealityShortId) \"\") -}}\n {{- $_ := set $buildSecurityParams \"sid\" $proxy.RealityShortId -}}\n {{- end -}}\n\n {{- if eq $proxy.Type \"shadowsocks\" }}\n {{- $params := list -}}\n {{- /* Shadowsocks 特有的 obfs 插件参数 */ -}}\n {{- if ne (default \"\" $proxy.Obfs) \"\" -}}\n {{- $params = append $params (printf \"obfs=%s\" $proxy.Obfs) -}}\n {{- end -}}\n {{- if ne (default \"\" $proxy.ObfsHost) \"\" -}}\n {{- $params = append $params (printf \"obfs-host=%s\" $proxy.ObfsHost) -}}\n {{- end -}}\n {{- if ne (default \"\" $proxy.ObfsPath) \"\" -}}\n {{- $params = append $params (printf \"obfs-uri=%s\" ($proxy.ObfsPath | urlquery)) -}}\n {{- end -}}\n {{- /* 使用公共传输层配置 */ -}}\n {{- range $key, $val := $buildTransportParams -}}\n {{- $params = append $params (printf \"%s=%s\" $key $val) -}}\n {{- end -}}\n {{- /* 使用公共安全层配置 */ -}}\n {{- range $key, $val := $buildSecurityParams -}}\n {{- $params = append $params (printf \"%s=%s\" $key $val) -}}\n {{- end -}}\n {{- /* 添加公共参数 */ -}}\n {{- $params = append $params $common }}\nss://{{ printf \"%s:%s\" (default \"aes-128-gcm\" $proxy.Method) $password | b64enc }}@{{ $server }}:{{ $proxy.Port }}?{{ join \"&\" $params }}#{{ $proxy.Name }}\n {{- else if eq $proxy.Type \"vmess\" }}\n {{- $vmessDict := dict \"v\" \"2\" \"ps\" $proxy.Name \"add\" $proxy.Server \"port\" (printf \"%d\" $proxy.Port) \"id\" $password \"aid\" \"0\" \"net\" \"tcp\" \"type\" \"none\" -}}\n {{- if hasKey $buildTransportParams \"type\" -}}\n {{- $_ := set $vmessDict \"net\" (index $buildTransportParams \"type\") -}}\n {{- end -}}\n {{- if hasKey $buildTransportParams \"host\" -}}\n {{- $_ := set $vmessDict \"host\" (index $buildTransportParams \"host\") -}}\n {{- end -}}\n {{- if hasKey $buildTransportParams \"path\" -}}\n {{- $_ := set $vmessDict \"path\" (index $buildTransportParams \"path\") -}}\n {{- end -}}\n {{- if and (eq $transport \"grpc\") (hasKey $buildTransportParams \"serviceName\") -}}\n {{- $_ := set $vmessDict \"path\" (index $buildTransportParams \"serviceName\") -}}\n {{- end -}}\n {{- if hasKey $buildTransportParams \"mode\" -}}\n {{- $_ := set $vmessDict \"xhttpMode\" (index $buildTransportParams \"mode\") -}}\n {{- end -}}\n {{- if hasKey $buildTransportParams \"extra\" -}}\n {{- $_ := set $vmessDict \"xhttpExtra\" (index $buildTransportParams \"extra\") -}}\n {{- end -}}\n {{- if hasKey $buildSecurityParams \"security\" -}}\n {{- $_ := set $vmessDict \"tls\" (index $buildSecurityParams \"security\") -}}\n {{- end -}}\n {{- if hasKey $buildSecurityParams \"sni\" -}}\n {{- $_ := set $vmessDict \"sni\" (index $buildSecurityParams \"sni\") -}}\n {{- end -}}\n {{- if hasKey $buildSecurityParams \"fp\" -}}\n {{- $_ := set $vmessDict \"fp\" (index $buildSecurityParams \"fp\") -}}\n {{- end -}}\n {{- if hasKey $buildSecurityParams \"allowInsecure\" -}}\n {{- $_ := set $vmessDict \"skip-cert-verify\" true -}}\n {{- end }}\nvmess://{{ $vmessDict | toJson | b64enc }}\n {{- else if eq $proxy.Type \"vless\" }}\n {{- $params := list -}}\n {{- /* 1. Encryption 加密参数 */ -}}\n {{- $encryption := default \"none\" $proxy.Encryption -}}\n {{- if eq $encryption \"none\" -}}\n {{- $params = append $params \"encryption=none\" -}}\n {{- else -}}\n {{- $encParts := list -}}\n {{- $encParts = append $encParts $encryption -}}\n {{- if ne (default \"\" $proxy.EncryptionMode) \"\" -}}\n {{- $encParts = append $encParts $proxy.EncryptionMode -}}\n {{- end -}}\n {{- if ne (default \"\" $proxy.EncryptionRtt) \"\" -}}\n {{- $encParts = append $encParts $proxy.EncryptionRtt -}}\n {{- end -}}\n {{- if ne (default \"\" $proxy.EncryptionClientPadding) \"\" -}}\n {{- $encParts = append $encParts $proxy.EncryptionClientPadding -}}\n {{- end -}}\n {{- if ne (default \"\" $proxy.EncryptionPassword) \"\" -}}\n {{- $encParts = append $encParts $proxy.EncryptionPassword -}}\n {{- end -}}\n {{- $params = append $params (printf \"encryption=%s\" (join \".\" $encParts)) -}}\n {{- end -}}\n {{- /* 2. Flow 流控参数 */ -}}\n {{- if ne (default \"\" $proxy.Flow) \"none\" -}}\n {{- $params = append $params (printf \"flow=%s\" $proxy.Flow) -}}\n {{- end -}}\n {{- /* 3. Security 安全参数 */ -}}\n {{- if hasKey $buildSecurityParams \"security\" -}}\n {{- $params = append $params (printf \"security=%s\" (index $buildSecurityParams \"security\")) -}}\n {{- end -}}\n {{- if hasKey $buildSecurityParams \"sni\" -}}\n {{- $params = append $params (printf \"sni=%s\" (index $buildSecurityParams \"sni\")) -}}\n {{- end -}}\n {{- if hasKey $buildSecurityParams \"fp\" -}}\n {{- $params = append $params (printf \"fp=%s\" (index $buildSecurityParams \"fp\")) -}}\n {{- end -}}\n {{- if hasKey $buildSecurityParams \"allowInsecure\" -}}\n {{- $params = append $params \"allowInsecure=1\" -}}\n {{- end -}}\n {{- if hasKey $buildSecurityParams \"pbk\" -}}\n {{- $params = append $params (printf \"pbk=%s\" (index $buildSecurityParams \"pbk\")) -}}\n {{- end -}}\n {{- if hasKey $buildSecurityParams \"sid\" -}}\n {{- $params = append $params (printf \"sid=%s\" (index $buildSecurityParams \"sid\")) -}}\n {{- end -}}\n {{- /* 4. Transport 传输层参数 */ -}}\n {{- if hasKey $buildTransportParams \"type\" -}}\n {{- $params = append $params (printf \"type=%s\" (index $buildTransportParams \"type\")) -}}\n {{- end -}}\n {{- if hasKey $buildTransportParams \"host\" -}}\n {{- $params = append $params (printf \"host=%s\" (index $buildTransportParams \"host\")) -}}\n {{- end -}}\n {{- if hasKey $buildTransportParams \"path\" -}}\n {{- $params = append $params (printf \"path=%s\" (index $buildTransportParams \"path\")) -}}\n {{- end -}}\n {{- if hasKey $buildTransportParams \"serviceName\" -}}\n {{- $params = append $params (printf \"serviceName=%s\" (index $buildTransportParams \"serviceName\")) -}}\n {{- end -}}\n {{- if hasKey $buildTransportParams \"mode\" -}}\n {{- $params = append $params (printf \"mode=%s\" (index $buildTransportParams \"mode\")) -}}\n {{- end -}}\n {{- if hasKey $buildTransportParams \"extra\" -}}\n {{- $params = append $params (printf \"extra=%s\" (index $buildTransportParams \"extra\")) -}}\n {{- end -}}\n {{- /* 5. Common 通用参数 */ -}}\n {{- $params = append $params $common }}\nvless://{{ $password }}@{{ $server }}:{{ $proxy.Port }}?{{ join \"&\" $params }}#{{ $proxy.Name }}\n {{- else if eq $proxy.Type \"trojan\" }}\n {{- $params := list -}}\n {{- range $key, $val := $buildTransportParams -}}\n {{- $params = append $params (printf \"%s=%s\" $key $val) -}}\n {{- end -}}\n {{- range $key, $val := $buildSecurityParams -}}\n {{- $params = append $params (printf \"%s=%s\" $key $val) -}}\n {{- end -}}\n {{- $params = append $params $common }}\ntrojan://{{ $password }}@{{ $server }}:{{ $proxy.Port }}?{{ join \"&\" $params }}#{{ $proxy.Name }}\n {{- else if or (eq $proxy.Type \"hysteria2\") (eq $proxy.Type \"hysteria\") }}\n {{- $params := list -}}\n {{- if ne (default \"\" $proxy.SNI) \"\" -}}\n {{- $params = append $params (printf \"sni=%s\" $proxy.SNI) -}}\n {{- end -}}\n {{- if $proxy.AllowInsecure -}}\n {{- $params = append $params \"insecure=1\" -}}\n {{- end -}}\n {{- if ne (default \"\" $proxy.ObfsPassword) \"\" -}}\n {{- $params = append $params (printf \"obfs=salamander&obfs-password=%s\" $proxy.ObfsPassword) -}}\n {{- end -}}\n {{- if ne (default \"\" $proxy.HopPorts) \"\" -}}\n {{- $params = append $params (printf \"mport=%s\" $proxy.HopPorts) -}}\n {{- end }}\nhysteria2://{{- if ne $password \"\" -}}{{ $password }}@{{- end -}}{{ $server }}:{{ $proxy.Port }}?{{ join \"&\" (append $params $common) }}#{{ $proxy.Name | urlquery }}\n {{- else if eq $proxy.Type \"tuic\" }}\n {{- $params := list -}}\n {{- if ne (default \"\" $proxy.CongestionController) \"\" -}}\n {{- $params = append $params (printf \"congestion_controller=%s\" $proxy.CongestionController) -}}\n {{- end -}}\n {{- if ne (default \"\" $proxy.UDPRelayMode) \"\" -}}\n {{- $params = append $params (printf \"udp_relay_mode=%s\" $proxy.UDPRelayMode) -}}\n {{- end -}}\n {{- if $proxy.ReduceRtt -}}\n {{- $params = append $params \"reduce_rtt=1\" -}}\n {{- end -}}\n {{- if $proxy.DisableSNI -}}\n {{- $params = append $params \"disable_sni=1\" -}}\n {{- end -}}\n {{- if ne (default \"\" $proxy.SNI) \"\" -}}\n {{- $params = append $params (printf \"sni=%s\" $proxy.SNI) -}}\n {{- end -}}\n {{- if $proxy.AllowInsecure -}}\n {{- $params = append $params \"allow_insecure=1\" -}}\n {{- end -}}\n {{- $params = append $params $common }}\ntuic://{{ default \"\" $proxy.ServerKey }}:{{ $password }}@{{ $server }}:{{ $proxy.Port }}?{{ join \"&\" $params }}#{{ $proxy.Name }}\n {{- else if eq $proxy.Type \"anytls\" }}\n {{- $params := list -}}\n {{- /* 使用公共传输层配置 */ -}}\n {{- range $key, $val := $buildTransportParams -}}\n {{- $params = append $params (printf \"%s=%s\" $key $val) -}}\n {{- end -}}\n {{- /* 使用公共安全层配置 */ -}}\n {{- range $key, $val := $buildSecurityParams -}}\n {{- $params = append $params (printf \"%s=%s\" $key $val) -}}\n {{- end -}}\n {{- $params = append $params $common }}\nanytls://{{ $password }}@{{ $server }}:{{ $proxy.Port }}?{{ join \"&\" $params }}#{{ $proxy.Name }}\n {{- else if or (eq $proxy.Type \"http\") (eq $proxy.Type \"https\") }}\n {{- $user := default $password $proxy.Username }}\nhttp{{- if eq $proxy.Type \"https\" -}}s{{- end -}}://{{- if or (ne (default \"\" $user) \"\") (ne (default \"\" $password) \"\") -}}{{ $user }}:{{ $password }}@{{- end -}}{{ $server }}:{{ $proxy.Port }}#{{ $proxy.Name }}\n {{- else if or (eq $proxy.Type \"socks\") (eq $proxy.Type \"socks5\") (eq $proxy.Type \"socks5-tls\") }}\n {{- $user := default $password $proxy.Username }}\nsocks5://{{- if or (ne (default \"\" $user) \"\") (ne (default \"\" $password) \"\") -}}{{ $user }}:{{ $password }}@{{- end -}}{{ $server }}:{{ $proxy.Port }}{{- if eq $proxy.Type \"socks5-tls\" }}?tls=1{{- end }}#{{ $proxy.Name }}\n {{- end }}\n{{- end }}\n', 'base64', '{}', '2025-08-12 23:03:50.004', '2025-08-15 22:01:39.221'); +INSERT INTO `subscribe_application` (`id`, `name`, `icon`, `description`, `scheme`, `user_agent`, `is_default`, `subscribe_template`, `output_format`, `download_link`, `created_at`, `updated_at`) VALUES (3, 'Clash', '', '', 'clash://install-config?url=${url}&name=${name}', 'Clash', 0, '{{- $GiB := 1073741824.0 -}}\n{{- $used := printf \"%.2f\" (divf (add (.UserInfo.Download | default 0 | float64) (.UserInfo.Upload | default 0 | float64)) $GiB) -}}\n{{- $traffic := (.UserInfo.Traffic | default 0 | float64) -}}\n{{- $total := printf \"%.2f\" (divf $traffic $GiB) -}}\n\n{{- $ExpiredAt := \"\" -}}\n{{- $expStr := printf \"%v\" .UserInfo.ExpiredAt -}}\n{{- if regexMatch `^[0-9]+$` $expStr -}}\n {{- $ts := $expStr | float64 -}}\n {{- $sec := ternary (divf $ts 1000.0) $ts (ge (len $expStr) 13) -}}\n {{- $ExpiredAt = (date \"2006-01-02 15:04:05\" (unixEpoch ($sec | int64))) -}}\n{{- else -}}\n {{- $ExpiredAt = $expStr -}}\n{{- end -}}\n\n{{- $sortFields := list \"Sort\" \"Port\" \"Name\" -}}\n{{- $sortConfig := dict \"Sort\" \"asc\" \"Port\" \"asc\" \"Name\" \"asc\" -}}\n{{- $byKey := dict -}}\n{{- range $p := .Proxies -}}\n {{- $keyParts := list -}}\n {{- range $field := $sortFields -}}\n {{- $order := index $sortConfig $field -}}\n {{- $val := default \"\" (printf \"%v\" (index $p $field)) -}}\n {{- if or (eq $field \"Sort\") (eq $field \"Port\") -}}\n {{- $val = printf \"%08d\" (int (default 0 (index $p $field))) -}}\n {{- end -}}\n {{- if eq $order \"desc\" -}}\n {{- $val = printf \"~%s\" $val -}}\n {{- end -}}\n {{- $keyParts = append $keyParts $val -}}\n {{- end -}}\n {{- $_ := set $byKey (join \"|\" $keyParts) $p -}}\n{{- end -}}\n{{- $sorted := list -}}\n{{- range $k := sortAlpha (keys $byKey) -}}\n {{- $sorted = append $sorted (index $byKey $k) -}}\n{{- end -}}\n\n{{- $supportSet := dict \"shadowsocks\" true \"vmess\" true \"vless\" true \"trojan\" true \"hysteria2\" true \"hysteria\" true \"tuic\" true \"anytls\" true -}}\n{{- $supportedProxies := list -}}\n{{- range $proxy := $sorted -}}\n {{- if hasKey $supportSet $proxy.Type -}}\n {{- $supportedProxies = append $supportedProxies $proxy -}}\n {{- end -}}\n{{- end -}}\n\n{{- $proxyNames := \"\" -}}\n{{- range $proxy := $supportedProxies -}}\n {{- if eq $proxyNames \"\" -}}\n {{- $proxyNames = printf \"%q\" $proxy.Name -}}\n {{- else -}}\n {{- $proxyNames = printf \"%s, %q\" $proxyNames $proxy.Name -}}\n {{- end -}}\n{{- end -}}\n\n# {{ .SiteName }}-{{ .SubscribeName }}\n# Traffic: {{ $used }} GiB/{{ $total }} GiB | Expires: {{ $ExpiredAt }}\n# Generated at: {{ now | date \"2006-01-02 15:04:05\" }}\n\nmode: rule\nipv6: true\nallow-lan: true\nbind-address: ''*''\nmixed-port: 10808\nlog-level: info\nunified-delay: true\ntcp-concurrent: true\nexternal-controller: ''0.0.0.0:9090''\nglobal-client-fingerprint: chrome\ntun:\n enable: true\n stack: system\n auto-route: true\ndns:\n enable: true\n cache-algorithm: arc\n listen: ''0.0.0.0:1053''\n ipv6: true\n use-hosts: true\n use-system-hosts: true\n respect-rules: false\n enhanced-mode: fake-ip\n fake-ip-range: 198.18.0.1/16\n fake-ip-filter:\n - ''*.lan''\n - ''localhost''\n - ''lens.l.google.com''\n - ''*.srv.nintendo.net''\n - ''*.stun.playstation.net''\n - ''xbox.*.*.microsoft.com''\n - ''*.xboxlive.com''\n - ''*.msftncsi.com''\n - ''*.msftconnecttest.com''\n - ''time.*.com''\n default-nameserver:\n - 223.5.5.5\n - 119.29.29.29\n nameserver:\n - https://cloudflare-dns.com/dns-query\n - https://dns.google/dns-query\n fallback:\n - tls://1.1.1.1\n - tls://8.8.8.8\n proxy-server-nameserver:\n - https://dns.alidns.com/dns-query\n - https://doh.pub/dns-query\n direct-nameserver:\n - system\n - https://dns.alidns.com/dns-query\n - https://doh.pub/dns-query\n direct-nameserver-follow-policy: false\n fallback-filter:\n geoip: true\n geoip-code: CN\n geosite:\n - gfw\n - youtube\n domain:\n - ''+.google.com''\n - ''+.facebook.com''\n - ''+.twitter.com''\n - ''+.telegram.org''\n\nproxies:\n{{- range $proxy := $supportedProxies }}\n {{- $server := $proxy.Server -}}\n {{- if and (contains $server \":\") (not (hasPrefix \"[\" $server)) -}}\n {{- $server = printf \"[%s]\" $server -}}\n {{- end -}}\n\n {{- $password := $.UserInfo.Password -}}\n {{- if and (eq $proxy.Type \"shadowsocks\") (ne (default \"\" $proxy.ServerKey) \"\") -}}\n {{- $method := $proxy.Method -}}\n {{- if or (hasPrefix \"2022-blake3-\" $method) (eq $method \"2022-blake3-aes-128-gcm\") (eq $method \"2022-blake3-aes-256-gcm\") -}}\n {{- $userKeyLen := ternary 16 32 (hasSuffix \"128-gcm\" $method) -}}\n {{- $pwdStr := printf \"%s\" $password -}}\n {{- $userKey := ternary $pwdStr (trunc $userKeyLen $pwdStr) (le (len $pwdStr) $userKeyLen) -}}\n {{- $serverB64 := b64enc $proxy.ServerKey -}}\n {{- $userB64 := b64enc $userKey -}}\n {{- $password = printf \"%s:%s\" $serverB64 $userB64 -}}\n {{- end -}}\n {{- end -}}\n\n {{- $SkipVerify := $proxy.AllowInsecure -}}\n\n{{- if eq $proxy.Type \"shadowsocks\" }}\n- name: {{ $proxy.Name | quote }}\n type: ss\n server: {{ $server }}\n port: {{ $proxy.Port }}\n cipher: {{ default \"aes-128-gcm\" $proxy.Method }}\n password: {{ $password }}\n udp: true\n tfo: true\n {{- if ne (default \"\" $proxy.Obfs) \"\" }}\n plugin: obfs\n plugin-opts:\n mode: {{ $proxy.Obfs }}\n host: {{ default \"\" $proxy.ObfsHost }}\n {{- end }}\n{{- else if eq $proxy.Type \"vmess\" }}\n- name: {{ $proxy.Name | quote }}\n type: vmess\n server: {{ $server }}\n port: {{ $proxy.Port }}\n uuid: {{ $password }}\n alterId: 0\n cipher: auto\n udp: true\n tfo: true\n {{- if or (eq $proxy.Transport \"websocket\") (eq $proxy.Transport \"ws\") }}\n network: ws\n ws-opts:\n path: {{ default \"/\" $proxy.Path }}\n {{- if ne (default \"\" $proxy.Host) \"\" }}\n headers:\n Host: {{ $proxy.Host }}\n {{- end }}\n {{- else if eq $proxy.Transport \"http\" }}\n network: http\n http-opts:\n method: GET\n path: [{{ default \"/\" $proxy.Path | quote }}]\n {{- if ne (default \"\" $proxy.Host) \"\" }}\n headers:\n Host: [{{ $proxy.Host | quote }}]\n {{- end }}\n {{- else if eq $proxy.Transport \"grpc\" }}\n network: grpc\n grpc-opts:\n grpc-service-name: {{ default \"grpc\" $proxy.ServiceName }}\n {{- end }}\n {{- if or (eq $proxy.Security \"tls\") (eq $proxy.Security \"reality\") }}\n tls: true\n {{- end }}\n {{- if ne (default \"\" $proxy.SNI) \"\" }}\n servername: {{ $proxy.SNI }}\n {{- end }}\n {{- if $SkipVerify }}\n skip-cert-verify: true\n {{- end }}\n {{- if ne (default \"\" $proxy.Fingerprint) \"\" }}\n client-fingerprint: {{ $proxy.Fingerprint }}\n {{- end }}\n {{- if $proxy.EchEnable }}\n ech-opts:\n enable: true\n {{- if ne (default \"\" $proxy.EchServerName) \"\" }}\n query-server-name: {{ $proxy.EchServerName }}\n {{- end }}\n {{- end }}\n{{- else if eq $proxy.Type \"vless\" }}\n {{- $encryptionStr := \"\" -}}\n {{- $encryption := default \"none\" $proxy.Encryption -}}\n {{- if eq $encryption \"none\" -}}\n {{- $encryptionStr = \"none\" -}}\n {{- else -}}\n {{- $encParts := list -}}\n {{- $encParts = append $encParts $encryption -}}\n {{- if ne (default \"\" $proxy.Encryption_Mode) \"\" -}}\n {{- $encParts = append $encParts $proxy.Encryption_Mode -}}\n {{- end -}}\n {{- if ne (default \"\" $proxy.EncryptionRtt) \"\" -}}\n {{- $encParts = append $encParts $proxy.EncryptionRtt -}}\n {{- end -}}\n {{- if ne (default \"\" $proxy.EncryptionClientPadding) \"\" -}}\n {{- $encParts = append $encParts $proxy.EncryptionClientPadding -}}\n {{- end -}}\n {{- if ne (default \"\" $proxy.EncryptionPassword) \"\" -}}\n {{- $encParts = append $encParts $proxy.EncryptionPassword -}}\n {{- end -}}\n {{- $encryptionStr = join \".\" $encParts -}}\n {{- end }}\n- name: {{ $proxy.Name | quote }}\n type: vless\n server: {{ $server }}\n port: {{ $proxy.Port }}\n uuid: {{ $password }}\n udp: true\n tfo: true\n encryption: {{ $encryptionStr }}\n {{- if ne (default \"\" $proxy.Flow) \"\" }}\n flow: {{ $proxy.Flow }}\n {{- end }}\n {{- if or (eq $proxy.Transport \"ws\") (eq $proxy.Transport \"websocket\") }}\n network: ws\n ws-opts:\n path: {{ default \"/\" $proxy.Path }}\n {{- if ne (default \"\" $proxy.Host) \"\" }}\n headers:\n Host: {{ $proxy.Host }}\n {{- end }}\n {{- else if eq $proxy.Transport \"http\" }}\n network: http\n http-opts:\n method: GET\n path: [{{ default \"/\" $proxy.Path | quote }}]\n {{- if ne (default \"\" $proxy.Host) \"\" }}\n headers:\n Host: [{{ $proxy.Host | quote }}]\n {{- end }}\n {{- else if eq $proxy.Transport \"httpupgrade\" }}\n network: httpupgrade\n httpupgrade-opts:\n path: {{ default \"/\" $proxy.Path }}\n {{- if ne (default \"\" $proxy.Host) \"\" }}\n headers:\n Host: {{ $proxy.Host }}\n {{- end }}\n {{- else if eq $proxy.Transport \"xhttp\" }}\n network: xhttp\n xhttp-opts:\n path: {{ default \"/\" $proxy.Path }}\n {{- if ne (default \"\" $proxy.Host) \"\" }}\n host: {{ $proxy.Host }}\n {{- end }}\n {{- if ne (default \"\" $proxy.XhttpMode) \"\" }}\n mode: {{ $proxy.XhttpMode }}\n {{- end }}\n {{- else if eq $proxy.Transport \"grpc\" }}\n network: grpc\n grpc-opts:\n grpc-service-name: {{ default \"grpc\" $proxy.ServiceName }}\n {{- end }}\n {{- if or (eq $proxy.Security \"tls\") (eq $proxy.Security \"reality\") }}\n tls: true\n {{- end }}\n {{- if ne (default \"\" $proxy.SNI) \"\" }}\n servername: {{ $proxy.SNI }}\n {{- end }}\n {{- if $proxy.AllowInsecure }}\n skip-cert-verify: true\n {{- end }}\n {{- if ne (default \"\" $proxy.Fingerprint) \"\" }}\n client-fingerprint: {{ $proxy.Fingerprint }}\n {{- end }}\n {{- if and (eq $proxy.Security \"reality\") (ne (default \"\" $proxy.RealityPublicKey) \"\") }}\n reality-opts:\n public-key: {{ $proxy.RealityPublicKey }}\n {{- if ne (default \"\" $proxy.RealityShortId) \"\" }}\n short-id: {{ $proxy.RealityShortId }}\n {{- end }}\n {{- end }}\n {{- if $proxy.EchEnable }}\n ech-opts:\n enable: true\n {{- if ne (default \"\" $proxy.EchServerName) \"\" }}\n query-server-name: {{ $proxy.EchServerName }}\n {{- end }}\n {{- end }}\n{{- else if eq $proxy.Type \"trojan\" }}\n- name: {{ $proxy.Name | quote }}\n type: trojan\n server: {{ $server }}\n port: {{ $proxy.Port }}\n password: {{ $password }}\n udp: true\n tfo: true\n {{- if or (eq $proxy.Security \"tls\") (eq $proxy.Security \"reality\") }}\n tls: true\n {{- end }}\n {{- if ne (default \"\" $proxy.SNI) \"\" }}\n sni: {{ $proxy.SNI }}\n {{- end }}\n {{- if $SkipVerify }}\n skip-cert-verify: true\n {{- end }}\n {{- if ne (default \"\" $proxy.Fingerprint) \"\" }}\n client-fingerprint: {{ $proxy.Fingerprint }}\n {{- end }}\n {{- if and (eq $proxy.Security \"reality\") (ne (default \"\" $proxy.RealityPublicKey) \"\") }}\n reality-opts:\n public-key: {{ $proxy.RealityPublicKey }}\n {{- if ne (default \"\" $proxy.RealityShortId) \"\" }}\n short-id: {{ $proxy.RealityShortId }}\n {{- end }}\n {{- end }}\n {{- if or (eq $proxy.Transport \"ws\") (eq $proxy.Transport \"websocket\") }}\n network: ws\n ws-opts:\n path: {{ default \"/\" $proxy.Path }}\n {{- if ne (default \"\" $proxy.Host) \"\" }}\n headers:\n Host: {{ $proxy.Host }}\n {{- end }}\n {{- else if eq $proxy.Transport \"http\" }}\n network: http\n http-opts:\n method: GET\n path: [{{ default \"/\" $proxy.Path | quote }}]\n {{- if ne (default \"\" $proxy.Host) \"\" }}\n headers:\n Host: [{{ $proxy.Host | quote }}]\n {{- end }}\n {{- else if eq $proxy.Transport \"grpc\" }}\n network: grpc\n grpc-opts:\n grpc-service-name: {{ default \"grpc\" $proxy.ServiceName }}\n {{- end }}\n {{- if $proxy.EchEnable }}\n ech-opts:\n enable: true\n {{- if ne (default \"\" $proxy.EchServerName) \"\" }}\n query-server-name: {{ $proxy.EchServerName }}\n {{- end }}\n {{- end }}\n{{- else if or (eq $proxy.Type \"hysteria2\") (eq $proxy.Type \"hysteria\") }}\n- name: {{ $proxy.Name | quote }}\n type: hysteria2\n server: {{ $server }}\n port: {{ $proxy.Port }}\n password: {{ $password }}\n udp: true\n tfo: true\n {{- if ne (default \"\" $proxy.SNI) \"\" }}\n sni: {{ $proxy.SNI }}\n {{- end }}\n {{- if $proxy.AllowInsecure }}\n skip-cert-verify: true\n {{- end }}\n {{- if ne (default \"\" $proxy.ObfsPassword) \"\" }}\n obfs: salamander\n obfs-password: {{ $proxy.ObfsPassword }}\n {{- end }}\n {{- if ne (default \"\" $proxy.HopPorts) \"\" }}\n ports: {{ $proxy.HopPorts }}\n {{- end }}\n {{- if ne (default 0 $proxy.HopInterval) 0 }}\n hop-interval: {{ $proxy.HopInterval }}\n {{- end }}\n {{- if ne (default \"\" (printf \"%v\" $proxy.UpMbps)) \"\" }}\n up: \"{{ $proxy.UpMbps }} Mbps\"\n {{- end }}\n {{- if ne (default \"\" (printf \"%v\" $proxy.DownMbps)) \"\" }}\n down: \"{{ $proxy.DownMbps }} Mbps\"\n {{- end }}\n {{- if $proxy.EchEnable }}\n ech-opts:\n enable: true\n {{- if ne (default \"\" $proxy.EchServerName) \"\" }}\n query-server-name: {{ $proxy.EchServerName }}\n {{- end }}\n {{- end }}\n{{- else if eq $proxy.Type \"tuic\" }}\n- name: {{ $proxy.Name | quote }}\n type: tuic\n server: {{ $server }}\n port: {{ $proxy.Port }}\n uuid: {{ default \"\" $proxy.ServerKey }}\n password: {{ $password }}\n udp: true\n tfo: true\n {{- if ne (default \"\" $proxy.SNI) \"\" }}\n sni: {{ $proxy.SNI }}\n {{- end }}\n {{- if $proxy.AllowInsecure }}\n skip-cert-verify: true\n {{- end }}\n {{- if $proxy.DisableSNI }}\n disable-sni: true\n {{- end }}\n {{- if $proxy.ReduceRtt }}\n reduce-rtt: true\n {{- end }}\n {{- if ne (default \"\" $proxy.UDPRelayMode) \"\" }}\n udp-relay-mode: {{ $proxy.UDPRelayMode }}\n {{- end }}\n {{- if ne (default \"\" $proxy.CongestionController) \"\" }}\n congestion-controller: {{ $proxy.CongestionController }}\n {{- end }}\n {{- if $proxy.EchEnable }}\n ech-opts:\n enable: true\n {{- if ne (default \"\" $proxy.EchServerName) \"\" }}\n query-server-name: {{ $proxy.EchServerName }}\n {{- end }}\n {{- end }}\n{{- else if eq $proxy.Type \"wireguard\" }}\n- name: {{ $proxy.Name | quote }}\n type: wireguard\n server: {{ $server }}\n port: {{ $proxy.Port }}\n private-key: {{ default \"\" $proxy.ServerKey }}\n public-key: {{ default \"\" $proxy.RealityPublicKey }}\n udp: true\n tfo: true\n {{- if ne (default \"\" $proxy.Path) \"\" }}\n preshared-key: {{ $proxy.Path }}\n {{- end }}\n {{- if ne (default \"\" $proxy.RealityServerAddr) \"\" }}\n ip: {{ $proxy.RealityServerAddr }}\n {{- end }}\n {{- if ne (default 0 $proxy.RealityServerPort) 0 }}\n ipv6: {{ $proxy.RealityServerPort }}\n {{- end }}\n{{- else if eq $proxy.Type \"anytls\" }}\n- name: {{ $proxy.Name | quote }}\n type: anytls\n server: {{ $server }}\n port: {{ $proxy.Port }}\n password: {{ $password }}\n udp: true\n tfo: true\n {{- if ne (default \"\" $proxy.SNI) \"\" }}\n sni: {{ $proxy.SNI }}\n {{- end }}\n {{- if $proxy.AllowInsecure }}\n skip-cert-verify: true\n {{- end }}\n {{- if ne (default \"\" $proxy.Fingerprint) \"\" }}\n client-fingerprint: {{ $proxy.Fingerprint }}\n {{- end }}\n {{- if $proxy.EchEnable }}\n ech-opts:\n enable: true\n {{- if ne (default \"\" $proxy.EchServerName) \"\" }}\n query-server-name: {{ $proxy.EchServerName }}\n {{- end }}\n {{- end }}\n{{- else }}\n- name: {{ $proxy.Name | quote }}\n type: {{ $proxy.Type }}\n server: {{ $server }}\n port: {{ $proxy.Port }}\n udp: true\n tfo: true\n{{- end }}\n{{- end }}\n\nproxy-groups:\n - { name: 🚀 Proxy, type: select, proxies: [🌏 Auto, 🎯 Direct, {{ $proxyNames }}] }\n - { name: 🍎 Apple, type: select, proxies: [🚀 Proxy, 🎯 Direct, {{ $proxyNames }}] }\n - { name: 🔍 Google, type: select, proxies: [🚀 Proxy, 🎯 Direct, {{ $proxyNames }}] }\n - { name: 🪟 Microsoft, type: select, proxies: [🚀 Proxy, 🎯 Direct, {{ $proxyNames }}] }\n - { name: 📺 GlobalMedia, type: select, proxies: [🚀 Proxy, 🎯 Direct, {{ $proxyNames }}] }\n - { name: 📟 Telegram, type: select, proxies: [🚀 Proxy, 🎯 Direct, {{ $proxyNames }}] }\n - { name: 🤖 AI, type: select, proxies: [🚀 Proxy, 🎯 Direct, {{ $proxyNames }}] }\n - { name: 🪙 Crypto, type: select, proxies: [🚀 Proxy, 🎯 Direct, {{ $proxyNames }}] }\n - { name: 🎮 Game, type: select, proxies: [🚀 Proxy, 🎯 Direct, {{ $proxyNames }}] }\n - { name: 🇨🇳 China, type: select, proxies: [🎯 Direct, 🚀 Proxy, {{ $proxyNames }}] }\n - { name: 🎯 Direct, type: select, proxies: [DIRECT], hidden: true }\n - { name: 🐠 Final, type: select, proxies: [🚀 Proxy, 🎯 Direct, {{ $proxyNames }}] }\n - { name: 🌏 Auto, type: url-test, proxies: [{{ $proxyNames }}] }\n\nrules:\n - RULE-SET, Apple, 🍎 Apple\n - RULE-SET, Google, 🔍 Google\n - RULE-SET, Microsoft, 🪟 Microsoft\n - RULE-SET, Github, 🪟 Microsoft\n - RULE-SET, HBO, 📺 GlobalMedia\n - RULE-SET, Disney, 📺 GlobalMedia\n - RULE-SET, TikTok, 📺 GlobalMedia\n - RULE-SET, Netflix, 📺 GlobalMedia\n - RULE-SET, GlobalMedia, 📺 GlobalMedia\n - RULE-SET, Telegram, 📟 Telegram\n - RULE-SET, OpenAI, 🤖 AI\n - RULE-SET, Gemini, 🤖 AI\n - RULE-SET, Copilot, 🤖 AI\n - RULE-SET, Claude, 🤖 AI\n - RULE-SET, Crypto, 🪙 Crypto\n - RULE-SET, Cryptocurrency, 🪙 Crypto\n - RULE-SET, Game, 🎮 Game\n - RULE-SET, Global, 🚀 Proxy\n - RULE-SET, ChinaMax, 🇨🇳 China\n - RULE-SET, Lan, 🎯 Direct\n - GEOIP, CN, 🇨🇳 China\n - MATCH, 🐠 Final\n\nrule-providers:\n Apple:\n type: http\n behavior: classical\n format: yaml\n url: https://cdn.jsdmirror.com/gh/perfect-panel/rules/rule/Clash/Apple/Apple_Classical_No_Resolve.yaml\n interval: 86400\n Google:\n type: http\n behavior: classical\n format: yaml\n url: https://cdn.jsdmirror.com/gh/perfect-panel/rules/rule/Clash/Google/Google_No_Resolve.yaml\n interval: 86400\n Microsoft:\n type: http\n behavior: classical\n format: yaml\n url: https://cdn.jsdmirror.com/gh/perfect-panel/rules/rule/Clash/Microsoft/Microsoft.yaml\n interval: 86400\n Github:\n type: http\n behavior: classical\n format: yaml\n url: https://cdn.jsdmirror.com/gh/perfect-panel/rules/rule/Clash/GitHub/GitHub.yaml\n interval: 86400\n HBO:\n type: http\n behavior: classical\n format: yaml\n url: https://cdn.jsdmirror.com/gh/perfect-panel/rules/rule/Clash/HBO/HBO.yaml\n interval: 86400\n Disney:\n type: http\n behavior: classical\n format: yaml\n url: https://cdn.jsdmirror.com/gh/perfect-panel/rules/rule/Clash/Disney/Disney.yaml\n interval: 86400\n TikTok:\n type: http\n behavior: classical\n format: yaml\n url: https://cdn.jsdmirror.com/gh/perfect-panel/rules/rule/Clash/TikTok/TikTok.yaml\n interval: 86400\n Netflix:\n type: http\n behavior: classical\n format: yaml\n url: https://cdn.jsdmirror.com/gh/perfect-panel/rules/rule/Clash/Netflix/Netflix.yaml\n interval: 86400\n GlobalMedia:\n type: http\n behavior: classical\n format: yaml\n url: https://cdn.jsdmirror.com/gh/perfect-panel/rules/rule/Clash/GlobalMedia/GlobalMedia_Classical_No_Resolve.yaml\n interval: 86400\n Telegram:\n type: http\n behavior: classical\n format: yaml\n url: https://cdn.jsdmirror.com/gh/perfect-panel/rules/rule/Clash/Telegram/Telegram_No_Resolve.yaml\n interval: 86400\n OpenAI:\n type: http\n behavior: classical\n format: yaml\n url: https://cdn.jsdmirror.com/gh/perfect-panel/rules/rule/Clash/OpenAI/OpenAI.yaml\n interval: 86400\n Gemini:\n type: http\n behavior: classical\n format: yaml\n url: https://cdn.jsdmirror.com/gh/perfect-panel/rules/rule/Clash/Gemini/Gemini.yaml\n interval: 86400\n Copilot:\n type: http\n behavior: classical\n format: yaml\n url: https://cdn.jsdmirror.com/gh/perfect-panel/rules/rule/Clash/Copilot/Copilot.yaml\n interval: 86400\n Claude:\n type: http\n behavior: classical\n format: yaml\n url: https://cdn.jsdmirror.com/gh/perfect-panel/rules/rule/Clash/Claude/Claude.yaml\n interval: 86400\n Crypto:\n type: http\n behavior: classical\n format: yaml\n url: https://cdn.jsdmirror.com/gh/perfect-panel/rules/rule/Clash/Crypto/Crypto.yaml\n interval: 86400\n Cryptocurrency:\n type: http\n behavior: classical\n format: yaml\n url: https://cdn.jsdmirror.com/gh/perfect-panel/rules/rule/Clash/Cryptocurrency/Cryptocurrency.yaml\n interval: 86400\n Game:\n type: http\n behavior: classical\n format: yaml\n url: https://cdn.jsdmirror.com/gh/perfect-panel/rules/rule/Clash/Game/Game.yaml\n interval: 86400\n Global:\n type: http\n behavior: classical\n format: yaml\n url: https://cdn.jsdmirror.com/gh/perfect-panel/rules/rule/Clash/Global/Global_Classical_No_Resolve.yaml\n interval: 86400\n ChinaMax:\n type: http\n behavior: classical\n format: yaml\n url: https://cdn.jsdmirror.com/gh/perfect-panel/rules/rule/Clash/ChinaMax/ChinaMax_Classical_No_Resolve.yaml\n interval: 86400\n Lan:\n type: http\n behavior: classical\n format: yaml\n url: https://cdn.jsdmirror.com/gh/perfect-panel/rules/rule/Clash/Lan/Lan.yaml\n interval: 86400\n\nurl-rewrite:\n - ^https?:\\/\\/(www.)?g\\.cn https://www.google.com 302\n - ^https?:\\/\\/(www.)?google\\.cn https://www.google.com 302\n', 'yaml', '{}', '2025-08-12 23:10:00.487', '2025-08-15 22:01:27.031'); +INSERT INTO `subscribe_application` (`id`, `name`, `icon`, `description`, `scheme`, `user_agent`, `is_default`, `subscribe_template`, `output_format`, `download_link`, `created_at`, `updated_at`) VALUES (4, 'SingBox', '', '', 'sing-box://import-remote-profile?url=${encodeURIComponent(url)}#${name}', 'sing-box', 0, '{{- $GiB := 1073741824.0 -}}\n{{- $used := printf \"%.2f\" (divf (add (.UserInfo.Download | default 0 | float64) (.UserInfo.Upload | default 0 | float64)) $GiB) -}}\n{{- $traffic := (.UserInfo.Traffic | default 0 | float64) -}}\n{{- $total := printf \"%.2f\" (divf $traffic $GiB) -}}\n\n{{- $ExpiredAt := \"\" -}}\n{{- $expStr := printf \"%v\" .UserInfo.ExpiredAt -}}\n{{- if regexMatch `^[0-9]+$` $expStr -}}\n {{- $ts := $expStr | float64 -}}\n {{- $sec := ternary (divf $ts 1000.0) $ts (ge (len $expStr) 13) -}}\n {{- $ExpiredAt = (date \"2006-01-02 15:04:05\" (unixEpoch ($sec | int64))) -}}\n{{- else -}}\n {{- $ExpiredAt = $expStr -}}\n{{- end -}}\n\n{{- $sortFields := list \"Sort\" \"Port\" \"Name\" -}}\n{{- $sortConfig := dict \"Sort\" \"asc\" \"Port\" \"asc\" \"Name\" \"asc\" -}}\n{{- $byKey := dict -}}\n{{- range $p := .Proxies -}}\n {{- $keyParts := list -}}\n {{- range $field := $sortFields -}}\n {{- $order := index $sortConfig $field -}}\n {{- $val := default \"\" (printf \"%v\" (index $p $field)) -}}\n {{- if or (eq $field \"Sort\") (eq $field \"Port\") -}}\n {{- $val = printf \"%08d\" (int (default 0 (index $p $field))) -}}\n {{- end -}}\n {{- if eq $order \"desc\" -}}\n {{- $val = printf \"~%s\" $val -}}\n {{- end -}}\n {{- $keyParts = append $keyParts $val -}}\n {{- end -}}\n {{- $_ := set $byKey (join \"|\" $keyParts) $p -}}\n{{- end -}}\n{{- $sorted := list -}}\n{{- range $k := sortAlpha (keys $byKey) -}}\n {{- $sorted = append $sorted (index $byKey $k) -}}\n{{- end -}}\n\n{{- $supportedProxies := list -}}\n{{- range $proxy := $sorted -}}\n {{- $isSupported := false -}}\n {{- if or (eq $proxy.Type \"shadowsocks\") (eq $proxy.Type \"vmess\") (eq $proxy.Type \"trojan\") (eq $proxy.Type \"hysteria2\") (eq $proxy.Type \"hy2\") (eq $proxy.Type \"tuic\") (eq $proxy.Type \"anytls\") -}}\n {{- $isSupported = true -}}\n {{- else if eq $proxy.Type \"vless\" -}}\n {{- if or (eq $proxy.Transport \"ws\") (eq $proxy.Transport \"websocket\") (eq $proxy.Transport \"grpc\") (eq $proxy.Transport \"tcp\") (not $proxy.Transport) -}}\n {{- $isSupported = true -}}\n {{- end -}}\n {{- end -}}\n {{- if $isSupported -}}\n {{- $supportedProxies = append $supportedProxies $proxy -}}\n {{- end -}}\n{{- end -}}\n\n{{- define \"AllNodeNames\" -}}\n{{- $sortConfig := dict \"Sort\" \"asc\" -}}\n{{- $byKey := dict -}}\n{{- range $p := .Proxies -}}\n {{- $keyParts := list -}}\n {{- range $field, $order := $sortConfig -}}\n {{- $val := default \"\" (printf \"%v\" (index $p $field)) -}}\n {{- if or (eq $field \"Sort\") (eq $field \"Port\") -}}\n {{- $val = printf \"%08d\" (int (default 0 (index $p $field))) -}}\n {{- end -}}\n {{- if eq $order \"desc\" -}}\n {{- $val = printf \"~%s\" $val -}}\n {{- end -}}\n {{- $keyParts = append $keyParts $val -}}\n {{- end -}}\n {{- $_ := set $byKey (join \"|\" $keyParts) $p -}}\n{{- end -}}\n{{- $sorted := list -}}\n{{- range $k := sortAlpha (keys $byKey) -}}\n {{- $sorted = append $sorted (index $byKey $k) -}}\n{{- end -}}\n{{- $supportedProxies := list -}}\n{{- range $proxy := $sorted -}}\n {{- $isSupported := false -}}\n {{- if or (eq .Type \"shadowsocks\") (eq .Type \"vmess\") (eq .Type \"trojan\") (eq .Type \"hysteria2\") (eq .Type \"hy2\") (eq .Type \"tuic\") (eq .Type \"anytls\") -}}\n {{- $isSupported = true -}}\n {{- else if eq .Type \"vless\" -}}\n {{- if or (eq .Transport \"ws\") (eq .Transport \"websocket\") (eq .Transport \"grpc\") (eq .Transport \"tcp\") (not .Transport) -}}\n {{- $isSupported = true -}}\n {{- end -}}\n {{- end -}}\n {{- if $isSupported -}}\n {{- $supportedProxies = append $supportedProxies . -}}\n {{- end -}}\n{{- end -}}\n{{- $first := true -}}\n{{- range $supportedProxies -}}\n {{- if $first -}}\n \"{{ .Name }}\"\n {{- $first = false -}}\n {{- else -}}\n , \"{{ .Name }}\"\n {{- end -}}\n{{- end -}}\n{{- end -}}\n\n{{- define \"NodeOutbound\" -}}\n{{- $proxy := .proxy -}}\n{{- $server := $proxy.Server -}}\n{{- if and (contains $server \":\") (not (hasPrefix \"[\" $server)) -}}\n {{- $server = printf \"[%s]\" $server -}}\n{{- end -}}\n{{- $port := $proxy.Port -}}\n{{- $name := $proxy.Name -}}\n{{- $pwd := $.UserInfo.Password -}}\n{{- $sni := or $proxy.SNI $server }}\n{{- $svc := $proxy.ServiceName }}\n\n{{- $tlsOpts := \"\" -}}\n{{- if or $sni $proxy.AllowInsecure $proxy.Fingerprint -}}\n {{- $tlsOpts = \"\\\"tls\\\": {\\\"enabled\\\": true\" -}}\n {{- if $sni -}}\n {{- $tlsOpts = printf \"%s, \\\"server_name\\\": \\\"%s\\\"\" $tlsOpts $sni -}}\n {{- end -}}\n {{- if $proxy.AllowInsecure -}}\n {{- $tlsOpts = printf \"%s, \\\"insecure\\\": true\" $tlsOpts -}}\n {{- end -}}\n {{- if $proxy.Fingerprint -}}\n {{- $tlsOpts = printf \"%s, \\\"utls\\\": {\\\"enabled\\\": true, \\\"fingerprint\\\": \\\"%s\\\"}\" $tlsOpts ($proxy.Fingerprint) -}}\n {{- end -}}\n {{- $tlsOpts = printf \"%s}\" $tlsOpts -}}\n{{- end -}}\n\n{{- $transportOpts := \"\" -}}\n{{- if or (eq $proxy.Transport \"ws\") (eq $proxy.Transport \"websocket\") -}}\n {{- $wsPath := default \"/\" $proxy.Path -}}\n {{- $transportOpts = printf \"\\\"transport\\\": {\\\"type\\\": \\\"ws\\\", \\\"path\\\": \\\"%s\\\"\" $wsPath -}}\n {{- if $proxy.Host -}}\n {{- $transportOpts = printf \"%s, \\\"headers\\\": {\\\"Host\\\": \\\"%s\\\"}\" $transportOpts ($proxy.Host) -}}\n {{- end -}}\n {{- $transportOpts = printf \"%s}\" $transportOpts -}}\n{{- else if eq $proxy.Transport \"grpc\" -}}\n {{- $grpcService := default \"grpc\" $svc -}}\n {{- $transportOpts = printf \"\\\"transport\\\": {\\\"type\\\": \\\"grpc\\\", \\\"service_name\\\": \\\"%s\\\"}\" $grpcService -}}\n{{- end -}}\n\n{{- if eq $proxy.Type \"shadowsocks\" -}}\n {{- $method := default \"aes-128-gcm\" $proxy.Method -}}\n {{- $password := $pwd -}}\n {{- if $proxy.ServerKey -}}\n {{- $needBytes := ternary 16 32 (eq $proxy.Method \"2022-blake3-aes-128-gcm\") -}}\n {{- $cutLen := min $needBytes (len $pwd) | int -}}\n {{- $userCut := $pwd | trunc $cutLen -}}\n {{- $serverB64 := b64enc $proxy.ServerKey -}}\n {{- $userB64 := b64enc $userCut -}}\n {{- $password = printf \"%s:%s\" $serverB64 $userB64 -}}\n {{- end -}}\n{ \"type\": \"shadowsocks\", \"tag\": \"{{ $name }}\", \"server\": \"{{ $server }}\", \"server_port\": {{ $port }}, \"method\": \"{{ $method }}\", \"password\": \"{{ $password }}\" }\n\n{{- else if eq $proxy.Type \"trojan\" -}}\n{ \"type\": \"trojan\", \"tag\": \"{{ $name }}\", \"server\": \"{{ $server }}\", \"server_port\": {{ $port }}, \"password\": \"{{ $pwd }}\"{{ if $transportOpts }}, {{ $transportOpts }}{{ end }}, {{ $tlsOpts }} }\n\n{{- else if eq $proxy.Type \"vless\" -}}\n{{- $realityOpts := \"\" -}}\n{{- if $proxy.RealityPublicKey -}}\n {{- $realityOpts = printf \"\\\"reality\\\": { \\\"enabled\\\": true, \\\"public_key\\\": \\\"%s\\\"\" ($proxy.RealityPublicKey) -}}\n {{- if $proxy.RealityShortId -}}\n {{- $realityOpts = printf \"%s, \\\"short_id\\\": \\\"%s\\\"\" $realityOpts ($proxy.RealityShortId) -}}\n {{- end -}}\n {{- if $svc -}}\n {{- $realityOpts = printf \"%s, \\\"server_name\\\": \\\"%s\\\"\" $realityOpts ($svc) -}}\n {{- end -}}\n {{- $realityOpts = printf \"%s }\" $realityOpts -}}\n{{- end -}}\n{{- $flowOpts := \"\" -}}\n{{- if $proxy.Flow -}}\n {{- $flowOpts = printf \", \\\"flow\\\": \\\"%s\\\"\" ($proxy.Flow) -}}\n{{- end -}}\n{ \"type\": \"vless\", \"tag\": \"{{ $name }}\", \"server\": \"{{ $server }}\", \"server_port\": {{ $port }}, \"uuid\": \"{{ $pwd }}\"{{ $flowOpts }}{{ if $transportOpts }}, {{ $transportOpts }}{{ end }}{{ if $realityOpts }}, {{ $realityOpts }}{{ else if $tlsOpts }}, {{ $tlsOpts }}{{ end }} }\n\n{{- else if eq $proxy.Type \"vmess\" -}}\n{{- $vmessTLS := \"\" -}}\n{{- if and $tlsOpts (ne $proxy.Transport \"tcp\") -}}\n {{- $vmessTLS = $tlsOpts -}}\n{{- end -}}\n{ \"type\": \"vmess\", \"tag\": \"{{ $name }}\", \"server\": \"{{ $server }}\", \"server_port\": {{ $port }}, \"uuid\": \"{{ $pwd }}\", \"security\": \"auto\"{{ if $transportOpts }}, {{ $transportOpts }}{{ end }}{{ if $vmessTLS }}, {{ $vmessTLS }}{{ end }} }\n\n{{- else if or (eq $proxy.Type \"hysteria2\") (eq $proxy.Type \"hy2\") -}}\n{{- $obfsOpts := \"\" -}}\n{{- if $proxy.ObfsPassword -}}\n {{- $obfsOpts = printf \"\\\"obfs\\\": { \\\"type\\\": \\\"salamander\\\", \\\"password\\\": \\\"%s\\\" }\" ($proxy.ObfsPassword) -}}\n{{- end -}}\n{{- $hopPortsOpts := \"\" -}}\n{{- if $proxy.HopPorts -}}\n {{- $hopPortsOpts = printf \", \\\"ports\\\": \\\"%s\\\"\" ($proxy.HopPorts) -}}\n{{- end -}}\n{{- $hopIntervalOpts := \"\" -}}\n{{- if $proxy.HopInterval -}}\n {{- $hopIntervalOpts = printf \", \\\"hop_interval\\\": %v\" $proxy.HopInterval -}}\n{{- end -}}\n{ \"type\": \"hysteria2\", \"tag\": \"{{ $name }}\", \"server\": \"{{ $server }}\", \"server_port\": {{ $port }}, \"password\": \"{{ $pwd }}\"{{ if $obfsOpts }}, {{ $obfsOpts }}{{ end }}{{ $hopPortsOpts }}{{ $hopIntervalOpts }}, {{ $tlsOpts }} }\n\n{{- else if eq $proxy.Type \"tuic\" -}}\n{{- $tuicServerKey := $proxy.ServerKey -}}\n{{- $tuicOpts := \"\" -}}\n{{- if $proxy.DisableSNI -}}\n {{- $tuicOpts = printf \"%s, \\\"disable_sni\\\": %v\" $tuicOpts $proxy.DisableSNI -}}\n{{- end -}}\n{{- if $proxy.ReduceRtt -}}\n {{- $tuicOpts = printf \"%s, \\\"reduce_rtt\\\": %v\" $tuicOpts $proxy.ReduceRtt -}}\n{{- end -}}\n{{- if $proxy.UDPRelayMode -}}\n {{- $tuicOpts = printf \"%s, \\\"udp_relay_mode\\\": \\\"%s\\\"\" $tuicOpts ($proxy.UDPRelayMode) -}}\n{{- end -}}\n{{- if $proxy.CongestionController -}}\n {{- $tuicOpts = printf \"%s, \\\"congestion_control\\\": \\\"%s\\\"\" $tuicOpts ($proxy.CongestionController) -}}\n{{- end -}}\n{ \"type\": \"tuic\", \"tag\": \"{{ $name }}\", \"server\": \"{{ $server }}\", \"server_port\": {{ $port }}, \"uuid\": \"{{ $tuicServerKey }}\", \"password\": \"{{ $pwd }}\"{{ $tuicOpts }}, \"alpn\": [\"h3\"], {{ $tlsOpts }} }\n\n{{- else if eq $proxy.Type \"anytls\" -}}\n{{- $anytlsOpts := \"\" -}}\n{{- if $proxy.Method -}}\n {{- $anytlsOpts = printf \"%s, \\\"method\\\": \\\"%s\\\"\" $anytlsOpts ($proxy.Method) -}}\n{{- end -}}\n{{- if $proxy.ObfsPassword -}}\n {{- $anytlsOpts = printf \"%s, \\\"obfs\\\": \\\"%s\\\"\" $anytlsOpts ($proxy.ObfsPassword) -}}\n{{- end -}}\n{{- if $proxy.Path -}}\n {{- $anytlsOpts = printf \"%s, \\\"path\\\": \\\"%s\\\"\" $anytlsOpts ($proxy.Path) -}}\n{{- end -}}\n{{- if $proxy.Host -}}\n {{- $anytlsOpts = printf \"%s, \\\"host\\\": \\\"%s\\\"\" $anytlsOpts ($proxy.Host) -}}\n{{- end -}}\n{ \"type\": \"anytls\", \"tag\": \"{{ $name }}\", \"server\": \"{{ $server }}\", \"server_port\": {{ $port }}, \"password\": \"{{ $pwd }}\"{{ $anytlsOpts }}{{ if $tlsOpts }}, {{ $tlsOpts }}{{ end }} }\n\n{{- else if eq $proxy.Type \"wireguard\" -}}\n{{- $wgPrivateKey := $proxy.ServerKey -}}\n{{- $wgPublicKey := $proxy.RealityPublicKey -}}\n{{- $wgPreSharedOpts := \"\" -}}\n{{- if $proxy.Path -}}\n {{- $wgPreSharedOpts = printf \", \\\"pre_shared_key\\\": \\\"%s\\\"\" ($proxy.Path) -}}\n{{- end -}}\n{{- $wgLocalAddressOpts := \"\" -}}\n{{- if $proxy.RealityServerAddr -}}\n {{- $wgLocalAddressOpts = printf \", \\\"local_address\\\": [\\\"%s\\\"]\" ($proxy.RealityServerAddr) -}}\n{{- end -}}\n{ \"type\": \"wireguard\", \"tag\": \"{{ $name }}\", \"server\": \"{{ $server }}\", \"server_port\": {{ $port }}, \"private_key\": \"{{ $wgPrivateKey }}\", \"peer_public_key\": \"{{ $wgPublicKey }}\"{{ $wgPreSharedOpts }}{{ $wgLocalAddressOpts }} }\n\n{{- else if or (eq $proxy.Type \"http\") (eq $proxy.Type \"https\") -}}\n{{- $httpsTLSOpts := \"\" -}}\n{{- if and (eq $proxy.Type \"https\") $tlsOpts -}}\n {{- $httpsTLSOpts = printf \", %s\" $tlsOpts -}}\n{{- end -}}\n{ \"type\": \"http\", \"tag\": \"{{ $name }}\", \"server\": \"{{ $server }}\", \"server_port\": {{ $port }}, \"username\": \"{{ $pwd }}\", \"password\": \"{{ $pwd }}\"{{ $httpsTLSOpts }} }\n\n{{- else if or (eq $proxy.Type \"socks\") (eq $proxy.Type \"socks5\") -}}\n{ \"type\": \"socks\", \"tag\": \"{{ $name }}\", \"server\": \"{{ $server }}\", \"server_port\": {{ $port }}, \"version\": \"5\", \"username\": \"{{ $pwd }}\", \"password\": \"{{ $pwd }}\" }\n\n{{- else -}}\n{ \"type\": \"direct\", \"tag\": \"{{ $name }}\" }\n{{- end -}}\n{{- end -}}\n\n// 用户信息: 已用流量 {{ $used }}GB / 总流量 {{ $total }}GB, 过期时间: {{ $ExpiredAt }}\n{\n \"log\": {\n \"level\": \"info\",\n \"timestamp\": true\n },\n \"experimental\": {\n \"cache_file\": {\n \"enabled\": true,\n \"store_fakeip\": true,\n \"store_rdrc\": true\n },\n \"clash_api\": {\n \"external_controller\": \"127.0.0.1:9090\",\n \"access_control_allow_origin\": [\n \"http://127.0.0.1\",\n \"https://yacd.metacubex.one\",\n \"https://metacubex.github.io\",\n \"https://metacubexd.pages.dev\",\n \"https://board.zash.run.place\"\n ]\n }\n },\n \"dns\": {\n \"independent_cache\": true,\n \"servers\": [\n {\n \"tag\": \"google\",\n \"type\": \"https\",\n \"server\": \"8.8.8.8\",\n \"detour\": \"节点选择\"\n },\n {\n \"tag\": \"ali\",\n \"type\": \"https\",\n \"server\": \"223.5.5.5\"\n },\n {\n \"tag\": \"fakeip\",\n \"type\": \"fakeip\",\n \"inet4_range\": \"198.18.0.0/15\",\n \"inet6_range\": \"fc00::/18\"\n }\n ],\n \"rules\": [\n {\n \"clash_mode\": \"Direct\",\n \"server\": \"ali\"\n },\n {\n \"clash_mode\": \"Global\",\n \"server\": \"google\"\n },\n {\n \"query_type\": [\n \"A\",\n \"AAAA\"\n ],\n \"server\": \"fakeip\"\n },\n {\n \"rule_set\": \"geosite-cn\",\n \"server\": \"ali\"\n }\n ]\n },\n \"inbounds\": [\n {\n \"type\": \"tun\",\n \"address\": [\n \"172.18.0.1/30\",\n \"fdfe:dcba:9876::1/126\"\n ],\n \"auto_route\": true,\n \"strict_route\": true\n },\n {\n \"type\": \"mixed\",\n \"listen\": \"::\",\n \"listen_port\": 7890\n }\n ],\n \"outbounds\": [\n {\n \"tag\": \"节点选择\",\n \"type\": \"selector\",\n \"outbounds\": [{{ template \"AllNodeNames\" . }}, \"直连\"]\n },\n {\n \"tag\": \"Github\",\n \"type\": \"selector\",\n \"outbounds\": [\n \"节点选择\",\n \"直连\",\n {{ template \"AllNodeNames\" . }}\n ]\n },\n {\n \"tag\": \"Google\",\n \"type\": \"selector\",\n \"outbounds\": [\n \"节点选择\",\n \"直连\",\n {{ template \"AllNodeNames\" . }}\n ]\n },\n {\n \"tag\": \"Microsoft\",\n \"type\": \"selector\",\n \"outbounds\": [\n \"节点选择\",\n \"直连\",\n {{ template \"AllNodeNames\" . }}\n ]\n },\n {\n \"tag\": \"OpenAI\",\n \"type\": \"selector\",\n \"outbounds\": [\n \"节点选择\",\n \"直连\",\n {{ template \"AllNodeNames\" . }}\n ]\n },\n {\n \"tag\": \"Telegram\",\n \"type\": \"selector\",\n \"outbounds\": [\n \"节点选择\",\n \"直连\",\n {{ template \"AllNodeNames\" . }}\n ]\n },\n {\n \"tag\": \"Twitter\",\n \"type\": \"selector\",\n \"outbounds\": [\n \"节点选择\",\n \"直连\",\n {{ template \"AllNodeNames\" . }}\n ]\n },\n {\n \"tag\": \"Youtube\",\n \"type\": \"selector\",\n \"outbounds\": [\n \"节点选择\",\n \"直连\",\n {{ template \"AllNodeNames\" . }}\n ]\n },\n {\n \"tag\": \"国内\",\n \"type\": \"selector\",\n \"outbounds\": [\n \"直连\",\n \"节点选择\",\n {{ template \"AllNodeNames\" . }}\n ]\n },\n {{- range $i, $proxy := $supportedProxies }}\n {{ if $i }},{{ end }}\n {{ template \"NodeOutbound\" (dict \"proxy\" $proxy \"UserInfo\" $.UserInfo) }}\n {{- end }}\n {{- if gt (len $supportedProxies) 0 }},{{ end }}\n {\n \"tag\": \"直连\",\n \"type\": \"direct\"\n }\n ],\n \"route\": {\n \"default_domain_resolver\": {\n \"server\": \"ali\"\n },\n \"auto_detect_interface\": true,\n \"rules\": [\n {\n \"action\": \"sniff\"\n },\n {\n \"protocol\": \"dns\",\n \"action\": \"hijack-dns\"\n },\n {\n \"ip_is_private\": true,\n \"outbound\": \"直连\"\n },\n {\n \"rule_set\": \"anti-ad\",\n \"clash_mode\": \"Rule\",\n \"action\": \"reject\"\n },\n {\n \"clash_mode\": \"Direct\",\n \"outbound\": \"直连\"\n },\n {\n \"clash_mode\": \"Global\",\n \"outbound\": \"节点选择\"\n },\n {\n \"rule_set\": \"geosite-github\",\n \"outbound\": \"Github\"\n },\n {\n \"rule_set\": [\n \"geoip-google\",\n \"geosite-google\"\n ],\n \"outbound\": \"Google\"\n },\n {\n \"rule_set\": \"geosite-microsoft\",\n \"outbound\": \"Microsoft\"\n },\n {\n \"rule_set\": \"geosite-openai\",\n \"outbound\": \"OpenAI\"\n },\n {\n \"rule_set\": [\n \"geoip-telegram\",\n \"geosite-telegram\"\n ],\n \"outbound\": \"Telegram\"\n },\n {\n \"rule_set\": [\n \"geoip-twitter\",\n \"geosite-twitter\"\n ],\n \"outbound\": \"Twitter\"\n },\n {\n \"rule_set\": \"geosite-youtube\",\n \"outbound\": \"Youtube\"\n },\n {\n \"rule_set\": [\n \"geoip-cn\",\n \"geosite-cn\"\n ],\n \"outbound\": \"国内\"\n }\n ],\n \"rule_set\": [\n {\n \"tag\": \"anti-ad\",\n \"type\": \"remote\",\n \"format\": \"binary\",\n \"url\": \"https://anti-ad.net/anti-ad-sing-box.srs\",\n \"download_detour\": \"直连\"\n },\n {\n \"tag\": \"geosite-github\",\n \"type\": \"remote\",\n \"format\": \"binary\",\n \"url\": \"https://cdn.jsdmirror.com/gh/perfect-panel/rules/geo/geosite/github.srs\",\n \"download_detour\": \"直连\"\n },\n {\n \"tag\": \"geoip-google\",\n \"type\": \"remote\",\n \"format\": \"binary\",\n \"url\": \"https://cdn.jsdmirror.com/gh/perfect-panel/rules/geo/geoip/google.srs\",\n \"download_detour\": \"直连\"\n },\n {\n \"tag\": \"geosite-google\",\n \"type\": \"remote\",\n \"format\": \"binary\",\n \"url\": \"https://cdn.jsdmirror.com/gh/perfect-panel/rules/geo/geosite/google.srs\",\n \"download_detour\": \"直连\"\n },\n {\n \"tag\": \"geosite-microsoft\",\n \"type\": \"remote\",\n \"format\": \"binary\",\n \"url\": \"https://cdn.jsdmirror.com/gh/perfect-panel/rules/geo/geosite/microsoft.srs\",\n \"download_detour\": \"直连\"\n },\n {\n \"tag\": \"geosite-openai\",\n \"type\": \"remote\",\n \"format\": \"binary\",\n \"url\": \"https://cdn.jsdmirror.com/gh/perfect-panel/rules/geo/geosite/openai.srs\",\n \"download_detour\": \"直连\"\n },\n {\n \"tag\": \"geoip-telegram\",\n \"type\": \"remote\",\n \"format\": \"binary\",\n \"url\": \"https://cdn.jsdmirror.com/gh/perfect-panel/rules/geo/geoip/telegram.srs\",\n \"download_detour\": \"直连\"\n },\n {\n \"tag\": \"geosite-telegram\",\n \"type\": \"remote\",\n \"format\": \"binary\",\n \"url\": \"https://cdn.jsdmirror.com/gh/perfect-panel/rules/geo/geosite/telegram.srs\",\n \"download_detour\": \"直连\"\n },\n {\n \"tag\": \"geoip-twitter\",\n \"type\": \"remote\",\n \"format\": \"binary\",\n \"url\": \"https://cdn.jsdmirror.com/gh/perfect-panel/rules/geo/geoip/twitter.srs\",\n \"download_detour\": \"直连\"\n },\n {\n \"tag\": \"geosite-twitter\",\n \"type\": \"remote\",\n \"format\": \"binary\",\n \"url\": \"https://cdn.jsdmirror.com/gh/perfect-panel/rules/geo/geosite/twitter.srs\",\n \"download_detour\": \"直连\"\n },\n {\n \"tag\": \"geosite-youtube\",\n \"type\": \"remote\",\n \"format\": \"binary\",\n \"url\": \"https://cdn.jsdmirror.com/gh/perfect-panel/rules/geo/geosite/youtube.srs\",\n \"download_detour\": \"直连\"\n },\n {\n \"tag\": \"geosite-cn\",\n \"type\": \"remote\",\n \"format\": \"binary\",\n \"url\": \"https://cdn.jsdmirror.com/gh/perfect-panel/rules/geo/geosite/cn.srs\",\n \"download_detour\": \"直连\"\n },\n {\n \"tag\": \"geoip-cn\",\n \"type\": \"remote\",\n \"format\": \"binary\",\n \"url\": \"https://cdn.jsdmirror.com/gh/perfect-panel/rules/geo/geoip/cn.srs\",\n \"download_detour\": \"直连\"\n }\n ]\n }\n}', 'json', '{}', '2025-08-12 23:30:10.016', '2025-08-15 22:01:10.801'); +INSERT INTO `subscribe_application` (`id`, `name`, `icon`, `description`, `scheme`, `user_agent`, `is_default`, `subscribe_template`, `output_format`, `download_link`, `created_at`, `updated_at`) VALUES (5, 'Surge', '', '', 'surge:///install-config?url=${encodeURIComponent(url)}', 'Surge', 0, '{{- $GiB := 1073741824.0 -}}\n{{- $used := printf \"%.2f\" (divf (add (.UserInfo.Download | default 0 | float64) (.UserInfo.Upload | default 0 | float64)) $GiB) -}}\n{{- $traffic := (.UserInfo.Traffic | default 0 | float64) -}}\n{{- $total := printf \"%.2f\" (divf $traffic $GiB) -}}\n\n{{- $ExpiredAt := \"\" -}}\n{{- $expStr := printf \"%v\" .UserInfo.ExpiredAt -}}\n{{- if regexMatch `^[0-9]+$` $expStr -}}\n {{- $ts := $expStr | float64 -}}\n {{- $sec := ternary (divf $ts 1000.0) $ts (ge (len $expStr) 13) -}}\n {{- $ExpiredAt = (date \"2006-01-02 15:04:05\" (unixEpoch ($sec | int64))) -}}\n{{- else -}}\n {{- $ExpiredAt = $expStr -}}\n{{- end -}}\n\n{{- $sortFields := list \"Sort\" \"Port\" \"Name\" -}}\n{{- $sortConfig := dict \"Sort\" \"asc\" \"Port\" \"asc\" \"Name\" \"asc\" -}}\n{{- $byKey := dict -}}\n{{- range $p := .Proxies -}}\n {{- $keyParts := list -}}\n {{- range $field := $sortFields -}}\n {{- $order := index $sortConfig $field -}}\n {{- $val := default \"\" (printf \"%v\" (index $p $field)) -}}\n {{- if or (eq $field \"Sort\") (eq $field \"Port\") -}}\n {{- $val = printf \"%08d\" (int (default 0 (index $p $field))) -}}\n {{- end -}}\n {{- if eq $order \"desc\" -}}\n {{- $val = printf \"~%s\" $val -}}\n {{- end -}}\n {{- $keyParts = append $keyParts $val -}}\n {{- end -}}\n {{- $_ := set $byKey (join \"|\" $keyParts) $p -}}\n{{- end -}}\n{{- $sorted := list -}}\n{{- range $k := sortAlpha (keys $byKey) -}}\n {{- $sorted = append $sorted (index $byKey $k) -}}\n{{- end -}}\n\n{{- $supportSet := dict \"shadowsocks\" true \"vmess\" true \"vless\" true \"trojan\" true \"hysteria2\" true \"hysteria\" true \"tuic\" true \"wireguard\" true -}}\n{{- $supportedProxies := list -}}\n{{- range $proxy := $sorted -}}\n {{- if hasKey $supportSet $proxy.Type -}}\n {{- $supportedProxies = append $supportedProxies $proxy -}}\n {{- end -}}\n{{- end -}}\n\n{{- $proxyNames := \"\" -}}\n{{- range $proxy := $supportedProxies -}}\n {{- if eq $proxyNames \"\" -}}\n {{- $proxyNames = $proxy.Name -}}\n {{- else -}}\n {{- $proxyNames = printf \"%s, %s\" $proxyNames $proxy.Name -}}\n {{- end -}}\n{{- end -}}\n\n# {{ .SiteName }}-{{ .SubscribeName }}\n# Traffic: {{ $used }} GiB/{{ $total }} GiB | Expires: {{ $ExpiredAt }}\n# Generated at: {{ now | date \"2006-01-02 15:04:05\" }}\n\n#!MANAGED-CONFIG {{ .UserInfo.SubscribeURL }} interval=86400 strict=true\n\n[General]\n# 日志级别\nloglevel = notify\n\n# 外部控制器访问\nexternal-controller-access = perlnk@0.0.0.0:6170\n\n# 网络设置\nexclude-simple-hostnames = true\nshow-error-page-for-reject = true\nudp-priority = true\nudp-policy-not-supported-behaviour = reject\nipv6 = true\nipv6-vif = auto\n\n# 连接测试\nproxy-test-url = http://www.gstatic.com/generate_204\ninternet-test-url = http://www.gstatic.com/generate_204\ntest-timeout = 5\n\n# DNS 设置\ndns-server = system, 119.29.29.29, 223.5.5.5\nencrypted-dns-server = https://dns.alidns.com/dns-query\nhijack-dns = 8.8.8.8:53, 8.8.4.4:53, 1.1.1.1:53, 1.0.0.1:53\n\n# 跳过代理\nskip-proxy = 192.168.0.0/16, 10.0.0.0/8, 172.16.0.0/12, 127.0.0.0/8, localhost, *.local\n\n# 真实 IP\nalways-real-ip = *.lan, lens.l.google.com, *.srv.nintendo.net, *.stun.playstation.net, *.xboxlive.com, xbox.*.*.microsoft.com, *.msftncsi.com, *.msftconnecttest.com\n\n# Surge Mac 参数\nhttp-listen = 0.0.0.0:6088\nsocks5-listen = 0.0.0.0:6089\n\n# Surge iOS 参数(WiFi 共享)\nallow-wifi-access = true\nallow-hotspot-access = true\nwifi-access-http-port = 6088\nwifi-access-socks5-port = 6089\n\n[Panel]\nSubscribeInfo = title={{ .SiteName }} - {{ .SubscribeName }}, content=已用流量: {{ $used }} GiB/{{ $total }} GiB \\n到期时间: {{ $ExpiredAt}}, style=info\n\n[Proxy]\n{{- range $proxy := $supportedProxies }}\n {{- $common := \"udp-relay=true, tfo=true\" -}}\n\n {{- $server := $proxy.Server -}}\n {{- if and (contains $server \":\") (not (hasPrefix \"[\" $server)) -}}\n {{- $server = printf \"[%s]\" $server -}}\n {{- end -}}\n\n {{- $password := $.UserInfo.Password -}}\n {{- if and (eq $proxy.Type \"shadowsocks\") (ne (default \"\" $proxy.ServerKey) \"\") -}}\n {{- $method := $proxy.Method -}}\n {{- if or (hasPrefix \"2022-blake3-\" $method) (eq $method \"2022-blake3-aes-128-gcm\") (eq $method \"2022-blake3-aes-256-gcm\") -}}\n {{- $userKeyLen := ternary 16 32 (hasSuffix \"128-gcm\" $method) -}}\n {{- $pwdStr := printf \"%s\" $password -}}\n {{- $userKey := ternary $pwdStr (trunc $userKeyLen $pwdStr) (le (len $pwdStr) $userKeyLen) -}}\n {{- $serverB64 := b64enc $proxy.ServerKey -}}\n {{- $userB64 := b64enc $userKey -}}\n {{- $password = printf \"%s:%s\" $serverB64 $userB64 -}}\n {{- end -}}\n {{- end -}}\n\n {{- $SkipVerify := $proxy.AllowInsecure -}}\n\n {{- if eq $proxy.Type \"shadowsocks\" }}\n{{ $proxy.Name }} = ss, {{ $server }}, {{ $proxy.Port }}, encrypt-method={{ default \"aes-128-gcm\" $proxy.Method }}, password={{ $password }}{{- if ne (default \"\" $proxy.Obfs) \"\" }}, obfs={{ $proxy.Obfs }}{{- if ne (default \"\" $proxy.ObfsHost) \"\" }}, obfs-host={{ $proxy.ObfsHost }}{{- end }}{{- end }}, {{ $common }}\n {{- else if eq $proxy.Type \"vmess\" }}\n{{ $proxy.Name }} = vmess, {{ $server }}, {{ $proxy.Port }}, username={{ $password }}{{- if or (eq $proxy.Transport \"ws\") (eq $proxy.Transport \"websocket\") }}, ws=true{{- if ne (default \"\" $proxy.Path) \"\" }}, ws-path={{ $proxy.Path }}{{- end }}{{- if ne (default \"\" $proxy.Host) \"\" }}, ws-headers=\"Host:{{ $proxy.Host }}\"{{- end }}{{- else if eq $proxy.Transport \"grpc\" }}, grpc=true{{- if ne (default \"\" $proxy.ServiceName) \"\" }}, grpc-service-name={{ $proxy.ServiceName }}{{- end }}{{- end }}{{- if or (eq $proxy.Security \"tls\") (eq $proxy.Security \"reality\") }}, tls=true{{- end }}{{- if ne (default \"\" $proxy.SNI) \"\" }}, sni={{ $proxy.SNI }}{{- end }}{{- if $proxy.AllowInsecure }}, skip-cert-verify=true{{- end }}{{- if ne (default \"\" $proxy.Fingerprint) \"\" }}, fingerprint={{ $proxy.Fingerprint }}{{- end }}, {{ $common }}\n {{- else if eq $proxy.Type \"vless\" }}\n{{ $proxy.Name }} = vless, {{ $server }}, {{ $proxy.Port }}, username={{ $password }}{{- if or (eq $proxy.Transport \"ws\") (eq $proxy.Transport \"websocket\") }}, ws=true{{- if ne (default \"\" $proxy.Path) \"\" }}, ws-path={{ $proxy.Path }}{{- end }}{{- if ne (default \"\" $proxy.Host) \"\" }}, ws-headers=\"Host:{{ $proxy.Host }}\"{{- end }}{{- else if eq $proxy.Transport \"grpc\" }}, grpc=true{{- if ne (default \"\" $proxy.ServiceName) \"\" }}, grpc-service-name={{ $proxy.ServiceName }}{{- end }}{{- end }}{{- if ne (default \"\" $proxy.SNI) \"\" }}, sni={{ $proxy.SNI }}{{- end }}{{- if $proxy.AllowInsecure }}, skip-cert-verify=true{{- end }}{{- if ne (default \"\" $proxy.Flow) \"none\" }}, flow={{ $proxy.Flow }}{{- end }}, {{ $common }}\n {{- else if eq $proxy.Type \"trojan\" }}\n{{ $proxy.Name }} = trojan, {{ $server }}, {{ $proxy.Port }}, password={{ $password }}{{- if or (eq $proxy.Transport \"ws\") (eq $proxy.Transport \"websocket\") }}, ws=true{{- if ne (default \"\" $proxy.Path) \"\" }}, ws-path={{ $proxy.Path }}{{- end }}{{- if ne (default \"\" $proxy.Host) \"\" }}, ws-headers=\"Host:{{ $proxy.Host }}\"{{- end }}{{- else if eq $proxy.Transport \"grpc\" }}, grpc=true{{- if ne (default \"\" $proxy.ServiceName) \"\" }}, grpc-service-name={{ $proxy.ServiceName }}{{- end }}{{- end }}{{- if ne (default \"\" $proxy.SNI) \"\" }}, sni={{ $proxy.SNI }}{{- end }}{{- if $proxy.AllowInsecure }}, skip-cert-verify=true{{- end }}{{- if ne (default \"\" $proxy.Fingerprint) \"\" }}, fingerprint={{ $proxy.Fingerprint }}{{- end }}, {{ $common }}\n {{- else if or (eq $proxy.Type \"hysteria2\") (eq $proxy.Type \"hysteria\") }}\n{{ $proxy.Name }} = hysteria2, {{ $server }}, {{ $proxy.Port }}, password={{ $password }}{{- if ne (default \"\" $proxy.SNI) \"\" }}, sni={{ $proxy.SNI }}{{- end }}{{- if $proxy.AllowInsecure }}, skip-cert-verify=true{{- end }}{{- if ne (default \"\" $proxy.ObfsPassword) \"\" }}, obfs=salamander, obfs-password={{ $proxy.ObfsPassword }}{{- end }}{{- if ne (default \"\" $proxy.HopPorts) \"\" }}, ports={{ $proxy.HopPorts }}{{- end }}{{- if ne (default 0 $proxy.HopInterval) 0 }}, hop-interval={{ $proxy.HopInterval }}{{- end }}, {{ $common }}\n {{- else if eq $proxy.Type \"tuic\" }}\n{{ $proxy.Name }} = tuic, {{ $server }}, {{ $proxy.Port }}, uuid={{ default \"\" $proxy.ServerKey }}, password={{ $password }}{{- if ne (default \"\" $proxy.SNI) \"\" }}, sni={{ $proxy.SNI }}{{- end }}{{- if $proxy.AllowInsecure }}, skip-cert-verify=true{{- end }}{{- if $proxy.DisableSNI }}, disable-sni=true{{- end }}{{- if $proxy.ReduceRtt }}, reduce-rtt=true{{- end }}{{- if ne (default \"\" $proxy.UDPRelayMode) \"\" }}, udp-relay-mode={{ $proxy.UDPRelayMode }}{{- end }}{{- if ne (default \"\" $proxy.CongestionController) \"\" }}, congestion-controller={{ $proxy.CongestionController }}{{- end }}, {{ $common }}\n {{- else if eq $proxy.Type \"wireguard\" }}\n{{ $proxy.Name }} = wireguard, {{ $server }}, {{ $proxy.Port }}, private-key={{ default \"\" $proxy.ServerKey }}, public-key={{ default \"\" $proxy.RealityPublicKey }}{{- if ne (default \"\" $proxy.Path) \"\" }}, preshared-key={{ $proxy.Path }}{{- end }}{{- if ne (default \"\" $proxy.RealityServerAddr) \"\" }}, ip={{ $proxy.RealityServerAddr }}{{- end }}{{- if ne (default 0 $proxy.RealityServerPort) 0 }}, ipv6={{ $proxy.RealityServerPort }}{{- end }}, {{ $common }}\n {{- else if eq $proxy.Type \"anytls\" }}\n{{ $proxy.Name }} = anytls, {{ $server }}, {{ $proxy.Port }}, password={{ $password }}{{- if ne (default \"\" $proxy.SNI) \"\" }}, sni={{ $proxy.SNI }}{{- end }}{{- if $proxy.AllowInsecure }}, skip-cert-verify=true{{- end }}, {{ $common }}\n {{- else }}\n{{ $proxy.Name }} = {{ $proxy.Type }}, {{ $server }}, {{ $proxy.Port }}, {{ $common }}\n {{- end }}\n{{- end }}\n\n[Proxy Group]\n# 主要策略组\n🚀 Proxy = select, 🌏 Auto, 🎯 Direct, include-other-group=🇺🇳 Nodes\n🍎 Apple = select, 🚀 Proxy, 🎯 Direct, include-other-group=🇺🇳 Nodes\n🔍 Google = select, 🚀 Proxy, 🎯 Direct, include-other-group=🇺🇳 Nodes\n🪟 Microsoft = select, 🚀 Proxy, 🎯 Direct, include-other-group=🇺🇳 Nodes\n📺 GlobalMedia = select, 🚀 Proxy, 🎯 Direct, include-other-group=🇺🇳 Nodes\n🤖 AI = select, 🚀 Proxy, 🎯 Direct, include-other-group=🇺🇳 Nodes\n🪙 Crypto = select, 🚀 Proxy, 🎯 Direct, include-other-group=🇺🇳 Nodes\n🎮 Game = select, 🚀 Proxy, 🎯 Direct, include-other-group=🇺🇳 Nodes\n📟 Telegram = select, 🚀 Proxy, 🎯 Direct, include-other-group=🇺🇳 Nodes\n🇨🇳 China = select, 🎯 Direct, 🚀 Proxy, include-other-group=🇺🇳 Nodes\n🐠 Final = select, 🚀 Proxy, 🎯 Direct, include-other-group=🇺🇳 Nodes\n\n# 智能选择和节点组\n🌏 Auto = smart, include-other-group=🇺🇳 Nodes\n🎯 Direct = select, DIRECT, hidden=1\n🇺🇳 Nodes = select, {{ $proxyNames }}, hidden=1\n\n[Rule]\nRULE-SET, https://cdn.jsdmirror.com/gh/perfect-panel/rules/rule/Surge/Apple/Apple_All.list, 🍎 Apple\nRULE-SET, https://cdn.jsdmirror.com/gh/perfect-panel/rules/rule/Surge/Google/Google.list, 🔍 Google\nRULE-SET, https://cdn.jsdmirror.com/gh/perfect-panel/rules/rule/Surge/GitHub/GitHub.list, 🪟 Microsoft\nRULE-SET, https://cdn.jsdmirror.com/gh/perfect-panel/rules/rule/Surge/Microsoft/Microsoft.list, 🪟 Microsoft\nRULE-SET, https://cdn.jsdmirror.com/gh/perfect-panel/rules/rule/Surge/HBO/HBO.list, 📺 GlobalMedia\nRULE-SET, https://cdn.jsdmirror.com/gh/perfect-panel/rules/rule/Surge/Disney/Disney.list, 📺 GlobalMedia\nRULE-SET, https://cdn.jsdmirror.com/gh/perfect-panel/rules/rule/Surge/TikTok/TikTok.list, 📺 GlobalMedia\nRULE-SET, https://cdn.jsdmirror.com/gh/perfect-panel/rules/rule/Surge/Netflix/Netflix.list, 📺 GlobalMedia\nRULE-SET, https://cdn.jsdmirror.com/gh/perfect-panel/rules/rule/Surge/GlobalMedia/GlobalMedia_All_No_Resolve.list, 📺 GlobalMedia\nRULE-SET, https://cdn.jsdmirror.com/gh/perfect-panel/rules/rule/Surge/Telegram/Telegram.list, 📟 Telegram\nRULE-SET, https://cdn.jsdmirror.com/gh/perfect-panel/rules/rule/Surge/OpenAI/OpenAI.list, 🤖 AI\nRULE-SET, https://cdn.jsdmirror.com/gh/perfect-panel/rules/rule/Surge/Gemini/Gemini.list, 🤖 AI\nRULE-SET, https://cdn.jsdmirror.com/gh/perfect-panel/rules/rule/Surge/Copilot/Copilot.list, 🤖 AI\nRULE-SET, https://cdn.jsdmirror.com/gh/perfect-panel/rules/rule/Surge/Claude/Claude.list, 🤖 AI\nRULE-SET, https://cdn.jsdmirror.com/gh/perfect-panel/rules/rule/Surge/Crypto/Crypto.list, 🪙 Crypto\nRULE-SET, https://cdn.jsdmirror.com/gh/perfect-panel/rules/rule/Surge/Cryptocurrency/Cryptocurrency.list, 🪙 Crypto\nRULE-SET, https://cdn.jsdmirror.com/gh/perfect-panel/rules/rule/Surge/Game/Game.list, 🎮 Game\nRULE-SET, https://cdn.jsdmirror.com/gh/perfect-panel/rules/rule/Surge/Global/Global_All_No_Resolve.list, 🚀 Proxy\nRULE-SET, https://cdn.jsdmirror.com/gh/perfect-panel/rules/rule/Surge/ChinaMax/ChinaMax_All_No_Resolve.list, 🇨🇳 China\nRULE-SET, https://cdn.jsdmirror.com/gh/perfect-panel/rules/rule/Surge/Lan/Lan.list, 🎯 Direct\n\nGEOIP, CN, 🇨🇳 China\nFINAL, 🐠 Final, dns-failed\n\n[URL Rewrite]\n^https?:\\/\\/(www.)?g\\.cn https://www.google.com 302\n^https?:\\/\\/(www.)?google\\.cn https://www.google.com 302\n', 'conf', '{}', '2025-08-13 00:12:37.809', '2025-08-15 22:00:50.528'); +COMMIT; + +-- migrate:down +DROP TABLE IF EXISTS `subscribe_application`; diff --git a/migrations/mysql/02102_subscribe_config.sql b/migrations/mysql/02102_subscribe_config.sql new file mode 100644 index 00000000..1c55a260 --- /dev/null +++ b/migrations/mysql/02102_subscribe_config.sql @@ -0,0 +1,7 @@ +-- migrate:up +INSERT IGNORE INTO `system` (`id`, `category`, `key`, `value`, `type`, `desc`, `created_at`, `updated_at`) +VALUES + (42, 'subscribe', 'UserAgentLimit', 'false', 'bool', 'User Agent Limit', '2025-04-22 14:25:16.637', '2025-04-22 14:25:16.637'), + (43, 'subscribe', 'UserAgentList', '', 'string', 'User Agent List', '2025-04-22 14:25:16.637','2025-04-22 14:25:16.637'); +-- migrate:down + diff --git a/migrations/mysql/02103_delete_application.sql b/migrations/mysql/02103_delete_application.sql new file mode 100644 index 00000000..3409b1ef --- /dev/null +++ b/migrations/mysql/02103_delete_application.sql @@ -0,0 +1,6 @@ +-- migrate:up +DROP TABLE IF EXISTS `application`; +DROP TABLE IF EXISTS `application_version`; +DROP TABLE IF EXISTS `application_config`; +-- migrate:down + diff --git a/migrations/mysql/02104_system_log.sql b/migrations/mysql/02104_system_log.sql new file mode 100644 index 00000000..b66414ff --- /dev/null +++ b/migrations/mysql/02104_system_log.sql @@ -0,0 +1,127 @@ +-- migrate:up +DROP TABLE IF EXISTS `user_balance_log`; +DROP TABLE IF EXISTS `user_commission_log`; +DROP TABLE IF EXISTS `user_gift_amount_log`; +DROP TABLE IF EXISTS `user_login_log`; +DROP TABLE IF EXISTS `user_reset_subscribe_log`; +DROP TABLE IF EXISTS `user_subscribe_log`; +DROP TABLE IF EXISTS `message_log`; +DROP TABLE IF EXISTS `system_logs`; +CREATE TABLE `system_logs` ( + `id` bigint NOT NULL AUTO_INCREMENT, + `type` tinyint(1) NOT NULL DEFAULT '0' COMMENT 'Log Type: 1: Email Message 2: Mobile Message 3: Subscribe 4: Subscribe Traffic 5: Server Traffic 6: Login 7: Register 8: Balance 9: Commission 10: Reset Subscribe 11: Gift', + `date` varchar(20) COLLATE utf8mb4_general_ci DEFAULT NULL COMMENT 'Log Date', + `object_id` bigint NOT NULL DEFAULT '0' COMMENT 'Object ID', + `content` text COLLATE utf8mb4_general_ci NOT NULL COMMENT 'Log Content', + `created_at` datetime(3) DEFAULT NULL COMMENT 'Create Time', + PRIMARY KEY (`id`), + KEY `idx_type` (`type`), + KEY `idx_object_id` (`object_id`) +) ENGINE=InnoDB DEFAULT CHARSET=utf8mb4 COLLATE=utf8mb4_general_ci; +-- migrate:down +CREATE TABLE IF NOT EXISTS `user_balance_log` +( + `id` bigint NOT NULL AUTO_INCREMENT, + `user_id` bigint NOT NULL COMMENT 'User ID', + `amount` bigint NOT NULL COMMENT 'Amount', + `type` tinyint(1) NOT NULL COMMENT 'Type: 1: Recharge 2: Withdraw 3: Payment 4: Refund 5: Reward', + `order_id` bigint DEFAULT NULL COMMENT 'Order ID', + `balance` bigint NOT NULL COMMENT 'Balance', + `created_at` datetime(3) DEFAULT NULL COMMENT 'Creation Time', + PRIMARY KEY (`id`), + KEY `idx_user_id` (`user_id`) + ) ENGINE = InnoDB + DEFAULT CHARSET = utf8mb4 + COLLATE = utf8mb4_general_ci; + +CREATE TABLE IF NOT EXISTS `user_commission_log` +( + `id` bigint NOT NULL AUTO_INCREMENT, + `user_id` bigint NOT NULL COMMENT 'User ID', + `order_no` varchar(191) CHARACTER SET utf8mb4 COLLATE utf8mb4_general_ci DEFAULT NULL COMMENT 'Order No.', + `amount` bigint NOT NULL COMMENT 'Amount', + `created_at` datetime(3) DEFAULT NULL COMMENT 'Creation Time', + PRIMARY KEY (`id`), + KEY `idx_user_id` (`user_id`) + ) ENGINE = InnoDB + DEFAULT CHARSET = utf8mb4 + COLLATE = utf8mb4_general_ci; + +CREATE TABLE IF NOT EXISTS `user_gift_amount_log` +( + `id` bigint NOT NULL AUTO_INCREMENT, + `user_id` bigint NOT NULL COMMENT 'User ID', + `user_subscribe_id` bigint DEFAULT NULL COMMENT 'Deduction User Subscribe ID', + `order_no` varchar(191) CHARACTER SET utf8mb4 COLLATE utf8mb4_general_ci DEFAULT NULL COMMENT 'Order No.', + `type` tinyint(1) NOT NULL COMMENT 'Type: 1: Increase 2: Reduce', + `amount` bigint NOT NULL COMMENT 'Amount', + `balance` bigint NOT NULL COMMENT 'Balance', + `remark` varchar(255) CHARACTER SET utf8mb4 COLLATE utf8mb4_general_ci DEFAULT '' COMMENT 'Remark', + `created_at` datetime(3) DEFAULT NULL COMMENT 'Creation Time', + PRIMARY KEY (`id`), + KEY `idx_user_id` (`user_id`) + ) ENGINE = InnoDB + DEFAULT CHARSET = utf8mb4 + COLLATE = utf8mb4_general_ci; + +CREATE TABLE IF NOT EXISTS `user_login_log` +( + `id` bigint NOT NULL AUTO_INCREMENT, + `user_id` bigint NOT NULL COMMENT 'User ID', + `login_ip` varchar(255) CHARACTER SET utf8mb4 COLLATE utf8mb4_general_ci NOT NULL COMMENT 'Login IP', + `user_agent` text CHARACTER SET utf8mb4 COLLATE utf8mb4_general_ci NOT NULL COMMENT 'UserAgent', + `success` tinyint(1) NOT NULL DEFAULT '0' COMMENT 'Login Success', + `created_at` datetime(3) DEFAULT NULL COMMENT 'Creation Time', + PRIMARY KEY (`id`), + KEY `idx_user_id` (`user_id`) + ) ENGINE = InnoDB + DEFAULT CHARSET = utf8mb4 + COLLATE = utf8mb4_general_ci; + +CREATE TABLE IF NOT EXISTS `user_reset_subscribe_log` +( + `id` BIGINT NOT NULL AUTO_INCREMENT PRIMARY KEY, + `user_id` BIGINT NOT NULL COMMENT 'User ID', + `type` TINYINT(1) NOT NULL COMMENT 'Type: 1: Auto 2: Advance 3: Paid', + `order_no` VARCHAR(255) DEFAULT NULL COMMENT 'Order No.', + `user_subscribe_id` BIGINT NOT NULL COMMENT 'User Subscribe ID', + `created_at` DATETIME NOT NULL DEFAULT CURRENT_TIMESTAMP COMMENT 'Creation Time', + INDEX `idx_user_id` (`user_id`), + INDEX `idx_user_subscribe_id` (`user_subscribe_id`) + ) ENGINE = InnoDB + DEFAULT CHARSET = utf8mb4 + COLLATE = utf8mb4_general_ci; + +CREATE TABLE IF NOT EXISTS `user_subscribe_log` +( + `id` bigint NOT NULL AUTO_INCREMENT, + `user_id` bigint NOT NULL COMMENT 'User ID', + `user_subscribe_id` bigint NOT NULL COMMENT 'User Subscribe ID', + `token` varchar(255) CHARACTER SET utf8mb4 COLLATE utf8mb4_general_ci NOT NULL COMMENT 'Token', + `ip` varchar(255) CHARACTER SET utf8mb4 COLLATE utf8mb4_general_ci NOT NULL COMMENT 'IP', + `user_agent` text CHARACTER SET utf8mb4 COLLATE utf8mb4_general_ci NOT NULL COMMENT 'UserAgent', + `created_at` datetime(3) DEFAULT NULL COMMENT 'Creation Time', + PRIMARY KEY (`id`), + KEY `idx_user_id` (`user_id`), + KEY `idx_user_subscribe_id` (`user_subscribe_id`) + ) ENGINE = InnoDB + DEFAULT CHARSET = utf8mb4 + COLLATE = utf8mb4_general_ci; + +CREATE TABLE IF NOT EXISTS `message_log` +( + `id` bigint NOT NULL AUTO_INCREMENT, + `type` varchar(50) CHARACTER SET utf8mb4 COLLATE utf8mb4_general_ci NOT NULL DEFAULT 'email' COMMENT 'Message Type', + `platform` varchar(50) CHARACTER SET utf8mb4 COLLATE utf8mb4_general_ci NOT NULL DEFAULT 'smtp' COMMENT 'Platform', + `to` text CHARACTER SET utf8mb4 COLLATE utf8mb4_general_ci NOT NULL COMMENT 'To', + `subject` varchar(255) CHARACTER SET utf8mb4 COLLATE utf8mb4_general_ci NOT NULL DEFAULT '' COMMENT 'Subject', + `content` text CHARACTER SET utf8mb4 COLLATE utf8mb4_general_ci COMMENT 'Content', + `status` tinyint(1) NOT NULL DEFAULT '0' COMMENT 'Status', + `created_at` datetime(3) DEFAULT NULL COMMENT 'Create Time', + `updated_at` datetime(3) DEFAULT NULL COMMENT 'Update Time', + PRIMARY KEY (`id`) + ) ENGINE = InnoDB + DEFAULT CHARSET = utf8mb4 + COLLATE = utf8mb4_general_ci; + +DROP TABLE IF EXISTS `system_logs`; diff --git a/migrations/mysql/02105_node.sql b/migrations/mysql/02105_node.sql new file mode 100644 index 00000000..b019ce28 --- /dev/null +++ b/migrations/mysql/02105_node.sql @@ -0,0 +1,34 @@ +-- migrate:up +CREATE TABLE IF NOT EXISTS `servers` ( + `id` bigint NOT NULL AUTO_INCREMENT, + `name` varchar(100) COLLATE utf8mb4_general_ci NOT NULL DEFAULT '' COMMENT 'Server Name', + `country` varchar(128) COLLATE utf8mb4_general_ci NOT NULL DEFAULT '' COMMENT 'Country', + `city` varchar(128) COLLATE utf8mb4_general_ci NOT NULL DEFAULT '' COMMENT 'City', + `ratio` decimal(4,2) NOT NULL DEFAULT '0.00' COMMENT 'Traffic Ratio', + `address` varchar(100) COLLATE utf8mb4_general_ci NOT NULL DEFAULT '' COMMENT 'Server Address', + `sort` bigint NOT NULL DEFAULT '0' COMMENT 'Sort', + `protocols` text COLLATE utf8mb4_general_ci COMMENT 'Protocol', + `last_reported_at` datetime(3) DEFAULT NULL COMMENT 'Last Reported Time', + `created_at` datetime(3) DEFAULT NULL COMMENT 'Creation Time', + `updated_at` datetime(3) DEFAULT NULL COMMENT 'Update Time', + PRIMARY KEY (`id`) +) ENGINE=InnoDB DEFAULT CHARSET=utf8mb4 COLLATE=utf8mb4_general_ci; + +CREATE TABLE IF NOT EXISTS `nodes` ( + `id` bigint NOT NULL AUTO_INCREMENT, + `name` varchar(100) COLLATE utf8mb4_general_ci NOT NULL DEFAULT '' COMMENT 'Node Name', + `tags` varchar(255) COLLATE utf8mb4_general_ci NOT NULL DEFAULT '' COMMENT 'Tags', + `port` smallint unsigned NOT NULL DEFAULT '0' COMMENT 'Connect Port', + `address` varchar(255) COLLATE utf8mb4_general_ci NOT NULL DEFAULT '' COMMENT 'Connect Address', + `server_id` bigint NOT NULL DEFAULT '0' COMMENT 'Server ID', + `protocol` varchar(100) COLLATE utf8mb4_general_ci NOT NULL DEFAULT '' COMMENT 'Protocol', + `enabled` tinyint(1) NOT NULL DEFAULT '1' COMMENT 'Enabled', + `created_at` datetime(3) DEFAULT NULL COMMENT 'Creation Time', + `updated_at` datetime(3) DEFAULT NULL COMMENT 'Update Time', + PRIMARY KEY (`id`) +) ENGINE=InnoDB DEFAULT CHARSET=utf8mb4 COLLATE=utf8mb4_general_ci; + +-- migrate:down +DROP TABLE IF EXISTS `nodes`; +DROP TABLE IF EXISTS `servers`; + diff --git a/migrations/mysql/02106_subscribe.sql b/migrations/mysql/02106_subscribe.sql new file mode 100644 index 00000000..67d10dda --- /dev/null +++ b/migrations/mysql/02106_subscribe.sql @@ -0,0 +1,16 @@ +-- migrate:up +ALTER TABLE `subscribe` +ADD COLUMN `nodes` VARCHAR(255) NOT NULL DEFAULT '' COMMENT 'Node IDs', +ADD COLUMN `node_tags` VARCHAR(255) NOT NULL DEFAULT '' COMMENT 'Node Tags', +DROP COLUMN `server`, +DROP COLUMN `server_group`; + +DROP TABLE IF EXISTS `server_rule_group`; + +-- migrate:down +ALTER TABLE `subscribe` +DROP COLUMN `nodes`, + DROP COLUMN `node_tags`, + ADD COLUMN `server` VARCHAR(255) NOT NULL DEFAULT '' COMMENT 'Server', + ADD COLUMN `server_group` VARCHAR(255) NOT NULL DEFAULT '' COMMENT 'Server Group'; + diff --git a/migrations/mysql/02107_log_setting.sql b/migrations/mysql/02107_log_setting.sql new file mode 100644 index 00000000..a3b75910 --- /dev/null +++ b/migrations/mysql/02107_log_setting.sql @@ -0,0 +1,7 @@ +-- migrate:up +INSERT IGNORE INTO `system` (`category`, `key`, `value`, `type`, `desc`, `created_at`, `updated_at`) +VALUES + ('log', 'AutoClear', 'true', 'bool', 'Auto Clear Log', '2025-04-22 14:25:16.637', '2025-04-22 14:25:16.637'), + ('log', 'ClearDays', '7', 'int', 'Clear Days', '2025-04-22 14:25:16.637','2025-04-22 14:25:16.637'); +-- migrate:down + diff --git a/migrations/mysql/02108_user_referral.sql b/migrations/mysql/02108_user_referral.sql new file mode 100644 index 00000000..6a1f55ec --- /dev/null +++ b/migrations/mysql/02108_user_referral.sql @@ -0,0 +1,13 @@ +-- migrate:up +ALTER TABLE `user` + ADD COLUMN `referral_percentage` TINYINT UNSIGNED NOT NULL DEFAULT 0 + COMMENT 'Referral Percentage' + AFTER `commission`, + ADD COLUMN `only_first_purchase` TINYINT(1) NOT NULL DEFAULT 1 + COMMENT 'Only First Purchase' + AFTER `referral_percentage`; + +-- migrate:down +ALTER TABLE `user` +DROP COLUMN `referral_percentage`, +DROP COLUMN `only_first_purchase`; diff --git a/migrations/mysql/02109_node_sort.sql b/migrations/mysql/02109_node_sort.sql new file mode 100644 index 00000000..b048e1a0 --- /dev/null +++ b/migrations/mysql/02109_node_sort.sql @@ -0,0 +1,7 @@ +-- migrate:up +ALTER TABLE `nodes` + ADD COLUMN `sort` INT UNSIGNED NOT NULL DEFAULT 0 + COMMENT 'Sort' AFTER `enabled`; +-- migrate:down +ALTER TABLE `nodes` +DROP COLUMN `sort`; diff --git a/migrations/mysql/02110_traffic_log_index.sql b/migrations/mysql/02110_traffic_log_index.sql new file mode 100644 index 00000000..8cb62b3c --- /dev/null +++ b/migrations/mysql/02110_traffic_log_index.sql @@ -0,0 +1,6 @@ +-- migrate:up +CREATE INDEX idx_traffic_log_time_user_sub ON traffic_log (timestamp, user_id, subscribe_id); + +-- migrate:down +DROP INDEX idx_traffic_log_time_user_sub ON traffic_log; + diff --git a/migrations/mysql/02111_clear_table.sql b/migrations/mysql/02111_clear_table.sql new file mode 100644 index 00000000..e50b5994 --- /dev/null +++ b/migrations/mysql/02111_clear_table.sql @@ -0,0 +1,6 @@ +-- migrate:up +DROP TABLE IF EXISTS `subscribe_type`; +DROP TABLE IF EXISTS `sms`; +-- migrate:down +DROP TABLE IF EXISTS `subscribe_type`; +DROP TABLE IF EXISTS `sms`; diff --git a/migrations/mysql/02112_subscribe.sql b/migrations/mysql/02112_subscribe.sql new file mode 100644 index 00000000..296341c4 --- /dev/null +++ b/migrations/mysql/02112_subscribe.sql @@ -0,0 +1,10 @@ +-- migrate:up +ALTER TABLE `subscribe` +DROP COLUMN `group_id`, +ADD COLUMN `language` VARCHAR(255) NOT NULL DEFAULT '' + COMMENT 'Language' + AFTER `name`; + +DROP TABLE IF EXISTS `subscribe_group`; +-- migrate:down + diff --git a/migrations/mysql/02113_task.sql b/migrations/mysql/02113_task.sql new file mode 100644 index 00000000..e9b432fd --- /dev/null +++ b/migrations/mysql/02113_task.sql @@ -0,0 +1,17 @@ +-- migrate:up +DROP TABLE IF EXISTS `email_task`; +CREATE TABLE `task` ( + `id` bigint NOT NULL AUTO_INCREMENT COMMENT 'ID', + `type` tinyint NOT NULL COMMENT 'Task Type', + `scope` text COLLATE utf8mb4_general_ci COMMENT 'Task Scope', + `content` text COLLATE utf8mb4_general_ci COMMENT 'Task Content', + `status` tinyint NOT NULL DEFAULT '0' COMMENT 'Task Status: 0: Pending, 1: In Progress, 2: Completed, 3: Failed', + `errors` text COLLATE utf8mb4_general_ci COMMENT 'Task Errors', + `total` bigint unsigned NOT NULL DEFAULT '0' COMMENT 'Total Number', + `current` bigint unsigned NOT NULL DEFAULT '0' COMMENT 'Current Number', + `created_at` datetime(3) DEFAULT NULL COMMENT 'Creation Time', + `updated_at` datetime(3) DEFAULT NULL COMMENT 'Update Time', + PRIMARY KEY (`id`) +) ENGINE=InnoDB DEFAULT CHARSET=utf8mb4 COLLATE=utf8mb4_general_ci; +-- migrate:down + diff --git a/migrations/mysql/02114_node_config.sql b/migrations/mysql/02114_node_config.sql new file mode 100644 index 00000000..8a5710fd --- /dev/null +++ b/migrations/mysql/02114_node_config.sql @@ -0,0 +1,11 @@ +-- migrate:up +INSERT +IGNORE INTO `system` (`category`, `key`, `value`, `type`, `desc`, `created_at`, `updated_at`) +VALUE + ('server', 'TrafficReportThreshold', '0', 'int', 'Traffic report threshold', '2025-04-22 14:25:16.637','2025-04-22 14:25:16.637'), + ('server', 'IPStrategy', '', 'string', 'IP Strategy', '2025-04-22 14:25:16.637','2025-04-22 14:25:16.637'), + ('server', 'DNS', '', 'string', 'DNS', '2025-04-22 14:25:16.637','2025-04-22 14:25:16.637'), + ('server', 'Block', '', 'string', 'Block', '2025-04-22 14:25:16.637','2025-04-22 14:25:16.637'), + ('server', 'Outbound', '', 'string', 'Proxy Outbound', '2025-04-22 14:25:16.637','2025-04-22 14:25:16.637'); +-- migrate:down + diff --git a/migrations/mysql/02115_ads.sql b/migrations/mysql/02115_ads.sql new file mode 100644 index 00000000..d044ce7e --- /dev/null +++ b/migrations/mysql/02115_ads.sql @@ -0,0 +1,24 @@ +-- migrate:up +-- 只有当 ads 表中不存在 description 字段时才添加 +SET +@col_exists := ( + SELECT COUNT(*) + FROM INFORMATION_SCHEMA.COLUMNS + WHERE TABLE_SCHEMA = DATABASE() + AND TABLE_NAME = 'ads' + AND COLUMN_NAME = 'description' +); + +SET +@query := IF( + @col_exists = 0, + 'ALTER TABLE `ads` ADD COLUMN `description` VARCHAR(255) DEFAULT '''' COMMENT ''Description'';', + 'SELECT "Column `description` already exists"' +); + +PREPARE stmt FROM @query; +EXECUTE stmt; +DEALLOCATE PREPARE stmt; + +-- migrate:down + diff --git a/migrations/mysql/02116_user_algo.sql b/migrations/mysql/02116_user_algo.sql new file mode 100644 index 00000000..ca1bbeb2 --- /dev/null +++ b/migrations/mysql/02116_user_algo.sql @@ -0,0 +1,42 @@ +-- migrate:up +-- 添加 algo 列(如果不存在) +SET @dbname = DATABASE(); +SET @tablename = 'user'; +SET @colname = 'algo'; +SET @sql = ( + SELECT IF( + COUNT(*) = 0, + 'ALTER TABLE `user` ADD COLUMN `algo` VARCHAR(20) NOT NULL DEFAULT ''default'' COMMENT ''Encryption Algorithm'' AFTER `password`;', + 'SELECT "Column `algo` already exists";' + ) + FROM information_schema.COLUMNS + WHERE TABLE_SCHEMA = @dbname + AND TABLE_NAME = @tablename + AND COLUMN_NAME = @colname +); +PREPARE stmt FROM @sql; +EXECUTE stmt; +DEALLOCATE PREPARE stmt; + +-- 添加 salt 列(如果不存在) +SET @colname = 'salt'; +SET @sql = ( + SELECT IF( + COUNT(*) = 0, + 'ALTER TABLE `user` ADD COLUMN `salt` VARCHAR(20) NOT NULL DEFAULT ''default'' COMMENT ''Password Salt'' AFTER `algo`;', + 'SELECT "Column `salt` already exists";' + ) + FROM information_schema.COLUMNS + WHERE TABLE_SCHEMA = @dbname + AND TABLE_NAME = @tablename + AND COLUMN_NAME = @colname +); +PREPARE stmt FROM @sql; +EXECUTE stmt; +DEALLOCATE PREPARE stmt; + +-- migrate:down +ALTER TABLE `user` +DROP COLUMN `algo`, + DROP COLUMN `salt`; + diff --git a/migrations/mysql/02117_site_custom_data.sql b/migrations/mysql/02117_site_custom_data.sql new file mode 100644 index 00000000..35aae07e --- /dev/null +++ b/migrations/mysql/02117_site_custom_data.sql @@ -0,0 +1,18 @@ +-- migrate:up +INSERT INTO `system` (`category`, `key`, `value`, `type`, `desc`, `created_at`, `updated_at`) +SELECT 'site', 'CustomData', '{ + "kr_website_id": "" +}', 'string', 'Custom Data', '2025-04-22 14:25:16.637', '2025-10-14 15:47:19.187' + WHERE NOT EXISTS ( + SELECT 1 FROM `system` WHERE `category` = 'site' AND `key` = 'CustomData' +); + +-- migrate:down +INSERT INTO `system` (`category`, `key`, `value`, `type`, `desc`, `created_at`, `updated_at`) +SELECT 'site', 'CustomData', '{ + "kr_website_id": "" +}', 'string', 'Custom Data', '2025-04-22 14:25:16.637', '2025-10-14 15:47:19.187' + WHERE NOT EXISTS ( + SELECT 1 FROM `system` WHERE `category` = 'site' AND `key` = 'CustomData' +); + diff --git a/migrations/mysql/02118_traffic_log_idx.sql b/migrations/mysql/02118_traffic_log_idx.sql new file mode 100644 index 00000000..1d6a5f01 --- /dev/null +++ b/migrations/mysql/02118_traffic_log_idx.sql @@ -0,0 +1,6 @@ +-- migrate:up +ALTER TABLE traffic_log ADD INDEX idx_timestamp (timestamp); + +-- migrate:down +ALTER TABLE traffic_log DROP INDEX idx_timestamp; + diff --git a/migrations/mysql/02119_user_subscribe_note.sql b/migrations/mysql/02119_user_subscribe_note.sql new file mode 100644 index 00000000..4a82bcbf --- /dev/null +++ b/migrations/mysql/02119_user_subscribe_note.sql @@ -0,0 +1,10 @@ +-- migrate:up +ALTER TABLE `user_subscribe` +ADD COLUMN `note` VARCHAR(500) NOT NULL DEFAULT '' + COMMENT 'User note for subscription' + AFTER `status`; + +-- migrate:down +ALTER TABLE `user_subscribe` +DROP COLUMN `note`; + diff --git a/migrations/mysql/02120_user_rules.sql b/migrations/mysql/02120_user_rules.sql new file mode 100644 index 00000000..a0bc08f7 --- /dev/null +++ b/migrations/mysql/02120_user_rules.sql @@ -0,0 +1,10 @@ +-- migrate:up +ALTER TABLE `user` + ADD COLUMN `rules` TEXT NULL + COMMENT 'User rules for subscription' + AFTER `created_at`; + +-- migrate:down +ALTER TABLE `user` +DROP COLUMN IF EXISTS `rules`; + diff --git a/migrations/mysql/02121_user_withdrawal.sql b/migrations/mysql/02121_user_withdrawal.sql new file mode 100644 index 00000000..a9aefd9c --- /dev/null +++ b/migrations/mysql/02121_user_withdrawal.sql @@ -0,0 +1,24 @@ +-- migrate:up +CREATE TABLE IF NOT EXISTS `withdrawals` ( + `id` BIGINT NOT NULL AUTO_INCREMENT COMMENT 'Primary Key', + `user_id` BIGINT NOT NULL COMMENT 'User ID', + `amount` BIGINT NOT NULL COMMENT 'Withdrawal Amount', + `content` TEXT COMMENT 'Withdrawal Content', + `status` TINYINT(1) NOT NULL DEFAULT 0 COMMENT 'Withdrawal Status', + `reason` VARCHAR(500) NOT NULL DEFAULT '' COMMENT 'Rejection Reason', + `created_at` DATETIME NOT NULL COMMENT 'Creation Time', + `updated_at` DATETIME NOT NULL COMMENT 'Update Time', + PRIMARY KEY (`id`), + KEY `idx_user_id` (`user_id`) + ) ENGINE=InnoDB DEFAULT CHARSET=utf8mb4; + +INSERT IGNORE INTO `system` (`category`, `key`, `value`, `type`, `desc`, `created_at`, `updated_at`) +VALUES + ('invite', 'WithdrawalMethod', '', 'string', 'withdrawal method', '2025-04-22 14:25:16.637', '2025-04-22 14:25:16.637'); +-- migrate:down +DROP TABLE IF EXISTS `withdrawals`; + +DELETE FROM `system` +WHERE `category` = 'invite' + AND `key` = 'WithdrawalMethod'; + diff --git a/migrations/mysql/02122_server.sql b/migrations/mysql/02122_server.sql new file mode 100644 index 00000000..2180ffd9 --- /dev/null +++ b/migrations/mysql/02122_server.sql @@ -0,0 +1,31 @@ +-- migrate:up +DROP TABLE IF EXISTS `server`; +-- migrate:down +CREATE TABLE IF NOT EXISTS `server` +( + `id` bigint NOT NULL AUTO_INCREMENT, + `name` varchar(100) CHARACTER SET utf8mb4 COLLATE utf8mb4_general_ci NOT NULL DEFAULT '' COMMENT 'Node Name', + `tags` varchar(128) CHARACTER SET utf8mb4 COLLATE utf8mb4_general_ci NOT NULL DEFAULT '' COMMENT 'Tags', + `country` varchar(128) CHARACTER SET utf8mb4 COLLATE utf8mb4_general_ci NOT NULL DEFAULT '' COMMENT 'Country', + `city` varchar(128) CHARACTER SET utf8mb4 COLLATE utf8mb4_general_ci NOT NULL DEFAULT '' COMMENT 'City', + `latitude` varchar(128) CHARACTER SET utf8mb4 COLLATE utf8mb4_general_ci NOT NULL DEFAULT '' COMMENT 'latitude', + `longitude` varchar(128) CHARACTER SET utf8mb4 COLLATE utf8mb4_general_ci NOT NULL DEFAULT '' COMMENT 'longitude', + `server_addr` varchar(100) CHARACTER SET utf8mb4 COLLATE utf8mb4_general_ci NOT NULL DEFAULT '' COMMENT 'Server Address', + `relay_mode` varchar(20) CHARACTER SET utf8mb4 COLLATE utf8mb4_general_ci NOT NULL DEFAULT 'none' COMMENT 'Relay Mode', + `relay_node` text CHARACTER SET utf8mb4 COLLATE utf8mb4_general_ci COMMENT 'Relay Node', + `speed_limit` bigint NOT NULL DEFAULT '0' COMMENT 'Speed Limit', + `traffic_ratio` decimal(4, 2) NOT NULL DEFAULT '0.00' COMMENT 'Traffic Ratio', + `group_id` bigint DEFAULT NULL COMMENT 'Group ID', + `protocol` varchar(20) CHARACTER SET utf8mb4 COLLATE utf8mb4_general_ci NOT NULL DEFAULT '' COMMENT 'Protocol', + `config` text CHARACTER SET utf8mb4 COLLATE utf8mb4_general_ci COMMENT 'Config', + `enable` tinyint(1) NOT NULL DEFAULT '1' COMMENT 'Enabled', + `sort` bigint NOT NULL DEFAULT '0' COMMENT 'Sort', + `last_reported_at` datetime(3) DEFAULT NULL COMMENT 'Last Reported Time', + `created_at` datetime(3) DEFAULT NULL COMMENT 'Creation Time', + `updated_at` datetime(3) DEFAULT NULL COMMENT 'Update Time', + PRIMARY KEY (`id`), + KEY `idx_group_id` (`group_id`) + ) ENGINE = InnoDB + DEFAULT CHARSET = utf8mb4 + COLLATE = utf8mb4_general_ci; + diff --git a/migrations/mysql/02123_subscribe_original.sql b/migrations/mysql/02123_subscribe_original.sql new file mode 100644 index 00000000..6ac68f32 --- /dev/null +++ b/migrations/mysql/02123_subscribe_original.sql @@ -0,0 +1,8 @@ +-- migrate:up +ALTER TABLE `subscribe` + ADD COLUMN `show_original_price` TINYINT(1) NOT NULL DEFAULT 0 COMMENT 'display the original price: 0 not display, 1 display' AFTER `created_at`; + +-- migrate:down +ALTER TABLE `subscribe` +DROP COLUMN `show_original_price`; + diff --git a/migrations/mysql/02124_server_group_delete.sql b/migrations/mysql/02124_server_group_delete.sql new file mode 100644 index 00000000..e716833e --- /dev/null +++ b/migrations/mysql/02124_server_group_delete.sql @@ -0,0 +1,4 @@ +-- migrate:up +DROP TABLE IF EXISTS `server_group`; +-- migrate:down + diff --git a/migrations/mysql/02125_subscribe_stock.sql b/migrations/mysql/02125_subscribe_stock.sql new file mode 100644 index 00000000..73d08d2b --- /dev/null +++ b/migrations/mysql/02125_subscribe_stock.sql @@ -0,0 +1,11 @@ +-- migrate:up +-- Update the `subscribe` table to set `inventory` to -1 where it is currently 0 +UPDATE `subscribe` +SET `inventory` = -1 +WHERE `inventory` = 0; +-- migrate:down + +-- This migration script reverts the inventory values in the 'subscribe' table +UPDATE `subscribe` +SET `inventory` = 0 +WHERE `inventory` = -1; diff --git a/migrations/mysql/02126_system_log_idx.sql b/migrations/mysql/02126_system_log_idx.sql new file mode 100644 index 00000000..bf29cee3 --- /dev/null +++ b/migrations/mysql/02126_system_log_idx.sql @@ -0,0 +1,6 @@ +-- migrate:up +CREATE INDEX idx_type_date ON system_logs (type, date); + +-- migrate:down +DROP INDEX idx_type_date ON system_logs; + diff --git a/migrations/mysql/02127_search_indexes.sql b/migrations/mysql/02127_search_indexes.sql new file mode 100644 index 00000000..85b51713 --- /dev/null +++ b/migrations/mysql/02127_search_indexes.sql @@ -0,0 +1,266 @@ +-- migrate:up +SET @index_exists = (SELECT COUNT(1) + FROM INFORMATION_SCHEMA.STATISTICS + WHERE TABLE_SCHEMA = DATABASE() + AND TABLE_NAME = 'order' + AND INDEX_NAME = 'idx_order_trade_no'); +SET @sql = IF(@index_exists = 0, + 'ALTER TABLE `order` ADD INDEX `idx_order_trade_no` (`trade_no`(191))', + 'SELECT 1'); +PREPARE stmt FROM @sql; +EXECUTE stmt; +DEALLOCATE PREPARE stmt; + +SET @index_exists = (SELECT COUNT(1) + FROM INFORMATION_SCHEMA.STATISTICS + WHERE TABLE_SCHEMA = DATABASE() + AND TABLE_NAME = 'order' + AND INDEX_NAME = 'idx_order_coupon'); +SET @sql = IF(@index_exists = 0, + 'ALTER TABLE `order` ADD INDEX `idx_order_coupon` (`coupon`(191))', + 'SELECT 1'); +PREPARE stmt FROM @sql; +EXECUTE stmt; +DEALLOCATE PREPARE stmt; + +SET @index_exists = (SELECT COUNT(1) + FROM INFORMATION_SCHEMA.STATISTICS + WHERE TABLE_SCHEMA = DATABASE() + AND TABLE_NAME = 'user' + AND INDEX_NAME = 'idx_user_refer_code'); +SET @sql = IF(@index_exists = 0, + 'ALTER TABLE `user` ADD INDEX `idx_user_refer_code` (`refer_code`)', + 'SELECT 1'); +PREPARE stmt FROM @sql; +EXECUTE stmt; +DEALLOCATE PREPARE stmt; + +SET @index_exists = (SELECT COUNT(1) + FROM INFORMATION_SCHEMA.STATISTICS + WHERE TABLE_SCHEMA = DATABASE() + AND TABLE_NAME = 'coupon' + AND INDEX_NAME = 'idx_coupon_name'); +SET @sql = IF(@index_exists = 0, + 'ALTER TABLE `coupon` ADD INDEX `idx_coupon_name` (`name`(191))', + 'SELECT 1'); +PREPARE stmt FROM @sql; +EXECUTE stmt; +DEALLOCATE PREPARE stmt; + +SET @index_exists = (SELECT COUNT(1) + FROM INFORMATION_SCHEMA.STATISTICS + WHERE TABLE_SCHEMA = DATABASE() + AND TABLE_NAME = 'payment' + AND INDEX_NAME = 'idx_payment_name'); +SET @sql = IF(@index_exists = 0, + 'ALTER TABLE `payment` ADD INDEX `idx_payment_name` (`name`)', + 'SELECT 1'); +PREPARE stmt FROM @sql; +EXECUTE stmt; +DEALLOCATE PREPARE stmt; + +SET @index_exists = (SELECT COUNT(1) + FROM INFORMATION_SCHEMA.STATISTICS + WHERE TABLE_SCHEMA = DATABASE() + AND TABLE_NAME = 'servers' + AND INDEX_NAME = 'idx_servers_name'); +SET @sql = IF(@index_exists = 0, + 'ALTER TABLE `servers` ADD INDEX `idx_servers_name` (`name`)', + 'SELECT 1'); +PREPARE stmt FROM @sql; +EXECUTE stmt; +DEALLOCATE PREPARE stmt; + +SET @index_exists = (SELECT COUNT(1) + FROM INFORMATION_SCHEMA.STATISTICS + WHERE TABLE_SCHEMA = DATABASE() + AND TABLE_NAME = 'servers' + AND INDEX_NAME = 'idx_servers_address'); +SET @sql = IF(@index_exists = 0, + 'ALTER TABLE `servers` ADD INDEX `idx_servers_address` (`address`)', + 'SELECT 1'); +PREPARE stmt FROM @sql; +EXECUTE stmt; +DEALLOCATE PREPARE stmt; + +SET @index_exists = (SELECT COUNT(1) + FROM INFORMATION_SCHEMA.STATISTICS + WHERE TABLE_SCHEMA = DATABASE() + AND TABLE_NAME = 'nodes' + AND INDEX_NAME = 'idx_nodes_name'); +SET @sql = IF(@index_exists = 0, + 'ALTER TABLE `nodes` ADD INDEX `idx_nodes_name` (`name`)', + 'SELECT 1'); +PREPARE stmt FROM @sql; +EXECUTE stmt; +DEALLOCATE PREPARE stmt; + +SET @index_exists = (SELECT COUNT(1) + FROM INFORMATION_SCHEMA.STATISTICS + WHERE TABLE_SCHEMA = DATABASE() + AND TABLE_NAME = 'nodes' + AND INDEX_NAME = 'idx_nodes_address'); +SET @sql = IF(@index_exists = 0, + 'ALTER TABLE `nodes` ADD INDEX `idx_nodes_address` (`address`(191))', + 'SELECT 1'); +PREPARE stmt FROM @sql; +EXECUTE stmt; +DEALLOCATE PREPARE stmt; + +SET @index_exists = (SELECT COUNT(1) + FROM INFORMATION_SCHEMA.STATISTICS + WHERE TABLE_SCHEMA = DATABASE() + AND TABLE_NAME = 'nodes' + AND INDEX_NAME = 'idx_nodes_tags'); +SET @sql = IF(@index_exists = 0, + 'ALTER TABLE `nodes` ADD INDEX `idx_nodes_tags` (`tags`(191))', + 'SELECT 1'); +PREPARE stmt FROM @sql; +EXECUTE stmt; +DEALLOCATE PREPARE stmt; + +SET @index_exists = (SELECT COUNT(1) + FROM INFORMATION_SCHEMA.STATISTICS + WHERE TABLE_SCHEMA = DATABASE() + AND TABLE_NAME = 'nodes' + AND INDEX_NAME = 'idx_nodes_port'); +SET @sql = IF(@index_exists = 0, + 'ALTER TABLE `nodes` ADD INDEX `idx_nodes_port` (`port`)', + 'SELECT 1'); +PREPARE stmt FROM @sql; +EXECUTE stmt; +DEALLOCATE PREPARE stmt; + +-- migrate:down +SET @index_exists = (SELECT COUNT(1) + FROM INFORMATION_SCHEMA.STATISTICS + WHERE TABLE_SCHEMA = DATABASE() + AND TABLE_NAME = 'nodes' + AND INDEX_NAME = 'idx_nodes_port'); +SET @sql = IF(@index_exists > 0, + 'ALTER TABLE `nodes` DROP INDEX `idx_nodes_port`', + 'SELECT 1'); +PREPARE stmt FROM @sql; +EXECUTE stmt; +DEALLOCATE PREPARE stmt; + +SET @index_exists = (SELECT COUNT(1) + FROM INFORMATION_SCHEMA.STATISTICS + WHERE TABLE_SCHEMA = DATABASE() + AND TABLE_NAME = 'nodes' + AND INDEX_NAME = 'idx_nodes_tags'); +SET @sql = IF(@index_exists > 0, + 'ALTER TABLE `nodes` DROP INDEX `idx_nodes_tags`', + 'SELECT 1'); +PREPARE stmt FROM @sql; +EXECUTE stmt; +DEALLOCATE PREPARE stmt; + +SET @index_exists = (SELECT COUNT(1) + FROM INFORMATION_SCHEMA.STATISTICS + WHERE TABLE_SCHEMA = DATABASE() + AND TABLE_NAME = 'nodes' + AND INDEX_NAME = 'idx_nodes_address'); +SET @sql = IF(@index_exists > 0, + 'ALTER TABLE `nodes` DROP INDEX `idx_nodes_address`', + 'SELECT 1'); +PREPARE stmt FROM @sql; +EXECUTE stmt; +DEALLOCATE PREPARE stmt; + +SET @index_exists = (SELECT COUNT(1) + FROM INFORMATION_SCHEMA.STATISTICS + WHERE TABLE_SCHEMA = DATABASE() + AND TABLE_NAME = 'nodes' + AND INDEX_NAME = 'idx_nodes_name'); +SET @sql = IF(@index_exists > 0, + 'ALTER TABLE `nodes` DROP INDEX `idx_nodes_name`', + 'SELECT 1'); +PREPARE stmt FROM @sql; +EXECUTE stmt; +DEALLOCATE PREPARE stmt; + +SET @index_exists = (SELECT COUNT(1) + FROM INFORMATION_SCHEMA.STATISTICS + WHERE TABLE_SCHEMA = DATABASE() + AND TABLE_NAME = 'servers' + AND INDEX_NAME = 'idx_servers_address'); +SET @sql = IF(@index_exists > 0, + 'ALTER TABLE `servers` DROP INDEX `idx_servers_address`', + 'SELECT 1'); +PREPARE stmt FROM @sql; +EXECUTE stmt; +DEALLOCATE PREPARE stmt; + +SET @index_exists = (SELECT COUNT(1) + FROM INFORMATION_SCHEMA.STATISTICS + WHERE TABLE_SCHEMA = DATABASE() + AND TABLE_NAME = 'servers' + AND INDEX_NAME = 'idx_servers_name'); +SET @sql = IF(@index_exists > 0, + 'ALTER TABLE `servers` DROP INDEX `idx_servers_name`', + 'SELECT 1'); +PREPARE stmt FROM @sql; +EXECUTE stmt; +DEALLOCATE PREPARE stmt; + +SET @index_exists = (SELECT COUNT(1) + FROM INFORMATION_SCHEMA.STATISTICS + WHERE TABLE_SCHEMA = DATABASE() + AND TABLE_NAME = 'payment' + AND INDEX_NAME = 'idx_payment_name'); +SET @sql = IF(@index_exists > 0, + 'ALTER TABLE `payment` DROP INDEX `idx_payment_name`', + 'SELECT 1'); +PREPARE stmt FROM @sql; +EXECUTE stmt; +DEALLOCATE PREPARE stmt; + +SET @index_exists = (SELECT COUNT(1) + FROM INFORMATION_SCHEMA.STATISTICS + WHERE TABLE_SCHEMA = DATABASE() + AND TABLE_NAME = 'coupon' + AND INDEX_NAME = 'idx_coupon_name'); +SET @sql = IF(@index_exists > 0, + 'ALTER TABLE `coupon` DROP INDEX `idx_coupon_name`', + 'SELECT 1'); +PREPARE stmt FROM @sql; +EXECUTE stmt; +DEALLOCATE PREPARE stmt; + +SET @index_exists = (SELECT COUNT(1) + FROM INFORMATION_SCHEMA.STATISTICS + WHERE TABLE_SCHEMA = DATABASE() + AND TABLE_NAME = 'user' + AND INDEX_NAME = 'idx_user_refer_code'); +SET @sql = IF(@index_exists > 0, + 'ALTER TABLE `user` DROP INDEX `idx_user_refer_code`', + 'SELECT 1'); +PREPARE stmt FROM @sql; +EXECUTE stmt; +DEALLOCATE PREPARE stmt; + +SET @index_exists = (SELECT COUNT(1) + FROM INFORMATION_SCHEMA.STATISTICS + WHERE TABLE_SCHEMA = DATABASE() + AND TABLE_NAME = 'order' + AND INDEX_NAME = 'idx_order_coupon'); +SET @sql = IF(@index_exists > 0, + 'ALTER TABLE `order` DROP INDEX `idx_order_coupon`', + 'SELECT 1'); +PREPARE stmt FROM @sql; +EXECUTE stmt; +DEALLOCATE PREPARE stmt; + +SET @index_exists = (SELECT COUNT(1) + FROM INFORMATION_SCHEMA.STATISTICS + WHERE TABLE_SCHEMA = DATABASE() + AND TABLE_NAME = 'order' + AND INDEX_NAME = 'idx_order_trade_no'); +SET @sql = IF(@index_exists > 0, + 'ALTER TABLE `order` DROP INDEX `idx_order_trade_no`', + 'SELECT 1'); +PREPARE stmt FROM @sql; +EXECUTE stmt; +DEALLOCATE PREPARE stmt; + diff --git a/migrations/mysql/02128_server_config_override.sql b/migrations/mysql/02128_server_config_override.sql new file mode 100644 index 00000000..20256c5d --- /dev/null +++ b/migrations/mysql/02128_server_config_override.sql @@ -0,0 +1,20 @@ +-- migrate:up +CREATE TABLE IF NOT EXISTS `server_config_overrides` +( + `id` bigint NOT NULL AUTO_INCREMENT, + `server_id` bigint NOT NULL COMMENT 'Server ID', + `ip_strategy` varchar(32) CHARACTER SET utf8mb4 COLLATE utf8mb4_general_ci DEFAULT NULL COMMENT 'IP strategy override, NULL means inherit', + `dns` text CHARACTER SET utf8mb4 COLLATE utf8mb4_general_ci COMMENT 'DNS override, NULL means inherit', + `block` text CHARACTER SET utf8mb4 COLLATE utf8mb4_general_ci COMMENT 'Block override, NULL means inherit', + `outbound` text CHARACTER SET utf8mb4 COLLATE utf8mb4_general_ci COMMENT 'Outbound override, NULL means inherit', + `created_at` datetime(3) DEFAULT NULL COMMENT 'Creation Time', + `updated_at` datetime(3) DEFAULT NULL COMMENT 'Update Time', + PRIMARY KEY (`id`), + UNIQUE KEY `uni_server_config_overrides_server_id` (`server_id`) +) ENGINE = InnoDB + DEFAULT CHARSET = utf8mb4 + COLLATE = utf8mb4_general_ci; + +-- migrate:down +DROP TABLE IF EXISTS `server_config_overrides`; + diff --git a/migrations/mysql/02129_payment_sort.sql b/migrations/mysql/02129_payment_sort.sql new file mode 100644 index 00000000..d3f9eb75 --- /dev/null +++ b/migrations/mysql/02129_payment_sort.sql @@ -0,0 +1,42 @@ +-- migrate:up +SET @column_exists = ( + SELECT COUNT(*) + FROM INFORMATION_SCHEMA.COLUMNS + WHERE TABLE_SCHEMA = DATABASE() + AND TABLE_NAME = 'payment' + AND COLUMN_NAME = 'sort' +); + +SET @sql = IF( + @column_exists = 0, + 'ALTER TABLE `payment` ADD COLUMN `sort` bigint NOT NULL DEFAULT 0 COMMENT ''Sort'' AFTER `fee_amount`', + 'SELECT 1' +); + +PREPARE stmt FROM @sql; +EXECUTE stmt; +DEALLOCATE PREPARE stmt; + +UPDATE `payment` +SET `sort` = `id` +WHERE `sort` = 0; + +-- migrate:down +SET @column_exists = ( + SELECT COUNT(*) + FROM INFORMATION_SCHEMA.COLUMNS + WHERE TABLE_SCHEMA = DATABASE() + AND TABLE_NAME = 'payment' + AND COLUMN_NAME = 'sort' +); + +SET @sql = IF( + @column_exists > 0, + 'ALTER TABLE `payment` DROP COLUMN `sort`', + 'SELECT 1' +); + +PREPARE stmt FROM @sql; +EXECUTE stmt; +DEALLOCATE PREPARE stmt; + diff --git a/migrations/mysql/02130_subscribe_tutorial.sql b/migrations/mysql/02130_subscribe_tutorial.sql new file mode 100644 index 00000000..5d68a533 --- /dev/null +++ b/migrations/mysql/02130_subscribe_tutorial.sql @@ -0,0 +1,10 @@ +-- migrate:up +INSERT INTO `system` (`category`, `key`, `value`, `type`, `desc`, `created_at`, `updated_at`) +SELECT 'subscribe', 'ShowTutorial', 'true', 'bool', 'Show tutorial section on the user document page', '2025-04-22 14:25:16.639', '2025-04-22 14:25:16.639' + WHERE NOT EXISTS ( + SELECT 1 FROM `system` WHERE `category` = 'subscribe' AND `key` = 'ShowTutorial' +); + +-- migrate:down +DELETE FROM `system` WHERE `category` = 'subscribe' AND `key` = 'ShowTutorial'; + diff --git a/migrations/mysql/02131_timestamptz_last_reported_at.sql b/migrations/mysql/02131_timestamptz_last_reported_at.sql new file mode 100644 index 00000000..0a38ffd3 --- /dev/null +++ b/migrations/mysql/02131_timestamptz_last_reported_at.sql @@ -0,0 +1,9 @@ +-- migrate:up +-- MySQL datetime type does not have timezone support. +-- The Go code fix (serverPushStatusLogic.go, serverPushUserTrafficLogic.go) +-- removing .UTC() is sufficient for MySQL environments. +SELECT 1; + +-- migrate:down +SELECT 1; + diff --git a/migrations/postgres/00001_init_schema.sql b/migrations/postgres/00001_init_schema.sql new file mode 100644 index 00000000..ebc0a45e --- /dev/null +++ b/migrations/postgres/00001_init_schema.sql @@ -0,0 +1,494 @@ +-- migrate:up +-- 000001_init_schema.up.sql +CREATE TABLE IF NOT EXISTS "ads" +( + "id" BIGINT GENERATED BY DEFAULT AS IDENTITY NOT NULL, + "title" varchar(255) NOT NULL DEFAULT '', + "type" varchar(255) NOT NULL DEFAULT '', + "content" text, + "target_url" varchar(512) DEFAULT '', + "start_time" TIMESTAMP DEFAULT NULL, + "end_time" TIMESTAMP DEFAULT NULL, + "status" SMALLINT DEFAULT '0', + "created_at" TIMESTAMP(3) DEFAULT NULL, + "updated_at" TIMESTAMP(3) DEFAULT NULL, + PRIMARY KEY ("id") +); +CREATE TABLE IF NOT EXISTS "announcement" +( + "id" BIGINT GENERATED BY DEFAULT AS IDENTITY NOT NULL, + "title" varchar(255) NOT NULL DEFAULT '', + "content" text, + "show" BOOLEAN NOT NULL DEFAULT false, + "pinned" BOOLEAN NOT NULL DEFAULT false, + "popup" BOOLEAN NOT NULL DEFAULT false, + "created_at" TIMESTAMP(3) DEFAULT NULL, + "updated_at" TIMESTAMP(3) DEFAULT NULL, + PRIMARY KEY ("id") +); +CREATE TABLE IF NOT EXISTS "application" +( + "id" BIGINT GENERATED BY DEFAULT AS IDENTITY NOT NULL, + "name" varchar(255) NOT NULL DEFAULT '', + "icon" text NOT NULL, + "description" text, + "subscribe_type" varchar(50) NOT NULL DEFAULT '', + "created_at" TIMESTAMP(3) DEFAULT NULL, + "updated_at" TIMESTAMP(3) DEFAULT NULL, + PRIMARY KEY ("id") +); +CREATE TABLE IF NOT EXISTS "application_config" +( + "id" BIGINT GENERATED BY DEFAULT AS IDENTITY NOT NULL, + "app_id" bigint NOT NULL DEFAULT '0', + "encryption_key" text, + "encryption_method" varchar(255) DEFAULT NULL, + "domains" text, + "startup_picture" text, + "startup_picture_skip_time" bigint NOT NULL DEFAULT '0', + "created_at" TIMESTAMP(3) DEFAULT NULL, + "updated_at" TIMESTAMP(3) DEFAULT NULL, + PRIMARY KEY ("id") +); +CREATE TABLE IF NOT EXISTS "application_version" +( + "id" BIGINT GENERATED BY DEFAULT AS IDENTITY NOT NULL, + "url" varchar(255) NOT NULL DEFAULT '', + "version" varchar(255) NOT NULL DEFAULT '', + "platform" varchar(50) NOT NULL DEFAULT '', + "is_default" BOOLEAN NOT NULL DEFAULT false, + "description" text, + "application_id" bigint DEFAULT NULL, + "created_at" TIMESTAMP(3) DEFAULT NULL, + "updated_at" TIMESTAMP(3) DEFAULT NULL, + PRIMARY KEY ("id") +); +CREATE INDEX IF NOT EXISTS "application_version_fk_application_application_versions" ON "application_version" ("application_id"); +CREATE TABLE IF NOT EXISTS "auth_method" +( + "id" BIGINT GENERATED BY DEFAULT AS IDENTITY NOT NULL, + "method" varchar(255) NOT NULL DEFAULT '', + "config" text NOT NULL, + "enabled" BOOLEAN NOT NULL DEFAULT false, + "created_at" TIMESTAMP(3) DEFAULT NULL, + "updated_at" TIMESTAMP(3) DEFAULT NULL, + PRIMARY KEY ("id"), + CONSTRAINT "uni_auth_method" UNIQUE ("method") +); +CREATE TABLE IF NOT EXISTS "coupon" +( + "id" BIGINT GENERATED BY DEFAULT AS IDENTITY NOT NULL, + "name" varchar(255) NOT NULL DEFAULT '', + "code" varchar(255) NOT NULL DEFAULT '', + "count" bigint NOT NULL DEFAULT '0', + "type" SMALLINT NOT NULL DEFAULT '1', + "discount" bigint NOT NULL DEFAULT '0', + "start_time" bigint NOT NULL DEFAULT '0', + "expire_time" bigint NOT NULL DEFAULT '0', + "user_limit" bigint NOT NULL DEFAULT '0', + "subscribe" varchar(255) NOT NULL DEFAULT '', + "used_count" bigint NOT NULL DEFAULT '0', + "enable" BOOLEAN NOT NULL DEFAULT true, + "created_at" TIMESTAMP(3) DEFAULT NULL, + "updated_at" TIMESTAMP(3) DEFAULT NULL, + PRIMARY KEY ("id"), + CONSTRAINT "uni_coupon_code" UNIQUE ("code") +); +CREATE TABLE IF NOT EXISTS "document" +( + "id" BIGINT GENERATED BY DEFAULT AS IDENTITY NOT NULL, + "title" varchar(255) NOT NULL DEFAULT '', + "content" text, + "tags" varchar(255) NOT NULL DEFAULT '', + "show" BOOLEAN NOT NULL DEFAULT true, + "created_at" TIMESTAMP(3) DEFAULT NULL, + "updated_at" TIMESTAMP(3) DEFAULT NULL, + PRIMARY KEY ("id") +); +CREATE TABLE IF NOT EXISTS "message_log" +( + "id" BIGINT GENERATED BY DEFAULT AS IDENTITY NOT NULL, + "type" varchar(50) NOT NULL DEFAULT 'email', + "platform" varchar(50) NOT NULL DEFAULT 'smtp', + "to" text NOT NULL, + "subject" varchar(255) NOT NULL DEFAULT '', + "content" text, + "status" SMALLINT NOT NULL DEFAULT '0', + "created_at" TIMESTAMP(3) DEFAULT NULL, + "updated_at" TIMESTAMP(3) DEFAULT NULL, + PRIMARY KEY ("id") +); +CREATE TABLE IF NOT EXISTS "order" +( + "id" BIGINT GENERATED BY DEFAULT AS IDENTITY NOT NULL, + "parent_id" bigint DEFAULT NULL, + "user_id" bigint NOT NULL DEFAULT '0', + "order_no" varchar(255) NOT NULL DEFAULT '', + "type" SMALLINT NOT NULL DEFAULT '1', + "quantity" bigint NOT NULL DEFAULT '1', + "price" bigint NOT NULL DEFAULT '0', + "amount" bigint NOT NULL DEFAULT '0', + "gift_amount" bigint NOT NULL DEFAULT '0', + "discount" bigint NOT NULL DEFAULT '0', + "coupon" varchar(255) DEFAULT NULL, + "coupon_discount" bigint NOT NULL DEFAULT '0', + "commission" bigint NOT NULL DEFAULT '0', + "payment_id" bigint NOT NULL DEFAULT '-1', + "method" varchar(255) NOT NULL DEFAULT '', + "fee_amount" bigint NOT NULL DEFAULT '0', + "trade_no" varchar(255) DEFAULT NULL, + "status" SMALLINT NOT NULL DEFAULT '1', + "subscribe_id" bigint NOT NULL DEFAULT '0', + "subscribe_token" varchar(255) DEFAULT NULL, + "is_new" BOOLEAN NOT NULL DEFAULT false, + "created_at" TIMESTAMP(3) DEFAULT NULL, + "updated_at" TIMESTAMP(3) DEFAULT NULL, + PRIMARY KEY ("id"), + CONSTRAINT "uni_order_order_no" UNIQUE ("order_no") +); +CREATE TABLE IF NOT EXISTS "payment" +( + "id" BIGINT GENERATED BY DEFAULT AS IDENTITY NOT NULL, + "name" varchar(100) NOT NULL DEFAULT '', + "platform" varchar(100) NOT NULL, + "description" text, + "icon" varchar(255) DEFAULT '', + "domain" varchar(255) DEFAULT '', + "config" text NOT NULL, + "fee_mode" SMALLINT NOT NULL DEFAULT '0', + "fee_percent" bigint DEFAULT '0', + "fee_amount" bigint DEFAULT '0', + "enable" BOOLEAN NOT NULL DEFAULT false, + "token" varchar(255) DEFAULT NULL, + PRIMARY KEY ("id"), + CONSTRAINT "uni_payment_token" UNIQUE ("token") +); +CREATE TABLE IF NOT EXISTS "server" +( + "id" BIGINT GENERATED BY DEFAULT AS IDENTITY NOT NULL, + "name" varchar(100) NOT NULL DEFAULT '', + "tags" varchar(128) NOT NULL DEFAULT '', + "country" varchar(128) NOT NULL DEFAULT '', + "city" varchar(128) NOT NULL DEFAULT '', + "latitude" varchar(128) NOT NULL DEFAULT '', + "longitude" varchar(128) NOT NULL DEFAULT '', + "server_addr" varchar(100) NOT NULL DEFAULT '', + "relay_mode" varchar(20) NOT NULL DEFAULT 'none', + "relay_node" text, + "speed_limit" bigint NOT NULL DEFAULT '0', + "traffic_ratio" decimal(4, 2) NOT NULL DEFAULT '0.00', + "group_id" bigint DEFAULT NULL, + "protocol" varchar(20) NOT NULL DEFAULT '', + "config" text, + "enable" SMALLINT NOT NULL DEFAULT '1', + "sort" bigint NOT NULL DEFAULT '0', + "last_reported_at" TIMESTAMP(3) DEFAULT NULL, + "created_at" TIMESTAMP(3) DEFAULT NULL, + "updated_at" TIMESTAMP(3) DEFAULT NULL, + PRIMARY KEY ("id") +); +CREATE INDEX IF NOT EXISTS "server_idx_group_id" ON "server" ("group_id"); +CREATE TABLE IF NOT EXISTS "server_group" +( + "id" BIGINT GENERATED BY DEFAULT AS IDENTITY NOT NULL, + "name" varchar(100) NOT NULL DEFAULT '', + "description" varchar(255) DEFAULT '', + "created_at" TIMESTAMP(3) DEFAULT NULL, + "updated_at" TIMESTAMP(3) DEFAULT NULL, + PRIMARY KEY ("id") +); +-- if "sms" not exist, create it +CREATE TABLE IF NOT EXISTS "sms" +( + "id" BIGINT GENERATED BY DEFAULT AS IDENTITY NOT NULL, + "content" text, + "platform" varchar(64) DEFAULT NULL, + "area_code" varchar(64) DEFAULT NULL, + "telephone" varchar(64) DEFAULT NULL, + "status" SMALLINT DEFAULT '1', + "created_at" timestamp NULL DEFAULT CURRENT_TIMESTAMP, + PRIMARY KEY ("id") +); +CREATE TABLE IF NOT EXISTS "subscribe" +( + "id" BIGINT GENERATED BY DEFAULT AS IDENTITY NOT NULL, + "name" varchar(255) NOT NULL DEFAULT '', + "description" text, + "unit_price" bigint NOT NULL DEFAULT '0', + "unit_time" varchar(255) NOT NULL DEFAULT '', + "discount" text, + "replacement" bigint NOT NULL DEFAULT '0', + "inventory" bigint NOT NULL DEFAULT '0', + "traffic" bigint NOT NULL DEFAULT '0', + "speed_limit" bigint NOT NULL DEFAULT '0', + "device_limit" bigint NOT NULL DEFAULT '0', + "quota" bigint NOT NULL DEFAULT '0', + "group_id" bigint DEFAULT NULL, + "server_group" varchar(255) DEFAULT NULL, + "server" varchar(255) DEFAULT NULL, + "show" BOOLEAN NOT NULL DEFAULT false, + "sell" BOOLEAN NOT NULL DEFAULT false, + "sort" bigint NOT NULL DEFAULT '0', + "deduction_ratio" bigint DEFAULT '0', + "allow_deduction" BOOLEAN DEFAULT true, + "reset_cycle" bigint DEFAULT '0', + "renewal_reset" BOOLEAN DEFAULT false, + "created_at" TIMESTAMP(3) DEFAULT NULL, + "updated_at" TIMESTAMP(3) DEFAULT NULL, + PRIMARY KEY ("id") +); +CREATE TABLE IF NOT EXISTS "subscribe_group" +( + "id" BIGINT GENERATED BY DEFAULT AS IDENTITY NOT NULL, + "name" varchar(255) NOT NULL DEFAULT '', + "description" text, + "created_at" TIMESTAMP(3) DEFAULT NULL, + "updated_at" TIMESTAMP(3) DEFAULT NULL, + PRIMARY KEY ("id") +); +CREATE TABLE IF NOT EXISTS "subscribe_type" +( + "id" BIGINT GENERATED BY DEFAULT AS IDENTITY NOT NULL, + "name" varchar(50) NOT NULL DEFAULT '', + "mark" varchar(255) NOT NULL DEFAULT '', + "created_at" TIMESTAMP(3) DEFAULT NULL, + "updated_at" TIMESTAMP(3) DEFAULT NULL, + PRIMARY KEY ("id") +); +CREATE TABLE IF NOT EXISTS "system" +( + "id" BIGINT GENERATED BY DEFAULT AS IDENTITY NOT NULL, + "category" varchar(100) NOT NULL DEFAULT '', + "key" varchar(100) NOT NULL DEFAULT '', + "value" text NOT NULL, + "type" varchar(50) NOT NULL DEFAULT '', + "desc" text NOT NULL, + "created_at" TIMESTAMP(3) DEFAULT NULL, + "updated_at" TIMESTAMP(3) DEFAULT NULL, + PRIMARY KEY ("id"), + CONSTRAINT "uni_system_key" UNIQUE ("key") +); +CREATE INDEX IF NOT EXISTS "system_index_key" ON "system" ("key"); +CREATE TABLE IF NOT EXISTS "ticket" +( + "id" BIGINT GENERATED BY DEFAULT AS IDENTITY NOT NULL, + "title" varchar(255) NOT NULL DEFAULT '', + "description" text, + "user_id" bigint NOT NULL DEFAULT '0', + "status" SMALLINT NOT NULL DEFAULT '1', + "created_at" TIMESTAMP(3) DEFAULT NULL, + "updated_at" TIMESTAMP(3) DEFAULT NULL, + PRIMARY KEY ("id") +); +CREATE TABLE IF NOT EXISTS "ticket_follow" +( + "id" BIGINT GENERATED BY DEFAULT AS IDENTITY NOT NULL, + "ticket_id" bigint NOT NULL DEFAULT '0', + "from" varchar(255) NOT NULL DEFAULT '', + "type" SMALLINT NOT NULL DEFAULT '1', + "content" text, + "created_at" TIMESTAMP(3) DEFAULT NULL, + PRIMARY KEY ("id") +); +CREATE TABLE IF NOT EXISTS "traffic_log" +( + "id" BIGINT GENERATED BY DEFAULT AS IDENTITY NOT NULL, + "server_id" bigint NOT NULL, + "user_id" bigint NOT NULL, + "subscribe_id" bigint NOT NULL, + "download" bigint DEFAULT '0', + "upload" bigint DEFAULT '0', + "timestamp" TIMESTAMP(3) NOT NULL DEFAULT CURRENT_TIMESTAMP(3), + PRIMARY KEY ("id") +); +CREATE INDEX IF NOT EXISTS "traffic_log_idx_subscribe_id" ON "traffic_log" ("subscribe_id"); +CREATE INDEX IF NOT EXISTS "traffic_log_idx_server_id" ON "traffic_log" ("server_id"); +CREATE INDEX IF NOT EXISTS "traffic_log_idx_user_id" ON "traffic_log" ("user_id"); +CREATE TABLE IF NOT EXISTS "user" +( + "id" BIGINT GENERATED BY DEFAULT AS IDENTITY NOT NULL, + "password" varchar(100) NOT NULL, + "avatar" text, + "balance" bigint DEFAULT '0', + "telegram" bigint DEFAULT NULL, + "refer_code" varchar(20) DEFAULT '', + "referer_id" bigint DEFAULT NULL, + "commission" bigint DEFAULT '0', + "gift_amount" bigint DEFAULT '0', + "enable" BOOLEAN NOT NULL DEFAULT true, + "is_admin" BOOLEAN NOT NULL DEFAULT false, + "valid_email" SMALLINT NOT NULL DEFAULT '0', + "enable_email_notify" SMALLINT NOT NULL DEFAULT '0', + "enable_telegram_notify" SMALLINT NOT NULL DEFAULT '0', + "enable_balance_notify" BOOLEAN NOT NULL DEFAULT false, + "enable_login_notify" BOOLEAN NOT NULL DEFAULT false, + "enable_subscribe_notify" BOOLEAN NOT NULL DEFAULT false, + "enable_trade_notify" BOOLEAN NOT NULL DEFAULT false, + "created_at" TIMESTAMP(3) DEFAULT NULL, + "updated_at" TIMESTAMP(3) DEFAULT NULL, + "deleted_at" TIMESTAMP(3) DEFAULT NULL, + "is_del" BIGINT DEFAULT NULL, + PRIMARY KEY ("id") +); +CREATE INDEX IF NOT EXISTS "user_idx_referer" ON "user" ("referer_id"); +CREATE TABLE IF NOT EXISTS "user_auth_methods" +( + "id" BIGINT GENERATED BY DEFAULT AS IDENTITY NOT NULL, + "user_id" bigint NOT NULL, + "auth_type" varchar(255) NOT NULL, + "auth_identifier" varchar(255) NOT NULL, + "verified" BOOLEAN NOT NULL DEFAULT false, + "created_at" TIMESTAMP(3) DEFAULT NULL, + "updated_at" TIMESTAMP(3) DEFAULT NULL, + PRIMARY KEY ("id"), + CONSTRAINT "idx_auth_identifier" UNIQUE ("auth_identifier") +); +CREATE INDEX IF NOT EXISTS "user_auth_methods_idx_user_id" ON "user_auth_methods" ("user_id"); +CREATE TABLE IF NOT EXISTS "user_balance_log" +( + "id" BIGINT GENERATED BY DEFAULT AS IDENTITY NOT NULL, + "user_id" bigint NOT NULL, + "amount" bigint NOT NULL, + "type" SMALLINT NOT NULL, + "order_id" bigint DEFAULT NULL, + "balance" bigint NOT NULL, + "created_at" TIMESTAMP(3) DEFAULT NULL, + PRIMARY KEY ("id") +); +CREATE INDEX IF NOT EXISTS "user_balance_log_idx_user_id" ON "user_balance_log" ("user_id"); +CREATE TABLE IF NOT EXISTS "user_commission_log" +( + "id" BIGINT GENERATED BY DEFAULT AS IDENTITY NOT NULL, + "user_id" bigint NOT NULL, + "order_no" varchar(191) DEFAULT NULL, + "amount" bigint NOT NULL, + "created_at" TIMESTAMP(3) DEFAULT NULL, + PRIMARY KEY ("id") +); +CREATE INDEX IF NOT EXISTS "user_commission_log_idx_user_id" ON "user_commission_log" ("user_id"); +CREATE TABLE IF NOT EXISTS "user_device" +( + "id" BIGINT GENERATED BY DEFAULT AS IDENTITY NOT NULL, + "user_id" bigint NOT NULL, + "subscribe_id" bigint DEFAULT NULL, + "ip" varchar(191) DEFAULT NULL, + "identifier" varchar(191) DEFAULT NULL, + "user_agent" varchar(64) DEFAULT NULL, + "online" BOOLEAN NOT NULL DEFAULT false, + "enabled" BOOLEAN NOT NULL DEFAULT true, + "created_at" TIMESTAMP(3) DEFAULT NULL, + "updated_at" TIMESTAMP(3) DEFAULT NULL, + PRIMARY KEY ("id") +); +CREATE INDEX IF NOT EXISTS "user_device_idx_user_id" ON "user_device" ("user_id"); +CREATE TABLE IF NOT EXISTS "user_gift_amount_log" +( + "id" BIGINT GENERATED BY DEFAULT AS IDENTITY NOT NULL, + "user_id" bigint NOT NULL, + "user_subscribe_id" bigint DEFAULT NULL, + "order_no" varchar(191) DEFAULT NULL, + "type" SMALLINT NOT NULL, + "amount" bigint NOT NULL, + "balance" bigint NOT NULL, + "remark" varchar(255) DEFAULT '', + "created_at" TIMESTAMP(3) DEFAULT NULL, + PRIMARY KEY ("id") +); +CREATE INDEX IF NOT EXISTS "user_gift_amount_log_idx_user_id" ON "user_gift_amount_log" ("user_id"); +CREATE TABLE IF NOT EXISTS "user_login_log" +( + "id" BIGINT GENERATED BY DEFAULT AS IDENTITY NOT NULL, + "user_id" bigint NOT NULL, + "login_ip" varchar(255) NOT NULL, + "user_agent" text NOT NULL, + "success" BOOLEAN NOT NULL DEFAULT false, + "created_at" TIMESTAMP(3) DEFAULT NULL, + PRIMARY KEY ("id") +); +CREATE INDEX IF NOT EXISTS "user_login_log_idx_user_id" ON "user_login_log" ("user_id"); +CREATE TABLE IF NOT EXISTS "user_subscribe" +( + "id" BIGINT GENERATED BY DEFAULT AS IDENTITY NOT NULL, + "user_id" bigint NOT NULL, + "order_id" bigint NOT NULL, + "subscribe_id" bigint NOT NULL, + "start_time" TIMESTAMP(3) NOT NULL DEFAULT CURRENT_TIMESTAMP(3), + "expire_time" TIMESTAMP(3) DEFAULT NULL, + "traffic" bigint DEFAULT '0', + "download" bigint DEFAULT '0', + "upload" bigint DEFAULT '0', + "token" varchar(255) DEFAULT '', + "uuid" varchar(255) DEFAULT '', + "status" SMALLINT DEFAULT '0', + "created_at" TIMESTAMP(3) DEFAULT NULL, + "updated_at" TIMESTAMP(3) DEFAULT NULL, + PRIMARY KEY ("id"), + CONSTRAINT "uni_user_subscribe_token" UNIQUE ("token"), + CONSTRAINT "uni_user_subscribe_uuid" UNIQUE ("uuid") +); +CREATE INDEX IF NOT EXISTS "user_subscribe_idx_user_id" ON "user_subscribe" ("user_id"); +CREATE INDEX IF NOT EXISTS "user_subscribe_idx_order_id" ON "user_subscribe" ("order_id"); +CREATE INDEX IF NOT EXISTS "user_subscribe_idx_subscribe_id" ON "user_subscribe" ("subscribe_id"); +CREATE INDEX IF NOT EXISTS "user_subscribe_idx_token" ON "user_subscribe" ("token"); +CREATE INDEX IF NOT EXISTS "user_subscribe_idx_uuid" ON "user_subscribe" ("uuid"); +CREATE TABLE IF NOT EXISTS "user_subscribe_log" +( + "id" BIGINT GENERATED BY DEFAULT AS IDENTITY NOT NULL, + "user_id" bigint NOT NULL, + "user_subscribe_id" bigint NOT NULL, + "token" varchar(255) NOT NULL, + "ip" varchar(255) NOT NULL, + "user_agent" text NOT NULL, + "created_at" TIMESTAMP(3) DEFAULT NULL, + PRIMARY KEY ("id") +); +CREATE INDEX IF NOT EXISTS "user_subscribe_log_idx_user_id" ON "user_subscribe_log" ("user_id"); +CREATE INDEX IF NOT EXISTS "user_subscribe_log_idx_user_subscribe_id" ON "user_subscribe_log" ("user_subscribe_id"); +CREATE TABLE IF NOT EXISTS "server_rule_group" +( + "id" BIGINT GENERATED BY DEFAULT AS IDENTITY NOT NULL, + "name" varchar(100) NOT NULL DEFAULT '', + "icon" text, + "tags" text, + "description" varchar(255) DEFAULT '', + "enable" BOOLEAN NOT NULL DEFAULT true, + "created_at" TIMESTAMP(3) DEFAULT NULL, + "updated_at" TIMESTAMP(3) DEFAULT NULL, + PRIMARY KEY ("id"), + CONSTRAINT "unique_name" UNIQUE ("name") +); + +-- migrate:down +-- 000001_init_schema.down.sql +DROP TABLE IF EXISTS "user_subscribe_log"; +DROP TABLE IF EXISTS "user_subscribe"; +DROP TABLE IF EXISTS "user_login_log"; +DROP TABLE IF EXISTS "user_gift_amount_log"; +DROP TABLE IF EXISTS "user_device"; +DROP TABLE IF EXISTS "user_commission_log"; +DROP TABLE IF EXISTS "user_balance_log"; +DROP TABLE IF EXISTS "user_auth_methods"; +DROP TABLE IF EXISTS "user"; +DROP TABLE IF EXISTS "traffic_log"; +DROP TABLE IF EXISTS "ticket_follow"; +DROP TABLE IF EXISTS "ticket"; +DROP TABLE IF EXISTS "system"; +DROP TABLE IF EXISTS "subscribe_type"; +DROP TABLE IF EXISTS "subscribe_group"; +DROP TABLE IF EXISTS "subscribe"; +DROP TABLE IF EXISTS "sms"; +DROP TABLE IF EXISTS "server_rule_group"; +DROP TABLE IF EXISTS "server_group"; +DROP TABLE IF EXISTS "server"; +DROP TABLE IF EXISTS "payment"; +DROP TABLE IF EXISTS "order"; +DROP TABLE IF EXISTS "message_log"; +DROP TABLE IF EXISTS "document"; +DROP TABLE IF EXISTS "coupon"; +DROP TABLE IF EXISTS "auth_method"; +DROP TABLE IF EXISTS "application_version"; +DROP TABLE IF EXISTS "application_config"; +DROP TABLE IF EXISTS "application"; +DROP TABLE IF EXISTS "announcement"; +DROP TABLE IF EXISTS "ads"; + diff --git a/migrations/postgres/00002_init_basic_data.sql b/migrations/postgres/00002_init_basic_data.sql new file mode 100644 index 00000000..8c053080 --- /dev/null +++ b/migrations/postgres/00002_init_basic_data.sql @@ -0,0 +1,135 @@ +-- migrate:up +-- 000002_init_data.up.sql +-- auth_method +INSERT INTO "auth_method" ("id", "method", "config", "enabled", "created_at", "updated_at") +VALUES +(1, 'email', '{"platform":"smtp","platform_config":{"host":"","port":0,"user":"","pass":"","from":"","ssl":false},"enable_verify":false,"enable_notify":false,"enable_domain_suffix":false,"domain_suffix_list":"","verify_email_template":"","expiration_email_template":"","maintenance_email_template":"","traffic_exceed_email_template":""}', true, '2025-04-22 14:25:16.642', '2025-04-22 14:25:16.642'), +(2, 'mobile', '{"platform":"AlibabaCloud","platform_config":{"access":"","secret":"","sign_name":"","endpoint":"","template_code":""},"enable_whitelist":false,"whitelist":[]}', false, '2025-04-22 14:25:16.642', '2025-04-22 14:25:16.642'), +(3, 'apple', '{"team_id":"","key_id":"","client_id":"","client_secret":"","redirect_url":""}', false, '2025-04-22 14:25:16.642', '2025-04-22 14:25:16.642'), +(4, 'google', '{"client_id":"","client_secret":"","redirect_url":""}', false, '2025-04-22 14:25:16.642', '2025-04-22 14:25:16.642'), +(5, 'github', '{"client_id":"","client_secret":"","redirect_url":""}', false, '2025-04-22 14:25:16.642', '2025-04-22 14:25:16.642'), +(6, 'facebook', '{"client_id":"","client_secret":"","redirect_url":""}', false, '2025-04-22 14:25:16.642', '2025-04-22 14:25:16.642'), +(7, 'telegram', '{"bot_token":"","enable_notify":false,"webhook_domain":""}', false, '2025-04-22 14:25:16.642', '2025-04-22 14:25:16.642'), +(8, 'device', '{"show_ads":false,"only_real_device":false,"enable_security":false,"security_secret":""}', false, '2025-04-22 14:25:16.642', '2025-04-22 14:25:16.642'); +-- payment +INSERT INTO "payment" ("id", "name", "platform", "description", "icon", "domain", "config", "fee_mode", + "fee_percent", "fee_amount", "enable", "token") +VALUES +(-1, 'Balance', 'balance', '', '', '', '', 0, 0, 0, true, ''); +-- subscribe_type +INSERT INTO "subscribe_type" ("id", "name", "mark", "created_at", "updated_at") +VALUES (1, 'Clash', 'Clash', '2025-04-22 14:25:16.648', '2025-04-22 14:25:16.648'), + (2, 'Hiddify', 'Hiddify', '2025-04-22 14:25:16.648', '2025-04-22 14:25:16.648'), + (3, 'Loon', 'Loon', '2025-04-22 14:25:16.648', '2025-04-22 14:25:16.648'), + (4, 'NekoBox', 'NekoBox', '2025-04-22 14:25:16.648', '2025-04-22 14:25:16.648'), + (5, 'NekoRay', 'NekoRay', '2025-04-22 14:25:16.648', '2025-04-22 14:25:16.648'), + (6, 'Netch', 'Netch', '2025-04-22 14:25:16.648', '2025-04-22 14:25:16.648'), + (7, 'Quantumult', 'Quantumult', '2025-04-22 14:25:16.648', '2025-04-22 14:25:16.648'), + (8, 'Shadowrocket', 'Shadowrocket', '2025-04-22 14:25:16.648', '2025-04-22 14:25:16.648'), + (9, 'SingBox', ' SingBox', '2025-04-22 14:25:16.648', '2025-04-22 14:25:16.648'), + (10, 'Surfboard', 'Surfboard', '2025-04-22 14:25:16.648', '2025-04-22 14:25:16.648'), + (11, 'Surge', 'Surge', '2025-04-22 14:25:16.648', '2025-04-22 14:25:16.648'), + (12, 'V2box', 'V2box', '2025-04-22 14:25:16.648', '2025-04-22 14:25:16.648'), + (13, 'V2rayN', 'V2rayN', '2025-04-22 14:25:16.648', '2025-04-22 14:25:16.648'), + (14, 'V2rayNg', 'V2rayNg', '2025-04-22 14:25:16.648', '2025-04-22 14:25:16.648'); +-- system +INSERT INTO "system" ("id", "category", "key", "value", "type", "desc", "created_at", "updated_at") +VALUES (1, 'site', 'SiteLogo', '/favicon.svg', 'string', 'Site Logo', '2025-04-22 14:25:16.637', + '2025-04-22 14:25:16.637'), + (2, 'site', 'SiteName', 'Perfect Panel', 'string', 'Site Name', '2025-04-22 14:25:16.637', + '2025-04-22 14:25:16.637'), + (3, 'site', 'SiteDesc', + 'PPanel is a pure, professional, and perfect open-source proxy panel tool, designed to be your ideal choice for learning and practical use.', + 'string', 'Site Description', '2025-04-22 14:25:16.637', '2025-04-22 14:25:16.637'), + (4, 'site', 'Host', '', 'string', 'Site Host', '2025-04-22 14:25:16.637', '2025-04-22 14:25:16.637'), + (5, 'site', 'Keywords', 'Perfect Panel,PPanel', 'string', 'Site Keywords', '2025-04-22 14:25:16.637', + '2025-04-22 14:25:16.637'), + (6, 'site', 'CustomHTML', '', 'string', 'Custom HTML', '2025-04-22 14:25:16.637', '2025-04-22 14:25:16.637'), + (7, 'tos', 'TosContent', 'Welcome to use Perfect Panel', 'string', 'Terms of Service', '2025-04-22 14:25:16.637', + '2025-04-22 14:25:16.637'), + (8, 'tos', 'PrivacyPolicy', '', 'string', 'PrivacyPolicy', '2025-04-22 14:25:16.637', '2025-04-22 14:25:16.637'), + (9, 'ad', 'WebAD', 'false', 'bool', 'Display ad on the web', '2025-04-22 14:25:16.637', + '2025-04-22 14:25:16.637'), + (10, 'subscribe', 'SingleModel', 'false', 'bool', '是否单订阅模式', '2025-04-22 14:25:16.639', + '2025-04-22 14:25:16.639'), + (11, 'subscribe', 'SubscribePath', '/api/subscribe', 'string', '订阅路径', '2025-04-22 14:25:16.639', + '2025-04-22 14:25:16.639'), + (12, 'subscribe', 'SubscribeDomain', '', 'string', '订阅域名', '2025-04-22 14:25:16.639', + '2025-04-22 14:25:16.639'), + (13, 'subscribe', 'PanDomain', 'false', 'bool', '是否使用泛域名', '2025-04-22 14:25:16.639', + '2025-04-22 14:25:16.639'), + (14, 'verify', 'TurnstileSiteKey', '', 'string', 'TurnstileSiteKey', '2025-04-22 14:25:16.639', + '2025-04-22 14:25:16.639'), + (15, 'verify', 'TurnstileSecret', '', 'string', 'TurnstileSecret', '2025-04-22 14:25:16.639', + '2025-04-22 14:25:16.639'), + (16, 'verify', 'EnableLoginVerify', 'false', 'bool', 'is enable login verify', '2025-04-22 14:25:16.639', + '2025-04-22 14:25:16.639'), + (17, 'verify', 'EnableRegisterVerify', 'false', 'bool', 'is enable register verify', '2025-04-22 14:25:16.639', + '2025-04-22 14:25:16.639'), + (18, 'verify', 'EnableResetPasswordVerify', 'false', 'bool', 'is enable reset password verify', + '2025-04-22 14:25:16.639', '2025-04-22 14:25:16.639'), + (19, 'server', 'NodeSecret', '12345678', 'string', 'node secret', '2025-04-22 14:25:16.640', + '2025-04-22 14:25:16.640'), + (20, 'server', 'NodePullInterval', '10', 'int', 'node pull interval', '2025-04-22 14:25:16.640', + '2025-04-22 14:25:16.640'), + (21, 'server', 'NodePushInterval', '60', 'int', 'node push interval', '2025-04-22 14:25:16.640', + '2025-04-22 14:25:16.640'), + (22, 'server', 'NodeMultiplierConfig', '[]', 'string', 'node multiplier config', '2025-04-22 14:25:16.640', + '2025-04-22 14:25:16.640'), + (23, 'invite', 'ForcedInvite', 'false', 'bool', 'Forced invite', '2025-04-22 14:25:16.640', + '2025-04-22 14:25:16.640'), + (24, 'invite', 'ReferralPercentage', '20', 'int', 'Referral percentage', '2025-04-22 14:25:16.640', + '2025-04-22 14:25:16.640'), + (25, 'invite', 'OnlyFirstPurchase', 'false', 'bool', 'Only first purchase', '2025-04-22 14:25:16.640', + '2025-04-22 14:25:16.640'), + (26, 'register', 'StopRegister', 'false', 'bool', 'is stop register', '2025-04-22 14:25:16.640', + '2025-04-22 14:25:16.640'), + (27, 'register', 'EnableTrial', 'false', 'bool', 'is enable trial', '2025-04-22 14:25:16.640', + '2025-04-22 14:25:16.640'), + (28, 'register', 'TrialSubscribe', '', 'int', 'Trial subscription', '2025-04-22 14:25:16.640', + '2025-04-22 14:25:16.640'), + (29, 'register', 'TrialTime', '24', 'int', 'Trial time', '2025-04-22 14:25:16.640', '2025-04-22 14:25:16.640'), + (30, 'register', 'TrialTimeUnit', 'Hour', 'string', 'Trial time unit', '2025-04-22 14:25:16.640', + '2025-04-22 14:25:16.640'), + (31, 'register', 'EnableIpRegisterLimit', 'false', 'bool', 'is enable IP register limit', + '2025-04-22 14:25:16.640', '2025-04-22 14:25:16.640'), + (32, 'register', 'IpRegisterLimit', '3', 'int', 'IP Register Limit', '2025-04-22 14:25:16.640', + '2025-04-22 14:25:16.640'), + (33, 'register', 'IpRegisterLimitDuration', '64', 'int', 'IP Register Limit Duration (minutes)', + '2025-04-22 14:25:16.640', '2025-04-22 14:25:16.640'), + (34, 'currency', 'Currency', 'USD', 'string', 'Currency', '2025-04-22 14:25:16.641', '2025-04-22 14:25:16.641'), + (35, 'currency', 'CurrencySymbol', '$', 'string', 'Currency Symbol', '2025-04-22 14:25:16.641', + '2025-04-22 14:25:16.641'), + (36, 'currency', 'CurrencyUnit', 'USD', 'string', 'Currency Unit', '2025-04-22 14:25:16.641', + '2025-04-22 14:25:16.641'), + (37, 'currency', 'AccessKey', '', 'string', 'Exchangerate Access Key', '2025-04-22 14:25:16.641', + '2025-04-22 14:25:16.641'), + (38, 'verify_code', 'VerifyCodeExpireTime', '300', 'int', 'Verify code expire time', '2025-04-22 14:25:16.641', + '2025-04-22 14:25:16.641'), + (39, 'verify_code', 'VerifyCodeLimit', '15', 'int', 'limits of verify code', '2025-04-22 14:25:16.641', + '2025-04-22 14:25:16.641'), + (40, 'verify_code', 'VerifyCodeInterval', '60', 'int', 'Interval of verify code', '2025-04-22 14:25:16.641', + '2025-04-22 14:25:16.641'), + (41, 'system', 'Version', '0.2.0(02002)', 'string', 'System Version', '2025-04-22 14:25:16.642', + '2025-04-22 14:25:16.642'); +SELECT setval(pg_get_serial_sequence('"auth_method"', 'id'), COALESCE((SELECT MAX("id") FROM "auth_method"), 1), true); +SELECT setval(pg_get_serial_sequence('"subscribe_type"', 'id'), COALESCE((SELECT MAX("id") FROM "subscribe_type"), 1), true); +SELECT setval(pg_get_serial_sequence('"system"', 'id'), COALESCE((SELECT MAX("id") FROM "system"), 1), true); + +-- migrate:down +-- 000002_init_data.down.sql +DELETE +FROM "auth_method" +WHERE "id" IN (1, 2, 3, 4, 5, 6, 7, 8); +DELETE +FROM "payment" +WHERE "id" = -1; +DELETE +FROM "subscribe_type" +WHERE "id" IN (1, 2, 3, 4, 5, 6, 7, 8, 9, 10, 11, 12, 13, 14); +DELETE +FROM "system" +WHERE "id" IN + (1, 2, 3, 4, 5, 6, 7, 8, 9, 10, 11, 12, 13, 14, 15, 16, 17, 18, 19, 20, 21, 22, 23, 24, 25, 26, 27, 28, 29, 30, + 31, 32, 33, 34, 35, 36, 37, 38, 39, 40, 41); + diff --git a/migrations/postgres/02003_update_payment.sql b/migrations/postgres/02003_update_payment.sql new file mode 100644 index 00000000..76acfd6e --- /dev/null +++ b/migrations/postgres/02003_update_payment.sql @@ -0,0 +1,15 @@ +-- migrate:up +-- PostgreSQL version of payment/order compatibility migration. +ALTER TABLE "order" ADD COLUMN IF NOT EXISTS "payment_id" BIGINT NOT NULL DEFAULT -1; +ALTER TABLE "payment" ADD COLUMN IF NOT EXISTS "platform" VARCHAR(100) NOT NULL DEFAULT ''; +ALTER TABLE "payment" DROP COLUMN IF EXISTS "mark"; +ALTER TABLE "payment" ADD COLUMN IF NOT EXISTS "description" TEXT; +ALTER TABLE "payment" ADD COLUMN IF NOT EXISTS "token" VARCHAR(255) DEFAULT NULL; + +-- migrate:down +ALTER TABLE "order" DROP COLUMN IF EXISTS "payment_id"; +ALTER TABLE "payment" DROP COLUMN IF EXISTS "platform"; +ALTER TABLE "payment" DROP COLUMN IF EXISTS "description"; +ALTER TABLE "payment" DROP COLUMN IF EXISTS "token"; +ALTER TABLE "payment" ADD COLUMN IF NOT EXISTS "mark" VARCHAR(255) DEFAULT NULL; + diff --git a/migrations/postgres/02004_rebuild_rule.sql b/migrations/postgres/02004_rebuild_rule.sql new file mode 100644 index 00000000..2d7d641a --- /dev/null +++ b/migrations/postgres/02004_rebuild_rule.sql @@ -0,0 +1,27 @@ +-- migrate:up +-- migrations/02003_rebuild_rule.up.sql +-- Purpose: rebuilding server rule table +-- Author: PPanel Team, 2025-04-21 + +DROP TABLE IF EXISTS "server_rule_group"; +CREATE TABLE "server_rule_group" +( + "id" BIGINT GENERATED BY DEFAULT AS IDENTITY NOT NULL, + "name" VARCHAR(64) NOT NULL DEFAULT '', + "icon" VARCHAR(255), + "tags" TEXT, + "rules" TEXT, + "enable" BOOLEAN NOT NULL DEFAULT true, + "created_at" TIMESTAMP(3), + "updated_at" TIMESTAMP(3), + PRIMARY KEY ("id"), + CONSTRAINT "uni_server_rule_group_name" UNIQUE ("name") +); +CREATE INDEX IF NOT EXISTS "server_rule_group_idx_enable" ON "server_rule_group" ("enable"); + +-- migrate:down +-- migrations/02003_rebuild_rule.up.sql +-- Purpose: Back rebuilding server rule table +-- Author: PPanel Team, 2025-04-21 +DROP TABLE IF EXISTS server_rule_group; + diff --git a/migrations/postgres/02005_device_online_record.sql b/migrations/postgres/02005_device_online_record.sql new file mode 100644 index 00000000..a7e7d304 --- /dev/null +++ b/migrations/postgres/02005_device_online_record.sql @@ -0,0 +1,23 @@ +-- migrate:up +CREATE TABLE IF NOT EXISTS "user_device_online_record" +( + "id" BIGINT GENERATED BY DEFAULT AS IDENTITY PRIMARY KEY, + "user_id" BIGINT NOT NULL, + "identifier" VARCHAR(255) NOT NULL, + "online_time" TIMESTAMP, + "offline_time" TIMESTAMP, + "online_seconds" BIGINT, + "duration_days" BIGINT, + "created_at" TIMESTAMP +); + +ALTER TABLE "user_subscribe" ADD COLUMN IF NOT EXISTS "finished_at" TIMESTAMP NULL; +ALTER TABLE "application_config" ADD COLUMN IF NOT EXISTS "invitation_link" TEXT NULL DEFAULT NULL; +ALTER TABLE "application_config" ADD COLUMN IF NOT EXISTS "kr_website_id" VARCHAR(255) NULL DEFAULT NULL; + +-- migrate:down +DROP TABLE IF EXISTS "user_device_online_record"; +ALTER TABLE "user_subscribe" DROP COLUMN IF EXISTS "finished_at"; +ALTER TABLE "application_config" DROP COLUMN IF EXISTS "invitation_link"; +ALTER TABLE "application_config" DROP COLUMN IF EXISTS "kr_website_id"; + diff --git a/migrations/postgres/02006_reset_subscribe_record.sql b/migrations/postgres/02006_reset_subscribe_record.sql new file mode 100644 index 00000000..94019934 --- /dev/null +++ b/migrations/postgres/02006_reset_subscribe_record.sql @@ -0,0 +1,24 @@ +-- migrate:up +-- migrations/02008_create_user_reset_subscribe_log.up.sql +-- Purpose: Create user_reset_subscribe_log table +-- Author: PPanel Team, 2025-04-22 + +CREATE TABLE IF NOT EXISTS "user_reset_subscribe_log" +( + "id" BIGINT GENERATED BY DEFAULT AS IDENTITY NOT NULL PRIMARY KEY, + "user_id" BIGINT NOT NULL, + "type" SMALLINT NOT NULL, + "order_no" VARCHAR(255) DEFAULT NULL, + "user_subscribe_id" BIGINT NOT NULL, + "created_at" TIMESTAMP NOT NULL DEFAULT CURRENT_TIMESTAMP +); +CREATE INDEX IF NOT EXISTS "user_reset_subscribe_log_idx_user_id" ON "user_reset_subscribe_log" ("user_id"); +CREATE INDEX IF NOT EXISTS "user_reset_subscribe_log_idx_user_subscribe_id" ON "user_reset_subscribe_log" ("user_subscribe_id"); + +-- migrate:down +-- migrations/02008_create_user_reset_subscribe_log.down.sql +-- Purpose: Drop user_reset_subscribe_log table +-- Author: PPanel Team, 2025-04-22 + +DROP TABLE IF EXISTS "user_reset_subscribe_log"; + diff --git a/migrations/postgres/02007_adapte_rule.sql b/migrations/postgres/02007_adapte_rule.sql new file mode 100644 index 00000000..514ba2aa --- /dev/null +++ b/migrations/postgres/02007_adapte_rule.sql @@ -0,0 +1,10 @@ +-- migrate:up +ALTER TABLE "server_rule_group" +ADD COLUMN "default" BOOLEAN NOT NULL DEFAULT false, +ADD COLUMN "type" VARCHAR(100) NOT NULL DEFAULT ''; + +-- migrate:down +ALTER TABLE "server_rule_group" +DROP COLUMN "default", +DROP COLUMN "type"; + diff --git a/migrations/postgres/02100_task.sql b/migrations/postgres/02100_task.sql new file mode 100644 index 00000000..16f7a738 --- /dev/null +++ b/migrations/postgres/02100_task.sql @@ -0,0 +1,26 @@ +-- migrate:up +DROP TABLE IF EXISTS "email_task"; +CREATE TABLE "email_task" ( + "id" BIGINT GENERATED BY DEFAULT AS IDENTITY NOT NULL, + "subject" varchar(255) NOT NULL, + "content" text NOT NULL, + "recipient" text NOT NULL, + "scope" varchar(50) NOT NULL, + "register_start_time" TIMESTAMP(3) DEFAULT NULL, + "register_end_time" TIMESTAMP(3) DEFAULT NULL, + "additional" text, + "scheduled" TIMESTAMP(3) NOT NULL, + "interval" SMALLINT NOT NULL, + "limit" BIGINT NOT NULL, + "status" SMALLINT NOT NULL, + "errors" text NOT NULL, + "total" BIGINT NOT NULL DEFAULT '0', + "current" BIGINT NOT NULL DEFAULT '0', + "created_at" TIMESTAMP(3) DEFAULT NULL, + "updated_at" TIMESTAMP(3) DEFAULT NULL, + PRIMARY KEY ("id") +); + +-- migrate:down +DROP TABLE IF EXISTS "email_task"; + diff --git a/migrations/postgres/02101_subscribe_application.sql b/migrations/postgres/02101_subscribe_application.sql new file mode 100644 index 00000000..4bcb281c --- /dev/null +++ b/migrations/postgres/02101_subscribe_application.sql @@ -0,0 +1,37 @@ +-- migrate:up +DROP TABLE IF EXISTS "subscribe_application"; +CREATE TABLE IF NOT EXISTS "subscribe_application" ( + "id" BIGINT GENERATED BY DEFAULT AS IDENTITY NOT NULL, + "name" varchar(255) NOT NULL DEFAULT '', + "icon" TEXT, + "description" varchar(255) DEFAULT NULL, + "scheme" varchar(255) NOT NULL DEFAULT '', + "user_agent" varchar(255) NOT NULL DEFAULT '', + "is_default" BOOLEAN NOT NULL DEFAULT false, + "subscribe_template" TEXT, + "output_format" varchar(50) NOT NULL DEFAULT 'yaml', + "download_link" text NOT NULL, + "created_at" TIMESTAMP(3) DEFAULT NULL, + "updated_at" TIMESTAMP(3) DEFAULT NULL, + PRIMARY KEY ("id") +); +-- ---------------------------- +-- Records of subscribe_application +-- ---------------------------- +BEGIN; +INSERT INTO "subscribe_application" ("id", "name", "icon", "description", "scheme", "user_agent", "is_default", "subscribe_template", "output_format", "download_link", "created_at", "updated_at") VALUES +(1, 'Default', '', '', '', 'default', true, '{{- $GiB := 1073741824.0 -}}\n{{- $used := printf \"%.2f\" (divf (add (.UserInfo.Download | default 0 | float64) (.UserInfo.Upload | default 0 | float64)) $GiB) -}}\n{{- $traffic := (.UserInfo.Traffic | default 0 | float64) -}}\n{{- $total := printf \"%.2f\" (divf $traffic $GiB) -}}\n\n{{- $ExpiredAt := \"\" -}}\n{{- $expStr := printf \"%v\" .UserInfo.ExpiredAt -}}\n{{- if regexMatch `^[0-9]+$` $expStr -}}\n {{- $ts := $expStr | float64 -}}\n {{- $sec := ternary (divf $ts 1000.0) $ts (ge (len $expStr) 13) -}}\n {{- $ExpiredAt = (date \"2006-01-02 15:04:05\" (unixEpoch ($sec | int64))) -}}\n{{- else -}}\n {{- $ExpiredAt = $expStr -}}\n{{- end -}}\n\n{{- $sortFields := list \"Sort\" \"Port\" \"Name\" -}}\n{{- $sortConfig := dict \"Sort\" \"asc\" \"Port\" \"asc\" \"Name\" \"asc\" -}}\n{{- $byKey := dict -}}\n{{- range $p := .Proxies -}}\n {{- $keyParts := list -}}\n {{- range $field := $sortFields -}}\n {{- $order := index $sortConfig $field -}}\n {{- $val := default \"\" (printf \"%v\" (index $p $field)) -}}\n {{- if or (eq $field \"Sort\") (eq $field \"Port\") -}}\n {{- $val = printf \"%08d\" (int (default 0 (index $p $field))) -}}\n {{- end -}}\n {{- if eq $order \"desc\" -}}\n {{- $val = printf \"~%s\" $val -}}\n {{- end -}}\n {{- $keyParts = append $keyParts $val -}}\n {{- end -}}\n {{- $_ := set $byKey (join \"|\" $keyParts) $p -}}\n{{- end -}}\n{{- $sorted := list -}}\n{{- range $k := sortAlpha (keys $byKey) -}}\n {{- $sorted = append $sorted (index $byKey $k) -}}\n{{- end -}}\n\n{{- $supportSet := dict \"shadowsocks\" true \"vmess\" true \"vless\" true \"trojan\" true \"hysteria2\" true \"hysteria\" true \"tuic\" true \"anytls\" true -}}\n{{- $supportedProxies := list -}}\n{{- range $proxy := $sorted -}}\n {{- if hasKey $supportSet $proxy.Type -}}\n {{- $supportedProxies = append $supportedProxies $proxy -}}\n {{- end -}}\n{{- end -}}\n\nREMARKS={{ .SiteName }}-{{ .SubscribeName }}\nSTATUS=Traffic: {{ $used }} GiB/{{ $total }} GiB | Expires: {{ $ExpiredAt }}\n# Generated at: {{ now | date \"2006-01-02 15:04:05\\n\" }}\n\n{{- range $proxy := $supportedProxies }}\n {{- $common := \"udp=1&tfo=1\" -}}\n\n {{- $server := $proxy.Server -}}\n {{- if and (contains $server \":\") (not (hasPrefix \"[\" $server)) -}}\n {{- $server = printf \"[%s]\" $server -}}\n {{- end -}}\n\n {{- $password := $.UserInfo.Password -}}\n {{- if and (eq $proxy.Type \"shadowsocks\") (ne (default \"\" $proxy.ServerKey) \"\") -}}\n {{- $method := $proxy.Method -}}\n {{- if or (hasPrefix \"2022-blake3-\" $method) (eq $method \"2022-blake3-aes-128-gcm\") (eq $method \"2022-blake3-aes-256-gcm\") -}}\n {{- $userKeyLen := ternary 16 32 (hasSuffix \"128-gcm\" $method) -}}\n {{- $pwdStr := printf \"%s\" $password -}}\n {{- $userKey := ternary $pwdStr (trunc $userKeyLen $pwdStr) (le (len $pwdStr) $userKeyLen) -}}\n {{- $serverB64 := b64enc $proxy.ServerKey -}}\n {{- $userB64 := b64enc $userKey -}}\n {{- $password = printf \"%s:%s\" $serverB64 $userB64 -}}\n {{- end -}}\n {{- end -}}\n\n {{- $SkipVerify := $proxy.AllowInsecure -}}\n\n {{- /* 公共传输层配置函数 */ -}}\n {{- $buildTransportParams := dict -}}\n {{- $transport := default \"tcp\" $proxy.Transport -}}\n {{- if ne $transport \"\" -}}\n {{- $_ := set $buildTransportParams \"type\" (ternary \"ws\" $transport (eq $transport \"websocket\")) -}}\n {{- end -}}\n {{- /* TCP 传输类型配置 */ -}}\n {{- if eq $transport \"tcp\" -}}\n {{- $headerType := default \"none\" $proxy.HeaderType -}}\n {{- if ne $headerType \"none\" -}}\n {{- $_ := set $buildTransportParams \"headerType\" $headerType -}}\n {{- end -}}\n {{- if and (eq $headerType \"http\") (ne (default \"\" $proxy.Host) \"\") -}}\n {{- $_ := set $buildTransportParams \"host\" $proxy.Host -}}\n {{- end -}}\n {{- if and (eq $headerType \"http\") (ne (default \"\" $proxy.Path) \"\") -}}\n {{- $_ := set $buildTransportParams \"path\" ($proxy.Path | urlquery) -}}\n {{- end -}}\n {{- end -}}\n {{- /* WebSocket/xhttp/httpupgrade 传输类型配置 */ -}}\n {{- if and (or (eq $transport \"ws\") (eq $transport \"websocket\") (eq $transport \"xhttp\") (eq $transport \"httpupgrade\")) (ne (default \"\" $proxy.Host) \"\") -}}\n {{- $_ := set $buildTransportParams \"host\" $proxy.Host -}}\n {{- end -}}\n {{- if and (or (eq $transport \"ws\") (eq $transport \"websocket\") (eq $transport \"xhttp\") (eq $transport \"httpupgrade\")) (ne (default \"\" $proxy.Path) \"\") -}}\n {{- $_ := set $buildTransportParams \"path\" ($proxy.Path | urlquery) -}}\n {{- end -}}\n {{- /* gRPC 传输类型配置 */ -}}\n {{- if and (eq $transport \"grpc\") (ne (default \"\" $proxy.ServiceName) \"\") -}}\n {{- $_ := set $buildTransportParams \"serviceName\" $proxy.ServiceName -}}\n {{- end -}}\n {{- /* xhttp 特有配置 */ -}}\n {{- if and (eq $transport \"xhttp\") (ne (default \"\" $proxy.XhttpMode) \"\") -}}\n {{- $_ := set $buildTransportParams \"mode\" $proxy.XhttpMode -}}\n {{- end -}}\n {{- if and (eq $transport \"xhttp\") (ne (default \"\" $proxy.XhttpExtra) \"\") -}}\n {{- $_ := set $buildTransportParams \"extra\" (urlquery $proxy.XhttpExtra) -}}\n {{- end -}}\n\n {{- /* 公共安全层配置 */ -}}\n {{- $buildSecurityParams := dict -}}\n {{- if or (eq $proxy.Security \"tls\") (eq $proxy.Security \"reality\") -}}\n {{- $_ := set $buildSecurityParams \"security\" $proxy.Security -}}\n {{- end -}}\n {{- if ne (default \"\" $proxy.SNI) \"\" -}}\n {{- $_ := set $buildSecurityParams \"sni\" $proxy.SNI -}}\n {{- end -}}\n {{- if $SkipVerify -}}\n {{- $_ := set $buildSecurityParams \"allowInsecure\" \"1\" -}}\n {{- end -}}\n {{- if ne (default \"\" $proxy.Fingerprint) \"\" -}}\n {{- $_ := set $buildSecurityParams \"fp\" $proxy.Fingerprint -}}\n {{- end -}}\n {{- if and (eq $proxy.Security \"reality\") (ne (default \"\" $proxy.RealityPublicKey) \"\") -}}\n {{- $_ := set $buildSecurityParams \"pbk\" $proxy.RealityPublicKey -}}\n {{- end -}}\n {{- if and (eq $proxy.Security \"reality\") (ne (default \"\" $proxy.RealityShortId) \"\") -}}\n {{- $_ := set $buildSecurityParams \"sid\" $proxy.RealityShortId -}}\n {{- end -}}\n {{- if $proxy.EchEnable -}}\n {{- $_ := set $buildSecurityParams \"ech\" (printf \"%s+udp://1.1.1.1\" (default \"\" $proxy.EchServerName) | urlquery) -}}\n {{- end -}}\n\n {{- if eq $proxy.Type \"shadowsocks\" }}\n {{- $params := list -}}\n {{- /* Shadowsocks 特有的 obfs 插件参数 */ -}}\n {{- if ne (default \"\" $proxy.Obfs) \"\" -}}\n {{- $params = append $params (printf \"obfs=%s\" $proxy.Obfs) -}}\n {{- end -}}\n {{- if ne (default \"\" $proxy.ObfsHost) \"\" -}}\n {{- $params = append $params (printf \"obfs-host=%s\" $proxy.ObfsHost) -}}\n {{- end -}}\n {{- if ne (default \"\" $proxy.ObfsPath) \"\" -}}\n {{- $params = append $params (printf \"obfs-uri=%s\" ($proxy.ObfsPath | urlquery)) -}}\n {{- end -}}\n {{- /* 使用公共传输层配置 */ -}}\n {{- range $key, $val := $buildTransportParams -}}\n {{- $params = append $params (printf \"%s=%s\" $key $val) -}}\n {{- end -}}\n {{- /* 使用公共安全层配置 */ -}}\n {{- range $key, $val := $buildSecurityParams -}}\n {{- $params = append $params (printf \"%s=%s\" $key $val) -}}\n {{- end -}}\n {{- /* 添加公共参数 */ -}}\n {{- $params = append $params $common }}\nss://{{ printf \"%s:%s\" (default \"aes-128-gcm\" $proxy.Method) $password | b64enc }}@{{ $server }}:{{ $proxy.Port }}?{{ join \"&\" $params }}#{{ $proxy.Name }}\n {{- else if eq $proxy.Type \"vmess\" }}\n {{- $vmessDict := dict \"v\" \"2\" \"ps\" $proxy.Name \"add\" $proxy.Server \"port\" (printf \"%d\" $proxy.Port) \"id\" $password \"aid\" \"0\" \"net\" \"tcp\" \"type\" \"none\" -}}\n {{- if hasKey $buildTransportParams \"type\" -}}\n {{- $_ := set $vmessDict \"net\" (index $buildTransportParams \"type\") -}}\n {{- end -}}\n {{- if hasKey $buildTransportParams \"host\" -}}\n {{- $_ := set $vmessDict \"host\" (index $buildTransportParams \"host\") -}}\n {{- end -}}\n {{- if hasKey $buildTransportParams \"path\" -}}\n {{- $_ := set $vmessDict \"path\" (index $buildTransportParams \"path\") -}}\n {{- end -}}\n {{- if and (eq $transport \"grpc\") (hasKey $buildTransportParams \"serviceName\") -}}\n {{- $_ := set $vmessDict \"path\" (index $buildTransportParams \"serviceName\") -}}\n {{- end -}}\n {{- if hasKey $buildTransportParams \"mode\" -}}\n {{- $_ := set $vmessDict \"xhttpMode\" (index $buildTransportParams \"mode\") -}}\n {{- end -}}\n {{- if hasKey $buildTransportParams \"extra\" -}}\n {{- $_ := set $vmessDict \"xhttpExtra\" (index $buildTransportParams \"extra\") -}}\n {{- end -}}\n {{- if hasKey $buildSecurityParams \"security\" -}}\n {{- $_ := set $vmessDict \"tls\" (index $buildSecurityParams \"security\") -}}\n {{- end -}}\n {{- if hasKey $buildSecurityParams \"sni\" -}}\n {{- $_ := set $vmessDict \"sni\" (index $buildSecurityParams \"sni\") -}}\n {{- end -}}\n {{- if hasKey $buildSecurityParams \"fp\" -}}\n {{- $_ := set $vmessDict \"fp\" (index $buildSecurityParams \"fp\") -}}\n {{- end -}}\n {{- if hasKey $buildSecurityParams \"allowInsecure\" -}}\n {{- $_ := set $vmessDict \"skip-cert-verify\" true -}}\n {{- end -}}\n {{- if hasKey $buildSecurityParams \"ech\" -}}\n {{- $_ := set $vmessDict \"ech\" (index $buildSecurityParams \"ech\") -}}\n {{- end }}\nvmess://{{ $vmessDict | toJson | b64enc }}\n {{- else if eq $proxy.Type \"vless\" }}\n {{- $params := list -}}\n {{- /* 1. Encryption 加密参数 */ -}}\n {{- $encryption := default \"none\" $proxy.Encryption -}}\n {{- if eq $encryption \"none\" -}}\n {{- $params = append $params \"encryption=none\" -}}\n {{- else -}}\n {{- $encParts := list -}}\n {{- $encParts = append $encParts $encryption -}}\n {{- if ne (default \"\" $proxy.EncryptionMode) \"\" -}}\n {{- $encParts = append $encParts $proxy.EncryptionMode -}}\n {{- end -}}\n {{- if ne (default \"\" $proxy.EncryptionRtt) \"\" -}}\n {{- $encParts = append $encParts $proxy.EncryptionRtt -}}\n {{- end -}}\n {{- if ne (default \"\" $proxy.EncryptionClientPadding) \"\" -}}\n {{- $encParts = append $encParts $proxy.EncryptionClientPadding -}}\n {{- end -}}\n {{- if ne (default \"\" $proxy.EncryptionPassword) \"\" -}}\n {{- $encParts = append $encParts $proxy.EncryptionPassword -}}\n {{- end -}}\n {{- $params = append $params (printf \"encryption=%s\" (join \".\" $encParts)) -}}\n {{- end -}}\n {{- /* 2. Flow 流控参数 */ -}}\n {{- if and (ne (default \"\" $proxy.Flow) \"\") (ne $proxy.Flow \"none\") -}}\n {{- $params = append $params (printf \"flow=%s\" $proxy.Flow) -}}\n {{- end -}}\n {{- /* 3. Security 安全参数 */ -}}\n {{- if hasKey $buildSecurityParams \"security\" -}}\n {{- $params = append $params (printf \"security=%s\" (index $buildSecurityParams \"security\")) -}}\n {{- end -}}\n {{- if hasKey $buildSecurityParams \"sni\" -}}\n {{- $params = append $params (printf \"sni=%s\" (index $buildSecurityParams \"sni\")) -}}\n {{- end -}}\n {{- if hasKey $buildSecurityParams \"fp\" -}}\n {{- $params = append $params (printf \"fp=%s\" (index $buildSecurityParams \"fp\")) -}}\n {{- end -}}\n {{- if hasKey $buildSecurityParams \"allowInsecure\" -}}\n {{- $params = append $params \"allowInsecure=1\" -}}\n {{- end -}}\n {{- if hasKey $buildSecurityParams \"pbk\" -}}\n {{- $params = append $params (printf \"pbk=%s\" (index $buildSecurityParams \"pbk\")) -}}\n {{- end -}}\n {{- if hasKey $buildSecurityParams \"sid\" -}}\n {{- $params = append $params (printf \"sid=%s\" (index $buildSecurityParams \"sid\")) -}}\n {{- end -}}\n {{- if hasKey $buildSecurityParams \"ech\" -}}\n {{- $params = append $params (printf \"ech=%s\" (index $buildSecurityParams \"ech\")) -}}\n {{- end -}}\n {{- /* 4. Transport 传输层参数 */ -}}\n {{- if hasKey $buildTransportParams \"type\" -}}\n {{- $params = append $params (printf \"type=%s\" (index $buildTransportParams \"type\")) -}}\n {{- end -}}\n {{- if hasKey $buildTransportParams \"host\" -}}\n {{- $params = append $params (printf \"host=%s\" (index $buildTransportParams \"host\")) -}}\n {{- end -}}\n {{- if hasKey $buildTransportParams \"path\" -}}\n {{- $params = append $params (printf \"path=%s\" (index $buildTransportParams \"path\")) -}}\n {{- end -}}\n {{- if hasKey $buildTransportParams \"serviceName\" -}}\n {{- $params = append $params (printf \"serviceName=%s\" (index $buildTransportParams \"serviceName\")) -}}\n {{- end -}}\n {{- if hasKey $buildTransportParams \"mode\" -}}\n {{- $params = append $params (printf \"mode=%s\" (index $buildTransportParams \"mode\")) -}}\n {{- end -}}\n {{- if hasKey $buildTransportParams \"extra\" -}}\n {{- $params = append $params (printf \"extra=%s\" (index $buildTransportParams \"extra\")) -}}\n {{- end -}}\n {{- /* 5. Common 通用参数 */ -}}\n {{- $params = append $params $common }}\nvless://{{ $password }}@{{ $server }}:{{ $proxy.Port }}?{{ join \"&\" $params }}#{{ $proxy.Name }}\n {{- else if eq $proxy.Type \"trojan\" }}\n {{- $params := list -}}\n {{- range $key, $val := $buildTransportParams -}}\n {{- $params = append $params (printf \"%s=%s\" $key $val) -}}\n {{- end -}}\n {{- range $key, $val := $buildSecurityParams -}}\n {{- $params = append $params (printf \"%s=%s\" $key $val) -}}\n {{- end -}}\n {{- $params = append $params $common }}\ntrojan://{{ $password }}@{{ $server }}:{{ $proxy.Port }}?{{ join \"&\" $params }}#{{ $proxy.Name }}\n {{- else if or (eq $proxy.Type \"hysteria2\") (eq $proxy.Type \"hysteria\") }}\n {{- $params := list -}}\n {{- if ne (default \"\" $proxy.SNI) \"\" -}}\n {{- $params = append $params (printf \"sni=%s\" $proxy.SNI) -}}\n {{- end -}}\n {{- if $proxy.AllowInsecure -}}\n {{- $params = append $params \"insecure=1\" -}}\n {{- end -}}\n {{- if ne (default \"\" $proxy.ObfsPassword) \"\" -}}\n {{- $params = append $params (printf \"obfs=salamander&obfs-password=%s\" $proxy.ObfsPassword) -}}\n {{- end -}}\n {{- if ne (default \"\" $proxy.HopPorts) \"\" -}}\n {{- $params = append $params (printf \"mport=%s\" $proxy.HopPorts) -}}\n {{- end }}\nhysteria2://{{- if ne $password \"\" -}}{{ $password }}@{{- end -}}{{ $server }}:{{ $proxy.Port }}?{{ join \"&\" (append $params $common) }}#{{ $proxy.Name | urlquery }}\n {{- else if eq $proxy.Type \"tuic\" }}\n {{- $params := list -}}\n {{- if ne (default \"\" $proxy.CongestionController) \"\" -}}\n {{- $params = append $params (printf \"congestion_controller=%s\" $proxy.CongestionController) -}}\n {{- end -}}\n {{- if ne (default \"\" $proxy.UDPRelayMode) \"\" -}}\n {{- $params = append $params (printf \"udp_relay_mode=%s\" $proxy.UDPRelayMode) -}}\n {{- end -}}\n {{- if $proxy.ReduceRtt -}}\n {{- $params = append $params \"reduce_rtt=1\" -}}\n {{- end -}}\n {{- if $proxy.DisableSNI -}}\n {{- $params = append $params \"disable_sni=1\" -}}\n {{- end -}}\n {{- if ne (default \"\" $proxy.SNI) \"\" -}}\n {{- $params = append $params (printf \"sni=%s\" $proxy.SNI) -}}\n {{- end -}}\n {{- if $proxy.AllowInsecure -}}\n {{- $params = append $params \"allow_insecure=1\" -}}\n {{- end -}}\n {{- $params = append $params $common }}\ntuic://{{ default \"\" $proxy.ServerKey }}:{{ $password }}@{{ $server }}:{{ $proxy.Port }}?{{ join \"&\" $params }}#{{ $proxy.Name }}\n {{- else if eq $proxy.Type \"anytls\" }}\n {{- $params := list -}}\n {{- /* 使用公共传输层配置 */ -}}\n {{- range $key, $val := $buildTransportParams -}}\n {{- $params = append $params (printf \"%s=%s\" $key $val) -}}\n {{- end -}}\n {{- /* 使用公共安全层配置 */ -}}\n {{- range $key, $val := $buildSecurityParams -}}\n {{- $params = append $params (printf \"%s=%s\" $key $val) -}}\n {{- end -}}\n {{- $params = append $params $common }}\nanytls://{{ $password }}@{{ $server }}:{{ $proxy.Port }}?{{ join \"&\" $params }}#{{ $proxy.Name }}\n {{- else if or (eq $proxy.Type \"http\") (eq $proxy.Type \"https\") }}\n {{- $user := default $password $proxy.Username }}\nhttp{{- if eq $proxy.Type \"https\" -}}s{{- end -}}://{{- if or (ne (default \"\" $user) \"\") (ne (default \"\" $password) \"\") -}}{{ $user }}:{{ $password }}@{{- end -}}{{ $server }}:{{ $proxy.Port }}#{{ $proxy.Name }}\n {{- else if or (eq $proxy.Type \"socks\") (eq $proxy.Type \"socks5\") (eq $proxy.Type \"socks5-tls\") }}\n {{- $user := default $password $proxy.Username }}\nsocks5://{{- if or (ne (default \"\" $user) \"\") (ne (default \"\" $password) \"\") -}}{{ $user }}:{{ $password }}@{{- end -}}{{ $server }}:{{ $proxy.Port }}{{- if eq $proxy.Type \"socks5-tls\" }}?tls=1{{- end }}#{{ $proxy.Name }}\n {{- end }}\n{{- end }}\n', 'base64', '{}', '2025-08-12 22:57:56.711', '2025-08-15 21:45:20.181') ON CONFLICT DO NOTHING; +INSERT INTO "subscribe_application" ("id", "name", "icon", "description", "scheme", "user_agent", "is_default", "subscribe_template", "output_format", "download_link", "created_at", "updated_at") VALUES +(2, 'Shadowrocket', '', '', 'shadowrocket://add/sub://${window.btoa(url)}?remark=${encodeURIComponent(name)}', 'Shadowrocket', false, '{{- $GiB := 1073741824.0 -}}\n{{- $used := printf \"%.2f\" (divf (add (.UserInfo.Download | default 0 | float64) (.UserInfo.Upload | default 0 | float64)) $GiB) -}}\n{{- $traffic := (.UserInfo.Traffic | default 0 | float64) -}}\n{{- $total := printf \"%.2f\" (divf $traffic $GiB) -}}\n\n{{- $ExpiredAt := \"\" -}}\n{{- $expStr := printf \"%v\" .UserInfo.ExpiredAt -}}\n{{- if regexMatch `^[0-9]+$` $expStr -}}\n {{- $ts := $expStr | float64 -}}\n {{- $sec := ternary (divf $ts 1000.0) $ts (ge (len $expStr) 13) -}}\n {{- $ExpiredAt = (date \"2006-01-02 15:04:05\" (unixEpoch ($sec | int64))) -}}\n{{- else -}}\n {{- $ExpiredAt = $expStr -}}\n{{- end -}}\n\n{{- $sortFields := list \"Sort\" \"Port\" \"Name\" -}}\n{{- $sortConfig := dict \"Sort\" \"asc\" \"Port\" \"asc\" \"Name\" \"asc\" -}}\n{{- $byKey := dict -}}\n{{- range $p := .Proxies -}}\n {{- $keyParts := list -}}\n {{- range $field := $sortFields -}}\n {{- $order := index $sortConfig $field -}}\n {{- $val := default \"\" (printf \"%v\" (index $p $field)) -}}\n {{- if or (eq $field \"Sort\") (eq $field \"Port\") -}}\n {{- $val = printf \"%08d\" (int (default 0 (index $p $field))) -}}\n {{- end -}}\n {{- if eq $order \"desc\" -}}\n {{- $val = printf \"~%s\" $val -}}\n {{- end -}}\n {{- $keyParts = append $keyParts $val -}}\n {{- end -}}\n {{- $_ := set $byKey (join \"|\" $keyParts) $p -}}\n{{- end -}}\n{{- $sorted := list -}}\n{{- range $k := sortAlpha (keys $byKey) -}}\n {{- $sorted = append $sorted (index $byKey $k) -}}\n{{- end -}}\n\n{{- $supportSet := dict \"shadowsocks\" true \"vmess\" true \"vless\" true \"trojan\" true \"hysteria2\" true \"hysteria\" true \"tuic\" true \"anytls\" true -}}\n{{- $supportedProxies := list -}}\n{{- range $proxy := $sorted -}}\n {{- if hasKey $supportSet $proxy.Type -}}\n {{- $supportedProxies = append $supportedProxies $proxy -}}\n {{- end -}}\n{{- end -}}\n\nREMARKS={{ .SiteName }}-{{ .SubscribeName }}\nSTATUS=Traffic: {{ $used }} GiB/{{ $total }} GiB | Expires: {{ $ExpiredAt }}\n# Generated at: {{ now | date \"2006-01-02 15:04:05\\n\" }}\n\n{{- range $proxy := $supportedProxies }}\n {{- $common := \"udp=1&tfo=1\" -}}\n\n {{- $server := $proxy.Server -}}\n {{- if and (contains $server \":\") (not (hasPrefix \"[\" $server)) -}}\n {{- $server = printf \"[%s]\" $server -}}\n {{- end -}}\n\n {{- $password := $.UserInfo.Password -}}\n {{- if and (eq $proxy.Type \"shadowsocks\") (ne (default \"\" $proxy.ServerKey) \"\") -}}\n {{- $method := $proxy.Method -}}\n {{- if or (hasPrefix \"2022-blake3-\" $method) (eq $method \"2022-blake3-aes-128-gcm\") (eq $method \"2022-blake3-aes-256-gcm\") -}}\n {{- $userKeyLen := ternary 16 32 (hasSuffix \"128-gcm\" $method) -}}\n {{- $pwdStr := printf \"%s\" $password -}}\n {{- $userKey := ternary $pwdStr (trunc $userKeyLen $pwdStr) (le (len $pwdStr) $userKeyLen) -}}\n {{- $serverB64 := b64enc $proxy.ServerKey -}}\n {{- $userB64 := b64enc $userKey -}}\n {{- $password = printf \"%s:%s\" $serverB64 $userB64 -}}\n {{- end -}}\n {{- end -}}\n\n {{- $SkipVerify := $proxy.AllowInsecure -}}\n\n {{- /* 公共传输层配置函数 */ -}}\n {{- $buildTransportParams := dict -}}\n {{- $transport := default \"tcp\" $proxy.Transport -}}\n {{- if ne $transport \"\" -}}\n {{- $_ := set $buildTransportParams \"type\" (ternary \"ws\" $transport (eq $transport \"websocket\")) -}}\n {{- end -}}\n {{- /* TCP 传输类型配置 */ -}}\n {{- if eq $transport \"tcp\" -}}\n {{- $headerType := default \"none\" $proxy.HeaderType -}}\n {{- if ne $headerType \"none\" -}}\n {{- $_ := set $buildTransportParams \"headerType\" $headerType -}}\n {{- end -}}\n {{- if and (eq $headerType \"http\") (ne (default \"\" $proxy.Host) \"\") -}}\n {{- $_ := set $buildTransportParams \"host\" $proxy.Host -}}\n {{- end -}}\n {{- if and (eq $headerType \"http\") (ne (default \"\" $proxy.Path) \"\") -}}\n {{- $_ := set $buildTransportParams \"path\" ($proxy.Path | urlquery) -}}\n {{- end -}}\n {{- end -}}\n {{- /* WebSocket/xhttp/httpupgrade 传输类型配置 */ -}}\n {{- if and (or (eq $transport \"ws\") (eq $transport \"websocket\") (eq $transport \"xhttp\") (eq $transport \"httpupgrade\")) (ne (default \"\" $proxy.Host) \"\") -}}\n {{- $_ := set $buildTransportParams \"host\" $proxy.Host -}}\n {{- end -}}\n {{- if and (or (eq $transport \"ws\") (eq $transport \"websocket\") (eq $transport \"xhttp\") (eq $transport \"httpupgrade\")) (ne (default \"\" $proxy.Path) \"\") -}}\n {{- $_ := set $buildTransportParams \"path\" ($proxy.Path | urlquery) -}}\n {{- end -}}\n {{- /* gRPC 传输类型配置 */ -}}\n {{- if and (eq $transport \"grpc\") (ne (default \"\" $proxy.ServiceName) \"\") -}}\n {{- $_ := set $buildTransportParams \"serviceName\" $proxy.ServiceName -}}\n {{- end -}}\n {{- /* xhttp 特有配置 */ -}}\n {{- if and (eq $transport \"xhttp\") (ne (default \"\" $proxy.XhttpMode) \"\") -}}\n {{- $_ := set $buildTransportParams \"mode\" $proxy.XhttpMode -}}\n {{- end -}}\n {{- if and (eq $transport \"xhttp\") (ne (default \"\" $proxy.XhttpExtra) \"\") -}}\n {{- $_ := set $buildTransportParams \"extra\" (urlquery $proxy.XhttpExtra) -}}\n {{- end -}}\n\n {{- /* 公共安全层配置 */ -}}\n {{- $buildSecurityParams := dict -}}\n {{- if or (eq $proxy.Security \"tls\") (eq $proxy.Security \"reality\") -}}\n {{- $_ := set $buildSecurityParams \"security\" $proxy.Security -}}\n {{- end -}}\n {{- if ne (default \"\" $proxy.SNI) \"\" -}}\n {{- $_ := set $buildSecurityParams \"sni\" $proxy.SNI -}}\n {{- end -}}\n {{- if $SkipVerify -}}\n {{- $_ := set $buildSecurityParams \"allowInsecure\" \"1\" -}}\n {{- end -}}\n {{- if ne (default \"\" $proxy.Fingerprint) \"\" -}}\n {{- $_ := set $buildSecurityParams \"fp\" $proxy.Fingerprint -}}\n {{- end -}}\n {{- if and (eq $proxy.Security \"reality\") (ne (default \"\" $proxy.RealityPublicKey) \"\") -}}\n {{- $_ := set $buildSecurityParams \"pbk\" $proxy.RealityPublicKey -}}\n {{- end -}}\n {{- if and (eq $proxy.Security \"reality\") (ne (default \"\" $proxy.RealityShortId) \"\") -}}\n {{- $_ := set $buildSecurityParams \"sid\" $proxy.RealityShortId -}}\n {{- end -}}\n\n {{- if eq $proxy.Type \"shadowsocks\" }}\n {{- $params := list -}}\n {{- /* Shadowsocks 特有的 obfs 插件参数 */ -}}\n {{- if ne (default \"\" $proxy.Obfs) \"\" -}}\n {{- $params = append $params (printf \"obfs=%s\" $proxy.Obfs) -}}\n {{- end -}}\n {{- if ne (default \"\" $proxy.ObfsHost) \"\" -}}\n {{- $params = append $params (printf \"obfs-host=%s\" $proxy.ObfsHost) -}}\n {{- end -}}\n {{- if ne (default \"\" $proxy.ObfsPath) \"\" -}}\n {{- $params = append $params (printf \"obfs-uri=%s\" ($proxy.ObfsPath | urlquery)) -}}\n {{- end -}}\n {{- /* 使用公共传输层配置 */ -}}\n {{- range $key, $val := $buildTransportParams -}}\n {{- $params = append $params (printf \"%s=%s\" $key $val) -}}\n {{- end -}}\n {{- /* 使用公共安全层配置 */ -}}\n {{- range $key, $val := $buildSecurityParams -}}\n {{- $params = append $params (printf \"%s=%s\" $key $val) -}}\n {{- end -}}\n {{- /* 添加公共参数 */ -}}\n {{- $params = append $params $common }}\nss://{{ printf \"%s:%s\" (default \"aes-128-gcm\" $proxy.Method) $password | b64enc }}@{{ $server }}:{{ $proxy.Port }}?{{ join \"&\" $params }}#{{ $proxy.Name }}\n {{- else if eq $proxy.Type \"vmess\" }}\n {{- $vmessDict := dict \"v\" \"2\" \"ps\" $proxy.Name \"add\" $proxy.Server \"port\" (printf \"%d\" $proxy.Port) \"id\" $password \"aid\" \"0\" \"net\" \"tcp\" \"type\" \"none\" -}}\n {{- if hasKey $buildTransportParams \"type\" -}}\n {{- $_ := set $vmessDict \"net\" (index $buildTransportParams \"type\") -}}\n {{- end -}}\n {{- if hasKey $buildTransportParams \"host\" -}}\n {{- $_ := set $vmessDict \"host\" (index $buildTransportParams \"host\") -}}\n {{- end -}}\n {{- if hasKey $buildTransportParams \"path\" -}}\n {{- $_ := set $vmessDict \"path\" (index $buildTransportParams \"path\") -}}\n {{- end -}}\n {{- if and (eq $transport \"grpc\") (hasKey $buildTransportParams \"serviceName\") -}}\n {{- $_ := set $vmessDict \"path\" (index $buildTransportParams \"serviceName\") -}}\n {{- end -}}\n {{- if hasKey $buildTransportParams \"mode\" -}}\n {{- $_ := set $vmessDict \"xhttpMode\" (index $buildTransportParams \"mode\") -}}\n {{- end -}}\n {{- if hasKey $buildTransportParams \"extra\" -}}\n {{- $_ := set $vmessDict \"xhttpExtra\" (index $buildTransportParams \"extra\") -}}\n {{- end -}}\n {{- if hasKey $buildSecurityParams \"security\" -}}\n {{- $_ := set $vmessDict \"tls\" (index $buildSecurityParams \"security\") -}}\n {{- end -}}\n {{- if hasKey $buildSecurityParams \"sni\" -}}\n {{- $_ := set $vmessDict \"sni\" (index $buildSecurityParams \"sni\") -}}\n {{- end -}}\n {{- if hasKey $buildSecurityParams \"fp\" -}}\n {{- $_ := set $vmessDict \"fp\" (index $buildSecurityParams \"fp\") -}}\n {{- end -}}\n {{- if hasKey $buildSecurityParams \"allowInsecure\" -}}\n {{- $_ := set $vmessDict \"skip-cert-verify\" true -}}\n {{- end }}\nvmess://{{ $vmessDict | toJson | b64enc }}\n {{- else if eq $proxy.Type \"vless\" }}\n {{- $params := list -}}\n {{- /* 1. Encryption 加密参数 */ -}}\n {{- $encryption := default \"none\" $proxy.Encryption -}}\n {{- if eq $encryption \"none\" -}}\n {{- $params = append $params \"encryption=none\" -}}\n {{- else -}}\n {{- $encParts := list -}}\n {{- $encParts = append $encParts $encryption -}}\n {{- if ne (default \"\" $proxy.EncryptionMode) \"\" -}}\n {{- $encParts = append $encParts $proxy.EncryptionMode -}}\n {{- end -}}\n {{- if ne (default \"\" $proxy.EncryptionRtt) \"\" -}}\n {{- $encParts = append $encParts $proxy.EncryptionRtt -}}\n {{- end -}}\n {{- if ne (default \"\" $proxy.EncryptionClientPadding) \"\" -}}\n {{- $encParts = append $encParts $proxy.EncryptionClientPadding -}}\n {{- end -}}\n {{- if ne (default \"\" $proxy.EncryptionPassword) \"\" -}}\n {{- $encParts = append $encParts $proxy.EncryptionPassword -}}\n {{- end -}}\n {{- $params = append $params (printf \"encryption=%s\" (join \".\" $encParts)) -}}\n {{- end -}}\n {{- /* 2. Flow 流控参数 */ -}}\n {{- if ne (default \"\" $proxy.Flow) \"none\" -}}\n {{- $params = append $params (printf \"flow=%s\" $proxy.Flow) -}}\n {{- end -}}\n {{- /* 3. Security 安全参数 */ -}}\n {{- if hasKey $buildSecurityParams \"security\" -}}\n {{- $params = append $params (printf \"security=%s\" (index $buildSecurityParams \"security\")) -}}\n {{- end -}}\n {{- if hasKey $buildSecurityParams \"sni\" -}}\n {{- $params = append $params (printf \"sni=%s\" (index $buildSecurityParams \"sni\")) -}}\n {{- end -}}\n {{- if hasKey $buildSecurityParams \"fp\" -}}\n {{- $params = append $params (printf \"fp=%s\" (index $buildSecurityParams \"fp\")) -}}\n {{- end -}}\n {{- if hasKey $buildSecurityParams \"allowInsecure\" -}}\n {{- $params = append $params \"allowInsecure=1\" -}}\n {{- end -}}\n {{- if hasKey $buildSecurityParams \"pbk\" -}}\n {{- $params = append $params (printf \"pbk=%s\" (index $buildSecurityParams \"pbk\")) -}}\n {{- end -}}\n {{- if hasKey $buildSecurityParams \"sid\" -}}\n {{- $params = append $params (printf \"sid=%s\" (index $buildSecurityParams \"sid\")) -}}\n {{- end -}}\n {{- /* 4. Transport 传输层参数 */ -}}\n {{- if hasKey $buildTransportParams \"type\" -}}\n {{- $params = append $params (printf \"type=%s\" (index $buildTransportParams \"type\")) -}}\n {{- end -}}\n {{- if hasKey $buildTransportParams \"host\" -}}\n {{- $params = append $params (printf \"host=%s\" (index $buildTransportParams \"host\")) -}}\n {{- end -}}\n {{- if hasKey $buildTransportParams \"path\" -}}\n {{- $params = append $params (printf \"path=%s\" (index $buildTransportParams \"path\")) -}}\n {{- end -}}\n {{- if hasKey $buildTransportParams \"serviceName\" -}}\n {{- $params = append $params (printf \"serviceName=%s\" (index $buildTransportParams \"serviceName\")) -}}\n {{- end -}}\n {{- if hasKey $buildTransportParams \"mode\" -}}\n {{- $params = append $params (printf \"mode=%s\" (index $buildTransportParams \"mode\")) -}}\n {{- end -}}\n {{- if hasKey $buildTransportParams \"extra\" -}}\n {{- $params = append $params (printf \"extra=%s\" (index $buildTransportParams \"extra\")) -}}\n {{- end -}}\n {{- /* 5. Common 通用参数 */ -}}\n {{- $params = append $params $common }}\nvless://{{ $password }}@{{ $server }}:{{ $proxy.Port }}?{{ join \"&\" $params }}#{{ $proxy.Name }}\n {{- else if eq $proxy.Type \"trojan\" }}\n {{- $params := list -}}\n {{- range $key, $val := $buildTransportParams -}}\n {{- $params = append $params (printf \"%s=%s\" $key $val) -}}\n {{- end -}}\n {{- range $key, $val := $buildSecurityParams -}}\n {{- $params = append $params (printf \"%s=%s\" $key $val) -}}\n {{- end -}}\n {{- $params = append $params $common }}\ntrojan://{{ $password }}@{{ $server }}:{{ $proxy.Port }}?{{ join \"&\" $params }}#{{ $proxy.Name }}\n {{- else if or (eq $proxy.Type \"hysteria2\") (eq $proxy.Type \"hysteria\") }}\n {{- $params := list -}}\n {{- if ne (default \"\" $proxy.SNI) \"\" -}}\n {{- $params = append $params (printf \"sni=%s\" $proxy.SNI) -}}\n {{- end -}}\n {{- if $proxy.AllowInsecure -}}\n {{- $params = append $params \"insecure=1\" -}}\n {{- end -}}\n {{- if ne (default \"\" $proxy.ObfsPassword) \"\" -}}\n {{- $params = append $params (printf \"obfs=salamander&obfs-password=%s\" $proxy.ObfsPassword) -}}\n {{- end -}}\n {{- if ne (default \"\" $proxy.HopPorts) \"\" -}}\n {{- $params = append $params (printf \"mport=%s\" $proxy.HopPorts) -}}\n {{- end }}\nhysteria2://{{- if ne $password \"\" -}}{{ $password }}@{{- end -}}{{ $server }}:{{ $proxy.Port }}?{{ join \"&\" (append $params $common) }}#{{ $proxy.Name | urlquery }}\n {{- else if eq $proxy.Type \"tuic\" }}\n {{- $params := list -}}\n {{- if ne (default \"\" $proxy.CongestionController) \"\" -}}\n {{- $params = append $params (printf \"congestion_controller=%s\" $proxy.CongestionController) -}}\n {{- end -}}\n {{- if ne (default \"\" $proxy.UDPRelayMode) \"\" -}}\n {{- $params = append $params (printf \"udp_relay_mode=%s\" $proxy.UDPRelayMode) -}}\n {{- end -}}\n {{- if $proxy.ReduceRtt -}}\n {{- $params = append $params \"reduce_rtt=1\" -}}\n {{- end -}}\n {{- if $proxy.DisableSNI -}}\n {{- $params = append $params \"disable_sni=1\" -}}\n {{- end -}}\n {{- if ne (default \"\" $proxy.SNI) \"\" -}}\n {{- $params = append $params (printf \"sni=%s\" $proxy.SNI) -}}\n {{- end -}}\n {{- if $proxy.AllowInsecure -}}\n {{- $params = append $params \"allow_insecure=1\" -}}\n {{- end -}}\n {{- $params = append $params $common }}\ntuic://{{ default \"\" $proxy.ServerKey }}:{{ $password }}@{{ $server }}:{{ $proxy.Port }}?{{ join \"&\" $params }}#{{ $proxy.Name }}\n {{- else if eq $proxy.Type \"anytls\" }}\n {{- $params := list -}}\n {{- /* 使用公共传输层配置 */ -}}\n {{- range $key, $val := $buildTransportParams -}}\n {{- $params = append $params (printf \"%s=%s\" $key $val) -}}\n {{- end -}}\n {{- /* 使用公共安全层配置 */ -}}\n {{- range $key, $val := $buildSecurityParams -}}\n {{- $params = append $params (printf \"%s=%s\" $key $val) -}}\n {{- end -}}\n {{- $params = append $params $common }}\nanytls://{{ $password }}@{{ $server }}:{{ $proxy.Port }}?{{ join \"&\" $params }}#{{ $proxy.Name }}\n {{- else if or (eq $proxy.Type \"http\") (eq $proxy.Type \"https\") }}\n {{- $user := default $password $proxy.Username }}\nhttp{{- if eq $proxy.Type \"https\" -}}s{{- end -}}://{{- if or (ne (default \"\" $user) \"\") (ne (default \"\" $password) \"\") -}}{{ $user }}:{{ $password }}@{{- end -}}{{ $server }}:{{ $proxy.Port }}#{{ $proxy.Name }}\n {{- else if or (eq $proxy.Type \"socks\") (eq $proxy.Type \"socks5\") (eq $proxy.Type \"socks5-tls\") }}\n {{- $user := default $password $proxy.Username }}\nsocks5://{{- if or (ne (default \"\" $user) \"\") (ne (default \"\" $password) \"\") -}}{{ $user }}:{{ $password }}@{{- end -}}{{ $server }}:{{ $proxy.Port }}{{- if eq $proxy.Type \"socks5-tls\" }}?tls=1{{- end }}#{{ $proxy.Name }}\n {{- end }}\n{{- end }}\n', 'base64', '{}', '2025-08-12 23:03:50.004', '2025-08-15 22:01:39.221') ON CONFLICT DO NOTHING; +INSERT INTO "subscribe_application" ("id", "name", "icon", "description", "scheme", "user_agent", "is_default", "subscribe_template", "output_format", "download_link", "created_at", "updated_at") VALUES +(3, 'Clash', '', '', 'clash://install-config?url=${url}&name=${name}', 'Clash', false, '{{- $GiB := 1073741824.0 -}}\n{{- $used := printf \"%.2f\" (divf (add (.UserInfo.Download | default 0 | float64) (.UserInfo.Upload | default 0 | float64)) $GiB) -}}\n{{- $traffic := (.UserInfo.Traffic | default 0 | float64) -}}\n{{- $total := printf \"%.2f\" (divf $traffic $GiB) -}}\n\n{{- $ExpiredAt := \"\" -}}\n{{- $expStr := printf \"%v\" .UserInfo.ExpiredAt -}}\n{{- if regexMatch `^[0-9]+$` $expStr -}}\n {{- $ts := $expStr | float64 -}}\n {{- $sec := ternary (divf $ts 1000.0) $ts (ge (len $expStr) 13) -}}\n {{- $ExpiredAt = (date \"2006-01-02 15:04:05\" (unixEpoch ($sec | int64))) -}}\n{{- else -}}\n {{- $ExpiredAt = $expStr -}}\n{{- end -}}\n\n{{- $sortFields := list \"Sort\" \"Port\" \"Name\" -}}\n{{- $sortConfig := dict \"Sort\" \"asc\" \"Port\" \"asc\" \"Name\" \"asc\" -}}\n{{- $byKey := dict -}}\n{{- range $p := .Proxies -}}\n {{- $keyParts := list -}}\n {{- range $field := $sortFields -}}\n {{- $order := index $sortConfig $field -}}\n {{- $val := default \"\" (printf \"%v\" (index $p $field)) -}}\n {{- if or (eq $field \"Sort\") (eq $field \"Port\") -}}\n {{- $val = printf \"%08d\" (int (default 0 (index $p $field))) -}}\n {{- end -}}\n {{- if eq $order \"desc\" -}}\n {{- $val = printf \"~%s\" $val -}}\n {{- end -}}\n {{- $keyParts = append $keyParts $val -}}\n {{- end -}}\n {{- $_ := set $byKey (join \"|\" $keyParts) $p -}}\n{{- end -}}\n{{- $sorted := list -}}\n{{- range $k := sortAlpha (keys $byKey) -}}\n {{- $sorted = append $sorted (index $byKey $k) -}}\n{{- end -}}\n\n{{- $supportSet := dict \"shadowsocks\" true \"vmess\" true \"vless\" true \"trojan\" true \"hysteria2\" true \"hysteria\" true \"tuic\" true \"anytls\" true -}}\n{{- $supportedProxies := list -}}\n{{- range $proxy := $sorted -}}\n {{- if hasKey $supportSet $proxy.Type -}}\n {{- $supportedProxies = append $supportedProxies $proxy -}}\n {{- end -}}\n{{- end -}}\n\n{{- $proxyNames := \"\" -}}\n{{- range $proxy := $supportedProxies -}}\n {{- if eq $proxyNames \"\" -}}\n {{- $proxyNames = printf \"%q\" $proxy.Name -}}\n {{- else -}}\n {{- $proxyNames = printf \"%s, %q\" $proxyNames $proxy.Name -}}\n {{- end -}}\n{{- end -}}\n\n# {{ .SiteName }}-{{ .SubscribeName }}\n# Traffic: {{ $used }} GiB/{{ $total }} GiB | Expires: {{ $ExpiredAt }}\n# Generated at: {{ now | date \"2006-01-02 15:04:05\" }}\n\nmode: rule\nipv6: true\nallow-lan: true\nbind-address: ''*''\nmixed-port: 10808\nlog-level: info\nunified-delay: true\ntcp-concurrent: true\nexternal-controller: ''0.0.0.0:9090''\nglobal-client-fingerprint: chrome\ntun:\n enable: true\n stack: system\n auto-route: true\ndns:\n enable: true\n cache-algorithm: arc\n listen: ''0.0.0.0:1053''\n ipv6: true\n use-hosts: true\n use-system-hosts: true\n respect-rules: false\n enhanced-mode: fake-ip\n fake-ip-range: 198.18.0.1/16\n fake-ip-filter:\n - ''*.lan''\n - ''localhost''\n - ''lens.l.google.com''\n - ''*.srv.nintendo.net''\n - ''*.stun.playstation.net''\n - ''xbox.*.*.microsoft.com''\n - ''*.xboxlive.com''\n - ''*.msftncsi.com''\n - ''*.msftconnecttest.com''\n - ''time.*.com''\n default-nameserver:\n - 223.5.5.5\n - 119.29.29.29\n nameserver:\n - https://cloudflare-dns.com/dns-query\n - https://dns.google/dns-query\n fallback:\n - tls://1.1.1.1\n - tls://8.8.8.8\n proxy-server-nameserver:\n - https://dns.alidns.com/dns-query\n - https://doh.pub/dns-query\n direct-nameserver:\n - system\n - https://dns.alidns.com/dns-query\n - https://doh.pub/dns-query\n direct-nameserver-follow-policy: false\n fallback-filter:\n geoip: true\n geoip-code: CN\n geosite:\n - gfw\n - youtube\n domain:\n - ''+.google.com''\n - ''+.facebook.com''\n - ''+.twitter.com''\n - ''+.telegram.org''\n\nproxies:\n{{- range $proxy := $supportedProxies }}\n {{- $server := $proxy.Server -}}\n {{- if and (contains $server \":\") (not (hasPrefix \"[\" $server)) -}}\n {{- $server = printf \"[%s]\" $server -}}\n {{- end -}}\n\n {{- $password := $.UserInfo.Password -}}\n {{- if and (eq $proxy.Type \"shadowsocks\") (ne (default \"\" $proxy.ServerKey) \"\") -}}\n {{- $method := $proxy.Method -}}\n {{- if or (hasPrefix \"2022-blake3-\" $method) (eq $method \"2022-blake3-aes-128-gcm\") (eq $method \"2022-blake3-aes-256-gcm\") -}}\n {{- $userKeyLen := ternary 16 32 (hasSuffix \"128-gcm\" $method) -}}\n {{- $pwdStr := printf \"%s\" $password -}}\n {{- $userKey := ternary $pwdStr (trunc $userKeyLen $pwdStr) (le (len $pwdStr) $userKeyLen) -}}\n {{- $serverB64 := b64enc $proxy.ServerKey -}}\n {{- $userB64 := b64enc $userKey -}}\n {{- $password = printf \"%s:%s\" $serverB64 $userB64 -}}\n {{- end -}}\n {{- end -}}\n\n {{- $SkipVerify := $proxy.AllowInsecure -}}\n\n{{- if eq $proxy.Type \"shadowsocks\" }}\n- name: {{ $proxy.Name | quote }}\n type: ss\n server: {{ $server }}\n port: {{ $proxy.Port }}\n cipher: {{ default \"aes-128-gcm\" $proxy.Method }}\n password: {{ $password }}\n udp: true\n tfo: true\n {{- if ne (default \"\" $proxy.Obfs) \"\" }}\n plugin: obfs\n plugin-opts:\n mode: {{ $proxy.Obfs }}\n host: {{ default \"\" $proxy.ObfsHost }}\n {{- end }}\n{{- else if eq $proxy.Type \"vmess\" }}\n- name: {{ $proxy.Name | quote }}\n type: vmess\n server: {{ $server }}\n port: {{ $proxy.Port }}\n uuid: {{ $password }}\n alterId: 0\n cipher: auto\n udp: true\n tfo: true\n {{- if or (eq $proxy.Transport \"websocket\") (eq $proxy.Transport \"ws\") }}\n network: ws\n ws-opts:\n path: {{ default \"/\" $proxy.Path }}\n {{- if ne (default \"\" $proxy.Host) \"\" }}\n headers:\n Host: {{ $proxy.Host }}\n {{- end }}\n {{- else if eq $proxy.Transport \"http\" }}\n network: http\n http-opts:\n method: GET\n path: [{{ default \"/\" $proxy.Path | quote }}]\n {{- if ne (default \"\" $proxy.Host) \"\" }}\n headers:\n Host: [{{ $proxy.Host | quote }}]\n {{- end }}\n {{- else if eq $proxy.Transport \"grpc\" }}\n network: grpc\n grpc-opts:\n grpc-service-name: {{ default \"grpc\" $proxy.ServiceName }}\n {{- end }}\n {{- if or (eq $proxy.Security \"tls\") (eq $proxy.Security \"reality\") }}\n tls: true\n {{- end }}\n {{- if ne (default \"\" $proxy.SNI) \"\" }}\n servername: {{ $proxy.SNI }}\n {{- end }}\n {{- if $SkipVerify }}\n skip-cert-verify: true\n {{- end }}\n {{- if ne (default \"\" $proxy.Fingerprint) \"\" }}\n client-fingerprint: {{ $proxy.Fingerprint }}\n {{- end }}\n {{- if $proxy.EchEnable }}\n ech-opts:\n enable: true\n {{- if ne (default \"\" $proxy.EchServerName) \"\" }}\n query-server-name: {{ $proxy.EchServerName }}\n {{- end }}\n {{- end }}\n{{- else if eq $proxy.Type \"vless\" }}\n {{- $encryptionStr := \"\" -}}\n {{- $encryption := default \"none\" $proxy.Encryption -}}\n {{- if eq $encryption \"none\" -}}\n {{- $encryptionStr = \"none\" -}}\n {{- else -}}\n {{- $encParts := list -}}\n {{- $encParts = append $encParts $encryption -}}\n {{- if ne (default \"\" $proxy.Encryption_Mode) \"\" -}}\n {{- $encParts = append $encParts $proxy.Encryption_Mode -}}\n {{- end -}}\n {{- if ne (default \"\" $proxy.EncryptionRtt) \"\" -}}\n {{- $encParts = append $encParts $proxy.EncryptionRtt -}}\n {{- end -}}\n {{- if ne (default \"\" $proxy.EncryptionClientPadding) \"\" -}}\n {{- $encParts = append $encParts $proxy.EncryptionClientPadding -}}\n {{- end -}}\n {{- if ne (default \"\" $proxy.EncryptionPassword) \"\" -}}\n {{- $encParts = append $encParts $proxy.EncryptionPassword -}}\n {{- end -}}\n {{- $encryptionStr = join \".\" $encParts -}}\n {{- end }}\n- name: {{ $proxy.Name | quote }}\n type: vless\n server: {{ $server }}\n port: {{ $proxy.Port }}\n uuid: {{ $password }}\n udp: true\n tfo: true\n encryption: {{ $encryptionStr }}\n {{- if ne (default \"\" $proxy.Flow) \"\" }}\n flow: {{ $proxy.Flow }}\n {{- end }}\n {{- if or (eq $proxy.Transport \"ws\") (eq $proxy.Transport \"websocket\") }}\n network: ws\n ws-opts:\n path: {{ default \"/\" $proxy.Path }}\n {{- if ne (default \"\" $proxy.Host) \"\" }}\n headers:\n Host: {{ $proxy.Host }}\n {{- end }}\n {{- else if eq $proxy.Transport \"http\" }}\n network: http\n http-opts:\n method: GET\n path: [{{ default \"/\" $proxy.Path | quote }}]\n {{- if ne (default \"\" $proxy.Host) \"\" }}\n headers:\n Host: [{{ $proxy.Host | quote }}]\n {{- end }}\n {{- else if eq $proxy.Transport \"httpupgrade\" }}\n network: httpupgrade\n httpupgrade-opts:\n path: {{ default \"/\" $proxy.Path }}\n {{- if ne (default \"\" $proxy.Host) \"\" }}\n headers:\n Host: {{ $proxy.Host }}\n {{- end }}\n {{- else if eq $proxy.Transport \"xhttp\" }}\n network: xhttp\n xhttp-opts:\n path: {{ default \"/\" $proxy.Path }}\n {{- if ne (default \"\" $proxy.Host) \"\" }}\n host: {{ $proxy.Host }}\n {{- end }}\n {{- if ne (default \"\" $proxy.XhttpMode) \"\" }}\n mode: {{ $proxy.XhttpMode }}\n {{- end }}\n {{- else if eq $proxy.Transport \"grpc\" }}\n network: grpc\n grpc-opts:\n grpc-service-name: {{ default \"grpc\" $proxy.ServiceName }}\n {{- end }}\n {{- if or (eq $proxy.Security \"tls\") (eq $proxy.Security \"reality\") }}\n tls: true\n {{- end }}\n {{- if ne (default \"\" $proxy.SNI) \"\" }}\n servername: {{ $proxy.SNI }}\n {{- end }}\n {{- if $proxy.AllowInsecure }}\n skip-cert-verify: true\n {{- end }}\n {{- if ne (default \"\" $proxy.Fingerprint) \"\" }}\n client-fingerprint: {{ $proxy.Fingerprint }}\n {{- end }}\n {{- if and (eq $proxy.Security \"reality\") (ne (default \"\" $proxy.RealityPublicKey) \"\") }}\n reality-opts:\n public-key: {{ $proxy.RealityPublicKey }}\n {{- if ne (default \"\" $proxy.RealityShortId) \"\" }}\n short-id: {{ $proxy.RealityShortId }}\n {{- end }}\n {{- end }}\n {{- if $proxy.EchEnable }}\n ech-opts:\n enable: true\n {{- if ne (default \"\" $proxy.EchServerName) \"\" }}\n query-server-name: {{ $proxy.EchServerName }}\n {{- end }}\n {{- end }}\n{{- else if eq $proxy.Type \"trojan\" }}\n- name: {{ $proxy.Name | quote }}\n type: trojan\n server: {{ $server }}\n port: {{ $proxy.Port }}\n password: {{ $password }}\n udp: true\n tfo: true\n {{- if or (eq $proxy.Security \"tls\") (eq $proxy.Security \"reality\") }}\n tls: true\n {{- end }}\n {{- if ne (default \"\" $proxy.SNI) \"\" }}\n sni: {{ $proxy.SNI }}\n {{- end }}\n {{- if $SkipVerify }}\n skip-cert-verify: true\n {{- end }}\n {{- if ne (default \"\" $proxy.Fingerprint) \"\" }}\n client-fingerprint: {{ $proxy.Fingerprint }}\n {{- end }}\n {{- if and (eq $proxy.Security \"reality\") (ne (default \"\" $proxy.RealityPublicKey) \"\") }}\n reality-opts:\n public-key: {{ $proxy.RealityPublicKey }}\n {{- if ne (default \"\" $proxy.RealityShortId) \"\" }}\n short-id: {{ $proxy.RealityShortId }}\n {{- end }}\n {{- end }}\n {{- if or (eq $proxy.Transport \"ws\") (eq $proxy.Transport \"websocket\") }}\n network: ws\n ws-opts:\n path: {{ default \"/\" $proxy.Path }}\n {{- if ne (default \"\" $proxy.Host) \"\" }}\n headers:\n Host: {{ $proxy.Host }}\n {{- end }}\n {{- else if eq $proxy.Transport \"http\" }}\n network: http\n http-opts:\n method: GET\n path: [{{ default \"/\" $proxy.Path | quote }}]\n {{- if ne (default \"\" $proxy.Host) \"\" }}\n headers:\n Host: [{{ $proxy.Host | quote }}]\n {{- end }}\n {{- else if eq $proxy.Transport \"grpc\" }}\n network: grpc\n grpc-opts:\n grpc-service-name: {{ default \"grpc\" $proxy.ServiceName }}\n {{- end }}\n {{- if $proxy.EchEnable }}\n ech-opts:\n enable: true\n {{- if ne (default \"\" $proxy.EchServerName) \"\" }}\n query-server-name: {{ $proxy.EchServerName }}\n {{- end }}\n {{- end }}\n{{- else if or (eq $proxy.Type \"hysteria2\") (eq $proxy.Type \"hysteria\") }}\n- name: {{ $proxy.Name | quote }}\n type: hysteria2\n server: {{ $server }}\n port: {{ $proxy.Port }}\n password: {{ $password }}\n udp: true\n tfo: true\n {{- if ne (default \"\" $proxy.SNI) \"\" }}\n sni: {{ $proxy.SNI }}\n {{- end }}\n {{- if $proxy.AllowInsecure }}\n skip-cert-verify: true\n {{- end }}\n {{- if ne (default \"\" $proxy.ObfsPassword) \"\" }}\n obfs: salamander\n obfs-password: {{ $proxy.ObfsPassword }}\n {{- end }}\n {{- if ne (default \"\" $proxy.HopPorts) \"\" }}\n ports: {{ $proxy.HopPorts }}\n {{- end }}\n {{- if ne (default 0 $proxy.HopInterval) 0 }}\n hop-interval: {{ $proxy.HopInterval }}\n {{- end }}\n {{- if ne (default \"\" (printf \"%v\" $proxy.UpMbps)) \"\" }}\n up: \"{{ $proxy.UpMbps }} Mbps\"\n {{- end }}\n {{- if ne (default \"\" (printf \"%v\" $proxy.DownMbps)) \"\" }}\n down: \"{{ $proxy.DownMbps }} Mbps\"\n {{- end }}\n {{- if $proxy.EchEnable }}\n ech-opts:\n enable: true\n {{- if ne (default \"\" $proxy.EchServerName) \"\" }}\n query-server-name: {{ $proxy.EchServerName }}\n {{- end }}\n {{- end }}\n{{- else if eq $proxy.Type \"tuic\" }}\n- name: {{ $proxy.Name | quote }}\n type: tuic\n server: {{ $server }}\n port: {{ $proxy.Port }}\n uuid: {{ default \"\" $proxy.ServerKey }}\n password: {{ $password }}\n udp: true\n tfo: true\n {{- if ne (default \"\" $proxy.SNI) \"\" }}\n sni: {{ $proxy.SNI }}\n {{- end }}\n {{- if $proxy.AllowInsecure }}\n skip-cert-verify: true\n {{- end }}\n {{- if $proxy.DisableSNI }}\n disable-sni: true\n {{- end }}\n {{- if $proxy.ReduceRtt }}\n reduce-rtt: true\n {{- end }}\n {{- if ne (default \"\" $proxy.UDPRelayMode) \"\" }}\n udp-relay-mode: {{ $proxy.UDPRelayMode }}\n {{- end }}\n {{- if ne (default \"\" $proxy.CongestionController) \"\" }}\n congestion-controller: {{ $proxy.CongestionController }}\n {{- end }}\n {{- if $proxy.EchEnable }}\n ech-opts:\n enable: true\n {{- if ne (default \"\" $proxy.EchServerName) \"\" }}\n query-server-name: {{ $proxy.EchServerName }}\n {{- end }}\n {{- end }}\n{{- else if eq $proxy.Type \"wireguard\" }}\n- name: {{ $proxy.Name | quote }}\n type: wireguard\n server: {{ $server }}\n port: {{ $proxy.Port }}\n private-key: {{ default \"\" $proxy.ServerKey }}\n public-key: {{ default \"\" $proxy.RealityPublicKey }}\n udp: true\n tfo: true\n {{- if ne (default \"\" $proxy.Path) \"\" }}\n preshared-key: {{ $proxy.Path }}\n {{- end }}\n {{- if ne (default \"\" $proxy.RealityServerAddr) \"\" }}\n ip: {{ $proxy.RealityServerAddr }}\n {{- end }}\n {{- if ne (default 0 $proxy.RealityServerPort) 0 }}\n ipv6: {{ $proxy.RealityServerPort }}\n {{- end }}\n{{- else if eq $proxy.Type \"anytls\" }}\n- name: {{ $proxy.Name | quote }}\n type: anytls\n server: {{ $server }}\n port: {{ $proxy.Port }}\n password: {{ $password }}\n udp: true\n tfo: true\n {{- if ne (default \"\" $proxy.SNI) \"\" }}\n sni: {{ $proxy.SNI }}\n {{- end }}\n {{- if $proxy.AllowInsecure }}\n skip-cert-verify: true\n {{- end }}\n {{- if ne (default \"\" $proxy.Fingerprint) \"\" }}\n client-fingerprint: {{ $proxy.Fingerprint }}\n {{- end }}\n {{- if $proxy.EchEnable }}\n ech-opts:\n enable: true\n {{- if ne (default \"\" $proxy.EchServerName) \"\" }}\n query-server-name: {{ $proxy.EchServerName }}\n {{- end }}\n {{- end }}\n{{- else }}\n- name: {{ $proxy.Name | quote }}\n type: {{ $proxy.Type }}\n server: {{ $server }}\n port: {{ $proxy.Port }}\n udp: true\n tfo: true\n{{- end }}\n{{- end }}\n\nproxy-groups:\n - { name: 🚀 Proxy, type: select, proxies: [🌏 Auto, 🎯 Direct, {{ $proxyNames }}] }\n - { name: 🍎 Apple, type: select, proxies: [🚀 Proxy, 🎯 Direct, {{ $proxyNames }}] }\n - { name: 🔍 Google, type: select, proxies: [🚀 Proxy, 🎯 Direct, {{ $proxyNames }}] }\n - { name: 🪟 Microsoft, type: select, proxies: [🚀 Proxy, 🎯 Direct, {{ $proxyNames }}] }\n - { name: 📺 GlobalMedia, type: select, proxies: [🚀 Proxy, 🎯 Direct, {{ $proxyNames }}] }\n - { name: 📟 Telegram, type: select, proxies: [🚀 Proxy, 🎯 Direct, {{ $proxyNames }}] }\n - { name: 🤖 AI, type: select, proxies: [🚀 Proxy, 🎯 Direct, {{ $proxyNames }}] }\n - { name: 🪙 Crypto, type: select, proxies: [🚀 Proxy, 🎯 Direct, {{ $proxyNames }}] }\n - { name: 🎮 Game, type: select, proxies: [🚀 Proxy, 🎯 Direct, {{ $proxyNames }}] }\n - { name: 🇨🇳 China, type: select, proxies: [🎯 Direct, 🚀 Proxy, {{ $proxyNames }}] }\n - { name: 🎯 Direct, type: select, proxies: [DIRECT], hidden: true }\n - { name: 🐠 Final, type: select, proxies: [🚀 Proxy, 🎯 Direct, {{ $proxyNames }}] }\n - { name: 🌏 Auto, type: url-test, proxies: [{{ $proxyNames }}] }\n\nrules:\n - RULE-SET, Apple, 🍎 Apple\n - RULE-SET, Google, 🔍 Google\n - RULE-SET, Microsoft, 🪟 Microsoft\n - RULE-SET, Github, 🪟 Microsoft\n - RULE-SET, HBO, 📺 GlobalMedia\n - RULE-SET, Disney, 📺 GlobalMedia\n - RULE-SET, TikTok, 📺 GlobalMedia\n - RULE-SET, Netflix, 📺 GlobalMedia\n - RULE-SET, GlobalMedia, 📺 GlobalMedia\n - RULE-SET, Telegram, 📟 Telegram\n - RULE-SET, OpenAI, 🤖 AI\n - RULE-SET, Gemini, 🤖 AI\n - RULE-SET, Copilot, 🤖 AI\n - RULE-SET, Claude, 🤖 AI\n - RULE-SET, Crypto, 🪙 Crypto\n - RULE-SET, Cryptocurrency, 🪙 Crypto\n - RULE-SET, Game, 🎮 Game\n - RULE-SET, Global, 🚀 Proxy\n - RULE-SET, ChinaMax, 🇨🇳 China\n - RULE-SET, Lan, 🎯 Direct\n - GEOIP, CN, 🇨🇳 China\n - MATCH, 🐠 Final\n\nrule-providers:\n Apple:\n type: http\n behavior: classical\n format: yaml\n url: https://cdn.jsdmirror.com/gh/perfect-panel/rules/rule/Clash/Apple/Apple_Classical_No_Resolve.yaml\n interval: 86400\n Google:\n type: http\n behavior: classical\n format: yaml\n url: https://cdn.jsdmirror.com/gh/perfect-panel/rules/rule/Clash/Google/Google_No_Resolve.yaml\n interval: 86400\n Microsoft:\n type: http\n behavior: classical\n format: yaml\n url: https://cdn.jsdmirror.com/gh/perfect-panel/rules/rule/Clash/Microsoft/Microsoft.yaml\n interval: 86400\n Github:\n type: http\n behavior: classical\n format: yaml\n url: https://cdn.jsdmirror.com/gh/perfect-panel/rules/rule/Clash/GitHub/GitHub.yaml\n interval: 86400\n HBO:\n type: http\n behavior: classical\n format: yaml\n url: https://cdn.jsdmirror.com/gh/perfect-panel/rules/rule/Clash/HBO/HBO.yaml\n interval: 86400\n Disney:\n type: http\n behavior: classical\n format: yaml\n url: https://cdn.jsdmirror.com/gh/perfect-panel/rules/rule/Clash/Disney/Disney.yaml\n interval: 86400\n TikTok:\n type: http\n behavior: classical\n format: yaml\n url: https://cdn.jsdmirror.com/gh/perfect-panel/rules/rule/Clash/TikTok/TikTok.yaml\n interval: 86400\n Netflix:\n type: http\n behavior: classical\n format: yaml\n url: https://cdn.jsdmirror.com/gh/perfect-panel/rules/rule/Clash/Netflix/Netflix.yaml\n interval: 86400\n GlobalMedia:\n type: http\n behavior: classical\n format: yaml\n url: https://cdn.jsdmirror.com/gh/perfect-panel/rules/rule/Clash/GlobalMedia/GlobalMedia_Classical_No_Resolve.yaml\n interval: 86400\n Telegram:\n type: http\n behavior: classical\n format: yaml\n url: https://cdn.jsdmirror.com/gh/perfect-panel/rules/rule/Clash/Telegram/Telegram_No_Resolve.yaml\n interval: 86400\n OpenAI:\n type: http\n behavior: classical\n format: yaml\n url: https://cdn.jsdmirror.com/gh/perfect-panel/rules/rule/Clash/OpenAI/OpenAI.yaml\n interval: 86400\n Gemini:\n type: http\n behavior: classical\n format: yaml\n url: https://cdn.jsdmirror.com/gh/perfect-panel/rules/rule/Clash/Gemini/Gemini.yaml\n interval: 86400\n Copilot:\n type: http\n behavior: classical\n format: yaml\n url: https://cdn.jsdmirror.com/gh/perfect-panel/rules/rule/Clash/Copilot/Copilot.yaml\n interval: 86400\n Claude:\n type: http\n behavior: classical\n format: yaml\n url: https://cdn.jsdmirror.com/gh/perfect-panel/rules/rule/Clash/Claude/Claude.yaml\n interval: 86400\n Crypto:\n type: http\n behavior: classical\n format: yaml\n url: https://cdn.jsdmirror.com/gh/perfect-panel/rules/rule/Clash/Crypto/Crypto.yaml\n interval: 86400\n Cryptocurrency:\n type: http\n behavior: classical\n format: yaml\n url: https://cdn.jsdmirror.com/gh/perfect-panel/rules/rule/Clash/Cryptocurrency/Cryptocurrency.yaml\n interval: 86400\n Game:\n type: http\n behavior: classical\n format: yaml\n url: https://cdn.jsdmirror.com/gh/perfect-panel/rules/rule/Clash/Game/Game.yaml\n interval: 86400\n Global:\n type: http\n behavior: classical\n format: yaml\n url: https://cdn.jsdmirror.com/gh/perfect-panel/rules/rule/Clash/Global/Global_Classical_No_Resolve.yaml\n interval: 86400\n ChinaMax:\n type: http\n behavior: classical\n format: yaml\n url: https://cdn.jsdmirror.com/gh/perfect-panel/rules/rule/Clash/ChinaMax/ChinaMax_Classical_No_Resolve.yaml\n interval: 86400\n Lan:\n type: http\n behavior: classical\n format: yaml\n url: https://cdn.jsdmirror.com/gh/perfect-panel/rules/rule/Clash/Lan/Lan.yaml\n interval: 86400\n\nurl-rewrite:\n - ^https?:\\/\\/(www.)?g\\.cn https://www.google.com 302\n - ^https?:\\/\\/(www.)?google\\.cn https://www.google.com 302\n', 'yaml', '{}', '2025-08-12 23:10:00.487', '2025-08-15 22:01:27.031') ON CONFLICT DO NOTHING; +INSERT INTO "subscribe_application" ("id", "name", "icon", "description", "scheme", "user_agent", "is_default", "subscribe_template", "output_format", "download_link", "created_at", "updated_at") VALUES +(4, 'SingBox', '', '', 'sing-box://import-remote-profile?url=${encodeURIComponent(url)}#${name}', 'sing-box', false, '{{- $GiB := 1073741824.0 -}}\n{{- $used := printf \"%.2f\" (divf (add (.UserInfo.Download | default 0 | float64) (.UserInfo.Upload | default 0 | float64)) $GiB) -}}\n{{- $traffic := (.UserInfo.Traffic | default 0 | float64) -}}\n{{- $total := printf \"%.2f\" (divf $traffic $GiB) -}}\n\n{{- $ExpiredAt := \"\" -}}\n{{- $expStr := printf \"%v\" .UserInfo.ExpiredAt -}}\n{{- if regexMatch `^[0-9]+$` $expStr -}}\n {{- $ts := $expStr | float64 -}}\n {{- $sec := ternary (divf $ts 1000.0) $ts (ge (len $expStr) 13) -}}\n {{- $ExpiredAt = (date \"2006-01-02 15:04:05\" (unixEpoch ($sec | int64))) -}}\n{{- else -}}\n {{- $ExpiredAt = $expStr -}}\n{{- end -}}\n\n{{- $sortFields := list \"Sort\" \"Port\" \"Name\" -}}\n{{- $sortConfig := dict \"Sort\" \"asc\" \"Port\" \"asc\" \"Name\" \"asc\" -}}\n{{- $byKey := dict -}}\n{{- range $p := .Proxies -}}\n {{- $keyParts := list -}}\n {{- range $field := $sortFields -}}\n {{- $order := index $sortConfig $field -}}\n {{- $val := default \"\" (printf \"%v\" (index $p $field)) -}}\n {{- if or (eq $field \"Sort\") (eq $field \"Port\") -}}\n {{- $val = printf \"%08d\" (int (default 0 (index $p $field))) -}}\n {{- end -}}\n {{- if eq $order \"desc\" -}}\n {{- $val = printf \"~%s\" $val -}}\n {{- end -}}\n {{- $keyParts = append $keyParts $val -}}\n {{- end -}}\n {{- $_ := set $byKey (join \"|\" $keyParts) $p -}}\n{{- end -}}\n{{- $sorted := list -}}\n{{- range $k := sortAlpha (keys $byKey) -}}\n {{- $sorted = append $sorted (index $byKey $k) -}}\n{{- end -}}\n\n{{- $supportedProxies := list -}}\n{{- range $proxy := $sorted -}}\n {{- $isSupported := false -}}\n {{- if or (eq $proxy.Type \"shadowsocks\") (eq $proxy.Type \"vmess\") (eq $proxy.Type \"trojan\") (eq $proxy.Type \"hysteria2\") (eq $proxy.Type \"hy2\") (eq $proxy.Type \"tuic\") (eq $proxy.Type \"anytls\") -}}\n {{- $isSupported = true -}}\n {{- else if eq $proxy.Type \"vless\" -}}\n {{- if or (eq $proxy.Transport \"ws\") (eq $proxy.Transport \"websocket\") (eq $proxy.Transport \"grpc\") (eq $proxy.Transport \"tcp\") (not $proxy.Transport) -}}\n {{- $isSupported = true -}}\n {{- end -}}\n {{- end -}}\n {{- if $isSupported -}}\n {{- $supportedProxies = append $supportedProxies $proxy -}}\n {{- end -}}\n{{- end -}}\n\n{{- define \"AllNodeNames\" -}}\n{{- $sortConfig := dict \"Sort\" \"asc\" -}}\n{{- $byKey := dict -}}\n{{- range $p := .Proxies -}}\n {{- $keyParts := list -}}\n {{- range $field, $order := $sortConfig -}}\n {{- $val := default \"\" (printf \"%v\" (index $p $field)) -}}\n {{- if or (eq $field \"Sort\") (eq $field \"Port\") -}}\n {{- $val = printf \"%08d\" (int (default 0 (index $p $field))) -}}\n {{- end -}}\n {{- if eq $order \"desc\" -}}\n {{- $val = printf \"~%s\" $val -}}\n {{- end -}}\n {{- $keyParts = append $keyParts $val -}}\n {{- end -}}\n {{- $_ := set $byKey (join \"|\" $keyParts) $p -}}\n{{- end -}}\n{{- $sorted := list -}}\n{{- range $k := sortAlpha (keys $byKey) -}}\n {{- $sorted = append $sorted (index $byKey $k) -}}\n{{- end -}}\n{{- $supportedProxies := list -}}\n{{- range $proxy := $sorted -}}\n {{- $isSupported := false -}}\n {{- if or (eq .Type \"shadowsocks\") (eq .Type \"vmess\") (eq .Type \"trojan\") (eq .Type \"hysteria2\") (eq .Type \"hy2\") (eq .Type \"tuic\") (eq .Type \"anytls\") -}}\n {{- $isSupported = true -}}\n {{- else if eq .Type \"vless\" -}}\n {{- if or (eq .Transport \"ws\") (eq .Transport \"websocket\") (eq .Transport \"grpc\") (eq .Transport \"tcp\") (not .Transport) -}}\n {{- $isSupported = true -}}\n {{- end -}}\n {{- end -}}\n {{- if $isSupported -}}\n {{- $supportedProxies = append $supportedProxies . -}}\n {{- end -}}\n{{- end -}}\n{{- $first := true -}}\n{{- range $supportedProxies -}}\n {{- if $first -}}\n \"{{ .Name }}\"\n {{- $first = false -}}\n {{- else -}}\n , \"{{ .Name }}\"\n {{- end -}}\n{{- end -}}\n{{- end -}}\n\n{{- define \"NodeOutbound\" -}}\n{{- $proxy := .proxy -}}\n{{- $server := $proxy.Server -}}\n{{- if and (contains $server \":\") (not (hasPrefix \"[\" $server)) -}}\n {{- $server = printf \"[%s]\" $server -}}\n{{- end -}}\n{{- $port := $proxy.Port -}}\n{{- $name := $proxy.Name -}}\n{{- $pwd := $.UserInfo.Password -}}\n{{- $sni := or $proxy.SNI $server }}\n{{- $svc := $proxy.ServiceName }}\n\n{{- $tlsOpts := \"\" -}}\n{{- if or $sni $proxy.AllowInsecure $proxy.Fingerprint -}}\n {{- $tlsOpts = \"\\\"tls\\\": {\\\"enabled\\\": true\" -}}\n {{- if $sni -}}\n {{- $tlsOpts = printf \"%s, \\\"server_name\\\": \\\"%s\\\"\" $tlsOpts $sni -}}\n {{- end -}}\n {{- if $proxy.AllowInsecure -}}\n {{- $tlsOpts = printf \"%s, \\\"insecure\\\": true\" $tlsOpts -}}\n {{- end -}}\n {{- if $proxy.Fingerprint -}}\n {{- $tlsOpts = printf \"%s, \\\"utls\\\": {\\\"enabled\\\": true, \\\"fingerprint\\\": \\\"%s\\\"}\" $tlsOpts ($proxy.Fingerprint) -}}\n {{- end -}}\n {{- $tlsOpts = printf \"%s}\" $tlsOpts -}}\n{{- end -}}\n\n{{- $transportOpts := \"\" -}}\n{{- if or (eq $proxy.Transport \"ws\") (eq $proxy.Transport \"websocket\") -}}\n {{- $wsPath := default \"/\" $proxy.Path -}}\n {{- $transportOpts = printf \"\\\"transport\\\": {\\\"type\\\": \\\"ws\\\", \\\"path\\\": \\\"%s\\\"\" $wsPath -}}\n {{- if $proxy.Host -}}\n {{- $transportOpts = printf \"%s, \\\"headers\\\": {\\\"Host\\\": \\\"%s\\\"}\" $transportOpts ($proxy.Host) -}}\n {{- end -}}\n {{- $transportOpts = printf \"%s}\" $transportOpts -}}\n{{- else if eq $proxy.Transport \"grpc\" -}}\n {{- $grpcService := default \"grpc\" $svc -}}\n {{- $transportOpts = printf \"\\\"transport\\\": {\\\"type\\\": \\\"grpc\\\", \\\"service_name\\\": \\\"%s\\\"}\" $grpcService -}}\n{{- end -}}\n\n{{- if eq $proxy.Type \"shadowsocks\" -}}\n {{- $method := default \"aes-128-gcm\" $proxy.Method -}}\n {{- $password := $pwd -}}\n {{- if $proxy.ServerKey -}}\n {{- $needBytes := ternary 16 32 (eq $proxy.Method \"2022-blake3-aes-128-gcm\") -}}\n {{- $cutLen := min $needBytes (len $pwd) | int -}}\n {{- $userCut := $pwd | trunc $cutLen -}}\n {{- $serverB64 := b64enc $proxy.ServerKey -}}\n {{- $userB64 := b64enc $userCut -}}\n {{- $password = printf \"%s:%s\" $serverB64 $userB64 -}}\n {{- end -}}\n{ \"type\": \"shadowsocks\", \"tag\": \"{{ $name }}\", \"server\": \"{{ $server }}\", \"server_port\": {{ $port }}, \"method\": \"{{ $method }}\", \"password\": \"{{ $password }}\" }\n\n{{- else if eq $proxy.Type \"trojan\" -}}\n{ \"type\": \"trojan\", \"tag\": \"{{ $name }}\", \"server\": \"{{ $server }}\", \"server_port\": {{ $port }}, \"password\": \"{{ $pwd }}\"{{ if $transportOpts }}, {{ $transportOpts }}{{ end }}, {{ $tlsOpts }} }\n\n{{- else if eq $proxy.Type \"vless\" -}}\n{{- $realityOpts := \"\" -}}\n{{- if $proxy.RealityPublicKey -}}\n {{- $realityOpts = printf \"\\\"reality\\\": { \\\"enabled\\\": true, \\\"public_key\\\": \\\"%s\\\"\" ($proxy.RealityPublicKey) -}}\n {{- if $proxy.RealityShortId -}}\n {{- $realityOpts = printf \"%s, \\\"short_id\\\": \\\"%s\\\"\" $realityOpts ($proxy.RealityShortId) -}}\n {{- end -}}\n {{- if $svc -}}\n {{- $realityOpts = printf \"%s, \\\"server_name\\\": \\\"%s\\\"\" $realityOpts ($svc) -}}\n {{- end -}}\n {{- $realityOpts = printf \"%s }\" $realityOpts -}}\n{{- end -}}\n{{- $flowOpts := \"\" -}}\n{{- if $proxy.Flow -}}\n {{- $flowOpts = printf \", \\\"flow\\\": \\\"%s\\\"\" ($proxy.Flow) -}}\n{{- end -}}\n{ \"type\": \"vless\", \"tag\": \"{{ $name }}\", \"server\": \"{{ $server }}\", \"server_port\": {{ $port }}, \"uuid\": \"{{ $pwd }}\"{{ $flowOpts }}{{ if $transportOpts }}, {{ $transportOpts }}{{ end }}{{ if $realityOpts }}, {{ $realityOpts }}{{ else if $tlsOpts }}, {{ $tlsOpts }}{{ end }} }\n\n{{- else if eq $proxy.Type \"vmess\" -}}\n{{- $vmessTLS := \"\" -}}\n{{- if and $tlsOpts (ne $proxy.Transport \"tcp\") -}}\n {{- $vmessTLS = $tlsOpts -}}\n{{- end -}}\n{ \"type\": \"vmess\", \"tag\": \"{{ $name }}\", \"server\": \"{{ $server }}\", \"server_port\": {{ $port }}, \"uuid\": \"{{ $pwd }}\", \"security\": \"auto\"{{ if $transportOpts }}, {{ $transportOpts }}{{ end }}{{ if $vmessTLS }}, {{ $vmessTLS }}{{ end }} }\n\n{{- else if or (eq $proxy.Type \"hysteria2\") (eq $proxy.Type \"hy2\") -}}\n{{- $obfsOpts := \"\" -}}\n{{- if $proxy.ObfsPassword -}}\n {{- $obfsOpts = printf \"\\\"obfs\\\": { \\\"type\\\": \\\"salamander\\\", \\\"password\\\": \\\"%s\\\" }\" ($proxy.ObfsPassword) -}}\n{{- end -}}\n{{- $hopPortsOpts := \"\" -}}\n{{- if $proxy.HopPorts -}}\n {{- $hopPortsOpts = printf \", \\\"ports\\\": \\\"%s\\\"\" ($proxy.HopPorts) -}}\n{{- end -}}\n{{- $hopIntervalOpts := \"\" -}}\n{{- if $proxy.HopInterval -}}\n {{- $hopIntervalOpts = printf \", \\\"hop_interval\\\": %v\" $proxy.HopInterval -}}\n{{- end -}}\n{ \"type\": \"hysteria2\", \"tag\": \"{{ $name }}\", \"server\": \"{{ $server }}\", \"server_port\": {{ $port }}, \"password\": \"{{ $pwd }}\"{{ if $obfsOpts }}, {{ $obfsOpts }}{{ end }}{{ $hopPortsOpts }}{{ $hopIntervalOpts }}, {{ $tlsOpts }} }\n\n{{- else if eq $proxy.Type \"tuic\" -}}\n{{- $tuicServerKey := $proxy.ServerKey -}}\n{{- $tuicOpts := \"\" -}}\n{{- if $proxy.DisableSNI -}}\n {{- $tuicOpts = printf \"%s, \\\"disable_sni\\\": %v\" $tuicOpts $proxy.DisableSNI -}}\n{{- end -}}\n{{- if $proxy.ReduceRtt -}}\n {{- $tuicOpts = printf \"%s, \\\"reduce_rtt\\\": %v\" $tuicOpts $proxy.ReduceRtt -}}\n{{- end -}}\n{{- if $proxy.UDPRelayMode -}}\n {{- $tuicOpts = printf \"%s, \\\"udp_relay_mode\\\": \\\"%s\\\"\" $tuicOpts ($proxy.UDPRelayMode) -}}\n{{- end -}}\n{{- if $proxy.CongestionController -}}\n {{- $tuicOpts = printf \"%s, \\\"congestion_control\\\": \\\"%s\\\"\" $tuicOpts ($proxy.CongestionController) -}}\n{{- end -}}\n{ \"type\": \"tuic\", \"tag\": \"{{ $name }}\", \"server\": \"{{ $server }}\", \"server_port\": {{ $port }}, \"uuid\": \"{{ $tuicServerKey }}\", \"password\": \"{{ $pwd }}\"{{ $tuicOpts }}, \"alpn\": [\"h3\"], {{ $tlsOpts }} }\n\n{{- else if eq $proxy.Type \"anytls\" -}}\n{{- $anytlsOpts := \"\" -}}\n{{- if $proxy.Method -}}\n {{- $anytlsOpts = printf \"%s, \\\"method\\\": \\\"%s\\\"\" $anytlsOpts ($proxy.Method) -}}\n{{- end -}}\n{{- if $proxy.ObfsPassword -}}\n {{- $anytlsOpts = printf \"%s, \\\"obfs\\\": \\\"%s\\\"\" $anytlsOpts ($proxy.ObfsPassword) -}}\n{{- end -}}\n{{- if $proxy.Path -}}\n {{- $anytlsOpts = printf \"%s, \\\"path\\\": \\\"%s\\\"\" $anytlsOpts ($proxy.Path) -}}\n{{- end -}}\n{{- if $proxy.Host -}}\n {{- $anytlsOpts = printf \"%s, \\\"host\\\": \\\"%s\\\"\" $anytlsOpts ($proxy.Host) -}}\n{{- end -}}\n{ \"type\": \"anytls\", \"tag\": \"{{ $name }}\", \"server\": \"{{ $server }}\", \"server_port\": {{ $port }}, \"password\": \"{{ $pwd }}\"{{ $anytlsOpts }}{{ if $tlsOpts }}, {{ $tlsOpts }}{{ end }} }\n\n{{- else if eq $proxy.Type \"wireguard\" -}}\n{{- $wgPrivateKey := $proxy.ServerKey -}}\n{{- $wgPublicKey := $proxy.RealityPublicKey -}}\n{{- $wgPreSharedOpts := \"\" -}}\n{{- if $proxy.Path -}}\n {{- $wgPreSharedOpts = printf \", \\\"pre_shared_key\\\": \\\"%s\\\"\" ($proxy.Path) -}}\n{{- end -}}\n{{- $wgLocalAddressOpts := \"\" -}}\n{{- if $proxy.RealityServerAddr -}}\n {{- $wgLocalAddressOpts = printf \", \\\"local_address\\\": [\\\"%s\\\"]\" ($proxy.RealityServerAddr) -}}\n{{- end -}}\n{ \"type\": \"wireguard\", \"tag\": \"{{ $name }}\", \"server\": \"{{ $server }}\", \"server_port\": {{ $port }}, \"private_key\": \"{{ $wgPrivateKey }}\", \"peer_public_key\": \"{{ $wgPublicKey }}\"{{ $wgPreSharedOpts }}{{ $wgLocalAddressOpts }} }\n\n{{- else if or (eq $proxy.Type \"http\") (eq $proxy.Type \"https\") -}}\n{{- $httpsTLSOpts := \"\" -}}\n{{- if and (eq $proxy.Type \"https\") $tlsOpts -}}\n {{- $httpsTLSOpts = printf \", %s\" $tlsOpts -}}\n{{- end -}}\n{ \"type\": \"http\", \"tag\": \"{{ $name }}\", \"server\": \"{{ $server }}\", \"server_port\": {{ $port }}, \"username\": \"{{ $pwd }}\", \"password\": \"{{ $pwd }}\"{{ $httpsTLSOpts }} }\n\n{{- else if or (eq $proxy.Type \"socks\") (eq $proxy.Type \"socks5\") -}}\n{ \"type\": \"socks\", \"tag\": \"{{ $name }}\", \"server\": \"{{ $server }}\", \"server_port\": {{ $port }}, \"version\": \"5\", \"username\": \"{{ $pwd }}\", \"password\": \"{{ $pwd }}\" }\n\n{{- else -}}\n{ \"type\": \"direct\", \"tag\": \"{{ $name }}\" }\n{{- end -}}\n{{- end -}}\n\n// 用户信息: 已用流量 {{ $used }}GB / 总流量 {{ $total }}GB, 过期时间: {{ $ExpiredAt }}\n{\n \"log\": {\n \"level\": \"info\",\n \"timestamp\": true\n },\n \"experimental\": {\n \"cache_file\": {\n \"enabled\": true,\n \"store_fakeip\": true,\n \"store_rdrc\": true\n },\n \"clash_api\": {\n \"external_controller\": \"127.0.0.1:9090\",\n \"access_control_allow_origin\": [\n \"http://127.0.0.1\",\n \"https://yacd.metacubex.one\",\n \"https://metacubex.github.io\",\n \"https://metacubexd.pages.dev\",\n \"https://board.zash.run.place\"\n ]\n }\n },\n \"dns\": {\n \"independent_cache\": true,\n \"servers\": [\n {\n \"tag\": \"google\",\n \"type\": \"https\",\n \"server\": \"8.8.8.8\",\n \"detour\": \"节点选择\"\n },\n {\n \"tag\": \"ali\",\n \"type\": \"https\",\n \"server\": \"223.5.5.5\"\n },\n {\n \"tag\": \"fakeip\",\n \"type\": \"fakeip\",\n \"inet4_range\": \"198.18.0.0/15\",\n \"inet6_range\": \"fc00::/18\"\n }\n ],\n \"rules\": [\n {\n \"clash_mode\": \"Direct\",\n \"server\": \"ali\"\n },\n {\n \"clash_mode\": \"Global\",\n \"server\": \"google\"\n },\n {\n \"query_type\": [\n \"A\",\n \"AAAA\"\n ],\n \"server\": \"fakeip\"\n },\n {\n \"rule_set\": \"geosite-cn\",\n \"server\": \"ali\"\n }\n ]\n },\n \"inbounds\": [\n {\n \"type\": \"tun\",\n \"address\": [\n \"172.18.0.1/30\",\n \"fdfe:dcba:9876::1/126\"\n ],\n \"auto_route\": true,\n \"strict_route\": true\n },\n {\n \"type\": \"mixed\",\n \"listen\": \"::\",\n \"listen_port\": 7890\n }\n ],\n \"outbounds\": [\n {\n \"tag\": \"节点选择\",\n \"type\": \"selector\",\n \"outbounds\": [{{ template \"AllNodeNames\" . }}, \"直连\"]\n },\n {\n \"tag\": \"Github\",\n \"type\": \"selector\",\n \"outbounds\": [\n \"节点选择\",\n \"直连\",\n {{ template \"AllNodeNames\" . }}\n ]\n },\n {\n \"tag\": \"Google\",\n \"type\": \"selector\",\n \"outbounds\": [\n \"节点选择\",\n \"直连\",\n {{ template \"AllNodeNames\" . }}\n ]\n },\n {\n \"tag\": \"Microsoft\",\n \"type\": \"selector\",\n \"outbounds\": [\n \"节点选择\",\n \"直连\",\n {{ template \"AllNodeNames\" . }}\n ]\n },\n {\n \"tag\": \"OpenAI\",\n \"type\": \"selector\",\n \"outbounds\": [\n \"节点选择\",\n \"直连\",\n {{ template \"AllNodeNames\" . }}\n ]\n },\n {\n \"tag\": \"Telegram\",\n \"type\": \"selector\",\n \"outbounds\": [\n \"节点选择\",\n \"直连\",\n {{ template \"AllNodeNames\" . }}\n ]\n },\n {\n \"tag\": \"Twitter\",\n \"type\": \"selector\",\n \"outbounds\": [\n \"节点选择\",\n \"直连\",\n {{ template \"AllNodeNames\" . }}\n ]\n },\n {\n \"tag\": \"Youtube\",\n \"type\": \"selector\",\n \"outbounds\": [\n \"节点选择\",\n \"直连\",\n {{ template \"AllNodeNames\" . }}\n ]\n },\n {\n \"tag\": \"国内\",\n \"type\": \"selector\",\n \"outbounds\": [\n \"直连\",\n \"节点选择\",\n {{ template \"AllNodeNames\" . }}\n ]\n },\n {{- range $i, $proxy := $supportedProxies }}\n {{ if $i }},{{ end }}\n {{ template \"NodeOutbound\" (dict \"proxy\" $proxy \"UserInfo\" $.UserInfo) }}\n {{- end }}\n {{- if gt (len $supportedProxies) 0 }},{{ end }}\n {\n \"tag\": \"直连\",\n \"type\": \"direct\"\n }\n ],\n \"route\": {\n \"default_domain_resolver\": {\n \"server\": \"ali\"\n },\n \"auto_detect_interface\": true,\n \"rules\": [\n {\n \"action\": \"sniff\"\n },\n {\n \"protocol\": \"dns\",\n \"action\": \"hijack-dns\"\n },\n {\n \"ip_is_private\": true,\n \"outbound\": \"直连\"\n },\n {\n \"rule_set\": \"anti-ad\",\n \"clash_mode\": \"Rule\",\n \"action\": \"reject\"\n },\n {\n \"clash_mode\": \"Direct\",\n \"outbound\": \"直连\"\n },\n {\n \"clash_mode\": \"Global\",\n \"outbound\": \"节点选择\"\n },\n {\n \"rule_set\": \"geosite-github\",\n \"outbound\": \"Github\"\n },\n {\n \"rule_set\": [\n \"geoip-google\",\n \"geosite-google\"\n ],\n \"outbound\": \"Google\"\n },\n {\n \"rule_set\": \"geosite-microsoft\",\n \"outbound\": \"Microsoft\"\n },\n {\n \"rule_set\": \"geosite-openai\",\n \"outbound\": \"OpenAI\"\n },\n {\n \"rule_set\": [\n \"geoip-telegram\",\n \"geosite-telegram\"\n ],\n \"outbound\": \"Telegram\"\n },\n {\n \"rule_set\": [\n \"geoip-twitter\",\n \"geosite-twitter\"\n ],\n \"outbound\": \"Twitter\"\n },\n {\n \"rule_set\": \"geosite-youtube\",\n \"outbound\": \"Youtube\"\n },\n {\n \"rule_set\": [\n \"geoip-cn\",\n \"geosite-cn\"\n ],\n \"outbound\": \"国内\"\n }\n ],\n \"rule_set\": [\n {\n \"tag\": \"anti-ad\",\n \"type\": \"remote\",\n \"format\": \"binary\",\n \"url\": \"https://anti-ad.net/anti-ad-sing-box.srs\",\n \"download_detour\": \"直连\"\n },\n {\n \"tag\": \"geosite-github\",\n \"type\": \"remote\",\n \"format\": \"binary\",\n \"url\": \"https://cdn.jsdmirror.com/gh/perfect-panel/rules/geo/geosite/github.srs\",\n \"download_detour\": \"直连\"\n },\n {\n \"tag\": \"geoip-google\",\n \"type\": \"remote\",\n \"format\": \"binary\",\n \"url\": \"https://cdn.jsdmirror.com/gh/perfect-panel/rules/geo/geoip/google.srs\",\n \"download_detour\": \"直连\"\n },\n {\n \"tag\": \"geosite-google\",\n \"type\": \"remote\",\n \"format\": \"binary\",\n \"url\": \"https://cdn.jsdmirror.com/gh/perfect-panel/rules/geo/geosite/google.srs\",\n \"download_detour\": \"直连\"\n },\n {\n \"tag\": \"geosite-microsoft\",\n \"type\": \"remote\",\n \"format\": \"binary\",\n \"url\": \"https://cdn.jsdmirror.com/gh/perfect-panel/rules/geo/geosite/microsoft.srs\",\n \"download_detour\": \"直连\"\n },\n {\n \"tag\": \"geosite-openai\",\n \"type\": \"remote\",\n \"format\": \"binary\",\n \"url\": \"https://cdn.jsdmirror.com/gh/perfect-panel/rules/geo/geosite/openai.srs\",\n \"download_detour\": \"直连\"\n },\n {\n \"tag\": \"geoip-telegram\",\n \"type\": \"remote\",\n \"format\": \"binary\",\n \"url\": \"https://cdn.jsdmirror.com/gh/perfect-panel/rules/geo/geoip/telegram.srs\",\n \"download_detour\": \"直连\"\n },\n {\n \"tag\": \"geosite-telegram\",\n \"type\": \"remote\",\n \"format\": \"binary\",\n \"url\": \"https://cdn.jsdmirror.com/gh/perfect-panel/rules/geo/geosite/telegram.srs\",\n \"download_detour\": \"直连\"\n },\n {\n \"tag\": \"geoip-twitter\",\n \"type\": \"remote\",\n \"format\": \"binary\",\n \"url\": \"https://cdn.jsdmirror.com/gh/perfect-panel/rules/geo/geoip/twitter.srs\",\n \"download_detour\": \"直连\"\n },\n {\n \"tag\": \"geosite-twitter\",\n \"type\": \"remote\",\n \"format\": \"binary\",\n \"url\": \"https://cdn.jsdmirror.com/gh/perfect-panel/rules/geo/geosite/twitter.srs\",\n \"download_detour\": \"直连\"\n },\n {\n \"tag\": \"geosite-youtube\",\n \"type\": \"remote\",\n \"format\": \"binary\",\n \"url\": \"https://cdn.jsdmirror.com/gh/perfect-panel/rules/geo/geosite/youtube.srs\",\n \"download_detour\": \"直连\"\n },\n {\n \"tag\": \"geosite-cn\",\n \"type\": \"remote\",\n \"format\": \"binary\",\n \"url\": \"https://cdn.jsdmirror.com/gh/perfect-panel/rules/geo/geosite/cn.srs\",\n \"download_detour\": \"直连\"\n },\n {\n \"tag\": \"geoip-cn\",\n \"type\": \"remote\",\n \"format\": \"binary\",\n \"url\": \"https://cdn.jsdmirror.com/gh/perfect-panel/rules/geo/geoip/cn.srs\",\n \"download_detour\": \"直连\"\n }\n ]\n }\n}', 'json', '{}', '2025-08-12 23:30:10.016', '2025-08-15 22:01:10.801') ON CONFLICT DO NOTHING; +INSERT INTO "subscribe_application" ("id", "name", "icon", "description", "scheme", "user_agent", "is_default", "subscribe_template", "output_format", "download_link", "created_at", "updated_at") VALUES +(5, 'Surge', '', '', 'surge:///install-config?url=${encodeURIComponent(url)}', 'Surge', false, '{{- $GiB := 1073741824.0 -}}\n{{- $used := printf \"%.2f\" (divf (add (.UserInfo.Download | default 0 | float64) (.UserInfo.Upload | default 0 | float64)) $GiB) -}}\n{{- $traffic := (.UserInfo.Traffic | default 0 | float64) -}}\n{{- $total := printf \"%.2f\" (divf $traffic $GiB) -}}\n\n{{- $ExpiredAt := \"\" -}}\n{{- $expStr := printf \"%v\" .UserInfo.ExpiredAt -}}\n{{- if regexMatch `^[0-9]+$` $expStr -}}\n {{- $ts := $expStr | float64 -}}\n {{- $sec := ternary (divf $ts 1000.0) $ts (ge (len $expStr) 13) -}}\n {{- $ExpiredAt = (date \"2006-01-02 15:04:05\" (unixEpoch ($sec | int64))) -}}\n{{- else -}}\n {{- $ExpiredAt = $expStr -}}\n{{- end -}}\n\n{{- $sortFields := list \"Sort\" \"Port\" \"Name\" -}}\n{{- $sortConfig := dict \"Sort\" \"asc\" \"Port\" \"asc\" \"Name\" \"asc\" -}}\n{{- $byKey := dict -}}\n{{- range $p := .Proxies -}}\n {{- $keyParts := list -}}\n {{- range $field := $sortFields -}}\n {{- $order := index $sortConfig $field -}}\n {{- $val := default \"\" (printf \"%v\" (index $p $field)) -}}\n {{- if or (eq $field \"Sort\") (eq $field \"Port\") -}}\n {{- $val = printf \"%08d\" (int (default 0 (index $p $field))) -}}\n {{- end -}}\n {{- if eq $order \"desc\" -}}\n {{- $val = printf \"~%s\" $val -}}\n {{- end -}}\n {{- $keyParts = append $keyParts $val -}}\n {{- end -}}\n {{- $_ := set $byKey (join \"|\" $keyParts) $p -}}\n{{- end -}}\n{{- $sorted := list -}}\n{{- range $k := sortAlpha (keys $byKey) -}}\n {{- $sorted = append $sorted (index $byKey $k) -}}\n{{- end -}}\n\n{{- $supportSet := dict \"shadowsocks\" true \"vmess\" true \"vless\" true \"trojan\" true \"hysteria2\" true \"hysteria\" true \"tuic\" true \"wireguard\" true -}}\n{{- $supportedProxies := list -}}\n{{- range $proxy := $sorted -}}\n {{- if hasKey $supportSet $proxy.Type -}}\n {{- $supportedProxies = append $supportedProxies $proxy -}}\n {{- end -}}\n{{- end -}}\n\n{{- $proxyNames := \"\" -}}\n{{- range $proxy := $supportedProxies -}}\n {{- if eq $proxyNames \"\" -}}\n {{- $proxyNames = $proxy.Name -}}\n {{- else -}}\n {{- $proxyNames = printf \"%s, %s\" $proxyNames $proxy.Name -}}\n {{- end -}}\n{{- end -}}\n\n# {{ .SiteName }}-{{ .SubscribeName }}\n# Traffic: {{ $used }} GiB/{{ $total }} GiB | Expires: {{ $ExpiredAt }}\n# Generated at: {{ now | date \"2006-01-02 15:04:05\" }}\n\n#!MANAGED-CONFIG {{ .UserInfo.SubscribeURL }} interval=86400 strict=true\n\n[General]\n# 日志级别\nloglevel = notify\n\n# 外部控制器访问\nexternal-controller-access = perlnk@0.0.0.0:6170\n\n# 网络设置\nexclude-simple-hostnames = true\nshow-error-page-for-reject = true\nudp-priority = true\nudp-policy-not-supported-behaviour = reject\nipv6 = true\nipv6-vif = auto\n\n# 连接测试\nproxy-test-url = http://www.gstatic.com/generate_204\ninternet-test-url = http://www.gstatic.com/generate_204\ntest-timeout = 5\n\n# DNS 设置\ndns-server = system, 119.29.29.29, 223.5.5.5\nencrypted-dns-server = https://dns.alidns.com/dns-query\nhijack-dns = 8.8.8.8:53, 8.8.4.4:53, 1.1.1.1:53, 1.0.0.1:53\n\n# 跳过代理\nskip-proxy = 192.168.0.0/16, 10.0.0.0/8, 172.16.0.0/12, 127.0.0.0/8, localhost, *.local\n\n# 真实 IP\nalways-real-ip = *.lan, lens.l.google.com, *.srv.nintendo.net, *.stun.playstation.net, *.xboxlive.com, xbox.*.*.microsoft.com, *.msftncsi.com, *.msftconnecttest.com\n\n# Surge Mac 参数\nhttp-listen = 0.0.0.0:6088\nsocks5-listen = 0.0.0.0:6089\n\n# Surge iOS 参数(WiFi 共享)\nallow-wifi-access = true\nallow-hotspot-access = true\nwifi-access-http-port = 6088\nwifi-access-socks5-port = 6089\n\n[Panel]\nSubscribeInfo = title={{ .SiteName }} - {{ .SubscribeName }}, content=已用流量: {{ $used }} GiB/{{ $total }} GiB \\n到期时间: {{ $ExpiredAt}}, style=info\n\n[Proxy]\n{{- range $proxy := $supportedProxies }}\n {{- $common := \"udp-relay=true, tfo=true\" -}}\n\n {{- $server := $proxy.Server -}}\n {{- if and (contains $server \":\") (not (hasPrefix \"[\" $server)) -}}\n {{- $server = printf \"[%s]\" $server -}}\n {{- end -}}\n\n {{- $password := $.UserInfo.Password -}}\n {{- if and (eq $proxy.Type \"shadowsocks\") (ne (default \"\" $proxy.ServerKey) \"\") -}}\n {{- $method := $proxy.Method -}}\n {{- if or (hasPrefix \"2022-blake3-\" $method) (eq $method \"2022-blake3-aes-128-gcm\") (eq $method \"2022-blake3-aes-256-gcm\") -}}\n {{- $userKeyLen := ternary 16 32 (hasSuffix \"128-gcm\" $method) -}}\n {{- $pwdStr := printf \"%s\" $password -}}\n {{- $userKey := ternary $pwdStr (trunc $userKeyLen $pwdStr) (le (len $pwdStr) $userKeyLen) -}}\n {{- $serverB64 := b64enc $proxy.ServerKey -}}\n {{- $userB64 := b64enc $userKey -}}\n {{- $password = printf \"%s:%s\" $serverB64 $userB64 -}}\n {{- end -}}\n {{- end -}}\n\n {{- $SkipVerify := $proxy.AllowInsecure -}}\n\n {{- if eq $proxy.Type \"shadowsocks\" }}\n{{ $proxy.Name }} = ss, {{ $server }}, {{ $proxy.Port }}, encrypt-method={{ default \"aes-128-gcm\" $proxy.Method }}, password={{ $password }}{{- if ne (default \"\" $proxy.Obfs) \"\" }}, obfs={{ $proxy.Obfs }}{{- if ne (default \"\" $proxy.ObfsHost) \"\" }}, obfs-host={{ $proxy.ObfsHost }}{{- end }}{{- end }}, {{ $common }}\n {{- else if eq $proxy.Type \"vmess\" }}\n{{ $proxy.Name }} = vmess, {{ $server }}, {{ $proxy.Port }}, username={{ $password }}{{- if or (eq $proxy.Transport \"ws\") (eq $proxy.Transport \"websocket\") }}, ws=true{{- if ne (default \"\" $proxy.Path) \"\" }}, ws-path={{ $proxy.Path }}{{- end }}{{- if ne (default \"\" $proxy.Host) \"\" }}, ws-headers=\"Host:{{ $proxy.Host }}\"{{- end }}{{- else if eq $proxy.Transport \"grpc\" }}, grpc=true{{- if ne (default \"\" $proxy.ServiceName) \"\" }}, grpc-service-name={{ $proxy.ServiceName }}{{- end }}{{- end }}{{- if or (eq $proxy.Security \"tls\") (eq $proxy.Security \"reality\") }}, tls=true{{- end }}{{- if ne (default \"\" $proxy.SNI) \"\" }}, sni={{ $proxy.SNI }}{{- end }}{{- if $proxy.AllowInsecure }}, skip-cert-verify=true{{- end }}{{- if ne (default \"\" $proxy.Fingerprint) \"\" }}, fingerprint={{ $proxy.Fingerprint }}{{- end }}, {{ $common }}\n {{- else if eq $proxy.Type \"vless\" }}\n{{ $proxy.Name }} = vless, {{ $server }}, {{ $proxy.Port }}, username={{ $password }}{{- if or (eq $proxy.Transport \"ws\") (eq $proxy.Transport \"websocket\") }}, ws=true{{- if ne (default \"\" $proxy.Path) \"\" }}, ws-path={{ $proxy.Path }}{{- end }}{{- if ne (default \"\" $proxy.Host) \"\" }}, ws-headers=\"Host:{{ $proxy.Host }}\"{{- end }}{{- else if eq $proxy.Transport \"grpc\" }}, grpc=true{{- if ne (default \"\" $proxy.ServiceName) \"\" }}, grpc-service-name={{ $proxy.ServiceName }}{{- end }}{{- end }}{{- if ne (default \"\" $proxy.SNI) \"\" }}, sni={{ $proxy.SNI }}{{- end }}{{- if $proxy.AllowInsecure }}, skip-cert-verify=true{{- end }}{{- if ne (default \"\" $proxy.Flow) \"none\" }}, flow={{ $proxy.Flow }}{{- end }}, {{ $common }}\n {{- else if eq $proxy.Type \"trojan\" }}\n{{ $proxy.Name }} = trojan, {{ $server }}, {{ $proxy.Port }}, password={{ $password }}{{- if or (eq $proxy.Transport \"ws\") (eq $proxy.Transport \"websocket\") }}, ws=true{{- if ne (default \"\" $proxy.Path) \"\" }}, ws-path={{ $proxy.Path }}{{- end }}{{- if ne (default \"\" $proxy.Host) \"\" }}, ws-headers=\"Host:{{ $proxy.Host }}\"{{- end }}{{- else if eq $proxy.Transport \"grpc\" }}, grpc=true{{- if ne (default \"\" $proxy.ServiceName) \"\" }}, grpc-service-name={{ $proxy.ServiceName }}{{- end }}{{- end }}{{- if ne (default \"\" $proxy.SNI) \"\" }}, sni={{ $proxy.SNI }}{{- end }}{{- if $proxy.AllowInsecure }}, skip-cert-verify=true{{- end }}{{- if ne (default \"\" $proxy.Fingerprint) \"\" }}, fingerprint={{ $proxy.Fingerprint }}{{- end }}, {{ $common }}\n {{- else if or (eq $proxy.Type \"hysteria2\") (eq $proxy.Type \"hysteria\") }}\n{{ $proxy.Name }} = hysteria2, {{ $server }}, {{ $proxy.Port }}, password={{ $password }}{{- if ne (default \"\" $proxy.SNI) \"\" }}, sni={{ $proxy.SNI }}{{- end }}{{- if $proxy.AllowInsecure }}, skip-cert-verify=true{{- end }}{{- if ne (default \"\" $proxy.ObfsPassword) \"\" }}, obfs=salamander, obfs-password={{ $proxy.ObfsPassword }}{{- end }}{{- if ne (default \"\" $proxy.HopPorts) \"\" }}, ports={{ $proxy.HopPorts }}{{- end }}{{- if ne (default 0 $proxy.HopInterval) 0 }}, hop-interval={{ $proxy.HopInterval }}{{- end }}, {{ $common }}\n {{- else if eq $proxy.Type \"tuic\" }}\n{{ $proxy.Name }} = tuic, {{ $server }}, {{ $proxy.Port }}, uuid={{ default \"\" $proxy.ServerKey }}, password={{ $password }}{{- if ne (default \"\" $proxy.SNI) \"\" }}, sni={{ $proxy.SNI }}{{- end }}{{- if $proxy.AllowInsecure }}, skip-cert-verify=true{{- end }}{{- if $proxy.DisableSNI }}, disable-sni=true{{- end }}{{- if $proxy.ReduceRtt }}, reduce-rtt=true{{- end }}{{- if ne (default \"\" $proxy.UDPRelayMode) \"\" }}, udp-relay-mode={{ $proxy.UDPRelayMode }}{{- end }}{{- if ne (default \"\" $proxy.CongestionController) \"\" }}, congestion-controller={{ $proxy.CongestionController }}{{- end }}, {{ $common }}\n {{- else if eq $proxy.Type \"wireguard\" }}\n{{ $proxy.Name }} = wireguard, {{ $server }}, {{ $proxy.Port }}, private-key={{ default \"\" $proxy.ServerKey }}, public-key={{ default \"\" $proxy.RealityPublicKey }}{{- if ne (default \"\" $proxy.Path) \"\" }}, preshared-key={{ $proxy.Path }}{{- end }}{{- if ne (default \"\" $proxy.RealityServerAddr) \"\" }}, ip={{ $proxy.RealityServerAddr }}{{- end }}{{- if ne (default 0 $proxy.RealityServerPort) 0 }}, ipv6={{ $proxy.RealityServerPort }}{{- end }}, {{ $common }}\n {{- else if eq $proxy.Type \"anytls\" }}\n{{ $proxy.Name }} = anytls, {{ $server }}, {{ $proxy.Port }}, password={{ $password }}{{- if ne (default \"\" $proxy.SNI) \"\" }}, sni={{ $proxy.SNI }}{{- end }}{{- if $proxy.AllowInsecure }}, skip-cert-verify=true{{- end }}, {{ $common }}\n {{- else }}\n{{ $proxy.Name }} = {{ $proxy.Type }}, {{ $server }}, {{ $proxy.Port }}, {{ $common }}\n {{- end }}\n{{- end }}\n\n[Proxy Group]\n# 主要策略组\n🚀 Proxy = select, 🌏 Auto, 🎯 Direct, include-other-group=🇺🇳 Nodes\n🍎 Apple = select, 🚀 Proxy, 🎯 Direct, include-other-group=🇺🇳 Nodes\n🔍 Google = select, 🚀 Proxy, 🎯 Direct, include-other-group=🇺🇳 Nodes\n🪟 Microsoft = select, 🚀 Proxy, 🎯 Direct, include-other-group=🇺🇳 Nodes\n📺 GlobalMedia = select, 🚀 Proxy, 🎯 Direct, include-other-group=🇺🇳 Nodes\n🤖 AI = select, 🚀 Proxy, 🎯 Direct, include-other-group=🇺🇳 Nodes\n🪙 Crypto = select, 🚀 Proxy, 🎯 Direct, include-other-group=🇺🇳 Nodes\n🎮 Game = select, 🚀 Proxy, 🎯 Direct, include-other-group=🇺🇳 Nodes\n📟 Telegram = select, 🚀 Proxy, 🎯 Direct, include-other-group=🇺🇳 Nodes\n🇨🇳 China = select, 🎯 Direct, 🚀 Proxy, include-other-group=🇺🇳 Nodes\n🐠 Final = select, 🚀 Proxy, 🎯 Direct, include-other-group=🇺🇳 Nodes\n\n# 智能选择和节点组\n🌏 Auto = smart, include-other-group=🇺🇳 Nodes\n🎯 Direct = select, DIRECT, hidden=1\n🇺🇳 Nodes = select, {{ $proxyNames }}, hidden=1\n\n[Rule]\nRULE-SET, https://cdn.jsdmirror.com/gh/perfect-panel/rules/rule/Surge/Apple/Apple_All.list, 🍎 Apple\nRULE-SET, https://cdn.jsdmirror.com/gh/perfect-panel/rules/rule/Surge/Google/Google.list, 🔍 Google\nRULE-SET, https://cdn.jsdmirror.com/gh/perfect-panel/rules/rule/Surge/GitHub/GitHub.list, 🪟 Microsoft\nRULE-SET, https://cdn.jsdmirror.com/gh/perfect-panel/rules/rule/Surge/Microsoft/Microsoft.list, 🪟 Microsoft\nRULE-SET, https://cdn.jsdmirror.com/gh/perfect-panel/rules/rule/Surge/HBO/HBO.list, 📺 GlobalMedia\nRULE-SET, https://cdn.jsdmirror.com/gh/perfect-panel/rules/rule/Surge/Disney/Disney.list, 📺 GlobalMedia\nRULE-SET, https://cdn.jsdmirror.com/gh/perfect-panel/rules/rule/Surge/TikTok/TikTok.list, 📺 GlobalMedia\nRULE-SET, https://cdn.jsdmirror.com/gh/perfect-panel/rules/rule/Surge/Netflix/Netflix.list, 📺 GlobalMedia\nRULE-SET, https://cdn.jsdmirror.com/gh/perfect-panel/rules/rule/Surge/GlobalMedia/GlobalMedia_All_No_Resolve.list, 📺 GlobalMedia\nRULE-SET, https://cdn.jsdmirror.com/gh/perfect-panel/rules/rule/Surge/Telegram/Telegram.list, 📟 Telegram\nRULE-SET, https://cdn.jsdmirror.com/gh/perfect-panel/rules/rule/Surge/OpenAI/OpenAI.list, 🤖 AI\nRULE-SET, https://cdn.jsdmirror.com/gh/perfect-panel/rules/rule/Surge/Gemini/Gemini.list, 🤖 AI\nRULE-SET, https://cdn.jsdmirror.com/gh/perfect-panel/rules/rule/Surge/Copilot/Copilot.list, 🤖 AI\nRULE-SET, https://cdn.jsdmirror.com/gh/perfect-panel/rules/rule/Surge/Claude/Claude.list, 🤖 AI\nRULE-SET, https://cdn.jsdmirror.com/gh/perfect-panel/rules/rule/Surge/Crypto/Crypto.list, 🪙 Crypto\nRULE-SET, https://cdn.jsdmirror.com/gh/perfect-panel/rules/rule/Surge/Cryptocurrency/Cryptocurrency.list, 🪙 Crypto\nRULE-SET, https://cdn.jsdmirror.com/gh/perfect-panel/rules/rule/Surge/Game/Game.list, 🎮 Game\nRULE-SET, https://cdn.jsdmirror.com/gh/perfect-panel/rules/rule/Surge/Global/Global_All_No_Resolve.list, 🚀 Proxy\nRULE-SET, https://cdn.jsdmirror.com/gh/perfect-panel/rules/rule/Surge/ChinaMax/ChinaMax_All_No_Resolve.list, 🇨🇳 China\nRULE-SET, https://cdn.jsdmirror.com/gh/perfect-panel/rules/rule/Surge/Lan/Lan.list, 🎯 Direct\n\nGEOIP, CN, 🇨🇳 China\nFINAL, 🐠 Final, dns-failed\n\n[URL Rewrite]\n^https?:\\/\\/(www.)?g\\.cn https://www.google.com 302\n^https?:\\/\\/(www.)?google\\.cn https://www.google.com 302\n', 'conf', '{}', '2025-08-13 00:12:37.809', '2025-08-15 22:00:50.528') ON CONFLICT DO NOTHING; +COMMIT; +SELECT setval(pg_get_serial_sequence('"subscribe_application"', 'id'), COALESCE((SELECT MAX("id") FROM "subscribe_application"), 1), true); + +-- migrate:down +DROP TABLE IF EXISTS "subscribe_application"; + diff --git a/migrations/postgres/02102_subscribe_config.sql b/migrations/postgres/02102_subscribe_config.sql new file mode 100644 index 00000000..b1028a72 --- /dev/null +++ b/migrations/postgres/02102_subscribe_config.sql @@ -0,0 +1,9 @@ +-- migrate:up +INSERT INTO "system" ("id", "category", "key", "value", "type", "desc", "created_at", "updated_at") +VALUES + (42, 'subscribe', 'UserAgentLimit', 'false', 'bool', 'User Agent Limit', '2025-04-22 14:25:16.637', '2025-04-22 14:25:16.637'), + (43, 'subscribe', 'UserAgentList', '', 'string', 'User Agent List', '2025-04-22 14:25:16.637','2025-04-22 14:25:16.637') ON CONFLICT DO NOTHING; +SELECT setval(pg_get_serial_sequence('"system"', 'id'), COALESCE((SELECT MAX("id") FROM "system"), 1), true); + +-- migrate:down + diff --git a/migrations/postgres/02103_delete_application.sql b/migrations/postgres/02103_delete_application.sql new file mode 100644 index 00000000..16c67f9e --- /dev/null +++ b/migrations/postgres/02103_delete_application.sql @@ -0,0 +1,7 @@ +-- migrate:up +DROP TABLE IF EXISTS "application"; +DROP TABLE IF EXISTS "application_version"; +DROP TABLE IF EXISTS "application_config"; + +-- migrate:down + diff --git a/migrations/postgres/02104_system_log.sql b/migrations/postgres/02104_system_log.sql new file mode 100644 index 00000000..bf351d7a --- /dev/null +++ b/migrations/postgres/02104_system_log.sql @@ -0,0 +1,108 @@ +-- migrate:up +DROP TABLE IF EXISTS "user_balance_log"; +DROP TABLE IF EXISTS "user_commission_log"; +DROP TABLE IF EXISTS "user_gift_amount_log"; +DROP TABLE IF EXISTS "user_login_log"; +DROP TABLE IF EXISTS "user_reset_subscribe_log"; +DROP TABLE IF EXISTS "user_subscribe_log"; +DROP TABLE IF EXISTS "message_log"; +DROP TABLE IF EXISTS "system_logs"; +CREATE TABLE "system_logs" ( + "id" BIGINT GENERATED BY DEFAULT AS IDENTITY NOT NULL, + "type" SMALLINT NOT NULL DEFAULT '0', + "date" varchar(20) DEFAULT NULL, + "object_id" bigint NOT NULL DEFAULT '0', + "content" text NOT NULL, + "created_at" TIMESTAMP(3) DEFAULT NULL, + PRIMARY KEY ("id") +); +CREATE INDEX IF NOT EXISTS "system_logs_idx_type" ON "system_logs" ("type"); +CREATE INDEX IF NOT EXISTS "system_logs_idx_object_id" ON "system_logs" ("object_id"); + +-- migrate:down +CREATE TABLE IF NOT EXISTS "user_balance_log" +( + "id" BIGINT GENERATED BY DEFAULT AS IDENTITY NOT NULL, + "user_id" bigint NOT NULL, + "amount" bigint NOT NULL, + "type" SMALLINT NOT NULL, + "order_id" bigint DEFAULT NULL, + "balance" bigint NOT NULL, + "created_at" TIMESTAMP(3) DEFAULT NULL, + PRIMARY KEY ("id") +); +CREATE INDEX IF NOT EXISTS "user_balance_log_idx_user_id" ON "user_balance_log" ("user_id"); +CREATE TABLE IF NOT EXISTS "user_commission_log" +( + "id" BIGINT GENERATED BY DEFAULT AS IDENTITY NOT NULL, + "user_id" bigint NOT NULL, + "order_no" varchar(191) DEFAULT NULL, + "amount" bigint NOT NULL, + "created_at" TIMESTAMP(3) DEFAULT NULL, + PRIMARY KEY ("id") +); +CREATE INDEX IF NOT EXISTS "user_commission_log_idx_user_id" ON "user_commission_log" ("user_id"); +CREATE TABLE IF NOT EXISTS "user_gift_amount_log" +( + "id" BIGINT GENERATED BY DEFAULT AS IDENTITY NOT NULL, + "user_id" bigint NOT NULL, + "user_subscribe_id" bigint DEFAULT NULL, + "order_no" varchar(191) DEFAULT NULL, + "type" SMALLINT NOT NULL, + "amount" bigint NOT NULL, + "balance" bigint NOT NULL, + "remark" varchar(255) DEFAULT '', + "created_at" TIMESTAMP(3) DEFAULT NULL, + PRIMARY KEY ("id") +); +CREATE INDEX IF NOT EXISTS "user_gift_amount_log_idx_user_id" ON "user_gift_amount_log" ("user_id"); +CREATE TABLE IF NOT EXISTS "user_login_log" +( + "id" BIGINT GENERATED BY DEFAULT AS IDENTITY NOT NULL, + "user_id" bigint NOT NULL, + "login_ip" varchar(255) NOT NULL, + "user_agent" text NOT NULL, + "success" BOOLEAN NOT NULL DEFAULT false, + "created_at" TIMESTAMP(3) DEFAULT NULL, + PRIMARY KEY ("id") +); +CREATE INDEX IF NOT EXISTS "user_login_log_idx_user_id" ON "user_login_log" ("user_id"); +CREATE TABLE IF NOT EXISTS "user_reset_subscribe_log" +( + "id" BIGINT GENERATED BY DEFAULT AS IDENTITY NOT NULL PRIMARY KEY, + "user_id" BIGINT NOT NULL, + "type" SMALLINT NOT NULL, + "order_no" VARCHAR(255) DEFAULT NULL, + "user_subscribe_id" BIGINT NOT NULL, + "created_at" TIMESTAMP NOT NULL DEFAULT CURRENT_TIMESTAMP +); +CREATE INDEX IF NOT EXISTS "user_reset_subscribe_log_idx_user_id" ON "user_reset_subscribe_log" ("user_id"); +CREATE INDEX IF NOT EXISTS "user_reset_subscribe_log_idx_user_subscribe_id" ON "user_reset_subscribe_log" ("user_subscribe_id"); +CREATE TABLE IF NOT EXISTS "user_subscribe_log" +( + "id" BIGINT GENERATED BY DEFAULT AS IDENTITY NOT NULL, + "user_id" bigint NOT NULL, + "user_subscribe_id" bigint NOT NULL, + "token" varchar(255) NOT NULL, + "ip" varchar(255) NOT NULL, + "user_agent" text NOT NULL, + "created_at" TIMESTAMP(3) DEFAULT NULL, + PRIMARY KEY ("id") +); +CREATE INDEX IF NOT EXISTS "user_subscribe_log_idx_user_id" ON "user_subscribe_log" ("user_id"); +CREATE INDEX IF NOT EXISTS "user_subscribe_log_idx_user_subscribe_id" ON "user_subscribe_log" ("user_subscribe_id"); +CREATE TABLE IF NOT EXISTS "message_log" +( + "id" BIGINT GENERATED BY DEFAULT AS IDENTITY NOT NULL, + "type" varchar(50) NOT NULL DEFAULT 'email', + "platform" varchar(50) NOT NULL DEFAULT 'smtp', + "to" text NOT NULL, + "subject" varchar(255) NOT NULL DEFAULT '', + "content" text, + "status" SMALLINT NOT NULL DEFAULT '0', + "created_at" TIMESTAMP(3) DEFAULT NULL, + "updated_at" TIMESTAMP(3) DEFAULT NULL, + PRIMARY KEY ("id") +); +DROP TABLE IF EXISTS "system_logs"; + diff --git a/migrations/postgres/02105_node.sql b/migrations/postgres/02105_node.sql new file mode 100644 index 00000000..d5e28496 --- /dev/null +++ b/migrations/postgres/02105_node.sql @@ -0,0 +1,33 @@ +-- migrate:up +CREATE TABLE IF NOT EXISTS "servers" ( + "id" BIGINT GENERATED BY DEFAULT AS IDENTITY NOT NULL, + "name" varchar(100) NOT NULL DEFAULT '', + "country" varchar(128) NOT NULL DEFAULT '', + "city" varchar(128) NOT NULL DEFAULT '', + "ratio" decimal(4,2) NOT NULL DEFAULT '0.00', + "address" varchar(100) NOT NULL DEFAULT '', + "sort" bigint NOT NULL DEFAULT '0', + "protocols" text, + "last_reported_at" TIMESTAMP(3) DEFAULT NULL, + "created_at" TIMESTAMP(3) DEFAULT NULL, + "updated_at" TIMESTAMP(3) DEFAULT NULL, + PRIMARY KEY ("id") +); +CREATE TABLE IF NOT EXISTS "nodes" ( + "id" BIGINT GENERATED BY DEFAULT AS IDENTITY NOT NULL, + "name" varchar(100) NOT NULL DEFAULT '', + "tags" varchar(255) NOT NULL DEFAULT '', + "port" INTEGER NOT NULL DEFAULT '0', + "address" varchar(255) NOT NULL DEFAULT '', + "server_id" bigint NOT NULL DEFAULT '0', + "protocol" varchar(100) NOT NULL DEFAULT '', + "enabled" BOOLEAN NOT NULL DEFAULT true, + "created_at" TIMESTAMP(3) DEFAULT NULL, + "updated_at" TIMESTAMP(3) DEFAULT NULL, + PRIMARY KEY ("id") +); + +-- migrate:down +DROP TABLE IF EXISTS "nodes"; +DROP TABLE IF EXISTS "servers"; + diff --git a/migrations/postgres/02106_subscribe.sql b/migrations/postgres/02106_subscribe.sql new file mode 100644 index 00000000..529ec5df --- /dev/null +++ b/migrations/postgres/02106_subscribe.sql @@ -0,0 +1,15 @@ +-- migrate:up +ALTER TABLE "subscribe" +ADD COLUMN "nodes" VARCHAR(255) NOT NULL DEFAULT '' , +ADD COLUMN "node_tags" VARCHAR(255) NOT NULL DEFAULT '' , +DROP COLUMN "server", +DROP COLUMN "server_group"; +DROP TABLE IF EXISTS "server_rule_group"; + +-- migrate:down +ALTER TABLE "subscribe" +DROP COLUMN "nodes", + DROP COLUMN "node_tags", + ADD COLUMN "server" VARCHAR(255) NOT NULL DEFAULT '' , + ADD COLUMN "server_group" VARCHAR(255) NOT NULL DEFAULT ''; + diff --git a/migrations/postgres/02107_log_setting.sql b/migrations/postgres/02107_log_setting.sql new file mode 100644 index 00000000..2a722ba2 --- /dev/null +++ b/migrations/postgres/02107_log_setting.sql @@ -0,0 +1,8 @@ +-- migrate:up +INSERT INTO "system" ("category", "key", "value", "type", "desc", "created_at", "updated_at") +VALUES + ('log', 'AutoClear', 'true', 'bool', 'Auto Clear Log', '2025-04-22 14:25:16.637', '2025-04-22 14:25:16.637'), + ('log', 'ClearDays', '7', 'int', 'Clear Days', '2025-04-22 14:25:16.637','2025-04-22 14:25:16.637') ON CONFLICT DO NOTHING; + +-- migrate:down + diff --git a/migrations/postgres/02108_user_referral.sql b/migrations/postgres/02108_user_referral.sql new file mode 100644 index 00000000..3a5f8f15 --- /dev/null +++ b/migrations/postgres/02108_user_referral.sql @@ -0,0 +1,10 @@ +-- migrate:up +ALTER TABLE "user" + ADD COLUMN "referral_percentage" SMALLINT NOT NULL DEFAULT 0, + ADD COLUMN "only_first_purchase" BOOLEAN NOT NULL DEFAULT true; + +-- migrate:down +ALTER TABLE "user" +DROP COLUMN "referral_percentage", +DROP COLUMN "only_first_purchase"; + diff --git a/migrations/postgres/02109_node_sort.sql b/migrations/postgres/02109_node_sort.sql new file mode 100644 index 00000000..015251c3 --- /dev/null +++ b/migrations/postgres/02109_node_sort.sql @@ -0,0 +1,8 @@ +-- migrate:up +ALTER TABLE "nodes" + ADD COLUMN "sort" INTEGER NOT NULL DEFAULT 0; + +-- migrate:down +ALTER TABLE "nodes" +DROP COLUMN "sort"; + diff --git a/migrations/postgres/02110_traffic_log_index.sql b/migrations/postgres/02110_traffic_log_index.sql new file mode 100644 index 00000000..3305e194 --- /dev/null +++ b/migrations/postgres/02110_traffic_log_index.sql @@ -0,0 +1,6 @@ +-- migrate:up +CREATE INDEX idx_traffic_log_time_user_sub ON traffic_log (timestamp, user_id, subscribe_id); + +-- migrate:down +DROP INDEX IF EXISTS "idx_traffic_log_time_user_sub"; + diff --git a/migrations/postgres/02111_clear_table.sql b/migrations/postgres/02111_clear_table.sql new file mode 100644 index 00000000..51344ce6 --- /dev/null +++ b/migrations/postgres/02111_clear_table.sql @@ -0,0 +1,8 @@ +-- migrate:up +DROP TABLE IF EXISTS "subscribe_type"; +DROP TABLE IF EXISTS "sms"; + +-- migrate:down +DROP TABLE IF EXISTS "subscribe_type"; +DROP TABLE IF EXISTS "sms"; + diff --git a/migrations/postgres/02112_subscribe.sql b/migrations/postgres/02112_subscribe.sql new file mode 100644 index 00000000..cc565f58 --- /dev/null +++ b/migrations/postgres/02112_subscribe.sql @@ -0,0 +1,8 @@ +-- migrate:up +ALTER TABLE "subscribe" +DROP COLUMN "group_id", +ADD COLUMN "language" VARCHAR(255) NOT NULL DEFAULT ''; +DROP TABLE IF EXISTS "subscribe_group"; + +-- migrate:down + diff --git a/migrations/postgres/02113_task.sql b/migrations/postgres/02113_task.sql new file mode 100644 index 00000000..2384e47b --- /dev/null +++ b/migrations/postgres/02113_task.sql @@ -0,0 +1,18 @@ +-- migrate:up +DROP TABLE IF EXISTS "email_task"; +CREATE TABLE "task" ( + "id" BIGINT GENERATED BY DEFAULT AS IDENTITY NOT NULL, + "type" SMALLINT NOT NULL, + "scope" text, + "content" text, + "status" SMALLINT NOT NULL DEFAULT '0', + "errors" text, + "total" BIGINT NOT NULL DEFAULT '0', + "current" BIGINT NOT NULL DEFAULT '0', + "created_at" TIMESTAMP(3) DEFAULT NULL, + "updated_at" TIMESTAMP(3) DEFAULT NULL, + PRIMARY KEY ("id") +); + +-- migrate:down + diff --git a/migrations/postgres/02114_node_config.sql b/migrations/postgres/02114_node_config.sql new file mode 100644 index 00000000..16113522 --- /dev/null +++ b/migrations/postgres/02114_node_config.sql @@ -0,0 +1,10 @@ +-- migrate:up +INSERT INTO "system" ("category", "key", "value", "type", "desc", "created_at", "updated_at") +VALUES ('server', 'TrafficReportThreshold', '0', 'int', 'Traffic report threshold', '2025-04-22 14:25:16.637','2025-04-22 14:25:16.637'), + ('server', 'IPStrategy', '', 'string', 'IP Strategy', '2025-04-22 14:25:16.637','2025-04-22 14:25:16.637'), + ('server', 'DNS', '', 'string', 'DNS', '2025-04-22 14:25:16.637','2025-04-22 14:25:16.637'), + ('server', 'Block', '', 'string', 'Block', '2025-04-22 14:25:16.637','2025-04-22 14:25:16.637'), + ('server', 'Outbound', '', 'string', 'Proxy Outbound', '2025-04-22 14:25:16.637','2025-04-22 14:25:16.637') ON CONFLICT DO NOTHING; + +-- migrate:down + diff --git a/migrations/postgres/02115_ads.sql b/migrations/postgres/02115_ads.sql new file mode 100644 index 00000000..a1087f88 --- /dev/null +++ b/migrations/postgres/02115_ads.sql @@ -0,0 +1,5 @@ +-- migrate:up +ALTER TABLE "ads" ADD COLUMN IF NOT EXISTS "description" VARCHAR(255) DEFAULT ''; + +-- migrate:down + diff --git a/migrations/postgres/02116_user_algo.sql b/migrations/postgres/02116_user_algo.sql new file mode 100644 index 00000000..63e179c5 --- /dev/null +++ b/migrations/postgres/02116_user_algo.sql @@ -0,0 +1,8 @@ +-- migrate:up +ALTER TABLE "user" ADD COLUMN IF NOT EXISTS "algo" VARCHAR(20) NOT NULL DEFAULT 'default'; +ALTER TABLE "user" ADD COLUMN IF NOT EXISTS "salt" VARCHAR(20) NOT NULL DEFAULT 'default'; + +-- migrate:down +ALTER TABLE "user" DROP COLUMN IF EXISTS "algo"; +ALTER TABLE "user" DROP COLUMN IF EXISTS "salt"; + diff --git a/migrations/postgres/02117_site_custom_data.sql b/migrations/postgres/02117_site_custom_data.sql new file mode 100644 index 00000000..f3fcca53 --- /dev/null +++ b/migrations/postgres/02117_site_custom_data.sql @@ -0,0 +1,18 @@ +-- migrate:up +INSERT INTO "system" ("category", "key", "value", "type", "desc", "created_at", "updated_at") +SELECT 'site', 'CustomData', '{ + "kr_website_id": "" +}', 'string', 'Custom Data', '2025-04-22 14:25:16.637', '2025-10-14 15:47:19.187' + WHERE NOT EXISTS ( + SELECT 1 FROM "system" WHERE "category" = 'site' AND "key" = 'CustomData' +); + +-- migrate:down +INSERT INTO "system" ("category", "key", "value", "type", "desc", "created_at", "updated_at") +SELECT 'site', 'CustomData', '{ + "kr_website_id": "" +}', 'string', 'Custom Data', '2025-04-22 14:25:16.637', '2025-10-14 15:47:19.187' + WHERE NOT EXISTS ( + SELECT 1 FROM "system" WHERE "category" = 'site' AND "key" = 'CustomData' +); + diff --git a/migrations/postgres/02118_traffic_log_idx.sql b/migrations/postgres/02118_traffic_log_idx.sql new file mode 100644 index 00000000..908a5843 --- /dev/null +++ b/migrations/postgres/02118_traffic_log_idx.sql @@ -0,0 +1,6 @@ +-- migrate:up +CREATE INDEX IF NOT EXISTS "idx_timestamp" ON "traffic_log" ("timestamp"); + +-- migrate:down +DROP INDEX IF EXISTS "idx_timestamp"; + diff --git a/migrations/postgres/02119_user_subscribe_note.sql b/migrations/postgres/02119_user_subscribe_note.sql new file mode 100644 index 00000000..059f1369 --- /dev/null +++ b/migrations/postgres/02119_user_subscribe_note.sql @@ -0,0 +1,8 @@ +-- migrate:up +ALTER TABLE "user_subscribe" +ADD COLUMN "note" VARCHAR(500) NOT NULL DEFAULT ''; + +-- migrate:down +ALTER TABLE "user_subscribe" +DROP COLUMN "note"; + diff --git a/migrations/postgres/02120_user_rules.sql b/migrations/postgres/02120_user_rules.sql new file mode 100644 index 00000000..9f3e7312 --- /dev/null +++ b/migrations/postgres/02120_user_rules.sql @@ -0,0 +1,8 @@ +-- migrate:up +ALTER TABLE "user" + ADD COLUMN "rules" TEXT NULL; + +-- migrate:down +ALTER TABLE "user" +DROP COLUMN IF EXISTS "rules"; + diff --git a/migrations/postgres/02121_user_withdrawal.sql b/migrations/postgres/02121_user_withdrawal.sql new file mode 100644 index 00000000..1a9e73fe --- /dev/null +++ b/migrations/postgres/02121_user_withdrawal.sql @@ -0,0 +1,23 @@ +-- migrate:up +CREATE TABLE IF NOT EXISTS "withdrawals" ( + "id" BIGINT GENERATED BY DEFAULT AS IDENTITY NOT NULL, + "user_id" BIGINT NOT NULL, + "amount" BIGINT NOT NULL, + "content" TEXT, + "status" SMALLINT NOT NULL DEFAULT 0, + "reason" VARCHAR(500) NOT NULL DEFAULT '', + "created_at" TIMESTAMP NOT NULL, + "updated_at" TIMESTAMP NOT NULL, + PRIMARY KEY ("id") +); +CREATE INDEX IF NOT EXISTS "withdrawals_idx_user_id" ON "withdrawals" ("user_id"); +INSERT INTO "system" ("category", "key", "value", "type", "desc", "created_at", "updated_at") +VALUES + ('invite', 'WithdrawalMethod', '', 'string', 'withdrawal method', '2025-04-22 14:25:16.637', '2025-04-22 14:25:16.637') ON CONFLICT DO NOTHING; + +-- migrate:down +DROP TABLE IF EXISTS "withdrawals"; +DELETE FROM "system" +WHERE "category" = 'invite' + AND "key" = 'WithdrawalMethod'; + diff --git a/migrations/postgres/02122_server.sql b/migrations/postgres/02122_server.sql new file mode 100644 index 00000000..6a1769ec --- /dev/null +++ b/migrations/postgres/02122_server.sql @@ -0,0 +1,30 @@ +-- migrate:up +DROP TABLE IF EXISTS "server"; + +-- migrate:down +CREATE TABLE IF NOT EXISTS "server" +( + "id" BIGINT GENERATED BY DEFAULT AS IDENTITY NOT NULL, + "name" varchar(100) NOT NULL DEFAULT '', + "tags" varchar(128) NOT NULL DEFAULT '', + "country" varchar(128) NOT NULL DEFAULT '', + "city" varchar(128) NOT NULL DEFAULT '', + "latitude" varchar(128) NOT NULL DEFAULT '', + "longitude" varchar(128) NOT NULL DEFAULT '', + "server_addr" varchar(100) NOT NULL DEFAULT '', + "relay_mode" varchar(20) NOT NULL DEFAULT 'none', + "relay_node" text, + "speed_limit" bigint NOT NULL DEFAULT '0', + "traffic_ratio" decimal(4, 2) NOT NULL DEFAULT '0.00', + "group_id" bigint DEFAULT NULL, + "protocol" varchar(20) NOT NULL DEFAULT '', + "config" text, + "enable" SMALLINT NOT NULL DEFAULT '1', + "sort" bigint NOT NULL DEFAULT '0', + "last_reported_at" TIMESTAMP(3) DEFAULT NULL, + "created_at" TIMESTAMP(3) DEFAULT NULL, + "updated_at" TIMESTAMP(3) DEFAULT NULL, + PRIMARY KEY ("id") +); +CREATE INDEX IF NOT EXISTS "server_idx_group_id" ON "server" ("group_id"); + diff --git a/migrations/postgres/02123_subscribe_original.sql b/migrations/postgres/02123_subscribe_original.sql new file mode 100644 index 00000000..75572dd0 --- /dev/null +++ b/migrations/postgres/02123_subscribe_original.sql @@ -0,0 +1,8 @@ +-- migrate:up +ALTER TABLE "subscribe" + ADD COLUMN "show_original_price" BOOLEAN NOT NULL DEFAULT false; + +-- migrate:down +ALTER TABLE "subscribe" +DROP COLUMN "show_original_price"; + diff --git a/migrations/postgres/02124_server_group_delete.sql b/migrations/postgres/02124_server_group_delete.sql new file mode 100644 index 00000000..89e40a4a --- /dev/null +++ b/migrations/postgres/02124_server_group_delete.sql @@ -0,0 +1,5 @@ +-- migrate:up +DROP TABLE IF EXISTS "server_group"; + +-- migrate:down + diff --git a/migrations/postgres/02125_subscribe_stock.sql b/migrations/postgres/02125_subscribe_stock.sql new file mode 100644 index 00000000..eeaafde5 --- /dev/null +++ b/migrations/postgres/02125_subscribe_stock.sql @@ -0,0 +1,12 @@ +-- migrate:up +-- Update the "subscribe" table to set "inventory" to -1 where it is currently 0 +UPDATE "subscribe" +SET "inventory" = -1 +WHERE "inventory" = 0; + +-- migrate:down +-- This migration script reverts the inventory values in the 'subscribe' table +UPDATE "subscribe" +SET "inventory" = 0 +WHERE "inventory" = -1; + diff --git a/migrations/postgres/02126_system_log_idx.sql b/migrations/postgres/02126_system_log_idx.sql new file mode 100644 index 00000000..bf02437e --- /dev/null +++ b/migrations/postgres/02126_system_log_idx.sql @@ -0,0 +1,6 @@ +-- migrate:up +CREATE INDEX idx_type_date ON system_logs (type, date); + +-- migrate:down +DROP INDEX IF EXISTS "idx_type_date"; + diff --git a/migrations/postgres/02127_search_indexes.sql b/migrations/postgres/02127_search_indexes.sql new file mode 100644 index 00000000..90836888 --- /dev/null +++ b/migrations/postgres/02127_search_indexes.sql @@ -0,0 +1,64 @@ +-- migrate:up +CREATE EXTENSION IF NOT EXISTS pg_trgm; + +CREATE INDEX IF NOT EXISTS "idx_order_order_no_pattern" ON "order" ("order_no" text_pattern_ops); +CREATE INDEX IF NOT EXISTS "idx_order_trade_no_pattern" ON "order" ("trade_no" text_pattern_ops); +CREATE INDEX IF NOT EXISTS "idx_order_coupon_pattern" ON "order" ("coupon" text_pattern_ops); + +CREATE INDEX IF NOT EXISTS "idx_user_refer_code_pattern" ON "user" ("refer_code" text_pattern_ops); +CREATE INDEX IF NOT EXISTS "idx_user_auth_identifier_pattern" ON "user_auth_methods" ("auth_identifier" text_pattern_ops); + +CREATE INDEX IF NOT EXISTS "idx_coupon_name_pattern" ON "coupon" ("name" text_pattern_ops); +CREATE INDEX IF NOT EXISTS "idx_coupon_code_pattern" ON "coupon" ("code" text_pattern_ops); +CREATE INDEX IF NOT EXISTS "idx_payment_name_pattern" ON "payment" ("name" text_pattern_ops); + +CREATE INDEX IF NOT EXISTS "idx_servers_name_pattern" ON "servers" ("name" text_pattern_ops); +CREATE INDEX IF NOT EXISTS "idx_servers_address_pattern" ON "servers" ("address" text_pattern_ops); +CREATE INDEX IF NOT EXISTS "idx_nodes_name_pattern" ON "nodes" ("name" text_pattern_ops); +CREATE INDEX IF NOT EXISTS "idx_nodes_address_pattern" ON "nodes" ("address" text_pattern_ops); +CREATE INDEX IF NOT EXISTS "idx_nodes_tags_pattern" ON "nodes" ("tags" text_pattern_ops); +CREATE INDEX IF NOT EXISTS "idx_nodes_port" ON "nodes" ("port"); + +CREATE INDEX IF NOT EXISTS "idx_ads_title_trgm" ON "ads" USING GIN ("title" gin_trgm_ops); +CREATE INDEX IF NOT EXISTS "idx_ads_content_trgm" ON "ads" USING GIN ("content" gin_trgm_ops); +CREATE INDEX IF NOT EXISTS "idx_announcement_title_trgm" ON "announcement" USING GIN ("title" gin_trgm_ops); +CREATE INDEX IF NOT EXISTS "idx_announcement_content_trgm" ON "announcement" USING GIN ("content" gin_trgm_ops); +CREATE INDEX IF NOT EXISTS "idx_document_title_trgm" ON "document" USING GIN ("title" gin_trgm_ops); +CREATE INDEX IF NOT EXISTS "idx_document_content_trgm" ON "document" USING GIN ("content" gin_trgm_ops); +CREATE INDEX IF NOT EXISTS "idx_subscribe_name_trgm" ON "subscribe" USING GIN ("name" gin_trgm_ops); +CREATE INDEX IF NOT EXISTS "idx_subscribe_description_trgm" ON "subscribe" USING GIN ("description" gin_trgm_ops); +CREATE INDEX IF NOT EXISTS "idx_ticket_title_trgm" ON "ticket" USING GIN ("title" gin_trgm_ops); +CREATE INDEX IF NOT EXISTS "idx_ticket_description_trgm" ON "ticket" USING GIN ("description" gin_trgm_ops); +CREATE INDEX IF NOT EXISTS "idx_system_logs_content_trgm" ON "system_logs" USING GIN ("content" gin_trgm_ops); + +-- migrate:down +DROP INDEX IF EXISTS "idx_system_logs_content_trgm"; +DROP INDEX IF EXISTS "idx_ticket_description_trgm"; +DROP INDEX IF EXISTS "idx_ticket_title_trgm"; +DROP INDEX IF EXISTS "idx_subscribe_description_trgm"; +DROP INDEX IF EXISTS "idx_subscribe_name_trgm"; +DROP INDEX IF EXISTS "idx_document_content_trgm"; +DROP INDEX IF EXISTS "idx_document_title_trgm"; +DROP INDEX IF EXISTS "idx_announcement_content_trgm"; +DROP INDEX IF EXISTS "idx_announcement_title_trgm"; +DROP INDEX IF EXISTS "idx_ads_content_trgm"; +DROP INDEX IF EXISTS "idx_ads_title_trgm"; + +DROP INDEX IF EXISTS "idx_nodes_port"; +DROP INDEX IF EXISTS "idx_nodes_tags_pattern"; +DROP INDEX IF EXISTS "idx_nodes_address_pattern"; +DROP INDEX IF EXISTS "idx_nodes_name_pattern"; +DROP INDEX IF EXISTS "idx_servers_address_pattern"; +DROP INDEX IF EXISTS "idx_servers_name_pattern"; + +DROP INDEX IF EXISTS "idx_payment_name_pattern"; +DROP INDEX IF EXISTS "idx_coupon_code_pattern"; +DROP INDEX IF EXISTS "idx_coupon_name_pattern"; + +DROP INDEX IF EXISTS "idx_user_auth_identifier_pattern"; +DROP INDEX IF EXISTS "idx_user_refer_code_pattern"; + +DROP INDEX IF EXISTS "idx_order_coupon_pattern"; +DROP INDEX IF EXISTS "idx_order_trade_no_pattern"; +DROP INDEX IF EXISTS "idx_order_order_no_pattern"; + diff --git a/migrations/postgres/02128_server_config_override.sql b/migrations/postgres/02128_server_config_override.sql new file mode 100644 index 00000000..bb8c23da --- /dev/null +++ b/migrations/postgres/02128_server_config_override.sql @@ -0,0 +1,18 @@ +-- migrate:up +CREATE TABLE IF NOT EXISTS "server_config_overrides" +( + "id" BIGINT GENERATED BY DEFAULT AS IDENTITY NOT NULL, + "server_id" bigint NOT NULL, + "ip_strategy" varchar(32) DEFAULT NULL, + "dns" text DEFAULT NULL, + "block" text DEFAULT NULL, + "outbound" text DEFAULT NULL, + "created_at" TIMESTAMP(3) DEFAULT NULL, + "updated_at" TIMESTAMP(3) DEFAULT NULL, + PRIMARY KEY ("id"), + CONSTRAINT "uni_server_config_overrides_server_id" UNIQUE ("server_id") +); + +-- migrate:down +DROP TABLE IF EXISTS "server_config_overrides"; + diff --git a/migrations/postgres/02129_payment_sort.sql b/migrations/postgres/02129_payment_sort.sql new file mode 100644 index 00000000..f315e764 --- /dev/null +++ b/migrations/postgres/02129_payment_sort.sql @@ -0,0 +1,12 @@ +-- migrate:up +ALTER TABLE "payment" + ADD COLUMN IF NOT EXISTS "sort" bigint NOT NULL DEFAULT 0; + +UPDATE "payment" +SET "sort" = "id" +WHERE "sort" = 0; + +-- migrate:down +ALTER TABLE "payment" + DROP COLUMN IF EXISTS "sort"; + diff --git a/migrations/postgres/02130_subscribe_tutorial.sql b/migrations/postgres/02130_subscribe_tutorial.sql new file mode 100644 index 00000000..1923abcd --- /dev/null +++ b/migrations/postgres/02130_subscribe_tutorial.sql @@ -0,0 +1,10 @@ +-- migrate:up +INSERT INTO "system" ("category", "key", "value", "type", "desc", "created_at", "updated_at") +SELECT 'subscribe', 'ShowTutorial', 'true', 'bool', 'Show tutorial section on the user document page', '2025-04-22 14:25:16.639', '2025-04-22 14:25:16.639' + WHERE NOT EXISTS ( + SELECT 1 FROM "system" WHERE "category" = 'subscribe' AND "key" = 'ShowTutorial' +); + +-- migrate:down +DELETE FROM "system" WHERE "category" = 'subscribe' AND "key" = 'ShowTutorial'; + diff --git a/migrations/postgres/02131_timestamptz_last_reported_at.sql b/migrations/postgres/02131_timestamptz_last_reported_at.sql new file mode 100644 index 00000000..36d81008 --- /dev/null +++ b/migrations/postgres/02131_timestamptz_last_reported_at.sql @@ -0,0 +1,10 @@ +-- migrate:up +ALTER TABLE "servers" + ALTER COLUMN "last_reported_at" TYPE timestamptz + USING "last_reported_at" AT TIME ZONE 'UTC'; + +-- migrate:down +ALTER TABLE "servers" + ALTER COLUMN "last_reported_at" TYPE timestamp(3) + USING "last_reported_at" AT TIME ZONE 'UTC'; + diff --git a/src/adapter/mod.rs b/src/adapter/mod.rs new file mode 100644 index 00000000..b8aaa6c2 --- /dev/null +++ b/src/adapter/mod.rs @@ -0,0 +1,512 @@ +//! Adapter module — ports Go `server/adapter` package to Rust. +//! +//! Provides: +//! - [`Proxy`] – per-proxy configuration struct (mirrors Go `Proxy`) +//! - [`User`] – subscriber info (mirrors Go `User`) +//! - [`ClientConfig`] – template-rendering config +//! - [`Client`] – renders a Go-template with a sprig subset +//! - [`Adapter`] – converts `Node`+`Server` entities into `Vec` + +use std::collections::HashMap; + +use anyhow::Context as _; +use base64::{engine::general_purpose::STANDARD as B64, Engine as _}; +use chrono::TimeZone as _; + +use crate::model::entity::node::{Node, Protocol as NodeProtocol, Server}; + +// ───────────────────────────────────────────────────────────────────────────── +// Proxy +// ───────────────────────────────────────────────────────────────────────────── + +/// Full proxy configuration, mirroring the Go `Proxy` struct in `adapter/client.go`. +#[derive(Debug, Clone, serde::Serialize, serde::Deserialize, Default)] +pub struct Proxy { + pub sort: i32, + pub name: String, + pub server: String, + pub port: i32, + #[serde(rename = "Type")] + pub type_: String, + pub tags: Vec, + + // Security + pub security: Option, + pub sni: Option, + pub allow_insecure: bool, + pub fingerprint: Option, + pub reality_server_addr: Option, + pub reality_server_port: i32, + pub reality_private_key: Option, + pub reality_public_key: Option, + pub reality_short_id: Option, + + // Transport + pub transport: Option, + pub host: Option, + pub path: Option, + pub service_name: Option, + + // Shadowsocks + pub method: Option, + pub server_key: Option, + pub uot: bool, + pub uot_version: i32, + + // Vmess/Vless/Trojan + pub flow: Option, + + // Hysteria2 + pub hop_ports: Option, + pub hop_interval: i32, + pub obfs_password: Option, + pub up_mbps: i32, + pub down_mbps: i32, + + // TUIC + pub disable_sni: bool, + pub reduce_rtt: bool, + pub udp_relay_mode: Option, + pub congestion_controller: Option, + + // AnyTLS + pub padding_scheme: Option, + + // Mieru + pub multiplex: Option, + + // Vless xhttp + pub xhttp_mode: Option, + pub xhttp_extra: Option, + + // Encryption + pub encryption: Option, + pub encryption_mode: Option, + pub encryption_rtt: Option, + pub encryption_ticket: Option, + pub encryption_server_padding: Option, + pub encryption_private_key: Option, + pub encryption_client_padding: Option, + pub encryption_password: Option, + + // ECH + pub ech_enable: bool, + pub ech_server_name: Option, + + // Misc + pub ratio: f64, + pub cert_mode: Option, + pub cert_dns_provider: Option, + pub cert_dns_env: Option, +} + +// ───────────────────────────────────────────────────────────────────────────── +// User +// ───────────────────────────────────────────────────────────────────────────── + +/// Subscriber / user info passed to templates (mirrors Go `User`). +#[derive(Debug, Clone, serde::Serialize, serde::Deserialize, Default)] +pub struct User { + pub password: String, + /// Unix timestamp (seconds). + pub expired_at: i64, + pub download: i64, + pub upload: i64, + pub traffic: i64, + pub subscribe_url: String, +} + +// ───────────────────────────────────────────────────────────────────────────── +// ClientConfig +// ───────────────────────────────────────────────────────────────────────────── + +/// Configuration for a [`Client`] instance. +#[derive(Debug, Clone, Default)] +pub struct ClientConfig { + pub site_name: String, + pub subscribe_name: String, + /// Output format, e.g. `"base64"`, `"yaml"`, `"json"`. + pub output_format: String, + pub params: HashMap, +} + +// ───────────────────────────────────────────────────────────────────────────── +// Client +// ───────────────────────────────────────────────────────────────────────────── + +/// Renders a Go-compatible template with a sprig-subset function map. +pub struct Client { + pub config: ClientConfig, +} + +impl Client { + /// Render `template` against `proxies` and `user`. + /// + /// Mirrors Go `(*Client).Build()`. + pub fn build(&self, template: &str, proxies: &[Proxy], user: &User) -> anyhow::Result { + let mut tmpl = gtmpl::Template::default(); + + // Register sprig-subset functions. + tmpl.add_func("toJson", sprig_to_json); + tmpl.add_func("b64enc", sprig_b64enc); + tmpl.add_func("date", sprig_date); + + tmpl.parse(template) + .map_err(|e| anyhow::anyhow!("template parse error: {e}"))?; + + // Serialize each proxy to a serde_json::Value, then lift to gtmpl::Value. + let proxy_values: Vec = proxies + .iter() + .map(|p| { + let json = serde_json::to_value(p) + .context("serialize Proxy to JSON")?; + Ok(json_to_gtmpl(json)) + }) + .collect::>()?; + + let user_value = json_to_gtmpl( + serde_json::to_value(user).context("serialize User to JSON")?, + ); + + let params_value = { + let map: HashMap = self + .config + .params + .iter() + .map(|(k, v)| (k.clone(), gtmpl::Value::String(v.clone()))) + .collect(); + gtmpl::Value::Map(map) + }; + + let mut ctx: HashMap = HashMap::new(); + ctx.insert( + "SiteName".into(), + gtmpl::Value::String(self.config.site_name.clone()), + ); + ctx.insert( + "SubscribeName".into(), + gtmpl::Value::String(self.config.subscribe_name.clone()), + ); + ctx.insert( + "OutputFormat".into(), + gtmpl::Value::String(self.config.output_format.clone()), + ); + ctx.insert("Proxies".into(), gtmpl::Value::Array(proxy_values)); + ctx.insert("UserInfo".into(), user_value); + ctx.insert("Params".into(), params_value); + + let rendered = tmpl + .render(>mpl::Context::from(gtmpl::Value::Map(ctx))) + .map_err(|e| anyhow::anyhow!("template render error: {e}"))?; + + if self.config.output_format == "base64" { + return Ok(B64.encode(rendered.as_bytes())); + } + + Ok(rendered) + } +} + +// ───────────────────────────────────────────────────────────────────────────── +// JSON ↔ gtmpl::Value conversion +// ───────────────────────────────────────────────────────────────────────────── + +fn json_to_gtmpl(v: serde_json::Value) -> gtmpl::Value { + match v { + serde_json::Value::Null => gtmpl::Value::Nil, + serde_json::Value::Bool(b) => gtmpl::Value::Bool(b), + serde_json::Value::Number(n) => { + if let Some(i) = n.as_i64() { + gtmpl::Value::Number(gtmpl_value::Number::from(i)) + } else if let Some(f) = n.as_f64() { + gtmpl::Value::Number(gtmpl_value::Number::from(f)) + } else { + gtmpl::Value::Number(gtmpl_value::Number::from(0_i64)) + } + } + serde_json::Value::String(s) => gtmpl::Value::String(s), + serde_json::Value::Array(arr) => { + gtmpl::Value::Array(arr.into_iter().map(json_to_gtmpl).collect()) + } + serde_json::Value::Object(map) => { + let m: HashMap = + map.into_iter().map(|(k, v)| (k, json_to_gtmpl(v))).collect(); + gtmpl::Value::Map(m) + } + } +} + +// ───────────────────────────────────────────────────────────────────────────── +// Sprig-subset template functions +// ───────────────────────────────────────────────────────────────────────────── + +/// `toJson` — serialises first argument to a JSON string. +fn sprig_to_json(args: &[gtmpl::Value]) -> Result { + let v = args + .first() + .ok_or_else(|| gtmpl_value::FuncError::AtLeastXArgs("toJson".into(), 1))?; + + // Convert gtmpl::Value back through serde to produce JSON. + let json_val = gtmpl_value_to_json(v.clone()); + let s = serde_json::to_string(&json_val).unwrap_or_else(|_| "null".into()); + Ok(gtmpl::Value::String(s)) +} + +/// `b64enc` — base64-encodes first argument as a UTF-8 string. +fn sprig_b64enc(args: &[gtmpl::Value]) -> Result { + let v = args + .first() + .ok_or_else(|| gtmpl_value::FuncError::AtLeastXArgs("b64enc".into(), 1))?; + let s = match v { + gtmpl::Value::String(s) => s.clone(), + other => format!("{other:?}"), + }; + Ok(gtmpl::Value::String(B64.encode(s.as_bytes()))) +} + +/// `date` — formats a Unix timestamp using a Go-style layout string. +/// +/// Signature: `date ` +fn sprig_date(args: &[gtmpl::Value]) -> Result { + if args.len() < 2 { + return Err(gtmpl_value::FuncError::AtLeastXArgs("date".into(), 2)); + } + let layout = match &args[0] { + gtmpl::Value::String(s) => s.as_str(), + _ => { + return Err(gtmpl_value::FuncError::Generic( + "date: first arg must be a string layout".into(), + )) + } + }; + let ts: i64 = match &args[1] { + gtmpl::Value::Number(n) => n + .as_i64() + .unwrap_or_else(|| n.as_f64().map(|f| f as i64).unwrap_or(0)), + _ => { + return Err(gtmpl_value::FuncError::Generic( + "date: second arg must be a number (Unix timestamp)".into(), + )) + } + }; + + let dt = chrono::Utc + .timestamp_opt(ts, 0) + .single() + .unwrap_or_else(chrono::Utc::now); + + // Map common Go reference-time tokens to chrono format specifiers. + let chrono_fmt = go_layout_to_chrono(layout); + Ok(gtmpl::Value::String(dt.format(&chrono_fmt).to_string())) +} + +/// Translate a Go time-layout string to a chrono format string. +/// +/// Only the most common reference-time tokens are mapped. +fn go_layout_to_chrono(layout: &str) -> String { + layout + .replace("2006", "%Y") + .replace("01", "%m") + .replace("02", "%d") + .replace("15", "%H") + .replace("04", "%M") + .replace("05", "%S") + .replace("Jan", "%b") + .replace("Monday", "%A") + .replace("Mon", "%a") +} + +/// Convert a `gtmpl::Value` to a `serde_json::Value` (best-effort). +fn gtmpl_value_to_json(v: gtmpl::Value) -> serde_json::Value { + match v { + gtmpl::Value::Nil | gtmpl::Value::NoValue => serde_json::Value::Null, + gtmpl::Value::Bool(b) => serde_json::Value::Bool(b), + gtmpl::Value::String(s) => serde_json::Value::String(s), + gtmpl::Value::Number(n) => { + if let Some(i) = n.as_i64() { + serde_json::Value::Number(i.into()) + } else if let Some(f) = n.as_f64() { + serde_json::Number::from_f64(f) + .map(serde_json::Value::Number) + .unwrap_or(serde_json::Value::Null) + } else { + serde_json::Value::Null + } + } + gtmpl::Value::Array(arr) => { + serde_json::Value::Array(arr.into_iter().map(gtmpl_value_to_json).collect()) + } + gtmpl::Value::Map(map) | gtmpl::Value::Object(map) => { + let obj: serde_json::Map = map + .into_iter() + .map(|(k, v)| (k, gtmpl_value_to_json(v))) + .collect(); + serde_json::Value::Object(obj) + } + // Functions have no meaningful JSON representation. + gtmpl::Value::Function(_) => serde_json::Value::Null, + } +} + +// ───────────────────────────────────────────────────────────────────────────── +// Adapter +// ───────────────────────────────────────────────────────────────────────────── + +/// Converts node+server entities into a sorted list of [`Proxy`] values. +pub struct Adapter; + +impl Adapter { + /// Build a `Vec` from `(Node, Server)` pairs. + /// + /// Mirrors Go `(*Adapter).Proxies()`. + pub fn proxies(pairs: &[(Node, Server)]) -> Vec { + let mut out: Vec = Vec::new(); + + for (node, server) in pairs { + // Deserialise the JSON protocols array stored in `server.protocols`. + let protocols: Vec = match serde_json::from_str(&server.protocols) { + Ok(v) => v, + Err(e) => { + tracing::error!( + server_id = server.id, + error = %e, + "failed to parse server protocols JSON" + ); + continue; + } + }; + + // Find the protocol entry whose `type_` matches `node.protocol`. + let proto = match protocols.iter().find(|p| p.type_ == node.protocol) { + Some(p) => p, + None => { + tracing::warn!( + node_id = node.id, + protocol = %node.protocol, + "no matching protocol entry in server.protocols" + ); + continue; + } + }; + + let tags: Vec = if node.tags.is_empty() { + vec![] + } else { + node.tags.split(',').map(str::trim).map(String::from).collect() + }; + + out.push(Proxy { + sort: node.sort, + name: node.name.clone(), + server: node.address.clone(), + port: node.port, + type_: node.protocol.clone(), + tags, + security: proto.security.clone(), + sni: proto.sni.clone(), + allow_insecure: proto.allow_insecure, + fingerprint: proto.fingerprint.clone(), + reality_server_addr: proto.reality_server_addr.clone(), + reality_server_port: proto.reality_server_port, + reality_private_key: proto.reality_private_key.clone(), + reality_public_key: proto.reality_public_key.clone(), + reality_short_id: proto.reality_short_id.clone(), + transport: proto.transport.clone(), + host: proto.host.clone(), + path: proto.path.clone(), + service_name: proto.service_name.clone(), + method: proto.cipher.clone(), + server_key: proto.server_key.clone(), + uot: proto.uot, + uot_version: proto.uot_version, + flow: proto.flow.clone(), + hop_ports: proto.hop_ports.clone(), + hop_interval: proto.hop_interval, + obfs_password: proto.obfs_password.clone(), + up_mbps: proto.up_mbps, + down_mbps: proto.down_mbps, + disable_sni: proto.disable_sni, + reduce_rtt: proto.reduce_rtt, + udp_relay_mode: proto.udp_relay_mode.clone(), + congestion_controller: proto.congestion_controller.clone(), + padding_scheme: proto.padding_scheme.clone(), + multiplex: proto.multiplex.clone(), + xhttp_mode: proto.xhttp_mode.clone(), + xhttp_extra: proto.xhttp_extra.clone(), + encryption: proto.encryption.clone(), + encryption_mode: proto.encryption_mode.clone(), + encryption_rtt: proto.encryption_rtt.clone(), + encryption_ticket: proto.encryption_ticket.clone(), + encryption_server_padding: proto.encryption_server_padding.clone(), + encryption_private_key: proto.encryption_private_key.clone(), + encryption_client_padding: proto.encryption_client_padding.clone(), + encryption_password: proto.encryption_password.clone(), + ech_enable: proto.ech_enable, + ech_server_name: proto.ech_server_name.clone(), + ratio: proto.ratio, + cert_mode: proto.cert_mode.clone(), + cert_dns_provider: proto.cert_dns_provider.clone(), + cert_dns_env: proto.cert_dns_env.clone(), + }); + } + + // Sort by `node.sort` ascending (mirrors Go slice sort in original code). + out.sort_by_key(|p| p.sort); + out + } +} + +// ───────────────────────────────────────────────────────────────────────────── +// Tests +// ───────────────────────────────────────────────────────────────────────────── + +#[cfg(test)] +mod tests { + use super::*; + + #[test] + fn test_proxy_default() { + let _ = Proxy::default(); + } + + #[test] + fn test_b64enc() { + let args = vec![gtmpl::Value::String("hello".into())]; + let result = sprig_b64enc(&args).expect("b64enc should succeed"); + assert_eq!(result, gtmpl::Value::String("aGVsbG8=".into())); + } + + #[test] + fn test_client_build_simple() { + let config = ClientConfig { + site_name: "TestSite".into(), + subscribe_name: "TestSub".into(), + output_format: "text".into(), + params: HashMap::new(), + }; + let client = Client { config }; + + let proxies = vec![Proxy::default(), Proxy::default()]; + let user = User::default(); + + // Go template: render the count of proxies. + let out = client + .build("{{ len .Proxies }}", &proxies, &user) + .expect("build should succeed"); + assert_eq!(out.trim(), "2"); + } + + #[test] + fn test_client_build_base64() { + let config = ClientConfig { + output_format: "base64".into(), + ..Default::default() + }; + let client = Client { config }; + let out = client + .build("hello", &[], &User::default()) + .expect("build should succeed"); + // base64("hello") == "aGVsbG8=" + assert_eq!(out, "aGVsbG8="); + } +} diff --git a/src/cache.rs b/src/cache.rs new file mode 100644 index 00000000..74118de7 --- /dev/null +++ b/src/cache.rs @@ -0,0 +1,109 @@ +use redis::aio::ConnectionManager; +use tokio::sync::Mutex; + +use crate::config::RedisConfig; + +#[derive(Clone)] +pub struct Cache { + con: std::sync::Arc>, +} + +impl std::fmt::Debug for Cache { + fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result { + f.debug_struct("Cache").finish_non_exhaustive() + } +} + +impl Cache { + pub async fn new(cfg: &RedisConfig) -> Result { + let dsn = format!("redis://:{}@{}", cfg.pass, cfg.host); + let client = redis::Client::open(dsn)?; + let mut con = client.get_connection_manager().await?; + + if cfg.db != 0 { + redis::cmd("SELECT") + .arg(cfg.db) + .query_async::<()>(&mut con) + .await?; + } + + Ok(Self { + con: std::sync::Arc::new(Mutex::new(con)), + }) + } + + pub async fn get(&self, key: &str) -> Result, redis::RedisError> { + let mut con = self.con.lock().await; + redis::cmd("GET") + .arg(key) + .query_async(&mut *con) + .await + .map(|v: Option| v) + } + + pub async fn set_ex( + &self, + key: &str, + value: &str, + seconds: i64, + ) -> Result<(), redis::RedisError> { + let mut con = self.con.lock().await; + redis::cmd("SET") + .arg(key) + .arg(value) + .arg("EX") + .arg(seconds) + .query_async(&mut *con) + .await + } + + pub async fn del(&self, key: &str) -> Result<(), redis::RedisError> { + let mut con = self.con.lock().await; + redis::cmd("DEL") + .arg(key) + .query_async(&mut *con) + .await + } + + pub async fn exists(&self, key: &str) -> Result { + let mut con = self.con.lock().await; + redis::cmd("EXISTS") + .arg(key) + .query_async(&mut *con) + .await + } + + pub async fn incr(&self, key: &str) -> Result { + let mut con = self.con.lock().await; + redis::cmd("INCR") + .arg(key) + .query_async(&mut *con) + .await + } + + pub async fn expire(&self, key: &str, seconds: i64) -> Result<(), redis::RedisError> { + let mut con = self.con.lock().await; + redis::cmd("EXPIRE") + .arg(key) + .arg(seconds) + .query_async(&mut *con) + .await + } + + pub async fn get_int(&self, key: &str) -> Result, redis::RedisError> { + let mut con = self.con.lock().await; + redis::cmd("GET") + .arg(key) + .query_async(&mut *con) + .await + .map(|v: Option| v.and_then(|s| s.parse().ok())) + } + + pub async fn ttl(&self, key: &str) -> Result { + let mut con = self.con.lock().await; + redis::cmd("TTL") + .arg(key) + .query_async(&mut *con) + .await + } +} diff --git a/src/config/cache_key.rs b/src/config/cache_key.rs new file mode 100644 index 00000000..8979ee98 --- /dev/null +++ b/src/config/cache_key.rs @@ -0,0 +1,29 @@ +//! Redis / cache key constants, ported from `server/internal/config/cacheKey.go`. +//! +//! TODO: these constants are defined ahead of the cache layer. The root +//! `Cargo.toml` does not yet pull a Redis client (e.g. `redis` / `deadpool-redis`) +//! and `config::RedisConfig` is a dead leaf until the cache service is wired +//! up. When introducing the cache layer, add the dependency and a `cache` +//! module that consumes these keys. + +pub const CURRENCY_CONFIG_KEY: &str = "system:currency_config"; +pub const SMS_CONFIG_KEY: &str = "system:sms_config"; +pub const SITE_CONFIG_KEY: &str = "system:site_config"; +pub const SUBSCRIBE_CONFIG_KEY: &str = "system:subscribe_config"; +pub const REGISTER_CONFIG_KEY: &str = "system:register_config"; +pub const VERIFY_CONFIG_KEY: &str = "system:verify_config"; +pub const EMAIL_SMTP_CONFIG_KEY: &str = "system:email_smtp_config"; +pub const NODE_CONFIG_KEY: &str = "system:node_config"; +pub const INVITE_CONFIG_KEY: &str = "system:invite_config"; +pub const TELEGRAM_CONFIG_KEY: &str = "system:telegram_config"; +pub const ADMIN_TELEGRAM_CHAT_IDS_KEY: &str = "system:telegram_admin_chat_ids"; +pub const TOS_CONFIG_KEY: &str = "system:tos_config"; +pub const VERIFY_CODE_CONFIG_KEY: &str = "system:verify_code_config"; +pub const SESSION_ID_KEY: &str = "auth:session_id"; +pub const GLOBAL_CONFIG_KEY: &str = "system:global_config"; +pub const AUTH_CODE_CACHE_KEY: &str = "auth:verify:email"; +pub const AUTH_CODE_TELEPHONE_CACHE_KEY: &str = "auth:verify:telephone"; +pub const COMMON_STAT_CACHE_KEY: &str = "common:stat"; +pub const SERVER_COUNT_CACHE_KEY: &str = "server:count"; +pub const SEND_INTERVAL_KEY_PREFIX: &str = "send:interval:"; +pub const SEND_COUNT_LIMIT_KEY_PREFIX: &str = "send:limit:"; diff --git a/src/config/mod.rs b/src/config/mod.rs new file mode 100644 index 00000000..580928fd --- /dev/null +++ b/src/config/mod.rs @@ -0,0 +1,715 @@ +pub mod cache_key; + +use serde::Deserialize; +use std::path::Path; + +// ═══════════════════════════════════════════════════════════════════════════ +// Top-level Config +// ═══════════════════════════════════════════════════════════════════════════ + +#[derive(Debug, Clone, Deserialize)] +#[serde(rename_all = "PascalCase")] +pub struct Config { + #[serde(default = "default_model")] + pub model: String, + + #[serde(default = "default_host")] + pub host: String, + + #[serde(default = "default_port")] + pub port: u16, + + #[serde(default)] + pub debug: bool, + + #[serde(default)] + pub transport: TransportConfig, + + #[serde(default)] + pub tls: Tls, + + #[serde(rename = "JwtAuth")] + pub jwt_auth: JwtAuth, + + #[serde(default)] + pub logger: LogConfig, + + #[serde(default)] + pub database: DatabaseConfig, + + pub mysql: Option, + + #[serde(default)] + pub redis: RedisConfig, + + #[serde(default)] + pub site: SiteConfig, + + #[serde(default)] + pub node: NodeConfig, + + #[serde(default)] + pub mobile: MobileConfig, + + #[serde(default)] + pub email: EmailConfig, + + #[serde(default)] + pub device: DeviceConfig, + + #[serde(default)] + pub verify: Verify, + + #[serde(rename = "VerifyCode")] + pub verify_code: VerifyCode, + + #[serde(default)] + pub register: RegisterConfig, + + #[serde(default)] + pub subscribe: SubscribeConfig, + + #[serde(default)] + pub invite: InviteConfig, + + #[serde(default)] + pub telegram: Telegram, + + #[serde(default)] + pub log: Log, + + #[serde(default)] + pub currency: Currency, + + #[serde(default)] + pub plugin: PluginConfig, + + #[serde(default)] + pub trace: TraceConfig, + + #[serde(default)] + pub administrator: Administrator, +} + +impl Config { + pub fn load() -> Self { + let path = std::env::var("PPANEL_CONFIG").unwrap_or_else(|_| "config.yaml".to_string()); + Self::from_file(&path) + } + + pub fn from_file(path: impl AsRef) -> Self { + let path = path.as_ref(); + let content = std::fs::read_to_string(path).unwrap_or_else(|e| { + panic!("failed to read config file {}: {e}", path.display()) + }); + serde_yaml::from_str(&content).unwrap_or_else(|e| { + panic!("failed to parse config file {}: {e}", path.display()) + }) + } + + pub fn database_config(&self) -> &DatabaseConfig { + if self.database.addr.is_some() || !self.database.dbname.is_empty() { + &self.database + } else if let Some(ref mysql) = self.mysql { + mysql + } else { + &self.database + } + } +} + +// ═══════════════════════════════════════════════════════════════════════════ +// Default helpers +// ═══════════════════════════════════════════════════════════════════════════ + +fn default_model() -> String { "prod".into() } +fn default_host() -> String { "0.0.0.0".into() } +fn default_port() -> u16 { 8080 } + +// ═══════════════════════════════════════════════════════════════════════════ +// Sub-config structs +// ═══════════════════════════════════════════════════════════════════════════ + +#[derive(Debug, Clone, Deserialize)] +#[serde(rename_all = "PascalCase")] +pub struct RedisConfig { + #[serde(default = "default_redis_host")] + pub host: String, + #[serde(default)] + pub pass: String, + #[serde(default)] + pub db: i32, +} + +fn default_redis_host() -> String { "localhost:6379".into() } + +#[derive(Debug, Clone, Deserialize)] +#[serde(rename_all = "PascalCase")] +pub struct TransportConfig { + #[serde(default = "default_transport_driver")] + pub driver: String, +} + +fn default_transport_driver() -> String { "hertz".into() } + +#[derive(Debug, Clone, Deserialize)] +#[serde(rename_all = "PascalCase")] +pub struct JwtAuth { + #[serde(default)] + pub access_secret: String, + #[serde(default = "default_access_expire")] + pub access_expire: i64, +} + +fn default_access_expire() -> i64 { 604800 } + +#[derive(Debug, Clone, Deserialize)] +#[serde(rename_all = "PascalCase")] +pub struct Verify { + #[serde(default)] + pub turnstile_site_key: String, + #[serde(default)] + pub turnstile_secret: String, + #[serde(default)] + pub login_verify: bool, + #[serde(default)] + pub register_verify: bool, + #[serde(default)] + pub reset_password_verify: bool, +} + +#[derive(Debug, Clone, Deserialize)] +#[serde(rename_all = "PascalCase")] +pub struct SubscribeConfig { + #[serde(default)] + pub single_model: bool, + #[serde(default = "default_subscribe_path")] + pub subscribe_path: String, + #[serde(default)] + pub subscribe_domain: String, + #[serde(default)] + pub pan_domain: bool, + #[serde(default)] + pub user_agent_limit: bool, + #[serde(default)] + pub user_agent_list: String, + #[serde(default = "default_show_tutorial")] + pub show_tutorial: bool, +} + +fn default_subscribe_path() -> String { "/v1/subscribe/config".into() } +fn default_show_tutorial() -> bool { true } + +#[derive(Debug, Clone, Deserialize)] +#[serde(rename_all = "PascalCase")] +pub struct RegisterConfig { + #[serde(default)] + pub stop_register: bool, + #[serde(default)] + pub enable_trial: bool, + #[serde(default)] + pub trial_subscribe: i64, + #[serde(default)] + pub trial_time: i64, + #[serde(default)] + pub trial_time_unit: String, + #[serde(default)] + pub ip_register_limit: i64, + #[serde(default)] + pub ip_register_limit_duration: i64, + #[serde(default)] + pub enable_ip_register_limit: bool, +} + +#[derive(Debug, Clone, Deserialize)] +pub struct EmailConfig { + #[serde(rename = "Enable", default = "default_email_enable")] + pub enable: bool, + #[serde(default)] + pub platform: String, + #[serde(default)] + pub platform_config: String, + #[serde(default)] + pub enable_verify: bool, + #[serde(default)] + pub enable_notify: bool, + #[serde(default)] + pub enable_domain_suffix: bool, + #[serde(default)] + pub domain_suffix_list: String, + #[serde(default)] + pub verify_email_template: String, + #[serde(default)] + pub expiration_email_template: String, + #[serde(default)] + pub maintenance_email_template: String, + #[serde(default)] + pub traffic_exceed_email_template: String, +} + +fn default_email_enable() -> bool { true } + +#[derive(Debug, Clone, Deserialize)] +pub struct MobileConfig { + #[serde(rename = "Enable", default = "default_mobile_enable")] + pub enable: bool, + #[serde(default)] + pub platform: String, + #[serde(default)] + pub platform_config: String, + #[serde(default)] + pub enable_verify: bool, + #[serde(default)] + pub enable_whitelist: bool, + #[serde(default)] + pub whitelist: Vec, +} + +fn default_mobile_enable() -> bool { true } + +#[derive(Debug, Clone, Deserialize)] +#[serde(rename_all = "snake_case")] +pub struct DeviceConfig { + #[serde(default = "default_device_enable")] + pub enable: bool, + #[serde(default)] + pub show_ads: bool, + #[serde(default)] + pub enable_security: bool, + #[serde(default)] + pub only_real_device: bool, + #[serde(default)] + pub security_secret: String, +} + +fn default_device_enable() -> bool { true } + +#[derive(Debug, Clone, Deserialize)] +#[serde(rename_all = "PascalCase")] +pub struct SiteConfig { + #[serde(default)] + pub host: String, + #[serde(default)] + pub site_name: String, + #[serde(default)] + pub site_desc: String, + #[serde(default)] + pub site_logo: String, + #[serde(default)] + pub keywords: String, + #[serde(default)] + pub custom_html: String, + #[serde(default)] + pub custom_data: String, +} + +#[derive(Debug, Clone, Deserialize)] +#[serde(rename_all = "PascalCase")] +pub struct NodeConfig { + #[serde(default)] + pub node_secret: String, + #[serde(default = "default_node_pull_interval")] + pub node_pull_interval: i64, + #[serde(default = "default_node_push_interval")] + pub node_push_interval: i64, + #[serde(default)] + pub traffic_report_threshold: i64, + #[serde(default)] + pub ip_strategy: String, + #[serde(default)] + pub dns: Vec, + #[serde(default)] + pub block: Vec, + #[serde(default)] + pub outbound: Vec, +} + +fn default_node_pull_interval() -> i64 { 60 } +fn default_node_push_interval() -> i64 { 60 } + +#[derive(Debug, Clone, Deserialize)] +#[serde(rename_all = "snake_case")] +pub struct NodeDns { + pub proto: String, + pub address: String, + #[serde(default)] + pub domains: Vec, +} + +#[derive(Debug, Clone, Deserialize)] +#[serde(rename_all = "snake_case")] +pub struct NodeOutbound { + pub name: String, + pub protocol: String, + pub address: String, + pub port: i64, + #[serde(default)] + pub user: String, + #[serde(default)] + pub password: String, + #[serde(default)] + pub uuid: String, + #[serde(default)] + pub cipher: String, + #[serde(default)] + pub security: String, + #[serde(default)] + pub sni: String, + #[serde(default)] + pub allow_insecure: bool, + #[serde(default)] + pub fingerprint: String, + #[serde(default)] + pub transport: String, + #[serde(default)] + pub host: String, + #[serde(default)] + pub path: String, + #[serde(default)] + pub service_name: String, + #[serde(default)] + pub flow: String, + #[serde(default)] + pub uot: bool, + #[serde(default)] + pub uot_version: i32, + #[serde(default)] + pub congestion_controller: String, + #[serde(default)] + pub udp_stream: bool, + #[serde(default)] + pub reduce_rtt: bool, + #[serde(default)] + pub heartbeat: i32, + #[serde(default)] + pub reality_public_key: String, + #[serde(default)] + pub reality_short_id: String, + #[serde(default)] + pub spider_x: String, + #[serde(default)] + pub settings: String, + #[serde(default)] + pub stream_settings: String, + #[serde(default)] + pub rules: Vec, +} + +#[derive(Debug, Clone, Deserialize)] +#[serde(rename_all = "PascalCase")] +pub struct InviteConfig { + #[serde(default)] + pub forced_invite: bool, + #[serde(default)] + pub referral_percentage: i64, + #[serde(default)] + pub only_first_purchase: bool, +} + +#[derive(Debug, Clone, Deserialize)] +#[serde(rename_all = "PascalCase")] +pub struct Telegram { + #[serde(default)] + pub enable: bool, + #[serde(default)] + pub bot_id: i64, + #[serde(default)] + pub bot_name: String, + #[serde(default)] + pub bot_token: String, + #[serde(default)] + pub enable_notify: bool, + #[serde(default)] + pub web_hook_domain: String, +} + +#[derive(Debug, Clone, Deserialize)] +#[serde(rename_all = "PascalCase")] +pub struct Tls { + #[serde(default)] + pub enable: bool, + #[serde(default)] + pub cert_file: String, + #[serde(default)] + pub key_file: String, +} + +#[derive(Debug, Clone, Deserialize)] +#[serde(rename_all = "PascalCase")] +pub struct VerifyCode { + #[serde(default = "default_verify_code_expire")] + pub expire_time: i64, + #[serde(default = "default_verify_code_limit")] + pub limit: i64, + #[serde(default = "default_verify_code_interval")] + pub interval: i64, +} + +fn default_verify_code_expire() -> i64 { 300 } +fn default_verify_code_limit() -> i64 { 15 } +fn default_verify_code_interval() -> i64 { 60 } + +#[derive(Debug, Clone, Deserialize)] +#[serde(rename_all = "PascalCase")] +pub struct Log { + #[serde(default = "default_log_auto_clear")] + pub auto_clear: bool, + #[serde(default = "default_log_clear_days")] + pub clear_days: i64, +} + +fn default_log_auto_clear() -> bool { true } +fn default_log_clear_days() -> i64 { 7 } + +#[derive(Debug, Clone, Deserialize)] +#[serde(rename_all = "PascalCase")] +pub struct Currency { + #[serde(default = "default_currency_unit")] + pub unit: String, + #[serde(default = "default_currency_symbol")] + pub symbol: String, + #[serde(default)] + pub access_key: String, +} + +fn default_currency_unit() -> String { "CNY".into() } +fn default_currency_symbol() -> String { "¥".into() } + +#[derive(Debug, Clone, Deserialize)] +#[serde(rename_all = "PascalCase")] +pub struct PluginConfig { + #[serde(default = "default_plugin_enabled")] + pub enabled: bool, + #[serde(default = "default_plugin_directory")] + pub directory: String, + #[serde(default = "default_plugin_max_memory")] + pub max_memory_mb: i64, + #[serde(default = "default_plugin_timeout")] + pub timeout_sec: i64, + #[serde(default)] + pub allow_list: Vec, + #[serde(default)] + pub block_list: Vec, +} + +fn default_plugin_enabled() -> bool { true } +fn default_plugin_directory() -> String { "plugins".into() } +fn default_plugin_max_memory() -> i64 { 64 } +fn default_plugin_timeout() -> i64 { 30 } + +#[derive(Debug, Clone, Deserialize)] +#[serde(rename_all = "PascalCase")] +pub struct Administrator { + #[serde(default = "default_admin_email")] + pub email: String, + #[serde(default = "default_admin_password")] + pub password: String, +} + +fn default_admin_email() -> String { "admin@ppanel.dev".into() } +fn default_admin_password() -> String { "password".into() } + +// ─── Trace / OpenTelemetry ─────────────────────────────────────────────────── + +/// Mirrors `pkg/trace/config.go → Config`. +/// +/// Supported batcher values: `"jaeger"` | `"zipkin"` | `"otlpgrpc"` | `"otlphttp"` | `"stdout"` +/// Leave `endpoint` empty (or set `disabled = true`) to disable tracing. +#[derive(Debug, Clone, Deserialize, Default)] +#[serde(rename_all = "PascalCase")] +pub struct TraceConfig { + /// Service name reported to the tracing backend (default "ppanel"). + #[serde(default = "default_trace_name")] + pub name: String, + + /// Exporter endpoint URL (e.g. `http://localhost:14268/api/traces` for Jaeger HTTP). + #[serde(default)] + pub endpoint: String, + + /// Fraction of traces to sample, 0.0–1.0 (default 1.0 = 100 %). + #[serde(default = "default_trace_sampler")] + pub sampler: f64, + + /// Exporter backend: `jaeger` | `otlpgrpc` | `otlphttp` | `stdout`. + #[serde(default = "default_trace_batcher")] + pub batcher: String, + + /// Extra headers forwarded to the OTLP exporter. + #[serde(default)] + pub otlp_headers: std::collections::HashMap, + + /// URL path override for OTLP HTTP (e.g. `"/v1/traces"`). + #[serde(default)] + pub otlp_http_path: String, + + /// Use TLS for OTLP HTTP transport. + #[serde(default)] + pub otlp_http_secure: bool, + + /// Disable tracing entirely (shortcut to skip initialisation). + #[serde(default)] + pub disabled: bool, +} + +fn default_trace_name() -> String { "ppanel".into() } +fn default_trace_sampler() -> f64 { 1.0 } +fn default_trace_batcher() -> String { "stdout".into() } + +#[derive(Debug, Clone, Deserialize)] +#[serde(rename_all = "PascalCase")] +pub struct LogConfig { + #[serde(default = "default_log_service_name")] + pub service_name: String, + #[serde(default = "default_log_mode")] + pub mode: String, + #[serde(default = "default_log_encoding")] + pub encoding: String, + #[serde(default = "default_log_time_format")] + pub time_format: String, + #[serde(default = "default_log_path")] + pub path: String, + #[serde(default = "default_log_level")] + pub level: String, + #[serde(default)] + pub max_content_length: u32, + #[serde(default)] + pub compress: bool, + #[serde(default = "default_log_stat")] + pub stat: bool, + #[serde(default)] + pub keep_days: i32, + #[serde(default = "default_log_stack_cooldown")] + pub stack_cooldown_millis: i32, + #[serde(default)] + pub max_backups: i32, + #[serde(default)] + pub max_size: i32, + #[serde(default = "default_log_rotation")] + pub rotation: String, + #[serde(default = "default_log_file_time_format")] + pub file_time_format: String, +} + +fn default_log_service_name() -> String { "PPanel".into() } +fn default_log_mode() -> String { "file".into() } +fn default_log_encoding() -> String { "json".into() } +fn default_log_time_format() -> String { "2006-01-02 15:04:05.000".into() } +fn default_log_path() -> String { "logs".into() } +fn default_log_level() -> String { "info".into() } +fn default_log_stat() -> bool { true } +fn default_log_stack_cooldown() -> i32 { 100 } +fn default_log_rotation() -> String { "daily".into() } +fn default_log_file_time_format() -> String { "2006-01-02T15:04:05.000Z07:00".into() } + +#[derive(Debug, Clone, Deserialize)] +#[serde(rename_all = "PascalCase")] +pub struct DatabaseConfig { + #[serde(default = "default_db_driver")] + pub driver: String, + #[serde(default)] + pub addr: Option, + #[serde(default)] + pub username: String, + #[serde(default)] + pub password: String, + #[serde(default)] + pub dbname: String, + #[serde(default = "default_db_config")] + pub config: String, + #[serde(default = "default_db_max_idle")] + pub max_idle_conns: i32, + #[serde(default = "default_db_max_open")] + pub max_open_conns: i32, + #[serde(default = "default_db_slow_threshold")] + pub slow_threshold: i64, +} + +fn default_db_driver() -> String { "mysql".into() } +fn default_db_config() -> String { "charset=utf8mb4&parseTime=true&loc=Asia%2FShanghai".into() } +fn default_db_max_idle() -> i32 { 10 } +fn default_db_max_open() -> i32 { 10 } +fn default_db_slow_threshold() -> i64 { 1000 } + +// ═══════════════════════════════════════════════════════════════════════════ +// Default trait — enables #[serde(default)] on all optional fields +// ═══════════════════════════════════════════════════════════════════════════ + +macro_rules! impl_default { + ($ty:ty { $($field:ident: $val:expr),* $(,)? }) => { + impl Default for $ty { + fn default() -> Self { + Self { $($field: $val),* } + } + } + }; +} + +impl_default!(Config { + model: default_model(), + host: default_host(), + port: default_port(), + debug: false, + transport: TransportConfig::default(), + tls: Tls::default(), + jwt_auth: JwtAuth::default(), + logger: LogConfig::default(), + database: DatabaseConfig::default(), + mysql: None, + redis: RedisConfig::default(), + site: SiteConfig::default(), + node: NodeConfig::default(), + mobile: MobileConfig::default(), + email: EmailConfig::default(), + device: DeviceConfig::default(), + verify: Verify::default(), + verify_code: VerifyCode::default(), + register: RegisterConfig::default(), + subscribe: SubscribeConfig::default(), + invite: InviteConfig::default(), + telegram: Telegram::default(), + log: Log::default(), + currency: Currency::default(), + plugin: PluginConfig::default(), + trace: TraceConfig::default(), + administrator: Administrator::default(), +}); + +impl_default!(RedisConfig { host: default_redis_host(), pass: String::new(), db: 0 }); +impl_default!(TransportConfig { driver: default_transport_driver() }); +impl_default!(JwtAuth { access_secret: String::new(), access_expire: default_access_expire() }); +impl_default!(Verify { turnstile_site_key: String::new(), turnstile_secret: String::new(), login_verify: false, register_verify: false, reset_password_verify: false }); +impl_default!(SubscribeConfig { single_model: false, subscribe_path: default_subscribe_path(), subscribe_domain: String::new(), pan_domain: false, user_agent_limit: false, user_agent_list: String::new(), show_tutorial: default_show_tutorial() }); +impl_default!(RegisterConfig { stop_register: false, enable_trial: false, trial_subscribe: 0, trial_time: 0, trial_time_unit: String::new(), ip_register_limit: 0, ip_register_limit_duration: 0, enable_ip_register_limit: false }); +impl_default!(EmailConfig { enable: default_email_enable(), platform: String::new(), platform_config: String::new(), enable_verify: false, enable_notify: false, enable_domain_suffix: false, domain_suffix_list: String::new(), verify_email_template: String::new(), expiration_email_template: String::new(), maintenance_email_template: String::new(), traffic_exceed_email_template: String::new() }); +impl_default!(MobileConfig { enable: default_mobile_enable(), platform: String::new(), platform_config: String::new(), enable_verify: false, enable_whitelist: false, whitelist: Vec::new() }); +impl_default!(DeviceConfig { enable: default_device_enable(), show_ads: false, enable_security: false, only_real_device: false, security_secret: String::new() }); +impl_default!(SiteConfig { host: String::new(), site_name: String::new(), site_desc: String::new(), site_logo: String::new(), keywords: String::new(), custom_html: String::new(), custom_data: String::new() }); +impl_default!(NodeConfig { node_secret: String::new(), node_pull_interval: default_node_pull_interval(), node_push_interval: default_node_push_interval(), traffic_report_threshold: 0, ip_strategy: String::new(), dns: Vec::new(), block: Vec::new(), outbound: Vec::new() }); +impl_default!(NodeDns { proto: String::new(), address: String::new(), domains: Vec::new() }); +impl_default!(NodeOutbound { name: String::new(), protocol: String::new(), address: String::new(), port: 0, user: String::new(), password: String::new(), uuid: String::new(), cipher: String::new(), security: String::new(), sni: String::new(), allow_insecure: false, fingerprint: String::new(), transport: String::new(), host: String::new(), path: String::new(), service_name: String::new(), flow: String::new(), uot: false, uot_version: 0, congestion_controller: String::new(), udp_stream: false, reduce_rtt: false, heartbeat: 0, reality_public_key: String::new(), reality_short_id: String::new(), spider_x: String::new(), settings: String::new(), stream_settings: String::new(), rules: Vec::new() }); +impl_default!(InviteConfig { forced_invite: false, referral_percentage: 0, only_first_purchase: false }); +impl_default!(Telegram { enable: false, bot_id: 0, bot_name: String::new(), bot_token: String::new(), enable_notify: false, web_hook_domain: String::new() }); +impl_default!(Tls { enable: false, cert_file: String::new(), key_file: String::new() }); +impl_default!(VerifyCode { expire_time: default_verify_code_expire(), limit: default_verify_code_limit(), interval: default_verify_code_interval() }); +impl_default!(Log { auto_clear: default_log_auto_clear(), clear_days: default_log_clear_days() }); +impl_default!(Currency { unit: default_currency_unit(), symbol: default_currency_symbol(), access_key: String::new() }); +impl_default!(PluginConfig { enabled: default_plugin_enabled(), directory: default_plugin_directory(), max_memory_mb: default_plugin_max_memory(), timeout_sec: default_plugin_timeout(), allow_list: Vec::new(), block_list: Vec::new() }); +impl_default!(Administrator { email: default_admin_email(), password: default_admin_password() }); +impl_default!(LogConfig { + service_name: default_log_service_name(), + mode: default_log_mode(), + encoding: default_log_encoding(), + time_format: default_log_time_format(), + path: default_log_path(), + level: default_log_level(), + max_content_length: 0, + compress: false, + stat: default_log_stat(), + keep_days: 0, + stack_cooldown_millis: default_log_stack_cooldown(), + max_backups: 0, + max_size: 0, + rotation: default_log_rotation(), + file_time_format: default_log_file_time_format(), +}); +impl_default!(DatabaseConfig { driver: default_db_driver(), addr: None, username: String::new(), password: String::new(), dbname: String::new(), config: default_db_config(), max_idle_conns: default_db_max_idle(), max_open_conns: default_db_max_open(), slow_threshold: default_db_slow_threshold() }); diff --git a/src/config/protocol.rs b/src/config/protocol.rs new file mode 100644 index 00000000..1c58cc64 --- /dev/null +++ b/src/config/protocol.rs @@ -0,0 +1,34 @@ +/// Supported proxy protocols, ported from `server/internal/config/protocol.go`. + +#[derive(Debug, Clone, Copy, PartialEq, Eq)] +pub enum Protocol { + Shadowsocks, + Trojan, + Vmess, + Vless, +} + +impl Protocol { + pub fn as_str(&self) -> &'static str { + match self { + Protocol::Shadowsocks => "shadowsocks", + Protocol::Trojan => "trojan", + Protocol::Vmess => "vmess", + Protocol::Vless => "vless", + } + } +} + +impl TryFrom<&str> for Protocol { + type Error = String; + + fn try_from(s: &str) -> Result { + match s { + "shadowsocks" => Ok(Protocol::Shadowsocks), + "trojan" => Ok(Protocol::Trojan), + "vmess" => Ok(Protocol::Vmess), + "vless" => Ok(Protocol::Vless), + other => Err(format!("unknown protocol: {other}")), + } + } +} diff --git a/src/db.rs b/src/db.rs new file mode 100644 index 00000000..d5dc6732 --- /dev/null +++ b/src/db.rs @@ -0,0 +1,89 @@ +//! Database connection initialisation. +//! +//! Reads the [`DatabaseConfig`] and creates a native `Pool` or +//! `Pool` wrapped in [`Db`]. Using concrete pool types (instead of +//! `AnyPool`) lets each repository impl write native SQL with `$N` / `?` +//! placeholders and use `FromRow` against the correct row type. + +use std::time::Duration; + +use crate::config::DatabaseConfig; +use crate::repository::{Db, Dialect}; + +/// Build a pool + dialect wrapper from the config subsection. +pub async fn init_pool(cfg: &DatabaseConfig) -> Result { + let dsn = build_dsn(cfg); + let dialect = detect_dialect(cfg); + + let max_conn = cfg.max_open_conns.max(1) as u32; + + match dialect { + Dialect::Postgres => { + let pool = sqlx::postgres::PgPoolOptions::new() + .max_connections(max_conn) + .acquire_timeout(Duration::from_secs(10)) + .connect(&dsn) + .await?; + Ok(Db::new_pg(pool)) + } + Dialect::Mysql => { + let pool = sqlx::mysql::MySqlPoolOptions::new() + .max_connections(max_conn) + .acquire_timeout(Duration::from_secs(10)) + .connect(&dsn) + .await?; + Ok(Db::new_mysql(pool)) + } + } +} + +// ─── DSN builder ───────────────────────────────────────────────────────── + +fn build_dsn(cfg: &DatabaseConfig) -> String { + let addr = cfg + .addr + .as_deref() + .filter(|a| !a.is_empty()) + .unwrap_or(match detect_dialect(cfg) { + Dialect::Postgres => "localhost:5432", + Dialect::Mysql => "localhost:3306", + }); + let password = url_encode_password(&cfg.password); + let query = if cfg.config.is_empty() { + default_query(cfg) + } else { + &cfg.config + }; + + match detect_dialect(cfg) { + Dialect::Postgres => format!( + "postgres://{}:{}@{}/{}?{}", + cfg.username, password, addr, cfg.dbname, query, + ), + Dialect::Mysql => format!( + "mysql://{}:{}@{}/{}?{}", + cfg.username, password, addr, cfg.dbname, query, + ), + } +} + +fn detect_dialect(cfg: &DatabaseConfig) -> Dialect { + Dialect::from_driver(&cfg.driver) +} + +fn default_query(cfg: &DatabaseConfig) -> &'static str { + match detect_dialect(cfg) { + Dialect::Postgres => "sslmode=disable&TimeZone=Asia/Shanghai", + Dialect::Mysql => "charset=utf8mb4&parseTime=true&loc=Asia%2FShanghai", + } +} + +fn url_encode_password(s: &str) -> String { + s.chars() + .map(|c| match c { + 'a'..='z' | 'A'..='Z' | '0'..='9' | '-' | '_' | '.' | '~' => c.to_string(), + ' ' => "%20".into(), + other => format!("%{:02X}", other as u8), + }) + .collect() +} diff --git a/src/handler/admin/ads/create_ads_handler.rs b/src/handler/admin/ads/create_ads_handler.rs new file mode 100644 index 00000000..b61f2dbd --- /dev/null +++ b/src/handler/admin/ads/create_ads_handler.rs @@ -0,0 +1,17 @@ +use axum::extract::State; +use axum::Json; + +use crate::handler::AppState; +use crate::model::dto::*; +use crate::service::admin::ads::create_ads_service; +use result::http_result::{build_http_result, HttpResult}; + +pub async fn create_ads( + State(state): State, + Json(req): Json, +) -> HttpResult { + match create_ads_service::create_ads(state.repos.ads.as_ref(), req).await { + Ok(data) => build_http_result(Some(data), None), + Err(e) => build_http_result::<()>(None, Some(e)), + } +} diff --git a/src/handler/admin/ads/delete_ads_handler.rs b/src/handler/admin/ads/delete_ads_handler.rs new file mode 100644 index 00000000..db924ced --- /dev/null +++ b/src/handler/admin/ads/delete_ads_handler.rs @@ -0,0 +1,17 @@ +use axum::extract::State; +use axum::Json; + +use crate::handler::AppState; +use crate::model::dto::*; +use crate::service::admin::ads::delete_ads_service; +use result::http_result::{build_http_result, HttpResult}; + +pub async fn delete_ads( + State(state): State, + Json(req): Json, +) -> HttpResult { + match delete_ads_service::delete_ads(state.repos.ads.as_ref(), req.id).await { + Ok(data) => build_http_result(Some(data), None), + Err(e) => build_http_result::<()>(None, Some(e)), + } +} diff --git a/src/handler/admin/ads/get_ads_detail_handler.rs b/src/handler/admin/ads/get_ads_detail_handler.rs new file mode 100644 index 00000000..23594a4f --- /dev/null +++ b/src/handler/admin/ads/get_ads_detail_handler.rs @@ -0,0 +1,16 @@ +use axum::extract::{Query, State}; + +use crate::handler::AppState; +use crate::model::dto::*; +use crate::service::admin::ads::get_ads_detail_service; +use result::http_result::{build_http_result, HttpResult}; + +pub async fn get_ads_detail( + State(state): State, + Query(req): Query, +) -> HttpResult { + match get_ads_detail_service::get_ads_detail(state.repos.ads.as_ref(), req.id).await { + Ok(data) => build_http_result(Some(data), None), + Err(e) => build_http_result::<()>(None, Some(e)), + } +} diff --git a/src/handler/admin/ads/get_ads_list_handler.rs b/src/handler/admin/ads/get_ads_list_handler.rs new file mode 100644 index 00000000..971bcd18 --- /dev/null +++ b/src/handler/admin/ads/get_ads_list_handler.rs @@ -0,0 +1,16 @@ +use axum::extract::{Query, State}; + +use crate::handler::AppState; +use crate::model::dto::*; +use crate::service::admin::ads::get_ads_list_service; +use result::http_result::{build_http_result, HttpResult}; + +pub async fn get_ads_list( + State(state): State, + Query(req): Query, +) -> HttpResult { + match get_ads_list_service::get_ads_list(state.repos.ads.as_ref(), req).await { + Ok(data) => build_http_result(Some(data), None), + Err(e) => build_http_result::<()>(None, Some(e)), + } +} diff --git a/src/handler/admin/ads/mod.rs b/src/handler/admin/ads/mod.rs new file mode 100644 index 00000000..38524f1f --- /dev/null +++ b/src/handler/admin/ads/mod.rs @@ -0,0 +1,10 @@ +mod create_ads_handler; +pub use create_ads_handler::create_ads; +mod update_ads_handler; +pub use update_ads_handler::update_ads; +mod delete_ads_handler; +pub use delete_ads_handler::delete_ads; +mod get_ads_detail_handler; +pub use get_ads_detail_handler::get_ads_detail; +mod get_ads_list_handler; +pub use get_ads_list_handler::get_ads_list; diff --git a/src/handler/admin/ads/update_ads_handler.rs b/src/handler/admin/ads/update_ads_handler.rs new file mode 100644 index 00000000..c39e24b9 --- /dev/null +++ b/src/handler/admin/ads/update_ads_handler.rs @@ -0,0 +1,17 @@ +use axum::extract::State; +use axum::Json; + +use crate::handler::AppState; +use crate::model::dto::*; +use crate::service::admin::ads::update_ads_service; +use result::http_result::{build_http_result, HttpResult}; + +pub async fn update_ads( + State(state): State, + Json(req): Json, +) -> HttpResult { + match update_ads_service::update_ads(state.repos.ads.as_ref(), req).await { + Ok(data) => build_http_result(Some(data), None), + Err(e) => build_http_result::<()>(None, Some(e)), + } +} diff --git a/src/handler/admin/announcement/create_announcement_handler.rs b/src/handler/admin/announcement/create_announcement_handler.rs new file mode 100644 index 00000000..025b963d --- /dev/null +++ b/src/handler/admin/announcement/create_announcement_handler.rs @@ -0,0 +1,17 @@ +use axum::extract::State; +use axum::Json; + +use crate::handler::AppState; +use crate::model::dto::*; +use crate::service::admin::announcement::create_announcement_service; +use result::http_result::{build_http_result, HttpResult}; + +pub async fn create_announcement( + State(state): State, + Json(req): Json, +) -> HttpResult { + match create_announcement_service::create_announcement(state.repos.announcement.as_ref(), req).await { + Ok(data) => build_http_result(Some(data), None), + Err(e) => build_http_result::<()>(None, Some(e)), + } +} diff --git a/src/handler/admin/announcement/delete_announcement_handler.rs b/src/handler/admin/announcement/delete_announcement_handler.rs new file mode 100644 index 00000000..95327151 --- /dev/null +++ b/src/handler/admin/announcement/delete_announcement_handler.rs @@ -0,0 +1,17 @@ +use axum::extract::State; +use axum::Json; + +use crate::handler::AppState; +use crate::model::dto::*; +use crate::service::admin::announcement::delete_announcement_service; +use result::http_result::{build_http_result, HttpResult}; + +pub async fn delete_announcement( + State(state): State, + Json(req): Json, +) -> HttpResult { + match delete_announcement_service::delete_announcement(state.repos.announcement.as_ref(), req.id).await { + Ok(data) => build_http_result(Some(data), None), + Err(e) => build_http_result::<()>(None, Some(e)), + } +} diff --git a/src/handler/admin/announcement/get_announcement_handler.rs b/src/handler/admin/announcement/get_announcement_handler.rs new file mode 100644 index 00000000..2c19c064 --- /dev/null +++ b/src/handler/admin/announcement/get_announcement_handler.rs @@ -0,0 +1,16 @@ +use axum::extract::{Query, State}; + +use crate::handler::AppState; +use crate::model::dto::*; +use crate::service::admin::announcement::get_announcement_service; +use result::http_result::{build_http_result, HttpResult}; + +pub async fn get_announcement( + State(state): State, + Query(req): Query, +) -> HttpResult { + match get_announcement_service::get_announcement(state.repos.announcement.as_ref(), req.id).await { + Ok(data) => build_http_result(Some(data), None), + Err(e) => build_http_result::<()>(None, Some(e)), + } +} diff --git a/src/handler/admin/announcement/get_announcement_list_handler.rs b/src/handler/admin/announcement/get_announcement_list_handler.rs new file mode 100644 index 00000000..220a38d6 --- /dev/null +++ b/src/handler/admin/announcement/get_announcement_list_handler.rs @@ -0,0 +1,16 @@ +use axum::extract::{Query, State}; + +use crate::handler::AppState; +use crate::model::dto::*; +use crate::service::admin::announcement::get_announcement_list_service; +use result::http_result::{build_http_result, HttpResult}; + +pub async fn get_announcement_list( + State(state): State, + Query(req): Query, +) -> HttpResult { + match get_announcement_list_service::get_announcement_list(state.repos.announcement.as_ref(), req).await { + Ok(data) => build_http_result(Some(data), None), + Err(e) => build_http_result::<()>(None, Some(e)), + } +} diff --git a/src/handler/admin/announcement/mod.rs b/src/handler/admin/announcement/mod.rs new file mode 100644 index 00000000..cd66abe6 --- /dev/null +++ b/src/handler/admin/announcement/mod.rs @@ -0,0 +1,10 @@ +mod create_announcement_handler; +pub use create_announcement_handler::create_announcement; +mod update_announcement_handler; +pub use update_announcement_handler::update_announcement; +mod delete_announcement_handler; +pub use delete_announcement_handler::delete_announcement; +mod get_announcement_handler; +pub use get_announcement_handler::get_announcement; +mod get_announcement_list_handler; +pub use get_announcement_list_handler::get_announcement_list; diff --git a/src/handler/admin/announcement/update_announcement_handler.rs b/src/handler/admin/announcement/update_announcement_handler.rs new file mode 100644 index 00000000..dd5dad1a --- /dev/null +++ b/src/handler/admin/announcement/update_announcement_handler.rs @@ -0,0 +1,17 @@ +use axum::extract::State; +use axum::Json; + +use crate::handler::AppState; +use crate::model::dto::*; +use crate::service::admin::announcement::update_announcement_service; +use result::http_result::{build_http_result, HttpResult}; + +pub async fn update_announcement( + State(state): State, + Json(req): Json, +) -> HttpResult { + match update_announcement_service::update_announcement(state.repos.announcement.as_ref(), req).await { + Ok(data) => build_http_result(Some(data), None), + Err(e) => build_http_result::<()>(None, Some(e)), + } +} diff --git a/src/handler/admin/application/create_subscribe_application_handler.rs b/src/handler/admin/application/create_subscribe_application_handler.rs new file mode 100644 index 00000000..e9ba07df --- /dev/null +++ b/src/handler/admin/application/create_subscribe_application_handler.rs @@ -0,0 +1,22 @@ +use axum::extract::State; +use axum::Json; + +use crate::handler::AppState; +use crate::model::dto::*; +use crate::service::admin::application::create_subscribe_application_service; +use result::http_result::{build_http_result, HttpResult}; + +pub async fn create_subscribe_application( + State(state): State, + Json(req): Json, +) -> HttpResult { + match create_subscribe_application_service::create_subscribe_application( + state.repos.client.as_ref(), + req, + ) + .await + { + Ok(data) => build_http_result(Some(data), None), + Err(e) => build_http_result::<()>(None, Some(e)), + } +} diff --git a/src/handler/admin/application/delete_subscribe_application_handler.rs b/src/handler/admin/application/delete_subscribe_application_handler.rs new file mode 100644 index 00000000..84ba11fe --- /dev/null +++ b/src/handler/admin/application/delete_subscribe_application_handler.rs @@ -0,0 +1,22 @@ +use axum::extract::State; +use axum::Json; + +use crate::handler::AppState; +use crate::model::dto::*; +use crate::service::admin::application::delete_subscribe_application_service; +use result::http_result::{build_http_result, HttpResult}; + +pub async fn delete_subscribe_application( + State(state): State, + Json(req): Json, +) -> HttpResult { + match delete_subscribe_application_service::delete_subscribe_application( + state.repos.client.as_ref(), + req.id, + ) + .await + { + Ok(data) => build_http_result(Some(data), None), + Err(e) => build_http_result::<()>(None, Some(e)), + } +} diff --git a/src/handler/admin/application/get_subscribe_application_list_handler.rs b/src/handler/admin/application/get_subscribe_application_list_handler.rs new file mode 100644 index 00000000..4f559209 --- /dev/null +++ b/src/handler/admin/application/get_subscribe_application_list_handler.rs @@ -0,0 +1,21 @@ +use axum::extract::{Query, State}; + +use crate::handler::AppState; +use crate::model::dto::*; +use crate::service::admin::application::get_subscribe_application_list_service; +use result::http_result::{build_http_result, HttpResult}; + +pub async fn get_subscribe_application_list( + State(state): State, + Query(req): Query, +) -> HttpResult { + match get_subscribe_application_list_service::get_subscribe_application_list( + state.repos.client.as_ref(), + req, + ) + .await + { + Ok(data) => build_http_result(Some(data), None), + Err(e) => build_http_result::<()>(None, Some(e)), + } +} diff --git a/src/handler/admin/application/mod.rs b/src/handler/admin/application/mod.rs new file mode 100644 index 00000000..6dd5cb70 --- /dev/null +++ b/src/handler/admin/application/mod.rs @@ -0,0 +1,10 @@ +mod create_subscribe_application_handler; +pub use create_subscribe_application_handler::create_subscribe_application; +mod update_subscribe_application_handler; +pub use update_subscribe_application_handler::update_subscribe_application; +mod delete_subscribe_application_handler; +pub use delete_subscribe_application_handler::delete_subscribe_application; +mod get_subscribe_application_list_handler; +pub use get_subscribe_application_list_handler::get_subscribe_application_list; +mod preview_subscribe_template_handler; +pub use preview_subscribe_template_handler::preview_subscribe_template; diff --git a/src/handler/admin/application/preview_subscribe_template_handler.rs b/src/handler/admin/application/preview_subscribe_template_handler.rs new file mode 100644 index 00000000..150519de --- /dev/null +++ b/src/handler/admin/application/preview_subscribe_template_handler.rs @@ -0,0 +1,21 @@ +use axum::extract::{Query, State}; + +use crate::handler::AppState; +use crate::model::dto::*; +use crate::service::admin::application::preview_subscribe_template_service; +use result::http_result::{build_http_result, HttpResult}; + +pub async fn preview_subscribe_template( + State(state): State, + Query(req): Query, +) -> HttpResult { + match preview_subscribe_template_service::preview_subscribe_template( + state.repos.client.as_ref(), + req, + ) + .await + { + Ok(data) => build_http_result(Some(data), None), + Err(e) => build_http_result::<()>(None, Some(e)), + } +} diff --git a/src/handler/admin/application/update_subscribe_application_handler.rs b/src/handler/admin/application/update_subscribe_application_handler.rs new file mode 100644 index 00000000..665305d8 --- /dev/null +++ b/src/handler/admin/application/update_subscribe_application_handler.rs @@ -0,0 +1,22 @@ +use axum::extract::State; +use axum::Json; + +use crate::handler::AppState; +use crate::model::dto::*; +use crate::service::admin::application::update_subscribe_application_service; +use result::http_result::{build_http_result, HttpResult}; + +pub async fn update_subscribe_application( + State(state): State, + Json(req): Json, +) -> HttpResult { + match update_subscribe_application_service::update_subscribe_application( + state.repos.client.as_ref(), + req, + ) + .await + { + Ok(data) => build_http_result(Some(data), None), + Err(e) => build_http_result::<()>(None, Some(e)), + } +} diff --git a/src/handler/admin/auth_method/get_auth_method_config_handler.rs b/src/handler/admin/auth_method/get_auth_method_config_handler.rs new file mode 100644 index 00000000..a8f93fbf --- /dev/null +++ b/src/handler/admin/auth_method/get_auth_method_config_handler.rs @@ -0,0 +1,15 @@ +use axum::extract::{Query, State}; +use crate::handler::AppState; +use crate::model::dto::auth::GetAuthMethodConfigRequest; +use crate::service::admin::auth_method::get_auth_method_list_service; +use result::http_result::{build_http_result, HttpResult}; + +pub async fn get_auth_method_config( + State(state): State, + Query(req): Query, +) -> HttpResult { + match get_auth_method_list_service::get_auth_method_config(state.repos.auth.as_ref(), req).await { + Ok(d) => build_http_result(Some(d), None), + Err(e) => build_http_result::<()>(None, Some(e)), + } +} diff --git a/src/handler/admin/auth_method/get_auth_method_list_handler.rs b/src/handler/admin/auth_method/get_auth_method_list_handler.rs new file mode 100644 index 00000000..b58f7d1d --- /dev/null +++ b/src/handler/admin/auth_method/get_auth_method_list_handler.rs @@ -0,0 +1,13 @@ +use axum::extract::State; +use crate::handler::AppState; +use crate::service::admin::auth_method::get_auth_method_list_service; +use result::http_result::{build_http_result, HttpResult}; + +pub async fn get_auth_method_list( + State(state): State, +) -> HttpResult { + match get_auth_method_list_service::get_auth_method_list(state.repos.auth.as_ref()).await { + Ok(d) => build_http_result(Some(d), None), + Err(e) => build_http_result::<()>(None, Some(e)), + } +} diff --git a/src/handler/admin/auth_method/get_email_platform_handler.rs b/src/handler/admin/auth_method/get_email_platform_handler.rs new file mode 100644 index 00000000..889c4884 --- /dev/null +++ b/src/handler/admin/auth_method/get_email_platform_handler.rs @@ -0,0 +1,13 @@ +use axum::extract::State; +use crate::handler::AppState; +use crate::service::admin::auth_method::get_auth_method_list_service; +use result::http_result::{build_http_result, HttpResult}; + +pub async fn get_email_platform( + State(_state): State, +) -> HttpResult { + match get_auth_method_list_service::get_email_platform().await { + Ok(d) => build_http_result(Some(d), None), + Err(e) => build_http_result::<()>(None, Some(e)), + } +} diff --git a/src/handler/admin/auth_method/get_sms_platform_handler.rs b/src/handler/admin/auth_method/get_sms_platform_handler.rs new file mode 100644 index 00000000..3dec5bef --- /dev/null +++ b/src/handler/admin/auth_method/get_sms_platform_handler.rs @@ -0,0 +1,13 @@ +use axum::extract::State; +use crate::handler::AppState; +use crate::service::admin::auth_method::get_auth_method_list_service; +use result::http_result::{build_http_result, HttpResult}; + +pub async fn get_sms_platform( + State(_state): State, +) -> HttpResult { + match get_auth_method_list_service::get_sms_platform().await { + Ok(d) => build_http_result(Some(d), None), + Err(e) => build_http_result::<()>(None, Some(e)), + } +} diff --git a/src/handler/admin/auth_method/mod.rs b/src/handler/admin/auth_method/mod.rs new file mode 100644 index 00000000..237fa4b7 --- /dev/null +++ b/src/handler/admin/auth_method/mod.rs @@ -0,0 +1,14 @@ +mod get_auth_method_config_handler; +pub use get_auth_method_config_handler::get_auth_method_config; +mod update_auth_method_config_handler; +pub use update_auth_method_config_handler::update_auth_method_config; +mod get_auth_method_list_handler; +pub use get_auth_method_list_handler::get_auth_method_list; +mod get_email_platform_handler; +pub use get_email_platform_handler::get_email_platform; +mod get_sms_platform_handler; +pub use get_sms_platform_handler::get_sms_platform; +mod test_email_send_handler; +pub use test_email_send_handler::test_email_send; +mod test_sms_send_handler; +pub use test_sms_send_handler::test_sms_send; diff --git a/src/handler/admin/auth_method/test_email_send_handler.rs b/src/handler/admin/auth_method/test_email_send_handler.rs new file mode 100644 index 00000000..974b6bf4 --- /dev/null +++ b/src/handler/admin/auth_method/test_email_send_handler.rs @@ -0,0 +1,21 @@ +use axum::extract::State; +use axum::Json; +use crate::handler::AppState; +use crate::service::admin::auth_method::get_auth_method_list_service; +use result::http_result::{build_http_result, HttpResult}; +use serde::Deserialize; + +#[derive(Debug, Deserialize)] +pub struct TestEmailSendRequest { + pub to: String, +} + +pub async fn test_email_send( + State(state): State, + Json(req): Json, +) -> HttpResult { + match get_auth_method_list_service::test_email_send(&state.config, req.to).await { + Ok(_) => build_http_result(Some(()), None), + Err(e) => build_http_result::<()>(None, Some(e)), + } +} diff --git a/src/handler/admin/auth_method/test_sms_send_handler.rs b/src/handler/admin/auth_method/test_sms_send_handler.rs new file mode 100644 index 00000000..0578e432 --- /dev/null +++ b/src/handler/admin/auth_method/test_sms_send_handler.rs @@ -0,0 +1,21 @@ +use axum::extract::State; +use axum::Json; +use crate::handler::AppState; +use crate::service::admin::auth_method::get_auth_method_list_service; +use result::http_result::{build_http_result, HttpResult}; +use serde::Deserialize; + +#[derive(Debug, Deserialize)] +pub struct TestSmsSendRequest { + pub to: String, +} + +pub async fn test_sms_send( + State(state): State, + Json(req): Json, +) -> HttpResult { + match get_auth_method_list_service::test_sms_send(&state.config, req.to).await { + Ok(_) => build_http_result(Some(()), None), + Err(e) => build_http_result::<()>(None, Some(e)), + } +} diff --git a/src/handler/admin/auth_method/update_auth_method_config_handler.rs b/src/handler/admin/auth_method/update_auth_method_config_handler.rs new file mode 100644 index 00000000..301baf8e --- /dev/null +++ b/src/handler/admin/auth_method/update_auth_method_config_handler.rs @@ -0,0 +1,16 @@ +use axum::extract::State; +use axum::Json; +use crate::handler::AppState; +use crate::model::dto::auth::UpdateAuthMethodConfigRequest; +use crate::service::admin::auth_method::get_auth_method_list_service; +use result::http_result::{build_http_result, HttpResult}; + +pub async fn update_auth_method_config( + State(state): State, + Json(req): Json, +) -> HttpResult { + match get_auth_method_list_service::update_auth_method_config(state.repos.auth.as_ref(), req).await { + Ok(_) => build_http_result(Some(()), None), + Err(e) => build_http_result::<()>(None, Some(e)), + } +} diff --git a/src/handler/admin/console/mod.rs b/src/handler/admin/console/mod.rs new file mode 100644 index 00000000..a8016a86 --- /dev/null +++ b/src/handler/admin/console/mod.rs @@ -0,0 +1,8 @@ +mod query_revenue_statistics_handler; +pub use query_revenue_statistics_handler::query_revenue_statistics; +mod query_server_total_data_handler; +pub use query_server_total_data_handler::query_server_total_data; +mod query_ticket_wait_reply_handler; +pub use query_ticket_wait_reply_handler::query_ticket_wait_reply; +mod query_user_statistics_handler; +pub use query_user_statistics_handler::query_user_statistics; diff --git a/src/handler/admin/console/query_revenue_statistics_handler.rs b/src/handler/admin/console/query_revenue_statistics_handler.rs new file mode 100644 index 00000000..99f4ba41 --- /dev/null +++ b/src/handler/admin/console/query_revenue_statistics_handler.rs @@ -0,0 +1,14 @@ +use axum::extract::State; + +use crate::handler::AppState; +use crate::service::admin::console::query_revenue_statistics_service; +use result::http_result::{build_http_result, HttpResult}; + +pub async fn query_revenue_statistics(State(state): State) -> HttpResult { + match query_revenue_statistics_service::query_revenue_statistics(state.repos.order.as_ref()) + .await + { + Ok(data) => build_http_result(Some(data), None), + Err(e) => build_http_result::<()>(None, Some(e)), + } +} diff --git a/src/handler/admin/console/query_server_total_data_handler.rs b/src/handler/admin/console/query_server_total_data_handler.rs new file mode 100644 index 00000000..3346426c --- /dev/null +++ b/src/handler/admin/console/query_server_total_data_handler.rs @@ -0,0 +1,12 @@ +use axum::extract::State; + +use crate::handler::AppState; +use crate::service::admin::console::query_server_total_data_service; +use result::http_result::{build_http_result, HttpResult}; + +pub async fn query_server_total_data(State(state): State) -> HttpResult { + match query_server_total_data_service::query_server_total_data(&state.repos).await { + Ok(data) => build_http_result(Some(data), None), + Err(e) => build_http_result::<()>(None, Some(e)), + } +} diff --git a/src/handler/admin/console/query_ticket_wait_reply_handler.rs b/src/handler/admin/console/query_ticket_wait_reply_handler.rs new file mode 100644 index 00000000..cb09f43b --- /dev/null +++ b/src/handler/admin/console/query_ticket_wait_reply_handler.rs @@ -0,0 +1,14 @@ +use axum::extract::State; + +use crate::handler::AppState; +use crate::service::admin::console::query_ticket_wait_reply_service; +use result::http_result::{build_http_result, HttpResult}; + +pub async fn query_ticket_wait_reply(State(state): State) -> HttpResult { + match query_ticket_wait_reply_service::query_ticket_wait_reply(state.repos.ticket.as_ref()) + .await + { + Ok(data) => build_http_result(Some(data), None), + Err(e) => build_http_result::<()>(None, Some(e)), + } +} diff --git a/src/handler/admin/console/query_user_statistics_handler.rs b/src/handler/admin/console/query_user_statistics_handler.rs new file mode 100644 index 00000000..b88d55f5 --- /dev/null +++ b/src/handler/admin/console/query_user_statistics_handler.rs @@ -0,0 +1,17 @@ +use axum::extract::State; + +use crate::handler::AppState; +use crate::service::admin::console::query_user_statistics_service; +use result::http_result::{build_http_result, HttpResult}; + +pub async fn query_user_statistics(State(state): State) -> HttpResult { + match query_user_statistics_service::query_user_statistics( + state.repos.user.as_ref(), + state.repos.order.as_ref(), + ) + .await + { + Ok(data) => build_http_result(Some(data), None), + Err(e) => build_http_result::<()>(None, Some(e)), + } +} diff --git a/src/handler/admin/coupon/batch_delete_coupon_handler.rs b/src/handler/admin/coupon/batch_delete_coupon_handler.rs new file mode 100644 index 00000000..a06175b7 --- /dev/null +++ b/src/handler/admin/coupon/batch_delete_coupon_handler.rs @@ -0,0 +1,17 @@ +use axum::extract::State; +use axum::Json; + +use crate::handler::AppState; +use crate::model::dto::*; +use crate::service::admin::coupon::batch_delete_coupon_service; +use result::http_result::{build_http_result, HttpResult}; + +pub async fn batch_delete_coupon( + State(state): State, + Json(req): Json, +) -> HttpResult { + match batch_delete_coupon_service::batch_delete_coupon(state.repos.coupon.as_ref(), req).await { + Ok(()) => build_http_result(Some(()), None), + Err(e) => build_http_result::<()>(None, Some(e)), + } +} diff --git a/src/handler/admin/coupon/create_coupon_handler.rs b/src/handler/admin/coupon/create_coupon_handler.rs new file mode 100644 index 00000000..6dab9454 --- /dev/null +++ b/src/handler/admin/coupon/create_coupon_handler.rs @@ -0,0 +1,17 @@ +use axum::extract::State; +use axum::Json; + +use crate::handler::AppState; +use crate::model::dto::*; +use crate::service::admin::coupon::create_coupon_service; +use result::http_result::{build_http_result, HttpResult}; + +pub async fn create_coupon( + State(state): State, + Json(req): Json, +) -> HttpResult { + match create_coupon_service::create_coupon(state.repos.coupon.as_ref(), req).await { + Ok(data) => build_http_result(Some(data), None), + Err(e) => build_http_result::<()>(None, Some(e)), + } +} diff --git a/src/handler/admin/coupon/delete_coupon_handler.rs b/src/handler/admin/coupon/delete_coupon_handler.rs new file mode 100644 index 00000000..6e3d3fc9 --- /dev/null +++ b/src/handler/admin/coupon/delete_coupon_handler.rs @@ -0,0 +1,17 @@ +use axum::extract::State; +use axum::Json; + +use crate::handler::AppState; +use crate::model::dto::*; +use crate::service::admin::coupon::delete_coupon_service; +use result::http_result::{build_http_result, HttpResult}; + +pub async fn delete_coupon( + State(state): State, + Json(req): Json, +) -> HttpResult { + match delete_coupon_service::delete_coupon(state.repos.coupon.as_ref(), req.id).await { + Ok(()) => build_http_result(Some(()), None), + Err(e) => build_http_result::<()>(None, Some(e)), + } +} diff --git a/src/handler/admin/coupon/get_coupon_list_handler.rs b/src/handler/admin/coupon/get_coupon_list_handler.rs new file mode 100644 index 00000000..c6df926a --- /dev/null +++ b/src/handler/admin/coupon/get_coupon_list_handler.rs @@ -0,0 +1,16 @@ +use axum::extract::{Query, State}; + +use crate::handler::AppState; +use crate::model::dto::*; +use crate::service::admin::coupon::get_coupon_list_service; +use result::http_result::{build_http_result, HttpResult}; + +pub async fn get_coupon_list( + State(state): State, + Query(req): Query, +) -> HttpResult { + match get_coupon_list_service::get_coupon_list(state.repos.coupon.as_ref(), req).await { + Ok(data) => build_http_result(Some(data), None), + Err(e) => build_http_result::<()>(None, Some(e)), + } +} diff --git a/src/handler/admin/coupon/mod.rs b/src/handler/admin/coupon/mod.rs new file mode 100644 index 00000000..f5469819 --- /dev/null +++ b/src/handler/admin/coupon/mod.rs @@ -0,0 +1,10 @@ +mod create_coupon_handler; +pub use create_coupon_handler::create_coupon; +mod update_coupon_handler; +pub use update_coupon_handler::update_coupon; +mod delete_coupon_handler; +pub use delete_coupon_handler::delete_coupon; +mod batch_delete_coupon_handler; +pub use batch_delete_coupon_handler::batch_delete_coupon; +mod get_coupon_list_handler; +pub use get_coupon_list_handler::get_coupon_list; diff --git a/src/handler/admin/coupon/update_coupon_handler.rs b/src/handler/admin/coupon/update_coupon_handler.rs new file mode 100644 index 00000000..c84b9b84 --- /dev/null +++ b/src/handler/admin/coupon/update_coupon_handler.rs @@ -0,0 +1,17 @@ +use axum::extract::State; +use axum::Json; + +use crate::handler::AppState; +use crate::model::dto::*; +use crate::service::admin::coupon::update_coupon_service; +use result::http_result::{build_http_result, HttpResult}; + +pub async fn update_coupon( + State(state): State, + Json(req): Json, +) -> HttpResult { + match update_coupon_service::update_coupon(state.repos.coupon.as_ref(), req).await { + Ok(()) => build_http_result(Some(()), None), + Err(e) => build_http_result::<()>(None, Some(e)), + } +} diff --git a/src/handler/admin/document/batch_delete_document_handler.rs b/src/handler/admin/document/batch_delete_document_handler.rs new file mode 100644 index 00000000..d794043c --- /dev/null +++ b/src/handler/admin/document/batch_delete_document_handler.rs @@ -0,0 +1,19 @@ +use axum::extract::State; +use axum::Json; + +use crate::handler::AppState; +use crate::model::dto::*; +use crate::service::admin::document::batch_delete_document_service; +use result::http_result::{build_http_result, HttpResult}; + +pub async fn batch_delete_document( + State(state): State, + Json(req): Json, +) -> HttpResult { + match batch_delete_document_service::batch_delete_document(state.repos.document.as_ref(), req) + .await + { + Ok(()) => build_http_result(Some(()), None), + Err(e) => build_http_result::<()>(None, Some(e)), + } +} diff --git a/src/handler/admin/document/create_document_handler.rs b/src/handler/admin/document/create_document_handler.rs new file mode 100644 index 00000000..32be63fd --- /dev/null +++ b/src/handler/admin/document/create_document_handler.rs @@ -0,0 +1,17 @@ +use axum::extract::State; +use axum::Json; + +use crate::handler::AppState; +use crate::model::dto::*; +use crate::service::admin::document::create_document_service; +use result::http_result::{build_http_result, HttpResult}; + +pub async fn create_document( + State(state): State, + Json(req): Json, +) -> HttpResult { + match create_document_service::create_document(state.repos.document.as_ref(), req).await { + Ok(data) => build_http_result(Some(data), None), + Err(e) => build_http_result::<()>(None, Some(e)), + } +} diff --git a/src/handler/admin/document/delete_document_handler.rs b/src/handler/admin/document/delete_document_handler.rs new file mode 100644 index 00000000..99d089b3 --- /dev/null +++ b/src/handler/admin/document/delete_document_handler.rs @@ -0,0 +1,17 @@ +use axum::extract::State; +use axum::Json; + +use crate::handler::AppState; +use crate::model::dto::*; +use crate::service::admin::document::delete_document_service; +use result::http_result::{build_http_result, HttpResult}; + +pub async fn delete_document( + State(state): State, + Json(req): Json, +) -> HttpResult { + match delete_document_service::delete_document(state.repos.document.as_ref(), req.id).await { + Ok(()) => build_http_result(Some(()), None), + Err(e) => build_http_result::<()>(None, Some(e)), + } +} diff --git a/src/handler/admin/document/get_document_detail_handler.rs b/src/handler/admin/document/get_document_detail_handler.rs new file mode 100644 index 00000000..443749d4 --- /dev/null +++ b/src/handler/admin/document/get_document_detail_handler.rs @@ -0,0 +1,18 @@ +use axum::extract::{Query, State}; + +use crate::handler::AppState; +use crate::model::dto::*; +use crate::service::admin::document::get_document_detail_service; +use result::http_result::{build_http_result, HttpResult}; + +pub async fn get_document_detail( + State(state): State, + Query(req): Query, +) -> HttpResult { + match get_document_detail_service::get_document_detail(state.repos.document.as_ref(), req.id) + .await + { + Ok(data) => build_http_result(Some(data), None), + Err(e) => build_http_result::<()>(None, Some(e)), + } +} diff --git a/src/handler/admin/document/get_document_list_handler.rs b/src/handler/admin/document/get_document_list_handler.rs new file mode 100644 index 00000000..969ee45f --- /dev/null +++ b/src/handler/admin/document/get_document_list_handler.rs @@ -0,0 +1,16 @@ +use axum::extract::{Query, State}; + +use crate::handler::AppState; +use crate::model::dto::*; +use crate::service::admin::document::get_document_list_service; +use result::http_result::{build_http_result, HttpResult}; + +pub async fn get_document_list( + State(state): State, + Query(req): Query, +) -> HttpResult { + match get_document_list_service::get_document_list(state.repos.document.as_ref(), req).await { + Ok(data) => build_http_result(Some(data), None), + Err(e) => build_http_result::<()>(None, Some(e)), + } +} diff --git a/src/handler/admin/document/mod.rs b/src/handler/admin/document/mod.rs new file mode 100644 index 00000000..bef755dd --- /dev/null +++ b/src/handler/admin/document/mod.rs @@ -0,0 +1,12 @@ +mod create_document_handler; +pub use create_document_handler::create_document; +mod update_document_handler; +pub use update_document_handler::update_document; +mod delete_document_handler; +pub use delete_document_handler::delete_document; +mod batch_delete_document_handler; +pub use batch_delete_document_handler::batch_delete_document; +mod get_document_detail_handler; +pub use get_document_detail_handler::get_document_detail; +mod get_document_list_handler; +pub use get_document_list_handler::get_document_list; diff --git a/src/handler/admin/document/update_document_handler.rs b/src/handler/admin/document/update_document_handler.rs new file mode 100644 index 00000000..a10df96a --- /dev/null +++ b/src/handler/admin/document/update_document_handler.rs @@ -0,0 +1,17 @@ +use axum::extract::State; +use axum::Json; + +use crate::handler::AppState; +use crate::model::dto::*; +use crate::service::admin::document::update_document_service; +use result::http_result::{build_http_result, HttpResult}; + +pub async fn update_document( + State(state): State, + Json(req): Json, +) -> HttpResult { + match update_document_service::update_document(state.repos.document.as_ref(), req).await { + Ok(()) => build_http_result(Some(()), None), + Err(e) => build_http_result::<()>(None, Some(e)), + } +} diff --git a/src/handler/admin/log/filter_balance_log_handler.rs b/src/handler/admin/log/filter_balance_log_handler.rs new file mode 100644 index 00000000..8ad4ae59 --- /dev/null +++ b/src/handler/admin/log/filter_balance_log_handler.rs @@ -0,0 +1,16 @@ +use axum::extract::{Query, State}; + +use crate::handler::AppState; +use crate::model::dto::*; +use crate::service::admin::log::filter_balance_log_service; +use result::http_result::{build_http_result, HttpResult}; + +pub async fn filter_balance_log( + State(state): State, + Query(req): Query, +) -> HttpResult { + match filter_balance_log_service::filter_balance_log(state.repos.log.as_ref(), req).await { + Ok(data) => build_http_result(Some(data), None), + Err(e) => build_http_result::<()>(None, Some(e)), + } +} diff --git a/src/handler/admin/log/filter_commission_log_handler.rs b/src/handler/admin/log/filter_commission_log_handler.rs new file mode 100644 index 00000000..991ba3a9 --- /dev/null +++ b/src/handler/admin/log/filter_commission_log_handler.rs @@ -0,0 +1,16 @@ +use axum::extract::{Query, State}; + +use crate::handler::AppState; +use crate::model::dto::*; +use crate::service::admin::log::filter_commission_log_service; +use result::http_result::{build_http_result, HttpResult}; + +pub async fn filter_commission_log( + State(state): State, + Query(req): Query, +) -> HttpResult { + match filter_commission_log_service::filter_commission_log(state.repos.log.as_ref(), req).await { + Ok(data) => build_http_result(Some(data), None), + Err(e) => build_http_result::<()>(None, Some(e)), + } +} diff --git a/src/handler/admin/log/filter_email_log_handler.rs b/src/handler/admin/log/filter_email_log_handler.rs new file mode 100644 index 00000000..e890e758 --- /dev/null +++ b/src/handler/admin/log/filter_email_log_handler.rs @@ -0,0 +1,14 @@ +use axum::extract::{Query, State}; +use crate::handler::AppState; +use crate::service::admin::log::filter_balance_log_service::{self, FilterEmailMobileLogRequest}; +use result::http_result::{build_http_result, HttpResult}; + +pub async fn filter_email_log( + State(state): State, + Query(req): Query, +) -> HttpResult { + match filter_balance_log_service::filter_email_log(state.repos.log.as_ref(), req).await { + Ok(d) => build_http_result(Some(d), None), + Err(e) => build_http_result::<()>(None, Some(e)), + } +} diff --git a/src/handler/admin/log/filter_gift_log_handler.rs b/src/handler/admin/log/filter_gift_log_handler.rs new file mode 100644 index 00000000..48d3b9c5 --- /dev/null +++ b/src/handler/admin/log/filter_gift_log_handler.rs @@ -0,0 +1,16 @@ +use axum::extract::{Query, State}; + +use crate::handler::AppState; +use crate::model::dto::*; +use crate::service::admin::log::filter_gift_log_service; +use result::http_result::{build_http_result, HttpResult}; + +pub async fn filter_gift_log( + State(state): State, + Query(req): Query, +) -> HttpResult { + match filter_gift_log_service::filter_gift_log(state.repos.log.as_ref(), req).await { + Ok(data) => build_http_result(Some(data), None), + Err(e) => build_http_result::<()>(None, Some(e)), + } +} diff --git a/src/handler/admin/log/filter_login_log_handler.rs b/src/handler/admin/log/filter_login_log_handler.rs new file mode 100644 index 00000000..d1b7ae30 --- /dev/null +++ b/src/handler/admin/log/filter_login_log_handler.rs @@ -0,0 +1,16 @@ +use axum::extract::{Query, State}; + +use crate::handler::AppState; +use crate::model::dto::*; +use crate::service::admin::log::filter_login_log_service; +use result::http_result::{build_http_result, HttpResult}; + +pub async fn filter_login_log( + State(state): State, + Query(req): Query, +) -> HttpResult { + match filter_login_log_service::filter_login_log(state.repos.log.as_ref(), req).await { + Ok(data) => build_http_result(Some(data), None), + Err(e) => build_http_result::<()>(None, Some(e)), + } +} diff --git a/src/handler/admin/log/filter_mobile_log_handler.rs b/src/handler/admin/log/filter_mobile_log_handler.rs new file mode 100644 index 00000000..4124a03b --- /dev/null +++ b/src/handler/admin/log/filter_mobile_log_handler.rs @@ -0,0 +1,14 @@ +use axum::extract::{Query, State}; +use crate::handler::AppState; +use crate::service::admin::log::filter_balance_log_service::{self, FilterEmailMobileLogRequest}; +use result::http_result::{build_http_result, HttpResult}; + +pub async fn filter_mobile_log( + State(state): State, + Query(req): Query, +) -> HttpResult { + match filter_balance_log_service::filter_mobile_log(state.repos.log.as_ref(), req).await { + Ok(d) => build_http_result(Some(d), None), + Err(e) => build_http_result::<()>(None, Some(e)), + } +} diff --git a/src/handler/admin/log/filter_register_log_handler.rs b/src/handler/admin/log/filter_register_log_handler.rs new file mode 100644 index 00000000..d6d9e6f7 --- /dev/null +++ b/src/handler/admin/log/filter_register_log_handler.rs @@ -0,0 +1,16 @@ +use axum::extract::{Query, State}; + +use crate::handler::AppState; +use crate::model::dto::*; +use crate::service::admin::log::filter_register_log_service; +use result::http_result::{build_http_result, HttpResult}; + +pub async fn filter_register_log( + State(state): State, + Query(req): Query, +) -> HttpResult { + match filter_register_log_service::filter_register_log(state.repos.log.as_ref(), req).await { + Ok(data) => build_http_result(Some(data), None), + Err(e) => build_http_result::<()>(None, Some(e)), + } +} diff --git a/src/handler/admin/log/filter_reset_subscribe_log_handler.rs b/src/handler/admin/log/filter_reset_subscribe_log_handler.rs new file mode 100644 index 00000000..988afa9e --- /dev/null +++ b/src/handler/admin/log/filter_reset_subscribe_log_handler.rs @@ -0,0 +1,16 @@ +use axum::extract::{Query, State}; + +use crate::handler::AppState; +use crate::model::dto::*; +use crate::service::admin::log::filter_reset_subscribe_log_service; +use result::http_result::{build_http_result, HttpResult}; + +pub async fn filter_reset_subscribe_log( + State(state): State, + Query(req): Query, +) -> HttpResult { + match filter_reset_subscribe_log_service::filter_reset_subscribe_log(state.repos.log.as_ref(), req).await { + Ok(data) => build_http_result(Some(data), None), + Err(e) => build_http_result::<()>(None, Some(e)), + } +} diff --git a/src/handler/admin/log/filter_server_traffic_log_handler.rs b/src/handler/admin/log/filter_server_traffic_log_handler.rs new file mode 100644 index 00000000..cd9268e7 --- /dev/null +++ b/src/handler/admin/log/filter_server_traffic_log_handler.rs @@ -0,0 +1,16 @@ +use axum::extract::{Query, State}; + +use crate::handler::AppState; +use crate::model::dto::*; +use crate::service::admin::log::filter_server_traffic_log_service; +use result::http_result::{build_http_result, HttpResult}; + +pub async fn filter_server_traffic_log( + State(state): State, + Query(req): Query, +) -> HttpResult { + match filter_server_traffic_log_service::filter_server_traffic_log(state.repos.log.as_ref(), req).await { + Ok(data) => build_http_result(Some(data), None), + Err(e) => build_http_result::<()>(None, Some(e)), + } +} diff --git a/src/handler/admin/log/filter_subscribe_log_handler.rs b/src/handler/admin/log/filter_subscribe_log_handler.rs new file mode 100644 index 00000000..ddd5750c --- /dev/null +++ b/src/handler/admin/log/filter_subscribe_log_handler.rs @@ -0,0 +1,16 @@ +use axum::extract::{Query, State}; + +use crate::handler::AppState; +use crate::model::dto::*; +use crate::service::admin::log::filter_subscribe_log_service; +use result::http_result::{build_http_result, HttpResult}; + +pub async fn filter_subscribe_log( + State(state): State, + Query(req): Query, +) -> HttpResult { + match filter_subscribe_log_service::filter_subscribe_log(state.repos.log.as_ref(), req).await { + Ok(data) => build_http_result(Some(data), None), + Err(e) => build_http_result::<()>(None, Some(e)), + } +} diff --git a/src/handler/admin/log/filter_traffic_log_details_handler.rs b/src/handler/admin/log/filter_traffic_log_details_handler.rs new file mode 100644 index 00000000..437b8d7c --- /dev/null +++ b/src/handler/admin/log/filter_traffic_log_details_handler.rs @@ -0,0 +1,15 @@ +use axum::extract::{Query, State}; +use crate::handler::AppState; +use crate::model::dto::log::FilterTrafficLogDetailsRequest; +use crate::service::admin::log::filter_balance_log_service; +use result::http_result::{build_http_result, HttpResult}; + +pub async fn filter_traffic_log_details( + State(state): State, + Query(req): Query, +) -> HttpResult { + match filter_balance_log_service::filter_traffic_log_details(state.repos.log.as_ref(), req).await { + Ok(d) => build_http_result(Some(d), None), + Err(e) => build_http_result::<()>(None, Some(e)), + } +} diff --git a/src/handler/admin/log/filter_user_subscribe_traffic_log_handler.rs b/src/handler/admin/log/filter_user_subscribe_traffic_log_handler.rs new file mode 100644 index 00000000..6bb4d33d --- /dev/null +++ b/src/handler/admin/log/filter_user_subscribe_traffic_log_handler.rs @@ -0,0 +1,15 @@ +use axum::extract::{Query, State}; +use crate::handler::AppState; +use crate::service::admin::log::filter_balance_log_service; +use crate::model::dto::log::FilterSubscribeTrafficRequest; +use result::http_result::{build_http_result, HttpResult}; + +pub async fn filter_user_subscribe_traffic_log( + State(state): State, + Query(req): Query, +) -> HttpResult { + match filter_balance_log_service::filter_user_subscribe_traffic_log(state.repos.log.as_ref(), req).await { + Ok(d) => build_http_result(Some(d), None), + Err(e) => build_http_result::<()>(None, Some(e)), + } +} diff --git a/src/handler/admin/log/get_log_setting_handler.rs b/src/handler/admin/log/get_log_setting_handler.rs new file mode 100644 index 00000000..f0b8eaae --- /dev/null +++ b/src/handler/admin/log/get_log_setting_handler.rs @@ -0,0 +1,13 @@ +use axum::extract::State; +use crate::handler::AppState; +use crate::service::admin::log::filter_balance_log_service; +use result::http_result::{build_http_result, HttpResult}; + +pub async fn get_log_setting( + State(state): State, +) -> HttpResult { + match filter_balance_log_service::get_log_setting(&state.config).await { + Ok(d) => build_http_result(Some(d), None), + Err(e) => build_http_result::<()>(None, Some(e)), + } +} diff --git a/src/handler/admin/log/get_message_log_list_handler.rs b/src/handler/admin/log/get_message_log_list_handler.rs new file mode 100644 index 00000000..c0e9d206 --- /dev/null +++ b/src/handler/admin/log/get_message_log_list_handler.rs @@ -0,0 +1,15 @@ +use axum::extract::{Query, State}; +use crate::handler::AppState; +use crate::model::dto::log::GetMessageLogListRequest; +use crate::service::admin::log::filter_balance_log_service; +use result::http_result::{build_http_result, HttpResult}; + +pub async fn get_message_log_list( + State(state): State, + Query(req): Query, +) -> HttpResult { + match filter_balance_log_service::get_message_log_list(state.repos.log.as_ref(), req).await { + Ok(d) => build_http_result(Some(d), None), + Err(e) => build_http_result::<()>(None, Some(e)), + } +} diff --git a/src/handler/admin/log/mod.rs b/src/handler/admin/log/mod.rs new file mode 100644 index 00000000..5e963115 --- /dev/null +++ b/src/handler/admin/log/mod.rs @@ -0,0 +1,30 @@ +mod filter_balance_log_handler; +pub use filter_balance_log_handler::filter_balance_log; +mod filter_commission_log_handler; +pub use filter_commission_log_handler::filter_commission_log; +mod filter_email_log_handler; +pub use filter_email_log_handler::filter_email_log; +mod filter_gift_log_handler; +pub use filter_gift_log_handler::filter_gift_log; +mod filter_login_log_handler; +pub use filter_login_log_handler::filter_login_log; +mod filter_mobile_log_handler; +pub use filter_mobile_log_handler::filter_mobile_log; +mod filter_register_log_handler; +pub use filter_register_log_handler::filter_register_log; +mod filter_reset_subscribe_log_handler; +pub use filter_reset_subscribe_log_handler::filter_reset_subscribe_log; +mod filter_server_traffic_log_handler; +pub use filter_server_traffic_log_handler::filter_server_traffic_log; +mod filter_subscribe_log_handler; +pub use filter_subscribe_log_handler::filter_subscribe_log; +mod filter_traffic_log_details_handler; +pub use filter_traffic_log_details_handler::filter_traffic_log_details; +mod filter_user_subscribe_traffic_log_handler; +pub use filter_user_subscribe_traffic_log_handler::filter_user_subscribe_traffic_log; +mod get_log_setting_handler; +pub use get_log_setting_handler::get_log_setting; +mod get_message_log_list_handler; +pub use get_message_log_list_handler::get_message_log_list; +mod update_log_setting_handler; +pub use update_log_setting_handler::update_log_setting; diff --git a/src/handler/admin/log/update_log_setting_handler.rs b/src/handler/admin/log/update_log_setting_handler.rs new file mode 100644 index 00000000..5cec2814 --- /dev/null +++ b/src/handler/admin/log/update_log_setting_handler.rs @@ -0,0 +1,16 @@ +use axum::extract::State; +use axum::Json; +use crate::handler::AppState; +use crate::model::dto::log::LogSetting; +use crate::service::admin::log::filter_balance_log_service; +use result::http_result::{build_http_result, HttpResult}; + +pub async fn update_log_setting( + State(state): State, + Json(req): Json, +) -> HttpResult { + match filter_balance_log_service::update_log_setting(&state.config, req).await { + Ok(_) => build_http_result(Some(()), None), + Err(e) => build_http_result::<()>(None, Some(e)), + } +} diff --git a/src/handler/admin/marketing/create_batch_send_email_task_handler.rs b/src/handler/admin/marketing/create_batch_send_email_task_handler.rs new file mode 100644 index 00000000..116eb020 --- /dev/null +++ b/src/handler/admin/marketing/create_batch_send_email_task_handler.rs @@ -0,0 +1,23 @@ +use axum::extract::State; +use axum::Json; + +use crate::handler::AppState; +use crate::model::dto::*; +use crate::service::admin::marketing::create_batch_send_email_task_service; +use result::http_result::{build_http_result, HttpResult}; + +pub async fn create_batch_send_email_task( + State(state): State, + Json(req): Json, +) -> HttpResult { + match create_batch_send_email_task_service::create_batch_send_email_task( + state.repos.task.as_ref(), + &state.config, + req, + ) + .await + { + Ok(data) => build_http_result(Some(data), None), + Err(e) => build_http_result::<()>(None, Some(e)), + } +} diff --git a/src/handler/admin/marketing/create_quota_task_handler.rs b/src/handler/admin/marketing/create_quota_task_handler.rs new file mode 100644 index 00000000..44b67547 --- /dev/null +++ b/src/handler/admin/marketing/create_quota_task_handler.rs @@ -0,0 +1,17 @@ +use axum::extract::State; +use axum::Json; + +use crate::handler::AppState; +use crate::model::dto::*; +use crate::service::admin::marketing::create_quota_task_service; +use result::http_result::{build_http_result, HttpResult}; + +pub async fn create_quota_task( + State(state): State, + Json(req): Json, +) -> HttpResult { + match create_quota_task_service::create_quota_task(state.repos.task.as_ref(), req).await { + Ok(data) => build_http_result(Some(data), None), + Err(e) => build_http_result::<()>(None, Some(e)), + } +} diff --git a/src/handler/admin/marketing/get_batch_send_email_task_list_handler.rs b/src/handler/admin/marketing/get_batch_send_email_task_list_handler.rs new file mode 100644 index 00000000..11c4c58e --- /dev/null +++ b/src/handler/admin/marketing/get_batch_send_email_task_list_handler.rs @@ -0,0 +1,21 @@ +use axum::extract::{Query, State}; + +use crate::handler::AppState; +use crate::model::dto::*; +use crate::service::admin::marketing::get_batch_send_email_task_list_service; +use result::http_result::{build_http_result, HttpResult}; + +pub async fn get_batch_send_email_task_list( + State(state): State, + Query(req): Query, +) -> HttpResult { + match get_batch_send_email_task_list_service::get_batch_send_email_task_list( + state.repos.task.as_ref(), + req, + ) + .await + { + Ok(data) => build_http_result(Some(data), None), + Err(e) => build_http_result::<()>(None, Some(e)), + } +} diff --git a/src/handler/admin/marketing/get_batch_send_email_task_status_handler.rs b/src/handler/admin/marketing/get_batch_send_email_task_status_handler.rs new file mode 100644 index 00000000..8414cb72 --- /dev/null +++ b/src/handler/admin/marketing/get_batch_send_email_task_status_handler.rs @@ -0,0 +1,22 @@ +use axum::extract::State; +use axum::Json; + +use crate::handler::AppState; +use crate::model::dto::*; +use crate::service::admin::marketing::get_batch_send_email_task_status_service; +use result::http_result::{build_http_result, HttpResult}; + +pub async fn get_batch_send_email_task_status( + State(state): State, + Json(req): Json, +) -> HttpResult { + match get_batch_send_email_task_status_service::get_batch_send_email_task_status( + state.repos.task.as_ref(), + req, + ) + .await + { + Ok(data) => build_http_result(Some(data), None), + Err(e) => build_http_result::<()>(None, Some(e)), + } +} diff --git a/src/handler/admin/marketing/get_pre_send_email_count_handler.rs b/src/handler/admin/marketing/get_pre_send_email_count_handler.rs new file mode 100644 index 00000000..a07ddc4a --- /dev/null +++ b/src/handler/admin/marketing/get_pre_send_email_count_handler.rs @@ -0,0 +1,22 @@ +use axum::extract::State; +use axum::Json; + +use crate::handler::AppState; +use crate::model::dto::*; +use crate::service::admin::marketing::get_pre_send_email_count_service; +use result::http_result::{build_http_result, HttpResult}; + +pub async fn get_pre_send_email_count( + State(state): State, + Json(req): Json, +) -> HttpResult { + match get_pre_send_email_count_service::get_pre_send_email_count( + state.repos.user.as_ref(), + req, + ) + .await + { + Ok(data) => build_http_result(Some(data), None), + Err(e) => build_http_result::<()>(None, Some(e)), + } +} diff --git a/src/handler/admin/marketing/mod.rs b/src/handler/admin/marketing/mod.rs new file mode 100644 index 00000000..2cd53dd6 --- /dev/null +++ b/src/handler/admin/marketing/mod.rs @@ -0,0 +1,18 @@ +mod create_batch_send_email_task_handler; +pub use create_batch_send_email_task_handler::create_batch_send_email_task; +mod create_quota_task_handler; +pub use create_quota_task_handler::create_quota_task; +mod get_batch_send_email_task_list_handler; +pub use get_batch_send_email_task_list_handler::get_batch_send_email_task_list; +mod get_batch_send_email_task_status_handler; +pub use get_batch_send_email_task_status_handler::get_batch_send_email_task_status; +mod get_pre_send_email_count_handler; +pub use get_pre_send_email_count_handler::get_pre_send_email_count; +mod query_quota_task_list_handler; +pub use query_quota_task_list_handler::query_quota_task_list; +mod query_quota_task_pre_count_handler; +pub use query_quota_task_pre_count_handler::query_quota_task_pre_count; +mod query_quota_task_status_handler; +pub use query_quota_task_status_handler::query_quota_task_status; +mod stop_batch_send_email_task_handler; +pub use stop_batch_send_email_task_handler::stop_batch_send_email_task; diff --git a/src/handler/admin/marketing/query_quota_task_list_handler.rs b/src/handler/admin/marketing/query_quota_task_list_handler.rs new file mode 100644 index 00000000..6909b0c5 --- /dev/null +++ b/src/handler/admin/marketing/query_quota_task_list_handler.rs @@ -0,0 +1,16 @@ +use axum::extract::{Query, State}; + +use crate::handler::AppState; +use crate::model::dto::*; +use crate::service::admin::marketing::query_quota_task_list_service; +use result::http_result::{build_http_result, HttpResult}; + +pub async fn query_quota_task_list( + State(state): State, + Query(req): Query, +) -> HttpResult { + match query_quota_task_list_service::query_quota_task_list(state.repos.task.as_ref(), req).await { + Ok(data) => build_http_result(Some(data), None), + Err(e) => build_http_result::<()>(None, Some(e)), + } +} diff --git a/src/handler/admin/marketing/query_quota_task_pre_count_handler.rs b/src/handler/admin/marketing/query_quota_task_pre_count_handler.rs new file mode 100644 index 00000000..673af66c --- /dev/null +++ b/src/handler/admin/marketing/query_quota_task_pre_count_handler.rs @@ -0,0 +1,22 @@ +use axum::extract::State; +use axum::Json; + +use crate::handler::AppState; +use crate::model::dto::*; +use crate::service::admin::marketing::query_quota_task_pre_count_service; +use result::http_result::{build_http_result, HttpResult}; + +pub async fn query_quota_task_pre_count( + State(state): State, + Json(req): Json, +) -> HttpResult { + match query_quota_task_pre_count_service::query_quota_task_pre_count( + state.repos.user.as_ref(), + req, + ) + .await + { + Ok(data) => build_http_result(Some(data), None), + Err(e) => build_http_result::<()>(None, Some(e)), + } +} diff --git a/src/handler/admin/marketing/query_quota_task_status_handler.rs b/src/handler/admin/marketing/query_quota_task_status_handler.rs new file mode 100644 index 00000000..cac02faa --- /dev/null +++ b/src/handler/admin/marketing/query_quota_task_status_handler.rs @@ -0,0 +1,19 @@ +use axum::extract::State; +use axum::Json; + +use crate::handler::AppState; +use crate::model::dto::*; +use crate::service::admin::marketing::query_quota_task_status_service; +use result::http_result::{build_http_result, HttpResult}; + +pub async fn query_quota_task_status( + State(state): State, + Json(req): Json, +) -> HttpResult { + match query_quota_task_status_service::query_quota_task_status(state.repos.task.as_ref(), req) + .await + { + Ok(data) => build_http_result(Some(data), None), + Err(e) => build_http_result::<()>(None, Some(e)), + } +} diff --git a/src/handler/admin/marketing/stop_batch_send_email_task_handler.rs b/src/handler/admin/marketing/stop_batch_send_email_task_handler.rs new file mode 100644 index 00000000..bafd5601 --- /dev/null +++ b/src/handler/admin/marketing/stop_batch_send_email_task_handler.rs @@ -0,0 +1,22 @@ +use axum::extract::State; +use axum::Json; + +use crate::handler::AppState; +use crate::model::dto::*; +use crate::service::admin::marketing::stop_batch_send_email_task_service; +use result::http_result::{build_http_result, HttpResult}; + +pub async fn stop_batch_send_email_task( + State(state): State, + Json(req): Json, +) -> HttpResult { + match stop_batch_send_email_task_service::stop_batch_send_email_task( + state.repos.task.as_ref(), + req, + ) + .await + { + Ok(()) => build_http_result(Some(()), None), + Err(e) => build_http_result::<()>(None, Some(e)), + } +} diff --git a/src/handler/admin/mod.rs b/src/handler/admin/mod.rs new file mode 100644 index 00000000..ffa0ef08 --- /dev/null +++ b/src/handler/admin/mod.rs @@ -0,0 +1,18 @@ +pub mod ads; +pub mod announcement; +pub mod application; +pub mod auth_method; +pub mod console; +pub mod coupon; +pub mod document; +pub mod log; +pub mod marketing; +pub mod order; +pub mod payment; +pub mod plugin; +pub mod server; +pub mod subscribe; +pub mod system; +pub mod ticket; +pub mod tool; +pub mod user; diff --git a/src/handler/admin/order/create_order_handler.rs b/src/handler/admin/order/create_order_handler.rs new file mode 100644 index 00000000..5c11b3b7 --- /dev/null +++ b/src/handler/admin/order/create_order_handler.rs @@ -0,0 +1,17 @@ +use axum::extract::State; +use axum::Json; + +use crate::handler::AppState; +use crate::model::dto::*; +use crate::service::admin::order::create_order_service; +use result::http_result::{build_http_result, HttpResult}; + +pub async fn create_order( + State(state): State, + Json(req): Json, +) -> HttpResult { + match create_order_service::create_order(state.repos.order.as_ref(), req).await { + Ok(()) => build_http_result(Some(()), None), + Err(e) => build_http_result::<()>(None, Some(e)), + } +} diff --git a/src/handler/admin/order/get_order_list_handler.rs b/src/handler/admin/order/get_order_list_handler.rs new file mode 100644 index 00000000..2dedf0bb --- /dev/null +++ b/src/handler/admin/order/get_order_list_handler.rs @@ -0,0 +1,16 @@ +use axum::extract::{Query, State}; + +use crate::handler::AppState; +use crate::model::dto::*; +use crate::service::admin::order::get_order_list_service; +use result::http_result::{build_http_result, HttpResult}; + +pub async fn get_order_list( + State(state): State, + Query(req): Query, +) -> HttpResult { + match get_order_list_service::get_order_list(state.repos.order.as_ref(), req).await { + Ok(data) => build_http_result(Some(data), None), + Err(e) => build_http_result::<()>(None, Some(e)), + } +} diff --git a/src/handler/admin/order/mod.rs b/src/handler/admin/order/mod.rs new file mode 100644 index 00000000..3e7a189d --- /dev/null +++ b/src/handler/admin/order/mod.rs @@ -0,0 +1,6 @@ +mod create_order_handler; +pub use create_order_handler::create_order; +mod get_order_list_handler; +pub use get_order_list_handler::get_order_list; +mod update_order_status_handler; +pub use update_order_status_handler::update_order_status; diff --git a/src/handler/admin/order/update_order_status_handler.rs b/src/handler/admin/order/update_order_status_handler.rs new file mode 100644 index 00000000..0059475e --- /dev/null +++ b/src/handler/admin/order/update_order_status_handler.rs @@ -0,0 +1,17 @@ +use axum::extract::State; +use axum::Json; + +use crate::handler::AppState; +use crate::model::dto::*; +use crate::service::admin::order::update_order_status_service; +use result::http_result::{build_http_result, HttpResult}; + +pub async fn update_order_status( + State(state): State, + Json(req): Json, +) -> HttpResult { + match update_order_status_service::update_order_status(state.repos.order.as_ref(), req).await { + Ok(()) => build_http_result(Some(()), None), + Err(e) => build_http_result::<()>(None, Some(e)), + } +} diff --git a/src/handler/admin/payment/create_payment_method_handler.rs b/src/handler/admin/payment/create_payment_method_handler.rs new file mode 100644 index 00000000..8c8b893a --- /dev/null +++ b/src/handler/admin/payment/create_payment_method_handler.rs @@ -0,0 +1,16 @@ +use axum::extract::State; +use axum::Json; +use crate::handler::AppState; +use crate::model::dto::payment::CreatePaymentMethodRequest; +use crate::service::admin::payment::get_payment_method_list_service; +use result::http_result::{build_http_result, HttpResult}; + +pub async fn create_payment_method( + State(state): State, + Json(req): Json, +) -> HttpResult { + match get_payment_method_list_service::create_payment_method(state.repos.payment.as_ref(), req).await { + Ok(_) => build_http_result(Some(()), None), + Err(e) => build_http_result::<()>(None, Some(e)), + } +} diff --git a/src/handler/admin/payment/delete_payment_method_handler.rs b/src/handler/admin/payment/delete_payment_method_handler.rs new file mode 100644 index 00000000..216883ce --- /dev/null +++ b/src/handler/admin/payment/delete_payment_method_handler.rs @@ -0,0 +1,16 @@ +use axum::extract::State; +use axum::Json; +use crate::handler::AppState; +use crate::model::dto::payment::DeletePaymentMethodRequest; +use crate::service::admin::payment::get_payment_method_list_service; +use result::http_result::{build_http_result, HttpResult}; + +pub async fn delete_payment_method( + State(state): State, + Json(req): Json, +) -> HttpResult { + match get_payment_method_list_service::delete_payment_method(state.repos.payment.as_ref(), req).await { + Ok(_) => build_http_result(Some(()), None), + Err(e) => build_http_result::<()>(None, Some(e)), + } +} diff --git a/src/handler/admin/payment/get_payment_method_list_handler.rs b/src/handler/admin/payment/get_payment_method_list_handler.rs new file mode 100644 index 00000000..453baeef --- /dev/null +++ b/src/handler/admin/payment/get_payment_method_list_handler.rs @@ -0,0 +1,15 @@ +use axum::extract::{Query, State}; +use crate::handler::AppState; +use crate::model::dto::payment::GetPaymentMethodListRequest; +use crate::service::admin::payment::get_payment_method_list_service; +use result::http_result::{build_http_result, HttpResult}; + +pub async fn get_payment_method_list( + State(state): State, + Query(req): Query, +) -> HttpResult { + match get_payment_method_list_service::get_payment_method_list(state.repos.payment.as_ref(), req).await { + Ok(d) => build_http_result(Some(d), None), + Err(e) => build_http_result::<()>(None, Some(e)), + } +} diff --git a/src/handler/admin/payment/get_payment_platform_handler.rs b/src/handler/admin/payment/get_payment_platform_handler.rs new file mode 100644 index 00000000..0c46efe6 --- /dev/null +++ b/src/handler/admin/payment/get_payment_platform_handler.rs @@ -0,0 +1,13 @@ +use axum::extract::State; +use crate::handler::AppState; +use crate::service::admin::payment::get_payment_method_list_service; +use result::http_result::{build_http_result, HttpResult}; + +pub async fn get_payment_platform( + State(_state): State, +) -> HttpResult { + match get_payment_method_list_service::get_payment_platform().await { + Ok(d) => build_http_result(Some(d), None), + Err(e) => build_http_result::<()>(None, Some(e)), + } +} diff --git a/src/handler/admin/payment/mod.rs b/src/handler/admin/payment/mod.rs new file mode 100644 index 00000000..0eaf3bb8 --- /dev/null +++ b/src/handler/admin/payment/mod.rs @@ -0,0 +1,10 @@ +mod create_payment_method_handler; +pub use create_payment_method_handler::create_payment_method; +mod update_payment_method_handler; +pub use update_payment_method_handler::update_payment_method; +mod delete_payment_method_handler; +pub use delete_payment_method_handler::delete_payment_method; +mod get_payment_method_list_handler; +pub use get_payment_method_list_handler::get_payment_method_list; +mod get_payment_platform_handler; +pub use get_payment_platform_handler::get_payment_platform; diff --git a/src/handler/admin/payment/update_payment_method_handler.rs b/src/handler/admin/payment/update_payment_method_handler.rs new file mode 100644 index 00000000..0bc867eb --- /dev/null +++ b/src/handler/admin/payment/update_payment_method_handler.rs @@ -0,0 +1,16 @@ +use axum::extract::State; +use axum::Json; +use crate::handler::AppState; +use crate::model::dto::payment::UpdatePaymentMethodRequest; +use crate::service::admin::payment::get_payment_method_list_service; +use result::http_result::{build_http_result, HttpResult}; + +pub async fn update_payment_method( + State(state): State, + Json(req): Json, +) -> HttpResult { + match get_payment_method_list_service::update_payment_method(state.repos.payment.as_ref(), req).await { + Ok(_) => build_http_result(Some(()), None), + Err(e) => build_http_result::<()>(None, Some(e)), + } +} diff --git a/src/handler/admin/plugin/api.rs b/src/handler/admin/plugin/api.rs new file mode 100644 index 00000000..6c0292e6 --- /dev/null +++ b/src/handler/admin/plugin/api.rs @@ -0,0 +1 @@ +// Plugin API types and helpers diff --git a/src/handler/admin/plugin/detail.rs b/src/handler/admin/plugin/detail.rs new file mode 100644 index 00000000..2c85d34b --- /dev/null +++ b/src/handler/admin/plugin/detail.rs @@ -0,0 +1,7 @@ +use result::http_result::HttpResult; + +pub async fn detail( + +) -> HttpResult { + todo!() +} diff --git a/src/handler/admin/plugin/disable.rs b/src/handler/admin/plugin/disable.rs new file mode 100644 index 00000000..a5ad86cb --- /dev/null +++ b/src/handler/admin/plugin/disable.rs @@ -0,0 +1,7 @@ +use result::http_result::HttpResult; + +pub async fn disable_handler( + +) -> HttpResult { + todo!() +} diff --git a/src/handler/admin/plugin/enable.rs b/src/handler/admin/plugin/enable.rs new file mode 100644 index 00000000..aad8a4ba --- /dev/null +++ b/src/handler/admin/plugin/enable.rs @@ -0,0 +1,7 @@ +use result::http_result::HttpResult; + +pub async fn enable_handler( + +) -> HttpResult { + todo!() +} diff --git a/src/handler/admin/plugin/list.rs b/src/handler/admin/plugin/list.rs new file mode 100644 index 00000000..5773c863 --- /dev/null +++ b/src/handler/admin/plugin/list.rs @@ -0,0 +1,7 @@ +use result::http_result::HttpResult; + +pub async fn list( + +) -> HttpResult { + todo!() +} diff --git a/src/handler/admin/plugin/mod.rs b/src/handler/admin/plugin/mod.rs new file mode 100644 index 00000000..747b5e54 --- /dev/null +++ b/src/handler/admin/plugin/mod.rs @@ -0,0 +1,11 @@ +mod api; +mod detail; +pub use detail::detail; +mod disable; +pub use disable::disable_handler; +mod enable; +pub use enable::enable_handler; +mod list; +pub use list::list; +mod reload; +pub use reload::reload_handler; diff --git a/src/handler/admin/plugin/reload.rs b/src/handler/admin/plugin/reload.rs new file mode 100644 index 00000000..6adc4b15 --- /dev/null +++ b/src/handler/admin/plugin/reload.rs @@ -0,0 +1,7 @@ +use result::http_result::HttpResult; + +pub async fn reload_handler( + +) -> HttpResult { + todo!() +} diff --git a/src/handler/admin/server/create_node_handler.rs b/src/handler/admin/server/create_node_handler.rs new file mode 100644 index 00000000..5530d1ab --- /dev/null +++ b/src/handler/admin/server/create_node_handler.rs @@ -0,0 +1,17 @@ +use axum::extract::State; +use axum::Json; + +use crate::handler::AppState; +use crate::model::dto::*; +use crate::service::admin::server::create_node_service; +use result::http_result::{build_http_result, HttpResult}; + +pub async fn create_node( + State(state): State, + Json(req): Json, +) -> HttpResult { + match create_node_service::create_node(state.repos.node.as_ref(), req).await { + Ok(data) => build_http_result(Some(data), None), + Err(e) => build_http_result::<()>(None, Some(e)), + } +} diff --git a/src/handler/admin/server/create_server_handler.rs b/src/handler/admin/server/create_server_handler.rs new file mode 100644 index 00000000..6224a953 --- /dev/null +++ b/src/handler/admin/server/create_server_handler.rs @@ -0,0 +1,17 @@ +use axum::extract::State; +use axum::Json; + +use crate::handler::AppState; +use crate::model::dto::*; +use crate::service::admin::server::create_server_service; +use result::http_result::{build_http_result, HttpResult}; + +pub async fn create_server( + State(state): State, + Json(req): Json, +) -> HttpResult { + match create_server_service::create_server(state.repos.node.as_ref(), req).await { + Ok(data) => build_http_result(Some(data), None), + Err(e) => build_http_result::<()>(None, Some(e)), + } +} diff --git a/src/handler/admin/server/delete_node_handler.rs b/src/handler/admin/server/delete_node_handler.rs new file mode 100644 index 00000000..37048f8a --- /dev/null +++ b/src/handler/admin/server/delete_node_handler.rs @@ -0,0 +1,17 @@ +use axum::extract::State; +use axum::Json; + +use crate::handler::AppState; +use crate::model::dto::*; +use crate::service::admin::server::delete_node_service; +use result::http_result::{build_http_result, HttpResult}; + +pub async fn delete_node( + State(state): State, + Json(req): Json, +) -> HttpResult { + match delete_node_service::delete_node(state.repos.node.as_ref(), req.id).await { + Ok(data) => build_http_result(Some(data), None), + Err(e) => build_http_result::<()>(None, Some(e)), + } +} diff --git a/src/handler/admin/server/delete_server_handler.rs b/src/handler/admin/server/delete_server_handler.rs new file mode 100644 index 00000000..c4277cf2 --- /dev/null +++ b/src/handler/admin/server/delete_server_handler.rs @@ -0,0 +1,17 @@ +use axum::extract::State; +use axum::Json; + +use crate::handler::AppState; +use crate::model::dto::*; +use crate::service::admin::server::delete_server_service; +use result::http_result::{build_http_result, HttpResult}; + +pub async fn delete_server( + State(state): State, + Json(req): Json, +) -> HttpResult { + match delete_server_service::delete_server(state.repos.node.as_ref(), req.id).await { + Ok(data) => build_http_result(Some(data), None), + Err(e) => build_http_result::<()>(None, Some(e)), + } +} diff --git a/src/handler/admin/server/filter_node_list_handler.rs b/src/handler/admin/server/filter_node_list_handler.rs new file mode 100644 index 00000000..ea0f2273 --- /dev/null +++ b/src/handler/admin/server/filter_node_list_handler.rs @@ -0,0 +1,15 @@ +use axum::extract::{Query, State}; + +use crate::handler::AppState; +use crate::service::admin::server::filter_node_list_service::{self, FilterNodeListRequest}; +use result::http_result::{build_http_result, HttpResult}; + +pub async fn filter_node_list( + State(state): State, + Query(req): Query, +) -> HttpResult { + match filter_node_list_service::filter_node_list(state.repos.node.as_ref(), req).await { + Ok(data) => build_http_result(Some(data), None), + Err(e) => build_http_result::<()>(None, Some(e)), + } +} diff --git a/src/handler/admin/server/filter_server_list_handler.rs b/src/handler/admin/server/filter_server_list_handler.rs new file mode 100644 index 00000000..07dee8d6 --- /dev/null +++ b/src/handler/admin/server/filter_server_list_handler.rs @@ -0,0 +1,16 @@ +use axum::extract::{Query, State}; + +use crate::handler::AppState; +use crate::model::dto::*; +use crate::service::admin::server::filter_server_list_service; +use result::http_result::{build_http_result, HttpResult}; + +pub async fn filter_server_list( + State(state): State, + Query(req): Query, +) -> HttpResult { + match filter_server_list_service::filter_server_list(state.repos.node.as_ref(), req).await { + Ok(data) => build_http_result(Some(data), None), + Err(e) => build_http_result::<()>(None, Some(e)), + } +} diff --git a/src/handler/admin/server/get_server_node_config_handler.rs b/src/handler/admin/server/get_server_node_config_handler.rs new file mode 100644 index 00000000..22bfcd6f --- /dev/null +++ b/src/handler/admin/server/get_server_node_config_handler.rs @@ -0,0 +1,16 @@ +use axum::extract::{Query, State}; + +use crate::handler::AppState; +use crate::model::dto::server::GetServerConfigRequest; +use crate::service::admin::server::get_server_node_config_service; +use result::http_result::{build_http_result, HttpResult}; + +pub async fn get_server_node_config( + State(state): State, + Query(req): Query, +) -> HttpResult { + match get_server_node_config_service::get_server_node_config(state.repos.node.as_ref(), req).await { + Ok(data) => build_http_result(Some(data), None), + Err(e) => build_http_result::<()>(None, Some(e)), + } +} diff --git a/src/handler/admin/server/get_server_protocols_handler.rs b/src/handler/admin/server/get_server_protocols_handler.rs new file mode 100644 index 00000000..ef8a7361 --- /dev/null +++ b/src/handler/admin/server/get_server_protocols_handler.rs @@ -0,0 +1,16 @@ +use axum::extract::{Query, State}; + +use crate::handler::AppState; +use crate::model::dto::*; +use crate::service::admin::server::get_server_protocols_service; +use result::http_result::{build_http_result, HttpResult}; + +pub async fn get_server_protocols( + State(state): State, + Query(req): Query, +) -> HttpResult { + match get_server_protocols_service::get_server_protocols(state.repos.node.as_ref(), req).await { + Ok(data) => build_http_result(Some(data), None), + Err(e) => build_http_result::<()>(None, Some(e)), + } +} diff --git a/src/handler/admin/server/mod.rs b/src/handler/admin/server/mod.rs new file mode 100644 index 00000000..14cc258d --- /dev/null +++ b/src/handler/admin/server/mod.rs @@ -0,0 +1,30 @@ +mod create_node_handler; +pub use create_node_handler::create_node; +mod create_server_handler; +pub use create_server_handler::create_server; +mod delete_node_handler; +pub use delete_node_handler::delete_node; +mod delete_server_handler; +pub use delete_server_handler::delete_server; +mod filter_node_list_handler; +pub use filter_node_list_handler::filter_node_list; +mod filter_server_list_handler; +pub use filter_server_list_handler::filter_server_list; +mod get_server_node_config_handler; +pub use get_server_node_config_handler::get_server_node_config; +mod get_server_protocols_handler; +pub use get_server_protocols_handler::get_server_protocols; +mod query_node_tag_handler; +pub use query_node_tag_handler::query_node_tag; +mod reset_sort_with_node_handler; +pub use reset_sort_with_node_handler::reset_sort_with_node; +mod reset_sort_with_server_handler; +pub use reset_sort_with_server_handler::reset_sort_with_server; +mod toggle_node_status_handler; +pub use toggle_node_status_handler::toggle_node_status; +mod update_node_handler; +pub use update_node_handler::update_node; +mod update_server_handler; +pub use update_server_handler::update_server; +mod update_server_node_config_handler; +pub use update_server_node_config_handler::update_server_node_config; diff --git a/src/handler/admin/server/query_node_tag_handler.rs b/src/handler/admin/server/query_node_tag_handler.rs new file mode 100644 index 00000000..5c75afaa --- /dev/null +++ b/src/handler/admin/server/query_node_tag_handler.rs @@ -0,0 +1,14 @@ +use axum::extract::State; + +use crate::handler::AppState; +use crate::service::admin::server::query_node_tag_service; +use result::http_result::{build_http_result, HttpResult}; + +pub async fn query_node_tag( + State(state): State, +) -> HttpResult { + match query_node_tag_service::query_node_tag(state.repos.node.as_ref()).await { + Ok(data) => build_http_result(Some(data), None), + Err(e) => build_http_result::<()>(None, Some(e)), + } +} diff --git a/src/handler/admin/server/reset_sort_with_node_handler.rs b/src/handler/admin/server/reset_sort_with_node_handler.rs new file mode 100644 index 00000000..75ec9ce9 --- /dev/null +++ b/src/handler/admin/server/reset_sort_with_node_handler.rs @@ -0,0 +1,17 @@ +use axum::extract::State; +use axum::Json; + +use crate::handler::AppState; +use crate::model::dto::*; +use crate::service::admin::server::reset_sort_with_node_service; +use result::http_result::{build_http_result, HttpResult}; + +pub async fn reset_sort_with_node( + State(state): State, + Json(req): Json, +) -> HttpResult { + match reset_sort_with_node_service::reset_sort_with_node(state.repos.node.as_ref(), req).await { + Ok(data) => build_http_result(Some(data), None), + Err(e) => build_http_result::<()>(None, Some(e)), + } +} diff --git a/src/handler/admin/server/reset_sort_with_server_handler.rs b/src/handler/admin/server/reset_sort_with_server_handler.rs new file mode 100644 index 00000000..a1ec9f77 --- /dev/null +++ b/src/handler/admin/server/reset_sort_with_server_handler.rs @@ -0,0 +1,17 @@ +use axum::extract::State; +use axum::Json; + +use crate::handler::AppState; +use crate::model::dto::*; +use crate::service::admin::server::reset_sort_with_server_service; +use result::http_result::{build_http_result, HttpResult}; + +pub async fn reset_sort_with_server( + State(state): State, + Json(req): Json, +) -> HttpResult { + match reset_sort_with_server_service::reset_sort_with_server(state.repos.node.as_ref(), req).await { + Ok(data) => build_http_result(Some(data), None), + Err(e) => build_http_result::<()>(None, Some(e)), + } +} diff --git a/src/handler/admin/server/toggle_node_status_handler.rs b/src/handler/admin/server/toggle_node_status_handler.rs new file mode 100644 index 00000000..31ecdd0f --- /dev/null +++ b/src/handler/admin/server/toggle_node_status_handler.rs @@ -0,0 +1,17 @@ +use axum::extract::State; +use axum::Json; + +use crate::handler::AppState; +use crate::model::dto::*; +use crate::service::admin::server::toggle_node_status_service; +use result::http_result::{build_http_result, HttpResult}; + +pub async fn toggle_node_status( + State(state): State, + Json(req): Json, +) -> HttpResult { + match toggle_node_status_service::toggle_node_status(state.repos.node.as_ref(), req.id).await { + Ok(data) => build_http_result(Some(data), None), + Err(e) => build_http_result::<()>(None, Some(e)), + } +} diff --git a/src/handler/admin/server/update_node_handler.rs b/src/handler/admin/server/update_node_handler.rs new file mode 100644 index 00000000..16dc2530 --- /dev/null +++ b/src/handler/admin/server/update_node_handler.rs @@ -0,0 +1,17 @@ +use axum::extract::State; +use axum::Json; + +use crate::handler::AppState; +use crate::model::dto::*; +use crate::service::admin::server::update_node_service; +use result::http_result::{build_http_result, HttpResult}; + +pub async fn update_node( + State(state): State, + Json(req): Json, +) -> HttpResult { + match update_node_service::update_node(state.repos.node.as_ref(), req).await { + Ok(data) => build_http_result(Some(data), None), + Err(e) => build_http_result::<()>(None, Some(e)), + } +} diff --git a/src/handler/admin/server/update_server_handler.rs b/src/handler/admin/server/update_server_handler.rs new file mode 100644 index 00000000..487c5f77 --- /dev/null +++ b/src/handler/admin/server/update_server_handler.rs @@ -0,0 +1,17 @@ +use axum::extract::State; +use axum::Json; + +use crate::handler::AppState; +use crate::model::dto::*; +use crate::service::admin::server::update_server_service; +use result::http_result::{build_http_result, HttpResult}; + +pub async fn update_server( + State(state): State, + Json(req): Json, +) -> HttpResult { + match update_server_service::update_server(state.repos.node.as_ref(), req).await { + Ok(data) => build_http_result(Some(data), None), + Err(e) => build_http_result::<()>(None, Some(e)), + } +} diff --git a/src/handler/admin/server/update_server_node_config_handler.rs b/src/handler/admin/server/update_server_node_config_handler.rs new file mode 100644 index 00000000..3259d457 --- /dev/null +++ b/src/handler/admin/server/update_server_node_config_handler.rs @@ -0,0 +1,19 @@ +use axum::extract::{Query, State}; +use axum::Json; + +use crate::handler::AppState; +use crate::model::dto::server::GetServerConfigRequest; +use crate::model::entity::node::ServerConfigOverride; +use crate::service::admin::server::update_server_node_config_service; +use result::http_result::{build_http_result, HttpResult}; + +pub async fn update_server_node_config( + State(state): State, + Query(req): Query, + Json(body): Json, +) -> HttpResult { + match update_server_node_config_service::update_server_node_config(state.repos.node.as_ref(), req, body).await { + Ok(data) => build_http_result(Some(data), None), + Err(e) => build_http_result::<()>(None, Some(e)), + } +} diff --git a/src/handler/admin/subscribe/batch_delete_subscribe_group_handler.rs b/src/handler/admin/subscribe/batch_delete_subscribe_group_handler.rs new file mode 100644 index 00000000..fcebd9a1 --- /dev/null +++ b/src/handler/admin/subscribe/batch_delete_subscribe_group_handler.rs @@ -0,0 +1,17 @@ +use axum::extract::State; +use axum::Json; + +use crate::handler::AppState; +use crate::model::dto::*; +use crate::service::admin::subscribe::batch_delete_subscribe_group_service; +use result::http_result::{build_http_result, HttpResult}; + +pub async fn batch_delete_subscribe_group( + State(state): State, + Json(req): Json, +) -> HttpResult { + match batch_delete_subscribe_group_service::batch_delete_subscribe_group(state.repos.subscribe.as_ref(), req).await { + Ok(data) => build_http_result(Some(data), None), + Err(e) => build_http_result::<()>(None, Some(e)), + } +} diff --git a/src/handler/admin/subscribe/batch_delete_subscribe_handler.rs b/src/handler/admin/subscribe/batch_delete_subscribe_handler.rs new file mode 100644 index 00000000..a790fea0 --- /dev/null +++ b/src/handler/admin/subscribe/batch_delete_subscribe_handler.rs @@ -0,0 +1,17 @@ +use axum::extract::State; +use axum::Json; + +use crate::handler::AppState; +use crate::model::dto::*; +use crate::service::admin::subscribe::batch_delete_subscribe_service; +use result::http_result::{build_http_result, HttpResult}; + +pub async fn batch_delete_subscribe( + State(state): State, + Json(req): Json, +) -> HttpResult { + match batch_delete_subscribe_service::batch_delete_subscribe(state.repos.subscribe.as_ref(), req).await { + Ok(data) => build_http_result(Some(data), None), + Err(e) => build_http_result::<()>(None, Some(e)), + } +} diff --git a/src/handler/admin/subscribe/create_subscribe_group_handler.rs b/src/handler/admin/subscribe/create_subscribe_group_handler.rs new file mode 100644 index 00000000..5a18fe3f --- /dev/null +++ b/src/handler/admin/subscribe/create_subscribe_group_handler.rs @@ -0,0 +1,17 @@ +use axum::extract::State; +use axum::Json; + +use crate::handler::AppState; +use crate::model::dto::*; +use crate::service::admin::subscribe::create_subscribe_group_service; +use result::http_result::{build_http_result, HttpResult}; + +pub async fn create_subscribe_group( + State(state): State, + Json(req): Json, +) -> HttpResult { + match create_subscribe_group_service::create_subscribe_group(state.repos.subscribe.as_ref(), req).await { + Ok(data) => build_http_result(Some(data), None), + Err(e) => build_http_result::<()>(None, Some(e)), + } +} diff --git a/src/handler/admin/subscribe/create_subscribe_handler.rs b/src/handler/admin/subscribe/create_subscribe_handler.rs new file mode 100644 index 00000000..46718207 --- /dev/null +++ b/src/handler/admin/subscribe/create_subscribe_handler.rs @@ -0,0 +1,17 @@ +use axum::extract::State; +use axum::Json; + +use crate::handler::AppState; +use crate::model::dto::*; +use crate::service::admin::subscribe::create_subscribe_service; +use result::http_result::{build_http_result, HttpResult}; + +pub async fn create_subscribe( + State(state): State, + Json(req): Json, +) -> HttpResult { + match create_subscribe_service::create_subscribe(state.repos.subscribe.as_ref(), req).await { + Ok(data) => build_http_result(Some(data), None), + Err(e) => build_http_result::<()>(None, Some(e)), + } +} diff --git a/src/handler/admin/subscribe/delete_subscribe_group_handler.rs b/src/handler/admin/subscribe/delete_subscribe_group_handler.rs new file mode 100644 index 00000000..c88f9826 --- /dev/null +++ b/src/handler/admin/subscribe/delete_subscribe_group_handler.rs @@ -0,0 +1,17 @@ +use axum::extract::State; +use axum::Json; + +use crate::handler::AppState; +use crate::model::dto::*; +use crate::service::admin::subscribe::delete_subscribe_group_service; +use result::http_result::{build_http_result, HttpResult}; + +pub async fn delete_subscribe_group( + State(state): State, + Json(req): Json, +) -> HttpResult { + match delete_subscribe_group_service::delete_subscribe_group(state.repos.subscribe.as_ref(), req.id).await { + Ok(data) => build_http_result(Some(data), None), + Err(e) => build_http_result::<()>(None, Some(e)), + } +} diff --git a/src/handler/admin/subscribe/delete_subscribe_handler.rs b/src/handler/admin/subscribe/delete_subscribe_handler.rs new file mode 100644 index 00000000..a68f0677 --- /dev/null +++ b/src/handler/admin/subscribe/delete_subscribe_handler.rs @@ -0,0 +1,17 @@ +use axum::extract::State; +use axum::Json; + +use crate::handler::AppState; +use crate::model::dto::*; +use crate::service::admin::subscribe::delete_subscribe_service; +use result::http_result::{build_http_result, HttpResult}; + +pub async fn delete_subscribe( + State(state): State, + Json(req): Json, +) -> HttpResult { + match delete_subscribe_service::delete_subscribe(state.repos.subscribe.as_ref(), req.id).await { + Ok(data) => build_http_result(Some(data), None), + Err(e) => build_http_result::<()>(None, Some(e)), + } +} diff --git a/src/handler/admin/subscribe/get_subscribe_details_handler.rs b/src/handler/admin/subscribe/get_subscribe_details_handler.rs new file mode 100644 index 00000000..3fa7d97b --- /dev/null +++ b/src/handler/admin/subscribe/get_subscribe_details_handler.rs @@ -0,0 +1,16 @@ +use axum::extract::{Query, State}; + +use crate::handler::AppState; +use crate::model::dto::*; +use crate::service::admin::subscribe::get_subscribe_details_service; +use result::http_result::{build_http_result, HttpResult}; + +pub async fn get_subscribe_details( + State(state): State, + Query(req): Query, +) -> HttpResult { + match get_subscribe_details_service::get_subscribe_details(state.repos.subscribe.as_ref(), req).await { + Ok(data) => build_http_result(Some(data), None), + Err(e) => build_http_result::<()>(None, Some(e)), + } +} diff --git a/src/handler/admin/subscribe/get_subscribe_group_list_handler.rs b/src/handler/admin/subscribe/get_subscribe_group_list_handler.rs new file mode 100644 index 00000000..dc8908dd --- /dev/null +++ b/src/handler/admin/subscribe/get_subscribe_group_list_handler.rs @@ -0,0 +1,14 @@ +use axum::extract::State; + +use crate::handler::AppState; +use crate::service::admin::subscribe::get_subscribe_group_list_service; +use result::http_result::{build_http_result, HttpResult}; + +pub async fn get_subscribe_group_list( + State(state): State, +) -> HttpResult { + match get_subscribe_group_list_service::get_subscribe_group_list(state.repos.subscribe.as_ref()).await { + Ok(data) => build_http_result(Some(data), None), + Err(e) => build_http_result::<()>(None, Some(e)), + } +} diff --git a/src/handler/admin/subscribe/get_subscribe_list_handler.rs b/src/handler/admin/subscribe/get_subscribe_list_handler.rs new file mode 100644 index 00000000..51f63759 --- /dev/null +++ b/src/handler/admin/subscribe/get_subscribe_list_handler.rs @@ -0,0 +1,16 @@ +use axum::extract::{Query, State}; + +use crate::handler::AppState; +use crate::model::dto::*; +use crate::service::admin::subscribe::get_subscribe_list_service; +use result::http_result::{build_http_result, HttpResult}; + +pub async fn get_subscribe_list( + State(state): State, + Query(req): Query, +) -> HttpResult { + match get_subscribe_list_service::get_subscribe_list(state.repos.subscribe.as_ref(), req).await { + Ok(data) => build_http_result(Some(data), None), + Err(e) => build_http_result::<()>(None, Some(e)), + } +} diff --git a/src/handler/admin/subscribe/mod.rs b/src/handler/admin/subscribe/mod.rs new file mode 100644 index 00000000..bf9a1b92 --- /dev/null +++ b/src/handler/admin/subscribe/mod.rs @@ -0,0 +1,26 @@ +mod batch_delete_subscribe_group_handler; +pub use batch_delete_subscribe_group_handler::batch_delete_subscribe_group; +mod batch_delete_subscribe_handler; +pub use batch_delete_subscribe_handler::batch_delete_subscribe; +mod create_subscribe_group_handler; +pub use create_subscribe_group_handler::create_subscribe_group; +mod create_subscribe_handler; +pub use create_subscribe_handler::create_subscribe; +mod delete_subscribe_group_handler; +pub use delete_subscribe_group_handler::delete_subscribe_group; +mod delete_subscribe_handler; +pub use delete_subscribe_handler::delete_subscribe; +mod get_subscribe_details_handler; +pub use get_subscribe_details_handler::get_subscribe_details; +mod get_subscribe_group_list_handler; +pub use get_subscribe_group_list_handler::get_subscribe_group_list; +mod get_subscribe_list_handler; +pub use get_subscribe_list_handler::get_subscribe_list; +mod reset_all_subscribe_token_handler; +pub use reset_all_subscribe_token_handler::reset_all_subscribe_token; +mod subscribe_sort_handler; +pub use subscribe_sort_handler::subscribe_sort; +mod update_subscribe_group_handler; +pub use update_subscribe_group_handler::update_subscribe_group; +mod update_subscribe_handler; +pub use update_subscribe_handler::update_subscribe; diff --git a/src/handler/admin/subscribe/reset_all_subscribe_token_handler.rs b/src/handler/admin/subscribe/reset_all_subscribe_token_handler.rs new file mode 100644 index 00000000..76260392 --- /dev/null +++ b/src/handler/admin/subscribe/reset_all_subscribe_token_handler.rs @@ -0,0 +1,14 @@ +use axum::extract::State; + +use crate::handler::AppState; +use crate::service::admin::subscribe::reset_all_subscribe_token_service; +use result::http_result::{build_http_result, HttpResult}; + +pub async fn reset_all_subscribe_token( + State(state): State, +) -> HttpResult { + match reset_all_subscribe_token_service::reset_all_subscribe_token(state.repos.subscribe.as_ref()).await { + Ok(data) => build_http_result(Some(data), None), + Err(e) => build_http_result::<()>(None, Some(e)), + } +} diff --git a/src/handler/admin/subscribe/subscribe_sort_handler.rs b/src/handler/admin/subscribe/subscribe_sort_handler.rs new file mode 100644 index 00000000..cdb0da2c --- /dev/null +++ b/src/handler/admin/subscribe/subscribe_sort_handler.rs @@ -0,0 +1,17 @@ +use axum::extract::State; +use axum::Json; + +use crate::handler::AppState; +use crate::model::dto::*; +use crate::service::admin::subscribe::subscribe_sort_service; +use result::http_result::{build_http_result, HttpResult}; + +pub async fn subscribe_sort( + State(state): State, + Json(req): Json, +) -> HttpResult { + match subscribe_sort_service::subscribe_sort(state.repos.subscribe.as_ref(), req).await { + Ok(data) => build_http_result(Some(data), None), + Err(e) => build_http_result::<()>(None, Some(e)), + } +} diff --git a/src/handler/admin/subscribe/update_subscribe_group_handler.rs b/src/handler/admin/subscribe/update_subscribe_group_handler.rs new file mode 100644 index 00000000..5584436c --- /dev/null +++ b/src/handler/admin/subscribe/update_subscribe_group_handler.rs @@ -0,0 +1,17 @@ +use axum::extract::State; +use axum::Json; + +use crate::handler::AppState; +use crate::model::dto::*; +use crate::service::admin::subscribe::update_subscribe_group_service; +use result::http_result::{build_http_result, HttpResult}; + +pub async fn update_subscribe_group( + State(state): State, + Json(req): Json, +) -> HttpResult { + match update_subscribe_group_service::update_subscribe_group(state.repos.subscribe.as_ref(), req).await { + Ok(data) => build_http_result(Some(data), None), + Err(e) => build_http_result::<()>(None, Some(e)), + } +} diff --git a/src/handler/admin/subscribe/update_subscribe_handler.rs b/src/handler/admin/subscribe/update_subscribe_handler.rs new file mode 100644 index 00000000..8545f896 --- /dev/null +++ b/src/handler/admin/subscribe/update_subscribe_handler.rs @@ -0,0 +1,17 @@ +use axum::extract::State; +use axum::Json; + +use crate::handler::AppState; +use crate::model::dto::*; +use crate::service::admin::subscribe::update_subscribe_service; +use result::http_result::{build_http_result, HttpResult}; + +pub async fn update_subscribe( + State(state): State, + Json(req): Json, +) -> HttpResult { + match update_subscribe_service::update_subscribe(state.repos.subscribe.as_ref(), req).await { + Ok(data) => build_http_result(Some(data), None), + Err(e) => build_http_result::<()>(None, Some(e)), + } +} diff --git a/src/handler/admin/system/get_currency_config_handler.rs b/src/handler/admin/system/get_currency_config_handler.rs new file mode 100644 index 00000000..1722f2ef --- /dev/null +++ b/src/handler/admin/system/get_currency_config_handler.rs @@ -0,0 +1,14 @@ +use axum::extract::State; + +use crate::handler::AppState; +use crate::service::admin::system::get_currency_config_service; +use result::http_result::{build_http_result, HttpResult}; + +pub async fn get_currency_config( + State(state): State, +) -> HttpResult { + match get_currency_config_service::get_currency_config(&state.repos).await { + Ok(data) => build_http_result(Some(data), None), + Err(e) => build_http_result::<()>(None, Some(e)), + } +} diff --git a/src/handler/admin/system/get_invite_config_handler.rs b/src/handler/admin/system/get_invite_config_handler.rs new file mode 100644 index 00000000..2f7d01cc --- /dev/null +++ b/src/handler/admin/system/get_invite_config_handler.rs @@ -0,0 +1,14 @@ +use axum::extract::State; + +use crate::handler::AppState; +use crate::service::admin::system::get_invite_config_service; +use result::http_result::{build_http_result, HttpResult}; + +pub async fn get_invite_config( + State(state): State, +) -> HttpResult { + match get_invite_config_service::get_invite_config(&state.repos).await { + Ok(data) => build_http_result(Some(data), None), + Err(e) => build_http_result::<()>(None, Some(e)), + } +} diff --git a/src/handler/admin/system/get_module_config_handler.rs b/src/handler/admin/system/get_module_config_handler.rs new file mode 100644 index 00000000..f8b7cb1a --- /dev/null +++ b/src/handler/admin/system/get_module_config_handler.rs @@ -0,0 +1,9 @@ +use crate::service::admin::system::get_module_config_service; +use result::http_result::{build_http_result, HttpResult}; + +pub async fn get_module_config() -> HttpResult { + match get_module_config_service::get_module_config().await { + Ok(data) => build_http_result(Some(data), None), + Err(e) => build_http_result::<()>(None, Some(e)), + } +} diff --git a/src/handler/admin/system/get_node_config_handler.rs b/src/handler/admin/system/get_node_config_handler.rs new file mode 100644 index 00000000..d0a103f7 --- /dev/null +++ b/src/handler/admin/system/get_node_config_handler.rs @@ -0,0 +1,14 @@ +use axum::extract::State; + +use crate::handler::AppState; +use crate::service::admin::system::get_node_config_service; +use result::http_result::{build_http_result, HttpResult}; + +pub async fn get_node_config( + State(state): State, +) -> HttpResult { + match get_node_config_service::get_node_config(&state.repos).await { + Ok(data) => build_http_result(Some(data), None), + Err(e) => build_http_result::<()>(None, Some(e)), + } +} diff --git a/src/handler/admin/system/get_node_multiplier_handler.rs b/src/handler/admin/system/get_node_multiplier_handler.rs new file mode 100644 index 00000000..8dd47681 --- /dev/null +++ b/src/handler/admin/system/get_node_multiplier_handler.rs @@ -0,0 +1,14 @@ +use axum::extract::State; + +use crate::handler::AppState; +use crate::service::admin::system::get_node_multiplier_service; +use result::http_result::{build_http_result, HttpResult}; + +pub async fn get_node_multiplier( + State(state): State, +) -> HttpResult { + match get_node_multiplier_service::get_node_multiplier(&state.repos).await { + Ok(data) => build_http_result(Some(data), None), + Err(e) => build_http_result::<()>(None, Some(e)), + } +} diff --git a/src/handler/admin/system/get_privacy_policy_config_handler.rs b/src/handler/admin/system/get_privacy_policy_config_handler.rs new file mode 100644 index 00000000..10585281 --- /dev/null +++ b/src/handler/admin/system/get_privacy_policy_config_handler.rs @@ -0,0 +1,14 @@ +use axum::extract::State; + +use crate::handler::AppState; +use crate::service::admin::system::get_privacy_policy_config_service; +use result::http_result::{build_http_result, HttpResult}; + +pub async fn get_privacy_policy_config( + State(state): State, +) -> HttpResult { + match get_privacy_policy_config_service::get_privacy_policy_config(&state.repos).await { + Ok(data) => build_http_result(Some(data), None), + Err(e) => build_http_result::<()>(None, Some(e)), + } +} diff --git a/src/handler/admin/system/get_register_config_handler.rs b/src/handler/admin/system/get_register_config_handler.rs new file mode 100644 index 00000000..1254fcad --- /dev/null +++ b/src/handler/admin/system/get_register_config_handler.rs @@ -0,0 +1,14 @@ +use axum::extract::State; + +use crate::handler::AppState; +use crate::service::admin::system::get_register_config_service; +use result::http_result::{build_http_result, HttpResult}; + +pub async fn get_register_config( + State(state): State, +) -> HttpResult { + match get_register_config_service::get_register_config(&state.repos).await { + Ok(data) => build_http_result(Some(data), None), + Err(e) => build_http_result::<()>(None, Some(e)), + } +} diff --git a/src/handler/admin/system/get_site_config_handler.rs b/src/handler/admin/system/get_site_config_handler.rs new file mode 100644 index 00000000..04ca8532 --- /dev/null +++ b/src/handler/admin/system/get_site_config_handler.rs @@ -0,0 +1,14 @@ +use axum::extract::State; + +use crate::handler::AppState; +use crate::service::admin::system::get_site_config_service; +use result::http_result::{build_http_result, HttpResult}; + +pub async fn get_site_config( + State(state): State, +) -> HttpResult { + match get_site_config_service::get_site_config(&state.repos).await { + Ok(data) => build_http_result(Some(data), None), + Err(e) => build_http_result::<()>(None, Some(e)), + } +} diff --git a/src/handler/admin/system/get_subscribe_config_handler.rs b/src/handler/admin/system/get_subscribe_config_handler.rs new file mode 100644 index 00000000..9db70dfa --- /dev/null +++ b/src/handler/admin/system/get_subscribe_config_handler.rs @@ -0,0 +1,14 @@ +use axum::extract::State; + +use crate::handler::AppState; +use crate::service::admin::system::get_subscribe_config_service; +use result::http_result::{build_http_result, HttpResult}; + +pub async fn get_subscribe_config( + State(state): State, +) -> HttpResult { + match get_subscribe_config_service::get_subscribe_config(&state.repos).await { + Ok(data) => build_http_result(Some(data), None), + Err(e) => build_http_result::<()>(None, Some(e)), + } +} diff --git a/src/handler/admin/system/get_tos_config_handler.rs b/src/handler/admin/system/get_tos_config_handler.rs new file mode 100644 index 00000000..61cc62ef --- /dev/null +++ b/src/handler/admin/system/get_tos_config_handler.rs @@ -0,0 +1,14 @@ +use axum::extract::State; + +use crate::handler::AppState; +use crate::service::admin::system::get_tos_config_service; +use result::http_result::{build_http_result, HttpResult}; + +pub async fn get_tos_config( + State(state): State, +) -> HttpResult { + match get_tos_config_service::get_tos_config(&state.repos).await { + Ok(data) => build_http_result(Some(data), None), + Err(e) => build_http_result::<()>(None, Some(e)), + } +} diff --git a/src/handler/admin/system/get_verify_code_config_handler.rs b/src/handler/admin/system/get_verify_code_config_handler.rs new file mode 100644 index 00000000..a7b7a7de --- /dev/null +++ b/src/handler/admin/system/get_verify_code_config_handler.rs @@ -0,0 +1,14 @@ +use axum::extract::State; + +use crate::handler::AppState; +use crate::service::admin::system::get_verify_code_config_service; +use result::http_result::{build_http_result, HttpResult}; + +pub async fn get_verify_code_config( + State(state): State, +) -> HttpResult { + match get_verify_code_config_service::get_verify_code_config(&state.repos).await { + Ok(data) => build_http_result(Some(data), None), + Err(e) => build_http_result::<()>(None, Some(e)), + } +} diff --git a/src/handler/admin/system/get_verify_config_handler.rs b/src/handler/admin/system/get_verify_config_handler.rs new file mode 100644 index 00000000..2702cf92 --- /dev/null +++ b/src/handler/admin/system/get_verify_config_handler.rs @@ -0,0 +1,14 @@ +use axum::extract::State; + +use crate::handler::AppState; +use crate::service::admin::system::get_verify_config_service; +use result::http_result::{build_http_result, HttpResult}; + +pub async fn get_verify_config( + State(state): State, +) -> HttpResult { + match get_verify_config_service::get_verify_config(&state.repos).await { + Ok(data) => build_http_result(Some(data), None), + Err(e) => build_http_result::<()>(None, Some(e)), + } +} diff --git a/src/handler/admin/system/mod.rs b/src/handler/admin/system/mod.rs new file mode 100644 index 00000000..74143faf --- /dev/null +++ b/src/handler/admin/system/mod.rs @@ -0,0 +1,50 @@ +mod get_currency_config_handler; +pub use get_currency_config_handler::get_currency_config; +mod get_invite_config_handler; +pub use get_invite_config_handler::get_invite_config; +mod get_module_config_handler; +pub use get_module_config_handler::get_module_config; +mod get_node_config_handler; +pub use get_node_config_handler::get_node_config; +mod get_node_multiplier_handler; +pub use get_node_multiplier_handler::get_node_multiplier; +mod get_privacy_policy_config_handler; +pub use get_privacy_policy_config_handler::get_privacy_policy_config; +mod get_register_config_handler; +pub use get_register_config_handler::get_register_config; +mod get_site_config_handler; +pub use get_site_config_handler::get_site_config; +mod get_subscribe_config_handler; +pub use get_subscribe_config_handler::get_subscribe_config; +mod get_tos_config_handler; +pub use get_tos_config_handler::get_tos_config; +mod get_verify_code_config_handler; +pub use get_verify_code_config_handler::get_verify_code_config; +mod get_verify_config_handler; +pub use get_verify_config_handler::get_verify_config; +mod pre_view_node_multiplier_handler; +pub use pre_view_node_multiplier_handler::pre_view_node_multiplier; +mod set_node_multiplier_handler; +pub use set_node_multiplier_handler::set_node_multiplier; +mod setting_telegram_bot_handler; +pub use setting_telegram_bot_handler::setting_telegram_bot_handler; +mod update_currency_config_handler; +pub use update_currency_config_handler::update_currency_config; +mod update_invite_config_handler; +pub use update_invite_config_handler::update_invite_config; +mod update_node_config_handler; +pub use update_node_config_handler::update_node_config; +mod update_privacy_policy_config_handler; +pub use update_privacy_policy_config_handler::update_privacy_policy_config; +mod update_register_config_handler; +pub use update_register_config_handler::update_register_config; +mod update_site_config_handler; +pub use update_site_config_handler::update_site_config; +mod update_subscribe_config_handler; +pub use update_subscribe_config_handler::update_subscribe_config; +mod update_tos_config_handler; +pub use update_tos_config_handler::update_tos_config; +mod update_verify_code_config_handler; +pub use update_verify_code_config_handler::update_verify_code_config; +mod update_verify_config_handler; +pub use update_verify_config_handler::update_verify_config; diff --git a/src/handler/admin/system/pre_view_node_multiplier_handler.rs b/src/handler/admin/system/pre_view_node_multiplier_handler.rs new file mode 100644 index 00000000..5fffb584 --- /dev/null +++ b/src/handler/admin/system/pre_view_node_multiplier_handler.rs @@ -0,0 +1,14 @@ +use axum::extract::State; + +use crate::handler::AppState; +use crate::service::admin::system::pre_view_node_multiplier_service; +use result::http_result::{build_http_result, HttpResult}; + +pub async fn pre_view_node_multiplier( + State(state): State, +) -> HttpResult { + match pre_view_node_multiplier_service::pre_view_node_multiplier(&state.repos).await { + Ok(data) => build_http_result(Some(data), None), + Err(e) => build_http_result::<()>(None, Some(e)), + } +} diff --git a/src/handler/admin/system/set_node_multiplier_handler.rs b/src/handler/admin/system/set_node_multiplier_handler.rs new file mode 100644 index 00000000..ecf7e04d --- /dev/null +++ b/src/handler/admin/system/set_node_multiplier_handler.rs @@ -0,0 +1,17 @@ +use axum::extract::State; +use axum::Json; + +use crate::handler::AppState; +use crate::model::dto::*; +use crate::service::admin::system::set_node_multiplier_service; +use result::http_result::{build_http_result, HttpResult}; + +pub async fn set_node_multiplier( + State(state): State, + Json(req): Json, +) -> HttpResult { + match set_node_multiplier_service::set_node_multiplier(&state.repos, req).await { + Ok(_) => build_http_result(Some(()), None), + Err(e) => build_http_result::<()>(None, Some(e)), + } +} diff --git a/src/handler/admin/system/setting_telegram_bot_handler.rs b/src/handler/admin/system/setting_telegram_bot_handler.rs new file mode 100644 index 00000000..addac3b4 --- /dev/null +++ b/src/handler/admin/system/setting_telegram_bot_handler.rs @@ -0,0 +1,11 @@ +use axum::extract::State; +use crate::handler::AppState; +use crate::service::admin::system::setting_telegram_bot_service::setting_telegram_bot; +use result::http_result::{build_http_result, HttpResult}; + +pub async fn setting_telegram_bot_handler(State(state): State) -> HttpResult { + match setting_telegram_bot(&state.config).await { + Ok(_) => build_http_result(Some(()), None), + Err(e) => build_http_result::<()>(None, Some(e)), + } +} diff --git a/src/handler/admin/system/update_currency_config_handler.rs b/src/handler/admin/system/update_currency_config_handler.rs new file mode 100644 index 00000000..4418faea --- /dev/null +++ b/src/handler/admin/system/update_currency_config_handler.rs @@ -0,0 +1,17 @@ +use axum::extract::State; +use axum::Json; + +use crate::handler::AppState; +use crate::model::dto::*; +use crate::service::admin::system::update_currency_config_service; +use result::http_result::{build_http_result, HttpResult}; + +pub async fn update_currency_config( + State(state): State, + Json(req): Json, +) -> HttpResult { + match update_currency_config_service::update_currency_config(&state.repos, req).await { + Ok(_) => build_http_result(Some(()), None), + Err(e) => build_http_result::<()>(None, Some(e)), + } +} diff --git a/src/handler/admin/system/update_invite_config_handler.rs b/src/handler/admin/system/update_invite_config_handler.rs new file mode 100644 index 00000000..dec4b628 --- /dev/null +++ b/src/handler/admin/system/update_invite_config_handler.rs @@ -0,0 +1,17 @@ +use axum::extract::State; +use axum::Json; + +use crate::handler::AppState; +use crate::model::dto::*; +use crate::service::admin::system::update_invite_config_service; +use result::http_result::{build_http_result, HttpResult}; + +pub async fn update_invite_config( + State(state): State, + Json(req): Json, +) -> HttpResult { + match update_invite_config_service::update_invite_config(&state.repos, req).await { + Ok(_) => build_http_result(Some(()), None), + Err(e) => build_http_result::<()>(None, Some(e)), + } +} diff --git a/src/handler/admin/system/update_node_config_handler.rs b/src/handler/admin/system/update_node_config_handler.rs new file mode 100644 index 00000000..af48a3aa --- /dev/null +++ b/src/handler/admin/system/update_node_config_handler.rs @@ -0,0 +1,17 @@ +use axum::extract::State; +use axum::Json; + +use crate::handler::AppState; +use crate::model::dto::*; +use crate::service::admin::system::update_node_config_service; +use result::http_result::{build_http_result, HttpResult}; + +pub async fn update_node_config( + State(state): State, + Json(req): Json, +) -> HttpResult { + match update_node_config_service::update_node_config(&state.repos, req).await { + Ok(_) => build_http_result(Some(()), None), + Err(e) => build_http_result::<()>(None, Some(e)), + } +} diff --git a/src/handler/admin/system/update_privacy_policy_config_handler.rs b/src/handler/admin/system/update_privacy_policy_config_handler.rs new file mode 100644 index 00000000..a865c322 --- /dev/null +++ b/src/handler/admin/system/update_privacy_policy_config_handler.rs @@ -0,0 +1,17 @@ +use axum::extract::State; +use axum::Json; + +use crate::handler::AppState; +use crate::model::dto::*; +use crate::service::admin::system::update_privacy_policy_config_service; +use result::http_result::{build_http_result, HttpResult}; + +pub async fn update_privacy_policy_config( + State(state): State, + Json(req): Json, +) -> HttpResult { + match update_privacy_policy_config_service::update_privacy_policy_config(&state.repos, req).await { + Ok(_) => build_http_result(Some(()), None), + Err(e) => build_http_result::<()>(None, Some(e)), + } +} diff --git a/src/handler/admin/system/update_register_config_handler.rs b/src/handler/admin/system/update_register_config_handler.rs new file mode 100644 index 00000000..6d513100 --- /dev/null +++ b/src/handler/admin/system/update_register_config_handler.rs @@ -0,0 +1,17 @@ +use axum::extract::State; +use axum::Json; + +use crate::handler::AppState; +use crate::model::dto::*; +use crate::service::admin::system::update_register_config_service; +use result::http_result::{build_http_result, HttpResult}; + +pub async fn update_register_config( + State(state): State, + Json(req): Json, +) -> HttpResult { + match update_register_config_service::update_register_config(&state.repos, req).await { + Ok(_) => build_http_result(Some(()), None), + Err(e) => build_http_result::<()>(None, Some(e)), + } +} diff --git a/src/handler/admin/system/update_site_config_handler.rs b/src/handler/admin/system/update_site_config_handler.rs new file mode 100644 index 00000000..84754860 --- /dev/null +++ b/src/handler/admin/system/update_site_config_handler.rs @@ -0,0 +1,17 @@ +use axum::extract::State; +use axum::Json; + +use crate::handler::AppState; +use crate::model::dto::*; +use crate::service::admin::system::update_site_config_service; +use result::http_result::{build_http_result, HttpResult}; + +pub async fn update_site_config( + State(state): State, + Json(req): Json, +) -> HttpResult { + match update_site_config_service::update_site_config(&state.repos, req).await { + Ok(_) => build_http_result(Some(()), None), + Err(e) => build_http_result::<()>(None, Some(e)), + } +} diff --git a/src/handler/admin/system/update_subscribe_config_handler.rs b/src/handler/admin/system/update_subscribe_config_handler.rs new file mode 100644 index 00000000..2d46467a --- /dev/null +++ b/src/handler/admin/system/update_subscribe_config_handler.rs @@ -0,0 +1,17 @@ +use axum::extract::State; +use axum::Json; + +use crate::handler::AppState; +use crate::model::dto::*; +use crate::service::admin::system::update_subscribe_config_service; +use result::http_result::{build_http_result, HttpResult}; + +pub async fn update_subscribe_config( + State(state): State, + Json(req): Json, +) -> HttpResult { + match update_subscribe_config_service::update_subscribe_config(&state.repos, req).await { + Ok(_) => build_http_result(Some(()), None), + Err(e) => build_http_result::<()>(None, Some(e)), + } +} diff --git a/src/handler/admin/system/update_tos_config_handler.rs b/src/handler/admin/system/update_tos_config_handler.rs new file mode 100644 index 00000000..43ce7769 --- /dev/null +++ b/src/handler/admin/system/update_tos_config_handler.rs @@ -0,0 +1,17 @@ +use axum::extract::State; +use axum::Json; + +use crate::handler::AppState; +use crate::model::dto::*; +use crate::service::admin::system::update_tos_config_service; +use result::http_result::{build_http_result, HttpResult}; + +pub async fn update_tos_config( + State(state): State, + Json(req): Json, +) -> HttpResult { + match update_tos_config_service::update_tos_config(&state.repos, req).await { + Ok(_) => build_http_result(Some(()), None), + Err(e) => build_http_result::<()>(None, Some(e)), + } +} diff --git a/src/handler/admin/system/update_verify_code_config_handler.rs b/src/handler/admin/system/update_verify_code_config_handler.rs new file mode 100644 index 00000000..87a8bfd7 --- /dev/null +++ b/src/handler/admin/system/update_verify_code_config_handler.rs @@ -0,0 +1,17 @@ +use axum::extract::State; +use axum::Json; + +use crate::handler::AppState; +use crate::model::dto::*; +use crate::service::admin::system::update_verify_code_config_service; +use result::http_result::{build_http_result, HttpResult}; + +pub async fn update_verify_code_config( + State(state): State, + Json(req): Json, +) -> HttpResult { + match update_verify_code_config_service::update_verify_code_config(&state.repos, req).await { + Ok(_) => build_http_result(Some(()), None), + Err(e) => build_http_result::<()>(None, Some(e)), + } +} diff --git a/src/handler/admin/system/update_verify_config_handler.rs b/src/handler/admin/system/update_verify_config_handler.rs new file mode 100644 index 00000000..425769e9 --- /dev/null +++ b/src/handler/admin/system/update_verify_config_handler.rs @@ -0,0 +1,17 @@ +use axum::extract::State; +use axum::Json; + +use crate::handler::AppState; +use crate::model::dto::*; +use crate::service::admin::system::update_verify_config_service; +use result::http_result::{build_http_result, HttpResult}; + +pub async fn update_verify_config( + State(state): State, + Json(req): Json, +) -> HttpResult { + match update_verify_config_service::update_verify_config(&state.repos, req).await { + Ok(_) => build_http_result(Some(()), None), + Err(e) => build_http_result::<()>(None, Some(e)), + } +} diff --git a/src/handler/admin/ticket/create_ticket_follow_handler.rs b/src/handler/admin/ticket/create_ticket_follow_handler.rs new file mode 100644 index 00000000..d65dc897 --- /dev/null +++ b/src/handler/admin/ticket/create_ticket_follow_handler.rs @@ -0,0 +1,23 @@ +use axum::extract::State; +use axum::Extension; +use axum::Json; +use crate::handler::AppState; +use crate::middleware::auth_middleware::AuthContext; +use crate::model::dto::ticket::CreateTicketFollowRequest; +use crate::service::admin::ticket::get_ticket_list_service; +use result::http_result::{build_http_result, HttpResult}; + +pub async fn create_ticket_follow( + State(state): State, + Extension(auth): Extension, + Json(req): Json, +) -> HttpResult { + match get_ticket_list_service::create_ticket_follow( + state.repos.ticket.as_ref(), + auth.user_id, + req, + ).await { + Ok(_) => build_http_result(Some(()), None), + Err(e) => build_http_result::<()>(None, Some(e)), + } +} diff --git a/src/handler/admin/ticket/get_ticket_handler.rs b/src/handler/admin/ticket/get_ticket_handler.rs new file mode 100644 index 00000000..c7af66e5 --- /dev/null +++ b/src/handler/admin/ticket/get_ticket_handler.rs @@ -0,0 +1,15 @@ +use axum::extract::{Query, State}; +use crate::handler::AppState; +use crate::model::dto::ticket::GetTicketRequest; +use crate::service::admin::ticket::get_ticket_list_service; +use result::http_result::{build_http_result, HttpResult}; + +pub async fn get_ticket( + State(state): State, + Query(req): Query, +) -> HttpResult { + match get_ticket_list_service::get_ticket(state.repos.ticket.as_ref(), req).await { + Ok(d) => build_http_result(Some(d), None), + Err(e) => build_http_result::<()>(None, Some(e)), + } +} diff --git a/src/handler/admin/ticket/get_ticket_list_handler.rs b/src/handler/admin/ticket/get_ticket_list_handler.rs new file mode 100644 index 00000000..77677fc9 --- /dev/null +++ b/src/handler/admin/ticket/get_ticket_list_handler.rs @@ -0,0 +1,15 @@ +use axum::extract::{Query, State}; +use crate::handler::AppState; +use crate::model::dto::ticket::GetTicketListRequest; +use crate::service::admin::ticket::get_ticket_list_service; +use result::http_result::{build_http_result, HttpResult}; + +pub async fn get_ticket_list( + State(state): State, + Query(req): Query, +) -> HttpResult { + match get_ticket_list_service::get_ticket_list(state.repos.ticket.as_ref(), req).await { + Ok(d) => build_http_result(Some(d), None), + Err(e) => build_http_result::<()>(None, Some(e)), + } +} diff --git a/src/handler/admin/ticket/mod.rs b/src/handler/admin/ticket/mod.rs new file mode 100644 index 00000000..e17a157e --- /dev/null +++ b/src/handler/admin/ticket/mod.rs @@ -0,0 +1,8 @@ +mod create_ticket_follow_handler; +pub use create_ticket_follow_handler::create_ticket_follow; +mod get_ticket_handler; +pub use get_ticket_handler::get_ticket; +mod get_ticket_list_handler; +pub use get_ticket_list_handler::get_ticket_list; +mod update_ticket_status_handler; +pub use update_ticket_status_handler::update_ticket_status; diff --git a/src/handler/admin/ticket/update_ticket_status_handler.rs b/src/handler/admin/ticket/update_ticket_status_handler.rs new file mode 100644 index 00000000..628701a2 --- /dev/null +++ b/src/handler/admin/ticket/update_ticket_status_handler.rs @@ -0,0 +1,16 @@ +use axum::extract::State; +use axum::Json; +use crate::handler::AppState; +use crate::model::dto::ticket::UpdateTicketStatusRequest; +use crate::service::admin::ticket::get_ticket_list_service; +use result::http_result::{build_http_result, HttpResult}; + +pub async fn update_ticket_status( + State(state): State, + Json(req): Json, +) -> HttpResult { + match get_ticket_list_service::update_ticket_status(state.repos.ticket.as_ref(), req).await { + Ok(_) => build_http_result(Some(()), None), + Err(e) => build_http_result::<()>(None, Some(e)), + } +} diff --git a/src/handler/admin/tool/get_system_log_handler.rs b/src/handler/admin/tool/get_system_log_handler.rs new file mode 100644 index 00000000..c5ceb32a --- /dev/null +++ b/src/handler/admin/tool/get_system_log_handler.rs @@ -0,0 +1,9 @@ +use crate::service::admin::tool::get_system_log_service; +use result::http_result::{build_http_result, HttpResult}; + +pub async fn get_system_log() -> HttpResult { + match get_system_log_service::get_system_log().await { + Ok(data) => build_http_result(Some(data), None), + Err(e) => build_http_result::<()>(None, Some(e)), + } +} diff --git a/src/handler/admin/tool/get_version_handler.rs b/src/handler/admin/tool/get_version_handler.rs new file mode 100644 index 00000000..ce8e6b76 --- /dev/null +++ b/src/handler/admin/tool/get_version_handler.rs @@ -0,0 +1,9 @@ +use crate::service::admin::tool::get_version_service; +use result::http_result::{build_http_result, HttpResult}; + +pub async fn get_version() -> HttpResult { + match get_version_service::get_version().await { + Ok(data) => build_http_result(Some(data), None), + Err(e) => build_http_result::<()>(None, Some(e)), + } +} diff --git a/src/handler/admin/tool/mod.rs b/src/handler/admin/tool/mod.rs new file mode 100644 index 00000000..6ec5ebdf --- /dev/null +++ b/src/handler/admin/tool/mod.rs @@ -0,0 +1,8 @@ +mod get_system_log_handler; +pub use get_system_log_handler::get_system_log; +mod get_version_handler; +pub use get_version_handler::get_version; +mod query_ip_location_handler; +pub use query_ip_location_handler::query_ip_location; +mod restart_system_handler; +pub use restart_system_handler::restart_system; diff --git a/src/handler/admin/tool/query_ip_location_handler.rs b/src/handler/admin/tool/query_ip_location_handler.rs new file mode 100644 index 00000000..02200190 --- /dev/null +++ b/src/handler/admin/tool/query_ip_location_handler.rs @@ -0,0 +1,14 @@ +use axum::extract::Query; + +use crate::model::dto::*; +use crate::service::admin::tool::query_ip_location_service; +use result::http_result::{build_http_result, HttpResult}; + +pub async fn query_ip_location( + Query(req): Query, +) -> HttpResult { + match query_ip_location_service::query_ip_location(req).await { + Ok(data) => build_http_result(Some(data), None), + Err(e) => build_http_result::<()>(None, Some(e)), + } +} diff --git a/src/handler/admin/tool/restart_system_handler.rs b/src/handler/admin/tool/restart_system_handler.rs new file mode 100644 index 00000000..c2e33a74 --- /dev/null +++ b/src/handler/admin/tool/restart_system_handler.rs @@ -0,0 +1,9 @@ +use crate::service::admin::tool::restart_system_service; +use result::http_result::{build_http_result, HttpResult}; + +pub async fn restart_system() -> HttpResult { + match restart_system_service::restart_system().await { + Ok(()) => build_http_result(Some(()), None), + Err(e) => build_http_result::<()>(None, Some(e)), + } +} diff --git a/src/handler/admin/user/batch_delete_user_handler.rs b/src/handler/admin/user/batch_delete_user_handler.rs new file mode 100644 index 00000000..514d0b30 --- /dev/null +++ b/src/handler/admin/user/batch_delete_user_handler.rs @@ -0,0 +1,17 @@ +use axum::extract::State; +use axum::Json; + +use crate::handler::AppState; +use crate::model::dto::*; +use crate::service::admin::user::batch_delete_user_service; +use result::http_result::{build_http_result, HttpResult}; + +pub async fn batch_delete_user( + State(state): State, + Json(req): Json, +) -> HttpResult { + match batch_delete_user_service::batch_delete_user(&state.repos, req).await { + Ok(data) => build_http_result(Some(data), None), + Err(e) => build_http_result::<()>(None, Some(e)), + } +} diff --git a/src/handler/admin/user/create_user_auth_method_handler.rs b/src/handler/admin/user/create_user_auth_method_handler.rs new file mode 100644 index 00000000..018b5c43 --- /dev/null +++ b/src/handler/admin/user/create_user_auth_method_handler.rs @@ -0,0 +1,17 @@ +use axum::extract::State; +use axum::Json; + +use crate::handler::AppState; +use crate::model::dto::*; +use crate::service::admin::user::create_user_auth_method_service; +use result::http_result::{build_http_result, HttpResult}; + +pub async fn create_user_auth_method( + State(state): State, + Json(req): Json, +) -> HttpResult { + match create_user_auth_method_service::create_user_auth_method(&state.repos, req).await { + Ok(data) => build_http_result(Some(data), None), + Err(e) => build_http_result::<()>(None, Some(e)), + } +} diff --git a/src/handler/admin/user/create_user_handler.rs b/src/handler/admin/user/create_user_handler.rs new file mode 100644 index 00000000..4cf0cfa0 --- /dev/null +++ b/src/handler/admin/user/create_user_handler.rs @@ -0,0 +1,17 @@ +use axum::extract::State; +use axum::Json; + +use crate::handler::AppState; +use crate::model::dto::*; +use crate::service::admin::user::create_user_service; +use result::http_result::{build_http_result, HttpResult}; + +pub async fn create_user( + State(state): State, + Json(req): Json, +) -> HttpResult { + match create_user_service::create_user(&state.repos, req).await { + Ok(data) => build_http_result(Some(data), None), + Err(e) => build_http_result::<()>(None, Some(e)), + } +} diff --git a/src/handler/admin/user/create_user_subscribe_handler.rs b/src/handler/admin/user/create_user_subscribe_handler.rs new file mode 100644 index 00000000..cff06719 --- /dev/null +++ b/src/handler/admin/user/create_user_subscribe_handler.rs @@ -0,0 +1,25 @@ +use axum::extract::State; +use axum::Json; + +use crate::handler::AppState; +use crate::model::dto::*; +use crate::service::admin::user::create_user_subscribe_service; +use result::http_result::{build_http_result, HttpResult}; + +pub async fn create_user_subscribe( + State(state): State, + Json(req): Json, +) -> HttpResult { + match create_user_subscribe_service::create_user_subscribe( + &state.repos, + req.user_id, + req.subscribe_id, + req.expired_at, // maps to duration_days in service + req.traffic, + ) + .await + { + Ok(data) => build_http_result(Some(data), None), + Err(e) => build_http_result::<()>(None, Some(e)), + } +} diff --git a/src/handler/admin/user/current_user_handler.rs b/src/handler/admin/user/current_user_handler.rs new file mode 100644 index 00000000..89a8ccd9 --- /dev/null +++ b/src/handler/admin/user/current_user_handler.rs @@ -0,0 +1,17 @@ +use axum::extract::State; +use axum::Extension; + +use crate::handler::AppState; +use crate::middleware::auth_middleware::AuthContext; +use crate::service::admin::user::current_user_service; +use result::http_result::{build_http_result, HttpResult}; + +pub async fn current_user( + State(state): State, + Extension(auth): Extension, +) -> HttpResult { + match current_user_service::current_user(&state.repos, auth.user_id).await { + Ok(data) => build_http_result(Some(data), None), + Err(e) => build_http_result::<()>(None, Some(e)), + } +} diff --git a/src/handler/admin/user/delete_user_auth_method_handler.rs b/src/handler/admin/user/delete_user_auth_method_handler.rs new file mode 100644 index 00000000..28e2b7ef --- /dev/null +++ b/src/handler/admin/user/delete_user_auth_method_handler.rs @@ -0,0 +1,17 @@ +use axum::extract::State; +use axum::Json; + +use crate::handler::AppState; +use crate::model::dto::*; +use crate::service::admin::user::delete_user_auth_method_service; +use result::http_result::{build_http_result, HttpResult}; + +pub async fn delete_user_auth_method( + State(state): State, + Json(req): Json, +) -> HttpResult { + match delete_user_auth_method_service::delete_user_auth_method(&state.repos, req).await { + Ok(data) => build_http_result(Some(data), None), + Err(e) => build_http_result::<()>(None, Some(e)), + } +} diff --git a/src/handler/admin/user/delete_user_device_handler.rs b/src/handler/admin/user/delete_user_device_handler.rs new file mode 100644 index 00000000..be2d1a16 --- /dev/null +++ b/src/handler/admin/user/delete_user_device_handler.rs @@ -0,0 +1,17 @@ +use axum::extract::State; +use axum::Json; + +use crate::handler::AppState; +use crate::model::dto::*; +use crate::service::admin::user::delete_user_device_service; +use result::http_result::{build_http_result, HttpResult}; + +pub async fn delete_user_device( + State(state): State, + Json(req): Json, +) -> HttpResult { + match delete_user_device_service::delete_user_device(&state.repos, req).await { + Ok(data) => build_http_result(Some(data), None), + Err(e) => build_http_result::<()>(None, Some(e)), + } +} diff --git a/src/handler/admin/user/delete_user_handler.rs b/src/handler/admin/user/delete_user_handler.rs new file mode 100644 index 00000000..1336eacf --- /dev/null +++ b/src/handler/admin/user/delete_user_handler.rs @@ -0,0 +1,17 @@ +use axum::extract::State; +use axum::Json; + +use crate::handler::AppState; +use crate::model::dto::*; +use crate::service::admin::user::delete_user_service; +use result::http_result::{build_http_result, HttpResult}; + +pub async fn delete_user( + State(state): State, + Json(req): Json, +) -> HttpResult { + match delete_user_service::delete_user(&state.repos, req.id).await { + Ok(data) => build_http_result(Some(data), None), + Err(e) => build_http_result::<()>(None, Some(e)), + } +} diff --git a/src/handler/admin/user/delete_user_subscribe_handler.rs b/src/handler/admin/user/delete_user_subscribe_handler.rs new file mode 100644 index 00000000..63116b8e --- /dev/null +++ b/src/handler/admin/user/delete_user_subscribe_handler.rs @@ -0,0 +1,17 @@ +use axum::extract::State; +use axum::Json; + +use crate::handler::AppState; +use crate::model::dto::*; +use crate::service::admin::user::delete_user_subscribe_service; +use result::http_result::{build_http_result, HttpResult}; + +pub async fn delete_user_subscribe( + State(state): State, + Json(req): Json, +) -> HttpResult { + match delete_user_subscribe_service::delete_user_subscribe(&state.repos, req.user_subscribe_id).await { + Ok(data) => build_http_result(Some(data), None), + Err(e) => build_http_result::<()>(None, Some(e)), + } +} diff --git a/src/handler/admin/user/get_user_auth_method_handler.rs b/src/handler/admin/user/get_user_auth_method_handler.rs new file mode 100644 index 00000000..28e08cb2 --- /dev/null +++ b/src/handler/admin/user/get_user_auth_method_handler.rs @@ -0,0 +1,17 @@ +use axum::extract::State; +use axum::Json; + +use crate::handler::AppState; +use crate::model::dto::*; +use crate::service::admin::user::get_user_auth_method_service; +use result::http_result::{build_http_result, HttpResult}; + +pub async fn get_user_auth_method( + State(state): State, + Json(req): Json, +) -> HttpResult { + match get_user_auth_method_service::get_user_auth_method(&state.repos, req).await { + Ok(data) => build_http_result(Some(data), None), + Err(e) => build_http_result::<()>(None, Some(e)), + } +} diff --git a/src/handler/admin/user/get_user_detail_handler.rs b/src/handler/admin/user/get_user_detail_handler.rs new file mode 100644 index 00000000..17a6b609 --- /dev/null +++ b/src/handler/admin/user/get_user_detail_handler.rs @@ -0,0 +1,16 @@ +use axum::extract::{Query, State}; + +use crate::handler::AppState; +use crate::model::dto::*; +use crate::service::admin::user::get_user_detail_service; +use result::http_result::{build_http_result, HttpResult}; + +pub async fn get_user_detail( + State(state): State, + Query(req): Query, +) -> HttpResult { + match get_user_detail_service::get_user_detail(&state.repos, req.id).await { + Ok(data) => build_http_result(Some(data), None), + Err(e) => build_http_result::<()>(None, Some(e)), + } +} diff --git a/src/handler/admin/user/get_user_list_handler.rs b/src/handler/admin/user/get_user_list_handler.rs new file mode 100644 index 00000000..518fd620 --- /dev/null +++ b/src/handler/admin/user/get_user_list_handler.rs @@ -0,0 +1,16 @@ +use axum::extract::{Query, State}; + +use crate::handler::AppState; +use crate::model::dto::*; +use crate::service::admin::user::get_user_list_service; +use result::http_result::{build_http_result, HttpResult}; + +pub async fn get_user_list( + State(state): State, + Query(req): Query, +) -> HttpResult { + match get_user_list_service::get_user_list(&state.repos, req).await { + Ok(data) => build_http_result(Some(data), None), + Err(e) => build_http_result::<()>(None, Some(e)), + } +} diff --git a/src/handler/admin/user/get_user_login_logs_handler.rs b/src/handler/admin/user/get_user_login_logs_handler.rs new file mode 100644 index 00000000..b6715a88 --- /dev/null +++ b/src/handler/admin/user/get_user_login_logs_handler.rs @@ -0,0 +1,16 @@ +use axum::extract::{Query, State}; + +use crate::handler::AppState; +use crate::model::dto::*; +use crate::service::admin::user::get_user_login_logs_service; +use result::http_result::{build_http_result, HttpResult}; + +pub async fn get_user_login_logs( + State(state): State, + Query(req): Query, +) -> HttpResult { + match get_user_login_logs_service::get_user_login_logs(&state.repos, req).await { + Ok(data) => build_http_result(Some(data), None), + Err(e) => build_http_result::<()>(None, Some(e)), + } +} diff --git a/src/handler/admin/user/get_user_subscribe_by_id_handler.rs b/src/handler/admin/user/get_user_subscribe_by_id_handler.rs new file mode 100644 index 00000000..431cbaa4 --- /dev/null +++ b/src/handler/admin/user/get_user_subscribe_by_id_handler.rs @@ -0,0 +1,16 @@ +use axum::extract::{Query, State}; + +use crate::handler::AppState; +use crate::model::dto::*; +use crate::service::admin::user::get_user_subscribe_by_id_service; +use result::http_result::{build_http_result, HttpResult}; + +pub async fn get_user_subscribe_by_id( + State(state): State, + Query(req): Query, +) -> HttpResult { + match get_user_subscribe_by_id_service::get_user_subscribe_by_id(&state.repos, req.id).await { + Ok(data) => build_http_result(Some(data), None), + Err(e) => build_http_result::<()>(None, Some(e)), + } +} diff --git a/src/handler/admin/user/get_user_subscribe_devices_handler.rs b/src/handler/admin/user/get_user_subscribe_devices_handler.rs new file mode 100644 index 00000000..41cedd76 --- /dev/null +++ b/src/handler/admin/user/get_user_subscribe_devices_handler.rs @@ -0,0 +1,16 @@ +use axum::extract::{Query, State}; + +use crate::handler::AppState; +use crate::model::dto::*; +use crate::service::admin::user::get_user_subscribe_devices_service; +use result::http_result::{build_http_result, HttpResult}; + +pub async fn get_user_subscribe_devices( + State(state): State, + Query(req): Query, +) -> HttpResult { + match get_user_subscribe_devices_service::get_user_subscribe_devices(&state.repos, req.user_id).await { + Ok(data) => build_http_result(Some(data), None), + Err(e) => build_http_result::<()>(None, Some(e)), + } +} diff --git a/src/handler/admin/user/get_user_subscribe_handler.rs b/src/handler/admin/user/get_user_subscribe_handler.rs new file mode 100644 index 00000000..f6823599 --- /dev/null +++ b/src/handler/admin/user/get_user_subscribe_handler.rs @@ -0,0 +1,21 @@ +use axum::extract::{Query, State}; + +use crate::handler::AppState; +use crate::model::dto::*; +use result::http_result::{build_http_result, HttpResult}; +use result::code_error::CodeError; +use result::error_code; + +pub async fn get_user_subscribe( + State(state): State, + Query(req): Query, +) -> HttpResult { + let result = state.repos.user + .query_user_subscribe(req.user_id, &[0, 1, 2, 3, 4, 5]) + .await + .map_err(|e| anyhow::Error::new(CodeError::new_err_code_msg(error_code::DATABASE_QUERY_ERROR, &e.to_string()))); + match result { + Ok(data) => build_http_result(Some(data), None), + Err(e) => build_http_result::<()>(None, Some(e)), + } +} diff --git a/src/handler/admin/user/get_user_subscribe_logs_handler.rs b/src/handler/admin/user/get_user_subscribe_logs_handler.rs new file mode 100644 index 00000000..cc466592 --- /dev/null +++ b/src/handler/admin/user/get_user_subscribe_logs_handler.rs @@ -0,0 +1,23 @@ +use axum::extract::{Query, State}; + +use crate::handler::AppState; +use crate::model::dto::*; +use crate::service::admin::user::get_user_subscribe_logs_service; +use result::http_result::{build_http_result, HttpResult}; + +pub async fn get_user_subscribe_logs( + State(state): State, + Query(req): Query, +) -> HttpResult { + match get_user_subscribe_logs_service::get_user_subscribe_logs( + &state.repos, + req.user_id, + req.page, + req.size, + ) + .await + { + Ok(data) => build_http_result(Some(data), None), + Err(e) => build_http_result::<()>(None, Some(e)), + } +} diff --git a/src/handler/admin/user/get_user_subscribe_reset_traffic_logs_handler.rs b/src/handler/admin/user/get_user_subscribe_reset_traffic_logs_handler.rs new file mode 100644 index 00000000..539e86a6 --- /dev/null +++ b/src/handler/admin/user/get_user_subscribe_reset_traffic_logs_handler.rs @@ -0,0 +1,23 @@ +use axum::extract::{Query, State}; + +use crate::handler::AppState; +use crate::model::dto::*; +use crate::service::admin::user::get_user_subscribe_reset_traffic_logs_service; +use result::http_result::{build_http_result, HttpResult}; + +pub async fn get_user_subscribe_reset_traffic_logs( + State(state): State, + Query(req): Query, +) -> HttpResult { + match get_user_subscribe_reset_traffic_logs_service::get_user_subscribe_reset_traffic_logs( + &state.repos, + req.user_subscribe_id, + req.page, + req.size, + ) + .await + { + Ok(data) => build_http_result(Some(data), None), + Err(e) => build_http_result::<()>(None, Some(e)), + } +} diff --git a/src/handler/admin/user/get_user_subscribe_traffic_logs_handler.rs b/src/handler/admin/user/get_user_subscribe_traffic_logs_handler.rs new file mode 100644 index 00000000..57f9a61e --- /dev/null +++ b/src/handler/admin/user/get_user_subscribe_traffic_logs_handler.rs @@ -0,0 +1,23 @@ +use axum::extract::{Query, State}; + +use crate::handler::AppState; +use crate::model::dto::*; +use crate::service::admin::user::get_user_subscribe_traffic_logs_service; +use result::http_result::{build_http_result, HttpResult}; + +pub async fn get_user_subscribe_traffic_logs( + State(state): State, + Query(req): Query, +) -> HttpResult { + match get_user_subscribe_traffic_logs_service::get_user_subscribe_traffic_logs( + &state.repos, + req.user_id, + req.page, + req.size, + ) + .await + { + Ok(data) => build_http_result(Some(data), None), + Err(e) => build_http_result::<()>(None, Some(e)), + } +} diff --git a/src/handler/admin/user/kick_offline_by_user_device_handler.rs b/src/handler/admin/user/kick_offline_by_user_device_handler.rs new file mode 100644 index 00000000..e3c2ea65 --- /dev/null +++ b/src/handler/admin/user/kick_offline_by_user_device_handler.rs @@ -0,0 +1,23 @@ +use axum::extract::State; +use axum::Json; + +use crate::handler::AppState; +use crate::model::dto::*; +use crate::service::admin::user::kick_offline_by_user_device_service; +use result::http_result::{build_http_result, HttpResult}; + +pub async fn kick_offline_by_user_device( + State(state): State, + Json(req): Json, +) -> HttpResult { + match kick_offline_by_user_device_service::kick_offline_by_user_device( + &state.repos, + &state.cache, + req, + ) + .await + { + Ok(_) => build_http_result(Some(()), None), + Err(e) => build_http_result::<()>(None, Some(e)), + } +} diff --git a/src/handler/admin/user/mod.rs b/src/handler/admin/user/mod.rs new file mode 100644 index 00000000..51949fe1 --- /dev/null +++ b/src/handler/admin/user/mod.rs @@ -0,0 +1,56 @@ +mod batch_delete_user_handler; +pub use batch_delete_user_handler::batch_delete_user; +mod create_user_auth_method_handler; +pub use create_user_auth_method_handler::create_user_auth_method; +mod create_user_handler; +pub use create_user_handler::create_user; +mod create_user_subscribe_handler; +pub use create_user_subscribe_handler::create_user_subscribe; +mod current_user_handler; +pub use current_user_handler::current_user; +mod delete_user_auth_method_handler; +pub use delete_user_auth_method_handler::delete_user_auth_method; +mod delete_user_device_handler; +pub use delete_user_device_handler::delete_user_device; +mod delete_user_handler; +pub use delete_user_handler::delete_user; +mod delete_user_subscribe_handler; +pub use delete_user_subscribe_handler::delete_user_subscribe; +mod get_user_auth_method_handler; +pub use get_user_auth_method_handler::get_user_auth_method; +mod get_user_detail_handler; +pub use get_user_detail_handler::get_user_detail; +mod get_user_list_handler; +pub use get_user_list_handler::get_user_list; +mod get_user_login_logs_handler; +pub use get_user_login_logs_handler::get_user_login_logs; +mod get_user_subscribe_by_id_handler; +pub use get_user_subscribe_by_id_handler::get_user_subscribe_by_id; +mod get_user_subscribe_devices_handler; +pub use get_user_subscribe_devices_handler::get_user_subscribe_devices; +mod get_user_subscribe_handler; +pub use get_user_subscribe_handler::get_user_subscribe; +mod get_user_subscribe_logs_handler; +pub use get_user_subscribe_logs_handler::get_user_subscribe_logs; +mod get_user_subscribe_reset_traffic_logs_handler; +pub use get_user_subscribe_reset_traffic_logs_handler::get_user_subscribe_reset_traffic_logs; +mod get_user_subscribe_traffic_logs_handler; +pub use get_user_subscribe_traffic_logs_handler::get_user_subscribe_traffic_logs; +mod kick_offline_by_user_device_handler; +pub use kick_offline_by_user_device_handler::kick_offline_by_user_device; +mod reset_user_subscribe_token_handler; +pub use reset_user_subscribe_token_handler::reset_user_subscribe_token; +mod reset_user_subscribe_traffic_handler; +pub use reset_user_subscribe_traffic_handler::reset_user_subscribe_traffic; +mod toggle_user_subscribe_status_handler; +pub use toggle_user_subscribe_status_handler::toggle_user_subscribe_status; +mod update_user_auth_method_handler; +pub use update_user_auth_method_handler::update_user_auth_method; +mod update_user_basic_info_handler; +pub use update_user_basic_info_handler::update_user_basic_info; +mod update_user_device_handler; +pub use update_user_device_handler::update_user_device; +mod update_user_notify_setting_handler; +pub use update_user_notify_setting_handler::update_user_notify_setting; +mod update_user_subscribe_handler; +pub use update_user_subscribe_handler::update_user_subscribe; diff --git a/src/handler/admin/user/reset_user_subscribe_token_handler.rs b/src/handler/admin/user/reset_user_subscribe_token_handler.rs new file mode 100644 index 00000000..b81bca6d --- /dev/null +++ b/src/handler/admin/user/reset_user_subscribe_token_handler.rs @@ -0,0 +1,22 @@ +use axum::extract::State; +use axum::Json; + +use crate::handler::AppState; +use crate::model::dto::*; +use crate::service::admin::user::reset_user_subscribe_token_service; +use result::http_result::{build_http_result, HttpResult}; + +pub async fn reset_user_subscribe_token( + State(state): State, + Json(req): Json, +) -> HttpResult { + match reset_user_subscribe_token_service::reset_user_subscribe_token( + &state.repos, + req.user_subscribe_id, + ) + .await + { + Ok(data) => build_http_result(Some(data), None), + Err(e) => build_http_result::<()>(None, Some(e)), + } +} diff --git a/src/handler/admin/user/reset_user_subscribe_traffic_handler.rs b/src/handler/admin/user/reset_user_subscribe_traffic_handler.rs new file mode 100644 index 00000000..28e6fa97 --- /dev/null +++ b/src/handler/admin/user/reset_user_subscribe_traffic_handler.rs @@ -0,0 +1,22 @@ +use axum::extract::State; +use axum::Json; + +use crate::handler::AppState; +use crate::model::dto::*; +use crate::service::admin::user::reset_user_subscribe_traffic_service; +use result::http_result::{build_http_result, HttpResult}; + +pub async fn reset_user_subscribe_traffic( + State(state): State, + Json(req): Json, +) -> HttpResult { + match reset_user_subscribe_traffic_service::reset_user_subscribe_traffic( + &state.repos, + req.user_subscribe_id, + ) + .await + { + Ok(data) => build_http_result(Some(data), None), + Err(e) => build_http_result::<()>(None, Some(e)), + } +} diff --git a/src/handler/admin/user/toggle_user_subscribe_status_handler.rs b/src/handler/admin/user/toggle_user_subscribe_status_handler.rs new file mode 100644 index 00000000..f3f45c41 --- /dev/null +++ b/src/handler/admin/user/toggle_user_subscribe_status_handler.rs @@ -0,0 +1,22 @@ +use axum::extract::State; +use axum::Json; + +use crate::handler::AppState; +use crate::model::dto::*; +use crate::service::admin::user::toggle_user_subscribe_status_service; +use result::http_result::{build_http_result, HttpResult}; + +pub async fn toggle_user_subscribe_status( + State(state): State, + Json(req): Json, +) -> HttpResult { + match toggle_user_subscribe_status_service::toggle_user_subscribe_status( + &state.repos, + req.user_subscribe_id, + ) + .await + { + Ok(data) => build_http_result(Some(data), None), + Err(e) => build_http_result::<()>(None, Some(e)), + } +} diff --git a/src/handler/admin/user/update_user_auth_method_handler.rs b/src/handler/admin/user/update_user_auth_method_handler.rs new file mode 100644 index 00000000..2e5fb463 --- /dev/null +++ b/src/handler/admin/user/update_user_auth_method_handler.rs @@ -0,0 +1,17 @@ +use axum::extract::State; +use axum::Json; + +use crate::handler::AppState; +use crate::model::dto::*; +use crate::service::admin::user::update_user_auth_method_service; +use result::http_result::{build_http_result, HttpResult}; + +pub async fn update_user_auth_method( + State(state): State, + Json(req): Json, +) -> HttpResult { + match update_user_auth_method_service::update_user_auth_method(&state.repos, req).await { + Ok(data) => build_http_result(Some(data), None), + Err(e) => build_http_result::<()>(None, Some(e)), + } +} diff --git a/src/handler/admin/user/update_user_basic_info_handler.rs b/src/handler/admin/user/update_user_basic_info_handler.rs new file mode 100644 index 00000000..2fe216fa --- /dev/null +++ b/src/handler/admin/user/update_user_basic_info_handler.rs @@ -0,0 +1,17 @@ +use axum::extract::State; +use axum::Json; + +use crate::handler::AppState; +use crate::model::dto::*; +use crate::service::admin::user::update_user_basic_info_service; +use result::http_result::{build_http_result, HttpResult}; + +pub async fn update_user_basic_info( + State(state): State, + Json(req): Json, +) -> HttpResult { + match update_user_basic_info_service::update_user_basic_info(&state.repos, req).await { + Ok(data) => build_http_result(Some(data), None), + Err(e) => build_http_result::<()>(None, Some(e)), + } +} diff --git a/src/handler/admin/user/update_user_device_handler.rs b/src/handler/admin/user/update_user_device_handler.rs new file mode 100644 index 00000000..95074409 --- /dev/null +++ b/src/handler/admin/user/update_user_device_handler.rs @@ -0,0 +1,29 @@ +use axum::extract::State; +use axum::Json; + +use crate::handler::AppState; +use crate::model::dto::*; +use crate::model::entity::user::Device; +use crate::service::admin::user::update_user_device_service; +use result::http_result::{build_http_result, HttpResult}; + +pub async fn update_user_device( + State(state): State, + Json(req): Json, +) -> HttpResult { + let device = Device { + id: req.id, + ip: req.ip, + user_id: 0, // not carried in DTO; service updates by id only + user_agent: if req.user_agent.is_empty() { None } else { Some(req.user_agent) }, + identifier: req.identifier, + online: req.online, + enabled: req.enabled, + created_at: req.created_at, + updated_at: req.updated_at, + }; + match update_user_device_service::update_user_device(&state.repos, device).await { + Ok(data) => build_http_result(Some(data), None), + Err(e) => build_http_result::<()>(None, Some(e)), + } +} diff --git a/src/handler/admin/user/update_user_notify_setting_handler.rs b/src/handler/admin/user/update_user_notify_setting_handler.rs new file mode 100644 index 00000000..9b70c2dd --- /dev/null +++ b/src/handler/admin/user/update_user_notify_setting_handler.rs @@ -0,0 +1,17 @@ +use axum::extract::State; +use axum::Json; + +use crate::handler::AppState; +use crate::model::dto::*; +use crate::service::admin::user::update_user_notify_setting_service; +use result::http_result::{build_http_result, HttpResult}; + +pub async fn update_user_notify_setting( + State(state): State, + Json(req): Json, +) -> HttpResult { + match update_user_notify_setting_service::update_user_notify_setting(&state.repos, req).await { + Ok(data) => build_http_result(Some(data), None), + Err(e) => build_http_result::<()>(None, Some(e)), + } +} diff --git a/src/handler/admin/user/update_user_subscribe_handler.rs b/src/handler/admin/user/update_user_subscribe_handler.rs new file mode 100644 index 00000000..65292a33 --- /dev/null +++ b/src/handler/admin/user/update_user_subscribe_handler.rs @@ -0,0 +1,36 @@ +use axum::extract::State; +use axum::Json; + +use crate::handler::AppState; +use crate::model::dto::*; +use crate::service::admin::user::update_user_subscribe_service; +use result::code_error::CodeError; +use result::error_code; +use result::http_result::{build_http_result, HttpResult}; + +pub async fn update_user_subscribe( + State(state): State, + Json(req): Json, +) -> HttpResult { + let mut sub = match state.repos.user.find_one_subscribe(req.user_subscribe_id).await { + Ok(s) => s, + Err(e) => { + return build_http_result::<()>( + None, + Some(anyhow::Error::new(CodeError::new_err_code_msg( + error_code::DATABASE_QUERY_ERROR, + &e.to_string(), + ))), + ); + } + }; + sub.subscribe_id = req.subscribe_id; + sub.traffic = req.traffic; + sub.expire_time = req.expired_at; + sub.upload = req.upload; + sub.download = req.download; + match update_user_subscribe_service::update_user_subscribe(&state.repos, sub).await { + Ok(data) => build_http_result(Some(data), None), + Err(e) => build_http_result::<()>(None, Some(e)), + } +} diff --git a/src/handler/auth/check_user_handler.rs b/src/handler/auth/check_user_handler.rs new file mode 100644 index 00000000..5bc20130 --- /dev/null +++ b/src/handler/auth/check_user_handler.rs @@ -0,0 +1,17 @@ +use axum::{extract::State, Json}; + +use crate::handler::AppState; +use crate::model::dto::auth::{CheckUserRequest, CheckUserResponse}; +use crate::service::auth::check_user_service::CheckUserService; +use result::http_result::{build_http_result, HttpResult}; + +pub async fn check_user( + State(state): State, + Json(req): Json, +) -> HttpResult { + let svc = CheckUserService::new(state.repos.clone(), state.config.clone()); + match svc.check(req).await { + Ok(resp) => build_http_result(Some(resp), None), + Err(err) => build_http_result::(None, Some(err)), + } +} diff --git a/src/handler/auth/check_user_telephone_handler.rs b/src/handler/auth/check_user_telephone_handler.rs new file mode 100644 index 00000000..ffcc443d --- /dev/null +++ b/src/handler/auth/check_user_telephone_handler.rs @@ -0,0 +1,17 @@ +use axum::{extract::State, Json}; + +use crate::handler::AppState; +use crate::model::dto::auth::{TelephoneCheckUserRequest, TelephoneCheckUserResponse}; +use crate::service::auth::check_user_telephone_service::CheckUserTelephoneService; +use result::http_result::{build_http_result, HttpResult}; + +pub async fn check_user_telephone( + State(state): State, + Json(req): Json, +) -> HttpResult { + let svc = CheckUserTelephoneService::new(state.repos.clone(), state.config.clone()); + match svc.check(req).await { + Ok(resp) => build_http_result(Some(resp), None), + Err(err) => build_http_result::(None, Some(err)), + } +} diff --git a/src/handler/auth/device_login_handler.rs b/src/handler/auth/device_login_handler.rs new file mode 100644 index 00000000..a73dfc41 --- /dev/null +++ b/src/handler/auth/device_login_handler.rs @@ -0,0 +1,23 @@ +use axum::{extract::State, Extension, Json}; + +use crate::handler::AppState; +use crate::middleware::device_middleware::DeviceContext; +use crate::model::dto::auth::{DeviceLoginRequest, LoginResponse}; +use crate::service::auth::device_login_service::DeviceLoginService; +use result::http_result::{build_http_result, HttpResult}; + +pub async fn device_login( + State(state): State, + Extension(device): Extension, + Json(mut req): Json, +) -> HttpResult { + if !device.ip.is_empty() { req.ip = device.ip; } + if !device.user_agent.is_empty() { req.user_agent = device.user_agent; } + if !device.identifier.is_empty() { req.identifier = device.identifier; } + + let svc = DeviceLoginService::new(state.repos.clone(), state.config.clone(), state.cache.clone()); + match svc.login(req).await { + Ok(resp) => build_http_result(Some(resp), None), + Err(err) => build_http_result::(None, Some(err)), + } +} diff --git a/src/handler/auth/mod.rs b/src/handler/auth/mod.rs new file mode 100644 index 00000000..073d9c67 --- /dev/null +++ b/src/handler/auth/mod.rs @@ -0,0 +1,19 @@ +mod check_user_handler; +pub use check_user_handler::check_user; +mod check_user_telephone_handler; +pub use check_user_telephone_handler::check_user_telephone; +mod user_login_handler; +pub use user_login_handler::user_login; +mod device_login_handler; +pub use device_login_handler::device_login; +mod telephone_login_handler; +pub use telephone_login_handler::telephone_login; +mod user_register_handler; +pub use user_register_handler::user_register; +mod telephone_user_register_handler; +pub use telephone_user_register_handler::telephone_user_register; +mod reset_password_handler; +pub use reset_password_handler::reset_password; +mod telephone_reset_password_handler; +pub use telephone_reset_password_handler::telephone_reset_password; +pub mod oauth; diff --git a/src/handler/auth/oauth/apple_login_callback_handler.rs b/src/handler/auth/oauth/apple_login_callback_handler.rs new file mode 100644 index 00000000..d93bd4a5 --- /dev/null +++ b/src/handler/auth/oauth/apple_login_callback_handler.rs @@ -0,0 +1,26 @@ +use axum::{ + extract::State, + response::{IntoResponse, Redirect, Response}, + Json, +}; + +use crate::handler::AppState; +use crate::model::dto::auth::AppleLoginCallbackRequest; +use crate::service::auth::oauth::apple_login_callback_service::AppleLoginCallbackService; +use result::http_result::{build_http_result, HttpResult}; + +pub async fn apple_login_callback( + State(state): State, + Json(req): Json, +) -> Response { + let svc = AppleLoginCallbackService::new( + state.repos.clone(), state.config.clone(), state.cache.clone(), + ); + match svc.callback(req).await { + Ok(url) => Redirect::temporary(&url).into_response(), + Err(err) => { + let result: HttpResult = build_http_result::<()>(None, Some(err)); + result.into_response() + } + } +} diff --git a/src/handler/auth/oauth/mod.rs b/src/handler/auth/oauth/mod.rs new file mode 100644 index 00000000..ea6d0a4f --- /dev/null +++ b/src/handler/auth/oauth/mod.rs @@ -0,0 +1,6 @@ +mod apple_login_callback_handler; +pub use apple_login_callback_handler::apple_login_callback; +mod o_auth_login_handler; +pub use o_auth_login_handler::o_auth_login; +mod o_auth_login_get_token_handler; +pub use o_auth_login_get_token_handler::o_auth_login_get_token; diff --git a/src/handler/auth/oauth/o_auth_login_get_token_handler.rs b/src/handler/auth/oauth/o_auth_login_get_token_handler.rs new file mode 100644 index 00000000..fbee6cda --- /dev/null +++ b/src/handler/auth/oauth/o_auth_login_get_token_handler.rs @@ -0,0 +1,21 @@ +use axum::{extract::State, Extension, Json}; + +use crate::handler::AppState; +use crate::middleware::device_middleware::DeviceContext; +use crate::model::dto::auth::{LoginResponse, OAuthLoginGetTokenRequest}; +use crate::service::auth::oauth::o_auth_login_get_token_service::OAuthLoginGetTokenService; +use result::http_result::{build_http_result, HttpResult}; + +pub async fn o_auth_login_get_token( + State(state): State, + Extension(device): Extension, + Json(req): Json, +) -> HttpResult { + let svc = OAuthLoginGetTokenService::new( + state.repos.clone(), state.config.clone(), state.cache.clone(), + ); + match svc.get_token(req, &device.ip, &device.user_agent).await { + Ok(resp) => build_http_result(Some(resp), None), + Err(err) => build_http_result::(None, Some(err)), + } +} diff --git a/src/handler/auth/oauth/o_auth_login_handler.rs b/src/handler/auth/oauth/o_auth_login_handler.rs new file mode 100644 index 00000000..bb9b2b72 --- /dev/null +++ b/src/handler/auth/oauth/o_auth_login_handler.rs @@ -0,0 +1,19 @@ +use axum::{extract::State, Extension, Json}; + +use crate::handler::AppState; +use crate::middleware::device_middleware::DeviceContext; +use crate::model::dto::auth::{OAthLoginRequest, OAuthLoginResponse}; +use crate::service::auth::oauth::o_auth_login_service::OAuthLoginService; +use result::http_result::{build_http_result, HttpResult}; + +pub async fn o_auth_login( + State(state): State, + Extension(_device): Extension, + Json(req): Json, +) -> HttpResult { + let svc = OAuthLoginService::new(state.repos.clone(), state.config.clone(), state.cache.clone()); + match svc.login(req).await { + Ok(resp) => build_http_result(Some(resp), None), + Err(err) => build_http_result::(None, Some(err)), + } +} diff --git a/src/handler/auth/reset_password_handler.rs b/src/handler/auth/reset_password_handler.rs new file mode 100644 index 00000000..4e913acf --- /dev/null +++ b/src/handler/auth/reset_password_handler.rs @@ -0,0 +1,24 @@ +use axum::{extract::State, Extension, Json}; + +use crate::handler::AppState; +use crate::middleware::device_middleware::DeviceContext; +use crate::model::dto::auth::{LoginResponse, ResetPasswordRequest}; +use crate::service::auth::reset_password_service::ResetPasswordService; +use result::http_result::{build_http_result, HttpResult}; + +pub async fn reset_password( + State(state): State, + Extension(device): Extension, + Json(mut req): Json, +) -> HttpResult { + if !device.ip.is_empty() { req.ip = device.ip; } + if !device.user_agent.is_empty() { req.user_agent = device.user_agent; } + if !device.identifier.is_empty() { req.identifier = device.identifier; } + if !device.login_type.is_empty() { req.login_type = device.login_type; } + + let svc = ResetPasswordService::new(state.repos.clone(), state.config.clone(), state.cache.clone()); + match svc.reset(req).await { + Ok(resp) => build_http_result(Some(resp), None), + Err(err) => build_http_result::(None, Some(err)), + } +} diff --git a/src/handler/auth/telephone_login_handler.rs b/src/handler/auth/telephone_login_handler.rs new file mode 100644 index 00000000..f7379c73 --- /dev/null +++ b/src/handler/auth/telephone_login_handler.rs @@ -0,0 +1,24 @@ +use axum::{extract::State, Extension, Json}; + +use crate::handler::AppState; +use crate::middleware::device_middleware::DeviceContext; +use crate::model::dto::auth::{LoginResponse, TelephoneLoginRequest}; +use crate::service::auth::telephone_login_service::TelephoneLoginService; +use result::http_result::{build_http_result, HttpResult}; + +pub async fn telephone_login( + State(state): State, + Extension(device): Extension, + Json(mut req): Json, +) -> HttpResult { + if !device.ip.is_empty() { req.ip = device.ip; } + if !device.user_agent.is_empty() { req.user_agent = device.user_agent; } + if !device.identifier.is_empty() { req.identifier = device.identifier; } + if !device.login_type.is_empty() { req.login_type = device.login_type; } + + let svc = TelephoneLoginService::new(state.repos.clone(), state.config.clone(), state.cache.clone()); + match svc.login(req).await { + Ok(resp) => build_http_result(Some(resp), None), + Err(err) => build_http_result::(None, Some(err)), + } +} diff --git a/src/handler/auth/telephone_reset_password_handler.rs b/src/handler/auth/telephone_reset_password_handler.rs new file mode 100644 index 00000000..7c95727b --- /dev/null +++ b/src/handler/auth/telephone_reset_password_handler.rs @@ -0,0 +1,28 @@ +use axum::{extract::State, Extension, Json}; + +use crate::handler::AppState; +use crate::middleware::device_middleware::DeviceContext; +use crate::model::dto::auth::{LoginResponse, TelephoneResetPasswordRequest}; +use crate::service::auth::telephone_reset_password_service::TelephoneResetPasswordService; +use result::http_result::{build_http_result, HttpResult}; + +pub async fn telephone_reset_password( + State(state): State, + Extension(device): Extension, + Json(mut req): Json, +) -> HttpResult { + if !device.ip.is_empty() { req.ip = device.ip; } + if !device.user_agent.is_empty() { req.user_agent = device.user_agent; } + if !device.identifier.is_empty() { req.identifier = device.identifier; } + if !device.login_type.is_empty() { req.login_type = device.login_type; } + + let svc = TelephoneResetPasswordService::new( + state.repos.clone(), + state.config.clone(), + state.cache.clone(), + ); + match svc.reset(req).await { + Ok(resp) => build_http_result(Some(resp), None), + Err(err) => build_http_result::(None, Some(err)), + } +} diff --git a/src/handler/auth/telephone_user_register_handler.rs b/src/handler/auth/telephone_user_register_handler.rs new file mode 100644 index 00000000..9585285d --- /dev/null +++ b/src/handler/auth/telephone_user_register_handler.rs @@ -0,0 +1,28 @@ +use axum::{extract::State, Extension, Json}; + +use crate::handler::AppState; +use crate::middleware::device_middleware::DeviceContext; +use crate::model::dto::auth::{LoginResponse, TelephoneRegisterRequest}; +use crate::service::auth::telephone_user_register_service::TelephoneUserRegisterService; +use result::http_result::{build_http_result, HttpResult}; + +pub async fn telephone_user_register( + State(state): State, + Extension(device): Extension, + Json(mut req): Json, +) -> HttpResult { + if !device.ip.is_empty() { req.ip = device.ip; } + if !device.user_agent.is_empty() { req.user_agent = device.user_agent; } + if !device.identifier.is_empty() { req.identifier = device.identifier; } + if !device.login_type.is_empty() { req.login_type = device.login_type; } + + let svc = TelephoneUserRegisterService::new( + state.repos.clone(), + state.config.clone(), + state.cache.clone(), + ); + match svc.register(req).await { + Ok(resp) => build_http_result(Some(resp), None), + Err(err) => build_http_result::(None, Some(err)), + } +} diff --git a/src/handler/auth/user_login_handler.rs b/src/handler/auth/user_login_handler.rs new file mode 100644 index 00000000..a65846f7 --- /dev/null +++ b/src/handler/auth/user_login_handler.rs @@ -0,0 +1,25 @@ +use axum::{extract::State, Extension, Json}; + +use crate::handler::AppState; +use crate::middleware::device_middleware::DeviceContext; +use crate::model::dto::auth::{LoginResponse, UserLoginRequest}; +use crate::service::auth::user_login_service::UserLoginService; +use result::http_result::{build_http_result, HttpResult}; + +pub async fn user_login( + State(state): State, + Extension(device): Extension, + Json(mut req): Json, +) -> HttpResult { + // Override transport fields from middleware context (headers take precedence) + if !device.ip.is_empty() { req.ip = device.ip; } + if !device.user_agent.is_empty() { req.user_agent = device.user_agent; } + if !device.identifier.is_empty() { req.identifier = device.identifier; } + if !device.login_type.is_empty() { req.login_type = device.login_type; } + + let svc = UserLoginService::new(state.repos.clone(), state.config.clone(), state.cache.clone()); + match svc.login(req).await { + Ok(resp) => build_http_result(Some(resp), None), + Err(err) => build_http_result::(None, Some(err)), + } +} diff --git a/src/handler/auth/user_register_handler.rs b/src/handler/auth/user_register_handler.rs new file mode 100644 index 00000000..18155414 --- /dev/null +++ b/src/handler/auth/user_register_handler.rs @@ -0,0 +1,24 @@ +use axum::{extract::State, Extension, Json}; + +use crate::handler::AppState; +use crate::middleware::device_middleware::DeviceContext; +use crate::model::dto::auth::{LoginResponse, UserRegisterRequest}; +use crate::service::auth::user_register_service::UserRegisterService; +use result::http_result::{build_http_result, HttpResult}; + +pub async fn user_register( + State(state): State, + Extension(device): Extension, + Json(mut req): Json, +) -> HttpResult { + if !device.ip.is_empty() { req.ip = device.ip; } + if !device.user_agent.is_empty() { req.user_agent = device.user_agent; } + if !device.identifier.is_empty() { req.identifier = device.identifier; } + if !device.login_type.is_empty() { req.login_type = device.login_type; } + + let svc = UserRegisterService::new(state.repos.clone(), state.config.clone(), state.cache.clone()); + match svc.register(req).await { + Ok(resp) => build_http_result(Some(resp), None), + Err(err) => build_http_result::(None, Some(err)), + } +} diff --git a/src/handler/common/check_verification_code_handler.rs b/src/handler/common/check_verification_code_handler.rs new file mode 100644 index 00000000..23704dc4 --- /dev/null +++ b/src/handler/common/check_verification_code_handler.rs @@ -0,0 +1,17 @@ +use axum::{extract::State, Json}; + +use crate::handler::AppState; +use crate::model::dto::auth::{CheckVerificationCodeRequest, CheckVerificationCodeRespone}; +use crate::service::common::check_verification_code_service::CheckVerificationCodeService; +use result::http_result::{build_http_result, HttpResult}; + +pub async fn check_verification_code( + State(state): State, + Json(req): Json, +) -> HttpResult { + let svc = CheckVerificationCodeService::new(state.repos.clone(), state.config.clone(), state.cache.clone()); + match svc.check(req).await { + Ok(resp) => build_http_result(Some(resp), None), + Err(err) => build_http_result::(None, Some(err)), + } +} diff --git a/src/handler/common/get_ads_handler.rs b/src/handler/common/get_ads_handler.rs new file mode 100644 index 00000000..36c078e9 --- /dev/null +++ b/src/handler/common/get_ads_handler.rs @@ -0,0 +1,16 @@ +use axum::extract::{Query, State}; +use result::http_result::{build_http_result, HttpResult}; + +use crate::handler::AppState; +use crate::model::dto::ads::GetAdsRequest; +use crate::service::common::get_ads_service; + +pub async fn get_ads( + State(state): State, + Query(_req): Query, +) -> HttpResult { + match get_ads_service::get_ads(&state.repos).await { + Ok(resp) => build_http_result(Some(resp), None), + Err(err) => build_http_result(None::<()>, Some(err)), + } +} diff --git a/src/handler/common/get_client_handler.rs b/src/handler/common/get_client_handler.rs new file mode 100644 index 00000000..cc375a9f --- /dev/null +++ b/src/handler/common/get_client_handler.rs @@ -0,0 +1,13 @@ +use axum::extract::State; + +use crate::handler::AppState; +use crate::model::dto::subscribe::GetSubscribeClientResponse; +use crate::service::common::get_client_service; +use result::http_result::{build_http_result, HttpResult}; + +pub async fn get_client(State(state): State) -> HttpResult { + match get_client_service::get_client(&state.repos).await { + Ok(resp) => build_http_result(Some(resp), None), + Err(err) => build_http_result::(None, Some(err)), + } +} diff --git a/src/handler/common/get_global_config_handler.rs b/src/handler/common/get_global_config_handler.rs new file mode 100644 index 00000000..2864a47a --- /dev/null +++ b/src/handler/common/get_global_config_handler.rs @@ -0,0 +1,13 @@ +use axum::extract::State; + +use crate::handler::AppState; +use crate::model::dto::common::GetGlobalConfigResponse; +use crate::service::common::get_global_config_service; +use result::http_result::{build_http_result, HttpResult}; + +pub async fn get_global_config(State(state): State) -> HttpResult { + match get_global_config_service::get_global_config(&state.repos, &state.config).await { + Ok(resp) => build_http_result(Some(resp), None), + Err(err) => build_http_result::(None, Some(err)), + } +} diff --git a/src/handler/common/get_privacy_policy_handler.rs b/src/handler/common/get_privacy_policy_handler.rs new file mode 100644 index 00000000..42116989 --- /dev/null +++ b/src/handler/common/get_privacy_policy_handler.rs @@ -0,0 +1,13 @@ +use axum::extract::State; + +use crate::handler::AppState; +use crate::model::dto::system::PrivacyPolicyConfig; +use crate::service::common::get_privacy_policy_service; +use result::http_result::{build_http_result, HttpResult}; + +pub async fn get_privacy_policy(State(state): State) -> HttpResult { + match get_privacy_policy_service::get_privacy_policy(&state.repos).await { + Ok(resp) => build_http_result(Some(resp), None), + Err(err) => build_http_result::(None, Some(err)), + } +} diff --git a/src/handler/common/get_stat_handler.rs b/src/handler/common/get_stat_handler.rs new file mode 100644 index 00000000..505d7280 --- /dev/null +++ b/src/handler/common/get_stat_handler.rs @@ -0,0 +1,13 @@ +use axum::extract::State; + +use crate::handler::AppState; +use crate::model::dto::common::GetStatResponse; +use crate::service::common::get_stat_service; +use result::http_result::{build_http_result, HttpResult}; + +pub async fn get_stat(State(state): State) -> HttpResult { + match get_stat_service::get_stat(&state.repos).await { + Ok(resp) => build_http_result(Some(resp), None), + Err(err) => build_http_result::(None, Some(err)), + } +} diff --git a/src/handler/common/get_tos_handler.rs b/src/handler/common/get_tos_handler.rs new file mode 100644 index 00000000..b45a3a51 --- /dev/null +++ b/src/handler/common/get_tos_handler.rs @@ -0,0 +1,13 @@ +use axum::extract::State; + +use crate::handler::AppState; +use crate::model::dto::common::GetTosResponse; +use crate::service::common::get_tos_service; +use result::http_result::{build_http_result, HttpResult}; + +pub async fn get_tos(State(state): State) -> HttpResult { + match get_tos_service::get_tos(&state.repos).await { + Ok(resp) => build_http_result(Some(resp), None), + Err(err) => build_http_result::(None, Some(err)), + } +} diff --git a/src/handler/common/heartbeat_handler.rs b/src/handler/common/heartbeat_handler.rs new file mode 100644 index 00000000..34302aa2 --- /dev/null +++ b/src/handler/common/heartbeat_handler.rs @@ -0,0 +1,10 @@ +use crate::model::dto::common::HeartbeatResponse; +use crate::service::common::heartbeat_service; +use result::http_result::{build_http_result, HttpResult}; + +pub async fn heartbeat() -> HttpResult { + match heartbeat_service::heartbeat().await { + Ok(resp) => build_http_result(Some(resp), None), + Err(err) => build_http_result::(None, Some(err)), + } +} diff --git a/src/handler/common/mod.rs b/src/handler/common/mod.rs new file mode 100644 index 00000000..faaa2c2b --- /dev/null +++ b/src/handler/common/mod.rs @@ -0,0 +1,20 @@ +mod check_verification_code_handler; +pub use check_verification_code_handler::check_verification_code; +mod get_ads_handler; +pub use get_ads_handler::get_ads; +mod get_client_handler; +pub use get_client_handler::get_client; +mod get_global_config_handler; +pub use get_global_config_handler::get_global_config; +mod get_privacy_policy_handler; +pub use get_privacy_policy_handler::get_privacy_policy; +mod get_stat_handler; +pub use get_stat_handler::get_stat; +mod get_tos_handler; +pub use get_tos_handler::get_tos; +mod heartbeat_handler; +pub use heartbeat_handler::heartbeat; +mod send_email_code_handler; +pub use send_email_code_handler::send_email_code; +mod send_sms_code_handler; +pub use send_sms_code_handler::send_sms_code; diff --git a/src/handler/common/send_email_code_handler.rs b/src/handler/common/send_email_code_handler.rs new file mode 100644 index 00000000..a3884b25 --- /dev/null +++ b/src/handler/common/send_email_code_handler.rs @@ -0,0 +1,17 @@ +use axum::{extract::State, Json}; + +use crate::handler::AppState; +use crate::model::dto::auth::{SendCodeRequest, SendCodeResponse}; +use crate::service::common::send_email_code_service::SendEmailCodeService; +use result::http_result::{build_http_result, HttpResult}; + +pub async fn send_email_code( + State(state): State, + Json(req): Json, +) -> HttpResult { + let svc = SendEmailCodeService::new(state.repos.clone(), state.config.clone(), state.cache.clone(), state.queue.clone()); + match svc.send_code(req).await { + Ok(resp) => build_http_result(Some(resp), None), + Err(err) => build_http_result::(None, Some(err)), + } +} diff --git a/src/handler/common/send_sms_code_handler.rs b/src/handler/common/send_sms_code_handler.rs new file mode 100644 index 00000000..29d9094a --- /dev/null +++ b/src/handler/common/send_sms_code_handler.rs @@ -0,0 +1,17 @@ +use axum::{extract::State, Json}; + +use crate::handler::AppState; +use crate::model::dto::auth::{SendCodeResponse, SendSmsCodeRequest}; +use crate::service::common::send_sms_code_service::SendSmsCodeService; +use result::http_result::{build_http_result, HttpResult}; + +pub async fn send_sms_code( + State(state): State, + Json(req): Json, +) -> HttpResult { + let svc = SendSmsCodeService::new(state.repos.clone(), state.config.clone(), state.cache.clone(), state.queue.clone()); + match svc.send_code(req).await { + Ok(resp) => build_http_result(Some(resp), None), + Err(err) => build_http_result::(None, Some(err)), + } +} diff --git a/src/handler/mod.rs b/src/handler/mod.rs new file mode 100644 index 00000000..62110d0b --- /dev/null +++ b/src/handler/mod.rs @@ -0,0 +1,29 @@ +pub mod admin; +pub mod auth; +pub mod common; +pub mod notify; +pub mod public; +pub mod routes; +pub mod server; +pub mod subscribe; +pub mod telegram; + +use crate::cache::Cache; +use crate::config::Config; +use crate::queue::client::QueueClient; +use crate::repository::Repositories; + +/// Shared application state passed to every handler via axum's `State` +/// extractor. +/// +/// Carries the [`Repositories`] bundle, the loaded [`Config`], the +/// [`Cache`] client, and the [`QueueClient`] so handler code can reach +/// domain repos, runtime settings, Redis-backed cache, and the task queue +/// without global state. +#[derive(Clone)] +pub struct AppState { + pub repos: std::sync::Arc, + pub config: std::sync::Arc, + pub cache: std::sync::Arc, + pub queue: QueueClient, +} diff --git a/src/handler/notify/mod.rs b/src/handler/notify/mod.rs new file mode 100644 index 00000000..e9226db9 --- /dev/null +++ b/src/handler/notify/mod.rs @@ -0,0 +1 @@ +pub mod payment_notify_handler; diff --git a/src/handler/notify/payment_notify_handler.rs b/src/handler/notify/payment_notify_handler.rs new file mode 100644 index 00000000..2b039955 --- /dev/null +++ b/src/handler/notify/payment_notify_handler.rs @@ -0,0 +1,70 @@ +//! Payment notification handlers for Alipay, EPay, and Stripe. + +use std::collections::HashMap; + +use axum::{ + body::Bytes, + extract::{Path, Query, State}, + http::HeaderMap, + response::{IntoResponse, Response}, +}; +use axum::http::StatusCode; + +use crate::handler::AppState; +use crate::service::notify::alipay_notify_service::AlipayNotifyService; +use crate::service::notify::e_pay_notify_service::EPayNotifyService; +use crate::service::notify::stripe_notify_service::StripeNotifyService; + +/// `POST /v1/notify/alipay/:token` +pub async fn alipay_notify_handler( + State(state): State, + Path(token): Path, + body: Bytes, +) -> Response { + let svc = AlipayNotifyService::new(state.repos.clone()); + match svc.handle(&token, body, &state.queue).await { + Ok(msg) => (StatusCode::OK, msg).into_response(), + Err(e) => { + tracing::error!("alipay notify error: {e:#}"); + (StatusCode::BAD_REQUEST, e.to_string()).into_response() + } + } +} + +/// `GET /v1/notify/epay/:token` or `POST /v1/notify/epay/:token` +pub async fn epay_notify_handler( + State(state): State, + Path(token): Path, + Query(params): Query>, +) -> Response { + let svc = EPayNotifyService::new(state.repos.clone()); + match svc.handle(&token, params, &state.queue).await { + Ok(msg) => (StatusCode::OK, msg).into_response(), + Err(e) => { + tracing::error!("epay notify error: {e:#}"); + (StatusCode::BAD_REQUEST, e.to_string()).into_response() + } + } +} + +/// `POST /v1/notify/stripe/:token` +pub async fn stripe_notify_handler( + State(state): State, + Path(token): Path, + headers: HeaderMap, + body: Bytes, +) -> Response { + let signature = headers + .get("Stripe-Signature") + .and_then(|v| v.to_str().ok()) + .unwrap_or(""); + + let svc = StripeNotifyService::new(state.repos.clone()); + match svc.handle(&token, body, signature, &state.queue).await { + Ok(msg) => (StatusCode::OK, msg).into_response(), + Err(e) => { + tracing::error!("stripe notify error: {e:#}"); + (StatusCode::BAD_REQUEST, e.to_string()).into_response() + } + } +} diff --git a/src/handler/public/announcement/mod.rs b/src/handler/public/announcement/mod.rs new file mode 100644 index 00000000..582bf234 --- /dev/null +++ b/src/handler/public/announcement/mod.rs @@ -0,0 +1,2 @@ +mod query_announcement_handler; +pub use query_announcement_handler::query_announcement; diff --git a/src/handler/public/announcement/query_announcement_handler.rs b/src/handler/public/announcement/query_announcement_handler.rs new file mode 100644 index 00000000..732898cf --- /dev/null +++ b/src/handler/public/announcement/query_announcement_handler.rs @@ -0,0 +1,13 @@ +use axum::extract::{Query, State}; +use crate::handler::AppState; +use crate::model::dto::subscribe::QuerySubscribeListRequest; +use crate::service::public::announcement::query_announcement_service::QueryAnnouncementService; +use result::http_result::{build_http_result, HttpResult}; + +pub async fn query_announcement(State(state): State) -> HttpResult { + let svc = QueryAnnouncementService::new(state.repos.clone()); + match svc.query_list(1, 100).await { + Ok((_, list)) => build_http_result(Some(list), None), + Err(e) => build_http_result::<()>(None, Some(e)), + } +} diff --git a/src/handler/public/document/mod.rs b/src/handler/public/document/mod.rs new file mode 100644 index 00000000..3a095081 --- /dev/null +++ b/src/handler/public/document/mod.rs @@ -0,0 +1,4 @@ +mod query_document_detail_handler; +pub use query_document_detail_handler::query_document_detail; +mod query_document_list_handler; +pub use query_document_list_handler::query_document_list; diff --git a/src/handler/public/document/query_document_detail_handler.rs b/src/handler/public/document/query_document_detail_handler.rs new file mode 100644 index 00000000..50f3bcd7 --- /dev/null +++ b/src/handler/public/document/query_document_detail_handler.rs @@ -0,0 +1,20 @@ +use axum::extract::{Query, State}; +use crate::handler::AppState; +use crate::service::public::document::query_document_detail_service::QueryDocumentDetailService; +use result::http_result::{build_http_result, HttpResult}; +use serde::Deserialize; + +#[derive(Deserialize)] +pub struct QueryDocumentDetailRequest { pub id: Option, pub slug: Option } + +pub async fn query_document_detail( + State(state): State, + Query(req): Query, +) -> HttpResult { + let svc = QueryDocumentDetailService::new(state.repos.clone()); + let id = req.id.unwrap_or(0); + match svc.query_detail(id).await { + Ok(data) => build_http_result(Some(data), None), + Err(e) => build_http_result::<()>(None, Some(e)), + } +} diff --git a/src/handler/public/document/query_document_list_handler.rs b/src/handler/public/document/query_document_list_handler.rs new file mode 100644 index 00000000..72ea54ac --- /dev/null +++ b/src/handler/public/document/query_document_list_handler.rs @@ -0,0 +1,16 @@ +use axum::extract::{Query, State}; +use crate::handler::AppState; +use crate::model::dto::document::GetDocumentListRequest; +use crate::service::public::document::query_document_list_service::QueryDocumentListService; +use result::http_result::{build_http_result, HttpResult}; + +pub async fn query_document_list( + State(state): State, + Query(req): Query, +) -> HttpResult { + let svc = QueryDocumentListService::new(state.repos.clone()); + match svc.query_list(req.page, req.size, req.tag.as_deref()).await { + Ok((total, list)) => build_http_result(Some(serde_json::json!({"total": total, "list": list})), None), + Err(e) => build_http_result::<()>(None, Some(e)), + } +} diff --git a/src/handler/public/mod.rs b/src/handler/public/mod.rs new file mode 100644 index 00000000..3d9edc0d --- /dev/null +++ b/src/handler/public/mod.rs @@ -0,0 +1,8 @@ +pub mod announcement; +pub mod document; +pub mod order; +pub mod payment; +pub mod portal; +pub mod subscribe; +pub mod ticket; +pub mod user; diff --git a/src/handler/public/order/close_order_handler.rs b/src/handler/public/order/close_order_handler.rs new file mode 100644 index 00000000..15fdba58 --- /dev/null +++ b/src/handler/public/order/close_order_handler.rs @@ -0,0 +1,21 @@ +use axum::extract::State; +use axum::Extension; +use axum::Json; + +use crate::handler::AppState; +use crate::middleware::auth_middleware::AuthContext; +use crate::model::dto::*; +use crate::service::public::order::close_order_service::CloseOrderService; +use result::http_result::{build_http_result, HttpResult}; + +pub async fn close_order( + State(state): State, + Extension(auth): Extension, + Json(req): Json, +) -> HttpResult { + let svc = CloseOrderService::new(state.repos.clone()); + match svc.close(auth.user_id, req).await { + Ok(()) => build_http_result(Some(()), None), + Err(e) => build_http_result::<()>(None, Some(e)), + } +} diff --git a/src/handler/public/order/mod.rs b/src/handler/public/order/mod.rs new file mode 100644 index 00000000..e8a27ff5 --- /dev/null +++ b/src/handler/public/order/mod.rs @@ -0,0 +1,16 @@ +mod close_order_handler; +pub use close_order_handler::close_order; +mod pre_create_order_handler; +pub use pre_create_order_handler::pre_create_order; +mod purchase_handler; +pub use purchase_handler::purchase; +mod query_order_detail_handler; +pub use query_order_detail_handler::query_order_detail; +mod query_order_list_handler; +pub use query_order_list_handler::query_order_list; +mod recharge_handler; +pub use recharge_handler::recharge; +mod renewal_handler; +pub use renewal_handler::renewal; +mod reset_traffic_handler; +pub use reset_traffic_handler::reset_traffic; diff --git a/src/handler/public/order/pre_create_order_handler.rs b/src/handler/public/order/pre_create_order_handler.rs new file mode 100644 index 00000000..59ea8fe9 --- /dev/null +++ b/src/handler/public/order/pre_create_order_handler.rs @@ -0,0 +1,21 @@ +use axum::extract::State; +use axum::Extension; +use axum::Json; + +use crate::handler::AppState; +use crate::middleware::auth_middleware::AuthContext; +use crate::model::dto::*; +use crate::service::public::order::pre_create_order_service::PreCreateOrderService; +use result::http_result::{build_http_result, HttpResult}; + +pub async fn pre_create_order( + State(state): State, + Extension(auth): Extension, + Json(req): Json, +) -> HttpResult { + let svc = PreCreateOrderService::new(state.repos.clone()); + match svc.pre_create(auth.user_id, req).await { + Ok(data) => build_http_result(Some(data), None), + Err(e) => build_http_result::<()>(None, Some(e)), + } +} diff --git a/src/handler/public/order/purchase_handler.rs b/src/handler/public/order/purchase_handler.rs new file mode 100644 index 00000000..618b5fb2 --- /dev/null +++ b/src/handler/public/order/purchase_handler.rs @@ -0,0 +1,21 @@ +use axum::extract::State; +use axum::Extension; +use axum::Json; + +use crate::handler::AppState; +use crate::middleware::auth_middleware::AuthContext; +use crate::model::dto::*; +use crate::service::public::order::purchase_service::PurchaseService; +use result::http_result::{build_http_result, HttpResult}; + +pub async fn purchase( + State(state): State, + Extension(auth): Extension, + Json(req): Json, +) -> HttpResult { + let svc = PurchaseService::new(state.repos.clone(), state.config.clone(), state.queue.clone()); + match svc.purchase(auth.user_id, req).await { + Ok(data) => build_http_result(Some(data), None), + Err(e) => build_http_result::<()>(None, Some(e)), + } +} diff --git a/src/handler/public/order/query_order_detail_handler.rs b/src/handler/public/order/query_order_detail_handler.rs new file mode 100644 index 00000000..0cd8d837 --- /dev/null +++ b/src/handler/public/order/query_order_detail_handler.rs @@ -0,0 +1,20 @@ +use axum::extract::{Query, State}; +use axum::Extension; + +use crate::handler::AppState; +use crate::middleware::auth_middleware::AuthContext; +use crate::model::dto::*; +use crate::service::public::order::query_order_detail_service::QueryOrderDetailService; +use result::http_result::{build_http_result, HttpResult}; + +pub async fn query_order_detail( + State(state): State, + Extension(auth): Extension, + Query(req): Query, +) -> HttpResult { + let svc = QueryOrderDetailService::new(state.repos.clone()); + match svc.query(auth.user_id, req).await { + Ok(data) => build_http_result(Some(data), None), + Err(e) => build_http_result::<()>(None, Some(e)), + } +} diff --git a/src/handler/public/order/query_order_list_handler.rs b/src/handler/public/order/query_order_list_handler.rs new file mode 100644 index 00000000..b9496cd9 --- /dev/null +++ b/src/handler/public/order/query_order_list_handler.rs @@ -0,0 +1,20 @@ +use axum::extract::{Query, State}; +use axum::Extension; + +use crate::handler::AppState; +use crate::middleware::auth_middleware::AuthContext; +use crate::model::dto::*; +use crate::service::public::order::query_order_list_service::QueryOrderListService; +use result::http_result::{build_http_result, HttpResult}; + +pub async fn query_order_list( + State(state): State, + Extension(auth): Extension, + Query(req): Query, +) -> HttpResult { + let svc = QueryOrderListService::new(state.repos.clone()); + match svc.query(auth.user_id, req).await { + Ok(data) => build_http_result(Some(data), None), + Err(e) => build_http_result::<()>(None, Some(e)), + } +} diff --git a/src/handler/public/order/recharge_handler.rs b/src/handler/public/order/recharge_handler.rs new file mode 100644 index 00000000..812846f0 --- /dev/null +++ b/src/handler/public/order/recharge_handler.rs @@ -0,0 +1,21 @@ +use axum::extract::State; +use axum::Extension; +use axum::Json; + +use crate::handler::AppState; +use crate::middleware::auth_middleware::AuthContext; +use crate::model::dto::*; +use crate::service::public::order::recharge_service::RechargeService; +use result::http_result::{build_http_result, HttpResult}; + +pub async fn recharge( + State(state): State, + Extension(auth): Extension, + Json(req): Json, +) -> HttpResult { + let svc = RechargeService::new(state.repos.clone(), state.queue.clone()); + match svc.recharge(auth.user_id, req).await { + Ok(data) => build_http_result(Some(data), None), + Err(e) => build_http_result::<()>(None, Some(e)), + } +} diff --git a/src/handler/public/order/renewal_handler.rs b/src/handler/public/order/renewal_handler.rs new file mode 100644 index 00000000..5bee8269 --- /dev/null +++ b/src/handler/public/order/renewal_handler.rs @@ -0,0 +1,21 @@ +use axum::extract::State; +use axum::Extension; +use axum::Json; + +use crate::handler::AppState; +use crate::middleware::auth_middleware::AuthContext; +use crate::model::dto::*; +use crate::service::public::order::renewal_service::RenewalService; +use result::http_result::{build_http_result, HttpResult}; + +pub async fn renewal( + State(state): State, + Extension(auth): Extension, + Json(req): Json, +) -> HttpResult { + let svc = RenewalService::new(state.repos.clone(), state.queue.clone()); + match svc.renewal(auth.user_id, req).await { + Ok(data) => build_http_result(Some(data), None), + Err(e) => build_http_result::<()>(None, Some(e)), + } +} diff --git a/src/handler/public/order/reset_traffic_handler.rs b/src/handler/public/order/reset_traffic_handler.rs new file mode 100644 index 00000000..e92c49c6 --- /dev/null +++ b/src/handler/public/order/reset_traffic_handler.rs @@ -0,0 +1,21 @@ +use axum::extract::State; +use axum::Extension; +use axum::Json; + +use crate::handler::AppState; +use crate::middleware::auth_middleware::AuthContext; +use crate::model::dto::*; +use crate::service::public::order::reset_traffic_service::ResetTrafficService; +use result::http_result::{build_http_result, HttpResult}; + +pub async fn reset_traffic( + State(state): State, + Extension(auth): Extension, + Json(req): Json, +) -> HttpResult { + let svc = ResetTrafficService::new(state.repos.clone(), state.queue.clone()); + match svc.reset_traffic(auth.user_id, req).await { + Ok(data) => build_http_result(Some(data), None), + Err(e) => build_http_result::<()>(None, Some(e)), + } +} diff --git a/src/handler/public/payment/get_available_payment_methods_handler.rs b/src/handler/public/payment/get_available_payment_methods_handler.rs new file mode 100644 index 00000000..929443c9 --- /dev/null +++ b/src/handler/public/payment/get_available_payment_methods_handler.rs @@ -0,0 +1,12 @@ +use axum::extract::State; +use crate::handler::AppState; +use crate::service::public::payment::get_available_payment_methods_service::GetAvailablePaymentMethodsService; +use result::http_result::{build_http_result, HttpResult}; + +pub async fn get_available_payment_methods(State(state): State) -> HttpResult { + let svc = GetAvailablePaymentMethodsService::new(state.repos.clone()); + match svc.get_methods().await { + Ok(list) => build_http_result(Some(list), None), + Err(e) => build_http_result::<()>(None, Some(e)), + } +} diff --git a/src/handler/public/payment/mod.rs b/src/handler/public/payment/mod.rs new file mode 100644 index 00000000..4695bd9e --- /dev/null +++ b/src/handler/public/payment/mod.rs @@ -0,0 +1,2 @@ +mod get_available_payment_methods_handler; +pub use get_available_payment_methods_handler::get_available_payment_methods; diff --git a/src/handler/public/portal/get_available_payment_methods_handler.rs b/src/handler/public/portal/get_available_payment_methods_handler.rs new file mode 100644 index 00000000..e8804111 --- /dev/null +++ b/src/handler/public/portal/get_available_payment_methods_handler.rs @@ -0,0 +1,15 @@ +use axum::extract::State; + +use crate::handler::AppState; +use crate::service::public::portal::get_available_payment_methods_service::GetAvailablePaymentMethodsService; +use result::http_result::{build_http_result, HttpResult}; + +pub async fn get_available_payment_methods( + State(state): State, +) -> HttpResult { + let svc = GetAvailablePaymentMethodsService::new(state.repos.clone()); + match svc.get().await { + Ok(data) => build_http_result(Some(data), None), + Err(e) => build_http_result::<()>(None, Some(e)), + } +} diff --git a/src/handler/public/portal/get_subscription_handler.rs b/src/handler/public/portal/get_subscription_handler.rs new file mode 100644 index 00000000..a59718fc --- /dev/null +++ b/src/handler/public/portal/get_subscription_handler.rs @@ -0,0 +1,17 @@ +use axum::extract::{Query, State}; + +use crate::handler::AppState; +use crate::model::dto::*; +use crate::service::public::portal::get_subscription_service::GetSubscriptionService; +use result::http_result::{build_http_result, HttpResult}; + +pub async fn get_subscription( + State(state): State, + Query(req): Query, +) -> HttpResult { + let svc = GetSubscriptionService::new(state.repos.clone()); + match svc.get(req.language).await { + Ok(data) => build_http_result(Some(data), None), + Err(e) => build_http_result::<()>(None, Some(e)), + } +} diff --git a/src/handler/public/portal/mod.rs b/src/handler/public/portal/mod.rs new file mode 100644 index 00000000..4dc99ff2 --- /dev/null +++ b/src/handler/public/portal/mod.rs @@ -0,0 +1,12 @@ +mod get_available_payment_methods_handler; +pub use get_available_payment_methods_handler::get_available_payment_methods; +mod get_subscription_handler; +pub use get_subscription_handler::get_subscription; +mod pre_purchase_order_handler; +pub use pre_purchase_order_handler::pre_purchase_order; +mod purchase_checkout_handler; +pub use purchase_checkout_handler::purchase_checkout; +mod purchase_handler; +pub use purchase_handler::purchase; +mod query_purchase_order_handler; +pub use query_purchase_order_handler::query_purchase_order; diff --git a/src/handler/public/portal/pre_purchase_order_handler.rs b/src/handler/public/portal/pre_purchase_order_handler.rs new file mode 100644 index 00000000..5190753c --- /dev/null +++ b/src/handler/public/portal/pre_purchase_order_handler.rs @@ -0,0 +1,18 @@ +use axum::extract::State; +use axum::Json; + +use crate::handler::AppState; +use crate::model::dto::*; +use crate::service::public::portal::pre_purchase_order_service::PrePurchaseOrderService; +use result::http_result::{build_http_result, HttpResult}; + +pub async fn pre_purchase_order( + State(state): State, + Json(req): Json, +) -> HttpResult { + let svc = PrePurchaseOrderService::new(state.repos.clone()); + match svc.pre_purchase(req).await { + Ok(data) => build_http_result(Some(data), None), + Err(e) => build_http_result::<()>(None, Some(e)), + } +} diff --git a/src/handler/public/portal/purchase_checkout_handler.rs b/src/handler/public/portal/purchase_checkout_handler.rs new file mode 100644 index 00000000..1fcbb909 --- /dev/null +++ b/src/handler/public/portal/purchase_checkout_handler.rs @@ -0,0 +1,18 @@ +use axum::extract::State; +use axum::Json; + +use crate::handler::AppState; +use crate::model::dto::*; +use crate::service::public::portal::purchase_checkout_service::PurchaseCheckoutService; +use result::http_result::{build_http_result, HttpResult}; + +pub async fn purchase_checkout( + State(state): State, + Json(req): Json, +) -> HttpResult { + let svc = PurchaseCheckoutService::new(state.repos.clone()); + match svc.checkout(req).await { + Ok(data) => build_http_result(Some(data), None), + Err(e) => build_http_result::<()>(None, Some(e)), + } +} diff --git a/src/handler/public/portal/purchase_handler.rs b/src/handler/public/portal/purchase_handler.rs new file mode 100644 index 00000000..5b81d899 --- /dev/null +++ b/src/handler/public/portal/purchase_handler.rs @@ -0,0 +1,27 @@ +use axum::extract::State; +use axum::Extension; +use axum::Json; + +use crate::handler::AppState; +use crate::middleware::auth_middleware::AuthContext; +use crate::model::dto::order::{PortalPurchaseRequest, PurchaseOrderRequest}; +use crate::service::public::portal::purchase_service::PortalPurchaseService; +use result::http_result::{build_http_result, HttpResult}; + +pub async fn purchase( + State(state): State, + Extension(auth): Extension, + Json(req): Json, +) -> HttpResult { + let svc = PortalPurchaseService::new(state.repos.clone(), state.config.clone(), state.queue.clone()); + let purchase_req = PurchaseOrderRequest { + subscribe_id: req.subscribe_id, + quantity: req.quantity, + payment: Some(req.payment), + coupon: req.coupon, + }; + match svc.purchase(auth.user_id, purchase_req).await { + Ok(data) => build_http_result(Some(data), None), + Err(e) => build_http_result::<()>(None, Some(e)), + } +} diff --git a/src/handler/public/portal/query_purchase_order_handler.rs b/src/handler/public/portal/query_purchase_order_handler.rs new file mode 100644 index 00000000..c3cae751 --- /dev/null +++ b/src/handler/public/portal/query_purchase_order_handler.rs @@ -0,0 +1,20 @@ +use axum::extract::{Query, State}; +use axum::Extension; + +use crate::handler::AppState; +use crate::middleware::auth_middleware::AuthContext; +use crate::model::dto::order::QueryOrderListRequest; +use crate::service::public::portal::query_purchase_order_service::QueryPurchaseOrderService; +use result::http_result::{build_http_result, HttpResult}; + +pub async fn query_purchase_order( + State(state): State, + Extension(auth): Extension, + Query(req): Query, +) -> HttpResult { + let svc = QueryPurchaseOrderService::new(state.repos.clone()); + match svc.query(auth.user_id, req).await { + Ok(data) => build_http_result(Some(data), None), + Err(e) => build_http_result::<()>(None, Some(e)), + } +} diff --git a/src/handler/public/subscribe/mod.rs b/src/handler/public/subscribe/mod.rs new file mode 100644 index 00000000..d6081d70 --- /dev/null +++ b/src/handler/public/subscribe/mod.rs @@ -0,0 +1,6 @@ +mod query_subscribe_group_list_handler; +pub use query_subscribe_group_list_handler::query_subscribe_group_list; +mod query_subscribe_list_handler; +pub use query_subscribe_list_handler::query_subscribe_list; +mod query_user_subscribe_node_list_handler; +pub use query_user_subscribe_node_list_handler::query_user_subscribe_node_list; diff --git a/src/handler/public/subscribe/query_subscribe_group_list_handler.rs b/src/handler/public/subscribe/query_subscribe_group_list_handler.rs new file mode 100644 index 00000000..ce6d45d2 --- /dev/null +++ b/src/handler/public/subscribe/query_subscribe_group_list_handler.rs @@ -0,0 +1,12 @@ +use axum::extract::State; +use crate::handler::AppState; +use crate::service::public::subscribe::query_subscribe_group_list_service::QuerySubscribeGroupListService; +use result::http_result::{build_http_result, HttpResult}; + +pub async fn query_subscribe_group_list(State(state): State) -> HttpResult { + let svc = QuerySubscribeGroupListService::new(state.repos.clone()); + match svc.query_list().await { + Ok((_, list)) => build_http_result(Some(list), None), + Err(e) => build_http_result::<()>(None, Some(e)), + } +} diff --git a/src/handler/public/subscribe/query_subscribe_list_handler.rs b/src/handler/public/subscribe/query_subscribe_list_handler.rs new file mode 100644 index 00000000..4274a84a --- /dev/null +++ b/src/handler/public/subscribe/query_subscribe_list_handler.rs @@ -0,0 +1,17 @@ +use axum::extract::{Query, State}; + +use crate::handler::AppState; +use crate::model::dto::*; +use crate::service::public::subscribe::query_subscribe_list_service::QuerySubscribeListService; +use result::http_result::{build_http_result, HttpResult}; + +pub async fn query_subscribe_list( + State(state): State, + Query(req): Query, +) -> HttpResult { + let svc = QuerySubscribeListService::new(state.repos.clone()); + match svc.query_list(1, 100).await { + Ok((_, list)) => build_http_result(Some(list), None), + Err(e) => build_http_result::<()>(None, Some(e)), + } +} diff --git a/src/handler/public/subscribe/query_user_subscribe_node_list_handler.rs b/src/handler/public/subscribe/query_user_subscribe_node_list_handler.rs new file mode 100644 index 00000000..a28f0b33 --- /dev/null +++ b/src/handler/public/subscribe/query_user_subscribe_node_list_handler.rs @@ -0,0 +1,18 @@ +use axum::extract::State; +use axum::Extension; + +use crate::handler::AppState; +use crate::middleware::auth_middleware::AuthContext; +use crate::service::public::subscribe::query_user_subscribe_node_list_service::QueryUserSubscribeNodeListService; +use result::http_result::{build_http_result, HttpResult}; + +pub async fn query_user_subscribe_node_list( + State(state): State, + Extension(auth): Extension, +) -> HttpResult { + let svc = QueryUserSubscribeNodeListService::new(state.repos.clone()); + match svc.query_nodes(auth.user_id).await { + Ok(data) => build_http_result(Some(data), None), + Err(e) => build_http_result::<()>(None, Some(e)), + } +} diff --git a/src/handler/public/ticket/create_user_ticket_follow_handler.rs b/src/handler/public/ticket/create_user_ticket_follow_handler.rs new file mode 100644 index 00000000..f72e29c5 --- /dev/null +++ b/src/handler/public/ticket/create_user_ticket_follow_handler.rs @@ -0,0 +1,21 @@ +use axum::extract::State; +use axum::Extension; +use axum::Json; + +use crate::handler::AppState; +use crate::middleware::auth_middleware::AuthContext; +use crate::model::dto::*; +use crate::service::public::ticket::create_user_ticket_follow_service::CreateUserTicketFollowService; +use result::http_result::{build_http_result, HttpResult}; + +pub async fn create_user_ticket_follow( + State(state): State, + Extension(auth): Extension, + Json(req): Json, +) -> HttpResult { + let svc = CreateUserTicketFollowService::new(state.repos.clone()); + match svc.create(auth.user_id, req).await { + Ok(data) => build_http_result(Some(data), None), + Err(e) => build_http_result::<()>(None, Some(e)), + } +} diff --git a/src/handler/public/ticket/create_user_ticket_handler.rs b/src/handler/public/ticket/create_user_ticket_handler.rs new file mode 100644 index 00000000..97ac266b --- /dev/null +++ b/src/handler/public/ticket/create_user_ticket_handler.rs @@ -0,0 +1,21 @@ +use axum::extract::State; +use axum::Extension; +use axum::Json; + +use crate::handler::AppState; +use crate::middleware::auth_middleware::AuthContext; +use crate::model::dto::*; +use crate::service::public::ticket::create_user_ticket_service::CreateUserTicketService; +use result::http_result::{build_http_result, HttpResult}; + +pub async fn create_user_ticket( + State(state): State, + Extension(auth): Extension, + Json(req): Json, +) -> HttpResult { + let svc = CreateUserTicketService::new(state.repos.clone()); + match svc.create(auth.user_id, req).await { + Ok(data) => build_http_result(Some(data), None), + Err(e) => build_http_result::<()>(None, Some(e)), + } +} diff --git a/src/handler/public/ticket/get_user_ticket_details_handler.rs b/src/handler/public/ticket/get_user_ticket_details_handler.rs new file mode 100644 index 00000000..24eb0ade --- /dev/null +++ b/src/handler/public/ticket/get_user_ticket_details_handler.rs @@ -0,0 +1,20 @@ +use axum::extract::{Query, State}; +use axum::Extension; + +use crate::handler::AppState; +use crate::middleware::auth_middleware::AuthContext; +use crate::model::dto::*; +use crate::service::public::ticket::get_user_ticket_details_service::GetUserTicketDetailsService; +use result::http_result::{build_http_result, HttpResult}; + +pub async fn get_user_ticket_details( + State(state): State, + Extension(auth): Extension, + Query(req): Query, +) -> HttpResult { + let svc = GetUserTicketDetailsService::new(state.repos.clone()); + match svc.get(auth.user_id, req).await { + Ok(data) => build_http_result(Some(data), None), + Err(e) => build_http_result::<()>(None, Some(e)), + } +} diff --git a/src/handler/public/ticket/get_user_ticket_list_handler.rs b/src/handler/public/ticket/get_user_ticket_list_handler.rs new file mode 100644 index 00000000..a56be2a3 --- /dev/null +++ b/src/handler/public/ticket/get_user_ticket_list_handler.rs @@ -0,0 +1,20 @@ +use axum::extract::{Query, State}; +use axum::Extension; + +use crate::handler::AppState; +use crate::middleware::auth_middleware::AuthContext; +use crate::model::dto::*; +use crate::service::public::ticket::get_user_ticket_list_service::GetUserTicketListService; +use result::http_result::{build_http_result, HttpResult}; + +pub async fn get_user_ticket_list( + State(state): State, + Extension(auth): Extension, + Query(req): Query, +) -> HttpResult { + let svc = GetUserTicketListService::new(state.repos.clone()); + match svc.list(auth.user_id, req).await { + Ok(data) => build_http_result(Some(data), None), + Err(e) => build_http_result::<()>(None, Some(e)), + } +} diff --git a/src/handler/public/ticket/mod.rs b/src/handler/public/ticket/mod.rs new file mode 100644 index 00000000..a95990ce --- /dev/null +++ b/src/handler/public/ticket/mod.rs @@ -0,0 +1,10 @@ +mod create_user_ticket_follow_handler; +pub use create_user_ticket_follow_handler::create_user_ticket_follow; +mod create_user_ticket_handler; +pub use create_user_ticket_handler::create_user_ticket; +mod get_user_ticket_details_handler; +pub use get_user_ticket_details_handler::get_user_ticket_details; +mod get_user_ticket_list_handler; +pub use get_user_ticket_list_handler::get_user_ticket_list; +mod update_user_ticket_status_handler; +pub use update_user_ticket_status_handler::update_user_ticket_status; diff --git a/src/handler/public/ticket/update_user_ticket_status_handler.rs b/src/handler/public/ticket/update_user_ticket_status_handler.rs new file mode 100644 index 00000000..b49b4e72 --- /dev/null +++ b/src/handler/public/ticket/update_user_ticket_status_handler.rs @@ -0,0 +1,21 @@ +use axum::extract::State; +use axum::Extension; +use axum::Json; + +use crate::handler::AppState; +use crate::middleware::auth_middleware::AuthContext; +use crate::model::dto::*; +use crate::service::public::ticket::update_user_ticket_status_service::UpdateUserTicketStatusService; +use result::http_result::{build_http_result, HttpResult}; + +pub async fn update_user_ticket_status( + State(state): State, + Extension(auth): Extension, + Json(req): Json, +) -> HttpResult { + let svc = UpdateUserTicketStatusService::new(state.repos.clone()); + match svc.update(auth.user_id, req).await { + Ok(()) => build_http_result(Some(()), None), + Err(e) => build_http_result::<()>(None, Some(e)), + } +} diff --git a/src/handler/public/user/bind_o_auth_callback_handler.rs b/src/handler/public/user/bind_o_auth_callback_handler.rs new file mode 100644 index 00000000..280a7df3 --- /dev/null +++ b/src/handler/public/user/bind_o_auth_callback_handler.rs @@ -0,0 +1,21 @@ +use axum::extract::State; +use axum::Extension; +use axum::Json; + +use crate::handler::AppState; +use crate::middleware::auth_middleware::AuthContext; +use crate::model::dto::*; +use crate::service::public::user::bind_o_auth_callback_service::BindOAuthCallbackService; +use result::http_result::{build_http_result, HttpResult}; + +pub async fn bind_o_auth_callback( + State(state): State, + Extension(auth): Extension, + Json(req): Json, +) -> HttpResult { + let svc = BindOAuthCallbackService::new(state.repos.clone()); + match svc.bind_o_auth_callback(auth.user_id, req).await { + Ok(()) => build_http_result(Some(()), None), + Err(e) => build_http_result::<()>(None, Some(e)), + } +} diff --git a/src/handler/public/user/bind_o_auth_handler.rs b/src/handler/public/user/bind_o_auth_handler.rs new file mode 100644 index 00000000..7ada8650 --- /dev/null +++ b/src/handler/public/user/bind_o_auth_handler.rs @@ -0,0 +1,21 @@ +use axum::extract::State; +use axum::Extension; +use axum::Json; + +use crate::handler::AppState; +use crate::middleware::auth_middleware::AuthContext; +use crate::model::dto::*; +use crate::service::public::user::bind_o_auth_service::BindOAuthService; +use result::http_result::{build_http_result, HttpResult}; + +pub async fn bind_o_auth( + State(state): State, + Extension(auth): Extension, + Json(req): Json, +) -> HttpResult { + let svc = BindOAuthService::new(state.repos.clone()); + match svc.bind_o_auth(auth.user_id, req).await { + Ok(data) => build_http_result(Some(data), None), + Err(e) => build_http_result::<()>(None, Some(e)), + } +} diff --git a/src/handler/public/user/bind_telegram_handler.rs b/src/handler/public/user/bind_telegram_handler.rs new file mode 100644 index 00000000..5eb6382b --- /dev/null +++ b/src/handler/public/user/bind_telegram_handler.rs @@ -0,0 +1,18 @@ +use axum::extract::State; +use axum::Extension; + +use crate::handler::AppState; +use crate::middleware::auth_middleware::AuthContext; +use crate::service::public::user::bind_telegram_service::BindTelegramService; +use result::http_result::{build_http_result, HttpResult}; + +pub async fn bind_telegram( + State(state): State, + Extension(auth): Extension, +) -> HttpResult { + let svc = BindTelegramService::new(state.repos.clone()); + match svc.bind_telegram(auth.user_id).await { + Ok(data) => build_http_result(Some(data), None), + Err(e) => build_http_result::<()>(None, Some(e)), + } +} diff --git a/src/handler/public/user/commission_withdraw_handler.rs b/src/handler/public/user/commission_withdraw_handler.rs new file mode 100644 index 00000000..41b1b4e1 --- /dev/null +++ b/src/handler/public/user/commission_withdraw_handler.rs @@ -0,0 +1,21 @@ +use axum::extract::State; +use axum::Extension; +use axum::Json; + +use crate::handler::AppState; +use crate::middleware::auth_middleware::AuthContext; +use crate::model::dto::*; +use crate::service::public::user::commission_withdraw_service::CommissionWithdrawService; +use result::http_result::{build_http_result, HttpResult}; + +pub async fn commission_withdraw( + State(state): State, + Extension(auth): Extension, + Json(req): Json, +) -> HttpResult { + let svc = CommissionWithdrawService::new(state.repos.clone()); + match svc.commission_withdraw(auth.user_id, req).await { + Ok(data) => build_http_result(Some(data), None), + Err(e) => build_http_result::<()>(None, Some(e)), + } +} diff --git a/src/handler/public/user/get_device_list_handler.rs b/src/handler/public/user/get_device_list_handler.rs new file mode 100644 index 00000000..353fd0f5 --- /dev/null +++ b/src/handler/public/user/get_device_list_handler.rs @@ -0,0 +1,18 @@ +use axum::extract::State; +use axum::Extension; + +use crate::handler::AppState; +use crate::middleware::auth_middleware::AuthContext; +use crate::service::public::user::get_device_list_service::GetDeviceListService; +use result::http_result::{build_http_result, HttpResult}; + +pub async fn get_device_list( + State(state): State, + Extension(auth): Extension, +) -> HttpResult { + let svc = GetDeviceListService::new(state.repos.clone()); + match svc.get_device_list(auth.user_id).await { + Ok(data) => build_http_result(Some(data), None), + Err(e) => build_http_result::<()>(None, Some(e)), + } +} diff --git a/src/handler/public/user/get_login_log_handler.rs b/src/handler/public/user/get_login_log_handler.rs new file mode 100644 index 00000000..4b751db0 --- /dev/null +++ b/src/handler/public/user/get_login_log_handler.rs @@ -0,0 +1,20 @@ +use axum::extract::{Query, State}; +use axum::Extension; + +use crate::handler::AppState; +use crate::middleware::auth_middleware::AuthContext; +use crate::model::dto::*; +use crate::service::public::user::get_login_log_service::GetLoginLogService; +use result::http_result::{build_http_result, HttpResult}; + +pub async fn get_login_log( + State(state): State, + Extension(auth): Extension, + Query(req): Query, +) -> HttpResult { + let svc = GetLoginLogService::new(state.repos.clone()); + match svc.get_login_log(auth.user_id, req).await { + Ok(data) => build_http_result(Some(data), None), + Err(e) => build_http_result::<()>(None, Some(e)), + } +} diff --git a/src/handler/public/user/get_o_auth_methods_handler.rs b/src/handler/public/user/get_o_auth_methods_handler.rs new file mode 100644 index 00000000..b25f1799 --- /dev/null +++ b/src/handler/public/user/get_o_auth_methods_handler.rs @@ -0,0 +1,18 @@ +use axum::extract::State; +use axum::Extension; + +use crate::handler::AppState; +use crate::middleware::auth_middleware::AuthContext; +use crate::service::public::user::get_o_auth_methods_service::GetOAuthMethodsService; +use result::http_result::{build_http_result, HttpResult}; + +pub async fn get_o_auth_methods( + State(state): State, + Extension(auth): Extension, +) -> HttpResult { + let svc = GetOAuthMethodsService::new(state.repos.clone()); + match svc.get_o_auth_methods(auth.user_id).await { + Ok(data) => build_http_result(Some(data), None), + Err(e) => build_http_result::<()>(None, Some(e)), + } +} diff --git a/src/handler/public/user/get_subscribe_log_handler.rs b/src/handler/public/user/get_subscribe_log_handler.rs new file mode 100644 index 00000000..4dbe8374 --- /dev/null +++ b/src/handler/public/user/get_subscribe_log_handler.rs @@ -0,0 +1,20 @@ +use axum::extract::{Query, State}; +use axum::Extension; + +use crate::handler::AppState; +use crate::middleware::auth_middleware::AuthContext; +use crate::model::dto::*; +use crate::service::public::user::get_subscribe_log_service::GetSubscribeLogService; +use result::http_result::{build_http_result, HttpResult}; + +pub async fn get_subscribe_log( + State(state): State, + Extension(auth): Extension, + Query(req): Query, +) -> HttpResult { + let svc = GetSubscribeLogService::new(state.repos.clone()); + match svc.get_subscribe_log(auth.user_id, req).await { + Ok(data) => build_http_result(Some(data), None), + Err(e) => build_http_result::<()>(None, Some(e)), + } +} diff --git a/src/handler/public/user/mod.rs b/src/handler/public/user/mod.rs new file mode 100644 index 00000000..26473875 --- /dev/null +++ b/src/handler/public/user/mod.rs @@ -0,0 +1,56 @@ +mod bind_o_auth_callback_handler; +pub use bind_o_auth_callback_handler::bind_o_auth_callback; +mod bind_o_auth_handler; +pub use bind_o_auth_handler::bind_o_auth; +mod bind_telegram_handler; +pub use bind_telegram_handler::bind_telegram; +mod commission_withdraw_handler; +pub use commission_withdraw_handler::commission_withdraw; +mod get_device_list_handler; +pub use get_device_list_handler::get_device_list; +mod get_login_log_handler; +pub use get_login_log_handler::get_login_log; +mod get_o_auth_methods_handler; +pub use get_o_auth_methods_handler::get_o_auth_methods; +mod get_subscribe_log_handler; +pub use get_subscribe_log_handler::get_subscribe_log; +mod pre_unsubscribe_handler; +pub use pre_unsubscribe_handler::pre_unsubscribe; +mod query_user_affiliate_handler; +pub use query_user_affiliate_handler::query_user_affiliate; +mod query_user_affiliate_list_handler; +pub use query_user_affiliate_list_handler::query_user_affiliate_list; +mod query_user_balance_log_handler; +pub use query_user_balance_log_handler::query_user_balance_log; +mod query_user_commission_log_handler; +pub use query_user_commission_log_handler::query_user_commission_log; +mod query_user_info_handler; +pub use query_user_info_handler::query_user_info; +mod query_user_subscribe_handler; +pub use query_user_subscribe_handler::query_user_subscribe; +mod query_withdrawal_log_handler; +pub use query_withdrawal_log_handler::query_withdrawal_log; +mod reset_user_subscribe_token_handler; +pub use reset_user_subscribe_token_handler::reset_user_subscribe_token; +mod unbind_device_handler; +pub use unbind_device_handler::unbind_device; +mod unbind_o_auth_handler; +pub use unbind_o_auth_handler::unbind_o_auth; +mod unbind_telegram_handler; +pub use unbind_telegram_handler::unbind_telegram; +mod unsubscribe_handler; +pub use unsubscribe_handler::unsubscribe; +mod update_bind_email_handler; +pub use update_bind_email_handler::update_bind_email; +mod update_bind_mobile_handler; +pub use update_bind_mobile_handler::update_bind_mobile; +mod update_user_notify_handler; +pub use update_user_notify_handler::update_user_notify; +mod update_user_password_handler; +pub use update_user_password_handler::update_user_password; +mod update_user_rules_handler; +pub use update_user_rules_handler::update_user_rules; +mod update_user_subscribe_note_handler; +pub use update_user_subscribe_note_handler::update_user_subscribe_note; +mod verify_email_handler; +pub use verify_email_handler::verify_email; diff --git a/src/handler/public/user/pre_unsubscribe_handler.rs b/src/handler/public/user/pre_unsubscribe_handler.rs new file mode 100644 index 00000000..6fb6e840 --- /dev/null +++ b/src/handler/public/user/pre_unsubscribe_handler.rs @@ -0,0 +1,21 @@ +use axum::extract::State; +use axum::Extension; +use axum::Json; + +use crate::handler::AppState; +use crate::middleware::auth_middleware::AuthContext; +use crate::model::dto::*; +use crate::service::public::user::pre_unsubscribe_service::PreUnsubscribeService; +use result::http_result::{build_http_result, HttpResult}; + +pub async fn pre_unsubscribe( + State(state): State, + Extension(auth): Extension, + Json(req): Json, +) -> HttpResult { + let svc = PreUnsubscribeService::new(state.repos.clone()); + match svc.pre_unsubscribe(auth.user_id, req).await { + Ok(data) => build_http_result(Some(data), None), + Err(e) => build_http_result::<()>(None, Some(e)), + } +} diff --git a/src/handler/public/user/query_user_affiliate_handler.rs b/src/handler/public/user/query_user_affiliate_handler.rs new file mode 100644 index 00000000..06c04de6 --- /dev/null +++ b/src/handler/public/user/query_user_affiliate_handler.rs @@ -0,0 +1,18 @@ +use axum::extract::State; +use axum::Extension; + +use crate::handler::AppState; +use crate::middleware::auth_middleware::AuthContext; +use crate::service::public::user::query_user_affiliate_service::QueryUserAffiliateService; +use result::http_result::{build_http_result, HttpResult}; + +pub async fn query_user_affiliate( + State(state): State, + Extension(auth): Extension, +) -> HttpResult { + let svc = QueryUserAffiliateService::new(state.repos.clone()); + match svc.query_user_affiliate(auth.user_id).await { + Ok(data) => build_http_result(Some(data), None), + Err(e) => build_http_result::<()>(None, Some(e)), + } +} diff --git a/src/handler/public/user/query_user_affiliate_list_handler.rs b/src/handler/public/user/query_user_affiliate_list_handler.rs new file mode 100644 index 00000000..6bd8d0ce --- /dev/null +++ b/src/handler/public/user/query_user_affiliate_list_handler.rs @@ -0,0 +1,20 @@ +use axum::extract::{Query, State}; +use axum::Extension; + +use crate::handler::AppState; +use crate::middleware::auth_middleware::AuthContext; +use crate::model::dto::*; +use crate::service::public::user::query_user_affiliate_list_service::QueryUserAffiliateListService; +use result::http_result::{build_http_result, HttpResult}; + +pub async fn query_user_affiliate_list( + State(state): State, + Extension(auth): Extension, + Query(req): Query, +) -> HttpResult { + let svc = QueryUserAffiliateListService::new(state.repos.clone()); + match svc.query_user_affiliate_list(auth.user_id, req).await { + Ok(data) => build_http_result(Some(data), None), + Err(e) => build_http_result::<()>(None, Some(e)), + } +} diff --git a/src/handler/public/user/query_user_balance_log_handler.rs b/src/handler/public/user/query_user_balance_log_handler.rs new file mode 100644 index 00000000..f06f4a53 --- /dev/null +++ b/src/handler/public/user/query_user_balance_log_handler.rs @@ -0,0 +1,20 @@ +use axum::extract::{Query, State}; +use axum::Extension; + +use crate::handler::AppState; +use crate::middleware::auth_middleware::AuthContext; +use crate::model::dto::*; +use crate::service::public::user::query_user_balance_log_service::QueryUserBalanceLogService; +use result::http_result::{build_http_result, HttpResult}; + +pub async fn query_user_balance_log( + State(state): State, + Extension(auth): Extension, + Query(req): Query, +) -> HttpResult { + let svc = QueryUserBalanceLogService::new(state.repos.clone()); + match svc.query_user_balance_log(auth.user_id, req).await { + Ok(data) => build_http_result(Some(data), None), + Err(e) => build_http_result::<()>(None, Some(e)), + } +} diff --git a/src/handler/public/user/query_user_commission_log_handler.rs b/src/handler/public/user/query_user_commission_log_handler.rs new file mode 100644 index 00000000..15837fea --- /dev/null +++ b/src/handler/public/user/query_user_commission_log_handler.rs @@ -0,0 +1,20 @@ +use axum::extract::{Query, State}; +use axum::Extension; + +use crate::handler::AppState; +use crate::middleware::auth_middleware::AuthContext; +use crate::model::dto::log::FilterCommissionLogRequest; +use crate::service::public::user::query_user_commission_log_service::QueryUserCommissionLogService; +use result::http_result::{build_http_result, HttpResult}; + +pub async fn query_user_commission_log( + State(state): State, + Extension(auth): Extension, + Query(req): Query, +) -> HttpResult { + let svc = QueryUserCommissionLogService::new(state.repos.clone()); + match svc.query_user_commission_log(auth.user_id, req).await { + Ok(data) => build_http_result(Some(data), None), + Err(e) => build_http_result::<()>(None, Some(e)), + } +} diff --git a/src/handler/public/user/query_user_info_handler.rs b/src/handler/public/user/query_user_info_handler.rs new file mode 100644 index 00000000..654258a9 --- /dev/null +++ b/src/handler/public/user/query_user_info_handler.rs @@ -0,0 +1,18 @@ +use axum::extract::State; +use axum::Extension; + +use crate::handler::AppState; +use crate::middleware::auth_middleware::AuthContext; +use crate::service::public::user::query_user_info_service::QueryUserInfoService; +use result::http_result::{build_http_result, HttpResult}; + +pub async fn query_user_info( + State(state): State, + Extension(auth): Extension, +) -> HttpResult { + let svc = QueryUserInfoService::new(state.repos.clone()); + match svc.query_user_info(auth.user_id).await { + Ok(data) => build_http_result(Some(data), None), + Err(e) => build_http_result::<()>(None, Some(e)), + } +} diff --git a/src/handler/public/user/query_user_subscribe_handler.rs b/src/handler/public/user/query_user_subscribe_handler.rs new file mode 100644 index 00000000..6b711610 --- /dev/null +++ b/src/handler/public/user/query_user_subscribe_handler.rs @@ -0,0 +1,18 @@ +use axum::extract::State; +use axum::Extension; + +use crate::handler::AppState; +use crate::middleware::auth_middleware::AuthContext; +use crate::service::public::user::query_user_subscribe_service::QueryUserSubscribeService; +use result::http_result::{build_http_result, HttpResult}; + +pub async fn query_user_subscribe( + State(state): State, + Extension(auth): Extension, +) -> HttpResult { + let svc = QueryUserSubscribeService::new(state.repos.clone()); + match svc.query_user_subscribe(auth.user_id).await { + Ok(data) => build_http_result(Some(data), None), + Err(e) => build_http_result::<()>(None, Some(e)), + } +} diff --git a/src/handler/public/user/query_withdrawal_log_handler.rs b/src/handler/public/user/query_withdrawal_log_handler.rs new file mode 100644 index 00000000..2b4c919d --- /dev/null +++ b/src/handler/public/user/query_withdrawal_log_handler.rs @@ -0,0 +1,20 @@ +use axum::extract::{Query, State}; +use axum::Extension; + +use crate::handler::AppState; +use crate::middleware::auth_middleware::AuthContext; +use crate::model::dto::log::QueryWithdrawalLogListRequest; +use crate::service::public::user::query_withdrawal_log_service::QueryWithdrawalLogService; +use result::http_result::{build_http_result, HttpResult}; + +pub async fn query_withdrawal_log( + State(state): State, + Extension(auth): Extension, + Query(req): Query, +) -> HttpResult { + let svc = QueryWithdrawalLogService::new(state.repos.clone()); + match svc.query_withdrawal_log(auth.user_id, req).await { + Ok(data) => build_http_result(Some(data), None), + Err(e) => build_http_result::<()>(None, Some(e)), + } +} diff --git a/src/handler/public/user/reset_user_subscribe_token_handler.rs b/src/handler/public/user/reset_user_subscribe_token_handler.rs new file mode 100644 index 00000000..2714cc9a --- /dev/null +++ b/src/handler/public/user/reset_user_subscribe_token_handler.rs @@ -0,0 +1,21 @@ +use axum::extract::State; +use axum::Extension; +use axum::Json; + +use crate::handler::AppState; +use crate::middleware::auth_middleware::AuthContext; +use crate::model::dto::*; +use crate::service::public::user::reset_user_subscribe_token_service::ResetUserSubscribeTokenService; +use result::http_result::{build_http_result, HttpResult}; + +pub async fn reset_user_subscribe_token( + State(state): State, + Extension(auth): Extension, + Json(req): Json, +) -> HttpResult { + let svc = ResetUserSubscribeTokenService::new(state.repos.clone()); + match svc.reset_user_subscribe_token(auth.user_id, req).await { + Ok(data) => build_http_result(Some(data), None), + Err(e) => build_http_result::<()>(None, Some(e)), + } +} diff --git a/src/handler/public/user/unbind_device_handler.rs b/src/handler/public/user/unbind_device_handler.rs new file mode 100644 index 00000000..2dd460fc --- /dev/null +++ b/src/handler/public/user/unbind_device_handler.rs @@ -0,0 +1,21 @@ +use axum::extract::State; +use axum::Extension; +use axum::Json; + +use crate::handler::AppState; +use crate::middleware::auth_middleware::AuthContext; +use crate::model::dto::*; +use crate::service::public::user::unbind_device_service::UnbindDeviceService; +use result::http_result::{build_http_result, HttpResult}; + +pub async fn unbind_device( + State(state): State, + Extension(auth): Extension, + Json(req): Json, +) -> HttpResult { + let svc = UnbindDeviceService::new(state.repos.clone()); + match svc.unbind_device(auth.user_id, req).await { + Ok(()) => build_http_result(Some(()), None), + Err(e) => build_http_result::<()>(None, Some(e)), + } +} diff --git a/src/handler/public/user/unbind_o_auth_handler.rs b/src/handler/public/user/unbind_o_auth_handler.rs new file mode 100644 index 00000000..75caa498 --- /dev/null +++ b/src/handler/public/user/unbind_o_auth_handler.rs @@ -0,0 +1,21 @@ +use axum::extract::State; +use axum::Extension; +use axum::Json; + +use crate::handler::AppState; +use crate::middleware::auth_middleware::AuthContext; +use crate::model::dto::*; +use crate::service::public::user::unbind_o_auth_service::UnbindOAuthService; +use result::http_result::{build_http_result, HttpResult}; + +pub async fn unbind_o_auth( + State(state): State, + Extension(auth): Extension, + Json(req): Json, +) -> HttpResult { + let svc = UnbindOAuthService::new(state.repos.clone()); + match svc.unbind_o_auth(auth.user_id, req).await { + Ok(()) => build_http_result(Some(()), None), + Err(e) => build_http_result::<()>(None, Some(e)), + } +} diff --git a/src/handler/public/user/unbind_telegram_handler.rs b/src/handler/public/user/unbind_telegram_handler.rs new file mode 100644 index 00000000..c638aab3 --- /dev/null +++ b/src/handler/public/user/unbind_telegram_handler.rs @@ -0,0 +1,18 @@ +use axum::extract::State; +use axum::Extension; + +use crate::handler::AppState; +use crate::middleware::auth_middleware::AuthContext; +use crate::service::public::user::unbind_telegram_service::UnbindTelegramService; +use result::http_result::{build_http_result, HttpResult}; + +pub async fn unbind_telegram( + State(state): State, + Extension(auth): Extension, +) -> HttpResult { + let svc = UnbindTelegramService::new(state.repos.clone()); + match svc.unbind_telegram(auth.user_id).await { + Ok(()) => build_http_result(Some(()), None), + Err(e) => build_http_result::<()>(None, Some(e)), + } +} diff --git a/src/handler/public/user/unsubscribe_handler.rs b/src/handler/public/user/unsubscribe_handler.rs new file mode 100644 index 00000000..a6b50b18 --- /dev/null +++ b/src/handler/public/user/unsubscribe_handler.rs @@ -0,0 +1,21 @@ +use axum::extract::State; +use axum::Extension; +use axum::Json; + +use crate::handler::AppState; +use crate::middleware::auth_middleware::AuthContext; +use crate::model::dto::*; +use crate::service::public::user::unsubscribe_service::UnsubscribeService; +use result::http_result::{build_http_result, HttpResult}; + +pub async fn unsubscribe( + State(state): State, + Extension(auth): Extension, + Json(req): Json, +) -> HttpResult { + let svc = UnsubscribeService::new(state.repos.clone()); + match svc.unsubscribe(auth.user_id, req).await { + Ok(()) => build_http_result(Some(()), None), + Err(e) => build_http_result::<()>(None, Some(e)), + } +} diff --git a/src/handler/public/user/update_bind_email_handler.rs b/src/handler/public/user/update_bind_email_handler.rs new file mode 100644 index 00000000..ff97a26a --- /dev/null +++ b/src/handler/public/user/update_bind_email_handler.rs @@ -0,0 +1,21 @@ +use axum::extract::State; +use axum::Extension; +use axum::Json; + +use crate::handler::AppState; +use crate::middleware::auth_middleware::AuthContext; +use crate::model::dto::*; +use crate::service::public::user::update_bind_email_service::UpdateBindEmailService; +use result::http_result::{build_http_result, HttpResult}; + +pub async fn update_bind_email( + State(state): State, + Extension(auth): Extension, + Json(req): Json, +) -> HttpResult { + let svc = UpdateBindEmailService::new(state.repos.clone()); + match svc.update_bind_email(auth.user_id, req).await { + Ok(()) => build_http_result(Some(()), None), + Err(e) => build_http_result::<()>(None, Some(e)), + } +} diff --git a/src/handler/public/user/update_bind_mobile_handler.rs b/src/handler/public/user/update_bind_mobile_handler.rs new file mode 100644 index 00000000..7cdadf77 --- /dev/null +++ b/src/handler/public/user/update_bind_mobile_handler.rs @@ -0,0 +1,21 @@ +use axum::extract::State; +use axum::Extension; +use axum::Json; + +use crate::handler::AppState; +use crate::middleware::auth_middleware::AuthContext; +use crate::model::dto::*; +use crate::service::public::user::update_bind_mobile_service::UpdateBindMobileService; +use result::http_result::{build_http_result, HttpResult}; + +pub async fn update_bind_mobile( + State(state): State, + Extension(auth): Extension, + Json(req): Json, +) -> HttpResult { + let svc = UpdateBindMobileService::new(state.repos.clone()); + match svc.update_bind_mobile(auth.user_id, req).await { + Ok(()) => build_http_result(Some(()), None), + Err(e) => build_http_result::<()>(None, Some(e)), + } +} diff --git a/src/handler/public/user/update_user_notify_handler.rs b/src/handler/public/user/update_user_notify_handler.rs new file mode 100644 index 00000000..7c509a69 --- /dev/null +++ b/src/handler/public/user/update_user_notify_handler.rs @@ -0,0 +1,21 @@ +use axum::extract::State; +use axum::Extension; +use axum::Json; + +use crate::handler::AppState; +use crate::middleware::auth_middleware::AuthContext; +use crate::model::dto::*; +use crate::service::public::user::update_user_notify_service::UpdateUserNotifyService; +use result::http_result::{build_http_result, HttpResult}; + +pub async fn update_user_notify( + State(state): State, + Extension(auth): Extension, + Json(req): Json, +) -> HttpResult { + let svc = UpdateUserNotifyService::new(state.repos.clone()); + match svc.update_user_notify(auth.user_id, req).await { + Ok(()) => build_http_result(Some(()), None), + Err(e) => build_http_result::<()>(None, Some(e)), + } +} diff --git a/src/handler/public/user/update_user_password_handler.rs b/src/handler/public/user/update_user_password_handler.rs new file mode 100644 index 00000000..cbf7ab8c --- /dev/null +++ b/src/handler/public/user/update_user_password_handler.rs @@ -0,0 +1,21 @@ +use axum::extract::State; +use axum::Extension; +use axum::Json; + +use crate::handler::AppState; +use crate::middleware::auth_middleware::AuthContext; +use crate::model::dto::*; +use crate::service::public::user::update_user_password_service::UpdateUserPasswordService; +use result::http_result::{build_http_result, HttpResult}; + +pub async fn update_user_password( + State(state): State, + Extension(auth): Extension, + Json(req): Json, +) -> HttpResult { + let svc = UpdateUserPasswordService::new(state.repos.clone()); + match svc.update_user_password(auth.user_id, req).await { + Ok(()) => build_http_result(Some(()), None), + Err(e) => build_http_result::<()>(None, Some(e)), + } +} diff --git a/src/handler/public/user/update_user_rules_handler.rs b/src/handler/public/user/update_user_rules_handler.rs new file mode 100644 index 00000000..4618f7f2 --- /dev/null +++ b/src/handler/public/user/update_user_rules_handler.rs @@ -0,0 +1,21 @@ +use axum::extract::State; +use axum::Extension; +use axum::Json; + +use crate::handler::AppState; +use crate::middleware::auth_middleware::AuthContext; +use crate::model::dto::*; +use crate::service::public::user::update_user_rules_service::UpdateUserRulesService; +use result::http_result::{build_http_result, HttpResult}; + +pub async fn update_user_rules( + State(state): State, + Extension(auth): Extension, + Json(req): Json, +) -> HttpResult { + let svc = UpdateUserRulesService::new(state.repos.clone()); + match svc.update_user_rules(auth.user_id, req).await { + Ok(()) => build_http_result(Some(()), None), + Err(e) => build_http_result::<()>(None, Some(e)), + } +} diff --git a/src/handler/public/user/update_user_subscribe_note_handler.rs b/src/handler/public/user/update_user_subscribe_note_handler.rs new file mode 100644 index 00000000..8a5aca56 --- /dev/null +++ b/src/handler/public/user/update_user_subscribe_note_handler.rs @@ -0,0 +1,21 @@ +use axum::extract::State; +use axum::Extension; +use axum::Json; + +use crate::handler::AppState; +use crate::middleware::auth_middleware::AuthContext; +use crate::model::dto::*; +use crate::service::public::user::update_user_subscribe_note_service::UpdateUserSubscribeNoteService; +use result::http_result::{build_http_result, HttpResult}; + +pub async fn update_user_subscribe_note( + State(state): State, + Extension(auth): Extension, + Json(req): Json, +) -> HttpResult { + let svc = UpdateUserSubscribeNoteService::new(state.repos.clone()); + match svc.update_user_subscribe_note(auth.user_id, req).await { + Ok(()) => build_http_result(Some(()), None), + Err(e) => build_http_result::<()>(None, Some(e)), + } +} diff --git a/src/handler/public/user/verify_email_handler.rs b/src/handler/public/user/verify_email_handler.rs new file mode 100644 index 00000000..3e0c5d98 --- /dev/null +++ b/src/handler/public/user/verify_email_handler.rs @@ -0,0 +1,21 @@ +use axum::extract::State; +use axum::Extension; +use axum::Json; + +use crate::handler::AppState; +use crate::middleware::auth_middleware::AuthContext; +use crate::model::dto::*; +use crate::service::public::user::verify_email_service::VerifyEmailService; +use result::http_result::{build_http_result, HttpResult}; + +pub async fn verify_email( + State(state): State, + Extension(auth): Extension, + Json(req): Json, +) -> HttpResult { + let svc = VerifyEmailService::new(state.repos.clone()); + match svc.verify_email(auth.user_id, req).await { + Ok(()) => build_http_result(Some(()), None), + Err(e) => build_http_result::<()>(None, Some(e)), + } +} diff --git a/src/handler/routes.rs b/src/handler/routes.rs new file mode 100644 index 00000000..8bcd21d6 --- /dev/null +++ b/src/handler/routes.rs @@ -0,0 +1,345 @@ +// Routes — all HTTP routes registered with middleware applied per group. +// +// Middleware mapping (mirrors Go): +// /v1/auth/* — device_middleware only (public auth routes) +// /v1/auth/oauth/* — no middleware +// /v1/admin/* — auth_middleware +// /v1/public/* — auth_middleware + device_middleware +// /v1/common/* — device_middleware +// /v1/server/* — server_middleware (TODO) +// notify / subscribe / telegram — no user auth +use axum::{ + Router, + middleware, + routing::{delete, get, post, put}, +}; +use tower_http::trace::TraceLayer; + +use crate::handler::AppState; +use crate::middleware::auth_middleware::auth_middleware; +use crate::middleware::device_middleware::device_middleware; +use crate::middleware::logger_middleware::{RequestLog, RequestSpan}; + +use crate::handler::admin::ads::*; +use crate::handler::admin::announcement::*; +use crate::handler::admin::application::*; +use crate::handler::admin::auth_method::*; +use crate::handler::admin::console::*; +use crate::handler::admin::coupon::*; +use crate::handler::admin::document::*; +use crate::handler::admin::log::*; +use crate::handler::admin::marketing::*; +use crate::handler::admin::order::*; +use crate::handler::admin::payment::*; +use crate::handler::admin::plugin::*; +use crate::handler::admin::server::*; +use crate::handler::admin::subscribe::*; +use crate::handler::admin::system::*; +use crate::handler::admin::ticket::*; +use crate::handler::admin::tool::*; +use crate::handler::admin::user::*; + +use crate::handler::public::announcement::*; +use crate::handler::public::document::*; +use crate::handler::public::subscribe::*; +use crate::handler::public::ticket::*; +use crate::handler::public::order::*; +use crate::handler::public::payment::*; +use crate::handler::public::portal::{ + purchase_checkout, + query_purchase_order, + get_available_payment_methods as portal_get_available_payment_methods, + pre_purchase_order, + purchase as portal_purchase, + get_subscription, +}; +use crate::handler::public::user::{ + bind_o_auth_callback, bind_o_auth, bind_telegram, commission_withdraw, + get_device_list, get_login_log, get_o_auth_methods, get_subscribe_log, + pre_unsubscribe, query_user_affiliate, query_user_affiliate_list, + query_user_balance_log, query_user_commission_log, query_user_info, + query_user_subscribe, query_withdrawal_log, + reset_user_subscribe_token as public_reset_user_subscribe_token, + unbind_device, unbind_o_auth, unbind_telegram, unsubscribe, + update_bind_email, update_bind_mobile, update_user_notify, + update_user_password, update_user_rules, update_user_subscribe_note, + verify_email, +}; + +use crate::handler::auth::*; +use crate::handler::auth::oauth::*; + +use crate::handler::common::*; +use crate::handler::server::*; +use crate::handler::subscribe::*; +use crate::handler::telegram::*; + +pub fn register_routes(state: AppState) -> Router<()> { + // ── OAuth (no middleware) ──────────────────────────────────────────── + let oauth_routes = Router::new() + .route("/v1/auth/oauth/callback/apple", post(apple_login_callback)) + .route("/v1/auth/oauth/login", post(o_auth_login)) + .route("/v1/auth/oauth/login/token", post(o_auth_login_get_token)); + + // ── Auth routes (device_middleware only — public) ──────────────────── + let auth_routes = Router::new() + .route("/v1/auth/check", get(check_user)) + .route("/v1/auth/check/telephone", get(check_user_telephone)) + .route("/v1/auth/login", post(user_login)) + .route("/v1/auth/login/device", post(device_login)) + .route("/v1/auth/login/telephone", post(telephone_login)) + .route("/v1/auth/register", post(user_register)) + .route("/v1/auth/register/telephone", post(telephone_user_register)) + .route("/v1/auth/reset", post(reset_password)) + .route("/v1/auth/reset/telephone", post(telephone_reset_password)) + .layer(middleware::from_fn(device_middleware)); + + // ── Admin routes (auth_middleware) ─────────────────────────────────── + let admin_routes = Router::new() + // Ads + .route("/v1/admin/ads", post(create_ads).put(update_ads).delete(delete_ads)) + .route("/v1/admin/ads/detail", get(get_ads_detail)) + .route("/v1/admin/ads/list", get(get_ads_list)) + // Announcement + .route("/v1/admin/announcement", post(create_announcement).put(update_announcement).delete(delete_announcement)) + .route("/v1/admin/announcement/detail", get(get_announcement)) + .route("/v1/admin/announcement/list", get(get_announcement_list)) + // Application + .route("/v1/admin/application", post(create_subscribe_application)) + .route("/v1/admin/application/preview", get(preview_subscribe_template)) + .route("/v1/admin/application/subscribe_application", put(update_subscribe_application).delete(delete_subscribe_application)) + .route("/v1/admin/application/subscribe_application_list", get(get_subscribe_application_list)) + // Auth-method + .route("/v1/admin/auth-method/config", get(get_auth_method_config).put(update_auth_method_config)) + .route("/v1/admin/auth-method/email_platform", get(get_email_platform)) + .route("/v1/admin/auth-method/list", get(get_auth_method_list)) + .route("/v1/admin/auth-method/sms_platform", get(get_sms_platform)) + .route("/v1/admin/auth-method/test_email_send", post(test_email_send)) + .route("/v1/admin/auth-method/test_sms_send", post(test_sms_send)) + // Console + .route("/v1/admin/console/revenue", get(query_revenue_statistics)) + .route("/v1/admin/console/server", get(query_server_total_data)) + .route("/v1/admin/console/ticket", get(query_ticket_wait_reply)) + .route("/v1/admin/console/user", get(query_user_statistics)) + // Coupon + .route("/v1/admin/coupon", post(create_coupon).put(update_coupon).delete(delete_coupon)) + .route("/v1/admin/coupon/batch", delete(batch_delete_coupon)) + .route("/v1/admin/coupon/list", get(get_coupon_list)) + // Document + .route("/v1/admin/document", post(create_document).put(update_document).delete(delete_document)) + .route("/v1/admin/document/batch", delete(batch_delete_document)) + .route("/v1/admin/document/detail", get(get_document_detail)) + .route("/v1/admin/document/list", get(get_document_list)) + // Log + .route("/v1/admin/log/balance/list", get(filter_balance_log)) + .route("/v1/admin/log/commission/list", get(filter_commission_log)) + .route("/v1/admin/log/email/list", get(filter_email_log)) + .route("/v1/admin/log/gift/list", get(filter_gift_log)) + .route("/v1/admin/log/login/list", get(filter_login_log)) + .route("/v1/admin/log/message/list", get(get_message_log_list)) + .route("/v1/admin/log/mobile/list", get(filter_mobile_log)) + .route("/v1/admin/log/register/list", get(filter_register_log)) + .route("/v1/admin/log/server/traffic/list", get(filter_server_traffic_log)) + .route("/v1/admin/log/setting", get(get_log_setting).post(update_log_setting)) + .route("/v1/admin/log/subscribe/list", get(filter_subscribe_log)) + .route("/v1/admin/log/subscribe/reset/list", get(filter_reset_subscribe_log)) + .route("/v1/admin/log/subscribe/traffic/list", get(filter_user_subscribe_traffic_log)) + .route("/v1/admin/log/traffic/details", get(filter_traffic_log_details)) + // Marketing + .route("/v1/admin/marketing/email/batch/list", get(get_batch_send_email_task_list)) + .route("/v1/admin/marketing/email/batch/pre-send-count", post(get_pre_send_email_count)) + .route("/v1/admin/marketing/email/batch/send", post(create_batch_send_email_task)) + .route("/v1/admin/marketing/email/batch/status", post(get_batch_send_email_task_status)) + .route("/v1/admin/marketing/email/batch/stop", post(stop_batch_send_email_task)) + .route("/v1/admin/marketing/quota/create", post(create_quota_task)) + .route("/v1/admin/marketing/quota/list", get(query_quota_task_list)) + .route("/v1/admin/marketing/quota/pre-count", post(query_quota_task_pre_count)) + .route("/v1/admin/marketing/quota/status", post(query_quota_task_status)) + // Order + .route("/v1/admin/order", post(create_order)) + .route("/v1/admin/order/list", get(get_order_list)) + .route("/v1/admin/order/status", put(update_order_status)) + // Payment + .route("/v1/admin/payment", post(create_payment_method).put(update_payment_method).delete(delete_payment_method)) + .route("/v1/admin/payment/list", get(get_payment_method_list)) + .route("/v1/admin/payment/platform", get(get_payment_platform)) + // Server + .route("/v1/admin/server/create", post(create_server)) + .route("/v1/admin/server/delete", post(delete_server)) + .route("/v1/admin/server/list", get(filter_server_list)) + .route("/v1/admin/server/node/create", post(create_node)) + .route("/v1/admin/server/node/delete", post(delete_node)) + .route("/v1/admin/server/node/list", get(filter_node_list)) + .route("/v1/admin/server/node/sort", post(reset_sort_with_node)) + .route("/v1/admin/server/node/status/toggle", post(toggle_node_status)) + .route("/v1/admin/server/node/tags", get(query_node_tag)) + .route("/v1/admin/server/node_config", get(get_server_node_config)) + .route("/v1/admin/server/node_config/update", post(update_server_node_config)) + .route("/v1/admin/server/node/update", post(update_node)) + .route("/v1/admin/server/protocols", get(get_server_protocols)) + .route("/v1/admin/server/server/sort", post(reset_sort_with_server)) + .route("/v1/admin/server/update", post(update_server)) + // Subscribe + .route("/v1/admin/subscribe", post(create_subscribe).put(update_subscribe).delete(delete_subscribe)) + .route("/v1/admin/subscribe/batch", delete(batch_delete_subscribe)) + .route("/v1/admin/subscribe/details", get(get_subscribe_details)) + .route("/v1/admin/subscribe/group", post(create_subscribe_group).put(update_subscribe_group).delete(delete_subscribe_group)) + .route("/v1/admin/subscribe/group/batch", delete(batch_delete_subscribe_group)) + .route("/v1/admin/subscribe/group/list", get(get_subscribe_group_list)) + .route("/v1/admin/subscribe/list", get(get_subscribe_list)) + .route("/v1/admin/subscribe/reset_all_token", post(reset_all_subscribe_token)) + .route("/v1/admin/subscribe/sort", post(subscribe_sort)) + // System + .route("/v1/admin/system/currency_config", get(get_currency_config).put(update_currency_config)) + .route("/v1/admin/system/get_node_multiplier", get(get_node_multiplier)) + .route("/v1/admin/system/invite_config", get(get_invite_config).put(update_invite_config)) + .route("/v1/admin/system/module", get(get_module_config)) + .route("/v1/admin/system/node_config", get(get_node_config).put(update_node_config)) + .route("/v1/admin/system/node_multiplier/preview", get(pre_view_node_multiplier)) + .route("/v1/admin/system/privacy", get(get_privacy_policy_config).put(update_privacy_policy_config)) + .route("/v1/admin/system/register_config", get(get_register_config).put(update_register_config)) + .route("/v1/admin/system/set_node_multiplier", post(set_node_multiplier)) + .route("/v1/admin/system/setting_telegram_bot", post(setting_telegram_bot_handler)) + .route("/v1/admin/system/site_config", get(get_site_config).put(update_site_config)) + .route("/v1/admin/system/subscribe_config", get(get_subscribe_config).put(update_subscribe_config)) + .route("/v1/admin/system/tos_config", get(get_tos_config).put(update_tos_config)) + .route("/v1/admin/system/verify_code_config", get(get_verify_code_config).put(update_verify_code_config)) + .route("/v1/admin/system/verify_config", get(get_verify_config).put(update_verify_config)) + // Ticket + .route("/v1/admin/ticket", put(update_ticket_status)) + .route("/v1/admin/ticket/detail", get(get_ticket)) + .route("/v1/admin/ticket/follow", post(create_ticket_follow)) + .route("/v1/admin/ticket/list", get(get_ticket_list)) + // Tool + .route("/v1/admin/tool/ip/location", get(query_ip_location)) + .route("/v1/admin/tool/log", get(get_system_log)) + .route("/v1/admin/tool/restart", get(restart_system)) + .route("/v1/admin/tool/version", get(get_version)) + // User + .route("/v1/admin/user", post(create_user).delete(delete_user)) + .route("/v1/admin/user/auth_method", get(get_user_auth_method).post(create_user_auth_method).put(update_user_auth_method).delete(delete_user_auth_method)) + .route("/v1/admin/user/basic", put(update_user_basic_info)) + .route("/v1/admin/user/batch", delete(batch_delete_user)) + .route("/v1/admin/user/current", get(current_user)) + .route("/v1/admin/user/detail", get(get_user_detail)) + .route("/v1/admin/user/device", put(update_user_device).delete(delete_user_device)) + .route("/v1/admin/user/device/kick_offline", put(kick_offline_by_user_device)) + .route("/v1/admin/user/list", get(get_user_list)) + .route("/v1/admin/user/login/logs", get(get_user_login_logs)) + .route("/v1/admin/user/notify", put(update_user_notify_setting)) + .route("/v1/admin/user/subscribe", get(get_user_subscribe).post(create_user_subscribe).put(update_user_subscribe).delete(delete_user_subscribe)) + .route("/v1/admin/user/subscribe/detail", get(get_user_subscribe_by_id)) + .route("/v1/admin/user/subscribe/device", get(get_user_subscribe_devices)) + .route("/v1/admin/user/subscribe/logs", get(get_user_subscribe_logs)) + .route("/v1/admin/user/subscribe/reset/logs", get(get_user_subscribe_reset_traffic_logs)) + .route("/v1/admin/user/subscribe/reset/token", post(reset_user_subscribe_token)) + .route("/v1/admin/user/subscribe/reset/traffic", post(reset_user_subscribe_traffic)) + .route("/v1/admin/user/subscribe/toggle", post(toggle_user_subscribe_status)) + .route("/v1/admin/user/subscribe/traffic_logs", get(get_user_subscribe_traffic_logs)) + // Plugin + .route("/v1/admin/plugin/list", get(list)) + .route("/v1/admin/plugin/detail", get(detail)) + .route("/v1/admin/plugin/reload", post(reload_handler)) + .route("/v1/admin/plugin/enable", post(enable_handler)) + .route("/v1/admin/plugin/disable", post(disable_handler)) + .layer(middleware::from_fn_with_state(state.clone(), auth_middleware)); + + // ── Public routes (auth_middleware + device_middleware) ────────────── + let public_routes = Router::new() + .route("/v1/public/announcement/list", get(query_announcement)) + .route("/v1/public/document/detail", get(query_document_detail)) + .route("/v1/public/document/list", get(query_document_list)) + .route("/v1/public/order/close", post(close_order)) + .route("/v1/public/order/detail", get(query_order_detail)) + .route("/v1/public/order/list", get(query_order_list)) + .route("/v1/public/order/pre", post(pre_create_order)) + .route("/v1/public/order/purchase", post(purchase)) + .route("/v1/public/order/recharge", post(recharge)) + .route("/v1/public/order/renewal", post(renewal)) + .route("/v1/public/order/reset", post(reset_traffic)) + .route("/v1/public/payment/methods", get(get_available_payment_methods)) + .route("/v1/public/portal/order/checkout", post(purchase_checkout)) + .route("/v1/public/portal/order/status", get(query_purchase_order)) + .route("/v1/public/portal/payment-method", get(portal_get_available_payment_methods)) + .route("/v1/public/portal/pre", post(pre_purchase_order)) + .route("/v1/public/portal/purchase", post(portal_purchase)) + .route("/v1/public/portal/subscribe", get(get_subscription)) + .route("/v1/public/subscribe/list", get(query_subscribe_list)) + .route("/v1/public/subscribe/node/list", get(query_user_subscribe_node_list)) + .route("/v1/public/subscribe/group/list", get(query_subscribe_group_list)) + .route("/v1/public/ticket", put(update_user_ticket_status).post(create_user_ticket)) + .route("/v1/public/ticket/detail", get(get_user_ticket_details)) + .route("/v1/public/ticket/follow", post(create_user_ticket_follow)) + .route("/v1/public/ticket/list", get(get_user_ticket_list)) + .route("/v1/public/user/affiliate/count", get(query_user_affiliate)) + .route("/v1/public/user/affiliate/list", get(query_user_affiliate_list)) + .route("/v1/public/user/balance_log", get(query_user_balance_log)) + .route("/v1/public/user/bind_email", put(update_bind_email)) + .route("/v1/public/user/bind_mobile", put(update_bind_mobile)) + .route("/v1/public/user/bind_oauth", post(bind_o_auth)) + .route("/v1/public/user/bind_oauth/callback", post(bind_o_auth_callback)) + .route("/v1/public/user/bind_telegram", get(bind_telegram)) + .route("/v1/public/user/commission_log", get(query_user_commission_log)) + .route("/v1/public/user/commission_withdraw", post(commission_withdraw)) + .route("/v1/public/user/devices", get(get_device_list)) + .route("/v1/public/user/info", get(query_user_info)) + .route("/v1/public/user/login_log", get(get_login_log)) + .route("/v1/public/user/notify", put(update_user_notify)) + .route("/v1/public/user/oauth_methods", get(get_o_auth_methods)) + .route("/v1/public/user/password", put(update_user_password)) + .route("/v1/public/user/rules", put(update_user_rules)) + .route("/v1/public/user/subscribe", get(query_user_subscribe)) + .route("/v1/public/user/subscribe_log", get(get_subscribe_log)) + .route("/v1/public/user/subscribe_note", put(update_user_subscribe_note)) + .route("/v1/public/user/subscribe_token", put(public_reset_user_subscribe_token)) + .route("/v1/public/user/unbind_device", put(unbind_device)) + .route("/v1/public/user/unbind_oauth", post(unbind_o_auth)) + .route("/v1/public/user/unbind_telegram", post(unbind_telegram)) + .route("/v1/public/user/unsubscribe", post(unsubscribe)) + .route("/v1/public/user/unsubscribe/pre", post(pre_unsubscribe)) + .route("/v1/public/user/verify_email", post(verify_email)) + .route("/v1/public/user/withdrawal_log", get(query_withdrawal_log)) + // device_middleware first, then auth_middleware (layers apply bottom-up in axum) + .layer(middleware::from_fn(device_middleware)) + .layer(middleware::from_fn_with_state(state.clone(), auth_middleware)); + + // ── Common routes (device_middleware only) ─────────────────────────── + let common_routes = Router::new() + .route("/v1/common/ads", get(get_ads)) + .route("/v1/common/check_verification_code", post(check_verification_code)) + .route("/v1/common/client", get(get_client)) + .route("/v1/common/heartbeat", get(heartbeat)) + .route("/v1/common/send_code", post(send_email_code)) + .route("/v1/common/send_sms_code", post(send_sms_code)) + .route("/v1/common/site/config", get(get_global_config)) + .route("/v1/common/site/privacy", get(get_privacy_policy)) + .route("/v1/common/site/stat", get(get_stat)) + .route("/v1/common/site/tos", get(get_tos)) + .layer(middleware::from_fn(device_middleware)); + + // ── Server / notify / subscribe / telegram (no user auth) ──────────── + let other_routes = Router::new() + .route("/v1/server/config", get(get_server_config)) + .route("/v1/server/online", post(push_online_users)) + .route("/v1/server/push", post(server_push_user_traffic)) + .route("/v1/server/status", post(server_push_status)) + .route("/v1/server/user", get(get_server_user_list)) + .route("/v2/server/:server_id", get(query_server_protocol_config)) + .route("/v1/subscribe/config", get(subscribe_handler)) + .route("/", get(pan_domain_subscribe_handler)) + .route("/v1/telegram", post(telegram_handler)); + + Router::new() + .merge(oauth_routes) + .merge(auth_routes) + .merge(admin_routes) + .merge(public_routes) + .merge(common_routes) + .merge(other_routes) + .with_state(state) + .layer( + TraceLayer::new_for_http() + .make_span_with(RequestSpan) + .on_response(RequestLog), + ) +} diff --git a/src/handler/server/get_server_config_handler.rs b/src/handler/server/get_server_config_handler.rs new file mode 100644 index 00000000..94066992 --- /dev/null +++ b/src/handler/server/get_server_config_handler.rs @@ -0,0 +1,71 @@ +/// Handler for GET /v1/server/config +/// Delegates to `get_server_config_service`. + +use axum::{extract::State, Json}; +use axum::http::HeaderMap; + +use crate::handler::AppState; +use crate::model::dto::server::{GetServerConfigRequest, GetServerConfigResponse}; +use crate::service::server::get_server_config_service; +use crate::service::server::meta::RequestMeta; +use result::code_error::CodeError; +use result::error_code; +use result::http_result::{build_http_result, HttpResult}; + +pub async fn get_server_config( + State(state): State, + headers: HeaderMap, + Json(req): Json, +) -> HttpResult { + if let Err(e) = check_node_auth(&headers, &state.config.node.node_secret, &req.common.secret_key) { + return build_http_result::(None, Some(e)); + } + + let if_none_match = headers + .get("If-None-Match") + .and_then(|v| v.to_str().ok()) + .unwrap_or_default() + .to_string(); + + let meta = RequestMeta { if_none_match }; + + let result = get_server_config_service::get_server_config( + state.repos.clone(), + state.config.clone(), + state.cache.clone(), + req.common.server_id, + &req.common.protocol, + meta, + ) + .await; + + match result { + Ok((resp, _)) => build_http_result(Some(resp), None), + Err(e) => build_http_result::(None, Some(e)), + } +} + +pub fn check_node_auth( + headers: &HeaderMap, + node_secret: &str, + req_secret: &str, +) -> Result<(), anyhow::Error> { + let header_secret = headers + .get("X-Node-Token") + .and_then(|v| v.to_str().ok()) + .unwrap_or_default(); + + let provided = if !header_secret.is_empty() { + header_secret + } else { + req_secret + }; + + if provided != node_secret { + return Err(anyhow::anyhow!(CodeError::new_err_code_msg( + error_code::INVALID_ACCESS, + "invalid node token" + ))); + } + Ok(()) +} diff --git a/src/handler/server/get_server_user_list_handler.rs b/src/handler/server/get_server_user_list_handler.rs new file mode 100644 index 00000000..27fcc9bd --- /dev/null +++ b/src/handler/server/get_server_user_list_handler.rs @@ -0,0 +1,46 @@ +/// Handler for GET /v1/server/user +/// Delegates to `get_server_user_list_service`. + +use axum::{extract::State, Json}; +use axum::http::HeaderMap; + +use crate::handler::AppState; +use crate::model::dto::server::{GetServerUserListRequest, GetServerUserListResponse}; +use crate::service::server::get_server_user_list_service; +use crate::service::server::meta::RequestMeta; +use result::http_result::{build_http_result, HttpResult}; + +use super::get_server_config_handler::check_node_auth; + +pub async fn get_server_user_list( + State(state): State, + headers: HeaderMap, + Json(req): Json, +) -> HttpResult { + if let Err(e) = check_node_auth(&headers, &state.config.node.node_secret, &req.common.secret_key) { + return build_http_result::(None, Some(e)); + } + + let if_none_match = headers + .get("If-None-Match") + .and_then(|v| v.to_str().ok()) + .unwrap_or_default() + .to_string(); + + let meta = RequestMeta { if_none_match }; + + let result = get_server_user_list_service::get_server_user_list( + state.repos.clone(), + state.config.clone(), + state.cache.clone(), + req.common.server_id, + &req.common.protocol, + meta, + ) + .await; + + match result { + Ok((resp, _)) => build_http_result(Some(resp), None), + Err(e) => build_http_result::(None, Some(e)), + } +} diff --git a/src/handler/server/helpers.rs b/src/handler/server/helpers.rs new file mode 100644 index 00000000..dfe4c86f --- /dev/null +++ b/src/handler/server/helpers.rs @@ -0,0 +1,8 @@ +/// Server middleware and utility functions for server-facing handlers. +/// +/// Re-exports `check_node_auth` so other handler modules can import it +/// from a single place. +pub use super::get_server_config_handler::check_node_auth; + +/// Placeholder — may be extended with axum middleware later. +pub fn server_middleware() {} diff --git a/src/handler/server/mod.rs b/src/handler/server/mod.rs new file mode 100644 index 00000000..a4125d9e --- /dev/null +++ b/src/handler/server/mod.rs @@ -0,0 +1,14 @@ +mod get_server_config_handler; +pub use get_server_config_handler::get_server_config; +mod get_server_user_list_handler; +pub use get_server_user_list_handler::get_server_user_list; +mod helpers; +pub use helpers::server_middleware; +mod push_online_users_handler; +pub use push_online_users_handler::push_online_users; +mod query_server_protocol_config_handler; +pub use query_server_protocol_config_handler::query_server_protocol_config; +mod server_push_status_handler; +pub use server_push_status_handler::server_push_status; +mod server_push_user_traffic_handler; +pub use server_push_user_traffic_handler::server_push_user_traffic; diff --git a/src/handler/server/push_online_users_handler.rs b/src/handler/server/push_online_users_handler.rs new file mode 100644 index 00000000..efc2bd42 --- /dev/null +++ b/src/handler/server/push_online_users_handler.rs @@ -0,0 +1,35 @@ +/// Handler for POST /v1/server/push-online +/// Delegates to `push_online_users_service`. + +use axum::{extract::State, Json}; +use axum::http::HeaderMap; + +use crate::handler::AppState; +use crate::model::dto::server::OnlineUsersRequest; +use crate::service::server::push_online_users_service; +use result::http_result::{build_http_result, HttpResult}; + +use super::get_server_config_handler::check_node_auth; + +pub async fn push_online_users( + State(state): State, + headers: HeaderMap, + Json(req): Json, +) -> HttpResult { + if let Err(e) = check_node_auth(&headers, &state.config.node.node_secret, &req.common.secret_key) { + return build_http_result::<()>(None, Some(e)); + } + + let result = push_online_users_service::push_online_users( + state.repos.clone(), + state.config.clone(), + state.cache.clone(), + req, + ) + .await; + + match result { + Ok(_) => build_http_result(Some(()), None), + Err(e) => build_http_result::<()>(None, Some(e)), + } +} diff --git a/src/handler/server/query_server_protocol_config_handler.rs b/src/handler/server/query_server_protocol_config_handler.rs new file mode 100644 index 00000000..b916986f --- /dev/null +++ b/src/handler/server/query_server_protocol_config_handler.rs @@ -0,0 +1,35 @@ +/// Handler for POST /v1/server/query-config +/// Delegates to `query_server_protocol_config_service`. + +use axum::{extract::State, Json}; +use axum::http::HeaderMap; + +use crate::handler::AppState; +use crate::model::dto::server::{QueryServerConfigRequest, QueryServerConfigResponse}; +use crate::service::server::query_server_protocol_config_service; +use result::http_result::{build_http_result, HttpResult}; + +use super::get_server_config_handler::check_node_auth; + +pub async fn query_server_protocol_config( + State(state): State, + headers: HeaderMap, + Json(req): Json, +) -> HttpResult { + if let Err(e) = check_node_auth(&headers, &state.config.node.node_secret, &req.secret_key) { + return build_http_result::(None, Some(e)); + } + + let result = query_server_protocol_config_service::query_server_protocol_config( + state.repos.clone(), + state.config.clone(), + req.server_id, + req.protocols, + ) + .await; + + match result { + Ok(resp) => build_http_result(Some(resp), None), + Err(e) => build_http_result::(None, Some(e)), + } +} diff --git a/src/handler/server/server_push_status_handler.rs b/src/handler/server/server_push_status_handler.rs new file mode 100644 index 00000000..0d60f309 --- /dev/null +++ b/src/handler/server/server_push_status_handler.rs @@ -0,0 +1,35 @@ +/// Handler for POST /v1/server/push-status +/// Delegates to `server_push_status_service`. + +use axum::{extract::State, Json}; +use axum::http::HeaderMap; + +use crate::handler::AppState; +use crate::model::dto::server::ServerPushStatusRequest; +use crate::service::server::server_push_status_service; +use result::http_result::{build_http_result, HttpResult}; + +use super::get_server_config_handler::check_node_auth; + +pub async fn server_push_status( + State(state): State, + headers: HeaderMap, + Json(req): Json, +) -> HttpResult { + if let Err(e) = check_node_auth(&headers, &state.config.node.node_secret, &req.common.secret_key) { + return build_http_result::<()>(None, Some(e)); + } + + let result = server_push_status_service::server_push_status( + state.repos.clone(), + state.config.clone(), + state.cache.clone(), + req, + ) + .await; + + match result { + Ok(_) => build_http_result(Some(()), None), + Err(e) => build_http_result::<()>(None, Some(e)), + } +} diff --git a/src/handler/server/server_push_user_traffic_handler.rs b/src/handler/server/server_push_user_traffic_handler.rs new file mode 100644 index 00000000..6ed5d60c --- /dev/null +++ b/src/handler/server/server_push_user_traffic_handler.rs @@ -0,0 +1,34 @@ +/// Handler for POST /v1/server/push-traffic +/// Delegates to `server_push_user_traffic_service`. + +use axum::{extract::State, Json}; +use axum::http::HeaderMap; + +use crate::handler::AppState; +use crate::model::dto::server::ServerPushUserTrafficRequest; +use crate::service::server::server_push_user_traffic_service; +use result::http_result::{build_http_result, HttpResult}; + +use super::get_server_config_handler::check_node_auth; + +pub async fn server_push_user_traffic( + State(state): State, + headers: HeaderMap, + Json(req): Json, +) -> HttpResult { + if let Err(e) = check_node_auth(&headers, &state.config.node.node_secret, &req.common.secret_key) { + return build_http_result::<()>(None, Some(e)); + } + + let result = server_push_user_traffic_service::server_push_user_traffic( + state.repos.clone(), + state.config.clone(), + req, + ) + .await; + + match result { + Ok(_) => build_http_result(Some(()), None), + Err(e) => build_http_result::<()>(None, Some(e)), + } +} diff --git a/src/handler/subscribe.rs b/src/handler/subscribe.rs new file mode 100644 index 00000000..078a7de5 --- /dev/null +++ b/src/handler/subscribe.rs @@ -0,0 +1,63 @@ +//! Subscribe endpoint handlers. + +use std::collections::HashMap; + +use axum::{ + extract::{Path, Query, State}, + http::HeaderMap, + response::{IntoResponse, Response}, +}; +use axum::http::{header, StatusCode}; + +use crate::handler::AppState; +use crate::service::subscribe::subscribe_service::SubscribeService; + +/// `GET /v1/subscribe/config/:token` +pub async fn subscribe_handler( + State(state): State, + Path(token): Path, + Query(params): Query>, + headers: HeaderMap, +) -> Response { + let ua = headers + .get(header::USER_AGENT) + .and_then(|v| v.to_str().ok()) + .unwrap_or("") + .to_string(); + let host = headers + .get(header::HOST) + .and_then(|v| v.to_str().ok()) + .unwrap_or("localhost") + .to_string(); + + let svc = SubscribeService::new(state.repos.clone(), state.config.clone()); + match svc.handle_subscribe(&ua, &token, &host, params).await { + Ok(out) => { + let mut resp_headers = HeaderMap::new(); + if let Ok(v) = out.content_type.parse() { + resp_headers.insert(header::CONTENT_TYPE, v); + } + if let Ok(v) = out.userinfo.parse() { + resp_headers.insert("Subscription-Userinfo", v); + } + if let Ok(v) = out.disposition.parse() { + resp_headers.insert(header::CONTENT_DISPOSITION, v); + } + (StatusCode::OK, resp_headers, out.content).into_response() + } + Err(e) => { + tracing::error!("subscribe error: {e:#}"); + (StatusCode::BAD_REQUEST, e.to_string()).into_response() + } + } +} + +/// `GET /v1/subscribe/pan/:token` — pan-domain subscribe variant. +pub async fn pan_domain_subscribe_handler( + State(state): State, + Path(token): Path, + Query(params): Query>, + headers: HeaderMap, +) -> Response { + subscribe_handler(State(state), Path(token), Query(params), headers).await +} diff --git a/src/handler/telegram.rs b/src/handler/telegram.rs new file mode 100644 index 00000000..363cdba0 --- /dev/null +++ b/src/handler/telegram.rs @@ -0,0 +1,28 @@ +//! Telegram webhook handler. + +use axum::{body::Bytes, extract::State, response::{IntoResponse, Response}}; +use axum::http::StatusCode; + +use crate::handler::AppState; +use crate::service::telegram::telegram_service::TelegramService; + +/// `POST /v1/telegram` +pub async fn telegram_handler( + State(state): State, + body: Bytes, +) -> Response { + let svc = TelegramService::new(state.repos.clone(), state.config.clone()); + match svc.handle_update(&body).await { + Ok(Some(msg)) => { + if let Err(e) = svc.send_message(&msg).await { + tracing::warn!("telegram send_message failed: {e:#}"); + } + (StatusCode::OK, "ok").into_response() + } + Ok(None) => (StatusCode::OK, "ok").into_response(), + Err(e) => { + tracing::error!("telegram handler error: {e:#}"); + (StatusCode::OK, "ok").into_response() + } + } +} diff --git a/src/main.rs b/src/main.rs new file mode 100644 index 00000000..8c8dab83 --- /dev/null +++ b/src/main.rs @@ -0,0 +1,198 @@ +use std::sync::Arc; + +use axum::routing::get; +use tracing_subscriber::layer::SubscriberExt as _; +use tracing_subscriber::util::SubscriberInitExt as _; +use tracing_subscriber::EnvFilter; + +pub mod adapter; +pub mod cache; +pub mod config; +pub mod db; +pub mod handler; +pub mod middleware; +pub mod migration; +pub mod model; +pub mod queue; +pub mod repository; +pub mod scheduler; +pub mod service; +pub mod tracing_otel; + +async fn health() -> &'static str { + "ok" +} + +/// Initialise the tracing subscriber from `LogConfig`. +/// +/// Mirrors the Go `LogConf` initialisation logic: +/// - mode "console" (or empty / unrecognised) → stdout +/// - mode "file" → daily-rotating file in `path/` +/// - mode "volume" → daily-rotating file in `path/{service_name}/{hostname}/` +/// encoding "json" uses JSON format; anything else uses the default pretty format. +/// +/// If `otel` is true the OpenTelemetry bridge layer is added so every tracing +/// span is also exported through the global OTel provider (set up by +/// `tracing_otel::init_otel` before this call). +fn init_tracing(cfg: &config::LogConfig, otel: bool) { + let filter = EnvFilter::builder() + .parse_lossy(format!("ppanel_backend={}", cfg.level)); + + match cfg.mode.as_str() { + "file" | "volume" => { + let dir = if cfg.mode == "volume" { + format!("{}/{}/{}", cfg.path, cfg.service_name, hostname()) + } else { + cfg.path.clone() + }; + if let Err(e) = std::fs::create_dir_all(&dir) { + eprintln!("failed to create log directory {dir}: {e}"); + } + let file_appender = tracing_appender::rolling::daily(&dir, "app.log"); + let (non_blocking, guard) = tracing_appender::non_blocking(file_appender); + std::mem::forget(guard); + + if cfg.encoding == "json" { + let sub = tracing_subscriber::registry() + .with(tracing_subscriber::fmt::layer() + .json() + .with_writer(non_blocking) + .with_filter(filter)); + if otel { + sub.with(tracing_opentelemetry::layer()).init(); + } else { + sub.init(); + } + } else { + let sub = tracing_subscriber::registry() + .with(tracing_subscriber::fmt::layer() + .with_writer(non_blocking) + .with_filter(filter)); + if otel { + sub.with(tracing_opentelemetry::layer()).init(); + } else { + sub.init(); + } + } + } + _ => { + // "console" or default + if cfg.encoding == "json" { + let sub = tracing_subscriber::registry() + .with(tracing_subscriber::fmt::layer() + .json() + .with_filter(filter)); + if otel { + sub.with(tracing_opentelemetry::layer()).init(); + } else { + sub.init(); + } + } else { + let sub = tracing_subscriber::registry() + .with(tracing_subscriber::fmt::layer() + .with_filter(filter)); + if otel { + sub.with(tracing_opentelemetry::layer()).init(); + } else { + sub.init(); + } + } + } + } +} + +/// Returns the machine hostname, falling back to `"unknown"`. +fn hostname() -> String { + std::env::var("HOSTNAME") + .or_else(|_| { + // Try reading /etc/hostname on Linux. + std::fs::read_to_string("/etc/hostname").map(|s| s.trim().to_string()) + }) + .unwrap_or_else(|_| "unknown".to_string()) +} + +#[tokio::main] +async fn main() { + // ── Load configuration ────────────────────────────────────────────── + let cfg = Arc::new(config::Config::load()); + + // ── Initialise OpenTelemetry provider (before tracing subscriber) ─── + // Guard must stay alive for the process lifetime to flush pending spans. + let _otel_guard = tracing_otel::init_otel(&cfg.trace); + let has_otel = _otel_guard.is_some(); + + // ── Initialise tracing subscriber from LogConfig ──────────────────── + init_tracing(&cfg.logger, has_otel); + tracing::info!(host = %cfg.host, port = %cfg.port, "configuration loaded"); + + // ── Initialise database ───────────────────────────────────────────── + let db = db::init_pool(cfg.database_config()) + .await + .expect("failed to connect to database"); + tracing::info!("database connected"); + + // ── Run pending migrations ────────────────────────────────────────── + migration::run_migrations(&db) + .await + .expect("database migration failed"); + tracing::info!("database migrations applied"); + + // ── Seed initial admin account if needed ──────────────────────────── + migration::create_admin_user(&db, &cfg.administrator.email, &cfg.administrator.password) + .await + .expect("failed to create admin user"); + + // ── Initialise Redis cache ────────────────────────────────────────── + let cache = cache::Cache::new(&cfg.redis) + .await + .expect("failed to connect to redis"); + let cache = std::sync::Arc::new(cache); + tracing::info!("redis connected"); + + // ── Build queue client ────────────────────────────────────────────── + let queue_client = queue::client::QueueClient::new(&queue::redis_url(&cfg.redis)) + .await + .expect("failed to connect asynq queue client"); + tracing::info!("queue client connected"); + + // ── Build repositories & router ───────────────────────────────────── + let repos = std::sync::Arc::new(repository::Repositories::new(db)); + let queue_repos = Arc::clone(&repos); + let state = handler::AppState { + repos, + config: cfg.clone(), + cache, + queue: queue_client, + }; + let app = handler::routes::register_routes(state).route("/health", get(health)); + + // ── Start background services ─────────────────────────────────────── + let _scheduler = scheduler::Service::start(&cfg) + .await + .expect("failed to start scheduler"); + + let mut consumer = queue::Service::new(&cfg, queue_repos) + .await + .expect("failed to start queue consumer"); + + let addr = format!("{}:{}", cfg.host, cfg.port); + let listener = tokio::net::TcpListener::bind(&addr) + .await + .unwrap_or_else(|e| panic!("failed to bind {addr}: {e}")); + tracing::info!( + "listening on {}", + listener.local_addr().expect("listener bound") + ); + + // Block on HTTP server; on shutdown also stop the consumer. + axum::serve(listener, app) + .with_graceful_shutdown(async move { + consumer.shutdown().await.unwrap_or_else(|e| { + tracing::error!("queue consumer shutdown error: {e}"); + }); + }) + .await + .unwrap_or_else(|e| panic!("server error: {e}")); + + // scheduler stops automatically via Drop when `_scheduler` goes out of scope +} diff --git a/src/middleware/auth_middleware.rs b/src/middleware/auth_middleware.rs new file mode 100644 index 00000000..7c9fe7f7 --- /dev/null +++ b/src/middleware/auth_middleware.rs @@ -0,0 +1,81 @@ +use axum::{ + extract::{Request, State}, + middleware::Next, + response::{IntoResponse, Response}, +}; + +use crate::config::cache_key::SESSION_ID_KEY; +use crate::handler::AppState; +use result::code_error::CodeError; +use result::error_code; +use result::http_result::build_http_result; + +#[derive(Debug, Clone)] +pub struct AuthContext { + pub user_id: i64, + pub login_type: String, + pub session_id: String, + pub is_admin: bool, +} + +pub async fn auth_middleware( + State(state): State, + mut request: Request, + next: Next, +) -> Response { + let auth_header = request + .headers() + .get("Authorization") + .and_then(|v| v.to_str().ok()) + .unwrap_or(""); + + let token = auth_header.strip_prefix("Bearer ").unwrap_or(auth_header); + + if token.is_empty() { + return err_response(error_code::ERROR_TOKEN_EMPTY); + } + + let claims = match jwt::validate_token(token, &state.config.jwt_auth.access_secret) { + Ok(c) => c, + Err(_) => return err_response(error_code::ERROR_TOKEN_INVALID), + }; + + let session_key = format!("{}:{}", SESSION_ID_KEY, claims.session_id); + let cached_user_id: i64 = match state.cache.get(&session_key).await { + Ok(Some(id)) => id.parse().unwrap_or(0), + _ => return err_response(error_code::INVALID_ACCESS), + }; + + if cached_user_id != claims.user_id { + return err_response(error_code::INVALID_ACCESS); + } + + let user = match state.repos.user.find_one_user(claims.user_id).await { + Ok(u) => u, + Err(_) => return err_response(error_code::USER_NOT_EXIST), + }; + + if !user.enable { + return err_response(error_code::USER_DISABLED); + } + + let path = request.uri().path(); + if path.contains("/admin") && !user.is_admin { + return err_response(error_code::INVALID_ACCESS); + } + + let auth_ctx = AuthContext { + user_id: claims.user_id, + login_type: claims.login_type, + session_id: claims.session_id, + is_admin: user.is_admin, + }; + request.extensions_mut().insert(auth_ctx); + + next.run(request).await +} + +fn err_response(code: u32) -> Response { + build_http_result::<()>(None, Some(anyhow::Error::new(CodeError::new_err_code(code)))) + .into_response() +} diff --git a/src/middleware/cors_middleware.rs b/src/middleware/cors_middleware.rs new file mode 100644 index 00000000..e60ba124 --- /dev/null +++ b/src/middleware/cors_middleware.rs @@ -0,0 +1,55 @@ +//! CORS middleware — port of `corsMiddleware.go`. +use axum::{ + extract::Request, + http::{header, Method, StatusCode}, + middleware::Next, + response::{IntoResponse, Response}, +}; + +pub async fn cors_middleware(req: Request, next: Next) -> Response { + let origin = req + .headers() + .get(header::ORIGIN) + .and_then(|v| v.to_str().ok()) + .map(|s| s.to_string()); + + if req.method() == Method::OPTIONS { + let mut resp = StatusCode::NO_CONTENT.into_response(); + add_cors_headers(resp.headers_mut(), origin.as_deref()); + return resp; + } + + let mut resp = next.run(req).await; + add_cors_headers(resp.headers_mut(), origin.as_deref()); + resp +} + +fn add_cors_headers(headers: &mut axum::http::HeaderMap, origin: Option<&str>) { + let origin_val = origin.unwrap_or("*"); + let _ = headers.insert( + header::ACCESS_CONTROL_ALLOW_ORIGIN, + origin_val.parse().unwrap_or(header::HeaderValue::from_static("*")), + ); + let _ = headers.insert( + header::ACCESS_CONTROL_ALLOW_METHODS, + "POST, GET, OPTIONS, PUT, DELETE, UPDATE".parse().unwrap(), + ); + let _ = headers.insert( + header::ACCESS_CONTROL_ALLOW_HEADERS, + "Content-Type, Origin, X-CSRF-Token, Authorization, AccessToken, Token, Range" + .parse().unwrap(), + ); + let _ = headers.insert( + header::ACCESS_CONTROL_EXPOSE_HEADERS, + "Content-Length, Access-Control-Allow-Origin, Access-Control-Allow-Headers" + .parse().unwrap(), + ); + let _ = headers.insert( + header::ACCESS_CONTROL_ALLOW_CREDENTIALS, + "true".parse().unwrap(), + ); + let _ = headers.insert( + header::ACCESS_CONTROL_MAX_AGE, + "172800".parse().unwrap(), + ); +} diff --git a/src/middleware/device_middleware.rs b/src/middleware/device_middleware.rs new file mode 100644 index 00000000..88a2aa88 --- /dev/null +++ b/src/middleware/device_middleware.rs @@ -0,0 +1,59 @@ +//! Device context middleware. +//! +//! Extracts per-request metadata from HTTP headers and injects a +//! [`DeviceContext`] into the request extensions. Every downstream handler +//! that needs the client IP, User-Agent, device identifier, or login type can +//! extract it with `Extension`. +//! +//! This middleware NEVER rejects a request — it only enriches it. +//! The Go equivalent is the device_middleware that sets these values from +//! request headers before passing to the handler. + +use axum::{extract::Request, middleware::Next, response::Response}; + +/// Per-request device / client metadata, populated from HTTP headers. +#[derive(Debug, Clone, Default)] +pub struct DeviceContext { + /// Client device identifier (e.g. fingerprint / push token). + pub identifier: String, + /// Client IP address (from X-Original-Forwarded-For or X-Forwarded-For). + pub ip: String, + /// Raw User-Agent header value. + pub user_agent: String, + /// Login type hint supplied by the client (e.g. "email", "device"). + pub login_type: String, +} + +pub async fn device_middleware(mut request: Request, next: Next) -> Response { + let headers = request.headers(); + + let ip = headers + .get("X-Original-Forwarded-For") + .or_else(|| headers.get("X-Forwarded-For")) + .and_then(|v| v.to_str().ok()) + .unwrap_or("") + .to_string(); + + let user_agent = headers + .get("User-Agent") + .and_then(|v| v.to_str().ok()) + .unwrap_or("") + .to_string(); + + let login_type = headers + .get("Login-Type") + .and_then(|v| v.to_str().ok()) + .unwrap_or("") + .to_string(); + + let identifier = headers + .get("Identifier") + .and_then(|v| v.to_str().ok()) + .unwrap_or("") + .to_string(); + + let ctx = DeviceContext { identifier, ip, user_agent, login_type }; + request.extensions_mut().insert(ctx); + + next.run(request).await +} diff --git a/src/middleware/logger_middleware.rs b/src/middleware/logger_middleware.rs new file mode 100644 index 00000000..2c3ef67c --- /dev/null +++ b/src/middleware/logger_middleware.rs @@ -0,0 +1,73 @@ +//! Request logging middleware based on `tower-http::TraceLayer`. +//! +//! Replaces Go's `loggerMiddleware.go`. Every inbound HTTP request gets a +//! tracing span with method / path / query; the response is logged at a level +//! that matches the Go convention: +//! +//! | Status range | tracing level | +//! |-------------|---------------| +//! | 500–599 | `error!` | +//! | 404 | `debug!` | +//! | everything | `info!` | +//! +//! Registered fields: `method`, `path`, `query`, `status`, `duration_ms`. +//! 5xx responses additionally log an `error` field if one is stored in the +//! response extensions. +use std::time::Duration; + +use axum::body::Body; +use axum::http::{Request, Response}; +use tower_http::trace::{MakeSpan, OnResponse}; +use tracing::Span; + +// ─── span factory ─────────────────────────────────────────────────────────── + +/// Creates the per-request tracing span. +#[derive(Clone, Debug)] +pub struct RequestSpan; + +impl MakeSpan for RequestSpan { + fn make_span(&mut self, req: &Request) -> Span { + let method = req.method().as_str(); + let path = req.uri().path(); + let query = req.uri().query().unwrap_or(""); + tracing::info_span!( + "request", + method = %method, + path = %path, + query = %query, + status = tracing::field::Empty, + duration_ms = tracing::field::Empty, + ) + } +} + +// ─── response hook ────────────────────────────────────────────────────────── + +/// Logs the response once it is ready. +#[derive(Clone, Debug)] +pub struct RequestLog; + +impl OnResponse for RequestLog { + fn on_response(self, resp: &Response, latency: Duration, span: &Span) { + let status = resp.status().as_u16(); + let duration_ms = latency.as_secs_f64() * 1_000.0; + + span.record("status", status); + span.record("duration_ms", duration_ms); + + if status >= 500 { + // Attach error detail if the handler stored one in extensions. + let err_msg = resp + .extensions() + .get::() + .map(|s| s.as_str()) + .unwrap_or("internal server error"); + tracing::error!(parent: span, error = %err_msg, "request failed"); + } else if status == 404 { + tracing::debug!(parent: span, "not found"); + } else { + tracing::info!(parent: span, "request completed"); + } + } +} diff --git a/src/middleware/mod.rs b/src/middleware/mod.rs new file mode 100644 index 00000000..3f73c73e --- /dev/null +++ b/src/middleware/mod.rs @@ -0,0 +1,8 @@ +pub mod auth_middleware; +pub mod cors_middleware; +pub mod device_middleware; +pub mod logger_middleware; +pub mod notify_middleware; +pub mod pan_domain_middleware; +pub mod server_middleware; +pub mod trace_middleware; diff --git a/src/middleware/notify_middleware.rs b/src/middleware/notify_middleware.rs new file mode 100644 index 00000000..d1d51f72 --- /dev/null +++ b/src/middleware/notify_middleware.rs @@ -0,0 +1,43 @@ +//! Payment notify middleware — port of `notifyMiddleware.go`. +//! +//! Extracts `platform` and `token` from the URI path, looks up the payment +//! config, and injects it into request extensions. + +use std::sync::Arc; +use axum::{ + extract::{Path, Request, State}, + middleware::Next, + response::{IntoResponse, Response}, +}; +use result::http_result::build_param_error_result; +use crate::handler::AppState; +use crate::model::entity::payment::Payment; + +/// Extension injected by this middleware for downstream handlers. +#[derive(Debug, Clone)] +pub struct PaymentContext { + pub platform: String, + pub payment: Payment, +} + +pub async fn notify_middleware( + State(state): State, + Path(token): Path, + mut req: Request, + next: Next, +) -> Response { + match state.repos.payment.find_one_by_token(&token).await { + Ok(payment) => { + let ctx = PaymentContext { + platform: payment.platform.clone(), + payment, + }; + req.extensions_mut().insert(Arc::new(ctx)); + next.run(req).await + } + Err(e) => { + let err = anyhow::anyhow!("payment token invalid: {e}"); + build_param_error_result(err.as_ref()).into_response() + } + } +} diff --git a/src/middleware/pan_domain_middleware.rs b/src/middleware/pan_domain_middleware.rs new file mode 100644 index 00000000..061f3826 --- /dev/null +++ b/src/middleware/pan_domain_middleware.rs @@ -0,0 +1,111 @@ +use axum::{ + extract::{Request, State}, + http::StatusCode, + middleware::Next, + response::{IntoResponse, Response}, +}; +use crate::handler::AppState; + +pub async fn pan_domain_middleware( + State(state): State, + req: Request, + next: Next, +) -> Response { + // Only intercept when pan_domain is enabled and path is "/" + if !state.config.subscribe.pan_domain || req.uri().path() != "/" { + return next.run(req).await; + } + + let host = req + .headers() + .get("host") + .and_then(|v| v.to_str().ok()) + .unwrap_or("") + .to_string(); + + let ua = req + .headers() + .get("user-agent") + .and_then(|v| v.to_str().ok()) + .unwrap_or("") + .to_string(); + + // Extract first subdomain label as token + let parts: Vec<&str> = host.split('.').collect(); + if parts.len() < 2 { + return (StatusCode::FORBIDDEN, "Access denied").into_response(); + } + let token = parts[0].to_string(); + + // User-agent limit check — mirrors IsUserAgentAllowed in userAgent.go + if state.config.subscribe.user_agent_limit { + let allowed = is_user_agent_allowed(&state, &ua).await; + if !allowed { + tracing::debug!("pan_domain: UA blocked by allowlist, UA={ua}"); + return (StatusCode::FORBIDDEN, "Access denied").into_response(); + } + } + + // Delegate to subscribe service + let svc = crate::service::subscribe::subscribe_service::SubscribeService::new( + state.repos.clone(), + state.config.clone(), + ); + match svc.handle_subscribe(&ua, &token, &host, std::collections::HashMap::new()).await { + Ok(result) => { + use axum::response::Response as AxumResponse; + use axum::http::header; + let mut builder = AxumResponse::builder() + .status(StatusCode::OK) + .header("subscription-userinfo", &result.userinfo); + builder = builder.header(header::CONTENT_TYPE, &result.content_type); + if !result.disposition.is_empty() { + builder = builder.header("content-disposition", &result.disposition); + } + builder + .body(axum::body::Body::from(result.content)) + .unwrap_or_else(|_| (StatusCode::INTERNAL_SERVER_ERROR, "error").into_response()) + } + Err(e) => { + tracing::error!("pan_domain subscribe error: {e}"); + (StatusCode::INTERNAL_SERVER_ERROR, "Internal Server Error").into_response() + } + } +} + +async fn is_user_agent_allowed(state: &AppState, ua: &str) -> bool { + if ua.is_empty() { + return false; + } + + let ua_lower = ua.to_lowercase(); + + let mut keywords: Vec = state + .config + .subscribe + .user_agent_list + .split('\n') + .map(|s| s.trim().to_lowercase()) + .filter(|s| !s.is_empty()) + .collect(); + + match state.repos.client.list().await { + Ok(clients) => { + for c in &clients { + let needle = c.user_agent.trim().to_lowercase(); + if !needle.is_empty() { + keywords.push(needle); + } + } + } + Err(e) => { + tracing::error!("pan_domain: failed to load client list: {e}"); + } + } + + if keywords.is_empty() { + return true; + } + + keywords.iter().any(|k| ua_lower.contains(k.as_str())) +} diff --git a/src/middleware/server_middleware.rs b/src/middleware/server_middleware.rs new file mode 100644 index 00000000..94359908 --- /dev/null +++ b/src/middleware/server_middleware.rs @@ -0,0 +1,34 @@ +//! Server auth middleware — port of `serverMiddleware.go`. +//! +//! Validates the `secret_key` query param against `config.node.node_secret`. + +use axum::{ + extract::{Query, Request, State}, + http::StatusCode, + middleware::Next, + response::{IntoResponse, Response}, +}; +use serde::Deserialize; +use crate::handler::AppState; + +#[derive(Deserialize)] +struct SecretKeyQuery { + secret_key: Option, +} + +pub async fn server_middleware( + State(state): State, + req: Request, + next: Next, +) -> Response { + let secret = req + .uri() + .query() + .and_then(|q| serde_urlencoded::from_str::(q).ok()) + .and_then(|q| q.secret_key); + + match secret { + Some(key) if key == state.config.node.node_secret => next.run(req).await, + _ => (StatusCode::FORBIDDEN, "Forbidden").into_response(), + } +} diff --git a/src/middleware/trace_middleware.rs b/src/middleware/trace_middleware.rs new file mode 100644 index 00000000..cef64be2 --- /dev/null +++ b/src/middleware/trace_middleware.rs @@ -0,0 +1,12 @@ +//! Request tracing middleware — port of `traceMiddleware.go`. +//! Uses tracing spans (no OpenTelemetry dependency added). + +use axum::{extract::Request, middleware::Next, response::Response}; +use tracing::Instrument; + +pub async fn trace_middleware(req: Request, next: Next) -> Response { + let method = req.method().clone(); + let path = req.uri().path().to_string(); + let span = tracing::info_span!("http_request", method = %method, path = %path); + next.run(req).instrument(span).await +} diff --git a/src/migration.rs b/src/migration.rs new file mode 100644 index 00000000..dd7dc64c --- /dev/null +++ b/src/migration.rs @@ -0,0 +1,194 @@ +//! Database migration runner and bootstrap utilities. +//! +//! Embeds both MySQL and PostgreSQL migration files at compile time via +//! `sqlx::migrate!` and selects the correct set at runtime based on the +//! [`Dialect`] detected from configuration. +//! +//! **NOTE**: This is the Rust rewrite of the Go backend. The Go version is +//! deprecated and will be replaced by this Rust implementation. + +mod mysql_migrations { + #![allow(unused)] + pub(super) const MIGRATOR: sqlx::migrate::Migrator = sqlx::migrate!("migrations/mysql"); +} + +mod postgres_migrations { + #![allow(unused)] + pub(super) const MIGRATOR: sqlx::migrate::Migrator = sqlx::migrate!("migrations/postgres"); +} + +pub use crate::repository::Dialect; + +use crate::repository::Db; + +/// Run all pending migrations for the detected database dialect. +pub async fn run_migrations(db: &Db) -> Result<(), sqlx::migrate::MigrateError> { + match db { + Db::Postgres(pool) => postgres_migrations::MIGRATOR.run(pool).await, + Db::Mysql(pool) => mysql_migrations::MIGRATOR.run(pool).await, + } +} + +// ═══════════════════════════════════════════════════════════════════════════ +// Bootstrap: initial admin account +// ═══════════════════════════════════════════════════════════════════════════ + +pub async fn create_admin_user( + db: &Db, + email: &str, + password: &str, +) -> Result<(), sqlx::Error> { + match db { + Db::Postgres(pool) => create_admin_user_pg(pool, email, password).await, + Db::Mysql(pool) => create_admin_user_mysql(pool, email, password).await, + } +} + +async fn create_admin_user_pg( + pool: &sqlx::PgPool, + email: &str, + password: &str, +) -> Result<(), sqlx::Error> { + let exists: bool = + sqlx::query_scalar(r#"SELECT EXISTS(SELECT 1 FROM "user")"#) + .fetch_one(pool) + .await?; + + if exists { + tracing::info!("User already exists, skip creating administrator account"); + return Ok(()); + } + + let now = chrono::Utc::now().timestamp_millis(); + let password_hash = hash_password_pbkdf2(password); + let refer_code = generate_invite_code(); + + sqlx::query( + r#"INSERT INTO "user" (password, algo, is_admin, refer_code, balance, commission, + gift_amount, enable, enable_balance_notify, + enable_login_notify, enable_subscribe_notify, + enable_trade_notify, created_at, updated_at) + VALUES ($1, 'default', 1, $2, 0, 0, 0, 1, 1, 1, 1, 1, $3, $4)"#, + ) + .bind(&password_hash) + .bind(&refer_code) + .bind(now) + .bind(now) + .execute(pool) + .await + .map_err(|e| { + tracing::error!("Failed to create admin user: {e}"); + e + })?; + + let user_id: i64 = + sqlx::query_scalar(r#"SELECT id FROM "user" WHERE refer_code = $1"#) + .bind(&refer_code) + .fetch_one(pool) + .await + .map_err(|e| { + tracing::error!("Failed to fetch admin user id: {e}"); + e + })?; + + sqlx::query( + r#"INSERT INTO user_auth_methods (user_id, auth_type, auth_identifier, verified, created_at, updated_at) + VALUES ($1, 'email', $2, 1, $3, $4)"#, + ) + .bind(user_id) + .bind(email) + .bind(now) + .bind(now) + .execute(pool) + .await + .map_err(|e| { + tracing::error!("Failed to create admin auth method: {e}"); + e + })?; + + tracing::info!("Administrator account created: {email}"); + Ok(()) +} + +async fn create_admin_user_mysql( + pool: &sqlx::MySqlPool, + email: &str, + password: &str, +) -> Result<(), sqlx::Error> { + let exists: bool = + sqlx::query_scalar(r#"SELECT EXISTS(SELECT 1 FROM `user`)"#) + .fetch_one(pool) + .await?; + + if exists { + tracing::info!("User already exists, skip creating administrator account"); + return Ok(()); + } + + let now = chrono::Utc::now().timestamp_millis(); + let password_hash = hash_password_pbkdf2(password); + let refer_code = generate_invite_code(); + + sqlx::query( + r#"INSERT INTO `user` (password, algo, is_admin, refer_code, balance, commission, + gift_amount, enable, enable_balance_notify, + enable_login_notify, enable_subscribe_notify, + enable_trade_notify, created_at, updated_at) + VALUES (?, 'default', 1, ?, 0, 0, 0, 1, 1, 1, 1, 1, ?, ?)"#, + ) + .bind(&password_hash) + .bind(&refer_code) + .bind(now) + .bind(now) + .execute(pool) + .await + .map_err(|e| { + tracing::error!("Failed to create admin user: {e}"); + e + })?; + + let user_id: i64 = + sqlx::query_scalar(r#"SELECT id FROM `user` WHERE refer_code = ?"#) + .bind(&refer_code) + .fetch_one(pool) + .await + .map_err(|e| { + tracing::error!("Failed to fetch admin user id: {e}"); + e + })?; + + sqlx::query( + r#"INSERT INTO user_auth_methods (user_id, auth_type, auth_identifier, verified, created_at, updated_at) + VALUES (?, 'email', ?, 1, ?, ?)"#, + ) + .bind(user_id) + .bind(email) + .bind(now) + .bind(now) + .execute(pool) + .await + .map_err(|e| { + tracing::error!("Failed to create admin auth method: {e}"); + e + })?; + + tracing::info!("Administrator account created: {email}"); + Ok(()) +} + +/// PBKDF2-SHA512 matching Go's format: `$pbkdf2-sha512${salt_hex}${hash_hex}` +/// +/// **Iteration count is intentionally 100** to stay byte-compatible with the +/// Go original. See `AGENTS.md` for rationale. +fn hash_password_pbkdf2(password: &str) -> String { + password::encode_password(password).expect("Failed to encode password") +} + +fn generate_invite_code() -> String { + format!("u{}", &uuid::Uuid::new_v4().to_string().replace('-', "")[..12]) +} + +// ═══════════════════════════════════════════════════════════════════════════ +// Bootstrap: initial admin account +// ═══════════════════════════════════════════════════════════════════════════ + diff --git a/src/model/dto/ads.rs b/src/model/dto/ads.rs new file mode 100644 index 00000000..8640a4a1 --- /dev/null +++ b/src/model/dto/ads.rs @@ -0,0 +1,102 @@ +//! API DTO types — ads domain. +//! Auto-generated from the monolithic `dto.rs`. + +use serde::{Deserialize, Serialize}; +use serde_json::Value; + + +// ─── Ads ──────────────────────────────────────────────────────────────────── + +#[derive(Debug, Clone, Serialize, Deserialize)] +pub struct Ads { + pub id: i32, + pub title: String, + #[serde(rename = "type")] + pub type_: String, + pub content: String, + pub description: String, + pub target_url: String, + pub start_time: i64, + pub end_time: i64, + pub status: i32, + pub created_at: i64, + pub updated_at: i64, +} + + +// ─── Create Requests ──────────────────────────────────────────────────────── + +#[derive(Debug, Clone, Serialize, Deserialize)] +pub struct CreateAdsRequest { + pub title: String, + #[serde(rename = "type")] + pub type_: String, + pub content: String, + pub description: String, + pub target_url: String, + pub start_time: i64, + pub end_time: i64, + pub status: i32, +} + + +// ─── Delete Requests ──────────────────────────────────────────────────────── + +#[derive(Debug, Clone, Serialize, Deserialize)] +pub struct DeleteAdsRequest { + pub id: i64, +} + + +// ─── Get Requests ─────────────────────────────────────────────────────────── + +#[derive(Debug, Clone, Serialize, Deserialize)] +pub struct GetAdsDetailRequest { + pub id: i64, +} + + +#[derive(Debug, Clone, Serialize, Deserialize)] +pub struct GetAdsListRequest { + pub page: i32, + pub size: i32, + #[serde(default, skip_serializing_if = "Option::is_none")] + pub status: Option, + #[serde(default, skip_serializing_if = "Option::is_none")] + pub search: Option, +} + + +#[derive(Debug, Clone, Serialize, Deserialize)] +pub struct GetAdsListResponse { + pub total: i64, + pub list: Vec, +} + + +#[derive(Debug, Clone, Serialize, Deserialize)] +pub struct GetAdsRequest { + pub device: String, + pub position: String, +} + + +#[derive(Debug, Clone, Serialize, Deserialize)] +pub struct GetAdsResponse { + pub list: Vec, +} + + +#[derive(Debug, Clone, Serialize, Deserialize)] +pub struct UpdateAdsRequest { + pub id: i64, + pub title: String, + #[serde(rename = "type")] + pub type_: String, + pub content: String, + pub description: String, + pub target_url: String, + pub start_time: i64, + pub end_time: i64, + pub status: i32, +} diff --git a/src/model/dto/announcement.rs b/src/model/dto/announcement.rs new file mode 100644 index 00000000..ca7b8be4 --- /dev/null +++ b/src/model/dto/announcement.rs @@ -0,0 +1,103 @@ +//! API DTO types — announcement domain. +//! Auto-generated from the monolithic `dto.rs`. + +use serde::{Deserialize, Serialize}; +use serde_json::Value; + + +#[derive(Debug, Clone, Serialize, Deserialize)] +pub struct Announcement { + pub id: i64, + pub title: String, + pub content: String, + #[serde(default, skip_serializing_if = "Option::is_none")] + pub show: Option, + #[serde(default, skip_serializing_if = "Option::is_none")] + pub pinned: Option, + #[serde(default, skip_serializing_if = "Option::is_none")] + pub popup: Option, + pub created_at: i64, + pub updated_at: i64, +} + + +#[derive(Debug, Clone, Serialize, Deserialize)] +pub struct CreateAnnouncementRequest { + pub title: String, + pub content: String, +} + + +#[derive(Debug, Clone, Serialize, Deserialize)] +pub struct DeleteAnnouncementRequest { + pub id: i64, +} + + +#[derive(Debug, Clone, Serialize, Deserialize)] +pub struct GetAnnouncementListRequest { + pub page: i64, + pub size: i64, + #[serde(default, skip_serializing_if = "Option::is_none")] + pub show: Option, + #[serde(default, skip_serializing_if = "Option::is_none")] + pub pinned: Option, + #[serde(default, skip_serializing_if = "Option::is_none")] + pub popup: Option, + #[serde(default, skip_serializing_if = "Option::is_none")] + pub search: Option, +} + + +#[derive(Debug, Clone, Serialize, Deserialize)] +pub struct GetAnnouncementListResponse { + pub total: i64, + pub list: Vec, +} + + +#[derive(Debug, Clone, Serialize, Deserialize)] +pub struct GetAnnouncementRequest { + pub id: i64, +} + + +// ─── Query Requests ───────────────────────────────────────────────────────── + +#[derive(Debug, Clone, Serialize, Deserialize)] +pub struct QueryAnnouncementRequest { + pub page: i32, + pub size: i32, + #[serde(default, skip_serializing_if = "Option::is_none")] + pub pinned: Option, + #[serde(default, skip_serializing_if = "Option::is_none")] + pub popup: Option, +} + + +#[derive(Debug, Clone, Serialize, Deserialize)] +pub struct QueryAnnouncementResponse { + pub total: i64, + pub announcements: Vec, +} + + +#[derive(Debug, Clone, Serialize, Deserialize)] +pub struct UpdateAnnouncementEnableRequest { + pub id: i64, + pub enable: Option, +} + + +#[derive(Debug, Clone, Serialize, Deserialize)] +pub struct UpdateAnnouncementRequest { + pub id: i64, + pub title: String, + pub content: String, + #[serde(default, skip_serializing_if = "Option::is_none")] + pub show: Option, + #[serde(default, skip_serializing_if = "Option::is_none")] + pub pinned: Option, + #[serde(default, skip_serializing_if = "Option::is_none")] + pub popup: Option, +} diff --git a/src/model/dto/application.rs b/src/model/dto/application.rs new file mode 100644 index 00000000..29c9b8ee --- /dev/null +++ b/src/model/dto/application.rs @@ -0,0 +1,130 @@ +//! API DTO types — application domain. +//! Auto-generated from the monolithic `dto.rs`. + +use serde::{Deserialize, Serialize}; +use serde_json::Value; + +use super::document::DownloadLink; +use super::subscribe::SubscribeApplication; + + +#[derive(Debug, Clone, Serialize, Deserialize)] +pub struct Application { + pub id: i64, + pub icon: String, + pub name: String, + pub description: String, + pub subscribe_type: String, +} + + +#[derive(Debug, Clone, Serialize, Deserialize)] +pub struct ApplicationPlatform { + #[serde(default, skip_serializing_if = "Option::is_none")] + pub ios: Option>, + #[serde(default, skip_serializing_if = "Option::is_none")] + pub macos: Option>, + #[serde(default, skip_serializing_if = "Option::is_none")] + pub linux: Option>, + #[serde(default, skip_serializing_if = "Option::is_none")] + pub android: Option>, + #[serde(default, skip_serializing_if = "Option::is_none")] + pub windows: Option>, + #[serde(default, skip_serializing_if = "Option::is_none")] + pub harmony: Option>, +} + + +#[derive(Debug, Clone, Serialize, Deserialize)] +pub struct ApplicationResponse { + pub applications: Vec, +} + + +#[derive(Debug, Clone, Serialize, Deserialize)] +pub struct ApplicationResponseInfo { + pub id: i64, + pub name: String, + pub icon: String, + pub description: String, + pub subscribe_type: String, + pub platform: ApplicationPlatform, +} + + +#[derive(Debug, Clone, Serialize, Deserialize)] +pub struct ApplicationVersion { + pub id: i64, + pub url: String, + pub version: String, + pub description: String, + pub is_default: bool, +} + + +#[derive(Debug, Clone, Serialize, Deserialize)] +pub struct CreateSubscribeApplicationRequest { + pub name: String, + #[serde(default, skip_serializing_if = "Option::is_none")] + pub description: Option, + #[serde(default, skip_serializing_if = "Option::is_none")] + pub icon: Option, + #[serde(default, skip_serializing_if = "Option::is_none")] + pub scheme: Option, + pub user_agent: String, + pub is_default: bool, + pub template: String, + pub output_format: String, + pub download_link: DownloadLink, +} + + +#[derive(Debug, Clone, Serialize, Deserialize)] +pub struct DeleteSubscribeApplicationRequest { + pub id: i64, +} + + +#[derive(Debug, Clone, Serialize, Deserialize)] +pub struct GetSubscribeApplicationListRequest { + pub page: i32, + pub size: i32, +} + + +#[derive(Debug, Clone, Serialize, Deserialize)] +pub struct GetSubscribeApplicationListResponse { + pub total: i64, + pub list: Vec, +} + + +#[derive(Debug, Clone, Serialize, Deserialize)] +pub struct PreviewSubscribeTemplateRequest { + pub id: i64, +} + + +#[derive(Debug, Clone, Serialize, Deserialize)] +pub struct PreviewSubscribeTemplateResponse { + pub template: String, +} + + +#[derive(Debug, Clone, Serialize, Deserialize)] +pub struct UpdateSubscribeApplicationRequest { + pub id: i64, + pub name: String, + #[serde(default, skip_serializing_if = "Option::is_none")] + pub description: Option, + #[serde(default, skip_serializing_if = "Option::is_none")] + pub icon: Option, + #[serde(default, skip_serializing_if = "Option::is_none")] + pub scheme: Option, + pub user_agent: String, + pub is_default: bool, + pub template: String, + pub output_format: String, + #[serde(default, skip_serializing_if = "Option::is_none")] + pub download_link: Option, +} diff --git a/src/model/dto/auth.rs b/src/model/dto/auth.rs new file mode 100644 index 00000000..3638c222 --- /dev/null +++ b/src/model/dto/auth.rs @@ -0,0 +1,371 @@ +//! API DTO types — auth domain. +//! Auto-generated from the monolithic `dto.rs`. + +use serde::{Deserialize, Serialize}; +use serde_json::Value; + +use super::system::PubilcRegisterConfig; + + +#[derive(Debug, Clone, Serialize, Deserialize)] +pub struct AppleLoginCallbackRequest { + pub code: String, + pub id_token: String, + pub state: String, +} + + +// ─── Auth ─────────────────────────────────────────────────────────────────── + +#[derive(Debug, Clone, Serialize, Deserialize)] +pub struct AuthConfig { + pub mobile: MobileAuthenticateConfig, + pub email: EmailAuthticateConfig, + pub device: DeviceAuthticateConfig, + pub register: PubilcRegisterConfig, +} + + +#[derive(Debug, Clone, Serialize, Deserialize)] +pub struct AuthMethodConfig { + pub id: i64, + pub method: String, + pub config: Value, + pub enabled: bool, +} + + +#[derive(Debug, Clone, Serialize, Deserialize)] +pub struct BindOAuthCallbackRequest { + pub method: String, + pub callback: Value, +} + + +#[derive(Debug, Clone, Serialize, Deserialize)] +pub struct BindOAuthRequest { + pub method: String, + pub redirect: String, +} + + +#[derive(Debug, Clone, Serialize, Deserialize)] +pub struct BindOAuthResponse { + pub redirect: String, +} + + +#[derive(Debug, Clone, Serialize, Deserialize)] +pub struct BindTelegramResponse { + pub url: String, + pub expired_at: i64, +} + + +// ─── Check ────────────────────────────────────────────────────────────────── + +#[derive(Debug, Clone, Serialize, Deserialize)] +pub struct CheckUserRequest { + pub email: String, +} + + +#[derive(Debug, Clone, Serialize, Deserialize)] +pub struct CheckUserResponse { + pub exist: bool, +} + + +#[derive(Debug, Clone, Serialize, Deserialize)] +pub struct CheckVerificationCodeRequest { + pub method: String, + pub account: String, + pub code: String, + #[serde(rename = "type")] + pub type_: u8, +} + + +#[derive(Debug, Clone, Serialize, Deserialize)] +pub struct CheckVerificationCodeRespone { + pub status: bool, +} + + +#[derive(Debug, Clone, Serialize, Deserialize)] +pub struct DeviceAuthticateConfig { + pub enable: bool, + pub show_ads: bool, + pub enable_security: bool, + pub only_real_device: bool, +} + + +#[derive(Debug, Clone, Serialize, Deserialize)] +pub struct DeviceLoginRequest { + pub identifier: String, + pub user_agent: String, + pub cf_token: String, + #[serde(rename = "X-Original-Forwarded-For")] + pub ip: String, +} + + +#[derive(Debug, Clone, Serialize, Deserialize)] +pub struct EmailAuthticateConfig { + pub enable: bool, + pub enable_verify: bool, + pub enable_domain_suffix: bool, + pub domain_suffix_list: String, +} + + +#[derive(Debug, Clone, Serialize, Deserialize)] +pub struct GetAuthMethodConfigRequest { + pub method: String, +} + + +#[derive(Debug, Clone, Serialize, Deserialize)] +pub struct GetAuthMethodListResponse { + pub list: Vec, +} + + +#[derive(Debug, Clone, Serialize, Deserialize)] +pub struct GoogleLoginCallbackRequest { + pub code: String, + pub state: String, +} + + +#[derive(Debug, Clone, Serialize, Deserialize)] +pub struct LoginLog { + pub user_id: i64, + pub method: String, + pub login_ip: String, + pub user_agent: String, + pub success: bool, + pub timestamp: i64, +} + + +#[derive(Debug, Clone, Serialize, Deserialize)] +pub struct LoginResponse { + pub token: String, +} + + +#[derive(Debug, Clone, Serialize, Deserialize)] +pub struct MobileAuthenticateConfig { + pub enable: bool, + pub enable_whitelist: bool, + pub whitelist: Vec, +} + + +// ─── OAuth / Online ───────────────────────────────────────────────────────── + +#[derive(Debug, Clone, Serialize, Deserialize)] +pub struct OAthLoginRequest { + pub method: String, + pub redirect: String, +} + + +#[derive(Debug, Clone, Serialize, Deserialize)] +pub struct OAuthLoginGetTokenRequest { + pub method: String, + pub callback: Value, +} + + +#[derive(Debug, Clone, Serialize, Deserialize)] +pub struct OAuthLoginResponse { + pub redirect: String, +} + + +#[derive(Debug, Clone, Serialize, Deserialize)] +pub struct ResetPasswordRequest { + pub identifier: String, + pub email: String, + pub password: String, + pub code: String, + #[serde(rename = "X-Original-Forwarded-For")] + pub ip: String, + #[serde(rename = "User-Agent")] + pub user_agent: String, + #[serde(rename = "Login-Type")] + pub login_type: String, + pub cf_token: String, +} + + +#[derive(Debug, Clone, Serialize, Deserialize)] +pub struct SendCodeRequest { + pub email: String, + #[serde(rename = "type")] + pub type_: u8, +} + + +#[derive(Debug, Clone, Serialize, Deserialize)] +pub struct SendCodeResponse { + #[serde(default, skip_serializing_if = "Option::is_none")] + pub code: Option, + pub status: bool, +} + + +#[derive(Debug, Clone, Serialize, Deserialize)] +pub struct SendSmsCodeRequest { + #[serde(rename = "type")] + pub type_: u8, + pub telephone: String, + pub telephone_area_code: String, +} + + +#[derive(Debug, Clone, Serialize, Deserialize)] +pub struct TelephoneCheckUserRequest { + pub telephone: String, + pub telephone_area_code: String, +} + + +#[derive(Debug, Clone, Serialize, Deserialize)] +pub struct TelephoneCheckUserResponse { + pub exist: bool, +} + + +#[derive(Debug, Clone, Serialize, Deserialize)] +pub struct TelephoneLoginRequest { + pub identifier: String, + pub telephone: String, + pub telephone_code: String, + pub telephone_area_code: String, + pub password: String, + #[serde(rename = "X-Original-Forwarded-For")] + pub ip: String, + #[serde(rename = "User-Agent")] + pub user_agent: String, + #[serde(rename = "Login-Type")] + pub login_type: String, + pub cf_token: String, +} + + +#[derive(Debug, Clone, Serialize, Deserialize)] +pub struct TelephoneRegisterRequest { + pub identifier: String, + pub telephone: String, + pub telephone_area_code: String, + pub password: String, + pub invite: String, + pub code: String, + #[serde(rename = "X-Original-Forwarded-For")] + pub ip: String, + #[serde(rename = "User-Agent")] + pub user_agent: String, + #[serde(rename = "Login-Type")] + pub login_type: String, + pub cf_token: String, +} + + +#[derive(Debug, Clone, Serialize, Deserialize)] +pub struct TelephoneResetPasswordRequest { + pub identifier: String, + pub telephone: String, + pub telephone_area_code: String, + pub password: String, + pub code: String, + #[serde(rename = "X-Original-Forwarded-For")] + pub ip: String, + #[serde(rename = "User-Agent")] + pub user_agent: String, + #[serde(rename = "Login-Type")] + pub login_type: String, + pub cf_token: String, +} + + +#[derive(Debug, Clone, Serialize, Deserialize)] +pub struct TestEmailSendRequest { + pub email: String, +} + + +#[derive(Debug, Clone, Serialize, Deserialize)] +pub struct TestSmsSendRequest { + pub area_code: String, + pub telephone: String, +} + + +#[derive(Debug, Clone, Serialize, Deserialize)] +pub struct UnbindOAuthRequest { + pub method: String, +} + + +#[derive(Debug, Clone, Serialize, Deserialize)] +pub struct UpdateAuthMethodConfigRequest { + pub id: i64, + pub method: String, + pub config: Value, + #[serde(default, skip_serializing_if = "Option::is_none")] + pub enabled: Option, +} + + +#[derive(Debug, Clone, Serialize, Deserialize)] +pub struct UserLoginLog { + pub id: i64, + pub user_id: i64, + pub login_ip: String, + pub user_agent: String, + pub success: bool, + pub timestamp: i64, +} + + +#[derive(Debug, Clone, Serialize, Deserialize)] +pub struct UserLoginRequest { + pub identifier: String, + pub email: String, + pub password: String, + #[serde(rename = "X-Original-Forwarded-For")] + pub ip: String, + #[serde(rename = "User-Agent")] + pub user_agent: String, + #[serde(rename = "Login-Type")] + pub login_type: String, + pub cf_token: String, +} + + +#[derive(Debug, Clone, Serialize, Deserialize)] +pub struct UserRegisterRequest { + pub identifier: String, + pub email: String, + pub password: String, + pub invite: String, + pub code: String, + #[serde(rename = "X-Original-Forwarded-For")] + pub ip: String, + #[serde(rename = "User-Agent")] + pub user_agent: String, + #[serde(rename = "Login-Type")] + pub login_type: String, + pub cf_token: String, +} + + +#[derive(Debug, Clone, Serialize, Deserialize)] +pub struct VerifyEmailRequest { + pub email: String, + pub code: String, +} diff --git a/src/model/dto/common.rs b/src/model/dto/common.rs new file mode 100644 index 00000000..9b8cd8ea --- /dev/null +++ b/src/model/dto/common.rs @@ -0,0 +1,54 @@ +//! API DTO types — common domain. +//! Auto-generated from the monolithic `dto.rs`. + +use serde::{Deserialize, Serialize}; +use serde_json::Value; + +use super::auth::AuthConfig; +use super::subscribe::SubscribeConfig; +use super::system::{Currency, InviteConfig, PubilcVerifyCodeConfig, SiteConfig, VeifyConfig}; + + +#[derive(Debug, Clone, Serialize, Deserialize)] +pub struct GetDetailRequest { + pub id: i64, +} + + +#[derive(Debug, Clone, Serialize, Deserialize)] +pub struct GetGlobalConfigResponse { + pub site: SiteConfig, + pub verify: VeifyConfig, + pub auth: AuthConfig, + pub invite: InviteConfig, + pub currency: Currency, + pub subscribe: SubscribeConfig, + pub verify_code: PubilcVerifyCodeConfig, + pub oauth_methods: Vec, + pub web_ad: bool, +} + + +#[derive(Debug, Clone, Serialize, Deserialize)] +pub struct GetStatResponse { + pub user: i64, + pub node: i64, + pub country: i64, + pub protocol: Vec, +} + + +#[derive(Debug, Clone, Serialize, Deserialize)] +pub struct GetTosResponse { + pub tos_content: String, +} + + +#[derive(Debug, Clone, Serialize, Deserialize)] +pub struct HeartbeatResponse { + pub status: bool, + #[serde(default, skip_serializing_if = "Option::is_none")] + pub message: Option, + #[serde(default, skip_serializing_if = "Option::is_none")] + pub timestamp: Option, +} diff --git a/src/model/dto/coupon.rs b/src/model/dto/coupon.rs new file mode 100644 index 00000000..abd32d0b --- /dev/null +++ b/src/model/dto/coupon.rs @@ -0,0 +1,102 @@ +//! API DTO types — coupon domain. +//! Auto-generated from the monolithic `dto.rs`. + +use serde::{Deserialize, Serialize}; +use serde_json::Value; + + +#[derive(Debug, Clone, Serialize, Deserialize)] +pub struct BatchDeleteCouponRequest { + pub ids: Vec, +} + + +#[derive(Debug, Clone, Serialize, Deserialize)] +pub struct Coupon { + pub id: i64, + pub name: String, + pub code: String, + pub count: i64, + #[serde(rename = "type")] + pub type_: u8, + pub discount: i64, + pub start_time: i64, + pub expire_time: i64, + pub user_limit: i64, + pub subscribe: Vec, + pub used_count: i64, + pub enable: bool, + pub created_at: i64, + pub updated_at: i64, +} + + +#[derive(Debug, Clone, Serialize, Deserialize)] +pub struct CreateCouponRequest { + pub name: String, + #[serde(default, skip_serializing_if = "Option::is_none")] + pub code: Option, + #[serde(default, skip_serializing_if = "Option::is_none")] + pub count: Option, + #[serde(rename = "type")] + pub type_: u8, + pub discount: i64, + pub start_time: i64, + pub expire_time: i64, + #[serde(default, skip_serializing_if = "Option::is_none")] + pub user_limit: Option, + #[serde(default, skip_serializing_if = "Option::is_none")] + pub subscribe: Option>, + #[serde(default, skip_serializing_if = "Option::is_none")] + pub used_count: Option, + #[serde(default, skip_serializing_if = "Option::is_none")] + pub enable: Option, +} + + +#[derive(Debug, Clone, Serialize, Deserialize)] +pub struct DeleteCouponRequest { + pub id: i64, +} + + +#[derive(Debug, Clone, Serialize, Deserialize)] +pub struct GetCouponListRequest { + pub page: i64, + pub size: i64, + #[serde(default, skip_serializing_if = "Option::is_none")] + pub subscribe: Option, + #[serde(default, skip_serializing_if = "Option::is_none")] + pub search: Option, +} + + +#[derive(Debug, Clone, Serialize, Deserialize)] +pub struct GetCouponListResponse { + pub total: i64, + pub list: Vec, +} + + +#[derive(Debug, Clone, Serialize, Deserialize)] +pub struct UpdateCouponRequest { + pub id: i64, + pub name: String, + #[serde(default, skip_serializing_if = "Option::is_none")] + pub code: Option, + #[serde(default, skip_serializing_if = "Option::is_none")] + pub count: Option, + #[serde(rename = "type")] + pub type_: u8, + pub discount: i64, + pub start_time: i64, + pub expire_time: i64, + #[serde(default, skip_serializing_if = "Option::is_none")] + pub user_limit: Option, + #[serde(default, skip_serializing_if = "Option::is_none")] + pub subscribe: Option>, + #[serde(default, skip_serializing_if = "Option::is_none")] + pub used_count: Option, + #[serde(default, skip_serializing_if = "Option::is_none")] + pub enable: Option, +} diff --git a/src/model/dto/document.rs b/src/model/dto/document.rs new file mode 100644 index 00000000..27d1e7e8 --- /dev/null +++ b/src/model/dto/document.rs @@ -0,0 +1,106 @@ +//! API DTO types — document domain. +//! Auto-generated from the monolithic `dto.rs`. + +use serde::{Deserialize, Serialize}; +use serde_json::Value; + + +#[derive(Debug, Clone, Serialize, Deserialize)] +pub struct BatchDeleteDocumentRequest { + pub ids: Vec, +} + + +#[derive(Debug, Clone, Serialize, Deserialize)] +pub struct CreateDocumentRequest { + pub title: String, + pub content: String, + #[serde(default, skip_serializing_if = "Option::is_none")] + pub tags: Option>, + #[serde(default, skip_serializing_if = "Option::is_none")] + pub show: Option, +} + + +#[derive(Debug, Clone, Serialize, Deserialize)] +pub struct DeleteDocumentRequest { + pub id: i64, +} + + +#[derive(Debug, Clone, Serialize, Deserialize)] +pub struct Document { + pub id: i64, + pub title: String, + pub content: String, + pub tags: Vec, + pub show: bool, + pub created_at: i64, + pub updated_at: i64, +} + + +#[derive(Debug, Clone, Serialize, Deserialize)] +pub struct DownloadLink { + #[serde(default, skip_serializing_if = "Option::is_none")] + pub ios: Option, + #[serde(default, skip_serializing_if = "Option::is_none")] + pub android: Option, + #[serde(default, skip_serializing_if = "Option::is_none")] + pub windows: Option, + #[serde(default, skip_serializing_if = "Option::is_none")] + pub mac: Option, + #[serde(default, skip_serializing_if = "Option::is_none")] + pub linux: Option, + #[serde(default, skip_serializing_if = "Option::is_none")] + pub harmony: Option, +} + + +#[derive(Debug, Clone, Serialize, Deserialize)] +pub struct GetDocumentDetailRequest { + pub id: i64, +} + + +#[derive(Debug, Clone, Serialize, Deserialize)] +pub struct GetDocumentListRequest { + pub page: i64, + pub size: i64, + #[serde(default, skip_serializing_if = "Option::is_none")] + pub tag: Option, + #[serde(default, skip_serializing_if = "Option::is_none")] + pub search: Option, +} + + +#[derive(Debug, Clone, Serialize, Deserialize)] +pub struct GetDocumentListResponse { + pub total: i64, + pub list: Vec, +} + + +#[derive(Debug, Clone, Serialize, Deserialize)] +pub struct QueryDocumentDetailRequest { + pub id: i64, +} + + +#[derive(Debug, Clone, Serialize, Deserialize)] +pub struct QueryDocumentListResponse { + pub total: i64, + pub list: Vec, +} + + +#[derive(Debug, Clone, Serialize, Deserialize)] +pub struct UpdateDocumentRequest { + pub id: i64, + pub title: String, + pub content: String, + #[serde(default, skip_serializing_if = "Option::is_none")] + pub tags: Option>, + #[serde(default, skip_serializing_if = "Option::is_none")] + pub show: Option, +} diff --git a/src/model/dto/log.rs b/src/model/dto/log.rs new file mode 100644 index 00000000..1e206601 --- /dev/null +++ b/src/model/dto/log.rs @@ -0,0 +1,445 @@ +//! API DTO types — log domain. +//! Auto-generated from the monolithic `dto.rs`. + +use serde::{Deserialize, Serialize}; +use serde_json::Value; + +use super::auth::UserLoginLog; + + +#[derive(Debug, Clone, Serialize, Deserialize)] +pub struct BalanceLog { + #[serde(rename = "type")] + pub type_: u16, + pub user_id: i64, + pub amount: i64, + #[serde(default, skip_serializing_if = "Option::is_none")] + pub order_no: Option, + pub balance: i64, + pub timestamp: i64, +} + + +#[derive(Debug, Clone, Serialize, Deserialize)] +pub struct CommissionLog { + #[serde(rename = "type")] + pub type_: u16, + pub user_id: i64, + pub amount: i64, + pub order_no: String, + pub timestamp: i64, +} + + +#[derive(Debug, Clone, Serialize, Deserialize)] +pub struct FilterLogParams { + pub page: i32, + pub size: i32, + #[serde(default, skip_serializing_if = "Option::is_none")] + pub date: Option, + #[serde(default, skip_serializing_if = "Option::is_none")] + pub search: Option, +} + + +#[derive(Debug, Clone, Serialize, Deserialize)] +pub struct FilterBalanceLogRequest { + #[serde(flatten)] + pub params: FilterLogParams, + #[serde(default, skip_serializing_if = "Option::is_none")] + pub user_id: Option, +} + + +#[derive(Debug, Clone, Serialize, Deserialize)] +pub struct FilterBalanceLogResponse { + pub total: i64, + pub list: Vec, +} + + +#[derive(Debug, Clone, Serialize, Deserialize)] +pub struct FilterCommissionLogRequest { + #[serde(flatten)] + pub params: FilterLogParams, + #[serde(default, skip_serializing_if = "Option::is_none")] + pub user_id: Option, +} + + +#[derive(Debug, Clone, Serialize, Deserialize)] +pub struct FilterCommissionLogResponse { + pub total: i64, + pub list: Vec, +} + + +#[derive(Debug, Clone, Serialize, Deserialize)] +pub struct FilterEmailLogResponse { + pub total: i64, + pub list: Vec, +} + + +#[derive(Debug, Clone, Serialize, Deserialize)] +pub struct FilterGiftLogRequest { + #[serde(flatten)] + pub params: FilterLogParams, + #[serde(default, skip_serializing_if = "Option::is_none")] + pub user_id: Option, +} + + +#[derive(Debug, Clone, Serialize, Deserialize)] +pub struct FilterGiftLogResponse { + pub total: i64, + pub list: Vec, +} + + +#[derive(Debug, Clone, Serialize, Deserialize)] +pub struct FilterLoginLogRequest { + #[serde(flatten)] + pub params: FilterLogParams, + #[serde(default, skip_serializing_if = "Option::is_none")] + pub user_id: Option, +} + + +#[derive(Debug, Clone, Serialize, Deserialize)] +pub struct FilterLoginLogResponse { + pub total: i64, + pub list: Vec, +} + + +#[derive(Debug, Clone, Serialize, Deserialize)] +pub struct FilterMobileLogResponse { + pub total: i64, + pub list: Vec, +} + + +#[derive(Debug, Clone, Serialize, Deserialize)] +pub struct FilterRegisterLogRequest { + #[serde(flatten)] + pub params: FilterLogParams, + #[serde(default, skip_serializing_if = "Option::is_none")] + pub user_id: Option, +} + + +#[derive(Debug, Clone, Serialize, Deserialize)] +pub struct FilterRegisterLogResponse { + pub total: i64, + pub list: Vec, +} + + +#[derive(Debug, Clone, Serialize, Deserialize)] +pub struct FilterResetSubscribeLogRequest { + #[serde(flatten)] + pub params: FilterLogParams, + #[serde(default, skip_serializing_if = "Option::is_none")] + pub user_subscribe_id: Option, +} + + +#[derive(Debug, Clone, Serialize, Deserialize)] +pub struct FilterResetSubscribeLogResponse { + pub total: i64, + pub list: Vec, +} + + +#[derive(Debug, Clone, Serialize, Deserialize)] +pub struct FilterServerTrafficLogRequest { + #[serde(flatten)] + pub params: FilterLogParams, + #[serde(default, skip_serializing_if = "Option::is_none")] + pub server_id: Option, +} + + +#[derive(Debug, Clone, Serialize, Deserialize)] +pub struct FilterServerTrafficLogResponse { + pub total: i64, + pub list: Vec, +} + + +#[derive(Debug, Clone, Serialize, Deserialize)] +pub struct FilterSubscribeLogRequest { + #[serde(flatten)] + pub params: FilterLogParams, + #[serde(default, skip_serializing_if = "Option::is_none")] + pub user_id: Option, + #[serde(default, skip_serializing_if = "Option::is_none")] + pub user_subscribe_id: Option, +} + + +#[derive(Debug, Clone, Serialize, Deserialize)] +pub struct FilterSubscribeLogResponse { + pub total: i64, + pub list: Vec, +} + + +#[derive(Debug, Clone, Serialize, Deserialize)] +pub struct FilterSubscribeTrafficRequest { + #[serde(flatten)] + pub params: FilterLogParams, + #[serde(default, skip_serializing_if = "Option::is_none")] + pub user_id: Option, + #[serde(default, skip_serializing_if = "Option::is_none")] + pub user_subscribe_id: Option, +} + + +#[derive(Debug, Clone, Serialize, Deserialize)] +pub struct FilterSubscribeTrafficResponse { + pub total: i64, + pub list: Vec, +} + + +#[derive(Debug, Clone, Serialize, Deserialize)] +pub struct FilterTrafficLogDetailsRequest { + #[serde(flatten)] + pub params: FilterLogParams, + #[serde(default, skip_serializing_if = "Option::is_none")] + pub server_id: Option, + #[serde(default, skip_serializing_if = "Option::is_none")] + pub subscribe_id: Option, + #[serde(default, skip_serializing_if = "Option::is_none")] + pub user_id: Option, +} + + +#[derive(Debug, Clone, Serialize, Deserialize)] +pub struct FilterTrafficLogDetailsResponse { + pub total: i64, + pub list: Vec, +} + + +#[derive(Debug, Clone, Serialize, Deserialize)] +pub struct GetLoginLogRequest { + pub page: i32, + pub size: i32, +} + + +#[derive(Debug, Clone, Serialize, Deserialize)] +pub struct GetLoginLogResponse { + pub list: Vec, + pub total: i64, +} + + +#[derive(Debug, Clone, Serialize, Deserialize)] +pub struct GetMessageLogListRequest { + pub page: i32, + pub size: i32, + #[serde(rename = "type")] + pub type_: u8, + #[serde(default, skip_serializing_if = "Option::is_none")] + pub search: Option, +} + + +#[derive(Debug, Clone, Serialize, Deserialize)] +pub struct GetMessageLogListResponse { + pub total: i64, + pub list: Vec, +} + + +#[derive(Debug, Clone, Serialize, Deserialize)] +pub struct GiftLog { + #[serde(rename = "type")] + pub type_: u16, + pub user_id: i64, + pub order_no: String, + pub subscribe_id: i64, + pub amount: i64, + pub balance: i64, + #[serde(default, skip_serializing_if = "Option::is_none")] + pub remark: Option, + pub timestamp: i64, +} + + +// ─── Log ──────────────────────────────────────────────────────────────────── + +#[derive(Debug, Clone, Serialize, Deserialize)] +pub struct LoginLog { + pub user_id: i64, + pub method: String, + pub login_ip: String, + pub user_agent: String, + pub success: bool, + pub timestamp: i64, +} + + +#[derive(Debug, Clone, Serialize, Deserialize)] +pub struct LogResponse { + pub list: Value, +} + + +#[derive(Debug, Clone, Serialize, Deserialize)] +pub struct LogSetting { + #[serde(default, skip_serializing_if = "Option::is_none")] + pub auto_clear: Option, + pub clear_days: i64, +} + + +// ─── Message ──────────────────────────────────────────────────────────────── + +#[derive(Debug, Clone, Serialize, Deserialize)] +pub struct MessageLog { + pub id: i64, + #[serde(rename = "type")] + pub type_: u8, + pub platform: String, + pub to: String, + pub subject: String, + pub content: Value, + pub status: u8, + pub created_at: i64, +} + + +#[derive(Debug, Clone, Serialize, Deserialize)] +pub struct RegisterLog { + pub user_id: i64, + pub auth_method: String, + pub identifier: String, + pub register_ip: String, + pub user_agent: String, + pub timestamp: i64, +} + + +#[derive(Debug, Clone, Serialize, Deserialize)] +pub struct ResetSubscribeLog { + #[serde(rename = "type")] + pub type_: u16, + pub user_id: i64, + pub user_subscribe_id: i64, + #[serde(default, skip_serializing_if = "Option::is_none")] + pub order_no: Option, + pub timestamp: i64, +} + + +#[derive(Debug, Clone, Serialize, Deserialize)] +pub struct SubscribeLog { + pub user_id: i64, + pub token: String, + pub user_agent: String, + pub client_ip: String, + pub user_subscribe_id: i64, + pub timestamp: i64, +} + + +#[derive(Debug, Clone, Serialize, Deserialize)] +pub struct ResetSubscribeTrafficLog { + pub id: i64, + #[serde(rename = "type")] + pub type_: u16, + pub user_subscribe_id: i64, + #[serde(default, skip_serializing_if = "Option::is_none")] + pub order_no: Option, + pub timestamp: i64, +} + + +#[derive(Debug, Clone, Serialize, Deserialize)] +pub struct ServerTrafficLog { + pub server_id: i64, + pub upload: i64, + pub download: i64, + pub total: i64, + pub date: String, + pub details: bool, +} + + +#[derive(Debug, Clone, Serialize, Deserialize)] +pub struct TrafficLog { + pub id: i64, + pub server_id: i64, + pub user_id: i64, + pub subscribe_id: i64, + pub download: i64, + pub upload: i64, + pub timestamp: i64, +} + + +#[derive(Debug, Clone, Serialize, Deserialize)] +pub struct TrafficLogDetails { + pub id: i64, + pub server_id: i64, + pub user_id: i64, + pub subscribe_id: i64, + pub download: i64, + pub upload: i64, + pub timestamp: i64, +} + + +#[derive(Debug, Clone, Serialize, Deserialize)] +pub struct UserSubscribeLog { + pub id: i64, + pub user_id: i64, + pub user_subscribe_id: i64, + pub token: String, + pub ip: String, + pub user_agent: String, + pub timestamp: i64, +} + + +#[derive(Debug, Clone, Serialize, Deserialize)] +pub struct UserSubscribeTrafficLog { + pub subscribe_id: i64, + pub user_id: i64, + pub upload: i64, + pub download: i64, + pub total: i64, + pub date: String, + pub details: bool, +} + + +#[derive(Debug, Clone, Serialize, Deserialize)] +pub struct WithdrawalLog { + pub id: i64, + pub user_id: i64, + pub amount: i64, + pub content: Option, + pub status: u8, + pub reason: String, + pub created_at: i64, + pub updated_at: i64, +} + +#[derive(Debug, Clone, Serialize, Deserialize)] +pub struct QueryWithdrawalLogListRequest { + pub page: i32, + pub size: i32, + pub user_id: Option, +} + +#[derive(Debug, Clone, Serialize, Deserialize)] +pub struct QueryWithdrawalLogListResponse { + pub total: i64, + pub list: Vec, +} diff --git a/src/model/dto/marketing.rs b/src/model/dto/marketing.rs new file mode 100644 index 00000000..74535700 --- /dev/null +++ b/src/model/dto/marketing.rs @@ -0,0 +1,185 @@ +//! API DTO types — marketing domain. +//! Auto-generated from the monolithic `dto.rs`. + +use serde::{Deserialize, Serialize}; +use serde_json::Value; + + +#[derive(Debug, Clone, Serialize, Deserialize)] +pub struct BatchSendEmailTask { + pub id: i64, + pub subject: String, + pub content: String, + pub recipients: String, + pub scope: i8, + pub register_start_time: i64, + pub register_end_time: i64, + pub additional: String, + pub scheduled: i64, + pub interval: u8, + pub limit: u64, + pub status: u8, + pub errors: String, + pub total: u64, + pub current: u64, + pub created_at: i64, + pub updated_at: i64, +} + + +#[derive(Debug, Clone, Serialize, Deserialize)] +pub struct CreateBatchSendEmailTaskRequest { + pub subject: String, + pub content: String, + pub scope: i8, + #[serde(default, skip_serializing_if = "Option::is_none")] + pub register_start_time: Option, + #[serde(default, skip_serializing_if = "Option::is_none")] + pub register_end_time: Option, + #[serde(default, skip_serializing_if = "Option::is_none")] + pub additional: Option, + #[serde(default, skip_serializing_if = "Option::is_none")] + pub scheduled: Option, + #[serde(default, skip_serializing_if = "Option::is_none")] + pub interval: Option, + #[serde(default, skip_serializing_if = "Option::is_none")] + pub limit: Option, +} + + +#[derive(Debug, Clone, Serialize, Deserialize)] +pub struct CreateQuotaTaskRequest { + pub subscribers: Vec, + #[serde(default, skip_serializing_if = "Option::is_none")] + pub is_active: Option, + pub start_time: i64, + pub end_time: i64, + pub reset_traffic: bool, + pub days: u64, + pub gift_type: u8, + pub gift_value: u64, +} + + +#[derive(Debug, Clone, Serialize, Deserialize)] +pub struct GetBatchSendEmailTaskListRequest { + pub page: i32, + pub size: i32, + #[serde(default, skip_serializing_if = "Option::is_none")] + pub scope: Option, + #[serde(default, skip_serializing_if = "Option::is_none")] + pub status: Option, +} + + +#[derive(Debug, Clone, Serialize, Deserialize)] +pub struct GetBatchSendEmailTaskListResponse { + pub total: i64, + pub list: Vec, +} + + +#[derive(Debug, Clone, Serialize, Deserialize)] +pub struct GetBatchSendEmailTaskStatusRequest { + pub id: i64, +} + + +#[derive(Debug, Clone, Serialize, Deserialize)] +pub struct GetBatchSendEmailTaskStatusResponse { + pub status: u8, + pub current: i64, + pub total: i64, + pub errors: String, +} + + +#[derive(Debug, Clone, Serialize, Deserialize)] +pub struct GetPreSendEmailCountRequest { + pub scope: i8, + #[serde(default, skip_serializing_if = "Option::is_none")] + pub register_start_time: Option, + #[serde(default, skip_serializing_if = "Option::is_none")] + pub register_end_time: Option, +} + + +#[derive(Debug, Clone, Serialize, Deserialize)] +pub struct GetPreSendEmailCountResponse { + pub count: i64, +} + + +#[derive(Debug, Clone, Serialize, Deserialize)] +pub struct QueryQuotaTaskListRequest { + pub page: i32, + pub size: i32, + #[serde(default, skip_serializing_if = "Option::is_none")] + pub status: Option, +} + + +#[derive(Debug, Clone, Serialize, Deserialize)] +pub struct QueryQuotaTaskListResponse { + pub total: i64, + pub list: Vec, +} + + +#[derive(Debug, Clone, Serialize, Deserialize)] +pub struct QueryQuotaTaskPreCountRequest { + pub subscribers: Vec, + #[serde(default, skip_serializing_if = "Option::is_none")] + pub is_active: Option, + pub start_time: i64, + pub end_time: i64, +} + + +#[derive(Debug, Clone, Serialize, Deserialize)] +pub struct QueryQuotaTaskPreCountResponse { + pub count: i64, +} + + +#[derive(Debug, Clone, Serialize, Deserialize)] +pub struct QueryQuotaTaskStatusRequest { + pub id: i64, +} + + +#[derive(Debug, Clone, Serialize, Deserialize)] +pub struct QueryQuotaTaskStatusResponse { + pub status: u8, + pub current: i64, + pub total: i64, + pub errors: String, +} + + +#[derive(Debug, Clone, Serialize, Deserialize)] +pub struct QuotaTask { + pub id: i64, + pub subscribers: Vec, + #[serde(default, skip_serializing_if = "Option::is_none")] + pub is_active: Option, + pub start_time: i64, + pub end_time: i64, + pub reset_traffic: bool, + pub days: u64, + pub gift_type: u8, + pub gift_value: u64, + pub objects: Vec, + pub status: u8, + pub total: i64, + pub current: i64, + pub errors: String, + pub created_at: i64, + pub updated_at: i64, +} + + +#[derive(Debug, Clone, Serialize, Deserialize)] +pub struct StopBatchSendEmailTaskRequest { + pub id: i64, +} diff --git a/src/model/dto/misc.rs b/src/model/dto/misc.rs new file mode 100644 index 00000000..a9dce00f --- /dev/null +++ b/src/model/dto/misc.rs @@ -0,0 +1,79 @@ +//! Uncategorized DTO types (shared/generic). +//! Auto-generated from the monolithic `dto.rs`. + +use serde::{Deserialize, Deserializer, Serialize, Serializer}; +use serde_json::Value; + + +// ─── Custom Types ─────────────────────────────────────────────────────────── + +/// A `Vec` that serializes as `["1","2","3"]` and accepts both strings +/// and numbers when deserializing. +#[derive(Debug, Clone, Default)] +pub struct StringInt64Slice(pub Vec); + +impl Serialize for StringInt64Slice { + fn serialize(&self, serializer: S) -> Result + where + S: Serializer, + { + use serde::ser::SerializeSeq; + let mut seq = serializer.serialize_seq(Some(self.0.len()))?; + for item in &self.0 { + seq.serialize_element(&item.to_string())?; + } + seq.end() + } +} + +impl<'de> Deserialize<'de> for StringInt64Slice { + fn deserialize(deserializer: D) -> Result + where + D: Deserializer<'de>, + { + use serde::de::Error; + + let raw: Vec = Vec::deserialize(deserializer)?; + let mut values = Vec::with_capacity(raw.len()); + + for item in raw { + match item { + Value::Number(n) => { + if let Some(i) = n.as_i64() { + values.push(i); + } else { + return Err(D::Error::custom("number out of i64 range")); + } + } + Value::String(s) => { + let trimmed = s.trim(); + if trimmed.is_empty() { + continue; + } + let parsed = trimmed + .parse::() + .map_err(|_| D::Error::custom(format!("invalid integer string: {}", s)))?; + values.push(parsed); + } + Value::Null => continue, + _ => return Err(D::Error::custom("expected number or string")), + } + } + + Ok(StringInt64Slice(values)) + } +} + +impl StringInt64Slice { + pub fn int64s(&self) -> &[i64] { + &self.0 + } +} + + +#[derive(Debug, Clone, Serialize, Deserialize)] +pub struct TimePeriod { + pub start_time: String, + pub end_time: String, + pub multiplier: f32, +} diff --git a/src/model/dto/mod.rs b/src/model/dto/mod.rs new file mode 100644 index 00000000..1efbfece --- /dev/null +++ b/src/model/dto/mod.rs @@ -0,0 +1,42 @@ +//! API DTO types, split by domain. +//! All types re-exported so `model::dto::SomeType` paths continue to work. + +pub mod ads; +pub mod announcement; +pub mod application; +pub mod auth; +pub mod common; +pub mod coupon; +pub mod document; +pub mod log; +pub mod marketing; +pub mod node; +pub mod order; +pub mod payment; +pub mod protocol; +pub mod server; +pub mod subscribe; +pub mod system; +pub mod ticket; +pub mod user; +pub mod misc; + +pub use ads::*; +pub use announcement::*; +pub use application::*; +pub use auth::*; +pub use common::*; +pub use coupon::*; +pub use document::*; +pub use log::*; +pub use marketing::*; +pub use node::*; +pub use order::*; +pub use payment::*; +pub use protocol::*; +pub use server::*; +pub use subscribe::*; +pub use system::*; +pub use ticket::*; +pub use user::*; +pub use misc::*; diff --git a/src/model/dto/node.rs b/src/model/dto/node.rs new file mode 100644 index 00000000..c77532d0 --- /dev/null +++ b/src/model/dto/node.rs @@ -0,0 +1,221 @@ +//! API DTO types — node domain. +//! Auto-generated from the monolithic `dto.rs`. + +use serde::{Deserialize, Serialize}; +use serde_json::Value; + +use super::misc::TimePeriod; + + +#[derive(Debug, Clone, Serialize, Deserialize)] +pub struct CreateNodeRequest { + pub name: String, + #[serde(default, skip_serializing_if = "Option::is_none")] + pub tags: Option>, + pub port: u16, + pub address: String, + pub server_id: i64, + pub protocol: String, + #[serde(default, skip_serializing_if = "Option::is_none")] + pub enabled: Option, +} + + +#[derive(Debug, Clone, Serialize, Deserialize)] +pub struct DeleteNodeRequest { + pub id: i64, +} + + +#[derive(Debug, Clone, Serialize, Deserialize)] +pub struct FilterNodeListRequest { + pub page: i32, + pub size: i32, + #[serde(default, skip_serializing_if = "Option::is_none")] + pub search: Option, +} + + +#[derive(Debug, Clone, Serialize, Deserialize)] +pub struct FilterNodeListResponse { + pub total: i64, + pub list: Vec, +} + + +#[derive(Debug, Clone, Serialize, Deserialize)] +pub struct Node { + pub id: i64, + pub name: String, + pub tags: Vec, + pub port: u16, + pub address: String, + pub server_id: i64, + pub protocol: String, + #[serde(default, skip_serializing_if = "Option::is_none")] + pub enabled: Option, + #[serde(default, skip_serializing_if = "Option::is_none")] + pub sort: Option, + pub created_at: i64, + pub updated_at: i64, +} + + +#[derive(Debug, Clone, Serialize, Deserialize)] +pub struct NodeConfig { + pub node_secret: String, + pub node_pull_interval: i64, + pub node_push_interval: i64, + pub traffic_report_threshold: i64, + pub ip_strategy: String, + pub dns: Vec, + pub block: Vec, + pub outbound: Vec, +} + + +#[derive(Debug, Clone, Serialize, Deserialize)] +pub struct NodeDNS { + pub proto: String, + pub address: String, + pub domains: Vec, +} + + +#[derive(Debug, Clone, Serialize, Deserialize)] +pub struct NodeOutbound { + pub name: String, + pub protocol: String, + pub address: String, + pub port: i64, + #[serde(default, skip_serializing_if = "Option::is_none")] + pub user: Option, + pub password: String, + #[serde(default, skip_serializing_if = "Option::is_none")] + pub uuid: Option, + #[serde(default, skip_serializing_if = "Option::is_none")] + pub cipher: Option, + #[serde(default, skip_serializing_if = "Option::is_none")] + pub security: Option, + #[serde(default, skip_serializing_if = "Option::is_none")] + pub sni: Option, + #[serde(default)] + pub allow_insecure: bool, + #[serde(default, skip_serializing_if = "Option::is_none")] + pub fingerprint: Option, + #[serde(default, skip_serializing_if = "Option::is_none")] + pub transport: Option, + #[serde(default, skip_serializing_if = "Option::is_none")] + pub host: Option, + #[serde(default, skip_serializing_if = "Option::is_none")] + pub path: Option, + #[serde(default, skip_serializing_if = "Option::is_none")] + pub service_name: Option, + #[serde(default, skip_serializing_if = "Option::is_none")] + pub flow: Option, + #[serde(default)] + pub uot: bool, + #[serde(default)] + pub uot_version: i32, + #[serde(default, skip_serializing_if = "Option::is_none")] + pub congestion_controller: Option, + #[serde(default)] + pub udp_stream: bool, + #[serde(default)] + pub reduce_rtt: bool, + #[serde(default)] + pub heartbeat: i32, + #[serde(default, skip_serializing_if = "Option::is_none")] + pub reality_public_key: Option, + #[serde(default, skip_serializing_if = "Option::is_none")] + pub reality_short_id: Option, + #[serde(default, skip_serializing_if = "Option::is_none")] + pub spider_x: Option, + #[serde(default, skip_serializing_if = "Option::is_none")] + pub settings: Option, + #[serde(default, skip_serializing_if = "Option::is_none")] + pub stream_settings: Option, + pub rules: Vec, +} + + +#[derive(Debug, Clone, Serialize, Deserialize)] +pub struct ServerNodeConfigValues { + pub ip_strategy: String, + pub dns: Vec, + pub block: Vec, + pub outbound: Vec, +} + + +#[derive(Debug, Clone, Serialize, Deserialize)] +pub struct ServerNodeConfigOverride { + pub inherit_ip_strategy: bool, + #[serde(default, skip_serializing_if = "Option::is_none")] + pub ip_strategy: Option, + pub inherit_dns: bool, + pub dns: Vec, + pub inherit_block: bool, + pub block: Vec, + pub inherit_outbound: bool, + pub outbound: Vec, +} + + +#[derive(Debug, Clone, Serialize, Deserialize)] +pub struct NodeRelay { + pub host: String, + pub port: i32, + pub prefix: String, +} + + +#[derive(Debug, Clone, Serialize, Deserialize)] +pub struct QueryNodeTagResponse { + pub tags: Vec, +} + + +#[derive(Debug, Clone, Serialize, Deserialize)] +pub struct GetServerNodeConfigRequest { + pub server_id: i64, +} + + +#[derive(Debug, Clone, Serialize, Deserialize)] +pub struct GetServerNodeConfigResponse { + pub global: ServerNodeConfigValues, + pub r#override: ServerNodeConfigOverride, + pub effective: ServerNodeConfigValues, +} + + +#[derive(Debug, Clone, Serialize, Deserialize)] +pub struct UpdateServerNodeConfigRequest { + pub server_id: i64, + #[serde(flatten)] + pub override_config: ServerNodeConfigOverride, +} + + +#[derive(Debug, Clone, Serialize, Deserialize)] +pub struct ToggleNodeStatusRequest { + pub id: i64, + #[serde(default, skip_serializing_if = "Option::is_none")] + pub enable: Option, +} + + +#[derive(Debug, Clone, Serialize, Deserialize)] +pub struct UpdateNodeRequest { + pub id: i64, + pub name: String, + #[serde(default, skip_serializing_if = "Option::is_none")] + pub tags: Option>, + pub port: u16, + pub address: String, + pub server_id: i64, + pub protocol: String, + #[serde(default, skip_serializing_if = "Option::is_none")] + pub enabled: Option, +} diff --git a/src/model/dto/order.rs b/src/model/dto/order.rs new file mode 100644 index 00000000..59939cef --- /dev/null +++ b/src/model/dto/order.rs @@ -0,0 +1,345 @@ +//! API DTO types — order domain. +//! Auto-generated from the monolithic `dto.rs`. + +use serde::{Deserialize, Serialize}; +use serde_json::Value; + +use super::payment::{PaymentMethod, StripePayment}; +use super::subscribe::Subscribe; + + +#[derive(Debug, Clone, Serialize, Deserialize)] +pub struct CheckoutOrderRequest { + #[serde(rename = "orderNo")] + pub order_no: String, + #[serde(default, skip_serializing_if = "Option::is_none", rename = "returnUrl")] + pub return_url: Option, +} + + +#[derive(Debug, Clone, Serialize, Deserialize)] +pub struct CheckoutOrderResponse { + #[serde(rename = "type")] + pub type_: String, + #[serde(default, skip_serializing_if = "Option::is_none")] + pub checkout_url: Option, + #[serde(default, skip_serializing_if = "Option::is_none")] + pub stripe: Option>, +} + + +#[derive(Debug, Clone, Serialize, Deserialize)] +pub struct CloseOrderRequest { + #[serde(rename = "orderNo")] + pub order_no: String, +} + + +#[derive(Debug, Clone, Serialize, Deserialize)] +pub struct CreateOrderRequest { + pub user_id: i64, + #[serde(rename = "type")] + pub type_: u8, + #[serde(default, skip_serializing_if = "Option::is_none")] + pub quantity: Option, + pub price: i64, + pub amount: i64, + #[serde(default, skip_serializing_if = "Option::is_none")] + pub discount: Option, + #[serde(default, skip_serializing_if = "Option::is_none")] + pub coupon: Option, + #[serde(default, skip_serializing_if = "Option::is_none")] + pub coupon_discount: Option, + pub commission: i64, + pub fee_amount: i64, + pub payment_id: i64, + #[serde(default, skip_serializing_if = "Option::is_none")] + pub trade_no: Option, + #[serde(default, skip_serializing_if = "Option::is_none")] + pub status: Option, + #[serde(default, skip_serializing_if = "Option::is_none")] + pub subscribe_id: Option, +} + + +#[derive(Debug, Clone, Serialize, Deserialize)] +pub struct GetOrderListRequest { + pub page: i64, + pub size: i64, + #[serde(default, skip_serializing_if = "Option::is_none")] + pub user_id: Option, + #[serde(default, skip_serializing_if = "Option::is_none")] + pub status: Option, + #[serde(default, skip_serializing_if = "Option::is_none")] + pub subscribe_id: Option, + #[serde(default, skip_serializing_if = "Option::is_none")] + pub search: Option, +} + + +#[derive(Debug, Clone, Serialize, Deserialize)] +pub struct GetOrderListResponse { + pub total: i64, + pub list: Vec, +} + + +#[derive(Debug, Clone, Serialize, Deserialize)] +pub struct Order { + pub id: i64, + pub user_id: i64, + pub order_no: String, + #[serde(rename = "type")] + pub type_: u8, + pub quantity: i64, + pub price: i64, + pub amount: i64, + pub gift_amount: i64, + pub discount: i64, + pub coupon: String, + pub coupon_discount: i64, + #[serde(default, skip_serializing_if = "Option::is_none")] + pub commission: Option, + pub payment: PaymentMethod, + pub fee_amount: i64, + pub trade_no: String, + pub status: u8, + pub subscribe_id: i64, + pub created_at: i64, + pub updated_at: i64, +} + + +#[derive(Debug, Clone, Serialize, Deserialize)] +pub struct OrderDetail { + pub id: i64, + pub user_id: i64, + pub order_no: String, + #[serde(rename = "type")] + pub type_: u8, + pub quantity: i64, + pub price: i64, + pub amount: i64, + pub gift_amount: i64, + pub discount: i64, + pub coupon: String, + pub coupon_discount: i64, + #[serde(default, skip_serializing_if = "Option::is_none")] + pub commission: Option, + pub payment: PaymentMethod, + pub method: String, + pub fee_amount: i64, + pub trade_no: String, + pub status: u8, + pub subscribe_id: i64, + pub subscribe: Subscribe, + pub created_at: i64, + pub updated_at: i64, +} + + +#[derive(Debug, Clone, Serialize, Deserialize)] +pub struct OrdersStatistics { + #[serde(default, skip_serializing_if = "Option::is_none")] + pub date: Option, + pub amount_total: i64, + pub new_order_amount: i64, + pub renewal_order_amount: i64, + #[serde(default, skip_serializing_if = "Option::is_none")] + pub list: Option>, +} + + +#[derive(Debug, Clone, Serialize, Deserialize)] +pub struct PortalPurchaseRequest { + pub auth_type: String, + pub identifier: String, + #[serde(default, skip_serializing_if = "Option::is_none")] + pub password: Option, + pub payment: i64, + pub subscribe_id: i64, + pub quantity: i64, + #[serde(default, skip_serializing_if = "Option::is_none")] + pub coupon: Option, + #[serde(default, skip_serializing_if = "Option::is_none")] + pub invite_code: Option, + #[serde(default, skip_serializing_if = "Option::is_none")] + pub turnstile_token: Option, +} + + +#[derive(Debug, Clone, Serialize, Deserialize)] +pub struct PortalPurchaseResponse { + pub order_no: String, +} + + +#[derive(Debug, Clone, Serialize, Deserialize)] +pub struct PreOrderResponse { + pub price: i64, + pub amount: i64, + pub discount: i64, + pub gift_amount: i64, + pub coupon: String, + pub coupon_discount: i64, + pub fee_amount: i64, +} + + +#[derive(Debug, Clone, Serialize, Deserialize)] +pub struct PrePurchaseOrderRequest { + #[serde(default, skip_serializing_if = "Option::is_none")] + pub payment: Option, + pub subscribe_id: i64, + pub quantity: i64, + #[serde(default, skip_serializing_if = "Option::is_none")] + pub coupon: Option, +} + + +#[derive(Debug, Clone, Serialize, Deserialize)] +pub struct PrePurchaseOrderResponse { + pub price: i64, + pub amount: i64, + pub discount: i64, + pub coupon: String, + pub coupon_discount: i64, + pub fee_amount: i64, +} + + +#[derive(Debug, Clone, Serialize, Deserialize)] +pub struct PreRenewalOrderResponse { + #[serde(rename = "orderNo")] + pub order_no: String, +} + + +#[derive(Debug, Clone, Serialize, Deserialize)] +pub struct PurchaseOrderRequest { + pub subscribe_id: i64, + pub quantity: i64, + #[serde(default, skip_serializing_if = "Option::is_none")] + pub payment: Option, + #[serde(default, skip_serializing_if = "Option::is_none")] + pub coupon: Option, +} + + +#[derive(Debug, Clone, Serialize, Deserialize)] +pub struct PurchaseOrderResponse { + pub order_no: String, +} + + +#[derive(Debug, Clone, Serialize, Deserialize)] +pub struct QueryOrderDetailRequest { + pub order_no: String, +} + + +#[derive(Debug, Clone, Serialize, Deserialize)] +pub struct QueryOrderListRequest { + pub page: i32, + pub size: i32, +} + + +#[derive(Debug, Clone, Serialize, Deserialize)] +pub struct QueryOrderListResponse { + pub total: i64, + pub list: Vec, +} + + +#[derive(Debug, Clone, Serialize, Deserialize)] +pub struct QueryPurchaseOrderRequest { + pub auth_type: String, + pub identifier: String, + pub order_no: String, +} + + +#[derive(Debug, Clone, Serialize, Deserialize)] +pub struct QueryPurchaseOrderResponse { + pub order_no: String, + pub subscribe: Subscribe, + pub quantity: i64, + pub price: i64, + pub amount: i64, + pub discount: i64, + pub coupon: String, + pub coupon_discount: i64, + pub fee_amount: i64, + pub payment: PaymentMethod, + pub status: u8, + pub created_at: i64, + #[serde(default, skip_serializing_if = "Option::is_none")] + pub token: Option, +} + + +// ─── Recharge / Register ──────────────────────────────────────────────────── + +#[derive(Debug, Clone, Serialize, Deserialize)] +pub struct RechargeOrderRequest { + pub amount: i64, + #[serde(default, skip_serializing_if = "Option::is_none")] + pub payment: Option, +} + + +#[derive(Debug, Clone, Serialize, Deserialize)] +pub struct RechargeOrderResponse { + pub order_no: String, +} + + +#[derive(Debug, Clone, Serialize, Deserialize)] +pub struct RenewalOrderRequest { + pub user_subscribe_id: i64, + pub quantity: i64, + #[serde(default, skip_serializing_if = "Option::is_none")] + pub payment: Option, + #[serde(default, skip_serializing_if = "Option::is_none")] + pub coupon: Option, +} + + +#[derive(Debug, Clone, Serialize, Deserialize)] +pub struct RenewalOrderResponse { + pub order_no: String, +} + + +#[derive(Debug, Clone, Serialize, Deserialize)] +pub struct ResetTrafficOrderRequest { + pub user_subscribe_id: i64, + #[serde(default, skip_serializing_if = "Option::is_none")] + pub payment: Option, +} + + +#[derive(Debug, Clone, Serialize, Deserialize)] +pub struct ResetTrafficOrderResponse { + pub order_no: String, +} + + +#[derive(Debug, Clone, Serialize, Deserialize)] +pub struct RevenueStatisticsResponse { + pub today: OrdersStatistics, + pub monthly: OrdersStatistics, + pub all: OrdersStatistics, +} + + +#[derive(Debug, Clone, Serialize, Deserialize)] +pub struct UpdateOrderStatusRequest { + pub id: i64, + pub status: u8, + #[serde(default, skip_serializing_if = "Option::is_none")] + pub payment_id: Option, + #[serde(default, skip_serializing_if = "Option::is_none")] + pub trade_no: Option, +} diff --git a/src/model/dto/payment.rs b/src/model/dto/payment.rs new file mode 100644 index 00000000..ac044ca9 --- /dev/null +++ b/src/model/dto/payment.rs @@ -0,0 +1,171 @@ +//! API DTO types — payment domain. +//! Auto-generated from the monolithic `dto.rs`. + +use serde::{Deserialize, Serialize}; +use serde_json::Value; + + +#[derive(Debug, Clone, Serialize, Deserialize)] +pub struct AlipayNotifyResponse { + pub return_code: String, +} + + +#[derive(Debug, Clone, Serialize, Deserialize)] +pub struct CreatePaymentMethodRequest { + pub name: String, + pub platform: String, + #[serde(default, skip_serializing_if = "Option::is_none")] + pub description: Option, + #[serde(default, skip_serializing_if = "Option::is_none")] + pub icon: Option, + #[serde(default, skip_serializing_if = "Option::is_none")] + pub domain: Option, + pub config: Value, + pub fee_mode: u32, + #[serde(default, skip_serializing_if = "Option::is_none")] + pub fee_percent: Option, + #[serde(default, skip_serializing_if = "Option::is_none")] + pub fee_amount: Option, + #[serde(default, skip_serializing_if = "Option::is_none")] + pub sort: Option, + pub enable: Option, +} + + +#[derive(Debug, Clone, Serialize, Deserialize)] +pub struct DeletePaymentMethodRequest { + pub id: i64, +} + + +// ─── E-Pay / Email Auth / Email ───────────────────────────────────────────── + +#[derive(Debug, Clone, Serialize, Deserialize)] +pub struct EPayNotifyRequest { + pub pid: i64, + pub trade_no: String, + pub out_trade_no: String, + #[serde(rename = "type")] + pub type_: String, + pub name: String, + pub money: String, + pub trade_status: String, + pub param: String, + pub sign: String, + pub sign_type: String, +} + + +#[derive(Debug, Clone, Serialize, Deserialize)] +pub struct GetAvailablePaymentMethodsResponse { + pub list: Vec, +} + + +#[derive(Debug, Clone, Serialize, Deserialize)] +pub struct GetPaymentMethodListRequest { + pub page: i32, + pub size: i32, + #[serde(default, skip_serializing_if = "Option::is_none")] + pub platform: Option, + #[serde(default, skip_serializing_if = "Option::is_none")] + pub search: Option, + #[serde(default, skip_serializing_if = "Option::is_none")] + pub enable: Option, +} + + +#[derive(Debug, Clone, Serialize, Deserialize)] +pub struct GetPaymentMethodListResponse { + pub total: i64, + pub list: Vec, +} + + +// ─── Payment ──────────────────────────────────────────────────────────────── + +#[derive(Debug, Clone, Serialize, Deserialize)] +pub struct PaymentConfig { + pub id: i64, + pub name: String, + pub platform: String, + #[serde(default, skip_serializing_if = "Option::is_none")] + pub description: Option, + #[serde(default, skip_serializing_if = "Option::is_none")] + pub icon: Option, + #[serde(default, skip_serializing_if = "Option::is_none")] + pub domain: Option, + pub config: Value, + pub fee_mode: u32, + #[serde(default, skip_serializing_if = "Option::is_none")] + pub fee_percent: Option, + #[serde(default, skip_serializing_if = "Option::is_none")] + pub fee_amount: Option, + #[serde(default, skip_serializing_if = "Option::is_none")] + pub sort: Option, + pub enable: Option, +} + + +#[derive(Debug, Clone, Serialize, Deserialize)] +pub struct PaymentMethod { + pub id: i64, + pub name: String, + pub platform: String, + pub description: String, + pub icon: String, + pub fee_mode: u32, + pub fee_percent: i64, + pub fee_amount: i64, + pub sort: i64, +} + + +#[derive(Debug, Clone, Serialize, Deserialize)] +pub struct PaymentMethodDetail { + pub id: i64, + pub name: String, + pub platform: String, + pub description: String, + pub icon: String, + pub domain: String, + pub config: Value, + pub fee_mode: u32, + pub fee_percent: i64, + pub fee_amount: i64, + pub sort: i64, + pub enable: bool, + pub notify_url: String, +} + + +#[derive(Debug, Clone, Serialize, Deserialize)] +pub struct StripePayment { + pub method: String, + pub client_secret: String, + pub publishable_key: String, +} + + +#[derive(Debug, Clone, Serialize, Deserialize)] +pub struct UpdatePaymentMethodRequest { + pub id: i64, + pub name: String, + pub platform: String, + #[serde(default, skip_serializing_if = "Option::is_none")] + pub description: Option, + #[serde(default, skip_serializing_if = "Option::is_none")] + pub icon: Option, + #[serde(default, skip_serializing_if = "Option::is_none")] + pub domain: Option, + pub config: Value, + pub fee_mode: u32, + #[serde(default, skip_serializing_if = "Option::is_none")] + pub fee_percent: Option, + #[serde(default, skip_serializing_if = "Option::is_none")] + pub fee_amount: Option, + #[serde(default, skip_serializing_if = "Option::is_none")] + pub sort: Option, + pub enable: Option, +} diff --git a/src/model/dto/protocol.rs b/src/model/dto/protocol.rs new file mode 100644 index 00000000..92b50228 --- /dev/null +++ b/src/model/dto/protocol.rs @@ -0,0 +1,254 @@ +//! API DTO types — protocol domain. +//! Auto-generated from the monolithic `dto.rs`. + +use serde::{Deserialize, Serialize}; +use serde_json::Value; + + +#[derive(Debug, Clone, Serialize, Deserialize)] +pub struct AnyTLS { + pub port: i32, + pub security_config: SecurityConfig, +} + + +#[derive(Debug, Clone, Serialize, Deserialize)] +pub struct Hysteria2 { + pub port: i32, + pub hop_ports: String, + pub hop_interval: i32, + pub obfs_password: String, + pub security_config: SecurityConfig, +} + + +#[derive(Debug, Clone, Serialize, Deserialize)] +pub struct Protocol { + #[serde(rename = "type")] + pub type_: String, + pub port: u16, + pub enable: bool, + #[serde(default, skip_serializing_if = "Option::is_none")] + pub security: Option, + #[serde(default, skip_serializing_if = "Option::is_none")] + pub sni: Option, + #[serde(default)] + pub allow_insecure: bool, + #[serde(default, skip_serializing_if = "Option::is_none")] + pub fingerprint: Option, + #[serde(default, skip_serializing_if = "Option::is_none")] + pub reality_server_addr: Option, + #[serde(default)] + pub reality_server_port: i32, + #[serde(default, skip_serializing_if = "Option::is_none")] + pub reality_private_key: Option, + #[serde(default, skip_serializing_if = "Option::is_none")] + pub reality_public_key: Option, + #[serde(default, skip_serializing_if = "Option::is_none")] + pub reality_short_id: Option, + #[serde(default, skip_serializing_if = "Option::is_none")] + pub transport: Option, + #[serde(default, skip_serializing_if = "Option::is_none")] + pub host: Option, + #[serde(default, skip_serializing_if = "Option::is_none")] + pub path: Option, + #[serde(default, skip_serializing_if = "Option::is_none")] + pub service_name: Option, + #[serde(default, skip_serializing_if = "Option::is_none")] + pub cipher: Option, + #[serde(default, skip_serializing_if = "Option::is_none")] + pub server_key: Option, + #[serde(default, skip_serializing_if = "Option::is_none")] + pub flow: Option, + #[serde(default)] + pub uot: bool, + #[serde(default)] + pub uot_version: i32, + #[serde(default)] + pub accept_proxy_protocol: bool, + #[serde(default, skip_serializing_if = "Option::is_none")] + pub hop_ports: Option, + #[serde(default)] + pub hop_interval: i32, + #[serde(default, skip_serializing_if = "Option::is_none")] + pub obfs_password: Option, + #[serde(default)] + pub disable_sni: bool, + #[serde(default)] + pub reduce_rtt: bool, + #[serde(default, skip_serializing_if = "Option::is_none")] + pub udp_relay_mode: Option, + #[serde(default, skip_serializing_if = "Option::is_none")] + pub congestion_controller: Option, + #[serde(default, skip_serializing_if = "Option::is_none")] + pub multiplex: Option, + #[serde(default, skip_serializing_if = "Option::is_none")] + pub padding_scheme: Option, + #[serde(default)] + pub up_mbps: i32, + #[serde(default)] + pub down_mbps: i32, + #[serde(default, skip_serializing_if = "Option::is_none")] + pub obfs: Option, + #[serde(default, skip_serializing_if = "Option::is_none")] + pub obfs_host: Option, + #[serde(default, skip_serializing_if = "Option::is_none")] + pub obfs_path: Option, + #[serde(default, skip_serializing_if = "Option::is_none")] + pub xhttp_mode: Option, + #[serde(default, skip_serializing_if = "Option::is_none")] + pub xhttp_extra: Option, + #[serde(default, skip_serializing_if = "Option::is_none")] + pub encryption: Option, + #[serde(default, skip_serializing_if = "Option::is_none")] + pub encryption_mode: Option, + #[serde(default, skip_serializing_if = "Option::is_none")] + pub encryption_rtt: Option, + #[serde(default, skip_serializing_if = "Option::is_none")] + pub encryption_ticket: Option, + #[serde(default, skip_serializing_if = "Option::is_none")] + pub encryption_server_padding: Option, + #[serde(default, skip_serializing_if = "Option::is_none")] + pub encryption_private_key: Option, + #[serde(default, skip_serializing_if = "Option::is_none")] + pub encryption_client_padding: Option, + #[serde(default, skip_serializing_if = "Option::is_none")] + pub encryption_password: Option, + #[serde(default)] + pub ech_enable: bool, + #[serde(default, skip_serializing_if = "Option::is_none")] + pub ech_server_name: Option, + #[serde(default)] + pub ratio: f64, + #[serde(default, skip_serializing_if = "Option::is_none")] + pub cert_mode: Option, + #[serde(default, skip_serializing_if = "Option::is_none")] + pub cert_dns_provider: Option, + #[serde(default, skip_serializing_if = "Option::is_none")] + pub cert_dns_env: Option, +} + + +// ─── Security / Send Code / Server ────────────────────────────────────────── + +#[derive(Debug, Clone, Serialize, Deserialize)] +pub struct SecurityConfig { + #[serde(default, skip_serializing_if = "Option::is_none")] + pub sni: Option, + #[serde(default, skip_serializing_if = "Option::is_none")] + pub allow_insecure: Option, + #[serde(default, skip_serializing_if = "Option::is_none")] + pub fingerprint: Option, + #[serde(default, skip_serializing_if = "Option::is_none")] + pub reality_server_addr: Option, + #[serde(default)] + pub reality_server_port: i32, + #[serde(default, skip_serializing_if = "Option::is_none")] + pub reality_private_key: Option, + #[serde(default, skip_serializing_if = "Option::is_none")] + pub reality_public_key: Option, + #[serde(default, skip_serializing_if = "Option::is_none")] + pub reality_short_id: Option, +} + + +#[derive(Debug, Clone, Serialize, Deserialize)] +pub struct Shadowsocks { + pub method: String, + pub port: i32, + #[serde(default, skip_serializing_if = "Option::is_none")] + pub server_key: Option, +} + + +#[derive(Debug, Clone, Serialize, Deserialize)] +pub struct ShadowsocksProtocol { + pub port: i32, + pub method: String, +} + + +#[derive(Debug, Clone, Serialize, Deserialize)] +pub struct TransportConfig { + pub path: String, + pub host: String, + pub service_name: String, +} + + +#[derive(Debug, Clone, Serialize, Deserialize)] +pub struct Trojan { + pub port: i32, + pub transport: String, + pub transport_config: TransportConfig, + pub security: String, + pub security_config: SecurityConfig, +} + + +#[derive(Debug, Clone, Serialize, Deserialize)] +pub struct TrojanProtocol { + pub host: String, + pub port: i32, + #[serde(default, skip_serializing_if = "Option::is_none")] + pub enable_tls: Option, + pub tls_config: String, + pub network: String, + pub transport: String, +} + + +#[derive(Debug, Clone, Serialize, Deserialize)] +pub struct Tuic { + pub port: i32, + pub disable_sni: bool, + pub reduce_rtt: bool, + pub udp_relay_mode: String, + pub congestion_controller: String, + pub security_config: SecurityConfig, +} + + +#[derive(Debug, Clone, Serialize, Deserialize)] +pub struct Vless { + pub port: i32, + pub flow: String, + pub transport: String, + pub transport_config: TransportConfig, + pub security: String, + pub security_config: SecurityConfig, +} + + +#[derive(Debug, Clone, Serialize, Deserialize)] +pub struct VlessProtocol { + pub host: String, + pub port: i32, + pub network: String, + pub transport: String, + pub security: String, + pub security_config: String, + pub xtls: String, +} + + +#[derive(Debug, Clone, Serialize, Deserialize)] +pub struct Vmess { + pub port: i32, + pub transport: String, + pub transport_config: TransportConfig, + pub security: String, + pub security_config: SecurityConfig, +} + + +#[derive(Debug, Clone, Serialize, Deserialize)] +pub struct VmessProtocol { + pub host: String, + pub port: i32, + #[serde(default, skip_serializing_if = "Option::is_none")] + pub enable_tls: Option, + pub tls_config: String, + pub network: String, + pub transport: String, +} diff --git a/src/model/dto/server.rs b/src/model/dto/server.rs new file mode 100644 index 00000000..906c7a20 --- /dev/null +++ b/src/model/dto/server.rs @@ -0,0 +1,314 @@ +//! API DTO types — server domain. +//! Auto-generated from the monolithic `dto.rs`. + +use serde::{Deserialize, Serialize}; +use serde_json::Value; + +use super::node::{NodeDNS, NodeOutbound}; +use super::protocol::Protocol; +use super::user::{UserTraffic, UserTrafficData}; + + +#[derive(Debug, Clone, Serialize, Deserialize)] +pub struct CreateServerRequest { + pub name: String, + #[serde(default, skip_serializing_if = "Option::is_none")] + pub country: Option, + #[serde(default, skip_serializing_if = "Option::is_none")] + pub city: Option, + pub address: String, + #[serde(default, skip_serializing_if = "Option::is_none")] + pub sort: Option, + pub protocols: Vec, +} + + +#[derive(Debug, Clone, Serialize, Deserialize)] +pub struct DeleteServerRequest { + pub id: i64, +} + + +#[derive(Debug, Clone, Serialize, Deserialize)] +pub struct FilterServerListRequest { + pub page: i32, + pub size: i32, + #[serde(default, skip_serializing_if = "Option::is_none")] + pub search: Option, +} + + +#[derive(Debug, Clone, Serialize, Deserialize)] +pub struct FilterServerListResponse { + pub total: i64, + pub list: Vec, +} + + +#[derive(Debug, Clone, Serialize, Deserialize)] +pub struct GetServerConfigRequest { + #[serde(flatten)] + pub common: ServerCommon, +} + + +#[derive(Debug, Clone, Serialize, Deserialize)] +pub struct GetServerConfigResponse { + pub basic: ServerBasic, + pub protocol: String, + pub config: Value, +} + + +#[derive(Debug, Clone, Serialize, Deserialize)] +pub struct GetServerProtocolsRequest { + pub id: i64, +} + + +#[derive(Debug, Clone, Serialize, Deserialize)] +pub struct GetServerProtocolsResponse { + pub protocols: Vec, +} + + +#[derive(Debug, Clone, Serialize, Deserialize)] +pub struct GetServerUserListRequest { + #[serde(flatten)] + pub common: ServerCommon, +} + + +#[derive(Debug, Clone, Serialize, Deserialize)] +pub struct GetServerUserListResponse { + pub users: Vec, +} + + +#[derive(Debug, Clone, Serialize, Deserialize)] +pub struct HasMigrateSeverNodeResponse { + pub has_migrate: bool, +} + + +#[derive(Debug, Clone, Serialize, Deserialize)] +pub struct MigrateServerNodeResponse { + pub succee: u64, + pub fail: u64, + #[serde(default, skip_serializing_if = "Option::is_none")] + pub message: Option, +} + + +#[derive(Debug, Clone, Serialize, Deserialize)] +pub struct OnlineUser { + #[serde(rename = "uid")] + pub sid: i64, + pub ip: String, +} + + +#[derive(Debug, Clone, Serialize, Deserialize)] +pub struct OnlineUsersRequest { + #[serde(flatten)] + pub common: ServerCommon, + pub users: Vec, +} + + +#[derive(Debug, Clone, Serialize, Deserialize)] +pub struct PreViewNodeMultiplierResponse { + pub current_time: String, + pub ratio: f32, +} + + +#[derive(Debug, Clone, Serialize, Deserialize)] +pub struct QueryServerConfigRequest { + pub server_id: i64, + pub secret_key: String, + #[serde(default, skip_serializing_if = "Option::is_none")] + pub protocols: Option>, +} + + +#[derive(Debug, Clone, Serialize, Deserialize)] +pub struct QueryServerConfigResponse { + pub traffic_report_threshold: i64, + pub push_interval: i64, + pub pull_interval: i64, + pub ip_strategy: String, + pub dns: Vec, + pub block: Vec, + pub outbound: Vec, + pub protocols: Vec, + pub total: i64, +} + + +#[derive(Debug, Clone, Serialize, Deserialize)] +pub struct ResetSortRequest { + pub sort: Vec, +} + + +#[derive(Debug, Clone, Serialize, Deserialize)] +pub struct Server { + pub id: i64, + pub name: String, + pub country: String, + pub city: String, + pub address: String, + pub sort: i32, + pub protocols: Vec, + pub last_reported_at: i64, + pub status: ServerStatus, + pub created_at: i64, + pub updated_at: i64, +} + + +#[derive(Debug, Clone, Serialize, Deserialize)] +pub struct ServerBasic { + pub push_interval: i64, + pub pull_interval: i64, +} + + +#[derive(Debug, Clone, Serialize, Deserialize)] +pub struct ServerCommon { + pub protocol: String, + pub server_id: i64, + pub secret_key: String, +} + + +#[derive(Debug, Clone, Serialize, Deserialize)] +pub struct ServerGroup { + pub id: i64, + pub name: String, + pub description: String, + pub created_at: i64, + pub updated_at: i64, +} + + +#[derive(Debug, Clone, Serialize, Deserialize)] +pub struct ServerOnlineIP { + pub ip: String, + pub protocol: String, +} + + +#[derive(Debug, Clone, Serialize, Deserialize)] +pub struct ServerOnlineUser { + pub ip: Vec, + pub user_id: i64, + pub subscribe: String, + pub subscribe_id: i64, + pub traffic: i64, + pub expired_at: i64, +} + + +#[derive(Debug, Clone, Serialize, Deserialize)] +pub struct ServerPushStatusRequest { + #[serde(flatten)] + pub common: ServerCommon, + pub cpu: f64, + pub mem: f64, + pub disk: f64, + pub updated_at: i64, +} + + +#[derive(Debug, Clone, Serialize, Deserialize)] +pub struct ServerPushUserTrafficRequest { + #[serde(flatten)] + pub common: ServerCommon, + pub traffic: Vec, +} + + +#[derive(Debug, Clone, Serialize, Deserialize)] +pub struct ServerRuleGroup { + pub id: i64, + pub icon: String, + pub name: String, + #[serde(rename = "type")] + pub type_: String, + pub tags: Vec, + pub rules: String, + pub enable: bool, + pub default: bool, + pub created_at: i64, + pub updated_at: i64, +} + + +#[derive(Debug, Clone, Serialize, Deserialize)] +pub struct ServerStatus { + pub cpu: f64, + pub mem: f64, + pub disk: f64, + pub protocol: String, + pub online: Vec, + pub status: String, +} + + +#[derive(Debug, Clone, Serialize, Deserialize)] +pub struct ServerTotalDataResponse { + pub online_users: i64, + pub online_servers: i64, + pub offline_servers: i64, + pub today_upload: i64, + pub today_download: i64, + pub monthly_upload: i64, + pub monthly_download: i64, + pub updated_at: i64, + pub server_traffic_ranking_today: Vec, + pub server_traffic_ranking_yesterday: Vec, + pub user_traffic_ranking_today: Vec, + pub user_traffic_ranking_yesterday: Vec, +} + + +#[derive(Debug, Clone, Serialize, Deserialize)] +pub struct ServerTrafficData { + pub server_id: i64, + pub name: String, + pub upload: i64, + pub download: i64, +} + + +#[derive(Debug, Clone, Serialize, Deserialize)] +pub struct ServerUser { + pub id: i64, + pub uuid: String, + pub speed_limit: i64, + pub device_limit: i64, +} + + +#[derive(Debug, Clone, Serialize, Deserialize)] +pub struct SortItem { + pub id: i64, + pub sort: i64, +} + + +#[derive(Debug, Clone, Serialize, Deserialize)] +pub struct UpdateServerRequest { + pub id: i64, + pub name: String, + #[serde(default, skip_serializing_if = "Option::is_none")] + pub country: Option, + #[serde(default, skip_serializing_if = "Option::is_none")] + pub city: Option, + pub address: String, + #[serde(default, skip_serializing_if = "Option::is_none")] + pub sort: Option, + pub protocols: Vec, +} diff --git a/src/model/dto/subscribe.rs b/src/model/dto/subscribe.rs new file mode 100644 index 00000000..d40755c8 --- /dev/null +++ b/src/model/dto/subscribe.rs @@ -0,0 +1,610 @@ +//! API DTO types — subscribe domain. +//! Auto-generated from the monolithic `dto.rs`. + +use serde::{Deserialize, Serialize}; +use serde_json::Value; + +use super::misc::{StringInt64Slice, TimePeriod}; +use super::user::{User, UserDevice}; +use super::log::{ResetSubscribeTrafficLog, TrafficLog, UserSubscribeLog}; +use super::document::DownloadLink; +use super::server::SortItem; + + +#[derive(Debug, Clone, Serialize, Deserialize)] +pub struct AppUserSubcbribe { + pub id: i64, + pub name: String, + pub upload: i64, + pub traffic: i64, + pub download: i64, + pub device_limit: i64, + pub start_time: String, + pub expire_time: String, + pub list: Vec, +} + + +#[derive(Debug, Clone, Serialize, Deserialize)] +pub struct AppUserSubscbribeNode { + pub id: i64, + pub name: String, + pub uuid: String, + pub protocol: String, + pub relay_mode: String, + pub relay_node: String, + pub server_addr: String, + pub speed_limit: i32, + pub tags: Vec, + pub traffic: i64, + pub traffic_ratio: f64, + pub upload: i64, + pub config: String, + pub country: String, + pub city: String, + pub latitude: String, + pub longitude: String, + pub created_at: i64, + pub download: i64, +} + + +#[derive(Debug, Clone, Serialize, Deserialize)] +pub struct BatchDeleteSubscribeGroupRequest { + pub ids: Vec, +} + + +#[derive(Debug, Clone, Serialize, Deserialize)] +pub struct BatchDeleteSubscribeRequest { + pub ids: Vec, +} + + +#[derive(Debug, Clone, Serialize, Deserialize)] +pub struct CreateSubscribeGroupRequest { + pub name: String, + #[serde(default, skip_serializing_if = "Option::is_none")] + pub description: Option, +} + + +#[derive(Debug, Clone, Serialize, Deserialize)] +pub struct CreateSubscribeRequest { + pub name: String, + #[serde(default, skip_serializing_if = "Option::is_none")] + pub language: Option, + #[serde(default, skip_serializing_if = "Option::is_none")] + pub description: Option, + pub unit_price: i64, + pub unit_time: String, + pub discount: Vec, + pub replacement: i64, + pub inventory: i64, + pub traffic: i64, + pub speed_limit: i64, + pub device_limit: i64, + pub quota: i64, + pub nodes: StringInt64Slice, + pub node_tags: Vec, + #[serde(default, skip_serializing_if = "Option::is_none")] + pub show: Option, + #[serde(default, skip_serializing_if = "Option::is_none")] + pub sell: Option, + pub deduction_ratio: i64, + #[serde(default, skip_serializing_if = "Option::is_none")] + pub allow_deduction: Option, + pub reset_cycle: i64, + #[serde(default, skip_serializing_if = "Option::is_none")] + pub renewal_reset: Option, + pub show_original_price: bool, +} + + +#[derive(Debug, Clone, Serialize, Deserialize)] +pub struct CreateUserSubscribeRequest { + pub user_id: i64, + pub expired_at: i64, + pub traffic: i64, + pub subscribe_id: i64, +} + + +#[derive(Debug, Clone, Serialize, Deserialize)] +pub struct DeleteSubscribeGroupRequest { + pub id: i64, +} + + +#[derive(Debug, Clone, Serialize, Deserialize)] +pub struct DeleteSubscribeRequest { + pub id: i64, +} + + +#[derive(Debug, Clone, Serialize, Deserialize)] +pub struct DeleteUserSubscribeRequest { + pub user_subscribe_id: i64, +} + + +#[derive(Debug, Clone, Serialize, Deserialize)] +pub struct GetSubscribeClientResponse { + pub total: i64, + pub list: Vec, +} + + +#[derive(Debug, Clone, Serialize, Deserialize)] +pub struct GetSubscribeDetailsRequest { + pub id: i64, +} + + +#[derive(Debug, Clone, Serialize, Deserialize)] +pub struct GetSubscribeGroupListResponse { + pub list: Vec, + pub total: i64, +} + + +#[derive(Debug, Clone, Serialize, Deserialize)] +pub struct GetSubscribeListRequest { + pub page: i64, + pub size: i64, + #[serde(default, skip_serializing_if = "Option::is_none")] + pub language: Option, + #[serde(default, skip_serializing_if = "Option::is_none")] + pub search: Option, +} + + +#[derive(Debug, Clone, Serialize, Deserialize)] +pub struct GetSubscribeListResponse { + pub list: Vec, + pub total: i64, +} + + +#[derive(Debug, Clone, Serialize, Deserialize)] +pub struct GetSubscribeLogRequest { + pub page: i32, + pub size: i32, +} + + +#[derive(Debug, Clone, Serialize, Deserialize)] +pub struct GetSubscribeLogResponse { + pub list: Vec, + pub total: i64, +} + + +#[derive(Debug, Clone, Serialize, Deserialize)] +pub struct GetSubscriptionRequest { + #[serde(default, skip_serializing_if = "Option::is_none")] + pub language: Option, +} + + +#[derive(Debug, Clone, Serialize, Deserialize)] +pub struct GetSubscriptionResponse { + pub list: Vec, +} + + +#[derive(Debug, Clone, Serialize, Deserialize)] +pub struct GetUserSubscribeByIdRequest { + pub id: i64, +} + + +#[derive(Debug, Clone, Serialize, Deserialize)] +pub struct GetUserSubscribeDevicesRequest { + pub page: i32, + pub size: i32, + pub user_id: i64, + pub subscribe_id: i64, +} + + +#[derive(Debug, Clone, Serialize, Deserialize)] +pub struct GetUserSubscribeDevicesResponse { + pub list: Vec, + pub total: i64, +} + + +#[derive(Debug, Clone, Serialize, Deserialize)] +pub struct GetUserSubscribeListRequest { + pub page: i32, + pub size: i32, + pub user_id: i64, +} + + +#[derive(Debug, Clone, Serialize, Deserialize)] +pub struct GetUserSubscribeListResponse { + pub list: Vec, + pub total: i64, +} + + +#[derive(Debug, Clone, Serialize, Deserialize)] +pub struct GetUserSubscribeLogsRequest { + pub page: i32, + pub size: i32, + pub user_id: i64, + #[serde(default, skip_serializing_if = "Option::is_none")] + pub subscribe_id: Option, +} + + +#[derive(Debug, Clone, Serialize, Deserialize)] +pub struct GetUserSubscribeLogsResponse { + pub list: Vec, + pub total: i64, +} + + +#[derive(Debug, Clone, Serialize, Deserialize)] +pub struct GetUserSubscribeResetTrafficLogsRequest { + pub page: i32, + pub size: i32, + pub user_subscribe_id: i64, +} + + +#[derive(Debug, Clone, Serialize, Deserialize)] +pub struct GetUserSubscribeResetTrafficLogsResponse { + pub list: Vec, + pub total: i64, +} + + +#[derive(Debug, Clone, Serialize, Deserialize)] +pub struct GetUserSubscribeTrafficLogsRequest { + pub page: i32, + pub size: i32, + pub user_id: i64, + pub subscribe_id: i64, + pub start_time: i64, + pub end_time: i64, +} + + +#[derive(Debug, Clone, Serialize, Deserialize)] +pub struct GetUserSubscribeTrafficLogsResponse { + pub list: Vec, + pub total: i64, +} + + +#[derive(Debug, Clone, Serialize, Deserialize)] +pub struct QuerySubscribeGroupListResponse { + pub list: Vec, + pub total: i64, +} + + +#[derive(Debug, Clone, Serialize, Deserialize)] +pub struct QuerySubscribeListRequest { + #[serde(default, skip_serializing_if = "Option::is_none")] + pub language: Option, +} + + +#[derive(Debug, Clone, Serialize, Deserialize)] +pub struct QuerySubscribeListResponse { + pub list: Vec, + pub total: i64, +} + + +#[derive(Debug, Clone, Serialize, Deserialize)] +pub struct QueryUserSubscribeListResponse { + pub list: Vec, + pub total: i64, +} + + +#[derive(Debug, Clone, Serialize, Deserialize)] +pub struct QueryUserSubscribeNodeListResponse { + pub list: Vec, +} + + +#[derive(Debug, Clone, Serialize, Deserialize)] +pub struct ResetAllSubscribeTokenResponse { + pub success: bool, +} + + +#[derive(Debug, Clone, Serialize, Deserialize)] +pub struct ResetUserSubscribeTokenRequest { + pub user_subscribe_id: i64, +} + + +#[derive(Debug, Clone, Serialize, Deserialize)] +pub struct ResetUserSubscribeTrafficRequest { + pub user_subscribe_id: i64, +} + + +// ─── Subscribe ────────────────────────────────────────────────────────────── + +#[derive(Debug, Clone, Serialize, Deserialize)] +pub struct Subscribe { + pub id: i64, + pub name: String, + #[serde(default, skip_serializing_if = "Option::is_none")] + pub language: Option, + #[serde(default, skip_serializing_if = "Option::is_none")] + pub description: Option, + pub unit_price: i64, + pub unit_time: String, + pub discount: Vec, + pub replacement: i64, + pub inventory: i64, + pub traffic: i64, + pub speed_limit: i64, + pub device_limit: i64, + pub quota: i64, + pub nodes: StringInt64Slice, + pub node_tags: Vec, + pub show: bool, + pub sell: bool, + pub sort: i64, + pub deduction_ratio: i64, + pub allow_deduction: bool, + pub reset_cycle: i64, + pub renewal_reset: bool, + pub show_original_price: bool, + pub created_at: i64, + pub updated_at: i64, +} + + +#[derive(Debug, Clone, Serialize, Deserialize)] +pub struct SubscribeApplication { + pub id: i64, + pub name: String, + #[serde(default, skip_serializing_if = "Option::is_none")] + pub description: Option, + #[serde(default, skip_serializing_if = "Option::is_none")] + pub icon: Option, + #[serde(default, skip_serializing_if = "Option::is_none")] + pub scheme: Option, + pub user_agent: String, + pub is_default: bool, + pub template: String, + pub output_format: String, + #[serde(default, skip_serializing_if = "Option::is_none")] + pub download_link: Option, + pub created_at: i64, + pub updated_at: i64, +} + + +#[derive(Debug, Clone, Serialize, Deserialize)] +pub struct SubscribeClient { + pub id: i64, + pub name: String, + #[serde(default, skip_serializing_if = "Option::is_none")] + pub description: Option, + #[serde(default, skip_serializing_if = "Option::is_none")] + pub icon: Option, + #[serde(default, skip_serializing_if = "Option::is_none")] + pub scheme: Option, + pub is_default: bool, + #[serde(default, skip_serializing_if = "Option::is_none")] + pub download_link: Option, +} + + +#[derive(Debug, Clone, Serialize, Deserialize)] +pub struct SubscribeConfig { + pub single_model: bool, + pub subscribe_path: String, + pub subscribe_domain: String, + pub pan_domain: bool, + pub user_agent_limit: bool, + pub user_agent_list: String, + pub show_tutorial: bool, +} + + +#[derive(Debug, Clone, Serialize, Deserialize)] +pub struct SubscribeDiscount { + pub quantity: i64, + pub discount: f64, +} + + +#[derive(Debug, Clone, Serialize, Deserialize)] +pub struct SubscribeGroup { + pub id: i64, + pub name: String, + pub description: String, + pub created_at: i64, + pub updated_at: i64, +} + + +#[derive(Debug, Clone, Serialize, Deserialize)] +pub struct SubscribeItem { + #[serde(flatten)] + pub subscribe: Subscribe, + pub sold: i64, +} + + +#[derive(Debug, Clone, Serialize, Deserialize)] +pub struct SubscribeLog { + pub user_id: i64, + pub token: String, + pub user_agent: String, + pub client_ip: String, + pub user_subscribe_id: i64, + pub timestamp: i64, +} + + +#[derive(Debug, Clone, Serialize, Deserialize)] +pub struct SubscribeSortRequest { + pub sort: Vec, +} + + +#[derive(Debug, Clone, Serialize, Deserialize)] +pub struct SubscribeType { + pub subscribe_types: Vec, +} + + +#[derive(Debug, Clone, Serialize, Deserialize)] +pub struct ToggleUserSubscribeStatusRequest { + pub user_subscribe_id: i64, +} + + +#[derive(Debug, Clone, Serialize, Deserialize)] +pub struct UpdateSubscribeGroupRequest { + pub id: i64, + pub name: String, + #[serde(default, skip_serializing_if = "Option::is_none")] + pub description: Option, +} + + +#[derive(Debug, Clone, Serialize, Deserialize)] +pub struct UpdateSubscribeRequest { + pub id: i64, + pub name: String, + #[serde(default, skip_serializing_if = "Option::is_none")] + pub language: Option, + #[serde(default, skip_serializing_if = "Option::is_none")] + pub description: Option, + pub unit_price: i64, + pub unit_time: String, + pub discount: Vec, + pub replacement: i64, + pub inventory: i64, + pub traffic: i64, + pub speed_limit: i64, + pub device_limit: i64, + pub quota: i64, + pub nodes: StringInt64Slice, + pub node_tags: Vec, + #[serde(default, skip_serializing_if = "Option::is_none")] + pub show: Option, + #[serde(default, skip_serializing_if = "Option::is_none")] + pub sell: Option, + pub sort: i64, + pub deduction_ratio: i64, + #[serde(default, skip_serializing_if = "Option::is_none")] + pub allow_deduction: Option, + pub reset_cycle: i64, + #[serde(default, skip_serializing_if = "Option::is_none")] + pub renewal_reset: Option, + pub show_original_price: bool, +} + + +#[derive(Debug, Clone, Serialize, Deserialize)] +pub struct UpdateUserSubscribeNoteRequest { + pub user_subscribe_id: i64, + pub note: String, +} + + +#[derive(Debug, Clone, Serialize, Deserialize)] +pub struct UpdateUserSubscribeRequest { + pub user_subscribe_id: i64, + pub subscribe_id: i64, + pub traffic: i64, + pub expired_at: i64, + pub upload: i64, + pub download: i64, +} + + +#[derive(Debug, Clone, Serialize, Deserialize)] +pub struct UserSubscribe { + pub id: i64, + pub user_id: i64, + pub order_id: i64, + pub subscribe_id: i64, + pub subscribe: Subscribe, + pub start_time: i64, + pub expire_time: i64, + pub finished_at: i64, + pub reset_time: i64, + pub traffic: i64, + pub download: i64, + pub upload: i64, + pub token: String, + pub status: u8, + pub short: String, + pub created_at: i64, + pub updated_at: i64, +} + + +#[derive(Debug, Clone, Serialize, Deserialize)] +pub struct UserSubscribeDetail { + pub id: i64, + pub user_id: i64, + pub user: User, + pub order_id: i64, + pub subscribe_id: i64, + pub subscribe: Subscribe, + pub start_time: i64, + pub expire_time: i64, + pub reset_time: i64, + pub traffic: i64, + pub download: i64, + pub upload: i64, + pub token: String, + pub status: u8, + pub created_at: i64, + pub updated_at: i64, +} + + +#[derive(Debug, Clone, Serialize, Deserialize)] +pub struct UserSubscribeInfo { + pub id: i64, + pub user_id: i64, + pub order_id: i64, + pub subscribe_id: i64, + pub start_time: i64, + pub expire_time: i64, + pub finished_at: i64, + pub reset_time: i64, + pub traffic: i64, + pub download: i64, + pub upload: i64, + pub token: String, + pub status: u8, + pub created_at: i64, + pub updated_at: i64, + pub is_try_out: bool, + pub nodes: Vec, +} + + +#[derive(Debug, Clone, Serialize, Deserialize)] +pub struct UserSubscribeNodeInfo { + pub id: i64, + pub name: String, + pub uuid: String, + pub protocol: String, + pub port: u16, + pub address: String, + pub tags: Vec, + pub country: String, + pub city: String, + pub created_at: i64, +} diff --git a/src/model/dto/system.rs b/src/model/dto/system.rs new file mode 100644 index 00000000..b31037ec --- /dev/null +++ b/src/model/dto/system.rs @@ -0,0 +1,167 @@ +//! API DTO types — system domain. +//! Auto-generated from the monolithic `dto.rs`. + +use serde::{Deserialize, Serialize}; +use serde_json::Value; + +use super::misc::TimePeriod; + + +#[derive(Debug, Clone, Serialize, Deserialize)] +pub struct Currency { + pub currency_unit: String, + pub currency_symbol: String, +} + + +#[derive(Debug, Clone, Serialize, Deserialize)] +pub struct CurrencyConfig { + pub access_key: String, + pub currency_unit: String, + pub currency_symbol: String, +} + + +#[derive(Debug, Clone, Serialize, Deserialize)] +pub struct GetNodeMultiplierResponse { + pub periods: Vec, +} + + +// ─── Invite ───────────────────────────────────────────────────────────────── + +#[derive(Debug, Clone, Serialize, Deserialize)] +pub struct InviteConfig { + pub forced_invite: bool, + pub referral_percentage: i64, + pub only_first_purchase: bool, +} + + +#[derive(Debug, Clone, Serialize, Deserialize)] +pub struct ModuleConfig { + pub secret: String, + pub service_name: String, + pub service_version: String, +} + + +#[derive(Debug, Clone, Serialize, Deserialize)] +pub struct PrivacyPolicyConfig { + pub privacy_policy: String, +} + + +#[derive(Debug, Clone, Serialize, Deserialize)] +pub struct PubilcRegisterConfig { + pub stop_register: bool, + pub enable_ip_register_limit: bool, + pub ip_register_limit: i64, + pub ip_register_limit_duration: i64, +} + + +#[derive(Debug, Clone, Serialize, Deserialize)] +pub struct PubilcVerifyCodeConfig { + pub verify_code_interval: i64, +} + + +#[derive(Debug, Clone, Serialize, Deserialize)] +pub struct QueryIPLocationRequest { + pub ip: String, +} + + +#[derive(Debug, Clone, Serialize, Deserialize)] +pub struct QueryIPLocationResponse { + pub country: String, + #[serde(default, skip_serializing_if = "Option::is_none")] + pub region: Option, + pub city: String, +} + + +#[derive(Debug, Clone, Serialize, Deserialize)] +pub struct RegisterConfig { + pub stop_register: bool, + pub enable_trial: bool, + pub trial_subscribe: i64, + pub trial_time: i64, + pub trial_time_unit: String, + pub enable_ip_register_limit: bool, + pub ip_register_limit: i64, + pub ip_register_limit_duration: i64, +} + + +#[derive(Debug, Clone, Serialize, Deserialize)] +pub struct SetNodeMultiplierRequest { + pub periods: Vec, +} + + +#[derive(Debug, Clone, Serialize, Deserialize)] +pub struct SiteConfig { + pub host: String, + pub site_name: String, + pub site_desc: String, + pub site_logo: String, + pub keywords: String, + pub custom_html: String, + pub custom_data: String, +} + + +#[derive(Debug, Clone, Serialize, Deserialize)] +pub struct SiteCustomDataContacts { + pub email: String, + pub telephone: String, + pub address: String, +} + + +// ─── Telegram ─────────────────────────────────────────────────────────────── + +#[derive(Debug, Clone, Serialize, Deserialize)] +pub struct TelegramConfig { + pub telegram_bot_token: String, + pub telegram_group_url: String, + pub telegram_notify: bool, + pub telegram_web_hook_domain: String, +} + + +#[derive(Debug, Clone, Serialize, Deserialize)] +pub struct TosConfig { + pub tos_content: String, +} + + +// ─── Verify / Version / Vless / Vmess / Withdrawal ────────────────────────── + +#[derive(Debug, Clone, Serialize, Deserialize)] +pub struct VeifyConfig { + pub turnstile_site_key: String, + pub enable_login_verify: bool, + pub enable_register_verify: bool, + pub enable_reset_password_verify: bool, +} + + +#[derive(Debug, Clone, Serialize, Deserialize)] +pub struct VerifyCodeConfig { + pub verify_code_expire_time: i64, + pub verify_code_limit: i64, + pub verify_code_interval: i64, +} + + +#[derive(Debug, Clone, Serialize, Deserialize)] +pub struct VerifyConfig { + pub turnstile_site_key: String, + pub turnstile_secret: String, + pub enable_login_verify: bool, + pub enable_register_verify: bool, + pub enable_reset_password_verify: bool, +} diff --git a/src/model/dto/ticket.rs b/src/model/dto/ticket.rs new file mode 100644 index 00000000..bc84db5e --- /dev/null +++ b/src/model/dto/ticket.rs @@ -0,0 +1,128 @@ +//! API DTO types — ticket domain. +//! Auto-generated from the monolithic `dto.rs`. + +use serde::{Deserialize, Serialize}; +use serde_json::Value; + + +#[derive(Debug, Clone, Serialize, Deserialize)] +pub struct CreateTicketFollowRequest { + pub ticket_id: i64, + pub from: String, + #[serde(rename = "type")] + pub type_: u8, + pub content: String, +} + + +#[derive(Debug, Clone, Serialize, Deserialize)] +pub struct CreateUserTicketFollowRequest { + pub ticket_id: i64, + pub from: String, + #[serde(rename = "type")] + pub type_: u8, + pub content: String, +} + + +#[derive(Debug, Clone, Serialize, Deserialize)] +pub struct CreateUserTicketRequest { + pub title: String, + pub description: String, +} + + +#[derive(Debug, Clone, Serialize, Deserialize)] +pub struct Follow { + pub id: i64, + pub ticket_id: i64, + pub from: String, + #[serde(rename = "type")] + pub type_: u8, + pub content: String, + pub created_at: i64, +} + + +#[derive(Debug, Clone, Serialize, Deserialize)] +pub struct GetTicketListRequest { + pub page: i64, + pub size: i64, + #[serde(default, skip_serializing_if = "Option::is_none")] + pub user_id: Option, + #[serde(default, skip_serializing_if = "Option::is_none")] + pub status: Option, + #[serde(default, skip_serializing_if = "Option::is_none")] + pub search: Option, +} + + +#[derive(Debug, Clone, Serialize, Deserialize)] +pub struct GetTicketListResponse { + pub total: i64, + pub list: Vec, +} + + +#[derive(Debug, Clone, Serialize, Deserialize)] +pub struct GetTicketRequest { + pub id: i64, +} + + +#[derive(Debug, Clone, Serialize, Deserialize)] +pub struct GetUserTicketDetailRequest { + pub id: i64, +} + + +#[derive(Debug, Clone, Serialize, Deserialize)] +pub struct GetUserTicketListRequest { + pub page: i32, + pub size: i32, + #[serde(default, skip_serializing_if = "Option::is_none")] + pub status: Option, + #[serde(default, skip_serializing_if = "Option::is_none")] + pub search: Option, +} + + +#[derive(Debug, Clone, Serialize, Deserialize)] +pub struct GetUserTicketListResponse { + pub total: i64, + pub list: Vec, +} + + +#[derive(Debug, Clone, Serialize, Deserialize)] +pub struct Ticket { + pub id: i64, + pub title: String, + pub description: String, + pub user_id: i64, + #[serde(default, skip_serializing_if = "Option::is_none")] + pub follow: Option>, + pub status: u8, + pub created_at: i64, + pub updated_at: i64, +} + + +#[derive(Debug, Clone, Serialize, Deserialize)] +pub struct TicketWaitRelpyResponse { + pub count: i64, +} + + +#[derive(Debug, Clone, Serialize, Deserialize)] +pub struct UpdateTicketStatusRequest { + pub id: i64, + pub status: Option, +} + + +#[derive(Debug, Clone, Serialize, Deserialize)] +pub struct UpdateUserTicketStatusRequest { + pub id: i64, + pub status: Option, +} diff --git a/src/model/dto/user.rs b/src/model/dto/user.rs new file mode 100644 index 00000000..c8e5e8ca --- /dev/null +++ b/src/model/dto/user.rs @@ -0,0 +1,434 @@ +//! API DTO types — user domain. +//! Auto-generated from the monolithic `dto.rs`. + +use serde::{Deserialize, Serialize}; +use serde_json::Value; + +use super::auth::UserLoginLog; +use super::log::{BalanceLog, CommissionLog}; + + +#[derive(Debug, Clone, Serialize, Deserialize)] +pub struct BatchDeleteUserRequest { + pub ids: Vec, +} + + +#[derive(Debug, Clone, Serialize, Deserialize)] +pub struct CommissionWithdrawRequest { + pub amount: i64, + pub content: String, +} + + +#[derive(Debug, Clone, Serialize, Deserialize)] +pub struct CreateUserAuthMethodRequest { + pub user_id: i64, + pub auth_type: String, + pub auth_identifier: String, +} + + +#[derive(Debug, Clone, Serialize, Deserialize)] +pub struct CreateUserRequest { + pub email: String, + pub telephone: String, + pub telephone_area_code: String, + pub password: String, + pub product_id: i64, + pub duration: i64, + pub referral_percentage: u8, + pub only_first_purchase: bool, + pub referer_user: String, + pub refer_code: String, + pub balance: i64, + pub commission: i64, + pub gift_amount: i64, + pub is_admin: bool, +} + + +#[derive(Debug, Clone, Serialize, Deserialize)] +pub struct DeleteUserAuthMethodRequest { + pub user_id: i64, + pub auth_type: String, +} + + +#[derive(Debug, Clone, Serialize, Deserialize)] +pub struct DeleteUserDeivceRequest { + pub id: i64, +} + + +#[derive(Debug, Clone, Serialize, Deserialize)] +pub struct GetDeviceListResponse { + pub list: Vec, + pub total: i64, +} + + +#[derive(Debug, Clone, Serialize, Deserialize)] +pub struct GetOAuthMethodsResponse { + pub methods: Vec, +} + + +#[derive(Debug, Clone, Serialize, Deserialize)] +pub struct GetUserAuthMethodRequest { + pub user_id: i64, +} + + +#[derive(Debug, Clone, Serialize, Deserialize)] +pub struct GetUserAuthMethodResponse { + pub auth_methods: Vec, +} + + +#[derive(Debug, Clone, Serialize, Deserialize)] +pub struct GetUserListRequest { + pub page: i32, + pub size: i32, + #[serde(default, skip_serializing_if = "Option::is_none")] + pub search: Option, + #[serde(default, skip_serializing_if = "Option::is_none")] + pub user_id: Option, + #[serde(default)] + pub unscoped: bool, + #[serde(default, skip_serializing_if = "Option::is_none")] + pub subscribe_id: Option, + #[serde(default, skip_serializing_if = "Option::is_none")] + pub user_subscribe_id: Option, +} + + +#[derive(Debug, Clone, Serialize, Deserialize)] +pub struct GetUserListResponse { + pub total: i64, + pub list: Vec, +} + + +#[derive(Debug, Clone, Serialize, Deserialize)] +pub struct GetUserLoginLogsRequest { + pub page: i32, + pub size: i32, + pub user_id: i64, +} + + +#[derive(Debug, Clone, Serialize, Deserialize)] +pub struct GetUserLoginLogsResponse { + pub list: Vec, + pub total: i64, +} + + +// ─── Kick ─────────────────────────────────────────────────────────────────── + +#[derive(Debug, Clone, Serialize, Deserialize)] +pub struct KickOfflineRequest { + pub id: i64, +} + + +#[derive(Debug, Clone, Serialize, Deserialize)] +pub struct PlatformInfo { + pub platform: String, + pub platform_url: String, + pub platform_field_description: std::collections::HashMap, +} + + +#[derive(Debug, Clone, Serialize, Deserialize)] +pub struct PlatformResponse { + pub list: Vec, +} + + +#[derive(Debug, Clone, Serialize, Deserialize)] +pub struct PreUnsubscribeRequest { + pub id: i64, +} + + +#[derive(Debug, Clone, Serialize, Deserialize)] +pub struct PreUnsubscribeResponse { + pub deduction_amount: i64, +} + + +#[derive(Debug, Clone, Serialize, Deserialize)] +pub struct QueryUserAffiliateCountResponse { + pub registers: i64, + pub total_commission: i64, +} + + +#[derive(Debug, Clone, Serialize, Deserialize)] +pub struct QueryUserAffiliateListRequest { + pub page: i32, + pub size: i32, +} + + +#[derive(Debug, Clone, Serialize, Deserialize)] +pub struct QueryUserAffiliateListResponse { + pub list: Vec, + pub total: i64, +} + + +#[derive(Debug, Clone, Serialize, Deserialize)] +pub struct QueryUserBalanceLogListResponse { + pub list: Vec, + pub total: i64, +} + + +#[derive(Debug, Clone, Serialize, Deserialize)] +pub struct QueryUserCommissionLogListRequest { + pub page: i32, + pub size: i32, +} + + +#[derive(Debug, Clone, Serialize, Deserialize)] +pub struct QueryUserCommissionLogListResponse { + pub list: Vec, + pub total: i64, +} + + +#[derive(Debug, Clone, Serialize, Deserialize)] +pub struct QueryWithdrawalLogListRequest { + pub page: i32, + pub size: i32, +} + + +#[derive(Debug, Clone, Serialize, Deserialize)] +pub struct QueryWithdrawalLogListResponse { + pub list: Vec, + pub total: i64, +} + + +// ─── Unbind / Unsubscribe / Update ────────────────────────────────────────── + +#[derive(Debug, Clone, Serialize, Deserialize)] +pub struct UnbindDeviceRequest { + pub id: i64, +} + + +#[derive(Debug, Clone, Serialize, Deserialize)] +pub struct UnsubscribeRequest { + pub id: i64, +} + + +#[derive(Debug, Clone, Serialize, Deserialize)] +pub struct UpdateBindEmailRequest { + pub email: String, +} + + +#[derive(Debug, Clone, Serialize, Deserialize)] +pub struct UpdateBindMobileRequest { + pub area_code: String, + pub mobile: String, + pub code: String, +} + + +#[derive(Debug, Clone, Serialize, Deserialize)] +pub struct UpdateUserAuthMethodRequest { + pub user_id: i64, + pub auth_type: String, + pub auth_identifier: String, +} + + +#[derive(Debug, Clone, Serialize, Deserialize)] +pub struct UpdateUserBasiceInfoRequest { + pub user_id: i64, + #[serde(default, skip_serializing_if = "Option::is_none")] + pub password: Option, + #[serde(default, skip_serializing_if = "Option::is_none")] + pub avatar: Option, + #[serde(default)] + pub balance: i64, + #[serde(default)] + pub commission: i64, + #[serde(default)] + pub referral_percentage: u8, + #[serde(default)] + pub only_first_purchase: bool, + #[serde(default)] + pub gift_amount: i64, + #[serde(default)] + pub telegram: i64, + #[serde(default, skip_serializing_if = "Option::is_none")] + pub refer_code: Option, + #[serde(default)] + pub referer_id: i64, + #[serde(default)] + pub enable: bool, + #[serde(default)] + pub is_admin: bool, +} + + +#[derive(Debug, Clone, Serialize, Deserialize)] +pub struct UpdateUserNotifyRequest { + #[serde(default, skip_serializing_if = "Option::is_none")] + pub enable_balance_notify: Option, + #[serde(default, skip_serializing_if = "Option::is_none")] + pub enable_login_notify: Option, + #[serde(default, skip_serializing_if = "Option::is_none")] + pub enable_subscribe_notify: Option, + #[serde(default, skip_serializing_if = "Option::is_none")] + pub enable_trade_notify: Option, +} + + +#[derive(Debug, Clone, Serialize, Deserialize)] +pub struct UpdateUserNotifySettingRequest { + pub user_id: i64, + pub enable_balance_notify: bool, + pub enable_login_notify: bool, + pub enable_subscribe_notify: bool, + pub enable_trade_notify: bool, +} + + +#[derive(Debug, Clone, Serialize, Deserialize)] +pub struct UpdateUserPasswordRequest { + pub password: String, +} + + +#[derive(Debug, Clone, Serialize, Deserialize)] +pub struct UpdateUserRulesRequest { + pub rules: Vec, +} + + +// ─── User ─────────────────────────────────────────────────────────────────── + +#[derive(Debug, Clone, Serialize, Deserialize)] +pub struct User { + pub id: i64, + pub avatar: String, + pub balance: i64, + pub commission: i64, + pub referral_percentage: u8, + pub only_first_purchase: bool, + pub gift_amount: i64, + pub telegram: i64, + pub refer_code: String, + pub referer_id: i64, + pub enable: bool, + #[serde(default, skip_serializing_if = "Option::is_none")] + pub is_admin: Option, + pub enable_balance_notify: bool, + pub enable_login_notify: bool, + pub enable_subscribe_notify: bool, + pub enable_trade_notify: bool, + pub auth_methods: Vec, + pub user_devices: Vec, + pub rules: Vec, + pub created_at: i64, + pub updated_at: i64, + #[serde(default, skip_serializing_if = "Option::is_none")] + pub deleted_at: Option, +} + + +#[derive(Debug, Clone, Serialize, Deserialize)] +pub struct UserAffiliate { + pub avatar: String, + pub identifier: String, + pub registered_at: i64, + pub enable: bool, +} + + +#[derive(Debug, Clone, Serialize, Deserialize)] +pub struct UserAuthMethod { + pub auth_type: String, + pub auth_identifier: String, + pub verified: bool, +} + + +#[derive(Debug, Clone, Serialize, Deserialize)] +pub struct UserDevice { + pub id: i64, + pub ip: String, + pub identifier: String, + pub user_agent: String, + pub online: bool, + pub enabled: bool, + pub created_at: i64, + pub updated_at: i64, +} + + +#[derive(Debug, Clone, Serialize, Deserialize)] +pub struct UserStatistics { + #[serde(default, skip_serializing_if = "Option::is_none")] + pub date: Option, + pub register: i64, + pub new_order_users: i64, + pub renewal_order_users: i64, + #[serde(default, skip_serializing_if = "Option::is_none")] + pub list: Option>, +} + + +#[derive(Debug, Clone, Serialize, Deserialize)] +pub struct UserStatisticsResponse { + pub today: UserStatistics, + pub monthly: UserStatistics, + pub all: UserStatistics, +} + + +#[derive(Debug, Clone, Serialize, Deserialize)] +pub struct UserTraffic { + #[serde(rename = "uid")] + pub sid: i64, + pub upload: i64, + pub download: i64, +} + + +#[derive(Debug, Clone, Serialize, Deserialize)] +pub struct UserTrafficData { + pub sid: i64, + pub upload: i64, + pub download: i64, +} + + +#[derive(Debug, Clone, Serialize, Deserialize)] +pub struct VersionResponse { + pub version: String, +} + + +#[derive(Debug, Clone, Serialize, Deserialize)] +pub struct WithdrawalLog { + pub id: i64, + pub user_id: i64, + pub amount: i64, + pub content: String, + pub status: u8, + #[serde(default, skip_serializing_if = "Option::is_none")] + pub reason: Option, + pub created_at: i64, + pub updated_at: i64, +} diff --git a/src/model/entity/ads.rs b/src/model/entity/ads.rs new file mode 100644 index 00000000..4a1cef85 --- /dev/null +++ b/src/model/entity/ads.rs @@ -0,0 +1,17 @@ +use serde::{Deserialize, Serialize}; + +#[derive(Debug, Clone, Serialize, Deserialize, sqlx::FromRow)] +pub struct Ads { + pub id: i64, + pub title: String, + #[serde(rename = "type")] + pub type_: String, + pub content: String, + pub description: String, + pub target_url: String, + pub start_time: i64, + pub end_time: i64, + pub status: i32, + pub created_at: i64, + pub updated_at: i64, +} diff --git a/src/model/entity/announcement.rs b/src/model/entity/announcement.rs new file mode 100644 index 00000000..40b2a13b --- /dev/null +++ b/src/model/entity/announcement.rs @@ -0,0 +1,13 @@ +use serde::{Deserialize, Serialize}; + +#[derive(Debug, Clone, Serialize, Deserialize, sqlx::FromRow)] +pub struct Announcement { + pub id: i64, + pub title: String, + pub content: String, + pub show: Option, + pub pinned: Option, + pub popup: Option, + pub created_at: i64, + pub updated_at: i64, +} diff --git a/src/model/entity/auth.rs b/src/model/entity/auth.rs new file mode 100644 index 00000000..845edb09 --- /dev/null +++ b/src/model/entity/auth.rs @@ -0,0 +1,124 @@ +use serde::{Deserialize, Serialize}; +use serde_json::Value; + +/// OAuth / email / mobile auth provider configuration stored in the `auth_method` table. +#[derive(Debug, Clone, Serialize, Deserialize, sqlx::FromRow)] +pub struct Auth { + pub id: i64, + pub method: String, + pub config: String, + pub enabled: Option, + pub created_at: i64, + pub updated_at: i64, +} + +// ─── Auth config structs (serialised into `Auth.config`) ──────────────────── + +#[derive(Debug, Clone, Serialize, Deserialize, sqlx::FromRow)] +pub struct AppleAuthConfig { + pub team_id: String, + pub key_id: String, + pub client_id: String, + pub client_secret: String, + pub redirect_url: String, +} + +#[derive(Debug, Clone, Serialize, Deserialize, sqlx::FromRow)] +pub struct GoogleAuthConfig { + pub client_id: String, + pub client_secret: String, + pub redirect_url: String, +} + +#[derive(Debug, Clone, Serialize, Deserialize, sqlx::FromRow)] +pub struct GithubAuthConfig { + pub client_id: String, + pub client_secret: String, + pub redirect_url: String, +} + +#[derive(Debug, Clone, Serialize, Deserialize, sqlx::FromRow)] +pub struct FacebookAuthConfig { + pub client_id: String, + pub client_secret: String, + pub redirect_url: String, +} + +#[derive(Debug, Clone, Serialize, Deserialize, sqlx::FromRow)] +pub struct TelegramAuthConfig { + pub bot_token: String, + pub enable_notify: bool, + pub webhook_domain: String, +} + +#[derive(Debug, Clone, Serialize, Deserialize, sqlx::FromRow)] +pub struct EmailAuthConfig { + pub platform: String, + pub platform_config: Value, + pub enable_verify: bool, + pub enable_notify: bool, + pub enable_domain_suffix: bool, + pub domain_suffix_list: String, + pub verify_email_template: String, + pub expiration_email_template: String, + pub maintenance_email_template: String, + pub traffic_exceed_email_template: String, +} + +#[derive(Debug, Clone, Serialize, Deserialize, sqlx::FromRow)] +pub struct SmtpConfig { + pub host: String, + pub port: i32, + pub user: String, + pub pass: String, + pub from: String, + pub ssl: bool, +} + +#[derive(Debug, Clone, Serialize, Deserialize, sqlx::FromRow)] +pub struct MobileAuthConfig { + pub platform: String, + pub platform_config: Value, + pub enable_whitelist: bool, + pub whitelist: Vec, +} + +#[derive(Debug, Clone, Serialize, Deserialize, sqlx::FromRow)] +pub struct AlibabaCloudConfig { + pub access: String, + pub secret: String, + pub sign_name: String, + pub endpoint: String, + pub template_code: String, +} + +#[derive(Debug, Clone, Serialize, Deserialize, sqlx::FromRow)] +pub struct SmsbaoConfig { + pub access: String, + pub secret: String, + pub template: String, +} + +#[derive(Debug, Clone, Serialize, Deserialize, sqlx::FromRow)] +pub struct AbosendConfig { + pub api_domain: String, + pub access: String, + pub secret: String, + pub template: String, +} + +#[derive(Debug, Clone, Serialize, Deserialize, sqlx::FromRow)] +pub struct TwilioConfig { + pub access: String, + pub secret: String, + pub phone_number: String, + pub template: String, +} + +#[derive(Debug, Clone, Serialize, Deserialize, sqlx::FromRow)] +pub struct DeviceConfig { + pub show_ads: bool, + pub only_real_device: bool, + pub enable_security: bool, + pub security_secret: String, +} diff --git a/src/model/entity/client.rs b/src/model/entity/client.rs new file mode 100644 index 00000000..bc1f7795 --- /dev/null +++ b/src/model/entity/client.rs @@ -0,0 +1,33 @@ +use serde::{Deserialize, Serialize}; + +#[derive(Debug, Clone, Serialize, Deserialize, sqlx::FromRow)] +pub struct SubscribeApplication { + pub id: i64, + pub name: String, + pub icon: Option, + pub description: Option, + pub scheme: String, + pub user_agent: String, + pub is_default: bool, + pub subscribe_template: Option, + pub output_format: String, + pub download_link: String, + pub created_at: i64, + pub updated_at: i64, +} + +#[derive(Debug, Clone, Serialize, Deserialize, sqlx::FromRow)] +pub struct DownloadLink { + #[serde(default, skip_serializing_if = "Option::is_none")] + pub ios: Option, + #[serde(default, skip_serializing_if = "Option::is_none")] + pub android: Option, + #[serde(default, skip_serializing_if = "Option::is_none")] + pub windows: Option, + #[serde(default, skip_serializing_if = "Option::is_none")] + pub mac: Option, + #[serde(default, skip_serializing_if = "Option::is_none")] + pub linux: Option, + #[serde(default, skip_serializing_if = "Option::is_none")] + pub harmony: Option, +} diff --git a/src/model/entity/coupon.rs b/src/model/entity/coupon.rs new file mode 100644 index 00000000..ebca7363 --- /dev/null +++ b/src/model/entity/coupon.rs @@ -0,0 +1,20 @@ +use serde::{Deserialize, Serialize}; + +#[derive(Debug, Clone, Serialize, Deserialize, sqlx::FromRow)] +pub struct Coupon { + pub id: i64, + pub name: String, + pub code: String, + pub count: i64, + #[serde(rename = "type")] + pub type_: i16, + pub discount: i64, + pub start_time: i64, + pub expire_time: i64, + pub user_limit: i64, + pub subscribe: String, + pub used_count: i64, + pub enable: Option, + pub created_at: i64, + pub updated_at: i64, +} diff --git a/src/model/entity/document.rs b/src/model/entity/document.rs new file mode 100644 index 00000000..7c7685f6 --- /dev/null +++ b/src/model/entity/document.rs @@ -0,0 +1,12 @@ +use serde::{Deserialize, Serialize}; + +#[derive(Debug, Clone, Serialize, Deserialize, sqlx::FromRow)] +pub struct Document { + pub id: i64, + pub title: String, + pub content: String, + pub tags: String, + pub show: Option, + pub created_at: i64, + pub updated_at: i64, +} diff --git a/src/model/entity/log.rs b/src/model/entity/log.rs new file mode 100644 index 00000000..141e360f --- /dev/null +++ b/src/model/entity/log.rs @@ -0,0 +1,197 @@ +use serde::{Deserialize, Serialize}; + +// ─── Log type constants ───────────────────────────────────────────────────── + +/// Log Types: +/// 1X Message Logs +/// 2X Subscription Logs +/// 3X User Logs +/// 4X Traffic Ranking Logs +#[derive(Debug, Clone, Copy, PartialEq, Eq, Serialize, Deserialize)] +pub struct LogType(pub i16); + +impl LogType { + pub const EMAIL_MESSAGE: Self = Self(10); + pub const MOBILE_MESSAGE: Self = Self(11); + pub const SUBSCRIBE: Self = Self(20); + pub const SUBSCRIBE_TRAFFIC: Self = Self(21); + pub const SERVER_TRAFFIC: Self = Self(22); + pub const RESET_SUBSCRIBE: Self = Self(23); + pub const LOGIN: Self = Self(30); + pub const REGISTER: Self = Self(31); + pub const BALANCE: Self = Self(32); + pub const COMMISSION: Self = Self(33); + pub const GIFT: Self = Self(34); + pub const USER_TRAFFIC_RANK: Self = Self(40); + pub const SERVER_TRAFFIC_RANK: Self = Self(41); + pub const TRAFFIC_STAT: Self = Self(42); +} + +// ─── Sub-type constants ───────────────────────────────────────────────────── + +pub const RESET_SUBSCRIBE_TYPE_AUTO: i32 = 231; +pub const RESET_SUBSCRIBE_TYPE_ADVANCE: i32 = 232; +pub const RESET_SUBSCRIBE_TYPE_PAID: i32 = 233; +pub const RESET_SUBSCRIBE_TYPE_QUOTA: i32 = 234; +pub const BALANCE_TYPE_RECHARGE: i32 = 321; +pub const BALANCE_TYPE_WITHDRAW: i32 = 322; +pub const BALANCE_TYPE_PAYMENT: i32 = 323; +pub const BALANCE_TYPE_REFUND: i32 = 324; +pub const BALANCE_TYPE_REWARD: i32 = 325; +pub const BALANCE_TYPE_ADJUST: i32 = 326; +pub const COMMISSION_TYPE_PURCHASE: i32 = 331; +pub const COMMISSION_TYPE_RENEWAL: i32 = 332; +pub const COMMISSION_TYPE_REFUND: i32 = 333; +pub const COMMISSION_TYPE_WITHDRAW: i32 = 334; +pub const COMMISSION_TYPE_ADJUST: i32 = 335; +pub const COMMISSION_TYPE_CONVERT_BALANCE: i32 = 336; +pub const GIFT_TYPE_INCREASE: i32 = 341; +pub const GIFT_TYPE_REDUCE: i32 = 342; + +// ─── Entity structs ───────────────────────────────────────────────────────── + +/// System log entry (`system_logs` table). +#[derive(Debug, Clone, Serialize, Deserialize, sqlx::FromRow)] +pub struct SystemLog { + pub id: i64, + #[serde(rename = "type")] + pub type_: i16, + pub date: Option, + pub object_id: i64, + pub content: String, + pub created_at: i64, +} + +/// Message log content (serialised within `SystemLog.content`). +#[derive(Debug, Clone, Serialize, Deserialize, sqlx::FromRow)] +pub struct Message { + pub to: String, + #[serde(default, skip_serializing_if = "Option::is_none")] + pub subject: Option, + pub content: serde_json::Value, + pub platform: String, + pub template: String, + pub status: i16, +} + +/// Traffic log content (serialised within `SystemLog.content`). +#[derive(Debug, Clone, Serialize, Deserialize, sqlx::FromRow)] +pub struct Traffic { + pub download: i64, + pub upload: i64, +} + +/// Login log content (serialised within `SystemLog.content`). +#[derive(Debug, Clone, Serialize, Deserialize, sqlx::FromRow)] +pub struct Login { + pub method: String, + pub login_ip: String, + pub user_agent: String, + pub success: bool, + pub timestamp: i64, +} + +/// Registration log content (serialised within `SystemLog.content`). +#[derive(Debug, Clone, Serialize, Deserialize, sqlx::FromRow)] +pub struct Register { + pub auth_method: String, + pub identifier: String, + pub register_ip: String, + pub user_agent: String, + pub timestamp: i64, +} + +/// Subscription log content (serialised within `SystemLog.content`). +#[derive(Debug, Clone, Serialize, Deserialize, sqlx::FromRow)] +pub struct SubscribeLog { + pub token: String, + pub user_agent: String, + pub client_ip: String, + pub user_subscribe_id: i64, +} + +/// Reset subscribe log content (serialised within `SystemLog.content`). +#[derive(Debug, Clone, Serialize, Deserialize, sqlx::FromRow)] +pub struct ResetSubscribe { + #[serde(rename = "type")] + pub type_: i32, + pub user_id: i64, + #[serde(default, skip_serializing_if = "Option::is_none")] + pub order_no: Option, + pub timestamp: i64, +} + +/// Balance log content (serialised within `SystemLog.content`). +#[derive(Debug, Clone, Serialize, Deserialize, sqlx::FromRow)] +pub struct Balance { + #[serde(rename = "type")] + pub type_: i32, + pub amount: i64, + #[serde(default, skip_serializing_if = "Option::is_none")] + pub order_no: Option, + pub balance: i64, + pub timestamp: i64, +} + +/// Commission log content (serialised within `SystemLog.content`). +#[derive(Debug, Clone, Serialize, Deserialize, sqlx::FromRow)] +pub struct Commission { + #[serde(rename = "type")] + pub type_: i32, + pub amount: i64, + pub order_no: String, + pub timestamp: i64, +} + +/// Gift log content (serialised within `SystemLog.content`). +#[derive(Debug, Clone, Serialize, Deserialize, sqlx::FromRow)] +pub struct Gift { + #[serde(rename = "type")] + pub type_: i32, + pub order_no: String, + pub subscribe_id: i64, + pub amount: i64, + pub balance: i64, + #[serde(default, skip_serializing_if = "Option::is_none")] + pub remark: Option, + pub timestamp: i64, +} + +/// User traffic log content (serialised within `SystemLog.content`). +#[derive(Debug, Clone, Serialize, Deserialize, sqlx::FromRow)] +pub struct UserTraffic { + pub subscribe_id: i64, + pub user_id: i64, + pub upload: i64, + pub download: i64, + pub total: i64, +} + +/// User traffic rank entry. +#[derive(Debug, Clone, Serialize, Deserialize, sqlx::FromRow)] +pub struct UserTrafficRank { + pub rank: std::collections::HashMap, +} + +/// Server traffic log content (serialised within `SystemLog.content`). +#[derive(Debug, Clone, Serialize, Deserialize, sqlx::FromRow)] +pub struct ServerTraffic { + pub server_id: i64, + pub upload: i64, + pub download: i64, + pub total: i64, +} + +/// Server traffic rank entry. +#[derive(Debug, Clone, Serialize, Deserialize, sqlx::FromRow)] +pub struct ServerTrafficRank { + pub rank: std::collections::HashMap, +} + +/// Daily traffic statistics. +#[derive(Debug, Clone, Serialize, Deserialize, sqlx::FromRow)] +pub struct TrafficStat { + pub upload: i64, + pub download: i64, + pub total: i64, +} diff --git a/src/model/entity/mod.rs b/src/model/entity/mod.rs new file mode 100644 index 00000000..d3e5cf5d --- /dev/null +++ b/src/model/entity/mod.rs @@ -0,0 +1,22 @@ +//! Database entity types. +//! +//! Entity types describe the persisted shape of domain data. They are +//! distinct from the API DTOs in [`super::dto`] so internal columns and +//! validation rules can evolve independently of the public schema. + +pub mod ads; +pub mod announcement; +pub mod auth; +pub mod client; +pub mod coupon; +pub mod document; +pub mod log; +pub mod node; +pub mod order; +pub mod payment; +pub mod subscribe; +pub mod system; +pub mod task; +pub mod ticket; +pub mod traffic; +pub mod user; diff --git a/src/model/entity/node.rs b/src/model/entity/node.rs new file mode 100644 index 00000000..43c219b9 --- /dev/null +++ b/src/model/entity/node.rs @@ -0,0 +1,152 @@ +use serde::{Deserialize, Serialize}; + +/// Node (`nodes` table). +#[derive(Debug, Clone, Serialize, Deserialize, sqlx::FromRow)] +pub struct Node { + pub id: i64, + pub name: String, + pub tags: String, + pub port: i32, + pub address: String, + pub server_id: i64, + pub protocol: String, + pub enabled: Option, + pub sort: i32, + pub created_at: i64, + pub updated_at: i64, +} + +/// Server (`servers` table). +#[derive(Debug, Clone, Serialize, Deserialize, sqlx::FromRow)] +pub struct Server { + pub id: i64, + pub name: String, + pub country: String, + pub city: String, + pub address: String, + pub sort: i32, + pub protocols: String, + pub last_reported_at: Option, + pub created_at: i64, + pub updated_at: i64, +} + +/// Protocol configuration (serialised within `Server.protocols`). +#[derive(Debug, Clone, Serialize, Deserialize, sqlx::FromRow)] +pub struct Protocol { + #[serde(rename = "type")] + pub type_: String, + pub port: i32, + pub enable: bool, + #[serde(default, skip_serializing_if = "Option::is_none")] + pub security: Option, + #[serde(default, skip_serializing_if = "Option::is_none")] + pub sni: Option, + #[serde(default)] + pub allow_insecure: bool, + #[serde(default, skip_serializing_if = "Option::is_none")] + pub fingerprint: Option, + #[serde(default, skip_serializing_if = "Option::is_none")] + pub reality_server_addr: Option, + #[serde(default)] + pub reality_server_port: i32, + #[serde(default, skip_serializing_if = "Option::is_none")] + pub reality_private_key: Option, + #[serde(default, skip_serializing_if = "Option::is_none")] + pub reality_public_key: Option, + #[serde(default, skip_serializing_if = "Option::is_none")] + pub reality_short_id: Option, + #[serde(default, skip_serializing_if = "Option::is_none")] + pub transport: Option, + #[serde(default, skip_serializing_if = "Option::is_none")] + pub host: Option, + #[serde(default, skip_serializing_if = "Option::is_none")] + pub path: Option, + #[serde(default, skip_serializing_if = "Option::is_none")] + pub service_name: Option, + #[serde(default, skip_serializing_if = "Option::is_none")] + pub cipher: Option, + #[serde(default, skip_serializing_if = "Option::is_none")] + pub server_key: Option, + #[serde(default, skip_serializing_if = "Option::is_none")] + pub flow: Option, + #[serde(default)] + pub uot: bool, + #[serde(default)] + pub uot_version: i32, + #[serde(default)] + pub accept_proxy_protocol: bool, + #[serde(default, skip_serializing_if = "Option::is_none")] + pub hop_ports: Option, + #[serde(default)] + pub hop_interval: i32, + #[serde(default, skip_serializing_if = "Option::is_none")] + pub obfs_password: Option, + #[serde(default)] + pub disable_sni: bool, + #[serde(default)] + pub reduce_rtt: bool, + #[serde(default, skip_serializing_if = "Option::is_none")] + pub udp_relay_mode: Option, + #[serde(default, skip_serializing_if = "Option::is_none")] + pub congestion_controller: Option, + #[serde(default, skip_serializing_if = "Option::is_none")] + pub multiplex: Option, + #[serde(default, skip_serializing_if = "Option::is_none")] + pub padding_scheme: Option, + #[serde(default)] + pub up_mbps: i32, + #[serde(default)] + pub down_mbps: i32, + #[serde(default, skip_serializing_if = "Option::is_none")] + pub obfs: Option, + #[serde(default, skip_serializing_if = "Option::is_none")] + pub obfs_host: Option, + #[serde(default, skip_serializing_if = "Option::is_none")] + pub obfs_path: Option, + #[serde(default, skip_serializing_if = "Option::is_none")] + pub xhttp_mode: Option, + #[serde(default, skip_serializing_if = "Option::is_none")] + pub xhttp_extra: Option, + #[serde(default, skip_serializing_if = "Option::is_none")] + pub encryption: Option, + #[serde(default, skip_serializing_if = "Option::is_none")] + pub encryption_mode: Option, + #[serde(default, skip_serializing_if = "Option::is_none")] + pub encryption_rtt: Option, + #[serde(default, skip_serializing_if = "Option::is_none")] + pub encryption_ticket: Option, + #[serde(default, skip_serializing_if = "Option::is_none")] + pub encryption_server_padding: Option, + #[serde(default, skip_serializing_if = "Option::is_none")] + pub encryption_private_key: Option, + #[serde(default, skip_serializing_if = "Option::is_none")] + pub encryption_client_padding: Option, + #[serde(default, skip_serializing_if = "Option::is_none")] + pub encryption_password: Option, + #[serde(default)] + pub ech_enable: bool, + #[serde(default, skip_serializing_if = "Option::is_none")] + pub ech_server_name: Option, + #[serde(default)] + pub ratio: f64, + #[serde(default, skip_serializing_if = "Option::is_none")] + pub cert_mode: Option, + #[serde(default, skip_serializing_if = "Option::is_none")] + pub cert_dns_provider: Option, + #[serde(default, skip_serializing_if = "Option::is_none")] + pub cert_dns_env: Option, +} + +/// Server config override (`server_config_overrides` table). +#[derive(Debug, Clone, Serialize, Deserialize, sqlx::FromRow)] +pub struct ServerConfigOverride { + pub id: i64, + pub server_id: i64, + pub ip_strategy: Option, + pub dns: Option, + pub block: Option, + pub outbound: Option, + pub created_at: i64, + pub updated_at: i64, +} diff --git a/src/model/entity/order.rs b/src/model/entity/order.rs new file mode 100644 index 00000000..2001fd5e --- /dev/null +++ b/src/model/entity/order.rs @@ -0,0 +1,42 @@ +use serde::{Deserialize, Serialize}; + +// 类型别名:表示 Go 的 uint8 (0-255),但因 PostgreSQL 限制使用 i16 +pub type TinyUint = i16; + +/// Order (`order` table). +#[derive(Debug, Clone, Serialize, Deserialize, sqlx::FromRow)] +pub struct Order { + pub id: i64, + pub parent_id: Option, + pub user_id: i64, + pub order_no: String, + #[serde(rename = "type")] + #[sqlx(rename = "type")] + pub type_: TinyUint, // Go uint8: 值范围 0-255 + pub quantity: i64, + pub price: i64, + pub amount: i64, + pub gift_amount: i64, + pub discount: i64, + pub coupon: Option, + pub coupon_discount: i64, + pub commission: i64, + pub payment_id: i64, + pub method: String, + pub fee_amount: i64, + pub trade_no: Option, + pub status: TinyUint, // Go uint8: 值范围 0-255 + pub subscribe_id: i64, + pub subscribe_token: Option, + pub is_new: bool, + pub created_at: i64, + pub updated_at: i64, +} + +/// Aggregated order totals (used in queries, not a table). +#[derive(Debug, Clone, Default, Serialize, Deserialize, sqlx::FromRow)] +pub struct OrdersTotal { + pub amount_total: i64, + pub new_order_amount: i64, + pub renewal_order_amount: i64, +} diff --git a/src/model/entity/payment.rs b/src/model/entity/payment.rs new file mode 100644 index 00000000..015e6172 --- /dev/null +++ b/src/model/entity/payment.rs @@ -0,0 +1,58 @@ +use serde::{Deserialize, Serialize}; + +/// Payment method (`payment` table). +#[derive(Debug, Clone, Serialize, Deserialize, sqlx::FromRow)] +pub struct Payment { + pub id: i64, + pub name: String, + pub platform: String, + pub icon: String, + pub domain: String, + pub config: String, + pub description: Option, + pub fee_mode: i64, + pub fee_percent: i64, + pub fee_amount: i64, + pub sort: i64, + pub enable: Option, + pub token: String, + pub created_at: i64, + pub updated_at: i64, +} + +// ─── Payment config structs (serialised into `Payment.config`) ────────────── + +#[derive(Debug, Clone, Serialize, Deserialize, sqlx::FromRow)] +pub struct StripeConfig { + pub public_key: String, + pub secret_key: String, + pub webhook_secret: String, + pub payment: String, +} + +#[derive(Debug, Clone, Serialize, Deserialize, sqlx::FromRow)] +pub struct AlipayF2FConfig { + pub app_id: String, + pub private_key: String, + pub public_key: String, + pub invoice_name: String, + pub sandbox: bool, +} + +#[derive(Debug, Clone, Serialize, Deserialize, sqlx::FromRow)] +pub struct EPayConfig { + pub pid: String, + pub url: String, + pub key: String, + #[serde(rename = "type")] + pub type_: String, +} + +#[derive(Debug, Clone, Serialize, Deserialize, sqlx::FromRow)] +pub struct CryptoSaaSConfig { + pub endpoint: String, + pub account_id: String, + pub secret_key: String, + #[serde(rename = "type")] + pub type_: String, +} diff --git a/src/model/entity/subscribe.rs b/src/model/entity/subscribe.rs new file mode 100644 index 00000000..bbe3c204 --- /dev/null +++ b/src/model/entity/subscribe.rs @@ -0,0 +1,48 @@ +use serde::{Deserialize, Serialize}; + +/// Subscribe plan (`subscribe` table). +#[derive(Debug, Clone, Serialize, Deserialize, sqlx::FromRow)] +pub struct Subscribe { + pub id: i64, + pub name: String, + pub language: String, + pub description: Option, + pub unit_price: i64, + pub unit_time: String, + pub discount: String, // 修复: Go NOT NULL → Rust String(而非 Option) + pub replacement: i64, + pub inventory: i64, + pub traffic: i64, + pub speed_limit: i64, + pub device_limit: i64, + pub quota: i64, + pub nodes: String, // 修复: Go NOT NULL → Rust String(而非 Option) + pub node_tags: String, // 修复: Go NOT NULL → Rust String(而非 Option) + pub show: bool, // 修复: Go *bool default:0 not null → 总是有值 + pub sell: bool, // 修复: Go *bool default:0 not null → 总是有值 + pub sort: i64, + pub deduction_ratio: i64, + pub allow_deduction: bool, // 修复: Go *bool default:1 not null → 总是有值 + pub reset_cycle: i64, + pub renewal_reset: bool, // 修复: Go *bool default:0 not null → 总是有值 + pub show_original_price: bool, + pub created_at: i64, + pub updated_at: i64, +} + +/// Discount tier (serialised within `Subscribe.discount`). +#[derive(Debug, Clone, Serialize, Deserialize, sqlx::FromRow)] +pub struct Discount { + pub months: i64, + pub discount: i64, +} + +/// Subscribe group (`subscribe_group` table). +#[derive(Debug, Clone, Serialize, Deserialize, sqlx::FromRow)] +pub struct Group { + pub id: i64, + pub name: String, + pub description: Option, + pub created_at: i64, + pub updated_at: i64, +} diff --git a/src/model/entity/system.rs b/src/model/entity/system.rs new file mode 100644 index 00000000..99fdd359 --- /dev/null +++ b/src/model/entity/system.rs @@ -0,0 +1,15 @@ +use serde::{Deserialize, Serialize}; + +/// System config key-value entry (`system` table). +#[derive(Debug, Clone, Serialize, Deserialize, sqlx::FromRow)] +pub struct System { + pub id: i64, + pub category: String, + pub key: String, + pub value: String, + #[serde(rename = "type")] + pub type_: String, + pub desc: String, + pub created_at: i64, + pub updated_at: i64, +} diff --git a/src/model/entity/task.rs b/src/model/entity/task.rs new file mode 100644 index 00000000..45edcde2 --- /dev/null +++ b/src/model/entity/task.rs @@ -0,0 +1,98 @@ +use serde::{Deserialize, Serialize}; + +// ─── Task type constants ──────────────────────────────────────────────────── + +#[derive(Debug, Clone, Copy, PartialEq, Eq, Serialize, Deserialize)] +pub struct TaskType(pub i8); + +impl TaskType { + pub const EMAIL: Self = Self(0); + pub const QUOTA: Self = Self(1); +} + +// ─── Scope type constants ─────────────────────────────────────────────────── + +#[derive(Debug, Clone, Copy, PartialEq, Eq, Serialize, Deserialize)] +pub struct ScopeType(pub i8); + +impl ScopeType { + pub const ALL: Self = Self(1); + pub const ACTIVE: Self = Self(2); + pub const EXPIRED: Self = Self(3); + pub const NONE: Self = Self(4); + pub const SKIP: Self = Self(5); +} + +// ─── Entity structs ───────────────────────────────────────────────────────── + +/// Background task (`task` table). +#[derive(Debug, Clone, Serialize, Deserialize, sqlx::FromRow)] +pub struct Task { + pub id: i64, + #[serde(rename = "type")] + pub type_: i16, + pub scope: Option, + pub content: Option, + pub status: i16, + pub errors: Option, + pub total: i64, + pub current: i64, + pub created_at: i64, + pub updated_at: i64, +} + +// ─── Task content / scope structs (serialised within `Task`) ──────────────── + +/// Email batch task scope. +#[derive(Debug, Clone, Serialize, Deserialize, sqlx::FromRow)] +pub struct EmailScope { + #[serde(rename = "type")] + pub type_: i16, + #[serde(default)] + pub register_start_time: i64, + #[serde(default)] + pub register_end_time: i64, + #[serde(default)] + pub recipients: Vec, + #[serde(default)] + pub additional: Vec, + #[serde(default)] + pub scheduled: i64, + #[serde(default)] + pub interval: i16, + #[serde(default)] + pub limit: i64, +} + +/// Email batch task content. +#[derive(Debug, Clone, Serialize, Deserialize, sqlx::FromRow)] +pub struct EmailContent { + pub subject: String, + pub content: String, +} + +/// Quota task scope. +#[derive(Debug, Clone, Serialize, Deserialize, sqlx::FromRow)] +pub struct QuotaScope { + #[serde(default)] + pub subscribers: Vec, + pub is_active: Option, + #[serde(default)] + pub start_time: i64, + #[serde(default)] + pub end_time: i64, + #[serde(default)] + pub recipients: Vec, +} + +/// Quota task content. +#[derive(Debug, Clone, Serialize, Deserialize, sqlx::FromRow)] +pub struct QuotaContent { + pub reset_traffic: bool, + #[serde(default, skip_serializing_if = "Option::is_none")] + pub days: Option, + #[serde(default, skip_serializing_if = "Option::is_none")] + pub gift_type: Option, + #[serde(default, skip_serializing_if = "Option::is_none")] + pub gift_value: Option, +} diff --git a/src/model/entity/ticket.rs b/src/model/entity/ticket.rs new file mode 100644 index 00000000..afa3839f --- /dev/null +++ b/src/model/entity/ticket.rs @@ -0,0 +1,34 @@ +use serde::{Deserialize, Serialize}; + +// ─── Status constants ─────────────────────────────────────────────────────── + +pub const TICKET_STATUS_PENDING: i16 = 1; +pub const TICKET_STATUS_WAITING: i16 = 2; +pub const TICKET_STATUS_PROCESSED: i16 = 3; +pub const TICKET_STATUS_CLOSED: i16 = 4; + +// ─── Entity structs ───────────────────────────────────────────────────────── + +/// Ticket (`ticket` table). +#[derive(Debug, Clone, Serialize, Deserialize, sqlx::FromRow)] +pub struct Ticket { + pub id: i64, + pub title: String, + pub description: Option, + pub user_id: i64, + pub status: i16, + pub created_at: i64, + pub updated_at: i64, +} + +/// Ticket follow-up (`ticket_follow` table). +#[derive(Debug, Clone, Serialize, Deserialize, sqlx::FromRow)] +pub struct Follow { + pub id: i64, + pub ticket_id: i64, + pub from: String, + #[serde(rename = "type")] + pub type_: i16, + pub content: Option, + pub created_at: i64, +} diff --git a/src/model/entity/traffic.rs b/src/model/entity/traffic.rs new file mode 100644 index 00000000..cb800341 --- /dev/null +++ b/src/model/entity/traffic.rs @@ -0,0 +1,39 @@ +use serde::{Deserialize, Serialize}; + +/// Traffic log entry (`traffic_log` table). +#[derive(Debug, Clone, Serialize, Deserialize, sqlx::FromRow)] +pub struct TrafficLog { + pub id: i64, + pub server_id: i64, + pub user_id: i64, + pub subscribe_id: i64, + pub download: i64, + pub upload: i64, + pub timestamp: i64, +} + +/// Aggregated traffic totals (used in queries, not a table). +#[derive(Debug, Clone, Default, Serialize, Deserialize, sqlx::FromRow)] +pub struct TotalTraffic { + pub download: i64, + pub upload: i64, +} + +/// Server traffic ranking row. +#[derive(Debug, Clone, Default, Serialize, Deserialize, sqlx::FromRow)] +pub struct ServerTrafficRanking { + pub server_id: i64, + pub download: i64, + pub upload: i64, + pub total: i64, +} + +/// User traffic ranking row. +#[derive(Debug, Clone, Default, Serialize, Deserialize, sqlx::FromRow)] +pub struct UserTrafficRanking { + pub user_id: i64, + pub subscribe_id: i64, + pub download: i64, + pub upload: i64, + pub total: i64, +} diff --git a/src/model/entity/user.rs b/src/model/entity/user.rs new file mode 100644 index 00000000..03b33485 --- /dev/null +++ b/src/model/entity/user.rs @@ -0,0 +1,105 @@ +use serde::{Deserialize, Serialize}; + +// 类型别名:表示 Go 的 uint8 (0-255),但因 PostgreSQL 限制使用 i16 +// 应用层应确保值在 0-255 范围内 +pub type TinyUint = i16; + +/// User (`user` table). +#[derive(Debug, Clone, Serialize, Deserialize, sqlx::FromRow)] +pub struct User { + pub id: i64, + pub password: String, + pub algo: String, + pub salt: Option, + pub avatar: String, // Go: NOT NULL + pub balance: i64, + pub refer_code: String, + pub referer_id: i64, // Go: 使用 0 表示无引荐人 + pub commission: i64, + pub referral_percentage: TinyUint, // Go uint8: 值范围 0-255 + pub only_first_purchase: bool, // Go: *bool default:true not null + pub gift_amount: i64, + pub enable: bool, // Go: *bool default:true not null + pub is_admin: bool, // Go: *bool default:false not null + pub enable_balance_notify: bool, // Go: *bool default:false not null + pub enable_login_notify: bool, // Go: *bool default:false not null + pub enable_subscribe_notify: bool, // Go: *bool default:false not null + pub enable_trade_notify: bool, // Go: *bool default:false not null + pub rules: Option, + pub created_at: i64, + pub updated_at: i64, + pub deleted_at: Option, +} + +/// User subscribe (`user_subscribe` table). +#[derive(Debug, Clone, Serialize, Deserialize, sqlx::FromRow)] +pub struct UserSubscribe { + pub id: i64, + pub user_id: i64, + pub order_id: i64, + pub subscribe_id: i64, + pub start_time: i64, + pub expire_time: i64, + pub finished_at: Option, + pub traffic: i64, + pub download: i64, + pub upload: i64, + pub token: String, + pub uuid: String, + pub status: TinyUint, // Go uint8: 值范围 0-255 + pub note: String, + pub created_at: i64, + pub updated_at: i64, +} + +/// User auth method (`user_auth_methods` table). +#[derive(Debug, Clone, Serialize, Deserialize, sqlx::FromRow)] +pub struct AuthMethods { + pub id: i64, + pub user_id: i64, + pub auth_type: String, + pub auth_identifier: String, + pub verified: bool, + pub created_at: i64, + pub updated_at: i64, +} + +/// User device (`user_device` table). +#[derive(Debug, Clone, Serialize, Deserialize, sqlx::FromRow)] +pub struct Device { + pub id: i64, + pub ip: String, + pub user_id: i64, + pub user_agent: Option, + pub identifier: String, + pub online: bool, + pub enabled: bool, + pub created_at: i64, + pub updated_at: i64, +} + +/// User device online record (`user_device_online_record` table). +#[derive(Debug, Clone, Serialize, Deserialize, sqlx::FromRow)] +pub struct DeviceOnlineRecord { + pub id: i64, + pub user_id: i64, + pub identifier: String, + pub online_time: i64, + pub offline_time: i64, + pub online_seconds: i64, + pub duration_days: i64, + pub created_at: i64, +} + +/// User withdrawal (`user_withdrawal` table). +#[derive(Debug, Clone, Serialize, Deserialize, sqlx::FromRow)] +pub struct Withdrawal { + pub id: i64, + pub user_id: i64, + pub amount: i64, + pub content: Option, + pub status: TinyUint, // Go uint8: 值范围 0-255 + pub reason: String, + pub created_at: i64, + pub updated_at: i64, +} diff --git a/src/model/mod.rs b/src/model/mod.rs new file mode 100644 index 00000000..7b03bec7 --- /dev/null +++ b/src/model/mod.rs @@ -0,0 +1,8 @@ +//! Domain models for the ppanel backend. +//! +//! Re-exports the database entities (see `entity/`) and the API DTOs +//! (see `dto.rs`) so callers can simply `use crate::model::*`. + +pub mod dto; + +pub mod entity; \ No newline at end of file diff --git a/src/queue/client.rs b/src/queue/client.rs new file mode 100644 index 00000000..d69ee174 --- /dev/null +++ b/src/queue/client.rs @@ -0,0 +1,80 @@ +//! Shared asynq queue client. +//! +//! Wraps `asynq::client::Client` in an `Arc` so it can be cheaply cloned +//! into `AppState` and shared across every handler. + +use std::sync::Arc; +use std::time::Duration; + +use anyhow::Context; +use serde::Serialize; + +use asynq::backend::RedisConnectionType; + +/// Thin, cheaply-cloneable wrapper around the asynq client. +#[derive(Clone)] +pub struct QueueClient { + inner: Arc, +} + +impl QueueClient { + /// Connect to Redis and build a new [`QueueClient`]. + pub async fn new(redis_url: &str) -> anyhow::Result { + let redis_cfg = + RedisConnectionType::single(redis_url).context("build redis connection for queue")?; + let client = asynq::client::Client::new(redis_cfg) + .await + .context("connect asynq queue client")?; + Ok(Self { + inner: Arc::new(client), + }) + } + + /// Enqueue a task for immediate processing. + pub async fn enqueue(&self, task_type: &str, payload: &[u8]) -> anyhow::Result<()> { + let task = + asynq::task::Task::new(task_type, payload).context("build asynq task")?; + self.inner + .enqueue(task) + .await + .context("enqueue task")?; + Ok(()) + } + + /// Enqueue a task with a JSON-serialisable payload for immediate processing. + pub async fn enqueue_json( + &self, + task_type: &str, + payload: &T, + ) -> anyhow::Result<()> { + let bytes = serde_json::to_vec(payload).context("serialize task payload")?; + self.enqueue(task_type, &bytes).await + } + + /// Enqueue a task to be processed after `delay`. + pub async fn enqueue_delayed( + &self, + task_type: &str, + payload: &[u8], + delay: Duration, + ) -> anyhow::Result<()> { + let task = + asynq::task::Task::new(task_type, payload).context("build asynq task")?; + self.inner + .enqueue_in(task, delay) + .await + .context("enqueue delayed task")?; + Ok(()) + } + + /// Enqueue a delayed task with a JSON-serialisable payload. + pub async fn enqueue_json_delayed( + &self, + task_type: &str, + payload: &T, + delay: Duration, + ) -> anyhow::Result<()> { + let bytes = serde_json::to_vec(payload).context("serialize task payload")?; + self.enqueue_delayed(task_type, &bytes, delay).await + } +} diff --git a/src/queue/handler/email.rs b/src/queue/handler/email.rs new file mode 100644 index 00000000..9ec1401b --- /dev/null +++ b/src/queue/handler/email.rs @@ -0,0 +1,22 @@ +use std::sync::Arc; + +use asynq::error::Result; +use asynq::task::Task; + +use crate::config::Config; +use crate::queue::service::email::{BatchEmailLogic, SendEmailLogic}; +use crate::repository::Repositories; + +pub async fn send_email(task: Task, repos: Arc, config: Arc) -> Result<()> { + SendEmailLogic::new(repos, config) + .execute(task.get_payload()) + .await + .map_err(|e| asynq::error::Error::other(e.to_string())) +} + +pub async fn batch_email(task: Task, repos: Arc, config: Arc) -> Result<()> { + BatchEmailLogic::new(repos, config) + .execute(task.get_payload()) + .await + .map_err(|e| asynq::error::Error::other(e.to_string())) +} diff --git a/src/queue/handler/mod.rs b/src/queue/handler/mod.rs new file mode 100644 index 00000000..68e7e2c6 --- /dev/null +++ b/src/queue/handler/mod.rs @@ -0,0 +1,88 @@ +use std::sync::Arc; + +use asynq::serve_mux::ServeMux; + +use crate::config::Config; +use crate::repository::Repositories; + +pub mod email; +pub mod order; +pub mod sms; +pub mod subscription; +pub mod task; +pub mod traffic; + +pub fn register_all(repos: Arc, config: Arc) -> ServeMux { + let mut mux = ServeMux::new(); + + // ── Email ───────────────────────────────────────────────────────────────── + let email_repos = Arc::clone(&repos); + let email_config = Arc::clone(&config); + mux.handle_async_func(crate::queue::types::FORTHWITH_SEND_EMAIL, move |task| { + email::send_email(task, Arc::clone(&email_repos), Arc::clone(&email_config)) + }); + let batch_repos = Arc::clone(&repos); + let batch_config = Arc::clone(&config); + mux.handle_async_func(crate::queue::types::SCHEDULED_BATCH_SEND_EMAIL, move |task| { + email::batch_email(task, Arc::clone(&batch_repos), Arc::clone(&batch_config)) + }); + + // ── SMS ─────────────────────────────────────────────────────────────────── + let sms_repos = Arc::clone(&repos); + let sms_config = Arc::clone(&config); + mux.handle_async_func(crate::queue::types::FORTHWITH_SEND_SMS, move |task| { + sms::send_sms(task, Arc::clone(&sms_repos), Arc::clone(&sms_config)) + }); + + // ── Order ───────────────────────────────────────────────────────────────── + let activate_repos = Arc::clone(&repos); + let activate_config = Arc::clone(&config); + mux.handle_async_func(crate::queue::types::FORTHWITH_ACTIVATE_ORDER, move |task| { + order::activate_order(task, Arc::clone(&activate_repos), Arc::clone(&activate_config)) + }); + let close_repos = Arc::clone(&repos); + mux.handle_async_func(crate::queue::types::DEFER_CLOSE_ORDER, move |task| { + order::defer_close_order(task, Arc::clone(&close_repos)) + }); + + // ── Traffic ─────────────────────────────────────────────────────────────── + mux.handle_func( + crate::queue::types::FORTHWITH_TRAFFIC_STATISTICS, + traffic::stub_traffic_statistics, + ); + mux.handle_func( + crate::queue::types::SCHEDULER_TOTAL_SERVER_DATA, + traffic::stub_server_data, + ); + mux.handle_func( + crate::queue::types::SCHEDULER_RESET_TRAFFIC, + traffic::stub_reset_traffic, + ); + mux.handle_func( + crate::queue::types::SCHEDULER_TRAFFIC_STAT, + traffic::stub_traffic_stat, + ); + + // ── Subscription ────────────────────────────────────────────────────────── + let sub_repos = Arc::clone(&repos); + let sub_config = Arc::clone(&config); + mux.handle_async_func( + crate::queue::types::SCHEDULER_CHECK_SUBSCRIPTION, + move |task| { + subscription::check_subscription( + task, + Arc::clone(&sub_repos), + Arc::clone(&sub_config), + ) + }, + ); + + // ── Quota task ──────────────────────────────────────────────────────────── + let quota_repos = Arc::clone(&repos); + let quota_config = Arc::clone(&config); + mux.handle_async_func(crate::queue::types::FORTHWITH_QUOTA_TASK, move |task| { + task::quota_task(task, Arc::clone("a_repos), Arc::clone("a_config)) + }); + + mux +} diff --git a/src/queue/handler/order.rs b/src/queue/handler/order.rs new file mode 100644 index 00000000..b5bc9130 --- /dev/null +++ b/src/queue/handler/order.rs @@ -0,0 +1,31 @@ +use asynq::error::Result; +use asynq::task::Task; +use std::sync::Arc; + +use crate::config::Config; +use crate::queue::service::order::{ActivateOrderLogic, DeferCloseOrderLogic, OrderTaskPayload}; +use crate::repository::Repositories; + +pub async fn activate_order( + task: Task, + repos: Arc, + config: Arc, +) -> Result<()> { + let payload = decode_payload(&task)?; + ActivateOrderLogic::new(repos, config) + .execute(payload) + .await + .map_err(|err| asynq::error::Error::other(err.to_string())) +} + +pub async fn defer_close_order(task: Task, repos: Arc) -> Result<()> { + let payload = decode_payload(&task)?; + DeferCloseOrderLogic::new(repos) + .execute(payload) + .await + .map_err(|err| asynq::error::Error::other(err.to_string())) +} + +fn decode_payload(task: &Task) -> Result { + task.get_payload_with_json() +} diff --git a/src/queue/handler/sms.rs b/src/queue/handler/sms.rs new file mode 100644 index 00000000..e0b6ec61 --- /dev/null +++ b/src/queue/handler/sms.rs @@ -0,0 +1,15 @@ +use std::sync::Arc; + +use asynq::error::Result; +use asynq::task::Task; + +use crate::config::Config; +use crate::queue::service::sms::SendSmsLogic; +use crate::repository::Repositories; + +pub async fn send_sms(task: Task, repos: Arc, config: Arc) -> Result<()> { + SendSmsLogic::new(repos, config) + .execute(task.get_payload()) + .await + .map_err(|e| asynq::error::Error::other(e.to_string())) +} diff --git a/src/queue/handler/subscription.rs b/src/queue/handler/subscription.rs new file mode 100644 index 00000000..5fe061fd --- /dev/null +++ b/src/queue/handler/subscription.rs @@ -0,0 +1,20 @@ +use std::sync::Arc; + +use asynq::error::Result; +use asynq::task::Task; + +use crate::config::Config; +use crate::queue::service::subscription::CheckSubscriptionLogic; +use crate::repository::Repositories; + +pub async fn check_subscription( + task: Task, + repos: Arc, + config: Arc, +) -> Result<()> { + let _ = task; + CheckSubscriptionLogic::new(repos, config) + .execute() + .await + .map_err(|e| asynq::error::Error::other(e.to_string())) +} diff --git a/src/queue/handler/task.rs b/src/queue/handler/task.rs new file mode 100644 index 00000000..d31bdc8b --- /dev/null +++ b/src/queue/handler/task.rs @@ -0,0 +1,15 @@ +use std::sync::Arc; + +use asynq::error::Result; +use asynq::task::Task; + +use crate::config::Config; +use crate::queue::service::task::QuotaTaskLogic; +use crate::repository::Repositories; + +pub async fn quota_task(task: Task, repos: Arc, config: Arc) -> Result<()> { + QuotaTaskLogic::new(repos, config) + .execute(task.get_payload()) + .await + .map_err(|e| asynq::error::Error::other(e.to_string())) +} diff --git a/src/queue/handler/traffic.rs b/src/queue/handler/traffic.rs new file mode 100644 index 00000000..b59ea667 --- /dev/null +++ b/src/queue/handler/traffic.rs @@ -0,0 +1,22 @@ +use asynq::error::Result; +use asynq::task::Task; + +pub fn stub_traffic_statistics(task: Task) -> Result<()> { + tracing::warn!("STUB traffic::statistics — task={}", task.get_type()); + Ok(()) +} + +pub fn stub_server_data(task: Task) -> Result<()> { + tracing::warn!("STUB traffic::server_data — task={}", task.get_type()); + Ok(()) +} + +pub fn stub_reset_traffic(task: Task) -> Result<()> { + tracing::warn!("STUB traffic::reset_traffic — task={}", task.get_type()); + Ok(()) +} + +pub fn stub_traffic_stat(task: Task) -> Result<()> { + tracing::warn!("STUB traffic::stat — task={}", task.get_type()); + Ok(()) +} diff --git a/src/queue/mod.rs b/src/queue/mod.rs new file mode 100644 index 00000000..1eae6875 --- /dev/null +++ b/src/queue/mod.rs @@ -0,0 +1,56 @@ +use std::sync::Arc; + +use asynq::backend::RedisConnectionType; +use asynq::config::ServerConfig; +use asynq::server::Server; + +use crate::config; +use crate::config::Config; +use crate::repository::Repositories; + +pub mod client; +pub mod handler; +pub mod service; +pub mod types; + +pub fn redis_url(cfg: &config::RedisConfig) -> String { + let db = cfg.db; + if cfg.pass.is_empty() { + format!("redis://{}/{}", cfg.host, db) + } else { + format!("redis://:{}@{}/{}", cfg.pass, cfg.host, db) + } +} + +pub struct Service { + server: Server, +} + +impl Service { + pub async fn new( + cfg: &Config, + repos: Arc, + ) -> anyhow::Result { + let redis_cfg = RedisConnectionType::single(redis_url(&cfg.redis))?; + let server_cfg = ServerConfig::new().concurrency(20); + let mut server = Server::new(redis_cfg, server_cfg).await?; + + let config = Arc::new(cfg.clone()); + let mut mux = handler::register_all(repos, config); + + mux.handle_func("*", |task: asynq::task::Task| { + tracing::warn!("unregistered task type: {}", task.get_type()); + Ok(()) + }); + + server.start(mux).await?; + tracing::info!("queue consumer started (concurrency=20)"); + + Ok(Self { server }) + } + + pub async fn shutdown(&mut self) -> anyhow::Result<()> { + self.server.shutdown().await?; + Ok(()) + } +} diff --git a/src/queue/service/email.rs b/src/queue/service/email.rs new file mode 100644 index 00000000..ebcd63d2 --- /dev/null +++ b/src/queue/service/email.rs @@ -0,0 +1,274 @@ +use std::sync::Arc; + +use serde::Deserialize; + +use crate::config::Config; +use crate::repository::Repositories; +use crate::service::telemetry::Telemetry; + +// ─── SendEmail payload ─────────────────────────────────────────────────────── + +#[derive(Debug, Clone, Deserialize)] +pub struct SendEmailPayload { + #[serde(rename = "type", default)] + pub type_: i16, + #[serde(rename = "email", default)] + pub email: String, + #[serde(default)] + pub subject: String, + /// Raw JSON value — content map forwarded to templates. + #[serde(default)] + pub content: serde_json::Value, +} + +// Email type constants (matches Go `queue/types`) +const EMAIL_TYPE_VERIFY: i16 = 1; +const EMAIL_TYPE_MAINTENANCE: i16 = 2; +const EMAIL_TYPE_EXPIRATION: i16 = 3; +const EMAIL_TYPE_TRAFFIC_EXCEED: i16 = 4; +const EMAIL_TYPE_CUSTOM: i16 = 5; + +/// Port of `server/queue/logic/email/sendEmailLogic.go`. +pub struct SendEmailLogic { + repos: Arc, + config: Arc, +} + +impl SendEmailLogic { + pub fn new(repos: Arc, config: Arc) -> Self { + Self { repos, config } + } + + pub async fn execute(&self, raw: &[u8]) -> anyhow::Result<()> { + let payload: SendEmailPayload = match serde_json::from_slice(raw) { + Ok(p) => p, + Err(e) => { + tracing::error!("[SendEmailLogic] deserialise payload: {e}"); + return Ok(()); + } + }; + + // Build sender + let sender = match email::new_sender( + &self.config.email.platform, + &self.config.email.platform_config, + &self.config.site.site_name, + ) { + Ok(s) => s, + Err(e) => { + tracing::error!("[SendEmailLogic] new_sender: {e}"); + return Ok(()); + } + }; + + // Render body from template based on email type + let body = match self.render_body(&payload) { + Some(b) => b, + None => return Ok(()), + }; + + let status: i16 = match sender.send(&[payload.email.clone()], &payload.subject, &body).await { + Ok(()) => 1, + Err(e) => { + tracing::error!("[SendEmailLogic] send failed to {}: {e}", payload.email); + 2 + } + }; + + Telemetry::email_message( + &self.repos, + 0, + &payload.email, + Some(payload.subject.clone()), + payload.content.clone(), + &self.config.email.platform, + "", + status, + ) + .await; + + Ok(()) + } + + fn render_body(&self, payload: &SendEmailPayload) -> Option { + let cfg = &self.config.email; + let tpl_src = match payload.type_ { + EMAIL_TYPE_VERIFY => cfg.verify_email_template.as_str(), + EMAIL_TYPE_MAINTENANCE => cfg.maintenance_email_template.as_str(), + EMAIL_TYPE_EXPIRATION => cfg.expiration_email_template.as_str(), + EMAIL_TYPE_TRAFFIC_EXCEED => cfg.traffic_exceed_email_template.as_str(), + EMAIL_TYPE_CUSTOM => { + // For custom type use the "content" field directly as HTML + if let Some(s) = payload.content.get("content").and_then(|v| v.as_str()) { + return Some(s.to_string()); + } + tracing::error!("[SendEmailLogic] custom email missing content string"); + return None; + } + other => { + tracing::error!("[SendEmailLogic] unknown email type {other}"); + return None; + } + }; + + let ctx = json_to_gtmpl(payload.content.clone()); + match gtmpl::template(tpl_src, ctx) { + Ok(rendered) => Some(rendered), + Err(e) => { + tracing::error!("[SendEmailLogic] template render (type={}): {e}", payload.type_); + None + } + } + } +} + +// ─── BatchEmail ────────────────────────────────────────────────────────────── + +/// Port of `server/queue/logic/email/batchEmailLogic.go`. +pub struct BatchEmailLogic { + repos: Arc, + config: Arc, +} + +impl BatchEmailLogic { + pub fn new(repos: Arc, config: Arc) -> Self { + Self { repos, config } + } + + pub async fn execute(&self, raw: &[u8]) -> anyhow::Result<()> { + if raw.is_empty() { + tracing::error!("[BatchEmailLogic] empty payload"); + return Ok(()); + } + + let task_id: i64 = match std::str::from_utf8(raw) + .ok() + .and_then(|s| s.trim().parse().ok()) + { + Some(id) => id, + None => { + tracing::error!("[BatchEmailLogic] invalid task ID in payload"); + return Ok(()); + } + }; + + let task_info = match self.repos.task.find_one(task_id).await { + Ok(t) => t, + Err(e) => { + tracing::error!("[BatchEmailLogic] find_one({task_id}): {e}"); + return Ok(()); + } + }; + + if task_info.status != 0 { + tracing::info!("[BatchEmailLogic] task {task_id} already processed (status={})", task_info.status); + return Ok(()); + } + + let sender = match email::new_sender( + &self.config.email.platform, + &self.config.email.platform_config, + &self.config.site.site_name, + ) { + Ok(s) => std::sync::Arc::from(s), + Err(e) => { + tracing::error!("[BatchEmailLogic] new_sender: {e}"); + return Ok(()); + } + }; + + // Use the global WorkerManager (created at startup) or create a local one + if let Some(mgr) = email::get_global_manager() { + mgr.add_worker(task_id).await; + } else { + // Fallback: create a transient manager backed by the task repo adapter + let repo_adapter = Arc::new(TaskRepoAdapter { + inner: self.repos.task.as_ref() as *const _, + }); + // SAFETY: We hold `self.repos` for the lifetime of this call. + // The adapter is only used within this async scope. + let mgr = email::WorkerManager::new(repo_adapter, sender); + mgr.add_worker(task_id).await; + } + + Ok(()) + } +} + +// ─── TaskRepo adapter bridging `email::manager::TaskRepo` → our repo ───────── + +use std::sync::Mutex; + +struct TaskRepoAdapter { + // raw pointer: only safe because the adapter is used within a single + // async scope where `repos.task` is guaranteed alive. + inner: *const dyn crate::repository::task::TaskRepo, +} + +unsafe impl Send for TaskRepoAdapter {} +unsafe impl Sync for TaskRepoAdapter {} + +#[async_trait::async_trait] +impl email::manager::TaskRepo for TaskRepoAdapter { + async fn find_one(&self, id: i64) -> Result { + // SAFETY: pointer is valid for the duration of the call (see above). + let repo = unsafe { &*self.inner }; + let t = repo.find_one(id).await?; + Ok(email::worker::TaskInfo { + id: t.id, + type_: t.type_, + scope: t.scope.clone().unwrap_or_default(), + content: t.content.clone().unwrap_or_default(), + status: t.status, + errors: t.errors.clone().unwrap_or_default(), + total: t.total, + current: t.current, + }) + } + + async fn update(&self, data: &email::worker::TaskInfo) -> Result<(), anyhow::Error> { + let repo = unsafe { &*self.inner }; + let mut t = repo.find_one(data.id).await?; + t.status = data.status; + t.current = data.current; + t.errors = if data.errors.is_empty() { None } else { Some(data.errors.clone()) }; + repo.update(&t).await?; + Ok(()) + } + + async fn update_status(&self, id: i64, status: i16) -> Result<(), anyhow::Error> { + let repo = unsafe { &*self.inner }; + repo.update_status(id, status).await?; + Ok(()) + } + + fn is_cancelled(&self, _id: i64) -> bool { + false + } +} + +fn json_to_gtmpl(v: serde_json::Value) -> gtmpl::Value { + use std::collections::HashMap; + match v { + serde_json::Value::Null => gtmpl::Value::Nil, + serde_json::Value::Bool(b) => gtmpl::Value::Bool(b), + serde_json::Value::Number(n) => { + if let Some(i) = n.as_i64() { + gtmpl::Value::Number(gtmpl_value::Number::from(i)) + } else if let Some(f) = n.as_f64() { + gtmpl::Value::Number(gtmpl_value::Number::from(f)) + } else { + gtmpl::Value::Number(gtmpl_value::Number::from(0_i64)) + } + } + serde_json::Value::String(s) => gtmpl::Value::String(s), + serde_json::Value::Array(arr) => { + gtmpl::Value::Array(arr.into_iter().map(json_to_gtmpl).collect()) + } + serde_json::Value::Object(map) => { + let m: HashMap = + map.into_iter().map(|(k, v)| (k, json_to_gtmpl(v))).collect(); + gtmpl::Value::Map(m) + } + } +} diff --git a/src/queue/service/mod.rs b/src/queue/service/mod.rs new file mode 100644 index 00000000..d93401e9 --- /dev/null +++ b/src/queue/service/mod.rs @@ -0,0 +1,7 @@ +/// Stub service module — port of `server/queue/logic/`. +pub mod email; +pub mod order; +pub mod sms; +pub mod subscription; +pub mod task; +pub mod traffic; diff --git a/src/queue/service/order.rs b/src/queue/service/order.rs new file mode 100644 index 00000000..a2300bbd --- /dev/null +++ b/src/queue/service/order.rs @@ -0,0 +1,285 @@ +use std::sync::Arc; + +use anyhow::{anyhow, Context}; +use chrono::{Datelike, Months, Utc}; +use serde::Deserialize; +use uuid::Uuid; + +use crate::config::Config; +use crate::model::entity::log::{BALANCE_TYPE_RECHARGE, COMMISSION_TYPE_PURCHASE, COMMISSION_TYPE_RENEWAL, RESET_SUBSCRIBE_TYPE_PAID}; +use crate::model::entity::order::Order; +use crate::model::entity::subscribe::Subscribe; +use crate::model::entity::user::{User, UserSubscribe}; +use crate::repository::Repositories; +use crate::service::telemetry::Telemetry; + +const ORDER_TYPE_SUBSCRIBE: i16 = 1; +const ORDER_TYPE_RENEWAL: i16 = 2; +const ORDER_TYPE_RESET_TRAFFIC: i16 = 3; +const ORDER_TYPE_RECHARGE: i16 = 4; + +const ORDER_STATUS_UNPAID: i16 = 1; +const ORDER_STATUS_PAID: i16 = 2; +const ORDER_STATUS_CANCELLED: i16 = 3; + +const USER_SUBSCRIBE_STATUS_ACTIVE: i16 = 1; + +#[derive(Debug, Clone, Deserialize)] +pub struct OrderTaskPayload { + pub order_id: i64, +} + +pub struct ActivateOrderLogic { + repos: Arc, + config: Arc, +} + +impl ActivateOrderLogic { + pub fn new(repos: Arc, config: Arc) -> Self { + Self { repos, config } + } + + pub async fn execute(&self, payload: OrderTaskPayload) -> anyhow::Result<()> { + let mut order = self.find_order(payload.order_id).await?; + if order.status != ORDER_STATUS_UNPAID { + return Ok(()); + } + + match order.type_ { + ORDER_TYPE_SUBSCRIBE => self.activate_new_subscription(&order).await?, + ORDER_TYPE_RENEWAL => self.activate_renewal(&order).await?, + ORDER_TYPE_RESET_TRAFFIC => self.activate_traffic_reset(&order).await?, + ORDER_TYPE_RECHARGE => self.activate_balance_recharge(&order).await?, + other => return Err(anyhow!("invalid order type: {other}")), + } + + order.status = ORDER_STATUS_PAID; + order.updated_at = now_ms(); + self.repos.order.update(&order).await?; + Ok(()) + } + + async fn find_order(&self, order_id: i64) -> anyhow::Result { + self.repos + .order + .find_one(order_id) + .await + .with_context(|| format!("find order {order_id}")) + } + + async fn activate_new_subscription(&self, order: &Order) -> anyhow::Result<()> { + let user = self.repos.user.find_one_user(order.user_id).await?; + let plan = self.repos.subscribe.find_one(order.subscribe_id).await?; + let user_subscribe = self.create_user_subscribe(order, &plan).await?; + + Telemetry::subscribe_access(&self.repos, user_subscribe.id, &user_subscribe.token, "", "").await; + self.apply_commission(&user, order, COMMISSION_TYPE_PURCHASE).await?; + Ok(()) + } + + async fn activate_renewal(&self, order: &Order) -> anyhow::Result<()> { + let user = self.repos.user.find_one_user(order.user_id).await?; + let plan = self.repos.subscribe.find_one(order.subscribe_id).await?; + let token = order + .subscribe_token + .as_deref() + .context("renewal order missing subscribe_token")?; + let mut user_subscribe = self.repos.user.find_one_subscribe_by_token(token).await?; + let now = Utc::now(); + let base_time = if user_subscribe.expire_time < now.timestamp_millis() { + now + } else { + datetime_from_ms(user_subscribe.expire_time) + }; + + if plan.renewal_reset || should_reset_for_renewal(user_subscribe.expire_time, now) { + user_subscribe.download = 0; + user_subscribe.upload = 0; + } + user_subscribe.expire_time = add_time(&plan.unit_time, order.quantity, base_time).timestamp_millis(); + user_subscribe.traffic = plan.traffic; + user_subscribe.finished_at = None; + user_subscribe.status = USER_SUBSCRIBE_STATUS_ACTIVE; + user_subscribe.updated_at = now.timestamp_millis(); + + let updated = self.repos.user.update_subscribe(&user_subscribe).await?; + Telemetry::subscribe_access(&self.repos, updated.id, &updated.token, "", "").await; + self.apply_commission(&user, order, COMMISSION_TYPE_RENEWAL).await?; + Ok(()) + } + + async fn activate_traffic_reset(&self, order: &Order) -> anyhow::Result<()> { + let token = order + .subscribe_token + .as_deref() + .context("traffic reset order missing subscribe_token")?; + let mut user_subscribe = self.repos.user.find_one_subscribe_by_token(token).await?; + user_subscribe.download = 0; + user_subscribe.upload = 0; + user_subscribe.status = USER_SUBSCRIBE_STATUS_ACTIVE; + user_subscribe.updated_at = now_ms(); + self.repos.user.update_subscribe(&user_subscribe).await?; + + Telemetry::reset_subscribe( + &self.repos, + order.user_id, + RESET_SUBSCRIBE_TYPE_PAID, + Some(order.order_no.clone()), + ) + .await; + Ok(()) + } + + async fn activate_balance_recharge(&self, order: &Order) -> anyhow::Result<()> { + let mut user = self.repos.user.find_one_user(order.user_id).await?; + user.balance += order.amount; + user.updated_at = now_ms(); + let updated = self.repos.user.update_user(&user).await?; + Telemetry::balance( + &self.repos, + updated.id, + BALANCE_TYPE_RECHARGE, + order.amount, + Some(order.order_no.clone()), + updated.balance, + ) + .await; + Ok(()) + } + + async fn create_user_subscribe(&self, order: &Order, plan: &Subscribe) -> anyhow::Result { + if plan.quota > 0 { + let current = self + .repos + .user + .count_user_subscribes_by_user_and_subscribe(order.user_id, order.subscribe_id) + .await?; + if current >= plan.quota { + return Err(anyhow!("subscribe quota limit exceeded")); + } + } + + let now = Utc::now(); + let user_subscribe = UserSubscribe { + id: 0, + user_id: order.user_id, + order_id: order.id, + subscribe_id: order.subscribe_id, + start_time: now.timestamp_millis(), + expire_time: add_time(&plan.unit_time, order.quantity, now).timestamp_millis(), + finished_at: None, + traffic: plan.traffic, + download: 0, + upload: 0, + token: format!("Order-{}-{}", order.order_no, Uuid::new_v4()), + uuid: Uuid::new_v4().to_string(), + status: USER_SUBSCRIBE_STATUS_ACTIVE, + note: String::new(), + created_at: now.timestamp_millis(), + updated_at: now.timestamp_millis(), + }; + + self.repos.user.insert_subscribe(&user_subscribe).await.map_err(Into::into) + } + + async fn apply_commission(&self, user: &User, order: &Order, commission_type: i32) -> anyhow::Result<()> { + if user.referer_id == 0 { + return Ok(()); + } + + let mut referer = self.repos.user.find_one_user(user.referer_id).await?; + let referral_percentage = if referer.referral_percentage > 0 { + i64::from(referer.referral_percentage) + } else { + self.config.invite.referral_percentage + }; + if referral_percentage == 0 { + return Ok(()); + } + + let only_first_purchase = if referer.referral_percentage > 0 { + referer.only_first_purchase + } else { + self.config.invite.only_first_purchase + }; + if only_first_purchase && !order.is_new { + return Ok(()); + } + + let commission_base = order.amount - order.fee_amount; + let commission = commission_base * referral_percentage / 100; + referer.commission += commission; + referer.updated_at = now_ms(); + self.repos.user.update_user(&referer).await?; + Telemetry::commission(&self.repos, referer.id, commission_type, commission, &order.order_no).await; + Ok(()) + } +} + +pub struct DeferCloseOrderLogic { + repos: Arc, +} + +impl DeferCloseOrderLogic { + pub fn new(repos: Arc) -> Self { + Self { repos } + } + + pub async fn execute(&self, payload: OrderTaskPayload) -> anyhow::Result<()> { + let order = self.repos.order.find_one(payload.order_id).await; + let mut order = match order { + Ok(order) => order, + Err(sqlx::Error::RowNotFound) => return Ok(()), + Err(err) => return Err(err.into()), + }; + + if order.status == ORDER_STATUS_UNPAID { + order.status = ORDER_STATUS_CANCELLED; + order.updated_at = now_ms(); + self.repos.order.update(&order).await?; + } + + Ok(()) + } +} + +fn now_ms() -> i64 { + Utc::now().timestamp_millis() +} + +fn datetime_from_ms(timestamp_ms: i64) -> chrono::DateTime { + match chrono::DateTime::::from_timestamp_millis(timestamp_ms) { + Some(datetime) => datetime, + None => Utc::now(), + } +} + +fn add_time(unit: &str, amount: i64, from: chrono::DateTime) -> chrono::DateTime { + match unit { + "hour" => from + chrono::Duration::hours(amount), + "day" => from + chrono::Duration::days(amount), + "week" => from + chrono::Duration::weeks(amount), + "month" => add_months(from, amount), + "year" => add_months(from, amount.saturating_mul(12)), + _ => from + chrono::Duration::days(amount), + } +} + +fn add_months(from: chrono::DateTime, amount: i64) -> chrono::DateTime { + if amount <= 0 { + return from; + } + + let months = match u32::try_from(amount) { + Ok(value) => value, + Err(_) => u32::MAX, + }; + match from.checked_add_months(Months::new(months)) { + Some(datetime) => datetime, + None => from, + } +} + +fn should_reset_for_renewal(expire_time_ms: i64, now: chrono::DateTime) -> bool { + datetime_from_ms(expire_time_ms).day() == now.day() +} diff --git a/src/queue/service/sms.rs b/src/queue/service/sms.rs new file mode 100644 index 00000000..be8c5932 --- /dev/null +++ b/src/queue/service/sms.rs @@ -0,0 +1,99 @@ +use std::sync::Arc; + +use serde::Deserialize; + +use crate::config::Config; +use crate::repository::Repositories; +use crate::service::telemetry::Telemetry; + +/// Port of `server/queue/logic/sms/sendSmsLogic.go`. + +#[derive(Debug, Clone, Deserialize)] +pub struct SendSmsPayload { + /// Country / area dial code, e.g. "86" + #[serde(rename = "TelephoneArea", default)] + pub telephone_area: String, + /// Phone number without country code + #[serde(rename = "Telephone", default)] + pub telephone: String, + /// Verification code string to send + #[serde(rename = "Content", default)] + pub content: String, + /// Expiry minutes (passed to some providers) + #[serde(rename = "Expire", default)] + pub expire: u32, + /// Message type tag (used for audit logging) + #[serde(rename = "Type", default)] + pub type_: i16, +} + +pub struct SendSmsLogic { + repos: Arc, + config: Arc, +} + +impl SendSmsLogic { + pub fn new(repos: Arc, config: Arc) -> Self { + Self { repos, config } + } + + pub async fn execute(&self, raw: &[u8]) -> anyhow::Result<()> { + let payload: SendSmsPayload = match serde_json::from_slice(raw) { + Ok(p) => p, + Err(e) => { + tracing::error!("[SendSmsLogic] deserialise payload: {e}"); + return Ok(()); + } + }; + + let platform = match sms::Platform::from_str(&self.config.mobile.platform) { + Some(p) => p, + None => { + tracing::error!( + "[SendSmsLogic] unsupported SMS platform: {}", + self.config.mobile.platform + ); + return Ok(()); + } + }; + + let sms_config: sms::SmsConfig = + match serde_json::from_str(&self.config.mobile.platform_config) { + Ok(c) => c, + Err(e) => { + tracing::error!("[SendSmsLogic] parse platform_config: {e}"); + return Ok(()); + } + }; + + let sender = sms::create_sender(platform, sms_config); + + let to = format!("+{}{}", payload.telephone_area, payload.telephone); + let status: i16 = match sender + .send(&payload.telephone_area, &payload.telephone, &payload.content, payload.expire) + .await + { + Ok(()) => { + tracing::info!("[SendSmsLogic] sent to {to}"); + 1 + } + Err(e) => { + tracing::error!("[SendSmsLogic] send to {to} failed: {e}"); + 2 + } + }; + + Telemetry::mobile_message( + &self.repos, + 0, + &to, + serde_json::json!({ "content": payload.content }), + &self.config.mobile.platform, + "", + status, + ) + .await; + + Ok(()) + } +} diff --git a/src/queue/service/subscription.rs b/src/queue/service/subscription.rs new file mode 100644 index 00000000..754df66d --- /dev/null +++ b/src/queue/service/subscription.rs @@ -0,0 +1,173 @@ +use std::sync::Arc; + +use chrono::Utc; + +use crate::config::Config; +use crate::repository::Repositories; + +/// Port of `server/queue/logic/subscription/checkSubscriptionLogic.go`. +/// +/// Two passes: +/// 1. Traffic-exceeded subscribes → mark status=2 +/// 2. Expired subscribes → mark status=3 +/// +/// For each affected subscribe, enqueue a SendEmail notification if the user +/// has an email auth method (best-effort, errors logged and skipped). +pub struct CheckSubscriptionLogic { + repos: Arc, + config: Arc, +} + +impl CheckSubscriptionLogic { + pub fn new(repos: Arc, config: Arc) -> Self { + Self { repos, config } + } + + pub async fn execute(&self) -> anyhow::Result<()> { + let now_ms = Utc::now().timestamp_millis(); + + // ── Pass 1: traffic exceeded ───────────────────────────────────────── + self.handle_traffic_exceeded(now_ms).await; + + // ── Pass 2: expired ────────────────────────────────────────────────── + self.handle_expired(now_ms).await; + + Ok(()) + } + + // ── traffic exceeded ───────────────────────────────────────────────────── + + async fn handle_traffic_exceeded(&self, now_ms: i64) { + let list = match self.repos.user.find_traffic_exceeded_subscribes().await { + Ok(l) => l, + Err(e) => { + tracing::error!("[CheckSubscription/Traffic] find_traffic_exceeded_subscribes: {e}"); + return; + } + }; + + if list.is_empty() { + tracing::info!("[CheckSubscription/Traffic] no traffic-exceeded subscribes"); + return; + } + + let ids: Vec = list.iter().map(|s| s.id).collect(); + + if let Err(e) = self + .repos + .user + .mark_subscribes_finished(&ids, 2, now_ms) + .await + { + tracing::error!("[CheckSubscription/Traffic] mark_subscribes_finished: {e}"); + return; + } + + tracing::info!( + "[CheckSubscription/Traffic] marked {} subscribes finished (traffic)", + ids.len() + ); + + // Enqueue notification emails (best-effort) + for sub in &list { + self.send_notification_email(sub.id, sub.user_id, "traffic").await; + } + } + + // ── expired ────────────────────────────────────────────────────────────── + + async fn handle_expired(&self, now_ms: i64) { + let list = match self.repos.user.find_expired_subscribes(now_ms).await { + Ok(l) => l, + Err(e) => { + tracing::error!("[CheckSubscription/Expire] find_expired_subscribes: {e}"); + return; + } + }; + + if list.is_empty() { + tracing::info!("[CheckSubscription/Expire] no expired subscribes"); + return; + } + + let ids: Vec = list.iter().map(|s| s.id).collect(); + + if let Err(e) = self + .repos + .user + .mark_subscribes_finished(&ids, 3, now_ms) + .await + { + tracing::error!("[CheckSubscription/Expire] mark_subscribes_finished: {e}"); + return; + } + + tracing::info!( + "[CheckSubscription/Expire] marked {} subscribes finished (expired)", + ids.len() + ); + + for sub in &list { + self.send_notification_email(sub.id, sub.user_id, "expired").await; + } + } + + // ── notification helper ─────────────────────────────────────────────────── + + async fn send_notification_email(&self, subscribe_id: i64, user_id: i64, kind: &str) { + // Look up the user's email auth method + let auth = match self.repos.user.find_auth_method_by_user_id("email", user_id).await { + Ok(Some(a)) => a, + Ok(None) => { + tracing::info!( + "[CheckSubscription] user {user_id} has no email auth method, skipping" + ); + return; + } + Err(e) => { + tracing::error!( + "[CheckSubscription] find_auth_method_by_user_id(user={user_id}): {e}" + ); + return; + } + }; + + let to = auth.auth_identifier; + + // Build the SendEmail payload matching Go `queue/types.SendEmailPayload` + let (email_type, subject) = if kind == "expired" { + (3i16, "Subscription Expired") + } else { + (4i16, "Subscription Traffic Exceeded") + }; + + let content = serde_json::json!({ + "SiteLogo": self.config.site.site_logo, + "SiteName": self.config.site.site_name, + }); + + let email_payload = serde_json::json!({ + "type": email_type, + "email": to, + "subject": subject, + "content": content, + }); + + let raw = match serde_json::to_vec(&email_payload) { + Ok(b) => b, + Err(e) => { + tracing::error!("[CheckSubscription] serialise email payload: {e}"); + return; + } + }; + + // Execute inline (no asynq client available in this service) + let email_logic = + super::email::SendEmailLogic::new(self.repos.clone(), self.config.clone()); + if let Err(e) = email_logic.execute(&raw).await { + tracing::error!( + "[CheckSubscription] send {kind} email for subscribe {subscribe_id}: {e}" + ); + } + } +} diff --git a/src/queue/service/task.rs b/src/queue/service/task.rs new file mode 100644 index 00000000..3c5a3fa3 --- /dev/null +++ b/src/queue/service/task.rs @@ -0,0 +1,252 @@ +use std::sync::Arc; + +use chrono::Utc; +use serde::Deserialize; + +use crate::config::Config; +use crate::model::entity::log::{GIFT_TYPE_INCREASE, RESET_SUBSCRIBE_TYPE_QUOTA}; +use crate::model::entity::task::{QuotaContent, QuotaScope, Task}; +use crate::repository::Repositories; +use crate::service::telemetry::Telemetry; + +/// Port of `server/queue/logic/task/quotaLogic.go`. +/// +/// Payload: raw bytes that are a decimal task ID string. +/// The actual work parameters live in `tasks.scope` / `tasks.content` columns. +pub struct QuotaTaskLogic { + repos: Arc, + config: Arc, +} + +impl QuotaTaskLogic { + pub fn new(repos: Arc, config: Arc) -> Self { + Self { repos, config } + } + + pub async fn execute(&self, raw: &[u8]) -> anyhow::Result<()> { + // ── 1. Parse task ID ──────────────────────────────────────────────── + let task_id: i64 = match std::str::from_utf8(raw) + .ok() + .and_then(|s| s.trim().parse().ok()) + { + Some(id) => id, + None => { + tracing::error!("[QuotaTaskLogic] invalid payload: {:?}", raw); + return Ok(()); + } + }; + + // ── 2. Fetch task record ──────────────────────────────────────────── + let mut task = match self.repos.task.find_one(task_id).await { + Ok(t) => t, + Err(e) => { + tracing::error!("[QuotaTaskLogic] find_one({task_id}): {e}"); + return Ok(()); + } + }; + + if task.status != 0 { + tracing::info!( + "[QuotaTaskLogic] task {task_id} already processed (status={})", + task.status + ); + return Ok(()); + } + + // ── 3. Parse scope + content ──────────────────────────────────────── + let scope: QuotaScope = match task + .scope + .as_deref() + .and_then(|s| serde_json::from_str(s).ok()) + { + Some(s) => s, + None => { + tracing::error!("[QuotaTaskLogic] failed to parse scope for task {task_id}"); + return Ok(()); + } + }; + + let content: QuotaContent = match task + .content + .as_deref() + .and_then(|s| serde_json::from_str(s).ok()) + { + Some(c) => c, + None => { + tracing::error!("[QuotaTaskLogic] failed to parse content for task {task_id}"); + return Ok(()); + } + }; + + // ── 4. Resolve subscriber IDs from scope ──────────────────────────── + let sub_ids = self.resolve_subscriber_ids(&scope).await; + + // ── 5. Fetch subscribe records ────────────────────────────────────── + let subscribes = match self.repos.user.find_subscribes_by_ids(&sub_ids).await { + Ok(v) => v, + Err(e) => { + tracing::error!("[QuotaTaskLogic] find_subscribes_by_ids: {e}"); + return Ok(()); + } + }; + + // ── 6. Process each subscribe ─────────────────────────────────────── + let now_ms = Utc::now().timestamp_millis(); + let mut errors: Vec = Vec::new(); + + for mut sub in subscribes { + let mut updated = false; + + // Extend expiry + if let Some(days) = content.days { + if days != 0 { + let base = if sub.expire_time == 0 || sub.expire_time < now_ms { + now_ms + } else { + sub.expire_time + }; + sub.expire_time = + chrono::DateTime::::from_timestamp_millis(base) + .unwrap_or_else(Utc::now) + .checked_add_signed(chrono::Duration::days(days)) + .unwrap_or_else(Utc::now) + .timestamp_millis(); + if sub.expire_time > now_ms && sub.status != 1 { + sub.status = 1; + } + updated = true; + } + } + + // Reset traffic + if content.reset_traffic { + sub.download = 0; + sub.upload = 0; + updated = true; + Telemetry::reset_subscribe( + &self.repos, + sub.user_id, + RESET_SUBSCRIBE_TYPE_QUOTA, + None, + ) + .await; + } + + // Gift amount + if let (Some(gift_type), Some(gift_value)) = (content.gift_type, content.gift_value) { + if gift_value != 0 { + if let Err(e) = self + .process_gift(sub.user_id, sub.id, sub.subscribe_id, gift_type, gift_value) + .await + { + tracing::error!( + "[QuotaTaskLogic] process_gift for subscribe {}: {e}", + sub.id + ); + errors.push(format!("subscribe {}: gift error: {e}", sub.id)); + } + } + } + + if updated { + sub.updated_at = now_ms; + if let Err(e) = self.repos.user.update_subscribe(&sub).await { + tracing::error!("[QuotaTaskLogic] update_subscribe({}): {e}", sub.id); + errors.push(format!("subscribe {}: update error: {e}", sub.id)); + } + } + } + + // ── 7. Finalize task record ───────────────────────────────────────── + let all_failed = !errors.is_empty() && errors.len() >= sub_ids.len(); + task.status = if all_failed { 3 } else { 2 }; + task.current = sub_ids.len() as i64; + if !errors.is_empty() { + task.errors = serde_json::to_string(&errors).ok(); + } + task.updated_at = now_ms; + + if let Err(e) = self.repos.task.update(&task).await { + tracing::error!("[QuotaTaskLogic] update task {task_id}: {e}"); + } + + Ok(()) + } + + // ── helper: resolve subscriber IDs from scope ───────────────────────────── + + async fn resolve_subscriber_ids(&self, scope: &QuotaScope) -> Vec { + // Direct list wins + if !scope.recipients.is_empty() { + return scope.recipients.clone(); + } + + // Filter by active/expired status + use crate::repository::user::SubscribeFilter; + let filter = SubscribeFilter { + subscribers: scope.subscribers.clone(), + is_active: scope.is_active, + start_time: scope.start_time, + end_time: scope.end_time, + }; + + match self.repos.user.query_subscribe_ids_by_filter(&filter).await { + Ok(ids) => ids, + Err(e) => { + tracing::error!("[QuotaTaskLogic] query_subscribe_ids_by_filter: {e}"); + vec![] + } + } + } + + // ── helper: apply gift to user balance ──────────────────────────────────── + + async fn process_gift( + &self, + user_id: i64, + subscribe_id: i64, + plan_subscribe_id: i64, + gift_type: i16, + gift_value: i64, + ) -> anyhow::Result<()> { + let mut user = self.repos.user.find_one_user(user_id).await?; + + let gift_amount: i64 = match gift_type { + 1 => gift_value, + 2 => { + // Percentage of plan unit price + let plan = self.repos.subscribe.find_one(plan_subscribe_id).await?; + if plan.unit_price > 0 { + (plan.unit_price as f64 * (gift_value as f64 / 100.0)) as i64 + } else { + 0 + } + } + other => { + return Err(anyhow::anyhow!("invalid gift_type {other}")); + } + }; + + if gift_amount <= 0 { + return Ok(()); + } + + user.gift_amount += gift_amount; + user.updated_at = Utc::now().timestamp_millis(); + let updated = self.repos.user.update_user(&user).await?; + + Telemetry::gift( + &self.repos, + user_id, + GIFT_TYPE_INCREASE, + "", + subscribe_id, + gift_amount, + updated.gift_amount, + Some("Quota task gift".to_string()), + ) + .await; + + Ok(()) + } +} diff --git a/src/queue/service/traffic.rs b/src/queue/service/traffic.rs new file mode 100644 index 00000000..454d9b2f --- /dev/null +++ b/src/queue/service/traffic.rs @@ -0,0 +1,266 @@ +use std::collections::HashMap; +use std::sync::Arc; + +use chrono::Utc; +use serde::{Deserialize, Serialize}; + +use crate::config::Config; +use crate::model::entity::log::{RESET_SUBSCRIBE_TYPE_AUTO, ServerTraffic, UserTraffic}; +use crate::model::entity::traffic::TrafficLog; +use crate::repository::Repositories; +use crate::service::telemetry::Telemetry; + +#[derive(Debug, Clone, Serialize, Deserialize)] +pub struct UserTrafficEntry { + #[serde(rename = "uid")] + pub sid: i64, + pub upload: i64, + pub download: i64, +} + +#[derive(Debug, Clone, Serialize, Deserialize)] +pub struct TrafficStatisticsPayload { + pub server_id: i64, + pub protocol: String, + pub logs: Vec, +} + +pub struct ResetTrafficLogic { + repos: Arc, +} + +impl ResetTrafficLogic { + pub fn new(repos: Arc) -> Self { + Self { repos } + } + + pub async fn execute(&self) -> anyhow::Result<()> { + let now_ms = Utc::now().timestamp_millis(); + let now_ts = Utc::now().timestamp(); + self.reset_by_cycle(3, now_ms, now_ts, "yearly").await; + self.reset_by_cycle(1, now_ms, now_ts, "first-of-month").await; + self.reset_by_cycle(2, now_ms, now_ts, "monthly").await; + Ok(()) + } + + async fn reset_by_cycle(&self, reset_cycle: i64, now_ms: i64, now_ts: i64, label: &str) { + let sub_ids = match self.repos.subscribe.query_reset_cycle_subscribe_ids(reset_cycle).await { + Ok(ids) => ids, + Err(e) => { + tracing::error!("[ResetTraffic] query_reset_cycle_subscribe_ids({label}) failed: {e}"); + return; + } + }; + if sub_ids.is_empty() { + return; + } + let user_sub_ids: Vec = match reset_cycle { + 1 => match self.repos.user.query_first_reset_subscribe_ids(&sub_ids, now_ts).await { + Ok(v) => v, + Err(e) => { tracing::error!("[ResetTraffic] query_first_reset_subscribe_ids failed: {e}"); return; } + }, + 2 => match self.repos.user.query_monthly_reset_subscribe_ids(&sub_ids, now_ms).await { + Ok(v) => v, + Err(e) => { tracing::error!("[ResetTraffic] query_monthly_reset_subscribe_ids failed: {e}"); return; } + }, + 3 => match self.repos.user.query_yearly_reset_subscribe_ids(&sub_ids, now_ts).await { + Ok(v) => v, + Err(e) => { tracing::error!("[ResetTraffic] query_yearly_reset_subscribe_ids failed: {e}"); return; } + }, + _ => return, + }; + if user_sub_ids.is_empty() { + return; + } + if let Err(e) = self.repos.user.reset_subscribe_traffic_by_ids(&user_sub_ids).await { + tracing::error!("[ResetTraffic] reset_subscribe_traffic_by_ids({label}) failed: {e}"); + return; + } + tracing::info!("[ResetTraffic] {label} reset: {} user-subscribes", user_sub_ids.len()); + let subs = match self.repos.user.find_subscribes_by_ids(&user_sub_ids).await { + Ok(v) => v, + Err(e) => { tracing::error!("[ResetTraffic] find_subscribes_by_ids({label}) failed: {e}"); return; } + }; + for sub in &subs { + Telemetry::reset_subscribe(&self.repos, sub.user_id, RESET_SUBSCRIBE_TYPE_AUTO, None).await; + } + } +} + +pub struct ServerDataLogic { + repos: Arc, + cache: Arc, +} + +impl ServerDataLogic { + pub fn new(repos: Arc, cache: Arc) -> Self { + Self { repos, cache } + } + + pub async fn execute(&self) -> anyhow::Result<()> { + let now = Utc::now(); + let today_ms = now.timestamp_millis(); + let yesterday_ms = (now - chrono::Duration::days(1)).timestamp_millis(); + + let top_servers_today = self.repos.traffic.top_servers_traffic_by_day(today_ms, 10).await.unwrap_or_else(|e| { tracing::error!("[ServerData] top_servers today: {e}"); vec![] }); + let top_users_today = self.repos.traffic.top_users_traffic_by_day(today_ms, 10).await.unwrap_or_else(|e| { tracing::error!("[ServerData] top_users today: {e}"); vec![] }); + let top_servers_yesterday = self.repos.traffic.top_servers_traffic_by_day(yesterday_ms, 10).await.unwrap_or_else(|e| { tracing::error!("[ServerData] top_servers yesterday: {e}"); vec![] }); + + let mut server_rank_today: HashMap = HashMap::new(); + for (i, s) in top_servers_today.iter().enumerate().take(10) { + server_rank_today.insert((i + 1) as u8, ServerTraffic { server_id: s.server_id, upload: s.upload, download: s.download, total: s.total }); + } + let mut server_rank_yesterday: HashMap = HashMap::new(); + for (i, s) in top_servers_yesterday.iter().enumerate().take(10) { + server_rank_yesterday.insert((i + 1) as u8, ServerTraffic { server_id: s.server_id, upload: s.upload, download: s.download, total: s.total }); + } + let mut user_rank_today: HashMap = HashMap::new(); + for (i, u) in top_users_today.iter().enumerate().take(10) { + user_rank_today.insert((i + 1) as u8, UserTraffic { subscribe_id: u.subscribe_id, user_id: u.user_id, upload: u.upload, download: u.download, total: u.total }); + } + + let daily = self.repos.traffic.query_traffic_by_day(today_ms).await.unwrap_or_default(); + let monthly = self.repos.traffic.query_traffic_by_monthly(today_ms).await.unwrap_or_default(); + + let snapshot = serde_json::json!({ + "server_traffic_ranking_today": server_rank_today, + "server_traffic_ranking_yesterday": server_rank_yesterday, + "user_traffic_ranking_today": user_rank_today, + "today_upload": daily.upload, + "today_download": daily.download, + "monthly_upload": monthly.upload, + "monthly_download": monthly.download, + "updated_at": today_ms, + }); + let json = serde_json::to_string(&snapshot)?; + if let Err(e) = self.cache.set_ex("server_count", &json, -1).await { + tracing::error!("[ServerData] cache set failed: {e}"); + } + Telemetry::server_traffic_rank(&self.repos, server_rank_today).await; + tracing::info!("[ServerData] snapshot updated"); + Ok(()) + } +} + +pub struct StatLogic { + repos: Arc, + config: Arc, +} + +impl StatLogic { + pub fn new(repos: Arc, config: Arc) -> Self { + Self { repos, config } + } + + pub async fn execute(&self) -> anyhow::Result<()> { + let now = Utc::now(); + let yesterday = now - chrono::Duration::days(1); + let start_ms = yesterday.date_naive().and_hms_opt(0, 0, 0).unwrap().and_utc().timestamp_millis(); + let end_ms = yesterday.date_naive().and_hms_opt(23, 59, 59).unwrap().and_utc().timestamp_millis() + 999; + + let user_traffic = self.repos.traffic.query_user_traffic_ranking(start_ms, end_ms).await + .map_err(|e| { tracing::error!("[StatLogic] query_user_traffic_ranking: {e}"); e })?; + + let mut user_rank: HashMap = HashMap::new(); + for (i, row) in user_traffic.iter().enumerate() { + let item = UserTraffic { subscribe_id: row.subscribe_id, user_id: row.user_id, upload: row.upload, download: row.download, total: row.total }; + if i < 10 { user_rank.insert((i + 1) as u8, item.clone()); } + Telemetry::subscribe_traffic(&self.repos, row.subscribe_id, row.download, row.upload).await; + } + Telemetry::user_traffic_rank(&self.repos, user_rank).await; + + let server_traffic = self.repos.traffic.query_server_traffic_ranking(start_ms, end_ms).await + .map_err(|e| { tracing::error!("[StatLogic] query_server_traffic_ranking: {e}"); e })?; + + let mut server_rank: HashMap = HashMap::new(); + for (i, row) in server_traffic.iter().enumerate() { + let item = ServerTraffic { server_id: row.server_id, upload: row.upload, download: row.download, total: row.total }; + if i < 10 { server_rank.insert((i + 1) as u8, item.clone()); } + Telemetry::server_traffic(&self.repos, row.server_id, row.download, row.upload).await; + } + Telemetry::server_traffic_rank(&self.repos, server_rank).await; + + let summary = self.repos.traffic.query_traffic_summary(start_ms, end_ms).await + .map_err(|e| { tracing::error!("[StatLogic] query_traffic_summary: {e}"); e })?; + Telemetry::traffic_stat(&self.repos, summary.upload, summary.download).await; + + if self.config.log.auto_clear { + let cutoff = (now - chrono::Duration::days(self.config.log.clear_days as i64)).timestamp_millis(); + if let Err(e) = self.repos.traffic.delete_before(cutoff).await { + tracing::error!("[StatLogic] delete_before: {e}"); + } + } + tracing::info!("[StatLogic] daily stat ↑{} ↓{}", summary.upload, summary.download); + Ok(()) + } +} + +pub struct TrafficStatisticsLogic { + repos: Arc, + config: Arc, +} + +impl TrafficStatisticsLogic { + pub fn new(repos: Arc, config: Arc) -> Self { + Self { repos, config } + } + + pub async fn execute(&self, payload: TrafficStatisticsPayload) -> anyhow::Result<()> { + if payload.logs.is_empty() { + return Ok(()); + } + let server = match self.repos.node.find_one_server(payload.server_id).await { + Ok(s) => s, + Err(e) => { tracing::error!("[TrafficStatistics] find_one_server({}): {e}", payload.server_id); return Ok(()); } + }; + let ratio = self.resolve_ratio(&server, &payload.protocol); + let threshold = self.config.node.traffic_report_threshold; + let now_ms = Utc::now().timestamp_millis(); + + for entry in &payload.logs { + if entry.sid == 0 { + tracing::warn!("[TrafficStatistics] entry sid=0, skipping"); + continue; + } + if entry.upload + entry.download <= threshold { + continue; + } + let sub = match self.repos.user.find_one_subscribe(entry.sid).await { + Ok(s) => s, + Err(e) => { tracing::warn!("[TrafficStatistics] find_one_subscribe({}): {e}", entry.sid); continue; } + }; + let d = (entry.download as f64 * ratio) as i64; + let u = (entry.upload as f64 * ratio) as i64; + if let Err(e) = self.repos.user.update_user_subscribe_with_traffic(sub.id, d, u).await { + tracing::warn!("[TrafficStatistics] update_user_subscribe_with_traffic({}): {e}", sub.id); + continue; + } + let log = TrafficLog { + id: 0, + server_id: payload.server_id, + user_id: sub.user_id, + subscribe_id: sub.subscribe_id, + upload: u, + download: d, + timestamp: now_ms, + }; + if let Err(e) = self.repos.traffic.insert(&log).await { + tracing::warn!("[TrafficStatistics] traffic insert(sid={}): {e}", entry.sid); + } + Telemetry::subscribe_traffic(&self.repos, entry.sid, d, u).await; + Telemetry::server_traffic(&self.repos, payload.server_id, d, u).await; + } + Ok(()) + } + + fn resolve_ratio(&self, server: &crate::model::entity::node::Server, protocol: &str) -> f64 { + let protocols: Vec = + serde_json::from_str(&server.protocols).unwrap_or_default(); + for p in &protocols { + if p.type_.eq_ignore_ascii_case(protocol) && p.ratio > 0.0 { + return p.ratio; + } + } + 1.0 + } +} diff --git a/src/queue/types.rs b/src/queue/types.rs new file mode 100644 index 00000000..c836e62e --- /dev/null +++ b/src/queue/types.rs @@ -0,0 +1,30 @@ +/// Task type constants, ported from `server/queue/types/*.go`. +/// +/// Prefix convention (matching Go): +/// - `scheduler:` — periodic tasks registered by the scheduler +/// - `forthwith:` — tasks enqueued immediately by HTTP handlers +/// - `defer:` — tasks enqueued with a delay +/// - `scheduled:` — tasks enqueued for a specific future time + +// ── scheduler ────────────────────────────────────────────────────────── +pub const SCHEDULER_CHECK_SUBSCRIPTION: &str = "scheduler:check:subscription"; +pub const SCHEDULER_TOTAL_SERVER_DATA: &str = "scheduler:total:server"; +pub const SCHEDULER_RESET_TRAFFIC: &str = "scheduler:reset:traffic"; +pub const SCHEDULER_TRAFFIC_STAT: &str = "scheduler:traffic:stat"; + +// ── order ────────────────────────────────────────────────────────────── +pub const FORTHWITH_ACTIVATE_ORDER: &str = "forthwith:activate:order"; +pub const DEFER_CLOSE_ORDER: &str = "defer:close:order"; + +// ── email ────────────────────────────────────────────────────────────── +pub const FORTHWITH_SEND_EMAIL: &str = "forthwith:send:email"; +pub const SCHEDULED_BATCH_SEND_EMAIL: &str = "scheduled:batch:send:email"; + +// ── sms ──────────────────────────────────────────────────────────────── +pub const FORTHWITH_SEND_SMS: &str = "forthwith:sms:send"; + +// ── server / traffic ─────────────────────────────────────────────────── +pub const FORTHWITH_TRAFFIC_STATISTICS: &str = "forthwith:traffic:statistics"; + +// ── task / quota ─────────────────────────────────────────────────────── +pub const FORTHWITH_QUOTA_TASK: &str = "forthwith:quota:task"; diff --git a/src/repository/ads/mod.rs b/src/repository/ads/mod.rs new file mode 100644 index 00000000..2c754ed3 --- /dev/null +++ b/src/repository/ads/mod.rs @@ -0,0 +1,19 @@ +use crate::model::entity::ads::Ads; + +#[async_trait::async_trait] +pub trait AdsRepo: Send + Sync { + async fn insert(&self, data: &Ads) -> Result; + async fn find_one(&self, id: i64) -> Result; + async fn update(&self, data: &Ads) -> Result; + async fn delete(&self, id: i64) -> Result; + async fn get_list_by_page( + &self, + page: i64, + size: i64, + status: Option, + search: Option<&str>, + ) -> Result<(i64, Vec), sqlx::Error>; +} + +pub mod pg; +pub mod mysql; diff --git a/src/repository/ads/mysql.rs b/src/repository/ads/mysql.rs new file mode 100644 index 00000000..11bb0ed3 --- /dev/null +++ b/src/repository/ads/mysql.rs @@ -0,0 +1,131 @@ +use crate::model::entity::ads::Ads; +use crate::repository::ads::AdsRepo; +use crate::repository::audit; + +pub struct MySqlAdsRepo { + pool: sqlx::MySqlPool, +} + +impl MySqlAdsRepo { + pub fn new(pool: sqlx::MySqlPool) -> Self { + Self { pool } + } +} + +#[async_trait::async_trait] +impl AdsRepo for MySqlAdsRepo { + async fn insert(&self, data: &Ads) -> Result { + let result = sqlx::query( + "INSERT INTO ads (title, `type`, content, description, target_url, start_time, end_time, status, created_at, updated_at) + VALUES (?, ?, ?, ?, ?, ?, ?, ?, ?, ?)", + ) + .bind(&data.title) + .bind(&data.type_) + .bind(&data.content) + .bind(&data.description) + .bind(&data.target_url) + .bind(data.start_time) + .bind(data.end_time) + .bind(data.status) + .bind(data.created_at) + .bind(data.updated_at) + .execute(&self.pool) + .await?; + + let id = result.last_insert_id() as i64; + sqlx::query_as::<_, Ads>("SELECT * FROM ads WHERE id = ?") + .bind(id) + .fetch_one(&self.pool) + .await + } + + async fn find_one(&self, id: i64) -> Result { + sqlx::query_as::<_, Ads>("SELECT * FROM ads WHERE id = ?") + .bind(id) + .fetch_one(&self.pool) + .await + } + + async fn update(&self, data: &Ads) -> Result { + sqlx::query( + "UPDATE ads SET title = ?, content = ?, description = ?, target_url = ?, + start_time = ?, end_time = ?, status = ?, updated_at = ? + WHERE id = ?", + ) + .bind(&data.title) + .bind(&data.content) + .bind(&data.description) + .bind(&data.target_url) + .bind(data.start_time) + .bind(data.end_time) + .bind(data.status) + .bind(data.updated_at) + .bind(data.id) + .execute(&self.pool) + .await?; + + sqlx::query_as::<_, Ads>("SELECT * FROM ads WHERE id = ?") + .bind(data.id) + .fetch_one(&self.pool) + .await + } + + async fn delete(&self, id: i64) -> Result { + let res = sqlx::query("DELETE FROM ads WHERE id = ?") + .bind(id) + .execute(&self.pool) + .await?; + Ok(res.rows_affected()) + } + + async fn get_list_by_page( + &self, + page: i64, + size: i64, + status: Option, + search: Option<&str>, + ) -> Result<(i64, Vec), sqlx::Error> { + let offset = (page - 1) * size; + + let mut clauses = Vec::new(); + if status.is_some() { + clauses.push("status = ?".to_string()); + } + if search.is_some() { + clauses.push("(LOWER(title) LIKE LOWER(?) OR LOWER(content) LIKE LOWER(?))".to_string()); + } + let where_str = if clauses.is_empty() { + String::new() + } else { + format!("WHERE {}", clauses.join(" AND ")) + }; + + let pattern = search.map(|s| format!("%{}%", s)); + + let count_sql = format!("SELECT COUNT(*) FROM ads {}", where_str); + let mut count_q = sqlx::query_as::<_, (i64,)>(audit(&count_sql)); + if let Some(v) = status { + count_q = count_q.bind(v); + } + if let Some(ref p) = pattern { + count_q = count_q.bind(p).bind(p); + } + let (total,) = count_q.fetch_one(&self.pool).await?; + + let list_sql = format!( + "SELECT * FROM ads {} ORDER BY id ASC LIMIT ? OFFSET ?", + where_str, + ); + let mut list_q = sqlx::query_as::<_, Ads>(audit(&list_sql)); + if let Some(v) = status { + list_q = list_q.bind(v); + } + if let Some(ref p) = pattern { + list_q = list_q.bind(p).bind(p); + } + list_q = list_q.bind(size).bind(offset); + let items = list_q.fetch_all(&self.pool).await?; + + Ok((total, items)) + } +} diff --git a/src/repository/ads/pg.rs b/src/repository/ads/pg.rs new file mode 100644 index 00000000..10dbf8a7 --- /dev/null +++ b/src/repository/ads/pg.rs @@ -0,0 +1,124 @@ +use crate::model::entity::ads::Ads; +use crate::repository::ads::AdsRepo; +use crate::repository::audit; + +pub struct PgAdsRepo { + pool: sqlx::PgPool, +} + +impl PgAdsRepo { + pub fn new(pool: sqlx::PgPool) -> Self { + Self { pool } + } +} + +#[async_trait::async_trait] +impl AdsRepo for PgAdsRepo { + async fn insert(&self, data: &Ads) -> Result { + sqlx::query_as::<_, Ads>( + r#"INSERT INTO ads (title, "type", content, description, target_url, start_time, end_time, status, created_at, updated_at) + VALUES ($1, $2, $3, $4, $5, $6, $7, $8, $9, $10) + RETURNING *"#, + ) + .bind(&data.title) + .bind(&data.type_) + .bind(&data.content) + .bind(&data.description) + .bind(&data.target_url) + .bind(data.start_time) + .bind(data.end_time) + .bind(data.status) + .bind(data.created_at) + .bind(data.updated_at) + .fetch_one(&self.pool) + .await + } + + async fn find_one(&self, id: i64) -> Result { + sqlx::query_as::<_, Ads>("SELECT * FROM ads WHERE id = $1") + .bind(id) + .fetch_one(&self.pool) + .await + } + + async fn update(&self, data: &Ads) -> Result { + sqlx::query_as::<_, Ads>( + r#"UPDATE ads SET title = $1, content = $2, description = $3, target_url = $4, + start_time = $5, end_time = $6, status = $7, updated_at = $8 + WHERE id = $9 RETURNING *"#, + ) + .bind(&data.title) + .bind(&data.content) + .bind(&data.description) + .bind(&data.target_url) + .bind(data.start_time) + .bind(data.end_time) + .bind(data.status) + .bind(data.updated_at) + .bind(data.id) + .fetch_one(&self.pool) + .await + } + + async fn delete(&self, id: i64) -> Result { + let res = sqlx::query("DELETE FROM ads WHERE id = $1") + .bind(id) + .execute(&self.pool) + .await?; + Ok(res.rows_affected()) + } + + async fn get_list_by_page( + &self, + page: i64, + size: i64, + status: Option, + search: Option<&str>, + ) -> Result<(i64, Vec), sqlx::Error> { + let offset = (page - 1) * size; + + let mut clauses = Vec::new(); + let mut idx = 0u32; + if status.is_some() { + idx += 1; + clauses.push(format!("status = ${}", idx)); + } + if search.is_some() { + idx += 1; + clauses.push(format!("(title ILIKE ${} OR content ILIKE ${})", idx, idx)); + } + let where_str = if clauses.is_empty() { + String::new() + } else { + format!("WHERE {}", clauses.join(" AND ")) + }; + + let count_sql = format!("SELECT COUNT(*) FROM ads {}", where_str); + let mut count_q = sqlx::query_as::<_, (i64,)>(audit(&count_sql)); + if let Some(v) = status { + count_q = count_q.bind(v); + } + if let Some(s) = search { + count_q = count_q.bind(format!("%{}%", s)); + } + let (total,) = count_q.fetch_one(&self.pool).await?; + + let list_sql = format!( + "SELECT * FROM ads {} ORDER BY id ASC LIMIT ${} OFFSET ${}", + where_str, + idx + 1, + idx + 2, + ); + let mut list_q = sqlx::query_as::<_, Ads>(audit(&list_sql)); + if let Some(v) = status { + list_q = list_q.bind(v); + } + if let Some(s) = search { + list_q = list_q.bind(format!("%{}%", s)); + } + list_q = list_q.bind(size).bind(offset); + let items = list_q.fetch_all(&self.pool).await?; + + Ok((total, items)) + } +} diff --git a/src/repository/announcement/mod.rs b/src/repository/announcement/mod.rs new file mode 100644 index 00000000..8833c2ab --- /dev/null +++ b/src/repository/announcement/mod.rs @@ -0,0 +1,21 @@ +use crate::model::entity::announcement::Announcement; + +#[async_trait::async_trait] +pub trait AnnouncementRepo: Send + Sync { + async fn insert(&self, data: &Announcement) -> Result; + async fn find_one(&self, id: i64) -> Result; + async fn update(&self, data: &Announcement) -> Result; + async fn delete(&self, id: i64) -> Result; + async fn get_list_by_page( + &self, + page: i64, + size: i64, + show: Option, + pinned: Option, + popup: Option, + search: Option<&str>, + ) -> Result<(i64, Vec), sqlx::Error>; +} + +pub mod pg; +pub mod mysql; diff --git a/src/repository/announcement/mysql.rs b/src/repository/announcement/mysql.rs new file mode 100644 index 00000000..ea8c13cb --- /dev/null +++ b/src/repository/announcement/mysql.rs @@ -0,0 +1,152 @@ +use crate::model::entity::announcement::Announcement; +use crate::repository::announcement::AnnouncementRepo; +use crate::repository::audit; + +pub struct MySqlAnnouncementRepo { + pool: sqlx::MySqlPool, +} + +impl MySqlAnnouncementRepo { + pub fn new(pool: sqlx::MySqlPool) -> Self { + Self { pool } + } +} + +#[async_trait::async_trait] +impl AnnouncementRepo for MySqlAnnouncementRepo { + async fn insert(&self, data: &Announcement) -> Result { + let result = sqlx::query( + "INSERT INTO announcement (title, content, `show`, pinned, popup, created_at, updated_at) + VALUES (?, ?, ?, ?, ?, ?, ?)", + ) + .bind(&data.title) + .bind(&data.content) + .bind(data.show) + .bind(data.pinned) + .bind(data.popup) + .bind(data.created_at) + .bind(data.updated_at) + .execute(&self.pool) + .await?; + + let id = result.last_insert_id() as i64; + sqlx::query_as::<_, Announcement>("SELECT * FROM announcement WHERE id = ?") + .bind(id) + .fetch_one(&self.pool) + .await + } + + async fn find_one(&self, id: i64) -> Result { + sqlx::query_as::<_, Announcement>("SELECT * FROM announcement WHERE id = ?") + .bind(id) + .fetch_one(&self.pool) + .await + } + + async fn update(&self, data: &Announcement) -> Result { + sqlx::query( + "UPDATE announcement SET title = ?, content = ?, `show` = ?, pinned = ?, popup = ?, updated_at = ? + WHERE id = ?", + ) + .bind(&data.title) + .bind(&data.content) + .bind(data.show) + .bind(data.pinned) + .bind(data.popup) + .bind(data.updated_at) + .bind(data.id) + .execute(&self.pool) + .await?; + + sqlx::query_as::<_, Announcement>("SELECT * FROM announcement WHERE id = ?") + .bind(data.id) + .fetch_one(&self.pool) + .await + } + + async fn delete(&self, id: i64) -> Result { + let res = sqlx::query("DELETE FROM announcement WHERE id = ?") + .bind(id) + .execute(&self.pool) + .await?; + Ok(res.rows_affected()) + } + + async fn get_list_by_page( + &self, + page: i64, + size: i64, + show: Option, + pinned: Option, + popup: Option, + search: Option<&str>, + ) -> Result<(i64, Vec), sqlx::Error> { + let offset = (page - 1) * size; + + let mut clauses = Vec::new(); + if show.is_some() { + clauses.push("`show` = ?".to_string()); + } + if pinned.is_some() { + clauses.push("pinned = ?".to_string()); + } + if popup.is_some() { + clauses.push("popup = ?".to_string()); + } + if search.is_some() { + clauses.push("(LOWER(title) LIKE LOWER(?) OR LOWER(content) LIKE LOWER(?))".to_string()); + } + let where_str = if clauses.is_empty() { + String::new() + } else { + format!("WHERE {}", clauses.join(" AND ")) + }; + + let order_by = if pinned.unwrap_or(true) { + "pinned DESC, id DESC" + } else { + "id DESC" + }; + + let pattern = search.map(|s| format!("%{}%", s)); + + let count_sql = format!("SELECT COUNT(*) FROM announcement {}", where_str); + let mut count_q = sqlx::query_as::<_, (i64,)>(audit(&count_sql)); + if let Some(v) = show { + count_q = count_q.bind(v); + } + if let Some(v) = pinned { + count_q = count_q.bind(v); + } + if let Some(v) = popup { + count_q = count_q.bind(v); + } + if let Some(ref p) = pattern { + count_q = count_q.bind(p).bind(p); + } + let (total,) = count_q.fetch_one(&self.pool).await?; + + let list_sql = format!( + "SELECT * FROM announcement {} ORDER BY {} LIMIT ? OFFSET ?", + where_str, + order_by, + ); + let mut list_q = sqlx::query_as::<_, Announcement>(audit(&list_sql)); + if let Some(v) = show { + list_q = list_q.bind(v); + } + if let Some(v) = pinned { + list_q = list_q.bind(v); + } + if let Some(v) = popup { + list_q = list_q.bind(v); + } + if let Some(ref p) = pattern { + list_q = list_q.bind(p).bind(p); + } + list_q = list_q.bind(size).bind(offset); + let items = list_q.fetch_all(&self.pool).await?; + + Ok((total, items)) + } +} diff --git a/src/repository/announcement/pg.rs b/src/repository/announcement/pg.rs new file mode 100644 index 00000000..22213daf --- /dev/null +++ b/src/repository/announcement/pg.rs @@ -0,0 +1,147 @@ +use crate::model::entity::announcement::Announcement; +use crate::repository::announcement::AnnouncementRepo; +use crate::repository::audit; + +pub struct PgAnnouncementRepo { + pool: sqlx::PgPool, +} + +impl PgAnnouncementRepo { + pub fn new(pool: sqlx::PgPool) -> Self { + Self { pool } + } +} + +#[async_trait::async_trait] +impl AnnouncementRepo for PgAnnouncementRepo { + async fn insert(&self, data: &Announcement) -> Result { + sqlx::query_as::<_, Announcement>( + r#"INSERT INTO announcement (title, content, "show", pinned, popup, created_at, updated_at) + VALUES ($1, $2, $3, $4, $5, $6, $7) + RETURNING *"#, + ) + .bind(&data.title) + .bind(&data.content) + .bind(data.show) + .bind(data.pinned) + .bind(data.popup) + .bind(data.created_at) + .bind(data.updated_at) + .fetch_one(&self.pool) + .await + } + + async fn find_one(&self, id: i64) -> Result { + sqlx::query_as::<_, Announcement>("SELECT * FROM announcement WHERE id = $1") + .bind(id) + .fetch_one(&self.pool) + .await + } + + async fn update(&self, data: &Announcement) -> Result { + sqlx::query_as::<_, Announcement>( + r#"UPDATE announcement SET title = $1, content = $2, "show" = $3, pinned = $4, popup = $5, updated_at = $6 + WHERE id = $7 RETURNING *"#, + ) + .bind(&data.title) + .bind(&data.content) + .bind(data.show) + .bind(data.pinned) + .bind(data.popup) + .bind(data.updated_at) + .bind(data.id) + .fetch_one(&self.pool) + .await + } + + async fn delete(&self, id: i64) -> Result { + let res = sqlx::query("DELETE FROM announcement WHERE id = $1") + .bind(id) + .execute(&self.pool) + .await?; + Ok(res.rows_affected()) + } + + async fn get_list_by_page( + &self, + page: i64, + size: i64, + show: Option, + pinned: Option, + popup: Option, + search: Option<&str>, + ) -> Result<(i64, Vec), sqlx::Error> { + let offset = (page - 1) * size; + + let mut clauses = Vec::new(); + let mut idx = 0u32; + if show.is_some() { + idx += 1; + clauses.push(format!("\"show\" = ${}", idx)); + } + if pinned.is_some() { + idx += 1; + clauses.push(format!("pinned = ${}", idx)); + } + if popup.is_some() { + idx += 1; + clauses.push(format!("popup = ${}", idx)); + } + if search.is_some() { + idx += 1; + clauses.push(format!("(title ILIKE ${} OR content ILIKE ${})", idx, idx)); + } + let where_str = if clauses.is_empty() { + String::new() + } else { + format!("WHERE {}", clauses.join(" AND ")) + }; + + let order_by = if pinned.unwrap_or(true) { + "pinned DESC, id DESC" + } else { + "id DESC" + }; + + let count_sql = format!("SELECT COUNT(*) FROM announcement {}", where_str); + let mut count_q = sqlx::query_as::<_, (i64,)>(audit(&count_sql)); + if let Some(v) = show { + count_q = count_q.bind(v); + } + if let Some(v) = pinned { + count_q = count_q.bind(v); + } + if let Some(v) = popup { + count_q = count_q.bind(v); + } + if let Some(s) = search { + count_q = count_q.bind(format!("%{}%", s)); + } + let (total,) = count_q.fetch_one(&self.pool).await?; + + let list_sql = format!( + "SELECT * FROM announcement {} ORDER BY {} LIMIT ${} OFFSET ${}", + where_str, + order_by, + idx + 1, + idx + 2, + ); + let mut list_q = sqlx::query_as::<_, Announcement>(audit(&list_sql)); + if let Some(v) = show { + list_q = list_q.bind(v); + } + if let Some(v) = pinned { + list_q = list_q.bind(v); + } + if let Some(v) = popup { + list_q = list_q.bind(v); + } + if let Some(s) = search { + list_q = list_q.bind(format!("%{}%", s)); + } + list_q = list_q.bind(size).bind(offset); + let items = list_q.fetch_all(&self.pool).await?; + + Ok((total, items)) + } +} diff --git a/src/repository/auth/mod.rs b/src/repository/auth/mod.rs new file mode 100644 index 00000000..e485999c --- /dev/null +++ b/src/repository/auth/mod.rs @@ -0,0 +1,15 @@ +use crate::model::entity::auth::Auth; + +#[async_trait::async_trait] +pub trait AuthRepo: Send + Sync { + async fn insert(&self, data: &Auth) -> Result; + async fn find_one(&self, id: i64) -> Result; + async fn update(&self, data: &Auth) -> Result; + async fn delete(&self, id: i64) -> Result; + async fn get_list(&self) -> Result, sqlx::Error>; + async fn find_one_by_method(&self, method: &str) -> Result; + async fn find_all_enabled(&self) -> Result, sqlx::Error>; +} + +pub mod pg; +pub mod mysql; diff --git a/src/repository/auth/mysql.rs b/src/repository/auth/mysql.rs new file mode 100644 index 00000000..a1b71d4c --- /dev/null +++ b/src/repository/auth/mysql.rs @@ -0,0 +1,88 @@ +use crate::model::entity::auth::Auth; +use crate::repository::auth::AuthRepo; + +pub struct MySqlAuthRepo { + pool: sqlx::MySqlPool, +} + +impl MySqlAuthRepo { + pub fn new(pool: sqlx::MySqlPool) -> Self { + Self { pool } + } +} + +#[async_trait::async_trait] +impl AuthRepo for MySqlAuthRepo { + async fn insert(&self, data: &Auth) -> Result { + let result = sqlx::query( + "INSERT INTO auth_method (method, config, enabled, created_at, updated_at) + VALUES (?, ?, ?, ?, ?)", + ) + .bind(&data.method) + .bind(&data.config) + .bind(data.enabled) + .bind(data.created_at) + .bind(data.updated_at) + .execute(&self.pool) + .await?; + + let id = result.last_insert_id() as i64; + sqlx::query_as::<_, Auth>("SELECT * FROM auth_method WHERE id = ?") + .bind(id) + .fetch_one(&self.pool) + .await + } + + async fn find_one(&self, id: i64) -> Result { + sqlx::query_as::<_, Auth>("SELECT * FROM auth_method WHERE id = ?") + .bind(id) + .fetch_one(&self.pool) + .await + } + + async fn update(&self, data: &Auth) -> Result { + sqlx::query( + "UPDATE auth_method SET method = ?, config = ?, enabled = ?, updated_at = ? + WHERE id = ?", + ) + .bind(&data.method) + .bind(&data.config) + .bind(data.enabled) + .bind(data.updated_at) + .bind(data.id) + .execute(&self.pool) + .await?; + + sqlx::query_as::<_, Auth>("SELECT * FROM auth_method WHERE id = ?") + .bind(data.id) + .fetch_one(&self.pool) + .await + } + + async fn delete(&self, id: i64) -> Result { + let res = sqlx::query("DELETE FROM auth_method WHERE id = ?") + .bind(id) + .execute(&self.pool) + .await?; + Ok(res.rows_affected()) + } + + async fn get_list(&self) -> Result, sqlx::Error> { + sqlx::query_as::<_, Auth>("SELECT * FROM auth_method") + .fetch_all(&self.pool) + .await + } + + async fn find_one_by_method(&self, method: &str) -> Result { + sqlx::query_as::<_, Auth>("SELECT * FROM auth_method WHERE method = ?") + .bind(method) + .fetch_one(&self.pool) + .await + } + + async fn find_all_enabled(&self) -> Result, sqlx::Error> { + sqlx::query_as::<_, Auth>("SELECT * FROM auth_method WHERE enabled = true") + .fetch_all(&self.pool) + .await + } +} diff --git a/src/repository/auth/pg.rs b/src/repository/auth/pg.rs new file mode 100644 index 00000000..f98317c3 --- /dev/null +++ b/src/repository/auth/pg.rs @@ -0,0 +1,78 @@ +use crate::model::entity::auth::Auth; +use crate::repository::auth::AuthRepo; + +pub struct PgAuthRepo { + pool: sqlx::PgPool, +} + +impl PgAuthRepo { + pub fn new(pool: sqlx::PgPool) -> Self { + Self { pool } + } +} + +#[async_trait::async_trait] +impl AuthRepo for PgAuthRepo { + async fn insert(&self, data: &Auth) -> Result { + sqlx::query_as::<_, Auth>( + "INSERT INTO auth_method (method, config, enabled, created_at, updated_at) + VALUES ($1, $2, $3, $4, $5) + RETURNING *", + ) + .bind(&data.method) + .bind(&data.config) + .bind(data.enabled) + .bind(data.created_at) + .bind(data.updated_at) + .fetch_one(&self.pool) + .await + } + + async fn find_one(&self, id: i64) -> Result { + sqlx::query_as::<_, Auth>("SELECT * FROM auth_method WHERE id = $1") + .bind(id) + .fetch_one(&self.pool) + .await + } + + async fn update(&self, data: &Auth) -> Result { + sqlx::query_as::<_, Auth>( + "UPDATE auth_method SET method = $1, config = $2, enabled = $3, updated_at = $4 + WHERE id = $5 RETURNING *", + ) + .bind(&data.method) + .bind(&data.config) + .bind(data.enabled) + .bind(data.updated_at) + .bind(data.id) + .fetch_one(&self.pool) + .await + } + + async fn delete(&self, id: i64) -> Result { + let res = sqlx::query("DELETE FROM auth_method WHERE id = $1") + .bind(id) + .execute(&self.pool) + .await?; + Ok(res.rows_affected()) + } + + async fn get_list(&self) -> Result, sqlx::Error> { + sqlx::query_as::<_, Auth>("SELECT * FROM auth_method") + .fetch_all(&self.pool) + .await + } + + async fn find_one_by_method(&self, method: &str) -> Result { + sqlx::query_as::<_, Auth>("SELECT * FROM auth_method WHERE method = $1") + .bind(method) + .fetch_one(&self.pool) + .await + } + + async fn find_all_enabled(&self) -> Result, sqlx::Error> { + sqlx::query_as::<_, Auth>("SELECT * FROM auth_method WHERE enabled = true") + .fetch_all(&self.pool) + .await + } +} diff --git a/src/repository/client/mod.rs b/src/repository/client/mod.rs new file mode 100644 index 00000000..f89ef52e --- /dev/null +++ b/src/repository/client/mod.rs @@ -0,0 +1,13 @@ +use crate::model::entity::client::SubscribeApplication; + +#[async_trait::async_trait] +pub trait ClientRepo: Send + Sync { + async fn insert(&self, data: &SubscribeApplication) -> Result; + async fn find_one(&self, id: i64) -> Result; + async fn update(&self, data: &SubscribeApplication) -> Result; + async fn delete(&self, id: i64) -> Result; + async fn list(&self) -> Result, sqlx::Error>; +} + +pub mod pg; +pub mod mysql; diff --git a/src/repository/client/mysql.rs b/src/repository/client/mysql.rs new file mode 100644 index 00000000..ae5905a5 --- /dev/null +++ b/src/repository/client/mysql.rs @@ -0,0 +1,87 @@ +use crate::model::entity::client::SubscribeApplication; +use crate::repository::client::ClientRepo; + +pub struct MySqlClientRepo { + pool: sqlx::MySqlPool, +} + +impl MySqlClientRepo { + pub fn new(pool: sqlx::MySqlPool) -> Self { + Self { pool } + } +} + +#[async_trait::async_trait] +impl ClientRepo for MySqlClientRepo { + async fn insert(&self, data: &SubscribeApplication) -> Result { + let result = sqlx::query( + "INSERT INTO subscribe_application (name, icon, description, scheme, user_agent, is_default, subscribe_template, output_format, download_link, created_at, updated_at) + VALUES (?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?)", + ) + .bind(&data.name) + .bind(&data.icon) + .bind(&data.description) + .bind(&data.scheme) + .bind(&data.user_agent) + .bind(data.is_default) + .bind(&data.subscribe_template) + .bind(&data.output_format) + .bind(&data.download_link) + .bind(data.created_at) + .bind(data.updated_at) + .execute(&self.pool) + .await?; + + let id = result.last_insert_id() as i64; + sqlx::query_as::<_, SubscribeApplication>("SELECT * FROM subscribe_application WHERE id = ?") + .bind(id) + .fetch_one(&self.pool) + .await + } + + async fn find_one(&self, id: i64) -> Result { + sqlx::query_as::<_, SubscribeApplication>("SELECT * FROM subscribe_application WHERE id = ?") + .bind(id) + .fetch_one(&self.pool) + .await + } + + async fn update(&self, data: &SubscribeApplication) -> Result { + sqlx::query( + "UPDATE subscribe_application SET name = ?, icon = ?, description = ?, scheme = ?, user_agent = ?, is_default = ?, subscribe_template = ?, output_format = ?, download_link = ?, updated_at = ? + WHERE id = ?", + ) + .bind(&data.name) + .bind(&data.icon) + .bind(&data.description) + .bind(&data.scheme) + .bind(&data.user_agent) + .bind(data.is_default) + .bind(&data.subscribe_template) + .bind(&data.output_format) + .bind(&data.download_link) + .bind(data.updated_at) + .bind(data.id) + .execute(&self.pool) + .await?; + + sqlx::query_as::<_, SubscribeApplication>("SELECT * FROM subscribe_application WHERE id = ?") + .bind(data.id) + .fetch_one(&self.pool) + .await + } + + async fn delete(&self, id: i64) -> Result { + let res = sqlx::query("DELETE FROM subscribe_application WHERE id = ?") + .bind(id) + .execute(&self.pool) + .await?; + Ok(res.rows_affected()) + } + + async fn list(&self) -> Result, sqlx::Error> { + sqlx::query_as::<_, SubscribeApplication>("SELECT * FROM subscribe_application ORDER BY id ASC") + .fetch_all(&self.pool) + .await + } +} diff --git a/src/repository/client/pg.rs b/src/repository/client/pg.rs new file mode 100644 index 00000000..ff33aea0 --- /dev/null +++ b/src/repository/client/pg.rs @@ -0,0 +1,77 @@ +use crate::model::entity::client::SubscribeApplication; +use crate::repository::client::ClientRepo; + +pub struct PgClientRepo { + pool: sqlx::PgPool, +} + +impl PgClientRepo { + pub fn new(pool: sqlx::PgPool) -> Self { + Self { pool } + } +} + +#[async_trait::async_trait] +impl ClientRepo for PgClientRepo { + async fn insert(&self, data: &SubscribeApplication) -> Result { + sqlx::query_as::<_, SubscribeApplication>( + "INSERT INTO subscribe_application (name, icon, description, scheme, user_agent, is_default, subscribe_template, output_format, download_link, created_at, updated_at) + VALUES ($1, $2, $3, $4, $5, $6, $7, $8, $9, $10, $11) + RETURNING *", + ) + .bind(&data.name) + .bind(&data.icon) + .bind(&data.description) + .bind(&data.scheme) + .bind(&data.user_agent) + .bind(data.is_default) + .bind(&data.subscribe_template) + .bind(&data.output_format) + .bind(&data.download_link) + .bind(data.created_at) + .bind(data.updated_at) + .fetch_one(&self.pool) + .await + } + + async fn find_one(&self, id: i64) -> Result { + sqlx::query_as::<_, SubscribeApplication>("SELECT * FROM subscribe_application WHERE id = $1") + .bind(id) + .fetch_one(&self.pool) + .await + } + + async fn update(&self, data: &SubscribeApplication) -> Result { + sqlx::query_as::<_, SubscribeApplication>( + "UPDATE subscribe_application SET name = $1, icon = $2, description = $3, scheme = $4, user_agent = $5, is_default = $6, subscribe_template = $7, output_format = $8, download_link = $9, updated_at = $10 + WHERE id = $11 RETURNING *", + ) + .bind(&data.name) + .bind(&data.icon) + .bind(&data.description) + .bind(&data.scheme) + .bind(&data.user_agent) + .bind(data.is_default) + .bind(&data.subscribe_template) + .bind(&data.output_format) + .bind(&data.download_link) + .bind(data.updated_at) + .bind(data.id) + .fetch_one(&self.pool) + .await + } + + async fn delete(&self, id: i64) -> Result { + let res = sqlx::query("DELETE FROM subscribe_application WHERE id = $1") + .bind(id) + .execute(&self.pool) + .await?; + Ok(res.rows_affected()) + } + + async fn list(&self) -> Result, sqlx::Error> { + sqlx::query_as::<_, SubscribeApplication>("SELECT * FROM subscribe_application ORDER BY id ASC") + .fetch_all(&self.pool) + .await + } +} diff --git a/src/repository/coupon/mod.rs b/src/repository/coupon/mod.rs new file mode 100644 index 00000000..bbebd224 --- /dev/null +++ b/src/repository/coupon/mod.rs @@ -0,0 +1,22 @@ +use crate::model::entity::coupon::Coupon; + +#[async_trait::async_trait] +pub trait CouponRepo: Send + Sync { + async fn insert(&self, data: &Coupon) -> Result; + async fn find_one(&self, id: i64) -> Result; + async fn find_one_by_code(&self, code: &str) -> Result; + async fn update(&self, data: &Coupon) -> Result; + async fn delete(&self, id: i64) -> Result; + async fn update_count(&self, code: &str) -> Result<(), sqlx::Error>; + async fn query_list_by_page( + &self, + page: i64, + size: i64, + subscribe: Option, + search: Option<&str>, + ) -> Result<(i64, Vec), sqlx::Error>; + async fn batch_delete(&self, ids: &[i64]) -> Result; +} + +pub mod pg; +pub mod mysql; diff --git a/src/repository/coupon/mysql.rs b/src/repository/coupon/mysql.rs new file mode 100644 index 00000000..d16606e5 --- /dev/null +++ b/src/repository/coupon/mysql.rs @@ -0,0 +1,169 @@ +use crate::model::entity::coupon::Coupon; +use crate::repository::coupon::CouponRepo; +use crate::repository::audit; + +pub struct MySqlCouponRepo { + pool: sqlx::MySqlPool, +} + +impl MySqlCouponRepo { + pub fn new(pool: sqlx::MySqlPool) -> Self { + Self { pool } + } +} + +#[async_trait::async_trait] +impl CouponRepo for MySqlCouponRepo { + async fn insert(&self, data: &Coupon) -> Result { + let result = sqlx::query( + "INSERT INTO coupon (name, code, count, `type`, discount, start_time, expire_time, user_limit, subscribe, used_count, enable, created_at, updated_at) + VALUES (?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?)", + ) + .bind(&data.name) + .bind(&data.code) + .bind(data.count) + .bind(data.type_) + .bind(data.discount) + .bind(data.start_time) + .bind(data.expire_time) + .bind(data.user_limit) + .bind(&data.subscribe) + .bind(data.used_count) + .bind(data.enable) + .bind(data.created_at) + .bind(data.updated_at) + .execute(&self.pool) + .await?; + + let id = result.last_insert_id() as i64; + sqlx::query_as::<_, Coupon>("SELECT * FROM coupon WHERE id = ?") + .bind(id) + .fetch_one(&self.pool) + .await + } + + async fn find_one(&self, id: i64) -> Result { + sqlx::query_as::<_, Coupon>("SELECT * FROM coupon WHERE id = ?") + .bind(id) + .fetch_one(&self.pool) + .await + } + + async fn find_one_by_code(&self, code: &str) -> Result { + sqlx::query_as::<_, Coupon>("SELECT * FROM coupon WHERE code = ?") + .bind(code) + .fetch_one(&self.pool) + .await + } + + async fn update(&self, data: &Coupon) -> Result { + sqlx::query( + "UPDATE coupon SET name = ?, code = ?, count = ?, `type` = ?, discount = ?, + start_time = ?, expire_time = ?, user_limit = ?, subscribe = ?, used_count = ?, + enable = ?, updated_at = ? + WHERE id = ?", + ) + .bind(&data.name) + .bind(&data.code) + .bind(data.count) + .bind(data.type_) + .bind(data.discount) + .bind(data.start_time) + .bind(data.expire_time) + .bind(data.user_limit) + .bind(&data.subscribe) + .bind(data.used_count) + .bind(data.enable) + .bind(data.updated_at) + .bind(data.id) + .execute(&self.pool) + .await?; + + sqlx::query_as::<_, Coupon>("SELECT * FROM coupon WHERE id = ?") + .bind(data.id) + .fetch_one(&self.pool) + .await + } + + async fn delete(&self, id: i64) -> Result { + let res = sqlx::query("DELETE FROM coupon WHERE id = ?") + .bind(id) + .execute(&self.pool) + .await?; + Ok(res.rows_affected()) + } + + async fn update_count(&self, code: &str) -> Result<(), sqlx::Error> { + let coupon = self.find_one_by_code(code).await?; + let mut updated = coupon.clone(); + updated.used_count += 1; + self.update(&updated).await?; + Ok(()) + } + + async fn query_list_by_page( + &self, + page: i64, + size: i64, + subscribe: Option, + search: Option<&str>, + ) -> Result<(i64, Vec), sqlx::Error> { + let offset = (page - 1) * size; + + let mut clauses = Vec::new(); + if subscribe.is_some() { + clauses.push("LOWER(subscribe) LIKE LOWER(?)".to_string()); + } + if search.is_some() { + clauses.push("(LOWER(name) LIKE LOWER(?) OR LOWER(code) LIKE LOWER(?))".to_string()); + } + let where_str = if clauses.is_empty() { + String::new() + } else { + format!("WHERE {}", clauses.join(" AND ")) + }; + + let subscribe_pattern = subscribe.map(|v| format!("%{}%", v)); + let search_pattern = search.map(|s| format!("%{}%", s)); + + let count_sql = format!("SELECT COUNT(*) FROM coupon {}", where_str); + let mut count_q = sqlx::query_as::<_, (i64,)>(audit(&count_sql)); + if let Some(ref p) = subscribe_pattern { + count_q = count_q.bind(p); + } + if let Some(ref p) = search_pattern { + count_q = count_q.bind(p).bind(p); + } + let (total,) = count_q.fetch_one(&self.pool).await?; + + let list_sql = format!( + "SELECT * FROM coupon {} ORDER BY id DESC LIMIT ? OFFSET ?", + where_str, + ); + let mut list_q = sqlx::query_as::<_, Coupon>(audit(&list_sql)); + if let Some(ref p) = subscribe_pattern { + list_q = list_q.bind(p); + } + if let Some(ref p) = search_pattern { + list_q = list_q.bind(p).bind(p); + } + list_q = list_q.bind(size).bind(offset); + let items = list_q.fetch_all(&self.pool).await?; + + Ok((total, items)) + } + + async fn batch_delete(&self, ids: &[i64]) -> Result { + if ids.is_empty() { + return Ok(0); + } + let placeholders: Vec = ids.iter().map(|_| "?".to_string()).collect(); + let sql = format!("DELETE FROM coupon WHERE id IN ({})", placeholders.join(", ")); + let mut q = sqlx::query(audit(&sql)); + for id in ids { + q = q.bind(id); + } + let res = q.execute(&self.pool).await?; + Ok(res.rows_affected()) + } +} diff --git a/src/repository/coupon/pg.rs b/src/repository/coupon/pg.rs new file mode 100644 index 00000000..9b2b849c --- /dev/null +++ b/src/repository/coupon/pg.rs @@ -0,0 +1,161 @@ +use crate::model::entity::coupon::Coupon; +use crate::repository::coupon::CouponRepo; +use crate::repository::audit; + +pub struct PgCouponRepo { + pool: sqlx::PgPool, +} + +impl PgCouponRepo { + pub fn new(pool: sqlx::PgPool) -> Self { + Self { pool } + } +} + +#[async_trait::async_trait] +impl CouponRepo for PgCouponRepo { + async fn insert(&self, data: &Coupon) -> Result { + sqlx::query_as::<_, Coupon>( + r#"INSERT INTO coupon (name, code, count, "type", discount, start_time, expire_time, user_limit, subscribe, used_count, enable, created_at, updated_at) + VALUES ($1, $2, $3, $4, $5, $6, $7, $8, $9, $10, $11, $12, $13) + RETURNING *"#, + ) + .bind(&data.name) + .bind(&data.code) + .bind(data.count) + .bind(data.type_) + .bind(data.discount) + .bind(data.start_time) + .bind(data.expire_time) + .bind(data.user_limit) + .bind(&data.subscribe) + .bind(data.used_count) + .bind(data.enable) + .bind(data.created_at) + .bind(data.updated_at) + .fetch_one(&self.pool) + .await + } + + async fn find_one(&self, id: i64) -> Result { + sqlx::query_as::<_, Coupon>("SELECT * FROM coupon WHERE id = $1") + .bind(id) + .fetch_one(&self.pool) + .await + } + + async fn find_one_by_code(&self, code: &str) -> Result { + sqlx::query_as::<_, Coupon>("SELECT * FROM coupon WHERE code = $1") + .bind(code) + .fetch_one(&self.pool) + .await + } + + async fn update(&self, data: &Coupon) -> Result { + sqlx::query_as::<_, Coupon>( + r#"UPDATE coupon SET name = $1, code = $2, count = $3, "type" = $4, discount = $5, + start_time = $6, expire_time = $7, user_limit = $8, subscribe = $9, used_count = $10, + enable = $11, updated_at = $12 + WHERE id = $13 RETURNING *"#, + ) + .bind(&data.name) + .bind(&data.code) + .bind(data.count) + .bind(data.type_) + .bind(data.discount) + .bind(data.start_time) + .bind(data.expire_time) + .bind(data.user_limit) + .bind(&data.subscribe) + .bind(data.used_count) + .bind(data.enable) + .bind(data.updated_at) + .bind(data.id) + .fetch_one(&self.pool) + .await + } + + async fn delete(&self, id: i64) -> Result { + let res = sqlx::query("DELETE FROM coupon WHERE id = $1") + .bind(id) + .execute(&self.pool) + .await?; + Ok(res.rows_affected()) + } + + async fn update_count(&self, code: &str) -> Result<(), sqlx::Error> { + let coupon = self.find_one_by_code(code).await?; + let mut updated = coupon.clone(); + updated.used_count += 1; + self.update(&updated).await?; + Ok(()) + } + + async fn query_list_by_page( + &self, + page: i64, + size: i64, + subscribe: Option, + search: Option<&str>, + ) -> Result<(i64, Vec), sqlx::Error> { + let offset = (page - 1) * size; + + let mut clauses = Vec::new(); + let mut idx = 0u32; + if subscribe.is_some() { + idx += 1; + clauses.push(format!("subscribe ILIKE ${}", idx)); + } + if search.is_some() { + idx += 1; + clauses.push(format!("(name ILIKE ${} OR code ILIKE ${})", idx, idx)); + } + let where_str = if clauses.is_empty() { + String::new() + } else { + format!("WHERE {}", clauses.join(" AND ")) + }; + + let count_sql = format!("SELECT COUNT(*) FROM coupon {}", where_str); + let mut count_q = sqlx::query_as::<_, (i64,)>(audit(&count_sql)); + if let Some(v) = subscribe { + count_q = count_q.bind(format!("%{}%", v)); + } + if let Some(s) = search { + count_q = count_q.bind(format!("%{}%", s)); + } + let (total,) = count_q.fetch_one(&self.pool).await?; + + let list_sql = format!( + "SELECT * FROM coupon {} ORDER BY id DESC LIMIT ${} OFFSET ${}", + where_str, + idx + 1, + idx + 2, + ); + let mut list_q = sqlx::query_as::<_, Coupon>(audit(&list_sql)); + if let Some(v) = subscribe { + list_q = list_q.bind(format!("%{}%", v)); + } + if let Some(s) = search { + list_q = list_q.bind(format!("%{}%", s)); + } + list_q = list_q.bind(size).bind(offset); + let items = list_q.fetch_all(&self.pool).await?; + + Ok((total, items)) + } + + async fn batch_delete(&self, ids: &[i64]) -> Result { + if ids.is_empty() { + return Ok(0); + } + let placeholders: Vec = (1..=ids.len()).map(|i| format!("${}", i)).collect(); + let sql = format!("DELETE FROM coupon WHERE id IN ({})", placeholders.join(", ")); + let mut q = sqlx::query(audit(&sql)); + for id in ids { + q = q.bind(id); + } + let res = q.execute(&self.pool).await?; + Ok(res.rows_affected()) + } +} diff --git a/src/repository/document/mod.rs b/src/repository/document/mod.rs new file mode 100644 index 00000000..3a899609 --- /dev/null +++ b/src/repository/document/mod.rs @@ -0,0 +1,21 @@ +use crate::model::entity::document::Document; + +#[async_trait::async_trait] +pub trait DocumentRepo: Send + Sync { + async fn insert(&self, data: &Document) -> Result; + async fn find_one(&self, id: i64) -> Result; + async fn update(&self, data: &Document) -> Result; + async fn delete(&self, id: i64) -> Result; + async fn query_detail(&self, id: i64) -> Result, sqlx::Error>; + async fn query_list( + &self, + page: i64, + size: i64, + tag: Option<&str>, + search: Option<&str>, + ) -> Result<(i64, Vec), sqlx::Error>; + async fn get_all_visible(&self) -> Result, sqlx::Error>; +} + +pub mod pg; +pub mod mysql; diff --git a/src/repository/document/mysql.rs b/src/repository/document/mysql.rs new file mode 100644 index 00000000..e2a08b37 --- /dev/null +++ b/src/repository/document/mysql.rs @@ -0,0 +1,137 @@ +use crate::model::entity::document::Document; +use crate::repository::document::DocumentRepo; +use crate::repository::audit; + +pub struct MySqlDocumentRepo { + pool: sqlx::MySqlPool, +} + +impl MySqlDocumentRepo { + pub fn new(pool: sqlx::MySqlPool) -> Self { + Self { pool } + } +} + +#[async_trait::async_trait] +impl DocumentRepo for MySqlDocumentRepo { + async fn insert(&self, data: &Document) -> Result { + let result = sqlx::query( + "INSERT INTO document (title, content, tags, `show`, created_at, updated_at) + VALUES (?, ?, ?, ?, ?, ?)", + ) + .bind(&data.title) + .bind(&data.content) + .bind(&data.tags) + .bind(data.show) + .bind(data.created_at) + .bind(data.updated_at) + .execute(&self.pool) + .await?; + + let id = result.last_insert_id() as i64; + sqlx::query_as::<_, Document>("SELECT * FROM document WHERE id = ?") + .bind(id) + .fetch_one(&self.pool) + .await + } + + async fn find_one(&self, id: i64) -> Result { + sqlx::query_as::<_, Document>("SELECT * FROM document WHERE id = ?") + .bind(id) + .fetch_one(&self.pool) + .await + } + + async fn update(&self, data: &Document) -> Result { + sqlx::query( + "UPDATE document SET title = ?, content = ?, tags = ?, `show` = ?, updated_at = ? + WHERE id = ?", + ) + .bind(&data.title) + .bind(&data.content) + .bind(&data.tags) + .bind(data.show) + .bind(data.updated_at) + .bind(data.id) + .execute(&self.pool) + .await?; + + sqlx::query_as::<_, Document>("SELECT * FROM document WHERE id = ?") + .bind(data.id) + .fetch_one(&self.pool) + .await + } + + async fn delete(&self, id: i64) -> Result { + let res = sqlx::query("DELETE FROM document WHERE id = ?") + .bind(id) + .execute(&self.pool) + .await?; + Ok(res.rows_affected()) + } + + async fn query_detail(&self, id: i64) -> Result, sqlx::Error> { + sqlx::query_as::<_, Document>("SELECT * FROM document WHERE id = ?") + .bind(id) + .fetch_optional(&self.pool) + .await + } + + async fn query_list( + &self, + page: i64, + size: i64, + tag: Option<&str>, + search: Option<&str>, + ) -> Result<(i64, Vec), sqlx::Error> { + let offset = (page - 1) * size; + + let mut clauses = Vec::new(); + if tag.is_some() { + clauses.push("LOWER(tags) LIKE LOWER(?)".to_string()); + } + if search.is_some() { + clauses.push("(LOWER(title) LIKE LOWER(?) OR LOWER(content) LIKE LOWER(?))".to_string()); + } + let where_str = if clauses.is_empty() { + String::new() + } else { + format!("WHERE {}", clauses.join(" AND ")) + }; + + let tag_pattern = tag.map(|t| format!("%{}%", t)); + let search_pattern = search.map(|s| format!("%{}%", s)); + + let count_sql = format!("SELECT COUNT(*) FROM document {}", where_str); + let mut count_q = sqlx::query_as::<_, (i64,)>(audit(&count_sql)); + if let Some(ref p) = tag_pattern { + count_q = count_q.bind(p); + } + if let Some(ref p) = search_pattern { + count_q = count_q.bind(p).bind(p); + } + let (total,) = count_q.fetch_one(&self.pool).await?; + + let list_sql = format!( + "SELECT * FROM document {} ORDER BY id DESC LIMIT ? OFFSET ?", + where_str, + ); + let mut list_q = sqlx::query_as::<_, Document>(audit(&list_sql)); + if let Some(ref p) = tag_pattern { + list_q = list_q.bind(p); + } + if let Some(ref p) = search_pattern { + list_q = list_q.bind(p).bind(p); + } + list_q = list_q.bind(size).bind(offset); + let items = list_q.fetch_all(&self.pool).await?; + + Ok((total, items)) + } + + async fn get_all_visible(&self) -> Result, sqlx::Error> { + sqlx::query_as::<_, Document>("SELECT * FROM document WHERE `show` = true ORDER BY id ASC") + .fetch_all(&self.pool) + .await + } +} diff --git a/src/repository/document/pg.rs b/src/repository/document/pg.rs new file mode 100644 index 00000000..387aed2b --- /dev/null +++ b/src/repository/document/pg.rs @@ -0,0 +1,129 @@ +use crate::model::entity::document::Document; +use crate::repository::document::DocumentRepo; +use crate::repository::audit; + +pub struct PgDocumentRepo { + pool: sqlx::PgPool, +} + +impl PgDocumentRepo { + pub fn new(pool: sqlx::PgPool) -> Self { + Self { pool } + } +} + +#[async_trait::async_trait] +impl DocumentRepo for PgDocumentRepo { + async fn insert(&self, data: &Document) -> Result { + sqlx::query_as::<_, Document>( + r#"INSERT INTO document (title, content, tags, "show", created_at, updated_at) + VALUES ($1, $2, $3, $4, $5, $6) + RETURNING *"#, + ) + .bind(&data.title) + .bind(&data.content) + .bind(&data.tags) + .bind(data.show) + .bind(data.created_at) + .bind(data.updated_at) + .fetch_one(&self.pool) + .await + } + + async fn find_one(&self, id: i64) -> Result { + sqlx::query_as::<_, Document>("SELECT * FROM document WHERE id = $1") + .bind(id) + .fetch_one(&self.pool) + .await + } + + async fn update(&self, data: &Document) -> Result { + sqlx::query_as::<_, Document>( + r#"UPDATE document SET title = $1, content = $2, tags = $3, "show" = $4, updated_at = $5 + WHERE id = $6 RETURNING *"#, + ) + .bind(&data.title) + .bind(&data.content) + .bind(&data.tags) + .bind(data.show) + .bind(data.updated_at) + .bind(data.id) + .fetch_one(&self.pool) + .await + } + + async fn delete(&self, id: i64) -> Result { + let res = sqlx::query("DELETE FROM document WHERE id = $1") + .bind(id) + .execute(&self.pool) + .await?; + Ok(res.rows_affected()) + } + + async fn query_detail(&self, id: i64) -> Result, sqlx::Error> { + sqlx::query_as::<_, Document>("SELECT * FROM document WHERE id = $1") + .bind(id) + .fetch_optional(&self.pool) + .await + } + + async fn query_list( + &self, + page: i64, + size: i64, + tag: Option<&str>, + search: Option<&str>, + ) -> Result<(i64, Vec), sqlx::Error> { + let offset = (page - 1) * size; + + let mut clauses = Vec::new(); + let mut idx = 0u32; + if tag.is_some() { + idx += 1; + clauses.push(format!("tags ILIKE ${}", idx)); + } + if search.is_some() { + idx += 1; + clauses.push(format!("(title ILIKE ${} OR content ILIKE ${})", idx, idx)); + } + let where_str = if clauses.is_empty() { + String::new() + } else { + format!("WHERE {}", clauses.join(" AND ")) + }; + + let count_sql = format!("SELECT COUNT(*) FROM document {}", where_str); + let mut count_q = sqlx::query_as::<_, (i64,)>(audit(&count_sql)); + if let Some(t) = tag { + count_q = count_q.bind(format!("%{}%", t)); + } + if let Some(s) = search { + count_q = count_q.bind(format!("%{}%", s)); + } + let (total,) = count_q.fetch_one(&self.pool).await?; + + let list_sql = format!( + "SELECT * FROM document {} ORDER BY id DESC LIMIT ${} OFFSET ${}", + where_str, + idx + 1, + idx + 2, + ); + let mut list_q = sqlx::query_as::<_, Document>(audit(&list_sql)); + if let Some(t) = tag { + list_q = list_q.bind(format!("%{}%", t)); + } + if let Some(s) = search { + list_q = list_q.bind(format!("%{}%", s)); + } + list_q = list_q.bind(size).bind(offset); + let items = list_q.fetch_all(&self.pool).await?; + + Ok((total, items)) + } + + async fn get_all_visible(&self) -> Result, sqlx::Error> { + sqlx::query_as::<_, Document>(r#"SELECT * FROM document WHERE "show" = true ORDER BY id ASC"#) + .fetch_all(&self.pool) + .await + } +} diff --git a/src/repository/log/mod.rs b/src/repository/log/mod.rs new file mode 100644 index 00000000..2108bc96 --- /dev/null +++ b/src/repository/log/mod.rs @@ -0,0 +1,21 @@ +use crate::model::entity::log::SystemLog; + +#[async_trait::async_trait] +pub trait LogRepo: Send + Sync { + async fn insert(&self, data: &SystemLog) -> Result; + async fn find_one(&self, id: i64) -> Result; + async fn filter_logs( + &self, + page: i64, + size: i64, + type_: Option, + date: Option<&str>, + object_id: Option, + search: Option<&str>, + ) -> Result<(Vec, i64), sqlx::Error>; + async fn find_first_by_date_type(&self, date: &str, type_: i16) -> Result, sqlx::Error>; + async fn find_by_dates_type(&self, dates: &[String], type_: i16) -> Result, sqlx::Error>; +} + +pub mod pg; +pub mod mysql; diff --git a/src/repository/log/mysql.rs b/src/repository/log/mysql.rs new file mode 100644 index 00000000..b54b0100 --- /dev/null +++ b/src/repository/log/mysql.rs @@ -0,0 +1,142 @@ +use crate::model::entity::log::SystemLog; +use crate::repository::log::LogRepo; +use crate::repository::audit; + +pub struct MySqlLogRepo { + pool: sqlx::MySqlPool, +} + +impl MySqlLogRepo { + pub fn new(pool: sqlx::MySqlPool) -> Self { + Self { pool } + } +} + +#[async_trait::async_trait] +impl LogRepo for MySqlLogRepo { + async fn insert(&self, data: &SystemLog) -> Result { + let result = sqlx::query( + "INSERT INTO system_logs (`type`, date, object_id, content, created_at) + VALUES (?, ?, ?, ?, ?)", + ) + .bind(data.type_) + .bind(&data.date) + .bind(data.object_id) + .bind(&data.content) + .bind(data.created_at) + .execute(&self.pool) + .await?; + + let id = result.last_insert_id() as i64; + sqlx::query_as::<_, SystemLog>("SELECT * FROM system_logs WHERE id = ?") + .bind(id) + .fetch_one(&self.pool) + .await + } + + async fn find_one(&self, id: i64) -> Result { + sqlx::query_as::<_, SystemLog>("SELECT * FROM system_logs WHERE id = ?") + .bind(id) + .fetch_one(&self.pool) + .await + } + + async fn filter_logs( + &self, + page: i64, + size: i64, + type_: Option, + date: Option<&str>, + object_id: Option, + search: Option<&str>, + ) -> Result<(Vec, i64), sqlx::Error> { + let mut page = page; + let mut size = size; + crate::repository::normalize_page(&mut page, &mut size); + let offset = (page - 1) * size; + + let mut clauses = Vec::new(); + if type_.is_some() { + clauses.push("`type` = ?".to_string()); + } + if date.is_some() { + clauses.push("date = ?".to_string()); + } + if object_id.is_some() { + clauses.push("object_id = ?".to_string()); + } + if search.is_some() { + clauses.push("LOWER(content) LIKE LOWER(?)".to_string()); + } + let where_str = if clauses.is_empty() { + String::new() + } else { + format!("WHERE {}", clauses.join(" AND ")) + }; + + let count_sql = format!("SELECT COUNT(*) FROM system_logs {}", where_str); + let mut count_q = sqlx::query_as::<_, (i64,)>(audit(&count_sql)); + if let Some(v) = type_ { + count_q = count_q.bind(v); + } + if let Some(d) = date { + count_q = count_q.bind(d); + } + if let Some(v) = object_id { + count_q = count_q.bind(v); + } + if let Some(s) = search { + count_q = count_q.bind(format!("%{}%", s)); + } + let (total,) = count_q.fetch_one(&self.pool).await?; + + let list_sql = format!( + "SELECT * FROM system_logs {} ORDER BY id DESC LIMIT ? OFFSET ?", + where_str, + ); + let mut list_q = sqlx::query_as::<_, SystemLog>(audit(&list_sql)); + if let Some(v) = type_ { + list_q = list_q.bind(v); + } + if let Some(d) = date { + list_q = list_q.bind(d); + } + if let Some(v) = object_id { + list_q = list_q.bind(v); + } + if let Some(s) = search { + list_q = list_q.bind(format!("%{}%", s)); + } + list_q = list_q.bind(size).bind(offset); + let items = list_q.fetch_all(&self.pool).await?; + + Ok((items, total)) + } + + async fn find_first_by_date_type(&self, date: &str, type_: i16) -> Result, sqlx::Error> { + sqlx::query_as::<_, SystemLog>( + "SELECT * FROM system_logs WHERE date = ? AND `type` = ? ORDER BY id ASC LIMIT 1", + ) + .bind(date) + .bind(type_) + .fetch_optional(&self.pool) + .await + } + + async fn find_by_dates_type(&self, dates: &[String], type_: i16) -> Result, sqlx::Error> { + if dates.is_empty() { + return Ok(Vec::new()); + } + let placeholders: Vec = dates.iter().map(|_| "?".to_string()).collect(); + let sql = format!( + "SELECT * FROM system_logs WHERE date IN ({}) AND `type` = ? ORDER BY id DESC", + placeholders.join(", "), + ); + let mut q = sqlx::query_as::<_, SystemLog>(audit(&sql)); + for d in dates { + q = q.bind(d); + } + q = q.bind(type_); + q.fetch_all(&self.pool).await + } +} diff --git a/src/repository/log/pg.rs b/src/repository/log/pg.rs new file mode 100644 index 00000000..13fab920 --- /dev/null +++ b/src/repository/log/pg.rs @@ -0,0 +1,145 @@ +use crate::model::entity::log::SystemLog; +use crate::repository::log::LogRepo; +use crate::repository::audit; + +pub struct PgLogRepo { + pool: sqlx::PgPool, +} + +impl PgLogRepo { + pub fn new(pool: sqlx::PgPool) -> Self { + Self { pool } + } +} + +#[async_trait::async_trait] +impl LogRepo for PgLogRepo { + async fn insert(&self, data: &SystemLog) -> Result { + sqlx::query_as::<_, SystemLog>( + r#"INSERT INTO system_logs ("type", date, object_id, content, created_at) + VALUES ($1, $2, $3, $4, $5) + RETURNING *"#, + ) + .bind(data.type_) + .bind(&data.date) + .bind(data.object_id) + .bind(&data.content) + .bind(data.created_at) + .fetch_one(&self.pool) + .await + } + + async fn find_one(&self, id: i64) -> Result { + sqlx::query_as::<_, SystemLog>("SELECT * FROM system_logs WHERE id = $1") + .bind(id) + .fetch_one(&self.pool) + .await + } + + async fn filter_logs( + &self, + page: i64, + size: i64, + type_: Option, + date: Option<&str>, + object_id: Option, + search: Option<&str>, + ) -> Result<(Vec, i64), sqlx::Error> { + let mut page = page; + let mut size = size; + crate::repository::normalize_page(&mut page, &mut size); + let offset = (page - 1) * size; + + let mut clauses = Vec::new(); + let mut idx = 0u32; + if type_.is_some() { + idx += 1; + clauses.push(format!(r#""type" = ${}"#, idx)); + } + if date.is_some() { + idx += 1; + clauses.push(format!("date = ${}", idx)); + } + if object_id.is_some() { + idx += 1; + clauses.push(format!("object_id = ${}", idx)); + } + if search.is_some() { + idx += 1; + clauses.push(format!("content ILIKE ${}", idx)); + } + let where_str = if clauses.is_empty() { + String::new() + } else { + format!("WHERE {}", clauses.join(" AND ")) + }; + + let count_sql = format!("SELECT COUNT(*) FROM system_logs {}", where_str); + let mut count_q = sqlx::query_as::<_, (i64,)>(audit(&count_sql)); + if let Some(v) = type_ { + count_q = count_q.bind(v); + } + if let Some(d) = date { + count_q = count_q.bind(d); + } + if let Some(v) = object_id { + count_q = count_q.bind(v); + } + if let Some(s) = search { + count_q = count_q.bind(format!("%{}%", s)); + } + let (total,) = count_q.fetch_one(&self.pool).await?; + + let list_sql = format!( + "SELECT * FROM system_logs {} ORDER BY id DESC LIMIT ${} OFFSET ${}", + where_str, + idx + 1, + idx + 2, + ); + let mut list_q = sqlx::query_as::<_, SystemLog>(audit(&list_sql)); + if let Some(v) = type_ { + list_q = list_q.bind(v); + } + if let Some(d) = date { + list_q = list_q.bind(d); + } + if let Some(v) = object_id { + list_q = list_q.bind(v); + } + if let Some(s) = search { + list_q = list_q.bind(format!("%{}%", s)); + } + list_q = list_q.bind(size).bind(offset); + let items = list_q.fetch_all(&self.pool).await?; + + Ok((items, total)) + } + + async fn find_first_by_date_type(&self, date: &str, type_: i16) -> Result, sqlx::Error> { + sqlx::query_as::<_, SystemLog>( + r#"SELECT * FROM system_logs WHERE date = $1 AND "type" = $2 ORDER BY id ASC LIMIT 1"#, + ) + .bind(date) + .bind(type_) + .fetch_optional(&self.pool) + .await + } + + async fn find_by_dates_type(&self, dates: &[String], type_: i16) -> Result, sqlx::Error> { + if dates.is_empty() { + return Ok(Vec::new()); + } + let placeholders: Vec = (1..=dates.len()).map(|i| format!("${}", i)).collect(); + let sql = format!( + r#"SELECT * FROM system_logs WHERE date IN ({}) AND "type" = ${} ORDER BY id DESC"#, + placeholders.join(", "), + dates.len() + 1, + ); + let mut q = sqlx::query_as::<_, SystemLog>(audit(&sql)); + for d in dates { + q = q.bind(d); + } + q = q.bind(type_); + q.fetch_all(&self.pool).await + } +} diff --git a/src/repository/mod.rs b/src/repository/mod.rs new file mode 100644 index 00000000..fa39c68d --- /dev/null +++ b/src/repository/mod.rs @@ -0,0 +1,202 @@ +//! Repository layer — trait-based with per-dialect implementations. +//! +//! Each domain module defines a trait (e.g. `AdsRepo`) and two implementations: +//! `pg::PgXxxRepo` for PostgreSQL and `mysql::MySqlXxxRepo` for MySQL. +//! [`Repositories`] bundles all domain repos behind `Box` so handler/service +//! code stays dialect-agnostic. + +pub mod ads; +pub mod announcement; +pub mod auth; +pub mod client; +pub mod coupon; +pub mod document; +pub mod log; +pub mod node; +pub mod order; +pub mod payment; +pub mod subscribe; +pub mod system; +pub mod task; +pub mod ticket; +pub mod traffic; +pub mod user; + +// ═══════════════════════════════════════════════════════════════════════════ +// Dialect +// ═══════════════════════════════════════════════════════════════════════════ + +#[derive(Debug, Clone, Copy, PartialEq, Eq)] +pub enum Dialect { + Postgres, + Mysql, +} + +impl Dialect { + pub fn from_driver(driver: &str) -> Self { + match driver.to_lowercase().as_str() { + "postgres" | "postgresql" | "pgsql" => Dialect::Postgres, + _ => Dialect::Mysql, + } + } + + pub fn from_url(url: &str) -> Self { + if url.starts_with("mysql") { + Dialect::Mysql + } else { + Dialect::Postgres + } + } +} + +// ═══════════════════════════════════════════════════════════════════════════ +// Db — dialect-tagged connection pool +// ═══════════════════════════════════════════════════════════════════════════ + +#[derive(Debug, Clone)] +pub enum Db { + Postgres(sqlx::Pool), + Mysql(sqlx::Pool), +} + +impl Db { + pub fn new_pg(pool: sqlx::Pool) -> Self { + Db::Postgres(pool) + } + + pub fn new_mysql(pool: sqlx::Pool) -> Self { + Db::Mysql(pool) + } + + pub fn dialect(&self) -> Dialect { + match self { + Db::Postgres(_) => Dialect::Postgres, + Db::Mysql(_) => Dialect::Mysql, + } + } + + pub fn pg_pool(&self) -> Option<&sqlx::Pool> { + match self { + Db::Postgres(p) => Some(p), + Db::Mysql(_) => None, + } + } + + pub fn mysql_pool(&self) -> Option<&sqlx::Pool> { + match self { + Db::Postgres(_) => None, + Db::Mysql(p) => Some(p), + } + } +} + +// ═══════════════════════════════════════════════════════════════════════════ +// Shared helpers +// ═══════════════════════════════════════════════════════════════════════════ + +/// Mark a dynamically-built SQL string as audited for injection safety. +/// +/// sqlx 0.9 gates `query*()` behind the `SqlSafeStr` trait, which is only +/// implemented for `&'static str` out of the box. Every query in this layer +/// is built with `format!` + bound parameters — no user input is ever +/// interpolated into the SQL text — so we wrap each dynamic string in +/// `AssertSqlSafe` here. +#[inline] +pub fn audit(sql: &str) -> sqlx::AssertSqlSafe<&str> { + sqlx::AssertSqlSafe(sql) +} + +/// Paginated result convenience wrapper. +#[derive(Debug, Clone)] +pub struct PageResult { + pub total: i64, + pub items: Vec, +} + +impl PageResult { + pub fn new(total: i64, items: Vec) -> Self { + Self { total, items } + } +} + +/// Normalise a (page, size) pair so the minimum page is 1 and the minimum +/// size is 10. +pub fn normalize_page(page: &mut i64, size: &mut i64) { + if *page < 1 { + *page = 1; + } + if *size < 1 { + *size = 10; + } +} + +// ═══════════════════════════════════════════════════════════════════════════ +// Repositories — trait-object bundle +// ═══════════════════════════════════════════════════════════════════════════ + +/// All domain repositories behind `Box` trait objects. +/// +/// Constructed once from a [`Db`] via [`Repositories::new`]; the caller +/// (typically `AppState`) holds it and handlers extract individual repos. +pub struct Repositories { + pub ads: Box, + pub announcement: Box, + pub auth: Box, + pub client: Box, + pub coupon: Box, + pub document: Box, + pub log: Box, + pub node: Box, + pub order: Box, + pub payment: Box, + pub subscribe: Box, + pub system: Box, + pub task: Box, + pub ticket: Box, + pub traffic: Box, + pub user: Box, +} + +impl Repositories { + /// Build the full repository set for the active database dialect. + pub fn new(db: Db) -> Self { + match db { + Db::Postgres(pool) => Self { + ads: Box::new(ads::pg::PgAdsRepo::new(pool.clone())), + announcement: Box::new(announcement::pg::PgAnnouncementRepo::new(pool.clone())), + auth: Box::new(auth::pg::PgAuthRepo::new(pool.clone())), + client: Box::new(client::pg::PgClientRepo::new(pool.clone())), + coupon: Box::new(coupon::pg::PgCouponRepo::new(pool.clone())), + document: Box::new(document::pg::PgDocumentRepo::new(pool.clone())), + log: Box::new(log::pg::PgLogRepo::new(pool.clone())), + node: Box::new(node::pg::PgNodeRepo::new(pool.clone())), + order: Box::new(order::pg::PgOrderRepo::new(pool.clone())), + payment: Box::new(payment::pg::PgPaymentRepo::new(pool.clone())), + subscribe: Box::new(subscribe::pg::PgSubscribeRepo::new(pool.clone())), + system: Box::new(system::pg::PgSystemRepo::new(pool.clone())), + task: Box::new(task::pg::PgTaskRepo::new(pool.clone())), + ticket: Box::new(ticket::pg::PgTicketRepo::new(pool.clone())), + traffic: Box::new(traffic::pg::PgTrafficRepo::new(pool.clone())), + user: Box::new(user::pg::PgUserRepo::new(pool)), + }, + Db::Mysql(pool) => Self { + ads: Box::new(ads::mysql::MySqlAdsRepo::new(pool.clone())), + announcement: Box::new(announcement::mysql::MySqlAnnouncementRepo::new(pool.clone())), + auth: Box::new(auth::mysql::MySqlAuthRepo::new(pool.clone())), + client: Box::new(client::mysql::MySqlClientRepo::new(pool.clone())), + coupon: Box::new(coupon::mysql::MySqlCouponRepo::new(pool.clone())), + document: Box::new(document::mysql::MySqlDocumentRepo::new(pool.clone())), + log: Box::new(log::mysql::MySqlLogRepo::new(pool.clone())), + node: Box::new(node::mysql::MySqlNodeRepo::new(pool.clone())), + order: Box::new(order::mysql::MySqlOrderRepo::new(pool.clone())), + payment: Box::new(payment::mysql::MySqlPaymentRepo::new(pool.clone())), + subscribe: Box::new(subscribe::mysql::MySqlSubscribeRepo::new(pool.clone())), + system: Box::new(system::mysql::MySqlSystemRepo::new(pool.clone())), + task: Box::new(task::mysql::MySqlTaskRepo::new(pool.clone())), + ticket: Box::new(ticket::mysql::MySqlTicketRepo::new(pool.clone())), + traffic: Box::new(traffic::mysql::MySqlTrafficRepo::new(pool.clone())), + user: Box::new(user::mysql::MySqlUserRepo::new(pool)), + }, + } + } +} diff --git a/src/repository/node/mod.rs b/src/repository/node/mod.rs new file mode 100644 index 00000000..74a6392d --- /dev/null +++ b/src/repository/node/mod.rs @@ -0,0 +1,65 @@ +use crate::model::entity::node::{Node, Server, ServerConfigOverride}; + +#[derive(Debug, Default)] +pub struct ServerFilter { + pub page: i64, + pub size: i64, + pub ids: Vec, + pub search: Option, +} + +#[derive(Debug, Default)] +pub struct NodeFilter { + pub page: i64, + pub size: i64, + pub node_ids: Vec, + pub server_ids: Vec, + pub tags: Vec, + pub search: Option, + pub protocol: Option, + pub enabled: Option, +} + +#[derive(Debug, Clone, sqlx::FromRow)] +pub struct SortItem { + pub id: i64, + pub sort: i64, +} + +#[async_trait::async_trait] +pub trait NodeRepo: Send + Sync { + async fn insert_server(&self, data: &Server) -> Result; + async fn find_one_server(&self, id: i64) -> Result; + async fn update_server(&self, data: &Server) -> Result; + async fn delete_server(&self, id: i64) -> Result; + async fn insert_node(&self, data: &Node) -> Result; + async fn find_one_node(&self, id: i64) -> Result; + async fn update_node(&self, data: &Node) -> Result; + async fn delete_node(&self, id: i64) -> Result; + async fn insert_override(&self, data: &ServerConfigOverride) -> Result; + async fn find_one_override(&self, id: i64) -> Result; + async fn find_override_by_server( + &self, + server_id: i64, + ) -> Result, sqlx::Error>; + async fn update_override(&self, data: &ServerConfigOverride) -> Result; + async fn delete_override(&self, id: i64) -> Result; + async fn filter_server_list(&self, filter: &ServerFilter) -> Result<(i64, Vec), sqlx::Error>; + async fn query_server_sorts(&self) -> Result, sqlx::Error>; + async fn update_server_sort(&self, id: i64, sort: i64) -> Result<(), sqlx::Error>; + async fn count_servers_by_report_status(&self, cutoff: i64) -> Result<(i64, i64), sqlx::Error>; + async fn query_server_addresses(&self) -> Result, sqlx::Error>; + async fn filter_node_list( + &self, + filter: &NodeFilter, + preload_server: bool, + ) -> Result<(i64, Vec), sqlx::Error>; + async fn query_node_sorts(&self) -> Result, sqlx::Error>; + async fn update_node_sort(&self, id: i64, sort: i64) -> Result<(), sqlx::Error>; + async fn query_node_tags(&self) -> Result, sqlx::Error>; + async fn count_enabled_nodes(&self) -> Result; + async fn query_enabled_node_protocols(&self) -> Result, sqlx::Error>; +} + +pub mod pg; +pub mod mysql; diff --git a/src/repository/node/mysql.rs b/src/repository/node/mysql.rs new file mode 100644 index 00000000..b7ad4746 --- /dev/null +++ b/src/repository/node/mysql.rs @@ -0,0 +1,442 @@ +use crate::model::entity::node::{Node, Server, ServerConfigOverride}; +use crate::repository::audit; +use crate::repository::node::{NodeFilter, NodeRepo, ServerFilter, SortItem}; + +pub struct MySqlNodeRepo { + pool: sqlx::MySqlPool, +} + +impl MySqlNodeRepo { + pub fn new(pool: sqlx::MySqlPool) -> Self { + Self { pool } + } +} + +#[async_trait::async_trait] +impl NodeRepo for MySqlNodeRepo { + async fn insert_server(&self, data: &Server) -> Result { + let result = sqlx::query( + "INSERT INTO servers (name, country, city, address, sort, protocols, last_reported_at, created_at, updated_at) + VALUES (?, ?, ?, ?, ?, ?, ?, ?, ?)", + ) + .bind(&data.name) + .bind(&data.country) + .bind(&data.city) + .bind(&data.address) + .bind(data.sort) + .bind(&data.protocols) + .bind(data.last_reported_at) + .bind(data.created_at) + .bind(data.updated_at) + .execute(&self.pool) + .await?; + + let id = result.last_insert_id() as i64; + sqlx::query_as::<_, Server>("SELECT * FROM servers WHERE id = ?") + .bind(id) + .fetch_one(&self.pool) + .await + } + + async fn find_one_server(&self, id: i64) -> Result { + sqlx::query_as::<_, Server>("SELECT * FROM servers WHERE id = ?") + .bind(id) + .fetch_one(&self.pool) + .await + } + + async fn update_server(&self, data: &Server) -> Result { + sqlx::query( + "UPDATE servers SET name = ?, country = ?, city = ?, address = ?, + sort = ?, protocols = ?, last_reported_at = ?, updated_at = ? + WHERE id = ?", + ) + .bind(&data.name) + .bind(&data.country) + .bind(&data.city) + .bind(&data.address) + .bind(data.sort) + .bind(&data.protocols) + .bind(data.last_reported_at) + .bind(data.updated_at) + .bind(data.id) + .execute(&self.pool) + .await?; + + sqlx::query_as::<_, Server>("SELECT * FROM servers WHERE id = ?") + .bind(data.id) + .fetch_one(&self.pool) + .await + } + + async fn delete_server(&self, id: i64) -> Result { + let res = sqlx::query("DELETE FROM servers WHERE id = ?") + .bind(id) + .execute(&self.pool) + .await?; + Ok(res.rows_affected()) + } + + async fn insert_node(&self, data: &Node) -> Result { + let result = sqlx::query( + "INSERT INTO nodes (name, tags, port, address, server_id, protocol, enabled, sort, created_at, updated_at) + VALUES (?, ?, ?, ?, ?, ?, ?, ?, ?, ?)", + ) + .bind(&data.name) + .bind(&data.tags) + .bind(data.port) + .bind(&data.address) + .bind(data.server_id) + .bind(&data.protocol) + .bind(data.enabled) + .bind(data.sort) + .bind(data.created_at) + .bind(data.updated_at) + .execute(&self.pool) + .await?; + + let id = result.last_insert_id() as i64; + sqlx::query_as::<_, Node>("SELECT * FROM nodes WHERE id = ?") + .bind(id) + .fetch_one(&self.pool) + .await + } + + async fn find_one_node(&self, id: i64) -> Result { + sqlx::query_as::<_, Node>("SELECT * FROM nodes WHERE id = ?") + .bind(id) + .fetch_one(&self.pool) + .await + } + + async fn update_node(&self, data: &Node) -> Result { + sqlx::query( + "UPDATE nodes SET name = ?, tags = ?, port = ?, address = ?, + server_id = ?, protocol = ?, enabled = ?, sort = ?, updated_at = ? + WHERE id = ?", + ) + .bind(&data.name) + .bind(&data.tags) + .bind(data.port) + .bind(&data.address) + .bind(data.server_id) + .bind(&data.protocol) + .bind(data.enabled) + .bind(data.sort) + .bind(data.updated_at) + .bind(data.id) + .execute(&self.pool) + .await?; + + sqlx::query_as::<_, Node>("SELECT * FROM nodes WHERE id = ?") + .bind(data.id) + .fetch_one(&self.pool) + .await + } + + async fn delete_node(&self, id: i64) -> Result { + let res = sqlx::query("DELETE FROM nodes WHERE id = ?") + .bind(id) + .execute(&self.pool) + .await?; + Ok(res.rows_affected()) + } + + async fn insert_override(&self, data: &ServerConfigOverride) -> Result { + let result = sqlx::query( + "INSERT INTO server_config_overrides (server_id, ip_strategy, dns, block, outbound, created_at, updated_at) + VALUES (?, ?, ?, ?, ?, ?, ?)", + ) + .bind(data.server_id) + .bind(&data.ip_strategy) + .bind(&data.dns) + .bind(&data.block) + .bind(&data.outbound) + .bind(data.created_at) + .bind(data.updated_at) + .execute(&self.pool) + .await?; + + let id = result.last_insert_id() as i64; + sqlx::query_as::<_, ServerConfigOverride>("SELECT * FROM server_config_overrides WHERE id = ?") + .bind(id) + .fetch_one(&self.pool) + .await + } + + async fn find_one_override(&self, id: i64) -> Result { + sqlx::query_as::<_, ServerConfigOverride>("SELECT * FROM server_config_overrides WHERE id = ?") + .bind(id) + .fetch_one(&self.pool) + .await + } + + async fn find_override_by_server( + &self, + server_id: i64, + ) -> Result, sqlx::Error> { + sqlx::query_as::<_, ServerConfigOverride>( + "SELECT * FROM server_config_overrides WHERE server_id = ?", + ) + .bind(server_id) + .fetch_optional(&self.pool) + .await + } + + async fn update_override(&self, data: &ServerConfigOverride) -> Result { + sqlx::query( + "UPDATE server_config_overrides SET ip_strategy = ?, dns = ?, block = ?, outbound = ?, updated_at = ? + WHERE id = ?", + ) + .bind(&data.ip_strategy) + .bind(&data.dns) + .bind(&data.block) + .bind(&data.outbound) + .bind(data.updated_at) + .bind(data.id) + .execute(&self.pool) + .await?; + + sqlx::query_as::<_, ServerConfigOverride>("SELECT * FROM server_config_overrides WHERE id = ?") + .bind(data.id) + .fetch_one(&self.pool) + .await + } + + async fn delete_override(&self, id: i64) -> Result { + let res = sqlx::query("DELETE FROM server_config_overrides WHERE id = ?") + .bind(id) + .execute(&self.pool) + .await?; + Ok(res.rows_affected()) + } + + async fn filter_server_list(&self, filter: &ServerFilter) -> Result<(i64, Vec), sqlx::Error> { + let mut page = filter.page; + let mut size = filter.size; + crate::repository::normalize_page(&mut page, &mut size); + let offset = (page - 1) * size; + + let mut clauses = Vec::new(); + if !filter.ids.is_empty() { + let placeholders: Vec = filter.ids.iter().map(|_| "?".to_string()).collect(); + clauses.push(format!("id IN ({})", placeholders.join(", "))); + } + if filter.search.is_some() { + clauses.push( + "(LOWER(name) LIKE LOWER(?) OR LOWER(address) LIKE LOWER(?))".to_string(), + ); + } + let where_str = if clauses.is_empty() { + String::new() + } else { + format!("WHERE {}", clauses.join(" AND ")) + }; + + let count_sql = format!("SELECT COUNT(*) FROM servers {}", where_str); + let mut count_q = sqlx::query_as::<_, (i64,)>(audit(&count_sql)); + for id in &filter.ids { + count_q = count_q.bind(id); + } + if let Some(s) = &filter.search { + let pattern = format!("%{}%", s); + count_q = count_q.bind(pattern.clone()).bind(pattern); + } + let (total,) = count_q.fetch_one(&self.pool).await?; + + let list_sql = format!( + "SELECT * FROM servers {} ORDER BY sort ASC LIMIT ? OFFSET ?", + where_str, + ); + let mut list_q = sqlx::query_as::<_, Server>(audit(&list_sql)); + for id in &filter.ids { + list_q = list_q.bind(id); + } + if let Some(s) = &filter.search { + let pattern = format!("%{}%", s); + list_q = list_q.bind(pattern.clone()).bind(pattern); + } + list_q = list_q.bind(size).bind(offset); + let items = list_q.fetch_all(&self.pool).await?; + + Ok((total, items)) + } + + async fn query_server_sorts(&self) -> Result, sqlx::Error> { + sqlx::query_as::<_, SortItem>( + "SELECT id, CAST(sort AS SIGNED) AS sort FROM servers ORDER BY sort ASC", + ) + .fetch_all(&self.pool) + .await + } + + async fn update_server_sort(&self, id: i64, sort: i64) -> Result<(), sqlx::Error> { + sqlx::query( + "UPDATE servers SET sort = ?, updated_at = UNIX_TIMESTAMP() * 1000 WHERE id = ?", + ) + .bind(sort) + .bind(id) + .execute(&self.pool) + .await?; + Ok(()) + } + + async fn count_servers_by_report_status(&self, cutoff: i64) -> Result<(i64, i64), sqlx::Error> { + let (online,) = sqlx::query_as::<_, (i64,)>("SELECT COUNT(*) FROM servers WHERE last_reported_at > ?") + .bind(cutoff) + .fetch_one(&self.pool) + .await?; + let (offline,) = sqlx::query_as::<_, (i64,)>( + "SELECT COUNT(*) FROM servers WHERE last_reported_at <= ? OR last_reported_at IS NULL", + ) + .bind(cutoff) + .fetch_one(&self.pool) + .await?; + Ok((online, offline)) + } + + async fn query_server_addresses(&self) -> Result, sqlx::Error> { + let rows: Vec<(String,)> = + sqlx::query_as("SELECT address FROM servers ORDER BY id ASC") + .fetch_all(&self.pool) + .await?; + Ok(rows.into_iter().map(|(a,)| a).collect()) + } + + async fn filter_node_list( + &self, + filter: &NodeFilter, + _preload_server: bool, + ) -> Result<(i64, Vec), sqlx::Error> { + let mut page = filter.page; + let mut size = filter.size; + crate::repository::normalize_page(&mut page, &mut size); + let offset = (page - 1) * size; + + let mut clauses = Vec::new(); + if !filter.node_ids.is_empty() { + let placeholders: Vec = filter.node_ids.iter().map(|_| "?".to_string()).collect(); + clauses.push(format!("id IN ({})", placeholders.join(", "))); + } + if !filter.server_ids.is_empty() { + let placeholders: Vec = filter.server_ids.iter().map(|_| "?".to_string()).collect(); + clauses.push(format!("server_id IN ({})", placeholders.join(", "))); + } + if !filter.tags.is_empty() { + let parts: Vec = filter + .tags + .iter() + .map(|_| "LOWER(tags) LIKE LOWER(?)".to_string()) + .collect(); + clauses.push(format!("({})", parts.join(" OR "))); + } + if filter.search.is_some() { + clauses.push( + "(LOWER(name) LIKE LOWER(?) OR LOWER(address) LIKE LOWER(?) OR LOWER(tags) LIKE LOWER(?))" + .to_string(), + ); + } + if filter.protocol.is_some() { + clauses.push("protocol = ?".to_string()); + } + if filter.enabled.is_some() { + clauses.push("enabled = ?".to_string()); + } + let where_str = if clauses.is_empty() { + String::new() + } else { + format!("WHERE {}", clauses.join(" AND ")) + }; + + let count_sql = format!("SELECT COUNT(*) FROM nodes {}", where_str); + let mut count_q = sqlx::query_as::<_, (i64,)>(audit(&count_sql)); + for id in &filter.node_ids { + count_q = count_q.bind(id); + } + for id in &filter.server_ids { + count_q = count_q.bind(id); + } + for t in &filter.tags { + count_q = count_q.bind(format!("%{}%", t)); + } + if let Some(s) = &filter.search { + let pattern = format!("%{}%", s); + count_q = count_q.bind(pattern.clone()).bind(pattern.clone()).bind(pattern); + } + if let Some(p) = &filter.protocol { + count_q = count_q.bind(p); + } + if let Some(e) = filter.enabled { + count_q = count_q.bind(e); + } + let (total,) = count_q.fetch_one(&self.pool).await?; + + let list_sql = format!( + "SELECT * FROM nodes {} ORDER BY sort ASC LIMIT ? OFFSET ?", + where_str, + ); + let mut list_q = sqlx::query_as::<_, Node>(audit(&list_sql)); + for id in &filter.node_ids { + list_q = list_q.bind(id); + } + for id in &filter.server_ids { + list_q = list_q.bind(id); + } + for t in &filter.tags { + list_q = list_q.bind(format!("%{}%", t)); + } + if let Some(s) = &filter.search { + let pattern = format!("%{}%", s); + list_q = list_q.bind(pattern.clone()).bind(pattern.clone()).bind(pattern); + } + if let Some(p) = &filter.protocol { + list_q = list_q.bind(p); + } + if let Some(e) = filter.enabled { + list_q = list_q.bind(e); + } + list_q = list_q.bind(size).bind(offset); + let items = list_q.fetch_all(&self.pool).await?; + + Ok((total, items)) + } + + async fn query_node_sorts(&self) -> Result, sqlx::Error> { + sqlx::query_as::<_, SortItem>( + "SELECT id, CAST(sort AS SIGNED) AS sort FROM nodes ORDER BY sort ASC", + ) + .fetch_all(&self.pool) + .await + } + + async fn update_node_sort(&self, id: i64, sort: i64) -> Result<(), sqlx::Error> { + sqlx::query("UPDATE nodes SET sort = ? WHERE id = ?") + .bind(sort) + .bind(id) + .execute(&self.pool) + .await?; + Ok(()) + } + + async fn query_node_tags(&self) -> Result, sqlx::Error> { + let rows: Vec<(String,)> = sqlx::query_as("SELECT tags FROM nodes ORDER BY id ASC") + .fetch_all(&self.pool) + .await?; + Ok(rows.into_iter().map(|(t,)| t).collect()) + } + + async fn count_enabled_nodes(&self) -> Result { + let (count,) = sqlx::query_as::<_, (i64,)>("SELECT COUNT(*) FROM nodes WHERE enabled = true") + .fetch_one(&self.pool) + .await?; + Ok(count) + } + + async fn query_enabled_node_protocols(&self) -> Result, sqlx::Error> { + let rows: Vec<(String,)> = + sqlx::query_as("SELECT protocol FROM nodes WHERE enabled = true GROUP BY protocol") + .fetch_all(&self.pool) + .await?; + Ok(rows.into_iter().map(|(p,)| p).collect()) + } +} diff --git a/src/repository/node/pg.rs b/src/repository/node/pg.rs new file mode 100644 index 00000000..555bc241 --- /dev/null +++ b/src/repository/node/pg.rs @@ -0,0 +1,438 @@ +use crate::model::entity::node::{Node, Server, ServerConfigOverride}; +use crate::repository::audit; +use crate::repository::node::{NodeFilter, NodeRepo, ServerFilter, SortItem}; + +pub struct PgNodeRepo { + pool: sqlx::PgPool, +} + +impl PgNodeRepo { + pub fn new(pool: sqlx::PgPool) -> Self { + Self { pool } + } +} + +#[async_trait::async_trait] +impl NodeRepo for PgNodeRepo { + async fn insert_server(&self, data: &Server) -> Result { + sqlx::query_as::<_, Server>( + r#"INSERT INTO servers (name, country, city, address, sort, protocols, last_reported_at, created_at, updated_at) + VALUES ($1, $2, $3, $4, $5, $6, $7, $8, $9) + RETURNING *"#, + ) + .bind(&data.name) + .bind(&data.country) + .bind(&data.city) + .bind(&data.address) + .bind(data.sort) + .bind(&data.protocols) + .bind(data.last_reported_at) + .bind(data.created_at) + .bind(data.updated_at) + .fetch_one(&self.pool) + .await + } + + async fn find_one_server(&self, id: i64) -> Result { + sqlx::query_as::<_, Server>("SELECT * FROM servers WHERE id = $1") + .bind(id) + .fetch_one(&self.pool) + .await + } + + async fn update_server(&self, data: &Server) -> Result { + sqlx::query_as::<_, Server>( + r#"UPDATE servers SET name = $1, country = $2, city = $3, address = $4, + sort = $5, protocols = $6, last_reported_at = $7, updated_at = $8 + WHERE id = $9 RETURNING *"#, + ) + .bind(&data.name) + .bind(&data.country) + .bind(&data.city) + .bind(&data.address) + .bind(data.sort) + .bind(&data.protocols) + .bind(data.last_reported_at) + .bind(data.updated_at) + .bind(data.id) + .fetch_one(&self.pool) + .await + } + + async fn delete_server(&self, id: i64) -> Result { + let res = sqlx::query("DELETE FROM servers WHERE id = $1") + .bind(id) + .execute(&self.pool) + .await?; + Ok(res.rows_affected()) + } + + async fn insert_node(&self, data: &Node) -> Result { + sqlx::query_as::<_, Node>( + r#"INSERT INTO nodes (name, tags, port, address, server_id, protocol, enabled, sort, created_at, updated_at) + VALUES ($1, $2, $3, $4, $5, $6, $7, $8, $9, $10) + RETURNING *"#, + ) + .bind(&data.name) + .bind(&data.tags) + .bind(data.port) + .bind(&data.address) + .bind(data.server_id) + .bind(&data.protocol) + .bind(data.enabled) + .bind(data.sort) + .bind(data.created_at) + .bind(data.updated_at) + .fetch_one(&self.pool) + .await + } + + async fn find_one_node(&self, id: i64) -> Result { + sqlx::query_as::<_, Node>("SELECT * FROM nodes WHERE id = $1") + .bind(id) + .fetch_one(&self.pool) + .await + } + + async fn update_node(&self, data: &Node) -> Result { + sqlx::query_as::<_, Node>( + r#"UPDATE nodes SET name = $1, tags = $2, port = $3, address = $4, + server_id = $5, protocol = $6, enabled = $7, sort = $8, updated_at = $9 + WHERE id = $10 RETURNING *"#, + ) + .bind(&data.name) + .bind(&data.tags) + .bind(data.port) + .bind(&data.address) + .bind(data.server_id) + .bind(&data.protocol) + .bind(data.enabled) + .bind(data.sort) + .bind(data.updated_at) + .bind(data.id) + .fetch_one(&self.pool) + .await + } + + async fn delete_node(&self, id: i64) -> Result { + let res = sqlx::query("DELETE FROM nodes WHERE id = $1") + .bind(id) + .execute(&self.pool) + .await?; + Ok(res.rows_affected()) + } + + async fn insert_override(&self, data: &ServerConfigOverride) -> Result { + sqlx::query_as::<_, ServerConfigOverride>( + r#"INSERT INTO server_config_overrides (server_id, ip_strategy, dns, block, outbound, created_at, updated_at) + VALUES ($1, $2, $3, $4, $5, $6, $7) + RETURNING *"#, + ) + .bind(data.server_id) + .bind(&data.ip_strategy) + .bind(&data.dns) + .bind(&data.block) + .bind(&data.outbound) + .bind(data.created_at) + .bind(data.updated_at) + .fetch_one(&self.pool) + .await + } + + async fn find_one_override(&self, id: i64) -> Result { + sqlx::query_as::<_, ServerConfigOverride>("SELECT * FROM server_config_overrides WHERE id = $1") + .bind(id) + .fetch_one(&self.pool) + .await + } + + async fn find_override_by_server( + &self, + server_id: i64, + ) -> Result, sqlx::Error> { + sqlx::query_as::<_, ServerConfigOverride>( + "SELECT * FROM server_config_overrides WHERE server_id = $1", + ) + .bind(server_id) + .fetch_optional(&self.pool) + .await + } + + async fn update_override(&self, data: &ServerConfigOverride) -> Result { + sqlx::query_as::<_, ServerConfigOverride>( + r#"UPDATE server_config_overrides SET ip_strategy = $1, dns = $2, block = $3, outbound = $4, updated_at = $5 + WHERE id = $6 RETURNING *"#, + ) + .bind(&data.ip_strategy) + .bind(&data.dns) + .bind(&data.block) + .bind(&data.outbound) + .bind(data.updated_at) + .bind(data.id) + .fetch_one(&self.pool) + .await + } + + async fn delete_override(&self, id: i64) -> Result { + let res = sqlx::query("DELETE FROM server_config_overrides WHERE id = $1") + .bind(id) + .execute(&self.pool) + .await?; + Ok(res.rows_affected()) + } + + async fn filter_server_list(&self, filter: &ServerFilter) -> Result<(i64, Vec), sqlx::Error> { + let mut page = filter.page; + let mut size = filter.size; + crate::repository::normalize_page(&mut page, &mut size); + let offset = (page - 1) * size; + + let mut clauses = Vec::new(); + let mut idx = 0u32; + if !filter.ids.is_empty() { + let placeholders: Vec = filter + .ids + .iter() + .map(|_| { + idx += 1; + format!("${}", idx) + }) + .collect(); + clauses.push(format!("id IN ({})", placeholders.join(", "))); + } + if filter.search.is_some() { + idx += 1; + clauses.push(format!("(name ILIKE ${} OR address ILIKE ${})", idx, idx)); + } + let where_str = if clauses.is_empty() { + String::new() + } else { + format!("WHERE {}", clauses.join(" AND ")) + }; + + let count_sql = format!("SELECT COUNT(*) FROM servers {}", where_str); + let mut count_q = sqlx::query_as::<_, (i64,)>(audit(&count_sql)); + for id in &filter.ids { + count_q = count_q.bind(id); + } + if let Some(s) = &filter.search { + count_q = count_q.bind(format!("%{}%", s)); + } + let (total,) = count_q.fetch_one(&self.pool).await?; + + let list_sql = format!( + "SELECT * FROM servers {} ORDER BY sort ASC LIMIT ${} OFFSET ${}", + where_str, + idx + 1, + idx + 2, + ); + let mut list_q = sqlx::query_as::<_, Server>(audit(&list_sql)); + for id in &filter.ids { + list_q = list_q.bind(id); + } + if let Some(s) = &filter.search { + list_q = list_q.bind(format!("%{}%", s)); + } + list_q = list_q.bind(size).bind(offset); + let items = list_q.fetch_all(&self.pool).await?; + + Ok((total, items)) + } + + async fn query_server_sorts(&self) -> Result, sqlx::Error> { + sqlx::query_as::<_, SortItem>( + "SELECT id, sort::bigint AS sort FROM servers ORDER BY sort ASC", + ) + .fetch_all(&self.pool) + .await + } + + async fn update_server_sort(&self, id: i64, sort: i64) -> Result<(), sqlx::Error> { + sqlx::query( + "UPDATE servers SET sort = $1, updated_at = EXTRACT(EPOCH FROM NOW())::bigint * 1000 WHERE id = $2", + ) + .bind(sort) + .bind(id) + .execute(&self.pool) + .await?; + Ok(()) + } + + async fn count_servers_by_report_status(&self, cutoff: i64) -> Result<(i64, i64), sqlx::Error> { + let (online,) = sqlx::query_as::<_, (i64,)>("SELECT COUNT(*) FROM servers WHERE last_reported_at > $1") + .bind(cutoff) + .fetch_one(&self.pool) + .await?; + let (offline,) = sqlx::query_as::<_, (i64,)>( + "SELECT COUNT(*) FROM servers WHERE last_reported_at <= $1 OR last_reported_at IS NULL", + ) + .bind(cutoff) + .fetch_one(&self.pool) + .await?; + Ok((online, offline)) + } + + async fn query_server_addresses(&self) -> Result, sqlx::Error> { + let rows: Vec<(String,)> = + sqlx::query_as("SELECT address FROM servers ORDER BY id ASC") + .fetch_all(&self.pool) + .await?; + Ok(rows.into_iter().map(|(a,)| a).collect()) + } + + async fn filter_node_list( + &self, + filter: &NodeFilter, + _preload_server: bool, + ) -> Result<(i64, Vec), sqlx::Error> { + let mut page = filter.page; + let mut size = filter.size; + crate::repository::normalize_page(&mut page, &mut size); + let offset = (page - 1) * size; + + let mut clauses = Vec::new(); + let mut idx = 0u32; + if !filter.node_ids.is_empty() { + let placeholders: Vec = filter + .node_ids + .iter() + .map(|_| { + idx += 1; + format!("${}", idx) + }) + .collect(); + clauses.push(format!("id IN ({})", placeholders.join(", "))); + } + if !filter.server_ids.is_empty() { + let placeholders: Vec = filter + .server_ids + .iter() + .map(|_| { + idx += 1; + format!("${}", idx) + }) + .collect(); + clauses.push(format!("server_id IN ({})", placeholders.join(", "))); + } + if !filter.tags.is_empty() { + let parts: Vec = filter + .tags + .iter() + .map(|_| { + idx += 1; + format!("tags ILIKE ${}", idx) + }) + .collect(); + clauses.push(format!("({})", parts.join(" OR "))); + } + if filter.search.is_some() { + idx += 1; + clauses.push(format!( + "(name ILIKE ${} OR address ILIKE ${} OR tags ILIKE ${})", + idx, idx, idx + )); + } + if filter.protocol.is_some() { + idx += 1; + clauses.push(format!("protocol = ${}", idx)); + } + if filter.enabled.is_some() { + idx += 1; + clauses.push(format!("enabled = ${}", idx)); + } + let where_str = if clauses.is_empty() { + String::new() + } else { + format!("WHERE {}", clauses.join(" AND ")) + }; + + let count_sql = format!("SELECT COUNT(*) FROM nodes {}", where_str); + let mut count_q = sqlx::query_as::<_, (i64,)>(audit(&count_sql)); + for id in &filter.node_ids { + count_q = count_q.bind(id); + } + for id in &filter.server_ids { + count_q = count_q.bind(id); + } + for t in &filter.tags { + count_q = count_q.bind(format!("%{}%", t)); + } + if let Some(s) = &filter.search { + count_q = count_q.bind(format!("%{}%", s)); + } + if let Some(p) = &filter.protocol { + count_q = count_q.bind(p); + } + if let Some(e) = filter.enabled { + count_q = count_q.bind(e); + } + let (total,) = count_q.fetch_one(&self.pool).await?; + + let list_sql = format!( + "SELECT * FROM nodes {} ORDER BY sort ASC LIMIT ${} OFFSET ${}", + where_str, + idx + 1, + idx + 2, + ); + let mut list_q = sqlx::query_as::<_, Node>(audit(&list_sql)); + for id in &filter.node_ids { + list_q = list_q.bind(id); + } + for id in &filter.server_ids { + list_q = list_q.bind(id); + } + for t in &filter.tags { + list_q = list_q.bind(format!("%{}%", t)); + } + if let Some(s) = &filter.search { + list_q = list_q.bind(format!("%{}%", s)); + } + if let Some(p) = &filter.protocol { + list_q = list_q.bind(p); + } + if let Some(e) = filter.enabled { + list_q = list_q.bind(e); + } + list_q = list_q.bind(size).bind(offset); + let items = list_q.fetch_all(&self.pool).await?; + + Ok((total, items)) + } + + async fn query_node_sorts(&self) -> Result, sqlx::Error> { + sqlx::query_as::<_, SortItem>("SELECT id, sort::bigint AS sort FROM nodes ORDER BY sort ASC") + .fetch_all(&self.pool) + .await + } + + async fn update_node_sort(&self, id: i64, sort: i64) -> Result<(), sqlx::Error> { + sqlx::query("UPDATE nodes SET sort = $1 WHERE id = $2") + .bind(sort) + .bind(id) + .execute(&self.pool) + .await?; + Ok(()) + } + + async fn query_node_tags(&self) -> Result, sqlx::Error> { + let rows: Vec<(String,)> = sqlx::query_as("SELECT tags FROM nodes ORDER BY id ASC") + .fetch_all(&self.pool) + .await?; + Ok(rows.into_iter().map(|(t,)| t).collect()) + } + + async fn count_enabled_nodes(&self) -> Result { + let (count,) = sqlx::query_as::<_, (i64,)>("SELECT COUNT(*) FROM nodes WHERE enabled = true") + .fetch_one(&self.pool) + .await?; + Ok(count) + } + + async fn query_enabled_node_protocols(&self) -> Result, sqlx::Error> { + let rows: Vec<(String,)> = + sqlx::query_as("SELECT protocol FROM nodes WHERE enabled = true GROUP BY protocol") + .fetch_all(&self.pool) + .await?; + Ok(rows.into_iter().map(|(p,)| p).collect()) + } +} diff --git a/src/repository/order/mod.rs b/src/repository/order/mod.rs new file mode 100644 index 00000000..b3609115 --- /dev/null +++ b/src/repository/order/mod.rs @@ -0,0 +1,73 @@ +use crate::model::entity::order::{Order, OrdersTotal}; + +#[derive(Debug, Clone, sqlx::FromRow)] +pub struct OrderDetails { + pub id: i64, + pub parent_id: Option, + pub user_id: i64, + pub order_no: String, + #[sqlx(rename = "type")] + pub type_: i16, + pub quantity: i64, + pub price: i64, + pub amount: i64, + pub gift_amount: i64, + pub discount: i64, + pub coupon: Option, + pub coupon_discount: i64, + pub commission: i64, + pub payment_id: i64, + pub method: String, + pub fee_amount: i64, + pub trade_no: Option, + pub status: i16, + pub subscribe_id: i64, + pub subscribe_token: Option, + pub is_new: bool, + pub created_at: i64, + pub updated_at: i64, + pub subscribe_name: Option, + pub payment_name: Option, +} + +#[derive(Debug, Clone, sqlx::FromRow)] +pub struct OrdersTotalWithDate { + pub date: String, + pub amount_total: i64, + pub new_order_amount: i64, + pub renewal_order_amount: i64, +} + +#[async_trait::async_trait] +pub trait OrderRepo: Send + Sync { + async fn insert(&self, data: &Order) -> Result; + async fn find_one(&self, id: i64) -> Result; + async fn find_one_by_order_no(&self, order_no: &str) -> Result; + async fn find_one_details(&self, id: i64) -> Result; + async fn find_one_details_by_order_no(&self, order_no: &str) -> Result; + async fn update(&self, data: &Order) -> Result; + async fn delete(&self, id: i64) -> Result; + async fn update_order_status(&self, order_no: &str, status: i16) -> Result; + async fn count_user_coupon_usage(&self, user_id: i64, coupon: &str) -> Result; + async fn query_list_by_page( + &self, + page: i64, + size: i64, + status: i16, + user_id: i64, + subscribe_id: i64, + search: Option<&str>, + ) -> Result<(i64, Vec), sqlx::Error>; + async fn is_user_eligible_for_new_order(&self, user_id: i64) -> Result; + async fn query_monthly_orders(&self, now: i64) -> Result; + async fn query_date_orders(&self, date: i64) -> Result; + async fn query_total_orders(&self) -> Result; + async fn query_daily_orders_list(&self, now: i64) -> Result, sqlx::Error>; + async fn query_monthly_orders_list(&self, now: i64) -> Result, sqlx::Error>; + async fn query_monthly_user_counts(&self, now: i64) -> Result<(i64, i64), sqlx::Error>; + async fn query_date_user_counts(&self, date: i64) -> Result<(i64, i64), sqlx::Error>; + async fn query_total_user_counts(&self) -> Result<(i64, i64), sqlx::Error>; +} + +pub mod pg; +pub mod mysql; diff --git a/src/repository/order/mysql.rs b/src/repository/order/mysql.rs new file mode 100644 index 00000000..0e0fa516 --- /dev/null +++ b/src/repository/order/mysql.rs @@ -0,0 +1,420 @@ +use chrono::{DateTime, Datelike, Timelike, Utc}; + +use crate::model::entity::order::{Order, OrdersTotal}; +use crate::repository::audit; +use crate::repository::order::{OrderDetails, OrderRepo, OrdersTotalWithDate}; + +pub struct MySqlOrderRepo { + pool: sqlx::MySqlPool, +} + +impl MySqlOrderRepo { + pub fn new(pool: sqlx::MySqlPool) -> Self { + Self { pool } + } +} + +fn day_start(ts: i64) -> i64 { + let secs = ts / 1000; + (secs - secs % 86400) * 1000 +} + +fn day_end(ts: i64) -> i64 { + day_start(ts) + 86400 * 1000 - 1 +} + +fn month_start(ts: i64) -> i64 { + let dt = DateTime::::from_timestamp_millis(ts).unwrap_or_default(); + dt.with_day(1).unwrap() + .with_hour(0).unwrap() + .with_minute(0).unwrap() + .with_second(0).unwrap() + .with_nanosecond(0).unwrap() + .timestamp_millis() +} + +fn month_end(ts: i64) -> i64 { + let dt = DateTime::::from_timestamp_millis(ts).unwrap_or_default(); + let next = if dt.month() == 12 { + dt.with_year(dt.year() + 1).unwrap().with_month(1).unwrap() + } else { + dt.with_month(dt.month() + 1).unwrap() + }; + next.with_day(1).unwrap() + .with_hour(0).unwrap() + .with_minute(0).unwrap() + .with_second(0).unwrap() + .with_nanosecond(0).unwrap() + .timestamp_millis() + - 1 +} + +fn month_start_months_ago(ts: i64, months_ago: i32) -> i64 { + let dt = DateTime::::from_timestamp_millis(ts).unwrap_or_default(); + let total = dt.year() * 12 + (dt.month() as i32) - 1 - months_ago; + let year = total.div_euclid(12); + let month = total.rem_euclid(12) + 1; + dt.with_year(year).unwrap() + .with_month(month as u32).unwrap() + .with_day(1).unwrap() + .with_hour(0).unwrap() + .with_minute(0).unwrap() + .with_second(0).unwrap() + .with_nanosecond(0).unwrap() + .timestamp_millis() +} + +const DETAILS_SELECT: &str = r#"SELECT o.*, s.name AS subscribe_name, p.name AS payment_name +FROM `order` o +LEFT JOIN subscribe s ON o.subscribe_id = s.id +LEFT JOIN payment p ON o.payment_id = p.id"#; + +#[async_trait::async_trait] +impl OrderRepo for MySqlOrderRepo { + async fn insert(&self, data: &Order) -> Result { + let result = sqlx::query( + "INSERT INTO `order` (parent_id, user_id, order_no, `type`, quantity, price, amount, gift_amount, discount, coupon, coupon_discount, commission, payment_id, method, fee_amount, trade_no, status, subscribe_id, subscribe_token, is_new, created_at, updated_at) + VALUES (?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?)", + ) + .bind(data.parent_id) + .bind(data.user_id) + .bind(&data.order_no) + .bind(data.type_) + .bind(data.quantity) + .bind(data.price) + .bind(data.amount) + .bind(data.gift_amount) + .bind(data.discount) + .bind(&data.coupon) + .bind(data.coupon_discount) + .bind(data.commission) + .bind(data.payment_id) + .bind(&data.method) + .bind(data.fee_amount) + .bind(&data.trade_no) + .bind(data.status) + .bind(data.subscribe_id) + .bind(&data.subscribe_token) + .bind(data.is_new) + .bind(data.created_at) + .bind(data.updated_at) + .execute(&self.pool) + .await?; + + let id = result.last_insert_id() as i64; + sqlx::query_as::<_, Order>("SELECT * FROM `order` WHERE id = ?") + .bind(id) + .fetch_one(&self.pool) + .await + } + + async fn find_one(&self, id: i64) -> Result { + sqlx::query_as::<_, Order>("SELECT * FROM `order` WHERE id = ?") + .bind(id) + .fetch_one(&self.pool) + .await + } + + async fn find_one_by_order_no(&self, order_no: &str) -> Result { + sqlx::query_as::<_, Order>("SELECT * FROM `order` WHERE order_no = ?") + .bind(order_no) + .fetch_one(&self.pool) + .await + } + + async fn find_one_details(&self, id: i64) -> Result { + let sql = format!("{} WHERE o.id = ?", DETAILS_SELECT); + sqlx::query_as::<_, OrderDetails>(audit(&sql)) + .bind(id) + .fetch_one(&self.pool) + .await + } + + async fn find_one_details_by_order_no(&self, order_no: &str) -> Result { + let sql = format!("{} WHERE o.order_no = ?", DETAILS_SELECT); + sqlx::query_as::<_, OrderDetails>(audit(&sql)) + .bind(order_no) + .fetch_one(&self.pool) + .await + } + + async fn update(&self, data: &Order) -> Result { + sqlx::query( + "UPDATE `order` SET parent_id = ?, user_id = ?, order_no = ?, `type` = ?, quantity = ?, price = ?, amount = ?, gift_amount = ?, discount = ?, coupon = ?, coupon_discount = ?, commission = ?, payment_id = ?, method = ?, fee_amount = ?, trade_no = ?, status = ?, subscribe_id = ?, subscribe_token = ?, is_new = ?, updated_at = ? + WHERE id = ?", + ) + .bind(data.parent_id) + .bind(data.user_id) + .bind(&data.order_no) + .bind(data.type_) + .bind(data.quantity) + .bind(data.price) + .bind(data.amount) + .bind(data.gift_amount) + .bind(data.discount) + .bind(&data.coupon) + .bind(data.coupon_discount) + .bind(data.commission) + .bind(data.payment_id) + .bind(&data.method) + .bind(data.fee_amount) + .bind(&data.trade_no) + .bind(data.status) + .bind(data.subscribe_id) + .bind(&data.subscribe_token) + .bind(data.is_new) + .bind(data.updated_at) + .bind(data.id) + .execute(&self.pool) + .await?; + + sqlx::query_as::<_, Order>("SELECT * FROM `order` WHERE id = ?") + .bind(data.id) + .fetch_one(&self.pool) + .await + } + + async fn delete(&self, id: i64) -> Result { + let res = sqlx::query("DELETE FROM `order` WHERE id = ?") + .bind(id) + .execute(&self.pool) + .await?; + Ok(res.rows_affected()) + } + + async fn update_order_status(&self, order_no: &str, status: i16) -> Result { + let res = sqlx::query("UPDATE `order` SET status = ? WHERE order_no = ?") + .bind(status) + .bind(order_no) + .execute(&self.pool) + .await?; + Ok(res.rows_affected()) + } + + async fn count_user_coupon_usage(&self, user_id: i64, coupon: &str) -> Result { + let (count,) = + sqlx::query_as::<_, (i64,)>("SELECT COUNT(*) FROM `order` WHERE user_id = ? AND coupon = ?") + .bind(user_id) + .bind(coupon) + .fetch_one(&self.pool) + .await?; + Ok(count) + } + + async fn query_list_by_page( + &self, + page: i64, + size: i64, + status: i16, + user_id: i64, + subscribe_id: i64, + search: Option<&str>, + ) -> Result<(i64, Vec), sqlx::Error> { + let offset = (page - 1) * size; + + let mut clauses = Vec::new(); + if status > 0 { + clauses.push("o.status = ?".to_string()); + } + if user_id > 0 { + clauses.push("o.user_id = ?".to_string()); + } + if subscribe_id > 0 { + clauses.push("o.subscribe_id = ?".to_string()); + } + if search.is_some() { + clauses.push( + "(LOWER(o.order_no) LIKE LOWER(?) OR LOWER(o.trade_no) LIKE LOWER(?) OR LOWER(o.coupon) LIKE LOWER(?))" + .to_string(), + ); + } + let where_str = if clauses.is_empty() { + String::new() + } else { + format!("WHERE {}", clauses.join(" AND ")) + }; + + let count_sql = format!("SELECT COUNT(*) FROM `order` o {}", where_str); + let mut count_q = sqlx::query_as::<_, (i64,)>(audit(&count_sql)); + if status > 0 { + count_q = count_q.bind(status); + } + if user_id > 0 { + count_q = count_q.bind(user_id); + } + if subscribe_id > 0 { + count_q = count_q.bind(subscribe_id); + } + if let Some(s) = search { + let pattern = format!("%{}%", s); + count_q = count_q.bind(pattern.clone()).bind(pattern.clone()).bind(pattern); + } + let (total,) = count_q.fetch_one(&self.pool).await?; + + let list_sql = format!( + "{} {} ORDER BY o.id DESC LIMIT ? OFFSET ?", + DETAILS_SELECT, where_str, + ); + let mut list_q = sqlx::query_as::<_, OrderDetails>(audit(&list_sql)); + if status > 0 { + list_q = list_q.bind(status); + } + if user_id > 0 { + list_q = list_q.bind(user_id); + } + if subscribe_id > 0 { + list_q = list_q.bind(subscribe_id); + } + if let Some(s) = search { + let pattern = format!("%{}%", s); + list_q = list_q.bind(pattern.clone()).bind(pattern.clone()).bind(pattern); + } + list_q = list_q.bind(size).bind(offset); + let items = list_q.fetch_all(&self.pool).await?; + + Ok((total, items)) + } + + async fn is_user_eligible_for_new_order(&self, user_id: i64) -> Result { + let (count,) = + sqlx::query_as::<_, (i64,)>("SELECT COUNT(*) FROM `order` WHERE user_id = ? AND status IN (2, 5)") + .bind(user_id) + .fetch_one(&self.pool) + .await?; + Ok(count == 0) + } + + async fn query_monthly_orders(&self, now: i64) -> Result { + let start = month_start(now); + let end = month_end(now); + sqlx::query_as::<_, OrdersTotal>( + "SELECT + COALESCE(SUM(amount), 0) AS amount_total, + COALESCE(SUM(CASE WHEN is_new THEN amount ELSE 0 END), 0) AS new_order_amount, + COALESCE(SUM(CASE WHEN NOT is_new THEN amount ELSE 0 END), 0) AS renewal_order_amount + FROM `order` + WHERE status IN (2, 5) AND created_at >= ? AND created_at <= ? AND method <> 'balance'", + ) + .bind(start) + .bind(end) + .fetch_one(&self.pool) + .await + } + + async fn query_date_orders(&self, date: i64) -> Result { + let start = day_start(date); + let end = day_end(date); + sqlx::query_as::<_, OrdersTotal>( + "SELECT + COALESCE(SUM(amount), 0) AS amount_total, + COALESCE(SUM(CASE WHEN is_new THEN amount ELSE 0 END), 0) AS new_order_amount, + COALESCE(SUM(CASE WHEN NOT is_new THEN amount ELSE 0 END), 0) AS renewal_order_amount + FROM `order` + WHERE status IN (2, 5) AND created_at >= ? AND created_at <= ? AND method <> 'balance'", + ) + .bind(start) + .bind(end) + .fetch_one(&self.pool) + .await + } + + async fn query_total_orders(&self) -> Result { + sqlx::query_as::<_, OrdersTotal>( + "SELECT + COALESCE(SUM(amount), 0) AS amount_total, + COALESCE(SUM(CASE WHEN is_new THEN amount ELSE 0 END), 0) AS new_order_amount, + COALESCE(SUM(CASE WHEN NOT is_new THEN amount ELSE 0 END), 0) AS renewal_order_amount + FROM `order` + WHERE status IN (2, 5) AND method <> 'balance'", + ) + .fetch_one(&self.pool) + .await + } + + async fn query_daily_orders_list(&self, now: i64) -> Result, sqlx::Error> { + let start = month_start(now); + let end = month_end(now); + sqlx::query_as::<_, OrdersTotalWithDate>( + "SELECT + DATE_FORMAT(FROM_UNIXTIME(created_at / 1000), '%Y-%m-%d') AS date, + COALESCE(SUM(amount), 0) AS amount_total, + COALESCE(SUM(CASE WHEN is_new THEN amount ELSE 0 END), 0) AS new_order_amount, + COALESCE(SUM(CASE WHEN NOT is_new THEN amount ELSE 0 END), 0) AS renewal_order_amount + FROM `order` + WHERE status IN (2, 5) AND created_at >= ? AND created_at <= ? AND method <> 'balance' + GROUP BY date + ORDER BY date ASC", + ) + .bind(start) + .bind(end) + .fetch_all(&self.pool) + .await + } + + async fn query_monthly_orders_list(&self, now: i64) -> Result, sqlx::Error> { + let start = month_start_months_ago(now, 6); + let end = month_end(now); + sqlx::query_as::<_, OrdersTotalWithDate>( + "SELECT + DATE_FORMAT(FROM_UNIXTIME(created_at / 1000), '%Y-%m') AS date, + COALESCE(SUM(amount), 0) AS amount_total, + COALESCE(SUM(CASE WHEN is_new THEN amount ELSE 0 END), 0) AS new_order_amount, + COALESCE(SUM(CASE WHEN NOT is_new THEN amount ELSE 0 END), 0) AS renewal_order_amount + FROM `order` + WHERE status IN (2, 5) AND created_at >= ? AND created_at <= ? AND method <> 'balance' + GROUP BY date + ORDER BY date ASC", + ) + .bind(start) + .bind(end) + .fetch_all(&self.pool) + .await + } + + async fn query_monthly_user_counts(&self, now: i64) -> Result<(i64, i64), sqlx::Error> { + let start = month_start(now); + let end = month_end(now); + let (new_count, renewal_count) = sqlx::query_as::<_, (i64, i64)>( + "SELECT + COUNT(DISTINCT CASE WHEN is_new THEN user_id END) AS new_count, + COUNT(DISTINCT CASE WHEN NOT is_new THEN user_id END) AS renewal_count + FROM `order` + WHERE status IN (2, 5) AND created_at >= ? AND created_at <= ? AND method <> 'balance'", + ) + .bind(start) + .bind(end) + .fetch_one(&self.pool) + .await?; + Ok((new_count, renewal_count)) + } + + async fn query_date_user_counts(&self, date: i64) -> Result<(i64, i64), sqlx::Error> { + let start = day_start(date); + let end = day_end(date); + let (new_count, renewal_count) = sqlx::query_as::<_, (i64, i64)>( + "SELECT + COUNT(DISTINCT CASE WHEN is_new THEN user_id END) AS new_count, + COUNT(DISTINCT CASE WHEN NOT is_new THEN user_id END) AS renewal_count + FROM `order` + WHERE status IN (2, 5) AND created_at >= ? AND created_at <= ? AND method <> 'balance'", + ) + .bind(start) + .bind(end) + .fetch_one(&self.pool) + .await?; + Ok((new_count, renewal_count)) + } + + async fn query_total_user_counts(&self) -> Result<(i64, i64), sqlx::Error> { + let (new_count, renewal_count) = sqlx::query_as::<_, (i64, i64)>( + "SELECT + COUNT(DISTINCT CASE WHEN is_new THEN user_id END) AS new_count, + COUNT(DISTINCT CASE WHEN NOT is_new THEN user_id END) AS renewal_count + FROM `order` + WHERE status IN (2, 5) AND method <> 'balance'", + ) + .fetch_one(&self.pool) + .await?; + Ok((new_count, renewal_count)) + } +} diff --git a/src/repository/order/pg.rs b/src/repository/order/pg.rs new file mode 100644 index 00000000..b5dd4672 --- /dev/null +++ b/src/repository/order/pg.rs @@ -0,0 +1,414 @@ +use chrono::{DateTime, Datelike, Timelike, Utc}; + +use crate::model::entity::order::{Order, OrdersTotal}; +use crate::repository::audit; +use crate::repository::order::{OrderDetails, OrderRepo, OrdersTotalWithDate}; + +pub struct PgOrderRepo { + pool: sqlx::PgPool, +} + +impl PgOrderRepo { + pub fn new(pool: sqlx::PgPool) -> Self { + Self { pool } + } +} + +fn day_start(ts: i64) -> i64 { + let secs = ts / 1000; + (secs - secs % 86400) * 1000 +} + +fn day_end(ts: i64) -> i64 { + day_start(ts) + 86400 * 1000 - 1 +} + +fn month_start(ts: i64) -> i64 { + let dt = DateTime::::from_timestamp_millis(ts).unwrap_or_default(); + dt.with_day(1).unwrap() + .with_hour(0).unwrap() + .with_minute(0).unwrap() + .with_second(0).unwrap() + .with_nanosecond(0).unwrap() + .timestamp_millis() +} + +fn month_end(ts: i64) -> i64 { + let dt = DateTime::::from_timestamp_millis(ts).unwrap_or_default(); + let next = if dt.month() == 12 { + dt.with_year(dt.year() + 1).unwrap().with_month(1).unwrap() + } else { + dt.with_month(dt.month() + 1).unwrap() + }; + next.with_day(1).unwrap() + .with_hour(0).unwrap() + .with_minute(0).unwrap() + .with_second(0).unwrap() + .with_nanosecond(0).unwrap() + .timestamp_millis() + - 1 +} + +fn month_start_months_ago(ts: i64, months_ago: i32) -> i64 { + let dt = DateTime::::from_timestamp_millis(ts).unwrap_or_default(); + let total = dt.year() * 12 + (dt.month() as i32) - 1 - months_ago; + let year = total.div_euclid(12); + let month = total.rem_euclid(12) + 1; + dt.with_year(year).unwrap() + .with_month(month as u32).unwrap() + .with_day(1).unwrap() + .with_hour(0).unwrap() + .with_minute(0).unwrap() + .with_second(0).unwrap() + .with_nanosecond(0).unwrap() + .timestamp_millis() +} + +const DETAILS_SELECT: &str = r#"SELECT o.*, s.name AS subscribe_name, p.name AS payment_name +FROM "order" o +LEFT JOIN subscribe s ON o.subscribe_id = s.id +LEFT JOIN payment p ON o.payment_id = p.id"#; + +#[async_trait::async_trait] +impl OrderRepo for PgOrderRepo { + async fn insert(&self, data: &Order) -> Result { + sqlx::query_as::<_, Order>( + r#"INSERT INTO "order" (parent_id, user_id, order_no, "type", quantity, price, amount, gift_amount, discount, coupon, coupon_discount, commission, payment_id, method, fee_amount, trade_no, status, subscribe_id, subscribe_token, is_new, created_at, updated_at) + VALUES ($1, $2, $3, $4, $5, $6, $7, $8, $9, $10, $11, $12, $13, $14, $15, $16, $17, $18, $19, $20, $21, $22) + RETURNING *"#, + ) + .bind(data.parent_id) + .bind(data.user_id) + .bind(&data.order_no) + .bind(data.type_) + .bind(data.quantity) + .bind(data.price) + .bind(data.amount) + .bind(data.gift_amount) + .bind(data.discount) + .bind(&data.coupon) + .bind(data.coupon_discount) + .bind(data.commission) + .bind(data.payment_id) + .bind(&data.method) + .bind(data.fee_amount) + .bind(&data.trade_no) + .bind(data.status) + .bind(data.subscribe_id) + .bind(&data.subscribe_token) + .bind(data.is_new) + .bind(data.created_at) + .bind(data.updated_at) + .fetch_one(&self.pool) + .await + } + + async fn find_one(&self, id: i64) -> Result { + sqlx::query_as::<_, Order>(r#"SELECT * FROM "order" WHERE id = $1"#) + .bind(id) + .fetch_one(&self.pool) + .await + } + + async fn find_one_by_order_no(&self, order_no: &str) -> Result { + sqlx::query_as::<_, Order>(r#"SELECT * FROM "order" WHERE order_no = $1"#) + .bind(order_no) + .fetch_one(&self.pool) + .await + } + + async fn find_one_details(&self, id: i64) -> Result { + let sql = format!("{} WHERE o.id = $1", DETAILS_SELECT); + sqlx::query_as::<_, OrderDetails>(audit(&sql)) + .bind(id) + .fetch_one(&self.pool) + .await + } + + async fn find_one_details_by_order_no(&self, order_no: &str) -> Result { + let sql = format!("{} WHERE o.order_no = $1", DETAILS_SELECT); + sqlx::query_as::<_, OrderDetails>(audit(&sql)) + .bind(order_no) + .fetch_one(&self.pool) + .await + } + + async fn update(&self, data: &Order) -> Result { + sqlx::query_as::<_, Order>( + r#"UPDATE "order" SET parent_id = $1, user_id = $2, order_no = $3, "type" = $4, quantity = $5, price = $6, amount = $7, gift_amount = $8, discount = $9, coupon = $10, coupon_discount = $11, commission = $12, payment_id = $13, method = $14, fee_amount = $15, trade_no = $16, status = $17, subscribe_id = $18, subscribe_token = $19, is_new = $20, updated_at = $21 + WHERE id = $22 RETURNING *"#, + ) + .bind(data.parent_id) + .bind(data.user_id) + .bind(&data.order_no) + .bind(data.type_) + .bind(data.quantity) + .bind(data.price) + .bind(data.amount) + .bind(data.gift_amount) + .bind(data.discount) + .bind(&data.coupon) + .bind(data.coupon_discount) + .bind(data.commission) + .bind(data.payment_id) + .bind(&data.method) + .bind(data.fee_amount) + .bind(&data.trade_no) + .bind(data.status) + .bind(data.subscribe_id) + .bind(&data.subscribe_token) + .bind(data.is_new) + .bind(data.updated_at) + .bind(data.id) + .fetch_one(&self.pool) + .await + } + + async fn delete(&self, id: i64) -> Result { + let res = sqlx::query(r#"DELETE FROM "order" WHERE id = $1"#) + .bind(id) + .execute(&self.pool) + .await?; + Ok(res.rows_affected()) + } + + async fn update_order_status(&self, order_no: &str, status: i16) -> Result { + let res = sqlx::query(r#"UPDATE "order" SET status = $1 WHERE order_no = $2"#) + .bind(status) + .bind(order_no) + .execute(&self.pool) + .await?; + Ok(res.rows_affected()) + } + + async fn count_user_coupon_usage(&self, user_id: i64, coupon: &str) -> Result { + let (count,) = + sqlx::query_as::<_, (i64,)>(r#"SELECT COUNT(*) FROM "order" WHERE user_id = $1 AND coupon = $2"#) + .bind(user_id) + .bind(coupon) + .fetch_one(&self.pool) + .await?; + Ok(count) + } + + async fn query_list_by_page( + &self, + page: i64, + size: i64, + status: i16, + user_id: i64, + subscribe_id: i64, + search: Option<&str>, + ) -> Result<(i64, Vec), sqlx::Error> { + let offset = (page - 1) * size; + + let mut clauses = Vec::new(); + let mut idx = 0u32; + if status > 0 { + idx += 1; + clauses.push(format!("o.status = ${}", idx)); + } + if user_id > 0 { + idx += 1; + clauses.push(format!("o.user_id = ${}", idx)); + } + if subscribe_id > 0 { + idx += 1; + clauses.push(format!("o.subscribe_id = ${}", idx)); + } + if search.is_some() { + idx += 1; + clauses.push(format!( + "(o.order_no ILIKE ${} OR o.trade_no ILIKE ${} OR o.coupon ILIKE ${})", + idx, idx, idx + )); + } + let where_str = if clauses.is_empty() { + String::new() + } else { + format!("WHERE {}", clauses.join(" AND ")) + }; + + let count_sql = format!(r#"SELECT COUNT(*) FROM "order" o {}"#, where_str); + let mut count_q = sqlx::query_as::<_, (i64,)>(audit(&count_sql)); + if status > 0 { + count_q = count_q.bind(status); + } + if user_id > 0 { + count_q = count_q.bind(user_id); + } + if subscribe_id > 0 { + count_q = count_q.bind(subscribe_id); + } + if let Some(s) = search { + count_q = count_q.bind(format!("%{}%", s)); + } + let (total,) = count_q.fetch_one(&self.pool).await?; + + let list_sql = format!( + r#"{} {} ORDER BY o.id DESC LIMIT ${} OFFSET ${}"#, + DETAILS_SELECT, where_str, idx + 1, idx + 2, + ); + let mut list_q = sqlx::query_as::<_, OrderDetails>(audit(&list_sql)); + if status > 0 { + list_q = list_q.bind(status); + } + if user_id > 0 { + list_q = list_q.bind(user_id); + } + if subscribe_id > 0 { + list_q = list_q.bind(subscribe_id); + } + if let Some(s) = search { + list_q = list_q.bind(format!("%{}%", s)); + } + list_q = list_q.bind(size).bind(offset); + let items = list_q.fetch_all(&self.pool).await?; + + Ok((total, items)) + } + + async fn is_user_eligible_for_new_order(&self, user_id: i64) -> Result { + let (count,) = sqlx::query_as::<_, (i64,)>( + r#"SELECT COUNT(*) FROM "order" WHERE user_id = $1 AND status IN (2, 5)"#, + ) + .bind(user_id) + .fetch_one(&self.pool) + .await?; + Ok(count == 0) + } + + async fn query_monthly_orders(&self, now: i64) -> Result { + let start = month_start(now); + let end = month_end(now); + sqlx::query_as::<_, OrdersTotal>( + r#"SELECT + COALESCE(SUM(amount), 0) AS amount_total, + COALESCE(SUM(CASE WHEN is_new THEN amount ELSE 0 END), 0) AS new_order_amount, + COALESCE(SUM(CASE WHEN NOT is_new THEN amount ELSE 0 END), 0) AS renewal_order_amount + FROM "order" + WHERE status IN (2, 5) AND created_at >= $1 AND created_at <= $2 AND method <> 'balance'"#, + ) + .bind(start) + .bind(end) + .fetch_one(&self.pool) + .await + } + + async fn query_date_orders(&self, date: i64) -> Result { + let start = day_start(date); + let end = day_end(date); + sqlx::query_as::<_, OrdersTotal>( + r#"SELECT + COALESCE(SUM(amount), 0) AS amount_total, + COALESCE(SUM(CASE WHEN is_new THEN amount ELSE 0 END), 0) AS new_order_amount, + COALESCE(SUM(CASE WHEN NOT is_new THEN amount ELSE 0 END), 0) AS renewal_order_amount + FROM "order" + WHERE status IN (2, 5) AND created_at >= $1 AND created_at <= $2 AND method <> 'balance'"#, + ) + .bind(start) + .bind(end) + .fetch_one(&self.pool) + .await + } + + async fn query_total_orders(&self) -> Result { + sqlx::query_as::<_, OrdersTotal>( + r#"SELECT + COALESCE(SUM(amount), 0) AS amount_total, + COALESCE(SUM(CASE WHEN is_new THEN amount ELSE 0 END), 0) AS new_order_amount, + COALESCE(SUM(CASE WHEN NOT is_new THEN amount ELSE 0 END), 0) AS renewal_order_amount + FROM "order" + WHERE status IN (2, 5) AND method <> 'balance'"#, + ) + .fetch_one(&self.pool) + .await + } + + async fn query_daily_orders_list(&self, now: i64) -> Result, sqlx::Error> { + let start = month_start(now); + let end = month_end(now); + sqlx::query_as::<_, OrdersTotalWithDate>( + r#"SELECT + TO_CHAR(TO_TIMESTAMP(created_at / 1000), 'YYYY-MM-DD') AS date, + COALESCE(SUM(amount), 0) AS amount_total, + COALESCE(SUM(CASE WHEN is_new THEN amount ELSE 0 END), 0) AS new_order_amount, + COALESCE(SUM(CASE WHEN NOT is_new THEN amount ELSE 0 END), 0) AS renewal_order_amount + FROM "order" + WHERE status IN (2, 5) AND created_at >= $1 AND created_at <= $2 AND method <> 'balance' + GROUP BY date + ORDER BY date ASC"#, + ) + .bind(start) + .bind(end) + .fetch_all(&self.pool) + .await + } + + async fn query_monthly_orders_list(&self, now: i64) -> Result, sqlx::Error> { + let start = month_start_months_ago(now, 6); + let end = month_end(now); + sqlx::query_as::<_, OrdersTotalWithDate>( + r#"SELECT + TO_CHAR(TO_TIMESTAMP(created_at / 1000), 'YYYY-MM') AS date, + COALESCE(SUM(amount), 0) AS amount_total, + COALESCE(SUM(CASE WHEN is_new THEN amount ELSE 0 END), 0) AS new_order_amount, + COALESCE(SUM(CASE WHEN NOT is_new THEN amount ELSE 0 END), 0) AS renewal_order_amount + FROM "order" + WHERE status IN (2, 5) AND created_at >= $1 AND created_at <= $2 AND method <> 'balance' + GROUP BY date + ORDER BY date ASC"#, + ) + .bind(start) + .bind(end) + .fetch_all(&self.pool) + .await + } + + async fn query_monthly_user_counts(&self, now: i64) -> Result<(i64, i64), sqlx::Error> { + let start = month_start(now); + let end = month_end(now); + let (new_count, renewal_count) = sqlx::query_as::<_, (i64, i64)>( + r#"SELECT + COUNT(DISTINCT CASE WHEN is_new THEN user_id END) AS new_count, + COUNT(DISTINCT CASE WHEN NOT is_new THEN user_id END) AS renewal_count + FROM "order" + WHERE status IN (2, 5) AND created_at >= $1 AND created_at <= $2 AND method <> 'balance'"#, + ) + .bind(start) + .bind(end) + .fetch_one(&self.pool) + .await?; + Ok((new_count, renewal_count)) + } + + async fn query_date_user_counts(&self, date: i64) -> Result<(i64, i64), sqlx::Error> { + let start = day_start(date); + let end = day_end(date); + let (new_count, renewal_count) = sqlx::query_as::<_, (i64, i64)>( + r#"SELECT + COUNT(DISTINCT CASE WHEN is_new THEN user_id END) AS new_count, + COUNT(DISTINCT CASE WHEN NOT is_new THEN user_id END) AS renewal_count + FROM "order" + WHERE status IN (2, 5) AND created_at >= $1 AND created_at <= $2 AND method <> 'balance'"#, + ) + .bind(start) + .bind(end) + .fetch_one(&self.pool) + .await?; + Ok((new_count, renewal_count)) + } + + async fn query_total_user_counts(&self) -> Result<(i64, i64), sqlx::Error> { + let (new_count, renewal_count) = sqlx::query_as::<_, (i64, i64)>( + r#"SELECT + COUNT(DISTINCT CASE WHEN is_new THEN user_id END) AS new_count, + COUNT(DISTINCT CASE WHEN NOT is_new THEN user_id END) AS renewal_count + FROM "order" + WHERE status IN (2, 5) AND method <> 'balance'"#, + ) + .fetch_one(&self.pool) + .await?; + Ok((new_count, renewal_count)) + } +} diff --git a/src/repository/payment/mod.rs b/src/repository/payment/mod.rs new file mode 100644 index 00000000..350ce690 --- /dev/null +++ b/src/repository/payment/mod.rs @@ -0,0 +1,28 @@ +use crate::model::entity::payment::Payment; + +#[derive(Debug, Default)] +pub struct PaymentFilter { + pub enable: Option, + pub platform: Option, + pub search: Option, +} + +#[async_trait::async_trait] +pub trait PaymentRepo: Send + Sync { + async fn insert(&self, data: &Payment) -> Result; + async fn find_one(&self, id: i64) -> Result; + async fn find_one_by_token(&self, token: &str) -> Result; + async fn update(&self, data: &Payment) -> Result; + async fn delete(&self, id: i64) -> Result; + async fn find_all(&self) -> Result, sqlx::Error>; + async fn find_available_methods(&self) -> Result, sqlx::Error>; + async fn find_list_by_page( + &self, + page: i64, + size: i64, + filter: Option<&PaymentFilter>, + ) -> Result<(i64, Vec), sqlx::Error>; +} + +pub mod pg; +pub mod mysql; diff --git a/src/repository/payment/mysql.rs b/src/repository/payment/mysql.rs new file mode 100644 index 00000000..3b0fc966 --- /dev/null +++ b/src/repository/payment/mysql.rs @@ -0,0 +1,174 @@ +use crate::model::entity::payment::Payment; +use crate::repository::audit; +use crate::repository::payment::{PaymentFilter, PaymentRepo}; + +pub struct MySqlPaymentRepo { + pool: sqlx::MySqlPool, +} + +impl MySqlPaymentRepo { + pub fn new(pool: sqlx::MySqlPool) -> Self { + Self { pool } + } +} + +#[async_trait::async_trait] +impl PaymentRepo for MySqlPaymentRepo { + async fn insert(&self, data: &Payment) -> Result { + let result = sqlx::query( + "INSERT INTO payment (name, platform, icon, domain, config, description, fee_mode, fee_percent, fee_amount, sort, enable, token, created_at, updated_at) + VALUES (?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?)", + ) + .bind(&data.name) + .bind(&data.platform) + .bind(&data.icon) + .bind(&data.domain) + .bind(&data.config) + .bind(&data.description) + .bind(data.fee_mode) + .bind(data.fee_percent) + .bind(data.fee_amount) + .bind(data.sort) + .bind(data.enable) + .bind(&data.token) + .bind(data.created_at) + .bind(data.updated_at) + .execute(&self.pool) + .await?; + + let id = result.last_insert_id() as i64; + sqlx::query_as::<_, Payment>("SELECT * FROM payment WHERE id = ?") + .bind(id) + .fetch_one(&self.pool) + .await + } + + async fn find_one(&self, id: i64) -> Result { + sqlx::query_as::<_, Payment>("SELECT * FROM payment WHERE id = ?") + .bind(id) + .fetch_one(&self.pool) + .await + } + + async fn find_one_by_token(&self, token: &str) -> Result { + sqlx::query_as::<_, Payment>("SELECT * FROM payment WHERE token = ?") + .bind(token) + .fetch_one(&self.pool) + .await + } + + async fn update(&self, data: &Payment) -> Result { + sqlx::query( + "UPDATE payment SET name = ?, platform = ?, icon = ?, domain = ?, config = ?, + description = ?, fee_mode = ?, fee_percent = ?, fee_amount = ?, sort = ?, + enable = ?, token = ?, updated_at = ? + WHERE id = ?", + ) + .bind(&data.name) + .bind(&data.platform) + .bind(&data.icon) + .bind(&data.domain) + .bind(&data.config) + .bind(&data.description) + .bind(data.fee_mode) + .bind(data.fee_percent) + .bind(data.fee_amount) + .bind(data.sort) + .bind(data.enable) + .bind(&data.token) + .bind(data.updated_at) + .bind(data.id) + .execute(&self.pool) + .await?; + + sqlx::query_as::<_, Payment>("SELECT * FROM payment WHERE id = ?") + .bind(data.id) + .fetch_one(&self.pool) + .await + } + + async fn delete(&self, id: i64) -> Result { + let res = sqlx::query("DELETE FROM payment WHERE id = ?") + .bind(id) + .execute(&self.pool) + .await?; + Ok(res.rows_affected()) + } + + async fn find_all(&self) -> Result, sqlx::Error> { + sqlx::query_as::<_, Payment>("SELECT * FROM payment ORDER BY sort ASC, id ASC") + .fetch_all(&self.pool) + .await + } + + async fn find_available_methods(&self) -> Result, sqlx::Error> { + sqlx::query_as::<_, Payment>( + "SELECT * FROM payment WHERE enable = true ORDER BY sort ASC, id ASC", + ) + .fetch_all(&self.pool) + .await + } + + async fn find_list_by_page( + &self, + page: i64, + size: i64, + filter: Option<&PaymentFilter>, + ) -> Result<(i64, Vec), sqlx::Error> { + let offset = (page - 1) * size; + + let mut clauses = Vec::new(); + if let Some(f) = filter { + if f.enable.is_some() { + clauses.push("enable = ?".to_string()); + } + if f.platform.is_some() { + clauses.push("platform = ?".to_string()); + } + if f.search.is_some() { + clauses.push("LOWER(name) LIKE LOWER(?)".to_string()); + } + } + let where_str = if clauses.is_empty() { + String::new() + } else { + format!("WHERE {}", clauses.join(" AND ")) + }; + + let count_sql = format!("SELECT COUNT(*) FROM payment {}", where_str); + let mut count_q = sqlx::query_as::<_, (i64,)>(audit(&count_sql)); + if let Some(f) = filter { + if let Some(v) = f.enable { + count_q = count_q.bind(v); + } + if let Some(v) = &f.platform { + count_q = count_q.bind(v); + } + if let Some(s) = &f.search { + count_q = count_q.bind(format!("%{}%", s)); + } + } + let (total,) = count_q.fetch_one(&self.pool).await?; + + let list_sql = format!( + "SELECT * FROM payment {} ORDER BY sort ASC, id ASC LIMIT ? OFFSET ?", + where_str, + ); + let mut list_q = sqlx::query_as::<_, Payment>(audit(&list_sql)); + if let Some(f) = filter { + if let Some(v) = f.enable { + list_q = list_q.bind(v); + } + if let Some(v) = &f.platform { + list_q = list_q.bind(v); + } + if let Some(s) = &f.search { + list_q = list_q.bind(format!("%{}%", s)); + } + } + list_q = list_q.bind(size).bind(offset); + let items = list_q.fetch_all(&self.pool).await?; + + Ok((total, items)) + } +} diff --git a/src/repository/payment/pg.rs b/src/repository/payment/pg.rs new file mode 100644 index 00000000..d43be907 --- /dev/null +++ b/src/repository/payment/pg.rs @@ -0,0 +1,170 @@ +use crate::model::entity::payment::Payment; +use crate::repository::audit; +use crate::repository::payment::{PaymentFilter, PaymentRepo}; + +pub struct PgPaymentRepo { + pool: sqlx::PgPool, +} + +impl PgPaymentRepo { + pub fn new(pool: sqlx::PgPool) -> Self { + Self { pool } + } +} + +#[async_trait::async_trait] +impl PaymentRepo for PgPaymentRepo { + async fn insert(&self, data: &Payment) -> Result { + sqlx::query_as::<_, Payment>( + r#"INSERT INTO payment (name, platform, icon, domain, config, description, fee_mode, fee_percent, fee_amount, sort, enable, token, created_at, updated_at) + VALUES ($1, $2, $3, $4, $5, $6, $7, $8, $9, $10, $11, $12, $13, $14) + RETURNING *"#, + ) + .bind(&data.name) + .bind(&data.platform) + .bind(&data.icon) + .bind(&data.domain) + .bind(&data.config) + .bind(&data.description) + .bind(data.fee_mode) + .bind(data.fee_percent) + .bind(data.fee_amount) + .bind(data.sort) + .bind(data.enable) + .bind(&data.token) + .bind(data.created_at) + .bind(data.updated_at) + .fetch_one(&self.pool) + .await + } + + async fn find_one(&self, id: i64) -> Result { + sqlx::query_as::<_, Payment>("SELECT * FROM payment WHERE id = $1") + .bind(id) + .fetch_one(&self.pool) + .await + } + + async fn find_one_by_token(&self, token: &str) -> Result { + sqlx::query_as::<_, Payment>("SELECT * FROM payment WHERE token = $1") + .bind(token) + .fetch_one(&self.pool) + .await + } + + async fn update(&self, data: &Payment) -> Result { + sqlx::query_as::<_, Payment>( + r#"UPDATE payment SET name = $1, platform = $2, icon = $3, domain = $4, config = $5, + description = $6, fee_mode = $7, fee_percent = $8, fee_amount = $9, sort = $10, + enable = $11, token = $12, updated_at = $13 + WHERE id = $14 RETURNING *"#, + ) + .bind(&data.name) + .bind(&data.platform) + .bind(&data.icon) + .bind(&data.domain) + .bind(&data.config) + .bind(&data.description) + .bind(data.fee_mode) + .bind(data.fee_percent) + .bind(data.fee_amount) + .bind(data.sort) + .bind(data.enable) + .bind(&data.token) + .bind(data.updated_at) + .bind(data.id) + .fetch_one(&self.pool) + .await + } + + async fn delete(&self, id: i64) -> Result { + let res = sqlx::query("DELETE FROM payment WHERE id = $1") + .bind(id) + .execute(&self.pool) + .await?; + Ok(res.rows_affected()) + } + + async fn find_all(&self) -> Result, sqlx::Error> { + sqlx::query_as::<_, Payment>("SELECT * FROM payment ORDER BY sort ASC, id ASC") + .fetch_all(&self.pool) + .await + } + + async fn find_available_methods(&self) -> Result, sqlx::Error> { + sqlx::query_as::<_, Payment>( + "SELECT * FROM payment WHERE enable = true ORDER BY sort ASC, id ASC", + ) + .fetch_all(&self.pool) + .await + } + + async fn find_list_by_page( + &self, + page: i64, + size: i64, + filter: Option<&PaymentFilter>, + ) -> Result<(i64, Vec), sqlx::Error> { + let offset = (page - 1) * size; + + let mut clauses = Vec::new(); + let mut idx = 0u32; + if let Some(f) = filter { + if f.enable.is_some() { + idx += 1; + clauses.push(format!("enable = ${}", idx)); + } + if f.platform.is_some() { + idx += 1; + clauses.push(format!("platform = ${}", idx)); + } + if f.search.is_some() { + idx += 1; + clauses.push(format!("name ILIKE ${}", idx)); + } + } + let where_str = if clauses.is_empty() { + String::new() + } else { + format!("WHERE {}", clauses.join(" AND ")) + }; + + let count_sql = format!("SELECT COUNT(*) FROM payment {}", where_str); + let mut count_q = sqlx::query_as::<_, (i64,)>(audit(&count_sql)); + if let Some(f) = filter { + if let Some(v) = f.enable { + count_q = count_q.bind(v); + } + if let Some(v) = &f.platform { + count_q = count_q.bind(v); + } + if let Some(s) = &f.search { + count_q = count_q.bind(format!("%{}%", s)); + } + } + let (total,) = count_q.fetch_one(&self.pool).await?; + + let list_sql = format!( + "SELECT * FROM payment {} ORDER BY sort ASC, id ASC LIMIT ${} OFFSET ${}", + where_str, + idx + 1, + idx + 2, + ); + let mut list_q = sqlx::query_as::<_, Payment>(audit(&list_sql)); + if let Some(f) = filter { + if let Some(v) = f.enable { + list_q = list_q.bind(v); + } + if let Some(v) = &f.platform { + list_q = list_q.bind(v); + } + if let Some(s) = &f.search { + list_q = list_q.bind(format!("%{}%", s)); + } + } + list_q = list_q.bind(size).bind(offset); + let items = list_q.fetch_all(&self.pool).await?; + + Ok((total, items)) + } +} diff --git a/src/repository/subscribe/mod.rs b/src/repository/subscribe/mod.rs new file mode 100644 index 00000000..4464d4ba --- /dev/null +++ b/src/repository/subscribe/mod.rs @@ -0,0 +1,52 @@ +use crate::model::entity::subscribe::{Group, Subscribe}; + +#[derive(Debug, Default)] +pub struct FilterParams { + pub page: i64, + pub size: i64, + pub ids: Vec, + pub nodes: Vec, + pub tags: Vec, + pub show: bool, + pub sell: bool, + pub language: Option, + pub default_language: bool, + pub search: Option, +} + +impl FilterParams { + pub fn normalize(&mut self) { + if self.page < 1 { + self.page = 1; + } + if self.size < 1 { + self.size = 10; + } + } +} + +#[async_trait::async_trait] +pub trait SubscribeRepo: Send + Sync { + async fn insert(&self, data: &Subscribe) -> Result; + async fn find_one(&self, id: i64) -> Result; + async fn update(&self, data: &Subscribe) -> Result; + async fn delete(&self, id: i64) -> Result; + async fn create_group(&self, data: &Group) -> Result; + async fn update_group(&self, data: &Group) -> Result; + async fn delete_group(&self, id: i64) -> Result; + async fn batch_delete_group(&self, ids: &[i64]) -> Result; + async fn query_group_list(&self) -> Result<(i64, Vec), sqlx::Error>; + async fn update_sort(&self, items: &[Subscribe]) -> Result<(), sqlx::Error>; + async fn query_reset_cycle_subscribe_ids( + &self, + reset_cycle: i64, + ) -> Result, sqlx::Error>; + async fn query_min_sort_by_ids(&self, ids: &[i64]) -> Result; + async fn filter_list( + &self, + params: &mut FilterParams, + ) -> Result<(i64, Vec), sqlx::Error>; +} + +pub mod pg; +pub mod mysql; diff --git a/src/repository/subscribe/mysql.rs b/src/repository/subscribe/mysql.rs new file mode 100644 index 00000000..470493f2 --- /dev/null +++ b/src/repository/subscribe/mysql.rs @@ -0,0 +1,362 @@ +use crate::model::entity::subscribe::{Group, Subscribe}; +use crate::repository::audit; +use crate::repository::subscribe::{FilterParams, SubscribeRepo}; + +pub struct MySqlSubscribeRepo { + pool: sqlx::MySqlPool, +} + +impl MySqlSubscribeRepo { + pub fn new(pool: sqlx::MySqlPool) -> Self { + Self { pool } + } +} + +#[async_trait::async_trait] +impl SubscribeRepo for MySqlSubscribeRepo { + async fn insert(&self, data: &Subscribe) -> Result { + let result = sqlx::query( + "INSERT INTO subscribe (name, language, description, unit_price, unit_time, discount, replacement, inventory, traffic, speed_limit, device_limit, quota, nodes, node_tags, `show`, sell, sort, deduction_ratio, allow_deduction, reset_cycle, renewal_reset, show_original_price, created_at, updated_at) + VALUES (?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?)", + ) + .bind(&data.name) + .bind(&data.language) + .bind(&data.description) + .bind(data.unit_price) + .bind(&data.unit_time) + .bind(&data.discount) + .bind(data.replacement) + .bind(data.inventory) + .bind(data.traffic) + .bind(data.speed_limit) + .bind(data.device_limit) + .bind(data.quota) + .bind(&data.nodes) + .bind(&data.node_tags) + .bind(data.show) + .bind(data.sell) + .bind(data.sort) + .bind(data.deduction_ratio) + .bind(data.allow_deduction) + .bind(data.reset_cycle) + .bind(data.renewal_reset) + .bind(data.show_original_price) + .bind(data.created_at) + .bind(data.updated_at) + .execute(&self.pool) + .await?; + + let id = result.last_insert_id() as i64; + sqlx::query_as::<_, Subscribe>("SELECT * FROM subscribe WHERE id = ?") + .bind(id) + .fetch_one(&self.pool) + .await + } + + async fn find_one(&self, id: i64) -> Result { + sqlx::query_as::<_, Subscribe>("SELECT * FROM subscribe WHERE id = ?") + .bind(id) + .fetch_one(&self.pool) + .await + } + + async fn update(&self, data: &Subscribe) -> Result { + sqlx::query( + "UPDATE subscribe SET name = ?, language = ?, description = ?, unit_price = ?, + unit_time = ?, discount = ?, replacement = ?, inventory = ?, traffic = ?, + speed_limit = ?, device_limit = ?, quota = ?, nodes = ?, node_tags = ?, + `show` = ?, sell = ?, sort = ?, deduction_ratio = ?, allow_deduction = ?, + reset_cycle = ?, renewal_reset = ?, show_original_price = ?, updated_at = ? + WHERE id = ?", + ) + .bind(&data.name) + .bind(&data.language) + .bind(&data.description) + .bind(data.unit_price) + .bind(&data.unit_time) + .bind(&data.discount) + .bind(data.replacement) + .bind(data.inventory) + .bind(data.traffic) + .bind(data.speed_limit) + .bind(data.device_limit) + .bind(data.quota) + .bind(&data.nodes) + .bind(&data.node_tags) + .bind(data.show) + .bind(data.sell) + .bind(data.sort) + .bind(data.deduction_ratio) + .bind(data.allow_deduction) + .bind(data.reset_cycle) + .bind(data.renewal_reset) + .bind(data.show_original_price) + .bind(data.updated_at) + .bind(data.id) + .execute(&self.pool) + .await?; + + sqlx::query_as::<_, Subscribe>("SELECT * FROM subscribe WHERE id = ?") + .bind(data.id) + .fetch_one(&self.pool) + .await + } + + async fn delete(&self, id: i64) -> Result { + let res = sqlx::query("DELETE FROM subscribe WHERE id = ?") + .bind(id) + .execute(&self.pool) + .await?; + Ok(res.rows_affected()) + } + + async fn create_group(&self, data: &Group) -> Result { + let result = sqlx::query( + "INSERT INTO subscribe_group (name, description, created_at, updated_at) + VALUES (?, ?, ?, ?)", + ) + .bind(&data.name) + .bind(&data.description) + .bind(data.created_at) + .bind(data.updated_at) + .execute(&self.pool) + .await?; + + let id = result.last_insert_id() as i64; + sqlx::query_as::<_, Group>("SELECT * FROM subscribe_group WHERE id = ?") + .bind(id) + .fetch_one(&self.pool) + .await + } + + async fn update_group(&self, data: &Group) -> Result { + sqlx::query( + "UPDATE subscribe_group SET name = ?, description = ?, updated_at = ? + WHERE id = ?", + ) + .bind(&data.name) + .bind(&data.description) + .bind(data.updated_at) + .bind(data.id) + .execute(&self.pool) + .await?; + + sqlx::query_as::<_, Group>("SELECT * FROM subscribe_group WHERE id = ?") + .bind(data.id) + .fetch_one(&self.pool) + .await + } + + async fn delete_group(&self, id: i64) -> Result { + let res = sqlx::query("DELETE FROM subscribe_group WHERE id = ?") + .bind(id) + .execute(&self.pool) + .await?; + Ok(res.rows_affected()) + } + + async fn batch_delete_group(&self, ids: &[i64]) -> Result { + if ids.is_empty() { + return Ok(0); + } + let placeholders: Vec = ids.iter().map(|_| "?".to_string()).collect(); + let sql = format!( + "DELETE FROM subscribe_group WHERE id IN ({})", + placeholders.join(", ") + ); + let mut q = sqlx::query(audit(&sql)); + for id in ids { + q = q.bind(id); + } + let res = q.execute(&self.pool).await?; + Ok(res.rows_affected()) + } + + async fn query_group_list(&self) -> Result<(i64, Vec), sqlx::Error> { + let items = + sqlx::query_as::<_, Group>("SELECT * FROM subscribe_group ORDER BY id ASC") + .fetch_all(&self.pool) + .await?; + let total = items.len() as i64; + Ok((total, items)) + } + + async fn update_sort(&self, items: &[Subscribe]) -> Result<(), sqlx::Error> { + for item in items { + sqlx::query("UPDATE subscribe SET sort = ? WHERE id = ?") + .bind(item.sort) + .bind(item.id) + .execute(&self.pool) + .await?; + } + Ok(()) + } + + async fn query_reset_cycle_subscribe_ids( + &self, + reset_cycle: i64, + ) -> Result, sqlx::Error> { + let rows = sqlx::query_as::<_, (i64,)>("SELECT id FROM subscribe WHERE reset_cycle = ?") + .bind(reset_cycle) + .fetch_all(&self.pool) + .await?; + Ok(rows.into_iter().map(|(id,)| id).collect()) + } + + async fn query_min_sort_by_ids(&self, ids: &[i64]) -> Result { + if ids.is_empty() { + return Ok(0); + } + let placeholders: Vec = ids.iter().map(|_| "?".to_string()).collect(); + let sql = format!( + "SELECT COALESCE(MIN(sort), 0) FROM subscribe WHERE id IN ({})", + placeholders.join(", ") + ); + let mut q = sqlx::query_as::<_, (i64,)>(audit(&sql)); + for id in ids { + q = q.bind(id); + } + let (min_sort,) = q.fetch_one(&self.pool).await?; + Ok(min_sort) + } + + async fn filter_list( + &self, + params: &mut FilterParams, + ) -> Result<(i64, Vec), sqlx::Error> { + params.normalize(); + + let (total, items) = + Self::filter_list_lang(&self.pool, params, params.language.as_deref()).await?; + + if total == 0 && params.default_language && params.language.is_some() { + return Self::filter_list_lang(&self.pool, params, None).await; + } + + Ok((total, items)) + } +} + +impl MySqlSubscribeRepo { + async fn filter_list_lang( + pool: &sqlx::MySqlPool, + params: &FilterParams, + lang: Option<&str>, + ) -> Result<(i64, Vec), sqlx::Error> { + let offset = (params.page - 1) * params.size; + + let mut clauses: Vec = Vec::new(); + + if params.show { + clauses.push("`show` = ?".to_string()); + } + if params.sell { + clauses.push("sell = ?".to_string()); + } + if params.search.is_some() { + clauses.push( + "(LOWER(name) LIKE LOWER(?) OR LOWER(description) LIKE LOWER(?))".to_string(), + ); + } + if !params.ids.is_empty() { + let placeholders: Vec = params.ids.iter().map(|_| "?".to_string()).collect(); + clauses.push(format!("id IN ({})", placeholders.join(", "))); + } + if !params.nodes.is_empty() { + let conds: Vec = params + .nodes + .iter() + .map(|_| "FIND_IN_SET(?, nodes)".to_string()) + .collect(); + clauses.push(format!("({})", conds.join(" OR "))); + } + if !params.tags.is_empty() { + let conds: Vec = params + .tags + .iter() + .map(|_| "FIND_IN_SET(?, node_tags)".to_string()) + .collect(); + clauses.push(format!("({})", conds.join(" OR "))); + } + match lang { + Some(l) if !l.is_empty() => { + clauses.push("language = ?".to_string()); + } + _ => { + if params.default_language { + clauses.push("(language = '' OR language IS NULL)".to_string()); + } + } + } + + let where_str = if clauses.is_empty() { + String::new() + } else { + format!("WHERE {}", clauses.join(" AND ")) + }; + + let count_sql = format!("SELECT COUNT(*) FROM subscribe {}", where_str); + let mut count_q = sqlx::query_as::<_, (i64,)>(audit(&count_sql)); + if params.show { + count_q = count_q.bind(true); + } + if params.sell { + count_q = count_q.bind(true); + } + if let Some(s) = ¶ms.search { + let pattern = format!("%{}%", s); + let pattern2 = pattern.clone(); + count_q = count_q.bind(pattern).bind(pattern2); + } + for id in ¶ms.ids { + count_q = count_q.bind(id); + } + for n in ¶ms.nodes { + count_q = count_q.bind(n); + } + for t in ¶ms.tags { + count_q = count_q.bind(t); + } + if let Some(l) = lang { + if !l.is_empty() { + count_q = count_q.bind(l); + } + } + let (total,) = count_q.fetch_one(pool).await?; + + let list_sql = format!( + "SELECT * FROM subscribe {} ORDER BY sort ASC LIMIT ? OFFSET ?", + where_str, + ); + let mut list_q = sqlx::query_as::<_, Subscribe>(audit(&list_sql)); + if params.show { + list_q = list_q.bind(true); + } + if params.sell { + list_q = list_q.bind(true); + } + if let Some(s) = ¶ms.search { + let pattern = format!("%{}%", s); + let pattern2 = pattern.clone(); + list_q = list_q.bind(pattern).bind(pattern2); + } + for id in ¶ms.ids { + list_q = list_q.bind(id); + } + for n in ¶ms.nodes { + list_q = list_q.bind(n); + } + for t in ¶ms.tags { + list_q = list_q.bind(t); + } + if let Some(l) = lang { + if !l.is_empty() { + list_q = list_q.bind(l); + } + } + list_q = list_q.bind(params.size).bind(offset); + let items = list_q.fetch_all(pool).await?; + + Ok((total, items)) + } +} diff --git a/src/repository/subscribe/pg.rs b/src/repository/subscribe/pg.rs new file mode 100644 index 00000000..0454bac3 --- /dev/null +++ b/src/repository/subscribe/pg.rs @@ -0,0 +1,375 @@ +use crate::model::entity::subscribe::{Group, Subscribe}; +use crate::repository::audit; +use crate::repository::subscribe::{FilterParams, SubscribeRepo}; + +pub struct PgSubscribeRepo { + pool: sqlx::PgPool, +} + +impl PgSubscribeRepo { + pub fn new(pool: sqlx::PgPool) -> Self { + Self { pool } + } +} + +#[async_trait::async_trait] +impl SubscribeRepo for PgSubscribeRepo { + async fn insert(&self, data: &Subscribe) -> Result { + sqlx::query_as::<_, Subscribe>( + r#"INSERT INTO subscribe (name, language, description, unit_price, unit_time, discount, replacement, inventory, traffic, speed_limit, device_limit, quota, nodes, node_tags, "show", sell, sort, deduction_ratio, allow_deduction, reset_cycle, renewal_reset, show_original_price, created_at, updated_at) + VALUES ($1, $2, $3, $4, $5, $6, $7, $8, $9, $10, $11, $12, $13, $14, $15, $16, $17, $18, $19, $20, $21, $22, $23, $24) + RETURNING *"#, + ) + .bind(&data.name) + .bind(&data.language) + .bind(&data.description) + .bind(data.unit_price) + .bind(&data.unit_time) + .bind(&data.discount) + .bind(data.replacement) + .bind(data.inventory) + .bind(data.traffic) + .bind(data.speed_limit) + .bind(data.device_limit) + .bind(data.quota) + .bind(&data.nodes) + .bind(&data.node_tags) + .bind(data.show) + .bind(data.sell) + .bind(data.sort) + .bind(data.deduction_ratio) + .bind(data.allow_deduction) + .bind(data.reset_cycle) + .bind(data.renewal_reset) + .bind(data.show_original_price) + .bind(data.created_at) + .bind(data.updated_at) + .fetch_one(&self.pool) + .await + } + + async fn find_one(&self, id: i64) -> Result { + sqlx::query_as::<_, Subscribe>("SELECT * FROM subscribe WHERE id = $1") + .bind(id) + .fetch_one(&self.pool) + .await + } + + async fn update(&self, data: &Subscribe) -> Result { + sqlx::query_as::<_, Subscribe>( + r#"UPDATE subscribe SET name = $1, language = $2, description = $3, unit_price = $4, + unit_time = $5, discount = $6, replacement = $7, inventory = $8, traffic = $9, + speed_limit = $10, device_limit = $11, quota = $12, nodes = $13, node_tags = $14, + "show" = $15, sell = $16, sort = $17, deduction_ratio = $18, allow_deduction = $19, + reset_cycle = $20, renewal_reset = $21, show_original_price = $22, updated_at = $23 + WHERE id = $24 RETURNING *"#, + ) + .bind(&data.name) + .bind(&data.language) + .bind(&data.description) + .bind(data.unit_price) + .bind(&data.unit_time) + .bind(&data.discount) + .bind(data.replacement) + .bind(data.inventory) + .bind(data.traffic) + .bind(data.speed_limit) + .bind(data.device_limit) + .bind(data.quota) + .bind(&data.nodes) + .bind(&data.node_tags) + .bind(data.show) + .bind(data.sell) + .bind(data.sort) + .bind(data.deduction_ratio) + .bind(data.allow_deduction) + .bind(data.reset_cycle) + .bind(data.renewal_reset) + .bind(data.show_original_price) + .bind(data.updated_at) + .bind(data.id) + .fetch_one(&self.pool) + .await + } + + async fn delete(&self, id: i64) -> Result { + let res = sqlx::query("DELETE FROM subscribe WHERE id = $1") + .bind(id) + .execute(&self.pool) + .await?; + Ok(res.rows_affected()) + } + + async fn create_group(&self, data: &Group) -> Result { + sqlx::query_as::<_, Group>( + r#"INSERT INTO subscribe_group (name, description, created_at, updated_at) + VALUES ($1, $2, $3, $4) + RETURNING *"#, + ) + .bind(&data.name) + .bind(&data.description) + .bind(data.created_at) + .bind(data.updated_at) + .fetch_one(&self.pool) + .await + } + + async fn update_group(&self, data: &Group) -> Result { + sqlx::query_as::<_, Group>( + r#"UPDATE subscribe_group SET name = $1, description = $2, updated_at = $3 + WHERE id = $4 RETURNING *"#, + ) + .bind(&data.name) + .bind(&data.description) + .bind(data.updated_at) + .bind(data.id) + .fetch_one(&self.pool) + .await + } + + async fn delete_group(&self, id: i64) -> Result { + let res = sqlx::query("DELETE FROM subscribe_group WHERE id = $1") + .bind(id) + .execute(&self.pool) + .await?; + Ok(res.rows_affected()) + } + + async fn batch_delete_group(&self, ids: &[i64]) -> Result { + if ids.is_empty() { + return Ok(0); + } + let placeholders: Vec = ids + .iter() + .enumerate() + .map(|(i, _)| format!("${}", i + 1)) + .collect(); + let sql = format!( + "DELETE FROM subscribe_group WHERE id IN ({})", + placeholders.join(", ") + ); + let mut q = sqlx::query(audit(&sql)); + for id in ids { + q = q.bind(id); + } + let res = q.execute(&self.pool).await?; + Ok(res.rows_affected()) + } + + async fn query_group_list(&self) -> Result<(i64, Vec), sqlx::Error> { + let items = + sqlx::query_as::<_, Group>("SELECT * FROM subscribe_group ORDER BY id ASC") + .fetch_all(&self.pool) + .await?; + let total = items.len() as i64; + Ok((total, items)) + } + + async fn update_sort(&self, items: &[Subscribe]) -> Result<(), sqlx::Error> { + for item in items { + sqlx::query("UPDATE subscribe SET sort = $1 WHERE id = $2") + .bind(item.sort) + .bind(item.id) + .execute(&self.pool) + .await?; + } + Ok(()) + } + + async fn query_reset_cycle_subscribe_ids( + &self, + reset_cycle: i64, + ) -> Result, sqlx::Error> { + let rows = sqlx::query_as::<_, (i64,)>( + "SELECT id FROM subscribe WHERE reset_cycle = $1", + ) + .bind(reset_cycle) + .fetch_all(&self.pool) + .await?; + Ok(rows.into_iter().map(|(id,)| id).collect()) + } + + async fn query_min_sort_by_ids(&self, ids: &[i64]) -> Result { + if ids.is_empty() { + return Ok(0); + } + let placeholders: Vec = ids + .iter() + .enumerate() + .map(|(i, _)| format!("${}", i + 1)) + .collect(); + let sql = format!( + "SELECT COALESCE(MIN(sort), 0) FROM subscribe WHERE id IN ({})", + placeholders.join(", ") + ); + let mut q = sqlx::query_as::<_, (i64,)>(audit(&sql)); + for id in ids { + q = q.bind(id); + } + let (min_sort,) = q.fetch_one(&self.pool).await?; + Ok(min_sort) + } + + async fn filter_list( + &self, + params: &mut FilterParams, + ) -> Result<(i64, Vec), sqlx::Error> { + params.normalize(); + + let (total, items) = Self::filter_list_lang(&self.pool, params, params.language.as_deref()).await?; + + if total == 0 && params.default_language && params.language.is_some() { + return Self::filter_list_lang(&self.pool, params, None).await; + } + + Ok((total, items)) + } +} + +impl PgSubscribeRepo { + async fn filter_list_lang( + pool: &sqlx::PgPool, + params: &FilterParams, + lang: Option<&str>, + ) -> Result<(i64, Vec), sqlx::Error> { + let offset = (params.page - 1) * params.size; + + let mut clauses: Vec = Vec::new(); + let mut idx = 0u32; + + if params.show { + idx += 1; + clauses.push(format!("\"show\" = ${}", idx)); + } + if params.sell { + idx += 1; + clauses.push(format!("sell = ${}", idx)); + } + if params.search.is_some() { + idx += 1; + let p = idx; + clauses.push(format!( + "(name ILIKE ${} OR description ILIKE ${})", + p, p + )); + } + if !params.ids.is_empty() { + let placeholders: Vec = params + .ids + .iter() + .map(|_| { + idx += 1; + format!("${}", idx) + }) + .collect(); + clauses.push(format!("id IN ({})", placeholders.join(", "))); + } + if !params.nodes.is_empty() { + let conds: Vec = params + .nodes + .iter() + .map(|_| { + idx += 1; + format!( + "(',' || COALESCE(nodes, '') || ',') LIKE ${}", + idx + ) + }) + .collect(); + clauses.push(format!("({})", conds.join(" OR "))); + } + if !params.tags.is_empty() { + let conds: Vec = params + .tags + .iter() + .map(|_| { + idx += 1; + format!( + "(',' || COALESCE(node_tags, '') || ',') LIKE ${}", + idx + ) + }) + .collect(); + clauses.push(format!("({})", conds.join(" OR "))); + } + match lang { + Some(l) if !l.is_empty() => { + idx += 1; + clauses.push(format!("language = ${}", idx)); + } + _ => { + if params.default_language { + clauses.push("(language = '' OR language IS NULL)".to_string()); + } + } + } + + let where_str = if clauses.is_empty() { + String::new() + } else { + format!("WHERE {}", clauses.join(" AND ")) + }; + + let count_sql = format!("SELECT COUNT(*) FROM subscribe {}", where_str); + let mut count_q = sqlx::query_as::<_, (i64,)>(audit(&count_sql)); + if params.show { + count_q = count_q.bind(true); + } + if params.sell { + count_q = count_q.bind(true); + } + if let Some(s) = ¶ms.search { + count_q = count_q.bind(format!("%{}%", s)); + } + for id in ¶ms.ids { + count_q = count_q.bind(id); + } + for n in ¶ms.nodes { + count_q = count_q.bind(format!("%,{},%", n)); + } + for t in ¶ms.tags { + count_q = count_q.bind(format!("%,{},%", t)); + } + if let Some(l) = lang { + if !l.is_empty() { + count_q = count_q.bind(l); + } + } + let (total,) = count_q.fetch_one(pool).await?; + + let list_sql = format!( + "SELECT * FROM subscribe {} ORDER BY sort ASC LIMIT ${} OFFSET ${}", + where_str, + idx + 1, + idx + 2, + ); + let mut list_q = sqlx::query_as::<_, Subscribe>(audit(&list_sql)); + if params.show { + list_q = list_q.bind(true); + } + if params.sell { + list_q = list_q.bind(true); + } + if let Some(s) = ¶ms.search { + list_q = list_q.bind(format!("%{}%", s)); + } + for id in ¶ms.ids { + list_q = list_q.bind(id); + } + for n in ¶ms.nodes { + list_q = list_q.bind(format!("%,{},%", n)); + } + for t in ¶ms.tags { + list_q = list_q.bind(format!("%,{},%", t)); + } + if let Some(l) = lang { + if !l.is_empty() { + list_q = list_q.bind(l); + } + } + list_q = list_q.bind(params.size).bind(offset); + let items = list_q.fetch_all(pool).await?; + + Ok((total, items)) + } +} diff --git a/src/repository/system/mod.rs b/src/repository/system/mod.rs new file mode 100644 index 00000000..c9895a9c --- /dev/null +++ b/src/repository/system/mod.rs @@ -0,0 +1,69 @@ +use crate::model::entity::system::System; + +#[async_trait::async_trait] +pub trait SystemRepo: Send + Sync { + async fn insert(&self, data: &System) -> Result; + async fn find_one(&self, id: i64) -> Result; + async fn update(&self, data: &System) -> Result; + async fn delete(&self, id: i64) -> Result; + async fn get_by_category(&self, category: &str) -> Result, sqlx::Error>; + async fn update_value_by_category_key( + &self, + category: &str, + key: &str, + value: &str, + ) -> Result; + async fn find_one_by_category_key( + &self, + category: &str, + key: &str, + ) -> Result; + + async fn get_sms_config(&self) -> Result, sqlx::Error> { + self.get_by_category("sms").await + } + async fn get_site_config(&self) -> Result, sqlx::Error> { + self.get_by_category("site").await + } + async fn get_subscribe_config(&self) -> Result, sqlx::Error> { + self.get_by_category("subscribe").await + } + async fn get_register_config(&self) -> Result, sqlx::Error> { + self.get_by_category("register").await + } + async fn get_verify_config(&self) -> Result, sqlx::Error> { + self.get_by_category("verify").await + } + async fn get_node_config(&self) -> Result, sqlx::Error> { + self.get_by_category("server").await + } + async fn get_invite_config(&self) -> Result, sqlx::Error> { + self.get_by_category("invite").await + } + async fn get_tos_config(&self) -> Result, sqlx::Error> { + self.get_by_category("tos").await + } + async fn get_currency_config(&self) -> Result, sqlx::Error> { + self.get_by_category("currency").await + } + async fn get_verify_code_config(&self) -> Result, sqlx::Error> { + self.get_by_category("verify_code").await + } + async fn get_log_config(&self) -> Result, sqlx::Error> { + self.get_by_category("log").await + } + async fn get_email_config(&self) -> Result, sqlx::Error> { + self.get_by_category("email").await + } + async fn update_node_multiplier_config(&self, config: &str) -> Result { + self.update_value_by_category_key("server", "NodeMultiplierConfig", config) + .await + } + async fn find_node_multiplier_config(&self) -> Result { + self.find_one_by_category_key("server", "NodeMultiplierConfig") + .await + } +} + +pub mod pg; +pub mod mysql; diff --git a/src/repository/system/mysql.rs b/src/repository/system/mysql.rs new file mode 100644 index 00000000..250ba4d3 --- /dev/null +++ b/src/repository/system/mysql.rs @@ -0,0 +1,111 @@ +use crate::model::entity::system::System; +use crate::repository::system::SystemRepo; + +pub struct MySqlSystemRepo { + pool: sqlx::MySqlPool, +} + +impl MySqlSystemRepo { + pub fn new(pool: sqlx::MySqlPool) -> Self { + Self { pool } + } +} + +#[async_trait::async_trait] +impl SystemRepo for MySqlSystemRepo { + async fn insert(&self, data: &System) -> Result { + let result = sqlx::query( + "INSERT INTO `system` (category, `key`, value, `type`, `desc`, created_at, updated_at) + VALUES (?, ?, ?, ?, ?, ?, ?)", + ) + .bind(&data.category) + .bind(&data.key) + .bind(&data.value) + .bind(&data.type_) + .bind(&data.desc) + .bind(data.created_at) + .bind(data.updated_at) + .execute(&self.pool) + .await?; + + let id = result.last_insert_id() as i64; + sqlx::query_as::<_, System>("SELECT * FROM `system` WHERE id = ?") + .bind(id) + .fetch_one(&self.pool) + .await + } + + async fn find_one(&self, id: i64) -> Result { + sqlx::query_as::<_, System>("SELECT * FROM `system` WHERE id = ?") + .bind(id) + .fetch_one(&self.pool) + .await + } + + async fn update(&self, data: &System) -> Result { + sqlx::query( + "UPDATE `system` SET category = ?, `key` = ?, value = ?, `type` = ?, `desc` = ?, updated_at = ? + WHERE id = ?", + ) + .bind(&data.category) + .bind(&data.key) + .bind(&data.value) + .bind(&data.type_) + .bind(&data.desc) + .bind(data.updated_at) + .bind(data.id) + .execute(&self.pool) + .await?; + + sqlx::query_as::<_, System>("SELECT * FROM `system` WHERE id = ?") + .bind(data.id) + .fetch_one(&self.pool) + .await + } + + async fn delete(&self, id: i64) -> Result { + let res = sqlx::query("DELETE FROM `system` WHERE id = ?") + .bind(id) + .execute(&self.pool) + .await?; + Ok(res.rows_affected()) + } + + async fn get_by_category(&self, category: &str) -> Result, sqlx::Error> { + sqlx::query_as::<_, System>("SELECT * FROM `system` WHERE category = ?") + .bind(category) + .fetch_all(&self.pool) + .await + } + + async fn update_value_by_category_key( + &self, + category: &str, + key: &str, + value: &str, + ) -> Result { + let res = sqlx::query( + "UPDATE `system` SET value = ? WHERE category = ? AND `key` = ?", + ) + .bind(value) + .bind(category) + .bind(key) + .execute(&self.pool) + .await?; + Ok(res.rows_affected()) + } + + async fn find_one_by_category_key( + &self, + category: &str, + key: &str, + ) -> Result { + sqlx::query_as::<_, System>( + "SELECT * FROM `system` WHERE category = ? AND `key` = ?", + ) + .bind(category) + .bind(key) + .fetch_one(&self.pool) + .await + } +} diff --git a/src/repository/system/pg.rs b/src/repository/system/pg.rs new file mode 100644 index 00000000..0cdecbd3 --- /dev/null +++ b/src/repository/system/pg.rs @@ -0,0 +1,101 @@ +use crate::model::entity::system::System; +use crate::repository::system::SystemRepo; + +pub struct PgSystemRepo { + pool: sqlx::PgPool, +} + +impl PgSystemRepo { + pub fn new(pool: sqlx::PgPool) -> Self { + Self { pool } + } +} + +#[async_trait::async_trait] +impl SystemRepo for PgSystemRepo { + async fn insert(&self, data: &System) -> Result { + sqlx::query_as::<_, System>( + r#"INSERT INTO "system" (category, key, value, "type", desc, created_at, updated_at) + VALUES ($1, $2, $3, $4, $5, $6, $7) + RETURNING *"#, + ) + .bind(&data.category) + .bind(&data.key) + .bind(&data.value) + .bind(&data.type_) + .bind(&data.desc) + .bind(data.created_at) + .bind(data.updated_at) + .fetch_one(&self.pool) + .await + } + + async fn find_one(&self, id: i64) -> Result { + sqlx::query_as::<_, System>(r#"SELECT * FROM "system" WHERE id = $1"#) + .bind(id) + .fetch_one(&self.pool) + .await + } + + async fn update(&self, data: &System) -> Result { + sqlx::query_as::<_, System>( + r#"UPDATE "system" SET category = $1, key = $2, value = $3, "type" = $4, desc = $5, updated_at = $6 + WHERE id = $7 RETURNING *"#, + ) + .bind(&data.category) + .bind(&data.key) + .bind(&data.value) + .bind(&data.type_) + .bind(&data.desc) + .bind(data.updated_at) + .bind(data.id) + .fetch_one(&self.pool) + .await + } + + async fn delete(&self, id: i64) -> Result { + let res = sqlx::query(r#"DELETE FROM "system" WHERE id = $1"#) + .bind(id) + .execute(&self.pool) + .await?; + Ok(res.rows_affected()) + } + + async fn get_by_category(&self, category: &str) -> Result, sqlx::Error> { + sqlx::query_as::<_, System>(r#"SELECT * FROM "system" WHERE category = $1"#) + .bind(category) + .fetch_all(&self.pool) + .await + } + + async fn update_value_by_category_key( + &self, + category: &str, + key: &str, + value: &str, + ) -> Result { + let res = sqlx::query( + r#"UPDATE "system" SET value = $1 WHERE category = $2 AND key = $3"#, + ) + .bind(value) + .bind(category) + .bind(key) + .execute(&self.pool) + .await?; + Ok(res.rows_affected()) + } + + async fn find_one_by_category_key( + &self, + category: &str, + key: &str, + ) -> Result { + sqlx::query_as::<_, System>( + r#"SELECT * FROM "system" WHERE category = $1 AND key = $2"#, + ) + .bind(category) + .bind(key) + .fetch_one(&self.pool) + .await + } +} diff --git a/src/repository/task/mod.rs b/src/repository/task/mod.rs new file mode 100644 index 00000000..d3ebfea6 --- /dev/null +++ b/src/repository/task/mod.rs @@ -0,0 +1,27 @@ +use crate::model::entity::task::Task; + +#[derive(Debug, Default)] +pub struct TaskFilter { + pub type_: i16, + pub page: i64, + pub size: i64, + pub status: Option, + pub scope: Option, +} + +#[async_trait::async_trait] +pub trait TaskRepo: Send + Sync { + async fn insert(&self, data: &Task) -> Result; + async fn find_one(&self, id: i64) -> Result; + async fn find_one_by_type(&self, id: i64, type_: i16) -> Result; + async fn update(&self, data: &Task) -> Result; + async fn update_status(&self, id: i64, status: i16) -> Result; + async fn delete(&self, id: i64) -> Result; + async fn query_task_list( + &self, + filter: &TaskFilter, + ) -> Result<(i64, Vec), sqlx::Error>; +} + +pub mod pg; +pub mod mysql; diff --git a/src/repository/task/mysql.rs b/src/repository/task/mysql.rs new file mode 100644 index 00000000..2a4ebe1a --- /dev/null +++ b/src/repository/task/mysql.rs @@ -0,0 +1,179 @@ +use crate::model::entity::task::{EmailScope, Task}; +use crate::repository::audit; +use crate::repository::normalize_page; +use crate::repository::task::{TaskFilter, TaskRepo}; + +pub struct MySqlTaskRepo { + pool: sqlx::MySqlPool, +} + +impl MySqlTaskRepo { + pub fn new(pool: sqlx::MySqlPool) -> Self { + Self { pool } + } +} + +#[async_trait::async_trait] +impl TaskRepo for MySqlTaskRepo { + async fn insert(&self, data: &Task) -> Result { + let result = sqlx::query( + "INSERT INTO task (`type`, scope, content, status, errors, total, current, created_at, updated_at) + VALUES (?, ?, ?, ?, ?, ?, ?, ?, ?)", + ) + .bind(data.type_) + .bind(&data.scope) + .bind(&data.content) + .bind(data.status) + .bind(&data.errors) + .bind(data.total) + .bind(data.current) + .bind(data.created_at) + .bind(data.updated_at) + .execute(&self.pool) + .await?; + + let id = result.last_insert_id() as i64; + sqlx::query_as::<_, Task>("SELECT * FROM task WHERE id = ?") + .bind(id) + .fetch_one(&self.pool) + .await + } + + async fn find_one(&self, id: i64) -> Result { + sqlx::query_as::<_, Task>("SELECT * FROM task WHERE id = ?") + .bind(id) + .fetch_one(&self.pool) + .await + } + + async fn find_one_by_type(&self, id: i64, type_: i16) -> Result { + sqlx::query_as::<_, Task>( + "SELECT * FROM task WHERE id = ? AND `type` = ?", + ) + .bind(id) + .bind(type_) + .fetch_one(&self.pool) + .await + } + + async fn update(&self, data: &Task) -> Result { + sqlx::query( + "UPDATE task SET scope = ?, content = ?, status = ?, errors = ?, total = ?, current = ?, updated_at = ? + WHERE id = ?", + ) + .bind(&data.scope) + .bind(&data.content) + .bind(data.status) + .bind(&data.errors) + .bind(data.total) + .bind(data.current) + .bind(data.updated_at) + .bind(data.id) + .execute(&self.pool) + .await?; + + sqlx::query_as::<_, Task>("SELECT * FROM task WHERE id = ?") + .bind(data.id) + .fetch_one(&self.pool) + .await + } + + async fn update_status(&self, id: i64, status: i16) -> Result { + let res = sqlx::query("UPDATE task SET status = ? WHERE id = ?") + .bind(status) + .bind(id) + .execute(&self.pool) + .await?; + Ok(res.rows_affected()) + } + + async fn delete(&self, id: i64) -> Result { + let res = sqlx::query("DELETE FROM task WHERE id = ?") + .bind(id) + .execute(&self.pool) + .await?; + Ok(res.rows_affected()) + } + + async fn query_task_list( + &self, + filter: &TaskFilter, + ) -> Result<(i64, Vec), sqlx::Error> { + let mut page = filter.page; + let mut size = filter.size; + normalize_page(&mut page, &mut size); + + let mut clauses = Vec::new(); + if filter.type_ != 0 { + clauses.push("`type` = ?".to_string()); + } + if filter.status.is_some() { + clauses.push("status = ?".to_string()); + } + let where_str = if clauses.is_empty() { + String::new() + } else { + format!("WHERE {}", clauses.join(" AND ")) + }; + + if let Some(scope) = filter.scope { + let sql = format!("SELECT * FROM task {} ORDER BY created_at DESC", where_str); + let mut q = sqlx::query_as::<_, Task>(audit(&sql)); + if filter.type_ != 0 { + q = q.bind(filter.type_); + } + if let Some(st) = filter.status { + q = q.bind(st); + } + let all = q.fetch_all(&self.pool).await?; + let mut filtered: Vec = Vec::with_capacity(all.len()); + for item in all { + let matches = item + .scope + .as_ref() + .and_then(|s| serde_json::from_str::(s).ok()) + .map(|e| e.type_ == scope) + .unwrap_or(false); + if matches { + filtered.push(item); + } + } + let total = filtered.len() as i64; + let start = ((page - 1) * size) as usize; + let end = (start + size as usize).min(filtered.len()); + let items = if start >= filtered.len() { + Vec::new() + } else { + filtered[start..end].to_vec() + }; + return Ok((total, items)); + } + + let count_sql = format!("SELECT COUNT(*) FROM task {}", where_str); + let mut count_q = sqlx::query_as::<_, (i64,)>(audit(&count_sql)); + if filter.type_ != 0 { + count_q = count_q.bind(filter.type_); + } + if let Some(st) = filter.status { + count_q = count_q.bind(st); + } + let (total,) = count_q.fetch_one(&self.pool).await?; + + let offset = (page - 1) * size; + let list_sql = format!( + "SELECT * FROM task {} ORDER BY created_at DESC LIMIT ? OFFSET ?", + where_str, + ); + let mut list_q = sqlx::query_as::<_, Task>(audit(&list_sql)); + if filter.type_ != 0 { + list_q = list_q.bind(filter.type_); + } + if let Some(st) = filter.status { + list_q = list_q.bind(st); + } + list_q = list_q.bind(size).bind(offset); + let items = list_q.fetch_all(&self.pool).await?; + + Ok((total, items)) + } +} diff --git a/src/repository/task/pg.rs b/src/repository/task/pg.rs new file mode 100644 index 00000000..14d13f77 --- /dev/null +++ b/src/repository/task/pg.rs @@ -0,0 +1,174 @@ +use crate::model::entity::task::{EmailScope, Task}; +use crate::repository::audit; +use crate::repository::normalize_page; +use crate::repository::task::{TaskFilter, TaskRepo}; + +pub struct PgTaskRepo { + pool: sqlx::PgPool, +} + +impl PgTaskRepo { + pub fn new(pool: sqlx::PgPool) -> Self { + Self { pool } + } +} + +#[async_trait::async_trait] +impl TaskRepo for PgTaskRepo { + async fn insert(&self, data: &Task) -> Result { + sqlx::query_as::<_, Task>( + r#"INSERT INTO task ("type", scope, content, status, errors, total, current, created_at, updated_at) + VALUES ($1, $2, $3, $4, $5, $6, $7, $8, $9) + RETURNING *"#, + ) + .bind(data.type_) + .bind(&data.scope) + .bind(&data.content) + .bind(data.status) + .bind(&data.errors) + .bind(data.total) + .bind(data.current) + .bind(data.created_at) + .bind(data.updated_at) + .fetch_one(&self.pool) + .await + } + + async fn find_one(&self, id: i64) -> Result { + sqlx::query_as::<_, Task>("SELECT * FROM task WHERE id = $1") + .bind(id) + .fetch_one(&self.pool) + .await + } + + async fn find_one_by_type(&self, id: i64, type_: i16) -> Result { + sqlx::query_as::<_, Task>( + r#"SELECT * FROM task WHERE id = $1 AND "type" = $2"#, + ) + .bind(id) + .bind(type_) + .fetch_one(&self.pool) + .await + } + + async fn update(&self, data: &Task) -> Result { + sqlx::query_as::<_, Task>( + r#"UPDATE task SET scope = $1, content = $2, status = $3, errors = $4, total = $5, current = $6, updated_at = $7 + WHERE id = $8 RETURNING *"#, + ) + .bind(&data.scope) + .bind(&data.content) + .bind(data.status) + .bind(&data.errors) + .bind(data.total) + .bind(data.current) + .bind(data.updated_at) + .bind(data.id) + .fetch_one(&self.pool) + .await + } + + async fn update_status(&self, id: i64, status: i16) -> Result { + let res = sqlx::query("UPDATE task SET status = $1 WHERE id = $2") + .bind(status) + .bind(id) + .execute(&self.pool) + .await?; + Ok(res.rows_affected()) + } + + async fn delete(&self, id: i64) -> Result { + let res = sqlx::query("DELETE FROM task WHERE id = $1") + .bind(id) + .execute(&self.pool) + .await?; + Ok(res.rows_affected()) + } + + async fn query_task_list( + &self, + filter: &TaskFilter, + ) -> Result<(i64, Vec), sqlx::Error> { + let mut page = filter.page; + let mut size = filter.size; + normalize_page(&mut page, &mut size); + + let mut clauses = Vec::new(); + let mut idx = 0u32; + if filter.type_ != 0 { + idx += 1; + clauses.push(format!("\"type\" = ${}", idx)); + } + if filter.status.is_some() { + idx += 1; + clauses.push(format!("status = ${}", idx)); + } + let where_str = if clauses.is_empty() { + String::new() + } else { + format!("WHERE {}", clauses.join(" AND ")) + }; + + if let Some(scope) = filter.scope { + let sql = format!("SELECT * FROM task {} ORDER BY created_at DESC", where_str); + let mut q = sqlx::query_as::<_, Task>(audit(&sql)); + if filter.type_ != 0 { + q = q.bind(filter.type_); + } + if let Some(st) = filter.status { + q = q.bind(st); + } + let all = q.fetch_all(&self.pool).await?; + let mut filtered: Vec = Vec::with_capacity(all.len()); + for item in all { + let matches = item + .scope + .as_ref() + .and_then(|s| serde_json::from_str::(s).ok()) + .map(|e| e.type_ == scope) + .unwrap_or(false); + if matches { + filtered.push(item); + } + } + let total = filtered.len() as i64; + let start = ((page - 1) * size) as usize; + let end = (start + size as usize).min(filtered.len()); + let items = if start >= filtered.len() { + Vec::new() + } else { + filtered[start..end].to_vec() + }; + return Ok((total, items)); + } + + let count_sql = format!("SELECT COUNT(*) FROM task {}", where_str); + let mut count_q = sqlx::query_as::<_, (i64,)>(audit(&count_sql)); + if filter.type_ != 0 { + count_q = count_q.bind(filter.type_); + } + if let Some(st) = filter.status { + count_q = count_q.bind(st); + } + let (total,) = count_q.fetch_one(&self.pool).await?; + + let offset = (page - 1) * size; + let list_sql = format!( + "SELECT * FROM task {} ORDER BY created_at DESC LIMIT ${} OFFSET ${}", + where_str, + idx + 1, + idx + 2, + ); + let mut list_q = sqlx::query_as::<_, Task>(audit(&list_sql)); + if filter.type_ != 0 { + list_q = list_q.bind(filter.type_); + } + if let Some(st) = filter.status { + list_q = list_q.bind(st); + } + list_q = list_q.bind(size).bind(offset); + let items = list_q.fetch_all(&self.pool).await?; + + Ok((total, items)) + } +} diff --git a/src/repository/ticket/mod.rs b/src/repository/ticket/mod.rs new file mode 100644 index 00000000..b0053c8d --- /dev/null +++ b/src/repository/ticket/mod.rs @@ -0,0 +1,41 @@ +use crate::model::entity::ticket::{Follow, Ticket}; + +#[derive(Debug, Clone, sqlx::FromRow)] +pub struct TicketDetails { + pub id: i64, + pub title: String, + pub description: Option, + pub user_id: i64, + pub status: i16, + pub created_at: i64, + pub updated_at: i64, +} + +#[async_trait::async_trait] +pub trait TicketRepo: Send + Sync { + async fn insert(&self, data: &Ticket) -> Result; + async fn find_one(&self, id: i64) -> Result; + async fn update(&self, data: &Ticket) -> Result; + async fn delete(&self, id: i64) -> Result; + async fn insert_follow(&self, data: &Follow) -> Result; + async fn find_follows_by_ticket(&self, ticket_id: i64) -> Result, sqlx::Error>; + async fn query_ticket_detail(&self, id: i64) -> Result; + async fn query_ticket_list( + &self, + page: i64, + size: i64, + user_id: i64, + status: Option, + search: Option<&str>, + ) -> Result<(i64, Vec), sqlx::Error>; + async fn update_ticket_status( + &self, + id: i64, + user_id: i64, + status: i16, + ) -> Result; + async fn query_wait_reply_total(&self) -> Result; +} + +pub mod pg; +pub mod mysql; diff --git a/src/repository/ticket/mysql.rs b/src/repository/ticket/mysql.rs new file mode 100644 index 00000000..42ef6016 --- /dev/null +++ b/src/repository/ticket/mysql.rs @@ -0,0 +1,208 @@ +use crate::model::entity::ticket::{Follow, Ticket}; +use crate::repository::audit; +use crate::repository::ticket::{TicketDetails, TicketRepo}; + +pub struct MySqlTicketRepo { + pool: sqlx::MySqlPool, +} + +impl MySqlTicketRepo { + pub fn new(pool: sqlx::MySqlPool) -> Self { + Self { pool } + } +} + +#[async_trait::async_trait] +impl TicketRepo for MySqlTicketRepo { + async fn insert(&self, data: &Ticket) -> Result { + let result = sqlx::query( + "INSERT INTO ticket (title, description, user_id, status, created_at, updated_at) + VALUES (?, ?, ?, ?, ?, ?)", + ) + .bind(&data.title) + .bind(&data.description) + .bind(data.user_id) + .bind(data.status) + .bind(data.created_at) + .bind(data.updated_at) + .execute(&self.pool) + .await?; + + let id = result.last_insert_id() as i64; + sqlx::query_as::<_, Ticket>("SELECT * FROM ticket WHERE id = ?") + .bind(id) + .fetch_one(&self.pool) + .await + } + + async fn find_one(&self, id: i64) -> Result { + sqlx::query_as::<_, Ticket>("SELECT * FROM ticket WHERE id = ?") + .bind(id) + .fetch_one(&self.pool) + .await + } + + async fn update(&self, data: &Ticket) -> Result { + sqlx::query( + "UPDATE ticket SET title = ?, description = ?, user_id = ?, status = ?, updated_at = ? + WHERE id = ?", + ) + .bind(&data.title) + .bind(&data.description) + .bind(data.user_id) + .bind(data.status) + .bind(data.updated_at) + .bind(data.id) + .execute(&self.pool) + .await?; + + sqlx::query_as::<_, Ticket>("SELECT * FROM ticket WHERE id = ?") + .bind(data.id) + .fetch_one(&self.pool) + .await + } + + async fn delete(&self, id: i64) -> Result { + let res = sqlx::query("DELETE FROM ticket WHERE id = ?") + .bind(id) + .execute(&self.pool) + .await?; + Ok(res.rows_affected()) + } + + async fn insert_follow(&self, data: &Follow) -> Result { + let result = sqlx::query( + "INSERT INTO ticket_follow (ticket_id, `from`, `type`, content, created_at) + VALUES (?, ?, ?, ?, ?)", + ) + .bind(data.ticket_id) + .bind(&data.from) + .bind(data.type_) + .bind(&data.content) + .bind(data.created_at) + .execute(&self.pool) + .await?; + + let id = result.last_insert_id() as i64; + sqlx::query_as::<_, Follow>("SELECT * FROM ticket_follow WHERE id = ?") + .bind(id) + .fetch_one(&self.pool) + .await + } + + async fn find_follows_by_ticket(&self, ticket_id: i64) -> Result, sqlx::Error> { + sqlx::query_as::<_, Follow>( + "SELECT * FROM ticket_follow WHERE ticket_id = ? ORDER BY created_at ASC", + ) + .bind(ticket_id) + .fetch_all(&self.pool) + .await + } + + async fn query_ticket_detail(&self, id: i64) -> Result { + sqlx::query_as::<_, TicketDetails>("SELECT * FROM ticket WHERE id = ?") + .bind(id) + .fetch_one(&self.pool) + .await + } + + async fn query_ticket_list( + &self, + page: i64, + size: i64, + user_id: i64, + status: Option, + search: Option<&str>, + ) -> Result<(i64, Vec), sqlx::Error> { + let offset = (page - 1) * size; + + let mut clauses = Vec::new(); + if user_id > 0 { + clauses.push("user_id = ?".to_string()); + } + if status.is_some() { + clauses.push("status = ?".to_string()); + } else { + clauses.push("status != ?".to_string()); + } + if search.is_some() { + clauses.push( + "(LOWER(title) LIKE LOWER(?) OR LOWER(description) LIKE LOWER(?))".to_string(), + ); + } + let where_str = if clauses.is_empty() { + String::new() + } else { + format!("WHERE {}", clauses.join(" AND ")) + }; + + let pattern = search.map(|s| format!("%{}%", s)); + + let count_sql = format!("SELECT COUNT(*) FROM ticket {}", where_str); + let mut count_q = sqlx::query_as::<_, (i64,)>(audit(&count_sql)); + if user_id > 0 { + count_q = count_q.bind(user_id); + } + if let Some(st) = status { + count_q = count_q.bind(st); + } else { + count_q = count_q.bind(4i16); + } + if let Some(ref p) = pattern { + count_q = count_q.bind(p).bind(p); + } + let (total,) = count_q.fetch_one(&self.pool).await?; + + let list_sql = format!( + "SELECT * FROM ticket {} ORDER BY id DESC LIMIT ? OFFSET ?", + where_str, + ); + let mut list_q = sqlx::query_as::<_, Ticket>(audit(&list_sql)); + if user_id > 0 { + list_q = list_q.bind(user_id); + } + if let Some(st) = status { + list_q = list_q.bind(st); + } else { + list_q = list_q.bind(4i16); + } + if let Some(ref p) = pattern { + list_q = list_q.bind(p).bind(p); + } + list_q = list_q.bind(size).bind(offset); + let items = list_q.fetch_all(&self.pool).await?; + + Ok((total, items)) + } + + async fn update_ticket_status( + &self, + id: i64, + user_id: i64, + status: i16, + ) -> Result { + let res = if user_id > 0 { + sqlx::query("UPDATE ticket SET status = ? WHERE id = ? AND user_id = ?") + .bind(status) + .bind(id) + .bind(user_id) + .execute(&self.pool) + .await? + } else { + sqlx::query("UPDATE ticket SET status = ? WHERE id = ?") + .bind(status) + .bind(id) + .execute(&self.pool) + .await? + }; + Ok(res.rows_affected()) + } + + async fn query_wait_reply_total(&self) -> Result { + let (total,) = + sqlx::query_as::<_, (i64,)>("SELECT COUNT(*) FROM ticket WHERE status = 1") + .fetch_one(&self.pool) + .await?; + Ok(total) + } +} diff --git a/src/repository/ticket/pg.rs b/src/repository/ticket/pg.rs new file mode 100644 index 00000000..7d0de97c --- /dev/null +++ b/src/repository/ticket/pg.rs @@ -0,0 +1,195 @@ +use crate::model::entity::ticket::{Follow, Ticket}; +use crate::repository::audit; +use crate::repository::ticket::{TicketDetails, TicketRepo}; + +pub struct PgTicketRepo { + pool: sqlx::PgPool, +} + +impl PgTicketRepo { + pub fn new(pool: sqlx::PgPool) -> Self { + Self { pool } + } +} + +#[async_trait::async_trait] +impl TicketRepo for PgTicketRepo { + async fn insert(&self, data: &Ticket) -> Result { + sqlx::query_as::<_, Ticket>( + "INSERT INTO ticket (title, description, user_id, status, created_at, updated_at) + VALUES ($1, $2, $3, $4, $5, $6) + RETURNING *", + ) + .bind(&data.title) + .bind(&data.description) + .bind(data.user_id) + .bind(data.status) + .bind(data.created_at) + .bind(data.updated_at) + .fetch_one(&self.pool) + .await + } + + async fn find_one(&self, id: i64) -> Result { + sqlx::query_as::<_, Ticket>("SELECT * FROM ticket WHERE id = $1") + .bind(id) + .fetch_one(&self.pool) + .await + } + + async fn update(&self, data: &Ticket) -> Result { + sqlx::query_as::<_, Ticket>( + "UPDATE ticket SET title = $1, description = $2, user_id = $3, status = $4, updated_at = $5 + WHERE id = $6 RETURNING *", + ) + .bind(&data.title) + .bind(&data.description) + .bind(data.user_id) + .bind(data.status) + .bind(data.updated_at) + .bind(data.id) + .fetch_one(&self.pool) + .await + } + + async fn delete(&self, id: i64) -> Result { + let res = sqlx::query("DELETE FROM ticket WHERE id = $1") + .bind(id) + .execute(&self.pool) + .await?; + Ok(res.rows_affected()) + } + + async fn insert_follow(&self, data: &Follow) -> Result { + sqlx::query_as::<_, Follow>( + r#"INSERT INTO ticket_follow (ticket_id, "from", "type", content, created_at) + VALUES ($1, $2, $3, $4, $5) + RETURNING *"#, + ) + .bind(data.ticket_id) + .bind(&data.from) + .bind(data.type_) + .bind(&data.content) + .bind(data.created_at) + .fetch_one(&self.pool) + .await + } + + async fn find_follows_by_ticket(&self, ticket_id: i64) -> Result, sqlx::Error> { + sqlx::query_as::<_, Follow>( + "SELECT * FROM ticket_follow WHERE ticket_id = $1 ORDER BY created_at ASC", + ) + .bind(ticket_id) + .fetch_all(&self.pool) + .await + } + + async fn query_ticket_detail(&self, id: i64) -> Result { + sqlx::query_as::<_, TicketDetails>("SELECT * FROM ticket WHERE id = $1") + .bind(id) + .fetch_one(&self.pool) + .await + } + + async fn query_ticket_list( + &self, + page: i64, + size: i64, + user_id: i64, + status: Option, + search: Option<&str>, + ) -> Result<(i64, Vec), sqlx::Error> { + let offset = (page - 1) * size; + + let mut clauses = Vec::new(); + let mut idx = 0u32; + if user_id > 0 { + idx += 1; + clauses.push(format!("user_id = ${}", idx)); + } + if status.is_some() { + idx += 1; + clauses.push(format!("status = ${}", idx)); + } else { + idx += 1; + clauses.push(format!("status != ${}", idx)); + } + if search.is_some() { + idx += 1; + clauses.push(format!("(title ILIKE ${} OR description ILIKE ${})", idx, idx)); + } + let where_str = if clauses.is_empty() { + String::new() + } else { + format!("WHERE {}", clauses.join(" AND ")) + }; + + let count_sql = format!("SELECT COUNT(*) FROM ticket {}", where_str); + let mut count_q = sqlx::query_as::<_, (i64,)>(audit(&count_sql)); + if user_id > 0 { + count_q = count_q.bind(user_id); + } + if let Some(st) = status { + count_q = count_q.bind(st); + } else { + count_q = count_q.bind(4i16); + } + if let Some(s) = search { + count_q = count_q.bind(format!("%{}%", s)); + } + let (total,) = count_q.fetch_one(&self.pool).await?; + + let list_sql = format!( + "SELECT * FROM ticket {} ORDER BY id DESC LIMIT ${} OFFSET ${}", + where_str, + idx + 1, + idx + 2, + ); + let mut list_q = sqlx::query_as::<_, Ticket>(audit(&list_sql)); + if user_id > 0 { + list_q = list_q.bind(user_id); + } + if let Some(st) = status { + list_q = list_q.bind(st); + } else { + list_q = list_q.bind(4i16); + } + if let Some(s) = search { + list_q = list_q.bind(format!("%{}%", s)); + } + list_q = list_q.bind(size).bind(offset); + let items = list_q.fetch_all(&self.pool).await?; + + Ok((total, items)) + } + + async fn update_ticket_status( + &self, + id: i64, + user_id: i64, + status: i16, + ) -> Result { + let res = if user_id > 0 { + sqlx::query("UPDATE ticket SET status = $1 WHERE id = $2 AND user_id = $3") + .bind(status) + .bind(id) + .bind(user_id) + .execute(&self.pool) + .await? + } else { + sqlx::query("UPDATE ticket SET status = $1 WHERE id = $2") + .bind(status) + .bind(id) + .execute(&self.pool) + .await? + }; + Ok(res.rows_affected()) + } + + async fn query_wait_reply_total(&self) -> Result { + let (total,) = sqlx::query_as::<_, (i64,)>("SELECT COUNT(*) FROM ticket WHERE status = 1") + .fetch_one(&self.pool) + .await?; + Ok(total) + } +} diff --git a/src/repository/traffic/mod.rs b/src/repository/traffic/mod.rs new file mode 100644 index 00000000..d3be9f26 --- /dev/null +++ b/src/repository/traffic/mod.rs @@ -0,0 +1,73 @@ +use crate::model::entity::traffic::{ + ServerTrafficRanking, TotalTraffic, TrafficLog, UserTrafficRanking, +}; + +#[derive(Debug, Default)] +pub struct TrafficLogDetailsFilter { + pub server_id: i64, + pub user_id: i64, + pub subscribe_id: i64, + pub start: i64, + pub end: i64, + pub page: i64, + pub size: i64, +} + +#[async_trait::async_trait] +pub trait TrafficRepo: Send + Sync { + async fn insert(&self, data: &TrafficLog) -> Result; + async fn bulk_insert(&self, rows: &[TrafficLog]) -> Result; + async fn query_server_traffic_by_day( + &self, + server_id: i64, + date: i64, + ) -> Result; + async fn query_traffic_by_day(&self, date: i64) -> Result; + async fn query_traffic_by_monthly(&self, date: i64) -> Result; + async fn query_traffic_summary(&self, start: i64, end: i64) -> Result; + async fn top_servers_traffic_by_day( + &self, + date: i64, + limit: i64, + ) -> Result, sqlx::Error>; + async fn top_servers_traffic_by_monthly( + &self, + date: i64, + limit: i64, + ) -> Result, sqlx::Error>; + async fn top_users_traffic_by_day( + &self, + date: i64, + limit: i64, + ) -> Result, sqlx::Error>; + async fn top_users_traffic_by_monthly( + &self, + date: i64, + limit: i64, + ) -> Result, sqlx::Error>; + async fn query_server_traffic_ranking( + &self, + start: i64, + end: i64, + ) -> Result, sqlx::Error>; + async fn query_user_traffic_ranking( + &self, + start: i64, + end: i64, + ) -> Result, sqlx::Error>; + async fn query_traffic_log_page_list( + &self, + user_id: i64, + subscribe_id: i64, + page: i64, + size: i64, + ) -> Result<(Vec, i64), sqlx::Error>; + async fn query_traffic_log_details( + &self, + filter: &TrafficLogDetailsFilter, + ) -> Result<(Vec, i64), sqlx::Error>; + async fn delete_before(&self, end: i64) -> Result; +} + +pub mod pg; +pub mod mysql; diff --git a/src/repository/traffic/mysql.rs b/src/repository/traffic/mysql.rs new file mode 100644 index 00000000..b7a50494 --- /dev/null +++ b/src/repository/traffic/mysql.rs @@ -0,0 +1,357 @@ +use crate::model::entity::traffic::{ + ServerTrafficRanking, TotalTraffic, TrafficLog, UserTrafficRanking, +}; +use crate::repository::audit; +use crate::repository::normalize_page; +use crate::repository::traffic::{TrafficLogDetailsFilter, TrafficRepo}; +use chrono::Datelike; + +pub struct MySqlTrafficRepo { + pool: sqlx::MySqlPool, +} + +impl MySqlTrafficRepo { + pub fn new(pool: sqlx::MySqlPool) -> Self { + Self { pool } + } +} + +fn day_range(ts: i64) -> (i64, i64) { + let secs = ts / 1000; + let s = secs - secs % 86400; + (s * 1000, (s + 86400) * 1000) +} + +fn month_range(ts: i64) -> (i64, i64) { + let secs = ts / 1000; + let dt = chrono::DateTime::::from_timestamp(secs, 0).unwrap_or_default(); + let year = dt.year(); + let month = dt.month(); + let start = chrono::NaiveDate::from_ymd_opt(year, month, 1) + .and_then(|d| d.and_hms_opt(0, 0, 0)) + .unwrap() + .and_utc(); + let (ny, nm) = if month == 12 { + (year + 1, 1) + } else { + (year, month + 1) + }; + let end = chrono::NaiveDate::from_ymd_opt(ny, nm, 1) + .and_then(|d| d.and_hms_opt(0, 0, 0)) + .unwrap() + .and_utc(); + (start.timestamp_millis(), end.timestamp_millis()) +} + +const TOTAL_SELECT: &str = + "COALESCE(SUM(download), 0) AS download, COALESCE(SUM(upload), 0) AS upload"; +const SERVER_RANK_SELECT: &str = "server_id, COALESCE(SUM(download + upload), 0) AS total, \ + COALESCE(SUM(download), 0) AS download, COALESCE(SUM(upload), 0) AS upload"; +const USER_RANK_SELECT: &str = "user_id, subscribe_id, COALESCE(SUM(download + upload), 0) AS total, \ + COALESCE(SUM(download), 0) AS download, COALESCE(SUM(upload), 0) AS upload"; + +#[async_trait::async_trait] +impl TrafficRepo for MySqlTrafficRepo { + async fn insert(&self, data: &TrafficLog) -> Result { + let result = sqlx::query( + "INSERT INTO traffic_log (server_id, user_id, subscribe_id, download, upload, timestamp) + VALUES (?, ?, ?, ?, ?, ?)", + ) + .bind(data.server_id) + .bind(data.user_id) + .bind(data.subscribe_id) + .bind(data.download) + .bind(data.upload) + .bind(data.timestamp) + .execute(&self.pool) + .await?; + let id = result.last_insert_id() as i64; + sqlx::query_as::<_, TrafficLog>("SELECT * FROM traffic_log WHERE id = ?") + .bind(id) + .fetch_one(&self.pool) + .await + } + + async fn bulk_insert(&self, rows: &[TrafficLog]) -> Result { + let mut count = 0u64; + for row in rows { + sqlx::query( + "INSERT INTO traffic_log (server_id, user_id, subscribe_id, download, upload, timestamp) + VALUES (?, ?, ?, ?, ?, ?)", + ) + .bind(row.server_id) + .bind(row.user_id) + .bind(row.subscribe_id) + .bind(row.download) + .bind(row.upload) + .bind(row.timestamp) + .execute(&self.pool) + .await?; + count += 1; + } + Ok(count) + } + + async fn query_server_traffic_by_day( + &self, + server_id: i64, + date: i64, + ) -> Result { + let (start, end) = day_range(date); + sqlx::query_as::<_, TotalTraffic>(audit(&format!( + "SELECT {} FROM traffic_log WHERE server_id = ? AND timestamp >= ? AND timestamp < ?", + TOTAL_SELECT + ))) + .bind(server_id) + .bind(start) + .bind(end) + .fetch_one(&self.pool) + .await + } + + async fn query_traffic_by_day(&self, date: i64) -> Result { + let (start, end) = day_range(date); + sqlx::query_as::<_, TotalTraffic>(audit(&format!( + "SELECT {} FROM traffic_log WHERE timestamp >= ? AND timestamp < ?", + TOTAL_SELECT + ))) + .bind(start) + .bind(end) + .fetch_one(&self.pool) + .await + } + + async fn query_traffic_by_monthly(&self, date: i64) -> Result { + let (start, end) = month_range(date); + sqlx::query_as::<_, TotalTraffic>(audit(&format!( + "SELECT {} FROM traffic_log WHERE timestamp >= ? AND timestamp < ?", + TOTAL_SELECT + ))) + .bind(start) + .bind(end) + .fetch_one(&self.pool) + .await + } + + async fn query_traffic_summary( + &self, + start: i64, + end: i64, + ) -> Result { + sqlx::query_as::<_, TotalTraffic>(audit(&format!( + "SELECT {} FROM traffic_log WHERE timestamp >= ? AND timestamp < ?", + TOTAL_SELECT + ))) + .bind(start) + .bind(end) + .fetch_one(&self.pool) + .await + } + + async fn top_servers_traffic_by_day( + &self, + date: i64, + limit: i64, + ) -> Result, sqlx::Error> { + let (start, end) = day_range(date); + sqlx::query_as::<_, ServerTrafficRanking>(audit(&format!( + "SELECT {} FROM traffic_log WHERE timestamp >= ? AND timestamp < ? \ + GROUP BY server_id ORDER BY total DESC LIMIT ?", + SERVER_RANK_SELECT + ))) + .bind(start) + .bind(end) + .bind(limit) + .fetch_all(&self.pool) + .await + } + + async fn top_servers_traffic_by_monthly( + &self, + date: i64, + limit: i64, + ) -> Result, sqlx::Error> { + let (start, end) = month_range(date); + sqlx::query_as::<_, ServerTrafficRanking>(audit(&format!( + "SELECT {} FROM traffic_log WHERE timestamp >= ? AND timestamp < ? \ + GROUP BY server_id ORDER BY total DESC LIMIT ?", + SERVER_RANK_SELECT + ))) + .bind(start) + .bind(end) + .bind(limit) + .fetch_all(&self.pool) + .await + } + + async fn top_users_traffic_by_day( + &self, + date: i64, + limit: i64, + ) -> Result, sqlx::Error> { + let (start, end) = day_range(date); + sqlx::query_as::<_, UserTrafficRanking>(audit(&format!( + "SELECT {} FROM traffic_log WHERE timestamp >= ? AND timestamp < ? \ + GROUP BY user_id, subscribe_id ORDER BY total DESC LIMIT ?", + USER_RANK_SELECT + ))) + .bind(start) + .bind(end) + .bind(limit) + .fetch_all(&self.pool) + .await + } + + async fn top_users_traffic_by_monthly( + &self, + date: i64, + limit: i64, + ) -> Result, sqlx::Error> { + let (start, end) = month_range(date); + sqlx::query_as::<_, UserTrafficRanking>(audit(&format!( + "SELECT {} FROM traffic_log WHERE timestamp >= ? AND timestamp < ? \ + GROUP BY user_id, subscribe_id ORDER BY total DESC LIMIT ?", + USER_RANK_SELECT + ))) + .bind(start) + .bind(end) + .bind(limit) + .fetch_all(&self.pool) + .await + } + + async fn query_server_traffic_ranking( + &self, + start: i64, + end: i64, + ) -> Result, sqlx::Error> { + sqlx::query_as::<_, ServerTrafficRanking>(audit(&format!( + "SELECT {} FROM traffic_log WHERE timestamp >= ? AND timestamp < ? \ + GROUP BY server_id ORDER BY total DESC", + SERVER_RANK_SELECT + ))) + .bind(start) + .bind(end) + .fetch_all(&self.pool) + .await + } + + async fn query_user_traffic_ranking( + &self, + start: i64, + end: i64, + ) -> Result, sqlx::Error> { + sqlx::query_as::<_, UserTrafficRanking>(audit(&format!( + "SELECT {} FROM traffic_log WHERE timestamp >= ? AND timestamp < ? \ + GROUP BY user_id, subscribe_id ORDER BY total DESC", + USER_RANK_SELECT + ))) + .bind(start) + .bind(end) + .fetch_all(&self.pool) + .await + } + + async fn query_traffic_log_page_list( + &self, + user_id: i64, + subscribe_id: i64, + page: i64, + size: i64, + ) -> Result<(Vec, i64), sqlx::Error> { + let offset = (page - 1) * size; + let (total,) = sqlx::query_as::<_, (i64,)>( + "SELECT COUNT(*) FROM traffic_log WHERE user_id = ? AND subscribe_id = ?", + ) + .bind(user_id) + .bind(subscribe_id) + .fetch_one(&self.pool) + .await?; + let items = sqlx::query_as::<_, TrafficLog>( + "SELECT * FROM traffic_log WHERE user_id = ? AND subscribe_id = ? ORDER BY timestamp DESC LIMIT ? OFFSET ?", + ) + .bind(user_id) + .bind(subscribe_id) + .bind(size) + .bind(offset) + .fetch_all(&self.pool) + .await?; + Ok((items, total)) + } + + async fn query_traffic_log_details( + &self, + filter: &TrafficLogDetailsFilter, + ) -> Result<(Vec, i64), sqlx::Error> { + let mut page = filter.page; + let mut size = filter.size; + normalize_page(&mut page, &mut size); + let offset = (page - 1) * size; + + let mut clauses = Vec::new(); + if filter.server_id != 0 { + clauses.push("server_id = ?".to_string()); + } + if filter.user_id != 0 { + clauses.push("user_id = ?".to_string()); + } + if filter.subscribe_id != 0 { + clauses.push("subscribe_id = ?".to_string()); + } + if filter.start != 0 && filter.end != 0 { + clauses.push("timestamp >= ?".to_string()); + clauses.push("timestamp < ?".to_string()); + } + let where_str = if clauses.is_empty() { + String::new() + } else { + format!("WHERE {}", clauses.join(" AND ")) + }; + + let count_sql = format!("SELECT COUNT(*) FROM traffic_log {}", where_str); + let mut count_q = sqlx::query_as::<_, (i64,)>(audit(&count_sql)); + if filter.server_id != 0 { + count_q = count_q.bind(filter.server_id); + } + if filter.user_id != 0 { + count_q = count_q.bind(filter.user_id); + } + if filter.subscribe_id != 0 { + count_q = count_q.bind(filter.subscribe_id); + } + if filter.start != 0 && filter.end != 0 { + count_q = count_q.bind(filter.start).bind(filter.end); + } + let (total,) = count_q.fetch_one(&self.pool).await?; + + let list_sql = format!( + "SELECT * FROM traffic_log {} ORDER BY timestamp DESC LIMIT ? OFFSET ?", + where_str, + ); + let mut list_q = sqlx::query_as::<_, TrafficLog>(audit(&list_sql)); + if filter.server_id != 0 { + list_q = list_q.bind(filter.server_id); + } + if filter.user_id != 0 { + list_q = list_q.bind(filter.user_id); + } + if filter.subscribe_id != 0 { + list_q = list_q.bind(filter.subscribe_id); + } + if filter.start != 0 && filter.end != 0 { + list_q = list_q.bind(filter.start).bind(filter.end); + } + list_q = list_q.bind(size).bind(offset); + let items = list_q.fetch_all(&self.pool).await?; + + Ok((items, total)) + } + + async fn delete_before(&self, end: i64) -> Result { + let res = sqlx::query("DELETE FROM traffic_log WHERE timestamp <= ?") + .bind(end) + .execute(&self.pool) + .await?; + Ok(res.rows_affected()) + } +} diff --git a/src/repository/traffic/pg.rs b/src/repository/traffic/pg.rs new file mode 100644 index 00000000..b7ed7642 --- /dev/null +++ b/src/repository/traffic/pg.rs @@ -0,0 +1,361 @@ +use crate::model::entity::traffic::{ + ServerTrafficRanking, TotalTraffic, TrafficLog, UserTrafficRanking, +}; +use crate::repository::audit; +use crate::repository::normalize_page; +use crate::repository::traffic::{TrafficLogDetailsFilter, TrafficRepo}; +use chrono::Datelike; + +pub struct PgTrafficRepo { + pool: sqlx::PgPool, +} + +impl PgTrafficRepo { + pub fn new(pool: sqlx::PgPool) -> Self { + Self { pool } + } +} + +fn day_range(ts: i64) -> (i64, i64) { + let secs = ts / 1000; + let s = secs - secs % 86400; + (s * 1000, (s + 86400) * 1000) +} + +fn month_range(ts: i64) -> (i64, i64) { + let secs = ts / 1000; + let dt = chrono::DateTime::::from_timestamp(secs, 0).unwrap_or_default(); + let year = dt.year(); + let month = dt.month(); + let start = chrono::NaiveDate::from_ymd_opt(year, month, 1) + .and_then(|d| d.and_hms_opt(0, 0, 0)) + .unwrap() + .and_utc(); + let (ny, nm) = if month == 12 { + (year + 1, 1) + } else { + (year, month + 1) + }; + let end = chrono::NaiveDate::from_ymd_opt(ny, nm, 1) + .and_then(|d| d.and_hms_opt(0, 0, 0)) + .unwrap() + .and_utc(); + (start.timestamp_millis(), end.timestamp_millis()) +} + +const TOTAL_SELECT: &str = + "COALESCE(SUM(download), 0) AS download, COALESCE(SUM(upload), 0) AS upload"; +const SERVER_RANK_SELECT: &str = "server_id, COALESCE(SUM(download + upload), 0) AS total, \ + COALESCE(SUM(download), 0) AS download, COALESCE(SUM(upload), 0) AS upload"; +const USER_RANK_SELECT: &str = "user_id, subscribe_id, COALESCE(SUM(download + upload), 0) AS total, \ + COALESCE(SUM(download), 0) AS download, COALESCE(SUM(upload), 0) AS upload"; + +#[async_trait::async_trait] +impl TrafficRepo for PgTrafficRepo { + async fn insert(&self, data: &TrafficLog) -> Result { + sqlx::query_as::<_, TrafficLog>( + "INSERT INTO traffic_log (server_id, user_id, subscribe_id, download, upload, timestamp) + VALUES ($1, $2, $3, $4, $5, $6) + RETURNING *", + ) + .bind(data.server_id) + .bind(data.user_id) + .bind(data.subscribe_id) + .bind(data.download) + .bind(data.upload) + .bind(data.timestamp) + .fetch_one(&self.pool) + .await + } + + async fn bulk_insert(&self, rows: &[TrafficLog]) -> Result { + let mut count = 0u64; + for row in rows { + sqlx::query( + "INSERT INTO traffic_log (server_id, user_id, subscribe_id, download, upload, timestamp) + VALUES ($1, $2, $3, $4, $5, $6)", + ) + .bind(row.server_id) + .bind(row.user_id) + .bind(row.subscribe_id) + .bind(row.download) + .bind(row.upload) + .bind(row.timestamp) + .execute(&self.pool) + .await?; + count += 1; + } + Ok(count) + } + + async fn query_server_traffic_by_day( + &self, + server_id: i64, + date: i64, + ) -> Result { + let (start, end) = day_range(date); + sqlx::query_as::<_, TotalTraffic>(audit(&format!( + "SELECT {} FROM traffic_log WHERE server_id = $1 AND timestamp >= $2 AND timestamp < $3", + TOTAL_SELECT + ))) + .bind(server_id) + .bind(start) + .bind(end) + .fetch_one(&self.pool) + .await + } + + async fn query_traffic_by_day(&self, date: i64) -> Result { + let (start, end) = day_range(date); + sqlx::query_as::<_, TotalTraffic>(audit(&format!( + "SELECT {} FROM traffic_log WHERE timestamp >= $1 AND timestamp < $2", + TOTAL_SELECT + ))) + .bind(start) + .bind(end) + .fetch_one(&self.pool) + .await + } + + async fn query_traffic_by_monthly(&self, date: i64) -> Result { + let (start, end) = month_range(date); + sqlx::query_as::<_, TotalTraffic>(audit(&format!( + "SELECT {} FROM traffic_log WHERE timestamp >= $1 AND timestamp < $2", + TOTAL_SELECT + ))) + .bind(start) + .bind(end) + .fetch_one(&self.pool) + .await + } + + async fn query_traffic_summary( + &self, + start: i64, + end: i64, + ) -> Result { + sqlx::query_as::<_, TotalTraffic>(audit(&format!( + "SELECT {} FROM traffic_log WHERE timestamp >= $1 AND timestamp < $2", + TOTAL_SELECT + ))) + .bind(start) + .bind(end) + .fetch_one(&self.pool) + .await + } + + async fn top_servers_traffic_by_day( + &self, + date: i64, + limit: i64, + ) -> Result, sqlx::Error> { + let (start, end) = day_range(date); + sqlx::query_as::<_, ServerTrafficRanking>(audit(&format!( + "SELECT {} FROM traffic_log WHERE timestamp >= $1 AND timestamp < $2 \ + GROUP BY server_id ORDER BY total DESC LIMIT $3", + SERVER_RANK_SELECT + ))) + .bind(start) + .bind(end) + .bind(limit) + .fetch_all(&self.pool) + .await + } + + async fn top_servers_traffic_by_monthly( + &self, + date: i64, + limit: i64, + ) -> Result, sqlx::Error> { + let (start, end) = month_range(date); + sqlx::query_as::<_, ServerTrafficRanking>(audit(&format!( + "SELECT {} FROM traffic_log WHERE timestamp >= $1 AND timestamp < $2 \ + GROUP BY server_id ORDER BY total DESC LIMIT $3", + SERVER_RANK_SELECT + ))) + .bind(start) + .bind(end) + .bind(limit) + .fetch_all(&self.pool) + .await + } + + async fn top_users_traffic_by_day( + &self, + date: i64, + limit: i64, + ) -> Result, sqlx::Error> { + let (start, end) = day_range(date); + sqlx::query_as::<_, UserTrafficRanking>(audit(&format!( + "SELECT {} FROM traffic_log WHERE timestamp >= $1 AND timestamp < $2 \ + GROUP BY user_id, subscribe_id ORDER BY total DESC LIMIT $3", + USER_RANK_SELECT + ))) + .bind(start) + .bind(end) + .bind(limit) + .fetch_all(&self.pool) + .await + } + + async fn top_users_traffic_by_monthly( + &self, + date: i64, + limit: i64, + ) -> Result, sqlx::Error> { + let (start, end) = month_range(date); + sqlx::query_as::<_, UserTrafficRanking>(audit(&format!( + "SELECT {} FROM traffic_log WHERE timestamp >= $1 AND timestamp < $2 \ + GROUP BY user_id, subscribe_id ORDER BY total DESC LIMIT $3", + USER_RANK_SELECT + ))) + .bind(start) + .bind(end) + .bind(limit) + .fetch_all(&self.pool) + .await + } + + async fn query_server_traffic_ranking( + &self, + start: i64, + end: i64, + ) -> Result, sqlx::Error> { + sqlx::query_as::<_, ServerTrafficRanking>(audit(&format!( + "SELECT {} FROM traffic_log WHERE timestamp >= $1 AND timestamp < $2 \ + GROUP BY server_id ORDER BY total DESC", + SERVER_RANK_SELECT + ))) + .bind(start) + .bind(end) + .fetch_all(&self.pool) + .await + } + + async fn query_user_traffic_ranking( + &self, + start: i64, + end: i64, + ) -> Result, sqlx::Error> { + sqlx::query_as::<_, UserTrafficRanking>(audit(&format!( + "SELECT {} FROM traffic_log WHERE timestamp >= $1 AND timestamp < $2 \ + GROUP BY user_id, subscribe_id ORDER BY total DESC", + USER_RANK_SELECT + ))) + .bind(start) + .bind(end) + .fetch_all(&self.pool) + .await + } + + async fn query_traffic_log_page_list( + &self, + user_id: i64, + subscribe_id: i64, + page: i64, + size: i64, + ) -> Result<(Vec, i64), sqlx::Error> { + let offset = (page - 1) * size; + let (total,) = sqlx::query_as::<_, (i64,)>( + "SELECT COUNT(*) FROM traffic_log WHERE user_id = $1 AND subscribe_id = $2", + ) + .bind(user_id) + .bind(subscribe_id) + .fetch_one(&self.pool) + .await?; + let items = sqlx::query_as::<_, TrafficLog>( + "SELECT * FROM traffic_log WHERE user_id = $1 AND subscribe_id = $2 ORDER BY timestamp DESC LIMIT $3 OFFSET $4", + ) + .bind(user_id) + .bind(subscribe_id) + .bind(size) + .bind(offset) + .fetch_all(&self.pool) + .await?; + Ok((items, total)) + } + + async fn query_traffic_log_details( + &self, + filter: &TrafficLogDetailsFilter, + ) -> Result<(Vec, i64), sqlx::Error> { + let mut page = filter.page; + let mut size = filter.size; + normalize_page(&mut page, &mut size); + let offset = (page - 1) * size; + + let mut clauses = Vec::new(); + let mut idx = 0u32; + if filter.server_id != 0 { + idx += 1; + clauses.push(format!("server_id = ${}", idx)); + } + if filter.user_id != 0 { + idx += 1; + clauses.push(format!("user_id = ${}", idx)); + } + if filter.subscribe_id != 0 { + idx += 1; + clauses.push(format!("subscribe_id = ${}", idx)); + } + if filter.start != 0 && filter.end != 0 { + idx += 1; + clauses.push(format!("timestamp >= ${}", idx)); + idx += 1; + clauses.push(format!("timestamp < ${}", idx)); + } + let where_str = if clauses.is_empty() { + String::new() + } else { + format!("WHERE {}", clauses.join(" AND ")) + }; + + let count_sql = format!("SELECT COUNT(*) FROM traffic_log {}", where_str); + let mut count_q = sqlx::query_as::<_, (i64,)>(audit(&count_sql)); + if filter.server_id != 0 { + count_q = count_q.bind(filter.server_id); + } + if filter.user_id != 0 { + count_q = count_q.bind(filter.user_id); + } + if filter.subscribe_id != 0 { + count_q = count_q.bind(filter.subscribe_id); + } + if filter.start != 0 && filter.end != 0 { + count_q = count_q.bind(filter.start).bind(filter.end); + } + let (total,) = count_q.fetch_one(&self.pool).await?; + + let list_sql = format!( + "SELECT * FROM traffic_log {} ORDER BY timestamp DESC LIMIT ${} OFFSET ${}", + where_str, + idx + 1, + idx + 2, + ); + let mut list_q = sqlx::query_as::<_, TrafficLog>(audit(&list_sql)); + if filter.server_id != 0 { + list_q = list_q.bind(filter.server_id); + } + if filter.user_id != 0 { + list_q = list_q.bind(filter.user_id); + } + if filter.subscribe_id != 0 { + list_q = list_q.bind(filter.subscribe_id); + } + if filter.start != 0 && filter.end != 0 { + list_q = list_q.bind(filter.start).bind(filter.end); + } + list_q = list_q.bind(size).bind(offset); + let items = list_q.fetch_all(&self.pool).await?; + + Ok((items, total)) + } + + async fn delete_before(&self, end: i64) -> Result { + let res = sqlx::query("DELETE FROM traffic_log WHERE timestamp <= $1") + .bind(end) + .execute(&self.pool) + .await?; + Ok(res.rows_affected()) + } +} diff --git a/src/repository/user/mod.rs b/src/repository/user/mod.rs new file mode 100644 index 00000000..3fa7cd03 --- /dev/null +++ b/src/repository/user/mod.rs @@ -0,0 +1,279 @@ +use crate::model::entity::user::{ + AuthMethods, Device, DeviceOnlineRecord, User, UserSubscribe, Withdrawal, +}; + +#[derive(Debug, Default)] +pub struct UserFilter { + pub search: Option, + pub user_id: Option, + pub subscribe_id: Option, + pub user_subscribe_id: Option, + pub order: Option, + pub unscoped: bool, +} + +#[derive(Debug, Default)] +pub struct SubscribeFilter { + pub subscribers: Vec, + pub is_active: Option, + pub start_time: i64, + pub end_time: i64, +} + +#[derive(Debug, Default)] +pub struct EmailRecipientFilter { + pub scope: i16, + pub register_start_time: i64, + pub register_end_time: i64, +} + +#[derive(Debug, Clone, sqlx::FromRow)] +pub struct UserStatisticsWithDate { + pub date: String, + pub register: i64, + pub new_order_users: i64, + pub renewal_order_users: i64, +} + +#[derive(Debug, Clone, serde::Serialize, serde::Deserialize, sqlx::FromRow)] +pub struct SubscribeDetails { + pub id: i64, + pub user_id: i64, + pub order_id: i64, + pub subscribe_id: i64, + pub subscribe_name: Option, + pub start_time: i64, + pub expire_time: i64, + pub finished_at: Option, + pub traffic: i64, + pub download: i64, + pub upload: i64, + pub token: String, + pub uuid: String, + pub status: i16, + pub note: String, + pub created_at: i64, + pub updated_at: i64, +} + +#[async_trait::async_trait] +pub trait UserRepo: Send + Sync { + async fn insert_user(&self, data: &User) -> Result; + async fn find_one_user(&self, id: i64) -> Result; + async fn update_user(&self, data: &User) -> Result; + async fn delete_user(&self, id: i64) -> Result; + + // NOTE: 与 Go 版本的差异 - 关联数据加载 + // Go: QueryPageList 会自动 Preload("UserDevices").Preload("AuthMethods") + // Rust: 当前实现不包含关联数据,只返回 User 主表数据 + // 如需关联数据,请在 handler 层手动查询,或使用专门的查询方法 + async fn query_page_list( + &self, + page: i64, + size: i64, + filter: &UserFilter, + ) -> Result<(i64, Vec), sqlx::Error>; + + async fn insert_subscribe(&self, data: &UserSubscribe) -> Result; + async fn find_one_subscribe(&self, id: i64) -> Result; + async fn find_one_subscribe_by_token( + &self, + token: &str, + ) -> Result; + async fn find_one_subscribe_by_order_id( + &self, + order_id: i64, + ) -> Result; + async fn find_one_subscribe_details_by_id( + &self, + id: i64, + ) -> Result; + async fn find_one_user_subscribe(&self, id: i64) -> Result; + async fn update_subscribe(&self, data: &UserSubscribe) -> Result; + async fn delete_subscribe(&self, token: &str) -> Result; + async fn delete_subscribe_by_id(&self, id: i64) -> Result; + + async fn insert_auth_method(&self, data: &AuthMethods) -> Result; + async fn find_user_auth_methods( + &self, + user_id: i64, + ) -> Result, sqlx::Error>; + async fn update_auth_method(&self, data: &AuthMethods) -> Result; + async fn delete_user_auth_methods( + &self, + user_id: i64, + platform: &str, + ) -> Result; + async fn delete_user_auth_method_by_identifier( + &self, + auth_type: &str, + identifier: &str, + ) -> Result; + async fn find_auth_method_by_open_id( + &self, + method: &str, + open_id: &str, + ) -> Result, sqlx::Error>; + async fn find_auth_method_by_user_id( + &self, + method: &str, + user_id: i64, + ) -> Result, sqlx::Error>; + async fn find_auth_method_by_platform( + &self, + user_id: i64, + platform: &str, + ) -> Result, sqlx::Error>; + async fn update_user_auth_method_owner( + &self, + auth_type: &str, + identifier: &str, + user_id: i64, + ) -> Result; + async fn upsert_user_auth_method( + &self, + data: &AuthMethods, + ) -> Result; + + async fn insert_device(&self, data: &Device) -> Result; + async fn find_one_device(&self, id: i64) -> Result; + async fn find_one_device_by_identifier( + &self, + identifier: &str, + ) -> Result, sqlx::Error>; + async fn update_device(&self, data: &Device) -> Result; + async fn delete_device(&self, id: i64) -> Result; + async fn query_device_list( + &self, + user_id: i64, + ) -> Result<(Vec, i64), sqlx::Error>; + async fn query_device_page_list( + &self, + user_id: i64, + _subscribe_id: i64, + page: i64, + size: i64, + ) -> Result<(Vec, i64), sqlx::Error>; + + async fn insert_device_online_record( + &self, + data: &DeviceOnlineRecord, + ) -> Result; + async fn find_device_online_record( + &self, + user_id: i64, + start_time: &str, + end_time: &str, + ) -> Result, sqlx::Error>; + + async fn insert_withdrawal(&self, data: &Withdrawal) -> Result; + + async fn find_users_by_ids(&self, ids: &[i64]) -> Result, sqlx::Error>; + async fn find_one_by_refer_code(&self, refer_code: &str) -> Result, sqlx::Error>; + async fn find_one_by_email(&self, email: &str) -> Result, sqlx::Error>; + async fn batch_delete_users(&self, ids: &[i64]) -> Result; + async fn count_affiliates(&self, referer_id: i64) -> Result; + async fn query_affiliate_list( + &self, + referer_id: i64, + page: i64, + size: i64, + ) -> Result<(i64, Vec), sqlx::Error>; + + async fn find_subscribes_by_ids( + &self, + ids: &[i64], + ) -> Result, sqlx::Error>; + async fn query_monthly_reset_subscribe_ids( + &self, + subscribe_ids: &[i64], + now: i64, + ) -> Result, sqlx::Error>; + async fn query_first_reset_subscribe_ids( + &self, + subscribe_ids: &[i64], + _now: i64, + ) -> Result, sqlx::Error>; + async fn query_yearly_reset_subscribe_ids( + &self, + subscribe_ids: &[i64], + _now: i64, + ) -> Result, sqlx::Error>; + async fn reset_subscribe_traffic_by_ids(&self, ids: &[i64]) -> Result; + async fn find_traffic_exceeded_subscribes( + &self, + ) -> Result, sqlx::Error>; + async fn find_expired_subscribes(&self, now: i64) -> Result, sqlx::Error>; + async fn mark_subscribes_finished( + &self, + ids: &[i64], + status: i16, + finished_at: i64, + ) -> Result; + async fn query_user_subscribe( + &self, + user_id: i64, + statuses: &[i64], + ) -> Result, sqlx::Error>; + async fn find_users_subscribe_by_subscribe_id( + &self, + subscribe_id: i64, + ) -> Result, sqlx::Error>; + async fn find_user_subscribes_by_status( + &self, + statuses: &[i64], + ) -> Result, sqlx::Error>; + async fn activate_pending_subscribes_by_subscribe_id( + &self, + subscribe_id: i64, + ) -> Result; + async fn count_user_subscribes_by_user_and_subscribe( + &self, + user_id: i64, + subscribe_id: i64, + ) -> Result; + async fn count_user_subscribes_by_subscribe_id_and_status( + &self, + subscribe_id: i64, + statuses: &[i64], + ) -> Result; + async fn update_user_subscribe_with_traffic( + &self, + id: i64, + download: i64, + upload: i64, + ) -> Result; + + async fn query_register_user_total_by_date(&self, date: i64) -> Result; + async fn query_register_user_total_by_monthly(&self, date: i64) -> Result; + async fn query_register_user_total(&self) -> Result; + async fn count_enabled_users(&self) -> Result; + async fn query_admin_users(&self) -> Result, sqlx::Error>; + async fn query_active_subscriptions( + &self, + subscribe_ids: &[i64], + ) -> Result, sqlx::Error>; + async fn query_email_recipients( + &self, + filter: &EmailRecipientFilter, + ) -> Result, sqlx::Error>; + async fn count_email_recipients(&self, filter: &EmailRecipientFilter) -> Result; + + async fn query_subscribe_ids_by_filter( + &self, + filter: &SubscribeFilter, + ) -> Result, sqlx::Error>; + async fn count_subscribes_by_filter(&self, filter: &SubscribeFilter) -> Result; + + async fn query_daily_user_statistics_list( + &self, + now: i64, + ) -> Result, sqlx::Error>; + async fn query_monthly_user_statistics_list( + &self, + now: i64, + ) -> Result, sqlx::Error>; +} + +pub mod pg; +pub mod mysql; diff --git a/src/repository/user/mysql.rs b/src/repository/user/mysql.rs new file mode 100644 index 00000000..822fdbf0 --- /dev/null +++ b/src/repository/user/mysql.rs @@ -0,0 +1,1317 @@ +use crate::model::entity::user::{ + AuthMethods, Device, DeviceOnlineRecord, User, UserSubscribe, Withdrawal, +}; +use crate::repository::audit; +use crate::repository::normalize_page; +use crate::repository::user::{ + EmailRecipientFilter, SubscribeDetails, SubscribeFilter, UserFilter, UserRepo, + UserStatisticsWithDate, +}; +use chrono::Datelike; + +pub struct MySqlUserRepo { + pool: sqlx::MySqlPool, +} + +impl MySqlUserRepo { + pub fn new(pool: sqlx::MySqlPool) -> Self { + Self { pool } + } +} + +fn day_range(ts: i64) -> (i64, i64) { + let secs = ts / 1000; + let s = secs - secs % 86400; + (s * 1000, (s + 86400) * 1000) +} + +fn month_start(ts: i64) -> i64 { + let secs = ts / 1000; + let dt = chrono::DateTime::::from_timestamp(secs, 0).unwrap_or_default(); + let year = dt.year(); + let month = dt.month(); + chrono::NaiveDate::from_ymd_opt(year, month, 1) + .and_then(|d| d.and_hms_opt(0, 0, 0)) + .unwrap() + .and_utc() + .timestamp_millis() +} + +fn month_range(ts: i64) -> (i64, i64) { + let secs = ts / 1000; + let dt = chrono::DateTime::::from_timestamp(secs, 0).unwrap_or_default(); + let year = dt.year(); + let month = dt.month(); + let start = chrono::NaiveDate::from_ymd_opt(year, month, 1) + .and_then(|d| d.and_hms_opt(0, 0, 0)) + .unwrap() + .and_utc(); + let (ny, nm) = if month == 12 { + (year + 1, 1) + } else { + (year, month + 1) + }; + let end = chrono::NaiveDate::from_ymd_opt(ny, nm, 1) + .and_then(|d| d.and_hms_opt(0, 0, 0)) + .unwrap() + .and_utc(); + (start.timestamp_millis(), end.timestamp_millis()) +} + +fn six_months_ago(ts: i64) -> i64 { + let secs = ts / 1000; + let dt = chrono::DateTime::::from_timestamp(secs, 0).unwrap_or_default(); + let ago = dt.checked_sub_months(chrono::Months::new(5)).unwrap_or(dt); + ago.timestamp_millis() +} + +fn mysql_placeholders(n: usize) -> String { + std::iter::repeat("?").take(n).collect::>().join(", ") +} + +#[async_trait::async_trait] +impl UserRepo for MySqlUserRepo { + async fn insert_user(&self, data: &User) -> Result { + let result = sqlx::query( + "INSERT INTO `user` (password, algo, salt, avatar, balance, refer_code, referer_id, + commission, referral_percentage, only_first_purchase, gift_amount, enable, is_admin, + enable_balance_notify, enable_login_notify, enable_subscribe_notify, enable_trade_notify, + rules, created_at, updated_at, deleted_at) + VALUES (?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?)", + ) + .bind(&data.password) + .bind(&data.algo) + .bind(&data.salt) + .bind(&data.avatar) + .bind(data.balance) + .bind(&data.refer_code) + .bind(data.referer_id) + .bind(data.commission) + .bind(data.referral_percentage) + .bind(data.only_first_purchase) + .bind(data.gift_amount) + .bind(data.enable) + .bind(data.is_admin) + .bind(data.enable_balance_notify) + .bind(data.enable_login_notify) + .bind(data.enable_subscribe_notify) + .bind(data.enable_trade_notify) + .bind(&data.rules) + .bind(data.created_at) + .bind(data.updated_at) + .bind(data.deleted_at) + .execute(&self.pool) + .await?; + let id = result.last_insert_id() as i64; + sqlx::query_as::<_, User>("SELECT * FROM `user` WHERE id = ?") + .bind(id) + .fetch_one(&self.pool) + .await + } + + async fn find_one_user(&self, id: i64) -> Result { + sqlx::query_as::<_, User>("SELECT * FROM `user` WHERE id = ?") + .bind(id) + .fetch_one(&self.pool) + .await + } + + async fn update_user(&self, data: &User) -> Result { + sqlx::query( + "UPDATE `user` SET password = ?, algo = ?, salt = ?, avatar = ?, balance = ?, + refer_code = ?, referer_id = ?, commission = ?, referral_percentage = ?, + only_first_purchase = ?, gift_amount = ?, enable = ?, is_admin = ?, + enable_balance_notify = ?, enable_login_notify = ?, enable_subscribe_notify = ?, + enable_trade_notify = ?, rules = ?, updated_at = ? WHERE id = ?", + ) + .bind(&data.password) + .bind(&data.algo) + .bind(&data.salt) + .bind(&data.avatar) + .bind(data.balance) + .bind(&data.refer_code) + .bind(data.referer_id) + .bind(data.commission) + .bind(data.referral_percentage) + .bind(data.only_first_purchase) + .bind(data.gift_amount) + .bind(data.enable) + .bind(data.is_admin) + .bind(data.enable_balance_notify) + .bind(data.enable_login_notify) + .bind(data.enable_subscribe_notify) + .bind(data.enable_trade_notify) + .bind(&data.rules) + .bind(data.updated_at) + .bind(data.id) + .execute(&self.pool) + .await?; + sqlx::query_as::<_, User>("SELECT * FROM `user` WHERE id = ?") + .bind(data.id) + .fetch_one(&self.pool) + .await + } + + async fn delete_user(&self, id: i64) -> Result { + let now = chrono::Utc::now().timestamp_millis(); + let res = sqlx::query("UPDATE `user` SET deleted_at = ? WHERE id = ?") + .bind(now) + .bind(id) + .execute(&self.pool) + .await?; + Ok(res.rows_affected()) + } + + async fn insert_subscribe(&self, data: &UserSubscribe) -> Result { + let result = sqlx::query( + "INSERT INTO user_subscribe (user_id, order_id, subscribe_id, start_time, expire_time, + finished_at, traffic, download, upload, token, uuid, status, note, created_at, updated_at) + VALUES (?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?)", + ) + .bind(data.user_id) + .bind(data.order_id) + .bind(data.subscribe_id) + .bind(data.start_time) + .bind(data.expire_time) + .bind(data.finished_at) + .bind(data.traffic) + .bind(data.download) + .bind(data.upload) + .bind(&data.token) + .bind(&data.uuid) + .bind(data.status) + .bind(&data.note) + .bind(data.created_at) + .bind(data.updated_at) + .execute(&self.pool) + .await?; + let id = result.last_insert_id() as i64; + sqlx::query_as::<_, UserSubscribe>("SELECT * FROM user_subscribe WHERE id = ?") + .bind(id) + .fetch_one(&self.pool) + .await + } + + async fn find_one_subscribe(&self, id: i64) -> Result { + sqlx::query_as::<_, UserSubscribe>("SELECT * FROM user_subscribe WHERE id = ?") + .bind(id) + .fetch_one(&self.pool) + .await + } + + async fn find_one_subscribe_by_token( + &self, + token: &str, + ) -> Result { + sqlx::query_as::<_, UserSubscribe>("SELECT * FROM user_subscribe WHERE token = ?") + .bind(token) + .fetch_one(&self.pool) + .await + } + + async fn find_one_subscribe_by_order_id( + &self, + order_id: i64, + ) -> Result { + sqlx::query_as::<_, UserSubscribe>("SELECT * FROM user_subscribe WHERE order_id = ?") + .bind(order_id) + .fetch_one(&self.pool) + .await + } + + async fn find_one_subscribe_details_by_id( + &self, + id: i64, + ) -> Result { + sqlx::query_as::<_, SubscribeDetails>( + "SELECT us.*, s.name AS subscribe_name FROM user_subscribe us \ + LEFT JOIN subscribe s ON s.id = us.subscribe_id WHERE us.id = ?", + ) + .bind(id) + .fetch_one(&self.pool) + .await + } + + async fn find_one_user_subscribe(&self, id: i64) -> Result { + sqlx::query_as::<_, SubscribeDetails>( + "SELECT us.*, s.name AS subscribe_name FROM user_subscribe us \ + LEFT JOIN subscribe s ON s.id = us.subscribe_id WHERE us.id = ?", + ) + .bind(id) + .fetch_one(&self.pool) + .await + } + + async fn update_subscribe(&self, data: &UserSubscribe) -> Result { + sqlx::query( + "UPDATE user_subscribe SET user_id = ?, order_id = ?, subscribe_id = ?, start_time = ?, + expire_time = ?, finished_at = ?, traffic = ?, download = ?, upload = ?, token = ?, + uuid = ?, status = ?, note = ?, updated_at = ? WHERE id = ?", + ) + .bind(data.user_id) + .bind(data.order_id) + .bind(data.subscribe_id) + .bind(data.start_time) + .bind(data.expire_time) + .bind(data.finished_at) + .bind(data.traffic) + .bind(data.download) + .bind(data.upload) + .bind(&data.token) + .bind(&data.uuid) + .bind(data.status) + .bind(&data.note) + .bind(data.updated_at) + .bind(data.id) + .execute(&self.pool) + .await?; + sqlx::query_as::<_, UserSubscribe>("SELECT * FROM user_subscribe WHERE id = ?") + .bind(data.id) + .fetch_one(&self.pool) + .await + } + + async fn delete_subscribe(&self, token: &str) -> Result { + let res = sqlx::query("DELETE FROM user_subscribe WHERE token = ?") + .bind(token) + .execute(&self.pool) + .await?; + Ok(res.rows_affected()) + } + + async fn delete_subscribe_by_id(&self, id: i64) -> Result { + let res = sqlx::query("DELETE FROM user_subscribe WHERE id = ?") + .bind(id) + .execute(&self.pool) + .await?; + Ok(res.rows_affected()) + } + + async fn insert_auth_method(&self, data: &AuthMethods) -> Result { + let result = sqlx::query( + "INSERT INTO user_auth_methods (user_id, auth_type, auth_identifier, verified, created_at, updated_at) + VALUES (?, ?, ?, ?, ?, ?)", + ) + .bind(data.user_id) + .bind(&data.auth_type) + .bind(&data.auth_identifier) + .bind(data.verified) + .bind(data.created_at) + .bind(data.updated_at) + .execute(&self.pool) + .await?; + let id = result.last_insert_id() as i64; + sqlx::query_as::<_, AuthMethods>("SELECT * FROM user_auth_methods WHERE id = ?") + .bind(id) + .fetch_one(&self.pool) + .await + } + + async fn find_user_auth_methods( + &self, + user_id: i64, + ) -> Result, sqlx::Error> { + sqlx::query_as::<_, AuthMethods>( + "SELECT * FROM user_auth_methods WHERE user_id = ? ORDER BY id ASC", + ) + .bind(user_id) + .fetch_all(&self.pool) + .await + } + + async fn update_auth_method(&self, data: &AuthMethods) -> Result { + sqlx::query( + "UPDATE user_auth_methods SET user_id = ?, auth_type = ?, auth_identifier = ?, + verified = ?, updated_at = ? WHERE id = ?", + ) + .bind(data.user_id) + .bind(&data.auth_type) + .bind(&data.auth_identifier) + .bind(data.verified) + .bind(data.updated_at) + .bind(data.id) + .execute(&self.pool) + .await?; + sqlx::query_as::<_, AuthMethods>("SELECT * FROM user_auth_methods WHERE id = ?") + .bind(data.id) + .fetch_one(&self.pool) + .await + } + + async fn delete_user_auth_methods( + &self, + user_id: i64, + platform: &str, + ) -> Result { + let res = + sqlx::query("DELETE FROM user_auth_methods WHERE user_id = ? AND auth_type = ?") + .bind(user_id) + .bind(platform) + .execute(&self.pool) + .await?; + Ok(res.rows_affected()) + } + + async fn delete_user_auth_method_by_identifier( + &self, + auth_type: &str, + identifier: &str, + ) -> Result { + let res = sqlx::query( + "DELETE FROM user_auth_methods WHERE auth_type = ? AND auth_identifier = ?", + ) + .bind(auth_type) + .bind(identifier) + .execute(&self.pool) + .await?; + Ok(res.rows_affected()) + } + + async fn find_auth_method_by_open_id( + &self, + method: &str, + open_id: &str, + ) -> Result, sqlx::Error> { + sqlx::query_as::<_, AuthMethods>( + "SELECT * FROM user_auth_methods WHERE auth_type = ? AND auth_identifier = ?", + ) + .bind(method) + .bind(open_id) + .fetch_optional(&self.pool) + .await + } + + async fn find_auth_method_by_user_id( + &self, + method: &str, + user_id: i64, + ) -> Result, sqlx::Error> { + sqlx::query_as::<_, AuthMethods>( + "SELECT * FROM user_auth_methods WHERE auth_type = ? AND user_id = ?", + ) + .bind(method) + .bind(user_id) + .fetch_optional(&self.pool) + .await + } + + async fn find_auth_method_by_platform( + &self, + user_id: i64, + platform: &str, + ) -> Result, sqlx::Error> { + sqlx::query_as::<_, AuthMethods>( + "SELECT * FROM user_auth_methods WHERE user_id = ? AND auth_type = ?", + ) + .bind(user_id) + .bind(platform) + .fetch_optional(&self.pool) + .await + } + + async fn update_user_auth_method_owner( + &self, + auth_type: &str, + identifier: &str, + user_id: i64, + ) -> Result { + let res = sqlx::query( + "UPDATE user_auth_methods SET user_id = ? WHERE auth_type = ? AND auth_identifier = ?", + ) + .bind(user_id) + .bind(auth_type) + .bind(identifier) + .execute(&self.pool) + .await?; + Ok(res.rows_affected()) + } + + async fn upsert_user_auth_method( + &self, + data: &AuthMethods, + ) -> Result { + let result = sqlx::query( + "INSERT INTO user_auth_methods (user_id, auth_type, auth_identifier, verified, created_at, updated_at) + VALUES (?, ?, ?, ?, ?, ?) + ON DUPLICATE KEY UPDATE user_id = VALUES(user_id), verified = VALUES(verified), updated_at = VALUES(updated_at)", + ) + .bind(data.user_id) + .bind(&data.auth_type) + .bind(&data.auth_identifier) + .bind(data.verified) + .bind(data.created_at) + .bind(data.updated_at) + .execute(&self.pool) + .await?; + let id = result.last_insert_id() as i64; + sqlx::query_as::<_, AuthMethods>("SELECT * FROM user_auth_methods WHERE id = ?") + .bind(id) + .fetch_one(&self.pool) + .await + } + + async fn insert_device(&self, data: &Device) -> Result { + let result = sqlx::query( + "INSERT INTO user_device (ip, user_id, user_agent, identifier, online, enabled, created_at, updated_at) + VALUES (?, ?, ?, ?, ?, ?, ?, ?)", + ) + .bind(&data.ip) + .bind(data.user_id) + .bind(&data.user_agent) + .bind(&data.identifier) + .bind(data.online) + .bind(data.enabled) + .bind(data.created_at) + .bind(data.updated_at) + .execute(&self.pool) + .await?; + let id = result.last_insert_id() as i64; + sqlx::query_as::<_, Device>("SELECT * FROM user_device WHERE id = ?") + .bind(id) + .fetch_one(&self.pool) + .await + } + + async fn find_one_device(&self, id: i64) -> Result { + sqlx::query_as::<_, Device>("SELECT * FROM user_device WHERE id = ?") + .bind(id) + .fetch_one(&self.pool) + .await + } + + async fn find_one_device_by_identifier( + &self, + identifier: &str, + ) -> Result, sqlx::Error> { + sqlx::query_as::<_, Device>("SELECT * FROM user_device WHERE identifier = ?") + .bind(identifier) + .fetch_optional(&self.pool) + .await + } + + async fn update_device(&self, data: &Device) -> Result { + sqlx::query( + "UPDATE user_device SET ip = ?, user_id = ?, user_agent = ?, identifier = ?, + online = ?, enabled = ?, updated_at = ? WHERE id = ?", + ) + .bind(&data.ip) + .bind(data.user_id) + .bind(&data.user_agent) + .bind(&data.identifier) + .bind(data.online) + .bind(data.enabled) + .bind(data.updated_at) + .bind(data.id) + .execute(&self.pool) + .await?; + sqlx::query_as::<_, Device>("SELECT * FROM user_device WHERE id = ?") + .bind(data.id) + .fetch_one(&self.pool) + .await + } + + async fn delete_device(&self, id: i64) -> Result { + let res = sqlx::query("DELETE FROM user_device WHERE id = ?") + .bind(id) + .execute(&self.pool) + .await?; + Ok(res.rows_affected()) + } + + async fn query_device_list( + &self, + user_id: i64, + ) -> Result<(Vec, i64), sqlx::Error> { + let items = sqlx::query_as::<_, Device>( + "SELECT * FROM user_device WHERE user_id = ? ORDER BY updated_at DESC", + ) + .bind(user_id) + .fetch_all(&self.pool) + .await?; + let total = items.len() as i64; + Ok((items, total)) + } + + async fn query_device_page_list( + &self, + user_id: i64, + _subscribe_id: i64, + page: i64, + size: i64, + ) -> Result<(Vec, i64), sqlx::Error> { + let mut page = page; + let mut size = size; + normalize_page(&mut page, &mut size); + let offset = (page - 1) * size; + let (total,) = + sqlx::query_as::<_, (i64,)>("SELECT COUNT(*) FROM user_device WHERE user_id = ?") + .bind(user_id) + .fetch_one(&self.pool) + .await?; + let items = sqlx::query_as::<_, Device>( + "SELECT * FROM user_device WHERE user_id = ? ORDER BY updated_at DESC LIMIT ? OFFSET ?", + ) + .bind(user_id) + .bind(size) + .bind(offset) + .fetch_all(&self.pool) + .await?; + Ok((items, total)) + } + + async fn insert_device_online_record( + &self, + data: &DeviceOnlineRecord, + ) -> Result { + let result = sqlx::query( + "INSERT INTO user_device_online_record (user_id, identifier, online_time, offline_time, + online_seconds, duration_days, created_at) + VALUES (?, ?, ?, ?, ?, ?, ?)", + ) + .bind(data.user_id) + .bind(&data.identifier) + .bind(data.online_time) + .bind(data.offline_time) + .bind(data.online_seconds) + .bind(data.duration_days) + .bind(data.created_at) + .execute(&self.pool) + .await?; + let id = result.last_insert_id() as i64; + sqlx::query_as::<_, DeviceOnlineRecord>( + "SELECT * FROM user_device_online_record WHERE id = ?", + ) + .bind(id) + .fetch_one(&self.pool) + .await + } + + async fn find_device_online_record( + &self, + user_id: i64, + start_time: &str, + end_time: &str, + ) -> Result, sqlx::Error> { + sqlx::query_as::<_, DeviceOnlineRecord>( + "SELECT * FROM user_device_online_record \ + WHERE user_id = ? AND online_time >= ? AND online_time < ? LIMIT 1", + ) + .bind(user_id) + .bind(start_time) + .bind(end_time) + .fetch_optional(&self.pool) + .await + } + + async fn insert_withdrawal(&self, data: &Withdrawal) -> Result { + let result = sqlx::query( + "INSERT INTO user_withdrawal (user_id, amount, content, status, reason, created_at, updated_at) + VALUES (?, ?, ?, ?, ?, ?, ?)", + ) + .bind(data.user_id) + .bind(data.amount) + .bind(&data.content) + .bind(data.status) + .bind(&data.reason) + .bind(data.created_at) + .bind(data.updated_at) + .execute(&self.pool) + .await?; + let id = result.last_insert_id() as i64; + sqlx::query_as::<_, Withdrawal>("SELECT * FROM user_withdrawal WHERE id = ?") + .bind(id) + .fetch_one(&self.pool) + .await + } + + async fn query_page_list( + &self, + page: i64, + size: i64, + filter: &UserFilter, + ) -> Result<(i64, Vec), sqlx::Error> { + let offset = (page - 1) * size; + + let mut clauses = Vec::new(); + if !filter.unscoped { + clauses.push("u.deleted_at IS NULL".to_string()); + } + if filter.user_id.is_some() { + clauses.push("u.id = ?".to_string()); + } + if filter.subscribe_id.is_some() { + clauses.push( + "EXISTS (SELECT 1 FROM user_subscribe WHERE user_id = u.id AND subscribe_id = ? AND status IN (0,1))" + .to_string(), + ); + } + if filter.user_subscribe_id.is_some() { + clauses.push( + "EXISTS (SELECT 1 FROM user_subscribe WHERE user_id = u.id AND id = ? AND status IN (0,1))" + .to_string(), + ); + } + if let Some(ref search) = filter.search { + if !search.is_empty() { + clauses.push( + "(LOWER(u.refer_code) LIKE LOWER(?) OR EXISTS (SELECT 1 FROM user_auth_methods WHERE user_id = u.id AND LOWER(auth_identifier) LIKE LOWER(?)))" + .to_string(), + ); + } + } + let where_str = if clauses.is_empty() { + String::new() + } else { + format!("WHERE {}", clauses.join(" AND ")) + }; + let dir = match filter.order.as_deref() { + Some("ASC") => "ASC", + _ => "DESC", + }; + + let pattern = filter.search.as_ref() + .filter(|s| !s.is_empty()) + .map(|search| format!("{}%", search)); + + let count_sql = format!("SELECT COUNT(*) FROM `user` u {}", where_str); + let mut count_q = sqlx::query_as::<_, (i64,)>(audit(&count_sql)); + if let Some(uid) = filter.user_id { + count_q = count_q.bind(uid); + } + if let Some(sid) = filter.subscribe_id { + count_q = count_q.bind(sid); + } + if let Some(usid) = filter.user_subscribe_id { + count_q = count_q.bind(usid); + } + if let Some(ref p) = pattern { + count_q = count_q.bind(p).bind(p); + } + let (total,) = count_q.fetch_one(&self.pool).await?; + + let list_sql = format!( + "SELECT * FROM `user` u {} ORDER BY u.id {} LIMIT ? OFFSET ?", + where_str, dir, + ); + let mut list_q = sqlx::query_as::<_, User>(audit(&list_sql)); + if let Some(uid) = filter.user_id { + list_q = list_q.bind(uid); + } + if let Some(sid) = filter.subscribe_id { + list_q = list_q.bind(sid); + } + if let Some(usid) = filter.user_subscribe_id { + list_q = list_q.bind(usid); + } + if let Some(ref p) = pattern { + list_q = list_q.bind(p).bind(p); + } + list_q = list_q.bind(size).bind(offset); + let items = list_q.fetch_all(&self.pool).await?; + + Ok((total, items)) + } + + async fn find_users_by_ids(&self, ids: &[i64]) -> Result, sqlx::Error> { + if ids.is_empty() { + return Ok(vec![]); + } + let placeholders = mysql_placeholders(ids.len()); + let sql = format!("SELECT * FROM `user` WHERE id IN ({})", placeholders); + let mut q = sqlx::query_as::<_, User>(audit(&sql)); + for id in ids { + q = q.bind(id); + } + q.fetch_all(&self.pool).await + } + + async fn find_one_by_refer_code(&self, refer_code: &str) -> Result, sqlx::Error> { + sqlx::query_as::<_, User>("SELECT * FROM `user` WHERE refer_code = ?") + .bind(refer_code) + .fetch_optional(&self.pool) + .await + } + + async fn find_one_by_email(&self, email: &str) -> Result, sqlx::Error> { + sqlx::query_as::<_, User>( + "SELECT u.* FROM `user` u INNER JOIN user_auth_methods a ON a.user_id = u.id + WHERE a.auth_type = 'email' AND a.auth_identifier = ?", + ) + .bind(email) + .fetch_optional(&self.pool) + .await + } + + async fn batch_delete_users(&self, ids: &[i64]) -> Result { + if ids.is_empty() { + return Ok(0); + } + let now = chrono::Utc::now().timestamp_millis(); + let placeholders = mysql_placeholders(ids.len()); + let sql = format!("UPDATE `user` SET deleted_at = ? WHERE id IN ({})", placeholders); + let mut q = sqlx::query(audit(&sql)).bind(now); + for id in ids { + q = q.bind(id); + } + let res = q.execute(&self.pool).await?; + Ok(res.rows_affected()) + } + + async fn count_affiliates(&self, referer_id: i64) -> Result { + let (total,) = + sqlx::query_as::<_, (i64,)>("SELECT COUNT(*) FROM `user` WHERE referer_id = ?") + .bind(referer_id) + .fetch_one(&self.pool) + .await?; + Ok(total) + } + + async fn query_affiliate_list( + &self, + referer_id: i64, + page: i64, + size: i64, + ) -> Result<(i64, Vec), sqlx::Error> { + let offset = (page - 1) * size; + let (total,) = + sqlx::query_as::<_, (i64,)>("SELECT COUNT(*) FROM `user` WHERE referer_id = ?") + .bind(referer_id) + .fetch_one(&self.pool) + .await?; + let items = sqlx::query_as::<_, User>( + "SELECT * FROM `user` WHERE referer_id = ? ORDER BY created_at DESC LIMIT ? OFFSET ?", + ) + .bind(referer_id) + .bind(size) + .bind(offset) + .fetch_all(&self.pool) + .await?; + Ok((total, items)) + } + + async fn find_subscribes_by_ids( + &self, + ids: &[i64], + ) -> Result, sqlx::Error> { + if ids.is_empty() { + return Ok(vec![]); + } + let placeholders = mysql_placeholders(ids.len()); + let sql = format!("SELECT * FROM user_subscribe WHERE id IN ({})", placeholders); + let mut q = sqlx::query_as::<_, UserSubscribe>(audit(&sql)); + for id in ids { + q = q.bind(id); + } + q.fetch_all(&self.pool).await + } + + async fn query_monthly_reset_subscribe_ids( + &self, + subscribe_ids: &[i64], + now: i64, + ) -> Result, sqlx::Error> { + if subscribe_ids.is_empty() { + return Ok(vec![]); + } + let in_ph = mysql_placeholders(subscribe_ids.len()); + let sql = format!( + "SELECT id FROM user_subscribe WHERE subscribe_id IN ({}) AND status IN (0,1) \ + AND (DAY(FROM_UNIXTIME(start_time / 1000)) = 1 OR start_time >= ?) \ + AND expire_time > ? ORDER BY id", + in_ph, + ); + let mut q = sqlx::query_as::<_, (i64,)>(audit(&sql)); + for id in subscribe_ids { + q = q.bind(id); + } + q = q.bind(now - 86400000).bind(now); + let rows = q.fetch_all(&self.pool).await?; + Ok(rows.into_iter().map(|(i,)| i).collect()) + } + + async fn query_first_reset_subscribe_ids( + &self, + subscribe_ids: &[i64], + _now: i64, + ) -> Result, sqlx::Error> { + if subscribe_ids.is_empty() { + return Ok(vec![]); + } + let placeholders = mysql_placeholders(subscribe_ids.len()); + let sql = format!( + "SELECT id FROM user_subscribe WHERE subscribe_id IN ({}) AND status IN (0,1) ORDER BY id", + placeholders, + ); + let mut q = sqlx::query_as::<_, (i64,)>(audit(&sql)); + for id in subscribe_ids { + q = q.bind(id); + } + let rows = q.fetch_all(&self.pool).await?; + Ok(rows.into_iter().map(|(i,)| i).collect()) + } + + async fn query_yearly_reset_subscribe_ids( + &self, + subscribe_ids: &[i64], + _now: i64, + ) -> Result, sqlx::Error> { + if subscribe_ids.is_empty() { + return Ok(vec![]); + } + let placeholders = mysql_placeholders(subscribe_ids.len()); + let sql = format!( + "SELECT id FROM user_subscribe WHERE subscribe_id IN ({}) AND status IN (0,1) ORDER BY id", + placeholders, + ); + let mut q = sqlx::query_as::<_, (i64,)>(audit(&sql)); + for id in subscribe_ids { + q = q.bind(id); + } + let rows = q.fetch_all(&self.pool).await?; + Ok(rows.into_iter().map(|(i,)| i).collect()) + } + + async fn reset_subscribe_traffic_by_ids(&self, ids: &[i64]) -> Result { + if ids.is_empty() { + return Ok(0); + } + let placeholders = mysql_placeholders(ids.len()); + let sql = format!( + "UPDATE user_subscribe SET download = 0, upload = 0 WHERE id IN ({})", + placeholders, + ); + let mut q = sqlx::query(audit(&sql)); + for id in ids { + q = q.bind(id); + } + let res = q.execute(&self.pool).await?; + Ok(res.rows_affected()) + } + + async fn find_traffic_exceeded_subscribes( + &self, + ) -> Result, sqlx::Error> { + sqlx::query_as::<_, UserSubscribe>( + "SELECT * FROM user_subscribe WHERE status = 1 AND traffic > 0 AND (download + upload) >= traffic", + ) + .fetch_all(&self.pool) + .await + } + + async fn find_expired_subscribes(&self, now: i64) -> Result, sqlx::Error> { + sqlx::query_as::<_, UserSubscribe>( + "SELECT * FROM user_subscribe WHERE status = 1 AND expire_time <= ?", + ) + .bind(now) + .fetch_all(&self.pool) + .await + } + + async fn mark_subscribes_finished( + &self, + ids: &[i64], + status: i16, + finished_at: i64, + ) -> Result { + if ids.is_empty() { + return Ok(0); + } + let placeholders = mysql_placeholders(ids.len()); + let sql = format!( + "UPDATE user_subscribe SET status = ?, finished_at = ? WHERE id IN ({})", + placeholders, + ); + let mut q = sqlx::query(audit(&sql)) + .bind(status) + .bind(finished_at); + for id in ids { + q = q.bind(id); + } + let res = q.execute(&self.pool).await?; + Ok(res.rows_affected()) + } + + async fn query_user_subscribe( + &self, + user_id: i64, + statuses: &[i64], + ) -> Result, sqlx::Error> { + if statuses.is_empty() { + return sqlx::query_as::<_, SubscribeDetails>( + "SELECT us.*, s.name AS subscribe_name FROM user_subscribe us \ + LEFT JOIN subscribe s ON s.id = us.subscribe_id WHERE us.user_id = ? ORDER BY us.id DESC", + ) + .bind(user_id) + .fetch_all(&self.pool) + .await; + } + let placeholders = mysql_placeholders(statuses.len()); + let sql = format!( + "SELECT us.*, s.name AS subscribe_name FROM user_subscribe us \ + LEFT JOIN subscribe s ON s.id = us.subscribe_id WHERE us.user_id = ? AND us.status IN ({}) ORDER BY us.id DESC", + placeholders, + ); + let mut q = sqlx::query_as::<_, SubscribeDetails>(audit(&sql)).bind(user_id); + for s in statuses { + q = q.bind(s); + } + q.fetch_all(&self.pool).await + } + + async fn find_users_subscribe_by_subscribe_id( + &self, + subscribe_id: i64, + ) -> Result, sqlx::Error> { + sqlx::query_as::<_, UserSubscribe>( + "SELECT * FROM user_subscribe WHERE subscribe_id = ? AND status IN (0,1)", + ) + .bind(subscribe_id) + .fetch_all(&self.pool) + .await + } + + async fn find_user_subscribes_by_status( + &self, + statuses: &[i64], + ) -> Result, sqlx::Error> { + if statuses.is_empty() { + return sqlx::query_as::<_, UserSubscribe>("SELECT * FROM user_subscribe") + .fetch_all(&self.pool) + .await; + } + let placeholders = mysql_placeholders(statuses.len()); + let sql = format!("SELECT * FROM user_subscribe WHERE status IN ({})", placeholders); + let mut q = sqlx::query_as::<_, UserSubscribe>(audit(&sql)); + for s in statuses { + q = q.bind(s); + } + q.fetch_all(&self.pool).await + } + + async fn activate_pending_subscribes_by_subscribe_id( + &self, + subscribe_id: i64, + ) -> Result { + let res = sqlx::query( + "UPDATE user_subscribe SET status = 1 WHERE subscribe_id = ? AND status = 0", + ) + .bind(subscribe_id) + .execute(&self.pool) + .await?; + Ok(res.rows_affected()) + } + + async fn count_user_subscribes_by_user_and_subscribe( + &self, + user_id: i64, + subscribe_id: i64, + ) -> Result { + let (total,) = sqlx::query_as::<_, (i64,)>( + "SELECT COUNT(*) FROM user_subscribe WHERE user_id = ? AND subscribe_id = ?", + ) + .bind(user_id) + .bind(subscribe_id) + .fetch_one(&self.pool) + .await?; + Ok(total) + } + + async fn count_user_subscribes_by_subscribe_id_and_status( + &self, + subscribe_id: i64, + statuses: &[i64], + ) -> Result { + if statuses.is_empty() { + let (total,) = sqlx::query_as::<_, (i64,)>( + "SELECT COUNT(*) FROM user_subscribe WHERE subscribe_id = ?", + ) + .bind(subscribe_id) + .fetch_one(&self.pool) + .await?; + return Ok(total); + } + let placeholders = mysql_placeholders(statuses.len()); + let sql = format!( + "SELECT COUNT(*) FROM user_subscribe WHERE subscribe_id = ? AND status IN ({})", + placeholders, + ); + let mut q = sqlx::query_as::<_, (i64,)>(audit(&sql)).bind(subscribe_id); + for s in statuses { + q = q.bind(s); + } + let (total,) = q.fetch_one(&self.pool).await?; + Ok(total) + } + + async fn update_user_subscribe_with_traffic( + &self, + id: i64, + download: i64, + upload: i64, + ) -> Result { + let res = sqlx::query( + "UPDATE user_subscribe SET download = download + ?, upload = upload + ? WHERE id = ?", + ) + .bind(download) + .bind(upload) + .bind(id) + .execute(&self.pool) + .await?; + Ok(res.rows_affected()) + } + + async fn query_register_user_total_by_date(&self, date: i64) -> Result { + let (start, end) = day_range(date); + let (total,) = sqlx::query_as::<_, (i64,)>( + "SELECT COUNT(*) FROM `user` WHERE created_at >= ? AND created_at < ?", + ) + .bind(start) + .bind(end) + .fetch_one(&self.pool) + .await?; + Ok(total) + } + + async fn query_register_user_total_by_monthly(&self, date: i64) -> Result { + let (start, end) = month_range(date); + let (total,) = sqlx::query_as::<_, (i64,)>( + "SELECT COUNT(*) FROM `user` WHERE created_at >= ? AND created_at < ?", + ) + .bind(start) + .bind(end) + .fetch_one(&self.pool) + .await?; + Ok(total) + } + + async fn query_register_user_total(&self) -> Result { + let (total,) = sqlx::query_as::<_, (i64,)>("SELECT COUNT(*) FROM `user`") + .fetch_one(&self.pool) + .await?; + Ok(total) + } + + async fn count_enabled_users(&self) -> Result { + let (total,) = + sqlx::query_as::<_, (i64,)>("SELECT COUNT(*) FROM `user` WHERE enable = ?") + .bind(true) + .fetch_one(&self.pool) + .await?; + Ok(total) + } + + async fn query_admin_users(&self) -> Result, sqlx::Error> { + sqlx::query_as::<_, User>("SELECT * FROM `user` WHERE is_admin = ?") + .bind(true) + .fetch_all(&self.pool) + .await + } + + async fn query_active_subscriptions( + &self, + subscribe_ids: &[i64], + ) -> Result, sqlx::Error> { + if subscribe_ids.is_empty() { + return Ok(vec![]); + } + let placeholders = mysql_placeholders(subscribe_ids.len()); + let sql = format!( + "SELECT subscribe_id, COUNT(*) FROM user_subscribe WHERE subscribe_id IN ({}) AND status IN (0,1) GROUP BY subscribe_id", + placeholders, + ); + let mut q = sqlx::query_as::<_, (i64, i64)>(audit(&sql)); + for id in subscribe_ids { + q = q.bind(id); + } + q.fetch_all(&self.pool).await + } + + async fn query_email_recipients( + &self, + filter: &EmailRecipientFilter, + ) -> Result, sqlx::Error> { + if filter.scope == 5 { + return Ok(vec![]); + } + let (sql, binds) = email_recipient_sql_mysql(filter, "a.auth_identifier"); + let mut q = sqlx::query_as::<_, (String,)>(audit(&sql)); + for b in &binds { + q = q.bind(b); + } + let rows = q.fetch_all(&self.pool).await?; + Ok(rows.into_iter().map(|(s,)| s).collect()) + } + + async fn count_email_recipients(&self, filter: &EmailRecipientFilter) -> Result { + if filter.scope == 5 { + return Ok(0); + } + let (sql, binds) = email_recipient_sql_mysql(filter, "COUNT(*)"); + let mut q = sqlx::query_as::<_, (i64,)>(audit(&sql)); + for b in &binds { + q = q.bind(b); + } + let (total,) = q.fetch_one(&self.pool).await?; + Ok(total) + } + + async fn query_subscribe_ids_by_filter( + &self, + filter: &SubscribeFilter, + ) -> Result, sqlx::Error> { + let (sql, binds) = subscribe_filter_sql_mysql(filter, "SELECT id FROM user_subscribe"); + let mut q = sqlx::query_as::<_, (i64,)>(audit(&sql)); + for b in &binds { + q = q.bind(b); + } + let rows = q.fetch_all(&self.pool).await?; + Ok(rows.into_iter().map(|(i,)| i).collect()) + } + + async fn count_subscribes_by_filter(&self, filter: &SubscribeFilter) -> Result { + let (sql, binds) = subscribe_filter_sql_mysql(filter, "SELECT COUNT(*) FROM user_subscribe"); + let mut q = sqlx::query_as::<_, (i64,)>(audit(&sql)); + for b in &binds { + q = q.bind(b); + } + let (total,) = q.fetch_one(&self.pool).await?; + Ok(total) + } + + async fn query_daily_user_statistics_list( + &self, + now: i64, + ) -> Result, sqlx::Error> { + let start = month_start(now); + sqlx::query_as::<_, UserStatisticsWithDate>( + "SELECT DATE_FORMAT(FROM_UNIXTIME(u.created_at / 1000), '%Y-%m-%d') AS date, + COUNT(*) AS register, + COALESCE(MAX(n.new_order_users), 0) AS new_order_users, + COALESCE(MAX(r.renewal_order_users), 0) AS renewal_order_users + FROM `user` u + LEFT JOIN ( + SELECT DATE_FORMAT(FROM_UNIXTIME(created_at / 1000), '%Y-%m-%d') AS date, + COUNT(DISTINCT user_id) AS new_order_users + FROM `order` + WHERE is_new = true AND created_at >= ? AND created_at <= ? AND status IN (2,5) + GROUP BY DATE_FORMAT(FROM_UNIXTIME(created_at / 1000), '%Y-%m-%d') + ) n ON DATE_FORMAT(FROM_UNIXTIME(u.created_at / 1000), '%Y-%m-%d') = n.date + LEFT JOIN ( + SELECT DATE_FORMAT(FROM_UNIXTIME(created_at / 1000), '%Y-%m-%d') AS date, + COUNT(DISTINCT user_id) AS renewal_order_users + FROM `order` + WHERE is_new = false AND created_at >= ? AND created_at <= ? AND status IN (2,5) + GROUP BY DATE_FORMAT(FROM_UNIXTIME(created_at / 1000), '%Y-%m-%d') + ) r ON DATE_FORMAT(FROM_UNIXTIME(u.created_at / 1000), '%Y-%m-%d') = r.date + WHERE u.created_at >= ? AND u.created_at <= ? + GROUP BY DATE_FORMAT(FROM_UNIXTIME(u.created_at / 1000), '%Y-%m-%d') + ORDER BY date ASC", + ) + .bind(start) + .bind(now) + .bind(start) + .bind(now) + .bind(start) + .bind(now) + .fetch_all(&self.pool) + .await + } + + async fn query_monthly_user_statistics_list( + &self, + now: i64, + ) -> Result, sqlx::Error> { + let start = six_months_ago(now); + sqlx::query_as::<_, UserStatisticsWithDate>( + "SELECT DATE_FORMAT(FROM_UNIXTIME(u.created_at / 1000), '%Y-%m') AS date, + COUNT(*) AS register, + COALESCE(MAX(n.new_order_users), 0) AS new_order_users, + COALESCE(MAX(r.renewal_order_users), 0) AS renewal_order_users + FROM `user` u + LEFT JOIN ( + SELECT DATE_FORMAT(FROM_UNIXTIME(created_at / 1000), '%Y-%m') AS date, + COUNT(DISTINCT user_id) AS new_order_users + FROM `order` + WHERE is_new = true AND created_at >= ? AND status IN (2,5) + GROUP BY DATE_FORMAT(FROM_UNIXTIME(created_at / 1000), '%Y-%m') + ) n ON DATE_FORMAT(FROM_UNIXTIME(u.created_at / 1000), '%Y-%m') = n.date + LEFT JOIN ( + SELECT DATE_FORMAT(FROM_UNIXTIME(created_at / 1000), '%Y-%m') AS date, + COUNT(DISTINCT user_id) AS renewal_order_users + FROM `order` + WHERE is_new = false AND created_at >= ? AND status IN (2,5) + GROUP BY DATE_FORMAT(FROM_UNIXTIME(created_at / 1000), '%Y-%m') + ) r ON DATE_FORMAT(FROM_UNIXTIME(u.created_at / 1000), '%Y-%m') = r.date + WHERE u.created_at >= ? + GROUP BY DATE_FORMAT(FROM_UNIXTIME(u.created_at / 1000), '%Y-%m') + ORDER BY date ASC", + ) + .bind(start) + .bind(start) + .bind(start) + .fetch_all(&self.pool) + .await + } +} + +fn subscribe_filter_sql_mysql(filter: &SubscribeFilter, head: &str) -> (String, Vec) { + let mut clauses = Vec::new(); + let mut binds: Vec = Vec::new(); + if !filter.subscribers.is_empty() { + let ph = mysql_placeholders(filter.subscribers.len()); + clauses.push(format!("subscribe_id IN ({})", ph)); + for s in &filter.subscribers { + binds.push(*s); + } + } + if filter.is_active == Some(true) { + clauses.push("status IN (0,1,2)".to_string()); + } + if filter.start_time != 0 { + clauses.push("start_time <= ?".to_string()); + binds.push(filter.start_time); + } + if filter.end_time != 0 { + clauses.push("expire_time >= ?".to_string()); + binds.push(filter.end_time); + } + let where_str = if clauses.is_empty() { + String::new() + } else { + format!("WHERE {}", clauses.join(" AND ")) + }; + (format!("{} {}", head, where_str), binds) +} + +fn email_recipient_sql_mysql(filter: &EmailRecipientFilter, select_expr: &str) -> (String, Vec) { + let mut clauses = vec!["a.auth_type = 'email'".to_string()]; + let mut binds: Vec = Vec::new(); + if filter.register_start_time != 0 { + clauses.push("u.created_at >= ?".to_string()); + binds.push(filter.register_start_time); + } + if filter.register_end_time != 0 { + clauses.push("u.created_at <= ?".to_string()); + binds.push(filter.register_end_time); + } + match filter.scope { + 2 => clauses.push( + "EXISTS (SELECT 1 FROM user_subscribe us WHERE us.user_id = u.id AND us.status IN (1,2))" + .to_string(), + ), + 3 => clauses.push( + "EXISTS (SELECT 1 FROM user_subscribe us WHERE us.user_id = u.id AND us.status = 3)" + .to_string(), + ), + 4 => clauses.push( + "NOT EXISTS (SELECT 1 FROM user_subscribe us WHERE us.user_id = u.id)".to_string(), + ), + _ => {} + } + let where_str = format!("WHERE {}", clauses.join(" AND ")); + let sql = format!( + "SELECT {} FROM user_auth_methods a INNER JOIN `user` u ON u.id = a.user_id {}", + select_expr, where_str, + ); + (sql, binds) +} diff --git a/src/repository/user/pg.rs b/src/repository/user/pg.rs new file mode 100644 index 00000000..5d368483 --- /dev/null +++ b/src/repository/user/pg.rs @@ -0,0 +1,1307 @@ +use crate::model::entity::user::{ + AuthMethods, Device, DeviceOnlineRecord, User, UserSubscribe, Withdrawal, +}; +use crate::repository::audit; +use crate::repository::normalize_page; +use crate::repository::user::{ + EmailRecipientFilter, SubscribeDetails, SubscribeFilter, UserFilter, UserRepo, + UserStatisticsWithDate, +}; +use chrono::Datelike; + +pub struct PgUserRepo { + pool: sqlx::PgPool, +} + +impl PgUserRepo { + pub fn new(pool: sqlx::PgPool) -> Self { + Self { pool } + } +} + +fn day_range(ts: i64) -> (i64, i64) { + let secs = ts / 1000; + let s = secs - secs % 86400; + (s * 1000, (s + 86400) * 1000) +} + +fn month_start(ts: i64) -> i64 { + let secs = ts / 1000; + let dt = chrono::DateTime::::from_timestamp(secs, 0).unwrap_or_default(); + let year = dt.year(); + let month = dt.month(); + chrono::NaiveDate::from_ymd_opt(year, month, 1) + .and_then(|d| d.and_hms_opt(0, 0, 0)) + .unwrap() + .and_utc() + .timestamp_millis() +} + +fn month_range(ts: i64) -> (i64, i64) { + let secs = ts / 1000; + let dt = chrono::DateTime::::from_timestamp(secs, 0).unwrap_or_default(); + let year = dt.year(); + let month = dt.month(); + let start = chrono::NaiveDate::from_ymd_opt(year, month, 1) + .and_then(|d| d.and_hms_opt(0, 0, 0)) + .unwrap() + .and_utc(); + let (ny, nm) = if month == 12 { + (year + 1, 1) + } else { + (year, month + 1) + }; + let end = chrono::NaiveDate::from_ymd_opt(ny, nm, 1) + .and_then(|d| d.and_hms_opt(0, 0, 0)) + .unwrap() + .and_utc(); + (start.timestamp_millis(), end.timestamp_millis()) +} + +fn six_months_ago(ts: i64) -> i64 { + let secs = ts / 1000; + let dt = chrono::DateTime::::from_timestamp(secs, 0).unwrap_or_default(); + let ago = dt.checked_sub_months(chrono::Months::new(5)).unwrap_or(dt); + ago.timestamp_millis() +} + +fn pg_placeholders(start: usize, n: usize) -> String { + (0..n) + .map(|i| format!("${}", start + i)) + .collect::>() + .join(", ") +} + +#[async_trait::async_trait] +impl UserRepo for PgUserRepo { + async fn insert_user(&self, data: &User) -> Result { + sqlx::query_as::<_, User>( + r#"INSERT INTO "user" (password, algo, salt, avatar, balance, refer_code, referer_id, + commission, referral_percentage, only_first_purchase, gift_amount, enable, is_admin, + enable_balance_notify, enable_login_notify, enable_subscribe_notify, enable_trade_notify, + rules, created_at, updated_at, deleted_at) + VALUES ($1, $2, $3, $4, $5, $6, $7, $8, $9, $10, $11, $12, $13, $14, $15, $16, $17, $18, $19, $20, $21) + RETURNING *"#, + ) + .bind(&data.password) + .bind(&data.algo) + .bind(&data.salt) + .bind(&data.avatar) + .bind(data.balance) + .bind(&data.refer_code) + .bind(data.referer_id) + .bind(data.commission) + .bind(data.referral_percentage) + .bind(data.only_first_purchase) + .bind(data.gift_amount) + .bind(data.enable) + .bind(data.is_admin) + .bind(data.enable_balance_notify) + .bind(data.enable_login_notify) + .bind(data.enable_subscribe_notify) + .bind(data.enable_trade_notify) + .bind(&data.rules) + .bind(data.created_at) + .bind(data.updated_at) + .bind(data.deleted_at) + .fetch_one(&self.pool) + .await + } + + async fn find_one_user(&self, id: i64) -> Result { + sqlx::query_as::<_, User>(r#"SELECT * FROM "user" WHERE id = $1"#) + .bind(id) + .fetch_one(&self.pool) + .await + } + + async fn update_user(&self, data: &User) -> Result { + sqlx::query_as::<_, User>( + r#"UPDATE "user" SET password = $1, algo = $2, salt = $3, avatar = $4, balance = $5, + refer_code = $6, referer_id = $7, commission = $8, referral_percentage = $9, + only_first_purchase = $10, gift_amount = $11, enable = $12, is_admin = $13, + enable_balance_notify = $14, enable_login_notify = $15, enable_subscribe_notify = $16, + enable_trade_notify = $17, rules = $18, updated_at = $19 WHERE id = $20 RETURNING *"#, + ) + .bind(&data.password) + .bind(&data.algo) + .bind(&data.salt) + .bind(&data.avatar) + .bind(data.balance) + .bind(&data.refer_code) + .bind(data.referer_id) + .bind(data.commission) + .bind(data.referral_percentage) + .bind(data.only_first_purchase) + .bind(data.gift_amount) + .bind(data.enable) + .bind(data.is_admin) + .bind(data.enable_balance_notify) + .bind(data.enable_login_notify) + .bind(data.enable_subscribe_notify) + .bind(data.enable_trade_notify) + .bind(&data.rules) + .bind(data.updated_at) + .bind(data.id) + .fetch_one(&self.pool) + .await + } + + async fn delete_user(&self, id: i64) -> Result { + let now = chrono::Utc::now().timestamp_millis(); + let res = sqlx::query(r#"UPDATE "user" SET deleted_at = $1 WHERE id = $2"#) + .bind(now) + .bind(id) + .execute(&self.pool) + .await?; + Ok(res.rows_affected()) + } + + async fn insert_subscribe(&self, data: &UserSubscribe) -> Result { + sqlx::query_as::<_, UserSubscribe>( + "INSERT INTO user_subscribe (user_id, order_id, subscribe_id, start_time, expire_time, + finished_at, traffic, download, upload, token, uuid, status, note, created_at, updated_at) + VALUES ($1, $2, $3, $4, $5, $6, $7, $8, $9, $10, $11, $12, $13, $14, $15) + RETURNING *", + ) + .bind(data.user_id) + .bind(data.order_id) + .bind(data.subscribe_id) + .bind(data.start_time) + .bind(data.expire_time) + .bind(data.finished_at) + .bind(data.traffic) + .bind(data.download) + .bind(data.upload) + .bind(&data.token) + .bind(&data.uuid) + .bind(data.status) + .bind(&data.note) + .bind(data.created_at) + .bind(data.updated_at) + .fetch_one(&self.pool) + .await + } + + async fn find_one_subscribe(&self, id: i64) -> Result { + sqlx::query_as::<_, UserSubscribe>("SELECT * FROM user_subscribe WHERE id = $1") + .bind(id) + .fetch_one(&self.pool) + .await + } + + async fn find_one_subscribe_by_token( + &self, + token: &str, + ) -> Result { + sqlx::query_as::<_, UserSubscribe>("SELECT * FROM user_subscribe WHERE token = $1") + .bind(token) + .fetch_one(&self.pool) + .await + } + + async fn find_one_subscribe_by_order_id( + &self, + order_id: i64, + ) -> Result { + sqlx::query_as::<_, UserSubscribe>("SELECT * FROM user_subscribe WHERE order_id = $1") + .bind(order_id) + .fetch_one(&self.pool) + .await + } + + async fn find_one_subscribe_details_by_id( + &self, + id: i64, + ) -> Result { + sqlx::query_as::<_, SubscribeDetails>( + "SELECT us.*, s.name AS subscribe_name FROM user_subscribe us \ + LEFT JOIN subscribe s ON s.id = us.subscribe_id WHERE us.id = $1", + ) + .bind(id) + .fetch_one(&self.pool) + .await + } + + async fn find_one_user_subscribe(&self, id: i64) -> Result { + sqlx::query_as::<_, SubscribeDetails>( + "SELECT us.*, s.name AS subscribe_name FROM user_subscribe us \ + LEFT JOIN subscribe s ON s.id = us.subscribe_id WHERE us.id = $1", + ) + .bind(id) + .fetch_one(&self.pool) + .await + } + + async fn update_subscribe(&self, data: &UserSubscribe) -> Result { + sqlx::query_as::<_, UserSubscribe>( + "UPDATE user_subscribe SET user_id = $1, order_id = $2, subscribe_id = $3, start_time = $4, + expire_time = $5, finished_at = $6, traffic = $7, download = $8, upload = $9, token = $10, + uuid = $11, status = $12, note = $13, updated_at = $14 WHERE id = $15 RETURNING *", + ) + .bind(data.user_id) + .bind(data.order_id) + .bind(data.subscribe_id) + .bind(data.start_time) + .bind(data.expire_time) + .bind(data.finished_at) + .bind(data.traffic) + .bind(data.download) + .bind(data.upload) + .bind(&data.token) + .bind(&data.uuid) + .bind(data.status) + .bind(&data.note) + .bind(data.updated_at) + .bind(data.id) + .fetch_one(&self.pool) + .await + } + + async fn delete_subscribe(&self, token: &str) -> Result { + let res = sqlx::query("DELETE FROM user_subscribe WHERE token = $1") + .bind(token) + .execute(&self.pool) + .await?; + Ok(res.rows_affected()) + } + + async fn delete_subscribe_by_id(&self, id: i64) -> Result { + let res = sqlx::query("DELETE FROM user_subscribe WHERE id = $1") + .bind(id) + .execute(&self.pool) + .await?; + Ok(res.rows_affected()) + } + + async fn insert_auth_method(&self, data: &AuthMethods) -> Result { + sqlx::query_as::<_, AuthMethods>( + "INSERT INTO user_auth_methods (user_id, auth_type, auth_identifier, verified, created_at, updated_at) + VALUES ($1, $2, $3, $4, $5, $6) RETURNING *", + ) + .bind(data.user_id) + .bind(&data.auth_type) + .bind(&data.auth_identifier) + .bind(data.verified) + .bind(data.created_at) + .bind(data.updated_at) + .fetch_one(&self.pool) + .await + } + + async fn find_user_auth_methods( + &self, + user_id: i64, + ) -> Result, sqlx::Error> { + sqlx::query_as::<_, AuthMethods>( + "SELECT * FROM user_auth_methods WHERE user_id = $1 ORDER BY id ASC", + ) + .bind(user_id) + .fetch_all(&self.pool) + .await + } + + async fn update_auth_method(&self, data: &AuthMethods) -> Result { + sqlx::query_as::<_, AuthMethods>( + "UPDATE user_auth_methods SET user_id = $1, auth_type = $2, auth_identifier = $3, + verified = $4, updated_at = $5 WHERE id = $6 RETURNING *", + ) + .bind(data.user_id) + .bind(&data.auth_type) + .bind(&data.auth_identifier) + .bind(data.verified) + .bind(data.updated_at) + .bind(data.id) + .fetch_one(&self.pool) + .await + } + + async fn delete_user_auth_methods( + &self, + user_id: i64, + platform: &str, + ) -> Result { + let res = + sqlx::query("DELETE FROM user_auth_methods WHERE user_id = $1 AND auth_type = $2") + .bind(user_id) + .bind(platform) + .execute(&self.pool) + .await?; + Ok(res.rows_affected()) + } + + async fn delete_user_auth_method_by_identifier( + &self, + auth_type: &str, + identifier: &str, + ) -> Result { + let res = sqlx::query( + "DELETE FROM user_auth_methods WHERE auth_type = $1 AND auth_identifier = $2", + ) + .bind(auth_type) + .bind(identifier) + .execute(&self.pool) + .await?; + Ok(res.rows_affected()) + } + + async fn find_auth_method_by_open_id( + &self, + method: &str, + open_id: &str, + ) -> Result, sqlx::Error> { + sqlx::query_as::<_, AuthMethods>( + "SELECT * FROM user_auth_methods WHERE auth_type = $1 AND auth_identifier = $2", + ) + .bind(method) + .bind(open_id) + .fetch_optional(&self.pool) + .await + } + + async fn find_auth_method_by_user_id( + &self, + method: &str, + user_id: i64, + ) -> Result, sqlx::Error> { + sqlx::query_as::<_, AuthMethods>( + "SELECT * FROM user_auth_methods WHERE auth_type = $1 AND user_id = $2", + ) + .bind(method) + .bind(user_id) + .fetch_optional(&self.pool) + .await + } + + async fn find_auth_method_by_platform( + &self, + user_id: i64, + platform: &str, + ) -> Result, sqlx::Error> { + sqlx::query_as::<_, AuthMethods>( + "SELECT * FROM user_auth_methods WHERE user_id = $1 AND auth_type = $2", + ) + .bind(user_id) + .bind(platform) + .fetch_optional(&self.pool) + .await + } + + async fn update_user_auth_method_owner( + &self, + auth_type: &str, + identifier: &str, + user_id: i64, + ) -> Result { + let res = sqlx::query( + "UPDATE user_auth_methods SET user_id = $1 WHERE auth_type = $2 AND auth_identifier = $3", + ) + .bind(user_id) + .bind(auth_type) + .bind(identifier) + .execute(&self.pool) + .await?; + Ok(res.rows_affected()) + } + + async fn upsert_user_auth_method( + &self, + data: &AuthMethods, + ) -> Result { + sqlx::query_as::<_, AuthMethods>( + "INSERT INTO user_auth_methods (user_id, auth_type, auth_identifier, verified, created_at, updated_at) + VALUES ($1, $2, $3, $4, $5, $6) + ON CONFLICT (auth_type, auth_identifier) DO UPDATE SET user_id = EXCLUDED.user_id, + verified = EXCLUDED.verified, updated_at = EXCLUDED.updated_at RETURNING *", + ) + .bind(data.user_id) + .bind(&data.auth_type) + .bind(&data.auth_identifier) + .bind(data.verified) + .bind(data.created_at) + .bind(data.updated_at) + .fetch_one(&self.pool) + .await + } + + async fn insert_device(&self, data: &Device) -> Result { + sqlx::query_as::<_, Device>( + "INSERT INTO user_device (ip, user_id, user_agent, identifier, online, enabled, created_at, updated_at) + VALUES ($1, $2, $3, $4, $5, $6, $7, $8) RETURNING *", + ) + .bind(&data.ip) + .bind(data.user_id) + .bind(&data.user_agent) + .bind(&data.identifier) + .bind(data.online) + .bind(data.enabled) + .bind(data.created_at) + .bind(data.updated_at) + .fetch_one(&self.pool) + .await + } + + async fn find_one_device(&self, id: i64) -> Result { + sqlx::query_as::<_, Device>("SELECT * FROM user_device WHERE id = $1") + .bind(id) + .fetch_one(&self.pool) + .await + } + + async fn find_one_device_by_identifier( + &self, + identifier: &str, + ) -> Result, sqlx::Error> { + sqlx::query_as::<_, Device>("SELECT * FROM user_device WHERE identifier = $1") + .bind(identifier) + .fetch_optional(&self.pool) + .await + } + + async fn update_device(&self, data: &Device) -> Result { + sqlx::query_as::<_, Device>( + "UPDATE user_device SET ip = $1, user_id = $2, user_agent = $3, identifier = $4, + online = $5, enabled = $6, updated_at = $7 WHERE id = $8 RETURNING *", + ) + .bind(&data.ip) + .bind(data.user_id) + .bind(&data.user_agent) + .bind(&data.identifier) + .bind(data.online) + .bind(data.enabled) + .bind(data.updated_at) + .bind(data.id) + .fetch_one(&self.pool) + .await + } + + async fn delete_device(&self, id: i64) -> Result { + let res = sqlx::query("DELETE FROM user_device WHERE id = $1") + .bind(id) + .execute(&self.pool) + .await?; + Ok(res.rows_affected()) + } + + async fn query_device_list( + &self, + user_id: i64, + ) -> Result<(Vec, i64), sqlx::Error> { + let items = sqlx::query_as::<_, Device>( + "SELECT * FROM user_device WHERE user_id = $1 ORDER BY updated_at DESC", + ) + .bind(user_id) + .fetch_all(&self.pool) + .await?; + let total = items.len() as i64; + Ok((items, total)) + } + + async fn query_device_page_list( + &self, + user_id: i64, + _subscribe_id: i64, + page: i64, + size: i64, + ) -> Result<(Vec, i64), sqlx::Error> { + let mut page = page; + let mut size = size; + normalize_page(&mut page, &mut size); + let offset = (page - 1) * size; + let (total,) = + sqlx::query_as::<_, (i64,)>("SELECT COUNT(*) FROM user_device WHERE user_id = $1") + .bind(user_id) + .fetch_one(&self.pool) + .await?; + let items = sqlx::query_as::<_, Device>( + "SELECT * FROM user_device WHERE user_id = $1 ORDER BY updated_at DESC LIMIT $2 OFFSET $3", + ) + .bind(user_id) + .bind(size) + .bind(offset) + .fetch_all(&self.pool) + .await?; + Ok((items, total)) + } + + async fn insert_device_online_record( + &self, + data: &DeviceOnlineRecord, + ) -> Result { + sqlx::query_as::<_, DeviceOnlineRecord>( + "INSERT INTO user_device_online_record (user_id, identifier, online_time, offline_time, + online_seconds, duration_days, created_at) + VALUES ($1, $2, $3, $4, $5, $6, $7) RETURNING *", + ) + .bind(data.user_id) + .bind(&data.identifier) + .bind(data.online_time) + .bind(data.offline_time) + .bind(data.online_seconds) + .bind(data.duration_days) + .bind(data.created_at) + .fetch_one(&self.pool) + .await + } + + async fn find_device_online_record( + &self, + user_id: i64, + start_time: &str, + end_time: &str, + ) -> Result, sqlx::Error> { + sqlx::query_as::<_, DeviceOnlineRecord>( + "SELECT * FROM user_device_online_record \ + WHERE user_id = $1 AND online_time >= $2 AND online_time < $3 LIMIT 1", + ) + .bind(user_id) + .bind(start_time) + .bind(end_time) + .fetch_optional(&self.pool) + .await + } + + async fn insert_withdrawal(&self, data: &Withdrawal) -> Result { + sqlx::query_as::<_, Withdrawal>( + "INSERT INTO user_withdrawal (user_id, amount, content, status, reason, created_at, updated_at) + VALUES ($1, $2, $3, $4, $5, $6, $7) RETURNING *", + ) + .bind(data.user_id) + .bind(data.amount) + .bind(&data.content) + .bind(data.status) + .bind(&data.reason) + .bind(data.created_at) + .bind(data.updated_at) + .fetch_one(&self.pool) + .await + } + + async fn query_page_list( + &self, + page: i64, + size: i64, + filter: &UserFilter, + ) -> Result<(i64, Vec), sqlx::Error> { + let offset = (page - 1) * size; + + let mut clauses = Vec::new(); + if !filter.unscoped { + clauses.push("u.deleted_at IS NULL".to_string()); + } + let mut idx = 0u32; + if let Some(uid) = filter.user_id { + idx += 1; + clauses.push(format!("u.id = ${}", idx)); + } + if filter.subscribe_id.is_some() { + idx += 1; + clauses.push(format!( + "EXISTS (SELECT 1 FROM user_subscribe WHERE user_id = u.id AND subscribe_id = ${} AND status IN (0,1))", + idx + )); + } + if filter.user_subscribe_id.is_some() { + idx += 1; + clauses.push(format!( + "EXISTS (SELECT 1 FROM user_subscribe WHERE user_id = u.id AND id = ${} AND status IN (0,1))", + idx + )); + } + if let Some(ref search) = filter.search { + if !search.is_empty() { + idx += 1; + clauses.push(format!( + "(u.refer_code ILIKE ${} OR EXISTS (SELECT 1 FROM user_auth_methods WHERE user_id = u.id AND auth_identifier ILIKE ${}))", + idx, idx + )); + } + } + let where_str = if clauses.is_empty() { + String::new() + } else { + format!("WHERE {}", clauses.join(" AND ")) + }; + let dir = match filter.order.as_deref() { + Some("ASC") => "ASC", + _ => "DESC", + }; + + let count_sql = format!(r#"SELECT COUNT(*) FROM "user" u {}"#, where_str); + let mut count_q = sqlx::query_as::<_, (i64,)>(audit(&count_sql)); + if let Some(uid) = filter.user_id { + count_q = count_q.bind(uid); + } + if let Some(sid) = filter.subscribe_id { + count_q = count_q.bind(sid); + } + if let Some(usid) = filter.user_subscribe_id { + count_q = count_q.bind(usid); + } + if let Some(ref search) = filter.search { + if !search.is_empty() { + count_q = count_q.bind(format!("{}%", search)); + } + } + let (total,) = count_q.fetch_one(&self.pool).await?; + + let list_sql = format!( + r#"SELECT * FROM "user" u {} ORDER BY u.id {} LIMIT ${} OFFSET ${}"#, + where_str, + dir, + idx + 1, + idx + 2, + ); + let mut list_q = sqlx::query_as::<_, User>(audit(&list_sql)); + if let Some(uid) = filter.user_id { + list_q = list_q.bind(uid); + } + if let Some(sid) = filter.subscribe_id { + list_q = list_q.bind(sid); + } + if let Some(usid) = filter.user_subscribe_id { + list_q = list_q.bind(usid); + } + if let Some(ref search) = filter.search { + if !search.is_empty() { + list_q = list_q.bind(format!("{}%", search)); + } + } + list_q = list_q.bind(size).bind(offset); + let items = list_q.fetch_all(&self.pool).await?; + + Ok((total, items)) + } + + async fn find_users_by_ids(&self, ids: &[i64]) -> Result, sqlx::Error> { + if ids.is_empty() { + return Ok(vec![]); + } + let placeholders = pg_placeholders(1, ids.len()); + let sql = format!(r#"SELECT * FROM "user" WHERE id IN ({})"#, placeholders); + let mut q = sqlx::query_as::<_, User>(audit(&sql)); + for id in ids { + q = q.bind(id); + } + q.fetch_all(&self.pool).await + } + + async fn find_one_by_refer_code(&self, refer_code: &str) -> Result, sqlx::Error> { + sqlx::query_as::<_, User>(r#"SELECT * FROM "user" WHERE refer_code = $1"#) + .bind(refer_code) + .fetch_optional(&self.pool) + .await + } + + async fn find_one_by_email(&self, email: &str) -> Result, sqlx::Error> { + sqlx::query_as::<_, User>( + r#"SELECT u.* FROM "user" u INNER JOIN user_auth_methods a ON a.user_id = u.id + WHERE a.auth_type = 'email' AND a.auth_identifier = $1"#, + ) + .bind(email) + .fetch_optional(&self.pool) + .await + } + + async fn batch_delete_users(&self, ids: &[i64]) -> Result { + if ids.is_empty() { + return Ok(0); + } + let now = chrono::Utc::now().timestamp_millis(); + let placeholders = pg_placeholders(2, ids.len()); + let sql = format!(r#"UPDATE "user" SET deleted_at = $1 WHERE id IN ({})"#, placeholders); + let mut q = sqlx::query(audit(&sql)).bind(now); + for id in ids { + q = q.bind(id); + } + let res = q.execute(&self.pool).await?; + Ok(res.rows_affected()) + } + + async fn count_affiliates(&self, referer_id: i64) -> Result { + let (total,) = sqlx::query_as::<_, (i64,)>( + r#"SELECT COUNT(*) FROM "user" WHERE referer_id = $1"#, + ) + .bind(referer_id) + .fetch_one(&self.pool) + .await?; + Ok(total) + } + + async fn query_affiliate_list( + &self, + referer_id: i64, + page: i64, + size: i64, + ) -> Result<(i64, Vec), sqlx::Error> { + let offset = (page - 1) * size; + let (total,) = sqlx::query_as::<_, (i64,)>( + r#"SELECT COUNT(*) FROM "user" WHERE referer_id = $1"#, + ) + .bind(referer_id) + .fetch_one(&self.pool) + .await?; + let items = sqlx::query_as::<_, User>( + r#"SELECT * FROM "user" WHERE referer_id = $1 ORDER BY created_at DESC LIMIT $2 OFFSET $3"#, + ) + .bind(referer_id) + .bind(size) + .bind(offset) + .fetch_all(&self.pool) + .await?; + Ok((total, items)) + } + + async fn find_subscribes_by_ids( + &self, + ids: &[i64], + ) -> Result, sqlx::Error> { + if ids.is_empty() { + return Ok(vec![]); + } + let placeholders = pg_placeholders(1, ids.len()); + let sql = format!("SELECT * FROM user_subscribe WHERE id IN ({})", placeholders); + let mut q = sqlx::query_as::<_, UserSubscribe>(audit(&sql)); + for id in ids { + q = q.bind(id); + } + q.fetch_all(&self.pool).await + } + + async fn query_monthly_reset_subscribe_ids( + &self, + subscribe_ids: &[i64], + now: i64, + ) -> Result, sqlx::Error> { + if subscribe_ids.is_empty() { + return Ok(vec![]); + } + let n = subscribe_ids.len(); + let in_ph = pg_placeholders(1, n); + let day_idx = n + 1; + let now_idx = n + 2; + let sql = format!( + "SELECT id FROM user_subscribe WHERE subscribe_id IN ({}) AND status IN (0,1) \ + AND (EXTRACT(DAY FROM TO_TIMESTAMP(start_time / 1000)) = 1 OR start_time >= ${}) \ + AND expire_time > ${}", + in_ph, day_idx, now_idx, + ); + let mut q = sqlx::query_as::<_, (i64,)>(audit(&sql)); + for id in subscribe_ids { + q = q.bind(id); + } + q = q.bind(now - 86400000).bind(now); + let rows = q.fetch_all(&self.pool).await?; + Ok(rows.into_iter().map(|(i,)| i).collect()) + } + + async fn query_first_reset_subscribe_ids( + &self, + subscribe_ids: &[i64], + _now: i64, + ) -> Result, sqlx::Error> { + if subscribe_ids.is_empty() { + return Ok(vec![]); + } + let placeholders = pg_placeholders(1, subscribe_ids.len()); + let sql = format!( + "SELECT id FROM user_subscribe WHERE subscribe_id IN ({}) AND status IN (0,1) ORDER BY id", + placeholders, + ); + let mut q = sqlx::query_as::<_, (i64,)>(audit(&sql)); + for id in subscribe_ids { + q = q.bind(id); + } + let rows = q.fetch_all(&self.pool).await?; + Ok(rows.into_iter().map(|(i,)| i).collect()) + } + + async fn query_yearly_reset_subscribe_ids( + &self, + subscribe_ids: &[i64], + _now: i64, + ) -> Result, sqlx::Error> { + if subscribe_ids.is_empty() { + return Ok(vec![]); + } + let placeholders = pg_placeholders(1, subscribe_ids.len()); + let sql = format!( + "SELECT id FROM user_subscribe WHERE subscribe_id IN ({}) AND status IN (0,1) ORDER BY id", + placeholders, + ); + let mut q = sqlx::query_as::<_, (i64,)>(audit(&sql)); + for id in subscribe_ids { + q = q.bind(id); + } + let rows = q.fetch_all(&self.pool).await?; + Ok(rows.into_iter().map(|(i,)| i).collect()) + } + + async fn reset_subscribe_traffic_by_ids(&self, ids: &[i64]) -> Result { + if ids.is_empty() { + return Ok(0); + } + let placeholders = pg_placeholders(1, ids.len()); + let sql = format!( + "UPDATE user_subscribe SET download = 0, upload = 0 WHERE id IN ({})", + placeholders, + ); + let mut q = sqlx::query(audit(&sql)); + for id in ids { + q = q.bind(id); + } + let res = q.execute(&self.pool).await?; + Ok(res.rows_affected()) + } + + async fn find_traffic_exceeded_subscribes( + &self, + ) -> Result, sqlx::Error> { + sqlx::query_as::<_, UserSubscribe>( + "SELECT * FROM user_subscribe WHERE status = 1 AND traffic > 0 AND (download + upload) >= traffic", + ) + .fetch_all(&self.pool) + .await + } + + async fn find_expired_subscribes(&self, now: i64) -> Result, sqlx::Error> { + sqlx::query_as::<_, UserSubscribe>( + "SELECT * FROM user_subscribe WHERE status = 1 AND expire_time <= $1", + ) + .bind(now) + .fetch_all(&self.pool) + .await + } + + async fn mark_subscribes_finished( + &self, + ids: &[i64], + status: i16, + finished_at: i64, + ) -> Result { + if ids.is_empty() { + return Ok(0); + } + let placeholders = pg_placeholders(3, ids.len()); + let sql = format!( + "UPDATE user_subscribe SET status = $1, finished_at = $2 WHERE id IN ({})", + placeholders, + ); + let mut q = sqlx::query(audit(&sql)).bind(status).bind(finished_at); + for id in ids { + q = q.bind(id); + } + let res = q.execute(&self.pool).await?; + Ok(res.rows_affected()) + } + + async fn query_user_subscribe( + &self, + user_id: i64, + statuses: &[i64], + ) -> Result, sqlx::Error> { + if statuses.is_empty() { + return sqlx::query_as::<_, SubscribeDetails>( + "SELECT us.*, s.name AS subscribe_name FROM user_subscribe us \ + LEFT JOIN subscribe s ON s.id = us.subscribe_id WHERE us.user_id = $1 ORDER BY us.id DESC", + ) + .bind(user_id) + .fetch_all(&self.pool) + .await; + } + let placeholders = pg_placeholders(2, statuses.len()); + let sql = format!( + "SELECT us.*, s.name AS subscribe_name FROM user_subscribe us \ + LEFT JOIN subscribe s ON s.id = us.subscribe_id WHERE us.user_id = $1 AND us.status IN ({}) ORDER BY us.id DESC", + placeholders, + ); + let mut q = sqlx::query_as::<_, SubscribeDetails>(audit(&sql)).bind(user_id); + for s in statuses { + q = q.bind(s); + } + q.fetch_all(&self.pool).await + } + + async fn find_users_subscribe_by_subscribe_id( + &self, + subscribe_id: i64, + ) -> Result, sqlx::Error> { + sqlx::query_as::<_, UserSubscribe>( + "SELECT * FROM user_subscribe WHERE subscribe_id = $1 AND status IN (0,1)", + ) + .bind(subscribe_id) + .fetch_all(&self.pool) + .await + } + + async fn find_user_subscribes_by_status( + &self, + statuses: &[i64], + ) -> Result, sqlx::Error> { + if statuses.is_empty() { + return sqlx::query_as::<_, UserSubscribe>("SELECT * FROM user_subscribe") + .fetch_all(&self.pool) + .await; + } + let placeholders = pg_placeholders(1, statuses.len()); + let sql = format!("SELECT * FROM user_subscribe WHERE status IN ({})", placeholders); + let mut q = sqlx::query_as::<_, UserSubscribe>(audit(&sql)); + for s in statuses { + q = q.bind(s); + } + q.fetch_all(&self.pool).await + } + + async fn activate_pending_subscribes_by_subscribe_id( + &self, + subscribe_id: i64, + ) -> Result { + let res = sqlx::query( + "UPDATE user_subscribe SET status = 1 WHERE subscribe_id = $1 AND status = 0", + ) + .bind(subscribe_id) + .execute(&self.pool) + .await?; + Ok(res.rows_affected()) + } + + async fn count_user_subscribes_by_user_and_subscribe( + &self, + user_id: i64, + subscribe_id: i64, + ) -> Result { + let (total,) = sqlx::query_as::<_, (i64,)>( + "SELECT COUNT(*) FROM user_subscribe WHERE user_id = $1 AND subscribe_id = $2", + ) + .bind(user_id) + .bind(subscribe_id) + .fetch_one(&self.pool) + .await?; + Ok(total) + } + + async fn count_user_subscribes_by_subscribe_id_and_status( + &self, + subscribe_id: i64, + statuses: &[i64], + ) -> Result { + if statuses.is_empty() { + let (total,) = sqlx::query_as::<_, (i64,)>( + "SELECT COUNT(*) FROM user_subscribe WHERE subscribe_id = $1", + ) + .bind(subscribe_id) + .fetch_one(&self.pool) + .await?; + return Ok(total); + } + let placeholders = pg_placeholders(2, statuses.len()); + let sql = format!( + "SELECT COUNT(*) FROM user_subscribe WHERE subscribe_id = $1 AND status IN ({})", + placeholders, + ); + let mut q = sqlx::query_as::<_, (i64,)>(audit(&sql)).bind(subscribe_id); + for s in statuses { + q = q.bind(s); + } + let (total,) = q.fetch_one(&self.pool).await?; + Ok(total) + } + + async fn update_user_subscribe_with_traffic( + &self, + id: i64, + download: i64, + upload: i64, + ) -> Result { + let res = sqlx::query( + "UPDATE user_subscribe SET download = download + $1, upload = upload + $2 WHERE id = $3", + ) + .bind(download) + .bind(upload) + .bind(id) + .execute(&self.pool) + .await?; + Ok(res.rows_affected()) + } + + async fn query_register_user_total_by_date(&self, date: i64) -> Result { + let (start, end) = day_range(date); + let (total,) = sqlx::query_as::<_, (i64,)>( + r#"SELECT COUNT(*) FROM "user" WHERE created_at >= $1 AND created_at < $2"#, + ) + .bind(start) + .bind(end) + .fetch_one(&self.pool) + .await?; + Ok(total) + } + + async fn query_register_user_total_by_monthly(&self, date: i64) -> Result { + let (start, end) = month_range(date); + let (total,) = sqlx::query_as::<_, (i64,)>( + r#"SELECT COUNT(*) FROM "user" WHERE created_at >= $1 AND created_at < $2"#, + ) + .bind(start) + .bind(end) + .fetch_one(&self.pool) + .await?; + Ok(total) + } + + async fn query_register_user_total(&self) -> Result { + let (total,) = sqlx::query_as::<_, (i64,)>(r#"SELECT COUNT(*) FROM "user""#) + .fetch_one(&self.pool) + .await?; + Ok(total) + } + + async fn count_enabled_users(&self) -> Result { + let (total,) = sqlx::query_as::<_, (i64,)>(r#"SELECT COUNT(*) FROM "user" WHERE enable = $1"#) + .bind(true) + .fetch_one(&self.pool) + .await?; + Ok(total) + } + + async fn query_admin_users(&self) -> Result, sqlx::Error> { + sqlx::query_as::<_, User>(r#"SELECT * FROM "user" WHERE is_admin = $1"#) + .bind(true) + .fetch_all(&self.pool) + .await + } + + async fn query_active_subscriptions( + &self, + subscribe_ids: &[i64], + ) -> Result, sqlx::Error> { + if subscribe_ids.is_empty() { + return Ok(vec![]); + } + let placeholders = pg_placeholders(1, subscribe_ids.len()); + let sql = format!( + "SELECT subscribe_id, COUNT(*) FROM user_subscribe WHERE subscribe_id IN ({}) AND status IN (0,1) GROUP BY subscribe_id", + placeholders, + ); + let mut q = sqlx::query_as::<_, (i64, i64)>(audit(&sql)); + for id in subscribe_ids { + q = q.bind(id); + } + q.fetch_all(&self.pool).await + } + + async fn query_email_recipients( + &self, + filter: &EmailRecipientFilter, + ) -> Result, sqlx::Error> { + if filter.scope == 5 { + return Ok(vec![]); + } + let mut clauses = vec!["a.auth_type = 'email'".to_string()]; + let mut idx = 0u32; + if filter.register_start_time != 0 { + idx += 1; + clauses.push(format!("u.created_at >= ${}", idx)); + } + if filter.register_end_time != 0 { + idx += 1; + clauses.push(format!("u.created_at <= ${}", idx)); + } + match filter.scope { + 2 => clauses.push( + "EXISTS (SELECT 1 FROM user_subscribe us WHERE us.user_id = u.id AND us.status IN (1,2))" + .to_string(), + ), + 3 => clauses.push( + "EXISTS (SELECT 1 FROM user_subscribe us WHERE us.user_id = u.id AND us.status = 3)" + .to_string(), + ), + 4 => clauses.push( + "NOT EXISTS (SELECT 1 FROM user_subscribe us WHERE us.user_id = u.id)".to_string(), + ), + _ => {} + } + let where_str = format!("WHERE {}", clauses.join(" AND ")); + let sql = format!( + r#"SELECT a.auth_identifier FROM user_auth_methods a INNER JOIN "user" u ON u.id = a.user_id {}"#, + where_str, + ); + let mut q = sqlx::query_as::<_, (String,)>(audit(&sql)); + if filter.register_start_time != 0 { + q = q.bind(filter.register_start_time); + } + if filter.register_end_time != 0 { + q = q.bind(filter.register_end_time); + } + let rows = q.fetch_all(&self.pool).await?; + Ok(rows.into_iter().map(|(s,)| s).collect()) + } + + async fn count_email_recipients(&self, filter: &EmailRecipientFilter) -> Result { + if filter.scope == 5 { + return Ok(0); + } + let mut clauses = vec!["a.auth_type = 'email'".to_string()]; + let mut idx = 0u32; + if filter.register_start_time != 0 { + idx += 1; + clauses.push(format!("u.created_at >= ${}", idx)); + } + if filter.register_end_time != 0 { + idx += 1; + clauses.push(format!("u.created_at <= ${}", idx)); + } + match filter.scope { + 2 => clauses.push( + "EXISTS (SELECT 1 FROM user_subscribe us WHERE us.user_id = u.id AND us.status IN (1,2))" + .to_string(), + ), + 3 => clauses.push( + "EXISTS (SELECT 1 FROM user_subscribe us WHERE us.user_id = u.id AND us.status = 3)" + .to_string(), + ), + 4 => clauses.push( + "NOT EXISTS (SELECT 1 FROM user_subscribe us WHERE us.user_id = u.id)".to_string(), + ), + _ => {} + } + let where_str = format!("WHERE {}", clauses.join(" AND ")); + let sql = format!( + r#"SELECT COUNT(*) FROM user_auth_methods a INNER JOIN "user" u ON u.id = a.user_id {}"#, + where_str, + ); + let mut q = sqlx::query_as::<_, (i64,)>(audit(&sql)); + if filter.register_start_time != 0 { + q = q.bind(filter.register_start_time); + } + if filter.register_end_time != 0 { + q = q.bind(filter.register_end_time); + } + let (total,) = q.fetch_one(&self.pool).await?; + Ok(total) + } + + async fn query_subscribe_ids_by_filter( + &self, + filter: &SubscribeFilter, + ) -> Result, sqlx::Error> { + let (sql, binds) = subscribe_filter_sql_pg(filter, "SELECT id FROM user_subscribe"); + let mut q = sqlx::query_as::<_, (i64,)>(audit(&sql)); + for b in &binds { + q = q.bind(b); + } + let rows = q.fetch_all(&self.pool).await?; + Ok(rows.into_iter().map(|(i,)| i).collect()) + } + + async fn count_subscribes_by_filter(&self, filter: &SubscribeFilter) -> Result { + let (sql, binds) = subscribe_filter_sql_pg(filter, "SELECT COUNT(*) FROM user_subscribe"); + let mut q = sqlx::query_as::<_, (i64,)>(audit(&sql)); + for b in &binds { + q = q.bind(b); + } + let (total,) = q.fetch_one(&self.pool).await?; + Ok(total) + } + + async fn query_daily_user_statistics_list( + &self, + now: i64, + ) -> Result, sqlx::Error> { + let start = month_start(now); + sqlx::query_as::<_, UserStatisticsWithDate>( + r#"SELECT TO_CHAR(TO_TIMESTAMP(u.created_at / 1000), 'YYYY-MM-DD') AS date, + COUNT(*) AS register, + COALESCE(MAX(n.new_order_users), 0) AS new_order_users, + COALESCE(MAX(r.renewal_order_users), 0) AS renewal_order_users + FROM "user" u + LEFT JOIN ( + SELECT TO_CHAR(TO_TIMESTAMP(created_at / 1000), 'YYYY-MM-DD') AS date, + COUNT(DISTINCT user_id) AS new_order_users + FROM "order" + WHERE is_new = true AND created_at >= $1 AND created_at <= $2 AND status IN (2,5) + GROUP BY TO_CHAR(TO_TIMESTAMP(created_at / 1000), 'YYYY-MM-DD') + ) n ON TO_CHAR(TO_TIMESTAMP(u.created_at / 1000), 'YYYY-MM-DD') = n.date + LEFT JOIN ( + SELECT TO_CHAR(TO_TIMESTAMP(created_at / 1000), 'YYYY-MM-DD') AS date, + COUNT(DISTINCT user_id) AS renewal_order_users + FROM "order" + WHERE is_new = false AND created_at >= $1 AND created_at <= $2 AND status IN (2,5) + GROUP BY TO_CHAR(TO_TIMESTAMP(created_at / 1000), 'YYYY-MM-DD') + ) r ON TO_CHAR(TO_TIMESTAMP(u.created_at / 1000), 'YYYY-MM-DD') = r.date + WHERE u.created_at >= $1 AND u.created_at <= $2 + GROUP BY TO_CHAR(TO_TIMESTAMP(u.created_at / 1000), 'YYYY-MM-DD') + ORDER BY date ASC"#, + ) + .bind(start) + .bind(now) + .fetch_all(&self.pool) + .await + } + + async fn query_monthly_user_statistics_list( + &self, + now: i64, + ) -> Result, sqlx::Error> { + let start = six_months_ago(now); + sqlx::query_as::<_, UserStatisticsWithDate>( + r#"SELECT TO_CHAR(TO_TIMESTAMP(u.created_at / 1000), 'YYYY-MM') AS date, + COUNT(*) AS register, + COALESCE(MAX(n.new_order_users), 0) AS new_order_users, + COALESCE(MAX(r.renewal_order_users), 0) AS renewal_order_users + FROM "user" u + LEFT JOIN ( + SELECT TO_CHAR(TO_TIMESTAMP(created_at / 1000), 'YYYY-MM') AS date, + COUNT(DISTINCT user_id) AS new_order_users + FROM "order" + WHERE is_new = true AND created_at >= $1 AND status IN (2,5) + GROUP BY TO_CHAR(TO_TIMESTAMP(created_at / 1000), 'YYYY-MM') + ) n ON TO_CHAR(TO_TIMESTAMP(u.created_at / 1000), 'YYYY-MM') = n.date + LEFT JOIN ( + SELECT TO_CHAR(TO_TIMESTAMP(created_at / 1000), 'YYYY-MM') AS date, + COUNT(DISTINCT user_id) AS renewal_order_users + FROM "order" + WHERE is_new = false AND created_at >= $1 AND status IN (2,5) + GROUP BY TO_CHAR(TO_TIMESTAMP(created_at / 1000), 'YYYY-MM') + ) r ON TO_CHAR(TO_TIMESTAMP(u.created_at / 1000), 'YYYY-MM') = r.date + WHERE u.created_at >= $1 + GROUP BY TO_CHAR(TO_TIMESTAMP(u.created_at / 1000), 'YYYY-MM') + ORDER BY date ASC"#, + ) + .bind(start) + .fetch_all(&self.pool) + .await + } +} + +fn subscribe_filter_sql_pg(filter: &SubscribeFilter, head: &str) -> (String, Vec) { + let mut clauses = Vec::new(); + let mut idx = 0u32; + let mut binds: Vec = Vec::new(); + if !filter.subscribers.is_empty() { + let ph = pg_placeholders(1, filter.subscribers.len()); + idx = filter.subscribers.len() as u32; + clauses.push(format!("subscribe_id IN ({})", ph)); + for s in &filter.subscribers { + binds.push(*s); + } + } + if filter.is_active == Some(true) { + clauses.push("status IN (0,1,2)".to_string()); + } + if filter.start_time != 0 { + idx += 1; + clauses.push(format!("start_time <= ${}", idx)); + binds.push(filter.start_time); + } + if filter.end_time != 0 { + idx += 1; + clauses.push(format!("expire_time >= ${}", idx)); + binds.push(filter.end_time); + } + let where_str = if clauses.is_empty() { + String::new() + } else { + format!("WHERE {}", clauses.join(" AND ")) + }; + (format!("{} {}", head, where_str), binds) +} diff --git a/src/scheduler/mod.rs b/src/scheduler/mod.rs new file mode 100644 index 00000000..5a15659a --- /dev/null +++ b/src/scheduler/mod.rs @@ -0,0 +1,94 @@ +use std::sync::Arc; +use std::time::Duration; + +use asynq::backend::RedisConnectionType; +use asynq::client::Client; +use asynq::scheduler::{PeriodicTask, Scheduler}; + +use crate::config; +use crate::queue::types; + +pub struct Service { + _scheduler: Arc, +} + +impl Service { + /// Build a Redis URL from our config (compatible with `redis://` scheme). + fn redis_url(cfg: &config::RedisConfig) -> String { + let db = cfg.db; + if cfg.pass.is_empty() { + format!("redis://{}/{}", cfg.host, db) + } else { + format!("redis://:{}@{}/{}", cfg.pass, cfg.host, db) + } + } + + /// Create and start the scheduler. + /// + /// Registers all periodic tasks (mirrors `scheduler/scheduler.go:Start()`). + pub async fn start(cfg: &config::Config) -> anyhow::Result { + let redis_cfg = RedisConnectionType::single(Self::redis_url(&cfg.redis))?; + let client = Arc::new(Client::new(redis_cfg).await?); + + let scheduler = Arc::new(Scheduler::new(client, Some(Duration::from_secs(30))).await?); + + // ── register periodic tasks ────────────────────────────────────── + + // every 60s: check subscription + Self::register( + &scheduler, + types::SCHEDULER_CHECK_SUBSCRIPTION, + "@every 60s", + "default", + ) + .await?; + + // every day at 00:30: reset traffic + Self::register( + &scheduler, + types::SCHEDULER_RESET_TRAFFIC, + "30 0 * * *", + "default", + ) + .await?; + + // every day at 00:00: traffic stat + Self::register( + &scheduler, + types::SCHEDULER_TRAFFIC_STAT, + "0 0 * * *", + "default", + ) + .await?; + + // every day at 01:00: quota task + Self::register( + &scheduler, + types::FORTHWITH_QUOTA_TASK, + "0 1 * * *", + "default", + ) + .await?; + + tracing::info!("scheduler started with 4 periodic tasks"); + + Ok(Self { _scheduler: scheduler }) + } + + async fn register( + scheduler: &Scheduler, + task_type: &str, + cron: &str, + queue: &str, + ) -> anyhow::Result<()> { + let task = PeriodicTask::new( + task_type.to_string(), + cron.to_string(), + Vec::new(), + queue.to_string(), + )?; + scheduler.register(task, queue).await?; + tracing::info!("registered periodic task: {task_type} ({cron})"); + Ok(()) + } +} diff --git a/src/service/admin/ads/create_ads_service.rs b/src/service/admin/ads/create_ads_service.rs new file mode 100644 index 00000000..e175e20e --- /dev/null +++ b/src/service/admin/ads/create_ads_service.rs @@ -0,0 +1,48 @@ +use chrono::Utc; + +use crate::model::dto::{Ads, CreateAdsRequest}; +use crate::model::entity::ads::Ads as AdsEntity; +use crate::repository::ads::AdsRepo; +use result::code_error::CodeError; +use result::error_code; + +pub async fn create_ads( + repo: &dyn AdsRepo, + req: CreateAdsRequest, +) -> Result { + let now = Utc::now().timestamp_millis(); + let entity = AdsEntity { + id: 0, + title: req.title, + type_: req.type_, + content: req.content, + description: req.description, + target_url: req.target_url, + start_time: req.start_time, + end_time: req.end_time, + status: req.status, + created_at: now, + updated_at: now, + }; + let result = repo + .insert(&entity) + .await + .map_err(|e| anyhow::Error::new(CodeError::new_err_code_msg( + error_code::DATABASE_INSERT_ERROR, + &e.to_string(), + )))?; + + Ok(Ads { + id: result.id as i32, + title: result.title, + type_: result.type_, + content: result.content, + description: result.description, + target_url: result.target_url, + start_time: result.start_time, + end_time: result.end_time, + status: result.status, + created_at: result.created_at, + updated_at: result.updated_at, + }) +} diff --git a/src/service/admin/ads/delete_ads_service.rs b/src/service/admin/ads/delete_ads_service.rs new file mode 100644 index 00000000..edb0867a --- /dev/null +++ b/src/service/admin/ads/delete_ads_service.rs @@ -0,0 +1,25 @@ +use crate::repository::ads::AdsRepo; +use result::code_error::CodeError; +use result::error_code; + +pub async fn delete_ads( + repo: &dyn AdsRepo, + id: i64, +) -> Result<(), anyhow::Error> { + let affected = repo + .delete(id) + .await + .map_err(|e| anyhow::Error::new(CodeError::new_err_code_msg( + error_code::DATABASE_DELETED_ERROR, + &e.to_string(), + )))?; + + if affected == 0 { + return Err(anyhow::Error::new(CodeError::new_err_code_msg( + error_code::DATABASE_DELETED_ERROR, + "delete ads error: record not found", + ))); + } + + Ok(()) +} diff --git a/src/service/admin/ads/get_ads_detail_service.rs b/src/service/admin/ads/get_ads_detail_service.rs new file mode 100644 index 00000000..2d51c238 --- /dev/null +++ b/src/service/admin/ads/get_ads_detail_service.rs @@ -0,0 +1,32 @@ +use crate::model::dto::Ads; +use crate::model::entity::ads::Ads as AdsEntity; +use crate::repository::ads::AdsRepo; +use result::code_error::CodeError; +use result::error_code; + +pub async fn get_ads_detail( + repo: &dyn AdsRepo, + id: i64, +) -> Result { + let entity: AdsEntity = repo + .find_one(id) + .await + .map_err(|e| anyhow::Error::new(CodeError::new_err_code_msg( + error_code::DATABASE_QUERY_ERROR, + &e.to_string(), + )))?; + + Ok(Ads { + id: entity.id as i32, + title: entity.title, + type_: entity.type_, + content: entity.content, + description: entity.description, + target_url: entity.target_url, + start_time: entity.start_time, + end_time: entity.end_time, + status: entity.status, + created_at: entity.created_at, + updated_at: entity.updated_at, + }) +} diff --git a/src/service/admin/ads/get_ads_list_service.rs b/src/service/admin/ads/get_ads_list_service.rs new file mode 100644 index 00000000..12708749 --- /dev/null +++ b/src/service/admin/ads/get_ads_list_service.rs @@ -0,0 +1,41 @@ +use crate::model::dto::{Ads, GetAdsListRequest, GetAdsListResponse}; +use crate::repository::ads::AdsRepo; +use result::code_error::CodeError; +use result::error_code; + +pub async fn get_ads_list( + repo: &dyn AdsRepo, + req: GetAdsListRequest, +) -> Result { + let (total, items) = repo + .get_list_by_page( + req.page as i64, + req.size as i64, + req.status, + req.search.as_deref(), + ) + .await + .map_err(|e| anyhow::Error::new(CodeError::new_err_code_msg( + error_code::DATABASE_QUERY_ERROR, + &e.to_string(), + )))?; + + let list = items + .into_iter() + .map(|e| Ads { + id: e.id as i32, + title: e.title, + type_: e.type_, + content: e.content, + description: e.description, + target_url: e.target_url, + start_time: e.start_time, + end_time: e.end_time, + status: e.status, + created_at: e.created_at, + updated_at: e.updated_at, + }) + .collect(); + + Ok(GetAdsListResponse { total, list }) +} diff --git a/src/service/admin/ads/mod.rs b/src/service/admin/ads/mod.rs new file mode 100644 index 00000000..4eafc9a9 --- /dev/null +++ b/src/service/admin/ads/mod.rs @@ -0,0 +1,5 @@ +pub mod create_ads_service; +pub mod delete_ads_service; +pub mod get_ads_detail_service; +pub mod get_ads_list_service; +pub mod update_ads_service; diff --git a/src/service/admin/ads/update_ads_service.rs b/src/service/admin/ads/update_ads_service.rs new file mode 100644 index 00000000..573130d4 --- /dev/null +++ b/src/service/admin/ads/update_ads_service.rs @@ -0,0 +1,36 @@ +use chrono::Utc; + +use crate::model::entity::ads::Ads as AdsEntity; +use crate::model::dto::UpdateAdsRequest; +use crate::repository::ads::AdsRepo; +use result::code_error::CodeError; +use result::error_code; + +pub async fn update_ads( + repo: &dyn AdsRepo, + req: UpdateAdsRequest, +) -> Result<(), anyhow::Error> { + let now = Utc::now().timestamp_millis(); + let entity = AdsEntity { + id: req.id, + title: req.title, + type_: req.type_, + content: req.content, + description: req.description, + target_url: req.target_url, + start_time: req.start_time, + end_time: req.end_time, + status: req.status, + created_at: 0, + updated_at: now, + }; + + repo.update(&entity) + .await + .map_err(|e| anyhow::Error::new(CodeError::new_err_code_msg( + error_code::DATABASE_UPDATE_ERROR, + &e.to_string(), + )))?; + + Ok(()) +} diff --git a/src/service/admin/announcement/create_announcement_service.rs b/src/service/admin/announcement/create_announcement_service.rs new file mode 100644 index 00000000..0a73b25e --- /dev/null +++ b/src/service/admin/announcement/create_announcement_service.rs @@ -0,0 +1,42 @@ +use chrono::Utc; + +use crate::model::dto::{Announcement, CreateAnnouncementRequest}; +use crate::model::entity::announcement::Announcement as AnnouncementEntity; +use crate::repository::announcement::AnnouncementRepo; +use result::code_error::CodeError; +use result::error_code; + +pub async fn create_announcement( + repo: &dyn AnnouncementRepo, + req: CreateAnnouncementRequest, +) -> Result { + let now = Utc::now().timestamp_millis(); + let entity = AnnouncementEntity { + id: 0, + title: req.title, + content: req.content, + show: None, + pinned: None, + popup: None, + created_at: now, + updated_at: now, + }; + let result = repo + .insert(&entity) + .await + .map_err(|e| anyhow::Error::new(CodeError::new_err_code_msg( + error_code::DATABASE_INSERT_ERROR, + &e.to_string(), + )))?; + + Ok(Announcement { + id: result.id, + title: result.title, + content: result.content, + show: result.show, + pinned: result.pinned, + popup: result.popup, + created_at: result.created_at, + updated_at: result.updated_at, + }) +} diff --git a/src/service/admin/announcement/delete_announcement_service.rs b/src/service/admin/announcement/delete_announcement_service.rs new file mode 100644 index 00000000..05358a23 --- /dev/null +++ b/src/service/admin/announcement/delete_announcement_service.rs @@ -0,0 +1,25 @@ +use crate::repository::announcement::AnnouncementRepo; +use result::code_error::CodeError; +use result::error_code; + +pub async fn delete_announcement( + repo: &dyn AnnouncementRepo, + id: i64, +) -> Result<(), anyhow::Error> { + let affected = repo + .delete(id) + .await + .map_err(|e| anyhow::Error::new(CodeError::new_err_code_msg( + error_code::DATABASE_DELETED_ERROR, + &e.to_string(), + )))?; + + if affected == 0 { + return Err(anyhow::Error::new(CodeError::new_err_code_msg( + error_code::DATABASE_DELETED_ERROR, + "delete announcement error: record not found", + ))); + } + + Ok(()) +} diff --git a/src/service/admin/announcement/get_announcement_list_service.rs b/src/service/admin/announcement/get_announcement_list_service.rs new file mode 100644 index 00000000..f1f94f20 --- /dev/null +++ b/src/service/admin/announcement/get_announcement_list_service.rs @@ -0,0 +1,40 @@ +use crate::model::dto::{Announcement, GetAnnouncementListRequest, GetAnnouncementListResponse}; +use crate::repository::announcement::AnnouncementRepo; +use result::code_error::CodeError; +use result::error_code; + +pub async fn get_announcement_list( + repo: &dyn AnnouncementRepo, + req: GetAnnouncementListRequest, +) -> Result { + let (total, items) = repo + .get_list_by_page( + req.page, + req.size, + req.show, + req.pinned, + req.popup, + req.search.as_deref(), + ) + .await + .map_err(|e| anyhow::Error::new(CodeError::new_err_code_msg( + error_code::DATABASE_QUERY_ERROR, + &e.to_string(), + )))?; + + let list: Vec = items + .into_iter() + .map(|e| Announcement { + id: e.id, + title: e.title, + content: e.content, + show: e.show, + pinned: e.pinned, + popup: e.popup, + created_at: e.created_at, + updated_at: e.updated_at, + }) + .collect(); + + Ok(GetAnnouncementListResponse { total, list }) +} diff --git a/src/service/admin/announcement/get_announcement_service.rs b/src/service/admin/announcement/get_announcement_service.rs new file mode 100644 index 00000000..94e08bd3 --- /dev/null +++ b/src/service/admin/announcement/get_announcement_service.rs @@ -0,0 +1,29 @@ +use crate::model::dto::Announcement; +use crate::model::entity::announcement::Announcement as AnnouncementEntity; +use crate::repository::announcement::AnnouncementRepo; +use result::code_error::CodeError; +use result::error_code; + +pub async fn get_announcement( + repo: &dyn AnnouncementRepo, + id: i64, +) -> Result { + let entity: AnnouncementEntity = repo + .find_one(id) + .await + .map_err(|e| anyhow::Error::new(CodeError::new_err_code_msg( + error_code::DATABASE_QUERY_ERROR, + &e.to_string(), + )))?; + + Ok(Announcement { + id: entity.id, + title: entity.title, + content: entity.content, + show: entity.show, + pinned: entity.pinned, + popup: entity.popup, + created_at: entity.created_at, + updated_at: entity.updated_at, + }) +} diff --git a/src/service/admin/announcement/mod.rs b/src/service/admin/announcement/mod.rs new file mode 100644 index 00000000..d169293e --- /dev/null +++ b/src/service/admin/announcement/mod.rs @@ -0,0 +1,5 @@ +pub mod create_announcement_service; +pub mod delete_announcement_service; +pub mod get_announcement_list_service; +pub mod get_announcement_service; +pub mod update_announcement_service; diff --git a/src/service/admin/announcement/update_announcement_service.rs b/src/service/admin/announcement/update_announcement_service.rs new file mode 100644 index 00000000..d2f595be --- /dev/null +++ b/src/service/admin/announcement/update_announcement_service.rs @@ -0,0 +1,42 @@ +use chrono::Utc; + +use crate::model::dto::UpdateAnnouncementRequest; +use crate::model::entity::announcement::Announcement as AnnouncementEntity; +use crate::repository::announcement::AnnouncementRepo; +use result::code_error::CodeError; +use result::error_code; + +pub async fn update_announcement( + repo: &dyn AnnouncementRepo, + req: UpdateAnnouncementRequest, +) -> Result<(), anyhow::Error> { + let mut entity: AnnouncementEntity = repo + .find_one(req.id) + .await + .map_err(|e| anyhow::Error::new(CodeError::new_err_code_msg( + error_code::DATABASE_QUERY_ERROR, + &e.to_string(), + )))?; + + entity.title = req.title; + entity.content = req.content; + if let Some(v) = req.show { + entity.show = Some(v); + } + if let Some(v) = req.pinned { + entity.pinned = Some(v); + } + if let Some(v) = req.popup { + entity.popup = Some(v); + } + entity.updated_at = Utc::now().timestamp_millis(); + + repo.update(&entity) + .await + .map_err(|e| anyhow::Error::new(CodeError::new_err_code_msg( + error_code::DATABASE_UPDATE_ERROR, + &e.to_string(), + )))?; + + Ok(()) +} diff --git a/src/service/admin/application/create_subscribe_application_service.rs b/src/service/admin/application/create_subscribe_application_service.rs new file mode 100644 index 00000000..17a437ad --- /dev/null +++ b/src/service/admin/application/create_subscribe_application_service.rs @@ -0,0 +1,63 @@ +use chrono::Utc; + +use crate::model::dto::{DownloadLink, SubscribeApplication}; +use crate::model::dto::CreateSubscribeApplicationRequest; +use crate::model::entity::client::SubscribeApplication as SubscribeApplicationEntity; +use crate::repository::client::ClientRepo; +use result::code_error::CodeError; +use result::error_code; + +pub async fn create_subscribe_application( + repo: &dyn ClientRepo, + req: CreateSubscribeApplicationRequest, +) -> Result { + let link_json = serde_json::to_string(&req.download_link) + .map_err(|e| anyhow::Error::new(CodeError::new_err_code_msg( + error_code::ERROR, + &format!("failed to marshal download link: {}", e), + )))?; + + let now = Utc::now().timestamp_millis(); + let entity = SubscribeApplicationEntity { + id: 0, + name: req.name, + icon: req.icon, + description: req.description, + scheme: req.scheme.unwrap_or_default(), + user_agent: req.user_agent, + is_default: req.is_default, + subscribe_template: Some(req.template), + output_format: req.output_format, + download_link: link_json, + created_at: now, + updated_at: now, + }; + let result = repo + .insert(&entity) + .await + .map_err(|e| anyhow::Error::new(CodeError::new_err_code_msg( + error_code::DATABASE_INSERT_ERROR, + &e.to_string(), + )))?; + + let dl: Option = if result.download_link.is_empty() { + None + } else { + serde_json::from_str(&result.download_link).ok() + }; + + Ok(SubscribeApplication { + id: result.id, + name: result.name, + description: result.description, + icon: result.icon, + scheme: Some(result.scheme), + user_agent: result.user_agent, + is_default: result.is_default, + template: result.subscribe_template.unwrap_or_default(), + output_format: result.output_format, + download_link: dl, + created_at: result.created_at, + updated_at: result.updated_at, + }) +} diff --git a/src/service/admin/application/delete_subscribe_application_service.rs b/src/service/admin/application/delete_subscribe_application_service.rs new file mode 100644 index 00000000..7780770b --- /dev/null +++ b/src/service/admin/application/delete_subscribe_application_service.rs @@ -0,0 +1,25 @@ +use crate::repository::client::ClientRepo; +use result::code_error::CodeError; +use result::error_code; + +pub async fn delete_subscribe_application( + repo: &dyn ClientRepo, + id: i64, +) -> Result<(), anyhow::Error> { + let affected = repo + .delete(id) + .await + .map_err(|e| anyhow::Error::new(CodeError::new_err_code_msg( + error_code::DATABASE_DELETED_ERROR, + &e.to_string(), + )))?; + + if affected == 0 { + return Err(anyhow::Error::new(CodeError::new_err_code_msg( + error_code::DATABASE_DELETED_ERROR, + "delete subscribe application error: record not found", + ))); + } + + Ok(()) +} diff --git a/src/service/admin/application/get_subscribe_application_list_service.rs b/src/service/admin/application/get_subscribe_application_list_service.rs new file mode 100644 index 00000000..df022959 --- /dev/null +++ b/src/service/admin/application/get_subscribe_application_list_service.rs @@ -0,0 +1,45 @@ +use crate::model::dto::{DownloadLink, GetSubscribeApplicationListRequest, GetSubscribeApplicationListResponse, SubscribeApplication}; +use crate::repository::client::ClientRepo; +use result::code_error::CodeError; +use result::error_code; + +pub async fn get_subscribe_application_list( + repo: &dyn ClientRepo, + _req: GetSubscribeApplicationListRequest, +) -> Result { + let items = repo + .list() + .await + .map_err(|e| anyhow::Error::new(CodeError::new_err_code_msg( + error_code::DATABASE_QUERY_ERROR, + &e.to_string(), + )))?; + + let list: Vec = items + .into_iter() + .map(|e| { + let dl: Option = if e.download_link.is_empty() { + None + } else { + serde_json::from_str(&e.download_link).ok() + }; + SubscribeApplication { + id: e.id, + name: e.name, + description: e.description, + icon: e.icon, + scheme: Some(e.scheme), + user_agent: e.user_agent, + is_default: e.is_default, + template: e.subscribe_template.unwrap_or_default(), + output_format: e.output_format, + download_link: dl, + created_at: e.created_at, + updated_at: e.updated_at, + } + }) + .collect(); + + let total = list.len() as i64; + Ok(GetSubscribeApplicationListResponse { total, list }) +} diff --git a/src/service/admin/application/mod.rs b/src/service/admin/application/mod.rs new file mode 100644 index 00000000..42df699e --- /dev/null +++ b/src/service/admin/application/mod.rs @@ -0,0 +1,5 @@ +pub mod create_subscribe_application_service; +pub mod delete_subscribe_application_service; +pub mod get_subscribe_application_list_service; +pub mod preview_subscribe_template_service; +pub mod update_subscribe_application_service; diff --git a/src/service/admin/application/preview_subscribe_template_service.rs b/src/service/admin/application/preview_subscribe_template_service.rs new file mode 100644 index 00000000..d9975cc9 --- /dev/null +++ b/src/service/admin/application/preview_subscribe_template_service.rs @@ -0,0 +1,21 @@ +use crate::model::dto::{PreviewSubscribeTemplateRequest, PreviewSubscribeTemplateResponse}; +use crate::repository::client::ClientRepo; +use result::code_error::CodeError; +use result::error_code; + +pub async fn preview_subscribe_template( + repo: &dyn ClientRepo, + req: PreviewSubscribeTemplateRequest, +) -> Result { + let app = repo + .find_one(req.id) + .await + .map_err(|e| anyhow::Error::new(CodeError::new_err_code_msg( + error_code::DATABASE_QUERY_ERROR, + &e.to_string(), + )))?; + + let template = app.subscribe_template.unwrap_or_default(); + + Ok(PreviewSubscribeTemplateResponse { template }) +} diff --git a/src/service/admin/application/update_subscribe_application_service.rs b/src/service/admin/application/update_subscribe_application_service.rs new file mode 100644 index 00000000..f8215fe0 --- /dev/null +++ b/src/service/admin/application/update_subscribe_application_service.rs @@ -0,0 +1,67 @@ +use chrono::Utc; + +use crate::model::dto::SubscribeApplication; +use crate::model::dto::UpdateSubscribeApplicationRequest; +use crate::repository::client::ClientRepo; +use result::code_error::CodeError; +use result::error_code; + +pub async fn update_subscribe_application( + repo: &dyn ClientRepo, + req: UpdateSubscribeApplicationRequest, +) -> Result { + let mut entity = repo + .find_one(req.id) + .await + .map_err(|e| anyhow::Error::new(CodeError::new_err_code_msg( + error_code::DATABASE_QUERY_ERROR, + &e.to_string(), + )))?; + + entity.name = req.name; + entity.icon = req.icon; + entity.description = req.description; + entity.scheme = req.scheme.unwrap_or_default(); + entity.user_agent = req.user_agent; + entity.is_default = req.is_default; + entity.subscribe_template = Some(req.template); + entity.output_format = req.output_format; + if let Some(ref dl) = req.download_link { + let link_json = serde_json::to_string(dl) + .map_err(|e| anyhow::Error::new(CodeError::new_err_code_msg( + error_code::ERROR, + &format!("failed to marshal download link: {}", e), + )))?; + entity.download_link = link_json; + } + entity.updated_at = Utc::now().timestamp_millis(); + + let result = repo + .update(&entity) + .await + .map_err(|e| anyhow::Error::new(CodeError::new_err_code_msg( + error_code::DATABASE_UPDATE_ERROR, + &e.to_string(), + )))?; + + let dl = if result.download_link.is_empty() { + None + } else { + serde_json::from_str(&result.download_link).ok() + }; + + Ok(SubscribeApplication { + id: result.id, + name: result.name, + description: result.description, + icon: result.icon, + scheme: Some(result.scheme), + user_agent: result.user_agent, + is_default: result.is_default, + template: result.subscribe_template.unwrap_or_default(), + output_format: result.output_format, + download_link: dl, + created_at: result.created_at, + updated_at: result.updated_at, + }) +} diff --git a/src/service/admin/auth_method/get_auth_method_config_service.rs b/src/service/admin/auth_method/get_auth_method_config_service.rs new file mode 100644 index 00000000..345e4bd6 --- /dev/null +++ b/src/service/admin/auth_method/get_auth_method_config_service.rs @@ -0,0 +1,20 @@ +use anyhow::Context; + +use crate::model::dto::auth::{AuthMethodConfig, GetAuthMethodConfigRequest}; +use crate::repository::auth::AuthRepo; + +pub async fn get_auth_method_config( + repo: &dyn AuthRepo, + req: GetAuthMethodConfigRequest, +) -> anyhow::Result { + let m = repo + .find_one_by_method(&req.method) + .await + .context("find auth method by method")?; + Ok(AuthMethodConfig { + id: m.id, + method: m.method, + config: serde_json::from_str(&m.config).unwrap_or_default(), + enabled: m.enabled.unwrap_or(false), + }) +} diff --git a/src/service/admin/auth_method/get_auth_method_list_service.rs b/src/service/admin/auth_method/get_auth_method_list_service.rs new file mode 100644 index 00000000..9e8121b3 --- /dev/null +++ b/src/service/admin/auth_method/get_auth_method_list_service.rs @@ -0,0 +1,95 @@ +//! Admin authMethod services — all 7 auth method management services. + +use result::code_error::CodeError; +use result::error_code; + +use crate::model::dto::auth::{ + AuthMethodConfig, GetAuthMethodConfigRequest, GetAuthMethodListResponse, + UpdateAuthMethodConfigRequest, +}; +use crate::model::entity::auth::Auth; +use crate::repository::auth::AuthRepo; + +// ── get_auth_method_list ────────────────────────────────────────────────────── + +pub async fn get_auth_method_list(repo: &dyn AuthRepo) -> Result { + let methods = repo.get_list().await + .map_err(|e| anyhow::Error::new(CodeError::new_err_code_msg(error_code::DATABASE_QUERY_ERROR, e.to_string())))?; + let list: Vec = methods.into_iter().map(|m| AuthMethodConfig { + id: m.id, + method: m.method, + config: serde_json::from_str(&m.config).unwrap_or_default(), + enabled: m.enabled.unwrap_or(false), + }).collect(); + Ok(GetAuthMethodListResponse { list }) +} + +// ── get_auth_method_config ──────────────────────────────────────────────────── + +pub async fn get_auth_method_config( + repo: &dyn AuthRepo, + req: GetAuthMethodConfigRequest, +) -> Result { + let m = repo.find_one_by_method(&req.method).await + .map_err(|e| anyhow::Error::new(CodeError::new_err_code_msg(error_code::DATABASE_QUERY_ERROR, e.to_string())))?; + Ok(AuthMethodConfig { + id: m.id, + method: m.method, + config: serde_json::from_str(&m.config).unwrap_or_default(), + enabled: m.enabled.unwrap_or(false), + }) +} + +// ── update_auth_method_config ───────────────────────────────────────────────── + +pub async fn update_auth_method_config( + repo: &dyn AuthRepo, + req: UpdateAuthMethodConfigRequest, +) -> Result<(), anyhow::Error> { + let mut auth = repo.find_one(req.id).await + .map_err(|e| anyhow::Error::new(CodeError::new_err_code_msg(error_code::DATABASE_QUERY_ERROR, e.to_string())))?; + auth.config = serde_json::to_string(&req.config).unwrap_or_default(); + if let Some(e) = req.enabled { auth.enabled = Some(e); } + repo.update(&auth).await + .map_err(|e| anyhow::Error::new(CodeError::new_err_code_msg(error_code::DATABASE_UPDATE_ERROR, e.to_string())))?; + Ok(()) +} + +// ── get_email_platform ──────────────────────────────────────────────────────── + +pub async fn get_email_platform() -> Result, anyhow::Error> { + // Mirror Go — hard-coded list of supported email platforms. + Ok(vec!["smtp".to_string()]) +} + +// ── get_sms_platform ────────────────────────────────────────────────────────── + +pub async fn get_sms_platform() -> Result, anyhow::Error> { + Ok(vec!["aliyun".to_string(), "tencent".to_string()]) +} + +// ── test_email_send ─────────────────────────────────────────────────────────── + +pub async fn test_email_send( + _cfg: &crate::config::Config, + _to: String, +) -> Result<(), anyhow::Error> { + // TODO: use email crate to send a test message when email service is wired. + tracing::info!("[test_email_send] stub called"); + Ok(()) +} + +// ── test_sms_send ───────────────────────────────────────────────────────────── + +pub async fn test_sms_send( + _cfg: &crate::config::Config, + _to: String, +) -> Result<(), anyhow::Error> { + // TODO: use SMS crate (Phase 4G) to send a test message. + tracing::info!("[test_sms_send] stub called"); + Ok(()) +} + +// Suppress unused import warning. +#[allow(dead_code)] +fn _silence(a: Auth) { let _ = a; } \ No newline at end of file diff --git a/src/service/admin/auth_method/get_email_platform_service.rs b/src/service/admin/auth_method/get_email_platform_service.rs new file mode 100644 index 00000000..640aeb53 --- /dev/null +++ b/src/service/admin/auth_method/get_email_platform_service.rs @@ -0,0 +1,6 @@ +use email::get_supported_platforms; + +pub async fn get_email_platform() -> anyhow::Result> { + let platforms = get_supported_platforms(); + Ok(platforms.iter().map(|p| p.platform.clone()).collect()) +} diff --git a/src/service/admin/auth_method/get_sms_platform_service.rs b/src/service/admin/auth_method/get_sms_platform_service.rs new file mode 100644 index 00000000..8580497e --- /dev/null +++ b/src/service/admin/auth_method/get_sms_platform_service.rs @@ -0,0 +1,10 @@ +pub async fn get_sms_platform() -> anyhow::Result> { + // SMS crate is not yet created (per AGENTS.md). Return known platforms. + Ok(vec![ + "aliyun".to_string(), + "tencent".to_string(), + "twilio".to_string(), + "abosend".to_string(), + "smsbao".to_string(), + ]) +} diff --git a/src/service/admin/auth_method/mod.rs b/src/service/admin/auth_method/mod.rs new file mode 100644 index 00000000..0c37ad00 --- /dev/null +++ b/src/service/admin/auth_method/mod.rs @@ -0,0 +1,7 @@ +pub mod get_auth_method_config_service; +pub mod get_auth_method_list_service; +pub mod get_email_platform_service; +pub mod get_sms_platform_service; +pub mod test_email_send_service; +pub mod test_sms_send_service; +pub mod update_auth_method_config_service; diff --git a/src/service/admin/auth_method/test_email_send_service.rs b/src/service/admin/auth_method/test_email_send_service.rs new file mode 100644 index 00000000..c5927a77 --- /dev/null +++ b/src/service/admin/auth_method/test_email_send_service.rs @@ -0,0 +1,28 @@ +use anyhow::Context; + +use crate::config::Config; +use crate::model::dto::auth::TestEmailSendRequest; + +pub async fn test_email_send( + cfg: &Config, + req: TestEmailSendRequest, +) -> anyhow::Result<()> { + if !cfg.email.enable { + anyhow::bail!("email is not enabled"); + } + let sender = email::new_sender( + &cfg.email.platform, + &cfg.email.platform_config, + &cfg.site.site_name, + ) + .context("create email sender")?; + sender + .send( + &[req.email], + "Test Email Send", + "This is a test email sent by ppanel.", + ) + .await + .context("send test email")?; + Ok(()) +} diff --git a/src/service/admin/auth_method/test_sms_send_service.rs b/src/service/admin/auth_method/test_sms_send_service.rs new file mode 100644 index 00000000..0a1dff75 --- /dev/null +++ b/src/service/admin/auth_method/test_sms_send_service.rs @@ -0,0 +1,24 @@ +use anyhow::Context; +use crate::model::dto::auth::TestSmsSendRequest; +use crate::queue::types::FORTHWITH_SEND_SMS; +use crate::queue::redis_url; + +pub async fn test_sms_send( + cfg: &crate::config::Config, + req: TestSmsSendRequest, +) -> anyhow::Result<()> { + let payload = serde_json::json!({ + "area_code": req.area_code, + "telephone": req.telephone, + "code": "123456", + }); + let payload_bytes = serde_json::to_vec(&payload).context("serialize sms payload")?; + let url = redis_url(&cfg.redis); + let redis_cfg = asynq::backend::RedisConnectionType::single(url) + .context("build redis connection")?; + let client = asynq::client::Client::new(redis_cfg).await.context("build asynq client")?; + let task = asynq::task::Task::new(FORTHWITH_SEND_SMS, &payload_bytes) + .context("build asynq task")?; + client.enqueue(task).await.context("enqueue test sms")?; + Ok(()) +} diff --git a/src/service/admin/auth_method/update_auth_method_config_service.rs b/src/service/admin/auth_method/update_auth_method_config_service.rs new file mode 100644 index 00000000..52563e6a --- /dev/null +++ b/src/service/admin/auth_method/update_auth_method_config_service.rs @@ -0,0 +1 @@ +pub use super::get_auth_method_list_service::*; diff --git a/src/service/admin/console/mod.rs b/src/service/admin/console/mod.rs new file mode 100644 index 00000000..05f1610f --- /dev/null +++ b/src/service/admin/console/mod.rs @@ -0,0 +1,4 @@ +pub mod query_revenue_statistics_service; +pub mod query_server_total_data_service; +pub mod query_ticket_wait_reply_service; +pub mod query_user_statistics_service; diff --git a/src/service/admin/console/query_revenue_statistics_service.rs b/src/service/admin/console/query_revenue_statistics_service.rs new file mode 100644 index 00000000..e4bf7234 --- /dev/null +++ b/src/service/admin/console/query_revenue_statistics_service.rs @@ -0,0 +1,77 @@ +use anyhow::Context; +use chrono::Utc; + +use crate::model::dto::order::{OrdersStatistics, RevenueStatisticsResponse}; +use crate::repository::order::OrderRepo; + +pub async fn query_revenue_statistics( + order_repo: &dyn OrderRepo, +) -> anyhow::Result { + let now = Utc::now().timestamp_millis(); + + let today_data = order_repo + .query_date_orders(now) + .await + .context("query today orders")?; + let today = OrdersStatistics { + date: None, + amount_total: today_data.amount_total, + new_order_amount: today_data.new_order_amount, + renewal_order_amount: today_data.renewal_order_amount, + list: None, + }; + + let monthly_data = order_repo + .query_monthly_orders(now) + .await + .context("query monthly orders")?; + let monthly_list_raw = order_repo + .query_daily_orders_list(now) + .await + .unwrap_or_default(); + let monthly_list: Vec = monthly_list_raw + .into_iter() + .map(|d| OrdersStatistics { + date: Some(d.date), + amount_total: d.amount_total, + new_order_amount: d.new_order_amount, + renewal_order_amount: d.renewal_order_amount, + list: None, + }) + .collect(); + let monthly = OrdersStatistics { + date: None, + amount_total: monthly_data.amount_total, + new_order_amount: monthly_data.new_order_amount, + renewal_order_amount: monthly_data.renewal_order_amount, + list: Some(monthly_list), + }; + + let all_data = order_repo + .query_total_orders() + .await + .context("query total orders")?; + let all_list_raw = order_repo + .query_monthly_orders_list(now) + .await + .unwrap_or_default(); + let all_list: Vec = all_list_raw + .into_iter() + .map(|d| OrdersStatistics { + date: Some(d.date), + amount_total: d.amount_total, + new_order_amount: d.new_order_amount, + renewal_order_amount: d.renewal_order_amount, + list: None, + }) + .collect(); + let all = OrdersStatistics { + date: None, + amount_total: all_data.amount_total, + new_order_amount: all_data.new_order_amount, + renewal_order_amount: all_data.renewal_order_amount, + list: Some(all_list), + }; + + Ok(RevenueStatisticsResponse { today, monthly, all }) +} diff --git a/src/service/admin/console/query_server_total_data_service.rs b/src/service/admin/console/query_server_total_data_service.rs new file mode 100644 index 00000000..9aaf2c78 --- /dev/null +++ b/src/service/admin/console/query_server_total_data_service.rs @@ -0,0 +1,24 @@ +use crate::model::dto::server::ServerTotalDataResponse; +use crate::repository::Repositories; + +pub async fn query_server_total_data( + _repos: &Repositories, +) -> anyhow::Result { + // Minimal implementation — returns zeroed response. + // Full implementation mirrors queryServerTotalDataLogic.go but requires + // traffic/log repos not yet fully ported. + Ok(ServerTotalDataResponse { + online_users: 0, + online_servers: 0, + offline_servers: 0, + today_upload: 0, + today_download: 0, + monthly_upload: 0, + monthly_download: 0, + updated_at: chrono::Utc::now().timestamp(), + server_traffic_ranking_today: vec![], + server_traffic_ranking_yesterday: vec![], + user_traffic_ranking_today: vec![], + user_traffic_ranking_yesterday: vec![], + }) +} diff --git a/src/service/admin/console/query_ticket_wait_reply_service.rs b/src/service/admin/console/query_ticket_wait_reply_service.rs new file mode 100644 index 00000000..4e267f99 --- /dev/null +++ b/src/service/admin/console/query_ticket_wait_reply_service.rs @@ -0,0 +1,14 @@ +use anyhow::Context; + +use crate::model::dto::ticket::TicketWaitRelpyResponse; +use crate::repository::ticket::TicketRepo; + +pub async fn query_ticket_wait_reply( + repo: &dyn TicketRepo, +) -> anyhow::Result { + let count = repo + .query_wait_reply_total() + .await + .context("count waiting tickets")?; + Ok(TicketWaitRelpyResponse { count }) +} diff --git a/src/service/admin/console/query_user_statistics_service.rs b/src/service/admin/console/query_user_statistics_service.rs new file mode 100644 index 00000000..0c28b070 --- /dev/null +++ b/src/service/admin/console/query_user_statistics_service.rs @@ -0,0 +1,95 @@ +use anyhow::Context; +use chrono::Utc; + +use crate::model::dto::user::{UserStatistics, UserStatisticsResponse}; +use crate::repository::order::OrderRepo; +use crate::repository::user::UserRepo; + +pub async fn query_user_statistics( + user_repo: &dyn UserRepo, + order_repo: &dyn OrderRepo, +) -> anyhow::Result { + let now = Utc::now().timestamp_millis(); + + // today + let today_register = user_repo + .query_register_user_total_by_date(now) + .await + .unwrap_or(0); + let (today_new, today_renewal) = order_repo + .query_date_user_counts(now) + .await + .unwrap_or((0, 0)); + + // monthly + let monthly_register = user_repo + .query_register_user_total_by_monthly(now) + .await + .unwrap_or(0); + let (monthly_new, monthly_renewal) = order_repo + .query_monthly_user_counts(now) + .await + .unwrap_or((0, 0)); + let monthly_list_raw = user_repo + .query_daily_user_statistics_list(now) + .await + .unwrap_or_default(); + let monthly_list: Vec = monthly_list_raw + .into_iter() + .map(|d| UserStatistics { + date: Some(d.date), + register: d.register, + new_order_users: d.new_order_users, + renewal_order_users: d.renewal_order_users, + list: None, + }) + .collect(); + + // all-time + let all_register = user_repo + .query_register_user_total() + .await + .unwrap_or(0); + let (all_new, all_renewal) = order_repo + .query_total_user_counts() + .await + .unwrap_or((0, 0)); + let all_list_raw = user_repo + .query_monthly_user_statistics_list(now) + .await + .unwrap_or_default(); + let all_list: Vec = all_list_raw + .into_iter() + .map(|d| UserStatistics { + date: Some(d.date), + register: d.register, + new_order_users: d.new_order_users, + renewal_order_users: d.renewal_order_users, + list: None, + }) + .collect(); + + Ok(UserStatisticsResponse { + today: UserStatistics { + date: None, + register: today_register, + new_order_users: today_new, + renewal_order_users: today_renewal, + list: None, + }, + monthly: UserStatistics { + date: None, + register: monthly_register, + new_order_users: monthly_new, + renewal_order_users: monthly_renewal, + list: Some(monthly_list), + }, + all: UserStatistics { + date: None, + register: all_register, + new_order_users: all_new, + renewal_order_users: all_renewal, + list: Some(all_list), + }, + }) +} diff --git a/src/service/admin/coupon/batch_delete_coupon_service.rs b/src/service/admin/coupon/batch_delete_coupon_service.rs new file mode 100644 index 00000000..9cfd9586 --- /dev/null +++ b/src/service/admin/coupon/batch_delete_coupon_service.rs @@ -0,0 +1,19 @@ +use crate::model::dto::BatchDeleteCouponRequest; +use crate::repository::coupon::CouponRepo; +use result::code_error::CodeError; +use result::error_code; + +pub async fn batch_delete_coupon( + repo: &dyn CouponRepo, + req: BatchDeleteCouponRequest, +) -> Result<(), anyhow::Error> { + repo.batch_delete(&req.ids) + .await + .map_err(|e| { + anyhow::Error::new(CodeError::new_err_code_msg( + error_code::DATABASE_DELETED_ERROR, + &e.to_string(), + )) + })?; + Ok(()) +} diff --git a/src/service/admin/coupon/create_coupon_service.rs b/src/service/admin/coupon/create_coupon_service.rs new file mode 100644 index 00000000..ac47b77b --- /dev/null +++ b/src/service/admin/coupon/create_coupon_service.rs @@ -0,0 +1,103 @@ +use chrono::Utc; + +use crate::model::dto::{Coupon, CreateCouponRequest}; +use crate::model::entity::coupon::Coupon as CouponEntity; +use crate::repository::coupon::CouponRepo; +use result::code_error::CodeError; +use result::error_code; + +/// Coupon type constants (mirrors Go model) +const COUPON_TYPE_PERCENTAGE: u8 = 1; + +/// Compute the stored `discount` value based on coupon type. +/// - percentage (1): discount field stores the percentage value directly (e.g. 10 = 10%) +/// - fixed (other): discount field stores the fixed amount +fn compute_discount(type_: u8, value: i64) -> i64 { + // The discount field stores the raw value in both cases; + // actual reduction calculation happens at order time. + // Store as-is, consistent with Go behaviour (DeepCopy keeps discount as provided). + let _ = COUPON_TYPE_PERCENTAGE; + value +} + +pub async fn create_coupon( + repo: &dyn CouponRepo, + req: CreateCouponRequest, +) -> Result { + let now = Utc::now().timestamp_millis(); + + // Auto-generate code if not provided + let code = match req.code { + Some(c) if !c.is_empty() => c, + _ => { + // Simple random code: timestamp-based hex + format!("{:X}", now & 0xFFFFFFFF) + } + }; + + let subscribe_str = req + .subscribe + .as_deref() + .unwrap_or(&[]) + .iter() + .map(|v| v.to_string()) + .collect::>() + .join(","); + + let discount = compute_discount(req.type_, req.discount); + + let entity = CouponEntity { + id: 0, + name: req.name, + code, + count: req.count.unwrap_or(0), + type_: req.type_ as i16, + discount, + start_time: req.start_time, + expire_time: req.expire_time, + user_limit: req.user_limit.unwrap_or(0), + subscribe: subscribe_str, + used_count: req.used_count.unwrap_or(0), + enable: Some(req.enable.unwrap_or(true)), + created_at: now, + updated_at: now, + }; + + let result = repo + .insert(&entity) + .await + .map_err(|e| { + anyhow::Error::new(CodeError::new_err_code_msg( + error_code::DATABASE_INSERT_ERROR, + &e.to_string(), + )) + })?; + + let subscribe_vec: Vec = if result.subscribe.is_empty() { + vec![] + } else { + result + .subscribe + .split(',') + .filter(|s| !s.is_empty()) + .filter_map(|s| s.trim().parse::().ok()) + .collect() + }; + + Ok(Coupon { + id: result.id, + name: result.name, + code: result.code, + count: result.count, + type_: result.type_ as u8, + discount: result.discount, + start_time: result.start_time, + expire_time: result.expire_time, + user_limit: result.user_limit, + subscribe: subscribe_vec, + used_count: result.used_count, + enable: result.enable.unwrap_or(false), + created_at: result.created_at, + updated_at: result.updated_at, + }) +} diff --git a/src/service/admin/coupon/delete_coupon_service.rs b/src/service/admin/coupon/delete_coupon_service.rs new file mode 100644 index 00000000..efec2ab1 --- /dev/null +++ b/src/service/admin/coupon/delete_coupon_service.rs @@ -0,0 +1,27 @@ +use crate::repository::coupon::CouponRepo; +use result::code_error::CodeError; +use result::error_code; + +pub async fn delete_coupon( + repo: &dyn CouponRepo, + id: i64, +) -> Result<(), anyhow::Error> { + let affected = repo + .delete(id) + .await + .map_err(|e| { + anyhow::Error::new(CodeError::new_err_code_msg( + error_code::DATABASE_DELETED_ERROR, + &e.to_string(), + )) + })?; + + if affected == 0 { + return Err(anyhow::Error::new(CodeError::new_err_code_msg( + error_code::DATABASE_DELETED_ERROR, + "delete coupon error: record not found", + ))); + } + + Ok(()) +} diff --git a/src/service/admin/coupon/get_coupon_list_service.rs b/src/service/admin/coupon/get_coupon_list_service.rs new file mode 100644 index 00000000..e437b19d --- /dev/null +++ b/src/service/admin/coupon/get_coupon_list_service.rs @@ -0,0 +1,58 @@ +use crate::model::dto::{Coupon, GetCouponListRequest, GetCouponListResponse}; +use crate::repository::coupon::CouponRepo; +use result::code_error::CodeError; +use result::error_code; + +pub async fn get_coupon_list( + repo: &dyn CouponRepo, + req: GetCouponListRequest, +) -> Result { + let (total, items) = repo + .query_list_by_page( + req.page, + req.size, + req.subscribe, + req.search.as_deref(), + ) + .await + .map_err(|e| { + anyhow::Error::new(CodeError::new_err_code_msg( + error_code::DATABASE_QUERY_ERROR, + &e.to_string(), + )) + })?; + + let list: Vec = items + .into_iter() + .map(|e| { + // subscribe is stored as comma-separated string of i64s + let subscribe_vec: Vec = if e.subscribe.is_empty() { + vec![] + } else { + e.subscribe + .split(',') + .filter(|s| !s.is_empty()) + .filter_map(|s| s.trim().parse::().ok()) + .collect() + }; + Coupon { + id: e.id, + name: e.name, + code: e.code, + count: e.count, + type_: e.type_ as u8, + discount: e.discount, + start_time: e.start_time, + expire_time: e.expire_time, + user_limit: e.user_limit, + subscribe: subscribe_vec, + used_count: e.used_count, + enable: e.enable.unwrap_or(false), + created_at: e.created_at, + updated_at: e.updated_at, + } + }) + .collect(); + + Ok(GetCouponListResponse { total, list }) +} diff --git a/src/service/admin/coupon/mod.rs b/src/service/admin/coupon/mod.rs new file mode 100644 index 00000000..59c1388b --- /dev/null +++ b/src/service/admin/coupon/mod.rs @@ -0,0 +1,5 @@ +pub mod batch_delete_coupon_service; +pub mod create_coupon_service; +pub mod delete_coupon_service; +pub mod get_coupon_list_service; +pub mod update_coupon_service; diff --git a/src/service/admin/coupon/update_coupon_service.rs b/src/service/admin/coupon/update_coupon_service.rs new file mode 100644 index 00000000..d83fdfe5 --- /dev/null +++ b/src/service/admin/coupon/update_coupon_service.rs @@ -0,0 +1,64 @@ +use chrono::Utc; + +use crate::model::dto::UpdateCouponRequest; +use crate::model::entity::coupon::Coupon as CouponEntity; +use crate::repository::coupon::CouponRepo; +use result::code_error::CodeError; +use result::error_code; + +pub async fn update_coupon( + repo: &dyn CouponRepo, + req: UpdateCouponRequest, +) -> Result<(), anyhow::Error> { + let mut entity: CouponEntity = repo + .find_one(req.id) + .await + .map_err(|e| { + anyhow::Error::new(CodeError::new_err_code_msg( + error_code::DATABASE_QUERY_ERROR, + &e.to_string(), + )) + })?; + + entity.name = req.name; + if let Some(code) = req.code { + if !code.is_empty() { + entity.code = code; + } + } + if let Some(count) = req.count { + entity.count = count; + } + entity.type_ = req.type_ as i16; + entity.discount = req.discount; + entity.start_time = req.start_time; + entity.expire_time = req.expire_time; + if let Some(user_limit) = req.user_limit { + entity.user_limit = user_limit; + } + if let Some(subscribe) = req.subscribe { + entity.subscribe = subscribe + .iter() + .map(|v| v.to_string()) + .collect::>() + .join(","); + } + if let Some(used_count) = req.used_count { + entity.used_count = used_count; + } + if let Some(enable) = req.enable { + entity.enable = Some(enable); + } + entity.updated_at = Utc::now().timestamp_millis(); + + repo.update(&entity) + .await + .map_err(|e| { + anyhow::Error::new(CodeError::new_err_code_msg( + error_code::DATABASE_UPDATE_ERROR, + &e.to_string(), + )) + })?; + + Ok(()) +} diff --git a/src/service/admin/document/batch_delete_document_service.rs b/src/service/admin/document/batch_delete_document_service.rs new file mode 100644 index 00000000..c33a72a6 --- /dev/null +++ b/src/service/admin/document/batch_delete_document_service.rs @@ -0,0 +1,23 @@ +use crate::model::dto::BatchDeleteDocumentRequest; +use crate::repository::document::DocumentRepo; +use result::code_error::CodeError; +use result::error_code; + +pub async fn batch_delete_document( + repo: &dyn DocumentRepo, + req: BatchDeleteDocumentRequest, +) -> Result<(), anyhow::Error> { + // Use the repo's delete method for each id; there's no batch_delete on DocumentRepo, + // so delete individually. + for id in req.ids { + repo.delete(id) + .await + .map_err(|e| { + anyhow::Error::new(CodeError::new_err_code_msg( + error_code::DATABASE_DELETED_ERROR, + &e.to_string(), + )) + })?; + } + Ok(()) +} diff --git a/src/service/admin/document/create_document_service.rs b/src/service/admin/document/create_document_service.rs new file mode 100644 index 00000000..2e7b77fa --- /dev/null +++ b/src/service/admin/document/create_document_service.rs @@ -0,0 +1,58 @@ +use chrono::Utc; + +use crate::model::dto::{CreateDocumentRequest, Document}; +use crate::model::entity::document::Document as DocumentEntity; +use crate::repository::document::DocumentRepo; +use result::code_error::CodeError; +use result::error_code; + +pub async fn create_document( + repo: &dyn DocumentRepo, + req: CreateDocumentRequest, +) -> Result { + let now = Utc::now().timestamp_millis(); + let tags_str = req + .tags + .as_deref() + .unwrap_or(&[]) + .join(","); + let entity = DocumentEntity { + id: 0, + title: req.title, + content: req.content, + tags: tags_str, + show: req.show, + created_at: now, + updated_at: now, + }; + let result = repo + .insert(&entity) + .await + .map_err(|e| { + anyhow::Error::new(CodeError::new_err_code_msg( + error_code::DATABASE_INSERT_ERROR, + &e.to_string(), + )) + })?; + + let tags_vec: Vec = if result.tags.is_empty() { + vec![] + } else { + result + .tags + .split(',') + .filter(|s| !s.is_empty()) + .map(|s| s.to_string()) + .collect() + }; + + Ok(Document { + id: result.id, + title: result.title, + content: result.content, + tags: tags_vec, + show: result.show.unwrap_or(false), + created_at: result.created_at, + updated_at: result.updated_at, + }) +} diff --git a/src/service/admin/document/delete_document_service.rs b/src/service/admin/document/delete_document_service.rs new file mode 100644 index 00000000..ec924839 --- /dev/null +++ b/src/service/admin/document/delete_document_service.rs @@ -0,0 +1,27 @@ +use crate::repository::document::DocumentRepo; +use result::code_error::CodeError; +use result::error_code; + +pub async fn delete_document( + repo: &dyn DocumentRepo, + id: i64, +) -> Result<(), anyhow::Error> { + let affected = repo + .delete(id) + .await + .map_err(|e| { + anyhow::Error::new(CodeError::new_err_code_msg( + error_code::DATABASE_DELETED_ERROR, + &e.to_string(), + )) + })?; + + if affected == 0 { + return Err(anyhow::Error::new(CodeError::new_err_code_msg( + error_code::DATABASE_DELETED_ERROR, + "delete document error: record not found", + ))); + } + + Ok(()) +} diff --git a/src/service/admin/document/get_document_detail_service.rs b/src/service/admin/document/get_document_detail_service.rs new file mode 100644 index 00000000..5330da86 --- /dev/null +++ b/src/service/admin/document/get_document_detail_service.rs @@ -0,0 +1,40 @@ +use crate::model::dto::Document; +use crate::repository::document::DocumentRepo; +use result::code_error::CodeError; +use result::error_code; + +pub async fn get_document_detail( + repo: &dyn DocumentRepo, + id: i64, +) -> Result { + let entity = repo + .find_one(id) + .await + .map_err(|e| { + anyhow::Error::new(CodeError::new_err_code_msg( + error_code::DATABASE_QUERY_ERROR, + &e.to_string(), + )) + })?; + + let tags_vec: Vec = if entity.tags.is_empty() { + vec![] + } else { + entity + .tags + .split(',') + .filter(|s| !s.is_empty()) + .map(|s| s.to_string()) + .collect() + }; + + Ok(Document { + id: entity.id, + title: entity.title, + content: entity.content, + tags: tags_vec, + show: entity.show.unwrap_or(false), + created_at: entity.created_at, + updated_at: entity.updated_at, + }) +} diff --git a/src/service/admin/document/get_document_list_service.rs b/src/service/admin/document/get_document_list_service.rs new file mode 100644 index 00000000..45e4f1ba --- /dev/null +++ b/src/service/admin/document/get_document_list_service.rs @@ -0,0 +1,50 @@ +use crate::model::dto::{Document, GetDocumentListRequest, GetDocumentListResponse}; +use crate::repository::document::DocumentRepo; +use result::code_error::CodeError; +use result::error_code; + +pub async fn get_document_list( + repo: &dyn DocumentRepo, + req: GetDocumentListRequest, +) -> Result { + let (total, items) = repo + .query_list( + req.page, + req.size, + req.tag.as_deref(), + req.search.as_deref(), + ) + .await + .map_err(|e| { + anyhow::Error::new(CodeError::new_err_code_msg( + error_code::DATABASE_QUERY_ERROR, + &e.to_string(), + )) + })?; + + let list: Vec = items + .into_iter() + .map(|e| { + let tags_vec: Vec = if e.tags.is_empty() { + vec![] + } else { + e.tags + .split(',') + .filter(|s| !s.is_empty()) + .map(|s| s.to_string()) + .collect() + }; + Document { + id: e.id, + title: e.title, + content: e.content, + tags: tags_vec, + show: e.show.unwrap_or(false), + created_at: e.created_at, + updated_at: e.updated_at, + } + }) + .collect(); + + Ok(GetDocumentListResponse { total, list }) +} diff --git a/src/service/admin/document/mod.rs b/src/service/admin/document/mod.rs new file mode 100644 index 00000000..fa75240b --- /dev/null +++ b/src/service/admin/document/mod.rs @@ -0,0 +1,6 @@ +pub mod batch_delete_document_service; +pub mod create_document_service; +pub mod delete_document_service; +pub mod get_document_detail_service; +pub mod get_document_list_service; +pub mod update_document_service; diff --git a/src/service/admin/document/update_document_service.rs b/src/service/admin/document/update_document_service.rs new file mode 100644 index 00000000..1e33bb0d --- /dev/null +++ b/src/service/admin/document/update_document_service.rs @@ -0,0 +1,43 @@ +use chrono::Utc; + +use crate::model::dto::UpdateDocumentRequest; +use crate::model::entity::document::Document as DocumentEntity; +use crate::repository::document::DocumentRepo; +use result::code_error::CodeError; +use result::error_code; + +pub async fn update_document( + repo: &dyn DocumentRepo, + req: UpdateDocumentRequest, +) -> Result<(), anyhow::Error> { + let mut entity: DocumentEntity = repo + .find_one(req.id) + .await + .map_err(|e| { + anyhow::Error::new(CodeError::new_err_code_msg( + error_code::DATABASE_QUERY_ERROR, + &e.to_string(), + )) + })?; + + entity.title = req.title; + entity.content = req.content; + if let Some(tags) = req.tags { + entity.tags = tags.join(","); + } + if let Some(v) = req.show { + entity.show = Some(v); + } + entity.updated_at = Utc::now().timestamp_millis(); + + repo.update(&entity) + .await + .map_err(|e| { + anyhow::Error::new(CodeError::new_err_code_msg( + error_code::DATABASE_UPDATE_ERROR, + &e.to_string(), + )) + })?; + + Ok(()) +} diff --git a/src/service/admin/log/filter_balance_log_service.rs b/src/service/admin/log/filter_balance_log_service.rs new file mode 100644 index 00000000..b225c424 --- /dev/null +++ b/src/service/admin/log/filter_balance_log_service.rs @@ -0,0 +1,89 @@ +use crate::config::Config; +use crate::model::dto::log::{ + BalanceLog, FilterBalanceLogRequest, FilterBalanceLogResponse, + FilterEmailLogResponse, FilterMobileLogResponse, FilterTrafficLogDetailsRequest, + FilterTrafficLogDetailsResponse, FilterSubscribeTrafficRequest, FilterSubscribeTrafficResponse, + GetMessageLogListRequest, GetMessageLogListResponse, LogSetting, +}; +use crate::model::entity::log::{Balance, LogType}; +use crate::repository::log::LogRepo; +use result::code_error::CodeError; +use result::error_code; +use serde::{Deserialize, Serialize}; + +pub async fn filter_balance_log( + repo: &dyn LogRepo, + req: FilterBalanceLogRequest, +) -> anyhow::Result { + let page = req.params.page.max(1) as i64; + let size = req.params.size.max(1) as i64; + let (rows, total) = repo + .filter_logs( + page, + size, + Some(LogType::BALANCE.0), + req.params.date.as_deref(), + req.user_id, + req.params.search.as_deref(), + ) + .await + .map_err(|e| { + anyhow::Error::new(CodeError::new_err_code_msg( + error_code::DATABASE_QUERY_ERROR, + &e.to_string(), + )) + })?; + let list = rows + .into_iter() + .filter_map(|row| { + let content: Balance = serde_json::from_str(&row.content).ok()?; + Some(BalanceLog { + type_: content.type_ as u16, + user_id: row.object_id, + amount: content.amount, + order_no: content.order_no, + balance: content.balance, + timestamp: content.timestamp, + }) + }) + .collect(); + Ok(FilterBalanceLogResponse { total, list }) +} + +#[derive(Debug, Clone, Serialize, Deserialize)] +pub struct FilterEmailMobileLogRequest { + pub page: i32, + pub size: i32, + #[serde(default, skip_serializing_if = "Option::is_none")] + pub search: Option, + #[serde(default, skip_serializing_if = "Option::is_none")] + pub date: Option, +} + +pub async fn filter_email_log(_repo: &dyn LogRepo, _req: FilterEmailMobileLogRequest) -> anyhow::Result { + Ok(FilterEmailLogResponse { total: 0, list: vec![] }) +} + +pub async fn filter_mobile_log(_repo: &dyn LogRepo, _req: FilterEmailMobileLogRequest) -> anyhow::Result { + Ok(FilterMobileLogResponse { total: 0, list: vec![] }) +} + +pub async fn filter_traffic_log_details(_repo: &dyn LogRepo, _req: FilterTrafficLogDetailsRequest) -> anyhow::Result { + Ok(FilterTrafficLogDetailsResponse { total: 0, list: vec![] }) +} + +pub async fn filter_user_subscribe_traffic_log(_repo: &dyn LogRepo, _req: FilterSubscribeTrafficRequest) -> anyhow::Result { + Ok(FilterSubscribeTrafficResponse { total: 0, list: vec![] }) +} + +pub async fn get_log_setting(_config: &Config) -> anyhow::Result { + Ok(LogSetting { auto_clear: Some(false), clear_days: 30 }) +} + +pub async fn update_log_setting(_config: &Config, _req: LogSetting) -> anyhow::Result<()> { + Ok(()) +} + +pub async fn get_message_log_list(_repo: &dyn LogRepo, _req: GetMessageLogListRequest) -> anyhow::Result { + Ok(GetMessageLogListResponse { total: 0, list: vec![] }) +} diff --git a/src/service/admin/log/filter_commission_log_service.rs b/src/service/admin/log/filter_commission_log_service.rs new file mode 100644 index 00000000..925fcfb2 --- /dev/null +++ b/src/service/admin/log/filter_commission_log_service.rs @@ -0,0 +1,50 @@ +use crate::model::dto::log::{ + CommissionLog, FilterCommissionLogRequest, FilterCommissionLogResponse, +}; +use crate::model::entity::log::{Commission, LogType}; +use crate::repository::log::LogRepo; +use result::code_error::CodeError; +use result::error_code; + +pub async fn filter_commission_log( + repo: &dyn LogRepo, + req: FilterCommissionLogRequest, +) -> Result { + let (rows, total) = repo + .filter_logs( + req.params.page as i64, + req.params.size as i64, + Some(LogType::COMMISSION.0), + req.params.date.as_deref(), + req.user_id, + req.params.search.as_deref(), + ) + .await + .map_err(|e| { + anyhow::Error::new(CodeError::new_err_code_msg( + error_code::DATABASE_QUERY_ERROR, + &e.to_string(), + )) + })?; + + let list = rows + .into_iter() + .map(|row| { + let content: Commission = serde_json::from_str(&row.content).unwrap_or(Commission { + type_: 0, + amount: 0, + order_no: String::new(), + timestamp: row.created_at, + }); + CommissionLog { + type_: content.type_ as u16, + user_id: row.object_id, + amount: content.amount, + order_no: content.order_no, + timestamp: content.timestamp, + } + }) + .collect(); + + Ok(FilterCommissionLogResponse { total, list }) +} diff --git a/src/service/admin/log/filter_email_log_service.rs b/src/service/admin/log/filter_email_log_service.rs new file mode 100644 index 00000000..3c9d104f --- /dev/null +++ b/src/service/admin/log/filter_email_log_service.rs @@ -0,0 +1,70 @@ +use crate::model::dto::log::{ + FilterEmailLogResponse, MessageLog, +}; +use crate::model::entity::log::{LogType, Message, SystemLog}; +use crate::repository::log::LogRepo; +use result::code_error::CodeError; +use result::error_code; +use serde::{Deserialize, Serialize}; + +#[derive(Debug, Clone, Serialize, Deserialize)] +pub struct FilterEmailLogRequest { + pub page: i32, + pub size: i32, + #[serde(default, skip_serializing_if = "Option::is_none")] + pub search: Option, + #[serde(default, skip_serializing_if = "Option::is_none")] + pub date: Option, +} + +pub async fn filter_email_log( + repo: &dyn LogRepo, + req: FilterEmailLogRequest, +) -> Result { + let (rows, total) = repo + .filter_logs( + req.page as i64, + req.size as i64, + Some(LogType::EMAIL_MESSAGE.0), + req.date.as_deref(), + None, + req.search.as_deref(), + ) + .await + .map_err(|e| { + anyhow::Error::new(CodeError::new_err_code_msg( + error_code::DATABASE_QUERY_ERROR, + &e.to_string(), + )) + })?; + + let list = rows + .into_iter() + .map(message_log_from_system_log) + .collect(); + + Ok(FilterEmailLogResponse { total, list }) +} + +pub fn message_log_from_system_log(row: SystemLog) -> MessageLog { + let msg: Message = serde_json::from_str(&row.content).unwrap_or(Message { + to: String::new(), + subject: None, + content: serde_json::Value::Null, + platform: String::new(), + template: String::new(), + status: 0, + }); + let content_json = serde_json::to_value(&msg).unwrap_or(serde_json::Value::Null); + let subject = msg.subject.unwrap_or_default(); + MessageLog { + id: row.id, + type_: row.type_ as u8, + platform: msg.platform, + to: msg.to, + subject, + content: content_json, + status: msg.status as u8, + created_at: row.created_at, + } +} diff --git a/src/service/admin/log/filter_gift_log_service.rs b/src/service/admin/log/filter_gift_log_service.rs new file mode 100644 index 00000000..2aaee0aa --- /dev/null +++ b/src/service/admin/log/filter_gift_log_service.rs @@ -0,0 +1,56 @@ +use crate::model::dto::log::{ + FilterGiftLogRequest, FilterGiftLogResponse, GiftLog, +}; +use crate::model::entity::log::{Gift, LogType}; +use crate::repository::log::LogRepo; +use result::code_error::CodeError; +use result::error_code; + +pub async fn filter_gift_log( + repo: &dyn LogRepo, + req: FilterGiftLogRequest, +) -> Result { + let (rows, total) = repo + .filter_logs( + req.params.page as i64, + req.params.size as i64, + Some(LogType::GIFT.0), + req.params.date.as_deref(), + req.user_id, + req.params.search.as_deref(), + ) + .await + .map_err(|e| { + anyhow::Error::new(CodeError::new_err_code_msg( + error_code::DATABASE_QUERY_ERROR, + &e.to_string(), + )) + })?; + + let list = rows + .into_iter() + .map(|row| { + let content: Gift = serde_json::from_str(&row.content).unwrap_or(Gift { + type_: 0, + order_no: String::new(), + subscribe_id: 0, + amount: 0, + balance: 0, + remark: None, + timestamp: row.created_at, + }); + GiftLog { + type_: content.type_ as u16, + user_id: row.object_id, + order_no: content.order_no, + subscribe_id: content.subscribe_id, + amount: content.amount, + balance: content.balance, + remark: content.remark, + timestamp: content.timestamp, + } + }) + .collect(); + + Ok(FilterGiftLogResponse { total, list }) +} diff --git a/src/service/admin/log/filter_login_log_service.rs b/src/service/admin/log/filter_login_log_service.rs new file mode 100644 index 00000000..e62f4799 --- /dev/null +++ b/src/service/admin/log/filter_login_log_service.rs @@ -0,0 +1,52 @@ +use crate::model::dto::log::{ + FilterLoginLogRequest, FilterLoginLogResponse, LoginLog, +}; +use crate::model::entity::log::{Login, LogType}; +use crate::repository::log::LogRepo; +use result::code_error::CodeError; +use result::error_code; + +pub async fn filter_login_log( + repo: &dyn LogRepo, + req: FilterLoginLogRequest, +) -> Result { + let (rows, total) = repo + .filter_logs( + req.params.page as i64, + req.params.size as i64, + Some(LogType::LOGIN.0), + req.params.date.as_deref(), + req.user_id, + req.params.search.as_deref(), + ) + .await + .map_err(|e| { + anyhow::Error::new(CodeError::new_err_code_msg( + error_code::DATABASE_QUERY_ERROR, + &e.to_string(), + )) + })?; + + let list = rows + .into_iter() + .map(|row| { + let content: Login = serde_json::from_str(&row.content).unwrap_or(Login { + method: String::new(), + login_ip: String::new(), + user_agent: String::new(), + success: false, + timestamp: row.created_at, + }); + LoginLog { + user_id: row.object_id, + method: content.method, + login_ip: content.login_ip, + user_agent: content.user_agent, + success: content.success, + timestamp: content.timestamp, + } + }) + .collect(); + + Ok(FilterLoginLogResponse { total, list }) +} diff --git a/src/service/admin/log/filter_mobile_log_service.rs b/src/service/admin/log/filter_mobile_log_service.rs new file mode 100644 index 00000000..e590a1b7 --- /dev/null +++ b/src/service/admin/log/filter_mobile_log_service.rs @@ -0,0 +1,65 @@ +use crate::model::dto::log::{FilterMobileLogResponse, MessageLog}; +use crate::model::entity::log::{LogType, Message, SystemLog}; +use crate::repository::log::LogRepo; +use result::code_error::CodeError; +use result::error_code; +use serde::{Deserialize, Serialize}; + +#[derive(Debug, Clone, Serialize, Deserialize)] +pub struct FilterMobileLogRequest { + pub page: i32, + pub size: i32, + #[serde(default, skip_serializing_if = "Option::is_none")] + pub search: Option, + #[serde(default, skip_serializing_if = "Option::is_none")] + pub date: Option, +} + +pub async fn filter_mobile_log( + repo: &dyn LogRepo, + req: FilterMobileLogRequest, +) -> Result { + let (rows, total) = repo + .filter_logs( + req.page as i64, + req.size as i64, + Some(LogType::MOBILE_MESSAGE.0), + req.date.as_deref(), + None, + req.search.as_deref(), + ) + .await + .map_err(|e| { + anyhow::Error::new(CodeError::new_err_code_msg( + error_code::DATABASE_QUERY_ERROR, + &e.to_string(), + )) + })?; + + let list = rows.into_iter().map(message_log_from_row).collect(); + + Ok(FilterMobileLogResponse { total, list }) +} + +fn message_log_from_row(row: SystemLog) -> MessageLog { + let msg: Message = serde_json::from_str(&row.content).unwrap_or(Message { + to: String::new(), + subject: None, + content: serde_json::Value::Null, + platform: String::new(), + template: String::new(), + status: 0, + }); + let content_json = serde_json::to_value(&msg).unwrap_or(serde_json::Value::Null); + let subject = msg.subject.unwrap_or_default(); + MessageLog { + id: row.id, + type_: row.type_ as u8, + platform: msg.platform, + to: msg.to, + subject, + content: content_json, + status: msg.status as u8, + created_at: row.created_at, + } +} diff --git a/src/service/admin/log/filter_register_log_service.rs b/src/service/admin/log/filter_register_log_service.rs new file mode 100644 index 00000000..df445909 --- /dev/null +++ b/src/service/admin/log/filter_register_log_service.rs @@ -0,0 +1,52 @@ +use crate::model::dto::log::{ + FilterRegisterLogRequest, FilterRegisterLogResponse, RegisterLog, +}; +use crate::model::entity::log::{LogType, Register}; +use crate::repository::log::LogRepo; +use result::code_error::CodeError; +use result::error_code; + +pub async fn filter_register_log( + repo: &dyn LogRepo, + req: FilterRegisterLogRequest, +) -> Result { + let (rows, total) = repo + .filter_logs( + req.params.page as i64, + req.params.size as i64, + Some(LogType::REGISTER.0), + req.params.date.as_deref(), + req.user_id, + req.params.search.as_deref(), + ) + .await + .map_err(|e| { + anyhow::Error::new(CodeError::new_err_code_msg( + error_code::DATABASE_QUERY_ERROR, + &e.to_string(), + )) + })?; + + let list = rows + .into_iter() + .map(|row| { + let content: Register = serde_json::from_str(&row.content).unwrap_or(Register { + auth_method: String::new(), + identifier: String::new(), + register_ip: String::new(), + user_agent: String::new(), + timestamp: row.created_at, + }); + RegisterLog { + user_id: row.object_id, + auth_method: content.auth_method, + identifier: content.identifier, + register_ip: content.register_ip, + user_agent: content.user_agent, + timestamp: content.timestamp, + } + }) + .collect(); + + Ok(FilterRegisterLogResponse { total, list }) +} diff --git a/src/service/admin/log/filter_reset_subscribe_log_service.rs b/src/service/admin/log/filter_reset_subscribe_log_service.rs new file mode 100644 index 00000000..09f03f80 --- /dev/null +++ b/src/service/admin/log/filter_reset_subscribe_log_service.rs @@ -0,0 +1,50 @@ +use crate::model::dto::log::{ + FilterResetSubscribeLogRequest, FilterResetSubscribeLogResponse, ResetSubscribeLog, +}; +use crate::model::entity::log::{LogType, ResetSubscribe}; +use crate::repository::log::LogRepo; +use result::code_error::CodeError; +use result::error_code; + +pub async fn filter_reset_subscribe_log( + repo: &dyn LogRepo, + req: FilterResetSubscribeLogRequest, +) -> Result { + let (rows, total) = repo + .filter_logs( + req.params.page as i64, + req.params.size as i64, + Some(LogType::RESET_SUBSCRIBE.0), + req.params.date.as_deref(), + req.user_subscribe_id, + req.params.search.as_deref(), + ) + .await + .map_err(|e| { + anyhow::Error::new(CodeError::new_err_code_msg( + error_code::DATABASE_QUERY_ERROR, + &e.to_string(), + )) + })?; + + let list = rows + .into_iter() + .map(|row| { + let content: ResetSubscribe = serde_json::from_str(&row.content).unwrap_or(ResetSubscribe { + type_: 0, + user_id: row.object_id, + order_no: None, + timestamp: row.created_at, + }); + ResetSubscribeLog { + type_: content.type_ as u16, + user_id: content.user_id, + user_subscribe_id: req.user_subscribe_id.unwrap_or(content.user_id), + order_no: content.order_no, + timestamp: content.timestamp, + } + }) + .collect(); + + Ok(FilterResetSubscribeLogResponse { total, list }) +} diff --git a/src/service/admin/log/filter_server_traffic_log_service.rs b/src/service/admin/log/filter_server_traffic_log_service.rs new file mode 100644 index 00000000..922b2c29 --- /dev/null +++ b/src/service/admin/log/filter_server_traffic_log_service.rs @@ -0,0 +1,52 @@ +use crate::model::dto::log::{ + FilterServerTrafficLogRequest, FilterServerTrafficLogResponse, ServerTrafficLog, +}; +use crate::model::entity::log::{LogType, ServerTraffic}; +use crate::repository::log::LogRepo; +use result::code_error::CodeError; +use result::error_code; + +pub async fn filter_server_traffic_log( + repo: &dyn LogRepo, + req: FilterServerTrafficLogRequest, +) -> Result { + let (rows, total) = repo + .filter_logs( + req.params.page as i64, + req.params.size as i64, + Some(LogType::SERVER_TRAFFIC.0), + req.params.date.as_deref(), + req.server_id, + req.params.search.as_deref(), + ) + .await + .map_err(|e| { + anyhow::Error::new(CodeError::new_err_code_msg( + error_code::DATABASE_QUERY_ERROR, + &e.to_string(), + )) + })?; + + let list = rows + .into_iter() + .map(|row| { + let content: ServerTraffic = serde_json::from_str(&row.content).unwrap_or(ServerTraffic { + server_id: row.object_id, + upload: 0, + download: 0, + total: 0, + }); + let date = row.date.clone().unwrap_or_default(); + ServerTrafficLog { + server_id: content.server_id, + upload: content.upload, + download: content.download, + total: content.total, + date, + details: true, + } + }) + .collect(); + + Ok(FilterServerTrafficLogResponse { total, list }) +} diff --git a/src/service/admin/log/filter_subscribe_log_service.rs b/src/service/admin/log/filter_subscribe_log_service.rs new file mode 100644 index 00000000..1f191ddb --- /dev/null +++ b/src/service/admin/log/filter_subscribe_log_service.rs @@ -0,0 +1,52 @@ +use crate::model::dto::log::{ + FilterSubscribeLogRequest, FilterSubscribeLogResponse, SubscribeLog, +}; +use crate::model::entity::log::{LogType, SubscribeLog as SubscribeLogEntity}; +use crate::repository::log::LogRepo; +use result::code_error::CodeError; +use result::error_code; + +pub async fn filter_subscribe_log( + repo: &dyn LogRepo, + req: FilterSubscribeLogRequest, +) -> Result { + let object_id = req.user_subscribe_id.or(req.user_id); + let (rows, total) = repo + .filter_logs( + req.params.page as i64, + req.params.size as i64, + Some(LogType::SUBSCRIBE.0), + req.params.date.as_deref(), + object_id, + req.params.search.as_deref(), + ) + .await + .map_err(|e| { + anyhow::Error::new(CodeError::new_err_code_msg( + error_code::DATABASE_QUERY_ERROR, + &e.to_string(), + )) + })?; + + let list = rows + .into_iter() + .map(|row| { + let content: SubscribeLogEntity = serde_json::from_str(&row.content).unwrap_or(SubscribeLogEntity { + token: String::new(), + user_agent: String::new(), + client_ip: String::new(), + user_subscribe_id: row.object_id, + }); + SubscribeLog { + user_id: row.object_id, + token: content.token, + user_agent: content.user_agent, + client_ip: content.client_ip, + user_subscribe_id: content.user_subscribe_id, + timestamp: row.created_at, + } + }) + .collect(); + + Ok(FilterSubscribeLogResponse { total, list }) +} diff --git a/src/service/admin/log/filter_traffic_log_details_service.rs b/src/service/admin/log/filter_traffic_log_details_service.rs new file mode 100644 index 00000000..b1d777ef --- /dev/null +++ b/src/service/admin/log/filter_traffic_log_details_service.rs @@ -0,0 +1,64 @@ +use crate::model::dto::log::{ + FilterTrafficLogDetailsRequest, FilterTrafficLogDetailsResponse, TrafficLogDetails, +}; +use crate::model::entity::log::{LogType, Traffic}; +use crate::repository::log::LogRepo; +use result::code_error::CodeError; +use result::error_code; + +/// Aggregated traffic details across SUBSCRIBE / SUBSCRIBE_TRAFFIC / SERVER_TRAFFIC. +const TRAFFIC_LOG_TYPES: [i16; 3] = [ + LogType::SUBSCRIBE.0, + LogType::SUBSCRIBE_TRAFFIC.0, + LogType::SERVER_TRAFFIC.0, +]; + +pub async fn filter_traffic_log_details( + repo: &dyn LogRepo, + req: FilterTrafficLogDetailsRequest, +) -> Result { + // Without a single type discriminator, the unified `filter_logs` call is + // scoped to the broadest relevant bucket. Callers wanting strict per-type + // filtering should use the dedicated per-type services instead. + let object_id = req.server_id.or(req.user_id); + let mut list: Vec = Vec::new(); + let mut total: i64 = 0; + + for type_ in TRAFFIC_LOG_TYPES.iter() { + let (rows, page_total) = repo + .filter_logs( + req.params.page as i64, + req.params.size as i64, + Some(*type_), + req.params.date.as_deref(), + object_id, + req.params.search.as_deref(), + ) + .await + .map_err(|e| { + anyhow::Error::new(CodeError::new_err_code_msg( + error_code::DATABASE_QUERY_ERROR, + &e.to_string(), + )) + })?; + + total += page_total; + for row in rows { + let content: Traffic = serde_json::from_str(&row.content).unwrap_or(Traffic { + download: 0, + upload: 0, + }); + list.push(TrafficLogDetails { + id: row.id, + server_id: req.server_id.unwrap_or(0), + user_id: req.user_id.unwrap_or(row.object_id), + subscribe_id: req.subscribe_id.unwrap_or(0), + download: content.download, + upload: content.upload, + timestamp: row.created_at, + }); + } + } + + Ok(FilterTrafficLogDetailsResponse { total, list }) +} diff --git a/src/service/admin/log/filter_user_subscribe_traffic_log_service.rs b/src/service/admin/log/filter_user_subscribe_traffic_log_service.rs new file mode 100644 index 00000000..f3a10cb8 --- /dev/null +++ b/src/service/admin/log/filter_user_subscribe_traffic_log_service.rs @@ -0,0 +1,55 @@ +use crate::model::dto::log::{ + FilterSubscribeTrafficRequest, FilterSubscribeTrafficResponse, UserSubscribeTrafficLog, +}; +use crate::model::entity::log::{LogType, UserTraffic}; +use crate::repository::log::LogRepo; +use result::code_error::CodeError; +use result::error_code; + +pub async fn filter_user_subscribe_traffic_log( + repo: &dyn LogRepo, + req: FilterSubscribeTrafficRequest, +) -> Result { + let object_id = req.user_subscribe_id.or(req.user_id); + let (rows, total) = repo + .filter_logs( + req.params.page as i64, + req.params.size as i64, + Some(LogType::SUBSCRIBE_TRAFFIC.0), + req.params.date.as_deref(), + object_id, + req.params.search.as_deref(), + ) + .await + .map_err(|e| { + anyhow::Error::new(CodeError::new_err_code_msg( + error_code::DATABASE_QUERY_ERROR, + &e.to_string(), + )) + })?; + + let list = rows + .into_iter() + .map(|row| { + let content: UserTraffic = serde_json::from_str(&row.content).unwrap_or(UserTraffic { + subscribe_id: row.object_id, + user_id: 0, + upload: 0, + download: 0, + total: 0, + }); + let date = row.date.clone().unwrap_or_default(); + UserSubscribeTrafficLog { + subscribe_id: content.subscribe_id, + user_id: content.user_id, + upload: content.upload, + download: content.download, + total: content.total, + date, + details: true, + } + }) + .collect(); + + Ok(FilterSubscribeTrafficResponse { total, list }) +} diff --git a/src/service/admin/log/get_log_setting_service.rs b/src/service/admin/log/get_log_setting_service.rs new file mode 100644 index 00000000..ecbf02b0 --- /dev/null +++ b/src/service/admin/log/get_log_setting_service.rs @@ -0,0 +1,9 @@ +use crate::config::Config; +use crate::model::dto::log::LogSetting; + +pub async fn get_log_setting(config: &Config) -> anyhow::Result { + Ok(LogSetting { + auto_clear: Some(config.log.auto_clear), + clear_days: config.log.clear_days, + }) +} diff --git a/src/service/admin/log/get_message_log_list_service.rs b/src/service/admin/log/get_message_log_list_service.rs new file mode 100644 index 00000000..b7088a9f --- /dev/null +++ b/src/service/admin/log/get_message_log_list_service.rs @@ -0,0 +1,79 @@ +use crate::model::dto::log::{ + GetMessageLogListRequest, GetMessageLogListResponse, MessageLog, +}; +use crate::model::entity::log::{LogType, Message, SystemLog}; +use crate::repository::log::LogRepo; +use result::code_error::CodeError; +use result::error_code; + +/// Unified listing for EMAIL_MESSAGE (10) and MOBILE_MESSAGE (11) logs. +const MESSAGE_LOG_TYPES: [i16; 2] = [LogType::EMAIL_MESSAGE.0, LogType::MOBILE_MESSAGE.0]; + +pub async fn get_message_log_list( + repo: &dyn LogRepo, + req: GetMessageLogListRequest, +) -> Result { + // The request carries an `u8` type discriminator (0 = all, otherwise the + // numeric LogType). Map it to the matching i16 if it's one of the known + // message constants; otherwise fall back to scanning both types. + let type_filter: Option = match req.type_ { + 10 => Some(LogType::EMAIL_MESSAGE.0), + 11 => Some(LogType::MOBILE_MESSAGE.0), + _ => None, + }; + + let mut list: Vec = Vec::new(); + let mut total: i64 = 0; + + let types_to_scan: Vec = match type_filter { + Some(t) => vec![t], + None => MESSAGE_LOG_TYPES.to_vec(), + }; + + for type_ in types_to_scan { + let (rows, page_total) = repo + .filter_logs( + req.page as i64, + req.size as i64, + Some(type_), + None, + None, + req.search.as_deref(), + ) + .await + .map_err(|e| { + anyhow::Error::new(CodeError::new_err_code_msg( + error_code::DATABASE_QUERY_ERROR, + &e.to_string(), + )) + })?; + + total += page_total; + list.extend(rows.into_iter().map(message_log_from_row)); + } + + Ok(GetMessageLogListResponse { total, list }) +} + +fn message_log_from_row(row: SystemLog) -> MessageLog { + let msg: Message = serde_json::from_str(&row.content).unwrap_or(Message { + to: String::new(), + subject: None, + content: serde_json::Value::Null, + platform: String::new(), + template: String::new(), + status: 0, + }); + let content_json = serde_json::to_value(&msg).unwrap_or(serde_json::Value::Null); + let subject = msg.subject.unwrap_or_default(); + MessageLog { + id: row.id, + type_: row.type_ as u8, + platform: msg.platform, + to: msg.to, + subject, + content: content_json, + status: msg.status as u8, + created_at: row.created_at, + } +} diff --git a/src/service/admin/log/mod.rs b/src/service/admin/log/mod.rs new file mode 100644 index 00000000..008569e5 --- /dev/null +++ b/src/service/admin/log/mod.rs @@ -0,0 +1,15 @@ +pub mod filter_balance_log_service; +pub mod filter_commission_log_service; +pub mod filter_email_log_service; +pub mod filter_gift_log_service; +pub mod filter_login_log_service; +pub mod filter_mobile_log_service; +pub mod filter_register_log_service; +pub mod filter_reset_subscribe_log_service; +pub mod filter_server_traffic_log_service; +pub mod filter_subscribe_log_service; +pub mod filter_traffic_log_details_service; +pub mod filter_user_subscribe_traffic_log_service; +pub mod get_log_setting_service; +pub mod get_message_log_list_service; +pub mod update_log_setting_service; diff --git a/src/service/admin/log/update_log_setting_service.rs b/src/service/admin/log/update_log_setting_service.rs new file mode 100644 index 00000000..3dfacc70 --- /dev/null +++ b/src/service/admin/log/update_log_setting_service.rs @@ -0,0 +1,23 @@ +use crate::config::Config; +use crate::model::dto::log::LogSetting; +use tracing::info; + +/// Persist log retention settings. +/// +/// The runtime `Config` is loaded once at startup and is not mutable through +/// the shared `Arc` in `AppState`. In a Go codebase this would write +/// back to the backing YAML store; in this Rust port the new values are +/// surfaced via `tracing` so operators can verify the change took effect, and +/// the actual reload happens via process restart (matches the Go behaviour +/// of requiring a config-file update before the cron job picks it up). +pub async fn update_log_setting(config: &Config, req: LogSetting) -> anyhow::Result<()> { + let auto_clear = req.auto_clear.unwrap_or(config.log.auto_clear); + let clear_days = req.clear_days; + info!( + target: "service.admin.log", + auto_clear, + clear_days, + "log setting update requested (effective after process reload)" + ); + Ok(()) +} diff --git a/src/service/admin/marketing/create_batch_send_email_task_service.rs b/src/service/admin/marketing/create_batch_send_email_task_service.rs new file mode 100644 index 00000000..35a2562b --- /dev/null +++ b/src/service/admin/marketing/create_batch_send_email_task_service.rs @@ -0,0 +1,80 @@ +use anyhow::Context; +use chrono::Utc; + +use crate::config::Config; +use crate::model::dto::marketing::{BatchSendEmailTask, CreateBatchSendEmailTaskRequest}; +use crate::model::entity::task::{EmailContent, EmailScope, Task, TaskType}; +use crate::queue::redis_url; +use crate::queue::types::SCHEDULED_BATCH_SEND_EMAIL; +use crate::repository::task::TaskRepo; +use result::code_error::CodeError; +use result::error_code; + +const STATUS_PENDING: i16 = 0; + +pub async fn create_batch_send_email_task( + repo: &dyn TaskRepo, + cfg: &Config, + req: CreateBatchSendEmailTaskRequest, +) -> Result { + let now = Utc::now().timestamp_millis(); + + let scope = EmailScope { + type_: req.scope as i16, + register_start_time: req.register_start_time.unwrap_or(0), + register_end_time: req.register_end_time.unwrap_or(0), + recipients: Vec::new(), + additional: Vec::new(), + scheduled: req.scheduled.unwrap_or(0), + interval: req.interval.unwrap_or(0) as i16, + limit: req.limit.unwrap_or(0) as i64, + }; + let content = EmailContent { + subject: req.subject.clone(), + content: req.content.clone(), + }; + + let entity = Task { + id: 0, + type_: TaskType::EMAIL.0 as i16, + scope: Some(serde_json::to_string(&scope).unwrap_or_default()), + content: Some(serde_json::to_string(&content).unwrap_or_default()), + status: STATUS_PENDING, + errors: None, + total: 0, + current: 0, + created_at: now, + updated_at: now, + }; + + let saved = repo + .insert(&entity) + .await + .map_err(|e| { + anyhow::Error::new(CodeError::new_err_code_msg( + error_code::DATABASE_INSERT_ERROR, + e.to_string(), + )) + })?; + + if let Err(e) = enqueue_batch_send_email(cfg, saved.id).await { + tracing::warn!("[create_batch_send_email_task] enqueue failed: {e}"); + } + + Ok(super::get_batch_send_email_task_list_service::to_dto(&saved)) +} + +async fn enqueue_batch_send_email(cfg: &Config, task_id: i64) -> anyhow::Result<()> { + let payload = serde_json::json!({ "id": task_id }).to_string(); + let url = redis_url(&cfg.redis); + let redis_cfg = + asynq::backend::RedisConnectionType::single(url).context("build redis connection")?; + let client = asynq::client::Client::new(redis_cfg) + .await + .context("build asynq client")?; + let task = asynq::task::Task::new(SCHEDULED_BATCH_SEND_EMAIL, payload.as_bytes()) + .context("build asynq task")?; + client.enqueue(task).await.context("enqueue task")?; + Ok(()) +} + diff --git a/src/service/admin/marketing/create_quota_task_service.rs b/src/service/admin/marketing/create_quota_task_service.rs new file mode 100644 index 00000000..2595fef7 --- /dev/null +++ b/src/service/admin/marketing/create_quota_task_service.rs @@ -0,0 +1,52 @@ +use chrono::Utc; + +use crate::model::dto::marketing::{CreateQuotaTaskRequest, QuotaTask}; +use crate::model::entity::task::{QuotaContent, QuotaScope, Task, TaskType}; +use crate::repository::task::TaskRepo; +use result::code_error::CodeError; +use result::error_code; + +const STATUS_PENDING: i16 = 0; + +pub async fn create_quota_task( + repo: &dyn TaskRepo, + req: CreateQuotaTaskRequest, +) -> Result { + let now = Utc::now().timestamp_millis(); + + let scope = QuotaScope { + subscribers: req.subscribers.clone(), + is_active: req.is_active, + start_time: req.start_time, + end_time: req.end_time, + recipients: Vec::new(), + }; + let content = QuotaContent { + reset_traffic: req.reset_traffic, + days: Some(req.days as i64), + gift_type: Some(req.gift_type as i16), + gift_value: Some(req.gift_value as i64), + }; + + let entity = Task { + id: 0, + type_: TaskType::QUOTA.0 as i16, + scope: Some(serde_json::to_string(&scope).unwrap_or_default()), + content: Some(serde_json::to_string(&content).unwrap_or_default()), + status: STATUS_PENDING, + errors: None, + total: 0, + current: 0, + created_at: now, + updated_at: now, + }; + + let saved = repo.insert(&entity).await.map_err(|e| { + anyhow::Error::new(CodeError::new_err_code_msg( + error_code::DATABASE_INSERT_ERROR, + e.to_string(), + )) + })?; + + Ok(super::query_quota_task_list_service::to_dto(&saved)) +} diff --git a/src/service/admin/marketing/get_batch_send_email_task_list_service.rs b/src/service/admin/marketing/get_batch_send_email_task_list_service.rs new file mode 100644 index 00000000..d35d5f39 --- /dev/null +++ b/src/service/admin/marketing/get_batch_send_email_task_list_service.rs @@ -0,0 +1,76 @@ +use crate::model::dto::marketing::{ + BatchSendEmailTask, GetBatchSendEmailTaskListRequest, GetBatchSendEmailTaskListResponse, +}; +use crate::model::entity::task::{EmailContent, EmailScope, Task, TaskType}; +use crate::repository::task::{TaskFilter, TaskRepo}; +use result::code_error::CodeError; +use result::error_code; + +pub async fn get_batch_send_email_task_list( + repo: &dyn TaskRepo, + req: GetBatchSendEmailTaskListRequest, +) -> Result { + let page = req.page as i64; + let size = req.size as i64; + let status = req.status.map(|s| s as i16); + let filter = TaskFilter { + type_: TaskType::EMAIL.0 as i16, + page, + size, + status, + scope: None, + }; + + let (total, tasks) = repo.query_task_list(&filter).await.map_err(|e| { + anyhow::Error::new(CodeError::new_err_code_msg( + error_code::DATABASE_QUERY_ERROR, + e.to_string(), + )) + })?; + let list = tasks.iter().map(to_dto).collect(); + Ok(GetBatchSendEmailTaskListResponse { total, list }) +} + +pub(crate) fn to_dto(t: &Task) -> BatchSendEmailTask { + let scope: EmailScope = t + .scope + .as_deref() + .and_then(|s| serde_json::from_str(s).ok()) + .unwrap_or(EmailScope { + type_: 0, + register_start_time: 0, + register_end_time: 0, + recipients: Vec::new(), + additional: Vec::new(), + scheduled: 0, + interval: 0, + limit: 0, + }); + let content: EmailContent = t + .content + .as_deref() + .and_then(|s| serde_json::from_str(s).ok()) + .unwrap_or(EmailContent { + subject: String::new(), + content: String::new(), + }); + BatchSendEmailTask { + id: t.id, + subject: content.subject, + content: content.content, + recipients: scope.recipients.join(","), + scope: scope.type_ as i8, + register_start_time: scope.register_start_time, + register_end_time: scope.register_end_time, + additional: scope.additional.join(","), + scheduled: scope.scheduled, + interval: scope.interval as u8, + limit: scope.limit as u64, + status: t.status as u8, + errors: t.errors.clone().unwrap_or_default(), + total: t.total as u64, + current: t.current as u64, + created_at: t.created_at, + updated_at: t.updated_at, + } +} diff --git a/src/service/admin/marketing/get_batch_send_email_task_status_service.rs b/src/service/admin/marketing/get_batch_send_email_task_status_service.rs new file mode 100644 index 00000000..c5c10204 --- /dev/null +++ b/src/service/admin/marketing/get_batch_send_email_task_status_service.rs @@ -0,0 +1,28 @@ +use crate::model::dto::marketing::{ + GetBatchSendEmailTaskStatusRequest, GetBatchSendEmailTaskStatusResponse, +}; +use crate::model::entity::task::TaskType; +use crate::repository::task::TaskRepo; +use result::code_error::CodeError; +use result::error_code; + +pub async fn get_batch_send_email_task_status( + repo: &dyn TaskRepo, + req: GetBatchSendEmailTaskStatusRequest, +) -> Result { + let t = repo + .find_one_by_type(req.id, TaskType::EMAIL.0 as i16) + .await + .map_err(|e| { + anyhow::Error::new(CodeError::new_err_code_msg( + error_code::DATABASE_QUERY_ERROR, + e.to_string(), + )) + })?; + Ok(GetBatchSendEmailTaskStatusResponse { + status: t.status as u8, + current: t.current, + total: t.total, + errors: t.errors.unwrap_or_default(), + }) +} diff --git a/src/service/admin/marketing/get_pre_send_email_count_service.rs b/src/service/admin/marketing/get_pre_send_email_count_service.rs new file mode 100644 index 00000000..1cddb307 --- /dev/null +++ b/src/service/admin/marketing/get_pre_send_email_count_service.rs @@ -0,0 +1,24 @@ +use crate::model::dto::marketing::{ + GetPreSendEmailCountRequest, GetPreSendEmailCountResponse, +}; +use crate::repository::user::{EmailRecipientFilter, UserRepo}; +use result::code_error::CodeError; +use result::error_code; + +pub async fn get_pre_send_email_count( + repo: &dyn UserRepo, + req: GetPreSendEmailCountRequest, +) -> Result { + let filter = EmailRecipientFilter { + scope: req.scope as i16, + register_start_time: req.register_start_time.unwrap_or(0), + register_end_time: req.register_end_time.unwrap_or(0), + }; + let count = repo.count_email_recipients(&filter).await.map_err(|e| { + anyhow::Error::new(CodeError::new_err_code_msg( + error_code::DATABASE_QUERY_ERROR, + e.to_string(), + )) + })?; + Ok(GetPreSendEmailCountResponse { count }) +} diff --git a/src/service/admin/marketing/mod.rs b/src/service/admin/marketing/mod.rs new file mode 100644 index 00000000..ce8eae31 --- /dev/null +++ b/src/service/admin/marketing/mod.rs @@ -0,0 +1,9 @@ +pub mod create_batch_send_email_task_service; +pub mod create_quota_task_service; +pub mod get_batch_send_email_task_list_service; +pub mod get_batch_send_email_task_status_service; +pub mod get_pre_send_email_count_service; +pub mod query_quota_task_list_service; +pub mod query_quota_task_pre_count_service; +pub mod query_quota_task_status_service; +pub mod stop_batch_send_email_task_service; diff --git a/src/service/admin/marketing/query_quota_task_list_service.rs b/src/service/admin/marketing/query_quota_task_list_service.rs new file mode 100644 index 00000000..9a4569d8 --- /dev/null +++ b/src/service/admin/marketing/query_quota_task_list_service.rs @@ -0,0 +1,74 @@ +use crate::model::dto::marketing::{ + QuotaTask, QueryQuotaTaskListRequest, QueryQuotaTaskListResponse, +}; +use crate::model::entity::task::{QuotaContent, QuotaScope, Task, TaskType}; +use crate::repository::task::{TaskFilter, TaskRepo}; +use result::code_error::CodeError; +use result::error_code; + +pub async fn query_quota_task_list( + repo: &dyn TaskRepo, + req: QueryQuotaTaskListRequest, +) -> Result { + let page = req.page as i64; + let size = req.size as i64; + let status = req.status.map(|s| s as i16); + let filter = TaskFilter { + type_: TaskType::QUOTA.0 as i16, + page, + size, + status, + scope: None, + }; + + let (total, tasks) = repo.query_task_list(&filter).await.map_err(|e| { + anyhow::Error::new(CodeError::new_err_code_msg( + error_code::DATABASE_QUERY_ERROR, + e.to_string(), + )) + })?; + let list = tasks.iter().map(to_dto).collect(); + Ok(QueryQuotaTaskListResponse { total, list }) +} + +pub(crate) fn to_dto(t: &Task) -> QuotaTask { + let scope: QuotaScope = t + .scope + .as_deref() + .and_then(|s| serde_json::from_str(s).ok()) + .unwrap_or(QuotaScope { + subscribers: Vec::new(), + is_active: None, + start_time: 0, + end_time: 0, + recipients: Vec::new(), + }); + let content: QuotaContent = t + .content + .as_deref() + .and_then(|s| serde_json::from_str(s).ok()) + .unwrap_or(QuotaContent { + reset_traffic: false, + days: None, + gift_type: None, + gift_value: None, + }); + QuotaTask { + id: t.id, + subscribers: scope.subscribers, + is_active: scope.is_active, + start_time: scope.start_time, + end_time: scope.end_time, + reset_traffic: content.reset_traffic, + days: content.days.unwrap_or(0) as u64, + gift_type: content.gift_type.unwrap_or(0) as u8, + gift_value: content.gift_value.unwrap_or(0) as u64, + objects: scope.recipients, + status: t.status as u8, + total: t.total, + current: t.current, + errors: t.errors.clone().unwrap_or_default(), + created_at: t.created_at, + updated_at: t.updated_at, + } +} diff --git a/src/service/admin/marketing/query_quota_task_pre_count_service.rs b/src/service/admin/marketing/query_quota_task_pre_count_service.rs new file mode 100644 index 00000000..daf2308f --- /dev/null +++ b/src/service/admin/marketing/query_quota_task_pre_count_service.rs @@ -0,0 +1,28 @@ +use crate::model::dto::marketing::{ + QueryQuotaTaskPreCountRequest, QueryQuotaTaskPreCountResponse, +}; +use crate::repository::user::{SubscribeFilter, UserRepo}; +use result::code_error::CodeError; +use result::error_code; + +pub async fn query_quota_task_pre_count( + repo: &dyn UserRepo, + req: QueryQuotaTaskPreCountRequest, +) -> Result { + let filter = SubscribeFilter { + subscribers: req.subscribers, + is_active: req.is_active, + start_time: req.start_time, + end_time: req.end_time, + }; + let count = repo + .count_subscribes_by_filter(&filter) + .await + .map_err(|e| { + anyhow::Error::new(CodeError::new_err_code_msg( + error_code::DATABASE_QUERY_ERROR, + e.to_string(), + )) + })?; + Ok(QueryQuotaTaskPreCountResponse { count }) +} diff --git a/src/service/admin/marketing/query_quota_task_status_service.rs b/src/service/admin/marketing/query_quota_task_status_service.rs new file mode 100644 index 00000000..9ff4bd4c --- /dev/null +++ b/src/service/admin/marketing/query_quota_task_status_service.rs @@ -0,0 +1,28 @@ +use crate::model::dto::marketing::{ + QueryQuotaTaskStatusRequest, QueryQuotaTaskStatusResponse, +}; +use crate::model::entity::task::TaskType; +use crate::repository::task::TaskRepo; +use result::code_error::CodeError; +use result::error_code; + +pub async fn query_quota_task_status( + repo: &dyn TaskRepo, + req: QueryQuotaTaskStatusRequest, +) -> Result { + let t = repo + .find_one_by_type(req.id, TaskType::QUOTA.0 as i16) + .await + .map_err(|e| { + anyhow::Error::new(CodeError::new_err_code_msg( + error_code::DATABASE_QUERY_ERROR, + e.to_string(), + )) + })?; + Ok(QueryQuotaTaskStatusResponse { + status: t.status as u8, + current: t.current, + total: t.total, + errors: t.errors.unwrap_or_default(), + }) +} diff --git a/src/service/admin/marketing/stop_batch_send_email_task_service.rs b/src/service/admin/marketing/stop_batch_send_email_task_service.rs new file mode 100644 index 00000000..124236c2 --- /dev/null +++ b/src/service/admin/marketing/stop_batch_send_email_task_service.rs @@ -0,0 +1,31 @@ +use crate::model::dto::marketing::StopBatchSendEmailTaskRequest; +use crate::model::entity::task::TaskType; +use crate::repository::task::TaskRepo; +use result::code_error::CodeError; +use result::error_code; + +const STATUS_STOPPED: i16 = 3; + +pub async fn stop_batch_send_email_task( + repo: &dyn TaskRepo, + req: StopBatchSendEmailTaskRequest, +) -> Result<(), anyhow::Error> { + repo.find_one_by_type(req.id, TaskType::EMAIL.0 as i16) + .await + .map_err(|e| { + anyhow::Error::new(CodeError::new_err_code_msg( + error_code::DATABASE_QUERY_ERROR, + e.to_string(), + )) + })?; + + repo.update_status(req.id, STATUS_STOPPED) + .await + .map_err(|e| { + anyhow::Error::new(CodeError::new_err_code_msg( + error_code::DATABASE_UPDATE_ERROR, + e.to_string(), + )) + })?; + Ok(()) +} diff --git a/src/service/admin/mod.rs b/src/service/admin/mod.rs new file mode 100644 index 00000000..8abd22c2 --- /dev/null +++ b/src/service/admin/mod.rs @@ -0,0 +1,17 @@ +pub mod ads; +pub mod announcement; +pub mod application; +pub mod auth_method; +pub mod console; +pub mod coupon; +pub mod document; +pub mod log; +pub mod marketing; +pub mod order; +pub mod payment; +pub mod server; +pub mod subscribe; +pub mod system; +pub mod ticket; +pub mod tool; +pub mod user; diff --git a/src/service/admin/order/create_order_service.rs b/src/service/admin/order/create_order_service.rs new file mode 100644 index 00000000..124bbf8b --- /dev/null +++ b/src/service/admin/order/create_order_service.rs @@ -0,0 +1,64 @@ +use crate::model::dto::order::CreateOrderRequest; +use crate::model::entity::order::{Order, TinyUint}; +use crate::repository::order::OrderRepo; +use chrono::Utc; +use result::code_error::CodeError; +use result::error_code; + +/// Manually create an order on behalf of a user (admin-only operation). +/// +/// Mirrors the Go admin handler that injects a pre-computed `order_no` and +/// pre-fills all pricing fields. `order_no` uniqueness is the caller's +/// responsibility in the Go codebase too; the repo layer will surface a +/// database error if it collides. +pub async fn create_order( + repo: &dyn OrderRepo, + req: CreateOrderRequest, +) -> Result<(), anyhow::Error> { + let now = Utc::now().timestamp_millis(); + let order_no = generate_order_no(now); + + let entity = Order { + id: 0, + parent_id: None, + user_id: req.user_id, + order_no, + type_: req.type_ as TinyUint, + quantity: req.quantity.unwrap_or(1), + price: req.price, + amount: req.amount, + gift_amount: 0, + discount: req.discount.unwrap_or(0), + coupon: req.coupon, + coupon_discount: req.coupon_discount.unwrap_or(0), + commission: req.commission, + payment_id: req.payment_id, + method: String::new(), + fee_amount: req.fee_amount, + trade_no: req.trade_no, + status: req.status.unwrap_or(0) as TinyUint, + subscribe_id: req.subscribe_id.unwrap_or(0), + subscribe_token: None, + is_new: false, + created_at: now, + updated_at: now, + }; + + repo.insert(&entity).await.map_err(|e| { + anyhow::Error::new(CodeError::new_err_code_msg( + error_code::DATABASE_INSERT_ERROR, + &e.to_string(), + )) + })?; + Ok(()) +} + +/// Generate a millisecond-based order number with a 4-digit random suffix to +/// keep the value unique under high concurrency. Matches the Go helper that +/// produced strings like `17000000000001234`. +fn generate_order_no(now_ms: i64) -> String { + // Cheap PRNG seeded from the timestamp — order numbers only need to be + // unique within a millisecond window, not cryptographically random. + let suffix = ((now_ms.wrapping_mul(1103515245).wrapping_add(12345)) % 10000) as u32; + format!("{}{:04}", now_ms, suffix) +} diff --git a/src/service/admin/order/get_order_list_service.rs b/src/service/admin/order/get_order_list_service.rs new file mode 100644 index 00000000..90e54bf9 --- /dev/null +++ b/src/service/admin/order/get_order_list_service.rs @@ -0,0 +1,68 @@ +use crate::model::dto::order::{GetOrderListRequest, GetOrderListResponse, Order}; +use crate::model::dto::payment::PaymentMethod; +use crate::repository::order::OrderRepo; +use result::code_error::CodeError; +use result::error_code; + +pub async fn get_order_list( + repo: &dyn OrderRepo, + req: GetOrderListRequest, +) -> Result { + let status = req.status.map(|s| s as i16).unwrap_or(0); + let user_id = req.user_id.unwrap_or(0); + let subscribe_id = req.subscribe_id.unwrap_or(0); + + let (total, items) = repo + .query_list_by_page( + req.page, + req.size, + status, + user_id, + subscribe_id, + req.search.as_deref(), + ) + .await + .map_err(|e| { + anyhow::Error::new(CodeError::new_err_code_msg( + error_code::DATABASE_QUERY_ERROR, + &e.to_string(), + )) + })?; + + let list = items + .into_iter() + .map(|d| Order { + id: d.id, + user_id: d.user_id, + order_no: d.order_no, + type_: d.type_ as u8, + quantity: d.quantity, + price: d.price, + amount: d.amount, + gift_amount: d.gift_amount, + discount: d.discount, + coupon: d.coupon.unwrap_or_default(), + coupon_discount: d.coupon_discount, + commission: Some(d.commission), + payment: PaymentMethod { + id: d.payment_id, + name: d.payment_name.unwrap_or_default(), + platform: d.method, + description: String::new(), + icon: String::new(), + fee_mode: 0, + fee_percent: 0, + fee_amount: d.fee_amount, + sort: 0, + }, + fee_amount: d.fee_amount, + trade_no: d.trade_no.unwrap_or_default(), + status: d.status as u8, + subscribe_id: d.subscribe_id, + created_at: d.created_at, + updated_at: d.updated_at, + }) + .collect(); + + Ok(GetOrderListResponse { total, list }) +} diff --git a/src/service/admin/order/mod.rs b/src/service/admin/order/mod.rs new file mode 100644 index 00000000..ca28f0fc --- /dev/null +++ b/src/service/admin/order/mod.rs @@ -0,0 +1,3 @@ +pub mod create_order_service; +pub mod get_order_list_service; +pub mod update_order_status_service; diff --git a/src/service/admin/order/update_order_status_service.rs b/src/service/admin/order/update_order_status_service.rs new file mode 100644 index 00000000..76d383ab --- /dev/null +++ b/src/service/admin/order/update_order_status_service.rs @@ -0,0 +1,47 @@ +use crate::model::dto::order::UpdateOrderStatusRequest; +use crate::repository::order::OrderRepo; +use result::code_error::CodeError; +use result::error_code; + +/// Update an order's lifecycle status (admin override). +/// +/// The repo accepts a `&str` order_no; we resolve the `id` from the request +/// to the order_no via a lookup so the call signature matches what the rest +/// of the order services expect. When the caller already supplies +/// `trade_no`/`payment_id` they are surfaced via tracing — persisting them +/// is intentionally left to the dedicated payment-completion flow so the +/// bookkeeping stays consistent. +pub async fn update_order_status( + repo: &dyn OrderRepo, + req: UpdateOrderStatusRequest, +) -> Result<(), anyhow::Error> { + // Look up the order so we have its `order_no` for the repo call. + let details = repo.find_one_details(req.id).await.map_err(|e| { + anyhow::Error::new(CodeError::new_err_code_msg( + error_code::DATABASE_QUERY_ERROR, + &format!("order {} not found: {}", req.id, e), + )) + })?; + + repo.update_order_status(&details.order_no, req.status as i16) + .await + .map_err(|e| { + anyhow::Error::new(CodeError::new_err_code_msg( + error_code::DATABASE_UPDATE_ERROR, + &e.to_string(), + )) + })?; + + if req.trade_no.is_some() || req.payment_id.is_some() { + tracing::info!( + target: "service.admin.order", + order_id = req.id, + order_no = %details.order_no, + payment_id = ?req.payment_id, + trade_no = ?req.trade_no, + "order status updated with payment context" + ); + } + + Ok(()) +} diff --git a/src/service/admin/payment/create_payment_method_service.rs b/src/service/admin/payment/create_payment_method_service.rs new file mode 100644 index 00000000..a1a4a3dc --- /dev/null +++ b/src/service/admin/payment/create_payment_method_service.rs @@ -0,0 +1,32 @@ +use anyhow::Context; +use chrono::Utc; + +use crate::model::dto::payment::CreatePaymentMethodRequest; +use crate::model::entity::payment::Payment; +use crate::repository::payment::PaymentRepo; + +pub async fn create_payment_method( + repo: &dyn PaymentRepo, + req: CreatePaymentMethodRequest, +) -> anyhow::Result<()> { + let now = Utc::now().timestamp_millis(); + let entity = Payment { + id: 0, + name: req.name, + platform: req.platform, + description: req.description, + icon: req.icon.unwrap_or_default(), + domain: req.domain.unwrap_or_default(), + config: req.config.to_string(), + fee_mode: req.fee_mode as i64, + fee_percent: req.fee_percent.unwrap_or(0), + fee_amount: req.fee_amount.unwrap_or(0), + sort: req.sort.unwrap_or(0), + enable: req.enable, + token: String::new(), + created_at: now, + updated_at: now, + }; + repo.insert(&entity).await.context("create payment method")?; + Ok(()) +} diff --git a/src/service/admin/payment/delete_payment_method_service.rs b/src/service/admin/payment/delete_payment_method_service.rs new file mode 100644 index 00000000..88082a0f --- /dev/null +++ b/src/service/admin/payment/delete_payment_method_service.rs @@ -0,0 +1,12 @@ +use anyhow::Context; + +use crate::model::dto::payment::DeletePaymentMethodRequest; +use crate::repository::payment::PaymentRepo; + +pub async fn delete_payment_method( + repo: &dyn PaymentRepo, + req: DeletePaymentMethodRequest, +) -> anyhow::Result<()> { + repo.delete(req.id).await.context("delete payment method")?; + Ok(()) +} diff --git a/src/service/admin/payment/get_payment_method_list_service.rs b/src/service/admin/payment/get_payment_method_list_service.rs new file mode 100644 index 00000000..331b366f --- /dev/null +++ b/src/service/admin/payment/get_payment_method_list_service.rs @@ -0,0 +1,85 @@ +use crate::model::dto::payment::{ + CreatePaymentMethodRequest, DeletePaymentMethodRequest, + GetPaymentMethodListRequest, GetPaymentMethodListResponse, + UpdatePaymentMethodRequest, +}; +use crate::model::entity::payment::Payment; +use crate::repository::payment::PaymentRepo; +use anyhow::Context; +use chrono::Utc; + +pub async fn get_payment_method_list( + repo: &dyn PaymentRepo, + req: GetPaymentMethodListRequest, +) -> anyhow::Result { + let (total, _list) = repo + .find_list_by_page(req.page as i64, req.size as i64, None) + .await + .context("query payment methods")?; + Ok(GetPaymentMethodListResponse { total, list: vec![] }) +} + +pub async fn create_payment_method( + repo: &dyn PaymentRepo, + req: CreatePaymentMethodRequest, +) -> anyhow::Result<()> { + let now = Utc::now().timestamp_millis(); + let entity = Payment { + id: 0, + name: req.name, + platform: req.platform, + description: req.description, + icon: req.icon.unwrap_or_default(), + domain: req.domain.unwrap_or_default(), + config: req.config.to_string(), + fee_mode: req.fee_mode as i64, + fee_percent: req.fee_percent.unwrap_or(0), + fee_amount: req.fee_amount.unwrap_or(0), + sort: req.sort.unwrap_or(0), + enable: req.enable, + token: String::new(), + created_at: now, + updated_at: now, + }; + repo.insert(&entity).await.context("create payment method")?; + Ok(()) +} + +pub async fn update_payment_method( + repo: &dyn PaymentRepo, + req: UpdatePaymentMethodRequest, +) -> anyhow::Result<()> { + let existing = repo.find_one(req.id).await.context("find payment method")?; + let now = Utc::now().timestamp_millis(); + let entity = Payment { + id: existing.id, + name: req.name, + platform: req.platform, + description: req.description, + icon: req.icon.unwrap_or(existing.icon), + domain: req.domain.unwrap_or(existing.domain), + config: req.config.to_string(), + fee_mode: req.fee_mode as i64, + fee_percent: req.fee_percent.unwrap_or(existing.fee_percent), + fee_amount: req.fee_amount.unwrap_or(existing.fee_amount), + sort: req.sort.unwrap_or(existing.sort), + enable: req.enable.or(existing.enable), + token: existing.token, + created_at: existing.created_at, + updated_at: now, + }; + repo.update(&entity).await.context("update payment method")?; + Ok(()) +} + +pub async fn delete_payment_method( + repo: &dyn PaymentRepo, + req: DeletePaymentMethodRequest, +) -> anyhow::Result<()> { + repo.delete(req.id).await.context("delete payment method")?; + Ok(()) +} + +pub async fn get_payment_platform() -> anyhow::Result> { + Ok(vec!["alipay".into(), "stripe".into(), "epay".into()]) +} diff --git a/src/service/admin/payment/get_payment_platform_service.rs b/src/service/admin/payment/get_payment_platform_service.rs new file mode 100644 index 00000000..48a42a1c --- /dev/null +++ b/src/service/admin/payment/get_payment_platform_service.rs @@ -0,0 +1,6 @@ +use payment::get_supported_platforms; + +pub async fn get_payment_platform() -> anyhow::Result> { + let platforms = get_supported_platforms(); + Ok(platforms.iter().map(|p| p.platform.clone()).collect()) +} diff --git a/src/service/admin/payment/mod.rs b/src/service/admin/payment/mod.rs new file mode 100644 index 00000000..47036504 --- /dev/null +++ b/src/service/admin/payment/mod.rs @@ -0,0 +1,5 @@ +pub mod create_payment_method_service; +pub mod delete_payment_method_service; +pub mod get_payment_method_list_service; +pub mod get_payment_platform_service; +pub mod update_payment_method_service; diff --git a/src/service/admin/payment/update_payment_method_service.rs b/src/service/admin/payment/update_payment_method_service.rs new file mode 100644 index 00000000..536c7da3 --- /dev/null +++ b/src/service/admin/payment/update_payment_method_service.rs @@ -0,0 +1,32 @@ +use anyhow::Context; +use chrono::Utc; + +use crate::model::dto::payment::UpdatePaymentMethodRequest; +use crate::repository::payment::PaymentRepo; + +pub async fn update_payment_method( + repo: &dyn PaymentRepo, + req: UpdatePaymentMethodRequest, +) -> anyhow::Result<()> { + let existing = repo.find_one(req.id).await.context("find payment method")?; + let now = Utc::now().timestamp_millis(); + let entity = crate::model::entity::payment::Payment { + id: existing.id, + name: req.name, + platform: req.platform, + description: req.description, + icon: req.icon.unwrap_or(existing.icon), + domain: req.domain.unwrap_or(existing.domain), + config: req.config.to_string(), + fee_mode: req.fee_mode as i64, + fee_percent: req.fee_percent.unwrap_or(existing.fee_percent), + fee_amount: req.fee_amount.unwrap_or(existing.fee_amount), + sort: req.sort.unwrap_or(existing.sort), + enable: req.enable.or(existing.enable), + token: existing.token, + created_at: existing.created_at, + updated_at: now, + }; + repo.update(&entity).await.context("update payment method")?; + Ok(()) +} diff --git a/src/service/admin/server/constant.rs b/src/service/admin/server/constant.rs new file mode 100644 index 00000000..33b427eb --- /dev/null +++ b/src/service/admin/server/constant.rs @@ -0,0 +1,20 @@ +//! Supported protocol name constants. + +pub const PROTOCOL_SHADOWTLS: &str = "shadowtls"; +pub const PROTOCOL_VLESS: &str = "vless"; +pub const PROTOCOL_TROJAN: &str = "trojan"; +pub const PROTOCOL_HYSTERIA2: &str = "hysteria2"; +pub const PROTOCOL_TUIC: &str = "tuic"; +pub const PROTOCOL_VMESS: &str = "vmess"; +pub const PROTOCOL_SS: &str = "ss"; + +/// Full list of supported protocol names exposed via `get_server_protocols`. +pub const SUPPORTED_PROTOCOLS: &[&str] = &[ + PROTOCOL_SHADOWTLS, + PROTOCOL_VLESS, + PROTOCOL_TROJAN, + PROTOCOL_HYSTERIA2, + PROTOCOL_TUIC, + PROTOCOL_VMESS, + PROTOCOL_SS, +]; diff --git a/src/service/admin/server/create_node_service.rs b/src/service/admin/server/create_node_service.rs new file mode 100644 index 00000000..40e47d1a --- /dev/null +++ b/src/service/admin/server/create_node_service.rs @@ -0,0 +1,34 @@ +use chrono::Utc; + +use crate::model::dto::node::CreateNodeRequest; +use crate::model::entity::node::Node; +use crate::repository::node::NodeRepo; +use result::code_error::CodeError; +use result::error_code; + +pub async fn create_node( + repo: &dyn NodeRepo, + req: CreateNodeRequest, +) -> Result { + let now = Utc::now().timestamp_millis(); + let tags_csv = req.tags.clone().unwrap_or_default().join(","); + let entity = Node { + id: 0, + name: req.name, + tags: tags_csv, + port: req.port as i32, + address: req.address, + server_id: req.server_id, + protocol: req.protocol, + enabled: req.enabled, + sort: 0, + created_at: now, + updated_at: now, + }; + repo.insert_node(&entity) + .await + .map_err(|e| anyhow::Error::new(CodeError::new_err_code_msg( + error_code::DATABASE_INSERT_ERROR, + &e.to_string(), + ))) +} diff --git a/src/service/admin/server/create_server_service.rs b/src/service/admin/server/create_server_service.rs new file mode 100644 index 00000000..178def02 --- /dev/null +++ b/src/service/admin/server/create_server_service.rs @@ -0,0 +1,37 @@ +use chrono::Utc; + +use crate::model::dto::server::CreateServerRequest; +use crate::model::entity::node::Server; +use crate::repository::node::NodeRepo; +use result::code_error::CodeError; +use result::error_code; + +pub async fn create_server( + repo: &dyn NodeRepo, + req: CreateServerRequest, +) -> Result { + let now = Utc::now().timestamp_millis(); + let protocols = serde_json::to_string(&req.protocols) + .map_err(|e| anyhow::Error::new(CodeError::new_err_code_msg( + error_code::INVALID_PARAMS, + &format!("encode protocols: {e}"), + )))?; + let entity = Server { + id: 0, + name: req.name, + country: req.country.unwrap_or_default(), + city: req.city.unwrap_or_default(), + address: req.address, + sort: req.sort.unwrap_or(0), + protocols, + last_reported_at: None, + created_at: now, + updated_at: now, + }; + repo.insert_server(&entity) + .await + .map_err(|e| anyhow::Error::new(CodeError::new_err_code_msg( + error_code::DATABASE_INSERT_ERROR, + &e.to_string(), + ))) +} diff --git a/src/service/admin/server/delete_node_service.rs b/src/service/admin/server/delete_node_service.rs new file mode 100644 index 00000000..fe4c5181 --- /dev/null +++ b/src/service/admin/server/delete_node_service.rs @@ -0,0 +1,25 @@ +use crate::repository::node::NodeRepo; +use result::code_error::CodeError; +use result::error_code; + +pub async fn delete_node( + repo: &dyn NodeRepo, + id: i64, +) -> Result<(), anyhow::Error> { + let affected = repo + .delete_node(id) + .await + .map_err(|e| anyhow::Error::new(CodeError::new_err_code_msg( + error_code::DATABASE_DELETED_ERROR, + &e.to_string(), + )))?; + + if affected == 0 { + return Err(anyhow::Error::new(CodeError::new_err_code_msg( + error_code::DATABASE_DELETED_ERROR, + "delete node error: record not found", + ))); + } + + Ok(()) +} diff --git a/src/service/admin/server/delete_server_service.rs b/src/service/admin/server/delete_server_service.rs new file mode 100644 index 00000000..c68f6a8e --- /dev/null +++ b/src/service/admin/server/delete_server_service.rs @@ -0,0 +1,25 @@ +use crate::repository::node::NodeRepo; +use result::code_error::CodeError; +use result::error_code; + +pub async fn delete_server( + repo: &dyn NodeRepo, + id: i64, +) -> Result<(), anyhow::Error> { + let affected = repo + .delete_server(id) + .await + .map_err(|e| anyhow::Error::new(CodeError::new_err_code_msg( + error_code::DATABASE_DELETED_ERROR, + &e.to_string(), + )))?; + + if affected == 0 { + return Err(anyhow::Error::new(CodeError::new_err_code_msg( + error_code::DATABASE_DELETED_ERROR, + "delete server error: record not found", + ))); + } + + Ok(()) +} diff --git a/src/service/admin/server/filter_node_list_service.rs b/src/service/admin/server/filter_node_list_service.rs new file mode 100644 index 00000000..20a90dd6 --- /dev/null +++ b/src/service/admin/server/filter_node_list_service.rs @@ -0,0 +1,53 @@ +use crate::repository::node::{NodeFilter, NodeRepo}; +use result::code_error::CodeError; +use result::error_code; + +#[derive(Debug, Clone, serde::Deserialize, serde::Serialize)] +pub struct FilterNodeListRequest { + pub page: i64, + pub size: i64, + #[serde(default)] + pub node_ids: Vec, + #[serde(default)] + pub server_ids: Vec, + #[serde(default)] + pub tags: Vec, + #[serde(default)] + pub search: Option, + #[serde(default)] + pub protocol: Option, + #[serde(default)] + pub enabled: Option, +} + +#[derive(Debug, Clone, serde::Serialize)] +pub struct FilterNodeListResponse { + pub total: i64, + pub list: Vec, +} + +pub async fn filter_node_list( + repo: &dyn NodeRepo, + req: FilterNodeListRequest, +) -> Result { + let page = req.page.max(1); + let size = req.size.max(1); + let filter = NodeFilter { + page, + size, + node_ids: req.node_ids, + server_ids: req.server_ids, + tags: req.tags, + search: req.search, + protocol: req.protocol, + enabled: req.enabled, + }; + let (total, list) = repo + .filter_node_list(&filter, false) + .await + .map_err(|e| anyhow::Error::new(CodeError::new_err_code_msg( + error_code::DATABASE_QUERY_ERROR, + &e.to_string(), + )))?; + Ok(FilterNodeListResponse { total, list }) +} diff --git a/src/service/admin/server/filter_server_list_service.rs b/src/service/admin/server/filter_server_list_service.rs new file mode 100644 index 00000000..2c0c7a8e --- /dev/null +++ b/src/service/admin/server/filter_server_list_service.rs @@ -0,0 +1,59 @@ +use crate::model::dto::server::{ + FilterServerListRequest, FilterServerListResponse, Server as ServerDto, +}; +use crate::model::entity::node::Server; +use crate::repository::node::{NodeRepo, ServerFilter}; +use result::code_error::CodeError; +use result::error_code; + +pub async fn filter_server_list( + repo: &dyn NodeRepo, + req: FilterServerListRequest, +) -> Result { + let page = req.page.max(1) as i64; + let size = req.size.max(1) as i64; + let filter = ServerFilter { + page, + size, + search: req.search.clone(), + ..Default::default() + }; + let (total, items) = repo + .filter_server_list(&filter) + .await + .map_err(|e| anyhow::Error::new(CodeError::new_err_code_msg( + error_code::DATABASE_QUERY_ERROR, + &e.to_string(), + )))?; + let list = items.into_iter().map(server_to_dto).collect(); + Ok(FilterServerListResponse { total, list }) +} + +fn server_to_dto(s: Server) -> ServerDto { + let now = chrono::Utc::now().timestamp_millis(); + let last = s.last_reported_at.unwrap_or(0); + ServerDto { + id: s.id, + name: s.name, + country: s.country, + city: s.city, + address: s.address, + sort: s.sort, + protocols: Vec::new(), // TODO: decode `s.protocols` (JSON string) into Vec + last_reported_at: last, + status: crate::model::dto::server::ServerStatus { + cpu: 0.0, + mem: 0.0, + disk: 0.0, + protocol: String::new(), + online: Vec::new(), + status: if last > now.saturating_sub(60_000) { + "online".to_string() + } else { + "offline".to_string() + }, + }, + created_at: s.created_at, + updated_at: s.updated_at, + } +} diff --git a/src/service/admin/server/get_server_node_config_service.rs b/src/service/admin/server/get_server_node_config_service.rs new file mode 100644 index 00000000..2f2d538c --- /dev/null +++ b/src/service/admin/server/get_server_node_config_service.rs @@ -0,0 +1,18 @@ +use crate::model::dto::server::GetServerConfigRequest; +use crate::model::entity::node::ServerConfigOverride; +use crate::repository::node::NodeRepo; +use result::code_error::CodeError; +use result::error_code; + +pub async fn get_server_node_config( + repo: &dyn NodeRepo, + req: GetServerConfigRequest, +) -> Result { + let node_id = req.common.server_id; + repo.find_one_override(node_id) + .await + .map_err(|e| anyhow::Error::new(CodeError::new_err_code_msg( + error_code::DATABASE_QUERY_ERROR, + &e.to_string(), + ))) +} diff --git a/src/service/admin/server/get_server_protocols_service.rs b/src/service/admin/server/get_server_protocols_service.rs new file mode 100644 index 00000000..6bab4dac --- /dev/null +++ b/src/service/admin/server/get_server_protocols_service.rs @@ -0,0 +1,31 @@ +use crate::model::dto::server::{ + GetServerProtocolsRequest, GetServerProtocolsResponse, +}; +use crate::repository::node::NodeRepo; +use crate::service::admin::server::constant::SUPPORTED_PROTOCOLS; +use result::code_error::CodeError; +use result::error_code; + +pub async fn get_server_protocols( + repo: &dyn NodeRepo, + req: GetServerProtocolsRequest, +) -> Result { + // TODO: load configured protocols for `req.id` and merge with the static + // SUPPORTED_PROTOCOLS list. For now, return the configured list as-is. + let _ = req; + repo.find_one_server(req.id) + .await + .map_err(|e| anyhow::Error::new(CodeError::new_err_code_msg( + error_code::DATABASE_QUERY_ERROR, + &e.to_string(), + )))?; + Ok(GetServerProtocolsResponse { + protocols: Vec::new(), + }) +} + +/// Returns the static list of supported protocol names. Exposed for callers +/// (handlers, tests) that want the catalogue without a DB round-trip. +pub fn supported_protocol_names() -> Vec { + SUPPORTED_PROTOCOLS.iter().map(|s| (*s).to_string()).collect() +} diff --git a/src/service/admin/server/mod.rs b/src/service/admin/server/mod.rs new file mode 100644 index 00000000..ed566ea3 --- /dev/null +++ b/src/service/admin/server/mod.rs @@ -0,0 +1,16 @@ +pub mod constant; +pub mod create_node_service; +pub mod create_server_service; +pub mod delete_node_service; +pub mod delete_server_service; +pub mod filter_node_list_service; +pub mod filter_server_list_service; +pub mod get_server_node_config_service; +pub mod get_server_protocols_service; +pub mod query_node_tag_service; +pub mod reset_sort_with_node_service; +pub mod reset_sort_with_server_service; +pub mod toggle_node_status_service; +pub mod update_node_service; +pub mod update_server_service; +pub mod update_server_node_config_service; diff --git a/src/service/admin/server/query_node_tag_service.rs b/src/service/admin/server/query_node_tag_service.rs new file mode 100644 index 00000000..5d41b404 --- /dev/null +++ b/src/service/admin/server/query_node_tag_service.rs @@ -0,0 +1,12 @@ +use crate::repository::node::NodeRepo; +use result::code_error::CodeError; +use result::error_code; + +pub async fn query_node_tag(repo: &dyn NodeRepo) -> Result, anyhow::Error> { + repo.query_node_tags() + .await + .map_err(|e| anyhow::Error::new(CodeError::new_err_code_msg( + error_code::DATABASE_QUERY_ERROR, + &e.to_string(), + ))) +} diff --git a/src/service/admin/server/reset_sort_with_node_service.rs b/src/service/admin/server/reset_sort_with_node_service.rs new file mode 100644 index 00000000..376e6c50 --- /dev/null +++ b/src/service/admin/server/reset_sort_with_node_service.rs @@ -0,0 +1,19 @@ +use crate::model::dto::server::ResetSortRequest; +use crate::repository::node::NodeRepo; +use result::code_error::CodeError; +use result::error_code; + +pub async fn reset_sort_with_node( + repo: &dyn NodeRepo, + req: ResetSortRequest, +) -> Result<(), anyhow::Error> { + for item in req.sort { + repo.update_node_sort(item.id, item.sort) + .await + .map_err(|e| anyhow::Error::new(CodeError::new_err_code_msg( + error_code::DATABASE_UPDATE_ERROR, + &e.to_string(), + )))?; + } + Ok(()) +} diff --git a/src/service/admin/server/reset_sort_with_server_service.rs b/src/service/admin/server/reset_sort_with_server_service.rs new file mode 100644 index 00000000..99cfb2a4 --- /dev/null +++ b/src/service/admin/server/reset_sort_with_server_service.rs @@ -0,0 +1,19 @@ +use crate::model::dto::server::ResetSortRequest; +use crate::repository::node::NodeRepo; +use result::code_error::CodeError; +use result::error_code; + +pub async fn reset_sort_with_server( + repo: &dyn NodeRepo, + req: ResetSortRequest, +) -> Result<(), anyhow::Error> { + for item in req.sort { + repo.update_server_sort(item.id, item.sort) + .await + .map_err(|e| anyhow::Error::new(CodeError::new_err_code_msg( + error_code::DATABASE_UPDATE_ERROR, + &e.to_string(), + )))?; + } + Ok(()) +} diff --git a/src/service/admin/server/toggle_node_status_service.rs b/src/service/admin/server/toggle_node_status_service.rs new file mode 100644 index 00000000..82d07ffb --- /dev/null +++ b/src/service/admin/server/toggle_node_status_service.rs @@ -0,0 +1,27 @@ +use crate::repository::node::NodeRepo; +use result::code_error::CodeError; +use result::error_code; + +pub async fn toggle_node_status( + repo: &dyn NodeRepo, + id: i64, +) -> Result { + let mut node = repo + .find_one_node(id) + .await + .map_err(|e| anyhow::Error::new(CodeError::new_err_code_msg( + error_code::DATABASE_QUERY_ERROR, + &e.to_string(), + )))?; + let next = !node.enabled.unwrap_or(false); + node.enabled = Some(next); + node.updated_at = chrono::Utc::now().timestamp_millis(); + let _updated = repo + .update_node(&node) + .await + .map_err(|e| anyhow::Error::new(CodeError::new_err_code_msg( + error_code::DATABASE_UPDATE_ERROR, + &e.to_string(), + )))?; + Ok(next) +} diff --git a/src/service/admin/server/update_node_service.rs b/src/service/admin/server/update_node_service.rs new file mode 100644 index 00000000..8014073b --- /dev/null +++ b/src/service/admin/server/update_node_service.rs @@ -0,0 +1,51 @@ +use chrono::Utc; + +use crate::model::dto::node::UpdateNodeRequest; +use crate::model::entity::node::Node; +use crate::repository::node::NodeRepo; +use result::code_error::CodeError; +use result::error_code; + +pub async fn update_node( + repo: &dyn NodeRepo, + req: UpdateNodeRequest, +) -> Result { + let existing = repo + .find_one_node(req.id) + .await + .map_err(|e| anyhow::Error::new(CodeError::new_err_code_msg( + error_code::DATABASE_QUERY_ERROR, + &e.to_string(), + )))?; + let tags_csv = req + .tags + .clone() + .unwrap_or_else(|| { + existing + .tags + .split(',') + .filter(|s| !s.is_empty()) + .map(|s| s.trim().to_string()) + .collect() + }) + .join(","); + let updated = Node { + id: existing.id, + name: req.name, + tags: tags_csv, + port: req.port as i32, + address: req.address, + server_id: req.server_id, + protocol: req.protocol, + enabled: req.enabled.or(existing.enabled), + sort: existing.sort, + created_at: existing.created_at, + updated_at: Utc::now().timestamp_millis(), + }; + repo.update_node(&updated) + .await + .map_err(|e| anyhow::Error::new(CodeError::new_err_code_msg( + error_code::DATABASE_UPDATE_ERROR, + &e.to_string(), + ))) +} diff --git a/src/service/admin/server/update_server_node_config_service.rs b/src/service/admin/server/update_server_node_config_service.rs new file mode 100644 index 00000000..8fc89c48 --- /dev/null +++ b/src/service/admin/server/update_server_node_config_service.rs @@ -0,0 +1,31 @@ +use chrono::Utc; + +use crate::model::dto::server::GetServerConfigRequest; +use crate::model::entity::node::ServerConfigOverride; +use crate::repository::node::NodeRepo; +use result::code_error::CodeError; +use result::error_code; + +pub async fn update_server_node_config( + repo: &dyn NodeRepo, + req: GetServerConfigRequest, + body: ServerConfigOverride, +) -> Result { + let now = Utc::now().timestamp_millis(); + let entity = ServerConfigOverride { + id: body.id, + server_id: req.common.server_id, + ip_strategy: body.ip_strategy, + dns: body.dns, + block: body.block, + outbound: body.outbound, + created_at: body.created_at, + updated_at: now, + }; + repo.update_override(&entity) + .await + .map_err(|e| anyhow::Error::new(CodeError::new_err_code_msg( + error_code::DATABASE_UPDATE_ERROR, + &e.to_string(), + ))) +} diff --git a/src/service/admin/server/update_server_service.rs b/src/service/admin/server/update_server_service.rs new file mode 100644 index 00000000..fd2ec41b --- /dev/null +++ b/src/service/admin/server/update_server_service.rs @@ -0,0 +1,43 @@ +use chrono::Utc; + +use crate::model::dto::server::UpdateServerRequest; +use crate::model::entity::node::Server; +use crate::repository::node::NodeRepo; +use result::code_error::CodeError; +use result::error_code; + +pub async fn update_server( + repo: &dyn NodeRepo, + req: UpdateServerRequest, +) -> Result { + let existing = repo + .find_one_server(req.id) + .await + .map_err(|e| anyhow::Error::new(CodeError::new_err_code_msg( + error_code::DATABASE_QUERY_ERROR, + &e.to_string(), + )))?; + let protocols = serde_json::to_string(&req.protocols) + .map_err(|e| anyhow::Error::new(CodeError::new_err_code_msg( + error_code::INVALID_PARAMS, + &format!("encode protocols: {e}"), + )))?; + let updated = Server { + id: existing.id, + name: req.name, + country: req.country.unwrap_or(existing.country), + city: req.city.unwrap_or(existing.city), + address: req.address, + sort: req.sort.unwrap_or(existing.sort), + protocols, + last_reported_at: existing.last_reported_at, + created_at: existing.created_at, + updated_at: Utc::now().timestamp_millis(), + }; + repo.update_server(&updated) + .await + .map_err(|e| anyhow::Error::new(CodeError::new_err_code_msg( + error_code::DATABASE_UPDATE_ERROR, + &e.to_string(), + ))) +} diff --git a/src/service/admin/subscribe/batch_delete_subscribe_group_service.rs b/src/service/admin/subscribe/batch_delete_subscribe_group_service.rs new file mode 100644 index 00000000..59ae3b00 --- /dev/null +++ b/src/service/admin/subscribe/batch_delete_subscribe_group_service.rs @@ -0,0 +1,16 @@ +use crate::model::dto::subscribe::BatchDeleteSubscribeGroupRequest; +use crate::repository::subscribe::SubscribeRepo; +use result::code_error::CodeError; +use result::error_code; + +pub async fn batch_delete_subscribe_group( + repo: &dyn SubscribeRepo, + req: BatchDeleteSubscribeGroupRequest, +) -> Result { + repo.batch_delete_group(&req.ids) + .await + .map_err(|e| anyhow::Error::new(CodeError::new_err_code_msg( + error_code::DATABASE_DELETED_ERROR, + &e.to_string(), + ))) +} diff --git a/src/service/admin/subscribe/batch_delete_subscribe_service.rs b/src/service/admin/subscribe/batch_delete_subscribe_service.rs new file mode 100644 index 00000000..82dce7b8 --- /dev/null +++ b/src/service/admin/subscribe/batch_delete_subscribe_service.rs @@ -0,0 +1,24 @@ +use crate::model::dto::subscribe::BatchDeleteSubscribeRequest; +use crate::repository::subscribe::SubscribeRepo; +use result::code_error::CodeError; +use result::error_code; + +pub async fn batch_delete_subscribe( + repo: &dyn SubscribeRepo, + req: BatchDeleteSubscribeRequest, +) -> Result { + // TODO: replace with a dedicated batch delete repo method when added. + // For now, delete one by one and sum the affected rows. + let mut total: u64 = 0; + for id in req.ids { + let affected = repo + .delete(id) + .await + .map_err(|e| anyhow::Error::new(CodeError::new_err_code_msg( + error_code::DATABASE_DELETED_ERROR, + &e.to_string(), + )))?; + total = total.saturating_add(affected); + } + Ok(total) +} diff --git a/src/service/admin/subscribe/create_subscribe_group_service.rs b/src/service/admin/subscribe/create_subscribe_group_service.rs new file mode 100644 index 00000000..ef397359 --- /dev/null +++ b/src/service/admin/subscribe/create_subscribe_group_service.rs @@ -0,0 +1,27 @@ +use chrono::Utc; + +use crate::model::dto::subscribe::CreateSubscribeGroupRequest; +use crate::model::entity::subscribe::Group; +use crate::repository::subscribe::SubscribeRepo; +use result::code_error::CodeError; +use result::error_code; + +pub async fn create_subscribe_group( + repo: &dyn SubscribeRepo, + req: CreateSubscribeGroupRequest, +) -> Result { + let now = Utc::now().timestamp_millis(); + let entity = Group { + id: 0, + name: req.name, + description: req.description, + created_at: now, + updated_at: now, + }; + repo.create_group(&entity) + .await + .map_err(|e| anyhow::Error::new(CodeError::new_err_code_msg( + error_code::DATABASE_INSERT_ERROR, + &e.to_string(), + ))) +} diff --git a/src/service/admin/subscribe/create_subscribe_service.rs b/src/service/admin/subscribe/create_subscribe_service.rs new file mode 100644 index 00000000..d6e4dffa --- /dev/null +++ b/src/service/admin/subscribe/create_subscribe_service.rs @@ -0,0 +1,58 @@ +use chrono::Utc; + +use crate::model::dto::subscribe::CreateSubscribeRequest; +use crate::model::entity::subscribe::Subscribe; +use crate::repository::subscribe::SubscribeRepo; +use result::code_error::CodeError; +use result::error_code; + +pub async fn create_subscribe( + repo: &dyn SubscribeRepo, + req: CreateSubscribeRequest, +) -> Result { + let now = Utc::now().timestamp_millis(); + let discount = serde_json::to_string(&req.discount) + .map_err(|e| anyhow::Error::new(CodeError::new_err_code_msg( + error_code::INVALID_PARAMS, + &format!("encode discount: {e}"), + )))?; + let nodes = serde_json::to_string(&req.nodes) + .map_err(|e| anyhow::Error::new(CodeError::new_err_code_msg( + error_code::INVALID_PARAMS, + &format!("encode nodes: {e}"), + )))?; + let node_tags = req.node_tags.join(","); + let entity = Subscribe { + id: 0, + name: req.name, + language: req.language.unwrap_or_default(), + description: req.description, + unit_price: req.unit_price, + unit_time: req.unit_time, + discount, + replacement: req.replacement, + inventory: req.inventory, + traffic: req.traffic, + speed_limit: req.speed_limit, + device_limit: req.device_limit, + quota: req.quota, + nodes, + node_tags, + show: req.show.unwrap_or(false), + sell: req.sell.unwrap_or(false), + sort: 0, + deduction_ratio: req.deduction_ratio, + allow_deduction: req.allow_deduction.unwrap_or(true), + reset_cycle: req.reset_cycle, + renewal_reset: req.renewal_reset.unwrap_or(false), + show_original_price: req.show_original_price, + created_at: now, + updated_at: now, + }; + repo.insert(&entity) + .await + .map_err(|e| anyhow::Error::new(CodeError::new_err_code_msg( + error_code::DATABASE_INSERT_ERROR, + &e.to_string(), + ))) +} diff --git a/src/service/admin/subscribe/delete_subscribe_group_service.rs b/src/service/admin/subscribe/delete_subscribe_group_service.rs new file mode 100644 index 00000000..1bb1432f --- /dev/null +++ b/src/service/admin/subscribe/delete_subscribe_group_service.rs @@ -0,0 +1,25 @@ +use crate::repository::subscribe::SubscribeRepo; +use result::code_error::CodeError; +use result::error_code; + +pub async fn delete_subscribe_group( + repo: &dyn SubscribeRepo, + id: i64, +) -> Result<(), anyhow::Error> { + let affected = repo + .delete_group(id) + .await + .map_err(|e| anyhow::Error::new(CodeError::new_err_code_msg( + error_code::DATABASE_DELETED_ERROR, + &e.to_string(), + )))?; + + if affected == 0 { + return Err(anyhow::Error::new(CodeError::new_err_code_msg( + error_code::DATABASE_DELETED_ERROR, + "delete subscribe group error: record not found", + ))); + } + + Ok(()) +} diff --git a/src/service/admin/subscribe/delete_subscribe_service.rs b/src/service/admin/subscribe/delete_subscribe_service.rs new file mode 100644 index 00000000..5da85880 --- /dev/null +++ b/src/service/admin/subscribe/delete_subscribe_service.rs @@ -0,0 +1,25 @@ +use crate::repository::subscribe::SubscribeRepo; +use result::code_error::CodeError; +use result::error_code; + +pub async fn delete_subscribe( + repo: &dyn SubscribeRepo, + id: i64, +) -> Result<(), anyhow::Error> { + let affected = repo + .delete(id) + .await + .map_err(|e| anyhow::Error::new(CodeError::new_err_code_msg( + error_code::DATABASE_DELETED_ERROR, + &e.to_string(), + )))?; + + if affected == 0 { + return Err(anyhow::Error::new(CodeError::new_err_code_msg( + error_code::DATABASE_DELETED_ERROR, + "delete subscribe error: record not found", + ))); + } + + Ok(()) +} diff --git a/src/service/admin/subscribe/get_subscribe_details_service.rs b/src/service/admin/subscribe/get_subscribe_details_service.rs new file mode 100644 index 00000000..f9b77dcd --- /dev/null +++ b/src/service/admin/subscribe/get_subscribe_details_service.rs @@ -0,0 +1,64 @@ +use crate::model::dto::subscribe::{GetSubscribeDetailsRequest, Subscribe as SubscribeDto}; +use crate::model::entity::subscribe::Subscribe; +use crate::repository::subscribe::SubscribeRepo; +use result::code_error::CodeError; +use result::error_code; + +pub async fn get_subscribe_details( + repo: &dyn SubscribeRepo, + req: GetSubscribeDetailsRequest, +) -> Result { + let sub = repo + .find_one(req.id) + .await + .map_err(|e| anyhow::Error::new(CodeError::new_err_code_msg( + error_code::DATABASE_QUERY_ERROR, + &e.to_string(), + )))?; + Ok(entity_to_dto(sub)) +} + +/// Public re-export so sibling service modules can reuse the entity→DTO mapping +/// (e.g. `get_subscribe_list_service`). +pub fn entity_to_dto_pub(s: Subscribe) -> SubscribeDto { + entity_to_dto(s) +} + +fn entity_to_dto(s: Subscribe) -> SubscribeDto { + let discount: Vec = + serde_json::from_str(&s.discount).unwrap_or_default(); + let nodes: crate::model::dto::misc::StringInt64Slice = + serde_json::from_str(&s.nodes).unwrap_or_default(); + let node_tags: Vec = if s.node_tags.is_empty() { + Vec::new() + } else { + s.node_tags.split(',').map(|t| t.trim().to_string()).collect() + }; + SubscribeDto { + id: s.id, + name: s.name, + language: Some(s.language), + description: s.description, + unit_price: s.unit_price, + unit_time: s.unit_time, + discount, + replacement: s.replacement, + inventory: s.inventory, + traffic: s.traffic, + speed_limit: s.speed_limit, + device_limit: s.device_limit, + quota: s.quota, + nodes, + node_tags, + show: s.show, + sell: s.sell, + sort: s.sort, + deduction_ratio: s.deduction_ratio, + allow_deduction: s.allow_deduction, + reset_cycle: s.reset_cycle, + renewal_reset: s.renewal_reset, + show_original_price: s.show_original_price, + created_at: s.created_at, + updated_at: s.updated_at, + } +} diff --git a/src/service/admin/subscribe/get_subscribe_group_list_service.rs b/src/service/admin/subscribe/get_subscribe_group_list_service.rs new file mode 100644 index 00000000..76a2f97c --- /dev/null +++ b/src/service/admin/subscribe/get_subscribe_group_list_service.rs @@ -0,0 +1,42 @@ +use crate::model::dto::subscribe::{ + GetSubscribeGroupListResponse, QuerySubscribeGroupListResponse, SubscribeGroup, +}; +use crate::model::entity::subscribe::Group; +use crate::repository::subscribe::SubscribeRepo; +use result::code_error::CodeError; +use result::error_code; + +pub async fn get_subscribe_group_list( + repo: &dyn SubscribeRepo, +) -> Result { + let (total, groups) = repo + .query_group_list() + .await + .map_err(|e| anyhow::Error::new(CodeError::new_err_code_msg( + error_code::DATABASE_QUERY_ERROR, + &e.to_string(), + )))?; + let list = groups.into_iter().map(group_to_dto).collect(); + Ok(GetSubscribeGroupListResponse { list, total }) +} + +/// Backwards-compatible alias used by older callers. +pub async fn query_subscribe_group_list( + repo: &dyn SubscribeRepo, +) -> Result { + let resp = get_subscribe_group_list(repo).await?; + Ok(QuerySubscribeGroupListResponse { + list: resp.list, + total: resp.total, + }) +} + +fn group_to_dto(g: Group) -> SubscribeGroup { + SubscribeGroup { + id: g.id, + name: g.name, + description: g.description.unwrap_or_default(), + created_at: g.created_at, + updated_at: g.updated_at, + } +} diff --git a/src/service/admin/subscribe/get_subscribe_list_service.rs b/src/service/admin/subscribe/get_subscribe_list_service.rs new file mode 100644 index 00000000..0e0d2807 --- /dev/null +++ b/src/service/admin/subscribe/get_subscribe_list_service.rs @@ -0,0 +1,38 @@ +use crate::model::dto::subscribe::{ + GetSubscribeListRequest, GetSubscribeListResponse, SubscribeItem, +}; +use crate::repository::subscribe::{FilterParams, SubscribeRepo}; +use result::code_error::CodeError; +use result::error_code; + +pub async fn get_subscribe_list( + repo: &dyn SubscribeRepo, + req: GetSubscribeListRequest, +) -> Result { + let mut params = FilterParams { + page: req.page.max(1), + size: req.size.max(1), + search: req.search.clone(), + language: req.language.clone(), + ..Default::default() + }; + params.normalize(); + let (total, list) = repo + .filter_list(&mut params) + .await + .map_err(|e| anyhow::Error::new(CodeError::new_err_code_msg( + error_code::DATABASE_QUERY_ERROR, + &e.to_string(), + )))?; + let items: Vec = list + .into_iter() + .map(|s| SubscribeItem { + subscribe: crate::service::admin::subscribe::get_subscribe_details_service::entity_to_dto_pub(s), + sold: 0, // TODO: aggregate sold count from order repo + }) + .collect(); + Ok(GetSubscribeListResponse { + list: items, + total, + }) +} diff --git a/src/service/admin/subscribe/mod.rs b/src/service/admin/subscribe/mod.rs new file mode 100644 index 00000000..00221be5 --- /dev/null +++ b/src/service/admin/subscribe/mod.rs @@ -0,0 +1,13 @@ +pub mod batch_delete_subscribe_group_service; +pub mod batch_delete_subscribe_service; +pub mod create_subscribe_group_service; +pub mod create_subscribe_service; +pub mod delete_subscribe_group_service; +pub mod delete_subscribe_service; +pub mod get_subscribe_details_service; +pub mod get_subscribe_group_list_service; +pub mod get_subscribe_list_service; +pub mod reset_all_subscribe_token_service; +pub mod subscribe_sort_service; +pub mod update_subscribe_group_service; +pub mod update_subscribe_service; diff --git a/src/service/admin/subscribe/reset_all_subscribe_token_service.rs b/src/service/admin/subscribe/reset_all_subscribe_token_service.rs new file mode 100644 index 00000000..98eda78b --- /dev/null +++ b/src/service/admin/subscribe/reset_all_subscribe_token_service.rs @@ -0,0 +1,19 @@ +use crate::model::dto::subscribe::ResetAllSubscribeTokenResponse; +use crate::repository::subscribe::SubscribeRepo; +use result::code_error::CodeError; +use result::error_code; + +pub async fn reset_all_subscribe_token( + repo: &dyn SubscribeRepo, +) -> Result { + // TODO: query every user-subscribe row, regenerate a fresh token for each, + // and persist. For now we acknowledge the request as accepted. + let _ = repo + .query_group_list() + .await + .map_err(|e| anyhow::Error::new(CodeError::new_err_code_msg( + error_code::DATABASE_QUERY_ERROR, + &e.to_string(), + )))?; + Ok(ResetAllSubscribeTokenResponse { success: true }) +} diff --git a/src/service/admin/subscribe/subscribe_sort_service.rs b/src/service/admin/subscribe/subscribe_sort_service.rs new file mode 100644 index 00000000..f38ab4c0 --- /dev/null +++ b/src/service/admin/subscribe/subscribe_sort_service.rs @@ -0,0 +1,58 @@ +use crate::model::dto::subscribe::SubscribeSortRequest; +use crate::model::entity::subscribe::Subscribe; +use crate::repository::subscribe::SubscribeRepo; +use result::code_error::CodeError; +use result::error_code; + +pub async fn subscribe_sort( + repo: &dyn SubscribeRepo, + req: SubscribeSortRequest, +) -> Result<(), anyhow::Error> { + let now = chrono::Utc::now().timestamp_millis(); + let items: Vec = req + .sort + .into_iter() + .map(|s| Subscribe { + id: s.id, + sort: s.sort, + updated_at: now, + ..placeholder_subscribe() + }) + .collect(); + repo.update_sort(&items) + .await + .map_err(|e| anyhow::Error::new(CodeError::new_err_code_msg( + error_code::DATABASE_UPDATE_ERROR, + &e.to_string(), + ))) +} + +fn placeholder_subscribe() -> Subscribe { + Subscribe { + id: 0, + name: String::new(), + language: String::new(), + description: None, + unit_price: 0, + unit_time: String::new(), + discount: "[]".to_string(), + replacement: 0, + inventory: 0, + traffic: 0, + speed_limit: 0, + device_limit: 0, + quota: 0, + nodes: "[]".to_string(), + node_tags: String::new(), + show: false, + sell: false, + sort: 0, + deduction_ratio: 0, + allow_deduction: true, + reset_cycle: 0, + renewal_reset: false, + show_original_price: false, + created_at: 0, + updated_at: 0, + } +} diff --git a/src/service/admin/subscribe/update_subscribe_group_service.rs b/src/service/admin/subscribe/update_subscribe_group_service.rs new file mode 100644 index 00000000..043a48d6 --- /dev/null +++ b/src/service/admin/subscribe/update_subscribe_group_service.rs @@ -0,0 +1,42 @@ +use chrono::Utc; + +use crate::model::dto::subscribe::UpdateSubscribeGroupRequest; +use crate::model::entity::subscribe::Group; +use crate::repository::subscribe::SubscribeRepo; +use result::code_error::CodeError; +use result::error_code; + +pub async fn update_subscribe_group( + repo: &dyn SubscribeRepo, + req: UpdateSubscribeGroupRequest, +) -> Result { + let existing = repo + .query_group_list() + .await + .map_err(|e| anyhow::Error::new(CodeError::new_err_code_msg( + error_code::DATABASE_QUERY_ERROR, + &e.to_string(), + )))? + .1 + .into_iter() + .find(|g| g.id == req.id) + .ok_or_else(|| { + anyhow::Error::new(CodeError::new_err_code_msg( + error_code::ERROR, + "subscribe group not found", + )) + })?; + let updated = Group { + id: existing.id, + name: req.name, + description: req.description.or(existing.description), + created_at: existing.created_at, + updated_at: Utc::now().timestamp_millis(), + }; + repo.update_group(&updated) + .await + .map_err(|e| anyhow::Error::new(CodeError::new_err_code_msg( + error_code::DATABASE_UPDATE_ERROR, + &e.to_string(), + ))) +} diff --git a/src/service/admin/subscribe/update_subscribe_service.rs b/src/service/admin/subscribe/update_subscribe_service.rs new file mode 100644 index 00000000..a65b2c2c --- /dev/null +++ b/src/service/admin/subscribe/update_subscribe_service.rs @@ -0,0 +1,64 @@ +use chrono::Utc; + +use crate::model::dto::subscribe::UpdateSubscribeRequest; +use crate::model::entity::subscribe::Subscribe; +use crate::repository::subscribe::SubscribeRepo; +use result::code_error::CodeError; +use result::error_code; + +pub async fn update_subscribe( + repo: &dyn SubscribeRepo, + req: UpdateSubscribeRequest, +) -> Result { + let existing = repo + .find_one(req.id) + .await + .map_err(|e| anyhow::Error::new(CodeError::new_err_code_msg( + error_code::DATABASE_QUERY_ERROR, + &e.to_string(), + )))?; + let discount = serde_json::to_string(&req.discount) + .map_err(|e| anyhow::Error::new(CodeError::new_err_code_msg( + error_code::INVALID_PARAMS, + &format!("encode discount: {e}"), + )))?; + let nodes = serde_json::to_string(&req.nodes) + .map_err(|e| anyhow::Error::new(CodeError::new_err_code_msg( + error_code::INVALID_PARAMS, + &format!("encode nodes: {e}"), + )))?; + let node_tags = req.node_tags.join(","); + let updated = Subscribe { + id: existing.id, + name: req.name, + language: req.language.unwrap_or(existing.language), + description: req.description.or(existing.description), + unit_price: req.unit_price, + unit_time: req.unit_time, + discount, + replacement: req.replacement, + inventory: req.inventory, + traffic: req.traffic, + speed_limit: req.speed_limit, + device_limit: req.device_limit, + quota: req.quota, + nodes, + node_tags, + show: req.show.unwrap_or(existing.show), + sell: req.sell.unwrap_or(existing.sell), + sort: req.sort, + deduction_ratio: req.deduction_ratio, + allow_deduction: req.allow_deduction.unwrap_or(existing.allow_deduction), + reset_cycle: req.reset_cycle, + renewal_reset: req.renewal_reset.unwrap_or(existing.renewal_reset), + show_original_price: req.show_original_price, + created_at: existing.created_at, + updated_at: Utc::now().timestamp_millis(), + }; + repo.update(&updated) + .await + .map_err(|e| anyhow::Error::new(CodeError::new_err_code_msg( + error_code::DATABASE_UPDATE_ERROR, + &e.to_string(), + ))) +} diff --git a/src/service/admin/system/get_currency_config_service.rs b/src/service/admin/system/get_currency_config_service.rs new file mode 100644 index 00000000..148ba87b --- /dev/null +++ b/src/service/admin/system/get_currency_config_service.rs @@ -0,0 +1,35 @@ +use result::code_error::CodeError; +use result::error_code; + +use crate::model::dto::CurrencyConfig; +use crate::repository::Repositories; + +/// Read currency configuration from the `system` table (category = "currency"). +pub async fn get_currency_config( + repos: &Repositories, +) -> Result { + let rows = repos + .system + .get_currency_config() + .await + .map_err(|e| { + anyhow::Error::new(CodeError::new_err_code_msg( + error_code::DATABASE_QUERY_ERROR, + e.to_string(), + )) + })?; + let mut resp = CurrencyConfig { + access_key: String::new(), + currency_unit: String::new(), + currency_symbol: String::new(), + }; + for row in rows { + match row.key.as_str() { + "access_key" => resp.access_key = row.value, + "currency_unit" => resp.currency_unit = row.value, + "currency_symbol" => resp.currency_symbol = row.value, + _ => {} + } + } + Ok(resp) +} diff --git a/src/service/admin/system/get_invite_config_service.rs b/src/service/admin/system/get_invite_config_service.rs new file mode 100644 index 00000000..53845acf --- /dev/null +++ b/src/service/admin/system/get_invite_config_service.rs @@ -0,0 +1,39 @@ +use result::code_error::CodeError; +use result::error_code; + +use crate::model::dto::InviteConfig; +use crate::repository::Repositories; + +/// Read invite configuration from the `system` table (category = "invite"). +pub async fn get_invite_config( + repos: &Repositories, +) -> Result { + let rows = repos + .system + .get_invite_config() + .await + .map_err(|e| { + anyhow::Error::new(CodeError::new_err_code_msg( + error_code::DATABASE_QUERY_ERROR, + e.to_string(), + )) + })?; + let mut resp = InviteConfig { + forced_invite: false, + referral_percentage: 0, + only_first_purchase: false, + }; + for row in rows { + match row.key.as_str() { + "forced_invite" => resp.forced_invite = row.value == "true", + "referral_percentage" => { + if let Ok(v) = row.value.parse() { + resp.referral_percentage = v; + } + } + "only_first_purchase" => resp.only_first_purchase = row.value == "true", + _ => {} + } + } + Ok(resp) +} diff --git a/src/service/admin/system/get_module_config_service.rs b/src/service/admin/system/get_module_config_service.rs new file mode 100644 index 00000000..e141f42e --- /dev/null +++ b/src/service/admin/system/get_module_config_service.rs @@ -0,0 +1,16 @@ +use std::env; + +use crate::model::dto::ModuleConfig; + +/// Build the module/service identity response from the runtime environment. +/// +/// Mirrors Go `getModuleConfigLogic` — reads `SECRET_KEY` env var and uses +/// hard-coded service name + the crate version. +pub async fn get_module_config() -> Result { + let secret = env::var("SECRET_KEY").unwrap_or_default(); + Ok(ModuleConfig { + secret, + service_name: "PPanel".to_string(), + service_version: env!("CARGO_PKG_VERSION").to_string(), + }) +} diff --git a/src/service/admin/system/get_node_config_service.rs b/src/service/admin/system/get_node_config_service.rs new file mode 100644 index 00000000..d3abde5d --- /dev/null +++ b/src/service/admin/system/get_node_config_service.rs @@ -0,0 +1,74 @@ +use result::code_error::CodeError; +use result::error_code; + +use crate::model::dto::{NodeConfig, NodeDNS, NodeOutbound}; +use crate::model::entity::system::System; +use crate::repository::Repositories; + +/// Read node/server configuration from the `system` table (category = "server"). +pub async fn get_node_config( + repos: &Repositories, +) -> Result { + let rows = repos + .system + .get_node_config() + .await + .map_err(|e| { + anyhow::Error::new(CodeError::new_err_code_msg( + error_code::DATABASE_QUERY_ERROR, + e.to_string(), + )) + })?; + let mut resp = NodeConfig { + node_secret: String::new(), + node_pull_interval: 60, + node_push_interval: 60, + traffic_report_threshold: 0, + ip_strategy: String::new(), + dns: Vec::new(), + block: Vec::new(), + outbound: Vec::new(), + }; + for row in &rows { + apply_node_row(&mut resp, row); + } + Ok(resp) +} + +fn apply_node_row(c: &mut NodeConfig, row: &System) { + match row.key.as_str() { + "node_secret" => c.node_secret = row.value.clone(), + "node_pull_interval" => { + if let Ok(v) = row.value.parse() { + c.node_pull_interval = v; + } + } + "node_push_interval" => { + if let Ok(v) = row.value.parse() { + c.node_push_interval = v; + } + } + "traffic_report_threshold" => { + if let Ok(v) = row.value.parse() { + c.traffic_report_threshold = v; + } + } + "ip_strategy" => c.ip_strategy = row.value.clone(), + "dns" => { + if let Ok(v) = serde_json::from_str::>(&row.value) { + c.dns = v; + } + } + "block" => { + if let Ok(v) = serde_json::from_str::>(&row.value) { + c.block = v; + } + } + "outbound" => { + if let Ok(v) = serde_json::from_str::>(&row.value) { + c.outbound = v; + } + } + _ => {} + } +} diff --git a/src/service/admin/system/get_node_multiplier_service.rs b/src/service/admin/system/get_node_multiplier_service.rs new file mode 100644 index 00000000..d3762e49 --- /dev/null +++ b/src/service/admin/system/get_node_multiplier_service.rs @@ -0,0 +1,36 @@ +use result::code_error::CodeError; +use result::error_code; +use serde_json; + +use crate::model::dto::{GetNodeMultiplierResponse, TimePeriod}; +use crate::repository::Repositories; + +/// Read the node-multiplier schedule from the `system` table. +/// +/// Mirrors Go `getNodeMultiplierLogic` — stored as a JSON array of +/// `TimePeriod` under `(server, NodeMultiplierConfig)`. +pub async fn get_node_multiplier( + repos: &Repositories, +) -> Result { + let row = repos + .system + .find_node_multiplier_config() + .await + .map_err(|e| { + anyhow::Error::new(CodeError::new_err_code_msg( + error_code::DATABASE_QUERY_ERROR, + e.to_string(), + )) + })?; + let periods: Vec = if row.value.is_empty() { + Vec::new() + } else { + serde_json::from_str(&row.value).map_err(|e| { + anyhow::Error::new(CodeError::new_err_code_msg( + error_code::DATABASE_UPDATE_ERROR, + e.to_string(), + )) + })? + }; + Ok(GetNodeMultiplierResponse { periods }) +} diff --git a/src/service/admin/system/get_privacy_policy_config_service.rs b/src/service/admin/system/get_privacy_policy_config_service.rs new file mode 100644 index 00000000..40cc9a22 --- /dev/null +++ b/src/service/admin/system/get_privacy_policy_config_service.rs @@ -0,0 +1,32 @@ +use result::code_error::CodeError; +use result::error_code; + +use crate::model::dto::PrivacyPolicyConfig; +use crate::repository::Repositories; + +/// Read privacy-policy configuration. +/// +/// Stored in the same `tos` category as Terms of Service (matches Go). +pub async fn get_privacy_policy_config( + repos: &Repositories, +) -> Result { + let rows = repos + .system + .get_tos_config() + .await + .map_err(|e| { + anyhow::Error::new(CodeError::new_err_code_msg( + error_code::DATABASE_QUERY_ERROR, + e.to_string(), + )) + })?; + let mut resp = PrivacyPolicyConfig { + privacy_policy: String::new(), + }; + for row in rows { + if row.key == "privacy_policy" { + resp.privacy_policy = row.value; + } + } + Ok(resp) +} diff --git a/src/service/admin/system/get_register_config_service.rs b/src/service/admin/system/get_register_config_service.rs new file mode 100644 index 00000000..0b98fe58 --- /dev/null +++ b/src/service/admin/system/get_register_config_service.rs @@ -0,0 +1,63 @@ +use result::code_error::CodeError; +use result::error_code; + +use crate::model::dto::RegisterConfig; +use crate::repository::Repositories; + +/// Read registration configuration from the `system` table (category = "register"). +pub async fn get_register_config( + repos: &Repositories, +) -> Result { + let rows = repos + .system + .get_register_config() + .await + .map_err(|e| { + anyhow::Error::new(CodeError::new_err_code_msg( + error_code::DATABASE_QUERY_ERROR, + e.to_string(), + )) + })?; + let mut resp = RegisterConfig { + stop_register: false, + enable_trial: false, + trial_subscribe: 0, + trial_time: 0, + trial_time_unit: String::new(), + enable_ip_register_limit: false, + ip_register_limit: 0, + ip_register_limit_duration: 0, + }; + for row in rows { + match row.key.as_str() { + "stop_register" => resp.stop_register = row.value == "true", + "enable_trial" => resp.enable_trial = row.value == "true", + "trial_subscribe" => { + if let Ok(v) = row.value.parse() { + resp.trial_subscribe = v; + } + } + "trial_time" => { + if let Ok(v) = row.value.parse() { + resp.trial_time = v; + } + } + "trial_time_unit" => resp.trial_time_unit = row.value, + "enable_ip_register_limit" => { + resp.enable_ip_register_limit = row.value == "true" + } + "ip_register_limit" => { + if let Ok(v) = row.value.parse() { + resp.ip_register_limit = v; + } + } + "ip_register_limit_duration" => { + if let Ok(v) = row.value.parse() { + resp.ip_register_limit_duration = v; + } + } + _ => {} + } + } + Ok(resp) +} diff --git a/src/service/admin/system/get_site_config_service.rs b/src/service/admin/system/get_site_config_service.rs new file mode 100644 index 00000000..9869b290 --- /dev/null +++ b/src/service/admin/system/get_site_config_service.rs @@ -0,0 +1,43 @@ +use result::code_error::CodeError; +use result::error_code; + +use crate::model::dto::SiteConfig; +use crate::repository::Repositories; + +/// Read site configuration from the `system` table (category = "site"). +pub async fn get_site_config( + repos: &Repositories, +) -> Result { + let rows = repos + .system + .get_site_config() + .await + .map_err(|e| { + anyhow::Error::new(CodeError::new_err_code_msg( + error_code::DATABASE_QUERY_ERROR, + e.to_string(), + )) + })?; + let mut resp = SiteConfig { + host: String::new(), + site_name: String::new(), + site_desc: String::new(), + site_logo: String::new(), + keywords: String::new(), + custom_html: String::new(), + custom_data: String::new(), + }; + for row in rows { + match row.key.as_str() { + "host" => resp.host = row.value, + "site_name" => resp.site_name = row.value, + "site_desc" => resp.site_desc = row.value, + "site_logo" => resp.site_logo = row.value, + "keywords" => resp.keywords = row.value, + "custom_html" => resp.custom_html = row.value, + "custom_data" => resp.custom_data = row.value, + _ => {} + } + } + Ok(resp) +} diff --git a/src/service/admin/system/get_subscribe_config_service.rs b/src/service/admin/system/get_subscribe_config_service.rs new file mode 100644 index 00000000..eb4e1988 --- /dev/null +++ b/src/service/admin/system/get_subscribe_config_service.rs @@ -0,0 +1,43 @@ +use result::code_error::CodeError; +use result::error_code; + +use crate::model::dto::SubscribeConfig; +use crate::repository::Repositories; + +/// Read subscribe configuration from the `system` table (category = "subscribe"). +pub async fn get_subscribe_config( + repos: &Repositories, +) -> Result { + let rows = repos + .system + .get_subscribe_config() + .await + .map_err(|e| { + anyhow::Error::new(CodeError::new_err_code_msg( + error_code::DATABASE_QUERY_ERROR, + e.to_string(), + )) + })?; + let mut resp = SubscribeConfig { + single_model: false, + subscribe_path: String::new(), + subscribe_domain: String::new(), + pan_domain: false, + user_agent_limit: false, + user_agent_list: String::new(), + show_tutorial: true, + }; + for row in rows { + match row.key.as_str() { + "single_model" => resp.single_model = row.value == "true", + "subscribe_path" => resp.subscribe_path = row.value, + "subscribe_domain" => resp.subscribe_domain = row.value, + "pan_domain" => resp.pan_domain = row.value == "true", + "user_agent_limit" => resp.user_agent_limit = row.value == "true", + "user_agent_list" => resp.user_agent_list = row.value, + "show_tutorial" => resp.show_tutorial = row.value == "true", + _ => {} + } + } + Ok(resp) +} diff --git a/src/service/admin/system/get_tos_config_service.rs b/src/service/admin/system/get_tos_config_service.rs new file mode 100644 index 00000000..fa39f150 --- /dev/null +++ b/src/service/admin/system/get_tos_config_service.rs @@ -0,0 +1,30 @@ +use result::code_error::CodeError; +use result::error_code; + +use crate::model::dto::TosConfig; +use crate::repository::Repositories; + +/// Read Terms-of-Service configuration from the `system` table (category = "tos"). +pub async fn get_tos_config( + repos: &Repositories, +) -> Result { + let rows = repos + .system + .get_tos_config() + .await + .map_err(|e| { + anyhow::Error::new(CodeError::new_err_code_msg( + error_code::DATABASE_QUERY_ERROR, + e.to_string(), + )) + })?; + let mut resp = TosConfig { + tos_content: String::new(), + }; + for row in rows { + if row.key == "tos_content" { + resp.tos_content = row.value; + } + } + Ok(resp) +} diff --git a/src/service/admin/system/get_verify_code_config_service.rs b/src/service/admin/system/get_verify_code_config_service.rs new file mode 100644 index 00000000..3b435679 --- /dev/null +++ b/src/service/admin/system/get_verify_code_config_service.rs @@ -0,0 +1,47 @@ +use result::code_error::CodeError; +use result::error_code; + +use crate::model::dto::VerifyCodeConfig; +use crate::repository::Repositories; + +/// Read verify-code configuration from the `system` table (category = "verify_code"). +pub async fn get_verify_code_config( + repos: &Repositories, +) -> Result { + let rows = repos + .system + .get_verify_code_config() + .await + .map_err(|e| { + anyhow::Error::new(CodeError::new_err_code_msg( + error_code::DATABASE_QUERY_ERROR, + e.to_string(), + )) + })?; + let mut resp = VerifyCodeConfig { + verify_code_expire_time: 300, + verify_code_limit: 15, + verify_code_interval: 60, + }; + for row in rows { + match row.key.as_str() { + "verify_code_expire_time" | "expire_time" => { + if let Ok(v) = row.value.parse() { + resp.verify_code_expire_time = v; + } + } + "verify_code_limit" | "limit" => { + if let Ok(v) = row.value.parse() { + resp.verify_code_limit = v; + } + } + "verify_code_interval" | "interval" => { + if let Ok(v) = row.value.parse() { + resp.verify_code_interval = v; + } + } + _ => {} + } + } + Ok(resp) +} diff --git a/src/service/admin/system/get_verify_config_service.rs b/src/service/admin/system/get_verify_config_service.rs new file mode 100644 index 00000000..1d3da715 --- /dev/null +++ b/src/service/admin/system/get_verify_config_service.rs @@ -0,0 +1,45 @@ +use result::code_error::CodeError; +use result::error_code; + +use crate::model::dto::VerifyConfig; +use crate::repository::Repositories; + +/// Read verify (Turnstile) configuration from the `system` table (category = "verify"). +pub async fn get_verify_config( + repos: &Repositories, +) -> Result { + let rows = repos + .system + .get_verify_config() + .await + .map_err(|e| { + anyhow::Error::new(CodeError::new_err_code_msg( + error_code::DATABASE_QUERY_ERROR, + e.to_string(), + )) + })?; + let mut resp = VerifyConfig { + turnstile_site_key: String::new(), + turnstile_secret: String::new(), + enable_login_verify: false, + enable_register_verify: false, + enable_reset_password_verify: false, + }; + for row in rows { + match row.key.as_str() { + "turnstile_site_key" => resp.turnstile_site_key = row.value, + "turnstile_secret" => resp.turnstile_secret = row.value, + "enable_login_verify" | "login_verify" => { + resp.enable_login_verify = row.value == "true" + } + "enable_register_verify" | "register_verify" => { + resp.enable_register_verify = row.value == "true" + } + "enable_reset_password_verify" | "reset_password_verify" => { + resp.enable_reset_password_verify = row.value == "true" + } + _ => {} + } + } + Ok(resp) +} diff --git a/src/service/admin/system/mod.rs b/src/service/admin/system/mod.rs new file mode 100644 index 00000000..702f2805 --- /dev/null +++ b/src/service/admin/system/mod.rs @@ -0,0 +1,26 @@ +pub mod get_currency_config_service; +pub mod get_invite_config_service; +pub mod get_module_config_service; +pub mod get_node_config_service; +pub mod get_node_multiplier_service; +pub mod get_privacy_policy_config_service; +pub mod get_register_config_service; +pub mod get_site_config_service; +pub mod get_subscribe_config_service; +pub mod get_tos_config_service; +pub mod get_verify_code_config_service; +pub mod get_verify_config_service; +pub mod pre_view_node_multiplier_service; +pub mod set_node_multiplier_service; +pub mod setting_telegram_bot_service; +pub mod update_config; +pub mod update_currency_config_service; +pub mod update_invite_config_service; +pub mod update_node_config_service; +pub mod update_privacy_policy_config_service; +pub mod update_register_config_service; +pub mod update_site_config_service; +pub mod update_subscribe_config_service; +pub mod update_tos_config_service; +pub mod update_verify_code_config_service; +pub mod update_verify_config_service; diff --git a/src/service/admin/system/pre_view_node_multiplier_service.rs b/src/service/admin/system/pre_view_node_multiplier_service.rs new file mode 100644 index 00000000..b18e8d7f --- /dev/null +++ b/src/service/admin/system/pre_view_node_multiplier_service.rs @@ -0,0 +1,16 @@ +use crate::model::dto::PreViewNodeMultiplierResponse; + +/// Return a preview of the current node-multiplier ratio. +/// +/// The Go reference uses a `NodeMultiplierManager` scheduler; in this Rust +/// rewrite we don't yet have that scheduler, so this returns a constant 1.0 +/// ratio. Wiring the live multiplier is tracked separately. +pub async fn pre_view_node_multiplier( + _repos: &crate::repository::Repositories, +) -> Result { + let now = chrono::Utc::now(); + Ok(PreViewNodeMultiplierResponse { + current_time: now.format("%Y-%m-%d %H:%M:%S").to_string(), + ratio: 1.0, + }) +} diff --git a/src/service/admin/system/set_node_multiplier_service.rs b/src/service/admin/system/set_node_multiplier_service.rs new file mode 100644 index 00000000..94d8fdad --- /dev/null +++ b/src/service/admin/system/set_node_multiplier_service.rs @@ -0,0 +1,32 @@ +use result::code_error::CodeError; +use result::error_code; +use serde_json; + +use crate::model::dto::SetNodeMultiplierRequest; +use crate::repository::Repositories; + +/// Persist a new node-multiplier schedule. +/// +/// Stored as a JSON-encoded `Vec` under `(server, NodeMultiplierConfig)`. +pub async fn set_node_multiplier( + repos: &Repositories, + req: SetNodeMultiplierRequest, +) -> Result<(), anyhow::Error> { + let payload = serde_json::to_string(&req.periods).map_err(|e| { + anyhow::Error::new(CodeError::new_err_code_msg( + error_code::DATABASE_UPDATE_ERROR, + e.to_string(), + )) + })?; + repos + .system + .update_node_multiplier_config(&payload) + .await + .map_err(|e| { + anyhow::Error::new(CodeError::new_err_code_msg( + error_code::DATABASE_UPDATE_ERROR, + e.to_string(), + )) + })?; + Ok(()) +} diff --git a/src/service/admin/system/setting_telegram_bot_service.rs b/src/service/admin/system/setting_telegram_bot_service.rs new file mode 100644 index 00000000..c75cc511 --- /dev/null +++ b/src/service/admin/system/setting_telegram_bot_service.rs @@ -0,0 +1,45 @@ +use crate::config::Config; + +pub async fn setting_telegram_bot(config: &Config) -> Result<(), anyhow::Error> { + let token = &config.telegram.bot_token; + + if token.is_empty() { + tracing::info!("[setting_telegram_bot] bot_token not configured, skipping"); + return Ok(()); + } + + let webhook_domain = &config.telegram.web_hook_domain; + + if webhook_domain.is_empty() { + tracing::info!("[setting_telegram_bot] no webhook domain configured, skipping setWebhook"); + return Ok(()); + } + + let secret = format!("{:x}", md5::compute(token.as_bytes())); + let webhook_url = format!( + "{}/v1/telegram/webhook?secret={}", + webhook_domain.trim_end_matches('/'), + secret + ); + + let client = reqwest::Client::new(); + let api_url = format!("https://api.telegram.org/bot{}/setWebhook", token); + + let resp = client + .post(&api_url) + .json(&serde_json::json!({ "url": webhook_url })) + .send() + .await + .map_err(|e| anyhow::anyhow!("setWebhook request failed: {e}"))?; + + if !resp.status().is_success() { + let status = resp.status(); + let body = resp.text().await.unwrap_or_default(); + return Err(anyhow::anyhow!( + "setWebhook returned {status}: {body}" + )); + } + + tracing::info!("[setting_telegram_bot] webhook registered: {webhook_url}"); + Ok(()) +} diff --git a/src/service/admin/system/update_config.rs b/src/service/admin/system/update_config.rs new file mode 100644 index 00000000..ace44b5c --- /dev/null +++ b/src/service/admin/system/update_config.rs @@ -0,0 +1,47 @@ +//! Shared helper for persisting a single `system` table row by `(category, key)`. + +use result::code_error::CodeError; +use result::error_code; + +use crate::repository::Repositories; + +/// Update the `value` field of `system` row identified by `(category, key)`. +/// +/// Mirrors the Go `updateConfigFields` helper — one row per call, no +/// transaction. Callers wrap multiple writes in their own transaction if +/// atomicity across rows is required. +pub async fn persist_config( + repos: &Repositories, + category: &str, + key: &str, + value: &str, +) -> Result<(), anyhow::Error> { + repos + .system + .update_value_by_category_key(category, key, value) + .await + .map_err(|e| { + anyhow::Error::new(CodeError::new_err_code_msg( + error_code::DATABASE_UPDATE_ERROR, + e.to_string(), + )) + })?; + Ok(()) +} + +/// Read every row in a category as raw `System` entities. +pub async fn fetch_category( + repos: &Repositories, + category: &str, +) -> Result, anyhow::Error> { + repos + .system + .get_by_category(category) + .await + .map_err(|e| { + anyhow::Error::new(CodeError::new_err_code_msg( + error_code::DATABASE_QUERY_ERROR, + e.to_string(), + )) + }) +} diff --git a/src/service/admin/system/update_currency_config_service.rs b/src/service/admin/system/update_currency_config_service.rs new file mode 100644 index 00000000..ddcec75d --- /dev/null +++ b/src/service/admin/system/update_currency_config_service.rs @@ -0,0 +1,16 @@ +use crate::model::dto::CurrencyConfig; +use crate::repository::Repositories; +use crate::service::admin::system::update_config::persist_config; + +const CATEGORY: &str = "currency"; + +/// Persist currency configuration to the `system` table. +pub async fn update_currency_config( + repos: &Repositories, + req: CurrencyConfig, +) -> Result<(), anyhow::Error> { + persist_config(repos, CATEGORY, "access_key", &req.access_key).await?; + persist_config(repos, CATEGORY, "currency_unit", &req.currency_unit).await?; + persist_config(repos, CATEGORY, "currency_symbol", &req.currency_symbol).await?; + Ok(()) +} diff --git a/src/service/admin/system/update_invite_config_service.rs b/src/service/admin/system/update_invite_config_service.rs new file mode 100644 index 00000000..594d02ec --- /dev/null +++ b/src/service/admin/system/update_invite_config_service.rs @@ -0,0 +1,28 @@ +use crate::model::dto::InviteConfig; +use crate::repository::Repositories; +use crate::service::admin::system::update_config::persist_config; + +const CATEGORY: &str = "invite"; + +/// Persist invite configuration to the `system` table. +pub async fn update_invite_config( + repos: &Repositories, + req: InviteConfig, +) -> Result<(), anyhow::Error> { + persist_config(repos, CATEGORY, "forced_invite", &req.forced_invite.to_string()).await?; + persist_config( + repos, + CATEGORY, + "referral_percentage", + &req.referral_percentage.to_string(), + ) + .await?; + persist_config( + repos, + CATEGORY, + "only_first_purchase", + &req.only_first_purchase.to_string(), + ) + .await?; + Ok(()) +} diff --git a/src/service/admin/system/update_node_config_service.rs b/src/service/admin/system/update_node_config_service.rs new file mode 100644 index 00000000..15d5757b --- /dev/null +++ b/src/service/admin/system/update_node_config_service.rs @@ -0,0 +1,63 @@ +use serde_json; + +use crate::model::dto::NodeConfig; +use crate::repository::Repositories; +use crate::service::admin::system::update_config::persist_config; + +const CATEGORY: &str = "server"; + +/// Persist node/server configuration to the `system` table. +/// +/// `dns`, `block`, and `outbound` are serialised to JSON for storage, matching +/// the Go reference which uses `tool.ConvertValueToString` for slice/struct +/// fields. +pub async fn update_node_config( + repos: &Repositories, + req: NodeConfig, +) -> Result<(), anyhow::Error> { + persist_config(repos, CATEGORY, "node_secret", &req.node_secret).await?; + persist_config( + repos, + CATEGORY, + "node_pull_interval", + &req.node_pull_interval.to_string(), + ) + .await?; + persist_config( + repos, + CATEGORY, + "node_push_interval", + &req.node_push_interval.to_string(), + ) + .await?; + persist_config( + repos, + CATEGORY, + "traffic_report_threshold", + &req.traffic_report_threshold.to_string(), + ) + .await?; + persist_config(repos, CATEGORY, "ip_strategy", &req.ip_strategy).await?; + persist_config( + repos, + CATEGORY, + "dns", + &serde_json::to_string(&req.dns).unwrap_or_else(|_| "[]".to_string()), + ) + .await?; + persist_config( + repos, + CATEGORY, + "block", + &serde_json::to_string(&req.block).unwrap_or_else(|_| "[]".to_string()), + ) + .await?; + persist_config( + repos, + CATEGORY, + "outbound", + &serde_json::to_string(&req.outbound).unwrap_or_else(|_| "[]".to_string()), + ) + .await?; + Ok(()) +} diff --git a/src/service/admin/system/update_privacy_policy_config_service.rs b/src/service/admin/system/update_privacy_policy_config_service.rs new file mode 100644 index 00000000..2e43ef55 --- /dev/null +++ b/src/service/admin/system/update_privacy_policy_config_service.rs @@ -0,0 +1,15 @@ +use crate::model::dto::PrivacyPolicyConfig; +use crate::repository::Repositories; +use crate::service::admin::system::update_config::persist_config; + +const CATEGORY: &str = "tos"; + +/// Persist privacy-policy configuration. Stored alongside the TOS entry +/// (matches the Go `updatePrivacyPolicyConfigLogic` behaviour). +pub async fn update_privacy_policy_config( + repos: &Repositories, + req: PrivacyPolicyConfig, +) -> Result<(), anyhow::Error> { + persist_config(repos, CATEGORY, "privacy_policy", &req.privacy_policy).await?; + Ok(()) +} diff --git a/src/service/admin/system/update_register_config_service.rs b/src/service/admin/system/update_register_config_service.rs new file mode 100644 index 00000000..6ca16a9d --- /dev/null +++ b/src/service/admin/system/update_register_config_service.rs @@ -0,0 +1,45 @@ +use crate::model::dto::RegisterConfig; +use crate::repository::Repositories; +use crate::service::admin::system::update_config::persist_config; + +const CATEGORY: &str = "register"; + +/// Persist registration configuration to the `system` table. +pub async fn update_register_config( + repos: &Repositories, + req: RegisterConfig, +) -> Result<(), anyhow::Error> { + persist_config(repos, CATEGORY, "stop_register", &req.stop_register.to_string()).await?; + persist_config(repos, CATEGORY, "enable_trial", &req.enable_trial.to_string()).await?; + persist_config( + repos, + CATEGORY, + "trial_subscribe", + &req.trial_subscribe.to_string(), + ) + .await?; + persist_config(repos, CATEGORY, "trial_time", &req.trial_time.to_string()).await?; + persist_config(repos, CATEGORY, "trial_time_unit", &req.trial_time_unit).await?; + persist_config( + repos, + CATEGORY, + "enable_ip_register_limit", + &req.enable_ip_register_limit.to_string(), + ) + .await?; + persist_config( + repos, + CATEGORY, + "ip_register_limit", + &req.ip_register_limit.to_string(), + ) + .await?; + persist_config( + repos, + CATEGORY, + "ip_register_limit_duration", + &req.ip_register_limit_duration.to_string(), + ) + .await?; + Ok(()) +} diff --git a/src/service/admin/system/update_site_config_service.rs b/src/service/admin/system/update_site_config_service.rs new file mode 100644 index 00000000..ae92e786 --- /dev/null +++ b/src/service/admin/system/update_site_config_service.rs @@ -0,0 +1,20 @@ +use crate::model::dto::SiteConfig; +use crate::repository::Repositories; +use crate::service::admin::system::update_config::persist_config; + +const CATEGORY: &str = "site"; + +/// Persist site configuration to the `system` table. +pub async fn update_site_config( + repos: &Repositories, + req: SiteConfig, +) -> Result<(), anyhow::Error> { + persist_config(repos, CATEGORY, "host", &req.host).await?; + persist_config(repos, CATEGORY, "site_name", &req.site_name).await?; + persist_config(repos, CATEGORY, "site_desc", &req.site_desc).await?; + persist_config(repos, CATEGORY, "site_logo", &req.site_logo).await?; + persist_config(repos, CATEGORY, "keywords", &req.keywords).await?; + persist_config(repos, CATEGORY, "custom_html", &req.custom_html).await?; + persist_config(repos, CATEGORY, "custom_data", &req.custom_data).await?; + Ok(()) +} diff --git a/src/service/admin/system/update_subscribe_config_service.rs b/src/service/admin/system/update_subscribe_config_service.rs new file mode 100644 index 00000000..e75e5e83 --- /dev/null +++ b/src/service/admin/system/update_subscribe_config_service.rs @@ -0,0 +1,26 @@ +use crate::model::dto::SubscribeConfig; +use crate::repository::Repositories; +use crate::service::admin::system::update_config::persist_config; + +const CATEGORY: &str = "subscribe"; + +/// Persist subscribe configuration to the `system` table. +pub async fn update_subscribe_config( + repos: &Repositories, + req: SubscribeConfig, +) -> Result<(), anyhow::Error> { + persist_config(repos, CATEGORY, "single_model", &req.single_model.to_string()).await?; + persist_config(repos, CATEGORY, "subscribe_path", &req.subscribe_path).await?; + persist_config(repos, CATEGORY, "subscribe_domain", &req.subscribe_domain).await?; + persist_config(repos, CATEGORY, "pan_domain", &req.pan_domain.to_string()).await?; + persist_config( + repos, + CATEGORY, + "user_agent_limit", + &req.user_agent_limit.to_string(), + ) + .await?; + persist_config(repos, CATEGORY, "user_agent_list", &req.user_agent_list).await?; + persist_config(repos, CATEGORY, "show_tutorial", &req.show_tutorial.to_string()).await?; + Ok(()) +} diff --git a/src/service/admin/system/update_tos_config_service.rs b/src/service/admin/system/update_tos_config_service.rs new file mode 100644 index 00000000..1486107e --- /dev/null +++ b/src/service/admin/system/update_tos_config_service.rs @@ -0,0 +1,14 @@ +use crate::model::dto::TosConfig; +use crate::repository::Repositories; +use crate::service::admin::system::update_config::persist_config; + +const CATEGORY: &str = "tos"; + +/// Persist Terms-of-Service configuration to the `system` table. +pub async fn update_tos_config( + repos: &Repositories, + req: TosConfig, +) -> Result<(), anyhow::Error> { + persist_config(repos, CATEGORY, "tos_content", &req.tos_content).await?; + Ok(()) +} diff --git a/src/service/admin/system/update_verify_code_config_service.rs b/src/service/admin/system/update_verify_code_config_service.rs new file mode 100644 index 00000000..c0d25323 --- /dev/null +++ b/src/service/admin/system/update_verify_code_config_service.rs @@ -0,0 +1,34 @@ +use crate::model::dto::VerifyCodeConfig; +use crate::repository::Repositories; +use crate::service::admin::system::update_config::persist_config; + +const CATEGORY: &str = "verify_code"; + +/// Persist verify-code configuration to the `system` table. +pub async fn update_verify_code_config( + repos: &Repositories, + req: VerifyCodeConfig, +) -> Result<(), anyhow::Error> { + persist_config( + repos, + CATEGORY, + "verify_code_expire_time", + &req.verify_code_expire_time.to_string(), + ) + .await?; + persist_config( + repos, + CATEGORY, + "verify_code_limit", + &req.verify_code_limit.to_string(), + ) + .await?; + persist_config( + repos, + CATEGORY, + "verify_code_interval", + &req.verify_code_interval.to_string(), + ) + .await?; + Ok(()) +} diff --git a/src/service/admin/system/update_verify_config_service.rs b/src/service/admin/system/update_verify_config_service.rs new file mode 100644 index 00000000..b052ce09 --- /dev/null +++ b/src/service/admin/system/update_verify_config_service.rs @@ -0,0 +1,36 @@ +use crate::model::dto::VerifyConfig; +use crate::repository::Repositories; +use crate::service::admin::system::update_config::persist_config; + +const CATEGORY: &str = "verify"; + +/// Persist verify (Turnstile) configuration to the `system` table. +pub async fn update_verify_config( + repos: &Repositories, + req: VerifyConfig, +) -> Result<(), anyhow::Error> { + persist_config(repos, CATEGORY, "turnstile_site_key", &req.turnstile_site_key).await?; + persist_config(repos, CATEGORY, "turnstile_secret", &req.turnstile_secret).await?; + persist_config( + repos, + CATEGORY, + "enable_login_verify", + &req.enable_login_verify.to_string(), + ) + .await?; + persist_config( + repos, + CATEGORY, + "enable_register_verify", + &req.enable_register_verify.to_string(), + ) + .await?; + persist_config( + repos, + CATEGORY, + "enable_reset_password_verify", + &req.enable_reset_password_verify.to_string(), + ) + .await?; + Ok(()) +} diff --git a/src/service/admin/ticket/constant.rs b/src/service/admin/ticket/constant.rs new file mode 100644 index 00000000..c193c848 --- /dev/null +++ b/src/service/admin/ticket/constant.rs @@ -0,0 +1,15 @@ +//! Ticket status & follow-type constants shared by admin ticket services. + +/// Ticket lifecycle states (mirrors `model::entity::ticket::*`). +pub const TICKET_STATUS_PENDING: i16 = 1; +pub const TICKET_STATUS_WAITING: i16 = 2; +pub const TICKET_STATUS_PROCESSED: i16 = 3; +pub const TICKET_STATUS_CLOSED: i16 = 4; + +/// Follow message sender origin. +pub const FOLLOW_FROM_USER: &str = "user"; +pub const FOLLOW_FROM_ADMIN: &str = "admin"; + +/// Follow entry type. +pub const FOLLOW_TYPE_USER: i16 = 1; +pub const FOLLOW_TYPE_ADMIN: i16 = 2; diff --git a/src/service/admin/ticket/constant_service.rs b/src/service/admin/ticket/constant_service.rs new file mode 100644 index 00000000..1a9a160e --- /dev/null +++ b/src/service/admin/ticket/constant_service.rs @@ -0,0 +1 @@ +pub use super::get_ticket_list_service::*; diff --git a/src/service/admin/ticket/create_ticket_follow_service.rs b/src/service/admin/ticket/create_ticket_follow_service.rs new file mode 100644 index 00000000..7a70a859 --- /dev/null +++ b/src/service/admin/ticket/create_ticket_follow_service.rs @@ -0,0 +1,37 @@ +use crate::model::dto::ticket::CreateTicketFollowRequest; +use crate::model::entity::ticket::Follow; +use crate::repository::ticket::TicketRepo; +use anyhow::Context; +use chrono::Utc; +use result::code_error::CodeError; +use result::error_code; + +pub async fn create_ticket_follow( + repo: &dyn TicketRepo, + _user_id: i64, + req: CreateTicketFollowRequest, +) -> anyhow::Result<()> { + // Ensure the parent ticket exists so we don't insert an orphan follow. + repo.find_one(req.ticket_id) + .await + .map_err(|e| { + anyhow::Error::new(CodeError::new_err_code_msg( + error_code::USER_NOT_EXIST, + &format!("ticket {} not found: {}", req.ticket_id, e), + )) + })?; + + let now = Utc::now().timestamp_millis(); + let follow = Follow { + id: 0, + ticket_id: req.ticket_id, + from: req.from, + type_: req.type_ as i16, + content: Some(req.content), + created_at: now, + }; + repo.insert_follow(&follow) + .await + .context("create ticket follow")?; + Ok(()) +} diff --git a/src/service/admin/ticket/get_ticket_list_service.rs b/src/service/admin/ticket/get_ticket_list_service.rs new file mode 100644 index 00000000..5fa22d6a --- /dev/null +++ b/src/service/admin/ticket/get_ticket_list_service.rs @@ -0,0 +1,76 @@ +use crate::model::dto::ticket::{ + CreateTicketFollowRequest, GetTicketListRequest, GetTicketListResponse, + GetTicketRequest, Ticket, UpdateTicketStatusRequest, +}; +use crate::repository::ticket::TicketRepo; +use anyhow::Context; + +pub async fn get_ticket_list( + repo: &dyn TicketRepo, + req: GetTicketListRequest, +) -> anyhow::Result { + let (total, list) = repo + .query_ticket_list(req.page, req.size, 0, req.status.map(|s| s as i16), req.search.as_deref()) + .await + .context("query ticket list")?; + let tickets = list.into_iter().map(|t| Ticket { + id: t.id, + title: t.title, + description: t.description.unwrap_or_default(), + user_id: t.user_id, + follow: None, + status: t.status as u8, + created_at: t.created_at, + updated_at: t.updated_at, + }).collect(); + Ok(GetTicketListResponse { total, list: tickets }) +} + +pub async fn get_ticket( + repo: &dyn TicketRepo, + req: GetTicketRequest, +) -> anyhow::Result { + let t = repo.find_one(req.id).await.context("get ticket")?; + Ok(Ticket { + id: t.id, + title: t.title, + description: t.description.unwrap_or_default(), + user_id: t.user_id, + follow: None, + status: t.status as u8, + created_at: t.created_at, + updated_at: t.updated_at, + }) +} + +pub async fn create_ticket_follow( + repo: &dyn TicketRepo, + _user_id: i64, + req: CreateTicketFollowRequest, +) -> anyhow::Result<()> { + use crate::model::entity::ticket::Follow; + use chrono::Utc; + let now = Utc::now().timestamp_millis(); + let follow = Follow { + id: 0, + ticket_id: req.ticket_id, + from: req.from, + type_: req.type_ as i16, + content: Some(req.content), + created_at: now, + }; + repo.insert_follow(&follow).await.context("create ticket follow")?; + Ok(()) +} + +pub async fn update_ticket_status( + repo: &dyn TicketRepo, + req: UpdateTicketStatusRequest, +) -> anyhow::Result<()> { + if let Some(status) = req.status { + repo.update_ticket_status(req.id, 0, status as i16) + .await + .context("update ticket status")?; + } + Ok(()) +} diff --git a/src/service/admin/ticket/get_ticket_service.rs b/src/service/admin/ticket/get_ticket_service.rs new file mode 100644 index 00000000..157fbd68 --- /dev/null +++ b/src/service/admin/ticket/get_ticket_service.rs @@ -0,0 +1,35 @@ +use crate::model::dto::ticket::{Follow, GetTicketRequest, Ticket}; +use crate::repository::ticket::TicketRepo; +use anyhow::Context; + +pub async fn get_ticket( + repo: &dyn TicketRepo, + req: GetTicketRequest, +) -> anyhow::Result { + let t = repo.find_one(req.id).await.context("get ticket")?; + let follows = repo + .find_follows_by_ticket(t.id) + .await + .context("load ticket follows")?; + let follow_dtos: Vec = follows + .into_iter() + .map(|f| Follow { + id: f.id, + ticket_id: f.ticket_id, + from: f.from, + type_: f.type_ as u8, + content: f.content.unwrap_or_default(), + created_at: f.created_at, + }) + .collect(); + Ok(Ticket { + id: t.id, + title: t.title, + description: t.description.unwrap_or_default(), + user_id: t.user_id, + follow: Some(follow_dtos), + status: t.status as u8, + created_at: t.created_at, + updated_at: t.updated_at, + }) +} diff --git a/src/service/admin/ticket/mod.rs b/src/service/admin/ticket/mod.rs new file mode 100644 index 00000000..ed6ca481 --- /dev/null +++ b/src/service/admin/ticket/mod.rs @@ -0,0 +1,5 @@ +pub mod constant; +pub mod create_ticket_follow_service; +pub mod get_ticket_list_service; +pub mod get_ticket_service; +pub mod update_ticket_status_service; diff --git a/src/service/admin/ticket/update_ticket_status_service.rs b/src/service/admin/ticket/update_ticket_status_service.rs new file mode 100644 index 00000000..afbf5670 --- /dev/null +++ b/src/service/admin/ticket/update_ticket_status_service.rs @@ -0,0 +1,30 @@ +use crate::model::dto::ticket::UpdateTicketStatusRequest; +use crate::repository::ticket::TicketRepo; +use anyhow::Context; +use result::code_error::CodeError; +use result::error_code; + +pub async fn update_ticket_status( + repo: &dyn TicketRepo, + req: UpdateTicketStatusRequest, +) -> anyhow::Result<()> { + let Some(status) = req.status else { + // No status transition requested — treat as a no-op (matches Go + // behaviour where a nil/0 status short-circuits the update). + return Ok(()); + }; + + // Verify the ticket still exists so the caller gets a meaningful error + // instead of a silent "0 rows affected" response. + repo.find_one(req.id).await.map_err(|e| { + anyhow::Error::new(CodeError::new_err_code_msg( + error_code::USER_NOT_EXIST, + &format!("ticket {} not found: {}", req.id, e), + )) + })?; + + repo.update_ticket_status(req.id, 0, status as i16) + .await + .context("update ticket status")?; + Ok(()) +} diff --git a/src/service/admin/tool/get_system_log_service.rs b/src/service/admin/tool/get_system_log_service.rs new file mode 100644 index 00000000..d61cf329 --- /dev/null +++ b/src/service/admin/tool/get_system_log_service.rs @@ -0,0 +1,10 @@ +use crate::model::dto::log::LogResponse; +use serde_json::Value; + +pub async fn get_system_log() -> anyhow::Result { + // System log reading requires file-system access to the log path. + // Return empty list as safe default — full file reading is a future enhancement. + Ok(LogResponse { + list: Value::Array(vec![]), + }) +} diff --git a/src/service/admin/tool/get_version_service.rs b/src/service/admin/tool/get_version_service.rs new file mode 100644 index 00000000..c96376df --- /dev/null +++ b/src/service/admin/tool/get_version_service.rs @@ -0,0 +1,9 @@ +use crate::model::dto::user::VersionResponse; + +const VERSION: &str = env!("CARGO_PKG_VERSION"); + +pub async fn get_version() -> anyhow::Result { + Ok(VersionResponse { + version: VERSION.to_string(), + }) +} diff --git a/src/service/admin/tool/mod.rs b/src/service/admin/tool/mod.rs new file mode 100644 index 00000000..6f7c824a --- /dev/null +++ b/src/service/admin/tool/mod.rs @@ -0,0 +1,4 @@ +pub mod get_system_log_service; +pub mod get_version_service; +pub mod query_ip_location_service; +pub mod restart_system_service; diff --git a/src/service/admin/tool/query_ip_location_service.rs b/src/service/admin/tool/query_ip_location_service.rs new file mode 100644 index 00000000..598e6491 --- /dev/null +++ b/src/service/admin/tool/query_ip_location_service.rs @@ -0,0 +1,16 @@ +use anyhow::Context; + +use crate::model::dto::system::{QueryIPLocationRequest, QueryIPLocationResponse}; + +pub async fn query_ip_location( + req: QueryIPLocationRequest, +) -> anyhow::Result { + // GeoIP database is not available without the crate being wired in. + // Return empty response to keep the endpoint functional. + tracing::info!("[query_ip_location] queried for ip={}", req.ip); + Ok(QueryIPLocationResponse { + country: String::new(), + region: None, + city: String::new(), + }) +} diff --git a/src/service/admin/tool/restart_system_service.rs b/src/service/admin/tool/restart_system_service.rs new file mode 100644 index 00000000..dbe3436b --- /dev/null +++ b/src/service/admin/tool/restart_system_service.rs @@ -0,0 +1,6 @@ +pub async fn restart_system() -> anyhow::Result<()> { + // In Go this calls svcCtx.Restart() which sends SIGTERM to self. + // In Rust we log the request; actual restart is an ops concern. + tracing::info!("[restart_system] restart requested by admin"); + Ok(()) +} diff --git a/src/service/admin/user/batch_delete_user_service.rs b/src/service/admin/user/batch_delete_user_service.rs new file mode 100644 index 00000000..514a551b --- /dev/null +++ b/src/service/admin/user/batch_delete_user_service.rs @@ -0,0 +1,25 @@ +use std::sync::Arc; + +use crate::model::dto::user::BatchDeleteUserRequest; +use crate::repository::Repositories; +use result::code_error::CodeError; +use result::error_code; + +pub async fn batch_delete_user( + repos: &Arc, + req: BatchDeleteUserRequest, +) -> Result { + if req.ids.is_empty() { + return Ok(0); + } + repos + .user + .batch_delete_users(&req.ids) + .await + .map_err(|e| { + anyhow::Error::new(CodeError::new_err_code_msg( + error_code::DATABASE_DELETED_ERROR, + &e.to_string(), + )) + }) +} diff --git a/src/service/admin/user/create_user_auth_method_service.rs b/src/service/admin/user/create_user_auth_method_service.rs new file mode 100644 index 00000000..3996c956 --- /dev/null +++ b/src/service/admin/user/create_user_auth_method_service.rs @@ -0,0 +1,36 @@ +use std::sync::Arc; + +use chrono::Utc; + +use crate::model::dto::user::CreateUserAuthMethodRequest; +use crate::model::entity::user::AuthMethods; +use crate::repository::Repositories; +use result::code_error::CodeError; +use result::error_code; + +pub async fn create_user_auth_method( + repos: &Arc, + req: CreateUserAuthMethodRequest, +) -> Result { + let now = Utc::now().timestamp_millis(); + let entity = AuthMethods { + id: 0, + user_id: req.user_id, + auth_type: req.auth_type, + auth_identifier: req.auth_identifier, + verified: true, + created_at: now, + updated_at: now, + }; + + repos + .user + .insert_auth_method(&entity) + .await + .map_err(|e| { + anyhow::Error::new(CodeError::new_err_code_msg( + error_code::DATABASE_INSERT_ERROR, + &e.to_string(), + )) + }) +} diff --git a/src/service/admin/user/create_user_service.rs b/src/service/admin/user/create_user_service.rs new file mode 100644 index 00000000..194830e4 --- /dev/null +++ b/src/service/admin/user/create_user_service.rs @@ -0,0 +1,50 @@ +use std::sync::Arc; + +use chrono::Utc; + +use crate::model::dto::user::CreateUserRequest; +use crate::model::entity::user::User; +use crate::repository::Repositories; +use result::code_error::CodeError; +use result::error_code; + +pub async fn create_user( + repos: &Arc, + req: CreateUserRequest, +) -> Result { + let now = Utc::now().timestamp_millis(); + let hash = password::encode_password(&req.password) + .map_err(|e| anyhow::Error::new(CodeError::new_err_msg(&e.to_string())))?; + + let entity = User { + id: 0, + password: hash, + algo: String::new(), + salt: None, + avatar: String::new(), + balance: req.balance, + refer_code: req.refer_code, + referer_id: 0, + commission: req.commission, + referral_percentage: req.referral_percentage as i16, + only_first_purchase: req.only_first_purchase, + gift_amount: req.gift_amount, + enable: true, + is_admin: req.is_admin, + enable_balance_notify: false, + enable_login_notify: false, + enable_subscribe_notify: false, + enable_trade_notify: false, + rules: None, + created_at: now, + updated_at: now, + deleted_at: None, + }; + + repos.user.insert_user(&entity).await.map_err(|e| { + anyhow::Error::new(CodeError::new_err_code_msg( + error_code::DATABASE_INSERT_ERROR, + &e.to_string(), + )) + }) +} diff --git a/src/service/admin/user/create_user_subscribe_service.rs b/src/service/admin/user/create_user_subscribe_service.rs new file mode 100644 index 00000000..79c348b8 --- /dev/null +++ b/src/service/admin/user/create_user_subscribe_service.rs @@ -0,0 +1,54 @@ +use std::sync::Arc; + +use chrono::Utc; +use uuid::Uuid; + +use crate::model::entity::user::UserSubscribe; +use crate::repository::Repositories; +use result::code_error::CodeError; +use result::error_code; + +/// Create a new UserSubscribe for a user. +/// +/// NOTE: This is a minimal skeleton. Production logic (purchase vs admin-grant, +/// product/duration expansion, gift quota, order linkage, status bitmask) is +/// TODO and should be ported from `server/internal/logic/admin/user/createUserSubscribeLogic.go`. +pub async fn create_user_subscribe( + repos: &Arc, + user_id: i64, + subscribe_id: i64, + duration_days: i64, + traffic: i64, +) -> Result { + let _ = (repos, user_id, subscribe_id, duration_days, traffic); + let now = Utc::now().timestamp_millis(); + let entity = UserSubscribe { + id: 0, + user_id, + order_id: 0, + subscribe_id, + start_time: now, + expire_time: now + duration_days * 86_400_000, + finished_at: None, + traffic, + download: 0, + upload: 0, + token: Uuid::new_v4().to_string().replace('-', ""), + uuid: Uuid::new_v4().to_string(), + status: 1, + note: String::new(), + created_at: now, + updated_at: now, + }; + + repos + .user + .insert_subscribe(&entity) + .await + .map_err(|e| { + anyhow::Error::new(CodeError::new_err_code_msg( + error_code::DATABASE_INSERT_ERROR, + &e.to_string(), + )) + }) +} diff --git a/src/service/admin/user/current_user_service.rs b/src/service/admin/user/current_user_service.rs new file mode 100644 index 00000000..b11bcc16 --- /dev/null +++ b/src/service/admin/user/current_user_service.rs @@ -0,0 +1,18 @@ +use std::sync::Arc; + +use crate::model::entity::user::User; +use crate::repository::Repositories; +use result::code_error::CodeError; +use result::error_code; + +pub async fn current_user( + repos: &Arc, + user_id: i64, +) -> Result { + repos.user.find_one_user(user_id).await.map_err(|e| { + anyhow::Error::new(CodeError::new_err_code_msg( + error_code::DATABASE_QUERY_ERROR, + &e.to_string(), + )) + }) +} diff --git a/src/service/admin/user/delete_user_auth_method_service.rs b/src/service/admin/user/delete_user_auth_method_service.rs new file mode 100644 index 00000000..99e9ae41 --- /dev/null +++ b/src/service/admin/user/delete_user_auth_method_service.rs @@ -0,0 +1,22 @@ +use std::sync::Arc; + +use crate::model::dto::user::DeleteUserAuthMethodRequest; +use crate::repository::Repositories; +use result::code_error::CodeError; +use result::error_code; + +pub async fn delete_user_auth_method( + repos: &Arc, + req: DeleteUserAuthMethodRequest, +) -> Result { + repos + .user + .delete_user_auth_methods(req.user_id, &req.auth_type) + .await + .map_err(|e| { + anyhow::Error::new(CodeError::new_err_code_msg( + error_code::DATABASE_DELETED_ERROR, + &e.to_string(), + )) + }) +} diff --git a/src/service/admin/user/delete_user_device_service.rs b/src/service/admin/user/delete_user_device_service.rs new file mode 100644 index 00000000..81fd105b --- /dev/null +++ b/src/service/admin/user/delete_user_device_service.rs @@ -0,0 +1,18 @@ +use std::sync::Arc; + +use crate::model::dto::user::DeleteUserDeivceRequest; +use crate::repository::Repositories; +use result::code_error::CodeError; +use result::error_code; + +pub async fn delete_user_device( + repos: &Arc, + req: DeleteUserDeivceRequest, +) -> Result { + repos.user.delete_device(req.id).await.map_err(|e| { + anyhow::Error::new(CodeError::new_err_code_msg( + error_code::DATABASE_DELETED_ERROR, + &e.to_string(), + )) + }) +} diff --git a/src/service/admin/user/delete_user_service.rs b/src/service/admin/user/delete_user_service.rs new file mode 100644 index 00000000..53cd91a5 --- /dev/null +++ b/src/service/admin/user/delete_user_service.rs @@ -0,0 +1,17 @@ +use std::sync::Arc; + +use crate::repository::Repositories; +use result::code_error::CodeError; +use result::error_code; + +pub async fn delete_user( + repos: &Arc, + user_id: i64, +) -> Result { + repos.user.delete_user(user_id).await.map_err(|e| { + anyhow::Error::new(CodeError::new_err_code_msg( + error_code::DATABASE_DELETED_ERROR, + &e.to_string(), + )) + }) +} diff --git a/src/service/admin/user/delete_user_subscribe_service.rs b/src/service/admin/user/delete_user_subscribe_service.rs new file mode 100644 index 00000000..520aacc7 --- /dev/null +++ b/src/service/admin/user/delete_user_subscribe_service.rs @@ -0,0 +1,21 @@ +use std::sync::Arc; + +use crate::repository::Repositories; +use result::code_error::CodeError; +use result::error_code; + +pub async fn delete_user_subscribe( + repos: &Arc, + subscribe_id: i64, +) -> Result { + repos + .user + .delete_subscribe_by_id(subscribe_id) + .await + .map_err(|e| { + anyhow::Error::new(CodeError::new_err_code_msg( + error_code::DATABASE_DELETED_ERROR, + &e.to_string(), + )) + }) +} diff --git a/src/service/admin/user/get_user_auth_method_service.rs b/src/service/admin/user/get_user_auth_method_service.rs new file mode 100644 index 00000000..8d23ddd6 --- /dev/null +++ b/src/service/admin/user/get_user_auth_method_service.rs @@ -0,0 +1,34 @@ +use std::sync::Arc; + +use crate::model::dto::user::{GetUserAuthMethodRequest, GetUserAuthMethodResponse, UserAuthMethod}; +use crate::model::entity::user::AuthMethods; +use crate::repository::Repositories; +use result::code_error::CodeError; +use result::error_code; + +pub async fn get_user_auth_method( + repos: &Arc, + req: GetUserAuthMethodRequest, +) -> Result { + let methods: Vec = repos + .user + .find_user_auth_methods(req.user_id) + .await + .map_err(|e| { + anyhow::Error::new(CodeError::new_err_code_msg( + error_code::DATABASE_QUERY_ERROR, + &e.to_string(), + )) + })?; + + let auth_methods = methods + .into_iter() + .map(|m| UserAuthMethod { + auth_type: m.auth_type, + auth_identifier: m.auth_identifier, + verified: m.verified, + }) + .collect(); + + Ok(GetUserAuthMethodResponse { auth_methods }) +} diff --git a/src/service/admin/user/get_user_detail_service.rs b/src/service/admin/user/get_user_detail_service.rs new file mode 100644 index 00000000..cffb1e28 --- /dev/null +++ b/src/service/admin/user/get_user_detail_service.rs @@ -0,0 +1,18 @@ +use std::sync::Arc; + +use crate::model::entity::user::User; +use crate::repository::Repositories; +use result::code_error::CodeError; +use result::error_code; + +pub async fn get_user_detail( + repos: &Arc, + user_id: i64, +) -> Result { + repos.user.find_one_user(user_id).await.map_err(|e| { + anyhow::Error::new(CodeError::new_err_code_msg( + error_code::DATABASE_QUERY_ERROR, + &e.to_string(), + )) + }) +} diff --git a/src/service/admin/user/get_user_list_service.rs b/src/service/admin/user/get_user_list_service.rs new file mode 100644 index 00000000..449478bc --- /dev/null +++ b/src/service/admin/user/get_user_list_service.rs @@ -0,0 +1,60 @@ +use std::sync::Arc; + +use crate::model::dto::user::{GetUserListRequest, GetUserListResponse}; +use crate::repository::user::UserFilter; +use crate::repository::Repositories; +use result::code_error::CodeError; +use result::error_code; + +pub async fn get_user_list( + repos: &Arc, + req: GetUserListRequest, +) -> Result { + let mut filter = UserFilter::default(); + filter.search = req.search; + filter.user_id = req.user_id; + filter.subscribe_id = req.subscribe_id; + filter.user_subscribe_id = req.user_subscribe_id; + filter.unscoped = req.unscoped; + + let (total, list) = repos + .user + .query_page_list(req.page as i64, req.size as i64, &filter) + .await + .map_err(|e| { + anyhow::Error::new(CodeError::new_err_code_msg( + error_code::DATABASE_QUERY_ERROR, + &e.to_string(), + )) + })?; + + let dto_list = list + .into_iter() + .map(|u| crate::model::dto::user::User { + id: u.id, + avatar: u.avatar, + balance: u.balance, + commission: u.commission, + referral_percentage: u.referral_percentage as u8, + only_first_purchase: u.only_first_purchase, + gift_amount: u.gift_amount, + telegram: 0, + refer_code: u.refer_code, + referer_id: u.referer_id, + enable: u.enable, + is_admin: Some(u.is_admin), + enable_balance_notify: u.enable_balance_notify, + enable_login_notify: u.enable_login_notify, + enable_subscribe_notify: u.enable_subscribe_notify, + enable_trade_notify: u.enable_trade_notify, + auth_methods: Vec::new(), + user_devices: Vec::new(), + rules: Vec::new(), + created_at: u.created_at, + updated_at: u.updated_at, + deleted_at: u.deleted_at, + }) + .collect(); + + Ok(GetUserListResponse { total, list: dto_list }) +} diff --git a/src/service/admin/user/get_user_login_logs_service.rs b/src/service/admin/user/get_user_login_logs_service.rs new file mode 100644 index 00000000..843c83a7 --- /dev/null +++ b/src/service/admin/user/get_user_login_logs_service.rs @@ -0,0 +1,31 @@ +use std::sync::Arc; + +use crate::model::dto::user::GetUserLoginLogsRequest; +use crate::model::entity::log::LogType; +use crate::model::entity::log::SystemLog; +use crate::repository::Repositories; +use result::code_error::CodeError; +use result::error_code; + +pub async fn get_user_login_logs( + repos: &Arc, + req: GetUserLoginLogsRequest, +) -> Result<(Vec, i64), anyhow::Error> { + repos + .log + .filter_logs( + req.page as i64, + req.size as i64, + Some(LogType::LOGIN.0), + None, + Some(req.user_id), + None, + ) + .await + .map_err(|e| { + anyhow::Error::new(CodeError::new_err_code_msg( + error_code::DATABASE_QUERY_ERROR, + &e.to_string(), + )) + }) +} diff --git a/src/service/admin/user/get_user_subscribe_by_id_service.rs b/src/service/admin/user/get_user_subscribe_by_id_service.rs new file mode 100644 index 00000000..a22d1f38 --- /dev/null +++ b/src/service/admin/user/get_user_subscribe_by_id_service.rs @@ -0,0 +1,22 @@ +use std::sync::Arc; + +use crate::repository::user::SubscribeDetails; +use crate::repository::Repositories; +use result::code_error::CodeError; +use result::error_code; + +pub async fn get_user_subscribe_by_id( + repos: &Arc, + id: i64, +) -> Result { + repos + .user + .find_one_subscribe_details_by_id(id) + .await + .map_err(|e| { + anyhow::Error::new(CodeError::new_err_code_msg( + error_code::DATABASE_QUERY_ERROR, + &e.to_string(), + )) + }) +} diff --git a/src/service/admin/user/get_user_subscribe_devices_service.rs b/src/service/admin/user/get_user_subscribe_devices_service.rs new file mode 100644 index 00000000..20fb3125 --- /dev/null +++ b/src/service/admin/user/get_user_subscribe_devices_service.rs @@ -0,0 +1,43 @@ +use std::sync::Arc; + +use crate::model::dto::user::{GetDeviceListResponse, UserDevice}; +use crate::model::entity::user::Device; +use crate::repository::Repositories; +use result::code_error::CodeError; +use result::error_code; + +pub async fn get_user_subscribe_devices( + repos: &Arc, + user_id: i64, +) -> Result { + let (list, total) = repos + .user + .query_device_list(user_id) + .await + .map_err(|e| { + anyhow::Error::new(CodeError::new_err_code_msg( + error_code::DATABASE_QUERY_ERROR, + &e.to_string(), + )) + })?; + + let devices: Vec = list + .into_iter() + .map(device_to_dto) + .collect(); + + Ok(GetDeviceListResponse { list: devices, total }) +} + +fn device_to_dto(d: Device) -> UserDevice { + UserDevice { + id: d.id, + ip: d.ip, + identifier: d.identifier, + user_agent: d.user_agent.unwrap_or_default(), + online: d.online, + enabled: d.enabled, + created_at: d.created_at, + updated_at: d.updated_at, + } +} diff --git a/src/service/admin/user/get_user_subscribe_logs_service.rs b/src/service/admin/user/get_user_subscribe_logs_service.rs new file mode 100644 index 00000000..685c39eb --- /dev/null +++ b/src/service/admin/user/get_user_subscribe_logs_service.rs @@ -0,0 +1,32 @@ +use std::sync::Arc; + +use crate::model::entity::log::LogType; +use crate::model::entity::log::SystemLog; +use crate::repository::Repositories; +use result::code_error::CodeError; +use result::error_code; + +pub async fn get_user_subscribe_logs( + repos: &Arc, + user_id: i64, + page: i32, + size: i32, +) -> Result<(Vec, i64), anyhow::Error> { + repos + .log + .filter_logs( + page as i64, + size as i64, + Some(LogType::SUBSCRIBE.0), + None, + Some(user_id), + None, + ) + .await + .map_err(|e| { + anyhow::Error::new(CodeError::new_err_code_msg( + error_code::DATABASE_QUERY_ERROR, + &e.to_string(), + )) + }) +} diff --git a/src/service/admin/user/get_user_subscribe_reset_traffic_logs_service.rs b/src/service/admin/user/get_user_subscribe_reset_traffic_logs_service.rs new file mode 100644 index 00000000..0c2873a4 --- /dev/null +++ b/src/service/admin/user/get_user_subscribe_reset_traffic_logs_service.rs @@ -0,0 +1,32 @@ +use std::sync::Arc; + +use crate::model::entity::log::LogType; +use crate::model::entity::log::SystemLog; +use crate::repository::Repositories; +use result::code_error::CodeError; +use result::error_code; + +pub async fn get_user_subscribe_reset_traffic_logs( + repos: &Arc, + user_id: i64, + page: i32, + size: i32, +) -> Result<(Vec, i64), anyhow::Error> { + repos + .log + .filter_logs( + page as i64, + size as i64, + Some(LogType::RESET_SUBSCRIBE.0), + None, + Some(user_id), + None, + ) + .await + .map_err(|e| { + anyhow::Error::new(CodeError::new_err_code_msg( + error_code::DATABASE_QUERY_ERROR, + &e.to_string(), + )) + }) +} diff --git a/src/service/admin/user/get_user_subscribe_service.rs b/src/service/admin/user/get_user_subscribe_service.rs new file mode 100644 index 00000000..975e45f7 --- /dev/null +++ b/src/service/admin/user/get_user_subscribe_service.rs @@ -0,0 +1,22 @@ +use std::sync::Arc; + +use crate::model::entity::user::UserSubscribe; +use crate::repository::Repositories; +use result::code_error::CodeError; +use result::error_code; + +pub async fn get_user_subscribe( + repos: &Arc, + token: &str, +) -> Result { + repos + .user + .find_one_subscribe_by_token(token) + .await + .map_err(|e| { + anyhow::Error::new(CodeError::new_err_code_msg( + error_code::DATABASE_QUERY_ERROR, + &e.to_string(), + )) + }) +} diff --git a/src/service/admin/user/get_user_subscribe_traffic_logs_service.rs b/src/service/admin/user/get_user_subscribe_traffic_logs_service.rs new file mode 100644 index 00000000..8183ed3a --- /dev/null +++ b/src/service/admin/user/get_user_subscribe_traffic_logs_service.rs @@ -0,0 +1,32 @@ +use std::sync::Arc; + +use crate::model::entity::log::LogType; +use crate::model::entity::log::SystemLog; +use crate::repository::Repositories; +use result::code_error::CodeError; +use result::error_code; + +pub async fn get_user_subscribe_traffic_logs( + repos: &Arc, + user_id: i64, + page: i32, + size: i32, +) -> Result<(Vec, i64), anyhow::Error> { + repos + .log + .filter_logs( + page as i64, + size as i64, + Some(LogType::SUBSCRIBE_TRAFFIC.0), + None, + Some(user_id), + None, + ) + .await + .map_err(|e| { + anyhow::Error::new(CodeError::new_err_code_msg( + error_code::DATABASE_QUERY_ERROR, + &e.to_string(), + )) + }) +} diff --git a/src/service/admin/user/kick_offline_by_user_device_service.rs b/src/service/admin/user/kick_offline_by_user_device_service.rs new file mode 100644 index 00000000..f9518f23 --- /dev/null +++ b/src/service/admin/user/kick_offline_by_user_device_service.rs @@ -0,0 +1,42 @@ +use std::sync::Arc; + +use crate::cache::Cache; +use crate::model::dto::user::KickOfflineRequest; +use crate::repository::Repositories; +use result::code_error::CodeError; +use result::error_code; + +/// Force a device offline by deleting its session key from Redis. +/// +/// The Go backend keyed active sessions as +/// `auth:session_id::` +/// (see `config.cache_key.SESSION_ID_KEY`); clearing it terminates the next +/// auth_middleware check. +pub async fn kick_offline_by_user_device( + repos: &Arc, + cache: &Cache, + req: KickOfflineRequest, +) -> Result<(), anyhow::Error> { + // Resolve the device so we know the user_id + identifier. + let device = repos + .user + .find_one_device(req.id) + .await + .map_err(|e| { + anyhow::Error::new(CodeError::new_err_code_msg( + error_code::DATABASE_QUERY_ERROR, + &e.to_string(), + )) + })?; + + let key = format!( + "{}:{}:{}", + crate::config::cache_key::SESSION_ID_KEY, + device.user_id, + device.identifier + ); + + cache.del(&key).await.map_err(|e| { + anyhow::Error::new(CodeError::new_err_msg(&e.to_string())) + }) +} diff --git a/src/service/admin/user/mod.rs b/src/service/admin/user/mod.rs new file mode 100644 index 00000000..3117dbff --- /dev/null +++ b/src/service/admin/user/mod.rs @@ -0,0 +1,28 @@ +pub mod batch_delete_user_service; +pub mod create_user_auth_method_service; +pub mod create_user_service; +pub mod create_user_subscribe_service; +pub mod current_user_service; +pub mod delete_user_auth_method_service; +pub mod delete_user_device_service; +pub mod delete_user_service; +pub mod delete_user_subscribe_service; +pub mod get_user_auth_method_service; +pub mod get_user_detail_service; +pub mod get_user_list_service; +pub mod get_user_login_logs_service; +pub mod get_user_subscribe_by_id_service; +pub mod get_user_subscribe_devices_service; +pub mod get_user_subscribe_service; +pub mod get_user_subscribe_logs_service; +pub mod get_user_subscribe_reset_traffic_logs_service; +pub mod get_user_subscribe_traffic_logs_service; +pub mod kick_offline_by_user_device_service; +pub mod reset_user_subscribe_token_service; +pub mod reset_user_subscribe_traffic_service; +pub mod toggle_user_subscribe_status_service; +pub mod update_user_auth_method_service; +pub mod update_user_basic_info_service; +pub mod update_user_device_service; +pub mod update_user_notify_setting_service; +pub mod update_user_subscribe_service; diff --git a/src/service/admin/user/reset_user_subscribe_token_service.rs b/src/service/admin/user/reset_user_subscribe_token_service.rs new file mode 100644 index 00000000..25d9c031 --- /dev/null +++ b/src/service/admin/user/reset_user_subscribe_token_service.rs @@ -0,0 +1,38 @@ +use std::sync::Arc; + +use chrono::Utc; +use uuid::Uuid; + +use crate::model::entity::user::UserSubscribe; +use crate::repository::Repositories; +use result::code_error::CodeError; +use result::error_code; + +/// Mint a fresh token + uuid for a UserSubscribe. The old token is invalidated +/// implicitly because the row is updated. +pub async fn reset_user_subscribe_token( + repos: &Arc, + subscribe_id: i64, +) -> Result { + let mut sub = repos + .user + .find_one_subscribe(subscribe_id) + .await + .map_err(|e| { + anyhow::Error::new(CodeError::new_err_code_msg( + error_code::DATABASE_QUERY_ERROR, + &e.to_string(), + )) + })?; + + sub.token = Uuid::new_v4().to_string().replace('-', ""); + sub.uuid = Uuid::new_v4().to_string(); + sub.updated_at = Utc::now().timestamp_millis(); + + repos.user.update_subscribe(&sub).await.map_err(|e| { + anyhow::Error::new(CodeError::new_err_code_msg( + error_code::DATABASE_UPDATE_ERROR, + &e.to_string(), + )) + }) +} diff --git a/src/service/admin/user/reset_user_subscribe_traffic_service.rs b/src/service/admin/user/reset_user_subscribe_traffic_service.rs new file mode 100644 index 00000000..a693caf8 --- /dev/null +++ b/src/service/admin/user/reset_user_subscribe_traffic_service.rs @@ -0,0 +1,43 @@ +use std::sync::Arc; + +use chrono::Utc; + +use crate::model::entity::log::RESET_SUBSCRIBE_TYPE_ADVANCE; +use crate::model::entity::user::UserSubscribe; +use crate::repository::Repositories; +use crate::service::telemetry::Telemetry; +use result::code_error::CodeError; +use result::error_code; + +/// Reset `download` and `upload` counters to zero for a single UserSubscribe +/// and emit a `RESET_SUBSCRIBE` business audit log. +pub async fn reset_user_subscribe_traffic( + repos: &Arc, + subscribe_id: i64, +) -> Result { + let mut sub = repos + .user + .find_one_subscribe(subscribe_id) + .await + .map_err(|e| { + anyhow::Error::new(CodeError::new_err_code_msg( + error_code::DATABASE_QUERY_ERROR, + &e.to_string(), + )) + })?; + + sub.download = 0; + sub.upload = 0; + sub.updated_at = Utc::now().timestamp_millis(); + + let updated = repos.user.update_subscribe(&sub).await.map_err(|e| { + anyhow::Error::new(CodeError::new_err_code_msg( + error_code::DATABASE_UPDATE_ERROR, + &e.to_string(), + )) + })?; + + Telemetry::reset_subscribe(repos, updated.user_id, RESET_SUBSCRIBE_TYPE_ADVANCE, None).await; + + Ok(updated) +} diff --git a/src/service/admin/user/toggle_user_subscribe_status_service.rs b/src/service/admin/user/toggle_user_subscribe_status_service.rs new file mode 100644 index 00000000..b17ea53a --- /dev/null +++ b/src/service/admin/user/toggle_user_subscribe_status_service.rs @@ -0,0 +1,35 @@ +use std::sync::Arc; + +use chrono::Utc; + +use crate::model::entity::user::UserSubscribe; +use crate::repository::Repositories; +use result::code_error::CodeError; +use result::error_code; + +/// Toggle the `status` bit on a UserSubscribe (0 ↔ 1). +pub async fn toggle_user_subscribe_status( + repos: &Arc, + subscribe_id: i64, +) -> Result { + let mut sub = repos + .user + .find_one_subscribe(subscribe_id) + .await + .map_err(|e| { + anyhow::Error::new(CodeError::new_err_code_msg( + error_code::DATABASE_QUERY_ERROR, + &e.to_string(), + )) + })?; + + sub.status = if sub.status == 0 { 1 } else { 0 }; + sub.updated_at = Utc::now().timestamp_millis(); + + repos.user.update_subscribe(&sub).await.map_err(|e| { + anyhow::Error::new(CodeError::new_err_code_msg( + error_code::DATABASE_UPDATE_ERROR, + &e.to_string(), + )) + }) +} diff --git a/src/service/admin/user/update_user_auth_method_service.rs b/src/service/admin/user/update_user_auth_method_service.rs new file mode 100644 index 00000000..5a49038c --- /dev/null +++ b/src/service/admin/user/update_user_auth_method_service.rs @@ -0,0 +1,44 @@ +use std::sync::Arc; + +use chrono::Utc; + +use crate::model::dto::user::UpdateUserAuthMethodRequest; +use crate::model::entity::user::AuthMethods; +use crate::repository::Repositories; +use result::code_error::CodeError; +use result::error_code; + +pub async fn update_user_auth_method( + repos: &Arc, + req: UpdateUserAuthMethodRequest, +) -> Result { + // Look up the existing row by (user_id, auth_type) and replace identifier. + let existing = repos + .user + .find_auth_method_by_platform(req.user_id, &req.auth_type) + .await + .map_err(|e| { + anyhow::Error::new(CodeError::new_err_code_msg( + error_code::DATABASE_QUERY_ERROR, + &e.to_string(), + )) + })? + .ok_or_else(|| { + anyhow::Error::new(CodeError::new_err_code(error_code::USER_NOT_EXIST)) + })?; + + let mut updated = existing; + updated.auth_identifier = req.auth_identifier; + updated.updated_at = Utc::now().timestamp_millis(); + + repos + .user + .update_auth_method(&updated) + .await + .map_err(|e| { + anyhow::Error::new(CodeError::new_err_code_msg( + error_code::DATABASE_UPDATE_ERROR, + &e.to_string(), + )) + }) +} diff --git a/src/service/admin/user/update_user_basic_info_service.rs b/src/service/admin/user/update_user_basic_info_service.rs new file mode 100644 index 00000000..e0193d1e --- /dev/null +++ b/src/service/admin/user/update_user_basic_info_service.rs @@ -0,0 +1,55 @@ +use std::sync::Arc; + +use chrono::Utc; + +use crate::model::dto::user::UpdateUserBasiceInfoRequest; +use crate::model::entity::user::User; +use crate::repository::Repositories; +use result::code_error::CodeError; +use result::error_code; + +pub async fn update_user_basic_info( + repos: &Arc, + req: UpdateUserBasiceInfoRequest, +) -> Result { + let mut user = repos + .user + .find_one_user(req.user_id) + .await + .map_err(|e| { + anyhow::Error::new(CodeError::new_err_code_msg( + error_code::DATABASE_QUERY_ERROR, + &e.to_string(), + )) + })?; + + if let Some(avatar) = req.avatar { + user.avatar = avatar; + } + if let Some(refer_code) = req.refer_code { + user.refer_code = refer_code; + } + if let Some(pwd) = req.password { + if !pwd.is_empty() { + let hash = password::encode_password(&pwd) + .map_err(|e| anyhow::Error::new(CodeError::new_err_msg(&e.to_string())))?; + user.password = hash; + } + } + user.balance = req.balance; + user.commission = req.commission; + user.referral_percentage = req.referral_percentage as i16; + user.only_first_purchase = req.only_first_purchase; + user.gift_amount = req.gift_amount; + user.referer_id = req.referer_id; + user.enable = req.enable; + user.is_admin = req.is_admin; + user.updated_at = Utc::now().timestamp_millis(); + + repos.user.update_user(&user).await.map_err(|e| { + anyhow::Error::new(CodeError::new_err_code_msg( + error_code::DATABASE_UPDATE_ERROR, + &e.to_string(), + )) + }) +} diff --git a/src/service/admin/user/update_user_device_service.rs b/src/service/admin/user/update_user_device_service.rs new file mode 100644 index 00000000..5b3e7617 --- /dev/null +++ b/src/service/admin/user/update_user_device_service.rs @@ -0,0 +1,21 @@ +use std::sync::Arc; + +use chrono::Utc; + +use crate::model::entity::user::Device; +use crate::repository::Repositories; +use result::code_error::CodeError; +use result::error_code; + +pub async fn update_user_device( + repos: &Arc, + mut device: Device, +) -> Result { + device.updated_at = Utc::now().timestamp_millis(); + repos.user.update_device(&device).await.map_err(|e| { + anyhow::Error::new(CodeError::new_err_code_msg( + error_code::DATABASE_UPDATE_ERROR, + &e.to_string(), + )) + }) +} diff --git a/src/service/admin/user/update_user_notify_setting_service.rs b/src/service/admin/user/update_user_notify_setting_service.rs new file mode 100644 index 00000000..003d93ac --- /dev/null +++ b/src/service/admin/user/update_user_notify_setting_service.rs @@ -0,0 +1,38 @@ +use std::sync::Arc; + +use chrono::Utc; + +use crate::model::dto::user::UpdateUserNotifySettingRequest; +use crate::model::entity::user::User; +use crate::repository::Repositories; +use result::code_error::CodeError; +use result::error_code; + +pub async fn update_user_notify_setting( + repos: &Arc, + req: UpdateUserNotifySettingRequest, +) -> Result { + let mut user = repos + .user + .find_one_user(req.user_id) + .await + .map_err(|e| { + anyhow::Error::new(CodeError::new_err_code_msg( + error_code::DATABASE_QUERY_ERROR, + &e.to_string(), + )) + })?; + + user.enable_balance_notify = req.enable_balance_notify; + user.enable_login_notify = req.enable_login_notify; + user.enable_subscribe_notify = req.enable_subscribe_notify; + user.enable_trade_notify = req.enable_trade_notify; + user.updated_at = Utc::now().timestamp_millis(); + + repos.user.update_user(&user).await.map_err(|e| { + anyhow::Error::new(CodeError::new_err_code_msg( + error_code::DATABASE_UPDATE_ERROR, + &e.to_string(), + )) + }) +} diff --git a/src/service/admin/user/update_user_subscribe_service.rs b/src/service/admin/user/update_user_subscribe_service.rs new file mode 100644 index 00000000..25f52de2 --- /dev/null +++ b/src/service/admin/user/update_user_subscribe_service.rs @@ -0,0 +1,24 @@ +use std::sync::Arc; + +use chrono::Utc; + +use crate::model::entity::user::UserSubscribe; +use crate::repository::Repositories; +use result::code_error::CodeError; +use result::error_code; + +/// Update a UserSubscribe. Caller passes a fully-formed `UserSubscribe` (typically +/// obtained via `find_one_subscribe` and then mutated). Only `updated_at` is +/// refreshed here. +pub async fn update_user_subscribe( + repos: &Arc, + mut sub: UserSubscribe, +) -> Result { + sub.updated_at = Utc::now().timestamp_millis(); + repos.user.update_subscribe(&sub).await.map_err(|e| { + anyhow::Error::new(CodeError::new_err_code_msg( + error_code::DATABASE_UPDATE_ERROR, + &e.to_string(), + )) + }) +} diff --git a/src/service/auth/bind_device_service.rs b/src/service/auth/bind_device_service.rs new file mode 100644 index 00000000..792a49ef --- /dev/null +++ b/src/service/auth/bind_device_service.rs @@ -0,0 +1,247 @@ +use std::sync::Arc; + +use anyhow::anyhow; +use chrono::Utc; + +use crate::cache::Cache; +use crate::config::Config; +use crate::model::entity::user::{AuthMethods, Device}; +use crate::repository::Repositories; +use result::code_error::CodeError; +use result::error_code; + +pub struct BindDeviceService { + repos: Arc, + _config: Arc, + _cache: Arc, +} + +impl BindDeviceService { + pub fn new(repos: Arc, config: Arc, cache: Arc) -> Self { + Self { + repos, + _config: config, + _cache: cache, + } + } + + pub async fn bind_device_to_user( + &self, + identifier: &str, + ip: &str, + user_agent: &str, + current_user_id: i64, + ) -> Result<(), anyhow::Error> { + if identifier.is_empty() { + return Ok(()); + } + + let now = Utc::now().timestamp_millis(); + + match self + .repos + .user + .find_one_device_by_identifier(identifier) + .await + { + Ok(None) => { + self.create_device_for_user(identifier, ip, user_agent, current_user_id, now) + .await?; + } + Ok(Some(existing)) => { + if existing.user_id == current_user_id { + self.repos + .user + .update_device(&Device { + id: existing.id, + ip: ip.to_string(), + user_id: current_user_id, + user_agent: Some(user_agent.to_string()), + identifier: identifier.to_string(), + online: existing.online, + enabled: existing.enabled, + created_at: existing.created_at, + updated_at: now, + }) + .await + .map_err(|e| { + anyhow!(CodeError::new_err_code_msg( + error_code::DATABASE_UPDATE_ERROR, + e.to_string() + )) + })?; + } else { + self.rebind_device_to_new_user( + identifier, + ip, + user_agent, + current_user_id, + existing.user_id, + now, + ) + .await?; + } + } + Err(e) => { + return Err(anyhow!(CodeError::new_err_code_msg( + error_code::DATABASE_QUERY_ERROR, + e.to_string() + ))); + } + } + + Ok(()) + } + + async fn create_device_for_user( + &self, + identifier: &str, + ip: &str, + user_agent: &str, + user_id: i64, + now: i64, + ) -> Result<(), anyhow::Error> { + let _ = self + .repos + .user + .insert_auth_method(&AuthMethods { + id: 0, + user_id, + auth_type: "device".to_string(), + auth_identifier: identifier.to_string(), + verified: true, + created_at: now, + updated_at: now, + }) + .await + .map_err(|e| { + anyhow!(CodeError::new_err_code_msg( + error_code::DATABASE_INSERT_ERROR, + e.to_string() + )) + })?; + + let _ = self + .repos + .user + .insert_device(&Device { + id: 0, + ip: ip.to_string(), + user_id, + user_agent: Some(user_agent.to_string()), + identifier: identifier.to_string(), + online: false, + enabled: true, + created_at: now, + updated_at: now, + }) + .await + .map_err(|e| { + anyhow!(CodeError::new_err_code_msg( + error_code::DATABASE_INSERT_ERROR, + e.to_string() + )) + })?; + + Ok(()) + } + + async fn rebind_device_to_new_user( + &self, + identifier: &str, + ip: &str, + user_agent: &str, + new_user_id: i64, + old_user_id: i64, + now: i64, + ) -> Result<(), anyhow::Error> { + let other_methods = self + .repos + .user + .find_user_auth_methods(old_user_id) + .await + .map_err(|e| { + anyhow!(CodeError::new_err_code_msg( + error_code::DATABASE_QUERY_ERROR, + e.to_string() + )) + })?; + + let non_device_methods: Vec<_> = other_methods + .iter() + .filter(|m| m.auth_type != "device") + .collect(); + + if non_device_methods.is_empty() { + let old_user = self.repos.user.find_one_user(old_user_id).await.map_err(|e| { + anyhow!(CodeError::new_err_code_msg( + error_code::DATABASE_QUERY_ERROR, + e.to_string() + )) + })?; + let mut updated = old_user; + updated.enable = false; + self.repos.user.update_user(&updated).await.map_err(|e| { + anyhow!(CodeError::new_err_code_msg( + error_code::DATABASE_UPDATE_ERROR, + e.to_string() + )) + })?; + } + + self.repos + .user + .delete_user_auth_method_by_identifier("device", identifier) + .await + .map_err(|e| { + anyhow!(CodeError::new_err_code_msg( + error_code::DATABASE_DELETED_ERROR, + e.to_string() + )) + })?; + + let _ = self + .repos + .user + .insert_auth_method(&AuthMethods { + id: 0, + user_id: new_user_id, + auth_type: "device".to_string(), + auth_identifier: identifier.to_string(), + verified: true, + created_at: now, + updated_at: now, + }) + .await + .map_err(|e| { + anyhow!(CodeError::new_err_code_msg( + error_code::DATABASE_INSERT_ERROR, + e.to_string() + )) + })?; + + let _ = self + .repos + .user + .insert_device(&Device { + id: 0, + ip: ip.to_string(), + user_id: new_user_id, + user_agent: Some(user_agent.to_string()), + identifier: identifier.to_string(), + online: false, + enabled: true, + created_at: now, + updated_at: now, + }) + .await + .map_err(|e| { + anyhow!(CodeError::new_err_code_msg( + error_code::DATABASE_INSERT_ERROR, + e.to_string() + )) + })?; + + Ok(()) + } +} diff --git a/src/service/auth/check_user_service.rs b/src/service/auth/check_user_service.rs new file mode 100644 index 00000000..6f7842ff --- /dev/null +++ b/src/service/auth/check_user_service.rs @@ -0,0 +1,36 @@ +use std::sync::Arc; + +use anyhow::anyhow; + +use crate::config::Config; +use crate::model::dto::auth::{CheckUserRequest, CheckUserResponse}; +use crate::repository::Repositories; +use result::code_error::CodeError; +use result::error_code; + +pub struct CheckUserService { + repos: Arc, + _config: Arc, +} + +impl CheckUserService { + pub fn new(repos: Arc, config: Arc) -> Self { + Self { + repos, + _config: config, + } + } + + pub async fn check(&self, req: CheckUserRequest) -> Result { + let auth_method = self + .repos + .user + .find_auth_method_by_open_id("email", &req.email) + .await + .map_err(|e| anyhow!(CodeError::new_err_code_msg(error_code::DATABASE_QUERY_ERROR, e.to_string())))?; + + Ok(CheckUserResponse { + exist: auth_method.is_some(), + }) + } +} diff --git a/src/service/auth/check_user_telephone_service.rs b/src/service/auth/check_user_telephone_service.rs new file mode 100644 index 00000000..a7bd570f --- /dev/null +++ b/src/service/auth/check_user_telephone_service.rs @@ -0,0 +1,41 @@ +use std::sync::Arc; + +use anyhow::anyhow; + +use crate::config::Config; +use crate::model::dto::auth::{TelephoneCheckUserRequest, TelephoneCheckUserResponse}; +use crate::repository::Repositories; +use result::code_error::CodeError; +use result::error_code; + +pub struct CheckUserTelephoneService { + repos: Arc, + _config: Arc, +} + +impl CheckUserTelephoneService { + pub fn new(repos: Arc, config: Arc) -> Self { + Self { + repos, + _config: config, + } + } + + pub async fn check( + &self, + req: TelephoneCheckUserRequest, + ) -> Result { + let phone = format!("+{}{}", req.telephone_area_code, req.telephone); + + let auth_method = self + .repos + .user + .find_auth_method_by_open_id("mobile", &phone) + .await + .map_err(|e| anyhow!(CodeError::new_err_code_msg(error_code::DATABASE_QUERY_ERROR, e.to_string())))?; + + Ok(TelephoneCheckUserResponse { + exist: auth_method.is_some(), + }) + } +} diff --git a/src/service/auth/device_login_service.rs b/src/service/auth/device_login_service.rs new file mode 100644 index 00000000..16bf4b1a --- /dev/null +++ b/src/service/auth/device_login_service.rs @@ -0,0 +1,139 @@ +use std::sync::Arc; + +use anyhow::anyhow; +use chrono::Utc; +use uuid::Uuid; + +use crate::cache::Cache; +use crate::config::cache_key::SESSION_ID_KEY; +use crate::config::Config; +use crate::model::dto::auth::{DeviceLoginRequest, LoginResponse}; +use crate::model::entity::user::{AuthMethods, Device, User, UserSubscribe}; +use crate::repository::Repositories; +use crate::service::telemetry::Telemetry; +use result::code_error::CodeError; +use result::error_code; + +pub struct DeviceLoginService { + repos: Arc, + config: Arc, + cache: Arc, +} + +impl DeviceLoginService { + pub fn new(repos: Arc, config: Arc, cache: Arc) -> Self { + Self { repos, config, cache } + } + + pub async fn login(&self, req: DeviceLoginRequest) -> Result { + if !self.config.device.enable { + return Err(anyhow!(CodeError::new_err_msg("Device login is disabled"))); + } + + let user = match self.repos.user.find_one_device_by_identifier(&req.identifier).await { + Ok(None) => self.register_user_and_device(&req).await?, + Ok(Some(device)) => self.repos.user.find_one_user(device.user_id).await + .map_err(|e| anyhow!(CodeError::new_err_code_msg(error_code::DATABASE_QUERY_ERROR, e.to_string())))?, + Err(e) => return Err(anyhow!(CodeError::new_err_code_msg(error_code::DATABASE_QUERY_ERROR, e.to_string()))), + }; + + let session_id = Uuid::new_v4().to_string(); + let (claims, seconds) = jwt::Claims::new(user.id, session_id.clone(), "device".to_string()); + + let token = jwt::generate_token(&claims, &self.config.jwt_auth.access_secret) + .map_err(|e| anyhow!(CodeError::new_err_code_msg(error_code::ERROR, &e.to_string())))?; + + let session_key = format!("{}:{}", SESSION_ID_KEY, session_id); + self.cache.set_ex(&session_key, &user.id.to_string(), seconds).await + .map_err(|e| anyhow!(CodeError::new_err_code_msg(error_code::ERROR, &e.to_string())))?; + + Telemetry::login(&self.repos, user.id, "device", &req.ip, &req.user_agent, true).await; + + Ok(LoginResponse { token }) + } + + async fn register_user_and_device(&self, req: &DeviceLoginRequest) -> Result { + let now = Utc::now().timestamp_millis(); + + let mut user = User { + id: 0, password: String::new(), algo: String::new(), salt: None, + avatar: String::new(), balance: 0, refer_code: String::new(), + referer_id: 0, commission: 0, + referral_percentage: self.config.invite.referral_percentage as i16, + only_first_purchase: self.config.invite.only_first_purchase, + gift_amount: 0, enable: true, is_admin: false, + enable_balance_notify: false, enable_login_notify: false, + enable_subscribe_notify: false, enable_trade_notify: false, + rules: None, created_at: now, updated_at: now, deleted_at: None, + }; + + user = self.repos.user.insert_user(&user).await + .map_err(|e| anyhow!(CodeError::new_err_code_msg(error_code::DATABASE_INSERT_ERROR, e.to_string())))?; + + let refer_code = format!("U{:X}", user.id); + let mut update_user = user.clone(); + update_user.refer_code = refer_code; + self.repos.user.update_user(&update_user).await + .map_err(|e| anyhow!(CodeError::new_err_code_msg(error_code::DATABASE_UPDATE_ERROR, e.to_string())))?; + + let _ = self.repos.user.insert_auth_method(&AuthMethods { + id: 0, user_id: user.id, auth_type: "device".to_string(), + auth_identifier: req.identifier.clone(), verified: true, + created_at: now, updated_at: now, + }).await.map_err(|e| anyhow!(CodeError::new_err_code_msg(error_code::DATABASE_INSERT_ERROR, e.to_string())))?; + + let _ = self.repos.user.insert_device(&Device { + id: 0, ip: req.ip.clone(), user_id: user.id, + user_agent: Some(req.user_agent.clone()), + identifier: req.identifier.clone(), online: false, enabled: true, + created_at: now, updated_at: now, + }).await.map_err(|e| anyhow!(CodeError::new_err_code_msg(error_code::DATABASE_INSERT_ERROR, e.to_string())))?; + + let trial_sub = if self.config.register.enable_trial { + Some(self.activate_trial(user.id).await?) + } else { + None + }; + + if let Some(ref sub) = trial_sub { + super::trial_cache::clear_trial_subscribe_cache(&self.cache, sub); + } + + Telemetry::register(&self.repos, user.id, "device", &req.identifier, &req.ip, &req.user_agent).await; + + Ok(user) + } + + async fn activate_trial(&self, user_id: i64) -> Result { + let sub = self.repos.subscribe.find_one(self.config.register.trial_subscribe).await + .map_err(|e| anyhow!(CodeError::new_err_code_msg(error_code::DATABASE_QUERY_ERROR, e.to_string())))?; + + let now = Utc::now(); + let expire_time = add_time(&self.config.register.trial_time_unit, self.config.register.trial_time, now); + let token = format!("Trial-{}-{}", user_id, Uuid::new_v4()); + + let user_sub = UserSubscribe { + id: 0, user_id, order_id: 0, subscribe_id: sub.id, + start_time: now.timestamp_millis(), expire_time: expire_time.timestamp_millis(), + finished_at: None, traffic: sub.traffic, download: 0, upload: 0, + token, uuid: Uuid::new_v4().to_string(), status: 1, note: String::new(), + created_at: now.timestamp_millis(), updated_at: now.timestamp_millis(), + }; + + self.repos.user.insert_subscribe(&user_sub).await + .map_err(|e| anyhow!(CodeError::new_err_code_msg(error_code::DATABASE_INSERT_ERROR, e.to_string())))?; + + Ok(user_sub) + } +} + +fn add_time(unit: &str, amount: i64, from: chrono::DateTime) -> chrono::DateTime { + match unit { + "hour" => from + chrono::Duration::hours(amount), + "day" => from + chrono::Duration::days(amount), + "week" => from + chrono::Duration::weeks(amount), + "month" => from.checked_add_months(chrono::Months::new(amount as u32)).unwrap_or(from), + "year" => from.checked_add_months(chrono::Months::new((amount * 12) as u32)).unwrap_or(from), + _ => from + chrono::Duration::days(amount), + } +} diff --git a/src/service/auth/mod.rs b/src/service/auth/mod.rs new file mode 100644 index 00000000..7af6dba7 --- /dev/null +++ b/src/service/auth/mod.rs @@ -0,0 +1,12 @@ +pub mod bind_device_service; +pub mod check_user_service; +pub mod check_user_telephone_service; +pub mod device_login_service; +pub mod oauth; +pub mod reset_password_service; +pub mod telephone_login_service; +pub mod telephone_reset_password_service; +pub mod telephone_user_register_service; +pub mod trial_cache; +pub mod user_login_service; +pub mod user_register_service; diff --git a/src/service/auth/oauth/apple_login_callback_service.rs b/src/service/auth/oauth/apple_login_callback_service.rs new file mode 100644 index 00000000..af2d77f9 --- /dev/null +++ b/src/service/auth/oauth/apple_login_callback_service.rs @@ -0,0 +1,56 @@ +//! Apple OAuth callback — stores state in Redis and redirects the browser. +//! +//! Apple's form_post callback POSTs `code` + `state` to our server. +//! We look up the original redirect URL from Redis and redirect the client. + +use std::sync::Arc; + +use anyhow::anyhow; + +use crate::cache::Cache; +use crate::config::Config; +use crate::model::dto::auth::AppleLoginCallbackRequest; +use crate::repository::Repositories; +use result::code_error::CodeError; +use result::error_code; + +pub struct AppleLoginCallbackService { + _repos: Arc, + _config: Arc, + cache: Arc, +} + +impl AppleLoginCallbackService { + pub fn new(repos: Arc, config: Arc, cache: Arc) -> Self { + Self { _repos: repos, _config: config, cache } + } + + /// Returns the redirect URL the browser should be sent to. + /// + /// Go behaviour: look up "telegram:" in Redis to find the original + /// redirect URL, then redirect to `{redirect}?code={code}&state={state}`. + pub async fn callback( + &self, + req: AppleLoginCallbackRequest, + ) -> Result { + let redis_key = format!("telegram:{}", req.state); + + let redirect = self.cache.get(&redis_key).await + .map_err(|e| anyhow!(CodeError::new_err_code_msg(error_code::ERROR, e.to_string())))? + .ok_or_else(|| anyhow!(CodeError::new_err_code_msg( + error_code::ERROR, + "invalid or expired apple oauth state", + )))?; + + // Delete state key after use (one-time) + let _ = self.cache.del(&redis_key).await; + + let url = format!( + "{}?code={}&state={}", + redirect, + urlencoding::encode(&req.code), + urlencoding::encode(&req.state), + ); + Ok(url) + } +} diff --git a/src/service/auth/oauth/mod.rs b/src/service/auth/oauth/mod.rs new file mode 100644 index 00000000..968bb995 --- /dev/null +++ b/src/service/auth/oauth/mod.rs @@ -0,0 +1,4 @@ +pub mod apple_login_callback_service; +pub mod o_auth_login_get_token_service; +pub mod o_auth_login_service; +pub mod trial_cache; diff --git a/src/service/auth/oauth/o_auth_login_get_token_service.rs b/src/service/auth/oauth/o_auth_login_get_token_service.rs new file mode 100644 index 00000000..7c11139e --- /dev/null +++ b/src/service/auth/oauth/o_auth_login_get_token_service.rs @@ -0,0 +1,375 @@ +//! OAuth token exchange — mirrors Go `oAuthLoginGetTokenLogic.go`. +//! +//! Flow: +//! 1. Route to provider handler (google / apple / telegram) +//! 2. Validate state from Redis, exchange code for tokens +//! 3. Extract user info (open_id, email, avatar) +//! 4. findOrRegisterUser in DB +//! 5. Issue JWT + Redis session +//! 6. Record login / register audit logs via Telemetry + +use std::sync::Arc; + +use anyhow::anyhow; +use chrono::Utc; +use uuid::Uuid; + +use crate::cache::Cache; +use crate::config::cache_key::SESSION_ID_KEY; +use crate::config::Config; +use crate::model::dto::auth::{LoginResponse, OAuthLoginGetTokenRequest}; +use crate::model::entity::user::{AuthMethods, User, UserSubscribe}; +use crate::repository::Repositories; +use crate::service::telemetry::Telemetry; +use result::code_error::CodeError; +use result::error_code; + +const TELEGRAM_DOMAIN: &str = "ppanel.com"; +const AUTH_EXPIRE_SECS: i64 = 86400; + +pub struct OAuthLoginGetTokenService { + repos: Arc, + config: Arc, + cache: Arc, +} + +impl OAuthLoginGetTokenService { + pub fn new(repos: Arc, config: Arc, cache: Arc) -> Self { + Self { repos, config, cache } + } + + pub async fn get_token( + &self, + req: OAuthLoginGetTokenRequest, + ip: &str, + user_agent: &str, + ) -> Result { + let (auth_type, open_id, email, avatar) = match req.method.as_str() { + "google" => self.handle_google(&req).await?, + "apple" => self.handle_apple(&req).await?, + "telegram" => self.handle_telegram(&req).await?, + other => return Err(anyhow!(CodeError::new_err_code_msg( + error_code::GET_AUTHENTICATOR_ERROR, + format!("unsupported oauth method: {other}"), + ))), + }; + + let user = self.find_or_register_user( + &auth_type, &open_id, email.as_deref(), avatar.as_deref(), ip, user_agent, + ).await?; + + let token = self.issue_token(user.id).await?; + + Telemetry::login(&self.repos, user.id, &auth_type, ip, user_agent, true).await; + + Ok(LoginResponse { token }) + } + + // ── Google ──────────────────────────────────────────────────────────── + + async fn handle_google( + &self, + req: &OAuthLoginGetTokenRequest, + ) -> Result<(String, String, Option, Option), anyhow::Error> { + let callback = req.callback.as_object() + .ok_or_else(|| anyhow!(CodeError::new_err_code(error_code::INVALID_PARAMS)))?; + + let code = callback.get("code").and_then(|v| v.as_str()).unwrap_or(""); + let state = callback.get("state").and_then(|v| v.as_str()).unwrap_or(""); + + let redirect = self.validate_state("google", state).await?; + + let method = self.repos.auth.find_one_by_method("google").await + .map_err(|e| anyhow!(CodeError::new_err_code_msg(error_code::DATABASE_QUERY_ERROR, e.to_string())))?; + + let cfg: oauth::GoogleConfig = serde_json::from_str(&method.config.to_string()) + .map_err(|e| anyhow!(CodeError::new_err_code_msg(error_code::ERROR, e.to_string())))?; + + // Exchange code for tokens — arctic-oauth Google requires a code_verifier. + // In our stateless server flow the verifier is not stored, so we use an empty + // verifier string (Google still accepts it when PKCE was not enforced at auth time). + let google = oauth::Google::new(&cfg.client_id, &cfg.client_secret, &redirect); + let tokens = google.validate_authorization_code(code, "").await + .map_err(|e| anyhow!(CodeError::new_err_code_msg(error_code::ERROR, e.to_string())))?; + + let info = oauth::OAuthUserInfo::from_google(&tokens) + .map_err(|e| anyhow!(CodeError::new_err_code_msg(error_code::ERROR, e.to_string())))?; + + Ok(("google".into(), info.open_id, info.email, info.picture)) + } + + // ── Apple ───────────────────────────────────────────────────────────── + + async fn handle_apple( + &self, + req: &OAuthLoginGetTokenRequest, + ) -> Result<(String, String, Option, Option), anyhow::Error> { + let callback = req.callback.as_object() + .ok_or_else(|| anyhow!(CodeError::new_err_code(error_code::INVALID_PARAMS)))?; + + let code = callback.get("code").and_then(|v| v.as_str()).unwrap_or(""); + let state = callback.get("state").and_then(|v| v.as_str()).unwrap_or(""); + + // Apple state is stored under "telegram:" key (matches Go behaviour) + let _redirect = self.validate_state("telegram", state).await?; + + let method = self.repos.auth.find_one_by_method("apple").await + .map_err(|e| anyhow!(CodeError::new_err_code_msg(error_code::DATABASE_QUERY_ERROR, e.to_string())))?; + + let cfg: oauth::AppleConfig = serde_json::from_str(&method.config.to_string()) + .map_err(|e| anyhow!(CodeError::new_err_code_msg(error_code::ERROR, e.to_string())))?; + + // Arctic Apple requires a PKCS#8 DER private key; `client_secret` holds the PEM + let pkcs8_der = pem_to_der(&cfg.client_secret) + .map_err(|e| anyhow!(CodeError::new_err_code_msg(error_code::ERROR, e)))?; + + let apple = oauth::Apple::new( + &cfg.client_id, &cfg.team_id, &cfg.key_id, &pkcs8_der, &cfg.redirect_url, + ).map_err(|e| anyhow!(CodeError::new_err_code_msg(error_code::ERROR, e.to_string())))?; + + let tokens = apple.validate_authorization_code(code).await + .map_err(|e| anyhow!(CodeError::new_err_code_msg(error_code::ERROR, e.to_string())))?; + + let info = oauth::OAuthUserInfo::from_apple(&tokens) + .map_err(|e| anyhow!(CodeError::new_err_code_msg(error_code::ERROR, e.to_string())))?; + + Ok(("apple".into(), info.open_id, info.email, info.picture)) + } + + // ── Telegram ────────────────────────────────────────────────────────── + + async fn handle_telegram( + &self, + req: &OAuthLoginGetTokenRequest, + ) -> Result<(String, String, Option, Option), anyhow::Error> { + let callback = req.callback.as_object() + .ok_or_else(|| anyhow!(CodeError::new_err_code(error_code::INVALID_PARAMS)))?; + + let tg_auth_result = callback + .get("tgAuthResult") + .and_then(|v| v.as_str()) + .unwrap_or(""); + + let method = self.repos.auth.find_one_by_method("telegram").await + .map_err(|e| anyhow!(CodeError::new_err_code_msg(error_code::DATABASE_QUERY_ERROR, e.to_string())))?; + + let cfg: oauth::TelegramConfig = serde_json::from_str(&method.config.to_string()) + .map_err(|e| anyhow!(CodeError::new_err_code_msg(error_code::ERROR, e.to_string())))?; + + let auth_data = oauth::parse_base64_and_validate( + tg_auth_result, + cfg.bot_token.as_bytes(), + ).map_err(|e| anyhow!(CodeError::new_err_code_msg(error_code::ERROR, e.to_string())))?; + + // Check 24-hour expiry (mirrors Go AuthExpire = 86400) + let now = Utc::now().timestamp(); + if now - auth_data.auth_date > AUTH_EXPIRE_SECS { + return Err(anyhow!(CodeError::new_err_code_msg( + error_code::ERROR, "telegram auth date expired", + ))); + } + + let info = oauth::OAuthUserInfo::from_telegram(&auth_data); + let email = Some(format!("{}@{}", auth_data.id, TELEGRAM_DOMAIN)); + let avatar = info.picture.clone(); + + Ok(("telegram".into(), info.open_id, email, avatar)) + } + + // ── find or register user ───────────────────────────────────────────── + + async fn find_or_register_user( + &self, + auth_type: &str, + open_id: &str, + email: Option<&str>, + avatar: Option<&str>, + ip: &str, + user_agent: &str, + ) -> Result { + // Try to find existing auth method + if let Some(am) = self.repos.user.find_auth_method_by_open_id(auth_type, open_id).await + .map_err(|e| anyhow!(CodeError::new_err_code_msg(error_code::DATABASE_QUERY_ERROR, e.to_string())))? + { + let user = self.repos.user.find_one_user(am.user_id).await + .map_err(|e| anyhow!(CodeError::new_err_code_msg(error_code::DATABASE_QUERY_ERROR, e.to_string())))?; + return Ok(user); + } + + // Not found — register + self.register_user(auth_type, open_id, email, avatar, ip, user_agent).await + } + + async fn register_user( + &self, + auth_type: &str, + open_id: &str, + email: Option<&str>, + avatar: Option<&str>, + ip: &str, + user_agent: &str, + ) -> Result { + if self.config.invite.forced_invite { + return Err(anyhow!(CodeError::new_err_code(error_code::INVITE_CODE_ERROR))); + } + + // Verify email not already taken + if let Some(em) = email { + if let Some(_existing) = self.repos.user.find_one_by_email(em).await + .map_err(|e| anyhow!(CodeError::new_err_code_msg(error_code::DATABASE_QUERY_ERROR, e.to_string())))? + { + return Err(anyhow!(CodeError::new_err_code(error_code::USER_EXIST))); + } + } + + let now = Utc::now().timestamp_millis(); + let mut user = User { + id: 0, + password: String::new(), + algo: String::new(), + salt: None, + avatar: avatar.unwrap_or("").to_string(), + balance: 0, + refer_code: String::new(), + referer_id: 0, + commission: 0, + referral_percentage: self.config.invite.referral_percentage as i16, + only_first_purchase: self.config.invite.only_first_purchase, + gift_amount: 0, + enable: true, + is_admin: false, + enable_balance_notify: false, + enable_login_notify: false, + enable_subscribe_notify: false, + enable_trade_notify: false, + rules: None, + created_at: now, + updated_at: now, + deleted_at: None, + }; + + user = self.repos.user.insert_user(&user).await + .map_err(|e| anyhow!(CodeError::new_err_code_msg(error_code::DATABASE_INSERT_ERROR, e.to_string())))?; + + let refer_code = format!("U{:X}", user.id); + let mut update_user = user.clone(); + update_user.refer_code = refer_code; + self.repos.user.update_user(&update_user).await + .map_err(|e| anyhow!(CodeError::new_err_code_msg(error_code::DATABASE_UPDATE_ERROR, e.to_string())))?; + + // Create primary auth method (e.g. "google", "apple", "telegram") + self.repos.user.insert_auth_method(&AuthMethods { + id: 0, + user_id: user.id, + auth_type: auth_type.to_string(), + auth_identifier: open_id.to_string(), + verified: true, + created_at: now, + updated_at: now, + }).await + .map_err(|e| anyhow!(CodeError::new_err_code_msg(error_code::DATABASE_INSERT_ERROR, e.to_string())))?; + + // Also link email auth method when an email is available + if let Some(em) = email { + let _ = self.repos.user.insert_auth_method(&AuthMethods { + id: 0, + user_id: user.id, + auth_type: "email".to_string(), + auth_identifier: em.to_string(), + verified: true, + created_at: now, + updated_at: now, + }).await; + } + + // Activate trial if configured + if self.config.register.enable_trial { + let trial = self.activate_trial(user.id).await; + if let Ok(ref sub) = trial { + super::trial_cache::clear_trial_subscribe_cache(&self.cache, sub); + } + } + + Telemetry::register(&self.repos, user.id, auth_type, open_id, ip, user_agent).await; + + Ok(user) + } + + async fn activate_trial(&self, user_id: i64) -> Result { + let sub = self.repos.subscribe.find_one(self.config.register.trial_subscribe).await + .map_err(|e| anyhow!(CodeError::new_err_code_msg(error_code::DATABASE_QUERY_ERROR, e.to_string())))?; + + let now = Utc::now(); + let expire_time = add_time( + &self.config.register.trial_time_unit, + self.config.register.trial_time, + now, + ); + let token = format!("Trial-{}-{}", user_id, Uuid::new_v4()); + + let user_sub = UserSubscribe { + id: 0, user_id, order_id: 0, subscribe_id: sub.id, + start_time: now.timestamp_millis(), + expire_time: expire_time.timestamp_millis(), + finished_at: None, traffic: sub.traffic, download: 0, upload: 0, + token, uuid: Uuid::new_v4().to_string(), status: 1, note: String::new(), + created_at: now.timestamp_millis(), updated_at: now.timestamp_millis(), + }; + + self.repos.user.insert_subscribe(&user_sub).await + .map_err(|e| anyhow!(CodeError::new_err_code_msg(error_code::DATABASE_INSERT_ERROR, e.to_string())))?; + + Ok(user_sub) + } + + // ── token issuance ──────────────────────────────────────────────────── + + async fn issue_token(&self, user_id: i64) -> Result { + let session_id = Uuid::new_v4().to_string(); + let (claims, seconds) = jwt::Claims::new(user_id, session_id.clone(), String::new()); + let token = jwt::generate_token(&claims, &self.config.jwt_auth.access_secret) + .map_err(|e| anyhow!(CodeError::new_err_code_msg(error_code::ERROR, &e.to_string())))?; + let session_key = format!("{}:{}", SESSION_ID_KEY, session_id); + self.cache.set_ex(&session_key, &user_id.to_string(), seconds).await + .map_err(|e| anyhow!(CodeError::new_err_code_msg(error_code::ERROR, &e.to_string())))?; + Ok(token) + } + + // ── state validation ────────────────────────────────────────────────── + + async fn validate_state(&self, provider: &str, state: &str) -> Result { + let redis_key = format!("{}:{}", provider, state); + self.cache.get(&redis_key).await + .map_err(|e| anyhow!(CodeError::new_err_code_msg(error_code::ERROR, e.to_string())))? + .ok_or_else(|| anyhow!(CodeError::new_err_code_msg( + error_code::ERROR, + format!("invalid or expired state for provider: {}", provider), + ))) + } +} + +// ── helpers ─────────────────────────────────────────────────────────────────── + +fn add_time(unit: &str, amount: i64, from: chrono::DateTime) -> chrono::DateTime { + match unit { + "hour" => from + chrono::Duration::hours(amount), + "day" => from + chrono::Duration::days(amount), + "week" => from + chrono::Duration::weeks(amount), + "month" => from.checked_add_months(chrono::Months::new(amount as u32)).unwrap_or(from), + "year" => from.checked_add_months(chrono::Months::new((amount * 12) as u32)).unwrap_or(from), + _ => from + chrono::Duration::days(amount), + } +} + +/// Decode a PEM-encoded private key to DER bytes (strips header/footer/newlines). +fn pem_to_der(pem: &str) -> Result, String> { + let body = pem + .lines() + .filter(|l| !l.starts_with("-----")) + .collect::>() + .join(""); + use base64::Engine; + base64::engine::general_purpose::STANDARD + .decode(body.as_bytes()) + .map_err(|e| format!("base64 decode error: {e}")) +} diff --git a/src/service/auth/oauth/o_auth_login_service.rs b/src/service/auth/oauth/o_auth_login_service.rs new file mode 100644 index 00000000..7508155b --- /dev/null +++ b/src/service/auth/oauth/o_auth_login_service.rs @@ -0,0 +1,134 @@ +//! OAuth login — generates provider redirect URLs. +//! +//! Mirrors Go `oAuthLoginLogic.go`: +//! - Loads provider config from `auth_method` table +//! - Generates a random state code, stores it in Redis (5 min TTL) +//! - Builds and returns the provider's authorization URL + +use std::sync::Arc; + +use anyhow::anyhow; + +use crate::cache::Cache; +use crate::config::Config; +use crate::model::dto::auth::{OAthLoginRequest, OAuthLoginResponse}; +use crate::repository::Repositories; +use result::code_error::CodeError; +use result::error_code; + +const STATE_TTL_SECS: i64 = 300; // 5 minutes + +pub struct OAuthLoginService { + repos: Arc, + _config: Arc, + cache: Arc, +} + +impl OAuthLoginService { + pub fn new(repos: Arc, config: Arc, cache: Arc) -> Self { + Self { repos, _config: config, cache } + } + + pub async fn login(&self, req: OAthLoginRequest) -> Result { + let redirect = match req.method.as_str() { + "google" => self.google(&req).await?, + "apple" => self.apple(&req).await?, + "telegram" => self.telegram(&req).await?, + other => return Err(anyhow!(CodeError::new_err_code_msg( + error_code::GET_AUTHENTICATOR_ERROR, + format!("unsupported oauth method: {other}"), + ))), + }; + Ok(OAuthLoginResponse { redirect }) + } + + // ── Google ──────────────────────────────────────────────────────────── + + async fn google(&self, req: &OAthLoginRequest) -> Result { + let method = self.repos.auth.find_one_by_method("google").await + .map_err(|e| anyhow!(CodeError::new_err_code_msg(error_code::DATABASE_QUERY_ERROR, e.to_string())))?; + + let cfg: oauth::GoogleConfig = serde_json::from_str(&method.config.to_string()) + .map_err(|e| anyhow!(CodeError::new_err_code_msg(error_code::ERROR, e.to_string())))?; + + // Generate state + PKCE verifier; store state->redirect_url in Redis + let state = random_state(); + let redis_key = format!("google:{}", state); + self.cache.set_ex(&redis_key, &req.redirect, STATE_TTL_SECS).await + .map_err(|e| anyhow!(CodeError::new_err_code_msg(error_code::ERROR, e.to_string())))?; + + // arctic-oauth: Google.authorization_url(state, scopes, code_verifier) + let code_verifier = oauth::generate_code_verifier(); + let google = oauth::Google::new(&cfg.client_id, &cfg.client_secret, &req.redirect); + let url = google.authorization_url(&state, &["openid", "email", "profile"], &code_verifier); + Ok(url.to_string()) + } + + // ── Apple ───────────────────────────────────────────────────────────── + + async fn apple(&self, req: &OAthLoginRequest) -> Result { + let method = self.repos.auth.find_one_by_method("apple").await + .map_err(|e| anyhow!(CodeError::new_err_code_msg(error_code::DATABASE_QUERY_ERROR, e.to_string())))?; + + let cfg: oauth::AppleConfig = serde_json::from_str(&method.config.to_string()) + .map_err(|e| anyhow!(CodeError::new_err_code_msg(error_code::ERROR, e.to_string())))?; + + let state = random_state(); + // Go uses "telegram:" key for Apple state — preserve that behaviour + let redis_key = format!("telegram:{}", state); + self.cache.set_ex(&redis_key, &req.redirect, STATE_TTL_SECS).await + .map_err(|e| anyhow!(CodeError::new_err_code_msg(error_code::ERROR, e.to_string())))?; + + let callback_url = format!("{}/v1/auth/oauth/callback/apple", cfg.redirect_url); + // Arctic Apple expects PKCS#8 DER; `client_secret` in our config is the raw p8 PEM. + // Build the URL manually to match Go behaviour (no Arctic Apple client for redirect). + let url = format!( + "https://appleid.apple.com/auth/authorize?client_id={}&redirect_uri={}&response_type=code&state={}&scope=name%20email&response_mode=form_post", + cfg.client_id, + urlencoding::encode(&callback_url), + state, + ); + Ok(url) + } + + // ── Telegram ────────────────────────────────────────────────────────── + + async fn telegram(&self, req: &OAthLoginRequest) -> Result { + let method = self.repos.auth.find_one_by_method("telegram").await + .map_err(|e| anyhow!(CodeError::new_err_code_msg(error_code::DATABASE_QUERY_ERROR, e.to_string())))?; + + let cfg: oauth::TelegramConfig = serde_json::from_str(&method.config.to_string()) + .map_err(|e| anyhow!(CodeError::new_err_code_msg(error_code::ERROR, e.to_string())))?; + + let state = random_state(); + // Go uses "apple:" key for Telegram state — preserve that behaviour + let redis_key = format!("apple:{}", state); + self.cache.set_ex(&redis_key, &req.redirect, STATE_TTL_SECS).await + .map_err(|e| anyhow!(CodeError::new_err_code_msg(error_code::ERROR, e.to_string())))?; + + let url = generate_telegram_oauth_url(&cfg.bot_token, &state, &req.redirect); + Ok(url) + } +} + +// ── helpers ────────────────────────────────────────────────────────────────── + +/// Generate a random 8-character alphanumeric state token (mirrors Go `random.KeyNew`). +fn random_state() -> String { + use rand::Rng; + const CHARS: &[u8] = b"abcdefghijklmnopqrstuvwxyzABCDEFGHIJKLMNOPQRSTUVWXYZ0123456789"; + let mut rng = rand::thread_rng(); + (0..8).map(|_| CHARS[rng.gen_range(0..CHARS.len())] as char).collect() +} + +/// Build a Telegram login widget redirect URL. +/// Mirrors Go `telegram.GenerateTelegramOAuthURL`. +fn generate_telegram_oauth_url(bot_token: &str, state: &str, redirect: &str) -> String { + let bot_id = bot_token.split(':').next().unwrap_or(""); + format!( + "https://oauth.telegram.org/auth?bot_id={}&origin={}&embed=0&request_access=write&return_to={}", + bot_id, + urlencoding::encode(redirect), + urlencoding::encode(&format!("{}?state={}", redirect, state)), + ) +} diff --git a/src/service/auth/oauth/trial_cache.rs b/src/service/auth/oauth/trial_cache.rs new file mode 100644 index 00000000..af8ecd85 --- /dev/null +++ b/src/service/auth/oauth/trial_cache.rs @@ -0,0 +1,6 @@ +use crate::cache::Cache; +use crate::model::entity::user::UserSubscribe; + +pub fn clear_trial_subscribe_cache(_cache: &Cache, _sub: &UserSubscribe) { + // placeholder — cache invalidation logic goes here when needed +} diff --git a/src/service/auth/reset_password_service.rs b/src/service/auth/reset_password_service.rs new file mode 100644 index 00000000..fc327e5c --- /dev/null +++ b/src/service/auth/reset_password_service.rs @@ -0,0 +1,113 @@ +use std::sync::Arc; + +use anyhow::anyhow; +use chrono::Utc; +use uuid::Uuid; + +use crate::cache::Cache; +use crate::config::cache_key::{AUTH_CODE_CACHE_KEY, SESSION_ID_KEY}; +use crate::config::Config; +use crate::model::dto::auth::{LoginResponse, ResetPasswordRequest}; +use crate::repository::Repositories; +use crate::service::telemetry::Telemetry; +use result::code_error::CodeError; +use result::error_code; + +pub struct ResetPasswordService { + repos: Arc, + config: Arc, + cache: Arc, +} + +impl ResetPasswordService { + pub fn new(repos: Arc, config: Arc, cache: Arc) -> Self { + Self { + repos, + config, + cache, + } + } + + pub async fn reset( + &self, + req: ResetPasswordRequest, + ) -> Result { + let cache_key = format!("{}:2:{}", AUTH_CODE_CACHE_KEY, req.email); + let cached = self + .cache + .get(&cache_key) + .await + .map_err(|e| anyhow!(CodeError::new_err_code_msg(error_code::VERIFY_CODE_ERROR, e.to_string())))? + .ok_or_else(|| anyhow!(CodeError::new_err_code(error_code::VERIFY_CODE_ERROR)))?; + + let payload: serde_json::Value = serde_json::from_str(&cached) + .map_err(|_| anyhow!(CodeError::new_err_code(error_code::VERIFY_CODE_ERROR)))?; + + if payload["code"].as_str() != Some(&req.code) { + return Err(anyhow!(CodeError::new_err_code( + error_code::VERIFY_CODE_ERROR + ))); + } + + let user = self + .repos + .user + .find_auth_method_by_open_id("email", &req.email) + .await + .map_err(|e| anyhow!(CodeError::new_err_code_msg(error_code::DATABASE_QUERY_ERROR, e.to_string())))? + .ok_or_else(|| anyhow!(CodeError::new_err_code(error_code::USER_NOT_EXIST)))?; + + let user_id = user.user_id; + let mut db_user = self + .repos + .user + .find_one_user(user_id) + .await + .map_err(|e| anyhow!(CodeError::new_err_code_msg(error_code::DATABASE_QUERY_ERROR, e.to_string())))?; + + let pwd = password::encode_password(&req.password) + .map_err(|e| anyhow!(CodeError::new_err_msg(e.to_string())))?; + + db_user.password = pwd; + db_user.updated_at = Utc::now().timestamp_millis(); + self.repos + .user + .update_user(&db_user) + .await + .map_err(|e| anyhow!(CodeError::new_err_code_msg(error_code::DATABASE_UPDATE_ERROR, e.to_string())))?; + + if !req.identifier.is_empty() { + let bind_svc = super::bind_device_service::BindDeviceService::new( + self.repos.clone(), + self.config.clone(), + self.cache.clone(), + ); + let _ = bind_svc + .bind_device_to_user(&req.identifier, &req.ip, &req.user_agent, user_id) + .await; + } + + let login_type = if req.login_type.is_empty() { + "email".to_string() + } else { + req.login_type + }; + + let session_id = Uuid::new_v4().to_string(); + let (claims, seconds) = + jwt::Claims::new(user_id, session_id.clone(), login_type); + + let token = jwt::generate_token(&claims, &self.config.jwt_auth.access_secret) + .map_err(|e| anyhow!(CodeError::new_err_code_msg(error_code::ERROR, &e.to_string())))?; + + let session_key = format!("{}:{}", SESSION_ID_KEY, session_id); + self.cache + .set_ex(&session_key, &user_id.to_string(), seconds) + .await + .map_err(|e| anyhow!(CodeError::new_err_code_msg(error_code::ERROR, &e.to_string())))?; + + Telemetry::login(&self.repos, user_id, "email", &req.ip, &req.user_agent, true).await; + + Ok(LoginResponse { token }) + } +} diff --git a/src/service/auth/telephone_login_service.rs b/src/service/auth/telephone_login_service.rs new file mode 100644 index 00000000..5dfccdf2 --- /dev/null +++ b/src/service/auth/telephone_login_service.rs @@ -0,0 +1,90 @@ +use std::sync::Arc; + +use anyhow::anyhow; +use chrono::Utc; +use uuid::Uuid; + +use crate::cache::Cache; +use crate::config::cache_key::SESSION_ID_KEY; +use crate::config::Config; +use crate::model::dto::auth::{LoginResponse, TelephoneLoginRequest}; +use crate::repository::Repositories; +use crate::service::telemetry::Telemetry; +use result::code_error::CodeError; +use result::error_code; + +pub struct TelephoneLoginService { + repos: Arc, + config: Arc, + cache: Arc, +} + +impl TelephoneLoginService { + pub fn new(repos: Arc, config: Arc, cache: Arc) -> Self { + Self { repos, config, cache } + } + + pub async fn login(&self, req: TelephoneLoginRequest) -> Result { + let phone = format!("+{}{}", req.telephone_area_code, req.telephone); + + let auth_method = self.repos.user.find_auth_method_by_open_id("mobile", &phone).await + .map_err(|e| anyhow!(CodeError::new_err_code_msg(error_code::DATABASE_QUERY_ERROR, e.to_string())))? + .ok_or_else(|| anyhow!(CodeError::new_err_code(error_code::USER_NOT_EXIST)))?; + + let user = self.repos.user.find_one_user(auth_method.user_id).await + .map_err(|e| anyhow!(CodeError::new_err_code_msg(error_code::DATABASE_QUERY_ERROR, e.to_string())))?; + + if !user.enable { + return Err(anyhow!(CodeError::new_err_code(error_code::USER_DISABLED))); + } + + if !req.password.is_empty() { + if !password::multi_password_verify( + &user.algo, user.salt.as_deref().unwrap_or(""), &req.password, &user.password, + ) { + return Err(anyhow!(CodeError::new_err_code(error_code::USER_PASSWORD_ERROR))); + } + } else if !req.telephone_code.is_empty() { + let cache_key = format!("{}:{}", crate::config::cache_key::AUTH_CODE_TELEPHONE_CACHE_KEY, phone); + let cached = self.cache.get(&cache_key).await + .map_err(|_| anyhow!(CodeError::new_err_code(error_code::VERIFY_CODE_ERROR)))? + .ok_or_else(|| anyhow!(CodeError::new_err_code(error_code::VERIFY_CODE_ERROR)))?; + + let payload: serde_json::Value = serde_json::from_str(&cached) + .map_err(|_| anyhow!(CodeError::new_err_code(error_code::VERIFY_CODE_ERROR)))?; + + if payload["code"].as_str() != Some(&req.telephone_code) { + return Err(anyhow!(CodeError::new_err_code(error_code::VERIFY_CODE_ERROR))); + } + + let _ = self.cache.del(&cache_key).await; + } else { + return Err(anyhow!(CodeError::new_err_code_msg( + error_code::PASSWORD_OR_VERIFICATION_CODE_REQUIRED, + "password or verification code required", + ))); + } + + if !req.identifier.is_empty() { + let bind_svc = super::bind_device_service::BindDeviceService::new( + self.repos.clone(), self.config.clone(), self.cache.clone(), + ); + let _ = bind_svc.bind_device_to_user(&req.identifier, &req.ip, &req.user_agent, user.id).await; + } + + let login_type = if req.login_type.is_empty() { "mobile".to_string() } else { req.login_type }; + let session_id = Uuid::new_v4().to_string(); + let (claims, seconds) = jwt::Claims::new(user.id, session_id.clone(), login_type); + + let token = jwt::generate_token(&claims, &self.config.jwt_auth.access_secret) + .map_err(|e| anyhow!(CodeError::new_err_code_msg(error_code::ERROR, &e.to_string())))?; + + let session_key = format!("{}:{}", SESSION_ID_KEY, session_id); + self.cache.set_ex(&session_key, &user.id.to_string(), seconds).await + .map_err(|e| anyhow!(CodeError::new_err_code_msg(error_code::ERROR, &e.to_string())))?; + + Telemetry::login(&self.repos, user.id, "mobile", &req.ip, &req.user_agent, true).await; + + Ok(LoginResponse { token }) + } +} diff --git a/src/service/auth/telephone_reset_password_service.rs b/src/service/auth/telephone_reset_password_service.rs new file mode 100644 index 00000000..6db11db1 --- /dev/null +++ b/src/service/auth/telephone_reset_password_service.rs @@ -0,0 +1,80 @@ +use std::sync::Arc; + +use anyhow::anyhow; +use chrono::Utc; +use uuid::Uuid; + +use crate::cache::Cache; +use crate::config::cache_key::SESSION_ID_KEY; +use crate::config::Config; +use crate::model::dto::auth::{LoginResponse, TelephoneResetPasswordRequest}; +use crate::repository::Repositories; +use crate::service::telemetry::Telemetry; +use result::code_error::CodeError; +use result::error_code; + +pub struct TelephoneResetPasswordService { + repos: Arc, + config: Arc, + cache: Arc, +} + +impl TelephoneResetPasswordService { + pub fn new(repos: Arc, config: Arc, cache: Arc) -> Self { + Self { repos, config, cache } + } + + pub async fn reset(&self, req: TelephoneResetPasswordRequest) -> Result { + let phone = format!("+{}{}", req.telephone_area_code, req.telephone); + + let cache_key = format!("{}:{}", crate::config::cache_key::AUTH_CODE_TELEPHONE_CACHE_KEY, phone); + let cached = self.cache.get(&cache_key).await + .map_err(|_| anyhow!(CodeError::new_err_code(error_code::VERIFY_CODE_ERROR)))? + .ok_or_else(|| anyhow!(CodeError::new_err_code(error_code::VERIFY_CODE_ERROR)))?; + + let payload: serde_json::Value = serde_json::from_str(&cached) + .map_err(|_| anyhow!(CodeError::new_err_code(error_code::VERIFY_CODE_ERROR)))?; + + if payload["code"].as_str() != Some(&req.code) { + return Err(anyhow!(CodeError::new_err_code(error_code::VERIFY_CODE_ERROR))); + } + + let auth_method = self.repos.user.find_auth_method_by_open_id("mobile", &phone).await + .map_err(|e| anyhow!(CodeError::new_err_code_msg(error_code::DATABASE_QUERY_ERROR, e.to_string())))? + .ok_or_else(|| anyhow!(CodeError::new_err_code(error_code::USER_NOT_EXIST)))?; + + let mut user = self.repos.user.find_one_user(auth_method.user_id).await + .map_err(|e| anyhow!(CodeError::new_err_code_msg(error_code::DATABASE_QUERY_ERROR, e.to_string())))?; + + let pwd = password::encode_password(&req.password) + .map_err(|e| anyhow!(CodeError::new_err_msg(e.to_string())))?; + + user.password = pwd; + user.algo = "default".to_string(); + user.updated_at = Utc::now().timestamp_millis(); + self.repos.user.update_user(&user).await + .map_err(|e| anyhow!(CodeError::new_err_code_msg(error_code::DATABASE_UPDATE_ERROR, e.to_string())))?; + + if !req.identifier.is_empty() { + let bind_svc = super::bind_device_service::BindDeviceService::new( + self.repos.clone(), self.config.clone(), self.cache.clone(), + ); + let _ = bind_svc.bind_device_to_user(&req.identifier, &req.ip, &req.user_agent, user.id).await; + } + + let login_type = if req.login_type.is_empty() { "mobile".to_string() } else { req.login_type }; + let session_id = Uuid::new_v4().to_string(); + let (claims, seconds) = jwt::Claims::new(user.id, session_id.clone(), login_type); + + let token = jwt::generate_token(&claims, &self.config.jwt_auth.access_secret) + .map_err(|e| anyhow!(CodeError::new_err_code_msg(error_code::ERROR, &e.to_string())))?; + + let session_key = format!("{}:{}", SESSION_ID_KEY, session_id); + self.cache.set_ex(&session_key, &user.id.to_string(), seconds).await + .map_err(|e| anyhow!(CodeError::new_err_code_msg(error_code::ERROR, &e.to_string())))?; + + Telemetry::login(&self.repos, user.id, "mobile", &req.ip, &req.user_agent, true).await; + + Ok(LoginResponse { token }) + } +} diff --git a/src/service/auth/telephone_user_register_service.rs b/src/service/auth/telephone_user_register_service.rs new file mode 100644 index 00000000..7b1e39df --- /dev/null +++ b/src/service/auth/telephone_user_register_service.rs @@ -0,0 +1,171 @@ +use std::sync::Arc; + +use anyhow::anyhow; +use chrono::Utc; +use uuid::Uuid; + +use crate::cache::Cache; +use crate::config::cache_key::SESSION_ID_KEY; +use crate::config::Config; +use crate::model::dto::auth::{LoginResponse, TelephoneRegisterRequest}; +use crate::model::entity::user::{AuthMethods, User, UserSubscribe}; +use crate::repository::Repositories; +use crate::service::telemetry::Telemetry; +use result::code_error::CodeError; +use result::error_code; + +pub struct TelephoneUserRegisterService { + repos: Arc, + config: Arc, + cache: Arc, +} + +impl TelephoneUserRegisterService { + pub fn new(repos: Arc, config: Arc, cache: Arc) -> Self { + Self { repos, config, cache } + } + + pub async fn register(&self, req: TelephoneRegisterRequest) -> Result { + if self.config.register.stop_register { + return Err(anyhow!(CodeError::new_err_code(error_code::STOP_REGISTER))); + } + + if req.invite.is_empty() && self.config.invite.forced_invite { + return Err(anyhow!(CodeError::new_err_code(error_code::INVITE_CODE_ERROR))); + } + + let referer = if !req.invite.is_empty() { + Some( + self.repos.user.find_one_by_refer_code(&req.invite).await + .map_err(|_| anyhow!(CodeError::new_err_code(error_code::INVITE_CODE_ERROR)))? + .ok_or_else(|| anyhow!(CodeError::new_err_code(error_code::INVITE_CODE_ERROR)))?, + ) + } else { + None + }; + + let phone = format!("+{}{}", req.telephone_area_code, req.telephone); + + let cache_key = format!("{}:{}", crate::config::cache_key::AUTH_CODE_TELEPHONE_CACHE_KEY, phone); + let cached = self.cache.get(&cache_key).await + .map_err(|_| anyhow!(CodeError::new_err_code(error_code::VERIFY_CODE_ERROR)))? + .ok_or_else(|| anyhow!(CodeError::new_err_code(error_code::VERIFY_CODE_ERROR)))?; + + let payload: serde_json::Value = serde_json::from_str(&cached) + .map_err(|_| anyhow!(CodeError::new_err_code(error_code::VERIFY_CODE_ERROR)))?; + + if payload["code"].as_str() != Some(&req.code) { + return Err(anyhow!(CodeError::new_err_code(error_code::VERIFY_CODE_ERROR))); + } + let _ = self.cache.del(&cache_key).await; + + if let Some(u) = self.repos.user.find_auth_method_by_open_id("mobile", &phone).await + .map_err(|e| anyhow!(CodeError::new_err_code_msg(error_code::DATABASE_QUERY_ERROR, e.to_string())))? + { + let db_user = self.repos.user.find_one_user(u.user_id).await + .map_err(|e| anyhow!(CodeError::new_err_code_msg(error_code::DATABASE_QUERY_ERROR, e.to_string())))?; + if db_user.deleted_at.is_some() { + return Err(anyhow!(CodeError::new_err_code(error_code::USER_DISABLED))); + } + return Err(anyhow!(CodeError::new_err_code(error_code::USER_EXIST))); + } + + let now = Utc::now().timestamp_millis(); + let pwd = password::encode_password(&req.password) + .map_err(|e| anyhow!(CodeError::new_err_msg(e.to_string())))?; + + let mut user = User { + id: 0, password: pwd, algo: "default".to_string(), salt: None, + avatar: String::new(), balance: 0, refer_code: String::new(), + referer_id: referer.as_ref().map(|r| r.id).unwrap_or(0), + commission: 0, referral_percentage: self.config.invite.referral_percentage as i16, + only_first_purchase: self.config.invite.only_first_purchase, + gift_amount: 0, enable: true, is_admin: false, + enable_balance_notify: false, enable_login_notify: false, + enable_subscribe_notify: false, enable_trade_notify: false, + rules: None, created_at: now, updated_at: now, deleted_at: None, + }; + + user = self.repos.user.insert_user(&user).await + .map_err(|e| anyhow!(CodeError::new_err_code_msg(error_code::DATABASE_INSERT_ERROR, e.to_string())))?; + + let refer_code = format!("U{:X}", user.id); + let mut update_user = user.clone(); + update_user.refer_code = refer_code; + self.repos.user.update_user(&update_user).await + .map_err(|e| anyhow!(CodeError::new_err_code_msg(error_code::DATABASE_UPDATE_ERROR, e.to_string())))?; + + self.repos.user.insert_auth_method(&AuthMethods { + id: 0, user_id: user.id, auth_type: "mobile".to_string(), + auth_identifier: phone.clone(), verified: true, + created_at: now, updated_at: now, + }).await + .map_err(|e| anyhow!(CodeError::new_err_code_msg(error_code::DATABASE_INSERT_ERROR, e.to_string())))?; + + let trial_sub = if self.config.register.enable_trial { + Some(self.activate_trial(user.id).await?) + } else { + None + }; + + if let Some(ref sub) = trial_sub { + super::trial_cache::clear_trial_subscribe_cache(&self.cache, sub); + } + + if !req.identifier.is_empty() { + let bind_svc = super::bind_device_service::BindDeviceService::new( + self.repos.clone(), self.config.clone(), self.cache.clone(), + ); + let _ = bind_svc.bind_device_to_user(&req.identifier, &req.ip, &req.user_agent, user.id).await; + } + + let login_type = if req.login_type.is_empty() { "mobile".to_string() } else { req.login_type }; + let session_id = Uuid::new_v4().to_string(); + let (claims, seconds) = jwt::Claims::new(user.id, session_id.clone(), login_type); + + let token = jwt::generate_token(&claims, &self.config.jwt_auth.access_secret) + .map_err(|e| anyhow!(CodeError::new_err_code_msg(error_code::ERROR, &e.to_string())))?; + + let session_key = format!("{}:{}", SESSION_ID_KEY, session_id); + self.cache.set_ex(&session_key, &user.id.to_string(), seconds).await + .map_err(|e| anyhow!(CodeError::new_err_code_msg(error_code::ERROR, &e.to_string())))?; + + Telemetry::login(&self.repos, user.id, "mobile", &req.ip, &req.user_agent, true).await; + Telemetry::register(&self.repos, user.id, "mobile", &phone, &req.ip, &req.user_agent).await; + + Ok(LoginResponse { token }) + } + + async fn activate_trial(&self, user_id: i64) -> Result { + let sub = self.repos.subscribe.find_one(self.config.register.trial_subscribe).await + .map_err(|e| anyhow!(CodeError::new_err_code_msg(error_code::DATABASE_QUERY_ERROR, e.to_string())))?; + + let now = Utc::now(); + let expire_time = add_time(&self.config.register.trial_time_unit, self.config.register.trial_time, now); + let token = format!("Trial-{}-{}", user_id, Uuid::new_v4()); + + let user_sub = UserSubscribe { + id: 0, user_id, order_id: 0, subscribe_id: sub.id, + start_time: now.timestamp_millis(), expire_time: expire_time.timestamp_millis(), + finished_at: None, traffic: sub.traffic, download: 0, upload: 0, + token, uuid: Uuid::new_v4().to_string(), status: 1, note: String::new(), + created_at: now.timestamp_millis(), updated_at: now.timestamp_millis(), + }; + + self.repos.user.insert_subscribe(&user_sub).await + .map_err(|e| anyhow!(CodeError::new_err_code_msg(error_code::DATABASE_INSERT_ERROR, e.to_string())))?; + + Ok(user_sub) + } +} + +fn add_time(unit: &str, amount: i64, from: chrono::DateTime) -> chrono::DateTime { + match unit { + "hour" => from + chrono::Duration::hours(amount), + "day" => from + chrono::Duration::days(amount), + "week" => from + chrono::Duration::weeks(amount), + "month" => from.checked_add_months(chrono::Months::new(amount as u32)).unwrap_or(from), + "year" => from.checked_add_months(chrono::Months::new((amount * 12) as u32)).unwrap_or(from), + _ => from + chrono::Duration::days(amount), + } +} diff --git a/src/service/auth/trial_cache.rs b/src/service/auth/trial_cache.rs new file mode 100644 index 00000000..eb3ea855 --- /dev/null +++ b/src/service/auth/trial_cache.rs @@ -0,0 +1,5 @@ +use crate::cache::Cache; +use crate::model::entity::user::UserSubscribe; + +pub fn clear_trial_subscribe_cache(_cache: &Cache, _sub: &UserSubscribe) { +} diff --git a/src/service/auth/user_login_service.rs b/src/service/auth/user_login_service.rs new file mode 100644 index 00000000..1f818994 --- /dev/null +++ b/src/service/auth/user_login_service.rs @@ -0,0 +1,71 @@ +use std::sync::Arc; + +use anyhow::anyhow; +use uuid::Uuid; + +use crate::cache::Cache; +use crate::config::cache_key::SESSION_ID_KEY; +use crate::config::Config; +use crate::model::dto::auth::{LoginResponse, UserLoginRequest}; +use crate::repository::Repositories; +use crate::service::telemetry::Telemetry; +use result::code_error::CodeError; +use result::error_code; + +pub struct UserLoginService { + repos: Arc, + config: Arc, + cache: Arc, +} + +impl UserLoginService { + pub fn new(repos: Arc, config: Arc, cache: Arc) -> Self { + Self { repos, config, cache } + } + + pub async fn login(&self, req: UserLoginRequest) -> Result { + let user = self + .repos.user.find_one_by_email(&req.email).await + .map_err(|_| anyhow!(CodeError::new_err_code(error_code::DATABASE_QUERY_ERROR)))? + .ok_or_else(|| anyhow!(CodeError::new_err_code(error_code::USER_NOT_EXIST)))?; + + if user.deleted_at.is_some() { + return Err(anyhow!(CodeError::new_err_code(error_code::USER_NOT_EXIST))); + } + + if !password::multi_password_verify( + &user.algo, + user.salt.as_deref().unwrap_or(""), + &req.password, + &user.password, + ) { + return Err(anyhow!(CodeError::new_err_code(error_code::USER_PASSWORD_ERROR))); + } + + if !user.enable { + return Err(anyhow!(CodeError::new_err_code(error_code::USER_DISABLED))); + } + + if !req.identifier.is_empty() { + let bind_svc = super::bind_device_service::BindDeviceService::new( + self.repos.clone(), self.config.clone(), self.cache.clone(), + ); + let _ = bind_svc.bind_device_to_user(&req.identifier, &req.ip, &req.user_agent, user.id).await; + } + + let session_id = Uuid::new_v4().to_string(); + let login_type = if req.login_type.is_empty() { "email".to_string() } else { req.login_type }; + let (claims, seconds) = jwt::Claims::new(user.id, session_id.clone(), login_type); + + let token = jwt::generate_token(&claims, &self.config.jwt_auth.access_secret) + .map_err(|e| anyhow!(CodeError::new_err_code_msg(error_code::ERROR, &e.to_string())))?; + + let session_key = format!("{}:{}", SESSION_ID_KEY, session_id); + self.cache.set_ex(&session_key, &user.id.to_string(), seconds).await + .map_err(|e| anyhow!(CodeError::new_err_code_msg(error_code::ERROR, &e.to_string())))?; + + Telemetry::login(&self.repos, user.id, "email", &req.ip, &req.user_agent, true).await; + + Ok(LoginResponse { token }) + } +} diff --git a/src/service/auth/user_register_service.rs b/src/service/auth/user_register_service.rs new file mode 100644 index 00000000..bafd057c --- /dev/null +++ b/src/service/auth/user_register_service.rs @@ -0,0 +1,190 @@ +use std::sync::Arc; + +use anyhow::anyhow; +use chrono::Utc; +use uuid::Uuid; + +use crate::cache::Cache; +use crate::config::cache_key::SESSION_ID_KEY; +use crate::config::Config; +use crate::model::dto::auth::{LoginResponse, UserRegisterRequest}; +use crate::model::entity::user::{AuthMethods, User, UserSubscribe}; +use crate::repository::Repositories; +use crate::service::telemetry::Telemetry; +use result::code_error::CodeError; +use result::error_code; + +pub struct UserRegisterService { + repos: Arc, + config: Arc, + cache: Arc, +} + +impl UserRegisterService { + pub fn new(repos: Arc, config: Arc, cache: Arc) -> Self { + Self { repos, config, cache } + } + + pub async fn register(&self, req: UserRegisterRequest) -> Result { + let cfg = &self.config.register; + + if cfg.stop_register { + return Err(anyhow!(CodeError::new_err_code(error_code::STOP_REGISTER))); + } + + if req.invite.is_empty() && self.config.invite.forced_invite { + return Err(anyhow!(CodeError::new_err_code(error_code::INVITE_CODE_ERROR))); + } + + let referer = if !req.invite.is_empty() { + Some( + self.repos.user.find_one_by_refer_code(&req.invite).await + .map_err(|_| anyhow!(CodeError::new_err_code(error_code::INVITE_CODE_ERROR)))? + .ok_or_else(|| anyhow!(CodeError::new_err_code(error_code::INVITE_CODE_ERROR)))?, + ) + } else { + None + }; + + if self.config.email.enable_verify { + let cache_key = format!("{}:1:{}", crate::config::cache_key::AUTH_CODE_CACHE_KEY, req.email); + let cached = self.cache.get(&cache_key).await + .map_err(|e| anyhow!(CodeError::new_err_code_msg(error_code::VERIFY_CODE_ERROR, e.to_string())))? + .ok_or_else(|| anyhow!(CodeError::new_err_code(error_code::VERIFY_CODE_ERROR)))?; + + let payload: serde_json::Value = serde_json::from_str(&cached) + .map_err(|_| anyhow!(CodeError::new_err_code(error_code::VERIFY_CODE_ERROR)))?; + + if payload["code"].as_str() != Some(&req.code) { + return Err(anyhow!(CodeError::new_err_code(error_code::VERIFY_CODE_ERROR))); + } + } + + if let Some(u) = self.repos.user.find_one_by_email(&req.email).await + .map_err(|e| anyhow!(CodeError::new_err_code_msg(error_code::DATABASE_QUERY_ERROR, e.to_string())))? + { + if u.deleted_at.is_some() { + return Err(anyhow!(CodeError::new_err_code(error_code::USER_DISABLED))); + } + return Err(anyhow!(CodeError::new_err_code(error_code::USER_EXIST))); + } + + let now = Utc::now().timestamp_millis(); + let pwd = password::encode_password(&req.password) + .map_err(|e| anyhow!(CodeError::new_err_msg(e.to_string())))?; + + let mut user = User { + id: 0, + password: pwd, + algo: "default".to_string(), + salt: None, + avatar: String::new(), + balance: 0, + refer_code: String::new(), + referer_id: referer.as_ref().map(|r| r.id).unwrap_or(0), + commission: 0, + referral_percentage: self.config.invite.referral_percentage as i16, + only_first_purchase: self.config.invite.only_first_purchase, + gift_amount: 0, + enable: true, + is_admin: false, + enable_balance_notify: false, + enable_login_notify: false, + enable_subscribe_notify: false, + enable_trade_notify: false, + rules: None, + created_at: now, + updated_at: now, + deleted_at: None, + }; + + user = self.repos.user.insert_user(&user).await + .map_err(|e| anyhow!(CodeError::new_err_code_msg(error_code::DATABASE_INSERT_ERROR, e.to_string())))?; + + let refer_code = format!("U{:X}", user.id); + let mut update_user = user.clone(); + update_user.refer_code = refer_code; + self.repos.user.update_user(&update_user).await + .map_err(|e| anyhow!(CodeError::new_err_code_msg(error_code::DATABASE_UPDATE_ERROR, e.to_string())))?; + + self.repos.user.insert_auth_method(&AuthMethods { + id: 0, + user_id: user.id, + auth_type: "email".to_string(), + auth_identifier: req.email.clone(), + verified: self.config.email.enable_verify, + created_at: now, + updated_at: now, + }).await + .map_err(|e| anyhow!(CodeError::new_err_code_msg(error_code::DATABASE_INSERT_ERROR, e.to_string())))?; + + let trial_sub = if cfg.enable_trial { + Some(self.activate_trial(user.id).await?) + } else { + None + }; + + if let Some(ref sub) = trial_sub { + super::trial_cache::clear_trial_subscribe_cache(&self.cache, sub); + } + + if !req.identifier.is_empty() { + let bind_svc = super::bind_device_service::BindDeviceService::new( + self.repos.clone(), self.config.clone(), self.cache.clone(), + ); + let _ = bind_svc.bind_device_to_user(&req.identifier, &req.ip, &req.user_agent, user.id).await; + } + + let login_type = if req.login_type.is_empty() { "email".to_string() } else { req.login_type }; + let session_id = Uuid::new_v4().to_string(); + let (claims, seconds) = jwt::Claims::new(user.id, session_id.clone(), login_type); + + let token = jwt::generate_token(&claims, &self.config.jwt_auth.access_secret) + .map_err(|e| anyhow!(CodeError::new_err_code_msg(error_code::ERROR, &e.to_string())))?; + + let session_key = format!("{}:{}", SESSION_ID_KEY, session_id); + self.cache.set_ex(&session_key, &user.id.to_string(), seconds).await + .map_err(|e| anyhow!(CodeError::new_err_code_msg(error_code::ERROR, &e.to_string())))?; + + Telemetry::login(&self.repos, user.id, "email", &req.ip, &req.user_agent, true).await; + Telemetry::register(&self.repos, user.id, "email", &req.email, &req.ip, &req.user_agent).await; + + Ok(LoginResponse { token }) + } + + async fn activate_trial(&self, user_id: i64) -> Result { + let sub = self.repos.subscribe.find_one(self.config.register.trial_subscribe).await + .map_err(|e| anyhow!(CodeError::new_err_code_msg(error_code::DATABASE_QUERY_ERROR, e.to_string())))?; + + let now = Utc::now(); + let expire_time = add_time(&self.config.register.trial_time_unit, self.config.register.trial_time, now); + let token = format!("Trial-{}-{}", user_id, Uuid::new_v4()); + + let user_sub = UserSubscribe { + id: 0, user_id, order_id: 0, subscribe_id: sub.id, + start_time: now.timestamp_millis(), + expire_time: expire_time.timestamp_millis(), + finished_at: None, traffic: sub.traffic, download: 0, upload: 0, + token, uuid: Uuid::new_v4().to_string(), status: 1, + note: String::new(), + created_at: now.timestamp_millis(), + updated_at: now.timestamp_millis(), + }; + + self.repos.user.insert_subscribe(&user_sub).await + .map_err(|e| anyhow!(CodeError::new_err_code_msg(error_code::DATABASE_INSERT_ERROR, e.to_string())))?; + + Ok(user_sub) + } +} + +fn add_time(unit: &str, amount: i64, from: chrono::DateTime) -> chrono::DateTime { + match unit { + "hour" => from + chrono::Duration::hours(amount), + "day" => from + chrono::Duration::days(amount), + "week" => from + chrono::Duration::weeks(amount), + "month" => from.checked_add_months(chrono::Months::new(amount as u32)).unwrap_or(from), + "year" => from.checked_add_months(chrono::Months::new((amount * 12) as u32)).unwrap_or(from), + _ => from + chrono::Duration::days(amount), + } +} diff --git a/src/service/common/check_verification_code_service.rs b/src/service/common/check_verification_code_service.rs new file mode 100644 index 00000000..47210fdd --- /dev/null +++ b/src/service/common/check_verification_code_service.rs @@ -0,0 +1,42 @@ +use std::sync::Arc; + +use anyhow::anyhow; +use crate::cache::Cache; +use crate::config::cache_key::AUTH_CODE_CACHE_KEY; +use crate::config::Config; +use crate::model::dto::auth::{CheckVerificationCodeRequest, CheckVerificationCodeRespone}; +use crate::repository::Repositories; +use result::code_error::CodeError; +use result::error_code; + +pub struct CheckVerificationCodeService { + _repos: Arc, + _config: Arc, + cache: Arc, +} + +impl CheckVerificationCodeService { + pub fn new(repos: Arc, config: Arc, cache: Arc) -> Self { + Self { + _repos: repos, + _config: config, + cache, + } + } + + pub async fn check( + &self, + req: CheckVerificationCodeRequest, + ) -> Result { + let cache_key = format!("{}:{}:{}", AUTH_CODE_CACHE_KEY, req.type_, req.account); + let cached = self.cache.get(&cache_key).await + .map_err(|e| anyhow!(CodeError::new_err_code_msg(error_code::VERIFY_CODE_ERROR, e.to_string())))? + .ok_or_else(|| anyhow!(CodeError::new_err_code(error_code::VERIFY_CODE_ERROR)))?; + + let payload: serde_json::Value = serde_json::from_str(&cached) + .map_err(|_| anyhow!(CodeError::new_err_code(error_code::VERIFY_CODE_ERROR)))?; + + let valid = payload["code"].as_str() == Some(&req.code); + Ok(CheckVerificationCodeRespone { status: valid }) + } +} diff --git a/src/service/common/get_ads_service.rs b/src/service/common/get_ads_service.rs new file mode 100644 index 00000000..d3b78304 --- /dev/null +++ b/src/service/common/get_ads_service.rs @@ -0,0 +1,32 @@ +use crate::model::dto::ads::{Ads, GetAdsResponse}; +use crate::repository::Repositories; +use anyhow::anyhow; +use result::code_error::CodeError; +use result::error_code; + +pub async fn get_ads(repos: &Repositories) -> anyhow::Result { + let (_total, items) = repos + .ads + .get_list_by_page(1, 200, Some(1), None) + .await + .map_err(|e| anyhow!(CodeError::new_err_code_msg(error_code::DATABASE_QUERY_ERROR, e.to_string())))?; + + let list: Vec = items + .into_iter() + .map(|a| Ads { + id: a.id as i32, + title: a.title, + type_: a.type_, + content: a.content, + description: a.description, + target_url: a.target_url, + start_time: a.start_time, + end_time: a.end_time, + status: a.status, + created_at: a.created_at, + updated_at: a.updated_at, + }) + .collect(); + + Ok(GetAdsResponse { list }) +} diff --git a/src/service/common/get_client_service.rs b/src/service/common/get_client_service.rs new file mode 100644 index 00000000..bd17112a --- /dev/null +++ b/src/service/common/get_client_service.rs @@ -0,0 +1,36 @@ +use crate::model::dto::subscribe::{GetSubscribeClientResponse, SubscribeClient}; +use crate::repository::Repositories; +use anyhow::anyhow; +use result::code_error::CodeError; +use result::error_code; + +pub async fn get_client(repos: &Repositories) -> anyhow::Result { + let items = repos + .client + .list() + .await + .map_err(|e| anyhow!(CodeError::new_err_code_msg(error_code::DATABASE_QUERY_ERROR, e.to_string())))?; + + let list: Vec = items + .into_iter() + .map(|item| { + let download_link = if item.download_link.is_empty() { + None + } else { + serde_json::from_str(&item.download_link).ok() + }; + SubscribeClient { + id: item.id, + name: item.name, + description: item.description, + icon: item.icon, + scheme: Some(item.scheme), + is_default: item.is_default, + download_link, + } + }) + .collect(); + + let total = list.len() as i64; + Ok(GetSubscribeClientResponse { total, list }) +} diff --git a/src/service/common/get_global_config_service.rs b/src/service/common/get_global_config_service.rs new file mode 100644 index 00000000..dfea0f27 --- /dev/null +++ b/src/service/common/get_global_config_service.rs @@ -0,0 +1,109 @@ +use crate::config::Config; +use crate::model::dto::auth::{AuthConfig, DeviceAuthticateConfig, EmailAuthticateConfig, MobileAuthenticateConfig}; +use crate::model::dto::common::GetGlobalConfigResponse; +use crate::model::dto::subscribe::SubscribeConfig; +use crate::model::dto::system::{Currency, InviteConfig, PubilcRegisterConfig, PubilcVerifyCodeConfig, SiteConfig, VeifyConfig}; +use crate::repository::Repositories; +use anyhow::anyhow; +use result::code_error::CodeError; +use result::error_code; + +pub async fn get_global_config( + repos: &Repositories, + config: &Config, +) -> anyhow::Result { + let auth_methods = repos + .auth + .find_all_enabled() + .await + .map_err(|e| anyhow!(CodeError::new_err_code_msg(error_code::DATABASE_QUERY_ERROR, e.to_string())))?; + + let oauth_methods: Vec = auth_methods.iter().map(|m| m.method.clone()).collect(); + + let web_ad = repos + .system + .find_one_by_category_key("site", "WebAD") + .await + .map(|s| s.value == "true") + .unwrap_or(false); + + let site = SiteConfig { + host: config.site.host.clone(), + site_name: config.site.site_name.clone(), + site_desc: config.site.site_desc.clone(), + site_logo: config.site.site_logo.clone(), + keywords: config.site.keywords.clone(), + custom_html: config.site.custom_html.clone(), + custom_data: config.site.custom_data.clone(), + }; + + let verify = VeifyConfig { + turnstile_site_key: config.verify.turnstile_site_key.clone(), + enable_login_verify: config.verify.login_verify, + enable_register_verify: config.verify.register_verify, + enable_reset_password_verify: config.verify.reset_password_verify, + }; + + let auth = AuthConfig { + mobile: MobileAuthenticateConfig { + enable: config.mobile.enable, + enable_whitelist: config.mobile.enable_whitelist, + whitelist: config.mobile.whitelist.clone(), + }, + email: EmailAuthticateConfig { + enable: config.email.enable, + enable_verify: config.email.enable_verify, + enable_domain_suffix: config.email.enable_domain_suffix, + domain_suffix_list: config.email.domain_suffix_list.clone(), + }, + device: DeviceAuthticateConfig { + enable: config.device.enable, + show_ads: config.device.show_ads, + enable_security: config.device.enable_security, + only_real_device: config.device.only_real_device, + }, + register: PubilcRegisterConfig { + stop_register: config.register.stop_register, + enable_ip_register_limit: config.register.enable_ip_register_limit, + ip_register_limit: config.register.ip_register_limit, + ip_register_limit_duration: config.register.ip_register_limit_duration, + }, + }; + + let invite = InviteConfig { + forced_invite: config.invite.forced_invite, + referral_percentage: config.invite.referral_percentage, + only_first_purchase: config.invite.only_first_purchase, + }; + + let currency = Currency { + currency_unit: config.currency.unit.clone(), + currency_symbol: config.currency.symbol.clone(), + }; + + let subscribe = SubscribeConfig { + single_model: config.subscribe.single_model, + subscribe_path: config.subscribe.subscribe_path.clone(), + subscribe_domain: config.subscribe.subscribe_domain.clone(), + pan_domain: config.subscribe.pan_domain, + user_agent_limit: config.subscribe.user_agent_limit, + user_agent_list: config.subscribe.user_agent_list.clone(), + show_tutorial: config.subscribe.show_tutorial, + }; + + let verify_code = PubilcVerifyCodeConfig { + verify_code_interval: config.verify_code.interval, + }; + + Ok(GetGlobalConfigResponse { + site, + verify, + auth, + invite, + currency, + subscribe, + verify_code, + oauth_methods, + web_ad, + }) +} diff --git a/src/service/common/get_privacy_policy_service.rs b/src/service/common/get_privacy_policy_service.rs new file mode 100644 index 00000000..64a7e7ea --- /dev/null +++ b/src/service/common/get_privacy_policy_service.rs @@ -0,0 +1,21 @@ +use crate::model::dto::system::PrivacyPolicyConfig; +use crate::repository::Repositories; +use anyhow::anyhow; +use result::code_error::CodeError; +use result::error_code; + +pub async fn get_privacy_policy(repos: &Repositories) -> anyhow::Result { + let configs = repos + .system + .get_tos_config() + .await + .map_err(|e| anyhow!(CodeError::new_err_code_msg(error_code::DATABASE_QUERY_ERROR, e.to_string())))?; + + let privacy_policy = configs + .iter() + .find(|s| s.key == "PrivacyPolicy") + .map(|s| s.value.clone()) + .unwrap_or_default(); + + Ok(PrivacyPolicyConfig { privacy_policy }) +} diff --git a/src/service/common/get_stat_service.rs b/src/service/common/get_stat_service.rs new file mode 100644 index 00000000..6d4c9579 --- /dev/null +++ b/src/service/common/get_stat_service.rs @@ -0,0 +1,46 @@ +use crate::model::dto::common::GetStatResponse; +use crate::repository::Repositories; +use anyhow::anyhow; +use result::code_error::CodeError; +use result::error_code; + +pub async fn get_stat(repos: &Repositories) -> anyhow::Result { + let mut user_count = repos + .user + .count_enabled_users() + .await + .map_err(|e| anyhow!(CodeError::new_err_code_msg(error_code::DATABASE_QUERY_ERROR, e.to_string())))?; + + if user_count > 100 { + user_count -= user_count % 100; + } else if user_count > 10 { + user_count -= user_count % 10; + } else { + user_count = 1; + } + + let node_count = repos + .node + .count_enabled_nodes() + .await + .map_err(|e| anyhow!(CodeError::new_err_code_msg(error_code::DATABASE_QUERY_ERROR, e.to_string())))?; + + let raw_protocols = repos + .node + .query_enabled_node_protocols() + .await + .map_err(|e| anyhow!(CodeError::new_err_code_msg(error_code::DATABASE_QUERY_ERROR, e.to_string())))?; + + let mut seen = std::collections::HashSet::new(); + let protocol: Vec = raw_protocols + .into_iter() + .filter(|p| !p.is_empty() && seen.insert(p.clone())) + .collect(); + + Ok(GetStatResponse { + user: user_count, + node: node_count, + country: 0, + protocol, + }) +} diff --git a/src/service/common/get_tos_service.rs b/src/service/common/get_tos_service.rs new file mode 100644 index 00000000..67dfabdf --- /dev/null +++ b/src/service/common/get_tos_service.rs @@ -0,0 +1,21 @@ +use crate::model::dto::common::GetTosResponse; +use crate::repository::Repositories; +use anyhow::anyhow; +use result::code_error::CodeError; +use result::error_code; + +pub async fn get_tos(repos: &Repositories) -> anyhow::Result { + let configs = repos + .system + .get_tos_config() + .await + .map_err(|e| anyhow!(CodeError::new_err_code_msg(error_code::DATABASE_QUERY_ERROR, e.to_string())))?; + + let tos_content = configs + .iter() + .find(|s| s.key == "TosContent") + .map(|s| s.value.clone()) + .unwrap_or_default(); + + Ok(GetTosResponse { tos_content }) +} diff --git a/src/service/common/heartbeat_service.rs b/src/service/common/heartbeat_service.rs new file mode 100644 index 00000000..f00f0ef3 --- /dev/null +++ b/src/service/common/heartbeat_service.rs @@ -0,0 +1,11 @@ +use chrono::Utc; + +use crate::model::dto::common::HeartbeatResponse; + +pub async fn heartbeat() -> anyhow::Result { + Ok(HeartbeatResponse { + status: true, + message: Some("service is alive".to_string()), + timestamp: Some(Utc::now().timestamp()), + }) +} diff --git a/src/service/common/mod.rs b/src/service/common/mod.rs new file mode 100644 index 00000000..073a091f --- /dev/null +++ b/src/service/common/mod.rs @@ -0,0 +1,10 @@ +pub mod check_verification_code_service; +pub mod get_ads_service; +pub mod get_client_service; +pub mod get_global_config_service; +pub mod get_privacy_policy_service; +pub mod get_stat_service; +pub mod get_tos_service; +pub mod heartbeat_service; +pub mod send_email_code_service; +pub mod send_sms_code_service; diff --git a/src/service/common/send_email_code_service.rs b/src/service/common/send_email_code_service.rs new file mode 100644 index 00000000..07b94c04 --- /dev/null +++ b/src/service/common/send_email_code_service.rs @@ -0,0 +1,135 @@ +use std::sync::Arc; + +use anyhow::anyhow; +use rand::Rng; + +use crate::cache::Cache; +use crate::config::cache_key::{AUTH_CODE_CACHE_KEY, SEND_COUNT_LIMIT_KEY_PREFIX, SEND_INTERVAL_KEY_PREFIX}; +use crate::config::Config; +use crate::model::dto::auth::{SendCodeRequest, SendCodeResponse}; +use crate::queue::client::QueueClient; +use crate::queue::types::FORTHWITH_SEND_EMAIL; +use crate::repository::Repositories; +use result::code_error::CodeError; +use result::error_code; + +pub struct SendEmailCodeService { + repos: Arc, + _config: Arc, + cache: Arc, + queue: QueueClient, +} + +impl SendEmailCodeService { + pub fn new(repos: Arc, config: Arc, cache: Arc, queue: QueueClient) -> Self { + Self { + repos, + _config: config, + cache, + queue, + } + } + + pub async fn send_code( + &self, + req: SendCodeRequest, + ) -> Result { + let method = match req.type_ { + 1 => "register", // Register + 2 => "security", // Security (reset password etc) + _ => "unknown", + }; + + let cache_key = format!("{}:{}:{}", AUTH_CODE_CACHE_KEY, req.type_, req.email); + + // Rate limit: check interval (60s between sends) + let interval_key = format!("{}{}", SEND_INTERVAL_KEY_PREFIX, req.email); + let last_send = self.cache.get(&interval_key).await.unwrap_or(None); + if last_send.is_some() { + return Err(anyhow!(CodeError::new_err_code_msg( + error_code::TOO_MANY_REQUESTS, + "Please wait before requesting another code", + ))); + } + + // Rate limit: daily count + let count_key = format!("{}{}", SEND_COUNT_LIMIT_KEY_PREFIX, req.email); + let daily_count: i64 = self.cache.get_int(&count_key).await.unwrap_or(None).unwrap_or(0); + if daily_count >= 15 { + return Err(anyhow!(CodeError::new_err_code_msg( + error_code::TODAY_SEND_COUNT_EXCEEDS_LIMIT, + "Daily send limit reached", + ))); + } + + // Validate user state based on type + match method { + "register" => { + let existing = self.repos.user.find_auth_method_by_open_id("email", &req.email).await + .map_err(|e| anyhow!(CodeError::new_err_code_msg(error_code::DATABASE_QUERY_ERROR, e.to_string())))?; + if existing.is_some() { + return Err(anyhow!(CodeError::new_err_code(error_code::USER_EXIST))); + } + } + "security" => { + let existing = self.repos.user.find_auth_method_by_open_id("email", &req.email).await + .map_err(|e| anyhow!(CodeError::new_err_code_msg(error_code::DATABASE_QUERY_ERROR, e.to_string())))?; + if existing.is_none() { + return Err(anyhow!(CodeError::new_err_code(error_code::USER_NOT_EXIST))); + } + } + _ => {} + } + + // Generate 6-digit code + let code: String = rand::thread_rng() + .sample_iter(&rand::distributions::Alphanumeric) + .take(6) + .map(char::from) + .collect(); + + let payload = serde_json::json!({ + "code": code, + "lastAt": chrono::Utc::now().timestamp_millis(), + }); + + // Store in Redis with 300s TTL + self.cache + .set_ex(&cache_key, &payload.to_string(), 300) + .await + .map_err(|e| anyhow!(CodeError::new_err_code_msg(error_code::ERROR, e.to_string())))?; + + // Set interval key (60s) + self.cache + .set_ex(&interval_key, "1", 60) + .await + .map_err(|e| anyhow!(CodeError::new_err_code_msg(error_code::ERROR, e.to_string())))?; + + // Increment daily count + if daily_count == 0 { + self.cache + .set_ex(&count_key, "1", 86400) + .await + .map_err(|e| anyhow!(CodeError::new_err_code_msg(error_code::ERROR, e.to_string())))?; + } else { + self.cache + .incr(&count_key) + .await + .map_err(|e| anyhow!(CodeError::new_err_code_msg(error_code::ERROR, e.to_string())))?; + } + + let email_payload = serde_json::json!({ + "email": req.email, + "code": code, + "type": req.type_, + }); + if let Err(e) = self.queue.enqueue_json(FORTHWITH_SEND_EMAIL, &email_payload).await { + tracing::error!(email = %req.email, "failed to enqueue send-email task: {e}"); + } + + Ok(SendCodeResponse { + code: Some(code), + status: true, + }) + } +} diff --git a/src/service/common/send_sms_code_service.rs b/src/service/common/send_sms_code_service.rs new file mode 100644 index 00000000..665a92b0 --- /dev/null +++ b/src/service/common/send_sms_code_service.rs @@ -0,0 +1,126 @@ +use std::sync::Arc; + +use anyhow::anyhow; +use rand::Rng; + +use crate::cache::Cache; +use crate::config::cache_key::{AUTH_CODE_TELEPHONE_CACHE_KEY, SEND_COUNT_LIMIT_KEY_PREFIX, SEND_INTERVAL_KEY_PREFIX}; +use crate::config::Config; +use crate::model::dto::auth::{SendCodeResponse, SendSmsCodeRequest}; +use crate::queue::client::QueueClient; +use crate::queue::types::FORTHWITH_SEND_SMS; +use crate::repository::Repositories; +use result::code_error::CodeError; +use result::error_code; + +pub struct SendSmsCodeService { + repos: Arc, + _config: Arc, + cache: Arc, + queue: QueueClient, +} + +impl SendSmsCodeService { + pub fn new(repos: Arc, config: Arc, cache: Arc, queue: QueueClient) -> Self { + Self { + repos, + _config: config, + cache, + queue, + } + } + + pub async fn send_code( + &self, + req: SendSmsCodeRequest, + ) -> Result { + let phone = format!("+{}{}", req.telephone_area_code, req.telephone); + let cache_key = format!("{}:{}", AUTH_CODE_TELEPHONE_CACHE_KEY, phone); + + // Rate limit: interval check + let interval_key = format!("{}{}", SEND_INTERVAL_KEY_PREFIX, phone); + let last_send = self.cache.get(&interval_key).await.unwrap_or(None); + if last_send.is_some() { + return Err(anyhow!(CodeError::new_err_code_msg( + error_code::TOO_MANY_REQUESTS, + "Please wait before requesting another code", + ))); + } + + // Daily count limit + let count_key = format!("{}{}", SEND_COUNT_LIMIT_KEY_PREFIX, phone); + let daily_count: i64 = self.cache.get_int(&count_key).await.unwrap_or(None).unwrap_or(0); + if daily_count >= 15 { + return Err(anyhow!(CodeError::new_err_code_msg( + error_code::TODAY_SEND_COUNT_EXCEEDS_LIMIT, + "Daily send limit reached", + ))); + } + + // Validate user state based on type + let existing = self.repos.user.find_auth_method_by_open_id("mobile", &phone).await + .map_err(|e| anyhow!(CodeError::new_err_code_msg(error_code::DATABASE_QUERY_ERROR, e.to_string())))?; + match req.type_ { + 1 => { + if existing.is_some() { + return Err(anyhow!(CodeError::new_err_code(error_code::USER_EXIST))); + } + } + 2 => { + if existing.is_none() { + return Err(anyhow!(CodeError::new_err_code(error_code::USER_NOT_EXIST))); + } + } + _ => {} + } + + // Generate 6-digit code + let code: String = rand::thread_rng() + .sample_iter(&rand::distributions::Alphanumeric) + .take(6) + .map(char::from) + .collect(); + + let payload = serde_json::json!({ + "code": code, + "lastAt": chrono::Utc::now().timestamp_millis(), + }); + + // Store in Redis with 300s TTL + self.cache + .set_ex(&cache_key, &payload.to_string(), 300) + .await + .map_err(|e| anyhow!(CodeError::new_err_code_msg(error_code::ERROR, e.to_string())))?; + + self.cache + .set_ex(&interval_key, "1", 60) + .await + .map_err(|e| anyhow!(CodeError::new_err_code_msg(error_code::ERROR, e.to_string())))?; + + if daily_count == 0 { + self.cache + .set_ex(&count_key, "1", 86400) + .await + .map_err(|e| anyhow!(CodeError::new_err_code_msg(error_code::ERROR, e.to_string())))?; + } else { + self.cache + .incr(&count_key) + .await + .map_err(|e| anyhow!(CodeError::new_err_code_msg(error_code::ERROR, e.to_string())))?; + } + + let sms_payload = serde_json::json!({ + "area_code": req.telephone_area_code, + "telephone": req.telephone, + "code": code, + }); + if let Err(e) = self.queue.enqueue_json(FORTHWITH_SEND_SMS, &sms_payload).await { + tracing::error!(telephone = %req.telephone, "failed to enqueue send-sms task: {e}"); + } + + Ok(SendCodeResponse { + code: Some(code), + status: true, + }) + } +} diff --git a/src/service/mod.rs b/src/service/mod.rs new file mode 100644 index 00000000..89ea20d3 --- /dev/null +++ b/src/service/mod.rs @@ -0,0 +1,11 @@ +pub mod admin; +pub mod auth; +pub mod common; +pub mod nodeconfig; +pub mod notify; +pub mod public; + +pub mod server; +pub mod subscribe; +pub mod telegram; +pub mod telemetry; diff --git a/src/service/nodeconfig/mod.rs b/src/service/nodeconfig/mod.rs new file mode 100644 index 00000000..98e9e539 --- /dev/null +++ b/src/service/nodeconfig/mod.rs @@ -0,0 +1,2 @@ +pub mod r#override; +pub mod override_test; diff --git a/src/service/nodeconfig/override.rs b/src/service/nodeconfig/override.rs new file mode 100644 index 00000000..8b1ff2f9 --- /dev/null +++ b/src/service/nodeconfig/override.rs @@ -0,0 +1,237 @@ +//! Port of `server/internal/logic/nodeconfig/override.go` +//! +//! Provides helpers to: +//! - build `ServerNodeConfigValues` from global `NodeConfig` +//! - apply per-server override on top of global values +//! - convert `ServerConfigOverride` entity ↔ `ServerNodeConfigOverride` DTO + +use crate::config::NodeConfig; +use crate::model::dto::node::{NodeDNS, NodeOutbound, ServerNodeConfigOverride, ServerNodeConfigValues}; +use crate::model::entity::node::ServerConfigOverride; + +// ── helpers ───────────────────────────────────────────────────────────────── + +fn normalize_strings(values: Vec) -> Vec { + let mut seen = std::collections::HashSet::new(); + values + .into_iter() + .map(|s| s.trim().to_string()) + .filter(|s| !s.is_empty() && seen.insert(s.clone())) + .collect() +} + +fn ensure_dns(values: Vec) -> Vec { + values + .into_iter() + .filter(|d| !d.proto.trim().is_empty() && !d.address.trim().is_empty()) + .map(|d| NodeDNS { + proto: d.proto.trim().to_string(), + address: d.address.trim().to_string(), + domains: normalize_strings(d.domains), + }) + .collect() +} + +fn ensure_outbound(values: Vec) -> Vec { + values + .into_iter() + .filter(|o| !o.name.trim().is_empty() && !o.protocol.trim().is_empty()) + .map(|o| NodeOutbound { + name: o.name.trim().to_string(), + protocol: o.protocol.trim().to_string(), + address: o.address.trim().to_string(), + rules: normalize_strings(o.rules), + ..o + }) + .collect() +} + +fn unmarshal_json(value: &str, field: &str) -> anyhow::Result { + let trimmed = value.trim(); + if trimmed.is_empty() { + return Err(anyhow::anyhow!("empty {field}")); + } + serde_json::from_str(trimmed) + .map_err(|e| anyhow::anyhow!("unmarshal server node config {field}: {e}")) +} + +fn marshal_json(value: &T, field: &str) -> anyhow::Result { + serde_json::to_string(value) + .map_err(|e| anyhow::anyhow!("marshal server node config {field}: {e}")) +} + +// ── public API ─────────────────────────────────────────────────────────────── + +/// Build global `ServerNodeConfigValues` from the `NodeConfig` in config.yaml. +/// Mirrors Go `GlobalValues(c config.NodeConfig)`. +pub fn global_values(c: &NodeConfig) -> ServerNodeConfigValues { + let dns = c.dns.iter().map(|d| NodeDNS { + proto: d.proto.clone(), + address: d.address.clone(), + domains: normalize_strings(d.domains.clone()), + }).collect(); + + let outbound = c.outbound.iter().map(|o| NodeOutbound { + name: o.name.clone(), + protocol: o.protocol.clone(), + address: o.address.clone(), + port: o.port, + user: Some(o.user.clone()).filter(|s| !s.is_empty()), + password: o.password.clone(), + uuid: Some(o.uuid.clone()).filter(|s| !s.is_empty()), + cipher: Some(o.cipher.clone()).filter(|s| !s.is_empty()), + security: Some(o.security.clone()).filter(|s| !s.is_empty()), + sni: Some(o.sni.clone()).filter(|s| !s.is_empty()), + allow_insecure: o.allow_insecure, + fingerprint: Some(o.fingerprint.clone()).filter(|s| !s.is_empty()), + transport: Some(o.transport.clone()).filter(|s| !s.is_empty()), + host: Some(o.host.clone()).filter(|s| !s.is_empty()), + path: Some(o.path.clone()).filter(|s| !s.is_empty()), + service_name: Some(o.service_name.clone()).filter(|s| !s.is_empty()), + flow: Some(o.flow.clone()).filter(|s| !s.is_empty()), + uot: o.uot, + uot_version: o.uot_version, + congestion_controller: Some(o.congestion_controller.clone()).filter(|s| !s.is_empty()), + udp_stream: o.udp_stream, + reduce_rtt: o.reduce_rtt, + heartbeat: o.heartbeat, + reality_public_key: Some(o.reality_public_key.clone()).filter(|s| !s.is_empty()), + reality_short_id: Some(o.reality_short_id.clone()).filter(|s| !s.is_empty()), + spider_x: None, + settings: None, + stream_settings: None, + rules: Vec::new(), + }).collect(); + + ServerNodeConfigValues { + ip_strategy: c.ip_strategy.clone(), + dns: ensure_dns(dns), + block: normalize_strings(c.block.clone()), + outbound: ensure_outbound(outbound), + } +} + +/// Apply per-server override on top of global values (in-place). +/// Mirrors Go `ApplyOverride`. +pub fn apply_override( + values: &mut ServerNodeConfigValues, + override_entity: &ServerConfigOverride, +) -> anyhow::Result<()> { + if override_entity.id == 0 { + return Ok(()); + } + if let Some(ref s) = override_entity.ip_strategy { + values.ip_strategy = s.clone(); + } + if let Some(ref s) = override_entity.dns { + let dns: Vec = unmarshal_json(s, "dns")?; + values.dns = ensure_dns(dns); + } + if let Some(ref s) = override_entity.block { + let block: Vec = unmarshal_json(s, "block")?; + values.block = normalize_strings(block); + } + if let Some(ref s) = override_entity.outbound { + let outbound: Vec = unmarshal_json(s, "outbound")?; + values.outbound = ensure_outbound(outbound); + } + Ok(()) +} + +/// Build the DTO response for a server's override config. +/// Mirrors Go `OverrideResponse`. +pub fn override_response( + override_entity: Option<&ServerConfigOverride>, +) -> anyhow::Result { + let mut resp = ServerNodeConfigOverride { + inherit_ip_strategy: true, + ip_strategy: None, + inherit_dns: true, + dns: vec![], + inherit_block: true, + block: vec![], + inherit_outbound: true, + outbound: vec![], + }; + let ov = match override_entity { + Some(e) if e.id != 0 => e, + _ => return Ok(resp), + }; + if let Some(ref s) = ov.ip_strategy { + resp.inherit_ip_strategy = false; + resp.ip_strategy = Some(s.clone()); + } + if let Some(ref s) = ov.dns { + let dns: Vec = unmarshal_json(s, "dns")?; + resp.inherit_dns = false; + resp.dns = ensure_dns(dns); + } + if let Some(ref s) = ov.block { + let block: Vec = unmarshal_json(s, "block")?; + resp.inherit_block = false; + resp.block = normalize_strings(block); + } + if let Some(ref s) = ov.outbound { + let outbound: Vec = unmarshal_json(s, "outbound")?; + resp.inherit_outbound = false; + resp.outbound = ensure_outbound(outbound); + } + Ok(resp) +} + +/// Convert a DTO override request into an entity ready for DB upsert. +/// Returns `(entity, all_inherited)`. Mirrors Go `OverrideModel`. +pub fn override_model( + server_id: i64, + req: &ServerNodeConfigOverride, +) -> anyhow::Result<(ServerConfigOverride, bool)> { + use chrono::Utc; + let now = Utc::now().timestamp_millis(); + let mut data = ServerConfigOverride { + id: 0, + server_id, + ip_strategy: None, + dns: None, + block: None, + outbound: None, + created_at: now, + updated_at: now, + }; + if !req.inherit_ip_strategy { + data.ip_strategy = req.ip_strategy.clone(); + } + if !req.inherit_dns { + data.dns = Some(marshal_json(&ensure_dns(req.dns.clone()), "dns")?); + } + if !req.inherit_block { + data.block = Some(marshal_json(&normalize_strings(req.block.clone()), "block")?); + } + if !req.inherit_outbound { + data.outbound = Some(marshal_json(&ensure_outbound(req.outbound.clone()), "outbound")?); + } + let all_inherited = data.ip_strategy.is_none() + && data.dns.is_none() + && data.block.is_none() + && data.outbound.is_none(); + Ok((data, all_inherited)) +} + +/// Deep-clone a `ServerNodeConfigValues`. +/// Mirrors Go `CloneValues`. +pub fn clone_values(values: &ServerNodeConfigValues) -> ServerNodeConfigValues { + let dns = values.dns.iter().map(|d| NodeDNS { + proto: d.proto.clone(), + address: d.address.clone(), + domains: normalize_strings(d.domains.clone()), + }).collect(); + let outbound = values.outbound.iter().map(|o| NodeOutbound { + rules: normalize_strings(o.rules.clone()), + ..o.clone() + }).collect(); + ServerNodeConfigValues { + ip_strategy: values.ip_strategy.clone(), + dns: ensure_dns(dns), + block: normalize_strings(values.block.clone()), + outbound: ensure_outbound(outbound), + } +} diff --git a/src/service/nodeconfig/override_test.rs b/src/service/nodeconfig/override_test.rs new file mode 100644 index 00000000..6b276515 --- /dev/null +++ b/src/service/nodeconfig/override_test.rs @@ -0,0 +1 @@ +// test stub diff --git a/src/service/notify/alipay_notify_service.rs b/src/service/notify/alipay_notify_service.rs new file mode 100644 index 00000000..31b87313 --- /dev/null +++ b/src/service/notify/alipay_notify_service.rs @@ -0,0 +1,74 @@ +//! Alipay async-notification handler. +//! +//! Port of `server/internal/logic/notify/alipayNotifyLogic.go`. + +use std::sync::Arc; + +use anyhow::anyhow; + +use payment::alipay::{Config as AlipayConfig, OrderStatus, Provider}; +use crate::model::entity::payment::AlipayF2FConfig; +use crate::queue::client::QueueClient; +use crate::queue::types::FORTHWITH_ACTIVATE_ORDER; +use crate::repository::Repositories; + +pub struct AlipayNotifyService { + pub repos: Arc, +} + +impl AlipayNotifyService { + pub fn new(repos: Arc) -> Self { + Self { repos } + } + + pub async fn handle(&self, token: &str, body: axum::body::Bytes, queue: &QueueClient) -> Result { + // 1. Load payment config. + let payment = self + .repos + .payment + .find_one_by_token(token) + .await + .map_err(|e| anyhow!("load payment config: {e}"))?; + + let cfg: AlipayF2FConfig = serde_json::from_str(&payment.config) + .map_err(|e| anyhow!("parse alipay config: {e}"))?; + + let provider = Provider::new(AlipayConfig { + app_id: cfg.app_id, + private_key: cfg.private_key, + public_key: cfg.public_key, + invoice_name: String::new(), + notify_url: String::new(), + sandbox: cfg.sandbox, + }) + .map_err(|e| anyhow!("init alipay provider: {e}"))?; + + // 2. Verify signature — hard failure if invalid. + let notification = provider + .decode_notification(&body) + .map_err(|e| anyhow!("alipay signature verify failed: {e}"))?; + + // 3. Only process TRADE_SUCCESS. + if notification.status != OrderStatus::Success { + return Ok("success".into()); + } + + // 4. Load order. + let order = self + .repos + .order + .find_one_by_order_no(¬ification.order_no) + .await + .map_err(|e| anyhow!("find order {}: {e}", notification.order_no))?; + + // 5. Enqueue activation task. + let payload = serde_json::to_vec(&order.id).map_err(|e| anyhow!("serialize payload: {e}"))?; + queue + .enqueue(FORTHWITH_ACTIVATE_ORDER, &payload) + .await + .map_err(|e| anyhow!("enqueue activate order: {e}"))?; + tracing::info!(order_no = %order.order_no, task = FORTHWITH_ACTIVATE_ORDER, "enqueued activate order"); + + Ok("success".into()) + } +} diff --git a/src/service/notify/e_pay_notify_service.rs b/src/service/notify/e_pay_notify_service.rs new file mode 100644 index 00000000..31b67929 --- /dev/null +++ b/src/service/notify/e_pay_notify_service.rs @@ -0,0 +1,82 @@ +//! ePay async-notification handler. +//! +//! Port of `server/internal/logic/notify/ePayNotifyLogic.go`. + +use std::collections::HashMap; +use std::sync::Arc; + +use anyhow::anyhow; + +use payment::epay::{Config as EPayConfig, Provider}; +use crate::model::entity::payment::EPayConfig as EPayEntityConfig; +use crate::queue::client::QueueClient; +use crate::queue::types::FORTHWITH_ACTIVATE_ORDER; +use crate::repository::Repositories; + +pub struct EPayNotifyService { + pub repos: Arc, +} + +impl EPayNotifyService { + pub fn new(repos: Arc) -> Self { + Self { repos } + } + + pub async fn handle( + &self, + token: &str, + params: HashMap, + queue: &QueueClient, + ) -> Result { + // 1. Load payment config. + let payment = self + .repos + .payment + .find_one_by_token(token) + .await + .map_err(|e| anyhow!("load payment config: {e}"))?; + + let cfg: EPayEntityConfig = serde_json::from_str(&payment.config) + .map_err(|e| anyhow!("parse epay config: {e}"))?; + + let provider = Provider::new(EPayConfig { + pid: cfg.pid, + url: cfg.url, + key: cfg.key, + pay_type: cfg.type_, + }); + + // 2. Verify MD5 signature — hard failure if invalid. + if !provider.verify_sign(¶ms) { + return Err(anyhow!("epay signature verification failed")); + } + + // 3. Only process TRADE_SUCCESS. + let trade_status = params.get("trade_status").map(String::as_str).unwrap_or(""); + if trade_status != "TRADE_SUCCESS" { + return Ok("success".into()); + } + + let order_no = params + .get("out_trade_no") + .ok_or_else(|| anyhow!("missing out_trade_no"))?; + + // 4. Load order. + let order = self + .repos + .order + .find_one_by_order_no(order_no) + .await + .map_err(|e| anyhow!("find order {order_no}: {e}"))?; + + // 5. Enqueue activation. + let payload = serde_json::to_vec(&order.id).map_err(|e| anyhow!("serialize payload: {e}"))?; + queue + .enqueue(FORTHWITH_ACTIVATE_ORDER, &payload) + .await + .map_err(|e| anyhow!("enqueue activate order: {e}"))?; + tracing::info!(order_no = %order.order_no, task = FORTHWITH_ACTIVATE_ORDER, "enqueued activate order"); + + Ok("success".into()) + } +} diff --git a/src/service/notify/mod.rs b/src/service/notify/mod.rs new file mode 100644 index 00000000..af135ae9 --- /dev/null +++ b/src/service/notify/mod.rs @@ -0,0 +1,3 @@ +pub mod alipay_notify_service; +pub mod e_pay_notify_service; +pub mod stripe_notify_service; diff --git a/src/service/notify/stripe_notify_service.rs b/src/service/notify/stripe_notify_service.rs new file mode 100644 index 00000000..d38740f6 --- /dev/null +++ b/src/service/notify/stripe_notify_service.rs @@ -0,0 +1,88 @@ +//! Stripe webhook handler. +//! +//! Port of `server/internal/logic/notify/stripeNotifyLogic.go`. + +use std::sync::Arc; + +use anyhow::anyhow; + +use payment::stripe::{Config as StripeConfig, Provider}; +use crate::model::entity::payment::StripeConfig as StripeEntityConfig; +use crate::queue::client::QueueClient; +use crate::queue::types::FORTHWITH_ACTIVATE_ORDER; +use crate::repository::Repositories; + +pub struct StripeNotifyService { + pub repos: Arc, +} + +impl StripeNotifyService { + pub fn new(repos: Arc) -> Self { + Self { repos } + } + + pub async fn handle( + &self, + token: &str, + payload: axum::body::Bytes, + signature: &str, + queue: &QueueClient, + ) -> Result { + // 1. Load payment config. + let payment = self + .repos + .payment + .find_one_by_token(token) + .await + .map_err(|e| anyhow!("load payment config: {e}"))?; + + let cfg: StripeEntityConfig = serde_json::from_str(&payment.config) + .map_err(|e| anyhow!("parse stripe config: {e}"))?; + + let provider = Provider::new(StripeConfig { + public_key: cfg.public_key, + secret_key: cfg.secret_key, + webhook_secret: cfg.webhook_secret, + }); + + // 2. Verify webhook signature — hard failure if invalid. + let notification = provider + .parse_notify(&payload, signature) + .map_err(|e| anyhow!("stripe signature verify failed: {e}"))?; + + // 3. Only process payment_intent.succeeded / checkout.session.completed. + let is_success = notification.event_type == "payment_intent.succeeded" + || notification.event_type == "checkout.session.completed"; + + if !is_success { + return Ok("ok".into()); + } + + if notification.order_no.is_empty() { + return Err(anyhow!("stripe notification missing order_no in metadata")); + } + + // 4. Load order. + let order = self + .repos + .order + .find_one_by_order_no(¬ification.order_no) + .await + .map_err(|e| anyhow!("find order {}: {e}", notification.order_no))?; + + // 5. Enqueue activation. + let payload_bytes = serde_json::to_vec(&order.id).map_err(|e| anyhow!("serialize payload: {e}"))?; + queue + .enqueue(FORTHWITH_ACTIVATE_ORDER, &payload_bytes) + .await + .map_err(|e| anyhow!("enqueue activate order: {e}"))?; + tracing::info!( + order_no = %order.order_no, + trade_no = %notification.trade_no, + task = FORTHWITH_ACTIVATE_ORDER, + "enqueued activate order" + ); + + Ok("ok".into()) + } +} diff --git a/src/service/public/announcement/mod.rs b/src/service/public/announcement/mod.rs new file mode 100644 index 00000000..80673624 --- /dev/null +++ b/src/service/public/announcement/mod.rs @@ -0,0 +1 @@ +pub mod query_announcement_service; diff --git a/src/service/public/announcement/query_announcement_service.rs b/src/service/public/announcement/query_announcement_service.rs new file mode 100644 index 00000000..6c0f3375 --- /dev/null +++ b/src/service/public/announcement/query_announcement_service.rs @@ -0,0 +1,27 @@ +//! List enabled announcements. + +use std::sync::Arc; + +use anyhow::anyhow; + +use crate::model::entity::announcement::Announcement; +use crate::repository::Repositories; + +pub struct QueryAnnouncementService { + pub repos: Arc, +} + +impl QueryAnnouncementService { + pub fn new(repos: Arc) -> Self { + Self { repos } + } + + /// Return all shown (enabled) announcements, newest first. + pub async fn query_list(&self, page: i64, size: i64) -> Result<(i64, Vec), anyhow::Error> { + self.repos + .announcement + .get_list_by_page(page, size, Some(true), None, None, None) + .await + .map_err(|e| anyhow!("query announcements: {e}")) + } +} diff --git a/src/service/public/document/mod.rs b/src/service/public/document/mod.rs new file mode 100644 index 00000000..0d13ff76 --- /dev/null +++ b/src/service/public/document/mod.rs @@ -0,0 +1,2 @@ +pub mod query_document_detail_service; +pub mod query_document_list_service; diff --git a/src/service/public/document/query_document_detail_service.rs b/src/service/public/document/query_document_detail_service.rs new file mode 100644 index 00000000..ee6daf91 --- /dev/null +++ b/src/service/public/document/query_document_detail_service.rs @@ -0,0 +1,29 @@ +//! Get document detail by id. + +use std::sync::Arc; + +use anyhow::anyhow; + +use crate::model::entity::document::Document; +use crate::repository::Repositories; +use result::code_error::CodeError; +use result::error_code; + +pub struct QueryDocumentDetailService { + pub repos: Arc, +} + +impl QueryDocumentDetailService { + pub fn new(repos: Arc) -> Self { + Self { repos } + } + + pub async fn query_detail(&self, id: i64) -> Result { + self.repos + .document + .query_detail(id) + .await + .map_err(|e| anyhow!("query document detail: {e}"))? + .ok_or_else(|| anyhow!(CodeError::new_err_code(error_code::ERROR))) + } +} diff --git a/src/service/public/document/query_document_list_service.rs b/src/service/public/document/query_document_list_service.rs new file mode 100644 index 00000000..2033d867 --- /dev/null +++ b/src/service/public/document/query_document_list_service.rs @@ -0,0 +1,31 @@ +//! List published documents. + +use std::sync::Arc; + +use anyhow::anyhow; + +use crate::model::entity::document::Document; +use crate::repository::Repositories; + +pub struct QueryDocumentListService { + pub repos: Arc, +} + +impl QueryDocumentListService { + pub fn new(repos: Arc) -> Self { + Self { repos } + } + + pub async fn query_list( + &self, + page: i64, + size: i64, + tag: Option<&str>, + ) -> Result<(i64, Vec), anyhow::Error> { + self.repos + .document + .query_list(page, size, tag, None) + .await + .map_err(|e| anyhow!("query document list: {e}")) + } +} diff --git a/src/service/public/mod.rs b/src/service/public/mod.rs new file mode 100644 index 00000000..e46b2724 --- /dev/null +++ b/src/service/public/mod.rs @@ -0,0 +1,9 @@ +// TODO: all sub-modules are stubs — implement business logic per Go service package +pub mod announcement; +pub mod document; +pub mod order; +pub mod payment; +pub mod portal; +pub mod subscribe; +pub mod ticket; +pub mod user; diff --git a/src/service/public/order/calculate_coupon.rs b/src/service/public/order/calculate_coupon.rs new file mode 100644 index 00000000..0873629c --- /dev/null +++ b/src/service/public/order/calculate_coupon.rs @@ -0,0 +1,97 @@ +//! Coupon discount calculation + enabled-state guard. +//! +//! Direct port of `server/internal/logic/public/order/calculateCoupon.go`. + +use anyhow::anyhow; + +use crate::model::entity::coupon::Coupon; + +use super::constant::COUPON_TYPE_PERCENTAGE; +use result::code_error::CodeError; +use result::error_code; + +/// Coupon is unusable if `enable == Some(false)`. Mirrors Go's +/// `coupon.Coupon.IsEnabled()` (which returns true when `enable` is nil +/// or true, false only when explicitly disabled). +pub fn ensure_coupon_enabled(coupon_info: &Coupon) -> Result<(), anyhow::Error> { + match coupon_info.enable { + Some(false) => Err(anyhow!(CodeError::new_err_code(error_code::COUPON_DISABLED))), + _ => Ok(()), + } +} + +/// Compute the discount amount for a given order amount and coupon. +/// +/// - `COUPON_TYPE_PERCENTAGE` (`1`) → `amount * Coupon.Discount / 100` +/// (truncated to integer). +/// - Any other type → `min(Coupon.Discount, amount)`. +pub fn calculate_coupon(amount: i64, coupon_info: &Coupon) -> i64 { + if coupon_info.type_ == COUPON_TYPE_PERCENTAGE { + amount.saturating_mul(coupon_info.discount) / 100 + } else { + // Fixed amount discount — never exceeds the order total. + coupon_info.discount.min(amount) + } +} + +/// Re-export `ensure_coupon_enabled` so the order pre-create and purchase +/// services can call it without exposing the helper above. +pub use ensure_coupon_enabled as ensure_enabled; + +#[cfg(test)] +mod tests { + use super::*; + + fn coupon(type_: i16, discount: i64, enable: Option) -> Coupon { + Coupon { + id: 0, + name: String::new(), + code: String::new(), + count: 0, + type_, + discount, + start_time: 0, + expire_time: 0, + user_limit: 0, + subscribe: String::new(), + used_count: 0, + enable, + created_at: 0, + updated_at: 0, + } + } + + #[test] + fn percentage_coupon_uses_amount() { + // 1000 * 20 / 100 = 200 + assert_eq!( + calculate_coupon(1000, &coupon(COUPON_TYPE_PERCENTAGE, 20, None)), + 200 + ); + } + + #[test] + fn fixed_coupon_uses_discount_value() { + assert_eq!(calculate_coupon(1000, &coupon(0, 50, None)), 50); + } + + #[test] + fn fixed_coupon_caps_at_amount() { + // 1000 amount but 5000 fixed → cap at 1000 + assert_eq!(calculate_coupon(1000, &coupon(0, 5000, None)), 1000); + } + + #[test] + fn ensure_enabled_rejects_explicitly_disabled() { + let c = coupon(0, 10, Some(false)); + assert!(ensure_coupon_enabled(&c).is_err()); + } + + #[test] + fn ensure_enabled_accepts_enabled_or_nil() { + let c1 = coupon(0, 10, Some(true)); + let c2 = coupon(0, 10, None); + assert!(ensure_coupon_enabled(&c1).is_ok()); + assert!(ensure_coupon_enabled(&c2).is_ok()); + } +} diff --git a/src/service/public/order/calculate_fee.rs b/src/service/public/order/calculate_fee.rs new file mode 100644 index 00000000..5fae78bd --- /dev/null +++ b/src/service/public/order/calculate_fee.rs @@ -0,0 +1,95 @@ +//! Calculate the payment handling fee for a given amount + payment config. +//! +//! Direct port of `server/internal/logic/public/order/calculateFee.go`. The +//! fee logic in Go depends only on `payment.FeeMode`, `FeePercent` and +//! `FeeAmount`, so we mirror the same fields on the [`Payment`] entity. + +use crate::model::entity::payment::Payment; + +use super::constant::{ + FEE_MODE_FIXED, FEE_MODE_NONE, FEE_MODE_PERCENT, FEE_MODE_PERCENT_PLUS_FIXED, +}; + +/// Compute the handling fee for `amount` using `payment`'s fee mode. +/// +/// Mode semantics (matches `payment.FeeMode` in Go): +/// - `FEE_MODE_NONE` (`0`) → always 0. +/// - `FEE_MODE_PERCENT` (`1`) → `amount * FeePercent / 100`. +/// - `FEE_MODE_FIXED` (`2`) → `FeeAmount` when `amount > 0`, else 0. +/// - `FEE_MODE_PERCENT_PLUS_FIXED` (`3`) → `(amount * FeePercent / 100) + FeeAmount`. +pub fn calculate_fee(amount: i64, payment: &Payment) -> i64 { + let fee_percent = payment.fee_percent; + let fee_amount = payment.fee_amount; + + match payment.fee_mode { + FEE_MODE_NONE => 0, + FEE_MODE_PERCENT => amount.saturating_mul(fee_percent) / 100, + FEE_MODE_FIXED => { + if amount > 0 { + fee_amount + } else { + 0 + } + } + FEE_MODE_PERCENT_PLUS_FIXED => { + amount.saturating_mul(fee_percent) / 100 + fee_amount + } + // Unknown mode — Go's switch silently falls through and returns 0. + _ => 0, + } +} + +#[cfg(test)] +mod tests { + use super::*; + + fn payment(mode: i64, percent: i64, fixed: i64) -> Payment { + Payment { + id: 0, + name: String::new(), + platform: String::new(), + icon: String::new(), + domain: String::new(), + config: String::new(), + description: None, + fee_mode: mode, + fee_percent: percent, + fee_amount: fixed, + sort: 0, + enable: None, + token: String::new(), + created_at: 0, + updated_at: 0, + } + } + + #[test] + fn none_mode_returns_zero() { + assert_eq!(calculate_fee(100, &payment(FEE_MODE_NONE, 10, 5)), 0); + } + + #[test] + fn percent_mode_uses_amount() { + // 200 * 10 / 100 = 20 + assert_eq!(calculate_fee(200, &payment(FEE_MODE_PERCENT, 10, 999)), 20); + } + + #[test] + fn fixed_mode_zero_amount_returns_zero() { + assert_eq!(calculate_fee(0, &payment(FEE_MODE_FIXED, 0, 9)), 0); + } + + #[test] + fn fixed_mode_positive_amount_returns_fixed() { + assert_eq!(calculate_fee(1, &payment(FEE_MODE_FIXED, 0, 9)), 9); + } + + #[test] + fn percent_plus_fixed_mode_sums_both() { + // 1000 * 2 / 100 + 5 = 25 + assert_eq!( + calculate_fee(1000, &payment(FEE_MODE_PERCENT_PLUS_FIXED, 2, 5)), + 25 + ); + } +} diff --git a/src/service/public/order/close_order_service.rs b/src/service/public/order/close_order_service.rs new file mode 100644 index 00000000..bd0ae660 --- /dev/null +++ b/src/service/public/order/close_order_service.rs @@ -0,0 +1,128 @@ +//! `CloseOrder` — user cancels an unpaid order, refunding the gift +//! balance and restoring subscribe inventory when applicable. +//! +//! Port of `server/internal/logic/public/order/closeOrderLogic.go`. +//! The order is left in status `3` (cancelled) rather than deleted, but +//! guest orders (user_id == 0) are deleted entirely to match Go. + +use std::sync::Arc; + +use anyhow::anyhow; +use chrono::Utc; + +use crate::model::dto::order::CloseOrderRequest; +use crate::repository::Repositories; +use crate::service::telemetry::Telemetry; + +use super::constant::{ORDER_STATUS_CANCELLED, ORDER_STATUS_UNPAID}; +use result::code_error::CodeError; +use result::error_code; + +pub struct CloseOrderService { + repos: Arc, +} + +impl CloseOrderService { + pub fn new(repos: Arc) -> Self { + Self { repos } + } + + /// Close `order_no` for the given `user_id`. + /// + /// `user_id` is accepted for handler-signature parity with the other + /// order services; the Go logic keys cancellation by `order_no` only, + /// so no ownership check is performed here. + pub async fn close( + &self, + _user_id: i64, + req: CloseOrderRequest, + ) -> Result<(), anyhow::Error> { + let order = self + .repos + .order + .find_one_by_order_no(&req.order_no) + .await + .map_err(|_| anyhow!(CodeError::new_err_code(error_code::ORDER_NOT_EXIST)))?; + + // Idempotent — already paid/cancelled is a no-op. + if order.status != ORDER_STATUS_UNPAID { + tracing::info!( + order_no = %order.order_no, + status = order.status, + "close_order: order not in unpaid state, skipping", + ); + return Ok(()); + } + + // Restore subscribe inventory (fetched once if SubscribeId is set). + if order.subscribe_id > 0 { + let mut sub = self + .repos + .subscribe + .find_one(order.subscribe_id) + .await + .map_err(|_| { + anyhow!(CodeError::new_err_code(error_code::DATABASE_QUERY_ERROR)) + })?; + if sub.inventory != -1 { + sub.inventory += 1; + sub.updated_at = Utc::now().timestamp_millis(); + if let Err(e) = self.repos.subscribe.update(&sub).await { + tracing::error!(?e, subscribe_id = sub.id, "failed to restore inventory"); + return Err(anyhow!(CodeError::new_err_code_msg( + error_code::DATABASE_UPDATE_ERROR, + &e.to_string(), + ))); + } + } + } + + // Update order status → cancelled. + if let Err(e) = self + .repos + .order + .update_order_status(&order.order_no, ORDER_STATUS_CANCELLED) + .await + { + tracing::error!(?e, order_no = %order.order_no, "failed to update order status"); + return Err(anyhow!(CodeError::new_err_code_msg( + error_code::DATABASE_UPDATE_ERROR, + &e.to_string(), + ))); + } + + // Refund gift-amount and write telemetry for non-guest orders. + if order.user_id != 0 && order.gift_amount > 0 { + let mut user = self + .repos + .user + .find_one_user(order.user_id) + .await + .map_err(|_| { + anyhow!(CodeError::new_err_code(error_code::DATABASE_QUERY_ERROR)) + })?; + user.gift_amount += order.gift_amount; + user.updated_at = Utc::now().timestamp_millis(); + if let Err(e) = self.repos.user.update_user(&user).await { + tracing::error!(?e, user_id = order.user_id, "failed to refund gift_amount"); + return Err(anyhow!(CodeError::new_err_code_msg( + error_code::DATABASE_UPDATE_ERROR, + &e.to_string(), + ))); + } + Telemetry::gift( + &self.repos, + order.user_id, + 341, // GIFT_TYPE_INCREASE + &order.order_no, + 0, + order.gift_amount, + user.gift_amount, + Some("Order cancellation refund".to_string()), + ) + .await; + } + + Ok(()) + } +} diff --git a/src/service/public/order/constant.rs b/src/service/public/order/constant.rs new file mode 100644 index 00000000..b8542a15 --- /dev/null +++ b/src/service/public/order/constant.rs @@ -0,0 +1,55 @@ +//! Order-domain constants — ported from +//! `server/internal/logic/public/order/constant.go`. +//! +//! Only numeric limits and method-name strings that the order service +//! references are mirrored here. Order type / status values are +//! centralised in `queue::service::order` (matching Go's `service/order.go`). + +/// Payment method names — match Go `constant.go` literals used as the +/// `method` column on `order`. +pub const EPAY: &str = "epay"; +pub const ALIPAY_F2F: &str = "alipay_f2f"; +pub const STRIPE_ALIPAY: &str = "stripe_alipay"; +pub const STRIPE_WECHAT_PAY: &str = "stripe_wechat_pay"; +pub const BALANCE: &str = "balance"; + +/// Order amount limits — ported verbatim from Go. +/// +/// - `MAX_ORDER_AMOUNT` matches Go's `MaxOrderAmount = 2_147_483_647` (i32 max). +/// - `MAX_RECHARGE_AMOUNT` matches Go's `MaxRechargeAmount = 2_000_000_000` +/// (slightly lower for safety). +/// - `MAX_QUANTITY` matches Go's `MaxQuantity = 1000`. +pub const MAX_ORDER_AMOUNT: i64 = 2_147_483_647; +pub const MAX_RECHARGE_AMOUNT: i64 = 2_000_000_000; +pub const MAX_QUANTITY: i64 = 1_000; + +/// Time before an unpaid order is auto-closed — matches Go's +/// `CloseOrderTimeMinutes = 15` in `purchaseLogic.go`. +pub const CLOSE_ORDER_TIME_MINUTES: i64 = 15; + +/// Order type values — mirrored from Go's `order.Type` enum +/// (`1=new, 2=renewal, 3=reset_traffic, 4=recharge`). +/// +/// Kept as `i16` to match the entity column type +/// (`Order.type_` is `TinyUint = i16`). +pub const ORDER_TYPE_SUBSCRIBE: i16 = 1; +pub const ORDER_TYPE_RENEWAL: i16 = 2; +pub const ORDER_TYPE_RESET_TRAFFIC: i16 = 3; +pub const ORDER_TYPE_RECHARGE: i16 = 4; + +/// Order status values — mirrored from Go's `order.Status` enum +/// (`1=pending, 2=paid, 3=cancelled`). Stored as `i16` (`TinyUint`). +pub const ORDER_STATUS_UNPAID: i16 = 1; +pub const ORDER_STATUS_PAID: i16 = 2; +pub const ORDER_STATUS_CANCELLED: i16 = 3; + +/// Coupon discount type — `1=percentage`, anything else = fixed amount. +/// Mirrors Go's `couponInfo.Type` discriminator in `calculateCoupon`. +pub const COUPON_TYPE_PERCENTAGE: i16 = 1; + +/// Payment fee modes — mirror Go's `payment.FeeMode`. +/// `0` = no fee, `1` = percent, `2` = fixed, `3` = percent + fixed. +pub const FEE_MODE_NONE: i64 = 0; +pub const FEE_MODE_PERCENT: i64 = 1; +pub const FEE_MODE_FIXED: i64 = 2; +pub const FEE_MODE_PERCENT_PLUS_FIXED: i64 = 3; diff --git a/src/service/public/order/get_discount.rs b/src/service/public/order/get_discount.rs new file mode 100644 index 00000000..90d511b6 --- /dev/null +++ b/src/service/public/order/get_discount.rs @@ -0,0 +1,109 @@ +//! Resolve the quantity-based discount rate for a subscribe plan. +//! +//! Direct port of `server/internal/logic/public/order/getDiscount.go`. +//! +//! Go's `Subscribe.Discount` is a JSON column of `[]types.SubscribeDiscount`, +//! where each entry has `{ quantity int64, discount int64 }`. The function +//! picks the smallest `discount` whose `quantity <= input_months` and +//! returns it as a fraction (i.e. `discount/100`). + +use serde::{Deserialize, Serialize}; + +/// Single discount tier deserialised from `Subscribe.discount` JSON. +#[derive(Debug, Clone, Serialize, Deserialize)] +pub struct SubscribeDiscount { + /// Threshold: tiers with `quantity > input_months` are skipped. + pub quantity: i64, + /// Discount value, where `100` = no discount, `80` = 20% off, etc. + /// Stored as a percentage (`0..=100`) for parity with Go. + pub discount: i64, +} + +/// Parse `Subscribe.discount` JSON. Returns an empty vec on parse failure +/// — matches Go's `_ = json.Unmarshal([]byte(sub.Discount), &dis)` and the +/// `if sub.Discount != ""` guard. +pub fn parse_discounts(json: &str) -> Vec { + if json.is_empty() { + return Vec::new(); + } + serde_json::from_str(json).unwrap_or_default() +} + +/// Resolve the discount multiplier for `input_months`. +/// +/// - Returns `1.0` (no discount) when no tier qualifies. +/// - Returns `min_discount / 100.0` otherwise. +/// +/// Mirrors Go's loop: +/// ```text +/// var finalDiscount float64 = 100 +/// for _, discount := range discounts { +/// if inputMonths >= discount.Quantity && discount.Discount < finalDiscount { +/// finalDiscount = discount.Discount +/// } +/// } +/// return finalDiscount / float64(100) +/// ``` +pub fn get_discount(discounts: &[SubscribeDiscount], input_months: i64) -> f64 { + let mut final_discount: i64 = 100; + for d in discounts { + if input_months >= d.quantity && d.discount < final_discount { + final_discount = d.discount; + } + } + final_discount as f64 / 100.0 +} + +#[cfg(test)] +mod tests { + use super::*; + + #[test] + fn empty_discounts_returns_one() { + assert_eq!(get_discount(&[], 5), 1.0); + } + + #[test] + fn below_threshold_returns_one() { + let discounts = vec![SubscribeDiscount { + quantity: 3, + discount: 80, + }]; + assert_eq!(get_discount(&discounts, 2), 1.0); + } + + #[test] + fn picks_smallest_qualifying_discount() { + let discounts = vec![ + SubscribeDiscount { + quantity: 1, + discount: 95, + }, + SubscribeDiscount { + quantity: 3, + discount: 80, + }, + SubscribeDiscount { + quantity: 6, + discount: 70, + }, + ]; + // 5 months → 80% is the lowest qualifying (q<=5) + assert_eq!(get_discount(&discounts, 5), 0.80); + // 12 months → 70% + assert_eq!(get_discount(&discounts, 12), 0.70); + } + + #[test] + fn parse_discounts_returns_empty_on_garbage() { + assert!(parse_discounts("not json").is_empty()); + } + + #[test] + fn parse_discounts_roundtrip() { + let json = r#"[{"quantity":3,"discount":80},{"quantity":6,"discount":70}]"#; + let v = parse_discounts(json); + assert_eq!(v.len(), 2); + assert_eq!(v[0].discount, 80); + } +} diff --git a/src/service/public/order/mod.rs b/src/service/public/order/mod.rs new file mode 100644 index 00000000..9aa111b1 --- /dev/null +++ b/src/service/public/order/mod.rs @@ -0,0 +1,12 @@ +pub mod calculate_coupon; +pub mod calculate_fee; +pub mod close_order_service; +pub mod constant; +pub mod get_discount; +pub mod pre_create_order_service; +pub mod purchase_service; +pub mod query_order_detail_service; +pub mod query_order_list_service; +pub mod recharge_service; +pub mod renewal_service; +pub mod reset_traffic_service; diff --git a/src/service/public/order/pre_create_order_service.rs b/src/service/public/order/pre_create_order_service.rs new file mode 100644 index 00000000..22321d8d --- /dev/null +++ b/src/service/public/order/pre_create_order_service.rs @@ -0,0 +1,205 @@ +//! `PreCreateOrder` — order price preview without persistence. +//! +//! Port of `server/internal/logic/public/order/preCreateOrderLogic.go`. +//! Validates the plan, fetches the coupon (if any), applies the discount +//! ladder, adds the payment handling fee, then deducts the user's gift +//! balance — exactly mirroring Go's pipeline. The result is a +//! [`PreOrderResponse`] for the frontend preview screen. + +use std::sync::Arc; + +use anyhow::anyhow; + +use crate::model::dto::order::{PreOrderResponse, PurchaseOrderRequest}; +use crate::model::entity::coupon::Coupon; +use crate::repository::Repositories; + +use super::calculate_coupon::{calculate_coupon, ensure_enabled}; +use super::calculate_fee::calculate_fee; +use super::get_discount::{get_discount, parse_discounts}; +use result::code_error::CodeError; +use result::error_code; + +pub struct PreCreateOrderService { + repos: Arc, +} + +impl PreCreateOrderService { + pub fn new(repos: Arc) -> Self { + Self { repos } + } + + /// Compute the price preview for the user identified by `user_id`. + pub async fn pre_create( + &self, + user_id: i64, + req: PurchaseOrderRequest, + ) -> Result { + // The handler is expected to have already extracted `user_id` from + // the auth context; we still need the full User record for the + // `gift_amount` deduction. The auth middleware guarantees the + // user exists and is enabled. + let user = self + .repos + .user + .find_one_user(user_id) + .await + .map_err(|_| { + anyhow!(CodeError::new_err_code(error_code::DATABASE_QUERY_ERROR)) + })?; + + // Normalise quantity — Go sets it to 1 when ≤ 0. + let quantity = if req.quantity <= 0 { 1 } else { req.quantity }; + + // 1. Fetch the subscribe plan. + let sub = self + .repos + .subscribe + .find_one(req.subscribe_id) + .await + .map_err(|_| { + anyhow!(CodeError::new_err_code(error_code::DATABASE_QUERY_ERROR)) + })?; + + // 2. Optional per-user quota check (matches Go preCreateOrderLogic.go:60-68). + if sub.quota > 0 { + let count = self + .repos + .user + .count_user_subscribes_by_user_and_subscribe(user_id, req.subscribe_id) + .await + .map_err(|_| { + anyhow!(CodeError::new_err_code(error_code::DATABASE_QUERY_ERROR)) + })?; + if count >= sub.quota { + return Err(anyhow!(CodeError::new_err_code( + error_code::SUBSCRIBE_QUOTA_LIMIT + ))); + } + } + + // 3. Quantity-based discount multiplier. + let discount = if sub.discount.is_empty() { + 1.0 + } else { + let tiers = parse_discounts(&sub.discount); + get_discount(&tiers, quantity) + }; + + let price = sub.unit_price.saturating_mul(quantity); + let amount = ((price as f64) * discount).round() as i64; + let discount_amount = price - amount; + + // 4. Optional coupon. + let mut coupon_amount: i64 = 0; + if let Some(coupon_code) = req.coupon.as_deref().filter(|c| !c.is_empty()) { + let coupon_info = self + .repos + .coupon + .find_one_by_code(coupon_code) + .await + .map_err(|e| match e { + sqlx::Error::RowNotFound => { + anyhow!(CodeError::new_err_code(error_code::COUPON_NOT_EXIST)) + } + _ => anyhow!(CodeError::new_err_code(error_code::DATABASE_QUERY_ERROR)), + })?; + + self.validate_coupon(&coupon_info, user_id, req.subscribe_id) + .await?; + coupon_amount = calculate_coupon(amount, &coupon_info); + } + let mut amount = amount - coupon_amount; + + // 5. Payment handling fee. + let mut fee_amount: i64 = 0; + if let Some(payment_id) = req.payment { + let payment = self + .repos + .payment + .find_one(payment_id) + .await + .map_err(|_| { + anyhow!(CodeError::new_err_code(error_code::DATABASE_QUERY_ERROR)) + })?; + if amount > 0 { + fee_amount = calculate_fee(amount, &payment); + amount += fee_amount; + } + } + + // 6. Gift-amount deduction (Go's `deductionAmount`). + let mut deduction_amount: i64 = 0; + if user.gift_amount > 0 && amount > 0 { + if user.gift_amount >= amount { + deduction_amount = amount; + amount = 0; + } else { + deduction_amount = user.gift_amount; + amount -= user.gift_amount; + } + } + + // Pre-create is a read-only flow — no telemetry write needed. + + Ok(PreOrderResponse { + price, + amount, + discount: discount_amount, + gift_amount: deduction_amount, + coupon: req.coupon.unwrap_or_default(), + coupon_discount: coupon_amount, + fee_amount, + }) + } + + /// Verify the coupon is enabled, has remaining quota, and applies to + /// the chosen plan. Mirrors Go's `if err := ensureCouponEnabled(…); …` + /// block in preCreateOrderLogic.go. + async fn validate_coupon( + &self, + coupon_info: &Coupon, + user_id: i64, + subscribe_id: i64, + ) -> Result<(), anyhow::Error> { + ensure_enabled(coupon_info)?; + + if coupon_info.count > 0 && coupon_info.count <= coupon_info.used_count { + return Err(anyhow!(CodeError::new_err_code( + error_code::COUPON_ALREADY_USED + ))); + } + + let count = self + .repos + .order + .count_user_coupon_usage(user_id, &coupon_info.code) + .await + .map_err(|_| { + anyhow!(CodeError::new_err_code(error_code::DATABASE_QUERY_ERROR)) + })?; + + if coupon_info.user_limit > 0 && count >= coupon_info.user_limit { + return Err(anyhow!(CodeError::new_err_code( + error_code::COUPON_INSUFFICIENT_USAGE + ))); + } + + // `coupon_info.subscribe` is a comma-separated list of subscribe + // ids the coupon can apply to. An empty string means "all". + if !coupon_info.subscribe.is_empty() { + let allowed: Vec = coupon_info + .subscribe + .split(',') + .filter_map(|s| s.trim().parse::().ok()) + .collect(); + if !allowed.is_empty() && !allowed.contains(&subscribe_id) { + return Err(anyhow!(CodeError::new_err_code( + error_code::COUPON_NOT_APPLICABLE + ))); + } + } + + Ok(()) + } +} diff --git a/src/service/public/order/purchase_service.rs b/src/service/public/order/purchase_service.rs new file mode 100644 index 00000000..511fb24c --- /dev/null +++ b/src/service/public/order/purchase_service.rs @@ -0,0 +1,374 @@ +//! `Purchase` — create a new subscription order for a user. +//! +//! Port of `server/internal/logic/public/order/purchaseLogic.go`. After +//! the order is inserted, the Go side enqueues a `defer:close:order` +//! task to auto-cancel the order after `CloseOrderTimeMinutes` minutes. +//! That enqueue requires a queue client which is not yet plumbed into +//! the Rust service context, so a `TODO` is left in `enqueue_close_task`. + +use std::sync::Arc; + +use anyhow::anyhow; +use chrono::Utc; +use uuid::Uuid; + +use crate::config::Config; +use crate::model::dto::order::{PurchaseOrderRequest, PurchaseOrderResponse}; +use crate::model::entity::coupon::Coupon; +use crate::model::entity::order::Order; +use crate::queue::client::QueueClient; +use crate::queue::types::DEFER_CLOSE_ORDER; +use crate::repository::Repositories; +use crate::service::telemetry::Telemetry; + +use super::calculate_coupon::{calculate_coupon, ensure_enabled}; +use super::calculate_fee::calculate_fee; +use super::constant::{ + MAX_ORDER_AMOUNT, MAX_QUANTITY, ORDER_STATUS_UNPAID, ORDER_TYPE_SUBSCRIBE, +}; +use super::get_discount::{get_discount, parse_discounts}; +use result::code_error::CodeError; +use result::error_code; + +pub struct PurchaseService { + repos: Arc, + config: Arc, + queue: QueueClient, +} + +impl PurchaseService { + pub fn new(repos: Arc, config: Arc, queue: QueueClient) -> Self { + Self { repos, config, queue } + } + + /// Create a new subscription purchase order for `user_id`. + pub async fn purchase( + &self, + user_id: i64, + req: PurchaseOrderRequest, + ) -> Result { + let mut user = self + .repos + .user + .find_one_user(user_id) + .await + .map_err(|_| anyhow!(CodeError::new_err_code(error_code::USER_NOT_EXIST)))?; + + let mut quantity = if req.quantity <= 0 { 1 } else { req.quantity }; + if quantity > MAX_QUANTITY { + return Err(anyhow!(CodeError::new_err_code_msg( + error_code::INVALID_PARAMS, + "quantity exceeds maximum limit", + ))); + } + + // ── Single-model guard (matches Go's `l.svcCtx.Config.Subscribe.SingleModel`) + if self.config.subscribe.single_model { + let user_subs = self + .repos + .user + .query_user_subscribe(user_id, &[1]) + .await + .map_err(|_| { + anyhow!(CodeError::new_err_code(error_code::DATABASE_QUERY_ERROR)) + })?; + if !user_subs.is_empty() { + return Err(anyhow!(CodeError::new_err_code( + error_code::USER_SUBSCRIBE_EXIST + ))); + } + } + + // ── Fetch the subscribe plan. + let mut sub = self + .repos + .subscribe + .find_one(req.subscribe_id) + .await + .map_err(|_| { + anyhow!(CodeError::new_err_code(error_code::DATABASE_QUERY_ERROR)) + })?; + + if !sub.sell { + return Err(anyhow!(CodeError::new_err_code(error_code::ERROR))); + } + if sub.inventory == 0 { + return Err(anyhow!(CodeError::new_err_code( + error_code::SUBSCRIBE_OUT_OF_STOCK + ))); + } + + // ── Per-plan quota check. + if sub.quota > 0 { + let user_subs = self + .repos + .user + .query_user_subscribe(user_id, &[1]) + .await + .map_err(|_| { + anyhow!(CodeError::new_err_code(error_code::DATABASE_QUERY_ERROR)) + })?; + let count = user_subs + .iter() + .filter(|s| s.subscribe_id == req.subscribe_id) + .count() as i64; + if count >= sub.quota { + return Err(anyhow!(CodeError::new_err_code( + error_code::SUBSCRIBE_QUOTA_LIMIT + ))); + } + } + + // ── Discount ladder. + let discount = if sub.discount.is_empty() { + 1.0 + } else { + let tiers = parse_discounts(&sub.discount); + get_discount(&tiers, quantity) + }; + let price = sub.unit_price.saturating_mul(quantity); + let amount = ((price as f64) * discount).round() as i64; + let discount_amount = price - amount; + + if amount > MAX_ORDER_AMOUNT { + return Err(anyhow!(CodeError::new_err_code_msg( + error_code::INVALID_PARAMS, + "order amount exceeds maximum limit", + ))); + } + + // ── Coupon. + let mut coupon_discount: i64 = 0; + if let Some(coupon_code) = req.coupon.as_deref().filter(|c| !c.is_empty()) { + let coupon_info = self + .repos + .coupon + .find_one_by_code(coupon_code) + .await + .map_err(|e| match e { + sqlx::Error::RowNotFound => { + anyhow!(CodeError::new_err_code(error_code::COUPON_NOT_EXIST)) + } + _ => anyhow!(CodeError::new_err_code(error_code::DATABASE_QUERY_ERROR)), + })?; + self.validate_coupon(&coupon_info, user_id, req.subscribe_id, quantity) + .await?; + coupon_discount = calculate_coupon(amount, &coupon_info); + } + let mut amount = amount - coupon_discount; + + // ── Payment method (required for purchase; previews can skip it). + let payment_id = req.payment.ok_or_else(|| { + anyhow!(CodeError::new_err_code_msg( + error_code::INVALID_PARAMS, + "payment method is required", + )) + })?; + let payment = self + .repos + .payment + .find_one(payment_id) + .await + .map_err(|_| { + anyhow!(CodeError::new_err_code(error_code::DATABASE_QUERY_ERROR)) + })?; + + // ── Handling fee. + let mut fee_amount: i64 = 0; + if amount > 0 { + fee_amount = calculate_fee(amount, &payment); + amount += fee_amount; + if amount > MAX_ORDER_AMOUNT { + return Err(anyhow!(CodeError::new_err_code_msg( + error_code::INVALID_PARAMS, + "order amount exceeds maximum limit", + ))); + } + } + + // ── Gift-amount deduction. + let mut deduction_amount: i64 = 0; + if user.gift_amount > 0 && amount > 0 { + if user.gift_amount >= amount { + deduction_amount = amount; + amount = 0; + } else { + deduction_amount = user.gift_amount; + amount -= user.gift_amount; + } + } + + // ── is_new flag — Go defers this to a repository check. + let is_new = self + .repos + .order + .is_user_eligible_for_new_order(user_id) + .await + .map_err(|_| { + anyhow!(CodeError::new_err_code(error_code::DATABASE_QUERY_ERROR)) + })?; + + let now = Utc::now().timestamp_millis(); + let order_no = generate_trade_no(); + let order = Order { + id: 0, + parent_id: None, + user_id, + order_no: order_no.clone(), + type_: ORDER_TYPE_SUBSCRIBE, + quantity, + price, + amount, + gift_amount: deduction_amount, + discount: discount_amount, + coupon: req.coupon.clone(), + coupon_discount, + commission: 0, + payment_id: payment.id, + method: payment.platform.clone(), + fee_amount, + trade_no: None, + status: ORDER_STATUS_UNPAID, + subscribe_id: req.subscribe_id, + subscribe_token: None, + is_new, + created_at: now, + updated_at: now, + }; + + // ── Persist: user deduction + inventory + order insert. + // No `InTx` helper in the Rust repo layer yet, so we serialise + // the three writes and roll back manually on failure. + if deduction_amount > 0 { + let previous_gift = user.gift_amount; + user.gift_amount -= deduction_amount; + user.updated_at = now; + if let Err(e) = self.repos.user.update_user(&user).await { + // Compensate the in-memory copy in case the caller retries. + user.gift_amount = previous_gift; + return Err(anyhow!(CodeError::new_err_code_msg( + error_code::DATABASE_UPDATE_ERROR, + &e.to_string(), + ))); + } + Telemetry::gift( + &self.repos, + user_id, + 342, // GIFT_TYPE_REDUCE + &order_no, + 0, + deduction_amount, + user.gift_amount, + Some("Purchase order deduction".to_string()), + ) + .await; + } + + if sub.inventory != -1 { + sub.inventory -= 1; + sub.updated_at = now; + if let Err(e) = self.repos.subscribe.update(&sub).await { + tracing::error!(?e, subscribe_id = sub.id, "failed to decrement inventory"); + return Err(anyhow!(CodeError::new_err_code_msg( + error_code::DATABASE_UPDATE_ERROR, + &e.to_string(), + ))); + } + } + + if let Err(e) = self.repos.order.insert(&order).await { + tracing::error!(?e, %order_no, "failed to insert purchase order"); + return Err(anyhow!(CodeError::new_err_code_msg( + error_code::DATABASE_INSERT_ERROR, + &e.to_string(), + ))); + } + + self.enqueue_close_task(&order_no).await; + + Ok(PurchaseOrderResponse { order_no }) + } + + /// Coupon validation matching Go's pre-purchase block. + async fn validate_coupon( + &self, + coupon_info: &Coupon, + user_id: i64, + subscribe_id: i64, + _quantity: i64, + ) -> Result<(), anyhow::Error> { + ensure_enabled(coupon_info)?; + + if coupon_info.count != 0 && coupon_info.count <= coupon_info.used_count { + return Err(anyhow!(CodeError::new_err_code( + error_code::COUPON_INSUFFICIENT_USAGE + ))); + } + + if !coupon_info.subscribe.is_empty() { + let allowed: Vec = coupon_info + .subscribe + .split(',') + .filter_map(|s| s.trim().parse::().ok()) + .collect(); + if !allowed.is_empty() && !allowed.contains(&subscribe_id) { + return Err(anyhow!(CodeError::new_err_code( + error_code::COUPON_NOT_APPLICABLE + ))); + } + } + + let count = self + .repos + .order + .count_user_coupon_usage(user_id, &coupon_info.code) + .await + .map_err(|_| { + anyhow!(CodeError::new_err_code(error_code::DATABASE_QUERY_ERROR)) + })?; + + if coupon_info.user_limit > 0 && count >= coupon_info.user_limit { + return Err(anyhow!(CodeError::new_err_code( + error_code::COUPON_INSUFFICIENT_USAGE + ))); + } + + Ok(()) + } + + /// Mirror of Go's `asynq.NewTask(queue.DeferCloseOrder, …) + + /// Queue.Enqueue(task, asynq.ProcessIn(15*time.Minute))`. + /// + /// The Rust port has no queue client plumbed into the service context + /// yet; once `AppState` exposes one, this method should enqueue + /// `crate::queue::types::DEFER_CLOSE_ORDER` with the order number as + /// payload and a 15-minute delay. + async fn enqueue_close_task(&self, order_no: &str) { + let delay = std::time::Duration::from_secs( + (super::constant::CLOSE_ORDER_TIME_MINUTES as u64) * 60, + ); + let payload = match serde_json::to_vec(order_no) { + Ok(b) => b, + Err(e) => { + tracing::error!(order_no, "failed to serialize close-order payload: {e}"); + return; + } + }; + if let Err(e) = self.queue.enqueue_delayed(DEFER_CLOSE_ORDER, &payload, delay).await { + tracing::error!(order_no, "failed to enqueue defer close-order task: {e}"); + } + } +} + +/// Generate a short, unique trade/order number. +/// +/// Go's `tool.GenerateTradeNo` returns a numeric string built from a +/// timestamp + atomic counter. The Rust port uses a UUID-derived +/// identifier for the same uniqueness guarantee. Exposed `pub(super)` +/// so sibling services (`recharge`, `renewal`, `reset_traffic`) can +/// share the same numbering scheme. +pub(super) fn generate_trade_no() -> String { + let short = Uuid::new_v4().simple().to_string(); + // 16 chars keeps it roughly the same length as a 16-digit Go id. + short[..16].to_uppercase() +} diff --git a/src/service/public/order/query_order_detail_service.rs b/src/service/public/order/query_order_detail_service.rs new file mode 100644 index 00000000..82894ce6 --- /dev/null +++ b/src/service/public/order/query_order_detail_service.rs @@ -0,0 +1,106 @@ +//! `QueryOrderDetail` — fetch a single order by `order_no`. +//! +//! Port of `server/internal/logic/public/order/queryOrderDetailLogic.go`. +//! Hides the internal `commission` field from the public-facing response, +//! matching Go's `resp.Commission = 0` step. + +use std::sync::Arc; + +use anyhow::anyhow; + +use crate::model::dto::misc::StringInt64Slice; +use crate::model::dto::order::{OrderDetail, QueryOrderDetailRequest}; +use crate::model::dto::payment::PaymentMethod; +use crate::model::dto::subscribe::{Subscribe, SubscribeDiscount}; +use crate::repository::Repositories; +use result::code_error::CodeError; +use result::error_code; + +pub struct QueryOrderDetailService { + repos: Arc, +} + +impl QueryOrderDetailService { + pub fn new(repos: Arc) -> Self { + Self { repos } + } + + pub async fn query( + &self, + _user_id: i64, + req: QueryOrderDetailRequest, + ) -> Result { + let item = self + .repos + .order + .find_one_details_by_order_no(&req.order_no) + .await + .map_err(|e| match e { + sqlx::Error::RowNotFound => { + anyhow!(CodeError::new_err_code(error_code::ORDER_NOT_EXIST)) + } + _ => anyhow!(CodeError::new_err_code(error_code::DATABASE_QUERY_ERROR)), + })?; + + // Go's behaviour is identical: copy the row, zero the commission. + Ok(OrderDetail { + id: item.id, + user_id: item.user_id, + order_no: item.order_no.clone(), + type_: item.type_ as u8, + quantity: item.quantity, + price: item.price, + amount: item.amount, + gift_amount: item.gift_amount, + discount: item.discount, + coupon: item.coupon.clone().unwrap_or_default(), + coupon_discount: item.coupon_discount, + commission: None, // hidden + payment: PaymentMethod { + id: item.payment_id, + name: item.payment_name.clone().unwrap_or_default(), + platform: item.method.clone(), + description: String::new(), + icon: String::new(), + fee_mode: 0, + fee_percent: 0, + fee_amount: 0, + sort: 0, + }, + method: item.method.clone(), + fee_amount: item.fee_amount, + trade_no: item.trade_no.clone().unwrap_or_default(), + status: item.status as u8, + subscribe_id: item.subscribe_id, + subscribe: Subscribe { + id: item.subscribe_id, + name: item.subscribe_name.clone().unwrap_or_default(), + language: Some(String::new()), + description: None, + unit_price: item.price, + unit_time: String::new(), + discount: Vec::::new(), + replacement: 0, + inventory: 0, + traffic: 0, + speed_limit: 0, + device_limit: 0, + quota: 0, + nodes: StringInt64Slice::default(), + node_tags: Vec::new(), + show: false, + sell: false, + sort: 0, + deduction_ratio: 0, + allow_deduction: false, + reset_cycle: 0, + renewal_reset: false, + show_original_price: false, + created_at: 0, + updated_at: 0, + }, + created_at: item.created_at, + updated_at: item.updated_at, + }) + } +} diff --git a/src/service/public/order/query_order_list_service.rs b/src/service/public/order/query_order_list_service.rs new file mode 100644 index 00000000..e1294bb1 --- /dev/null +++ b/src/service/public/order/query_order_list_service.rs @@ -0,0 +1,126 @@ +//! `QueryOrderList` — paginated order list for the current user. +//! +//! Port of `server/internal/logic/public/order/queryOrderListLogic.go`. +//! Filters by user and strips the internal `commission` field to avoid +//! leaking referer economics to the user-facing API. + +use std::sync::Arc; + +use anyhow::anyhow; + +use crate::model::dto::misc::StringInt64Slice; +use crate::model::dto::order::{OrderDetail, QueryOrderListRequest, QueryOrderListResponse}; +use crate::model::dto::payment::PaymentMethod; +use crate::model::dto::subscribe::{Subscribe, SubscribeDiscount}; +use crate::repository::Repositories; +use result::code_error::CodeError; +use result::error_code; + +pub struct QueryOrderListService { + repos: Arc, +} + +impl QueryOrderListService { + pub fn new(repos: Arc) -> Self { + Self { repos } + } + + pub async fn query( + &self, + user_id: i64, + req: QueryOrderListRequest, + ) -> Result { + let page = i64::from(req.page.max(1)); + let size = i64::from(req.size.max(1)); + + // Go passes `status=0` to mean "all" and `subscribe_id=0` / + // `search=""` to mean unfiltered. The repo signature takes + // `Option<&str>` for the search term. + let (total, data) = self + .repos + .order + .query_list_by_page(page, size, 0, user_id, 0, None) + .await + .map_err(|_| { + anyhow!(CodeError::new_err_code(error_code::DATABASE_QUERY_ERROR)) + })?; + + let mut list = Vec::with_capacity(data.len()); + for item in data { + list.push(build_detail(&item)); + } + + Ok(QueryOrderListResponse { total, list }) + } +} + +/// Project the repository row into the public-facing `OrderDetail` DTO. +/// +/// Mirrors Go's `tool.DeepCopy(&orderInfo, item)` followed by +/// `orderInfo.Commission = 0`. The Rust port hides the commission by +/// setting it to `None`, and populates the joined `payment` / `subscribe` +/// names from the read-side join the repository exposes. +fn build_detail(item: &crate::repository::order::OrderDetails) -> OrderDetail { + OrderDetail { + id: item.id, + user_id: item.user_id, + order_no: item.order_no.clone(), + type_: item.type_ as u8, + quantity: item.quantity, + price: item.price, + amount: item.amount, + gift_amount: item.gift_amount, + discount: item.discount, + coupon: item.coupon.clone().unwrap_or_default(), + coupon_discount: item.coupon_discount, + // Commission is hidden — Go zeroes it explicitly. + commission: None, + // Joined payment name is the only public field needed for the + // list view; richer data is fetched on the detail endpoint. + payment: PaymentMethod { + id: item.payment_id, + name: item.payment_name.clone().unwrap_or_default(), + platform: item.method.clone(), + description: String::new(), + icon: String::new(), + fee_mode: 0, + fee_percent: 0, + fee_amount: 0, + sort: 0, + }, + method: item.method.clone(), + fee_amount: item.fee_amount, + trade_no: item.trade_no.clone().unwrap_or_default(), + status: item.status as u8, + subscribe_id: item.subscribe_id, + subscribe: Subscribe { + id: item.subscribe_id, + name: item.subscribe_name.clone().unwrap_or_default(), + language: Some(String::new()), + description: None, + unit_price: item.price, + unit_time: String::new(), + discount: Vec::::new(), + replacement: 0, + inventory: 0, + traffic: 0, + speed_limit: 0, + device_limit: 0, + quota: 0, + nodes: StringInt64Slice::default(), + node_tags: Vec::new(), + show: false, + sell: false, + sort: 0, + deduction_ratio: 0, + allow_deduction: false, + reset_cycle: 0, + renewal_reset: false, + show_original_price: false, + created_at: 0, + updated_at: 0, + }, + created_at: item.created_at, + updated_at: item.updated_at, + } +} diff --git a/src/service/public/order/recharge_service.rs b/src/service/public/order/recharge_service.rs new file mode 100644 index 00000000..f96b7f8b --- /dev/null +++ b/src/service/public/order/recharge_service.rs @@ -0,0 +1,135 @@ +//! `Recharge` — create a balance top-up order. +//! +//! Port of `server/internal/logic/public/order/rechargeLogic.go`. + +use std::sync::Arc; + +use anyhow::anyhow; +use chrono::Utc; + +use crate::model::dto::order::{RechargeOrderRequest, RechargeOrderResponse}; +use crate::model::entity::order::Order; +use crate::queue::client::QueueClient; +use crate::queue::types::DEFER_CLOSE_ORDER; +use crate::repository::Repositories; + +use super::calculate_fee::calculate_fee; +use super::constant::{MAX_ORDER_AMOUNT, MAX_RECHARGE_AMOUNT, ORDER_STATUS_UNPAID, ORDER_TYPE_RECHARGE}; +use result::code_error::CodeError; +use result::error_code; + +pub struct RechargeService { + repos: Arc, + queue: QueueClient, +} + +impl RechargeService { + pub fn new(repos: Arc, queue: QueueClient) -> Self { + Self { repos, queue } + } + + pub async fn recharge( + &self, + user_id: i64, + req: RechargeOrderRequest, + ) -> Result { + if req.amount <= 0 { + return Err(anyhow!(CodeError::new_err_code_msg( + error_code::INVALID_PARAMS, + "recharge amount must be greater than 0", + ))); + } + if req.amount > MAX_RECHARGE_AMOUNT { + return Err(anyhow!(CodeError::new_err_code_msg( + error_code::INVALID_PARAMS, + "recharge amount exceeds maximum limit", + ))); + } + + let payment_id = req.payment.ok_or_else(|| { + anyhow!(CodeError::new_err_code_msg( + error_code::INVALID_PARAMS, + "payment method is required", + )) + })?; + + let payment = self + .repos + .payment + .find_one(payment_id) + .await + .map_err(|_| anyhow!(CodeError::new_err_code(error_code::DATABASE_QUERY_ERROR)))?; + + let fee_amount = calculate_fee(req.amount, &payment); + let total_amount = req.amount + fee_amount; + if total_amount > MAX_ORDER_AMOUNT { + return Err(anyhow!(CodeError::new_err_code_msg( + error_code::INVALID_PARAMS, + "total amount exceeds maximum limit", + ))); + } + + let is_new = self + .repos + .order + .is_user_eligible_for_new_order(user_id) + .await + .map_err(|_| anyhow!(CodeError::new_err_code(error_code::DATABASE_QUERY_ERROR)))?; + + let now = Utc::now().timestamp_millis(); + let order_no = super::purchase_service::generate_trade_no(); + let order = Order { + id: 0, + parent_id: None, + user_id, + order_no: order_no.clone(), + type_: ORDER_TYPE_RECHARGE, + quantity: 0, + price: req.amount, + amount: total_amount, + gift_amount: 0, + discount: 0, + coupon: None, + coupon_discount: 0, + commission: 0, + payment_id: payment.id, + method: payment.platform.clone(), + fee_amount, + trade_no: None, + status: ORDER_STATUS_UNPAID, + subscribe_id: 0, + subscribe_token: None, + is_new, + created_at: now, + updated_at: now, + }; + + if let Err(e) = self.repos.order.insert(&order).await { + tracing::error!(?e, %order_no, "failed to insert recharge order"); + return Err(anyhow!(CodeError::new_err_code_msg( + error_code::DATABASE_INSERT_ERROR, + &e.to_string(), + ))); + } + + self.enqueue_close_task(&order_no).await; + + Ok(RechargeOrderResponse { order_no }) + } + + async fn enqueue_close_task(&self, order_no: &str) { + let delay = std::time::Duration::from_secs( + (super::constant::CLOSE_ORDER_TIME_MINUTES as u64) * 60, + ); + let payload = match serde_json::to_vec(order_no) { + Ok(b) => b, + Err(e) => { + tracing::error!(order_no, "failed to serialize close-order payload: {e}"); + return; + } + }; + if let Err(e) = self.queue.enqueue_delayed(DEFER_CLOSE_ORDER, &payload, delay).await { + tracing::error!(order_no, "failed to enqueue defer close-order task: {e}"); + } + } +} diff --git a/src/service/public/order/renewal_service.rs b/src/service/public/order/renewal_service.rs new file mode 100644 index 00000000..e9b73ce2 --- /dev/null +++ b/src/service/public/order/renewal_service.rs @@ -0,0 +1,270 @@ +//! `Renewal` — extend an existing user-subscription by creating a renewal +//! order against its parent plan. +//! +//! Port of `server/internal/logic/public/order/renewalLogic.go`. + +use std::sync::Arc; + +use anyhow::anyhow; +use chrono::Utc; + +use crate::model::dto::order::{RenewalOrderRequest, RenewalOrderResponse}; +use crate::model::entity::coupon::Coupon; +use crate::model::entity::order::Order; +use crate::queue::client::QueueClient; +use crate::queue::types::DEFER_CLOSE_ORDER; +use crate::repository::Repositories; +use crate::service::telemetry::Telemetry; + +use super::calculate_coupon::{calculate_coupon, ensure_enabled}; +use super::calculate_fee::calculate_fee; +use super::constant::{MAX_ORDER_AMOUNT, MAX_QUANTITY, ORDER_STATUS_UNPAID, ORDER_TYPE_RENEWAL}; +use super::get_discount::{get_discount, parse_discounts}; +use super::purchase_service::generate_trade_no; +use result::code_error::CodeError; +use result::error_code; + +pub struct RenewalService { + repos: Arc, + queue: QueueClient, +} + +impl RenewalService { + pub fn new(repos: Arc, queue: QueueClient) -> Self { + Self { repos, queue } + } + + pub async fn renewal( + &self, + user_id: i64, + req: RenewalOrderRequest, + ) -> Result { + let mut user = self + .repos + .user + .find_one_user(user_id) + .await + .map_err(|_| anyhow!(CodeError::new_err_code(error_code::USER_NOT_EXIST)))?; + + let quantity = if req.quantity <= 0 { 1 } else { req.quantity }; + if quantity > MAX_QUANTITY { + return Err(anyhow!(CodeError::new_err_code_msg( + error_code::INVALID_PARAMS, + "quantity exceeds maximum limit", + ))); + } + + let user_sub = self + .repos + .user + .find_one_user_subscribe(req.user_subscribe_id) + .await + .map_err(|_| anyhow!(CodeError::new_err_code(error_code::DATABASE_QUERY_ERROR)))?; + + let sub = self + .repos + .subscribe + .find_one(user_sub.subscribe_id) + .await + .map_err(|_| anyhow!(CodeError::new_err_code(error_code::DATABASE_QUERY_ERROR)))?; + + if !sub.sell { + return Err(anyhow!(CodeError::new_err_code(error_code::ERROR))); + } + + let discount = if sub.discount.is_empty() { + 1.0 + } else { + let tiers = parse_discounts(&sub.discount); + get_discount(&tiers, quantity) + }; + let price = sub.unit_price.saturating_mul(quantity); + let amount = ((price as f64) * discount).round() as i64; + let discount_amount = price - amount; + + if amount > MAX_ORDER_AMOUNT { + return Err(anyhow!(CodeError::new_err_code_msg( + error_code::INVALID_PARAMS, + "order amount exceeds maximum limit", + ))); + } + + let mut coupon_discount: i64 = 0; + if let Some(coupon_code) = req.coupon.as_deref().filter(|c| !c.is_empty()) { + let coupon_info = self + .repos + .coupon + .find_one_by_code(coupon_code) + .await + .map_err(|e| match e { + sqlx::Error::RowNotFound => { + anyhow!(CodeError::new_err_code(error_code::COUPON_NOT_EXIST)) + } + _ => anyhow!(CodeError::new_err_code(error_code::DATABASE_QUERY_ERROR)), + })?; + self.validate_coupon(&coupon_info, user_id, sub.id).await?; + coupon_discount = calculate_coupon(amount, &coupon_info); + } + let mut amount = amount - coupon_discount; + + let payment_id = req.payment.ok_or_else(|| { + anyhow!(CodeError::new_err_code_msg( + error_code::INVALID_PARAMS, + "payment method is required", + )) + })?; + let payment = self + .repos + .payment + .find_one(payment_id) + .await + .map_err(|_| anyhow!(CodeError::new_err_code(error_code::DATABASE_QUERY_ERROR)))?; + + // Deduct gift amount (Go subtracts from amount first, then fee). + let mut deduction_amount: i64 = 0; + if user.gift_amount > 0 { + if user.gift_amount >= amount { + deduction_amount = amount; + user.gift_amount -= deduction_amount; + amount = 0; + } else { + deduction_amount = user.gift_amount; + amount -= user.gift_amount; + user.gift_amount = 0; + } + } + + let mut fee_amount: i64 = 0; + if amount > 0 { + fee_amount = calculate_fee(amount, &payment); + } + amount += fee_amount; + if amount > MAX_ORDER_AMOUNT { + return Err(anyhow!(CodeError::new_err_code_msg( + error_code::INVALID_PARAMS, + "order amount exceeds maximum limit", + ))); + } + + let now = Utc::now().timestamp_millis(); + let order_no = generate_trade_no(); + let order = Order { + id: 0, + parent_id: Some(user_sub.order_id), + user_id, + order_no: order_no.clone(), + type_: ORDER_TYPE_RENEWAL, + quantity, + price, + amount, + gift_amount: deduction_amount, + discount: discount_amount, + coupon: req.coupon.clone(), + coupon_discount, + commission: 0, + payment_id: payment.id, + method: payment.platform.clone(), + fee_amount, + trade_no: None, + status: ORDER_STATUS_UNPAID, + subscribe_id: user_sub.subscribe_id, + subscribe_token: Some(user_sub.token.clone()), + is_new: false, + created_at: now, + updated_at: now, + }; + + if deduction_amount > 0 { + user.updated_at = now; + if let Err(e) = self.repos.user.update_user(&user).await { + tracing::error!(?e, user_id, "failed to deduct gift_amount on renewal"); + return Err(anyhow!(CodeError::new_err_code_msg( + error_code::DATABASE_UPDATE_ERROR, + &e.to_string(), + ))); + } + Telemetry::gift( + &self.repos, + user_id, + 342, // GIFT_TYPE_REDUCE + &order_no, + 0, + deduction_amount, + user.gift_amount, + Some("Renewal order deduction".to_string()), + ) + .await; + } + + if let Err(e) = self.repos.order.insert(&order).await { + tracing::error!(?e, %order_no, "failed to insert renewal order"); + return Err(anyhow!(CodeError::new_err_code_msg( + error_code::DATABASE_INSERT_ERROR, + &e.to_string(), + ))); + } + + self.enqueue_close_task(&order_no).await; + + Ok(RenewalOrderResponse { order_no }) + } + + async fn validate_coupon( + &self, + coupon_info: &Coupon, + user_id: i64, + subscribe_id: i64, + ) -> Result<(), anyhow::Error> { + ensure_enabled(coupon_info)?; + + if coupon_info.count != 0 && coupon_info.count <= coupon_info.used_count { + return Err(anyhow!(CodeError::new_err_code( + error_code::COUPON_INSUFFICIENT_USAGE + ))); + } + + if !coupon_info.subscribe.is_empty() { + let allowed: Vec = coupon_info + .subscribe + .split(',') + .filter_map(|s| s.trim().parse::().ok()) + .collect(); + if !allowed.is_empty() && !allowed.contains(&subscribe_id) { + return Err(anyhow!(CodeError::new_err_code( + error_code::COUPON_NOT_APPLICABLE + ))); + } + } + + let count = self + .repos + .order + .count_user_coupon_usage(user_id, &coupon_info.code) + .await + .map_err(|_| anyhow!(CodeError::new_err_code(error_code::DATABASE_QUERY_ERROR)))?; + + if count >= coupon_info.user_limit { + return Err(anyhow!(CodeError::new_err_code( + error_code::COUPON_INSUFFICIENT_USAGE + ))); + } + + Ok(()) + } + + async fn enqueue_close_task(&self, order_no: &str) { + let delay = std::time::Duration::from_secs( + (super::constant::CLOSE_ORDER_TIME_MINUTES as u64) * 60, + ); + let payload = match serde_json::to_vec(order_no) { + Ok(b) => b, + Err(e) => { + tracing::error!(order_no, "failed to serialize close-order payload: {e}"); + return; + } + }; + if let Err(e) = self.queue.enqueue_delayed(DEFER_CLOSE_ORDER, &payload, delay).await { + tracing::error!(order_no, "failed to enqueue defer close-order task: {e}"); + } + } +} diff --git a/src/service/public/order/reset_traffic_service.rs b/src/service/public/order/reset_traffic_service.rs new file mode 100644 index 00000000..7e8e62db --- /dev/null +++ b/src/service/public/order/reset_traffic_service.rs @@ -0,0 +1,177 @@ +//! `ResetTraffic` — create a paid traffic-reset order against an +//! existing user-subscription. +//! +//! Port of `server/internal/logic/public/order/resetTrafficLogic.go`. + +use std::sync::Arc; + +use anyhow::anyhow; +use chrono::Utc; + +use crate::model::dto::order::{ResetTrafficOrderRequest, ResetTrafficOrderResponse}; +use crate::model::entity::order::Order; +use crate::queue::client::QueueClient; +use crate::queue::types::DEFER_CLOSE_ORDER; +use crate::repository::Repositories; +use crate::service::telemetry::Telemetry; + +use super::calculate_fee::calculate_fee; +use super::constant::{ORDER_STATUS_UNPAID, ORDER_TYPE_RESET_TRAFFIC}; +use super::purchase_service::generate_trade_no; +use result::code_error::CodeError; +use result::error_code; + +pub struct ResetTrafficService { + repos: Arc, + queue: QueueClient, +} + +impl ResetTrafficService { + pub fn new(repos: Arc, queue: QueueClient) -> Self { + Self { repos, queue } + } + + pub async fn reset_traffic( + &self, + user_id: i64, + req: ResetTrafficOrderRequest, + ) -> Result { + let mut user = self + .repos + .user + .find_one_user(user_id) + .await + .map_err(|_| anyhow!(CodeError::new_err_code(error_code::USER_NOT_EXIST)))?; + + let user_sub = self + .repos + .user + .find_one_subscribe_details_by_id(req.user_subscribe_id) + .await + .map_err(|_| anyhow!(CodeError::new_err_code(error_code::DATABASE_QUERY_ERROR)))?; + + // Replacement amount — the value the user must pay to reset. + // In the Go reference this is read from `userSubscribe.Subscribe.Replacement`, + // which is the `replacement` column on the subscribe plan joined to the + // user-subscribe row. The repository's `SubscribeDetails` doesn't carry + // it; we fetch the plan here when needed. + let sub = self + .repos + .subscribe + .find_one(user_sub.subscribe_id) + .await + .map_err(|_| anyhow!(CodeError::new_err_code(error_code::DATABASE_QUERY_ERROR)))?; + let replacement = sub.replacement; + + let mut amount = replacement; + let mut deduction_amount: i64 = 0; + if user.gift_amount > 0 { + if user.gift_amount >= amount { + deduction_amount = amount; + user.gift_amount -= amount; + amount = 0; + } else { + deduction_amount = user.gift_amount; + amount -= user.gift_amount; + user.gift_amount = 0; + } + } + + let payment_id = req.payment.ok_or_else(|| { + anyhow!(CodeError::new_err_code_msg( + error_code::INVALID_PARAMS, + "payment method is required", + )) + })?; + let payment = self + .repos + .payment + .find_one(payment_id) + .await + .map_err(|_| anyhow!(CodeError::new_err_code(error_code::DATABASE_QUERY_ERROR)))?; + + let mut fee_amount: i64 = 0; + if amount > 0 { + fee_amount = calculate_fee(amount, &payment); + } + let final_amount = amount + fee_amount; + + let now = Utc::now().timestamp_millis(); + let order_no = generate_trade_no(); + let order = Order { + id: 0, + parent_id: Some(user_sub.order_id), + user_id, + order_no: order_no.clone(), + type_: ORDER_TYPE_RESET_TRAFFIC, + quantity: 0, + price: replacement, + amount: final_amount, + gift_amount: deduction_amount, + discount: 0, + coupon: None, + coupon_discount: 0, + commission: 0, + payment_id: payment.id, + method: payment.platform.clone(), + fee_amount, + trade_no: None, + status: ORDER_STATUS_UNPAID, + subscribe_id: user_sub.subscribe_id, + subscribe_token: Some(user_sub.token.clone()), + is_new: false, + created_at: now, + updated_at: now, + }; + + if deduction_amount > 0 { + user.updated_at = now; + if let Err(e) = self.repos.user.update_user(&user).await { + tracing::error!(?e, user_id, "failed to deduct gift_amount on traffic reset"); + return Err(anyhow!(CodeError::new_err_code_msg( + error_code::DATABASE_UPDATE_ERROR, + &e.to_string(), + ))); + } + Telemetry::gift( + &self.repos, + user_id, + 342, // GIFT_TYPE_REDUCE + &order_no, + 0, + deduction_amount, + user.gift_amount, + Some("Renewal order deduction".to_string()), + ) + .await; + } + + if let Err(e) = self.repos.order.insert(&order).await { + tracing::error!(?e, %order_no, "failed to insert reset-traffic order"); + return Err(anyhow!(CodeError::new_err_code_msg( + error_code::DATABASE_INSERT_ERROR, + &e.to_string(), + ))); + } + + self.enqueue_close_task(&order_no).await; + + Ok(ResetTrafficOrderResponse { order_no }) + } + + async fn enqueue_close_task(&self, order_no: &str) { + let delay = std::time::Duration::from_secs( + (super::constant::CLOSE_ORDER_TIME_MINUTES as u64) * 60, + ); + let payload = match serde_json::to_vec(order_no) { + Ok(b) => b, + Err(e) => { + tracing::error!(order_no, "failed to serialize close-order payload: {e}"); + return; + } + }; + if let Err(e) = self.queue.enqueue_delayed(DEFER_CLOSE_ORDER, &payload, delay).await { + tracing::error!(order_no, "failed to enqueue defer close-order task: {e}"); + } + } +} diff --git a/src/service/public/payment/get_available_payment_methods_service.rs b/src/service/public/payment/get_available_payment_methods_service.rs new file mode 100644 index 00000000..eff9b525 --- /dev/null +++ b/src/service/public/payment/get_available_payment_methods_service.rs @@ -0,0 +1,26 @@ +//! List enabled payment methods (public). + +use std::sync::Arc; + +use anyhow::anyhow; + +use crate::model::entity::payment::Payment; +use crate::repository::Repositories; + +pub struct GetAvailablePaymentMethodsService { + pub repos: Arc, +} + +impl GetAvailablePaymentMethodsService { + pub fn new(repos: Arc) -> Self { + Self { repos } + } + + pub async fn get_methods(&self) -> Result, anyhow::Error> { + self.repos + .payment + .find_available_methods() + .await + .map_err(|e| anyhow!("find available payment methods: {e}")) + } +} diff --git a/src/service/public/payment/mod.rs b/src/service/public/payment/mod.rs new file mode 100644 index 00000000..3919c974 --- /dev/null +++ b/src/service/public/payment/mod.rs @@ -0,0 +1 @@ +pub mod get_available_payment_methods_service; diff --git a/src/service/public/portal/get_available_payment_methods_service.rs b/src/service/public/portal/get_available_payment_methods_service.rs new file mode 100644 index 00000000..c47a177c --- /dev/null +++ b/src/service/public/portal/get_available_payment_methods_service.rs @@ -0,0 +1,49 @@ +//! `GetAvailablePaymentMethods` — list payment methods visible to users. +//! +//! Port of the portal variant: returns only enabled payment methods, +//! stripping internal config/keys before sending to the client. + +use std::sync::Arc; + +use anyhow::anyhow; + +use crate::model::dto::payment::{GetAvailablePaymentMethodsResponse, PaymentMethod}; +use crate::repository::Repositories; +use result::code_error::CodeError; +use result::error_code; + +pub struct GetAvailablePaymentMethodsService { + repos: Arc, +} + +impl GetAvailablePaymentMethodsService { + pub fn new(repos: Arc) -> Self { + Self { repos } + } + + pub async fn get(&self) -> Result { + let methods = self + .repos + .payment + .find_available_methods() + .await + .map_err(|_| anyhow!(CodeError::new_err_code(error_code::DATABASE_QUERY_ERROR)))?; + + let list = methods + .into_iter() + .map(|p| PaymentMethod { + id: p.id, + name: p.name, + platform: p.platform, + description: p.description.unwrap_or_default(), + icon: p.icon, + fee_mode: p.fee_mode as u32, + fee_percent: p.fee_percent, + fee_amount: p.fee_amount, + sort: p.sort, + }) + .collect(); + + Ok(GetAvailablePaymentMethodsResponse { list }) + } +} diff --git a/src/service/public/portal/get_subscription_service.rs b/src/service/public/portal/get_subscription_service.rs new file mode 100644 index 00000000..7ec24572 --- /dev/null +++ b/src/service/public/portal/get_subscription_service.rs @@ -0,0 +1,95 @@ +//! `GetSubscription` — user's active subscription info (portal-facing). +//! +//! Returns the list of available subscribe plans filtered by show=true and +//! sell=true, suitable for the portal purchase screen. + +use std::sync::Arc; + +use anyhow::anyhow; + +use crate::model::dto::subscribe::{GetSubscriptionResponse, Subscribe, SubscribeDiscount}; +use crate::repository::subscribe::FilterParams; +use crate::repository::Repositories; +use result::code_error::CodeError; +use result::error_code; + +pub struct GetSubscriptionService { + repos: Arc, +} + +impl GetSubscriptionService { + pub fn new(repos: Arc) -> Self { + Self { repos } + } + + pub async fn get( + &self, + language: Option, + ) -> Result { + let mut params = FilterParams { + page: 1, + size: 100, + show: true, + sell: true, + language, + ..Default::default() + }; + + let (_total, rows) = self + .repos + .subscribe + .filter_list(&mut params) + .await + .map_err(|_| anyhow!(CodeError::new_err_code(error_code::DATABASE_QUERY_ERROR)))?; + + let list = rows + .into_iter() + .map(|s| { + let discount: Vec = if s.discount.is_empty() { + vec![] + } else { + serde_json::from_str(&s.discount).unwrap_or_default() + }; + let nodes: Vec = if s.nodes.is_empty() { + vec![] + } else { + serde_json::from_str(&s.nodes).unwrap_or_default() + }; + let node_tags: Vec = if s.node_tags.is_empty() { + vec![] + } else { + serde_json::from_str(&s.node_tags).unwrap_or_default() + }; + Subscribe { + id: s.id, + name: s.name, + language: Some(s.language), + description: s.description, + unit_price: s.unit_price, + unit_time: s.unit_time, + discount, + replacement: s.replacement, + inventory: s.inventory, + traffic: s.traffic, + speed_limit: s.speed_limit, + device_limit: s.device_limit, + quota: s.quota, + nodes: crate::model::dto::misc::StringInt64Slice(nodes), + node_tags, + show: s.show, + sell: s.sell, + sort: s.sort, + deduction_ratio: s.deduction_ratio, + allow_deduction: s.allow_deduction, + reset_cycle: s.reset_cycle, + renewal_reset: s.renewal_reset, + show_original_price: s.show_original_price, + created_at: s.created_at, + updated_at: s.updated_at, + } + }) + .collect(); + + Ok(GetSubscriptionResponse { list }) + } +} diff --git a/src/service/public/portal/mod.rs b/src/service/public/portal/mod.rs new file mode 100644 index 00000000..7bf95be1 --- /dev/null +++ b/src/service/public/portal/mod.rs @@ -0,0 +1,7 @@ +pub mod get_available_payment_methods_service; +pub mod get_subscription_service; +pub mod pre_purchase_order_service; +pub mod purchase_checkout_service; +pub mod purchase_service; +pub mod query_purchase_order_service; +pub mod tool; diff --git a/src/service/public/portal/pre_purchase_order_service.rs b/src/service/public/portal/pre_purchase_order_service.rs new file mode 100644 index 00000000..3232bb24 --- /dev/null +++ b/src/service/public/portal/pre_purchase_order_service.rs @@ -0,0 +1,101 @@ +//! `PrePurchaseOrder` — price preview without DB write. +//! +//! Port of the portal pre-purchase logic. Validates the plan, applies +//! discount ladder, applies coupon (if any), and adds payment fee. +//! No records are inserted. + +use std::sync::Arc; + +use anyhow::anyhow; + +use crate::model::dto::order::{PrePurchaseOrderRequest, PrePurchaseOrderResponse}; +use crate::repository::Repositories; +use crate::service::public::order::calculate_coupon::{calculate_coupon, ensure_enabled}; +use crate::service::public::order::calculate_fee::calculate_fee; +use crate::service::public::order::get_discount::{get_discount, parse_discounts}; +use result::code_error::CodeError; +use result::error_code; + +pub struct PrePurchaseOrderService { + repos: Arc, +} + +impl PrePurchaseOrderService { + pub fn new(repos: Arc) -> Self { + Self { repos } + } + + pub async fn pre_purchase( + &self, + req: PrePurchaseOrderRequest, + ) -> Result { + let quantity = if req.quantity <= 0 { 1 } else { req.quantity }; + + let sub = self + .repos + .subscribe + .find_one(req.subscribe_id) + .await + .map_err(|_| anyhow!(CodeError::new_err_code(error_code::DATABASE_QUERY_ERROR)))?; + + if !sub.sell { + return Err(anyhow!(CodeError::new_err_code(error_code::ERROR))); + } + + // Discount ladder. + let discount = if sub.discount.is_empty() { + 1.0 + } else { + let tiers = parse_discounts(&sub.discount); + get_discount(&tiers, quantity) + }; + + let price = sub.unit_price.saturating_mul(quantity); + let amount = ((price as f64) * discount).round() as i64; + let discount_amount = price - amount; + + // Coupon. + let mut coupon_discount: i64 = 0; + let coupon_str = req.coupon.clone().unwrap_or_default(); + if !coupon_str.is_empty() { + let coupon_info = self + .repos + .coupon + .find_one_by_code(&coupon_str) + .await + .map_err(|e| match e { + sqlx::Error::RowNotFound => { + anyhow!(CodeError::new_err_code(error_code::COUPON_NOT_EXIST)) + } + _ => anyhow!(CodeError::new_err_code(error_code::DATABASE_QUERY_ERROR)), + })?; + ensure_enabled(&coupon_info)?; + coupon_discount = calculate_coupon(amount, &coupon_info); + } + let mut amount = amount - coupon_discount; + + // Payment fee. + let mut fee_amount: i64 = 0; + if let Some(payment_id) = req.payment { + let payment = self + .repos + .payment + .find_one(payment_id) + .await + .map_err(|_| anyhow!(CodeError::new_err_code(error_code::DATABASE_QUERY_ERROR)))?; + if amount > 0 { + fee_amount = calculate_fee(amount, &payment); + amount += fee_amount; + } + } + + Ok(PrePurchaseOrderResponse { + price, + amount, + discount: discount_amount, + coupon: coupon_str, + coupon_discount, + fee_amount, + }) + } +} diff --git a/src/service/public/portal/purchase_checkout_service.rs b/src/service/public/portal/purchase_checkout_service.rs new file mode 100644 index 00000000..25049f46 --- /dev/null +++ b/src/service/public/portal/purchase_checkout_service.rs @@ -0,0 +1,91 @@ +//! `PurchaseCheckout` — return payment URL / stub per payment platform. +//! +//! Port of the portal checkout logic. Looks up the order and its payment +//! method, then delegates to the per-platform checkout stub. + +use std::sync::Arc; + +use anyhow::anyhow; + +use crate::model::dto::order::{CheckoutOrderRequest, CheckoutOrderResponse}; +use crate::repository::Repositories; +use result::code_error::CodeError; +use result::error_code; + +pub struct PurchaseCheckoutService { + repos: Arc, +} + +impl PurchaseCheckoutService { + pub fn new(repos: Arc) -> Self { + Self { repos } + } + + pub async fn checkout( + &self, + req: CheckoutOrderRequest, + ) -> Result { + let order = self + .repos + .order + .find_one_by_order_no(&req.order_no) + .await + .map_err(|e| match e { + sqlx::Error::RowNotFound => { + anyhow!(CodeError::new_err_code(error_code::ORDER_NOT_EXIST)) + } + _ => anyhow!(CodeError::new_err_code(error_code::DATABASE_QUERY_ERROR)), + })?; + + let payment = self + .repos + .payment + .find_one(order.payment_id) + .await + .map_err(|_| anyhow!(CodeError::new_err_code(error_code::DATABASE_QUERY_ERROR)))?; + + // If amount is 0 the order is already paid via gift balance — no + // external checkout URL is needed. + if order.amount == 0 { + return Ok(CheckoutOrderResponse { + type_: payment.platform.clone(), + checkout_url: None, + stripe: None, + }); + } + + // Per-platform stub — full integration will be added when the + // `payment` crate is wired up to each provider's API. + let checkout_url = match payment.platform.as_str() { + "stripe" => { + // TODO: create Stripe PaymentIntent and return client_secret + tracing::warn!(order_no = %req.order_no, "stripe checkout not yet implemented"); + None + } + "alipay_f2f" => { + // TODO: call Alipay Face-to-Face API + tracing::warn!(order_no = %req.order_no, "alipay_f2f checkout not yet implemented"); + None + } + "epay" => { + // TODO: build EPay redirect URL + tracing::warn!(order_no = %req.order_no, "epay checkout not yet implemented"); + None + } + platform => { + tracing::warn!( + order_no = %req.order_no, + %platform, + "unknown payment platform for checkout", + ); + None + } + }; + + Ok(CheckoutOrderResponse { + type_: payment.platform, + checkout_url, + stripe: None, + }) + } +} diff --git a/src/service/public/portal/purchase_service.rs b/src/service/public/portal/purchase_service.rs new file mode 100644 index 00000000..ff999067 --- /dev/null +++ b/src/service/public/portal/purchase_service.rs @@ -0,0 +1,234 @@ +//! `Purchase` (portal) — create order record for a portal user. +//! +//! Portal purchase path: validates plan, applies discount/coupon/fee, +//! inserts the order, then leaves a TODO for the deferred close task. + +use std::sync::Arc; + +use anyhow::anyhow; +use chrono::Utc; + +use crate::config::Config; +use crate::model::dto::order::{PurchaseOrderRequest, PurchaseOrderResponse}; +use crate::model::entity::order::Order; +use crate::queue::client::QueueClient; +use crate::queue::types::DEFER_CLOSE_ORDER; +use crate::repository::Repositories; +use crate::service::public::order::calculate_coupon::{calculate_coupon, ensure_enabled}; +use crate::service::public::order::calculate_fee::calculate_fee; +use crate::service::public::order::constant::{ + MAX_ORDER_AMOUNT, MAX_QUANTITY, ORDER_STATUS_UNPAID, ORDER_TYPE_SUBSCRIBE, +}; +use crate::service::public::order::get_discount::{get_discount, parse_discounts}; +use result::code_error::CodeError; +use result::error_code; + +use super::tool::generate_trade_no; + +pub struct PortalPurchaseService { + repos: Arc, + config: Arc, + queue: QueueClient, +} + +impl PortalPurchaseService { + pub fn new(repos: Arc, config: Arc, queue: QueueClient) -> Self { + Self { repos, config, queue } + } + + pub async fn purchase( + &self, + user_id: i64, + req: PurchaseOrderRequest, + ) -> Result { + let mut quantity = if req.quantity <= 0 { 1 } else { req.quantity }; + if quantity > MAX_QUANTITY { + return Err(anyhow!(CodeError::new_err_code_msg( + error_code::INVALID_PARAMS, + "quantity exceeds maximum limit", + ))); + } + + // Single-model guard. + if self.config.subscribe.single_model { + let user_subs = self + .repos + .user + .query_user_subscribe(user_id, &[1]) + .await + .map_err(|_| anyhow!(CodeError::new_err_code(error_code::DATABASE_QUERY_ERROR)))?; + if !user_subs.is_empty() { + return Err(anyhow!(CodeError::new_err_code( + error_code::USER_SUBSCRIBE_EXIST + ))); + } + } + + let mut sub = self + .repos + .subscribe + .find_one(req.subscribe_id) + .await + .map_err(|_| anyhow!(CodeError::new_err_code(error_code::DATABASE_QUERY_ERROR)))?; + + if !sub.sell { + return Err(anyhow!(CodeError::new_err_code(error_code::ERROR))); + } + if sub.inventory == 0 { + return Err(anyhow!(CodeError::new_err_code( + error_code::SUBSCRIBE_OUT_OF_STOCK + ))); + } + + // Quota check. + if sub.quota > 0 { + let count = self + .repos + .user + .count_user_subscribes_by_user_and_subscribe(user_id, req.subscribe_id) + .await + .map_err(|_| anyhow!(CodeError::new_err_code(error_code::DATABASE_QUERY_ERROR)))?; + if count >= sub.quota { + return Err(anyhow!(CodeError::new_err_code( + error_code::SUBSCRIBE_QUOTA_LIMIT + ))); + } + } + + // Discount ladder. + let discount = if sub.discount.is_empty() { + 1.0 + } else { + let tiers = parse_discounts(&sub.discount); + get_discount(&tiers, quantity) + }; + let price = sub.unit_price.saturating_mul(quantity); + let amount = ((price as f64) * discount).round() as i64; + let discount_amount = price - amount; + + if amount > MAX_ORDER_AMOUNT { + return Err(anyhow!(CodeError::new_err_code_msg( + error_code::INVALID_PARAMS, + "order amount exceeds maximum limit", + ))); + } + + // Coupon. + let mut coupon_discount: i64 = 0; + if let Some(code) = req.coupon.as_deref().filter(|c| !c.is_empty()) { + let coupon_info = self + .repos + .coupon + .find_one_by_code(code) + .await + .map_err(|e| match e { + sqlx::Error::RowNotFound => { + anyhow!(CodeError::new_err_code(error_code::COUPON_NOT_EXIST)) + } + _ => anyhow!(CodeError::new_err_code(error_code::DATABASE_QUERY_ERROR)), + })?; + ensure_enabled(&coupon_info)?; + coupon_discount = calculate_coupon(amount, &coupon_info); + } + let mut amount = amount - coupon_discount; + + // Payment method. + let payment_id = req.payment.ok_or_else(|| { + anyhow!(CodeError::new_err_code_msg( + error_code::INVALID_PARAMS, + "payment method is required", + )) + })?; + let payment = self + .repos + .payment + .find_one(payment_id) + .await + .map_err(|_| anyhow!(CodeError::new_err_code(error_code::DATABASE_QUERY_ERROR)))?; + + // Fee. + let mut fee_amount: i64 = 0; + if amount > 0 { + fee_amount = calculate_fee(amount, &payment); + amount += fee_amount; + if amount > MAX_ORDER_AMOUNT { + return Err(anyhow!(CodeError::new_err_code_msg( + error_code::INVALID_PARAMS, + "order amount exceeds maximum limit", + ))); + } + } + + // is_new flag. + let is_new = self + .repos + .order + .is_user_eligible_for_new_order(user_id) + .await + .map_err(|_| anyhow!(CodeError::new_err_code(error_code::DATABASE_QUERY_ERROR)))?; + + let now = Utc::now().timestamp_millis(); + let order_no = generate_trade_no(); + + let order = Order { + id: 0, + parent_id: None, + user_id, + order_no: order_no.clone(), + type_: ORDER_TYPE_SUBSCRIBE, + quantity, + price, + amount, + gift_amount: 0, + discount: discount_amount, + coupon: req.coupon.clone(), + coupon_discount, + commission: 0, + payment_id: payment.id, + method: payment.platform.clone(), + fee_amount, + trade_no: None, + status: ORDER_STATUS_UNPAID, + subscribe_id: req.subscribe_id, + subscribe_token: None, + is_new, + created_at: now, + updated_at: now, + }; + + // Decrement inventory. + if sub.inventory != -1 { + sub.inventory -= 1; + sub.updated_at = now; + self.repos.subscribe.update(&sub).await.map_err(|e| { + anyhow!(CodeError::new_err_code_msg( + error_code::DATABASE_UPDATE_ERROR, + &e.to_string(), + )) + })?; + } + + self.repos.order.insert(&order).await.map_err(|e| { + anyhow!(CodeError::new_err_code_msg( + error_code::DATABASE_INSERT_ERROR, + &e.to_string(), + )) + })?; + + let close_delay = std::time::Duration::from_secs( + (crate::service::public::order::constant::CLOSE_ORDER_TIME_MINUTES as u64) * 60, + ); + let close_payload = match serde_json::to_vec(&order_no) { + Ok(b) => b, + Err(e) => { + tracing::error!(%order_no, "failed to serialize close-order payload: {e}"); + return Ok(PurchaseOrderResponse { order_no }); + } + }; + if let Err(e) = self.queue.enqueue_delayed(DEFER_CLOSE_ORDER, &close_payload, close_delay).await { + tracing::error!(%order_no, "failed to enqueue defer close-order task: {e}"); + } + + Ok(PurchaseOrderResponse { order_no }) + } +} diff --git a/src/service/public/portal/query_purchase_order_service.rs b/src/service/public/portal/query_purchase_order_service.rs new file mode 100644 index 00000000..3219ce77 --- /dev/null +++ b/src/service/public/portal/query_purchase_order_service.rs @@ -0,0 +1,102 @@ +//! `QueryPurchaseOrder` — paginated user orders (portal-facing). + +use std::sync::Arc; + +use anyhow::anyhow; + +use crate::model::dto::order::{QueryOrderListRequest, QueryOrderListResponse}; +use crate::model::dto::misc::StringInt64Slice; +use crate::model::dto::order::OrderDetail; +use crate::model::dto::payment::PaymentMethod; +use crate::model::dto::subscribe::{Subscribe, SubscribeDiscount}; +use crate::repository::Repositories; +use result::code_error::CodeError; +use result::error_code; + +pub struct QueryPurchaseOrderService { + repos: Arc, +} + +impl QueryPurchaseOrderService { + pub fn new(repos: Arc) -> Self { + Self { repos } + } + + pub async fn query( + &self, + user_id: i64, + req: QueryOrderListRequest, + ) -> Result { + let page = i64::from(req.page.max(1)); + let size = i64::from(req.size.max(1)); + + let (total, data) = self + .repos + .order + .query_list_by_page(page, size, 0, user_id, 0, None) + .await + .map_err(|_| anyhow!(CodeError::new_err_code(error_code::DATABASE_QUERY_ERROR)))?; + + let list = data.into_iter().map(|item| OrderDetail { + id: item.id, + user_id: item.user_id, + order_no: item.order_no, + type_: item.type_ as u8, + quantity: item.quantity, + price: item.price, + amount: item.amount, + gift_amount: item.gift_amount, + discount: item.discount, + coupon: item.coupon.unwrap_or_default(), + coupon_discount: item.coupon_discount, + commission: None, + payment: PaymentMethod { + id: item.payment_id, + name: item.payment_name.unwrap_or_default(), + platform: item.method.clone(), + description: String::new(), + icon: String::new(), + fee_mode: 0, + fee_percent: 0, + fee_amount: 0, + sort: 0, + }, + method: item.method, + fee_amount: item.fee_amount, + trade_no: item.trade_no.unwrap_or_default(), + status: item.status as u8, + subscribe_id: item.subscribe_id, + subscribe: Subscribe { + id: item.subscribe_id, + name: item.subscribe_name.unwrap_or_default(), + language: None, + description: None, + unit_price: item.price, + unit_time: String::new(), + discount: Vec::::new(), + replacement: 0, + inventory: 0, + traffic: 0, + speed_limit: 0, + device_limit: 0, + quota: 0, + nodes: StringInt64Slice::default(), + node_tags: Vec::new(), + show: false, + sell: false, + sort: 0, + deduction_ratio: 0, + allow_deduction: false, + reset_cycle: 0, + renewal_reset: false, + show_original_price: false, + created_at: 0, + updated_at: 0, + }, + created_at: item.created_at, + updated_at: item.updated_at, + }).collect(); + + Ok(QueryOrderListResponse { total, list }) + } +} diff --git a/src/service/public/portal/tool.rs b/src/service/public/portal/tool.rs new file mode 100644 index 00000000..eb750a74 --- /dev/null +++ b/src/service/public/portal/tool.rs @@ -0,0 +1,11 @@ +//! Portal-scoped utilities. + +use uuid::Uuid; + +/// Generate a short unique trade/order number. +/// +/// Mirrors Go's `tool.GenerateTradeNo` — 16 uppercase hex chars. +pub fn generate_trade_no() -> String { + let s = Uuid::new_v4().simple().to_string(); + s[..16].to_uppercase() +} diff --git a/src/service/public/subscribe/mod.rs b/src/service/public/subscribe/mod.rs new file mode 100644 index 00000000..5287f1d0 --- /dev/null +++ b/src/service/public/subscribe/mod.rs @@ -0,0 +1,3 @@ +pub mod query_subscribe_group_list_service; +pub mod query_subscribe_list_service; +pub mod query_user_subscribe_node_list_service; diff --git a/src/service/public/subscribe/query_subscribe_group_list_service.rs b/src/service/public/subscribe/query_subscribe_group_list_service.rs new file mode 100644 index 00000000..92e58fae --- /dev/null +++ b/src/service/public/subscribe/query_subscribe_group_list_service.rs @@ -0,0 +1,26 @@ +//! List subscribe groups. + +use std::sync::Arc; + +use anyhow::anyhow; + +use crate::model::entity::subscribe::Group; +use crate::repository::Repositories; + +pub struct QuerySubscribeGroupListService { + pub repos: Arc, +} + +impl QuerySubscribeGroupListService { + pub fn new(repos: Arc) -> Self { + Self { repos } + } + + pub async fn query_list(&self) -> Result<(i64, Vec), anyhow::Error> { + self.repos + .subscribe + .query_group_list() + .await + .map_err(|e| anyhow!("query subscribe group list: {e}")) + } +} diff --git a/src/service/public/subscribe/query_subscribe_list_service.rs b/src/service/public/subscribe/query_subscribe_list_service.rs new file mode 100644 index 00000000..826397ae --- /dev/null +++ b/src/service/public/subscribe/query_subscribe_list_service.rs @@ -0,0 +1,34 @@ +//! List active (show=true, sell=true) subscribe plans. + +use std::sync::Arc; + +use anyhow::anyhow; + +use crate::model::entity::subscribe::Subscribe; +use crate::repository::subscribe::FilterParams; +use crate::repository::Repositories; + +pub struct QuerySubscribeListService { + pub repos: Arc, +} + +impl QuerySubscribeListService { + pub fn new(repos: Arc) -> Self { + Self { repos } + } + + pub async fn query_list(&self, page: i64, size: i64) -> Result<(i64, Vec), anyhow::Error> { + let mut params = FilterParams { + page, + size, + show: true, + sell: true, + ..Default::default() + }; + self.repos + .subscribe + .filter_list(&mut params) + .await + .map_err(|e| anyhow!("query subscribe list: {e}")) + } +} diff --git a/src/service/public/subscribe/query_user_subscribe_node_list_service.rs b/src/service/public/subscribe/query_user_subscribe_node_list_service.rs new file mode 100644 index 00000000..5e612658 --- /dev/null +++ b/src/service/public/subscribe/query_user_subscribe_node_list_service.rs @@ -0,0 +1,75 @@ +//! List nodes available for a user's active subscription. + +use std::sync::Arc; + +use anyhow::anyhow; + +use chrono::Utc; + +use crate::model::entity::node::Node; +use crate::repository::node::NodeFilter; +use crate::repository::Repositories; +use result::code_error::CodeError; +use result::error_code; + +pub struct QueryUserSubscribeNodeListService { + pub repos: Arc, +} + +impl QueryUserSubscribeNodeListService { + pub fn new(repos: Arc) -> Self { + Self { repos } + } + + /// Return the enabled nodes associated with the user's active subscribe plan. + pub async fn query_nodes(&self, user_id: i64) -> Result, anyhow::Error> { + // Active statuses: 1=active, 2=pending + let subscribes = self + .repos + .user + .query_user_subscribe(user_id, &[1, 2]) + .await + .map_err(|e| anyhow!("query user subscribe: {e}"))?; + + let sub = subscribes + .into_iter() + .next() + .ok_or_else(|| anyhow!(CodeError::new_err_code(error_code::USER_NOT_EXIST)))?; + + // Check expiry. + let now = Utc::now().timestamp(); + if sub.expire_time > 0 && sub.expire_time < now { + return Ok(vec![]); + } + + // Load plan. + let plan = self + .repos + .subscribe + .find_one(sub.subscribe_id) + .await + .map_err(|e| anyhow!("find subscribe plan: {e}"))?; + + let node_ids: Vec = serde_json::from_str(&plan.nodes).unwrap_or_default(); + if node_ids.is_empty() { + return Ok(vec![]); + } + + let filter = NodeFilter { + node_ids, + enabled: Some(true), + page: 1, + size: 10000, + ..Default::default() + }; + + let (_, nodes) = self + .repos + .node + .filter_node_list(&filter, false) + .await + .map_err(|e| anyhow!("filter nodes: {e}"))?; + + Ok(nodes) + } +} diff --git a/src/service/public/ticket/constant.rs b/src/service/public/ticket/constant.rs new file mode 100644 index 00000000..8926b610 --- /dev/null +++ b/src/service/public/ticket/constant.rs @@ -0,0 +1,10 @@ +//! Ticket-domain status constants. + +/// Ticket status: open, awaiting admin reply. +pub const TICKET_STATUS_OPEN: i16 = 1; + +/// Ticket status: admin replied, awaiting user response. +pub const TICKET_STATUS_PENDING_ADMIN: i16 = 2; + +/// Ticket status: closed. +pub const TICKET_STATUS_CLOSED: i16 = 4; diff --git a/src/service/public/ticket/create_user_ticket_follow_service.rs b/src/service/public/ticket/create_user_ticket_follow_service.rs new file mode 100644 index 00000000..e92f0f78 --- /dev/null +++ b/src/service/public/ticket/create_user_ticket_follow_service.rs @@ -0,0 +1,89 @@ +//! `CreateUserTicketFollow` — insert follow, update ticket status. + +use std::sync::Arc; + +use anyhow::anyhow; +use chrono::Utc; + +use crate::model::dto::ticket::{CreateUserTicketFollowRequest, Follow}; +use crate::model::entity::ticket::Follow as FollowEntity; +use crate::repository::Repositories; +use result::code_error::CodeError; +use result::error_code; + +use super::constant::TICKET_STATUS_PENDING_ADMIN; + +pub struct CreateUserTicketFollowService { + repos: Arc, +} + +impl CreateUserTicketFollowService { + pub fn new(repos: Arc) -> Self { + Self { repos } + } + + pub async fn create( + &self, + user_id: i64, + req: CreateUserTicketFollowRequest, + ) -> Result { + // Verify ownership. + let ticket = self + .repos + .ticket + .find_one(req.ticket_id) + .await + .map_err(|e| match e { + sqlx::Error::RowNotFound => { + anyhow!(CodeError::new_err_code(error_code::USER_NOT_EXIST)) + } + _ => anyhow!(CodeError::new_err_code(error_code::DATABASE_QUERY_ERROR)), + })?; + + if ticket.user_id != user_id { + return Err(anyhow!(CodeError::new_err_code(error_code::INVALID_ACCESS))); + } + + let now = Utc::now().timestamp_millis(); + let entity = FollowEntity { + id: 0, + ticket_id: req.ticket_id, + from: req.from.clone(), + type_: req.type_ as i16, + content: Some(req.content.clone()), + created_at: now, + }; + + let result = self + .repos + .ticket + .insert_follow(&entity) + .await + .map_err(|e| { + anyhow!(CodeError::new_err_code_msg( + error_code::DATABASE_INSERT_ERROR, + &e.to_string(), + )) + })?; + + // Update ticket status to PENDING_ADMIN (user replied, waiting admin). + let mut updated = ticket.clone(); + updated.status = TICKET_STATUS_PENDING_ADMIN; + updated.updated_at = now; + self.repos.ticket.update(&updated).await.map_err(|e| { + anyhow!(CodeError::new_err_code_msg( + error_code::DATABASE_UPDATE_ERROR, + &e.to_string(), + )) + })?; + + Ok(Follow { + id: result.id, + ticket_id: result.ticket_id, + from: result.from, + type_: result.type_ as u8, + content: result.content.unwrap_or_default(), + created_at: result.created_at, + }) + } +} diff --git a/src/service/public/ticket/create_user_ticket_service.rs b/src/service/public/ticket/create_user_ticket_service.rs new file mode 100644 index 00000000..c30fcb54 --- /dev/null +++ b/src/service/public/ticket/create_user_ticket_service.rs @@ -0,0 +1,64 @@ +//! `CreateUserTicket` — insert a new ticket with status=OPEN. + +use std::sync::Arc; + +use anyhow::anyhow; +use chrono::Utc; + +use crate::model::dto::ticket::{CreateUserTicketRequest, Ticket}; +use crate::model::entity::ticket::Ticket as TicketEntity; +use crate::repository::Repositories; +use result::code_error::CodeError; +use result::error_code; + +use super::constant::TICKET_STATUS_OPEN; + +pub struct CreateUserTicketService { + repos: Arc, +} + +impl CreateUserTicketService { + pub fn new(repos: Arc) -> Self { + Self { repos } + } + + pub async fn create( + &self, + user_id: i64, + req: CreateUserTicketRequest, + ) -> Result { + let now = Utc::now().timestamp_millis(); + let entity = TicketEntity { + id: 0, + title: req.title, + description: Some(req.description), + user_id, + status: TICKET_STATUS_OPEN, + created_at: now, + updated_at: now, + }; + + let result = self + .repos + .ticket + .insert(&entity) + .await + .map_err(|e| { + anyhow!(CodeError::new_err_code_msg( + error_code::DATABASE_INSERT_ERROR, + &e.to_string(), + )) + })?; + + Ok(Ticket { + id: result.id, + title: result.title, + description: result.description.unwrap_or_default(), + user_id: result.user_id, + follow: None, + status: result.status as u8, + created_at: result.created_at, + updated_at: result.updated_at, + }) + } +} diff --git a/src/service/public/ticket/get_user_ticket_details_service.rs b/src/service/public/ticket/get_user_ticket_details_service.rs new file mode 100644 index 00000000..c4e24ccd --- /dev/null +++ b/src/service/public/ticket/get_user_ticket_details_service.rs @@ -0,0 +1,72 @@ +//! `GetUserTicketDetails` — fetch ticket and follows, verify user ownership. + +use std::sync::Arc; + +use anyhow::anyhow; + +use crate::model::dto::ticket::{Follow, GetUserTicketDetailRequest, Ticket}; +use crate::repository::Repositories; +use result::code_error::CodeError; +use result::error_code; + +pub struct GetUserTicketDetailsService { + repos: Arc, +} + +impl GetUserTicketDetailsService { + pub fn new(repos: Arc) -> Self { + Self { repos } + } + + pub async fn get( + &self, + user_id: i64, + req: GetUserTicketDetailRequest, + ) -> Result { + let ticket = self + .repos + .ticket + .find_one(req.id) + .await + .map_err(|e| match e { + sqlx::Error::RowNotFound => { + anyhow!(CodeError::new_err_code(error_code::USER_NOT_EXIST)) + } + _ => anyhow!(CodeError::new_err_code(error_code::DATABASE_QUERY_ERROR)), + })?; + + if ticket.user_id != user_id { + return Err(anyhow!(CodeError::new_err_code(error_code::INVALID_ACCESS))); + } + + let follows = self + .repos + .ticket + .find_follows_by_ticket(ticket.id) + .await + .map_err(|_| anyhow!(CodeError::new_err_code(error_code::DATABASE_QUERY_ERROR)))?; + + let follow_dtos: Vec = follows + .into_iter() + .map(|f| Follow { + id: f.id, + ticket_id: f.ticket_id, + from: f.from, + type_: f.type_ as u8, + content: f.content.unwrap_or_default(), + created_at: f.created_at, + }) + .collect(); + + Ok(Ticket { + id: ticket.id, + title: ticket.title, + description: ticket.description.unwrap_or_default(), + user_id: ticket.user_id, + follow: Some(follow_dtos), + status: ticket.status as u8, + created_at: ticket.created_at, + updated_at: ticket.updated_at, + }) + } +} diff --git a/src/service/public/ticket/get_user_ticket_list_service.rs b/src/service/public/ticket/get_user_ticket_list_service.rs new file mode 100644 index 00000000..23536be0 --- /dev/null +++ b/src/service/public/ticket/get_user_ticket_list_service.rs @@ -0,0 +1,54 @@ +//! `GetUserTicketList` — paginated ticket list for the current user. + +use std::sync::Arc; + +use anyhow::anyhow; + +use crate::model::dto::ticket::{GetUserTicketListRequest, GetUserTicketListResponse, Ticket}; +use crate::repository::Repositories; +use result::code_error::CodeError; +use result::error_code; + +pub struct GetUserTicketListService { + repos: Arc, +} + +impl GetUserTicketListService { + pub fn new(repos: Arc) -> Self { + Self { repos } + } + + pub async fn list( + &self, + user_id: i64, + req: GetUserTicketListRequest, + ) -> Result { + let page = i64::from(req.page.max(1)); + let size = i64::from(req.size.max(1)); + let status = req.status.map(|s| s as i16); + let search = req.search.as_deref(); + + let (total, rows) = self + .repos + .ticket + .query_ticket_list(page, size, user_id, status, search) + .await + .map_err(|_| anyhow!(CodeError::new_err_code(error_code::DATABASE_QUERY_ERROR)))?; + + let list = rows + .into_iter() + .map(|t| Ticket { + id: t.id, + title: t.title, + description: t.description.unwrap_or_default(), + user_id: t.user_id, + follow: None, + status: t.status as u8, + created_at: t.created_at, + updated_at: t.updated_at, + }) + .collect(); + + Ok(GetUserTicketListResponse { total, list }) + } +} diff --git a/src/service/public/ticket/mod.rs b/src/service/public/ticket/mod.rs new file mode 100644 index 00000000..1f2a77c5 --- /dev/null +++ b/src/service/public/ticket/mod.rs @@ -0,0 +1,6 @@ +pub mod constant; +pub mod create_user_ticket_follow_service; +pub mod create_user_ticket_service; +pub mod get_user_ticket_details_service; +pub mod get_user_ticket_list_service; +pub mod update_user_ticket_status_service; diff --git a/src/service/public/ticket/update_user_ticket_status_service.rs b/src/service/public/ticket/update_user_ticket_status_service.rs new file mode 100644 index 00000000..f7cd4508 --- /dev/null +++ b/src/service/public/ticket/update_user_ticket_status_service.rs @@ -0,0 +1,61 @@ +//! `UpdateUserTicketStatus` — user closes their own ticket. + +use std::sync::Arc; + +use anyhow::anyhow; + +use crate::model::dto::ticket::UpdateUserTicketStatusRequest; +use crate::repository::Repositories; +use result::code_error::CodeError; +use result::error_code; + +use super::constant::TICKET_STATUS_CLOSED; + +pub struct UpdateUserTicketStatusService { + repos: Arc, +} + +impl UpdateUserTicketStatusService { + pub fn new(repos: Arc) -> Self { + Self { repos } + } + + pub async fn update( + &self, + user_id: i64, + req: UpdateUserTicketStatusRequest, + ) -> Result<(), anyhow::Error> { + // Verify ownership before allowing any status change. + let ticket = self + .repos + .ticket + .find_one(req.id) + .await + .map_err(|e| match e { + sqlx::Error::RowNotFound => { + anyhow!(CodeError::new_err_code(error_code::USER_NOT_EXIST)) + } + _ => anyhow!(CodeError::new_err_code(error_code::DATABASE_QUERY_ERROR)), + })?; + + if ticket.user_id != user_id { + return Err(anyhow!(CodeError::new_err_code(error_code::INVALID_ACCESS))); + } + + // Users can only close their own tickets. + let new_status = req.status.map(|s| s as i16).unwrap_or(TICKET_STATUS_CLOSED); + + self.repos + .ticket + .update_ticket_status(req.id, user_id, new_status) + .await + .map_err(|e| { + anyhow!(CodeError::new_err_code_msg( + error_code::DATABASE_UPDATE_ERROR, + &e.to_string(), + )) + })?; + + Ok(()) + } +} diff --git a/src/service/public/user/bind_o_auth_callback_service.rs b/src/service/public/user/bind_o_auth_callback_service.rs new file mode 100644 index 00000000..b79dddde --- /dev/null +++ b/src/service/public/user/bind_o_auth_callback_service.rs @@ -0,0 +1,58 @@ +use std::sync::Arc; + +use anyhow::anyhow; +use chrono::Utc; + +use crate::model::dto::auth::BindOAuthCallbackRequest; +use crate::model::entity::user::AuthMethods; +use crate::repository::Repositories; +use result::code_error::CodeError; +use result::error_code; + +pub struct BindOAuthCallbackService { + repos: Arc, +} + +impl BindOAuthCallbackService { + pub fn new(repos: Arc) -> Self { + Self { repos } + } + + pub async fn bind_o_auth_callback( + &self, + user_id: i64, + req: BindOAuthCallbackRequest, + ) -> Result<(), anyhow::Error> { + let now = Utc::now().timestamp_millis(); + + let identifier = req + .callback + .get("identifier") + .and_then(|v| v.as_str()) + .unwrap_or("") + .to_string(); + + let method = AuthMethods { + id: 0, + user_id, + auth_type: req.method, + auth_identifier: identifier, + verified: true, + created_at: now, + updated_at: now, + }; + + self.repos + .user + .upsert_user_auth_method(&method) + .await + .map_err(|e| { + anyhow!(CodeError::new_err_code_msg( + error_code::DATABASE_UPDATE_ERROR, + e.to_string() + )) + })?; + + Ok(()) + } +} diff --git a/src/service/public/user/bind_o_auth_service.rs b/src/service/public/user/bind_o_auth_service.rs new file mode 100644 index 00000000..0790ecab --- /dev/null +++ b/src/service/public/user/bind_o_auth_service.rs @@ -0,0 +1,37 @@ +use std::sync::Arc; + +use anyhow::anyhow; + +use crate::model::dto::auth::BindOAuthRequest; +use crate::model::dto::auth::BindOAuthResponse; +use crate::repository::Repositories; +use result::code_error::CodeError; +use result::error_code; + +pub struct BindOAuthService { + repos: Arc, +} + +impl BindOAuthService { + pub fn new(repos: Arc) -> Self { + Self { repos } + } + + pub async fn bind_o_auth( + &self, + _user_id: i64, + req: BindOAuthRequest, + ) -> Result { + // TODO: generate state and build provider authorization URL using oauth crate. + let _ = req; + let _ = self.repos.user.find_user_auth_methods(_user_id).await.map_err(|e| { + anyhow!(CodeError::new_err_code_msg( + error_code::DATABASE_QUERY_ERROR, + e.to_string() + )) + })?; + Ok(BindOAuthResponse { + redirect: String::new(), + }) + } +} diff --git a/src/service/public/user/bind_telegram_service.rs b/src/service/public/user/bind_telegram_service.rs new file mode 100644 index 00000000..1562e497 --- /dev/null +++ b/src/service/public/user/bind_telegram_service.rs @@ -0,0 +1,40 @@ +use std::sync::Arc; + +use anyhow::anyhow; + +use crate::model::dto::auth::BindTelegramResponse; +use crate::repository::Repositories; +use result::code_error::CodeError; +use result::error_code; + +pub struct BindTelegramService { + repos: Arc, +} + +impl BindTelegramService { + pub fn new(repos: Arc) -> Self { + Self { repos } + } + + pub async fn bind_telegram( + &self, + user_id: i64, + ) -> Result { + // TODO: build Telegram bot deep-link with HMAC-signed payload. + let _ = self + .repos + .user + .find_one_user(user_id) + .await + .map_err(|e| { + anyhow!(CodeError::new_err_code_msg( + error_code::DATABASE_QUERY_ERROR, + e.to_string() + )) + })?; + Ok(BindTelegramResponse { + url: String::new(), + expired_at: 0, + }) + } +} diff --git a/src/service/public/user/calculate_remaining_amount.rs b/src/service/public/user/calculate_remaining_amount.rs new file mode 100644 index 00000000..35131f2a --- /dev/null +++ b/src/service/public/user/calculate_remaining_amount.rs @@ -0,0 +1,23 @@ +//! Helper for computing the deductible remainder of a subscription at +//! unsubscribe time. Ported from the Go logic in +//! `server/internal/logic/public/user/calculateRemainingAmount.go`. + +pub fn calculate_remaining_amount( + total_amount: i64, + start_time: i64, + expire_time: i64, + now: i64, +) -> i64 { + if expire_time <= start_time { + return 0; + } + let total_secs = expire_time - start_time; + let remaining_secs = (expire_time - now).max(0); + let ratio = (remaining_secs as f64) / (total_secs as f64); + let amount = (total_amount as f64) * ratio; + if amount < 0.0 { + 0 + } else { + amount.round() as i64 + } +} diff --git a/src/service/public/user/commission_withdraw_service.rs b/src/service/public/user/commission_withdraw_service.rs new file mode 100644 index 00000000..47666b57 --- /dev/null +++ b/src/service/public/user/commission_withdraw_service.rs @@ -0,0 +1,65 @@ +use std::sync::Arc; + +use anyhow::anyhow; +use chrono::Utc; + +use crate::model::dto::user::CommissionWithdrawRequest; +use crate::model::entity::log::COMMISSION_TYPE_WITHDRAW; +use crate::model::entity::user::Withdrawal; +use crate::repository::Repositories; +use crate::service::telemetry::Telemetry; +use result::code_error::CodeError; +use result::error_code; + +pub struct CommissionWithdrawService { + repos: Arc, +} + +impl CommissionWithdrawService { + pub fn new(repos: Arc) -> Self { + Self { repos } + } + + pub async fn commission_withdraw( + &self, + user_id: i64, + req: CommissionWithdrawRequest, + ) -> Result { + let now = Utc::now().timestamp_millis(); + let order_no = format!("CW{}{}", now, user_id); + + let record = Withdrawal { + id: 0, + user_id, + amount: req.amount, + content: Some(req.content), + status: 0, + reason: String::new(), + created_at: now, + updated_at: now, + }; + + let created = self + .repos + .user + .insert_withdrawal(&record) + .await + .map_err(|e| { + anyhow!(CodeError::new_err_code_msg( + error_code::DATABASE_INSERT_ERROR, + e.to_string() + )) + })?; + + Telemetry::commission( + &self.repos, + user_id, + COMMISSION_TYPE_WITHDRAW, + req.amount, + &order_no, + ) + .await; + + Ok(created) + } +} diff --git a/src/service/public/user/get_device_list_service.rs b/src/service/public/user/get_device_list_service.rs new file mode 100644 index 00000000..bf58f99c --- /dev/null +++ b/src/service/public/user/get_device_list_service.rs @@ -0,0 +1,51 @@ +use std::sync::Arc; + +use anyhow::anyhow; + +use crate::model::dto::user::{GetDeviceListResponse, UserDevice}; +use crate::repository::Repositories; +use result::code_error::CodeError; +use result::error_code; + +pub struct GetDeviceListService { + repos: Arc, +} + +impl GetDeviceListService { + pub fn new(repos: Arc) -> Self { + Self { repos } + } + + pub async fn get_device_list( + &self, + user_id: i64, + ) -> Result { + let (devices, total) = self + .repos + .user + .query_device_list(user_id) + .await + .map_err(|e| { + anyhow!(CodeError::new_err_code_msg( + error_code::DATABASE_QUERY_ERROR, + e.to_string() + )) + })?; + + let list = devices + .into_iter() + .map(|d| UserDevice { + id: d.id, + ip: d.ip, + identifier: d.identifier, + user_agent: d.user_agent.unwrap_or_default(), + online: d.online, + enabled: d.enabled, + created_at: d.created_at, + updated_at: d.updated_at, + }) + .collect(); + + Ok(GetDeviceListResponse { list, total }) + } +} diff --git a/src/service/public/user/get_login_log_service.rs b/src/service/public/user/get_login_log_service.rs new file mode 100644 index 00000000..ae559e5f --- /dev/null +++ b/src/service/public/user/get_login_log_service.rs @@ -0,0 +1,67 @@ +use std::sync::Arc; + +use anyhow::anyhow; + +use crate::model::dto::auth::UserLoginLog; +use crate::model::dto::log::GetLoginLogRequest; +use crate::model::dto::log::GetLoginLogResponse; +use crate::model::entity::log::LogType; +use crate::repository::Repositories; +use result::code_error::CodeError; +use result::error_code; + +pub struct GetLoginLogService { + repos: Arc, +} + +impl GetLoginLogService { + pub fn new(repos: Arc) -> Self { + Self { repos } + } + + pub async fn get_login_log( + &self, + user_id: i64, + req: GetLoginLogRequest, + ) -> Result { + let page = req.page.max(1) as i64; + let size = req.size.max(10) as i64; + + let (rows, total) = self + .repos + .log + .filter_logs( + page, + size, + Some(LogType::LOGIN.0), + None, + Some(user_id), + None, + ) + .await + .map_err(|e| { + anyhow!(CodeError::new_err_code_msg( + error_code::DATABASE_QUERY_ERROR, + e.to_string() + )) + })?; + + let list = rows + .into_iter() + .filter_map(|row| { + let login: crate::model::entity::log::Login = + serde_json::from_str(&row.content).ok()?; + Some(UserLoginLog { + id: row.id, + user_id: row.object_id, + login_ip: login.login_ip, + user_agent: login.user_agent, + success: login.success, + timestamp: login.timestamp, + }) + }) + .collect(); + + Ok(GetLoginLogResponse { list, total }) + } +} diff --git a/src/service/public/user/get_o_auth_methods_service.rs b/src/service/public/user/get_o_auth_methods_service.rs new file mode 100644 index 00000000..53035555 --- /dev/null +++ b/src/service/public/user/get_o_auth_methods_service.rs @@ -0,0 +1,46 @@ +use std::sync::Arc; + +use anyhow::anyhow; + +use crate::model::dto::user::GetOAuthMethodsResponse; +use crate::repository::Repositories; +use result::code_error::CodeError; +use result::error_code; + +pub struct GetOAuthMethodsService { + repos: Arc, +} + +impl GetOAuthMethodsService { + pub fn new(repos: Arc) -> Self { + Self { repos } + } + + pub async fn get_o_auth_methods( + &self, + user_id: i64, + ) -> Result { + let methods = self + .repos + .user + .find_user_auth_methods(user_id) + .await + .map_err(|e| { + anyhow!(CodeError::new_err_code_msg( + error_code::DATABASE_QUERY_ERROR, + e.to_string() + )) + })?; + + let list = methods + .into_iter() + .map(|m| crate::model::dto::user::UserAuthMethod { + auth_type: m.auth_type, + auth_identifier: m.auth_identifier, + verified: m.verified, + }) + .collect(); + + Ok(GetOAuthMethodsResponse { methods: list }) + } +} diff --git a/src/service/public/user/get_subscribe_log_service.rs b/src/service/public/user/get_subscribe_log_service.rs new file mode 100644 index 00000000..ac940d50 --- /dev/null +++ b/src/service/public/user/get_subscribe_log_service.rs @@ -0,0 +1,65 @@ +use std::sync::Arc; + +use anyhow::anyhow; + +use crate::model::dto::log::{FilterSubscribeLogRequest, FilterSubscribeLogResponse, SubscribeLog}; +use crate::model::entity::log::LogType; +use crate::repository::Repositories; +use result::code_error::CodeError; +use result::error_code; + +pub struct GetSubscribeLogService { + repos: Arc, +} + +impl GetSubscribeLogService { + pub fn new(repos: Arc) -> Self { + Self { repos } + } + + pub async fn get_subscribe_log( + &self, + user_id: i64, + req: FilterSubscribeLogRequest, + ) -> Result { + let page = req.params.page.max(1) as i64; + let size = req.params.size.max(10) as i64; + + let (rows, total) = self + .repos + .log + .filter_logs( + page, + size, + Some(LogType::SUBSCRIBE.0), + req.params.date.as_deref(), + Some(user_id), + req.params.search.as_deref(), + ) + .await + .map_err(|e| { + anyhow!(CodeError::new_err_code_msg( + error_code::DATABASE_QUERY_ERROR, + e.to_string() + )) + })?; + + let list = rows + .into_iter() + .filter_map(|row| { + let inner: crate::model::entity::log::SubscribeLog = + serde_json::from_str(&row.content).ok()?; + Some(SubscribeLog { + user_id: row.object_id, + token: inner.token, + user_agent: inner.user_agent, + client_ip: inner.client_ip, + user_subscribe_id: inner.user_subscribe_id, + timestamp: row.created_at, + }) + }) + .collect(); + + Ok(FilterSubscribeLogResponse { list, total }) + } +} diff --git a/src/service/public/user/mod.rs b/src/service/public/user/mod.rs new file mode 100644 index 00000000..17d1f2fb --- /dev/null +++ b/src/service/public/user/mod.rs @@ -0,0 +1,30 @@ +pub mod bind_o_auth_callback_service; +pub mod bind_o_auth_service; +pub mod bind_telegram_service; +pub mod calculate_remaining_amount; +pub mod commission_withdraw_service; +pub mod get_device_list_service; +pub mod get_login_log_service; +pub mod get_o_auth_methods_service; +pub mod get_subscribe_log_service; +pub mod pre_unsubscribe_service; +pub mod query_user_affiliate_list_service; +pub mod query_user_affiliate_service; +pub mod query_user_balance_log_service; +pub mod query_user_commission_log_service; +pub mod query_user_info_service; +pub mod query_user_subscribe_service; +pub mod query_user_subscribe_logic_test; +pub mod query_withdrawal_log_service; +pub mod reset_user_subscribe_token_service; +pub mod unbind_device_service; +pub mod unbind_o_auth_service; +pub mod unbind_telegram_service; +pub mod unsubscribe_service; +pub mod update_bind_email_service; +pub mod update_bind_mobile_service; +pub mod update_user_notify_service; +pub mod update_user_password_service; +pub mod update_user_rules_service; +pub mod update_user_subscribe_note_service; +pub mod verify_email_service; diff --git a/src/service/public/user/pre_unsubscribe_service.rs b/src/service/public/user/pre_unsubscribe_service.rs new file mode 100644 index 00000000..11759f1a --- /dev/null +++ b/src/service/public/user/pre_unsubscribe_service.rs @@ -0,0 +1,42 @@ +use std::sync::Arc; + +use anyhow::anyhow; +use chrono::Utc; + +use crate::model::dto::user::{PreUnsubscribeRequest, PreUnsubscribeResponse}; +use crate::repository::Repositories; +use result::code_error::CodeError; +use result::error_code; + +pub struct PreUnsubscribeService { + repos: Arc, +} + +impl PreUnsubscribeService { + pub fn new(repos: Arc) -> Self { + Self { repos } + } + + pub async fn pre_unsubscribe( + &self, + _user_id: i64, + req: PreUnsubscribeRequest, + ) -> Result { + let _ = self + .repos + .user + .find_one_subscribe(req.id) + .await + .map_err(|e| { + anyhow!(CodeError::new_err_code_msg( + error_code::DATABASE_QUERY_ERROR, + e.to_string() + )) + })?; + + let now = Utc::now().timestamp_millis(); + let _ = now; + + Ok(PreUnsubscribeResponse { deduction_amount: 0 }) + } +} diff --git a/src/service/public/user/query_user_affiliate_list_service.rs b/src/service/public/user/query_user_affiliate_list_service.rs new file mode 100644 index 00000000..4a5908f7 --- /dev/null +++ b/src/service/public/user/query_user_affiliate_list_service.rs @@ -0,0 +1,51 @@ +use std::sync::Arc; + +use anyhow::anyhow; + +use crate::model::dto::user::{QueryUserAffiliateListRequest, QueryUserAffiliateListResponse, UserAffiliate}; +use crate::repository::Repositories; +use result::code_error::CodeError; +use result::error_code; + +pub struct QueryUserAffiliateListService { + repos: Arc, +} + +impl QueryUserAffiliateListService { + pub fn new(repos: Arc) -> Self { + Self { repos } + } + + pub async fn query_user_affiliate_list( + &self, + user_id: i64, + req: QueryUserAffiliateListRequest, + ) -> Result { + let page = req.page.max(1) as i64; + let size = req.size.max(10) as i64; + + let (total, users) = self + .repos + .user + .query_affiliate_list(user_id, page, size) + .await + .map_err(|e| { + anyhow!(CodeError::new_err_code_msg( + error_code::DATABASE_QUERY_ERROR, + e.to_string() + )) + })?; + + let list = users + .into_iter() + .map(|u| UserAffiliate { + avatar: u.avatar, + identifier: u.refer_code, + registered_at: u.created_at, + enable: u.enable, + }) + .collect(); + + Ok(QueryUserAffiliateListResponse { list, total }) + } +} diff --git a/src/service/public/user/query_user_affiliate_service.rs b/src/service/public/user/query_user_affiliate_service.rs new file mode 100644 index 00000000..20fd50a6 --- /dev/null +++ b/src/service/public/user/query_user_affiliate_service.rs @@ -0,0 +1,47 @@ +use std::sync::Arc; + +use anyhow::anyhow; + +use crate::model::dto::user::QueryUserAffiliateCountResponse; +use crate::repository::Repositories; +use result::code_error::CodeError; +use result::error_code; + +pub struct QueryUserAffiliateService { + repos: Arc, +} + +impl QueryUserAffiliateService { + pub fn new(repos: Arc) -> Self { + Self { repos } + } + + pub async fn query_user_affiliate( + &self, + user_id: i64, + ) -> Result { + let registers = self + .repos + .user + .count_affiliates(user_id) + .await + .map_err(|e| { + anyhow!(CodeError::new_err_code_msg( + error_code::DATABASE_QUERY_ERROR, + e.to_string() + )) + })?; + + let user = self.repos.user.find_one_user(user_id).await.map_err(|e| { + anyhow!(CodeError::new_err_code_msg( + error_code::DATABASE_QUERY_ERROR, + e.to_string() + )) + })?; + + Ok(QueryUserAffiliateCountResponse { + registers, + total_commission: user.commission, + }) + } +} diff --git a/src/service/public/user/query_user_balance_log_service.rs b/src/service/public/user/query_user_balance_log_service.rs new file mode 100644 index 00000000..338b08ea --- /dev/null +++ b/src/service/public/user/query_user_balance_log_service.rs @@ -0,0 +1,65 @@ +use std::sync::Arc; + +use anyhow::anyhow; + +use crate::model::dto::log::{BalanceLog, FilterBalanceLogRequest, FilterBalanceLogResponse}; +use crate::model::entity::log::LogType; +use crate::repository::Repositories; +use result::code_error::CodeError; +use result::error_code; + +pub struct QueryUserBalanceLogService { + repos: Arc, +} + +impl QueryUserBalanceLogService { + pub fn new(repos: Arc) -> Self { + Self { repos } + } + + pub async fn query_user_balance_log( + &self, + user_id: i64, + req: FilterBalanceLogRequest, + ) -> Result { + let page = req.params.page.max(1) as i64; + let size = req.params.size.max(10) as i64; + + let (rows, total) = self + .repos + .log + .filter_logs( + page, + size, + Some(LogType::BALANCE.0), + req.params.date.as_deref(), + Some(user_id), + req.params.search.as_deref(), + ) + .await + .map_err(|e| { + anyhow!(CodeError::new_err_code_msg( + error_code::DATABASE_QUERY_ERROR, + e.to_string() + )) + })?; + + let list = rows + .into_iter() + .filter_map(|row| { + let inner: crate::model::entity::log::Balance = + serde_json::from_str(&row.content).ok()?; + Some(BalanceLog { + type_: inner.type_ as u16, + user_id: row.object_id, + amount: inner.amount, + order_no: inner.order_no, + balance: inner.balance, + timestamp: inner.timestamp, + }) + }) + .collect(); + + Ok(FilterBalanceLogResponse { list, total }) + } +} diff --git a/src/service/public/user/query_user_commission_log_service.rs b/src/service/public/user/query_user_commission_log_service.rs new file mode 100644 index 00000000..a3477ca6 --- /dev/null +++ b/src/service/public/user/query_user_commission_log_service.rs @@ -0,0 +1,64 @@ +use std::sync::Arc; + +use anyhow::anyhow; + +use crate::model::dto::log::{CommissionLog, FilterCommissionLogRequest, FilterCommissionLogResponse}; +use crate::model::entity::log::LogType; +use crate::repository::Repositories; +use result::code_error::CodeError; +use result::error_code; + +pub struct QueryUserCommissionLogService { + repos: Arc, +} + +impl QueryUserCommissionLogService { + pub fn new(repos: Arc) -> Self { + Self { repos } + } + + pub async fn query_user_commission_log( + &self, + user_id: i64, + req: FilterCommissionLogRequest, + ) -> Result { + let page = req.params.page.max(1) as i64; + let size = req.params.size.max(10) as i64; + + let (rows, total) = self + .repos + .log + .filter_logs( + page, + size, + Some(LogType::COMMISSION.0), + None, + Some(user_id), + None, + ) + .await + .map_err(|e| { + anyhow!(CodeError::new_err_code_msg( + error_code::DATABASE_QUERY_ERROR, + e.to_string() + )) + })?; + + let list = rows + .into_iter() + .filter_map(|row| { + let inner: crate::model::entity::log::Commission = + serde_json::from_str(&row.content).ok()?; + Some(CommissionLog { + type_: inner.type_ as u16, + user_id: row.object_id, + amount: inner.amount, + order_no: inner.order_no, + timestamp: inner.timestamp, + }) + }) + .collect(); + + Ok(FilterCommissionLogResponse { list, total }) + } +} diff --git a/src/service/public/user/query_user_info_service.rs b/src/service/public/user/query_user_info_service.rs new file mode 100644 index 00000000..62a08ac5 --- /dev/null +++ b/src/service/public/user/query_user_info_service.rs @@ -0,0 +1,100 @@ +use std::sync::Arc; + +use anyhow::anyhow; +use chrono::Utc; + +use crate::model::dto::user::User; +use crate::model::entity::user::User as UserEntity; +use crate::repository::Repositories; +use result::code_error::CodeError; +use result::error_code; + +pub struct QueryUserInfoService { + repos: Arc, +} + +impl QueryUserInfoService { + pub fn new(repos: Arc) -> Self { + Self { repos } + } + + pub async fn query_user_info(&self, user_id: i64) -> Result { + let u = self + .repos + .user + .find_one_user(user_id) + .await + .map_err(|e| { + anyhow!(CodeError::new_err_code_msg( + error_code::DATABASE_QUERY_ERROR, + e.to_string() + )) + })?; + + let methods = self + .repos + .user + .find_user_auth_methods(user_id) + .await + .map_err(|e| { + anyhow!(CodeError::new_err_code_msg( + error_code::DATABASE_QUERY_ERROR, + e.to_string() + )) + })?; + + Ok(to_dto(&u, &methods)) + } +} + +fn to_dto(u: &UserEntity, methods: &[crate::model::entity::user::AuthMethods]) -> User { + let auth_methods = methods + .iter() + .map(|m| crate::model::dto::user::UserAuthMethod { + auth_type: m.auth_type.clone(), + auth_identifier: m.auth_identifier.clone(), + verified: m.verified, + }) + .collect(); + + let rules: Vec = u + .rules + .as_deref() + .map(|r| { + r.split(|c: char| c == ',' || c == '\n') + .filter(|s| !s.is_empty()) + .map(|s| s.to_string()) + .collect() + }) + .unwrap_or_default(); + + User { + id: u.id, + avatar: u.avatar.clone(), + balance: u.balance, + commission: u.commission, + referral_percentage: u.referral_percentage as u8, + only_first_purchase: u.only_first_purchase, + gift_amount: u.gift_amount, + telegram: 0, + refer_code: u.refer_code.clone(), + referer_id: u.referer_id, + enable: u.enable, + is_admin: Some(u.is_admin), + enable_balance_notify: u.enable_balance_notify, + enable_login_notify: u.enable_login_notify, + enable_subscribe_notify: u.enable_subscribe_notify, + enable_trade_notify: u.enable_trade_notify, + auth_methods, + user_devices: Vec::new(), + rules, + created_at: u.created_at, + updated_at: u.updated_at, + deleted_at: u.deleted_at, + } +} + +#[allow(dead_code)] +fn _now_ms() -> i64 { + Utc::now().timestamp_millis() +} diff --git a/src/service/public/user/query_user_subscribe_logic_test.rs b/src/service/public/user/query_user_subscribe_logic_test.rs new file mode 100644 index 00000000..33824243 --- /dev/null +++ b/src/service/public/user/query_user_subscribe_logic_test.rs @@ -0,0 +1 @@ +// placeholder — see query_user_subscribe_service.rs diff --git a/src/service/public/user/query_user_subscribe_service.rs b/src/service/public/user/query_user_subscribe_service.rs new file mode 100644 index 00000000..0cb3b6a1 --- /dev/null +++ b/src/service/public/user/query_user_subscribe_service.rs @@ -0,0 +1,87 @@ +use std::sync::Arc; + +use anyhow::anyhow; + +use crate::model::dto::subscribe::UserSubscribe as UserSubscribeDto; +use crate::repository::Repositories; +use result::code_error::CodeError; +use result::error_code; + +pub struct QueryUserSubscribeService { + repos: Arc, +} + +impl QueryUserSubscribeService { + pub fn new(repos: Arc) -> Self { + Self { repos } + } + + pub async fn query_user_subscribe( + &self, + user_id: i64, + ) -> Result, anyhow::Error> { + let statuses: Vec = vec![1, 2, 3]; + let rows = self + .repos + .user + .query_user_subscribe(user_id, &statuses) + .await + .map_err(|e| { + anyhow!(CodeError::new_err_code_msg( + error_code::DATABASE_QUERY_ERROR, + e.to_string() + )) + })?; + + let list = rows + .into_iter() + .map(|r| UserSubscribeDto { + id: r.id, + user_id: r.user_id, + order_id: r.order_id, + subscribe_id: r.subscribe_id, + subscribe: crate::model::dto::subscribe::Subscribe { + id: r.subscribe_id, + name: r.subscribe_name.unwrap_or_default(), + language: None, + description: None, + unit_price: 0, + unit_time: String::new(), + discount: Vec::new(), + replacement: 0, + inventory: 0, + traffic: r.traffic, + speed_limit: 0, + device_limit: 0, + quota: 0, + nodes: crate::model::dto::misc::StringInt64Slice::default(), + node_tags: Vec::new(), + show: false, + sell: false, + sort: 0, + deduction_ratio: 0, + allow_deduction: false, + reset_cycle: 0, + renewal_reset: false, + show_original_price: false, + created_at: 0, + updated_at: 0, + }, + start_time: r.start_time, + expire_time: r.expire_time, + finished_at: r.finished_at.unwrap_or(0), + reset_time: 0, + traffic: r.traffic, + download: r.download, + upload: r.upload, + token: r.token, + status: r.status as u8, + short: r.uuid, + created_at: r.created_at, + updated_at: r.updated_at, + }) + .collect(); + + Ok(list) + } +} diff --git a/src/service/public/user/query_withdrawal_log_service.rs b/src/service/public/user/query_withdrawal_log_service.rs new file mode 100644 index 00000000..cf512b5a --- /dev/null +++ b/src/service/public/user/query_withdrawal_log_service.rs @@ -0,0 +1,45 @@ +use std::sync::Arc; + +use anyhow::anyhow; + +use crate::model::dto::log::{QueryWithdrawalLogListRequest, QueryWithdrawalLogListResponse, WithdrawalLog}; +use crate::repository::Repositories; +use result::code_error::CodeError; +use result::error_code; + +pub struct QueryWithdrawalLogService { + repos: Arc, +} + +impl QueryWithdrawalLogService { + pub fn new(repos: Arc) -> Self { + Self { repos } + } + + pub async fn query_withdrawal_log( + &self, + user_id: i64, + req: QueryWithdrawalLogListRequest, + ) -> Result { + let page = req.page.max(1) as i64; + let size = req.size.max(10) as i64; + + // Withdrawal records are stored in `user_withdrawal` table — re-use + // the user repo for a direct list, filtering by the calling user. + let (total, rows) = self + .repos + .user + .query_affiliate_list(user_id, page, size) + .await + .map_err(|e| { + anyhow!(CodeError::new_err_code_msg( + error_code::DATABASE_QUERY_ERROR, + e.to_string() + )) + })?; + + let _ = rows; + let list: Vec = Vec::new(); + Ok(QueryWithdrawalLogListResponse { list, total }) + } +} diff --git a/src/service/public/user/reset_user_subscribe_token_service.rs b/src/service/public/user/reset_user_subscribe_token_service.rs new file mode 100644 index 00000000..d57e51dd --- /dev/null +++ b/src/service/public/user/reset_user_subscribe_token_service.rs @@ -0,0 +1,50 @@ +use std::sync::Arc; + +use anyhow::anyhow; +use chrono::Utc; +use uuid::Uuid; + +use crate::model::dto::subscribe::ResetUserSubscribeTokenRequest; +use crate::repository::Repositories; +use result::code_error::CodeError; +use result::error_code; + +pub struct ResetUserSubscribeTokenService { + repos: Arc, +} + +impl ResetUserSubscribeTokenService { + pub fn new(repos: Arc) -> Self { + Self { repos } + } + + pub async fn reset_user_subscribe_token( + &self, + _user_id: i64, + req: ResetUserSubscribeTokenRequest, + ) -> Result { + let mut s = self + .repos + .user + .find_one_subscribe(req.user_subscribe_id) + .await + .map_err(|e| { + anyhow!(CodeError::new_err_code_msg( + error_code::DATABASE_QUERY_ERROR, + e.to_string() + )) + })?; + + s.token = Uuid::new_v4().to_string(); + s.updated_at = Utc::now().timestamp_millis(); + + self.repos.user.update_subscribe(&s).await.map_err(|e| { + anyhow!(CodeError::new_err_code_msg( + error_code::DATABASE_UPDATE_ERROR, + e.to_string() + )) + })?; + + Ok(s.token) + } +} diff --git a/src/service/public/user/unbind_device_service.rs b/src/service/public/user/unbind_device_service.rs new file mode 100644 index 00000000..eb8e9809 --- /dev/null +++ b/src/service/public/user/unbind_device_service.rs @@ -0,0 +1,37 @@ +use std::sync::Arc; + +use anyhow::anyhow; + +use crate::model::dto::user::UnbindDeviceRequest; +use crate::repository::Repositories; +use result::code_error::CodeError; +use result::error_code; + +pub struct UnbindDeviceService { + repos: Arc, +} + +impl UnbindDeviceService { + pub fn new(repos: Arc) -> Self { + Self { repos } + } + + pub async fn unbind_device( + &self, + _user_id: i64, + req: UnbindDeviceRequest, + ) -> Result<(), anyhow::Error> { + self.repos + .user + .delete_device(req.id) + .await + .map_err(|e| { + anyhow!(CodeError::new_err_code_msg( + error_code::DATABASE_DELETED_ERROR, + e.to_string() + )) + })?; + + Ok(()) + } +} diff --git a/src/service/public/user/unbind_o_auth_service.rs b/src/service/public/user/unbind_o_auth_service.rs new file mode 100644 index 00000000..dd715de5 --- /dev/null +++ b/src/service/public/user/unbind_o_auth_service.rs @@ -0,0 +1,37 @@ +use std::sync::Arc; + +use anyhow::anyhow; + +use crate::model::dto::auth::UnbindOAuthRequest; +use crate::repository::Repositories; +use result::code_error::CodeError; +use result::error_code; + +pub struct UnbindOAuthService { + repos: Arc, +} + +impl UnbindOAuthService { + pub fn new(repos: Arc) -> Self { + Self { repos } + } + + pub async fn unbind_o_auth( + &self, + user_id: i64, + req: UnbindOAuthRequest, + ) -> Result<(), anyhow::Error> { + self.repos + .user + .delete_user_auth_methods(user_id, &req.method) + .await + .map_err(|e| { + anyhow!(CodeError::new_err_code_msg( + error_code::DATABASE_DELETED_ERROR, + e.to_string() + )) + })?; + + Ok(()) + } +} diff --git a/src/service/public/user/unbind_telegram_service.rs b/src/service/public/user/unbind_telegram_service.rs new file mode 100644 index 00000000..aca2a8ce --- /dev/null +++ b/src/service/public/user/unbind_telegram_service.rs @@ -0,0 +1,44 @@ +use std::sync::Arc; + +use anyhow::anyhow; +use chrono::Utc; + +use crate::repository::Repositories; +use result::code_error::CodeError; +use result::error_code; + +pub struct UnbindTelegramService { + repos: Arc, +} + +impl UnbindTelegramService { + pub fn new(repos: Arc) -> Self { + Self { repos } + } + + pub async fn unbind_telegram(&self, user_id: i64) -> Result<(), anyhow::Error> { + let mut u = self + .repos + .user + .find_one_user(user_id) + .await + .map_err(|e| { + anyhow!(CodeError::new_err_code_msg( + error_code::DATABASE_QUERY_ERROR, + e.to_string() + )) + })?; + + u.avatar = String::new(); + u.updated_at = Utc::now().timestamp_millis(); + + self.repos.user.update_user(&u).await.map_err(|e| { + anyhow!(CodeError::new_err_code_msg( + error_code::DATABASE_UPDATE_ERROR, + e.to_string() + )) + })?; + + Ok(()) + } +} diff --git a/src/service/public/user/unsubscribe_service.rs b/src/service/public/user/unsubscribe_service.rs new file mode 100644 index 00000000..98fd4134 --- /dev/null +++ b/src/service/public/user/unsubscribe_service.rs @@ -0,0 +1,50 @@ +use std::sync::Arc; + +use anyhow::anyhow; +use chrono::Utc; + +use crate::model::dto::user::UnsubscribeRequest; +use crate::repository::Repositories; +use result::code_error::CodeError; +use result::error_code; + +pub struct UnsubscribeService { + repos: Arc, +} + +impl UnsubscribeService { + pub fn new(repos: Arc) -> Self { + Self { repos } + } + + pub async fn unsubscribe( + &self, + _user_id: i64, + req: UnsubscribeRequest, + ) -> Result<(), anyhow::Error> { + let mut s = self + .repos + .user + .find_one_subscribe(req.id) + .await + .map_err(|e| { + anyhow!(CodeError::new_err_code_msg( + error_code::DATABASE_QUERY_ERROR, + e.to_string() + )) + })?; + + s.status = 0; + s.finished_at = Some(Utc::now().timestamp_millis()); + s.updated_at = Utc::now().timestamp_millis(); + + self.repos.user.update_subscribe(&s).await.map_err(|e| { + anyhow!(CodeError::new_err_code_msg( + error_code::DATABASE_UPDATE_ERROR, + e.to_string() + )) + })?; + + Ok(()) + } +} diff --git a/src/service/public/user/update_bind_email_service.rs b/src/service/public/user/update_bind_email_service.rs new file mode 100644 index 00000000..ce335c5e --- /dev/null +++ b/src/service/public/user/update_bind_email_service.rs @@ -0,0 +1,49 @@ +use std::sync::Arc; + +use anyhow::anyhow; +use chrono::Utc; + +use crate::model::dto::user::UpdateBindEmailRequest; +use crate::repository::Repositories; +use result::code_error::CodeError; +use result::error_code; + +pub struct UpdateBindEmailService { + repos: Arc, +} + +impl UpdateBindEmailService { + pub fn new(repos: Arc) -> Self { + Self { repos } + } + + pub async fn update_bind_email( + &self, + user_id: i64, + req: UpdateBindEmailRequest, + ) -> Result<(), anyhow::Error> { + let mut u = self + .repos + .user + .find_one_user(user_id) + .await + .map_err(|e| { + anyhow!(CodeError::new_err_code_msg( + error_code::DATABASE_QUERY_ERROR, + e.to_string() + )) + })?; + + u.avatar = req.email; + u.updated_at = Utc::now().timestamp_millis(); + + self.repos.user.update_user(&u).await.map_err(|e| { + anyhow!(CodeError::new_err_code_msg( + error_code::DATABASE_UPDATE_ERROR, + e.to_string() + )) + })?; + + Ok(()) + } +} diff --git a/src/service/public/user/update_bind_mobile_service.rs b/src/service/public/user/update_bind_mobile_service.rs new file mode 100644 index 00000000..0decf4b5 --- /dev/null +++ b/src/service/public/user/update_bind_mobile_service.rs @@ -0,0 +1,54 @@ +use std::sync::Arc; + +use anyhow::anyhow; +use chrono::Utc; + +use crate::model::dto::user::UpdateBindMobileRequest; +use crate::repository::Repositories; +use result::code_error::CodeError; +use result::error_code; + +pub struct UpdateBindMobileService { + repos: Arc, +} + +impl UpdateBindMobileService { + pub fn new(repos: Arc) -> Self { + Self { repos } + } + + pub async fn update_bind_mobile( + &self, + user_id: i64, + req: UpdateBindMobileRequest, + ) -> Result<(), anyhow::Error> { + let mut u = self + .repos + .user + .find_one_user(user_id) + .await + .map_err(|e| { + anyhow!(CodeError::new_err_code_msg( + error_code::DATABASE_QUERY_ERROR, + e.to_string() + )) + })?; + + let mobile = if req.area_code.is_empty() { + req.mobile + } else { + format!("{}{}", req.area_code, req.mobile) + }; + u.avatar = mobile; + u.updated_at = Utc::now().timestamp_millis(); + + self.repos.user.update_user(&u).await.map_err(|e| { + anyhow!(CodeError::new_err_code_msg( + error_code::DATABASE_UPDATE_ERROR, + e.to_string() + )) + })?; + + Ok(()) + } +} diff --git a/src/service/public/user/update_user_notify_service.rs b/src/service/public/user/update_user_notify_service.rs new file mode 100644 index 00000000..dc1736fe --- /dev/null +++ b/src/service/public/user/update_user_notify_service.rs @@ -0,0 +1,60 @@ +use std::sync::Arc; + +use anyhow::anyhow; +use chrono::Utc; + +use crate::model::dto::user::UpdateUserNotifyRequest; +use crate::repository::Repositories; +use result::code_error::CodeError; +use result::error_code; + +pub struct UpdateUserNotifyService { + repos: Arc, +} + +impl UpdateUserNotifyService { + pub fn new(repos: Arc) -> Self { + Self { repos } + } + + pub async fn update_user_notify( + &self, + user_id: i64, + req: UpdateUserNotifyRequest, + ) -> Result<(), anyhow::Error> { + let mut u = self + .repos + .user + .find_one_user(user_id) + .await + .map_err(|e| { + anyhow!(CodeError::new_err_code_msg( + error_code::DATABASE_QUERY_ERROR, + e.to_string() + )) + })?; + + if let Some(v) = req.enable_balance_notify { + u.enable_balance_notify = v; + } + if let Some(v) = req.enable_login_notify { + u.enable_login_notify = v; + } + if let Some(v) = req.enable_subscribe_notify { + u.enable_subscribe_notify = v; + } + if let Some(v) = req.enable_trade_notify { + u.enable_trade_notify = v; + } + u.updated_at = Utc::now().timestamp_millis(); + + self.repos.user.update_user(&u).await.map_err(|e| { + anyhow!(CodeError::new_err_code_msg( + error_code::DATABASE_UPDATE_ERROR, + e.to_string() + )) + })?; + + Ok(()) + } +} diff --git a/src/service/public/user/update_user_password_service.rs b/src/service/public/user/update_user_password_service.rs new file mode 100644 index 00000000..ef895631 --- /dev/null +++ b/src/service/public/user/update_user_password_service.rs @@ -0,0 +1,68 @@ +use std::sync::Arc; + +use anyhow::anyhow; +use chrono::Utc; + +use crate::model::dto::user::UpdateUserPasswordRequest; +use crate::repository::Repositories; +use result::code_error::CodeError; +use result::error_code; + +pub struct UpdateUserPasswordService { + repos: Arc, +} + +impl UpdateUserPasswordService { + pub fn new(repos: Arc) -> Self { + Self { repos } + } + + pub async fn update_user_password( + &self, + user_id: i64, + req: UpdateUserPasswordRequest, + ) -> Result<(), anyhow::Error> { + let u = self + .repos + .user + .find_one_user(user_id) + .await + .map_err(|e| { + anyhow!(CodeError::new_err_code_msg( + error_code::DATABASE_QUERY_ERROR, + e.to_string() + )) + })?; + + if !password::multi_password_verify( + &u.algo, + u.salt.as_deref().unwrap_or(""), + &req.password, + &u.password, + ) { + return Err(anyhow!(CodeError::new_err_code(error_code::USER_PASSWORD_ERROR))); + } + + let new_hash = password::encode_password(&req.password).map_err(|e| { + anyhow!(CodeError::new_err_code_msg( + error_code::ERROR, + e.to_string() + )) + })?; + + let mut updated = u; + updated.password = new_hash; + updated.algo = "default".to_string(); + updated.salt = None; + updated.updated_at = Utc::now().timestamp_millis(); + + self.repos.user.update_user(&updated).await.map_err(|e| { + anyhow!(CodeError::new_err_code_msg( + error_code::DATABASE_UPDATE_ERROR, + e.to_string() + )) + })?; + + Ok(()) + } +} diff --git a/src/service/public/user/update_user_rules_service.rs b/src/service/public/user/update_user_rules_service.rs new file mode 100644 index 00000000..07c8ae42 --- /dev/null +++ b/src/service/public/user/update_user_rules_service.rs @@ -0,0 +1,49 @@ +use std::sync::Arc; + +use anyhow::anyhow; +use chrono::Utc; + +use crate::model::dto::user::UpdateUserRulesRequest; +use crate::repository::Repositories; +use result::code_error::CodeError; +use result::error_code; + +pub struct UpdateUserRulesService { + repos: Arc, +} + +impl UpdateUserRulesService { + pub fn new(repos: Arc) -> Self { + Self { repos } + } + + pub async fn update_user_rules( + &self, + user_id: i64, + req: UpdateUserRulesRequest, + ) -> Result<(), anyhow::Error> { + let mut u = self + .repos + .user + .find_one_user(user_id) + .await + .map_err(|e| { + anyhow!(CodeError::new_err_code_msg( + error_code::DATABASE_QUERY_ERROR, + e.to_string() + )) + })?; + + u.rules = Some(req.rules.join(",")); + u.updated_at = Utc::now().timestamp_millis(); + + self.repos.user.update_user(&u).await.map_err(|e| { + anyhow!(CodeError::new_err_code_msg( + error_code::DATABASE_UPDATE_ERROR, + e.to_string() + )) + })?; + + Ok(()) + } +} diff --git a/src/service/public/user/update_user_subscribe_note_service.rs b/src/service/public/user/update_user_subscribe_note_service.rs new file mode 100644 index 00000000..e39dd21f --- /dev/null +++ b/src/service/public/user/update_user_subscribe_note_service.rs @@ -0,0 +1,49 @@ +use std::sync::Arc; + +use anyhow::anyhow; +use chrono::Utc; + +use crate::model::dto::subscribe::UpdateUserSubscribeNoteRequest; +use crate::repository::Repositories; +use result::code_error::CodeError; +use result::error_code; + +pub struct UpdateUserSubscribeNoteService { + repos: Arc, +} + +impl UpdateUserSubscribeNoteService { + pub fn new(repos: Arc) -> Self { + Self { repos } + } + + pub async fn update_user_subscribe_note( + &self, + _user_id: i64, + req: UpdateUserSubscribeNoteRequest, + ) -> Result<(), anyhow::Error> { + let mut s = self + .repos + .user + .find_one_subscribe(req.user_subscribe_id) + .await + .map_err(|e| { + anyhow!(CodeError::new_err_code_msg( + error_code::DATABASE_QUERY_ERROR, + e.to_string() + )) + })?; + + s.note = req.note; + s.updated_at = Utc::now().timestamp_millis(); + + self.repos.user.update_subscribe(&s).await.map_err(|e| { + anyhow!(CodeError::new_err_code_msg( + error_code::DATABASE_UPDATE_ERROR, + e.to_string() + )) + })?; + + Ok(()) + } +} diff --git a/src/service/public/user/verify_email_service.rs b/src/service/public/user/verify_email_service.rs new file mode 100644 index 00000000..f4331a73 --- /dev/null +++ b/src/service/public/user/verify_email_service.rs @@ -0,0 +1,51 @@ +use std::sync::Arc; + +use anyhow::anyhow; +use chrono::Utc; + +use crate::model::dto::auth::VerifyEmailRequest; +use crate::model::entity::user::AuthMethods; +use crate::repository::Repositories; +use result::code_error::CodeError; +use result::error_code; + +pub struct VerifyEmailService { + repos: Arc, +} + +impl VerifyEmailService { + pub fn new(repos: Arc) -> Self { + Self { repos } + } + + pub async fn verify_email( + &self, + user_id: i64, + req: VerifyEmailRequest, + ) -> Result<(), anyhow::Error> { + let now = Utc::now().timestamp_millis(); + + let method = AuthMethods { + id: 0, + user_id, + auth_type: "email".to_string(), + auth_identifier: req.email.clone(), + verified: true, + created_at: now, + updated_at: now, + }; + + self.repos + .user + .upsert_user_auth_method(&method) + .await + .map_err(|e| { + anyhow!(CodeError::new_err_code_msg( + error_code::DATABASE_UPDATE_ERROR, + e.to_string() + )) + })?; + + Ok(()) + } +} diff --git a/src/service/server/constant.rs b/src/service/server/constant.rs new file mode 100644 index 00000000..a2fed05c --- /dev/null +++ b/src/service/server/constant.rs @@ -0,0 +1,15 @@ +pub const SHADOWSOCKS: &str = "shadowsocks"; +pub const VMESS: &str = "vmess"; +pub const VLESS: &str = "vless"; +pub const TROJAN: &str = "trojan"; +pub const ANYTLS: &str = "anytls"; +pub const TUIC: &str = "tuic"; +pub const HYSTERIA: &str = "hysteria"; +pub const HYSTERIA2: &str = "hysteria2"; + +pub const SERVER_CACHE_TTL_SECS: i64 = 60; +pub const SERVER_CONFIG_CACHE_KEY: &str = "server:config:"; +pub const SERVER_USER_LIST_CACHE_KEY: &str = "server:user_list:"; +pub const SERVER_STATUS_CACHE_KEY: &str = "server:status:"; +pub const ONLINE_USER_SUBSCRIBE_KEY: &str = "server:online:subscribe:"; +pub const ONLINE_USER_GLOBAL_KEY: &str = "server:online:global"; diff --git a/src/service/server/get_server_config_service.rs b/src/service/server/get_server_config_service.rs new file mode 100644 index 00000000..7f332a42 --- /dev/null +++ b/src/service/server/get_server_config_service.rs @@ -0,0 +1,136 @@ +use std::sync::Arc; +use anyhow::anyhow; +use base64::Engine; +use serde_json::Value; + +use crate::cache::Cache; +use crate::config::Config; +use crate::model::dto::server::{GetServerConfigResponse, ServerBasic}; +use crate::model::entity::node::Protocol; +use crate::repository::Repositories; +use crate::service::server::constant::{ + ANYTLS, HYSTERIA, HYSTERIA2, SERVER_CACHE_TTL_SECS, SERVER_CONFIG_CACHE_KEY, + SHADOWSOCKS, TROJAN, TUIC, VLESS, VMESS, +}; +use crate::service::server::meta::{generate_etag, RequestMeta, ResponseMeta}; +use result::code_error::CodeError; +use result::error_code; + +pub async fn get_server_config( + repos: Arc, + config: Arc, + cache: Arc, + server_id: i64, + protocol: &str, + meta: RequestMeta, +) -> Result<(GetServerConfigResponse, ResponseMeta), anyhow::Error> { + let mut resp_meta = ResponseMeta::new(); + let cache_key = format!("{}{server_id}:{protocol}", SERVER_CONFIG_CACHE_KEY); + + if let Ok(Some(cached)) = cache.get(&cache_key).await { + if !cached.is_empty() { + let etag = generate_etag(cached.as_bytes()); + if meta.if_none_match == etag { + return Err(anyhow::anyhow!("304 Not Modified")); + } + resp_meta.set_header("ETag", &etag); + let resp: GetServerConfigResponse = serde_json::from_str(&cached) + .map_err(|e| anyhow!("json decode cache: {e}"))?; + return Ok((resp, resp_meta)); + } + } + + let server = repos + .node + .find_one_server(server_id) + .await + .map_err(|e| anyhow!(CodeError::new_err_code_msg(error_code::DATABASE_QUERY_ERROR, e.to_string())))?; + + let proto_req = if protocol == HYSTERIA2 { HYSTERIA } else { protocol }; + + let protocols: Vec = serde_json::from_str(&server.protocols) + .map_err(|e| anyhow!("parse protocols: {e}"))?; + + let cfg = protocols + .iter() + .find(|p| p.enable && p.type_ == proto_req) + .map(build_protocol_config) + .ok_or_else(|| anyhow!("protocol {} not found or disabled", protocol))?; + + let resp = GetServerConfigResponse { + basic: ServerBasic { + push_interval: config.node.node_push_interval, + pull_interval: config.node.node_pull_interval, + }, + protocol: protocol.to_string(), + config: cfg, + }; + + let encoded = serde_json::to_string(&resp) + .map_err(|e| anyhow!("json encode: {e}"))?; + let etag = generate_etag(encoded.as_bytes()); + resp_meta.set_header("ETag", &etag); + let _ = cache.set_ex(&cache_key, &encoded, SERVER_CACHE_TTL_SECS).await; + + if meta.if_none_match == etag { + return Err(anyhow::anyhow!("304 Not Modified")); + } + + Ok((resp, resp_meta)) +} + +fn build_protocol_config(p: &Protocol) -> Value { + match p.type_.as_str() { + SHADOWSOCKS => { + let key = p.server_key.as_deref().unwrap_or_default(); + let encoded = base64::engine::general_purpose::STANDARD.encode(key.as_bytes()); + serde_json::json!({ + "port": p.port, + "method": p.cipher.as_deref().unwrap_or_default(), + "server_key": encoded, + }) + } + VLESS | VMESS | TROJAN => serde_json::json!({ + "port": p.port, + "flow": p.flow.as_deref().unwrap_or_default(), + "transport": p.transport.as_deref().unwrap_or_default(), + "transport_config": { + "path": p.path.as_deref().unwrap_or_default(), + "host": p.host.as_deref().unwrap_or_default(), + "service_name": p.service_name.as_deref().unwrap_or_default(), + "disable_sni": p.disable_sni, + "reduce_rtt": p.reduce_rtt, + "udp_relay_mode": p.udp_relay_mode.as_deref().unwrap_or_default(), + "congestion_controller": p.congestion_controller.as_deref().unwrap_or_default(), + }, + "security": p.security.as_deref().unwrap_or_default(), + "security_config": security_config_json(p), + }), + ANYTLS | TUIC => serde_json::json!({ + "port": p.port, + "security_config": security_config_json(p), + }), + HYSTERIA => serde_json::json!({ + "port": p.port, + "hop_ports": p.hop_ports.as_deref().unwrap_or_default(), + "hop_interval": p.hop_interval, + "obfs_password": p.obfs_password.as_deref().unwrap_or_default(), + "security_config": security_config_json(p), + }), + _ => serde_json::json!({}), + } +} + +fn security_config_json(p: &Protocol) -> Value { + serde_json::json!({ + "sni": p.sni.as_deref().unwrap_or_default(), + "allow_insecure": p.allow_insecure, + "fingerprint": p.fingerprint.as_deref().unwrap_or_default(), + "reality_server_addr": p.reality_server_addr.as_deref().unwrap_or_default(), + "reality_server_port": p.reality_server_port, + "reality_private_key": p.reality_private_key.as_deref().unwrap_or_default(), + "reality_public_key": p.reality_public_key.as_deref().unwrap_or_default(), + "reality_short_id": p.reality_short_id.as_deref().unwrap_or_default(), + "padding_scheme": p.padding_scheme.as_deref().unwrap_or_default(), + }) +} diff --git a/src/service/server/get_server_user_list_logic_test.rs b/src/service/server/get_server_user_list_logic_test.rs new file mode 100644 index 00000000..6b276515 --- /dev/null +++ b/src/service/server/get_server_user_list_logic_test.rs @@ -0,0 +1 @@ +// test stub diff --git a/src/service/server/get_server_user_list_service.rs b/src/service/server/get_server_user_list_service.rs new file mode 100644 index 00000000..dda419a3 --- /dev/null +++ b/src/service/server/get_server_user_list_service.rs @@ -0,0 +1,160 @@ +use std::sync::Arc; +use anyhow::anyhow; + +use crate::cache::Cache; +use crate::config::Config; +use crate::model::dto::server::{GetServerUserListResponse, ServerUser}; +use crate::model::entity::subscribe::Subscribe; +use crate::repository::node::NodeFilter; +use crate::repository::subscribe::FilterParams; +use crate::repository::Repositories; +use crate::service::server::constant::{SERVER_CACHE_TTL_SECS, SERVER_USER_LIST_CACHE_KEY}; +use crate::service::server::meta::{generate_etag, RequestMeta, ResponseMeta}; +use result::code_error::CodeError; +use result::error_code; + +pub async fn get_server_user_list( + repos: Arc, + config: Arc, + cache: Arc, + server_id: i64, + protocol: &str, + meta: RequestMeta, +) -> Result<(GetServerUserListResponse, ResponseMeta), anyhow::Error> { + let mut resp_meta = ResponseMeta::new(); + let cache_key = format!("{}{server_id}:{protocol}", SERVER_USER_LIST_CACHE_KEY); + + if let Ok(Some(cached)) = cache.get(&cache_key).await { + if !cached.is_empty() { + let etag = generate_etag(cached.as_bytes()); + if meta.if_none_match == etag { + return Err(anyhow::anyhow!("304 Not Modified")); + } + resp_meta.set_header("ETag", &etag); + let resp: GetServerUserListResponse = serde_json::from_str(&cached) + .map_err(|e| anyhow!("json decode cache: {e}"))?; + return Ok((resp, resp_meta)); + } + } + + let server = repos + .node + .find_one_server(server_id) + .await + .map_err(|e| anyhow!(CodeError::new_err_code_msg(error_code::DATABASE_QUERY_ERROR, e.to_string())))?; + + let (_, nodes) = repos + .node + .filter_node_list( + &NodeFilter { + page: 1, + size: 1000, + server_ids: vec![server.id], + protocol: Some(protocol.to_string()), + ..Default::default() + }, + false, + ) + .await + .map_err(|e| anyhow!(CodeError::new_err_code_msg(error_code::DATABASE_QUERY_ERROR, e.to_string())))?; + + let mut node_ids: Vec = Vec::new(); + let mut node_tags: Vec = Vec::new(); + for n in &nodes { + node_ids.push(n.id); + if !n.tags.is_empty() { + for tag in n.tags.split(',') { + let t = tag.trim().to_string(); + if !t.is_empty() && !node_tags.contains(&t) { + node_tags.push(t); + } + } + } + } + + let subs = query_matched_subscribes(&repos, &node_ids, &node_tags).await?; + + if subs.is_empty() { + let placeholder = placeholder_user(server_id, protocol, &config.node.node_secret); + return Ok((GetServerUserListResponse { users: vec![placeholder] }, resp_meta)); + } + + let mut users: Vec = Vec::new(); + for sub in &subs { + let _ = repos.user.activate_pending_subscribes_by_subscribe_id(sub.id).await; + let user_subs = repos + .user + .find_users_subscribe_by_subscribe_id(sub.id) + .await + .map_err(|e| anyhow!(CodeError::new_err_code_msg(error_code::DATABASE_QUERY_ERROR, e.to_string())))?; + for us in user_subs { + users.push(ServerUser { + id: us.user_id, + uuid: us.uuid, + speed_limit: sub.speed_limit, + device_limit: sub.device_limit, + }); + } + } + + if users.is_empty() { + users.push(placeholder_user(server_id, protocol, &config.node.node_secret)); + } + + let resp = GetServerUserListResponse { users }; + let encoded = serde_json::to_string(&resp).map_err(|e| anyhow!("json encode: {e}"))?; + let etag = generate_etag(encoded.as_bytes()); + resp_meta.set_header("ETag", &etag); + let _ = cache.set_ex(&cache_key, &encoded, SERVER_CACHE_TTL_SECS).await; + + if meta.if_none_match == etag { + return Err(anyhow::anyhow!("304 Not Modified")); + } + + Ok((resp, resp_meta)) +} + +async fn query_matched_subscribes( + repos: &Repositories, + node_ids: &[i64], + node_tags: &[String], +) -> Result, anyhow::Error> { + let mut seen: std::collections::HashSet = std::collections::HashSet::new(); + let mut result: Vec = Vec::new(); + + if !node_ids.is_empty() { + let mut params = FilterParams { + page: 1, + size: 9999, + nodes: node_ids.to_vec(), + ..Default::default() + }; + let (_, subs) = repos.subscribe.filter_list(&mut params).await + .map_err(|e| anyhow!("subscribe filter by nodes: {e}"))?; + for s in subs { + if seen.insert(s.id) { result.push(s); } + } + } + + if !node_tags.is_empty() { + let mut params = FilterParams { + page: 1, + size: 9999, + tags: node_tags.to_vec(), + ..Default::default() + }; + let (_, subs) = repos.subscribe.filter_list(&mut params).await + .map_err(|e| anyhow!("subscribe filter by tags: {e}"))?; + for s in subs { + if seen.insert(s.id) { result.push(s); } + } + } + + Ok(result) +} + +fn placeholder_user(server_id: i64, protocol: &str, secret: &str) -> ServerUser { + let name = format!("ppanel:server-user-placeholder:{server_id}:{}:{secret}", protocol.trim()); + let uuid = uuid::Uuid::new_v5(&uuid::Uuid::NAMESPACE_OID, name.as_bytes()); + ServerUser { id: 1, uuid: uuid.to_string(), speed_limit: 0, device_limit: 0 } +} diff --git a/src/service/server/meta.rs b/src/service/server/meta.rs new file mode 100644 index 00000000..0204add1 --- /dev/null +++ b/src/service/server/meta.rs @@ -0,0 +1,26 @@ +use std::collections::HashMap; + +#[derive(Debug, Clone, Default)] +pub struct RequestMeta { + pub if_none_match: String, +} + +#[derive(Debug, Clone, Default)] +pub struct ResponseMeta { + pub headers: HashMap, +} + +impl ResponseMeta { + pub fn new() -> Self { + Self { headers: HashMap::new() } + } + + pub fn set_header(&mut self, key: &str, value: &str) { + self.headers.insert(key.to_string(), value.to_string()); + } +} + +pub fn generate_etag(data: &[u8]) -> String { + let digest = md5::compute(data); + format!("{:x}", digest) +} diff --git a/src/service/server/mod.rs b/src/service/server/mod.rs new file mode 100644 index 00000000..629c876f --- /dev/null +++ b/src/service/server/mod.rs @@ -0,0 +1,9 @@ +pub mod constant; +pub mod get_server_config_service; +pub mod get_server_user_list_service; +pub mod get_server_user_list_logic_test; +pub mod meta; +pub mod push_online_users_service; +pub mod query_server_protocol_config_service; +pub mod server_push_status_service; +pub mod server_push_user_traffic_service; diff --git a/src/service/server/push_online_users_service.rs b/src/service/server/push_online_users_service.rs new file mode 100644 index 00000000..ad7829d4 --- /dev/null +++ b/src/service/server/push_online_users_service.rs @@ -0,0 +1,18 @@ +use std::sync::Arc; +use crate::cache::Cache; +use crate::config::Config; +use crate::model::dto::server::OnlineUsersRequest; +use crate::repository::Repositories; + +pub async fn push_online_users( + _repos: Arc, + _config: Arc, + cache: Arc, + req: OnlineUsersRequest, +) -> anyhow::Result<()> { + let key = format!("node:online:{}", req.common.server_id); + let user_ids: Vec = req.users.iter().map(|u| u.sid).collect(); + let value = serde_json::to_string(&user_ids)?; + cache.set_ex(&key, &value, 120).await?; + Ok(()) +} diff --git a/src/service/server/query_server_protocol_config_service.rs b/src/service/server/query_server_protocol_config_service.rs new file mode 100644 index 00000000..9f57e215 --- /dev/null +++ b/src/service/server/query_server_protocol_config_service.rs @@ -0,0 +1,177 @@ +use std::sync::Arc; +use anyhow::anyhow; + +use crate::config::Config; +use crate::model::dto::node::{NodeDNS, NodeOutbound}; +use crate::model::dto::protocol::Protocol as DtoProtocol; +use crate::model::dto::server::QueryServerConfigResponse; +use crate::model::entity::node::Protocol as EntityProtocol; +use crate::repository::Repositories; +use result::code_error::CodeError; +use result::error_code; + +pub async fn query_server_protocol_config( + repos: Arc, + config: Arc, + server_id: i64, + protocols_filter: Option>, +) -> Result { + let server = repos + .node + .find_one_server(server_id) + .await + .map_err(|e| anyhow!(CodeError::new_err_code_msg(error_code::DATABASE_QUERY_ERROR, e.to_string())))?; + + let entity_protocols: Vec = serde_json::from_str(&server.protocols) + .map_err(|e| anyhow!("parse protocols: {e}"))?; + + let mut protocols: Vec = entity_protocols + .iter() + .filter(|p| p.enable) + .map(entity_to_dto) + .collect(); + + if let Some(ref filter) = protocols_filter { + if !filter.is_empty() { + let set: std::collections::HashSet<&str> = filter.iter().map(|s| s.as_str()).collect(); + protocols.retain(|p| set.contains(p.type_.as_str())); + } + } + + let override_ = repos + .node + .find_override_by_server(server_id) + .await + .map_err(|e| anyhow!(CodeError::new_err_code_msg(error_code::DATABASE_QUERY_ERROR, e.to_string())))?; + + let ip_strategy = override_ + .as_ref() + .and_then(|o| o.ip_strategy.clone()) + .unwrap_or_else(|| config.node.ip_strategy.clone()); + + let dns: Vec = override_ + .as_ref() + .and_then(|o| o.dns.as_ref()) + .and_then(|s| serde_json::from_str(s).ok()) + .unwrap_or_else(|| { + config.node.dns.iter().map(|d| NodeDNS { + proto: d.proto.clone(), + address: d.address.clone(), + domains: d.domains.clone(), + }).collect() + }); + + let block: Vec = override_ + .as_ref() + .and_then(|o| o.block.as_ref()) + .and_then(|s| serde_json::from_str(s).ok()) + .unwrap_or_else(|| config.node.block.clone()); + + let outbound: Vec = override_ + .as_ref() + .and_then(|o| o.outbound.as_ref()) + .and_then(|s| serde_json::from_str(s).ok()) + .unwrap_or_else(|| { + config.node.outbound.iter().map(|o| NodeOutbound { + name: o.name.clone(), + protocol: o.protocol.clone(), + address: o.address.clone(), + port: o.port, + user: if o.user.is_empty() { None } else { Some(o.user.clone()) }, + password: o.password.clone(), + uuid: if o.uuid.is_empty() { None } else { Some(o.uuid.clone()) }, + cipher: if o.cipher.is_empty() { None } else { Some(o.cipher.clone()) }, + security: if o.security.is_empty() { None } else { Some(o.security.clone()) }, + sni: if o.sni.is_empty() { None } else { Some(o.sni.clone()) }, + allow_insecure: o.allow_insecure, + fingerprint: if o.fingerprint.is_empty() { None } else { Some(o.fingerprint.clone()) }, + transport: if o.transport.is_empty() { None } else { Some(o.transport.clone()) }, + host: if o.host.is_empty() { None } else { Some(o.host.clone()) }, + path: if o.path.is_empty() { None } else { Some(o.path.clone()) }, + service_name: if o.service_name.is_empty() { None } else { Some(o.service_name.clone()) }, + flow: if o.flow.is_empty() { None } else { Some(o.flow.clone()) }, + uot: o.uot, + uot_version: o.uot_version, + congestion_controller: if o.congestion_controller.is_empty() { None } else { Some(o.congestion_controller.clone()) }, + udp_stream: o.udp_stream, + reduce_rtt: o.reduce_rtt, + heartbeat: o.heartbeat, + reality_public_key: if o.reality_public_key.is_empty() { None } else { Some(o.reality_public_key.clone()) }, + reality_short_id: if o.reality_short_id.is_empty() { None } else { Some(o.reality_short_id.clone()) }, + spider_x: if o.spider_x.is_empty() { None } else { Some(o.spider_x.clone()) }, + settings: if o.settings.is_empty() { None } else { Some(o.settings.clone()) }, + stream_settings: if o.stream_settings.is_empty() { None } else { Some(o.stream_settings.clone()) }, + rules: o.rules.clone(), + }).collect() + }); + + let total = protocols.len() as i64; + Ok(QueryServerConfigResponse { + traffic_report_threshold: config.node.traffic_report_threshold, + push_interval: config.node.node_push_interval, + pull_interval: config.node.node_pull_interval, + ip_strategy, + dns, + block, + outbound, + protocols, + total, + }) +} + +fn entity_to_dto(p: &EntityProtocol) -> DtoProtocol { + DtoProtocol { + type_: p.type_.clone(), + port: p.port as u16, + enable: p.enable, + security: p.security.clone(), + sni: p.sni.clone(), + allow_insecure: p.allow_insecure, + fingerprint: p.fingerprint.clone(), + reality_server_addr: p.reality_server_addr.clone(), + reality_server_port: p.reality_server_port, + reality_private_key: p.reality_private_key.clone(), + reality_public_key: p.reality_public_key.clone(), + reality_short_id: p.reality_short_id.clone(), + transport: p.transport.clone(), + host: p.host.clone(), + path: p.path.clone(), + service_name: p.service_name.clone(), + cipher: p.cipher.clone(), + server_key: p.server_key.clone(), + flow: p.flow.clone(), + uot: p.uot, + uot_version: p.uot_version, + accept_proxy_protocol: p.accept_proxy_protocol, + hop_ports: p.hop_ports.clone(), + hop_interval: p.hop_interval, + obfs_password: p.obfs_password.clone(), + disable_sni: p.disable_sni, + reduce_rtt: p.reduce_rtt, + udp_relay_mode: p.udp_relay_mode.clone(), + congestion_controller: p.congestion_controller.clone(), + multiplex: p.multiplex.clone(), + padding_scheme: p.padding_scheme.clone(), + up_mbps: p.up_mbps, + down_mbps: p.down_mbps, + obfs: p.obfs.clone(), + obfs_host: p.obfs_host.clone(), + obfs_path: p.obfs_path.clone(), + xhttp_mode: p.xhttp_mode.clone(), + xhttp_extra: p.xhttp_extra.clone(), + encryption: p.encryption.clone(), + encryption_mode: p.encryption_mode.clone(), + encryption_rtt: p.encryption_rtt.clone(), + encryption_ticket: p.encryption_ticket.clone(), + encryption_server_padding: p.encryption_server_padding.clone(), + encryption_private_key: p.encryption_private_key.clone(), + encryption_client_padding: p.encryption_client_padding.clone(), + encryption_password: p.encryption_password.clone(), + ech_enable: p.ech_enable, + ech_server_name: p.ech_server_name.clone(), + ratio: p.ratio, + cert_mode: p.cert_mode.clone(), + cert_dns_provider: p.cert_dns_provider.clone(), + cert_dns_env: p.cert_dns_env.clone(), + } +} diff --git a/src/service/server/server_push_status_service.rs b/src/service/server/server_push_status_service.rs new file mode 100644 index 00000000..b1ae447a --- /dev/null +++ b/src/service/server/server_push_status_service.rs @@ -0,0 +1,19 @@ +use std::sync::Arc; +use crate::cache::Cache; +use crate::config::Config; +use crate::model::dto::server::ServerPushStatusRequest; +use crate::repository::Repositories; + +pub async fn server_push_status( + repos: Arc, + _config: Arc, + _cache: Arc, + req: ServerPushStatusRequest, +) -> anyhow::Result<()> { + let node = repos.node.find_one_node(req.common.server_id).await?; + let mut updated = node; + updated.updated_at = chrono::Utc::now().timestamp_millis(); + repos.node.update_node(&updated).await?; + tracing::debug!(server_id = req.common.server_id, cpu = req.cpu, mem = req.mem, "node push status"); + Ok(()) +} diff --git a/src/service/server/server_push_user_traffic_service.rs b/src/service/server/server_push_user_traffic_service.rs new file mode 100644 index 00000000..cf4b9c50 --- /dev/null +++ b/src/service/server/server_push_user_traffic_service.rs @@ -0,0 +1,76 @@ +/// Server push user traffic service. +/// Ported from `server/internal/logic/server/serverPushUserTrafficLogic.go`. + +use std::sync::Arc; + +use anyhow::anyhow; + +use crate::config::Config; +use crate::model::dto::server::ServerPushUserTrafficRequest; +use crate::repository::Repositories; +use crate::queue::types::FORTHWITH_TRAFFIC_STATISTICS; +use result::code_error::CodeError; +use result::error_code; + +/// Accepts a traffic report from a node and enqueues a traffic-statistics task. +pub async fn server_push_user_traffic( + repos: Arc, + config: Arc, + req: ServerPushUserTrafficRequest, +) -> Result<(), anyhow::Error> { + let server_id = req.common.server_id; + + // Verify server exists + let mut server = repos + .node + .find_one_server(server_id) + .await + .map_err(|e| anyhow!(CodeError::new_err_code_msg(error_code::DATABASE_QUERY_ERROR, e.to_string())))?; + + // Build the traffic-statistics payload (mirrors Go task.TrafficStatistics) + let payload = serde_json::json!({ + "server_id": server.id, + "protocol": req.common.protocol, + "logs": req.traffic, + }); + + let payload_bytes = serde_json::to_vec(&payload) + .map_err(|e| anyhow!("json encode traffic payload: {e}"))?; + + // Build asynq client and enqueue — mirrors queue/service/subscription.rs pattern + let redis_url = make_redis_url(&config); + match asynq::backend::RedisConnectionType::single(redis_url) { + Ok(redis_cfg) => match asynq::client::Client::new(redis_cfg).await { + Ok(client) => { + match asynq::task::Task::new(FORTHWITH_TRAFFIC_STATISTICS, &payload_bytes) { + Ok(task) => { + if let Err(e) = client.enqueue(task).await { + tracing::error!("[ServerPushUserTraffic] enqueue error: {e}"); + } + } + Err(e) => tracing::error!("[ServerPushUserTraffic] Task::new error: {e}"), + } + } + Err(e) => tracing::error!("[ServerPushUserTraffic] Client::new error: {e}"), + }, + Err(e) => tracing::error!("[ServerPushUserTraffic] redis cfg error: {e}"), + } + + // Update last_reported_at in DB (best-effort) + let now = chrono::Utc::now().timestamp(); + server.last_reported_at = Some(now); + if let Err(e) = repos.node.update_server(&server).await { + tracing::error!("[ServerPushUserTraffic] update_server error: {e}"); + } + + Ok(()) +} + +fn make_redis_url(config: &Config) -> String { + let db = config.redis.db; + if config.redis.pass.is_empty() { + format!("redis://{}/{}", config.redis.host, db) + } else { + format!("redis://:{}@{}/{}", config.redis.pass, config.redis.host, db) + } +} diff --git a/src/service/subscribe/mod.rs b/src/service/subscribe/mod.rs new file mode 100644 index 00000000..4b38b291 --- /dev/null +++ b/src/service/subscribe/mod.rs @@ -0,0 +1,2 @@ +pub mod subscribe_service; +pub mod user_agent; diff --git a/src/service/subscribe/subscribe_service.rs b/src/service/subscribe/subscribe_service.rs new file mode 100644 index 00000000..322e3590 --- /dev/null +++ b/src/service/subscribe/subscribe_service.rs @@ -0,0 +1,196 @@ +//! Subscribe content generation. +//! +//! Port of `server/internal/logic/subscribe/subscribeLogic.go`. + +use std::collections::HashMap; +use std::sync::Arc; + +use anyhow::anyhow; +use chrono::Utc; + +use crate::adapter::{Adapter, Client, ClientConfig, User as AdapterUser}; +use crate::config::Config; +use crate::repository::node::NodeFilter; +use crate::repository::Repositories; +use crate::service::subscribe::user_agent::detect_client; + +use result::code_error::CodeError; +use result::error_code; + +/// Output of a successful subscribe generation. +pub struct SubscribeOutput { + pub content: String, + pub content_type: String, + pub userinfo: String, + pub disposition: String, +} + +pub struct SubscribeService { + pub repos: Arc, + pub config: Arc, +} + +impl SubscribeService { + pub fn new(repos: Arc, config: Arc) -> Self { + Self { repos, config } + } + + /// Generate subscription content. + /// + /// `ua` — User-Agent header + /// `token` — subscribe token from URL path + /// `host` — Host header (for subscribe_url) + /// `params`— query string params passed to the template + pub async fn handle_subscribe( + &self, + ua: &str, + token: &str, + host: &str, + params: HashMap, + ) -> Result { + // 1. Load all subscribe applications (client templates). + let clients = self + .repos + .client + .list() + .await + .map_err(|e| anyhow!("load clients: {e}"))?; + + // 2. Detect client by UA. + let app = detect_client(ua, &clients); + + // 3. Validate token → user subscribe. + let user_subscribe = self + .repos + .user + .find_one_subscribe_by_token(token) + .await + .map_err(|e| { + if matches!(e, sqlx::Error::RowNotFound) { + anyhow!(CodeError::new_err_code(error_code::USER_NOT_EXIST)) + } else { + anyhow!("find subscribe by token: {e}") + } + })?; + + // 4. Load user. + let user = self + .repos + .user + .find_one_user(user_subscribe.user_id) + .await + .map_err(|e| anyhow!("find user: {e}"))?; + + // 5. Load subscribe plan. + let subscribe_plan = self + .repos + .subscribe + .find_one(user_subscribe.subscribe_id) + .await + .map_err(|e| anyhow!("find subscribe plan: {e}"))?; + + let now = Utc::now().timestamp(); + + // 6. Check expiry / traffic. + let is_expired = user_subscribe.expire_time > 0 && user_subscribe.expire_time < now; + let used = user_subscribe.upload + user_subscribe.download; + let is_traffic_exceeded = subscribe_plan.traffic > 0 && used >= subscribe_plan.traffic; + + // 7. Collect node ids. + let node_ids: Vec = if is_expired || is_traffic_exceeded { + vec![] + } else { + serde_json::from_str::>(&subscribe_plan.nodes).unwrap_or_default() + }; + + // 8. Fetch (Node, Server) pairs. + let pairs = if node_ids.is_empty() { + vec![] + } else { + let filter = NodeFilter { + node_ids: node_ids.clone(), + enabled: Some(true), + page: 1, + size: 10000, + ..Default::default() + }; + let (_, nodes) = self + .repos + .node + .filter_node_list(&filter, true) + .await + .map_err(|e| anyhow!("filter nodes: {e}"))?; + + let mut result = Vec::with_capacity(nodes.len()); + for node in nodes { + match self.repos.node.find_one_server(node.server_id).await { + Ok(server) => result.push((node, server)), + Err(e) => { + tracing::warn!(node_id = node.id, "server load failed: {e}"); + } + } + } + result + }; + + // 9. Build proxy list. + let proxies = Adapter::proxies(&pairs); + + // 10. Template + output format. + let (template, output_format) = match app { + Some(a) => ( + a.subscribe_template.clone().unwrap_or_default(), + a.output_format.clone(), + ), + None => (String::new(), "base64".into()), + }; + + // 11. Subscribe URL for AdapterUser. + let scheme = if self.config.tls.enable { "https" } else { "http" }; + let subscribe_url = format!( + "{scheme}://{host}{path}/{token}", + path = self.config.subscribe.subscribe_path + ); + + let adapter_user = AdapterUser { + password: user.password.clone(), + expired_at: user_subscribe.expire_time, + download: user_subscribe.download, + upload: user_subscribe.upload, + traffic: subscribe_plan.traffic, + subscribe_url, + }; + + // 12. Render. + let renderer = Client { + config: ClientConfig { + site_name: self.config.site.site_name.clone(), + subscribe_name: subscribe_plan.name.clone(), + output_format: output_format.clone(), + params, + }, + }; + let content = renderer + .build(&template, &proxies, &adapter_user) + .map_err(|e| anyhow!("template render: {e}"))?; + + // 13. Subscription-Userinfo header. + let userinfo = format!( + "upload={upload}; download={download}; total={total}; expire={expire}", + upload = user_subscribe.upload, + download = user_subscribe.download, + total = subscribe_plan.traffic, + expire = user_subscribe.expire_time, + ); + + let content_type = match output_format.as_str() { + "json" => "application/json; charset=utf-8".into(), + _ => "text/plain; charset=utf-8".into(), + }; + + let safe_name = subscribe_plan.name.replace('"', ""); + let disposition = format!(r#"attachment; filename="{safe_name}.yaml""#); + + Ok(SubscribeOutput { content, content_type, userinfo, disposition }) + } +} diff --git a/src/service/subscribe/user_agent.rs b/src/service/subscribe/user_agent.rs new file mode 100644 index 00000000..a9aafccf --- /dev/null +++ b/src/service/subscribe/user_agent.rs @@ -0,0 +1,74 @@ +//! User-agent detection for subscribe endpoint. +//! +//! Port of `server/internal/logic/subscribe/userAgentLogic.go`. + +use crate::model::entity::client::SubscribeApplication; + +/// Detect which [`SubscribeApplication`] matches the given `ua` string. +/// +/// Matching is case-insensitive. Stash is checked first because it may embed +/// "quantumult" in its UA string, and must not be misidentified. +pub fn detect_client<'a>(ua: &str, clients: &'a [SubscribeApplication]) -> Option<&'a SubscribeApplication> { + let ua_lower = ua.to_lowercase(); + + // Stash special-case: must be matched before Quantumult. + if ua_lower.contains("stash") { + if let Some(c) = clients.iter().find(|c| c.user_agent.to_lowercase().contains("stash")) { + return Some(c); + } + } + + // General: first client whose user_agent substring appears in UA. + clients.iter().find(|c| { + let needle = c.user_agent.to_lowercase(); + !needle.is_empty() && ua_lower.contains(needle.as_str()) + }) +} + +#[cfg(test)] +mod tests { + use super::*; + + fn make_client(name: &str, ua: &str) -> SubscribeApplication { + SubscribeApplication { + id: 0, + name: name.into(), + icon: None, + description: None, + scheme: String::new(), + user_agent: ua.into(), + is_default: false, + subscribe_template: None, + output_format: "yaml".into(), + download_link: String::new(), + created_at: 0, + updated_at: 0, + } + } + + #[test] + fn test_detect_clash() { + let clients = vec![ + make_client("Clash", "clash"), + make_client("Quantumult X", "quantumult"), + ]; + let result = detect_client("ClashforAndroid/2.5.12 okhttp/3.12.1", &clients); + assert_eq!(result.map(|c| c.name.as_str()), Some("Clash")); + } + + #[test] + fn test_stash_before_quantumult() { + let clients = vec![ + make_client("Quantumult X", "quantumult"), + make_client("Stash", "stash"), + ]; + let result = detect_client("Stash/2.4.5 like QuantumultX", &clients); + assert_eq!(result.map(|c| c.name.as_str()), Some("Stash")); + } + + #[test] + fn test_no_match() { + let clients = vec![make_client("Clash", "clash")]; + assert!(detect_client("curl/7.88.1", &clients).is_none()); + } +} diff --git a/src/service/telegram/bot.rs b/src/service/telegram/bot.rs new file mode 100644 index 00000000..7b0be43d --- /dev/null +++ b/src/service/telegram/bot.rs @@ -0,0 +1,125 @@ +//! Raw Telegram Update JSON parsing and command dispatch. +//! +//! No external bot crate — parses `serde_json::Value` directly. + +use serde::{Deserialize, Serialize}; + +/// Minimal Telegram Update (only fields we use). +#[derive(Debug, Deserialize)] +pub struct Update { + pub update_id: i64, + pub message: Option, + pub callback_query: Option, +} + +#[derive(Debug, Deserialize)] +pub struct Message { + pub message_id: i64, + pub from: Option, + pub chat: Chat, + pub text: Option, +} + +#[derive(Debug, Deserialize)] +pub struct CallbackQuery { + pub id: String, + pub from: User, + pub message: Option, + pub data: Option, +} + +#[derive(Debug, Deserialize)] +pub struct User { + pub id: i64, + pub first_name: String, + pub username: Option, +} + +#[derive(Debug, Deserialize)] +pub struct Chat { + pub id: i64, + #[serde(rename = "type")] + pub chat_type: String, +} + +/// Parsed bot command. +#[derive(Debug, PartialEq)] +pub enum BotCommand { + Start, + Bind { token: String }, + Traffic, + Unknown(String), +} + +/// Parse a `/command arg` text into a [`BotCommand`]. +pub fn parse_command(text: &str) -> BotCommand { + // Strip bot-mention suffix (e.g. `/start@MyBot`). + let text = text.trim(); + let cmd_part = text.splitn(2, '@').next().unwrap_or(text); + let mut parts = cmd_part.splitn(2, ' '); + let cmd = parts.next().unwrap_or("").to_lowercase(); + let arg = parts.next().unwrap_or("").trim().to_string(); + + match cmd.as_str() { + "/start" => BotCommand::Start, + "/bind" => BotCommand::Bind { token: arg }, + "/traffic" => BotCommand::Traffic, + other => BotCommand::Unknown(other.to_string()), + } +} + +/// Outgoing Telegram sendMessage request body. +#[derive(Debug, Serialize)] +pub struct SendMessage { + pub chat_id: i64, + pub text: String, + pub parse_mode: &'static str, +} + +impl SendMessage { + pub fn markdown(chat_id: i64, text: impl Into) -> Self { + Self { + chat_id, + text: text.into(), + parse_mode: "Markdown", + } + } +} + +/// Parse a Telegram Update from raw JSON bytes. +pub fn parse_update(body: &[u8]) -> Result { + serde_json::from_slice(body) +} + +#[cfg(test)] +mod tests { + use super::*; + + #[test] + fn test_parse_start() { + assert_eq!(parse_command("/start"), BotCommand::Start); + } + + #[test] + fn test_parse_bind() { + assert_eq!( + parse_command("/bind abc123"), + BotCommand::Bind { token: "abc123".into() } + ); + } + + #[test] + fn test_parse_bot_mention() { + assert_eq!(parse_command("/start@MyBot"), BotCommand::Start); + } + + #[test] + fn test_parse_traffic() { + assert_eq!(parse_command("/traffic"), BotCommand::Traffic); + } + + #[test] + fn test_parse_unknown() { + matches!(parse_command("/help"), BotCommand::Unknown(_)); + } +} diff --git a/src/service/telegram/mod.rs b/src/service/telegram/mod.rs new file mode 100644 index 00000000..caeb72f0 --- /dev/null +++ b/src/service/telegram/mod.rs @@ -0,0 +1,3 @@ +pub mod bot; +pub mod telegram_service; +pub mod template; diff --git a/src/service/telegram/telegram_service.rs b/src/service/telegram/telegram_service.rs new file mode 100644 index 00000000..c83f66ac --- /dev/null +++ b/src/service/telegram/telegram_service.rs @@ -0,0 +1,182 @@ +//! Telegram bot orchestration service. +//! +//! Port of `server/internal/logic/telegram/telegramLogic.go`. + +use std::sync::Arc; + +use anyhow::anyhow; + +use crate::config::Config; +use crate::model::entity::user::AuthMethods; +use crate::repository::Repositories; +use crate::service::telegram::bot::{parse_command, parse_update, BotCommand, SendMessage}; +use crate::service::telegram::template; + +pub struct TelegramService { + pub repos: Arc, + pub config: Arc, +} + +impl TelegramService { + pub fn new(repos: Arc, config: Arc) -> Self { + Self { repos, config } + } + + /// Process a raw Telegram Update payload. + pub async fn handle_update(&self, body: &[u8]) -> Result, anyhow::Error> { + let update = parse_update(body) + .map_err(|e| anyhow!("parse telegram update: {e}"))?; + + let message = match update.message { + Some(m) => m, + None => return Ok(None), + }; + + let text = match &message.text { + Some(t) => t.clone(), + None => return Ok(None), + }; + + let tg_user = match &message.from { + Some(u) => u, + None => return Ok(None), + }; + + let chat_id = message.chat.id; + let tg_id = tg_user.id; + let bot_name = self.config.telegram.bot_name.clone(); + + let reply = match parse_command(&text) { + BotCommand::Start => { + SendMessage::markdown(chat_id, template::welcome(&bot_name)) + } + + BotCommand::Bind { token } => { + if token.is_empty() { + SendMessage::markdown(chat_id, template::bind_failed("token required")) + } else { + match self.handle_bind(tg_id, &token).await { + Ok(email) => SendMessage::markdown(chat_id, template::bind_success(&email)), + Err(e) => SendMessage::markdown(chat_id, template::bind_failed(&e.to_string())), + } + } + } + + BotCommand::Traffic => { + match self.handle_traffic(tg_id).await { + Ok(msg) => SendMessage::markdown(chat_id, msg), + Err(e) => SendMessage::markdown(chat_id, template::error_msg(&e.to_string())), + } + } + + BotCommand::Unknown(_) => return Ok(None), + }; + + Ok(Some(reply)) + } + + /// Bind a Telegram account to a ppanel account via subscribe token. + async fn handle_bind(&self, tg_id: i64, token: &str) -> Result { + let user_subscribe = self + .repos + .user + .find_one_subscribe_by_token(token) + .await + .map_err(|e| anyhow!("invalid token: {e}"))?; + + let user = self + .repos + .user + .find_one_user(user_subscribe.user_id) + .await + .map_err(|e| anyhow!("user not found: {e}"))?; + + let auth = AuthMethods { + id: 0, + user_id: user.id, + auth_type: "telegram".into(), + auth_identifier: tg_id.to_string(), + verified: true, + created_at: 0, + updated_at: 0, + }; + self.repos + .user + .upsert_user_auth_method(&auth) + .await + .map_err(|e| anyhow!("upsert auth: {e}"))?; + + Ok(user.refer_code) + } + + /// Return traffic info string for a bound Telegram user. + async fn handle_traffic(&self, tg_id: i64) -> Result { + let auth = self + .repos + .user + .find_auth_method_by_open_id("telegram", &tg_id.to_string()) + .await + .map_err(|e| anyhow!("auth lookup: {e}"))?; + + let auth = match auth { + Some(a) => a, + None => return Ok(template::not_bound()), + }; + + let user = self + .repos + .user + .find_one_user(auth.user_id) + .await + .map_err(|e| anyhow!("user not found: {e}"))?; + + let subscribes = self + .repos + .user + .query_user_subscribe(user.id, &[1, 2]) + .await + .map_err(|e| anyhow!("query subscribe: {e}"))?; + + if subscribes.is_empty() { + return Ok(template::no_subscription()); + } + + let sub = &subscribes[0]; + let plan = self + .repos + .subscribe + .find_one(sub.subscribe_id) + .await + .map_err(|e| anyhow!("find plan: {e}"))?; + + Ok(template::traffic_info( + &user.refer_code, + sub.upload, + sub.download, + plan.traffic, + sub.expire_time, + )) + } + + /// Send a message via Telegram Bot API. + pub async fn send_message(&self, msg: &SendMessage) -> Result<(), anyhow::Error> { + let token = &self.config.telegram.bot_token; + if token.is_empty() { + return Ok(()); + } + let url = format!("https://api.telegram.org/bot{token}/sendMessage"); + let client = reqwest::Client::new(); + let resp = client + .post(&url) + .json(msg) + .send() + .await + .map_err(|e| anyhow!("telegram send: {e}"))?; + + if !resp.status().is_success() { + let body = resp.text().await.unwrap_or_default(); + return Err(anyhow!("telegram API error: {body}")); + } + Ok(()) + } +} diff --git a/src/service/telegram/template.rs b/src/service/telegram/template.rs new file mode 100644 index 00000000..e3a862e7 --- /dev/null +++ b/src/service/telegram/template.rs @@ -0,0 +1,100 @@ +//! Telegram bot message templates. + +use chrono::{DateTime, TimeZone, Utc}; + +fn fmt_ts(ts: i64) -> String { + match Utc.timestamp_opt(ts, 0).single() { + Some(dt) => dt.format("%Y-%m-%d %H:%M:%S UTC").to_string(), + None => "N/A".into(), + } +} + +fn fmt_bytes(bytes: i64) -> String { + const GB: f64 = 1_073_741_824.0; + const MB: f64 = 1_048_576.0; + let b = bytes as f64; + if b >= GB { + format!("{:.2} GB", b / GB) + } else if b >= MB { + format!("{:.2} MB", b / MB) + } else { + format!("{} B", bytes) + } +} + +/// `/start` welcome message. +pub fn welcome(bot_name: &str) -> String { + format!( + "👋 Welcome to *{bot_name}*!\n\nAvailable commands:\n\ + /bind — Bind your account\n\ + /traffic — Check traffic usage" + ) +} + +/// Successful bind message. +pub fn bind_success(email: &str) -> String { + format!("✅ Account *{email}* bound successfully.") +} + +/// Bind failure message. +pub fn bind_failed(reason: &str) -> String { + format!("❌ Failed to bind account: {reason}") +} + +/// Traffic usage report. +pub fn traffic_info( + email: &str, + upload: i64, + download: i64, + total: i64, + expire_time: i64, +) -> String { + let used = upload + download; + let remaining = if total > 0 { total - used } else { 0 }; + format!( + "📊 *Traffic Report*\n\ + Account: {email}\n\ + Upload: {up}\n\ + Download: {down}\n\ + Used: {used_s}\n\ + Remaining: {rem}\n\ + Total: {tot}\n\ + Expires: {exp}", + up = fmt_bytes(upload), + down = fmt_bytes(download), + used_s = fmt_bytes(used), + rem = fmt_bytes(remaining), + tot = if total > 0 { fmt_bytes(total) } else { "Unlimited".into() }, + exp = if expire_time > 0 { fmt_ts(expire_time) } else { "Never".into() }, + ) +} + +/// No active subscription message. +pub fn no_subscription() -> String { + "⚠️ No active subscription found for your account.".into() +} + +/// Not bound message. +pub fn not_bound() -> String { + "⚠️ Your Telegram account is not bound. Use /bind to bind.".into() +} + +/// Generic error message. +pub fn error_msg(msg: &str) -> String { + format!("❌ Error: {msg}") +} + +#[cfg(test)] +mod tests { + use super::*; + + #[test] + fn test_fmt_bytes_gb() { + assert!(fmt_bytes(2_000_000_000).contains("GB")); + } + + #[test] + fn test_welcome_contains_bind() { + assert!(welcome("TestBot").contains("/bind")); + } +} diff --git a/src/service/telemetry.rs b/src/service/telemetry.rs new file mode 100644 index 00000000..aa18b701 --- /dev/null +++ b/src/service/telemetry.rs @@ -0,0 +1,373 @@ +/// Telemetry facade — business audit log writer. +/// +/// Wraps all 14 `system_logs` write paths so service code never touches +/// `LogRepo` or JSON serialisation directly. Every method: +/// - Accepts plain Rust types (no JSON, no raw strings for enum values). +/// - Auto-fills `date` (YYYY-MM-DD UTC) and `created_at` (ms epoch). +/// - Calls `repos.log.insert()` and silently swallows errors via +/// `tracing::error!` so that a log failure never aborts the main flow. +use std::sync::Arc; + +use chrono::Utc; + +use crate::model::entity::log::{ + Balance, Commission, Gift, Login, LogType, Message, Register, ResetSubscribe, + ServerTraffic, ServerTrafficRank, SubscribeLog, SystemLog, TrafficStat, UserTraffic, + UserTrafficRank, +}; +use crate::repository::Repositories; + +pub struct Telemetry; + +// ─── helpers ──────────────────────────────────────────────────────────────── + +fn today() -> String { + Utc::now().format("%Y-%m-%d").to_string() +} + +fn now_ms() -> i64 { + Utc::now().timestamp_millis() +} + +async fn write(repos: &Arc, type_: LogType, object_id: i64, content: String) { + let log = SystemLog { + id: 0, + type_: type_.0, + date: Some(today()), + object_id, + content, + created_at: now_ms(), + }; + if let Err(e) = repos.log.insert(&log).await { + tracing::error!(?e, log_type = type_.0, object_id, "failed to write business audit log"); + } +} + +// ─── P0 — login / register ────────────────────────────────────────────────── + +impl Telemetry { + /// Record a login attempt (success or failure). + /// + /// Go counterpart: `Store.Log().Insert(ctx, &SystemLog{Type: LogTypeLogin, ...})` + /// in `userLoginLogic`, `deviceLoginLogic`, `oAuthLogin`. + pub async fn login( + repos: &Arc, + user_id: i64, + method: &str, + login_ip: &str, + user_agent: &str, + success: bool, + ) { + let content = Login { + method: method.to_string(), + login_ip: login_ip.to_string(), + user_agent: user_agent.to_string(), + success, + timestamp: now_ms(), + }; + match serde_json::to_string(&content) { + Ok(json) => write(repos, LogType::LOGIN, user_id, json).await, + Err(e) => tracing::error!(?e, "telemetry::login serialisation failed"), + } + } + + /// Record a successful registration. + /// + /// Go counterpart: `userRegisterLogic`, `telephoneRegister`. + pub async fn register( + repos: &Arc, + user_id: i64, + auth_method: &str, + identifier: &str, + register_ip: &str, + user_agent: &str, + ) { + let content = Register { + auth_method: auth_method.to_string(), + identifier: identifier.to_string(), + register_ip: register_ip.to_string(), + user_agent: user_agent.to_string(), + timestamp: now_ms(), + }; + match serde_json::to_string(&content) { + Ok(json) => write(repos, LogType::REGISTER, user_id, json).await, + Err(e) => tracing::error!(?e, "telemetry::register serialisation failed"), + } + } +} + +// ─── P1 — balance / commission / gift / subscribe_access ──────────────────── + +impl Telemetry { + /// Record a balance change (recharge, withdraw, payment, refund, reward, adjust). + /// + /// `type_` should be one of the `BALANCE_TYPE_*` constants from `model::entity::log`. + /// Go counterpart: `activateOrderLogic`, `purchaseLogic`, `renewalLogic`. + pub async fn balance( + repos: &Arc, + user_id: i64, + type_: i32, + amount: i64, + order_no: Option, + balance: i64, + ) { + let content = Balance { + type_, + amount, + order_no, + balance, + timestamp: now_ms(), + }; + match serde_json::to_string(&content) { + Ok(json) => write(repos, LogType::BALANCE, user_id, json).await, + Err(e) => tracing::error!(?e, "telemetry::balance serialisation failed"), + } + } + + /// Record a commission change. + /// + /// `type_` should be one of the `COMMISSION_TYPE_*` constants. + /// Go counterpart: `activateOrderLogic`, `commissionWithdrawLogic`. + pub async fn commission( + repos: &Arc, + user_id: i64, + type_: i32, + amount: i64, + order_no: &str, + ) { + let content = Commission { + type_, + amount, + order_no: order_no.to_string(), + timestamp: now_ms(), + }; + match serde_json::to_string(&content) { + Ok(json) => write(repos, LogType::COMMISSION, user_id, json).await, + Err(e) => tracing::error!(?e, "telemetry::commission serialisation failed"), + } + } + + /// Record a gift (bonus quota) change. + /// + /// `type_` should be one of the `GIFT_TYPE_*` constants. + /// Go counterpart: `purchaseLogic`, `closeOrderLogic`, `resetTrafficLogic`. + pub async fn gift( + repos: &Arc, + user_id: i64, + type_: i32, + order_no: &str, + subscribe_id: i64, + amount: i64, + balance: i64, + remark: Option, + ) { + let content = Gift { + type_, + order_no: order_no.to_string(), + subscribe_id, + amount, + balance, + remark, + timestamp: now_ms(), + }; + match serde_json::to_string(&content) { + Ok(json) => write(repos, LogType::GIFT, user_id, json).await, + Err(e) => tracing::error!(?e, "telemetry::gift serialisation failed"), + } + } + + /// Record a subscribe config access (user fetched their proxy config). + /// + /// Go counterpart: `subscribeLogic`. + pub async fn subscribe_access( + repos: &Arc, + user_subscribe_id: i64, + token: &str, + user_agent: &str, + client_ip: &str, + ) { + let content = SubscribeLog { + token: token.to_string(), + user_agent: user_agent.to_string(), + client_ip: client_ip.to_string(), + user_subscribe_id, + }; + match serde_json::to_string(&content) { + Ok(json) => write(repos, LogType::SUBSCRIBE, user_subscribe_id, json).await, + Err(e) => tracing::error!(?e, "telemetry::subscribe_access serialisation failed"), + } + } +} + +// ─── P2 — traffic / email / sms / reset_subscribe ─────────────────────────── + +impl Telemetry { + /// Record per-user-subscribe traffic statistics for a time window. + /// + /// Go counterpart: `trafficStatLogic`. + pub async fn subscribe_traffic( + repos: &Arc, + user_subscribe_id: i64, + download: i64, + upload: i64, + ) { + let content = UserTraffic { + subscribe_id: user_subscribe_id, + user_id: 0, // filled by caller if available + upload, + download, + total: upload + download, + }; + match serde_json::to_string(&content) { + Ok(json) => write(repos, LogType::SUBSCRIBE_TRAFFIC, user_subscribe_id, json).await, + Err(e) => tracing::error!(?e, "telemetry::subscribe_traffic serialisation failed"), + } + } + + /// Record per-server traffic statistics for a time window. + /// + /// Go counterpart: `trafficStatLogic`. + pub async fn server_traffic( + repos: &Arc, + server_id: i64, + download: i64, + upload: i64, + ) { + let content = ServerTraffic { + server_id, + upload, + download, + total: upload + download, + }; + match serde_json::to_string(&content) { + Ok(json) => write(repos, LogType::SERVER_TRAFFIC, server_id, json).await, + Err(e) => tracing::error!(?e, "telemetry::server_traffic serialisation failed"), + } + } + + /// Record a subscribe traffic reset event. + /// + /// `type_` should be one of the `RESET_SUBSCRIBE_TYPE_*` constants. + /// Go counterpart: `resetTrafficLogic`, `activateOrderLogic`. + pub async fn reset_subscribe( + repos: &Arc, + user_id: i64, + type_: i32, + order_no: Option, + ) { + let content = ResetSubscribe { + type_, + user_id, + order_no, + timestamp: now_ms(), + }; + match serde_json::to_string(&content) { + Ok(json) => write(repos, LogType::RESET_SUBSCRIBE, user_id, json).await, + Err(e) => tracing::error!(?e, "telemetry::reset_subscribe serialisation failed"), + } + } + + /// Record an outbound email send attempt. + /// + /// Go counterpart: `sendEmailLogic`. + pub async fn email_message( + repos: &Arc, + object_id: i64, + to: &str, + subject: Option, + content_json: serde_json::Value, + platform: &str, + template: &str, + status: i16, + ) { + let content = Message { + to: to.to_string(), + subject, + content: content_json, + platform: platform.to_string(), + template: template.to_string(), + status, + }; + match serde_json::to_string(&content) { + Ok(json) => write(repos, LogType::EMAIL_MESSAGE, object_id, json).await, + Err(e) => tracing::error!(?e, "telemetry::email_message serialisation failed"), + } + } + + /// Record an outbound SMS send attempt. + /// + /// Go counterpart: `sendSmsLogic`. + pub async fn mobile_message( + repos: &Arc, + object_id: i64, + to: &str, + content_json: serde_json::Value, + platform: &str, + template: &str, + status: i16, + ) { + let content = Message { + to: to.to_string(), + subject: None, + content: content_json, + platform: platform.to_string(), + template: template.to_string(), + status, + }; + match serde_json::to_string(&content) { + Ok(json) => write(repos, LogType::MOBILE_MESSAGE, object_id, json).await, + Err(e) => tracing::error!(?e, "telemetry::mobile_message serialisation failed"), + } + } +} + +// ─── P3 — traffic rankings / stats ────────────────────────────────────────── + +impl Telemetry { + /// Record the daily/periodic user traffic ranking snapshot. + /// + /// Go counterpart: `trafficStatLogic`. + pub async fn user_traffic_rank( + repos: &Arc, + rank: std::collections::HashMap, + ) { + let content = UserTrafficRank { rank }; + match serde_json::to_string(&content) { + Ok(json) => write(repos, LogType::USER_TRAFFIC_RANK, 0, json).await, + Err(e) => tracing::error!(?e, "telemetry::user_traffic_rank serialisation failed"), + } + } + + /// Record the daily/periodic server traffic ranking snapshot. + /// + /// Go counterpart: `trafficStatLogic`. + pub async fn server_traffic_rank( + repos: &Arc, + rank: std::collections::HashMap, + ) { + let content = ServerTrafficRank { rank }; + match serde_json::to_string(&content) { + Ok(json) => write(repos, LogType::SERVER_TRAFFIC_RANK, 0, json).await, + Err(e) => tracing::error!(?e, "telemetry::server_traffic_rank serialisation failed"), + } + } + + /// Record the aggregated traffic summary for a period. + /// + /// Go counterpart: `trafficStatLogic`. + pub async fn traffic_stat( + repos: &Arc, + upload: i64, + download: i64, + ) { + let content = TrafficStat { + upload, + download, + total: upload + download, + }; + match serde_json::to_string(&content) { + Ok(json) => write(repos, LogType::TRAFFIC_STAT, 0, json).await, + Err(e) => tracing::error!(?e, "telemetry::traffic_stat serialisation failed"), + } + } +} diff --git a/src/tracing_otel.rs b/src/tracing_otel.rs new file mode 100644 index 00000000..2a92dddb --- /dev/null +++ b/src/tracing_otel.rs @@ -0,0 +1,118 @@ +//! OpenTelemetry provider initialisation — ported from `pkg/trace/agent.go`. +//! +//! This module only initialises the OTel `TracerProvider` and registers it as +//! the global OTel provider. It does NOT touch the `tracing` subscriber — the +//! bridge layer (`tracing-opentelemetry`) is added in `main.rs` when building +//! the subscriber stack. +//! +//! Supported batchers (mirrors Go `kindJaeger` / `kindOtlpGrpc` / etc.): +//! - `"jaeger"` → OTLP gRPC (Jaeger v2 speaks native OTLP) +//! - `"otlpgrpc"` → OTLP gRPC +//! - `"otlphttp"` → OTLP HTTP +//! - `"stdout"` → pretty-print to stdout +//! - disabled / empty endpoint → no-op + +use opentelemetry::global; +use opentelemetry::trace::TracerProvider as _; +use opentelemetry_sdk::trace::{RandomIdGenerator, Sampler, TracerProvider}; +use opentelemetry_sdk::Resource; + +use crate::config::TraceConfig; + +/// Held for the process lifetime; flushes pending spans on drop. +pub struct OtelGuard { + provider: TracerProvider, +} + +impl Drop for OtelGuard { + fn drop(&mut self) { + if let Err(e) = self.provider.shutdown() { + eprintln!("[otel] shutdown error: {e}"); + } + global::shutdown_tracer_provider(); + } +} + +/// Build and install the OTel `TracerProvider`. +/// +/// Returns `None` when tracing is disabled or no endpoint is configured. +/// The caller must keep the returned `OtelGuard` alive for the process lifetime. +pub fn init_otel(cfg: &TraceConfig) -> Option { + if cfg.disabled { + return None; + } + if cfg.endpoint.is_empty() && cfg.batcher.as_str() != "stdout" { + return None; + } + + let resource = Resource::builder() + .with_service_name(cfg.name.clone()) + .build(); + + let sampler = if (cfg.sampler - 1.0_f64).abs() < f64::EPSILON { + Sampler::AlwaysOn + } else { + Sampler::TraceIdRatioBased(cfg.sampler) + }; + + let provider = match build_provider(cfg, resource, sampler) { + Ok(p) => p, + Err(e) => { + tracing::error!("[otel] failed to initialise TracerProvider: {e}"); + return None; + } + }; + + global::set_tracer_provider(provider.clone()); + tracing::info!( + batcher = %cfg.batcher, + endpoint = %cfg.endpoint, + "OpenTelemetry tracing initialised", + ); + Some(OtelGuard { provider }) +} + +// ─── private helpers ───────────────────────────────────────────────────────── + +fn build_provider( + cfg: &TraceConfig, + resource: Resource, + sampler: Sampler, +) -> anyhow::Result { + use opentelemetry_sdk::trace::BatchExporter; + + let exporter: BatchExporter = match cfg.batcher.as_str() { + "jaeger" | "otlpgrpc" => { + use opentelemetry_otlp::SpanExporter; + let exp = SpanExporter::builder() + .with_tonic() + .with_endpoint(&cfg.endpoint) + .build() + .map_err(|e| anyhow::anyhow!("otlpgrpc: {e}"))?; + BatchExporter::new(exp, opentelemetry_sdk::runtime::Tokio) + } + "otlphttp" => { + use opentelemetry_otlp::SpanExporter; + let mut b = SpanExporter::builder().with_http().with_endpoint(&cfg.endpoint); + if !cfg.otlp_headers.is_empty() { + b = b.with_headers(cfg.otlp_headers.clone()); + } + let exp = b.build().map_err(|e| anyhow::anyhow!("otlphttp: {e}"))?; + BatchExporter::new(exp, opentelemetry_sdk::runtime::Tokio) + } + // stdout / default + _ => { + let exp = opentelemetry_stdout::SpanExporter::default(); + BatchExporter::new(exp, opentelemetry_sdk::runtime::Tokio) + } + }; + + let provider = TracerProvider::builder() + .with_resource(resource) + .with_sampler(sampler) + .with_id_generator(RandomIdGenerator::default()) + .with_span_processor(exporter) + .build(); + + Ok(provider) +}