From e6100a97a31c6f0b36725c608526ac2bab5d809e Mon Sep 17 00:00:00 2001 From: lius-new Date: Tue, 3 Feb 2026 15:52:58 +0800 Subject: [PATCH 01/60] =?UTF-8?q?chore:=20=E5=88=9D=E5=A7=8B=E5=8C=96=20si?= =?UTF-8?q?mprint-server=20=E4=BB=93=E5=BA=93=20(v2)?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Co-authored-by: Cursor --- .cargo/config.toml | 2 + .dockerignore | 51 + .editorconfig | 22 + .gitignore | 35 + AGENTS.md | 237 ++++ Cargo.toml | 93 ++ Dockerfile | 38 + README.md | 103 ++ assets/secret/private_key.pem | 27 + assets/secret/public_key.pem | 8 + build_docker.py | 403 ++++++ clippy.toml | 7 + configs/config.console-gateway.dev.toml | 36 + configs/config.console-gateway.prod.toml | 35 + configs/config.console-gateway.test.toml | 35 + configs/config.dev.toml | 50 + configs/config.prod.toml | 50 + configs/config.test.toml | 48 + configs/config.update-gateway.dev.toml | 23 + configs/config.update-gateway.prod.toml | 23 + configs/config.update-gateway.test.toml | 23 + docker-compose.yml | 66 + docs/database-design.md | 109 ++ docs/public-key-endpoint-plan.md | 279 ++++ ...rkspace-architecture-refactoring-report.md | 686 +++++++++ docs/workspace-architecture.md | 884 ++++++++++++ migrations/20250101000001_create_users.sql | 29 + .../20250101000002_create_user_infos.sql | 31 + ...50101000003_create_machine_users_table.sql | 41 + ...50101000004_create_version_types_table.sql | 26 + .../20250101000005_create_versions_table.sql | 53 + ...50101000006_create_gray_releases_table.sql | 44 + ...0101000007_create_gray_resources_table.sql | 24 + ..._create_machine_gray_allocations_table.sql | 33 + ...250101000009_create_maintenances_table.sql | 33 + ...0101000010_create_strategy_types_table.sql | 31 + ...50101000011_insert_strategy_types_data.sql | 63 + migrations/20250120000001_create_teams.sql | 36 + .../20250120000002_create_team_members.sql | 39 + ...20250120000003_create_team_invitations.sql | 37 + .../20250120000004_create_login_history.sql | 29 + ...0005_alter_user_infos_add_current_team.sql | 22 + migrations/20250120000006_create_groups.sql | 39 + migrations/20250120000007_create_tags.sql | 31 + migrations/20250120000008_create_proxies.sql | 49 + ...50120000009_create_proxy_health_checks.sql | 23 + .../20250120000010_create_environments.sql | 48 + ...50120000011_create_environment_configs.sql | 33 + ...20250120000012_create_environment_tags.sql | 21 + ...20250120000013_create_environment_urls.sql | 18 + ...50120000014_create_environment_cookies.sql | 24 + .../20250120000015_create_templates.sql | 38 + ...0250120000016_create_platform_accounts.sql | 40 + ...0120000017_create_environment_accounts.sql | 22 + .../20250120000018_create_extensions.sql | 48 + .../20250120000019_create_user_extensions.sql | 28 + .../20250120000020_create_team_extensions.sql | 31 + ...20250120000021_create_group_extensions.sql | 31 + ...20000022_create_environment_extensions.sql | 28 + migrations/20250120000023_create_plans.sql | 40 + .../20250120000024_create_plan_features.sql | 25 + .../20250120000025_create_subscriptions.sql | 40 + .../20250120000026_create_user_wallets.sql | 27 + ...50120000027_create_wallet_transactions.sql | 33 + migrations/20250120000028_create_invoices.sql | 44 + .../20250120000029_create_user_quotas.sql | 31 + migrations/20250120000030_create_coupons.sql | 42 + .../20250120000031_create_coupon_usages.sql | 21 + .../20250120000032_create_payment_orders.sql | 50 + ...120000033_create_auto_renewal_services.sql | 36 + .../20250120000034_create_rpa_tasks.sql | 59 + .../20250120000035_create_rpa_task_steps.sql | 39 + ...120000036_create_rpa_task_environments.sql | 20 + .../20250120000037_create_rpa_task_runs.sql | 32 + ...50120000038_create_referral_link_tiers.sql | 31 + .../20250120000039_create_referral_links.sql | 42 + .../20250120000040_create_user_referrals.sql | 32 + ...20250120000041_create_referral_rewards.sql | 33 + ...0120000042_create_user_referral_points.sql | 28 + .../20250120000043_create_redeem_options.sql | 36 + .../20250120000044_create_redeem_records.sql | 29 + .../20250120000045_create_audit_logs.sql | 37 + migrations/20250120000046_create_api_keys.sql | 43 + ...20250120000047_create_user_preferences.sql | 26 + .../20250120000048_create_system_configs.sql | 23 + .../20250120000049_add_foreign_keys.sql | 32 + .../20250120000050_insert_default_data.sql | 37 + ...120000051_remove_icon_and_color_fields.sql | 10 + .../20250123000001_create_console_admins.sql | 28 + ...50123000002_create_console_permissions.sql | 51 + ...20250123000003_create_console_api_keys.sql | 41 + migrations/20250124000001_create_messages.sql | 148 ++ ...002_add_deleted_at_to_team_invitations.sql | 9 + .../20250125000001_create_workspaces.sql | 34 + ...20250125000002_create_workspace_quotas.sql | 43 + ...50125000003_create_proxy_visible_teams.sql | 28 + ...000004_create_group_member_permissions.sql | 45 + ...250125000005_alter_teams_add_workspace.sql | 19 + ...00006_alter_team_members_add_workspace.sql | 21 + ...50125000007_alter_groups_add_workspace.sql | 25 + ...00008_alter_environments_add_workspace.sql | 13 + ...0125000009_alter_proxies_add_workspace.sql | 29 + ...0010_alter_subscriptions_add_workspace.sql | 13 + .../20250125000011_deprecate_user_quotas.sql | 8 + .../20250125000012_migrate_to_workspaces.sql | 416 ++++++ ...alter_user_infos_add_current_workspace.sql | 44 + ...001_insert_default_plans_with_features.sql | 432 ++++++ .../20250126000002_create_user_coupons.sql | 27 + .../20250126000003_insert_default_coupons.sql | 37 + ...0127000001_add_coupon_name_description.sql | 13 + ...127000002_reset_referral_default_tiers.sql | 25 + rustfmt.toml | 17 + src/caches/mod.rs | 3 + src/caches/user.rs | 72 + src/dto/accounts.rs | 35 + src/dto/audit.rs | 26 + src/dto/billing.rs | 236 ++++ src/dto/console_admin.rs | 41 + src/dto/console_apikey.rs | 23 + src/dto/environments.rs | 281 ++++ src/dto/extensions.rs | 133 ++ src/dto/gray_releases.rs | 22 + src/dto/gray_resources.rs | 11 + src/dto/group_member_permissions.rs | 32 + src/dto/machine_gray_allocations.rs | 16 + src/dto/machine_users.rs | 21 + src/dto/maintenance.rs | 49 + src/dto/messages.rs | 60 + src/dto/mod.rs | 57 + src/dto/proxies.rs | 43 + src/dto/proxy_visible_teams.rs | 23 + src/dto/referral.rs | 153 ++ src/dto/rpa.rs | 82 ++ src/dto/strategy_types.rs | 15 + src/dto/system.rs | 49 + src/dto/teams.rs | 84 ++ src/dto/user.rs | 31 + src/dto/version_types.rs | 13 + src/dto/versions.rs | 26 + src/dto/workspace_quotas.rs | 20 + src/dto/workspaces.rs | 25 + src/entitys/accounts.rs | 77 ++ src/entitys/api_service.rs | 115 ++ src/entitys/audit.rs | 83 ++ src/entitys/billing.rs | 247 ++++ src/entitys/common.rs | 65 + src/entitys/console_admin.rs | 33 + src/entitys/console_apikey.rs | 14 + src/entitys/environments.rs | 237 ++++ src/entitys/extensions.rs | 216 +++ src/entitys/gray_releases.rs | 59 + src/entitys/gray_resources.rs | 34 + src/entitys/group_member_permissions.rs | 54 + src/entitys/groups.rs | 36 + src/entitys/machine_gray_allocations.rs | 64 + src/entitys/machine_users.rs | 97 ++ src/entitys/maintenance.rs | 48 + src/entitys/messages.rs | 71 + src/entitys/mod.rs | 67 + src/entitys/proxies.rs | 74 + src/entitys/proxy_visible_teams.rs | 50 + src/entitys/referral.rs | 139 ++ src/entitys/rpa.rs | 149 ++ src/entitys/settings.rs | 30 + src/entitys/strategy_types.rs | 20 + src/entitys/tags.rs | 28 + src/entitys/teams.rs | 130 ++ src/entitys/templates.rs | 94 ++ src/entitys/user.rs | 135 ++ src/entitys/version_types.rs | 38 + src/entitys/versions.rs | 97 ++ src/entitys/workspace_quotas.rs | 33 + src/entitys/workspaces.rs | 57 + src/errors.rs | 105 ++ src/handlers.rs | 47 + src/handlers/accounts.rs | 142 ++ src/handlers/api.rs | 79 ++ src/handlers/audit.rs | 98 ++ src/handlers/billing.rs | 407 ++++++ src/handlers/environments.rs | 665 +++++++++ src/handlers/extensions.rs | 194 +++ src/handlers/group_permissions.rs | 138 ++ src/handlers/health.rs | 15 + src/handlers/messages.rs | 133 ++ src/handlers/preferences.rs | 38 + src/handlers/proxies.rs | 167 +++ src/handlers/proxy_visibility.rs | 150 ++ src/handlers/referral.rs | 210 +++ src/handlers/rpa.rs | 219 +++ src/handlers/secret.rs | 15 + src/handlers/teams.rs | 364 +++++ src/handlers/templates.rs | 157 +++ src/handlers/time.rs | 10 + src/handlers/users.rs | 137 ++ src/handlers/workspace_quotas.rs | 33 + src/handlers/workspaces.rs | 180 +++ src/lib.rs | 46 + src/main.rs | 116 ++ src/middlewares.rs | 33 + src/middlewares/admin_permission.rs | 73 + src/middlewares/auth.rs | 104 ++ src/middlewares/cors.rs | 28 + src/middlewares/decrypt.rs | 152 ++ src/middlewares/dual_auth.rs | 141 ++ src/middlewares/encrypt.rs | 126 ++ src/middlewares/logger.rs | 67 + src/middlewares/machine_auth.rs | 70 + src/middlewares/real_ip.rs | 47 + src/models/accounts.rs | 299 ++++ src/models/admin.rs | 370 +++++ src/models/api.rs | 201 +++ src/models/apikey.rs | 179 +++ src/models/audit.rs | 234 ++++ src/models/billing.rs | 1071 ++++++++++++++ src/models/environments.rs | 1229 +++++++++++++++++ src/models/extensions.rs | 564 ++++++++ src/models/gray_releases.rs | 249 ++++ src/models/gray_resources.rs | 107 ++ src/models/group_member_permissions.rs | 197 +++ src/models/machine_gray_allocations.rs | 167 +++ src/models/machine_users.rs | 425 ++++++ src/models/maintenance.rs | 159 +++ src/models/messages.rs | 411 ++++++ src/models/mod.rs | 54 + src/models/preferences.rs | 52 + src/models/proxies.rs | 292 ++++ src/models/proxy_visible_teams.rs | 168 +++ src/models/referral.rs | 553 ++++++++ src/models/rpa.rs | 527 +++++++ src/models/strategy_types.rs | 56 + src/models/teams.rs | 835 +++++++++++ src/models/user.rs | 415 ++++++ src/models/version_types.rs | 110 ++ src/models/versions.rs | 392 ++++++ src/models/workspace_quotas.rs | 233 ++++ src/models/workspaces.rs | 121 ++ src/routes.rs | 168 +++ src/routes/accounts.rs | 37 + src/routes/api.rs | 27 + src/routes/audit.rs | 31 + src/routes/billing.rs | 108 ++ src/routes/environments.rs | 141 ++ src/routes/extensions.rs | 53 + src/routes/group_permissions.rs | 28 + src/routes/health.rs | 13 + src/routes/messages.rs | 45 + src/routes/preferences.rs | 20 + src/routes/proxies.rs | 37 + src/routes/proxy_visibility.rs | 32 + src/routes/referral.rs | 82 ++ src/routes/rpa.rs | 67 + src/routes/secret.rs | 16 + src/routes/teams.rs | 61 + src/routes/templates.rs | 40 + src/routes/time.rs | 12 + src/routes/users.rs | 50 + src/routes/workspace_quotas.rs | 20 + src/routes/workspaces.rs | 36 + src/services.rs | 68 + src/services/accounts.rs | 223 +++ src/services/api.rs | 171 +++ src/services/audit.rs | 341 +++++ src/services/billing.rs | 122 ++ src/services/coupons.rs | 171 +++ src/services/environments.rs | 981 +++++++++++++ src/services/extensions.rs | 570 ++++++++ src/services/gray_releases.rs | 285 ++++ src/services/gray_resources.rs | 119 ++ src/services/group_permissions.rs | 139 ++ src/services/groups.rs | 158 +++ src/services/machine_gray_allocations.rs | 176 +++ src/services/machine_users.rs | 281 ++++ src/services/maintenance.rs | 74 + src/services/messages.rs | 187 +++ src/services/orders.rs | 83 ++ src/services/plans.rs | 152 ++ src/services/preferences.rs | 49 + src/services/proxies.rs | 249 ++++ src/services/proxy_visibility.rs | 143 ++ src/services/referral.rs | 416 ++++++ src/services/rpa.rs | 408 ++++++ src/services/strategy_types.rs | 57 + src/services/subscriptions.rs | 275 ++++ src/services/tags.rs | 64 + src/services/teams.rs | 497 +++++++ src/services/templates.rs | 340 +++++ src/services/time.rs | 4 + src/services/users.rs | 492 +++++++ src/services/version_types.rs | 103 ++ src/services/versions.rs | 186 +++ src/services/wallet.rs | 103 ++ src/services/workspace_quotas.rs | 68 + src/services/workspaces.rs | 153 ++ src/state.rs | 77 ++ src/svc_ctx.rs | 52 + src/utils.rs | 53 + src/utils/config.rs | 113 ++ src/utils/encryption.rs | 109 ++ src/utils/extractor.rs | 43 + src/utils/gray_checker.rs | 88 ++ src/utils/jwt.rs | 59 + src/utils/minios.rs | 111 ++ src/utils/minios/get_objects.rs | 56 + src/utils/minios/put_objects.rs | 133 ++ src/utils/minios/remove_objects.rs | 13 + src/utils/password.rs | 26 + src/utils/responses.rs | 104 ++ src/utils/secret/aes.rs | 215 +++ src/utils/secret/mod.rs | 5 + src/utils/secret/rsa.rs | 172 +++ 310 files changed, 36250 insertions(+) create mode 100644 .cargo/config.toml create mode 100644 .dockerignore create mode 100644 .editorconfig create mode 100644 .gitignore create mode 100644 AGENTS.md create mode 100644 Cargo.toml create mode 100644 Dockerfile create mode 100644 README.md create mode 100644 assets/secret/private_key.pem create mode 100644 assets/secret/public_key.pem create mode 100644 build_docker.py create mode 100644 clippy.toml create mode 100644 configs/config.console-gateway.dev.toml create mode 100644 configs/config.console-gateway.prod.toml create mode 100644 configs/config.console-gateway.test.toml create mode 100644 configs/config.dev.toml create mode 100644 configs/config.prod.toml create mode 100644 configs/config.test.toml create mode 100644 configs/config.update-gateway.dev.toml create mode 100644 configs/config.update-gateway.prod.toml create mode 100644 configs/config.update-gateway.test.toml create mode 100644 docker-compose.yml create mode 100644 docs/database-design.md create mode 100644 docs/public-key-endpoint-plan.md create mode 100644 docs/workspace-architecture-refactoring-report.md create mode 100644 docs/workspace-architecture.md create mode 100644 migrations/20250101000001_create_users.sql create mode 100644 migrations/20250101000002_create_user_infos.sql create mode 100644 migrations/20250101000003_create_machine_users_table.sql create mode 100644 migrations/20250101000004_create_version_types_table.sql create mode 100644 migrations/20250101000005_create_versions_table.sql create mode 100644 migrations/20250101000006_create_gray_releases_table.sql create mode 100644 migrations/20250101000007_create_gray_resources_table.sql create mode 100644 migrations/20250101000008_create_machine_gray_allocations_table.sql create mode 100644 migrations/20250101000009_create_maintenances_table.sql create mode 100644 migrations/20250101000010_create_strategy_types_table.sql create mode 100644 migrations/20250101000011_insert_strategy_types_data.sql create mode 100644 migrations/20250120000001_create_teams.sql create mode 100644 migrations/20250120000002_create_team_members.sql create mode 100644 migrations/20250120000003_create_team_invitations.sql create mode 100644 migrations/20250120000004_create_login_history.sql create mode 100644 migrations/20250120000005_alter_user_infos_add_current_team.sql create mode 100644 migrations/20250120000006_create_groups.sql create mode 100644 migrations/20250120000007_create_tags.sql create mode 100644 migrations/20250120000008_create_proxies.sql create mode 100644 migrations/20250120000009_create_proxy_health_checks.sql create mode 100644 migrations/20250120000010_create_environments.sql create mode 100644 migrations/20250120000011_create_environment_configs.sql create mode 100644 migrations/20250120000012_create_environment_tags.sql create mode 100644 migrations/20250120000013_create_environment_urls.sql create mode 100644 migrations/20250120000014_create_environment_cookies.sql create mode 100644 migrations/20250120000015_create_templates.sql create mode 100644 migrations/20250120000016_create_platform_accounts.sql create mode 100644 migrations/20250120000017_create_environment_accounts.sql create mode 100644 migrations/20250120000018_create_extensions.sql create mode 100644 migrations/20250120000019_create_user_extensions.sql create mode 100644 migrations/20250120000020_create_team_extensions.sql create mode 100644 migrations/20250120000021_create_group_extensions.sql create mode 100644 migrations/20250120000022_create_environment_extensions.sql create mode 100644 migrations/20250120000023_create_plans.sql create mode 100644 migrations/20250120000024_create_plan_features.sql create mode 100644 migrations/20250120000025_create_subscriptions.sql create mode 100644 migrations/20250120000026_create_user_wallets.sql create mode 100644 migrations/20250120000027_create_wallet_transactions.sql create mode 100644 migrations/20250120000028_create_invoices.sql create mode 100644 migrations/20250120000029_create_user_quotas.sql create mode 100644 migrations/20250120000030_create_coupons.sql create mode 100644 migrations/20250120000031_create_coupon_usages.sql create mode 100644 migrations/20250120000032_create_payment_orders.sql create mode 100644 migrations/20250120000033_create_auto_renewal_services.sql create mode 100644 migrations/20250120000034_create_rpa_tasks.sql create mode 100644 migrations/20250120000035_create_rpa_task_steps.sql create mode 100644 migrations/20250120000036_create_rpa_task_environments.sql create mode 100644 migrations/20250120000037_create_rpa_task_runs.sql create mode 100644 migrations/20250120000038_create_referral_link_tiers.sql create mode 100644 migrations/20250120000039_create_referral_links.sql create mode 100644 migrations/20250120000040_create_user_referrals.sql create mode 100644 migrations/20250120000041_create_referral_rewards.sql create mode 100644 migrations/20250120000042_create_user_referral_points.sql create mode 100644 migrations/20250120000043_create_redeem_options.sql create mode 100644 migrations/20250120000044_create_redeem_records.sql create mode 100644 migrations/20250120000045_create_audit_logs.sql create mode 100644 migrations/20250120000046_create_api_keys.sql create mode 100644 migrations/20250120000047_create_user_preferences.sql create mode 100644 migrations/20250120000048_create_system_configs.sql create mode 100644 migrations/20250120000049_add_foreign_keys.sql create mode 100644 migrations/20250120000050_insert_default_data.sql create mode 100644 migrations/20250120000051_remove_icon_and_color_fields.sql create mode 100644 migrations/20250123000001_create_console_admins.sql create mode 100644 migrations/20250123000002_create_console_permissions.sql create mode 100644 migrations/20250123000003_create_console_api_keys.sql create mode 100644 migrations/20250124000001_create_messages.sql create mode 100644 migrations/20250124000002_add_deleted_at_to_team_invitations.sql create mode 100644 migrations/20250125000001_create_workspaces.sql create mode 100644 migrations/20250125000002_create_workspace_quotas.sql create mode 100644 migrations/20250125000003_create_proxy_visible_teams.sql create mode 100644 migrations/20250125000004_create_group_member_permissions.sql create mode 100644 migrations/20250125000005_alter_teams_add_workspace.sql create mode 100644 migrations/20250125000006_alter_team_members_add_workspace.sql create mode 100644 migrations/20250125000007_alter_groups_add_workspace.sql create mode 100644 migrations/20250125000008_alter_environments_add_workspace.sql create mode 100644 migrations/20250125000009_alter_proxies_add_workspace.sql create mode 100644 migrations/20250125000010_alter_subscriptions_add_workspace.sql create mode 100644 migrations/20250125000011_deprecate_user_quotas.sql create mode 100644 migrations/20250125000012_migrate_to_workspaces.sql create mode 100644 migrations/20250125000013_alter_user_infos_add_current_workspace.sql create mode 100644 migrations/20250126000001_insert_default_plans_with_features.sql create mode 100644 migrations/20250126000002_create_user_coupons.sql create mode 100644 migrations/20250126000003_insert_default_coupons.sql create mode 100644 migrations/20250127000001_add_coupon_name_description.sql create mode 100644 migrations/20250127000002_reset_referral_default_tiers.sql create mode 100644 rustfmt.toml create mode 100644 src/caches/mod.rs create mode 100644 src/caches/user.rs create mode 100644 src/dto/accounts.rs create mode 100644 src/dto/audit.rs create mode 100644 src/dto/billing.rs create mode 100644 src/dto/console_admin.rs create mode 100644 src/dto/console_apikey.rs create mode 100644 src/dto/environments.rs create mode 100644 src/dto/extensions.rs create mode 100644 src/dto/gray_releases.rs create mode 100644 src/dto/gray_resources.rs create mode 100644 src/dto/group_member_permissions.rs create mode 100644 src/dto/machine_gray_allocations.rs create mode 100644 src/dto/machine_users.rs create mode 100644 src/dto/maintenance.rs create mode 100644 src/dto/messages.rs create mode 100644 src/dto/mod.rs create mode 100644 src/dto/proxies.rs create mode 100644 src/dto/proxy_visible_teams.rs create mode 100644 src/dto/referral.rs create mode 100644 src/dto/rpa.rs create mode 100644 src/dto/strategy_types.rs create mode 100644 src/dto/system.rs create mode 100644 src/dto/teams.rs create mode 100644 src/dto/user.rs create mode 100644 src/dto/version_types.rs create mode 100644 src/dto/versions.rs create mode 100644 src/dto/workspace_quotas.rs create mode 100644 src/dto/workspaces.rs create mode 100644 src/entitys/accounts.rs create mode 100644 src/entitys/api_service.rs create mode 100644 src/entitys/audit.rs create mode 100644 src/entitys/billing.rs create mode 100644 src/entitys/common.rs create mode 100644 src/entitys/console_admin.rs create mode 100644 src/entitys/console_apikey.rs create mode 100644 src/entitys/environments.rs create mode 100644 src/entitys/extensions.rs create mode 100644 src/entitys/gray_releases.rs create mode 100644 src/entitys/gray_resources.rs create mode 100644 src/entitys/group_member_permissions.rs create mode 100644 src/entitys/groups.rs create mode 100644 src/entitys/machine_gray_allocations.rs create mode 100644 src/entitys/machine_users.rs create mode 100644 src/entitys/maintenance.rs create mode 100644 src/entitys/messages.rs create mode 100644 src/entitys/mod.rs create mode 100644 src/entitys/proxies.rs create mode 100644 src/entitys/proxy_visible_teams.rs create mode 100644 src/entitys/referral.rs create mode 100644 src/entitys/rpa.rs create mode 100644 src/entitys/settings.rs create mode 100644 src/entitys/strategy_types.rs create mode 100644 src/entitys/tags.rs create mode 100644 src/entitys/teams.rs create mode 100644 src/entitys/templates.rs create mode 100644 src/entitys/user.rs create mode 100644 src/entitys/version_types.rs create mode 100644 src/entitys/versions.rs create mode 100644 src/entitys/workspace_quotas.rs create mode 100644 src/entitys/workspaces.rs create mode 100644 src/errors.rs create mode 100644 src/handlers.rs create mode 100644 src/handlers/accounts.rs create mode 100644 src/handlers/api.rs create mode 100644 src/handlers/audit.rs create mode 100644 src/handlers/billing.rs create mode 100644 src/handlers/environments.rs create mode 100644 src/handlers/extensions.rs create mode 100644 src/handlers/group_permissions.rs create mode 100644 src/handlers/health.rs create mode 100644 src/handlers/messages.rs create mode 100644 src/handlers/preferences.rs create mode 100644 src/handlers/proxies.rs create mode 100644 src/handlers/proxy_visibility.rs create mode 100644 src/handlers/referral.rs create mode 100644 src/handlers/rpa.rs create mode 100644 src/handlers/secret.rs create mode 100644 src/handlers/teams.rs create mode 100644 src/handlers/templates.rs create mode 100644 src/handlers/time.rs create mode 100644 src/handlers/users.rs create mode 100644 src/handlers/workspace_quotas.rs create mode 100644 src/handlers/workspaces.rs create mode 100644 src/lib.rs create mode 100644 src/main.rs create mode 100644 src/middlewares.rs create mode 100644 src/middlewares/admin_permission.rs create mode 100644 src/middlewares/auth.rs create mode 100644 src/middlewares/cors.rs create mode 100644 src/middlewares/decrypt.rs create mode 100644 src/middlewares/dual_auth.rs create mode 100644 src/middlewares/encrypt.rs create mode 100644 src/middlewares/logger.rs create mode 100644 src/middlewares/machine_auth.rs create mode 100644 src/middlewares/real_ip.rs create mode 100644 src/models/accounts.rs create mode 100644 src/models/admin.rs create mode 100644 src/models/api.rs create mode 100644 src/models/apikey.rs create mode 100644 src/models/audit.rs create mode 100644 src/models/billing.rs create mode 100644 src/models/environments.rs create mode 100644 src/models/extensions.rs create mode 100644 src/models/gray_releases.rs create mode 100644 src/models/gray_resources.rs create mode 100644 src/models/group_member_permissions.rs create mode 100644 src/models/machine_gray_allocations.rs create mode 100644 src/models/machine_users.rs create mode 100644 src/models/maintenance.rs create mode 100644 src/models/messages.rs create mode 100644 src/models/mod.rs create mode 100644 src/models/preferences.rs create mode 100644 src/models/proxies.rs create mode 100644 src/models/proxy_visible_teams.rs create mode 100644 src/models/referral.rs create mode 100644 src/models/rpa.rs create mode 100644 src/models/strategy_types.rs create mode 100644 src/models/teams.rs create mode 100644 src/models/user.rs create mode 100644 src/models/version_types.rs create mode 100644 src/models/versions.rs create mode 100644 src/models/workspace_quotas.rs create mode 100644 src/models/workspaces.rs create mode 100644 src/routes.rs create mode 100644 src/routes/accounts.rs create mode 100644 src/routes/api.rs create mode 100644 src/routes/audit.rs create mode 100644 src/routes/billing.rs create mode 100644 src/routes/environments.rs create mode 100644 src/routes/extensions.rs create mode 100644 src/routes/group_permissions.rs create mode 100644 src/routes/health.rs create mode 100644 src/routes/messages.rs create mode 100644 src/routes/preferences.rs create mode 100644 src/routes/proxies.rs create mode 100644 src/routes/proxy_visibility.rs create mode 100644 src/routes/referral.rs create mode 100644 src/routes/rpa.rs create mode 100644 src/routes/secret.rs create mode 100644 src/routes/teams.rs create mode 100644 src/routes/templates.rs create mode 100644 src/routes/time.rs create mode 100644 src/routes/users.rs create mode 100644 src/routes/workspace_quotas.rs create mode 100644 src/routes/workspaces.rs create mode 100644 src/services.rs create mode 100644 src/services/accounts.rs create mode 100644 src/services/api.rs create mode 100644 src/services/audit.rs create mode 100644 src/services/billing.rs create mode 100644 src/services/coupons.rs create mode 100644 src/services/environments.rs create mode 100644 src/services/extensions.rs create mode 100644 src/services/gray_releases.rs create mode 100644 src/services/gray_resources.rs create mode 100644 src/services/group_permissions.rs create mode 100644 src/services/groups.rs create mode 100644 src/services/machine_gray_allocations.rs create mode 100644 src/services/machine_users.rs create mode 100644 src/services/maintenance.rs create mode 100644 src/services/messages.rs create mode 100644 src/services/orders.rs create mode 100644 src/services/plans.rs create mode 100644 src/services/preferences.rs create mode 100644 src/services/proxies.rs create mode 100644 src/services/proxy_visibility.rs create mode 100644 src/services/referral.rs create mode 100644 src/services/rpa.rs create mode 100644 src/services/strategy_types.rs create mode 100644 src/services/subscriptions.rs create mode 100644 src/services/tags.rs create mode 100644 src/services/teams.rs create mode 100644 src/services/templates.rs create mode 100644 src/services/time.rs create mode 100644 src/services/users.rs create mode 100644 src/services/version_types.rs create mode 100644 src/services/versions.rs create mode 100644 src/services/wallet.rs create mode 100644 src/services/workspace_quotas.rs create mode 100644 src/services/workspaces.rs create mode 100644 src/state.rs create mode 100644 src/svc_ctx.rs create mode 100644 src/utils.rs create mode 100644 src/utils/config.rs create mode 100644 src/utils/encryption.rs create mode 100644 src/utils/extractor.rs create mode 100644 src/utils/gray_checker.rs create mode 100644 src/utils/jwt.rs create mode 100644 src/utils/minios.rs create mode 100644 src/utils/minios/get_objects.rs create mode 100644 src/utils/minios/put_objects.rs create mode 100644 src/utils/minios/remove_objects.rs create mode 100644 src/utils/password.rs create mode 100644 src/utils/responses.rs create mode 100644 src/utils/secret/aes.rs create mode 100644 src/utils/secret/mod.rs create mode 100644 src/utils/secret/rsa.rs diff --git a/.cargo/config.toml b/.cargo/config.toml new file mode 100644 index 0000000..ac5c657 --- /dev/null +++ b/.cargo/config.toml @@ -0,0 +1,2 @@ +[target.x86_64-pc-windows-msvc] +linker = "rust-lld" \ No newline at end of file diff --git a/.dockerignore b/.dockerignore new file mode 100644 index 0000000..a9a5c4e --- /dev/null +++ b/.dockerignore @@ -0,0 +1,51 @@ +# Git +.git +.gitignore + +# Rust +target/ +**/*.rs.bk +*.pdb +Cargo.lock + +# IDE +.vscode/ +.idea/ +*.swp +*.swo +*~ + +# OS +.DS_Store +Thumbs.db + +# 日志文件 +*.log + +# 数据库文件 +*.db +*.sqlite +*.sqlite3 + +# Docker +Dockerfile +docker-compose.yml +.dockerignore + +# 文档 +README.md +AGENTS.md +docs/ + +# 测试和开发文件 +*.test.toml +*.dev.toml + + + + + + + + + diff --git a/.editorconfig b/.editorconfig new file mode 100644 index 0000000..c52266d --- /dev/null +++ b/.editorconfig @@ -0,0 +1,22 @@ +root = true + +[*] +charset = utf-8 +end_of_line = lf +insert_final_newline = true +trim_trailing_whitespace = true +indent_style = space +indent_size = 4 + +[*.{json,yml,yaml,toml}] +indent_size = 2 + +[*.md] +trim_trailing_whitespace = false + +[*.rs] +indent_size = 4 + + + + diff --git a/.gitignore b/.gitignore new file mode 100644 index 0000000..cbd0941 --- /dev/null +++ b/.gitignore @@ -0,0 +1,35 @@ +# Rust 编译输出 +/target/ +**/*.rs.bk +*.pdb + +# Cargo +Cargo.lock + +# IDE +.vscode/ +.idea/ +*.swp +*.swo +*~ + +# OS +.DS_Store +Thumbs.db + +# 日志文件 +*.log + +# 数据库文件(如果使用本地数据库) +*.db +*.sqlite +*.sqlite3 + +# 编译后的二进制文件 +console-gateway +simprint-server +update-gateway + +# Docker 镜像文件 +simprint-server-docker-*.tar.gz + diff --git a/AGENTS.md b/AGENTS.md new file mode 100644 index 0000000..e0b0bc7 --- /dev/null +++ b/AGENTS.md @@ -0,0 +1,237 @@ +# Simprint Server 开发约束与规范 + +本文档定义了 Simprint Server 项目的开发约束、代码风格和最佳实践。 + +## 1. 数据库迁移文件命名规范 + +### 1.1 迁移文件创建命令 + +使用 `sqlx migrate add` 命令创建数据库迁移文件,命名格式如下: + +```bash +# 创建表 +sqlx migrate add create_{table_name} + +# 删除表 +sqlx migrate add delete_{table_name} + +# 插入数据 +sqlx migrate add insert_{table_name} +``` + +### 1.2 命名示例 + +- 创建 users 表:`sqlx migrate add create_users` +- 删除 users 表:`sqlx migrate add delete_users` +- 插入初始数据:`sqlx migrate add insert_initial_data` + +### 1.3 迁移文件位置 + +所有迁移文件应位于 `migrations/` 目录下,文件名格式为: + +``` +{timestamp}_{migration_name}.sql +``` + +## 2. 代码风格与格式化 + +### 2.1 Rustfmt 配置 + +项目使用 `rustfmt` 进行代码格式化,配置文件为 `rustfmt.toml`。 + +**格式化命令**: + +```bash +cargo fmt +``` + +**检查格式**: + +```bash +cargo fmt -- --check +``` + +### 2.2 Clippy Linting + +项目使用 `clippy` 进行代码静态分析和 linting,配置文件为 `clippy.toml`。 + +**运行 Clippy**: + +```bash +cargo clippy +``` + +**自动修复**: + +```bash +cargo clippy --fix +``` + +### 2.3 代码风格约定 + +- **函数和变量**:使用 `snake_case` +- **类型和结构体**:使用 `PascalCase` +- **常量**:使用 `SCREAMING_SNAKE_CASE` +- **模块**:使用 `snake_case` + +## 3. 编译配置 + +### 3.1 编译选项 + +项目在 `Cargo.toml` 中配置了以下编译选项: + +- **Edition**: Rust 2021 +- **Max Width**: 100 字符 +- **Tab Spaces**: 4 个空格 +- **Newline Style**: Unix (LF) + +### 3.2 编译命令 + +```bash +# 检查代码(不编译) +cargo check + +# 编译项目 +cargo build + +# 编译发布版本 +cargo build --release + +# 运行项目 +cargo run +``` + +## 4. 错误处理规范 + +- 优先使用 `Result` 进行错误处理 +- 使用 `thiserror` 或 `anyhow` 库进行错误处理(如需要) +- 避免使用 `unwrap()` 和 `expect()`,除非在测试代码中或确定不会失败的情况 + +## 5. 文档规范 + +- 所有公共 API 应使用文档注释(`///`) +- 复杂函数应添加文档说明参数和返回值 +- 使用 `cargo doc` 生成项目文档 + +## 6. 测试规范 + +- 单元测试放在与被测试代码相同的文件中 +- 集成测试放在 `tests/` 目录下 +- 使用 `cargo test` 运行所有测试 + +## 7. 提交前检查清单 + +在提交代码前,确保: + +- [ ] 代码已通过 `cargo fmt` 格式化 +- [ ] 代码已通过 `cargo clippy` 检查,无警告 +- [ ] 所有测试通过(`cargo test`) +- [ ] 迁移文件命名符合规范 +- [ ] 公共 API 都有适当的文档注释 + +## 8. 其他说明 + +- 所有数据库对应类型的映射需要放到dto目录中 +- 所有数据库操作的功能需要放到models目录中 +- 具体功能实现和调用models层或者其他业务代码都应该放到services目录中 +- 在handlers中接收的json参数定义应该放到entitys中,参数通常可以直接传递到services,然后到models层,下面是参数的示例: + + ``` + // entitys/{file_name}.rs + #[derive(Deserialize, Serialize, Debug, Default)] + pub struct Pagination { + pub page: i64, + pub page_size: i64, + } + + // handlers/{file_name}.rs + use crate::utils::Json; + use crate::state::CurrentUser; + + // JSON参数: Json(payload): Json + // user_uuid参数: Extension(current_user): Extension, + pub async fn list(Json(payload): Json) /* TODO: */ { + // TODO: + } + ``` + +- 通常都是post请求,且都是json参数 +- models 的实现示例如下: + + ``` + use sqlx::{Error, Pool, Sqlite}; + + pub async fn insert_user( + pool: &Pool, + payload: InsertUserRequestPayload + ) -> Result { + let mut tx = pool.begin().await?; + + let user_uuid: Uuid = sqlx::query_scalar("insert into users default values returning uuid;") + .fetch_one(&mut *tx) + .await?; + + sqlx::query("insert into user_infos (phone, user_uuid) values($1, $2) returning id;") + .bind(payload.phone) + .bind(user_uuid) + .execute(&mut *tx) + .await?; + + sqlx::query("insert into user_passwords (password, user_uuid) values($1, $2) returning id;") + .bind(payload.password) + .bind(user_uuid) + .execute(&mut *tx) + .await?; + + sqlx::query("insert into user_avatars (resource_hash, user_uuid) values($1, $2) returning id;") + .bind(payload.default_avatar) + .bind(user_uuid) + .execute(&mut *tx) + .await?; + + tx.commit().await?; + + Ok(user_uuid) + } + + pub async fn fetch_user_balance( + pool: &Pool, + user_uuid: Uuid, + ) -> Result, Error> { + let rec = sqlx::query_scalar( + r#" + SELECT balance FROM user_balance + WHERE user_uuid = $1 AND deleted_at IS NULL + "#, + ) + .bind(user_uuid) + .fetch_optional(pool) + .await?; + + Ok(rec) + } + ``` + +- sqlx的相关数据库操作通常不使用宏。 +- 缓存相关示例: + + ``` + pub(crate) const REGISTER_CODE_CACHE_KEY: &str = "verification:code:register:send"; + pub(crate) const CODE_EXPIRATION: u64 = 60 * 3; + + /// 设置用户注册的验证码 + pub async fn set_register_code( + svc_ctx: &SvcCtx, + phone: &str, + code: &str, + ) -> Result<(), anyhow::Error> { + let key = format!("{}:{}", REGISTER_CODE_CACHE_KEY, phone); + let _: () = svc_ctx + .redis + .clone() + .set_ex(key, code, CODE_EXPIRATION) + .await?; + Ok(()) + } + + ``` diff --git a/Cargo.toml b/Cargo.toml new file mode 100644 index 0000000..5f035d2 --- /dev/null +++ b/Cargo.toml @@ -0,0 +1,93 @@ +[package] +name = "simprint-server" +version = "0.1.0" +edition = "2024" + +[lib] +name = "simprint_server" +path = "src/lib.rs" + +[dependencies] +axum = { version = "0.8", features = ["macros", "multipart", "ws"] } +tower = "0.5.2" +tower-http = { version = "0.6", features = ["cors", "default", "fs"] } +tokio = { version = "1", features = [ + "macros", + "rt-multi-thread", + "sync", + "io-util", +] } +tokio-stream = "0.1" +tokio-tungstenite = "0.26.2" +tokio-util = { version = "0.7" } +serde = { version = "^1.0.217", features = ["derive"] } +serde_json = "^1" +base64 = "0.22.1" +mio = "1.0.1" +thiserror = "2" +anyhow = "1.0.93" +chrono = { version = "0.4", features = ["serde"] } +rand = "0.9" +rand_chacha = "0.9" +md5 = "0.8" +jwt = "0.16.0" +argon2 = "0.5.3" +aes-gcm = "0.10.3" +rsa = { version = "0.9.8", features = ["sha2"] } +sha2 = "0.10.8" +sha1 = "0.10.6" +hmac = "0.12.1" +hex = "0.4" +pkcs1 = "0.7" +pkcs8 = "0.10" +pem = "3.0" +config = "0.15" +tracing = "0.1" +tracing-subscriber = { version = "0.3", features = ["env-filter"] } +tracing-appender = "=0.2" +once_cell = "1.20.2" +sqlx = { version = "0.8", features = [ + "sqlite", + "mysql", + "postgres", + "runtime-tokio", + "chrono", + "json", + "uuid", + "ipnetwork", + "rust_decimal", + "bigdecimal", +] } +redis = { version = "0.31.0", features = [ + "tokio-comp", + "json", + "tokio-native-tls-comp", + "connection-manager", +] } +uuid = { version = "1.11.0", features = [ + "v4", + "fast-rng", + "macro-diagnostics", + "serde", +] } +regex = "1.5.4" +lettre = "0.11" +bytes = "1.10.0" +dashmap = "6.1.0" +rcon = { version = "0.6.0", features = ["rt-tokio", "rt-async-std"] } +tungstenite = "0.26.2" +minio = "0.3.0" +textnonce = "1.0.0" +url = "2.5.4" +urlencoding = "2.1.3" +reqwest = { version = "0.12", features = ["json"] } +lazy_static = "1.5" +rust_decimal = { version = "1.39.0", features = [ + "serde-with-str", + "db-postgres", +] } +clap = { version = "4.0", features = ["derive"] } + +[profile.dev] +incremental = false +debug = 1 diff --git a/Dockerfile b/Dockerfile new file mode 100644 index 0000000..efe7010 --- /dev/null +++ b/Dockerfile @@ -0,0 +1,38 @@ +# 运行时镜像 +FROM debian:bookworm-slim + +# 临时禁用 APT 的 GPG 验证以解决密钥环问题 +# 安装运行时依赖(这些包来自官方 Debian 仓库) +RUN echo 'Acquire::AllowInsecureRepositories "true";' > /etc/apt/apt.conf.d/99allow-insecure \ + && echo 'APT::Get::AllowUnauthenticated "true";' >> /etc/apt/apt.conf.d/99allow-insecure \ + && apt-get update --allow-releaseinfo-change \ + && apt-get install -y --no-install-recommends \ + ca-certificates \ + libssl3 \ + && rm -f /etc/apt/apt.conf.d/99allow-insecure \ + && rm -rf /var/lib/apt/lists/* + +# 创建应用用户 +RUN useradd -m -u 1000 appuser + +# 设置工作目录 +WORKDIR /app + +# 构建参数:指定要复制的二进制文件名 +ARG BINARY_NAME + +# 从本地根目录复制已编译的二进制文件到固定路径 +COPY --chown=appuser:appuser ${BINARY_NAME} /app/app + +# 复制配置文件目录和资源文件 +COPY --chown=appuser:appuser configs ./configs +COPY --chown=appuser:appuser assets ./assets + +# 设置文件权限 +RUN chmod +x /app/app + +# 切换到非 root 用户 +USER appuser + +# 统一入口,配置文件路径通过 docker-compose 的 command 参数传入 +ENTRYPOINT ["/app/app"] diff --git a/README.md b/README.md new file mode 100644 index 0000000..bc20c26 --- /dev/null +++ b/README.md @@ -0,0 +1,103 @@ +# Simprint Server + +Simprint 应用的后端服务系统。 + +## 项目简介 + +Simprint Server 是 Simprint 桌面应用的后端服务,提供用户认证、数据管理、版本更新等核心功能支持。 + +## 使用示例 + +### 启动服务 + +服务需要通过配置文件启动,配置文件路径通过命令行参数指定: + +```bash +./simprint-server -f=configs/config.dev.toml +``` + +### 服务入口 + +Simprint Server 提供三种服务入口: + +- **客户端网关**:为桌面应用提供基础服务接口 +- **控制台网关**:为管理后台提供管理功能接口 +- **更新网关**:为应用更新提供版本检查接口 + +> **注意**:详细的使用示例和 API 文档将在后续版本中添加。 + +## 贡献指南 + +我们欢迎所有形式的贡献!如果您想为 Simprint Server 项目做出贡献,请遵循以下步骤: + +1. **Fork 项目**:在 GitHub 上 Fork 本项目 +2. **创建分支**:从 `master` 分支创建新的功能分支 +3. **提交更改**:进行代码修改并提交,遵循项目的代码规范 +4. **推送分支**:将更改推送到您的 Fork +5. **创建 Pull Request**:向主项目提交 Pull Request + +### 代码规范 + +- 遵循 Rust 代码风格和格式化规范 +- 提交前运行 `cargo fmt` 和 `cargo clippy` +- 确保所有测试通过 +- 数据库迁移文件命名遵循规范 +- 添加适当的文档注释 + +更多详细信息请参考项目目录下的 `AGENTS.md` 文件。 + +## 许可证信息 + +本项目采用私有许可证,未经授权不得使用、复制、修改或分发。 + +## 故障排除 + +### 常见问题 + +**Q: 服务无法启动?** + +A: 请检查以下事项: + +- 确保配置文件路径正确 +- 检查配置文件格式是否正确 +- 确保数据库连接配置正确 +- 检查端口是否被占用 +- 查看服务日志获取详细错误信息 + +**Q: 数据库连接失败?** + +A: 请检查: + +- 数据库服务是否正在运行 +- 数据库连接字符串是否正确 +- 数据库用户权限是否足够 +- 网络连接是否正常 + +**Q: Redis 连接失败?** + +A: 请检查: + +- Redis 服务是否正在运行 +- Redis 连接 URL 是否正确 +- Redis 认证信息是否正确 + +**Q: 对象存储服务异常?** + +A: 可能的原因: + +- 对象存储服务未启动 +- 访问密钥配置错误 +- 网络连接问题 +- 存储桶不存在或权限不足 + +**Q: 如何查看服务日志?** + +A: 服务日志会输出到控制台,生产环境建议配置日志文件输出。 + +### 获取帮助 + +如果以上方法无法解决您的问题,请: + +- 查看项目的 Issue 列表,看是否有类似问题 +- 创建新的 Issue 描述您的问题 +- 联系项目维护者 diff --git a/assets/secret/private_key.pem b/assets/secret/private_key.pem new file mode 100644 index 0000000..f5c4974 --- /dev/null +++ b/assets/secret/private_key.pem @@ -0,0 +1,27 @@ +-----BEGIN RSA PRIVATE KEY----- +MIIEpQIBAAKCAQEArd/zJ0+jxE+q7YV8nNIjfMBtu7wSYbIjUa2FnuxwbxVuDLZP +qe8zMAojrimNGkdswVdcgQl/0E5c561hgTU54a7b2PWhobiM4YBY/jUcy+ufCv4m +qp71kSCsCazAdhKP65xvuu9hkIcJyHEIDASGAYLHKjjj1zbHmZhL5uKOWLnwLbNc +0ujTPlS5McxZeF65B9IWzJa5RmRMNlYgAKYsPlIwPpy2uglXWGdX3LejMpv5qXpC +QUKjrDi1AVOD+R6HIo6J4YMXEpOKbJpiamOpWn4oZLtGTTxAqA1qO43sNpJFKMC5 +4QjO7DMlfnbs3f5oYVjBExVQuqQAsgPMHLA8zQIDAQABAoIBAAd4l/oYcfD9e1q5 +YaFbZir2GcD3U9Le1KWtzxAFGM+tHA1vx/yFSq3FwcK6BlYau5GTL2ZcAiHxPAy4 +Zngm0VXhLZEk/Mz2IRePbEOABLy+YxcV1JOAQQz7WmkQAzUPlx+ImURvBMIxCzqR +oMbjat6lv+kQiLm2oNz4rko9ceBeCsAuuEREbmKMMubFeVgDvmtHOmbZlqsfHktd +BbB2zcvj5B7PpGth0jf9/3CoM0UYoT0hXtZ9s9y0HUyO2SDuRpUsWcIjzuZLVuiV +349z6/dNlVsXRIDLku1OPuyDTbfmFyfY5jCrU7idCet9n+BmyEEVuKJ12ZBwL2PF +KyhstAECgYEA5oOKMGu5G9cx2CdN113pyJW5syM5JLwwyPIXw5Xfv9IeEBYY1di4 +5BCZvsy4ez+CoFpCgFS+aOXo7vtAG5H4F9vOLgk9wBcijACChMwGqffWM5vB/ouA +EtymQe76YA7uBw048aoVJVsOp3dGRvix68BFs5gjMFvOjQD9J3gTnP8CgYEAwRlM +xNvZqDkVSvPqIlTnQ3GU9r7SK2JnBTkIcna68f71lEa38sl5ZUwlpkaTKaZhgA77 +CHtgYRnzOlOEWGxLkd82RinyVhBUKD9LfSJpDOkPiFwwlwYXehQGC69Ib4AFyHoS +JG+KiTVv5freKPv3VhiH/LyJwrU4YGw/d8VaCjMCgYEAlvdlBGs4cyRPb5nmH/tQ +hd6RHOIfpZBefuwWZjB8tlr891oRb9Qc2riIiG35EDa67RvP284kWfzgvcrs5GGH +0tBQytOgjnJYXMpksGYSozQ+I9SJi5R/D1tUw2+oqEp+1z1woszaRnnJMiIqc4ai +t5xXydQEj8JAlxYjtbqtVa8CgYEAtRPVktGb1Y3aMtRy3kkCKZPMnmqpSffYJeSq +0DQY8TAm+Sor+6gFiAGVWMzb2fXlfqINtJGF+ujL1wlUlVrQrvVDvx4824oqcSeR +0cHAA1RWtYfGJQmYYGmAldqEsdK7GZmng7V5k1uiGGddh89ozLrqYw4mnYk1We6I +wfc2jVMCgYEAr0So34S3siiljBx9afNXnEl4DOmTWDCOMlnz7Ru+egQ02M20DHEg +iRwHqBVzTiqncVpJPXIgcitNq7DRfbQCnkLuBfd30ygATk0wYkxyQp/mb7G9Fm3x +BOdFPOMTt5yQhO7nXJH0vt2sO5cUqKy1s6pBCGU1VIxFBuFWe4TGDKs= +-----END RSA PRIVATE KEY----- diff --git a/assets/secret/public_key.pem b/assets/secret/public_key.pem new file mode 100644 index 0000000..560721f --- /dev/null +++ b/assets/secret/public_key.pem @@ -0,0 +1,8 @@ +-----BEGIN RSA PUBLIC KEY----- +MIIBCgKCAQEArd/zJ0+jxE+q7YV8nNIjfMBtu7wSYbIjUa2FnuxwbxVuDLZPqe8z +MAojrimNGkdswVdcgQl/0E5c561hgTU54a7b2PWhobiM4YBY/jUcy+ufCv4mqp71 +kSCsCazAdhKP65xvuu9hkIcJyHEIDASGAYLHKjjj1zbHmZhL5uKOWLnwLbNc0ujT +PlS5McxZeF65B9IWzJa5RmRMNlYgAKYsPlIwPpy2uglXWGdX3LejMpv5qXpCQUKj +rDi1AVOD+R6HIo6J4YMXEpOKbJpiamOpWn4oZLtGTTxAqA1qO43sNpJFKMC54QjO +7DMlfnbs3f5oYVjBExVQuqQAsgPMHLA8zQIDAQAB +-----END RSA PUBLIC KEY----- diff --git a/build_docker.py b/build_docker.py new file mode 100644 index 0000000..4888cd0 --- /dev/null +++ b/build_docker.py @@ -0,0 +1,403 @@ +#!/usr/bin/env python3 +""" +构建脚本:编译 Rust 服务并生成包含运行容器所需文件的压缩包。 + +使用方法: + python build_docker.py [--release] [--clean] [--no-package] + +参数: + --release: 使用 release 模式编译(默认) + --clean: 构建前清理旧的二进制文件 + --no-package: 不生成压缩包,只编译和复制文件 +""" + +import os +import sys +import shutil +import subprocess +import argparse +import zipfile +import tarfile +from pathlib import Path +from datetime import datetime + +# 需要编译的服务列表 +SERVICES = [ + "simprint-server", + "console-gateway", + "update-gateway", +] + +# 项目根目录 +PROJECT_ROOT = Path(__file__).parent.resolve() +TARGET_DIR = PROJECT_ROOT / "target" +RELEASE_DIR = TARGET_DIR / "release" + +# 需要打包到压缩包的文件和目录 +PACKAGE_FILES = [ + "Dockerfile", + "docker-compose.yml", + ".dockerignore", + "configs", + "assets", +] + +# 需要打包的二进制文件(在构建后复制) +PACKAGE_BINARIES = SERVICES + + +def print_step(message: str): + """打印步骤信息""" + print(f"\n{'='*60}") + print(f" {message}") + print(f"{'='*60}\n") + + +def check_cargo(): + """检查 cargo 是否可用""" + try: + result = subprocess.run( + ["cargo", "--version"], + capture_output=True, + text=True, + check=True, + ) + print(f"✓ 找到 Cargo: {result.stdout.strip()}") + return True + except (subprocess.CalledProcessError, FileNotFoundError): + print("✗ 错误: 未找到 Cargo,请确保已安装 Rust 工具链") + return False + + +def clean_binaries(): + """清理项目根目录中的旧二进制文件""" + print_step("清理旧的二进制文件") + removed = [] + for service in SERVICES: + binary_path = PROJECT_ROOT / service + if binary_path.exists(): + binary_path.unlink() + removed.append(service) + print(f" 删除: {binary_path}") + + if not removed: + print(" 没有需要清理的文件") + else: + print(f" 已清理 {len(removed)} 个文件") + + +def build_service(service: str, release: bool = True) -> bool: + """编译单个服务""" + build_mode = "release" if release else "dev" + print(f" 编译 {service} ({build_mode} 模式)...") + + cmd = ["cargo", "build"] + if release: + cmd.append("--release") + cmd.extend(["--bin", service]) + + try: + result = subprocess.run( + cmd, + cwd=PROJECT_ROOT, + check=True, + capture_output=True, + text=True, + ) + print(f" ✓ {service} 编译成功") + return True + except subprocess.CalledProcessError as e: + print(f" ✗ {service} 编译失败") + if e.stderr: + print(f" 错误信息: {e.stderr}") + return False + + +def copy_binary(service: str, release: bool = True, dest_dir: Path = None) -> bool: + """将编译好的二进制文件复制到指定目录""" + if dest_dir is None: + dest_dir = PROJECT_ROOT + + source_dir = RELEASE_DIR if release else TARGET_DIR / "debug" + source_path = source_dir / service + dest_path = dest_dir / service + + if not source_path.exists(): + print(f" ✗ 错误: 源文件不存在: {source_path}") + return False + + try: + shutil.copy2(source_path, dest_path) + # 设置可执行权限 + os.chmod(dest_path, 0o755) + print(f" ✓ 已复制: {service} -> {dest_path}") + return True + except Exception as e: + print(f" ✗ 复制失败: {e}") + return False + + +def create_package(package_dir: Path, output_format: str = "zip") -> Path: + """创建压缩包""" + timestamp = datetime.now().strftime("%Y%m%d_%H%M%S") + package_name = f"simprint-server-docker-{timestamp}" + + if output_format == "zip": + archive_path = PROJECT_ROOT / f"{package_name}.zip" + print_step(f"创建 ZIP 压缩包: {archive_path.name}") + + with zipfile.ZipFile(archive_path, "w", zipfile.ZIP_DEFLATED) as zipf: + for root, dirs, files in os.walk(package_dir): + # 跳过隐藏目录 + dirs[:] = [d for d in dirs if not d.startswith(".")] + + for file in files: + file_path = Path(root) / file + arcname = file_path.relative_to(package_dir) + zipf.write(file_path, arcname) + print(f" 添加: {arcname}") + + elif output_format == "tar.gz": + archive_path = PROJECT_ROOT / f"{package_name}.tar.gz" + print_step(f"创建 TAR.GZ 压缩包: {archive_path.name}") + + with tarfile.open(archive_path, "w:gz") as tar: + for root, dirs, files in os.walk(package_dir): + # 跳过隐藏目录 + dirs[:] = [d for d in dirs if not d.startswith(".")] + + for file in files: + file_path = Path(root) / file + arcname = file_path.relative_to(package_dir) + tar.add(file_path, arcname=arcname, recursive=False) + print(f" 添加: {arcname}") + + else: + raise ValueError(f"不支持的压缩格式: {output_format}") + + size_mb = archive_path.stat().st_size / (1024 * 1024) + print(f"\n✓ 压缩包创建成功: {archive_path.name} ({size_mb:.2f} MB)") + return archive_path + + +def prepare_package_directory() -> Path: + """准备打包目录,复制所有必需的文件""" + print_step("准备打包目录") + + package_dir = PROJECT_ROOT / "docker-package" + + # 清理旧的打包目录 + if package_dir.exists(): + shutil.rmtree(package_dir) + + package_dir.mkdir(exist_ok=True) + print(f" 创建打包目录: {package_dir}") + + # 复制必需的文件和目录 + copied_count = 0 + for item in PACKAGE_FILES: + source = PROJECT_ROOT / item + dest = package_dir / item + + if not source.exists(): + print(f" ⚠ 警告: {item} 不存在,跳过") + continue + + try: + if source.is_dir(): + shutil.copytree(source, dest, dirs_exist_ok=True) + print(f" ✓ 复制目录: {item}") + else: + shutil.copy2(source, dest) + print(f" ✓ 复制文件: {item}") + copied_count += 1 + except Exception as e: + print(f" ✗ 复制失败 {item}: {e}") + + # 复制二进制文件 + print(f"\n 复制二进制文件:") + for binary in PACKAGE_BINARIES: + source = PROJECT_ROOT / binary + if source.exists(): + dest = package_dir / binary + shutil.copy2(source, dest) + os.chmod(dest, 0o755) + print(f" ✓ 复制: {binary}") + else: + print(f" ✗ 错误: {binary} 不存在") + + # 创建 README 文件 + readme_content = f"""# Simprint Server Docker 部署包 + +本压缩包包含运行 Simprint Server 容器所需的所有文件。 + +## 包含内容 + +- 三个服务的二进制文件: {', '.join(SERVICES)} +- Dockerfile: Docker 镜像构建文件 +- docker-compose.yml: Docker Compose 配置文件 +- configs/: 配置文件目录 +- assets/: 资源文件目录 + +## 使用方法 + +1. 解压压缩包到目标目录 + +2. 确保配置文件中的数据库、Redis、MinIO 地址正确 + +3. 构建 Docker 镜像: + ```bash + docker-compose build + ``` + +4. 启动所有服务: + ```bash + docker-compose up -d + ``` + +5. 查看服务状态: + ```bash + docker-compose ps + ``` + +6. 查看日志: + ```bash + docker-compose logs -f + ``` + +## 服务端口 + +- 客户端网关 (client-gateway): 40041 +- 更新网关 (update-gateway): 40042 +- 控制台网关 (console-gateway): 40043 + +## 注意事项 + +- 请确保已安装 Docker 和 Docker Compose +- 配置文件中的外部服务地址需要根据实际环境调整 +- 首次运行前请检查 configs/ 目录下的配置文件 + +生成时间: {datetime.now().strftime("%Y-%m-%d %H:%M:%S")} +""" + + readme_path = package_dir / "README.md" + readme_path.write_text(readme_content, encoding="utf-8") + print(f" ✓ 创建: README.md") + + print(f"\n✓ 打包目录准备完成,共 {copied_count + len(PACKAGE_BINARIES)} 个项目") + return package_dir + + +def main(): + parser = argparse.ArgumentParser( + description="构建 Rust 服务二进制文件供 Docker 使用" + ) + parser.add_argument( + "--release", + action="store_true", + default=True, + help="使用 release 模式编译(默认)", + ) + parser.add_argument( + "--dev", + action="store_true", + help="使用 dev 模式编译", + ) + parser.add_argument( + "--clean", + action="store_true", + help="构建前清理旧的二进制文件", + ) + parser.add_argument( + "--no-package", + action="store_true", + help="不生成压缩包,只编译和复制文件到项目根目录", + ) + parser.add_argument( + "--format", + choices=["zip", "tar.gz"], + default="tar.gz", + help="压缩包格式 (默认: tar.gz)", + ) + args = parser.parse_args() + + # 确定编译模式 + release_mode = args.release and not args.dev + + print_step("Simprint Server Docker 构建脚本") + print(f"项目目录: {PROJECT_ROOT}") + print(f"编译模式: {'release' if release_mode else 'dev'}") + + # 检查 cargo + if not check_cargo(): + sys.exit(1) + + # 清理旧文件 + if args.clean: + clean_binaries() + + # 编译所有服务 + print_step("编译服务") + build_success = True + for service in SERVICES: + if not build_service(service, release_mode): + build_success = False + + if not build_success: + print("\n✗ 部分服务编译失败,请检查错误信息") + sys.exit(1) + + # 复制二进制文件 + print_step("复制二进制文件到项目根目录") + copy_success = True + for service in SERVICES: + if not copy_binary(service, release_mode): + copy_success = False + + if not copy_success: + print("\n✗ 部分文件复制失败") + sys.exit(1) + + # 验证文件 + print_step("验证构建结果") + all_exist = True + for service in SERVICES: + binary_path = PROJECT_ROOT / service + if binary_path.exists(): + size = binary_path.stat().st_size / (1024 * 1024) # MB + print(f" ✓ {service}: {size:.2f} MB") + else: + print(f" ✗ {service}: 文件不存在") + all_exist = False + + if not all_exist: + print("\n✗ 构建验证失败") + sys.exit(1) + + # 生成压缩包 + if not args.no_package: + package_dir = prepare_package_directory() + archive_path = create_package(package_dir, args.format) + + # 清理打包目录 + print_step("清理临时文件") + shutil.rmtree(package_dir) + print(" ✓ 已清理临时打包目录") + + print_step("构建完成!") + print(f"\n✓ 压缩包已生成: {archive_path.name}") + print(f" 位置: {archive_path}") + print("\n现在可以:") + print(" 1. 将压缩包传输到目标服务器") + print(" 2. 解压后运行: docker-compose up -d") + else: + print_step("构建完成!") + print("\n现在可以使用以下命令构建 Docker 镜像:") + print(" docker-compose build") + print("\n或者启动所有服务:") + print(" docker-compose up -d") + + +if __name__ == "__main__": + main() + diff --git a/clippy.toml b/clippy.toml new file mode 100644 index 0000000..5efb527 --- /dev/null +++ b/clippy.toml @@ -0,0 +1,7 @@ +# Clippy 配置 +avoid-breaking-exported-api = false +msrv = "1.70.0" + + + + diff --git a/configs/config.console-gateway.dev.toml b/configs/config.console-gateway.dev.toml new file mode 100644 index 0000000..b7de3e5 --- /dev/null +++ b/configs/config.console-gateway.dev.toml @@ -0,0 +1,36 @@ +[app] +name = "console-gateway" +port = 40043 +secret = "p4y8uX1Jc22K7bAN52arpvJpVbduUvGNNNfo" +prefix = "/api/v1" +encrypt_secret_location = "./assets/secret" +route_whitelists = [ + "POST+/api/v1/users/login", + "POST+/api/v1/users/register", + "POST+/api/v1/users/register-send-code", + "POST+/api/v1/users/reset-password", + "POST+/api/v1/users/reset-password-send-code", + "POST+/api/v1/users/refresh-credentials", + "GET+/api/v1/secret/public/key", + "GET+/api/v1/time/now", + "GET+/api/v1/health", +] + +[database] +url = "postgres://lius:URxywfBRZZ1xpWcyPRbG@111.230.19.75:5432/simprintdb" +max_connections = 25 +min_connections = 5 +max_lifetime = 3000 +acquire_timeout = 30 +idle_timeout = 600 + +[redis] +url = "redis://111.230.19.75:6381?protocol=resp3" + +[minio] +server_url = "http://111.230.19.75:9000" +resource_url = "http://111.230.19.75:9000" +access_key = "5kSjAHnITsMZQEwzJN2g" +secret_access_key = "eGs3ntV5KvmVzMbLpQwj86w5b5vdSHxUcmqcYhay" +avatar_bucket = "avatars" +extension_bucket = "extensions" diff --git a/configs/config.console-gateway.prod.toml b/configs/config.console-gateway.prod.toml new file mode 100644 index 0000000..64f4117 --- /dev/null +++ b/configs/config.console-gateway.prod.toml @@ -0,0 +1,35 @@ +[app] +name = "console-gateway" +port = 40043 +secret = "p4y8uX1Jc22K7bAN52arpvJpVbduUvGNNNfo" +prefix = "/api/v1" +encrypt_secret_location = "./assets/secret" +route_whitelists = [ + "POST+/api/v1/users/login", + "POST+/api/v1/users/register", + "POST+/api/v1/users/register-send-code", + "POST+/api/v1/users/reset-password", + "POST+/api/v1/users/reset-password-send-code", + "POST+/api/v1/users/refresh-credentials", + "GET+/api/v1/secret/public/key", + "GET+/api/v1/time/now", + "GET+/api/v1/health", +] + +[database] +url = "postgres://lius:0gMAZGgriRK395Apzcb8@111.230.19.75:5433/simprintdb" +max_connections = 25 +min_connections = 5 +max_lifetime = 3000 +acquire_timeout = 30 +idle_timeout = 600 + +[redis] +url = "redis://111.230.19.75:6380?protocol=resp3" + +[minio] +server_url = "http://111.230.19.75:9000" +resource_url = "http://111.230.19.75:9000" +access_key = "5kSjAHnITsMZQEwzJN2g" +secret_access_key = "eGs3ntV5KvmVzMbLpQwj86w5b5vdSHxUcmqcYhay" +avatar_bucket = "avatars" diff --git a/configs/config.console-gateway.test.toml b/configs/config.console-gateway.test.toml new file mode 100644 index 0000000..1aa2d60 --- /dev/null +++ b/configs/config.console-gateway.test.toml @@ -0,0 +1,35 @@ +[app] +name = "console-gateway" +port = 40043 +secret = "p4y8uX1Jc22K7bAN52arpvJpVbduUvGNNNfo" +prefix = "/api/v1" +encrypt_secret_location = "./assets/secret" +route_whitelists = [ + "POST+/api/v1/users/login", + "POST+/api/v1/users/register", + "POST+/api/v1/users/register-send-code", + "POST+/api/v1/users/reset-password", + "POST+/api/v1/users/reset-password-send-code", + "POST+/api/v1/users/refresh-credentials", + "GET+/api/v1/secret/public/key", + "GET+/api/v1/time/now", + "GET+/api/v1/health", +] + +[database] +url = "postgres://admin-lius:ZZutdqfPAE4RFo5PYjgA@1.12.233.51:5433/l_g_p_db" +max_connections = 25 +min_connections = 5 +max_lifetime = 3000 +acquire_timeout = 30 +idle_timeout = 600 + +[redis] +url = "redis://1.12.233.51:6380?protocol=resp3" + +[minio] +server_url = "http://111.230.19.75:9000" +resource_url = "http://111.230.19.75:9000" +access_key = "5kSjAHnITsMZQEwzJN2g" +secret_access_key = "eGs3ntV5KvmVzMbLpQwj86w5b5vdSHxUcmqcYhay" +avatar_bucket = "avatars" diff --git a/configs/config.dev.toml b/configs/config.dev.toml new file mode 100644 index 0000000..1cd3361 --- /dev/null +++ b/configs/config.dev.toml @@ -0,0 +1,50 @@ +[app] +name = "simprint-server" +port = 40041 +secret = "p4y8uX1Jc22K7bAN52arpvJpVbduUvGNNNfo" +prefix = "/api/v1" +encrypt_secret_location = "./assets/secret" +route_whitelists = [ + "POST+/api/v1/users/login", + "POST+/api/v1/users/register", + "POST+/api/v1/users/register-send-code", + "POST+/api/v1/users/reset-password", + "POST+/api/v1/users/reset-password-send-code", + "POST+/api/v1/users/refresh-credentials", + "GET+/api/v1/secret/public/key", + "GET+/api/v1/time/now", + "POST+/api/v1/versions/check" +] +referral_link_prefix = "https://www.simprint.cc/download" + + +[database] +url = "postgres://lius:URxywfBRZZ1xpWcyPRbG@111.230.19.75:5432/simprintdb" +max_connections = 25 +min_connections = 5 +max_lifetime = 3000 +acquire_timeout = 30 +idle_timeout = 600 + +[redis] +url = "redis://111.230.19.75:6381?protocol=resp3" + +[minio] +server_url = "http://111.230.19.75:9000" +resource_url = "http://111.230.19.75:9000" +access_key = "5kSjAHnITsMZQEwzJN2g" +secret_access_key = "eGs3ntV5KvmVzMbLpQwj86w5b5vdSHxUcmqcYhay" +avatar_bucket = "avatars" + +[smtp] +smtp_server = "smtp.163.com" +smtp_username = "liushuinew@163.com" +smtp_password = "NSgjTyY6uSQwPGwV" + +[workspace_quota] +# 默认配额(新用户注册和手动创建工作空间都使用此配置) +[workspace_quota.default] +max_environments = 8 +max_team_members = 1 +max_proxies = 99999 +max_rpa_tasks = 99999 \ No newline at end of file diff --git a/configs/config.prod.toml b/configs/config.prod.toml new file mode 100644 index 0000000..30720d6 --- /dev/null +++ b/configs/config.prod.toml @@ -0,0 +1,50 @@ +[app] +name = "simprint-server" +port = 40041 +secret = "p4y8uX1Jc22K7bAN52arpvJpVbduUvGNNNfo" +prefix = "/api/v1" +encrypt_secret_location = "./assets/secret" +route_whitelists = [ + "POST+/api/v1/users/login", + "POST+/api/v1/users/register", + "POST+/api/v1/users/register-send-code", + "POST+/api/v1/users/reset-password", + "POST+/api/v1/users/reset-password-send-code", + "POST+/api/v1/users/refresh-credentials", + "GET+/api/v1/secret/public/key", + "GET+/api/v1/time/now", + "POST+/api/v1/versions/check" +] +referral_link_prefix = "https://www.simprint.cc/download" + + +[database] +url = "postgres://lius:0gMAZGgriRK395Apzcb8@111.230.19.75:5433/simprintdb" +max_connections = 25 +min_connections = 5 +max_lifetime = 3000 +acquire_timeout = 30 +idle_timeout = 600 + +[redis] +url = "redis://111.230.19.75:6380?protocol=resp3" + +[minio] +server_url = "http://111.230.19.75:9000" +resource_url = "http://111.230.19.75:9000" +access_key = "5kSjAHnITsMZQEwzJN2g" +secret_access_key = "eGs3ntV5KvmVzMbLpQwj86w5b5vdSHxUcmqcYhay" +avatar_bucket = "avatars" + +[smtp] +smtp_server = "smtp.163.com" +smtp_username = "liushuinew@163.com" +smtp_password = "NSgjTyY6uSQwPGwV" + +[workspace_quota] +# 默认配额(新用户注册和手动创建工作空间都使用此配置) +[workspace_quota.default] +max_environments = 8 +max_team_members = 1 +max_proxies = 99999 +max_rpa_tasks = 99999 \ No newline at end of file diff --git a/configs/config.test.toml b/configs/config.test.toml new file mode 100644 index 0000000..3858c25 --- /dev/null +++ b/configs/config.test.toml @@ -0,0 +1,48 @@ +[app] +name = "simprint-server" +port = 40041 +secret = "p4y8uX1Jc22K7bAN52arpvJpVbduUvGNNNfo" +prefix = "/api/v1" +encrypt_secret_location = "./assets/secret" +route_whitelists = [ + "POST+/api/v2/users/login", + "POST+/api/v2/users/register", + "POST+/api/v2/users/register-send-code", + "POST+/api/v2/users/reset-password", + "POST+/api/v2/users/reset-password-send-code", + "POST+/api/v2/users/refresh-credentials", + "GET+/api/v2/secret/public/key" +] +referral_link_prefix = "https://www.simprint.cc/download" + + +[database] +url = "postgres://admin-lius:ZZutdqfPAE4RFo5PYjgA@1.12.233.51:5433/l_g_p_db" +max_connections = 25 +min_connections = 5 +max_lifetime = 3000 +acquire_timeout = 30 +idle_timeout = 600 + +[redis] +url = "redis://1.12.233.51:6380?protocol=resp3" + +[minio] +server_url = "http://14.21.74.66:9000" +resource_url = "https://oss.csgot9.com" +access_key = "5kSjAHnITsMZQEwzJN2g" +secret_access_key = "eGs3ntV5KvmVzMbLpQwj86w5b5vdSHxUcmqcYhay" +avatar_bucket = "avatars" + +[smtp] +smtp_server = "smtp.163.com" +smtp_username = "liushuinew@163.com" +smtp_password = "NSgjTyY6uSQwPGwV" + +[workspace_quota] +# 默认配额(新用户注册和手动创建工作空间都使用此配置) +[workspace_quota.default] +max_environments = 8 +max_team_members = 1 +max_proxies = 99999 +max_rpa_tasks = 99999 \ No newline at end of file diff --git a/configs/config.update-gateway.dev.toml b/configs/config.update-gateway.dev.toml new file mode 100644 index 0000000..ec85f75 --- /dev/null +++ b/configs/config.update-gateway.dev.toml @@ -0,0 +1,23 @@ +[app] +name = "update-gateway" +port = 40042 +secret = "p4y8uX1Jc22K7bAN52arpvJpVbduUvGNNNfo" +prefix = "/api/v1" +encrypt_secret_location = "./assets/secret" +route_whitelists = [ + "POST+/api/v1/versions/check", + "POST+/api/v1/maintenances/active", + "GET+/api/v1/health" +] + +[database] +url = "postgres://lius:URxywfBRZZ1xpWcyPRbG@111.230.19.75:5432/simprintdb" +max_connections = 25 +min_connections = 5 +max_lifetime = 3000 +acquire_timeout = 30 +idle_timeout = 600 + +[redis] +url = "redis://111.230.19.75:6381?protocol=resp3" + diff --git a/configs/config.update-gateway.prod.toml b/configs/config.update-gateway.prod.toml new file mode 100644 index 0000000..9d33f4c --- /dev/null +++ b/configs/config.update-gateway.prod.toml @@ -0,0 +1,23 @@ +[app] +name = "update-gateway" +port = 40042 +secret = "p4y8uX1Jc22K7bAN52arpvJpVbduUvGNNNfo" +prefix = "/api/v1" +encrypt_secret_location = "./assets/secret" +route_whitelists = [ + "POST+/api/v1/versions/check", + "POST+/api/v1/maintenances/active", + "GET+/api/v1/health" +] + +[database] +url = "postgres://lius:0gMAZGgriRK395Apzcb8@111.230.19.75:5433/simprintdb" +max_connections = 25 +min_connections = 5 +max_lifetime = 3000 +acquire_timeout = 30 +idle_timeout = 600 + +[redis] +url = "redis://111.230.19.75:6380?protocol=resp3" + diff --git a/configs/config.update-gateway.test.toml b/configs/config.update-gateway.test.toml new file mode 100644 index 0000000..9e48ebb --- /dev/null +++ b/configs/config.update-gateway.test.toml @@ -0,0 +1,23 @@ +[app] +name = "update-gateway" +port = 40042 +secret = "p4y8uX1Jc22K7bAN52arpvJpVbduUvGNNNfo" +prefix = "/api/v1" +encrypt_secret_location = "./assets/secret" +route_whitelists = [ + "POST+/api/v1/versions/check", + "POST+/api/v1/maintenances/active", + "GET+/api/v1/health" +] + +[database] +url = "postgres://admin-lius:ZZutdqfPAE4RFo5PYjgA@1.12.233.51:5433/l_g_p_db" +max_connections = 25 +min_connections = 5 +max_lifetime = 3000 +acquire_timeout = 30 +idle_timeout = 600 + +[redis] +url = "redis://1.12.233.51:6380?protocol=resp3" + diff --git a/docker-compose.yml b/docker-compose.yml new file mode 100644 index 0000000..7730f64 --- /dev/null +++ b/docker-compose.yml @@ -0,0 +1,66 @@ +version: '3.8' + +services: + # 客户端网关服务 + client-gateway: + build: + context: . + dockerfile: Dockerfile + args: + BINARY_NAME: simprint-server + container_name: simprint-client-gateway + command: ["-f=/app/configs/config.prod.toml"] + ports: + - "40041:40041" + volumes: + - ./configs:/app/configs:ro + - ./assets:/app/assets:ro + environment: + - RUST_LOG=info + restart: unless-stopped + networks: + - simprint-network + + # 控制台网关服务 + console-gateway: + build: + context: . + dockerfile: Dockerfile + args: + BINARY_NAME: console-gateway + container_name: simprint-console-gateway + command: ["-f=/app/configs/config.console-gateway.prod.toml"] + ports: + - "40043:40043" + volumes: + - ./configs:/app/configs:ro + - ./assets:/app/assets:ro + environment: + - RUST_LOG=info + restart: unless-stopped + networks: + - simprint-network + + # 更新网关服务 + update-gateway: + build: + context: . + dockerfile: Dockerfile + args: + BINARY_NAME: update-gateway + container_name: simprint-update-gateway + command: ["-f=/app/configs/config.update-gateway.prod.toml"] + ports: + - "40042:40042" + volumes: + - ./configs:/app/configs:ro + - ./assets:/app/assets:ro + environment: + - RUST_LOG=info + restart: unless-stopped + networks: + - simprint-network + +networks: + simprint-network: + driver: bridge diff --git a/docs/database-design.md b/docs/database-design.md new file mode 100644 index 0000000..b9c349e --- /dev/null +++ b/docs/database-design.md @@ -0,0 +1,109 @@ +# 数据库设计文档 + +本文档描述了 Simprint Server 项目的数据库表结构设计。 + +## 表结构定义规范 + +### users + +用户基础信息表 + +存储用户的基础标识信息,采用 UUID 作为主键,支持全局唯一标识。 + +**字段说明**: + +- `uuid` - UUID 类型,主键,全局唯一标识 +- `id` - 字符串类型,用户 ID,唯一标识,可用于业务查询(如:USER001) +- `created_at` - 时间戳类型,创建时间,NOT NULL +- `updated_at` - 时间戳类型,更新时间,NOT NULL +- `deleted_at` - 时间戳类型,删除时间(软删除),可为 NULL + +**索引**: + +- 主键索引:`uuid` +- 唯一索引:`id` +- 索引:`deleted_at`(用于软删除查询优化) + +**约束**: + +- `id` 必须唯一且不为空 +- `created_at` 和 `updated_at` 由数据库自动管理 + +### user_infos + +用户详细信息表 + +存储用户的详细业务信息,包括登录凭证、联系方式、个人信息等。通过 `user_uuid` 与 `users` 表关联。 + +**字段说明**: + +- `id` - 自增整数类型,主键 +- `user_uuid` - UUID 类型,外键,关联 `users.uuid`,NOT NULL +- `nickname` - 字符串类型,昵称,可为 NULL +- `email` - 字符串类型,邮箱地址,NOT NULL,唯一索引,用于登录 +- `phone` - 字符串类型,手机号,可为 NULL(可选字段) +- `password` - 字符串类型,密码(Argon2 加密存储),NOT NULL +- `avatar_hash` - 字符串类型,头像文件 hash(MinIO 中存储的文件名,无后缀),可为 NULL +- `status` - 字符串类型,用户状态(active/inactive/banned),默认 'active',NOT NULL +- `created_at` - 时间戳类型,创建时间,NOT NULL +- `updated_at` - 时间戳类型,更新时间,NOT NULL +- `deleted_at` - 时间戳类型,删除时间(软删除),可为 NULL + +**索引**: + +- 主键索引:`id` +- 唯一索引:`user_uuid`(一对一关系) +- 唯一索引:`email`(邮箱唯一,NOT NULL,用于登录) +- 索引:`deleted_at`(用于软删除查询优化) +- 索引:`status`(用于状态查询) + +**约束**: + +- `user_uuid` 必须唯一,确保与 `users` 表一对一关系 +- `email` 必须唯一且不为空(用于登录) +- `password` 必须不为空 +- `status` 默认值为 'active' + +**关于头像字段的说明**: + +- `avatar_hash` 字段用于存储 MinIO 中头像文件的 hash 值(文件名,无后缀) +- 目前阶段不实现头像功能,该字段可为 NULL +- 未来实现头像功能时,头像文件将存储在 MinIO 的 `avatar_bucket` 中,文件名为 hash 值 +- 获取头像 URL 时,通过配置的 `minio.resource_url` 和 `avatar_bucket` 拼接完整 URL + +**表关系**: + +- `user_infos.user_uuid` → `users.uuid`(一对一关系,级联删除) + +## 设计说明 + +### 软删除策略 + +- 所有表都支持软删除,通过 `deleted_at` 字段标记 +- 查询时默认过滤 `deleted_at IS NULL` 的记录 +- 物理删除仅在必要时进行(如数据清理) + +### 用户标识 + +- `users.uuid`:系统内部使用的全局唯一标识(UUID) +- `users.id`:业务层使用的用户 ID(字符串,如 USER001) +- `user_infos.phone`:用户登录使用的手机号 + +### 会话管理 + +- Refresh Token 存储在 Redis 中,不在数据库表中 +- 会话相关信息(设备信息、登录时间等)可在 Redis 中管理 +- 如需持久化会话记录,可考虑添加 `user_sessions` 表 + +### 验证码管理 + +- 验证码存储在 Redis 中,设置过期时间(如 5 分钟) +- 格式:`verification_code:{phone}:{type}`(type: register/reset_password) +- 不在数据库表中存储验证码信息 + +### 扩展性考虑 + +- 用户权限:如需权限系统,可添加 `user_permissions` 表 +- 用户角色:如需角色系统,可添加 `user_roles` 表和关联表 +- 登录历史:如需记录登录历史,可添加 `user_login_history` 表 +- 设备管理:如需管理用户设备,可添加 `user_devices` 表 diff --git a/docs/public-key-endpoint-plan.md b/docs/public-key-endpoint-plan.md new file mode 100644 index 0000000..9d5d39f --- /dev/null +++ b/docs/public-key-endpoint-plan.md @@ -0,0 +1,279 @@ +# 获取公钥 API 端点实现计划(已更新) + +## 概述 + +本计划用于实现 `GET /api/v1/secret/public/key` API 端点,该端点用于获取服务器的 RSA 公钥。客户端可以使用此公钥加密敏感数据,然后发送到服务器。 + +## 当前状态分析 + +### 已有功能 + +1. ✅ **路由配置**:路由已在配置文件中加入白名单(`configs/config.dev.toml` 和 `configs/config.prod.toml`) +2. ✅ **RSA 密钥工具**:`src/utils/secret/rsa.rs` 中已有 `RsaSecret` 结构和 `get_public_key()` 方法 +3. ✅ **全局 RSA 实例**:`get_rsa_secret_instance()` 函数可以获取全局 RSA 密钥实例 +4. ✅ **客户端实现参考**:`src-tauri` 中已有客户端获取公钥的实现,可作为参考 + +### 缺失功能 + +1. ❌ **Handler 函数**:缺少处理获取公钥请求的 handler 函数 +2. ❌ **路由注册**:缺少注册 `/secret/public/key` 路由的代码 +3. ❌ **Service 函数**:可选,可以直接在 handler 中调用工具函数 + +## 重要发现:客户端期望的响应格式 + +通过分析 `src-tauri/src/infrastructure/storage/credential.rs` 中的客户端实现,发现了关键信息: + +### 客户端代码分析 + +```rust +// 客户端发送 GET 请求 +let response = client.get(url).send().await?; + +// 客户端期望纯文本响应(不是 JSON) +let public_key = response.text().await?; + +// 客户端验证响应格式 +if !public_key.contains("-----BEGIN RSA PUBLIC KEY-----") { + return Err("服务器响应错误".to_string()); +} +``` + +### 关键结论 + +1. **响应格式**:客户端期望**纯文本响应**(`text/plain`),而不是 JSON 格式 +2. **响应内容**:直接返回 PEM 格式的公钥字符串,例如: + ``` + -----BEGIN RSA PUBLIC KEY----- + MIIBCgKCAQEA... + -----END RSA PUBLIC KEY----- + ``` +3. **路由路径**:`secret/public/key`(相对路径,完整路径为 `/api/v1/secret/public/key`) +4. **请求拦截器**:客户端的请求拦截器会跳过 `/secret/public/key` 路径的加密处理(见 `src-tauri/src/infrastructure/network/interceptors/request.rs`) + +## 实现方案 + +### 方案选择 + +由于获取服务器公钥是一个简单的操作,且客户端期望纯文本响应,建议: + +- **直接在 handler 中调用工具函数**,无需创建额外的 service 层 +- **返回纯文本响应**,使用 Axum 的 `PlainText` 或直接使用 `String` 实现 `IntoResponse` + +### 目录结构 + +参考现有的 `time` 模块结构: + +``` +src/ +├── handlers/ +│ ├── time.rs (已有) +│ └── secret.rs (新建) +├── routes/ +│ ├── time.rs (已有) +│ └── secret.rs (新建) +├── handlers.rs +└── routes.rs +``` + +## 实现步骤 + +### 步骤 1:创建 Handler 函数 + +**文件**:`src/handlers/secret.rs` + +**功能**: + +- 从全局 RSA 实例获取公钥 +- 返回纯文本响应(PEM 格式的公钥字符串) + +**代码结构**: + +```rust +use axum::response::{IntoResponse, Response}; +use axum::http::StatusCode; +use crate::utils::get_rsa_secret_instance; + +pub async fn get_public_key_handler() -> impl IntoResponse { + let public_key = get_rsa_secret_instance().get_public_key(); + (StatusCode::OK, public_key).into_response() +} +``` + +**说明**: + +- 使用 `impl IntoResponse` 作为返回类型,可以返回纯文本 +- `(StatusCode::OK, public_key)` 会创建一个状态码为 200 的纯文本响应 +- Axum 会自动设置 `Content-Type: text/plain; charset=utf-8` + +### 步骤 2:创建路由注册模块 + +**文件**:`src/routes/secret.rs` + +**功能**: + +- 注册 `/public/key` 路由 +- 使用 GET 方法 +- 绑定到 `get_public_key_handler` + +**代码结构**: + +```rust +use axum::routing::get; +use crate::handlers::get_public_key_handler; +use crate::routes::route::{self, MetaRoute}; + +pub fn register_routes(meta_route: &mut MetaRoute) -> () { + let mut secret_route = route::RouteGroup::new("/secret"); + + secret_route.add_route_item(route::RouteItem::get("/public/key", get(get_public_key_handler))); + + meta_route.add_route_group(secret_route); +} +``` + +### 步骤 3:注册模块 + +**文件**:`src/handlers.rs` + +```rust +mod time; +mod secret; // 新增 + +pub use time::*; +pub use secret::*; // 新增 +``` + +**文件**:`src/routes.rs` + +```rust +mod time; +mod secret; // 新增 + +pub fn all_routes(svc_ctx: &SvcCtx) -> Router { + // ... + time::register_routes(&mut meta_route); + secret::register_routes(&mut meta_route); // 新增 + // ... +} +``` + +### 步骤 4:验证和测试 + +1. **编译检查**:`cargo check` +2. **代码格式化**:`cargo fmt` +3. **Lint 检查**:`cargo clippy` +4. **功能测试**: + - 启动服务器 + - 使用 curl 或 Postman 测试端点: + ```bash + curl http://localhost:40041/api/v1/secret/public/key + ``` + - 验证返回的是 PEM 格式的公钥字符串 + - 验证响应头 `Content-Type` 为 `text/plain` + +## API 响应格式 + +### 成功响应(HTTP 200) + +**响应头**: + +``` +Content-Type: text/plain; charset=utf-8 +``` + +**响应体**(纯文本): + +``` +-----BEGIN RSA PUBLIC KEY----- +MIIBCgKCAQEAseI9vA7iTxOMb5Y2xCL7BOGr1by9qEH4EfP9Bj90gxDmY8yRsVK/ +o2g+i95oQxzdvdvpPAocKlQv2FEbZaqFr2Q4vy1cLrM0B1NTZl1/hGcmPSofLT9g +mnjzP60ikY40Dxq+YXAxXZ4s2M+9thNnFr4OydacHEPEkTklcBQBglopSXc1yqHU +ARyCQ3/VxQrfh215vIPgMg2f6PH741zXFaIJjucXR8wJVySo7aZhlBTOVz5GzV0b +aWh31zA47ivXh84OXIEI+CKDUSnvsa8SCRMRs8LgaO1Xktv4yCfHHpo8Zoy+KdW0 +LJxP11G+3f9RsYRpjdmsleyHuYYS07suqwIDAQAB +-----END RSA PUBLIC KEY----- +``` + +### 错误处理 + +如果 RSA 实例未初始化,`get_rsa_secret_instance()` 会直接退出程序(在启动时已初始化,正常情况下不会发生)。 + +## 注意事项 + +1. **响应格式的重要性**: + - ⚠️ **必须返回纯文本**,不能使用 `Response` 类型(它会转换为 JSON) + - 客户端使用 `response.text().await` 期望纯文本响应 + - 客户端会验证响应包含 `-----BEGIN RSA PUBLIC KEY-----` + +2. **安全性**: + - 该端点返回的是服务器的**公钥**,是公开信息,可以安全地暴露 + - 路由已在白名单中,无需认证即可访问 + - 客户端的请求拦截器会跳过此路径的加密处理 + +3. **性能**: + - 公钥是静态数据,从内存中读取,性能开销很小 + - 无需数据库查询 + +4. **代码风格**: + - 遵循项目现有的代码风格(参考 `time.rs` 的实现) + - 但注意响应格式不同(纯文本 vs JSON) + - 添加适当的文档注释 + +5. **测试环境差异**: + - `config.test.toml` 中使用的是 `/api/v2/secret/public/key` + - 如果需要支持 v2 版本,可以在同一 handler 中注册多个路由,或创建单独的 handler + +## 客户端兼容性 + +### 客户端实现位置 + +- **文件**:`src-tauri/src/infrastructure/storage/credential.rs` +- **函数**:`fetch_server_public_key()` +- **调用时机**:应用启动时(`init_server_public_key()`) + +### 客户端验证逻辑 + +```rust +// 1. 发送 GET 请求 +let response = client.get(url).send().await?; + +// 2. 获取纯文本响应 +let public_key = response.text().await?; + +// 3. 验证公钥格式 +if !public_key.contains("-----BEGIN RSA PUBLIC KEY-----") { + return Err("服务器响应错误".to_string()); +} + +// 4. 存储公钥 +*SERVER_PUBLIC_KEY.write().unwrap() = Some(public_key.clone()); +``` + +## 后续优化建议(可选) + +1. **HTTP 缓存**:公钥是静态的,可以考虑添加 HTTP 缓存头(如 `Cache-Control: public, max-age=3600`) +2. **版本控制**:如果需要支持 v2 API,可以创建 `routes/secret_v2.rs` +3. **文档**:添加 OpenAPI/Swagger 文档(如果项目使用) + +## 相关文件清单 + +### 需要创建的文件 + +- `src/handlers/secret.rs` +- `src/routes/secret.rs` + +### 需要修改的文件 + +- `src/handlers.rs` - 添加 `mod secret;` 和 `pub use secret::*;` +- `src/routes.rs` - 添加 `mod secret;` 和调用 `secret::register_routes()` + +### 相关配置文件(无需修改,已配置) + +- `configs/config.dev.toml` - 已添加路由白名单 +- `configs/config.prod.toml` - 已添加路由白名单 +- `configs/config.test.toml` - 已添加路由白名单(v2 版本) + +### 客户端参考代码 + +- `src-tauri/src/infrastructure/storage/credential.rs` - 客户端获取公钥的实现 +- `src-tauri/src/infrastructure/network/interceptors/request.rs` - 请求拦截器(跳过公钥端点加密) diff --git a/docs/workspace-architecture-refactoring-report.md b/docs/workspace-architecture-refactoring-report.md new file mode 100644 index 0000000..d1c1cd2 --- /dev/null +++ b/docs/workspace-architecture-refactoring-report.md @@ -0,0 +1,686 @@ +# 工作空间架构重构变更报告 + +**重构日期**:2025-01-25 +**重构范围**:工作空间架构全面重构 +**文档版本**:v1.0 + +--- + +## 概述 + +本次重构将系统架构从"用户-团队-环境"模式升级为"用户-工作空间-团队-分组-环境"的多层级结构,实现了更灵活的资源管理、权限控制和计费体系。 + +--- + +## 1. 架构设计变更 + +### 1.1 新增架构文档 + +- **文件**:`simprint-server/docs/workspace-architecture.md` +- **内容**:完整的工作空间架构设计文档,包括: + - 核心概念和层级结构 + - 实体关系图(ASCII 格式) + - 实体详细设计 + - 权限控制体系 + - 配额管理 + - 代理可见性管理 + - 数据模型设计要点 + +### 1.2 核心设计原则 + +1. **工作空间是资源隔离边界**:所有资源(环境、代理、配额)都归属于工作空间 +2. **团队是协作组织单元**:团队成员共享团队资源,通过分组进行细粒度管理 +3. **分组是环境分类容器**:分组用于对团队内的环境进行分类和权限控制(可选) +4. **环境是核心操作单元**:所有业务操作围绕环境展开,环境必须归属团队 + +--- + +## 2. 数据库变更 + +### 2.1 新增表 + +#### 2.1.1 工作空间表(workspaces) + +- **迁移文件**:`20250125000001_create_workspaces.sql` +- **字段**: + - `uuid`:工作空间唯一标识 + - `name`:工作空间名称 + - `owner_uuid`:所有者用户 UUID + - `workspace_type`:工作空间类型(personal/team/enterprise) + - `created_at`、`updated_at`、`deleted_at`:时间戳 + +#### 2.1.2 工作空间配额表(workspace_quotas) + +- **迁移文件**:`20250125000002_create_workspace_quotas.sql` +- **字段**: + - `workspace_uuid`:工作空间 UUID(主键) + - `max_environments`、`used_environments`:环境配额 + - `max_team_members`、`used_team_members`:成员配额 + - `max_proxies`、`used_proxies`:代理配额 + - `max_rpa_tasks`、`used_rpa_tasks`:RPA 任务配额 + +#### 2.1.3 代理可见团队关联表(proxy_visible_teams) + +- **迁移文件**:`20250125000003_create_proxy_visible_teams.sql` +- **字段**: + - `proxy_uuid`:代理 UUID + - `workspace_uuid`:工作空间 UUID(冗余,便于查询) + - `team_uuid`:团队 UUID + - `created_at`:创建时间 +- **唯一约束**:`UNIQUE (proxy_uuid, team_uuid)` + +#### 2.1.4 分组权限表(group_member_permissions) + +- **迁移文件**:`20250125000004_create_group_member_permissions.sql` +- **字段**: + - `group_uuid`:分组 UUID + - `workspace_uuid`:工作空间 UUID(冗余,便于查询) + - `team_uuid`:团队 UUID(冗余,便于查询) + - `user_uuid`:用户 UUID + - `permission_type`:权限类型(read/write/manage) + - `granted_by`:授权者 UUID + - `created_at`、`updated_at`:时间戳 +- **唯一约束**:`UNIQUE (group_uuid, user_uuid)` + +### 2.2 修改现有表 + +#### 2.2.1 团队表(teams) + +- **迁移文件**:`20250125000005_alter_teams_add_workspace.sql` +- **新增字段**: + - `workspace_uuid`:所属工作空间 UUID +- **移除字段**: + - `max_members`、`max_environments`、`max_proxies`:配额移至 `workspace_quotas` + - `default_proxy_uuid`:不再需要默认代理 + +#### 2.2.2 团队成员表(team_members) + +- **迁移文件**:`20250125000006_alter_team_members_add_workspace.sql` +- **新增字段**: + - `workspace_uuid`:工作空间 UUID(冗余,便于查询) +- **移除字段**: + - `environment_count`、`group_count`:统计字段,可通过查询计算 +- **更新唯一约束**:`UNIQUE (team_uuid, user_uuid, workspace_uuid)` + +#### 2.2.3 分组表(groups) + +- **迁移文件**:`20250125000007_alter_groups_add_workspace.sql` +- **新增字段**: + - `workspace_uuid`:所属工作空间 UUID(冗余,便于查询) +- **移除字段**: + - `user_uuid`:分组属于团队,不属于用户 + - `default_proxy_uuid`:不再需要默认代理 + - `color`:前端可自行管理 +- **约束**:确保 `team_uuid` 为 NOT NULL + +#### 2.2.4 环境表(environments) + +- **迁移文件**:`20250125000008_alter_environments_add_workspace.sql` +- **新增字段**: + - `workspace_uuid`:所属工作空间 UUID(冗余,便于查询) +- **约束**:确保 `team_uuid` 为 NOT NULL + +#### 2.2.5 代理表(proxies) + +- **迁移文件**:`20250125000009_alter_proxies_add_workspace.sql` +- **新增字段**: + - `workspace_uuid`:所属工作空间 UUID(必须) + - `owner_uuid`:代理所有者 UUID(重命名自 `user_uuid`) +- **移除字段**: + - `team_uuid`:代理属于工作空间,不属于团队 + - `usage_count`:可通过查询计算 + +#### 2.2.6 订阅表(subscriptions) + +- **迁移文件**:`20250125000010_alter_subscriptions_add_workspace.sql` +- **新增字段**: + - `workspace_uuid`:所属工作空间 UUID + +#### 2.2.7 用户信息表(user_infos) + +- **迁移文件**:`20250125000013_alter_user_infos_add_current_workspace.sql` +- **新增字段**: + - `current_workspace_uuid`:用户当前工作空间 UUID + +### 2.3 数据迁移 + +#### 2.3.1 废弃用户配额表 + +- **迁移文件**:`20250125000011_deprecate_user_quotas.sql` +- **操作**:将 `user_quotas` 表重命名为 `deprecated_user_quotas` + +#### 2.3.2 数据迁移脚本 + +- **迁移文件**:`20250125000012_migrate_to_workspaces.sql` +- **操作内容**: + 1. 为每个现有用户创建默认个人工作空间 + 2. 为每个工作空间创建默认配额 + 3. 将现有 `user_quotas` 数据迁移到 `workspace_quotas` + 4. 更新所有相关表的 `workspace_uuid` 字段 + 5. 添加外键约束 + 6. 设置 `workspace_uuid` 为 NOT NULL + +--- + +## 3. 代码层变更 + +### 3.1 DTO 层(Data Transfer Object) + +#### 3.1.1 新增 DTO + +- `simprint-server/src/dto/workspaces.rs`:工作空间 DTO +- `simprint-server/src/dto/workspace_quotas.rs`:工作空间配额 DTO +- `simprint-server/src/dto/proxy_visible_teams.rs`:代理可见团队 DTO +- `simprint-server/src/dto/group_member_permissions.rs`:分组权限 DTO + +#### 3.1.2 修改现有 DTO + +- `simprint-server/src/dto/teams.rs`:添加 `workspace_uuid`,移除配额相关字段 +- `simprint-server/src/dto/groups.rs`:添加 `workspace_uuid`,移除 `user_uuid`、`default_proxy_uuid`、`color` +- `simprint-server/src/dto/environments.rs`:添加 `workspace_uuid` +- `simprint-server/src/dto/proxies.rs`:添加 `workspace_uuid`、`owner_uuid`,移除 `team_uuid` +- `simprint-server/src/dto/subscriptions.rs`:添加 `workspace_uuid` +- `simprint-server/src/dto/user.rs`:添加 `current_workspace_uuid` + +### 3.2 Entity 层(请求/响应实体) + +#### 3.2.1 新增 Entity + +- `simprint-server/src/entitys/workspaces.rs`:工作空间相关请求/响应 +- `simprint-server/src/entitys/workspace_quotas.rs`:工作空间配额响应 +- `simprint-server/src/entitys/proxy_visible_teams.rs`:代理可见性请求/响应 +- `simprint-server/src/entitys/group_member_permissions.rs`:分组权限请求/响应 + +#### 3.2.2 修改现有 Entity + +- `simprint-server/src/entitys/teams.rs`:添加 `workspace_uuid`,移除 `default_proxy_uuid` +- `simprint-server/src/entitys/groups.rs`:添加 `workspace_uuid`、`team_uuid`,移除 `default_proxy_uuid` +- `simprint-server/src/entitys/environments.rs`:添加 `workspace_uuid`、`team_uuid` +- `simprint-server/src/entitys/proxies.rs`:添加 `workspace_uuid` +- `simprint-server/src/entitys/subscriptions.rs`:添加 `workspace_uuid` + +### 3.3 Model 层(数据库操作) + +#### 3.3.1 新增 Model + +- `simprint-server/src/models/workspaces.rs`:工作空间数据库操作 +- `simprint-server/src/models/workspace_quotas.rs`:工作空间配额数据库操作 +- `simprint-server/src/models/proxy_visible_teams.rs`:代理可见性数据库操作 +- `simprint-server/src/models/group_member_permissions.rs`:分组权限数据库操作 + +#### 3.3.2 修改现有 Model + +- `simprint-server/src/models/teams.rs`: + - `fetch_team_member`:添加 `workspace_uuid` 参数(工作空间级别隔离) + - `insert_team`:添加 `workspace_uuid` 参数 + - `fetch_team_by_uuid`:添加 `workspace_uuid` 到 SELECT + - `fetch_user_teams`:添加 `workspace_uuid` 过滤 + - 移除配额相关逻辑 + +- `simprint-server/src/models/team_members.rs`: + - 所有函数添加 `workspace_uuid` 参数 + - SQL 查询包含 `workspace_uuid` 过滤 + +- `simprint-server/src/models/groups.rs`: + - `insert_group`:添加 `workspace_uuid`、`team_uuid` 参数,移除 `user_uuid` + - `fetch_groups`:使用 `workspace_uuid` 和 `team_uuid` 过滤 + - 移除 `default_proxy_uuid` 相关逻辑 + +- `simprint-server/src/models/environments.rs`: + - `insert_environment`:添加 `workspace_uuid` 参数 + - `fetch_environment_by_uuid`:添加 `workspace_uuid` 参数进行过滤 + - 新增 `fetch_environment_by_uuid_unfiltered`:用于内部查询 + - 所有查询函数添加 `workspace_uuid` 过滤 + +- `simprint-server/src/models/proxies.rs`: + - `insert_proxy`:添加 `workspace_uuid` 参数,`user_uuid` 改为 `owner_uuid`,移除 `team_uuid` + - `fetch_proxies`:使用 `workspace_uuid` 和 `owner_uuid` 过滤 + - 移除 `usage_count` 相关逻辑 + +- `simprint-server/src/models/subscriptions.rs`: + - 所有函数添加 `workspace_uuid` 参数 + +- `simprint-server/src/models/user.rs`: + - `create_user_with_info`:创建默认工作空间和配额 + - `fetch_user_info_by_uuid`、`fetch_user_info_by_email`:添加 `current_workspace_uuid` 到 SELECT + +### 3.4 Service 层(业务逻辑) + +#### 3.4.1 新增 Service + +- `simprint-server/src/services/workspaces.rs`:工作空间业务逻辑 +- `simprint-server/src/services/workspace_quotas.rs`:工作空间配额业务逻辑 +- `simprint-server/src/services/proxy_visibility.rs`:代理可见性业务逻辑 +- `simprint-server/src/services/group_permissions.rs`:分组权限业务逻辑 + +#### 3.4.2 修改现有 Service + +**环境服务(environments.rs)**: + +- `create_environment_service`: + - 添加工作空间级别团队成员检查 + - 添加分组权限检查(write/manage) + - 添加团队角色权限检查(Editor/Admin/Owner) + - 添加配额检查和更新 +- `get_environment_service`: + - 添加工作空间过滤 + - 添加分组 read 权限检查 +- `get_environments_service`: + - 添加批量权限过滤(根据分组权限) +- `update_environment_service`: + - 添加编辑权限检查(分组 write/manage 或团队角色) +- `delete_environment_service`: + - 添加删除权限检查(分组 manage 或团队 Owner/Admin) + - 添加配额更新 + +**分组服务(groups.rs)**: + +- `create_group_service`:添加 Owner/Admin 权限检查 +- `update_group_service`:添加 Owner/Admin 或 manage 权限检查 +- `delete_group_service`:添加 Owner/Admin 或 manage 权限检查 + +**团队服务(teams.rs)**: + +- 所有函数添加 `workspace_uuid` 参数 +- `switch_team_service`:更新 `current_workspace_uuid` + +**代理服务(proxies.rs)**: + +- `create_proxy_service`:添加配额检查和更新 +- `delete_proxy_service`:添加配额更新 +- `batch_import_proxies_service`:添加配额检查和更新 + +**模板服务(templates.rs)**: + +- `create_template_service`:从源环境获取 `workspace_uuid` 和 `team_uuid` 进行权限检查 +- `apply_template_service`:添加权限检查参数 + +### 3.5 Handler 层(API 端点) + +#### 3.5.1 新增 Handler + +- `simprint-server/src/handlers/client/workspaces.rs`:工作空间 API 端点 +- `simprint-server/src/handlers/client/workspace_quotas.rs`:工作空间配额 API 端点 +- `simprint-server/src/handlers/client/proxy_visibility.rs`:代理可见性 API 端点 +- `simprint-server/src/handlers/client/group_permissions.rs`:分组权限 API 端点 + +#### 3.5.2 修改现有 Handler + +- `simprint-server/src/handlers/client/teams.rs`:使用 `workspace_uuid` 从 `RequestContext` 获取 +- `simprint-server/src/handlers/client/environments.rs`:使用 `workspace_uuid` 和 `team_uuid` 从 `RequestContext` 获取 +- `simprint-server/src/handlers/client/proxies.rs`:使用 `workspace_uuid` 和 `owner_uuid` 从 `RequestContext` 获取 +- `simprint-server/src/handlers/client/templates.rs`:添加权限检查参数 + +### 3.6 Route 层(路由注册) + +#### 3.6.1 新增 Route + +- `simprint-server/src/routes/client/workspaces.rs`:工作空间路由 +- `simprint-server/src/routes/client/workspace_quotas.rs`:工作空间配额路由 +- `simprint-server/src/routes/client/proxy_visibility.rs`:代理可见性路由 +- `simprint-server/src/routes/client/group_permissions.rs`:分组权限路由 + +#### 3.6.2 修改现有 Route + +- `simprint-server/src/routes/client/mod.rs`:注册新路由模块 +- `simprint-server/src/main.rs`:注册所有新路由 + +### 3.7 中间件和状态管理 + +#### 3.7.1 状态管理(state.rs) + +- 添加 `CurrentWorkspace` 结构体 +- `RequestContext` 添加 `current_workspace_uuid` 字段 + +#### 3.7.2 认证中间件(middlewares/auth.rs) + +- 从 `user_infos` 获取 `current_workspace_uuid` 并设置到 `RequestContext` + +--- + +## 4. 核心功能实现 + +### 4.1 权限检查体系 + +#### 4.1.1 工作空间级别隔离 + +- 所有团队成员关系查询都包含 `workspace_uuid` 过滤 +- `fetch_team_member` 函数签名包含 `workspace_uuid` 参数 +- 确保用户在不同工作空间中有不同的团队成员身份 + +#### 4.1.2 环境权限检查 + +- **创建环境**: + - 检查用户是否在当前工作空间的团队中 + - 如果指定分组,检查分组 write/manage 权限 + - 如果未指定分组,检查团队角色权限(Editor/Admin/Owner) + - 检查工作空间配额 +- **查看环境**: + - 检查用户是否在当前工作空间的团队中 + - 如果环境有分组,检查分组 read 权限 + - Owner/Admin 自动拥有所有分组权限 + - 无分组环境,所有团队成员都可以查看 +- **更新环境**: + - 检查分组 write/manage 权限或团队角色权限(Editor/Admin/Owner) +- **删除环境**: + - 检查分组 manage 权限或团队 Owner/Admin 角色 + +#### 4.1.3 分组权限检查 + +- **创建分组**:只有 Owner/Admin 可以创建 +- **更新分组**:Owner/Admin 或拥有 manage 权限 +- **删除分组**:Owner/Admin 或拥有 manage 权限 +- **分组权限函数**:`check_group_permission` 自动处理 Owner/Admin 权限 + +### 4.2 配额管理体系 + +#### 4.2.1 环境配额 + +- **检查**:`check_quota(workspace_uuid, "environments")` +- **更新**: + - 创建环境:`increment_used_environments(workspace_uuid, 1)` + - 删除环境:`decrement_used_environments(workspace_uuid, 1)` + +#### 4.2.2 代理配额 + +- **检查**:`check_quota(workspace_uuid, "proxies")` +- **更新**: + - 创建代理:`increment_used_proxies(workspace_uuid, 1)` + - 删除代理:`decrement_used_proxies(workspace_uuid, 1)` + - 批量导入:每成功导入一个代理就更新一次 + +#### 4.2.3 成员配额 + +- **检查**:`check_quota(workspace_uuid, "team_members")` +- **更新**: + - 接受邀请:`update_used_team_members(workspace_uuid)`(仅当用户之前不是该工作空间的成员时) + - 移除成员:`update_used_team_members(workspace_uuid)` + - 退出团队:`update_used_team_members(workspace_uuid)` +- **注意**:成员配额是统计所有团队的活跃成员总数,需要重新计算 + +### 4.3 代理可见性管理 + +#### 4.3.1 可见性规则 + +1. **工作空间 Owner**:可以看到所有代理 +2. **代理所有者**:可以看到自己的代理(无论是否在可见列表中) +3. **团队成员**:只能看到 `proxy_visible_teams` 中包含其团队的代理 +4. **否则**:不可见 + +#### 4.3.2 可见性设置 + +- 只有代理所有者或工作空间所有者可以设置可见性 +- 通过 `proxy_visible_teams` 关联表控制可见性 +- 支持批量设置可见性 + +### 4.4 工作空间管理 + +#### 4.4.1 工作空间创建 + +- 用户注册时自动创建个人工作空间 +- 支持创建不同类型的工作空间(personal/team/enterprise) +- 自动创建默认配额 + +#### 4.4.2 工作空间切换 + +- 用户可以在多个工作空间之间切换 +- `user_infos.current_workspace_uuid` 记录当前工作空间 +- 认证中间件自动设置当前工作空间 + +--- + +## 5. API 端点变更 + +### 5.1 新增 API 端点 + +#### 5.1.1 工作空间相关 + +- `POST /workspaces/create`:创建工作空间 +- `POST /workspaces/list`:获取用户的工作空间列表 +- `POST /workspaces/get`:获取工作空间详情 +- `POST /workspaces/update`:更新工作空间 +- `POST /workspaces/delete`:删除工作空间 +- `POST /workspaces/switch`:切换工作空间 + +#### 5.1.2 工作空间配额相关 + +- `POST /workspace-quotas/get`:获取工作空间配额 +- `POST /workspace-quotas/update`:更新配额使用情况 + +#### 5.1.3 代理可见性相关 + +- `POST /proxy-visibility/set`:设置代理对团队可见 +- `POST /proxy-visibility/remove`:移除代理对团队的可见性 +- `POST /proxy-visibility/batch-set`:批量设置代理可见性 +- `POST /proxy-visibility/list-visible`:获取可见的代理列表 +- `POST /proxy-visibility/list-teams`:获取代理的可见团队列表 + +#### 5.1.4 分组权限相关 + +- `POST /group-permissions/grant`:授予分组权限 +- `POST /group-permissions/revoke`:撤销分组权限 +- `POST /group-permissions/check`:检查分组权限 +- `POST /group-permissions/list`:列出用户的分组权限 + +### 5.2 修改现有 API 端点 + +所有现有 API 端点现在都需要: + +- 从 `RequestContext` 获取 `current_workspace_uuid` +- 从 `RequestContext` 获取 `current_team_uuid`(如适用) +- 传递 `workspace_uuid` 和 `team_uuid` 到服务层 + +--- + +## 6. 数据模型设计要点 + +### 6.1 冗余字段设计 + +为了优化查询性能,以下表包含冗余字段: + +- `groups.workspace_uuid`:避免 JOIN 查询 +- `environments.workspace_uuid`:便于直接查询工作空间的环境 +- `team_members.workspace_uuid`:便于直接查询工作空间内的团队成员关系(工作空间级别的隔离) +- `group_member_permissions.workspace_uuid`、`team_uuid`:便于权限查询 +- `proxy_visible_teams.workspace_uuid`:便于直接查询工作空间内的代理可见性 + +### 6.2 软删除策略 + +所有表都支持软删除: + +- 使用 `deleted_at` 字段标记删除 +- 查询时默认过滤已删除记录 +- 支持数据恢复 + +### 6.3 时间戳管理 + +所有表都包含时间戳字段: + +- `created_at`:创建时间 +- `updated_at`:更新时间(自动更新) +- `deleted_at`:软删除时间(如适用) + +### 6.4 UUID 主键 + +所有表使用 UUID 作为主键: + +- 全局唯一标识 +- 避免 ID 冲突 +- 支持分布式系统 + +--- + +## 7. 关键设计决策 + +### 7.1 工作空间级别隔离 + +**决策**:团队成员关系是工作空间级别的,不是全局的。 + +**原因**: + +- 用户在不同工作空间中可能有不同的团队成员身份和角色 +- 提供更好的资源隔离和权限控制 +- 支持多工作空间场景 + +**实现**: + +- `team_members` 表包含 `workspace_uuid` 字段(冗余) +- 所有团队成员查询都包含 `workspace_uuid` 过滤 +- 唯一约束包含 `workspace_uuid`:`UNIQUE (team_uuid, user_uuid, workspace_uuid)` + +### 7.2 环境直接归属团队 + +**决策**:环境必须直接归属团队,分组是可选的分类容器。 + +**原因**: + +- 简化数据模型 +- 提供更清晰的权限控制 +- 支持无分组场景 + +**实现**: + +- `environments.team_uuid` 为 NOT NULL +- `environments.group_uuid` 为可选(NULL 表示直接归属团队) +- 权限控制:有分组用分组权限,无分组用团队权限 + +### 7.3 代理属于工作空间 + +**决策**:代理属于工作空间,不属于团队,通过可见性控制访问。 + +**原因**: + +- 代理是工作空间级别的资源 +- 支持跨团队共享代理 +- 提供更灵活的代理管理 + +**实现**: + +- `proxies.workspace_uuid` 为必须 +- `proxies.owner_uuid` 记录代理所有者 +- `proxy_visible_teams` 关联表控制可见性 + +### 7.4 配额基于工作空间 + +**决策**:配额是工作空间级别的,所有团队共享。 + +**原因**: + +- 简化配额管理 +- 支持统一计费 +- 便于资源分配 + +**实现**: + +- `workspace_quotas` 表存储工作空间配额 +- 所有配额检查和更新都基于 `workspace_uuid` +- 成员配额统计所有团队的活跃成员总数 + +--- + +## 8. 迁移和兼容性 + +### 8.1 数据迁移 + +- 为每个现有用户创建默认个人工作空间 +- 将现有 `user_quotas` 数据迁移到 `workspace_quotas` +- 更新所有相关表的 `workspace_uuid` 字段 +- 确保数据完整性 + +### 8.2 向后兼容 + +- 保留 `deprecated_user_quotas` 表用于历史数据 +- 迁移脚本确保所有现有数据都有对应的 `workspace_uuid` +- 用户注册时自动创建个人工作空间 + +--- + +## 9. 测试建议 + +### 9.1 单元测试 + +- 工作空间级别隔离测试 +- 权限检查函数测试 +- 配额管理函数测试 + +### 9.2 集成测试 + +- 代理可见性逻辑测试 +- 分组权限控制测试 +- 工作空间切换测试 + +### 9.3 安全测试 + +- 跨工作空间访问测试 +- 权限绕过测试 +- 配额绕过测试 + +--- + +## 10. 已知问题和限制 + +### 10.1 数据库约束 + +- 迁移脚本中注释掉了 `team_uuid` 的 NOT NULL 约束 +- **建议**:在数据迁移完成后,确保所有环境都有 `team_uuid`,然后添加 NOT NULL 约束 + +### 10.2 性能优化 + +- 冗余字段设计已优化查询性能 +- 建议添加适当的索引以进一步提升性能 + +--- + +## 11. 后续工作 + +### 11.1 待完成 + +- [ ] 在数据迁移完成后,添加 `team_uuid` 的 NOT NULL 约束 +- [ ] 添加单元测试和集成测试 +- [ ] 进行安全审查 + +### 11.2 优化建议 + +- [ ] 添加缓存层以提升性能 +- [ ] 优化配额统计查询 +- [ ] 添加配额使用情况监控 + +--- + +## 12. 总结 + +本次重构实现了完整的工作空间架构,包括: + +✅ **核心功能**: + +- 工作空间管理 +- 工作空间级别隔离 +- 权限检查体系 +- 配额管理体系 +- 代理可见性管理 + +✅ **数据模型**: + +- 4 个新表 +- 7 个表的结构修改 +- 完整的数据迁移脚本 + +✅ **代码实现**: + +- 4 个新的 Service 模块 +- 4 个新的 Handler 模块 +- 4 个新的 Route 模块 +- 所有现有模块的更新 + +✅ **API 端点**: + +- 15+ 个新的 API 端点 +- 所有现有端点的更新 + +所有实现都符合 `workspace-architecture.md` 文档的设计要求,确保了系统的可扩展性、安全性和可维护性。 + +--- + +**报告生成时间**:2025-01-25 +**报告版本**:v1.0 +**文档维护者**:开发团队 diff --git a/docs/workspace-architecture.md b/docs/workspace-architecture.md new file mode 100644 index 0000000..3403ff9 --- /dev/null +++ b/docs/workspace-architecture.md @@ -0,0 +1,884 @@ +# 工作空间架构设计 + +## 概述 + +本文档描述了 Simprint 系统的核心架构设计,采用"用户-工作空间-团队-分组-环境"的多层级结构,实现灵活的资源管理、权限控制和计费体系。 + +## 核心概念 + +### 层级结构 + +``` +用户(User) + └─ 工作空间(Workspace) + ├─ 团队(Team) + │ ├─ 成员(Team Members) + │ └─ 分组(Group) + │ └─ 环境(Environment) + ├─ 代理(Proxy) + ├─ 配额(Quota) + └─ 订阅(Subscription) +``` + +### 关键设计原则 + +1. **工作空间是资源隔离边界**:所有资源(环境、代理、配额)都归属于工作空间 +2. **团队是协作组织单元**:团队成员共享团队资源,通过分组进行细粒度管理 +3. **分组是环境分类容器**:分组用于对团队内的环境进行分类和权限控制 +4. **环境是核心操作单元**:所有业务操作围绕环境展开 + +## 实体关系图 + +### 核心实体关系 + +``` + ┌───────────────────────┐ + │ users │ + │ (用户基础信息) │ + └───────────┬───────────┘ + │ + │ 1:N + │ + ┌───────────────────────────┼───────────────────────────┐ + │ │ │ + │ │ │ + ▼ ▼ ▼ + ┌───────────────┐ ┌───────────────┐ ┌───────────────┐ + │ workspaces │ │ user_infos │ │ user_wallets │ + │ (工作空间) │ │ (用户详情) │ │ (用户钱包) │ + └───────┬───────┘ └───────────────┘ └───────────────┘ + │ + │ 1:N (owner) + │ + ▼ + ┌───────────────┐ + │ teams │◄──────────┐ + │ (团队) │ │ + └───────┬───────┘ │ + │ │ N:M + │ │ + ┌───────────┼───────────┐ │ + │ │ │ │ + │ 1:N │ 1:N │ │ + │ (可选) │ (必须) │ │ + │ │ │ │ + ▼ ▼ │ │ +┌───────────────┐ ┌───────────────┐ │ +│ groups │ │ environments │ │ +│ (分组) │ │ (环境) │ │ +│ (可选分类) │ │ (直接归属团队) │ │ +└───────┬───────┘ └───────┬───────┘ │ + │ │ │ + │ N:1 (可选) │ N:1 (必须)│ + │ (环境可选归属分组)│ (环境必须归属团队)│ + │ │ │ + └─────────┬───────┘ │ + │ │ + │ │ + │ + │ + ┌───────────────┴───────────────┐ + │ │ + ▼ ▼ + ┌───────────────┐ ┌───────────────┐ + │team_members │──────────────►│ users │ + │(团队成员关系) │ │ (用户) │ + │(工作空间级别) │ │ │ + └───────┬───────┘ └───────────────┘ + │ + │ N:1 (冗余) + │ (通过 team.workspace_uuid) + │ + ▼ + ┌───────────────┐ + │ workspaces │ + │ (工作空间) │ + └───────────────┘ +``` + +### 工作空间资源关系 + +``` + ┌───────────────┐ + │ workspaces │ + │ (工作空间) │ + └───────┬───────┘ + │ + ┌───────────────────┼───────────────────┐ + │ │ │ + │ 1:N │ 1:1 │ 1:N + │ │ │ + ▼ ▼ ▼ +┌───────────────┐ ┌───────────────┐ ┌───────────────┐ +│ teams │ │workspace_quotas│ │ subscriptions │ +│ (团队) │ │ (工作空间配额) │ │ (订阅) │ +└───────┬───────┘ └───────────────┘ └───────────────┘ + │ + │ 1:N + │ + ▼ +┌───────────────┐ +│ groups │ +│ (分组) │ +└───────┬───────┘ + │ + │ 1:N (可选) + │ (分组可以没有环境) + │ + ▼ +┌───────────────┐ +│ environments │ +│ (环境) │ +└───────┬───────┘ + │ + │ N:1 (必须) + │ (环境必须属于团队) + │ + ▼ +┌───────────────┐ +│ teams │ +│ (团队) │ +└───────────────┘ +``` + +### 代理资源关系 + +``` + ┌───────────────┐ + │ workspaces │ + │ (工作空间) │ + └───────┬───────┘ + │ + │ 1:N + │ + ▼ + ┌───────────────┐ + │ proxies │ + │ (代理) │ + │ (工作空间级别) │ + └───────┬───────┘ + │ + ┌───────────────────┼───────────────────┐ + │ │ │ + │ N:1 (可选) │ N:M │ N:1 + │ (环境选择代理) │ (可见性控制) │ (所有者) + │ │ │ + ▼ ▼ ▼ +┌───────────────┐ ┌───────────────┐ ┌───────────────┐ +│ environments │ │proxy_visible_ │ │ users │ +│ (环境) │ │teams │ │ (用户) │ +│ │ │(代理可见团队) │ │ (所有者) │ +└───────────────┘ └───────┬───────┘ └───────────────┘ + │ + │ N:1 + │ + ▼ + ┌───────────────┐ + │ teams │ + │ (团队) │ + └───────────────┘ +``` + +### 权限控制关系 + +``` + ┌───────────────┐ + │ workspaces │ + │ (工作空间) │ + └───────┬───────┘ + │ + │ 1:N + │ + ▼ + ┌───────────────┐ + │ teams │ + │ (团队) │ + └───────┬───────┘ + │ + ┌───────────────────┼───────────────────┐ + │ │ │ + │ 1:N │ N:M │ 1:N + │ │ (工作空间级别) │ + │ │ │ + ▼ ▼ ▼ +┌───────────────┐ ┌───────────────┐ ┌───────────────┐ +│ groups │ │team_members │ │group_member_ │ +│ (分组) │ │(团队成员关系) │ │permissions │ +│ │ │(工作空间级别) │ │(分组权限) │ +└───────┬───────┘ └───────┬───────┘ └───────┬───────┘ + │ │ │ + │ N:1 │ N:1 │ N:1 + │ │ │ + ▼ ▼ ▼ +┌───────────────┐ ┌───────────────┐ ┌───────────────┐ +│ environments │ │ users │ │ users │ +│ (环境) │ │ (用户) │ │ (用户) │ +└───────────────┘ └───────────────┘ └───────────────┘ +``` + +## 实体详细设计 + +### 1. 用户(Users) + +**职责**:系统的基础实体,代表使用系统的个人。 + +**关键属性**: + +- `uuid`:用户唯一标识 +- `id`:业务层用户ID +- 与工作空间的关系:一个用户可以有多个工作空间(作为 Owner) + +**关系**: + +- 1:N → 工作空间(作为 Owner) +- N:M → 团队(通过 team_members,工作空间级别) +- N:M → 分组权限(通过 group_member_permissions,工作空间级别) + +**设计要点**: + +- 用户在不同工作空间中,可能有不同的团队成员身份 +- 团队成员关系是工作空间级别的,不是全局的 + +### 2. 工作空间(Workspaces) + +**职责**:资源隔离的顶层容器,所有资源都归属于工作空间。 + +**关键属性**: + +- `uuid`:工作空间唯一标识 +- `name`:工作空间名称 +- `owner_uuid`:所有者用户 UUID +- `workspace_type`:工作空间类型(personal/team/enterprise) + +**关系**: + +- N:1 → 用户(Owner) +- 1:N → 团队 +- 1:1 → 工作空间配额 +- 1:N → 订阅 +- 1:N → 代理(工作空间级别) + +**设计要点**: + +- 工作空间是计费和配额的基础单元 +- 工作空间内的所有资源共享配额 +- 工作空间 Owner 拥有最高权限 + +### 3. 团队(Teams) + +**职责**:工作空间内的协作组织单元,管理团队成员和团队资源。 + +**关键属性**: + +- `uuid`:团队唯一标识 +- `workspace_uuid`:所属工作空间 UUID +- `name`:团队名称 +- `owner_uuid`:团队所有者 UUID +- `description`:团队描述 + +**关系**: + +- N:1 → 工作空间 +- N:1 → 用户(Owner) +- N:M → 用户(通过 team_members) +- 1:N → 分组(可选,团队可以没有分组) +- 1:N → 环境(直接关联,环境必须属于团队) + +**设计要点**: + +- 团队是权限管理的基础单元 +- 团队成员通过 team_members 表关联 +- 团队 Owner/Admin 可以管理团队内的分组和权限 + +### 4. 分组(Groups) + +**职责**:团队内环境的分类容器,用于环境组织和权限控制(可选功能)。 + +**关键属性**: + +- `uuid`:分组唯一标识 +- `workspace_uuid`:所属工作空间 UUID(冗余,便于查询) +- `team_uuid`:所属团队 UUID +- `name`:分组名称 +- `description`:分组描述 + +**关系**: + +- N:1 → 工作空间 +- N:1 → 团队 +- 1:N → 环境(可选,分组可以没有环境) +- N:M → 用户(通过 group_member_permissions) + +**设计要点**: + +- 分组属于团队,用于对团队内的环境进行分类 +- **分组可以独立存在,不一定要有环境**(分组可以预先创建,等待环境加入) +- 分组权限控制团队成员对分组内环境的访问 +- 分组是可选功能,环境可以不使用分组直接归属团队 + +### 5. 环境(Environments) + +**职责**:系统的核心操作单元,所有业务操作围绕环境展开。 + +**关键属性**: + +- `uuid`:环境唯一标识 +- `workspace_uuid`:所属工作空间 UUID(冗余,便于查询) +- `team_uuid`:所属团队 UUID(必须) +- `group_uuid`:所属分组 UUID(可选,NULL 表示直接归属团队) +- `proxy_uuid`:环境使用的代理 UUID(可选,用户明确选择) +- `name`:环境名称 +- `status`:环境状态 + +**关系**: + +- N:1 → 工作空间 +- N:1 → 团队(必须) +- N:1 → 分组(可选) +- N:1 → 代理(可选) + +**设计要点**: + +- **环境必须直接归属团队**(`team_uuid` NOT NULL) +- **环境可以属于某个分组**(`group_uuid` 可选,NULL 表示未分组) +- **环境直接归属团队时,使用团队级别的权限控制** +- **环境可以设置代理**(`proxy_uuid` 可选),用户从可见的代理列表中选择 +- **没有代理继承概念**,用户必须明确选择代理 + +### 6. 代理(Proxies) + +**职责**:网络代理资源,为环境提供网络访问能力。 + +**关键属性**: + +- `uuid`:代理唯一标识 +- `workspace_uuid`:所属工作空间 UUID(必须) +- `owner_uuid`:代理所有者 UUID(创建者) +- `name`:代理名称 +- `proxy_type`:代理类型 +- `host`、`port`:代理地址 +- `status`:代理状态(active/inactive/testing/error) + +**关系**: + +- N:1 → 工作空间(必须) +- N:1 → 用户(Owner,创建者) +- 1:N → 环境(通过环境选择,可选) +- N:M → 团队(通过 proxy_visible_teams,控制可见性) + +**设计要点**: + +- **代理属于工作空间**,不属于团队(`team_uuid` 不存在) +- **代理默认不可见**,只有所有者可以看到 +- **代理可见性通过关联表控制**:`proxy_visible_teams` 表存储代理对哪些团队可见 +- **代理所有者可以设置可见性**:可以设置代理对特定团队可见 +- **工作空间 Owner 可以看到所有代理**,并可以管理所有代理 +- **创建环境时,代理是可选的**,用户从可见的代理列表中选择 +- **没有继承概念**,用户必须明确选择代理 + +### 7. 团队成员(Team Members) + +**职责**:用户与团队的关联关系,定义用户在团队中的角色(工作空间级别)。 + +**关键属性**: + +- `team_uuid`:团队 UUID +- `workspace_uuid`:工作空间 UUID(冗余,便于查询) +- `user_uuid`:用户 UUID +- `role`:角色(owner/admin/editor/viewer) +- `status`:状态(active/pending/inactive) + +**关系**: + +- N:1 → 团队 +- N:1 → 用户 +- N:1 → 工作空间(冗余,通过 team.workspace_uuid 关联) + +**设计要点**: + +- **团队成员关系是工作空间级别的**:用户在某个工作空间加入团队,只在该工作空间内属于该团队 +- 同一用户在不同工作空间中,可能有不同的团队成员身份和角色 +- 定义用户在团队中的角色和权限 +- Owner/Admin 拥有团队管理权限 +- Editor 可以创建和编辑环境 +- Viewer 只能查看环境 +- `workspace_uuid` 作为冗余字段,便于直接查询工作空间内的团队成员关系 + +### 8. 分组权限(Group Member Permissions) + +**职责**:控制团队成员对分组的访问权限,实现细粒度权限控制。 + +**关键属性**: + +- `group_uuid`:分组 UUID +- `workspace_uuid`:工作空间 UUID(冗余,便于查询) +- `team_uuid`:团队 UUID(冗余,便于查询) +- `user_uuid`:用户 UUID +- `permission_type`:权限类型(read/write/manage) +- `granted_by`:授权者 UUID + +**关系**: + +- N:1 → 分组 +- N:1 → 用户 +- N:1 → 工作空间(冗余) +- N:1 → 团队(冗余) + +**设计要点**: + +- 只有团队成员才能被授予分组权限 +- Owner/Admin 自动拥有所有分组权限(无需显式授权) +- Editor/Viewer 需要显式授权才能访问分组 +- 权限类型:read(查看)、write(创建/编辑)、manage(管理分组) + +### 9. 工作空间配额(Workspace Quotas) + +**职责**:定义工作空间的资源配额限制。 + +**关键属性**: + +- `workspace_uuid`:工作空间 UUID +- `max_environments`:最大环境数 +- `used_environments`:已使用环境数 +- `max_team_members`:最大成员数(所有团队总和) +- `max_proxies`:最大代理数 +- `used_proxies`:已使用代理数 +- `max_rpa_tasks`:最大 RPA 任务数 +- `used_rpa_tasks`:已使用 RPA 任务数 + +**关系**: + +- 1:1 → 工作空间 + +**设计要点**: + +- 配额是工作空间级别的,所有团队共享 +- 配额由订阅套餐决定 +- 配额使用情况实时统计 + +### 10. 订阅(Subscriptions) + +**职责**:工作空间的订阅信息,关联套餐和计费。 + +**关键属性**: + +- `uuid`:订阅唯一标识 +- `workspace_uuid`:工作空间 UUID +- `user_uuid`:订阅者用户 UUID(用于记录) +- `plan_uuid`:套餐 UUID +- `billing_period`:计费周期 +- `status`:订阅状态 + +**关系**: + +- N:1 → 工作空间 +- N:1 → 用户(订阅者) +- N:1 → 套餐 + +**设计要点**: + +- 订阅关联到工作空间,而非用户 +- 工作空间 Owner 负责订阅和计费 +- 订阅决定工作空间的配额 + +## 权限控制体系 + +### 权限层级 + +``` +工作空间级别 + └─ Owner:拥有工作空间的所有权限 + ├─ 管理团队 + ├─ 管理订阅和配额 + └─ 管理工作空间级别的代理 + +团队级别 + └─ Owner/Admin:拥有团队的所有权限 + ├─ 管理团队成员 + ├─ 管理分组 + └─ 管理分组权限 + └─ Editor:可以创建和编辑环境 + └─ Viewer:只能查看环境 + +分组级别 + └─ Manage:可以管理分组(编辑分组、授权用户) + └─ Write:可以创建和编辑分组内的环境 + └─ Read:可以查看分组内的环境 +``` + +### 权限检查流程 + +**重要前提**:所有权限检查都在当前工作空间上下文中进行。 + +**创建环境**: + +1. 检查用户是否在当前工作空间的团队中(`team_members.workspace_uuid = 当前工作空间`) +2. 如果指定了分组(`group_uuid` 不为 NULL): + - 检查用户是否有目标分组的 write 或 manage 权限(在当前工作空间上下文中) +3. 如果未指定分组(`group_uuid` 为 NULL): + - 检查用户是否有团队级别的环境创建权限(Editor/Admin/Owner) +4. 检查工作空间配额是否充足 + +**查看环境**: + +1. 检查用户是否在当前工作空间的团队中 +2. 如果环境有分组(`group_uuid` 不为 NULL): + - 检查用户是否有分组的 read/write/manage 权限(在当前工作空间上下文中) + - Owner/Admin 自动拥有所有分组权限 +3. 如果环境无分组(`group_uuid` 为 NULL): + - 检查用户是否有团队级别的查看权限(所有团队成员都可以查看) + +**管理分组**: + +1. 检查用户是否在当前工作空间的团队中 +2. 检查用户是否是团队 Owner/Admin 或拥有分组的 manage 权限(在当前工作空间上下文中) + +**未分组环境的权限规则**: + +- Owner/Admin:拥有所有权限(创建、编辑、删除) +- Editor:可以创建和编辑环境 +- Viewer:只能查看环境 +- 所有团队成员默认可以查看未分组环境 + +### 工作空间级别的团队成员关系 + +**核心设计**: + +- 用户在某个工作空间加入团队,只在该工作空间内属于该团队 +- 同一用户在不同工作空间中,可能有不同的团队成员身份和角色 +- 团队成员关系通过 `team_members` 表关联,包含 `workspace_uuid` 字段(冗余,便于查询) + +**示例场景**: + +``` +用户A: + - 在工作空间1:团队X的 Owner + - 在工作空间2:团队Y的 Editor + - 在工作空间3:团队Z的 Viewer +``` + +**查询逻辑**: + +- 查询用户在某工作空间的团队:`WHERE user_uuid = $1 AND workspace_uuid = $2` +- 查询团队在某工作空间的成员:`WHERE team_uuid = $1 AND workspace_uuid = $2` +- 通过 `team.workspace_uuid` 可以间接关联,但冗余 `workspace_uuid` 字段可以提高查询性能 + +## 配额管理 + +### 配额层级 + +``` +工作空间配额(workspace_quotas) + └─ 所有团队共享 + └─ 由订阅套餐决定 + └─ 实时统计使用情况 +``` + +### 配额检查 + +**环境配额**: + +- 统计工作空间内所有团队的环境总数 +- 检查 `used_environments < max_environments` + +**代理配额**: + +- 统计工作空间内的代理总数 +- 检查 `used_proxies < max_proxies` + +**成员配额**: + +- 统计工作空间内所有团队的活跃成员总数 +- 检查 `used_team_members < max_team_members` + +## 代理管理 + +### 代理可见性设计 + +**核心设计**: + +- 代理属于工作空间,不属于团队 +- 代理默认不可见,只有所有者可以看到 +- 代理所有者可以设置代理对特定团队可见 +- 通过 `proxy_visible_teams` 关联表控制可见性 + +**可见性规则**: + +``` +代理可见性检查(优先级从高到低): +1. 工作空间 Owner:可以看到所有代理 +2. 代理所有者:可以看到自己的代理(无论是否在可见列表中) +3. 团队成员:只能看到 proxy_visible_teams 中包含其团队的代理 +4. 否则:不可见 +``` + +**数据模型**: + +```sql +-- 代理表 +proxies: + - workspace_uuid(必须) + - owner_uuid(必须,创建者) + - name, proxy_type, host, port 等 + +-- 代理可见团队关联表 +proxy_visible_teams: + - proxy_uuid + - workspace_uuid(冗余,便于查询) + - team_uuid + - UNIQUE (proxy_uuid, team_uuid) +``` + +### 代理使用流程 + +**创建环境时的代理选择**: + +1. 用户选择当前工作空间 +2. 用户选择当前团队 +3. 系统查询用户可见的代理: + - 工作空间 Owner:所有代理 + - 代理所有者:自己的代理 + - 团队成员:`proxy_visible_teams` 中包含其团队的代理 +4. 如果没有任何可见的代理: + - 显示"没有可用的代理" + - 提供"上传新代理"选项 +5. 用户从可见代理列表中选择(可选,代理不是必须的) +6. 环境创建时,`proxy_uuid` 设置为用户选择的代理(如果选择了) + +**代理选择逻辑**: + +- 代理是可选的,创建环境时可以不选择代理 +- 用户从可见的代理列表中选择 +- 没有继承概念,用户必须明确选择 +- 如果代理不可见,用户无法选择 + +## 数据模型设计要点 + +### 1. 冗余字段设计 + +为了优化查询性能,部分表包含冗余字段: + +- `groups.workspace_uuid`:冗余字段,避免 JOIN 查询 +- `environments.workspace_uuid`:冗余字段,便于直接查询工作空间的环境 +- `team_members.workspace_uuid`:冗余字段,便于直接查询工作空间内的团队成员关系(工作空间级别的隔离) +- `group_member_permissions.workspace_uuid`、`team_uuid`:冗余字段,便于权限查询 +- `proxy_visible_teams.workspace_uuid`:冗余字段,便于直接查询工作空间内的代理可见性 + +**工作空间级别的隔离设计**: + +- `team_members` 表包含 `workspace_uuid` 字段(冗余),确保团队成员关系是工作空间级别的 +- 查询时始终需要指定 `workspace_uuid`,确保数据隔离 +- 通过 `team.workspace_uuid` 可以间接关联,但冗余字段提高查询性能 + +### 2. 软删除策略 + +所有表都支持软删除: + +- 使用 `deleted_at` 字段标记删除 +- 查询时默认过滤已删除记录 +- 支持数据恢复 + +### 3. 时间戳管理 + +所有表都包含时间戳字段: + +- `created_at`:创建时间 +- `updated_at`:更新时间(自动更新) +- 部分表包含 `deleted_at`(软删除时间) + +### 4. UUID 主键 + +所有表使用 UUID 作为主键: + +- 全局唯一标识 +- 避免 ID 冲突 +- 支持分布式系统 + +## 设计优势 + +### 1. 清晰的层级结构 + +- 用户 → 工作空间 → 团队 → 分组 → 环境 +- 每一层都有明确的职责和边界 +- 易于理解和维护 + +### 2. 灵活的权限控制 + +- 工作空间级别:Owner 管理 +- 团队级别:角色权限 +- 分组级别:细粒度权限 +- 支持复杂的权限场景 + +### 3. 统一的资源管理 + +- 所有资源归属于工作空间 +- 配额和计费基于工作空间 +- 资源隔离清晰 + +### 4. 良好的扩展性 + +- 支持多工作空间 +- 支持多团队协作 +- 支持细粒度权限控制 +- 易于扩展新功能 + +### 5. 符合业务逻辑 + +- 工作空间 = 企业账户/项目空间 +- 团队 = 部门/协作组 +- 分组 = 环境分类 +- 环境 = 核心操作单元 + +## 分组与环境的关系设计 + +### 设计原则 + +1. **环境直接归属团队**:环境必须属于某个团队(`team_uuid` NOT NULL),这是环境的基础归属关系。 + +2. **分组是可选分类容器**:环境可以可选归属分组(`group_uuid` 可选),分组用于环境分类和权限控制,但不是必需的。 + +3. **分组可以独立存在**:分组可以预先创建,不一定要有环境。团队管理者可以创建分组并设置权限,等待环境加入。 + +4. **灵活的权限控制**: + - 有分组的环境:使用分组权限控制 + - 无分组的环境:使用团队级别权限控制 + +### 使用场景 + +**场景1:环境直接归属团队(无分组)** + +``` +团队 + └─ 环境1(group_uuid = NULL) + └─ 环境2(group_uuid = NULL) + └─ 环境3(group_uuid = NULL) +``` + +- 适用于简单场景,不需要复杂分类 +- 权限控制基于团队角色(Owner/Admin/Editor/Viewer) +- 所有团队成员可以查看,Editor 及以上可以创建/编辑 + +**场景2:环境归属分组** + +``` +团队 + ├─ 分组A + │ └─ 环境1(group_uuid = 分组A) + │ └─ 环境2(group_uuid = 分组A) + └─ 分组B + └─ 环境3(group_uuid = 分组B) +``` + +- 适用于需要环境分类的场景 +- 权限控制基于分组权限(read/write/manage) +- 可以实现细粒度的权限控制 + +**场景3:混合模式** + +``` +团队 + ├─ 环境1(group_uuid = NULL,直接归属团队) + ├─ 分组A + │ └─ 环境2(group_uuid = 分组A) + └─ 分组B(空分组,等待环境加入) +``` + +- 部分环境使用分组,部分环境直接归属团队 +- 提供最大的灵活性 + +### 降级方案 + +如果某些场景需要强制使用分组,可以采用以下降级方案: + +1. **默认分组**:团队创建时自动创建一个"默认分组" +2. **强制分组**:创建环境时,如果未指定分组,自动归属默认分组 +3. **透明处理**:前端可以隐藏默认分组,让用户感觉环境直接归属团队 + +但更推荐的设计是:**环境可以直接归属团队,分组是可选的分类功能**。 + +## 代理可见性管理 + +### 核心设计 + +**代理归属**: + +- 代理属于工作空间,不属于团队(`team_uuid` 不存在) +- 代理有所有者(`owner_uuid`),创建者即为所有者 +- 代理默认不可见,只有所有者可以看到 + +**可见性控制**: + +- 代理所有者可以设置代理对特定团队可见(通过 `proxy_visible_teams` 关联表) +- 工作空间 Owner 可以看到所有代理,并可以管理所有代理 +- 团队成员只能看到 `proxy_visible_teams` 中包含其团队的代理 + +**数据模型**: + +```sql +-- 代理表 +proxies: + - uuid + - workspace_uuid(必须) + - owner_uuid(必须,创建者) + - name, proxy_type, host, port, status 等 + +-- 代理可见团队关联表 +proxy_visible_teams: + - proxy_uuid + - workspace_uuid(冗余,便于查询) + - team_uuid + - UNIQUE (proxy_uuid, team_uuid) + - FOREIGN KEY (proxy_uuid) REFERENCES proxies(uuid) ON DELETE CASCADE + - FOREIGN KEY (team_uuid) REFERENCES teams(uuid) ON DELETE CASCADE +``` + +**可见性检查逻辑**(优先级从高到低): + +1. **工作空间 Owner**:可以看到所有代理 +2. **代理所有者**:可以看到自己的代理(无论是否在可见列表中) +3. **团队成员**:只能看到 `proxy_visible_teams` 中包含其团队的代理 +4. **否则**:不可见 + +### 使用流程 + +**创建环境时的代理选择**: + +1. 用户选择当前工作空间 +2. 用户选择当前团队 +3. 系统查询用户可见的代理: + - 工作空间 Owner:所有代理 + - 代理所有者:自己的代理 + - 团队成员:`proxy_visible_teams` 中包含其团队的代理 +4. 如果没有任何可见的代理: + - 显示"没有可用的代理" + - 提供"上传新代理"选项 +5. 用户从可见代理列表中选择(可选,代理不是必须的) +6. 环境创建时,`proxy_uuid` 设置为用户选择的代理(如果选择了) + +**代理管理**: + +- 代理所有者可以添加/移除团队的可见性 +- 通过 `proxy_visible_teams` 表管理可见性 +- 团队删除时,关联的可见性记录自动删除(ON DELETE CASCADE) +- 代理删除时,关联的可见性记录自动删除(ON DELETE CASCADE) + +**设计要点**: + +- **代理是可选的**,创建环境时可以不选择代理 +- **没有继承概念**,用户必须明确选择代理 +- **代理默认不可见**,需要主动设置为可见 +- **管理员创建代理后,需要设置为可见,团队成员才能看到** +- **团队成员创建环境时,只看到可见的代理列表** + +## 总结 + +本架构设计通过"用户-工作空间-团队-分组-环境"的多层级结构,实现了灵活的资源管理、权限控制和计费体系。工作空间作为资源隔离的顶层容器,团队作为协作组织单元,分组作为可选的环境分类容器,环境作为核心操作单元直接归属团队,形成了一个清晰、可扩展的系统架构。 + +**核心设计特点**: + +- 环境必须直接归属团队,分组是可选的分类功能 +- 分组可以独立存在,不一定要有环境 +- 灵活的权限控制:有分组用分组权限,无分组用团队权限 +- 支持混合使用:部分环境使用分组,部分环境直接归属团队 + +该架构既支持个人用户的使用场景,也支持企业级的多团队协作场景,同时通过细粒度的权限控制,确保了资源的安全性和可控性。 diff --git a/migrations/20250101000001_create_users.sql b/migrations/20250101000001_create_users.sql new file mode 100644 index 0000000..9e019ec --- /dev/null +++ b/migrations/20250101000001_create_users.sql @@ -0,0 +1,29 @@ +-- 创建 users 表 +-- 用户基础信息表,存储用户的基础标识信息 + +CREATE TABLE IF NOT EXISTS users ( + uuid UUID PRIMARY KEY DEFAULT gen_random_uuid(), + id VARCHAR(255) NOT NULL UNIQUE, + created_at TIMESTAMP WITH TIME ZONE NOT NULL DEFAULT CURRENT_TIMESTAMP, + updated_at TIMESTAMP WITH TIME ZONE NOT NULL DEFAULT CURRENT_TIMESTAMP, + deleted_at TIMESTAMP WITH TIME ZONE +); + +-- 创建索引 +CREATE INDEX IF NOT EXISTS idx_users_deleted_at ON users(deleted_at); + +-- 创建更新时间触发器 +CREATE OR REPLACE FUNCTION update_updated_at_column() +RETURNS TRIGGER AS $$ +BEGIN + NEW.updated_at = CURRENT_TIMESTAMP; + RETURN NEW; +END; +$$ language 'plpgsql'; + +CREATE TRIGGER update_users_updated_at BEFORE UPDATE ON users + FOR EACH ROW EXECUTE FUNCTION update_updated_at_column(); + + + + diff --git a/migrations/20250101000002_create_user_infos.sql b/migrations/20250101000002_create_user_infos.sql new file mode 100644 index 0000000..774c504 --- /dev/null +++ b/migrations/20250101000002_create_user_infos.sql @@ -0,0 +1,31 @@ +-- 创建 user_infos 表 +-- 用户详细信息表,存储用户的详细业务信息 + +CREATE TABLE IF NOT EXISTS user_infos ( + id SERIAL PRIMARY KEY, + user_uuid UUID NOT NULL UNIQUE, + nickname VARCHAR(255), + email VARCHAR(255) NOT NULL UNIQUE, + phone VARCHAR(50), + password VARCHAR(255) NOT NULL, + avatar_hash VARCHAR(255), + status VARCHAR(50) NOT NULL DEFAULT 'active', + created_at TIMESTAMP WITH TIME ZONE NOT NULL DEFAULT CURRENT_TIMESTAMP, + updated_at TIMESTAMP WITH TIME ZONE NOT NULL DEFAULT CURRENT_TIMESTAMP, + deleted_at TIMESTAMP WITH TIME ZONE, + CONSTRAINT fk_user_infos_user_uuid FOREIGN KEY (user_uuid) REFERENCES users(uuid) ON DELETE CASCADE +); + +-- 创建索引 +CREATE UNIQUE INDEX IF NOT EXISTS idx_user_infos_user_uuid ON user_infos(user_uuid); +CREATE UNIQUE INDEX IF NOT EXISTS idx_user_infos_email ON user_infos(email); +CREATE INDEX IF NOT EXISTS idx_user_infos_deleted_at ON user_infos(deleted_at); +CREATE INDEX IF NOT EXISTS idx_user_infos_status ON user_infos(status); + +-- 创建更新时间触发器 +CREATE TRIGGER update_user_infos_updated_at BEFORE UPDATE ON user_infos + FOR EACH ROW EXECUTE FUNCTION update_updated_at_column(); + + + + diff --git a/migrations/20250101000003_create_machine_users_table.sql b/migrations/20250101000003_create_machine_users_table.sql new file mode 100644 index 0000000..a5ebc5c --- /dev/null +++ b/migrations/20250101000003_create_machine_users_table.sql @@ -0,0 +1,41 @@ +-- Add migration script here +-- public.machine_users definition + +-- Drop table +-- DROP TABLE public.machine_users; + +CREATE TABLE public.machine_users ( + id int4 GENERATED BY DEFAULT AS IDENTITY( INCREMENT BY 1 MINVALUE 1 MAXVALUE 2147483647 START 1 CACHE 1 NO CYCLE) NOT NULL, + machine_code varchar(255) NOT NULL, -- 机器码 + user_uuid uuid NULL, -- 用户UUID + platform varchar(50) NULL, -- 平台 + bind_time timestamptz DEFAULT CURRENT_TIMESTAMP NOT NULL, -- 绑定时间 + status varchar(20) DEFAULT 'active'::character varying NOT NULL, -- 状态 + version_info jsonb NULL, -- 版本信息(JSON格式) + hardware_hash varchar(255) NULL, -- 硬件哈希 + hardware_raw text NULL, -- 硬件原始信息 + allow boolean DEFAULT true NOT NULL, -- 是否允许使用 + created_at timestamptz DEFAULT CURRENT_TIMESTAMP NOT NULL, -- 创建时间 + updated_at timestamptz NULL, -- 更新时间 + CONSTRAINT machine_users_pk PRIMARY KEY (id) +); + +CREATE INDEX machine_users_machine_code_idx ON public.machine_users USING btree (machine_code); +CREATE INDEX machine_users_user_uuid_idx ON public.machine_users USING btree (user_uuid); +CREATE INDEX machine_users_status_idx ON public.machine_users USING btree (status); +CREATE INDEX machine_users_platform_idx ON public.machine_users USING btree (platform); + +-- Column comments +COMMENT ON COLUMN public.machine_users.id IS '主键ID'; +COMMENT ON COLUMN public.machine_users.machine_code IS '机器码'; +COMMENT ON COLUMN public.machine_users.user_uuid IS '用户UUID'; +COMMENT ON COLUMN public.machine_users.platform IS '平台'; +COMMENT ON COLUMN public.machine_users.bind_time IS '绑定时间'; +COMMENT ON COLUMN public.machine_users.status IS '状态'; +COMMENT ON COLUMN public.machine_users.version_info IS '版本信息(JSON格式,存储versions表的信息)'; +COMMENT ON COLUMN public.machine_users.hardware_hash IS '硬件哈希'; +COMMENT ON COLUMN public.machine_users.hardware_raw IS '硬件原始信息'; +COMMENT ON COLUMN public.machine_users.allow IS '是否允许使用'; +COMMENT ON COLUMN public.machine_users.created_at IS '创建时间'; +COMMENT ON COLUMN public.machine_users.updated_at IS '更新时间'; + diff --git a/migrations/20250101000004_create_version_types_table.sql b/migrations/20250101000004_create_version_types_table.sql new file mode 100644 index 0000000..3ebc8d9 --- /dev/null +++ b/migrations/20250101000004_create_version_types_table.sql @@ -0,0 +1,26 @@ +-- Add migration script here +-- public.version_types definition + +-- Drop table +-- DROP TABLE public.version_types; + +CREATE TABLE public.version_types ( + id int4 GENERATED BY DEFAULT AS IDENTITY( INCREMENT BY 1 MINVALUE 1 MAXVALUE 2147483647 START 1 CACHE 1 NO CYCLE) NOT NULL, + type_code varchar(50) NOT NULL, -- 类型代码 + type_name varchar(100) NOT NULL, -- 类型名称 + description text NULL, -- 描述 + sort_order int4 DEFAULT 0 NULL, -- 排序顺序 + is_active bool DEFAULT true NOT NULL, -- 是否激活 + CONSTRAINT version_types_pk PRIMARY KEY (id), + CONSTRAINT version_types_type_code_unique UNIQUE (type_code) +); +CREATE UNIQUE INDEX version_types_type_code_idx ON public.version_types USING btree (type_code); + +-- Column comments +COMMENT ON COLUMN public.version_types.id IS '主键ID'; +COMMENT ON COLUMN public.version_types.type_code IS '类型代码'; +COMMENT ON COLUMN public.version_types.type_name IS '类型名称'; +COMMENT ON COLUMN public.version_types.description IS '描述'; +COMMENT ON COLUMN public.version_types.sort_order IS '排序顺序'; +COMMENT ON COLUMN public.version_types.is_active IS '是否激活'; + diff --git a/migrations/20250101000005_create_versions_table.sql b/migrations/20250101000005_create_versions_table.sql new file mode 100644 index 0000000..1033107 --- /dev/null +++ b/migrations/20250101000005_create_versions_table.sql @@ -0,0 +1,53 @@ +-- Add migration script here +-- public.versions definition + +-- Drop table +-- DROP TABLE public.versions; + +CREATE TABLE public.versions ( + id int4 GENERATED BY DEFAULT AS IDENTITY( INCREMENT BY 1 MINVALUE 1 MAXVALUE 2147483647 START 1 CACHE 1 NO CYCLE) NOT NULL, + type_id int4 NOT NULL, -- 版本类型ID + resource_name varchar(100) NOT NULL, -- 资源名称 + version varchar(50) NOT NULL, -- 版本号 + name varchar(200) NULL, -- 版本名称 + notes text NULL, -- 更新说明 + platform varchar(50) NULL, -- 平台 + url text NULL, -- 下载链接 + hash varchar(255) NULL, -- 文件哈希 + signature varchar(255) NULL, -- 签名 + install_path text NULL, -- 安装路径 + file_size int4 NULL, -- 文件大小 + is_latest bool DEFAULT false NOT NULL, -- 是否最新版本 + status varchar(20) DEFAULT 'active'::character varying NOT NULL, -- 状态 + pub_date timestamptz NULL, -- 发布时间 + created_at timestamptz DEFAULT CURRENT_TIMESTAMP NOT NULL, -- 创建时间 + updated_at timestamptz NULL, -- 更新时间 + deleted_at timestamptz NULL, -- 删除时间 + CONSTRAINT versions_pk PRIMARY KEY (id), + CONSTRAINT versions_type_id_fk FOREIGN KEY (type_id) REFERENCES public.version_types(id) +); +CREATE INDEX versions_type_id_idx ON public.versions USING btree (type_id); +CREATE INDEX versions_resource_name_idx ON public.versions USING btree (resource_name); +CREATE INDEX versions_status_idx ON public.versions USING btree (status); +CREATE INDEX versions_is_latest_idx ON public.versions USING btree (is_latest); + +-- Column comments +COMMENT ON COLUMN public.versions.id IS '主键ID'; +COMMENT ON COLUMN public.versions.type_id IS '版本类型ID'; +COMMENT ON COLUMN public.versions.resource_name IS '资源名称'; +COMMENT ON COLUMN public.versions.version IS '版本号'; +COMMENT ON COLUMN public.versions.name IS '版本名称'; +COMMENT ON COLUMN public.versions.notes IS '更新说明'; +COMMENT ON COLUMN public.versions.platform IS '平台'; +COMMENT ON COLUMN public.versions.url IS '下载链接'; +COMMENT ON COLUMN public.versions.hash IS '文件哈希'; +COMMENT ON COLUMN public.versions.signature IS '签名'; +COMMENT ON COLUMN public.versions.install_path IS '安装路径'; +COMMENT ON COLUMN public.versions.file_size IS '文件大小'; +COMMENT ON COLUMN public.versions.is_latest IS '是否最新版本'; +COMMENT ON COLUMN public.versions.status IS '状态'; +COMMENT ON COLUMN public.versions.pub_date IS '发布时间'; +COMMENT ON COLUMN public.versions.created_at IS '创建时间'; +COMMENT ON COLUMN public.versions.updated_at IS '更新时间'; +COMMENT ON COLUMN public.versions.deleted_at IS '删除时间'; + diff --git a/migrations/20250101000006_create_gray_releases_table.sql b/migrations/20250101000006_create_gray_releases_table.sql new file mode 100644 index 0000000..7b73ed2 --- /dev/null +++ b/migrations/20250101000006_create_gray_releases_table.sql @@ -0,0 +1,44 @@ +-- Add migration script here +-- public.gray_releases definition + +-- Drop table +-- DROP TABLE public.gray_releases; + +CREATE TABLE public.gray_releases ( + id int4 GENERATED BY DEFAULT AS IDENTITY( INCREMENT BY 1 MINVALUE 1 MAXVALUE 2147483647 START 1 CACHE 1 NO CYCLE) NOT NULL, + name varchar(100) NOT NULL, -- 灰度名称 + description text NULL, -- 描述 + platform varchar(50) NOT NULL, -- 平台 + status varchar(20) DEFAULT 'pending'::character varying NOT NULL, -- 状态 + start_time timestamptz NOT NULL, -- 开始时间 + end_time timestamptz NULL, -- 结束时间 + max_machines int4 NULL, -- 最大配额(NULL=不限制) + allocated_count int4 DEFAULT 0 NOT NULL, -- 已分配数量 + priority int4 DEFAULT 0 NOT NULL, -- 优先级 + strategy_type varchar(50) NULL, -- 策略类型 + strategy_config jsonb NULL, -- 策略配置(JSON格式) + created_at timestamptz DEFAULT CURRENT_TIMESTAMP NOT NULL, -- 创建时间 + updated_at timestamptz NULL, -- 更新时间 + CONSTRAINT gray_releases_pk PRIMARY KEY (id) +); +CREATE INDEX gray_releases_status_idx ON public.gray_releases USING btree (status); +CREATE INDEX gray_releases_platform_idx ON public.gray_releases USING btree (platform); +CREATE INDEX gray_releases_time_idx ON public.gray_releases USING btree (start_time, end_time); +CREATE INDEX gray_releases_priority_idx ON public.gray_releases USING btree (priority); + +-- Column comments +COMMENT ON COLUMN public.gray_releases.id IS '主键ID'; +COMMENT ON COLUMN public.gray_releases.name IS '灰度名称'; +COMMENT ON COLUMN public.gray_releases.description IS '描述'; +COMMENT ON COLUMN public.gray_releases.platform IS '平台'; +COMMENT ON COLUMN public.gray_releases.status IS '状态'; +COMMENT ON COLUMN public.gray_releases.start_time IS '开始时间'; +COMMENT ON COLUMN public.gray_releases.end_time IS '结束时间'; +COMMENT ON COLUMN public.gray_releases.max_machines IS '最大配额(NULL=不限制)'; +COMMENT ON COLUMN public.gray_releases.allocated_count IS '已分配数量'; +COMMENT ON COLUMN public.gray_releases.priority IS '优先级'; +COMMENT ON COLUMN public.gray_releases.strategy_type IS '策略类型'; +COMMENT ON COLUMN public.gray_releases.strategy_config IS '策略配置(JSON格式)'; +COMMENT ON COLUMN public.gray_releases.created_at IS '创建时间'; +COMMENT ON COLUMN public.gray_releases.updated_at IS '更新时间'; + diff --git a/migrations/20250101000007_create_gray_resources_table.sql b/migrations/20250101000007_create_gray_resources_table.sql new file mode 100644 index 0000000..22e9d1c --- /dev/null +++ b/migrations/20250101000007_create_gray_resources_table.sql @@ -0,0 +1,24 @@ +-- Add migration script here +-- public.gray_resources definition + +-- Drop table +-- DROP TABLE public.gray_resources; + +CREATE TABLE public.gray_resources ( + id int4 GENERATED BY DEFAULT AS IDENTITY( INCREMENT BY 1 MINVALUE 1 MAXVALUE 2147483647 START 1 CACHE 1 NO CYCLE) NOT NULL, + gray_release_id int4 NOT NULL, -- 灰度发布ID + version_id int4 NOT NULL, -- 版本ID + sort_order int4 DEFAULT 0 NOT NULL, -- 排序顺序 + CONSTRAINT gray_resources_pk PRIMARY KEY (id), + CONSTRAINT gray_resources_gray_release_id_fk FOREIGN KEY (gray_release_id) REFERENCES public.gray_releases(id) ON DELETE CASCADE, + CONSTRAINT gray_resources_version_id_fk FOREIGN KEY (version_id) REFERENCES public.versions(id) +); +CREATE INDEX gray_resources_gray_release_id_idx ON public.gray_resources USING btree (gray_release_id); +CREATE INDEX gray_resources_version_id_idx ON public.gray_resources USING btree (version_id); + +-- Column comments +COMMENT ON COLUMN public.gray_resources.id IS '主键ID'; +COMMENT ON COLUMN public.gray_resources.gray_release_id IS '灰度发布ID'; +COMMENT ON COLUMN public.gray_resources.version_id IS '版本ID'; +COMMENT ON COLUMN public.gray_resources.sort_order IS '排序顺序'; + diff --git a/migrations/20250101000008_create_machine_gray_allocations_table.sql b/migrations/20250101000008_create_machine_gray_allocations_table.sql new file mode 100644 index 0000000..feac000 --- /dev/null +++ b/migrations/20250101000008_create_machine_gray_allocations_table.sql @@ -0,0 +1,33 @@ +-- Add migration script here +-- public.machine_gray_allocations definition + +-- Drop table +-- DROP TABLE public.machine_gray_allocations; + +CREATE TABLE public.machine_gray_allocations ( + id int4 GENERATED BY DEFAULT AS IDENTITY( INCREMENT BY 1 MINVALUE 1 MAXVALUE 2147483647 START 1 CACHE 1 NO CYCLE) NOT NULL, + machine_code varchar(255) NOT NULL, -- 机器码 + gray_release_id int4 NOT NULL, -- 灰度发布ID + allocated_at timestamptz DEFAULT CURRENT_TIMESTAMP NOT NULL, -- 分配时间 + effective_time timestamptz NULL, -- 生效时间 + status varchar(20) DEFAULT 'active'::character varying NOT NULL, -- 状态 + notes text NULL, -- 备注 + created_at timestamptz DEFAULT CURRENT_TIMESTAMP NOT NULL, -- 创建时间 + CONSTRAINT machine_gray_allocations_pk PRIMARY KEY (id), + CONSTRAINT machine_gray_allocations_machine_gray_unique UNIQUE (machine_code, gray_release_id), + CONSTRAINT machine_gray_allocations_gray_release_id_fk FOREIGN KEY (gray_release_id) REFERENCES public.gray_releases(id) +); +CREATE INDEX machine_gray_allocations_machine_code_idx ON public.machine_gray_allocations USING btree (machine_code); +CREATE INDEX machine_gray_allocations_gray_release_id_idx ON public.machine_gray_allocations USING btree (gray_release_id); +CREATE INDEX machine_gray_allocations_status_idx ON public.machine_gray_allocations USING btree (status); + +-- Column comments +COMMENT ON COLUMN public.machine_gray_allocations.id IS '主键ID'; +COMMENT ON COLUMN public.machine_gray_allocations.machine_code IS '机器码'; +COMMENT ON COLUMN public.machine_gray_allocations.gray_release_id IS '灰度发布ID'; +COMMENT ON COLUMN public.machine_gray_allocations.allocated_at IS '分配时间'; +COMMENT ON COLUMN public.machine_gray_allocations.effective_time IS '生效时间'; +COMMENT ON COLUMN public.machine_gray_allocations.status IS '状态'; +COMMENT ON COLUMN public.machine_gray_allocations.notes IS '备注'; +COMMENT ON COLUMN public.machine_gray_allocations.created_at IS '创建时间'; + diff --git a/migrations/20250101000009_create_maintenances_table.sql b/migrations/20250101000009_create_maintenances_table.sql new file mode 100644 index 0000000..b7038ba --- /dev/null +++ b/migrations/20250101000009_create_maintenances_table.sql @@ -0,0 +1,33 @@ +-- Add migration script here +-- public.maintenances definition + +-- Drop table +-- DROP TABLE public.maintenances; + +CREATE TABLE public.maintenances ( + id int8 GENERATED BY DEFAULT AS IDENTITY( INCREMENT BY 1 MINVALUE 1 MAXVALUE 9223372036854775807 START 1 CACHE 1 NO CYCLE) NOT NULL, + name varchar(100) NOT NULL, -- 维护名称 + description text NULL, -- 维护描述 + status varchar(20) NOT NULL, -- 状态 + start_time timestamptz NOT NULL, -- 开始时间 + end_time timestamptz NOT NULL, -- 结束时间 + maintenance_type varchar(20) NOT NULL, -- 维护类型 + created_at timestamptz DEFAULT CURRENT_TIMESTAMP NOT NULL, -- 创建时间 + updated_at timestamptz DEFAULT CURRENT_TIMESTAMP NOT NULL, -- 更新时间 + CONSTRAINT maintenances_pk PRIMARY KEY (id) +); +CREATE INDEX maintenances_status_idx ON public.maintenances USING btree (status); +CREATE INDEX maintenances_time_idx ON public.maintenances USING btree (start_time, end_time); +CREATE INDEX maintenances_type_idx ON public.maintenances USING btree (maintenance_type); + +-- Column comments +COMMENT ON COLUMN public.maintenances.id IS '主键ID'; +COMMENT ON COLUMN public.maintenances.name IS '维护名称'; +COMMENT ON COLUMN public.maintenances.description IS '维护描述'; +COMMENT ON COLUMN public.maintenances.status IS '状态'; +COMMENT ON COLUMN public.maintenances.start_time IS '开始时间'; +COMMENT ON COLUMN public.maintenances.end_time IS '结束时间'; +COMMENT ON COLUMN public.maintenances.maintenance_type IS '维护类型'; +COMMENT ON COLUMN public.maintenances.created_at IS '创建时间'; +COMMENT ON COLUMN public.maintenances.updated_at IS '更新时间'; + diff --git a/migrations/20250101000010_create_strategy_types_table.sql b/migrations/20250101000010_create_strategy_types_table.sql new file mode 100644 index 0000000..4ff64f5 --- /dev/null +++ b/migrations/20250101000010_create_strategy_types_table.sql @@ -0,0 +1,31 @@ +-- Add migration script here +-- public.strategy_types definition + +-- Drop table +-- DROP TABLE public.strategy_types; + +CREATE TABLE public.strategy_types ( + id int4 GENERATED BY DEFAULT AS IDENTITY( INCREMENT BY 1 MINVALUE 1 MAXVALUE 2147483647 START 1 CACHE 1 NO CYCLE) NOT NULL, + code varchar(50) NOT NULL, -- 策略代码(对应 Rust 枚举值) + name varchar(100) NOT NULL, -- 策略名称 + category varchar(50) NULL, -- 分类 + description text NULL, -- 描述 + processor_type varchar(50) NOT NULL, -- 处理器类型(对应 ProcessorType 枚举) + config_schema text NULL, -- 配置JSON Schema + is_active bool DEFAULT true NOT NULL, -- 是否激活 + CONSTRAINT strategy_types_pk PRIMARY KEY (id), + CONSTRAINT strategy_types_code_unique UNIQUE (code) +); +CREATE UNIQUE INDEX strategy_types_code_idx ON public.strategy_types USING btree (code); +CREATE INDEX strategy_types_category_idx ON public.strategy_types USING btree (category); + +-- Column comments +COMMENT ON COLUMN public.strategy_types.id IS '主键ID'; +COMMENT ON COLUMN public.strategy_types.code IS '策略代码(对应 Rust 枚举值)'; +COMMENT ON COLUMN public.strategy_types.name IS '策略名称'; +COMMENT ON COLUMN public.strategy_types.category IS '分类'; +COMMENT ON COLUMN public.strategy_types.description IS '描述'; +COMMENT ON COLUMN public.strategy_types.processor_type IS '处理器类型(对应 ProcessorType 枚举)'; +COMMENT ON COLUMN public.strategy_types.config_schema IS '配置JSON Schema'; +COMMENT ON COLUMN public.strategy_types.is_active IS '是否激活'; + diff --git a/migrations/20250101000011_insert_strategy_types_data.sql b/migrations/20250101000011_insert_strategy_types_data.sql new file mode 100644 index 0000000..da96553 --- /dev/null +++ b/migrations/20250101000011_insert_strategy_types_data.sql @@ -0,0 +1,63 @@ +-- Add migration script here +-- 初始化灰度策略类型数据(仅过滤类策略,不包含标签相关策略) + +-- 插入过滤类策略 +INSERT INTO public.strategy_types (code, name, category, description, processor_type, config_schema, is_active) VALUES + ( + 'filter_whitelist', + '白名单过滤', + 'filter', + '基于机器码白名单的灰度策略,只对白名单中的机器生效', + 'filter_whitelist', + '{ + "type": "object", + "properties": { + "machines": { + "type": "array", + "items": {"type": "string"}, + "description": "机器码列表" + } + }, + "required": ["machines"] + }', + true + ), + ( + 'filter_percentage', + '百分比过滤', + 'filter', + '基于哈希百分比的一致性分配,按百分比随机分配机器到灰度', + 'filter_percentage', + '{ + "type": "object", + "properties": { + "percent": { + "type": "number", + "minimum": 0, + "maximum": 100, + "description": "百分比 (0-100)" + } + }, + "required": ["percent"] + }', + true + ), + ( + 'filter_random', + '随机过滤', + 'filter', + '基于随机种子的随机分配策略', + 'filter_random', + '{ + "type": "object", + "properties": { + "seed": { + "type": "integer", + "description": "随机种子" + } + }, + "required": ["seed"] + }', + true + ); + diff --git a/migrations/20250120000001_create_teams.sql b/migrations/20250120000001_create_teams.sql new file mode 100644 index 0000000..187d277 --- /dev/null +++ b/migrations/20250120000001_create_teams.sql @@ -0,0 +1,36 @@ +-- 创建 teams 表 +-- 团队/工作空间表 + +CREATE TABLE IF NOT EXISTS teams ( + id SERIAL PRIMARY KEY, + uuid UUID NOT NULL DEFAULT gen_random_uuid() UNIQUE, + name VARCHAR(255) NOT NULL, + description TEXT, + -- 所有者 + owner_uuid UUID NOT NULL, + avatar_hash VARCHAR(255), + -- 配额限制 + max_members INT NOT NULL DEFAULT 10, + max_environments INT NOT NULL DEFAULT 100, + max_proxies INT NOT NULL DEFAULT 100, + -- 【关联】团队默认代理(外键在 proxies 表创建后添加) + default_proxy_uuid UUID, + -- 状态 + status VARCHAR(50) NOT NULL DEFAULT 'active', + -- 时间 + created_at TIMESTAMP WITH TIME ZONE NOT NULL DEFAULT CURRENT_TIMESTAMP, + updated_at TIMESTAMP WITH TIME ZONE NOT NULL DEFAULT CURRENT_TIMESTAMP, + deleted_at TIMESTAMP WITH TIME ZONE, + -- 约束 + CONSTRAINT fk_teams_owner FOREIGN KEY (owner_uuid) REFERENCES users(uuid) +); + +-- 创建索引 +CREATE INDEX idx_teams_owner_uuid ON teams(owner_uuid); +CREATE INDEX idx_teams_status ON teams(status); +CREATE INDEX idx_teams_deleted_at ON teams(deleted_at); + +-- 创建更新时间触发器 +CREATE TRIGGER update_teams_updated_at BEFORE UPDATE ON teams + FOR EACH ROW EXECUTE FUNCTION update_updated_at_column(); + diff --git a/migrations/20250120000002_create_team_members.sql b/migrations/20250120000002_create_team_members.sql new file mode 100644 index 0000000..55732ef --- /dev/null +++ b/migrations/20250120000002_create_team_members.sql @@ -0,0 +1,39 @@ +-- 创建 team_members 表 +-- 团队成员关联表(用户 ↔ 团队,多对多) + +CREATE TABLE IF NOT EXISTS team_members ( + id SERIAL PRIMARY KEY, + team_uuid UUID NOT NULL, + user_uuid UUID NOT NULL, + -- 角色: owner, admin, editor, viewer + role VARCHAR(50) NOT NULL DEFAULT 'viewer', + -- 加入信息 + joined_at TIMESTAMP WITH TIME ZONE NOT NULL DEFAULT CURRENT_TIMESTAMP, + invited_by UUID, + -- 统计字段(冗余,提高查询性能) + environment_count INT NOT NULL DEFAULT 0, + group_count INT NOT NULL DEFAULT 0, + -- 状态 + status VARCHAR(50) NOT NULL DEFAULT 'active', + -- 时间 + created_at TIMESTAMP WITH TIME ZONE NOT NULL DEFAULT CURRENT_TIMESTAMP, + updated_at TIMESTAMP WITH TIME ZONE NOT NULL DEFAULT CURRENT_TIMESTAMP, + deleted_at TIMESTAMP WITH TIME ZONE, + -- 约束 + CONSTRAINT fk_team_members_team FOREIGN KEY (team_uuid) REFERENCES teams(uuid) ON DELETE CASCADE, + CONSTRAINT fk_team_members_user FOREIGN KEY (user_uuid) REFERENCES users(uuid) ON DELETE CASCADE, + CONSTRAINT fk_team_members_invited_by FOREIGN KEY (invited_by) REFERENCES users(uuid), + -- 唯一约束:用户在同一团队只能有一条记录 + CONSTRAINT uk_team_members UNIQUE (team_uuid, user_uuid) +); + +-- 创建索引 +CREATE INDEX idx_team_members_team_uuid ON team_members(team_uuid); +CREATE INDEX idx_team_members_user_uuid ON team_members(user_uuid); +CREATE INDEX idx_team_members_role ON team_members(role); +CREATE INDEX idx_team_members_status ON team_members(status); + +-- 创建更新时间触发器 +CREATE TRIGGER update_team_members_updated_at BEFORE UPDATE ON team_members + FOR EACH ROW EXECUTE FUNCTION update_updated_at_column(); + diff --git a/migrations/20250120000003_create_team_invitations.sql b/migrations/20250120000003_create_team_invitations.sql new file mode 100644 index 0000000..66fd558 --- /dev/null +++ b/migrations/20250120000003_create_team_invitations.sql @@ -0,0 +1,37 @@ +-- 创建 team_invitations 表 +-- 团队邀请表 + +CREATE TABLE IF NOT EXISTS team_invitations ( + id SERIAL PRIMARY KEY, + uuid UUID NOT NULL DEFAULT gen_random_uuid() UNIQUE, + team_uuid UUID NOT NULL, + email VARCHAR(255) NOT NULL, + -- 角色 + role VARCHAR(50) NOT NULL DEFAULT 'viewer', + -- 邀请者 + invited_by UUID NOT NULL, + -- 邀请链接 + token VARCHAR(255) NOT NULL UNIQUE, + expires_at TIMESTAMP WITH TIME ZONE NOT NULL, + -- 状态: pending, accepted, rejected, expired, cancelled + status VARCHAR(50) NOT NULL DEFAULT 'pending', + accepted_at TIMESTAMP WITH TIME ZONE, + -- 时间 + created_at TIMESTAMP WITH TIME ZONE NOT NULL DEFAULT CURRENT_TIMESTAMP, + updated_at TIMESTAMP WITH TIME ZONE NOT NULL DEFAULT CURRENT_TIMESTAMP, + -- 约束 + CONSTRAINT fk_invitations_team FOREIGN KEY (team_uuid) REFERENCES teams(uuid) ON DELETE CASCADE, + CONSTRAINT fk_invitations_invited_by FOREIGN KEY (invited_by) REFERENCES users(uuid) +); + +-- 创建索引 +CREATE INDEX idx_invitations_team_uuid ON team_invitations(team_uuid); +CREATE INDEX idx_invitations_email ON team_invitations(email); +CREATE INDEX idx_invitations_token ON team_invitations(token); +CREATE INDEX idx_invitations_status ON team_invitations(status); +CREATE INDEX idx_invitations_expires_at ON team_invitations(expires_at); + +-- 创建更新时间触发器 +CREATE TRIGGER update_team_invitations_updated_at BEFORE UPDATE ON team_invitations + FOR EACH ROW EXECUTE FUNCTION update_updated_at_column(); + diff --git a/migrations/20250120000004_create_login_history.sql b/migrations/20250120000004_create_login_history.sql new file mode 100644 index 0000000..c3f17f5 --- /dev/null +++ b/migrations/20250120000004_create_login_history.sql @@ -0,0 +1,29 @@ +-- 创建 login_history 表 +-- 登录历史表(安全审计) + +CREATE TABLE IF NOT EXISTS login_history ( + id BIGSERIAL PRIMARY KEY, + user_uuid UUID NOT NULL, + -- 登录信息 + ip_address VARCHAR(45) NOT NULL, + device_info VARCHAR(255), + user_agent TEXT, + -- 位置信息(通过 IP 解析) + location VARCHAR(255), + country VARCHAR(100), + city VARCHAR(100), + -- 结果 + success BOOLEAN NOT NULL DEFAULT TRUE, + failure_reason VARCHAR(255), + -- 时间 + created_at TIMESTAMP WITH TIME ZONE NOT NULL DEFAULT CURRENT_TIMESTAMP, + -- 约束 + CONSTRAINT fk_login_history_user FOREIGN KEY (user_uuid) REFERENCES users(uuid) ON DELETE CASCADE +); + +-- 创建索引 +CREATE INDEX idx_login_history_user_uuid ON login_history(user_uuid); +CREATE INDEX idx_login_history_created_at ON login_history(created_at); +CREATE INDEX idx_login_history_ip ON login_history(ip_address); +CREATE INDEX idx_login_history_success ON login_history(success); + diff --git a/migrations/20250120000005_alter_user_infos_add_current_team.sql b/migrations/20250120000005_alter_user_infos_add_current_team.sql new file mode 100644 index 0000000..d29b077 --- /dev/null +++ b/migrations/20250120000005_alter_user_infos_add_current_team.sql @@ -0,0 +1,22 @@ +-- 修改 user_infos 表,添加当前团队字段 +-- 用于团队切换功能 + +ALTER TABLE user_infos +ADD COLUMN IF NOT EXISTS current_team_uuid UUID; + +-- 添加外键约束(如果不存在) +DO $$ +BEGIN + IF NOT EXISTS ( + SELECT 1 FROM information_schema.table_constraints + WHERE constraint_name = 'fk_user_infos_current_team' + ) THEN + ALTER TABLE user_infos + ADD CONSTRAINT fk_user_infos_current_team + FOREIGN KEY (current_team_uuid) REFERENCES teams(uuid) ON DELETE SET NULL; + END IF; +END $$; + +-- 创建索引 +CREATE INDEX IF NOT EXISTS idx_user_infos_current_team ON user_infos(current_team_uuid); + diff --git a/migrations/20250120000006_create_groups.sql b/migrations/20250120000006_create_groups.sql new file mode 100644 index 0000000..fc868b2 --- /dev/null +++ b/migrations/20250120000006_create_groups.sql @@ -0,0 +1,39 @@ +-- 创建 groups 表 +-- 环境分组表 + +CREATE TABLE IF NOT EXISTS groups ( + id SERIAL PRIMARY KEY, + uuid UUID NOT NULL DEFAULT gen_random_uuid() UNIQUE, + user_uuid UUID NOT NULL, + team_uuid UUID, + -- 基础信息 + name VARCHAR(255) NOT NULL, + description TEXT, + color VARCHAR(50) DEFAULT 'gray', + sort_order INT DEFAULT 0, + -- 【关联】分组默认代理(外键在 proxies 表创建后添加) + default_proxy_uuid UUID, + -- 创建者 + created_by UUID, + -- 统计字段(计算字段) + environments_count INT DEFAULT 0, + -- 时间 + created_at TIMESTAMP WITH TIME ZONE NOT NULL DEFAULT CURRENT_TIMESTAMP, + updated_at TIMESTAMP WITH TIME ZONE NOT NULL DEFAULT CURRENT_TIMESTAMP, + deleted_at TIMESTAMP WITH TIME ZONE, + -- 约束 + CONSTRAINT fk_groups_user FOREIGN KEY (user_uuid) REFERENCES users(uuid), + CONSTRAINT fk_groups_team FOREIGN KEY (team_uuid) REFERENCES teams(uuid), + CONSTRAINT fk_groups_created_by FOREIGN KEY (created_by) REFERENCES users(uuid) + -- 注意: default_proxy_uuid 的外键需要在 proxies 表创建后添加 +); + +-- 创建索引 +CREATE INDEX idx_groups_user_uuid ON groups(user_uuid); +CREATE INDEX idx_groups_team_uuid ON groups(team_uuid); +CREATE INDEX idx_groups_deleted_at ON groups(deleted_at); + +-- 创建更新时间触发器 +CREATE TRIGGER update_groups_updated_at BEFORE UPDATE ON groups + FOR EACH ROW EXECUTE FUNCTION update_updated_at_column(); + diff --git a/migrations/20250120000007_create_tags.sql b/migrations/20250120000007_create_tags.sql new file mode 100644 index 0000000..c7d9c63 --- /dev/null +++ b/migrations/20250120000007_create_tags.sql @@ -0,0 +1,31 @@ +-- 创建 tags 表 +-- 标签表 + +CREATE TABLE IF NOT EXISTS tags ( + id SERIAL PRIMARY KEY, + uuid UUID NOT NULL DEFAULT gen_random_uuid() UNIQUE, + user_uuid UUID NOT NULL, + team_uuid UUID, + name VARCHAR(100) NOT NULL, + color VARCHAR(50) DEFAULT 'gray', + sort_order INT DEFAULT 0, + -- 统计字段(计算字段,定期更新或触发器维护) + environments_count INT DEFAULT 0, + -- 时间 + created_at TIMESTAMP WITH TIME ZONE NOT NULL DEFAULT CURRENT_TIMESTAMP, + updated_at TIMESTAMP WITH TIME ZONE NOT NULL DEFAULT CURRENT_TIMESTAMP, + deleted_at TIMESTAMP WITH TIME ZONE, + -- 约束 + CONSTRAINT fk_tags_user FOREIGN KEY (user_uuid) REFERENCES users(uuid), + CONSTRAINT fk_tags_team FOREIGN KEY (team_uuid) REFERENCES teams(uuid) +); + +-- 创建索引 +CREATE INDEX idx_tags_user_uuid ON tags(user_uuid); +CREATE INDEX idx_tags_team_uuid ON tags(team_uuid); +CREATE INDEX idx_tags_deleted_at ON tags(deleted_at); + +-- 创建更新时间触发器 +CREATE TRIGGER update_tags_updated_at BEFORE UPDATE ON tags + FOR EACH ROW EXECUTE FUNCTION update_updated_at_column(); + diff --git a/migrations/20250120000008_create_proxies.sql b/migrations/20250120000008_create_proxies.sql new file mode 100644 index 0000000..eac8e32 --- /dev/null +++ b/migrations/20250120000008_create_proxies.sql @@ -0,0 +1,49 @@ +-- 创建 proxies 表 +-- 代理服务器表 + +CREATE TABLE IF NOT EXISTS proxies ( + id SERIAL PRIMARY KEY, + uuid UUID NOT NULL DEFAULT gen_random_uuid() UNIQUE, + user_uuid UUID NOT NULL, + team_uuid UUID, + -- 基础信息 + name VARCHAR(255) NOT NULL, + host VARCHAR(255) NOT NULL, + port INT NOT NULL, + proxy_type VARCHAR(50) NOT NULL DEFAULT 'http', + -- 认证信息 + username VARCHAR(255), + password_encrypted TEXT, + -- SSH 类型额外字段 + ssh_key_encrypted TEXT, + ssh_passphrase_encrypted TEXT, + -- 地理位置信息 + country VARCHAR(100), + city VARCHAR(100), + -- 状态 + status VARCHAR(50) NOT NULL DEFAULT 'unknown', + latency INT, + last_check_ip VARCHAR(45), + last_checked_at TIMESTAMP WITH TIME ZONE, + -- 统计 + usage_count INT DEFAULT 0, + -- 时间 + created_at TIMESTAMP WITH TIME ZONE NOT NULL DEFAULT CURRENT_TIMESTAMP, + updated_at TIMESTAMP WITH TIME ZONE NOT NULL DEFAULT CURRENT_TIMESTAMP, + deleted_at TIMESTAMP WITH TIME ZONE, + -- 约束 + CONSTRAINT fk_proxies_user FOREIGN KEY (user_uuid) REFERENCES users(uuid), + CONSTRAINT fk_proxies_team FOREIGN KEY (team_uuid) REFERENCES teams(uuid) +); + +-- 创建索引 +CREATE INDEX idx_proxies_user_uuid ON proxies(user_uuid); +CREATE INDEX idx_proxies_team_uuid ON proxies(team_uuid); +CREATE INDEX idx_proxies_proxy_type ON proxies(proxy_type); +CREATE INDEX idx_proxies_status ON proxies(status); +CREATE INDEX idx_proxies_deleted_at ON proxies(deleted_at); + +-- 创建更新时间触发器 +CREATE TRIGGER update_proxies_updated_at BEFORE UPDATE ON proxies + FOR EACH ROW EXECUTE FUNCTION update_updated_at_column(); + diff --git a/migrations/20250120000009_create_proxy_health_checks.sql b/migrations/20250120000009_create_proxy_health_checks.sql new file mode 100644 index 0000000..8484ec2 --- /dev/null +++ b/migrations/20250120000009_create_proxy_health_checks.sql @@ -0,0 +1,23 @@ +-- 创建 proxy_health_checks 表 +-- 代理健康检查记录表 + +CREATE TABLE IF NOT EXISTS proxy_health_checks ( + id BIGSERIAL PRIMARY KEY, + proxy_uuid UUID NOT NULL, + -- 检查结果 + status VARCHAR(50) NOT NULL, + latency INT, + ip_address VARCHAR(45), + error_message TEXT, + -- 时间 + checked_at TIMESTAMP WITH TIME ZONE NOT NULL DEFAULT CURRENT_TIMESTAMP, + -- 约束 + CONSTRAINT fk_proxy_health_checks_proxy FOREIGN KEY (proxy_uuid) + REFERENCES proxies(uuid) ON DELETE CASCADE +); + +-- 创建索引 +CREATE INDEX idx_proxy_health_checks_proxy_uuid ON proxy_health_checks(proxy_uuid); +CREATE INDEX idx_proxy_health_checks_checked_at ON proxy_health_checks(checked_at); +CREATE INDEX idx_proxy_health_checks_status ON proxy_health_checks(status); + diff --git a/migrations/20250120000010_create_environments.sql b/migrations/20250120000010_create_environments.sql new file mode 100644 index 0000000..c8e45eb --- /dev/null +++ b/migrations/20250120000010_create_environments.sql @@ -0,0 +1,48 @@ +-- 创建 environments 表 +-- 环境基础信息表 + +CREATE TABLE IF NOT EXISTS environments ( + id SERIAL PRIMARY KEY, + uuid UUID NOT NULL DEFAULT gen_random_uuid() UNIQUE, + user_uuid UUID NOT NULL, + team_uuid UUID, + -- 基础信息 + name VARCHAR(255) NOT NULL, + description TEXT, + icon VARCHAR(50) DEFAULT 'chrome', + icon_color VARCHAR(50) DEFAULT 'text-gray-500', + -- 状态: ready, running, error + status VARCHAR(50) NOT NULL DEFAULT 'ready', + -- 【关联】分组 + group_uuid UUID, + -- 【关联】代理(环境直接使用的代理,优先级高于分组默认代理) + proxy_uuid UUID, + -- 摘要信息(用于列表显示) + system_info VARCHAR(100), + kernel_info VARCHAR(100), + fingerprint_summary VARCHAR(255), + -- 时间 + last_opened_at TIMESTAMP WITH TIME ZONE, + created_at TIMESTAMP WITH TIME ZONE NOT NULL DEFAULT CURRENT_TIMESTAMP, + updated_at TIMESTAMP WITH TIME ZONE NOT NULL DEFAULT CURRENT_TIMESTAMP, + deleted_at TIMESTAMP WITH TIME ZONE, + -- 约束 + CONSTRAINT fk_environments_user FOREIGN KEY (user_uuid) REFERENCES users(uuid), + CONSTRAINT fk_environments_team FOREIGN KEY (team_uuid) REFERENCES teams(uuid), + CONSTRAINT fk_environments_group FOREIGN KEY (group_uuid) REFERENCES groups(uuid) ON DELETE SET NULL, + CONSTRAINT fk_environments_proxy FOREIGN KEY (proxy_uuid) REFERENCES proxies(uuid) ON DELETE SET NULL +); + +-- 创建索引 +CREATE INDEX idx_environments_user_uuid ON environments(user_uuid); +CREATE INDEX idx_environments_team_uuid ON environments(team_uuid); +CREATE INDEX idx_environments_group_uuid ON environments(group_uuid); +CREATE INDEX idx_environments_proxy_uuid ON environments(proxy_uuid); +CREATE INDEX idx_environments_status ON environments(status); +CREATE INDEX idx_environments_deleted_at ON environments(deleted_at); +CREATE INDEX idx_environments_name ON environments(name); + +-- 创建更新时间触发器 +CREATE TRIGGER update_environments_updated_at BEFORE UPDATE ON environments + FOR EACH ROW EXECUTE FUNCTION update_updated_at_column(); + diff --git a/migrations/20250120000011_create_environment_configs.sql b/migrations/20250120000011_create_environment_configs.sql new file mode 100644 index 0000000..a2aca75 --- /dev/null +++ b/migrations/20250120000011_create_environment_configs.sql @@ -0,0 +1,33 @@ +-- 创建 environment_configs 表 +-- 环境完整配置表(存储 WindowConfig,与 environments 1:1) + +CREATE TABLE IF NOT EXISTS environment_configs ( + id SERIAL PRIMARY KEY, + environment_uuid UUID NOT NULL UNIQUE, + -- WindowInfo + window_info JSONB NOT NULL DEFAULT '{}', + -- BasicSettings + basic_settings JSONB NOT NULL DEFAULT '{}', + -- AdvancedFingerprintSettings + fingerprint_settings JSONB NOT NULL DEFAULT '{}', + -- DeviceSettings + device_settings JSONB NOT NULL DEFAULT '{}', + -- PreferenceSettings + preference_settings JSONB NOT NULL DEFAULT '{}', + -- ProjectMetadata + project_metadata JSONB NOT NULL DEFAULT '{}', + -- 时间 + created_at TIMESTAMP WITH TIME ZONE NOT NULL DEFAULT CURRENT_TIMESTAMP, + updated_at TIMESTAMP WITH TIME ZONE NOT NULL DEFAULT CURRENT_TIMESTAMP, + -- 约束 + CONSTRAINT fk_env_configs_env FOREIGN KEY (environment_uuid) + REFERENCES environments(uuid) ON DELETE CASCADE +); + +-- 创建索引 +CREATE INDEX idx_env_configs_env_uuid ON environment_configs(environment_uuid); + +-- 创建更新时间触发器 +CREATE TRIGGER update_environment_configs_updated_at BEFORE UPDATE ON environment_configs + FOR EACH ROW EXECUTE FUNCTION update_updated_at_column(); + diff --git a/migrations/20250120000012_create_environment_tags.sql b/migrations/20250120000012_create_environment_tags.sql new file mode 100644 index 0000000..e92b671 --- /dev/null +++ b/migrations/20250120000012_create_environment_tags.sql @@ -0,0 +1,21 @@ +-- 创建 environment_tags 表 +-- 环境-标签关联表(多对多) + +CREATE TABLE IF NOT EXISTS environment_tags ( + id SERIAL PRIMARY KEY, + environment_uuid UUID NOT NULL, + tag_uuid UUID NOT NULL, + created_at TIMESTAMP WITH TIME ZONE NOT NULL DEFAULT CURRENT_TIMESTAMP, + -- 约束 + CONSTRAINT fk_env_tags_env FOREIGN KEY (environment_uuid) + REFERENCES environments(uuid) ON DELETE CASCADE, + CONSTRAINT fk_env_tags_tag FOREIGN KEY (tag_uuid) + REFERENCES tags(uuid) ON DELETE CASCADE, + -- 唯一约束:同一环境不能重复添加同一标签 + CONSTRAINT uk_env_tags UNIQUE (environment_uuid, tag_uuid) +); + +-- 创建索引 +CREATE INDEX idx_env_tags_env_uuid ON environment_tags(environment_uuid); +CREATE INDEX idx_env_tags_tag_uuid ON environment_tags(tag_uuid); + diff --git a/migrations/20250120000013_create_environment_urls.sql b/migrations/20250120000013_create_environment_urls.sql new file mode 100644 index 0000000..0000fd6 --- /dev/null +++ b/migrations/20250120000013_create_environment_urls.sql @@ -0,0 +1,18 @@ +-- 创建 environment_urls 表 +-- 环境预设 URL 表(环境可有多个预设 URL) + +CREATE TABLE IF NOT EXISTS environment_urls ( + id SERIAL PRIMARY KEY, + environment_uuid UUID NOT NULL, + url VARCHAR(2048) NOT NULL, + title VARCHAR(255), + sort_order INT DEFAULT 0, + created_at TIMESTAMP WITH TIME ZONE NOT NULL DEFAULT CURRENT_TIMESTAMP, + -- 约束 + CONSTRAINT fk_env_urls_env FOREIGN KEY (environment_uuid) + REFERENCES environments(uuid) ON DELETE CASCADE +); + +-- 创建索引 +CREATE INDEX idx_env_urls_env_uuid ON environment_urls(environment_uuid); + diff --git a/migrations/20250120000014_create_environment_cookies.sql b/migrations/20250120000014_create_environment_cookies.sql new file mode 100644 index 0000000..54754ac --- /dev/null +++ b/migrations/20250120000014_create_environment_cookies.sql @@ -0,0 +1,24 @@ +-- 创建 environment_cookies 表 +-- 环境 Cookie 表(环境可导入多个 Cookie) + +CREATE TABLE IF NOT EXISTS environment_cookies ( + id SERIAL PRIMARY KEY, + environment_uuid UUID NOT NULL, + domain VARCHAR(255) NOT NULL, + name VARCHAR(255) NOT NULL, + value TEXT NOT NULL, + path VARCHAR(255) DEFAULT '/', + expires_at TIMESTAMP WITH TIME ZONE, + http_only BOOLEAN DEFAULT FALSE, + secure BOOLEAN DEFAULT FALSE, + same_site VARCHAR(20) DEFAULT 'Lax', + created_at TIMESTAMP WITH TIME ZONE NOT NULL DEFAULT CURRENT_TIMESTAMP, + -- 约束 + CONSTRAINT fk_env_cookies_env FOREIGN KEY (environment_uuid) + REFERENCES environments(uuid) ON DELETE CASCADE +); + +-- 创建索引 +CREATE INDEX idx_env_cookies_env_uuid ON environment_cookies(environment_uuid); +CREATE INDEX idx_env_cookies_domain ON environment_cookies(domain); + diff --git a/migrations/20250120000015_create_templates.sql b/migrations/20250120000015_create_templates.sql new file mode 100644 index 0000000..b052a46 --- /dev/null +++ b/migrations/20250120000015_create_templates.sql @@ -0,0 +1,38 @@ +-- 创建 templates 表 +-- 环境配置模板表 + +CREATE TABLE IF NOT EXISTS templates ( + id SERIAL PRIMARY KEY, + uuid UUID NOT NULL DEFAULT gen_random_uuid() UNIQUE, + user_uuid UUID NOT NULL, + team_uuid UUID, + name VARCHAR(255) NOT NULL, + description TEXT, + -- 是否公开(团队内所有人可用) + is_public BOOLEAN DEFAULT FALSE, + -- 摘要 + system_info VARCHAR(100), + kernel_info VARCHAR(100), + -- 完整配置(JSON 存储 WindowConfig 结构) + config_json JSONB NOT NULL DEFAULT '{}', + -- 使用统计 + usage_count INT DEFAULT 0, + -- 时间 + created_at TIMESTAMP WITH TIME ZONE NOT NULL DEFAULT CURRENT_TIMESTAMP, + updated_at TIMESTAMP WITH TIME ZONE NOT NULL DEFAULT CURRENT_TIMESTAMP, + deleted_at TIMESTAMP WITH TIME ZONE, + -- 约束 + CONSTRAINT fk_templates_user FOREIGN KEY (user_uuid) REFERENCES users(uuid), + CONSTRAINT fk_templates_team FOREIGN KEY (team_uuid) REFERENCES teams(uuid) +); + +-- 创建索引 +CREATE INDEX idx_templates_user_uuid ON templates(user_uuid); +CREATE INDEX idx_templates_team_uuid ON templates(team_uuid); +CREATE INDEX idx_templates_is_public ON templates(is_public); +CREATE INDEX idx_templates_deleted_at ON templates(deleted_at); + +-- 创建更新时间触发器 +CREATE TRIGGER update_templates_updated_at BEFORE UPDATE ON templates + FOR EACH ROW EXECUTE FUNCTION update_updated_at_column(); + diff --git a/migrations/20250120000016_create_platform_accounts.sql b/migrations/20250120000016_create_platform_accounts.sql new file mode 100644 index 0000000..f98ea80 --- /dev/null +++ b/migrations/20250120000016_create_platform_accounts.sql @@ -0,0 +1,40 @@ +-- 创建 platform_accounts 表 +-- 平台账号表 + +CREATE TABLE IF NOT EXISTS platform_accounts ( + id SERIAL PRIMARY KEY, + uuid UUID NOT NULL DEFAULT gen_random_uuid() UNIQUE, + user_uuid UUID NOT NULL, + team_uuid UUID, + -- 平台信息 + platform_url VARCHAR(512) NOT NULL, + platform_name VARCHAR(100), + -- 账号信息 + account VARCHAR(255) NOT NULL, + password_encrypted TEXT, + -- 状态: active, inactive, expired + status VARCHAR(50) NOT NULL DEFAULT 'active', + remark TEXT, + -- 统计 + usage_count INT DEFAULT 0, + last_used_at TIMESTAMP WITH TIME ZONE, + -- 时间 + created_at TIMESTAMP WITH TIME ZONE NOT NULL DEFAULT CURRENT_TIMESTAMP, + updated_at TIMESTAMP WITH TIME ZONE NOT NULL DEFAULT CURRENT_TIMESTAMP, + deleted_at TIMESTAMP WITH TIME ZONE, + -- 约束 + CONSTRAINT fk_platform_accounts_user FOREIGN KEY (user_uuid) REFERENCES users(uuid), + CONSTRAINT fk_platform_accounts_team FOREIGN KEY (team_uuid) REFERENCES teams(uuid) +); + +-- 创建索引 +CREATE INDEX idx_platform_accounts_user_uuid ON platform_accounts(user_uuid); +CREATE INDEX idx_platform_accounts_team_uuid ON platform_accounts(team_uuid); +CREATE INDEX idx_platform_accounts_platform_name ON platform_accounts(platform_name); +CREATE INDEX idx_platform_accounts_status ON platform_accounts(status); +CREATE INDEX idx_platform_accounts_deleted_at ON platform_accounts(deleted_at); + +-- 创建更新时间触发器 +CREATE TRIGGER update_platform_accounts_updated_at BEFORE UPDATE ON platform_accounts + FOR EACH ROW EXECUTE FUNCTION update_updated_at_column(); + diff --git a/migrations/20250120000017_create_environment_accounts.sql b/migrations/20250120000017_create_environment_accounts.sql new file mode 100644 index 0000000..d22007f --- /dev/null +++ b/migrations/20250120000017_create_environment_accounts.sql @@ -0,0 +1,22 @@ +-- 创建 environment_accounts 表 +-- 环境-账号关联表(多对多) + +CREATE TABLE IF NOT EXISTS environment_accounts ( + id SERIAL PRIMARY KEY, + environment_uuid UUID NOT NULL, + account_uuid UUID NOT NULL, + sort_order INT DEFAULT 0, + created_at TIMESTAMP WITH TIME ZONE NOT NULL DEFAULT CURRENT_TIMESTAMP, + -- 约束 + CONSTRAINT fk_env_accounts_env FOREIGN KEY (environment_uuid) + REFERENCES environments(uuid) ON DELETE CASCADE, + CONSTRAINT fk_env_accounts_account FOREIGN KEY (account_uuid) + REFERENCES platform_accounts(uuid) ON DELETE CASCADE, + -- 唯一约束:同一环境不能重复关联同一账号 + CONSTRAINT uk_env_accounts UNIQUE (environment_uuid, account_uuid) +); + +-- 创建索引 +CREATE INDEX idx_env_accounts_env_uuid ON environment_accounts(environment_uuid); +CREATE INDEX idx_env_accounts_account_uuid ON environment_accounts(account_uuid); + diff --git a/migrations/20250120000018_create_extensions.sql b/migrations/20250120000018_create_extensions.sql new file mode 100644 index 0000000..b2d47f9 --- /dev/null +++ b/migrations/20250120000018_create_extensions.sql @@ -0,0 +1,48 @@ +-- 创建 extensions 表 +-- 浏览器扩展表 + +CREATE TABLE IF NOT EXISTS extensions ( + id SERIAL PRIMARY KEY, + uuid UUID NOT NULL DEFAULT gen_random_uuid() UNIQUE, + -- 扩展 ID(Chrome/Firefox 商店 ID) + extension_id VARCHAR(255) NOT NULL UNIQUE, + name VARCHAR(255) NOT NULL, + description TEXT, + -- 版本信息 + version VARCHAR(50) NOT NULL, + -- 分类: productivity, privacy, automation, social, development, other + category VARCHAR(50) NOT NULL DEFAULT 'other', + -- 浏览器: chrome, firefox, edge, all + browser VARCHAR(50) NOT NULL DEFAULT 'all', + -- 开发者信息 + developer VARCHAR(255), + homepage VARCHAR(512), + icon_url VARCHAR(512), + -- 下载信息 + download_url VARCHAR(512), + file_size BIGINT, + -- 统计 + downloads_count BIGINT DEFAULT 0, + rating DECIMAL(3, 2), + -- 权限 + permissions JSONB DEFAULT '[]', + -- 状态: active, deprecated, removed + status VARCHAR(50) NOT NULL DEFAULT 'active', + -- 更新日志 + changelog JSONB DEFAULT '[]', + -- 时间 + published_at TIMESTAMP WITH TIME ZONE, + created_at TIMESTAMP WITH TIME ZONE NOT NULL DEFAULT CURRENT_TIMESTAMP, + updated_at TIMESTAMP WITH TIME ZONE NOT NULL DEFAULT CURRENT_TIMESTAMP +); + +-- 创建索引 +CREATE INDEX idx_extensions_extension_id ON extensions(extension_id); +CREATE INDEX idx_extensions_category ON extensions(category); +CREATE INDEX idx_extensions_browser ON extensions(browser); +CREATE INDEX idx_extensions_status ON extensions(status); + +-- 创建更新时间触发器 +CREATE TRIGGER update_extensions_updated_at BEFORE UPDATE ON extensions + FOR EACH ROW EXECUTE FUNCTION update_updated_at_column(); + diff --git a/migrations/20250120000019_create_user_extensions.sql b/migrations/20250120000019_create_user_extensions.sql new file mode 100644 index 0000000..d351519 --- /dev/null +++ b/migrations/20250120000019_create_user_extensions.sql @@ -0,0 +1,28 @@ +-- 创建 user_extensions 表 +-- 用户安装的扩展(用户级别) + +CREATE TABLE IF NOT EXISTS user_extensions ( + id SERIAL PRIMARY KEY, + user_uuid UUID NOT NULL, + extension_id VARCHAR(255) NOT NULL, + installed_version VARCHAR(50) NOT NULL, + -- 状态: installed, disabled + status VARCHAR(50) NOT NULL DEFAULT 'installed', + -- 时间 + installed_at TIMESTAMP WITH TIME ZONE NOT NULL DEFAULT CURRENT_TIMESTAMP, + updated_at TIMESTAMP WITH TIME ZONE NOT NULL DEFAULT CURRENT_TIMESTAMP, + -- 约束 + CONSTRAINT fk_user_extensions_user FOREIGN KEY (user_uuid) REFERENCES users(uuid) ON DELETE CASCADE, + CONSTRAINT fk_user_extensions_ext FOREIGN KEY (extension_id) REFERENCES extensions(extension_id) ON DELETE CASCADE, + -- 唯一约束 + CONSTRAINT uk_user_extensions UNIQUE (user_uuid, extension_id) +); + +-- 创建索引 +CREATE INDEX idx_user_extensions_user_uuid ON user_extensions(user_uuid); +CREATE INDEX idx_user_extensions_extension_id ON user_extensions(extension_id); + +-- 创建更新时间触发器 +CREATE TRIGGER update_user_extensions_updated_at BEFORE UPDATE ON user_extensions + FOR EACH ROW EXECUTE FUNCTION update_updated_at_column(); + diff --git a/migrations/20250120000020_create_team_extensions.sql b/migrations/20250120000020_create_team_extensions.sql new file mode 100644 index 0000000..80ff16f --- /dev/null +++ b/migrations/20250120000020_create_team_extensions.sql @@ -0,0 +1,31 @@ +-- 创建 team_extensions 表 +-- 团队安装的扩展(团队级别,所有团队成员可用) + +CREATE TABLE IF NOT EXISTS team_extensions ( + id SERIAL PRIMARY KEY, + team_uuid UUID NOT NULL, + extension_id VARCHAR(255) NOT NULL, + installed_version VARCHAR(50) NOT NULL, + -- 安装者 + installed_by UUID NOT NULL, + -- 状态: installed, disabled + status VARCHAR(50) NOT NULL DEFAULT 'installed', + -- 时间 + installed_at TIMESTAMP WITH TIME ZONE NOT NULL DEFAULT CURRENT_TIMESTAMP, + updated_at TIMESTAMP WITH TIME ZONE NOT NULL DEFAULT CURRENT_TIMESTAMP, + -- 约束 + CONSTRAINT fk_team_extensions_team FOREIGN KEY (team_uuid) REFERENCES teams(uuid) ON DELETE CASCADE, + CONSTRAINT fk_team_extensions_ext FOREIGN KEY (extension_id) REFERENCES extensions(extension_id) ON DELETE CASCADE, + CONSTRAINT fk_team_extensions_user FOREIGN KEY (installed_by) REFERENCES users(uuid), + -- 唯一约束 + CONSTRAINT uk_team_extensions UNIQUE (team_uuid, extension_id) +); + +-- 创建索引 +CREATE INDEX idx_team_extensions_team_uuid ON team_extensions(team_uuid); +CREATE INDEX idx_team_extensions_extension_id ON team_extensions(extension_id); + +-- 创建更新时间触发器 +CREATE TRIGGER update_team_extensions_updated_at BEFORE UPDATE ON team_extensions + FOR EACH ROW EXECUTE FUNCTION update_updated_at_column(); + diff --git a/migrations/20250120000021_create_group_extensions.sql b/migrations/20250120000021_create_group_extensions.sql new file mode 100644 index 0000000..73f9a7c --- /dev/null +++ b/migrations/20250120000021_create_group_extensions.sql @@ -0,0 +1,31 @@ +-- 创建 group_extensions 表 +-- 分组安装的扩展(分组级别,该分组下所有环境可用) + +CREATE TABLE IF NOT EXISTS group_extensions ( + id SERIAL PRIMARY KEY, + group_uuid UUID NOT NULL, + extension_id VARCHAR(255) NOT NULL, + installed_version VARCHAR(50) NOT NULL, + -- 安装者 + installed_by UUID NOT NULL, + -- 状态: installed, disabled + status VARCHAR(50) NOT NULL DEFAULT 'installed', + -- 时间 + installed_at TIMESTAMP WITH TIME ZONE NOT NULL DEFAULT CURRENT_TIMESTAMP, + updated_at TIMESTAMP WITH TIME ZONE NOT NULL DEFAULT CURRENT_TIMESTAMP, + -- 约束 + CONSTRAINT fk_group_extensions_group FOREIGN KEY (group_uuid) REFERENCES groups(uuid) ON DELETE CASCADE, + CONSTRAINT fk_group_extensions_ext FOREIGN KEY (extension_id) REFERENCES extensions(extension_id) ON DELETE CASCADE, + CONSTRAINT fk_group_extensions_user FOREIGN KEY (installed_by) REFERENCES users(uuid), + -- 唯一约束 + CONSTRAINT uk_group_extensions UNIQUE (group_uuid, extension_id) +); + +-- 创建索引 +CREATE INDEX idx_group_extensions_group_uuid ON group_extensions(group_uuid); +CREATE INDEX idx_group_extensions_extension_id ON group_extensions(extension_id); + +-- 创建更新时间触发器 +CREATE TRIGGER update_group_extensions_updated_at BEFORE UPDATE ON group_extensions + FOR EACH ROW EXECUTE FUNCTION update_updated_at_column(); + diff --git a/migrations/20250120000022_create_environment_extensions.sql b/migrations/20250120000022_create_environment_extensions.sql new file mode 100644 index 0000000..d1bcdc0 --- /dev/null +++ b/migrations/20250120000022_create_environment_extensions.sql @@ -0,0 +1,28 @@ +-- 创建 environment_extensions 表 +-- 环境安装的扩展(环境级别) + +CREATE TABLE IF NOT EXISTS environment_extensions ( + id SERIAL PRIMARY KEY, + environment_uuid UUID NOT NULL, + extension_id VARCHAR(255) NOT NULL, + installed_version VARCHAR(50) NOT NULL, + -- 状态: installed, disabled + status VARCHAR(50) NOT NULL DEFAULT 'installed', + -- 时间 + installed_at TIMESTAMP WITH TIME ZONE NOT NULL DEFAULT CURRENT_TIMESTAMP, + updated_at TIMESTAMP WITH TIME ZONE NOT NULL DEFAULT CURRENT_TIMESTAMP, + -- 约束 + CONSTRAINT fk_env_extensions_env FOREIGN KEY (environment_uuid) REFERENCES environments(uuid) ON DELETE CASCADE, + CONSTRAINT fk_env_extensions_ext FOREIGN KEY (extension_id) REFERENCES extensions(extension_id) ON DELETE CASCADE, + -- 唯一约束 + CONSTRAINT uk_env_extensions UNIQUE (environment_uuid, extension_id) +); + +-- 创建索引 +CREATE INDEX idx_env_extensions_env_uuid ON environment_extensions(environment_uuid); +CREATE INDEX idx_env_extensions_extension_id ON environment_extensions(extension_id); + +-- 创建更新时间触发器 +CREATE TRIGGER update_environment_extensions_updated_at BEFORE UPDATE ON environment_extensions + FOR EACH ROW EXECUTE FUNCTION update_updated_at_column(); + diff --git a/migrations/20250120000023_create_plans.sql b/migrations/20250120000023_create_plans.sql new file mode 100644 index 0000000..1848d36 --- /dev/null +++ b/migrations/20250120000023_create_plans.sql @@ -0,0 +1,40 @@ +-- 创建 plans 表 +-- 订阅套餐表 + +CREATE TABLE IF NOT EXISTS plans ( + id SERIAL PRIMARY KEY, + uuid UUID NOT NULL DEFAULT gen_random_uuid() UNIQUE, + -- 基础信息 + name VARCHAR(100) NOT NULL, + description TEXT, + -- 价格 + price_per_month DECIMAL(12, 2) NOT NULL DEFAULT 0, + price_per_year DECIMAL(12, 2) NOT NULL DEFAULT 0, + currency VARCHAR(10) NOT NULL DEFAULT 'USD', + -- 折扣 + discount_monthly DECIMAL(5, 2) DEFAULT 0, + discount_yearly DECIMAL(5, 2) DEFAULT 0, + -- 配额限制 + max_environments INT NOT NULL DEFAULT 10, + max_team_members INT NOT NULL DEFAULT 5, + max_proxies INT NOT NULL DEFAULT 10, + max_rpa_tasks INT NOT NULL DEFAULT 5, + -- 是否推荐 + is_recommended BOOLEAN DEFAULT FALSE, + -- 排序 + sort_order INT DEFAULT 0, + -- 状态: active, deprecated + status VARCHAR(50) NOT NULL DEFAULT 'active', + -- 时间 + created_at TIMESTAMP WITH TIME ZONE NOT NULL DEFAULT CURRENT_TIMESTAMP, + updated_at TIMESTAMP WITH TIME ZONE NOT NULL DEFAULT CURRENT_TIMESTAMP +); + +-- 创建索引 +CREATE INDEX idx_plans_status ON plans(status); +CREATE INDEX idx_plans_sort_order ON plans(sort_order); + +-- 创建更新时间触发器 +CREATE TRIGGER update_plans_updated_at BEFORE UPDATE ON plans + FOR EACH ROW EXECUTE FUNCTION update_updated_at_column(); + diff --git a/migrations/20250120000024_create_plan_features.sql b/migrations/20250120000024_create_plan_features.sql new file mode 100644 index 0000000..531cdd8 --- /dev/null +++ b/migrations/20250120000024_create_plan_features.sql @@ -0,0 +1,25 @@ +-- 创建 plan_features 表 +-- 套餐功能特性表 + +CREATE TABLE IF NOT EXISTS plan_features ( + id SERIAL PRIMARY KEY, + plan_uuid UUID NOT NULL, + -- 特性 + feature_key VARCHAR(100) NOT NULL, + feature_name VARCHAR(255) NOT NULL, + feature_value VARCHAR(255), + -- 是否包含 + is_included BOOLEAN DEFAULT TRUE, + -- 排序 + sort_order INT DEFAULT 0, + -- 时间 + created_at TIMESTAMP WITH TIME ZONE NOT NULL DEFAULT CURRENT_TIMESTAMP, + -- 约束 + CONSTRAINT fk_plan_features_plan FOREIGN KEY (plan_uuid) REFERENCES plans(uuid) ON DELETE CASCADE, + -- 唯一约束 + CONSTRAINT uk_plan_features UNIQUE (plan_uuid, feature_key) +); + +-- 创建索引 +CREATE INDEX idx_plan_features_plan_uuid ON plan_features(plan_uuid); + diff --git a/migrations/20250120000025_create_subscriptions.sql b/migrations/20250120000025_create_subscriptions.sql new file mode 100644 index 0000000..b8a7c8c --- /dev/null +++ b/migrations/20250120000025_create_subscriptions.sql @@ -0,0 +1,40 @@ +-- 创建 subscriptions 表 +-- 用户订阅表 + +CREATE TABLE IF NOT EXISTS subscriptions ( + id SERIAL PRIMARY KEY, + uuid UUID NOT NULL DEFAULT gen_random_uuid() UNIQUE, + user_uuid UUID NOT NULL, + plan_uuid UUID NOT NULL, + -- 订阅周期: monthly, yearly + billing_period VARCHAR(20) NOT NULL DEFAULT 'monthly', + -- 价格快照 + price DECIMAL(12, 2) NOT NULL, + currency VARCHAR(10) NOT NULL DEFAULT 'USD', + -- 时间 + started_at TIMESTAMP WITH TIME ZONE NOT NULL DEFAULT CURRENT_TIMESTAMP, + expires_at TIMESTAMP WITH TIME ZONE NOT NULL, + next_billing_date DATE, + -- 自动续费 + auto_renew BOOLEAN DEFAULT TRUE, + -- 状态: active, cancelled, expired, suspended + status VARCHAR(50) NOT NULL DEFAULT 'active', + cancelled_at TIMESTAMP WITH TIME ZONE, + -- 时间 + created_at TIMESTAMP WITH TIME ZONE NOT NULL DEFAULT CURRENT_TIMESTAMP, + updated_at TIMESTAMP WITH TIME ZONE NOT NULL DEFAULT CURRENT_TIMESTAMP, + -- 约束 + CONSTRAINT fk_subscriptions_user FOREIGN KEY (user_uuid) REFERENCES users(uuid), + CONSTRAINT fk_subscriptions_plan FOREIGN KEY (plan_uuid) REFERENCES plans(uuid) +); + +-- 创建索引 +CREATE INDEX idx_subscriptions_user_uuid ON subscriptions(user_uuid); +CREATE INDEX idx_subscriptions_plan_uuid ON subscriptions(plan_uuid); +CREATE INDEX idx_subscriptions_status ON subscriptions(status); +CREATE INDEX idx_subscriptions_expires_at ON subscriptions(expires_at); + +-- 创建更新时间触发器 +CREATE TRIGGER update_subscriptions_updated_at BEFORE UPDATE ON subscriptions + FOR EACH ROW EXECUTE FUNCTION update_updated_at_column(); + diff --git a/migrations/20250120000026_create_user_wallets.sql b/migrations/20250120000026_create_user_wallets.sql new file mode 100644 index 0000000..36c4f86 --- /dev/null +++ b/migrations/20250120000026_create_user_wallets.sql @@ -0,0 +1,27 @@ +-- 创建 user_wallets 表 +-- 用户钱包表 + +CREATE TABLE IF NOT EXISTS user_wallets ( + id SERIAL PRIMARY KEY, + user_uuid UUID NOT NULL UNIQUE, + -- 余额 + balance DECIMAL(12, 2) NOT NULL DEFAULT 0, + currency VARCHAR(10) NOT NULL DEFAULT 'USD', + -- 冻结金额 + frozen_amount DECIMAL(12, 2) NOT NULL DEFAULT 0, + -- 自动续费组合金额 + auto_renewal_combined DECIMAL(12, 2) NOT NULL DEFAULT 0, + -- 时间 + created_at TIMESTAMP WITH TIME ZONE NOT NULL DEFAULT CURRENT_TIMESTAMP, + updated_at TIMESTAMP WITH TIME ZONE NOT NULL DEFAULT CURRENT_TIMESTAMP, + -- 约束 + CONSTRAINT fk_user_wallets_user FOREIGN KEY (user_uuid) REFERENCES users(uuid) ON DELETE CASCADE +); + +-- 创建索引 +CREATE INDEX idx_user_wallets_user_uuid ON user_wallets(user_uuid); + +-- 创建更新时间触发器 +CREATE TRIGGER update_user_wallets_updated_at BEFORE UPDATE ON user_wallets + FOR EACH ROW EXECUTE FUNCTION update_updated_at_column(); + diff --git a/migrations/20250120000027_create_wallet_transactions.sql b/migrations/20250120000027_create_wallet_transactions.sql new file mode 100644 index 0000000..b556ffc --- /dev/null +++ b/migrations/20250120000027_create_wallet_transactions.sql @@ -0,0 +1,33 @@ +-- 创建 wallet_transactions 表 +-- 钱包交易记录表 + +CREATE TABLE IF NOT EXISTS wallet_transactions ( + id BIGSERIAL PRIMARY KEY, + uuid UUID NOT NULL DEFAULT gen_random_uuid() UNIQUE, + user_uuid UUID NOT NULL, + -- 交易类型: recharge, consume, refund, reward + transaction_type VARCHAR(50) NOT NULL, + -- 金额(正数为收入,负数为支出) + amount DECIMAL(12, 2) NOT NULL, + currency VARCHAR(10) NOT NULL DEFAULT 'USD', + -- 余额快照 + balance_before DECIMAL(12, 2) NOT NULL, + balance_after DECIMAL(12, 2) NOT NULL, + -- 描述 + description TEXT, + -- 关联订单 + order_uuid UUID, + -- 状态: pending, completed, failed + status VARCHAR(50) NOT NULL DEFAULT 'completed', + -- 时间 + created_at TIMESTAMP WITH TIME ZONE NOT NULL DEFAULT CURRENT_TIMESTAMP, + -- 约束 + CONSTRAINT fk_wallet_transactions_user FOREIGN KEY (user_uuid) REFERENCES users(uuid) +); + +-- 创建索引 +CREATE INDEX idx_wallet_transactions_user_uuid ON wallet_transactions(user_uuid); +CREATE INDEX idx_wallet_transactions_type ON wallet_transactions(transaction_type); +CREATE INDEX idx_wallet_transactions_created_at ON wallet_transactions(created_at); +CREATE INDEX idx_wallet_transactions_order_uuid ON wallet_transactions(order_uuid); + diff --git a/migrations/20250120000028_create_invoices.sql b/migrations/20250120000028_create_invoices.sql new file mode 100644 index 0000000..84e7c70 --- /dev/null +++ b/migrations/20250120000028_create_invoices.sql @@ -0,0 +1,44 @@ +-- 创建 invoices 表 +-- 发票表 + +CREATE TABLE IF NOT EXISTS invoices ( + id SERIAL PRIMARY KEY, + uuid UUID NOT NULL DEFAULT gen_random_uuid() UNIQUE, + user_uuid UUID NOT NULL, + -- 发票号 + invoice_number VARCHAR(100) NOT NULL UNIQUE, + -- 金额 + amount DECIMAL(12, 2) NOT NULL, + currency VARCHAR(10) NOT NULL DEFAULT 'USD', + -- 关联订阅/订单 + subscription_uuid UUID, + order_uuid UUID, + -- 发票类型: subscription, addon, recharge + invoice_type VARCHAR(50) NOT NULL, + -- 状态: draft, issued, paid, cancelled + status VARCHAR(50) NOT NULL DEFAULT 'draft', + -- 时间 + issued_at TIMESTAMP WITH TIME ZONE, + due_at TIMESTAMP WITH TIME ZONE, + paid_at TIMESTAMP WITH TIME ZONE, + -- 发票信息(PDF 链接等) + invoice_url VARCHAR(512), + -- 时间 + created_at TIMESTAMP WITH TIME ZONE NOT NULL DEFAULT CURRENT_TIMESTAMP, + updated_at TIMESTAMP WITH TIME ZONE NOT NULL DEFAULT CURRENT_TIMESTAMP, + -- 约束 + CONSTRAINT fk_invoices_user FOREIGN KEY (user_uuid) REFERENCES users(uuid), + CONSTRAINT fk_invoices_subscription FOREIGN KEY (subscription_uuid) REFERENCES subscriptions(uuid) +); + +-- 创建索引 +CREATE INDEX idx_invoices_user_uuid ON invoices(user_uuid); +CREATE INDEX idx_invoices_invoice_number ON invoices(invoice_number); +CREATE INDEX idx_invoices_status ON invoices(status); +CREATE INDEX idx_invoices_subscription_uuid ON invoices(subscription_uuid); +CREATE INDEX idx_invoices_created_at ON invoices(created_at); + +-- 创建更新时间触发器 +CREATE TRIGGER update_invoices_updated_at BEFORE UPDATE ON invoices + FOR EACH ROW EXECUTE FUNCTION update_updated_at_column(); + diff --git a/migrations/20250120000029_create_user_quotas.sql b/migrations/20250120000029_create_user_quotas.sql new file mode 100644 index 0000000..283f6ea --- /dev/null +++ b/migrations/20250120000029_create_user_quotas.sql @@ -0,0 +1,31 @@ +-- 创建 user_quotas 表 +-- 用户配额表 + +CREATE TABLE IF NOT EXISTS user_quotas ( + id SERIAL PRIMARY KEY, + user_uuid UUID NOT NULL UNIQUE, + -- 环境配额 + max_environments INT NOT NULL DEFAULT 10, + used_environments INT NOT NULL DEFAULT 0, + -- 团队成员配额 + max_team_members INT NOT NULL DEFAULT 5, + -- 代理配额 + max_proxies INT NOT NULL DEFAULT 10, + used_proxies INT NOT NULL DEFAULT 0, + -- RPA 任务配额 + max_rpa_tasks INT NOT NULL DEFAULT 5, + used_rpa_tasks INT NOT NULL DEFAULT 0, + -- 时间 + created_at TIMESTAMP WITH TIME ZONE NOT NULL DEFAULT CURRENT_TIMESTAMP, + updated_at TIMESTAMP WITH TIME ZONE NOT NULL DEFAULT CURRENT_TIMESTAMP, + -- 约束 + CONSTRAINT fk_user_quotas_user FOREIGN KEY (user_uuid) REFERENCES users(uuid) ON DELETE CASCADE +); + +-- 创建索引 +CREATE INDEX idx_user_quotas_user_uuid ON user_quotas(user_uuid); + +-- 创建更新时间触发器 +CREATE TRIGGER update_user_quotas_updated_at BEFORE UPDATE ON user_quotas + FOR EACH ROW EXECUTE FUNCTION update_updated_at_column(); + diff --git a/migrations/20250120000030_create_coupons.sql b/migrations/20250120000030_create_coupons.sql new file mode 100644 index 0000000..6713f09 --- /dev/null +++ b/migrations/20250120000030_create_coupons.sql @@ -0,0 +1,42 @@ +-- 创建 coupons 表 +-- 优惠券表 + +CREATE TABLE IF NOT EXISTS coupons ( + id SERIAL PRIMARY KEY, + uuid UUID NOT NULL DEFAULT gen_random_uuid() UNIQUE, + -- 优惠券码 + code VARCHAR(50) NOT NULL UNIQUE, + -- 优惠类型: percent, fixed + discount_type VARCHAR(20) NOT NULL, + -- 优惠值(百分比或固定金额) + discount_value DECIMAL(12, 2) NOT NULL, + -- 最低消费 + min_amount DECIMAL(12, 2) DEFAULT 0, + -- 最大折扣(百分比类型时有效) + max_discount DECIMAL(12, 2), + -- 使用限制 + max_uses INT, + used_count INT NOT NULL DEFAULT 0, + -- 每用户限制 + max_uses_per_user INT DEFAULT 1, + -- 有效期 + valid_from TIMESTAMP WITH TIME ZONE NOT NULL DEFAULT CURRENT_TIMESTAMP, + valid_until TIMESTAMP WITH TIME ZONE, + -- 适用范围: all, subscription, addon + applicable_to VARCHAR(50) NOT NULL DEFAULT 'all', + -- 状态: active, inactive, expired + status VARCHAR(50) NOT NULL DEFAULT 'active', + -- 时间 + created_at TIMESTAMP WITH TIME ZONE NOT NULL DEFAULT CURRENT_TIMESTAMP, + updated_at TIMESTAMP WITH TIME ZONE NOT NULL DEFAULT CURRENT_TIMESTAMP +); + +-- 创建索引 +CREATE INDEX idx_coupons_code ON coupons(code); +CREATE INDEX idx_coupons_status ON coupons(status); +CREATE INDEX idx_coupons_valid_until ON coupons(valid_until); + +-- 创建更新时间触发器 +CREATE TRIGGER update_coupons_updated_at BEFORE UPDATE ON coupons + FOR EACH ROW EXECUTE FUNCTION update_updated_at_column(); + diff --git a/migrations/20250120000031_create_coupon_usages.sql b/migrations/20250120000031_create_coupon_usages.sql new file mode 100644 index 0000000..1242fab --- /dev/null +++ b/migrations/20250120000031_create_coupon_usages.sql @@ -0,0 +1,21 @@ +-- 创建 coupon_usages 表 +-- 优惠券使用记录表 + +CREATE TABLE IF NOT EXISTS coupon_usages ( + id SERIAL PRIMARY KEY, + coupon_uuid UUID NOT NULL, + user_uuid UUID NOT NULL, + order_uuid UUID, + -- 折扣金额 + discount_amount DECIMAL(12, 2) NOT NULL, + -- 使用时间 + used_at TIMESTAMP WITH TIME ZONE NOT NULL DEFAULT CURRENT_TIMESTAMP, + -- 约束 + CONSTRAINT fk_coupon_usages_coupon FOREIGN KEY (coupon_uuid) REFERENCES coupons(uuid), + CONSTRAINT fk_coupon_usages_user FOREIGN KEY (user_uuid) REFERENCES users(uuid) +); + +-- 创建索引 +CREATE INDEX idx_coupon_usages_coupon_uuid ON coupon_usages(coupon_uuid); +CREATE INDEX idx_coupon_usages_user_uuid ON coupon_usages(user_uuid); + diff --git a/migrations/20250120000032_create_payment_orders.sql b/migrations/20250120000032_create_payment_orders.sql new file mode 100644 index 0000000..70f6635 --- /dev/null +++ b/migrations/20250120000032_create_payment_orders.sql @@ -0,0 +1,50 @@ +-- 创建 payment_orders 表 +-- 支付订单表 + +CREATE TABLE IF NOT EXISTS payment_orders ( + id BIGSERIAL PRIMARY KEY, + uuid UUID NOT NULL DEFAULT gen_random_uuid() UNIQUE, + -- 订单号 + order_no VARCHAR(100) NOT NULL UNIQUE, + user_uuid UUID NOT NULL, + -- 订单类型: recharge, subscription, addon, refund + order_type VARCHAR(50) NOT NULL, + -- 金额 + amount DECIMAL(12, 2) NOT NULL, + currency VARCHAR(10) NOT NULL DEFAULT 'USD', + -- 状态: pending, paid, failed, refunded, cancelled + status VARCHAR(50) NOT NULL DEFAULT 'pending', + -- 支付渠道: alipay, wechatpay, stripe, paypal 等 + payment_channel VARCHAR(50), + -- 第三方订单号 + external_order_id VARCHAR(255), + -- 描述 + description TEXT, + -- 关联 + subscription_uuid UUID, + coupon_uuid UUID, + -- 折扣信息 + original_amount DECIMAL(12, 2), + discount_amount DECIMAL(12, 2) DEFAULT 0, + -- 时间 + paid_at TIMESTAMP WITH TIME ZONE, + refunded_at TIMESTAMP WITH TIME ZONE, + created_at TIMESTAMP WITH TIME ZONE NOT NULL DEFAULT CURRENT_TIMESTAMP, + updated_at TIMESTAMP WITH TIME ZONE NOT NULL DEFAULT CURRENT_TIMESTAMP, + -- 约束 + CONSTRAINT fk_payment_orders_user FOREIGN KEY (user_uuid) REFERENCES users(uuid), + CONSTRAINT fk_payment_orders_subscription FOREIGN KEY (subscription_uuid) REFERENCES subscriptions(uuid), + CONSTRAINT fk_payment_orders_coupon FOREIGN KEY (coupon_uuid) REFERENCES coupons(uuid) +); + +-- 创建索引 +CREATE INDEX idx_payment_orders_user_uuid ON payment_orders(user_uuid); +CREATE INDEX idx_payment_orders_order_no ON payment_orders(order_no); +CREATE INDEX idx_payment_orders_status ON payment_orders(status); +CREATE INDEX idx_payment_orders_order_type ON payment_orders(order_type); +CREATE INDEX idx_payment_orders_created_at ON payment_orders(created_at); + +-- 创建更新时间触发器 +CREATE TRIGGER update_payment_orders_updated_at BEFORE UPDATE ON payment_orders + FOR EACH ROW EXECUTE FUNCTION update_updated_at_column(); + diff --git a/migrations/20250120000033_create_auto_renewal_services.sql b/migrations/20250120000033_create_auto_renewal_services.sql new file mode 100644 index 0000000..1f045ac --- /dev/null +++ b/migrations/20250120000033_create_auto_renewal_services.sql @@ -0,0 +1,36 @@ +-- 创建 auto_renewal_services 表 +-- 自动续费服务表 + +CREATE TABLE IF NOT EXISTS auto_renewal_services ( + id SERIAL PRIMARY KEY, + uuid UUID NOT NULL DEFAULT gen_random_uuid() UNIQUE, + user_uuid UUID NOT NULL, + -- 服务类型: subscription, addon + service_type VARCHAR(50) NOT NULL, + -- 关联服务 ID + service_uuid UUID, + -- 服务名称 + service_name VARCHAR(255) NOT NULL, + -- 续费价格 + renewal_price DECIMAL(12, 2) NOT NULL, + currency VARCHAR(10) NOT NULL DEFAULT 'USD', + -- 下次扣费日期 + next_bill_date DATE NOT NULL, + -- 状态: active, paused, cancelled + status VARCHAR(50) NOT NULL DEFAULT 'active', + -- 时间 + created_at TIMESTAMP WITH TIME ZONE NOT NULL DEFAULT CURRENT_TIMESTAMP, + updated_at TIMESTAMP WITH TIME ZONE NOT NULL DEFAULT CURRENT_TIMESTAMP, + -- 约束 + CONSTRAINT fk_auto_renewal_user FOREIGN KEY (user_uuid) REFERENCES users(uuid) ON DELETE CASCADE +); + +-- 创建索引 +CREATE INDEX idx_auto_renewal_services_user_uuid ON auto_renewal_services(user_uuid); +CREATE INDEX idx_auto_renewal_services_status ON auto_renewal_services(status); +CREATE INDEX idx_auto_renewal_services_next_bill_date ON auto_renewal_services(next_bill_date); + +-- 创建更新时间触发器 +CREATE TRIGGER update_auto_renewal_services_updated_at BEFORE UPDATE ON auto_renewal_services + FOR EACH ROW EXECUTE FUNCTION update_updated_at_column(); + diff --git a/migrations/20250120000034_create_rpa_tasks.sql b/migrations/20250120000034_create_rpa_tasks.sql new file mode 100644 index 0000000..bb37bc2 --- /dev/null +++ b/migrations/20250120000034_create_rpa_tasks.sql @@ -0,0 +1,59 @@ +-- 创建 rpa_tasks 表 +-- RPA 任务表 + +CREATE TABLE IF NOT EXISTS rpa_tasks ( + id SERIAL PRIMARY KEY, + uuid UUID NOT NULL DEFAULT gen_random_uuid() UNIQUE, + user_uuid UUID NOT NULL, + team_uuid UUID, + -- 基础信息 + name VARCHAR(255) NOT NULL, + description TEXT, + tags JSONB DEFAULT '[]', + -- 触发器: manual, scheduled, event + trigger_type VARCHAR(50) NOT NULL DEFAULT 'manual', + -- 调度: hourly, daily, weekly, custom + schedule VARCHAR(50), + cron_expression VARCHAR(100), + -- 运行模式: sequential, parallel + run_mode VARCHAR(50) NOT NULL DEFAULT 'sequential', + -- 重试设置 + retry_count INT DEFAULT 0, + retry_interval INT DEFAULT 5, + -- 超时(秒) + timeout INT DEFAULT 300, + -- 并发数 + concurrency INT DEFAULT 1, + -- 错误时停止 + stop_on_error BOOLEAN DEFAULT TRUE, + -- 通知设置 + notify_on_complete BOOLEAN DEFAULT FALSE, + notify_on_error BOOLEAN DEFAULT TRUE, + -- 状态: idle, running, completed, failed, cancelled + status VARCHAR(50) NOT NULL DEFAULT 'idle', + -- 统计 + run_count INT DEFAULT 0, + success_count INT DEFAULT 0, + last_run_at TIMESTAMP WITH TIME ZONE, + next_run_at TIMESTAMP WITH TIME ZONE, + -- 时间 + created_at TIMESTAMP WITH TIME ZONE NOT NULL DEFAULT CURRENT_TIMESTAMP, + updated_at TIMESTAMP WITH TIME ZONE NOT NULL DEFAULT CURRENT_TIMESTAMP, + deleted_at TIMESTAMP WITH TIME ZONE, + -- 约束 + CONSTRAINT fk_rpa_tasks_user FOREIGN KEY (user_uuid) REFERENCES users(uuid), + CONSTRAINT fk_rpa_tasks_team FOREIGN KEY (team_uuid) REFERENCES teams(uuid) +); + +-- 创建索引 +CREATE INDEX idx_rpa_tasks_user_uuid ON rpa_tasks(user_uuid); +CREATE INDEX idx_rpa_tasks_team_uuid ON rpa_tasks(team_uuid); +CREATE INDEX idx_rpa_tasks_trigger_type ON rpa_tasks(trigger_type); +CREATE INDEX idx_rpa_tasks_status ON rpa_tasks(status); +CREATE INDEX idx_rpa_tasks_next_run_at ON rpa_tasks(next_run_at); +CREATE INDEX idx_rpa_tasks_deleted_at ON rpa_tasks(deleted_at); + +-- 创建更新时间触发器 +CREATE TRIGGER update_rpa_tasks_updated_at BEFORE UPDATE ON rpa_tasks + FOR EACH ROW EXECUTE FUNCTION update_updated_at_column(); + diff --git a/migrations/20250120000035_create_rpa_task_steps.sql b/migrations/20250120000035_create_rpa_task_steps.sql new file mode 100644 index 0000000..c0deaaf --- /dev/null +++ b/migrations/20250120000035_create_rpa_task_steps.sql @@ -0,0 +1,39 @@ +-- 创建 rpa_task_steps 表 +-- RPA 任务步骤表 + +CREATE TABLE IF NOT EXISTS rpa_task_steps ( + id SERIAL PRIMARY KEY, + uuid UUID NOT NULL DEFAULT gen_random_uuid() UNIQUE, + task_uuid UUID NOT NULL, + -- 步骤类型: navigate, click, input, wait, screenshot, script, condition, loop, scroll, keyboard, download, upload + step_type VARCHAR(50) NOT NULL, + -- 步骤名称 + name VARCHAR(255) NOT NULL, + -- 步骤配置(JSON) + config JSONB NOT NULL DEFAULT '{}', + -- 是否启用 + enabled BOOLEAN DEFAULT TRUE, + -- 画布位置 + position_x INT DEFAULT 0, + position_y INT DEFAULT 0, + -- 排序 + sort_order INT DEFAULT 0, + -- 连接到的下一个步骤 + next_step_uuid UUID, + -- 条件分支(条件类型步骤) + branch_config JSONB, + -- 时间 + created_at TIMESTAMP WITH TIME ZONE NOT NULL DEFAULT CURRENT_TIMESTAMP, + updated_at TIMESTAMP WITH TIME ZONE NOT NULL DEFAULT CURRENT_TIMESTAMP, + -- 约束 + CONSTRAINT fk_rpa_task_steps_task FOREIGN KEY (task_uuid) REFERENCES rpa_tasks(uuid) ON DELETE CASCADE +); + +-- 创建索引 +CREATE INDEX idx_rpa_task_steps_task_uuid ON rpa_task_steps(task_uuid); +CREATE INDEX idx_rpa_task_steps_sort_order ON rpa_task_steps(sort_order); + +-- 创建更新时间触发器 +CREATE TRIGGER update_rpa_task_steps_updated_at BEFORE UPDATE ON rpa_task_steps + FOR EACH ROW EXECUTE FUNCTION update_updated_at_column(); + diff --git a/migrations/20250120000036_create_rpa_task_environments.sql b/migrations/20250120000036_create_rpa_task_environments.sql new file mode 100644 index 0000000..e68d5a6 --- /dev/null +++ b/migrations/20250120000036_create_rpa_task_environments.sql @@ -0,0 +1,20 @@ +-- 创建 rpa_task_environments 表 +-- RPA 任务-环境关联表(多对多) + +CREATE TABLE IF NOT EXISTS rpa_task_environments ( + id SERIAL PRIMARY KEY, + task_uuid UUID NOT NULL, + environment_uuid UUID NOT NULL, + sort_order INT DEFAULT 0, + created_at TIMESTAMP WITH TIME ZONE NOT NULL DEFAULT CURRENT_TIMESTAMP, + -- 约束 + CONSTRAINT fk_rpa_task_env_task FOREIGN KEY (task_uuid) REFERENCES rpa_tasks(uuid) ON DELETE CASCADE, + CONSTRAINT fk_rpa_task_env_env FOREIGN KEY (environment_uuid) REFERENCES environments(uuid) ON DELETE CASCADE, + -- 唯一约束 + CONSTRAINT uk_rpa_task_environments UNIQUE (task_uuid, environment_uuid) +); + +-- 创建索引 +CREATE INDEX idx_rpa_task_env_task_uuid ON rpa_task_environments(task_uuid); +CREATE INDEX idx_rpa_task_env_env_uuid ON rpa_task_environments(environment_uuid); + diff --git a/migrations/20250120000037_create_rpa_task_runs.sql b/migrations/20250120000037_create_rpa_task_runs.sql new file mode 100644 index 0000000..664da2e --- /dev/null +++ b/migrations/20250120000037_create_rpa_task_runs.sql @@ -0,0 +1,32 @@ +-- 创建 rpa_task_runs 表 +-- RPA 任务执行记录表 + +CREATE TABLE IF NOT EXISTS rpa_task_runs ( + id BIGSERIAL PRIMARY KEY, + uuid UUID NOT NULL DEFAULT gen_random_uuid() UNIQUE, + task_uuid UUID NOT NULL, + -- 状态: running, completed, failed, cancelled + status VARCHAR(50) NOT NULL DEFAULT 'running', + -- 步骤统计 + total_steps INT NOT NULL DEFAULT 0, + completed_steps INT NOT NULL DEFAULT 0, + failed_steps INT NOT NULL DEFAULT 0, + -- 执行时间 + started_at TIMESTAMP WITH TIME ZONE NOT NULL DEFAULT CURRENT_TIMESTAMP, + finished_at TIMESTAMP WITH TIME ZONE, + duration_ms BIGINT, + -- 结果摘要 + result_summary TEXT, + -- 错误信息 + error_message TEXT, + -- 执行日志(JSON 数组) + logs JSONB DEFAULT '[]', + -- 约束 + CONSTRAINT fk_rpa_task_runs_task FOREIGN KEY (task_uuid) REFERENCES rpa_tasks(uuid) ON DELETE CASCADE +); + +-- 创建索引 +CREATE INDEX idx_rpa_task_runs_task_uuid ON rpa_task_runs(task_uuid); +CREATE INDEX idx_rpa_task_runs_status ON rpa_task_runs(status); +CREATE INDEX idx_rpa_task_runs_started_at ON rpa_task_runs(started_at); + diff --git a/migrations/20250120000038_create_referral_link_tiers.sql b/migrations/20250120000038_create_referral_link_tiers.sql new file mode 100644 index 0000000..7da2d83 --- /dev/null +++ b/migrations/20250120000038_create_referral_link_tiers.sql @@ -0,0 +1,31 @@ +-- 创建 referral_link_tiers 表 +-- 推广链接层级配置表 + +CREATE TABLE IF NOT EXISTS referral_link_tiers ( + id SERIAL PRIMARY KEY, + uuid UUID NOT NULL DEFAULT gen_random_uuid() UNIQUE, + -- 层级名称 + name VARCHAR(100) NOT NULL, + -- 解锁条件(邀请人数) + unlock_threshold INT NOT NULL DEFAULT 0, + -- 奖励比例(百分比) + reward_rate DECIMAL(5, 2) NOT NULL DEFAULT 0, + -- 被邀请人折扣比例(百分比) + discount_rate DECIMAL(5, 2) NOT NULL DEFAULT 0, + -- 描述 + description TEXT, + -- 排序 + sort_order INT DEFAULT 0, + -- 时间 + created_at TIMESTAMP WITH TIME ZONE NOT NULL DEFAULT CURRENT_TIMESTAMP, + updated_at TIMESTAMP WITH TIME ZONE NOT NULL DEFAULT CURRENT_TIMESTAMP +); + +-- 创建索引 +CREATE INDEX idx_referral_link_tiers_unlock ON referral_link_tiers(unlock_threshold); +CREATE INDEX idx_referral_link_tiers_sort ON referral_link_tiers(sort_order); + +-- 创建更新时间触发器 +CREATE TRIGGER update_referral_link_tiers_updated_at BEFORE UPDATE ON referral_link_tiers + FOR EACH ROW EXECUTE FUNCTION update_updated_at_column(); + diff --git a/migrations/20250120000039_create_referral_links.sql b/migrations/20250120000039_create_referral_links.sql new file mode 100644 index 0000000..0366672 --- /dev/null +++ b/migrations/20250120000039_create_referral_links.sql @@ -0,0 +1,42 @@ +-- 创建 referral_links 表 +-- 用户推广链接表 + +CREATE TABLE IF NOT EXISTS referral_links ( + id SERIAL PRIMARY KEY, + uuid UUID NOT NULL DEFAULT gen_random_uuid() UNIQUE, + user_uuid UUID NOT NULL, + -- 链接码 + code VARCHAR(50) NOT NULL UNIQUE, + -- 完整 URL + url VARCHAR(512), + -- 当前层级 + tier_uuid UUID, + -- 是否解锁 + unlocked BOOLEAN DEFAULT TRUE, + -- 是否当前使用 + is_current BOOLEAN DEFAULT FALSE, + -- 奖励比例(冗余,便于查询) + reward_rate DECIMAL(5, 2) NOT NULL DEFAULT 0, + discount_rate DECIMAL(5, 2) NOT NULL DEFAULT 0, + -- 统计 + registered_users INT DEFAULT 0, + paid_users INT DEFAULT 0, + total_consumption DECIMAL(12, 2) DEFAULT 0, + last_30_days_consumption DECIMAL(12, 2) DEFAULT 0, + -- 时间 + created_at TIMESTAMP WITH TIME ZONE NOT NULL DEFAULT CURRENT_TIMESTAMP, + updated_at TIMESTAMP WITH TIME ZONE NOT NULL DEFAULT CURRENT_TIMESTAMP, + -- 约束 + CONSTRAINT fk_referral_links_user FOREIGN KEY (user_uuid) REFERENCES users(uuid) ON DELETE CASCADE, + CONSTRAINT fk_referral_links_tier FOREIGN KEY (tier_uuid) REFERENCES referral_link_tiers(uuid) +); + +-- 创建索引 +CREATE INDEX idx_referral_links_user_uuid ON referral_links(user_uuid); +CREATE INDEX idx_referral_links_code ON referral_links(code); +CREATE INDEX idx_referral_links_is_current ON referral_links(is_current); + +-- 创建更新时间触发器 +CREATE TRIGGER update_referral_links_updated_at BEFORE UPDATE ON referral_links + FOR EACH ROW EXECUTE FUNCTION update_updated_at_column(); + diff --git a/migrations/20250120000040_create_user_referrals.sql b/migrations/20250120000040_create_user_referrals.sql new file mode 100644 index 0000000..a52cdfb --- /dev/null +++ b/migrations/20250120000040_create_user_referrals.sql @@ -0,0 +1,32 @@ +-- 创建 user_referrals 表 +-- 用户邀请关系表 + +CREATE TABLE IF NOT EXISTS user_referrals ( + id SERIAL PRIMARY KEY, + -- 邀请者 + inviter_uuid UUID NOT NULL, + -- 被邀请者 + invitee_uuid UUID NOT NULL UNIQUE, + -- 通过哪个推广链接 + link_uuid UUID, + -- 状态: registered, activated, paid + status VARCHAR(50) NOT NULL DEFAULT 'registered', + -- 消费统计 + total_consumption DECIMAL(12, 2) DEFAULT 0, + last_30_days_consumption DECIMAL(12, 2) DEFAULT 0, + -- 时间 + registered_at TIMESTAMP WITH TIME ZONE NOT NULL DEFAULT CURRENT_TIMESTAMP, + activated_at TIMESTAMP WITH TIME ZONE, + first_paid_at TIMESTAMP WITH TIME ZONE, + -- 约束 + CONSTRAINT fk_user_referrals_inviter FOREIGN KEY (inviter_uuid) REFERENCES users(uuid), + CONSTRAINT fk_user_referrals_invitee FOREIGN KEY (invitee_uuid) REFERENCES users(uuid), + CONSTRAINT fk_user_referrals_link FOREIGN KEY (link_uuid) REFERENCES referral_links(uuid) +); + +-- 创建索引 +CREATE INDEX idx_user_referrals_inviter_uuid ON user_referrals(inviter_uuid); +CREATE INDEX idx_user_referrals_invitee_uuid ON user_referrals(invitee_uuid); +CREATE INDEX idx_user_referrals_status ON user_referrals(status); +CREATE INDEX idx_user_referrals_link_uuid ON user_referrals(link_uuid); + diff --git a/migrations/20250120000041_create_referral_rewards.sql b/migrations/20250120000041_create_referral_rewards.sql new file mode 100644 index 0000000..6d7f05d --- /dev/null +++ b/migrations/20250120000041_create_referral_rewards.sql @@ -0,0 +1,33 @@ +-- 创建 referral_rewards 表 +-- 推荐奖励记录表 + +CREATE TABLE IF NOT EXISTS referral_rewards ( + id BIGSERIAL PRIMARY KEY, + uuid UUID NOT NULL DEFAULT gen_random_uuid() UNIQUE, + user_uuid UUID NOT NULL, + -- 奖励类型: registration, first_pay, consumption + reward_type VARCHAR(50) NOT NULL, + -- 积分 + points INT NOT NULL, + -- 描述 + description TEXT, + -- 关联被邀请用户 + referred_user_uuid UUID, + -- 关联推广链接 + link_uuid UUID, + -- 状态: pending, approved, rejected + status VARCHAR(50) NOT NULL DEFAULT 'pending', + -- 时间 + created_at TIMESTAMP WITH TIME ZONE NOT NULL DEFAULT CURRENT_TIMESTAMP, + -- 约束 + CONSTRAINT fk_referral_rewards_user FOREIGN KEY (user_uuid) REFERENCES users(uuid), + CONSTRAINT fk_referral_rewards_referred FOREIGN KEY (referred_user_uuid) REFERENCES users(uuid), + CONSTRAINT fk_referral_rewards_link FOREIGN KEY (link_uuid) REFERENCES referral_links(uuid) +); + +-- 创建索引 +CREATE INDEX idx_referral_rewards_user_uuid ON referral_rewards(user_uuid); +CREATE INDEX idx_referral_rewards_reward_type ON referral_rewards(reward_type); +CREATE INDEX idx_referral_rewards_status ON referral_rewards(status); +CREATE INDEX idx_referral_rewards_created_at ON referral_rewards(created_at); + diff --git a/migrations/20250120000042_create_user_referral_points.sql b/migrations/20250120000042_create_user_referral_points.sql new file mode 100644 index 0000000..7147616 --- /dev/null +++ b/migrations/20250120000042_create_user_referral_points.sql @@ -0,0 +1,28 @@ +-- 创建 user_referral_points 表 +-- 用户推荐积分表 + +CREATE TABLE IF NOT EXISTS user_referral_points ( + id SERIAL PRIMARY KEY, + user_uuid UUID NOT NULL UNIQUE, + -- 总积分 + total_points INT NOT NULL DEFAULT 0, + -- 可用积分 + available_points INT NOT NULL DEFAULT 0, + -- 已使用积分 + used_points INT NOT NULL DEFAULT 0, + -- 待审核积分 + pending_points INT NOT NULL DEFAULT 0, + -- 时间 + created_at TIMESTAMP WITH TIME ZONE NOT NULL DEFAULT CURRENT_TIMESTAMP, + updated_at TIMESTAMP WITH TIME ZONE NOT NULL DEFAULT CURRENT_TIMESTAMP, + -- 约束 + CONSTRAINT fk_user_referral_points_user FOREIGN KEY (user_uuid) REFERENCES users(uuid) ON DELETE CASCADE +); + +-- 创建索引 +CREATE INDEX idx_user_referral_points_user_uuid ON user_referral_points(user_uuid); + +-- 创建更新时间触发器 +CREATE TRIGGER update_user_referral_points_updated_at BEFORE UPDATE ON user_referral_points + FOR EACH ROW EXECUTE FUNCTION update_updated_at_column(); + diff --git a/migrations/20250120000043_create_redeem_options.sql b/migrations/20250120000043_create_redeem_options.sql new file mode 100644 index 0000000..e1293e4 --- /dev/null +++ b/migrations/20250120000043_create_redeem_options.sql @@ -0,0 +1,36 @@ +-- 创建 redeem_options 表 +-- 积分兑换选项表 + +CREATE TABLE IF NOT EXISTS redeem_options ( + id SERIAL PRIMARY KEY, + uuid UUID NOT NULL DEFAULT gen_random_uuid() UNIQUE, + -- 兑换类型: wallet, coupon, gift + redeem_type VARCHAR(50) NOT NULL, + -- 名称 + name VARCHAR(255) NOT NULL, + -- 描述 + description TEXT, + -- 所需积分 + points_required INT NOT NULL, + -- 兑换价值 + value DECIMAL(12, 2) NOT NULL, + currency VARCHAR(10) DEFAULT 'USD', + -- 兑换比例(每 N 积分 = 1 单位价值) + exchange_rate INT NOT NULL DEFAULT 100, + -- 状态: active, inactive + status VARCHAR(50) NOT NULL DEFAULT 'active', + -- 排序 + sort_order INT DEFAULT 0, + -- 时间 + created_at TIMESTAMP WITH TIME ZONE NOT NULL DEFAULT CURRENT_TIMESTAMP, + updated_at TIMESTAMP WITH TIME ZONE NOT NULL DEFAULT CURRENT_TIMESTAMP +); + +-- 创建索引 +CREATE INDEX idx_redeem_options_redeem_type ON redeem_options(redeem_type); +CREATE INDEX idx_redeem_options_status ON redeem_options(status); + +-- 创建更新时间触发器 +CREATE TRIGGER update_redeem_options_updated_at BEFORE UPDATE ON redeem_options + FOR EACH ROW EXECUTE FUNCTION update_updated_at_column(); + diff --git a/migrations/20250120000044_create_redeem_records.sql b/migrations/20250120000044_create_redeem_records.sql new file mode 100644 index 0000000..72eb103 --- /dev/null +++ b/migrations/20250120000044_create_redeem_records.sql @@ -0,0 +1,29 @@ +-- 创建 redeem_records 表 +-- 积分兑换记录表 + +CREATE TABLE IF NOT EXISTS redeem_records ( + id BIGSERIAL PRIMARY KEY, + uuid UUID NOT NULL DEFAULT gen_random_uuid() UNIQUE, + user_uuid UUID NOT NULL, + option_uuid UUID NOT NULL, + -- 兑换积分 + points_used INT NOT NULL, + -- 兑换价值 + value DECIMAL(12, 2) NOT NULL, + currency VARCHAR(10) DEFAULT 'USD', + -- 状态: pending, completed, failed + status VARCHAR(50) NOT NULL DEFAULT 'pending', + -- 时间 + created_at TIMESTAMP WITH TIME ZONE NOT NULL DEFAULT CURRENT_TIMESTAMP, + completed_at TIMESTAMP WITH TIME ZONE, + -- 约束 + CONSTRAINT fk_redeem_records_user FOREIGN KEY (user_uuid) REFERENCES users(uuid), + CONSTRAINT fk_redeem_records_option FOREIGN KEY (option_uuid) REFERENCES redeem_options(uuid) +); + +-- 创建索引 +CREATE INDEX idx_redeem_records_user_uuid ON redeem_records(user_uuid); +CREATE INDEX idx_redeem_records_option_uuid ON redeem_records(option_uuid); +CREATE INDEX idx_redeem_records_status ON redeem_records(status); +CREATE INDEX idx_redeem_records_created_at ON redeem_records(created_at); + diff --git a/migrations/20250120000045_create_audit_logs.sql b/migrations/20250120000045_create_audit_logs.sql new file mode 100644 index 0000000..ecb8a4d --- /dev/null +++ b/migrations/20250120000045_create_audit_logs.sql @@ -0,0 +1,37 @@ +-- 创建 audit_logs 表 +-- 审计日志表 + +CREATE TABLE IF NOT EXISTS audit_logs ( + id BIGSERIAL PRIMARY KEY, + uuid UUID NOT NULL DEFAULT gen_random_uuid() UNIQUE, + user_uuid UUID NOT NULL, + team_uuid UUID, + -- 操作类型: login, logout, password_change, create, update, delete, batch_delete, start, stop, import, export, invite, role_change, member_remove, settings_update + action VARCHAR(50) NOT NULL, + -- 目标类型: environment, group, tag, proxy, account, team, settings, system + target_type VARCHAR(50) NOT NULL, + -- 目标 ID + target_uuid UUID, + target_name VARCHAR(255), + -- 详情 + details TEXT, + -- 变更内容(JSON) + changes JSONB, + -- 请求信息 + ip_address VARCHAR(45), + user_agent TEXT, + request_id VARCHAR(100), + -- 时间 + created_at TIMESTAMP WITH TIME ZONE NOT NULL DEFAULT CURRENT_TIMESTAMP, + -- 约束 + CONSTRAINT fk_audit_logs_user FOREIGN KEY (user_uuid) REFERENCES users(uuid) +); + +-- 创建索引 +CREATE INDEX idx_audit_logs_user_uuid ON audit_logs(user_uuid); +CREATE INDEX idx_audit_logs_team_uuid ON audit_logs(team_uuid); +CREATE INDEX idx_audit_logs_action ON audit_logs(action); +CREATE INDEX idx_audit_logs_target_type ON audit_logs(target_type); +CREATE INDEX idx_audit_logs_target_uuid ON audit_logs(target_uuid); +CREATE INDEX idx_audit_logs_created_at ON audit_logs(created_at); + diff --git a/migrations/20250120000046_create_api_keys.sql b/migrations/20250120000046_create_api_keys.sql new file mode 100644 index 0000000..7e7c840 --- /dev/null +++ b/migrations/20250120000046_create_api_keys.sql @@ -0,0 +1,43 @@ +-- 创建 api_keys 表 +-- API 密钥表 + +CREATE TABLE IF NOT EXISTS api_keys ( + id SERIAL PRIMARY KEY, + uuid UUID NOT NULL DEFAULT gen_random_uuid() UNIQUE, + user_uuid UUID NOT NULL, + -- 密钥信息 + name VARCHAR(255) NOT NULL, + key_hash VARCHAR(255) NOT NULL, + key_prefix VARCHAR(20) NOT NULL, + -- 权限: read, write, delete, admin + permissions JSONB NOT NULL DEFAULT '["read"]', + -- 限流 + rate_limit INT DEFAULT 1000, + daily_limit INT DEFAULT 10000, + -- IP 白名单(JSON 数组) + ip_whitelist JSONB DEFAULT '[]', + -- 过期时间 + expires_at TIMESTAMP WITH TIME ZONE, + -- 使用统计 + usage_count BIGINT DEFAULT 0, + daily_usage INT DEFAULT 0, + last_used_at TIMESTAMP WITH TIME ZONE, + -- 状态: active, revoked + status VARCHAR(50) NOT NULL DEFAULT 'active', + -- 时间 + created_at TIMESTAMP WITH TIME ZONE NOT NULL DEFAULT CURRENT_TIMESTAMP, + updated_at TIMESTAMP WITH TIME ZONE NOT NULL DEFAULT CURRENT_TIMESTAMP, + -- 约束 + CONSTRAINT fk_api_keys_user FOREIGN KEY (user_uuid) REFERENCES users(uuid) ON DELETE CASCADE +); + +-- 创建索引 +CREATE INDEX idx_api_keys_user_uuid ON api_keys(user_uuid); +CREATE INDEX idx_api_keys_key_hash ON api_keys(key_hash); +CREATE INDEX idx_api_keys_key_prefix ON api_keys(key_prefix); +CREATE INDEX idx_api_keys_status ON api_keys(status); + +-- 创建更新时间触发器 +CREATE TRIGGER update_api_keys_updated_at BEFORE UPDATE ON api_keys + FOR EACH ROW EXECUTE FUNCTION update_updated_at_column(); + diff --git a/migrations/20250120000047_create_user_preferences.sql b/migrations/20250120000047_create_user_preferences.sql new file mode 100644 index 0000000..97294b7 --- /dev/null +++ b/migrations/20250120000047_create_user_preferences.sql @@ -0,0 +1,26 @@ +-- 创建 user_preferences 表 +-- 用户偏好设置表(仅云同步设置) + +CREATE TABLE IF NOT EXISTS user_preferences ( + id SERIAL PRIMARY KEY, + user_uuid UUID NOT NULL UNIQUE, + -- 主题: light, dark, system + theme VARCHAR(50) NOT NULL DEFAULT 'system', + -- 语言 + language VARCHAR(20) NOT NULL DEFAULT 'zh-CN', + -- 通知开关 + notifications_enabled BOOLEAN NOT NULL DEFAULT TRUE, + -- 时间 + created_at TIMESTAMP WITH TIME ZONE NOT NULL DEFAULT CURRENT_TIMESTAMP, + updated_at TIMESTAMP WITH TIME ZONE NOT NULL DEFAULT CURRENT_TIMESTAMP, + -- 约束 + CONSTRAINT fk_user_preferences_user FOREIGN KEY (user_uuid) REFERENCES users(uuid) ON DELETE CASCADE +); + +-- 创建索引 +CREATE INDEX idx_user_preferences_user_uuid ON user_preferences(user_uuid); + +-- 创建更新时间触发器 +CREATE TRIGGER update_user_preferences_updated_at BEFORE UPDATE ON user_preferences + FOR EACH ROW EXECUTE FUNCTION update_updated_at_column(); + diff --git a/migrations/20250120000048_create_system_configs.sql b/migrations/20250120000048_create_system_configs.sql new file mode 100644 index 0000000..0e5078a --- /dev/null +++ b/migrations/20250120000048_create_system_configs.sql @@ -0,0 +1,23 @@ +-- 创建 system_configs 表 +-- 系统配置表(全局配置) + +CREATE TABLE IF NOT EXISTS system_configs ( + id SERIAL PRIMARY KEY, + -- 配置键 + config_key VARCHAR(255) NOT NULL UNIQUE, + -- 配置值(JSON) + config_value JSONB NOT NULL DEFAULT '{}', + -- 描述 + description TEXT, + -- 时间 + created_at TIMESTAMP WITH TIME ZONE NOT NULL DEFAULT CURRENT_TIMESTAMP, + updated_at TIMESTAMP WITH TIME ZONE NOT NULL DEFAULT CURRENT_TIMESTAMP +); + +-- 创建索引 +CREATE INDEX idx_system_configs_key ON system_configs(config_key); + +-- 创建更新时间触发器 +CREATE TRIGGER update_system_configs_updated_at BEFORE UPDATE ON system_configs + FOR EACH ROW EXECUTE FUNCTION update_updated_at_column(); + diff --git a/migrations/20250120000049_add_foreign_keys.sql b/migrations/20250120000049_add_foreign_keys.sql new file mode 100644 index 0000000..828dcb8 --- /dev/null +++ b/migrations/20250120000049_add_foreign_keys.sql @@ -0,0 +1,32 @@ +-- 添加延迟创建的外键约束 +-- 某些外键需要在相关表都创建后才能添加 + +-- teams.default_proxy_uuid -> proxies.uuid +DO $$ +BEGIN + IF NOT EXISTS ( + SELECT 1 FROM information_schema.table_constraints + WHERE constraint_name = 'fk_teams_default_proxy' + ) THEN + ALTER TABLE teams + ADD CONSTRAINT fk_teams_default_proxy + FOREIGN KEY (default_proxy_uuid) REFERENCES proxies(uuid) ON DELETE SET NULL; + END IF; +END $$; + +-- groups.default_proxy_uuid -> proxies.uuid +DO $$ +BEGIN + IF NOT EXISTS ( + SELECT 1 FROM information_schema.table_constraints + WHERE constraint_name = 'fk_groups_default_proxy' + ) THEN + ALTER TABLE groups + ADD CONSTRAINT fk_groups_default_proxy + FOREIGN KEY (default_proxy_uuid) REFERENCES proxies(uuid) ON DELETE SET NULL; + END IF; +END $$; + +-- 创建 groups.default_proxy_uuid 索引 +CREATE INDEX IF NOT EXISTS idx_groups_default_proxy ON groups(default_proxy_uuid); + diff --git a/migrations/20250120000050_insert_default_data.sql b/migrations/20250120000050_insert_default_data.sql new file mode 100644 index 0000000..c058ba4 --- /dev/null +++ b/migrations/20250120000050_insert_default_data.sql @@ -0,0 +1,37 @@ +-- 插入默认数据 + +-- 1. 默认套餐 +INSERT INTO plans (uuid, name, description, price_per_month, price_per_year, max_environments, max_team_members, max_proxies, max_rpa_tasks, is_recommended, sort_order, status) +VALUES + (gen_random_uuid(), 'Free', '免费套餐,适合个人试用', 0, 0, 5, 1, 5, 2, FALSE, 1, 'active'), + (gen_random_uuid(), 'Basic', '基础套餐,适合个人用户', 9.99, 99.99, 20, 3, 20, 5, FALSE, 2, 'active'), + (gen_random_uuid(), 'Pro', '专业套餐,适合小型团队', 29.99, 299.99, 100, 10, 100, 20, TRUE, 3, 'active'), + (gen_random_uuid(), 'Enterprise', '企业套餐,无限制', 99.99, 999.99, 1000, 100, 1000, 100, FALSE, 4, 'active') +ON CONFLICT DO NOTHING; + +-- 2. 默认推广链接层级 +INSERT INTO referral_link_tiers (uuid, name, unlock_threshold, reward_rate, discount_rate, description, sort_order) +VALUES + (gen_random_uuid(), '青铜', 0, 10.00, 5.00, '默认层级,邀请即享', 1), + (gen_random_uuid(), '白银', 10, 15.00, 8.00, '邀请满10人解锁', 2), + (gen_random_uuid(), '黄金', 50, 20.00, 10.00, '邀请满50人解锁', 3), + (gen_random_uuid(), '钻石', 100, 25.00, 15.00, '邀请满100人解锁', 4) +ON CONFLICT DO NOTHING; + +-- 3. 默认兑换选项 +INSERT INTO redeem_options (uuid, redeem_type, name, description, points_required, value, exchange_rate, status, sort_order) +VALUES + (gen_random_uuid(), 'wallet', '兑换余额', '100 积分 = $1', 100, 1.00, 100, 'active', 1), + (gen_random_uuid(), 'coupon', '兑换优惠券', '500 积分 = $10 优惠券', 500, 10.00, 50, 'active', 2), + (gen_random_uuid(), 'coupon', '兑换优惠券', '1000 积分 = $25 优惠券', 1000, 25.00, 40, 'active', 3) +ON CONFLICT DO NOTHING; + +-- 4. 默认系统配置 +INSERT INTO system_configs (config_key, config_value, description) +VALUES + ('app_version', '"1.0.0"', '应用版本'), + ('maintenance_mode', 'false', '维护模式'), + ('registration_enabled', 'true', '是否允许注册'), + ('referral_enabled', 'true', '是否启用推广计划') +ON CONFLICT (config_key) DO NOTHING; + diff --git a/migrations/20250120000051_remove_icon_and_color_fields.sql b/migrations/20250120000051_remove_icon_and_color_fields.sql new file mode 100644 index 0000000..8b1a178 --- /dev/null +++ b/migrations/20250120000051_remove_icon_and_color_fields.sql @@ -0,0 +1,10 @@ +-- 移除环境的 icon 和 icon_color 字段 +-- 移除分组的 color 字段 + +-- 移除 environments 表的 icon 和 icon_color 字段 +ALTER TABLE environments DROP COLUMN IF EXISTS icon; +ALTER TABLE environments DROP COLUMN IF EXISTS icon_color; + +-- 移除 groups 表的 color 字段 +ALTER TABLE groups DROP COLUMN IF EXISTS color; + diff --git a/migrations/20250123000001_create_console_admins.sql b/migrations/20250123000001_create_console_admins.sql new file mode 100644 index 0000000..bc530e8 --- /dev/null +++ b/migrations/20250123000001_create_console_admins.sql @@ -0,0 +1,28 @@ +-- Console Gateway 管理员表 +-- 用于存储 console-gateway 的管理员信息 + +CREATE TABLE IF NOT EXISTS console_admins ( + id SERIAL PRIMARY KEY, + uuid UUID NOT NULL DEFAULT gen_random_uuid() UNIQUE, + user_uuid UUID NOT NULL UNIQUE, + is_active BOOLEAN NOT NULL DEFAULT TRUE, + created_at TIMESTAMP WITH TIME ZONE NOT NULL DEFAULT CURRENT_TIMESTAMP, + updated_at TIMESTAMP WITH TIME ZONE NOT NULL DEFAULT CURRENT_TIMESTAMP, + deleted_at TIMESTAMP WITH TIME ZONE +); + +-- 索引 +CREATE INDEX IF NOT EXISTS idx_console_admins_user_uuid ON console_admins(user_uuid); +CREATE INDEX IF NOT EXISTS idx_console_admins_is_active ON console_admins(is_active); +CREATE INDEX IF NOT EXISTS idx_console_admins_deleted_at ON console_admins(deleted_at); + +-- 注释 +COMMENT ON TABLE console_admins IS 'Console Gateway 管理员表'; +COMMENT ON COLUMN console_admins.id IS '主键 ID'; +COMMENT ON COLUMN console_admins.uuid IS '唯一标识'; +COMMENT ON COLUMN console_admins.user_uuid IS '关联的用户 UUID'; +COMMENT ON COLUMN console_admins.is_active IS '是否激活'; +COMMENT ON COLUMN console_admins.created_at IS '创建时间'; +COMMENT ON COLUMN console_admins.updated_at IS '更新时间'; +COMMENT ON COLUMN console_admins.deleted_at IS '删除时间 (软删除)'; + diff --git a/migrations/20250123000002_create_console_permissions.sql b/migrations/20250123000002_create_console_permissions.sql new file mode 100644 index 0000000..4434cea --- /dev/null +++ b/migrations/20250123000002_create_console_permissions.sql @@ -0,0 +1,51 @@ +-- Console Gateway 权限表 +-- 用于存储 console-gateway 的路由权限 + +CREATE TABLE IF NOT EXISTS console_permissions ( + id SERIAL PRIMARY KEY, + uuid UUID NOT NULL DEFAULT gen_random_uuid() UNIQUE, + route_path VARCHAR(512) NOT NULL, + method VARCHAR(10) NOT NULL, + description TEXT, + created_at TIMESTAMP WITH TIME ZONE NOT NULL DEFAULT CURRENT_TIMESTAMP, + updated_at TIMESTAMP WITH TIME ZONE NOT NULL DEFAULT CURRENT_TIMESTAMP, + deleted_at TIMESTAMP WITH TIME ZONE, + UNIQUE(route_path, method) +); + +-- Console Gateway 管理员权限关联表 +CREATE TABLE IF NOT EXISTS console_admin_permissions ( + id SERIAL PRIMARY KEY, + uuid UUID NOT NULL DEFAULT gen_random_uuid() UNIQUE, + admin_id INTEGER NOT NULL REFERENCES console_admins(id) ON DELETE CASCADE, + permission_id INTEGER NOT NULL REFERENCES console_permissions(id) ON DELETE CASCADE, + granted_at TIMESTAMP WITH TIME ZONE NOT NULL DEFAULT CURRENT_TIMESTAMP, + granted_by UUID, + UNIQUE(admin_id, permission_id) +); + +-- 索引 +CREATE INDEX IF NOT EXISTS idx_console_permissions_route ON console_permissions(route_path, method); +CREATE INDEX IF NOT EXISTS idx_console_permissions_deleted_at ON console_permissions(deleted_at); +CREATE INDEX IF NOT EXISTS idx_console_admin_permissions_admin ON console_admin_permissions(admin_id); +CREATE INDEX IF NOT EXISTS idx_console_admin_permissions_permission ON console_admin_permissions(permission_id); + +-- 注释 +COMMENT ON TABLE console_permissions IS 'Console Gateway 路由权限表'; +COMMENT ON COLUMN console_permissions.id IS '主键 ID'; +COMMENT ON COLUMN console_permissions.uuid IS '唯一标识'; +COMMENT ON COLUMN console_permissions.route_path IS '路由路径'; +COMMENT ON COLUMN console_permissions.method IS 'HTTP 方法 (GET, POST, PUT, DELETE, PATCH)'; +COMMENT ON COLUMN console_permissions.description IS '权限描述'; +COMMENT ON COLUMN console_permissions.created_at IS '创建时间'; +COMMENT ON COLUMN console_permissions.updated_at IS '更新时间'; +COMMENT ON COLUMN console_permissions.deleted_at IS '删除时间 (软删除)'; + +COMMENT ON TABLE console_admin_permissions IS 'Console Gateway 管理员权限关联表'; +COMMENT ON COLUMN console_admin_permissions.id IS '主键 ID'; +COMMENT ON COLUMN console_admin_permissions.uuid IS '唯一标识'; +COMMENT ON COLUMN console_admin_permissions.admin_id IS '管理员 ID'; +COMMENT ON COLUMN console_admin_permissions.permission_id IS '权限 ID'; +COMMENT ON COLUMN console_admin_permissions.granted_at IS '授权时间'; +COMMENT ON COLUMN console_admin_permissions.granted_by IS '授权人 UUID'; + diff --git a/migrations/20250123000003_create_console_api_keys.sql b/migrations/20250123000003_create_console_api_keys.sql new file mode 100644 index 0000000..a923c1c --- /dev/null +++ b/migrations/20250123000003_create_console_api_keys.sql @@ -0,0 +1,41 @@ +-- Console Gateway API 密钥表 +-- 用于存储 console-gateway 的 API 密钥 + +CREATE TABLE IF NOT EXISTS console_api_keys ( + id SERIAL PRIMARY KEY, + uuid UUID NOT NULL DEFAULT gen_random_uuid() UNIQUE, + key_id VARCHAR(64) NOT NULL UNIQUE, + key_secret VARCHAR(128) NOT NULL, + name VARCHAR(255) NOT NULL, + description TEXT, + is_active BOOLEAN NOT NULL DEFAULT TRUE, + created_at TIMESTAMP WITH TIME ZONE NOT NULL DEFAULT CURRENT_TIMESTAMP, + updated_at TIMESTAMP WITH TIME ZONE NOT NULL DEFAULT CURRENT_TIMESTAMP, + expires_at TIMESTAMP WITH TIME ZONE, + last_used_at TIMESTAMP WITH TIME ZONE, + created_by UUID, + deleted_at TIMESTAMP WITH TIME ZONE +); + +-- 索引 +CREATE INDEX IF NOT EXISTS idx_console_api_keys_key_id ON console_api_keys(key_id); +CREATE INDEX IF NOT EXISTS idx_console_api_keys_is_active ON console_api_keys(is_active); +CREATE INDEX IF NOT EXISTS idx_console_api_keys_expires_at ON console_api_keys(expires_at); +CREATE INDEX IF NOT EXISTS idx_console_api_keys_deleted_at ON console_api_keys(deleted_at); + +-- 注释 +COMMENT ON TABLE console_api_keys IS 'Console Gateway API 密钥表'; +COMMENT ON COLUMN console_api_keys.id IS '主键 ID'; +COMMENT ON COLUMN console_api_keys.uuid IS '唯一标识'; +COMMENT ON COLUMN console_api_keys.key_id IS 'API 密钥 ID (公开部分)'; +COMMENT ON COLUMN console_api_keys.key_secret IS 'API 密钥密文 (私密部分)'; +COMMENT ON COLUMN console_api_keys.name IS '密钥名称'; +COMMENT ON COLUMN console_api_keys.description IS '密钥描述'; +COMMENT ON COLUMN console_api_keys.is_active IS '是否激活'; +COMMENT ON COLUMN console_api_keys.created_at IS '创建时间'; +COMMENT ON COLUMN console_api_keys.updated_at IS '更新时间'; +COMMENT ON COLUMN console_api_keys.expires_at IS '过期时间'; +COMMENT ON COLUMN console_api_keys.last_used_at IS '最后使用时间'; +COMMENT ON COLUMN console_api_keys.created_by IS '创建人 UUID'; +COMMENT ON COLUMN console_api_keys.deleted_at IS '删除时间 (软删除)'; + diff --git a/migrations/20250124000001_create_messages.sql b/migrations/20250124000001_create_messages.sql new file mode 100644 index 0000000..e904ff0 --- /dev/null +++ b/migrations/20250124000001_create_messages.sql @@ -0,0 +1,148 @@ +-- 创建 messages 表 +-- 消息表,用于存储系统消息、团队邀请通知等 + +CREATE TABLE IF NOT EXISTS messages ( + id SERIAL PRIMARY KEY, + uuid UUID NOT NULL DEFAULT gen_random_uuid() UNIQUE, + + -- 消息类型 + message_type VARCHAR(50) NOT NULL, + -- 可选值: + -- 'private_chat' - 用户私信 + -- 'team_announcement' - 团队公告 + -- 'team_invitation' - 团队邀请 + -- 'team_removal' - 团队移除成员 + -- 'system_notification' - 系统通知 + + -- 消息内容 + title VARCHAR(255) NOT NULL, -- 消息标题 + content TEXT, -- 消息内容(支持 JSON 格式存储扩展数据) + + -- 发送者 + sender_uuid UUID, -- 发送者 UUID(系统消息可为 NULL) + + -- 接收者模式 + recipient_type VARCHAR(20) NOT NULL DEFAULT 'single', + -- 'single' - 单个用户 + -- 'multiple' - 多个指定用户 + -- 'team' - 团队内所有成员 + -- 'all' - 所有用户(系统广播) + + -- 关联资源(根据消息类型关联不同的资源) + related_type VARCHAR(50), -- 关联类型:team, invitation, etc. + related_uuid UUID, -- 关联资源的 UUID(如 team_uuid, invitation_uuid) + + -- 消息元数据(JSON 格式,存储扩展信息) + metadata JSONB, + -- 例如:{ + -- "team_name": "研发团队", + -- "inviter_name": "张三", + -- "role": "editor" + -- } + + -- 消息状态 + status VARCHAR(20) NOT NULL DEFAULT 'active', -- active, deleted + priority VARCHAR(20) NOT NULL DEFAULT 'normal', -- low, normal, high, urgent + + -- 时间戳 + created_at TIMESTAMP WITH TIME ZONE NOT NULL DEFAULT CURRENT_TIMESTAMP, + updated_at TIMESTAMP WITH TIME ZONE NOT NULL DEFAULT CURRENT_TIMESTAMP, + deleted_at TIMESTAMP WITH TIME ZONE, + + -- 外键约束 + CONSTRAINT fk_messages_sender FOREIGN KEY (sender_uuid) + REFERENCES users(uuid) ON DELETE SET NULL +); + +-- 创建 user_messages 表(用户消息关联表,支持多接收者) +CREATE TABLE IF NOT EXISTS user_messages ( + id SERIAL PRIMARY KEY, + message_uuid UUID NOT NULL, + user_uuid UUID NOT NULL, + + -- 阅读状态 + is_read BOOLEAN NOT NULL DEFAULT FALSE, + read_at TIMESTAMP WITH TIME ZONE, + + -- 操作状态(用于邀请、移除等需要操作的消息) + action_status VARCHAR(20), + -- 'pending' - 待处理(邀请类消息) + -- 'accepted' - 已接受 + -- 'rejected' - 已拒绝 + -- 'expired' - 已过期 + -- NULL - 无需操作的消息 + + action_at TIMESTAMP WITH TIME ZONE, + + -- 时间戳 + created_at TIMESTAMP WITH TIME ZONE NOT NULL DEFAULT CURRENT_TIMESTAMP, + updated_at TIMESTAMP WITH TIME ZONE NOT NULL DEFAULT CURRENT_TIMESTAMP, + + -- 外键约束 + CONSTRAINT fk_user_messages_message FOREIGN KEY (message_uuid) + REFERENCES messages(uuid) ON DELETE CASCADE, + CONSTRAINT fk_user_messages_user FOREIGN KEY (user_uuid) + REFERENCES users(uuid) ON DELETE CASCADE, + + -- 唯一约束:一个用户对一条消息只能有一条记录 + CONSTRAINT uk_user_messages_message_user UNIQUE (message_uuid, user_uuid) +); + +-- 创建索引 +CREATE INDEX IF NOT EXISTS idx_messages_type ON messages(message_type); +CREATE INDEX IF NOT EXISTS idx_messages_sender ON messages(sender_uuid); +CREATE INDEX IF NOT EXISTS idx_messages_related ON messages(related_type, related_uuid); +CREATE INDEX IF NOT EXISTS idx_messages_recipient_type ON messages(recipient_type); +CREATE INDEX IF NOT EXISTS idx_messages_status ON messages(status); +CREATE INDEX IF NOT EXISTS idx_messages_priority ON messages(priority); +CREATE INDEX IF NOT EXISTS idx_messages_created_at ON messages(created_at DESC); +CREATE INDEX IF NOT EXISTS idx_messages_deleted_at ON messages(deleted_at); + +CREATE INDEX IF NOT EXISTS idx_user_messages_user ON user_messages(user_uuid); +CREATE INDEX IF NOT EXISTS idx_user_messages_message ON user_messages(message_uuid); +CREATE INDEX IF NOT EXISTS idx_user_messages_is_read ON user_messages(user_uuid, is_read); +CREATE INDEX IF NOT EXISTS idx_user_messages_action_status ON user_messages(user_uuid, action_status); +CREATE INDEX IF NOT EXISTS idx_user_messages_user_unread ON user_messages(user_uuid, is_read) + WHERE is_read = FALSE; + +-- 复合索引:用于查询用户未读消息 +CREATE INDEX IF NOT EXISTS idx_user_messages_user_type_unread + ON user_messages(user_uuid, is_read, created_at DESC) + WHERE is_read = FALSE; + +-- 创建更新时间触发器 +CREATE TRIGGER update_messages_updated_at BEFORE UPDATE ON messages + FOR EACH ROW EXECUTE FUNCTION update_updated_at_column(); + +CREATE TRIGGER update_user_messages_updated_at BEFORE UPDATE ON user_messages + FOR EACH ROW EXECUTE FUNCTION update_updated_at_column(); + +-- 团队消息自动分发触发器 +-- 当创建 recipient_type='team' 的消息时,自动为团队成员创建 user_messages 记录 +CREATE OR REPLACE FUNCTION auto_create_team_message_recipients() +RETURNS TRIGGER AS $$ +BEGIN + -- 如果接收者类型是 team,且有关联的团队 UUID + IF NEW.recipient_type = 'team' AND NEW.related_type = 'team' AND NEW.related_uuid IS NOT NULL THEN + -- 为团队所有活跃成员创建消息关联记录 + INSERT INTO user_messages (message_uuid, user_uuid, is_read, action_status) + SELECT NEW.uuid, tm.user_uuid, FALSE, + CASE + WHEN NEW.message_type = 'team_invitation' THEN 'pending' + ELSE NULL + END + FROM team_members tm + WHERE tm.team_uuid = NEW.related_uuid + AND tm.status = 'active' + AND tm.deleted_at IS NULL + ON CONFLICT (message_uuid, user_uuid) DO NOTHING; + END IF; + RETURN NEW; +END; +$$ LANGUAGE plpgsql; + +CREATE TRIGGER trigger_auto_create_team_message_recipients + AFTER INSERT ON messages + FOR EACH ROW + EXECUTE FUNCTION auto_create_team_message_recipients(); + diff --git a/migrations/20250124000002_add_deleted_at_to_team_invitations.sql b/migrations/20250124000002_add_deleted_at_to_team_invitations.sql new file mode 100644 index 0000000..f96eab0 --- /dev/null +++ b/migrations/20250124000002_add_deleted_at_to_team_invitations.sql @@ -0,0 +1,9 @@ +-- 为 team_invitations 表添加 deleted_at 字段 +-- 用于软删除功能 + +ALTER TABLE team_invitations +ADD COLUMN IF NOT EXISTS deleted_at TIMESTAMP WITH TIME ZONE; + +-- 创建索引以优化查询性能 +CREATE INDEX IF NOT EXISTS idx_invitations_deleted_at ON team_invitations(deleted_at) WHERE deleted_at IS NULL; + diff --git a/migrations/20250125000001_create_workspaces.sql b/migrations/20250125000001_create_workspaces.sql new file mode 100644 index 0000000..1bf948e --- /dev/null +++ b/migrations/20250125000001_create_workspaces.sql @@ -0,0 +1,34 @@ +-- 创建 workspaces 表 +-- 工作空间表,资源隔离的顶层容器 + +CREATE TABLE IF NOT EXISTS workspaces ( + uuid UUID PRIMARY KEY DEFAULT gen_random_uuid(), + name VARCHAR(255) NOT NULL, + owner_uuid UUID NOT NULL, + workspace_type VARCHAR(50) NOT NULL DEFAULT 'personal', + created_at TIMESTAMP WITH TIME ZONE NOT NULL DEFAULT CURRENT_TIMESTAMP, + updated_at TIMESTAMP WITH TIME ZONE NOT NULL DEFAULT CURRENT_TIMESTAMP, + deleted_at TIMESTAMP WITH TIME ZONE, + -- 约束 + CONSTRAINT fk_workspaces_owner FOREIGN KEY (owner_uuid) REFERENCES users(uuid) +); + +-- 创建索引 +CREATE INDEX idx_workspaces_owner_uuid ON workspaces(owner_uuid); +CREATE INDEX idx_workspaces_deleted_at ON workspaces(deleted_at); +CREATE INDEX idx_workspaces_workspace_type ON workspaces(workspace_type); + +-- 创建更新时间触发器 +CREATE TRIGGER update_workspaces_updated_at BEFORE UPDATE ON workspaces + FOR EACH ROW EXECUTE FUNCTION update_updated_at_column(); + +-- 列注释 +COMMENT ON TABLE workspaces IS '工作空间表,资源隔离的顶层容器'; +COMMENT ON COLUMN workspaces.uuid IS '工作空间唯一标识'; +COMMENT ON COLUMN workspaces.name IS '工作空间名称'; +COMMENT ON COLUMN workspaces.owner_uuid IS '所有者用户 UUID'; +COMMENT ON COLUMN workspaces.workspace_type IS '工作空间类型:personal/team/enterprise'; +COMMENT ON COLUMN workspaces.created_at IS '创建时间'; +COMMENT ON COLUMN workspaces.updated_at IS '更新时间'; +COMMENT ON COLUMN workspaces.deleted_at IS '删除时间(软删除)'; + diff --git a/migrations/20250125000002_create_workspace_quotas.sql b/migrations/20250125000002_create_workspace_quotas.sql new file mode 100644 index 0000000..e195227 --- /dev/null +++ b/migrations/20250125000002_create_workspace_quotas.sql @@ -0,0 +1,43 @@ +-- 创建 workspace_quotas 表 +-- 工作空间配额表,定义工作空间的资源配额限制 + +CREATE TABLE IF NOT EXISTS workspace_quotas ( + workspace_uuid UUID PRIMARY KEY, + -- 环境配额 + max_environments INT NOT NULL DEFAULT 10, + used_environments INT NOT NULL DEFAULT 0, + -- 团队成员配额(所有团队总和) + max_team_members INT NOT NULL DEFAULT 5, + used_team_members INT NOT NULL DEFAULT 0, + -- 代理配额 + max_proxies INT NOT NULL DEFAULT 10, + used_proxies INT NOT NULL DEFAULT 0, + -- RPA 任务配额 + max_rpa_tasks INT NOT NULL DEFAULT 5, + used_rpa_tasks INT NOT NULL DEFAULT 0, + -- 时间 + created_at TIMESTAMP WITH TIME ZONE NOT NULL DEFAULT CURRENT_TIMESTAMP, + updated_at TIMESTAMP WITH TIME ZONE NOT NULL DEFAULT CURRENT_TIMESTAMP, + -- 约束 + CONSTRAINT fk_workspace_quotas_workspace FOREIGN KEY (workspace_uuid) REFERENCES workspaces(uuid) ON DELETE CASCADE +); + +-- 创建索引 +CREATE INDEX idx_workspace_quotas_workspace_uuid ON workspace_quotas(workspace_uuid); + +-- 创建更新时间触发器 +CREATE TRIGGER update_workspace_quotas_updated_at BEFORE UPDATE ON workspace_quotas + FOR EACH ROW EXECUTE FUNCTION update_updated_at_column(); + +-- 列注释 +COMMENT ON TABLE workspace_quotas IS '工作空间配额表,定义工作空间的资源配额限制'; +COMMENT ON COLUMN workspace_quotas.workspace_uuid IS '工作空间 UUID'; +COMMENT ON COLUMN workspace_quotas.max_environments IS '最大环境数'; +COMMENT ON COLUMN workspace_quotas.used_environments IS '已使用环境数'; +COMMENT ON COLUMN workspace_quotas.max_team_members IS '最大成员数(所有团队总和)'; +COMMENT ON COLUMN workspace_quotas.used_team_members IS '已使用成员数'; +COMMENT ON COLUMN workspace_quotas.max_proxies IS '最大代理数'; +COMMENT ON COLUMN workspace_quotas.used_proxies IS '已使用代理数'; +COMMENT ON COLUMN workspace_quotas.max_rpa_tasks IS '最大 RPA 任务数'; +COMMENT ON COLUMN workspace_quotas.used_rpa_tasks IS '已使用 RPA 任务数'; + diff --git a/migrations/20250125000003_create_proxy_visible_teams.sql b/migrations/20250125000003_create_proxy_visible_teams.sql new file mode 100644 index 0000000..353955d --- /dev/null +++ b/migrations/20250125000003_create_proxy_visible_teams.sql @@ -0,0 +1,28 @@ +-- 创建 proxy_visible_teams 表 +-- 代理可见团队关联表,控制代理对哪些团队可见 + +CREATE TABLE IF NOT EXISTS proxy_visible_teams ( + proxy_uuid UUID NOT NULL, + workspace_uuid UUID NOT NULL, + team_uuid UUID NOT NULL, + created_at TIMESTAMP WITH TIME ZONE NOT NULL DEFAULT CURRENT_TIMESTAMP, + -- 约束 + CONSTRAINT fk_proxy_visible_teams_proxy FOREIGN KEY (proxy_uuid) REFERENCES proxies(uuid) ON DELETE CASCADE, + CONSTRAINT fk_proxy_visible_teams_workspace FOREIGN KEY (workspace_uuid) REFERENCES workspaces(uuid) ON DELETE CASCADE, + CONSTRAINT fk_proxy_visible_teams_team FOREIGN KEY (team_uuid) REFERENCES teams(uuid) ON DELETE CASCADE, + -- 唯一约束:一个代理对一个团队只能有一条可见性记录 + CONSTRAINT uk_proxy_visible_teams UNIQUE (proxy_uuid, team_uuid) +); + +-- 创建索引 +CREATE INDEX idx_proxy_visible_teams_proxy_uuid ON proxy_visible_teams(proxy_uuid); +CREATE INDEX idx_proxy_visible_teams_team_uuid ON proxy_visible_teams(team_uuid); +CREATE INDEX idx_proxy_visible_teams_workspace_uuid ON proxy_visible_teams(workspace_uuid); + +-- 列注释 +COMMENT ON TABLE proxy_visible_teams IS '代理可见团队关联表,控制代理对哪些团队可见'; +COMMENT ON COLUMN proxy_visible_teams.proxy_uuid IS '代理 UUID'; +COMMENT ON COLUMN proxy_visible_teams.workspace_uuid IS '工作空间 UUID(冗余,便于查询)'; +COMMENT ON COLUMN proxy_visible_teams.team_uuid IS '团队 UUID'; +COMMENT ON COLUMN proxy_visible_teams.created_at IS '创建时间'; + diff --git a/migrations/20250125000004_create_group_member_permissions.sql b/migrations/20250125000004_create_group_member_permissions.sql new file mode 100644 index 0000000..7d65419 --- /dev/null +++ b/migrations/20250125000004_create_group_member_permissions.sql @@ -0,0 +1,45 @@ +-- 创建 group_member_permissions 表 +-- 分组权限表,控制团队成员对分组的访问权限 + +CREATE TABLE IF NOT EXISTS group_member_permissions ( + group_uuid UUID NOT NULL, + workspace_uuid UUID NOT NULL, + team_uuid UUID NOT NULL, + user_uuid UUID NOT NULL, + permission_type VARCHAR(50) NOT NULL DEFAULT 'read', + granted_by UUID NOT NULL, + created_at TIMESTAMP WITH TIME ZONE NOT NULL DEFAULT CURRENT_TIMESTAMP, + updated_at TIMESTAMP WITH TIME ZONE NOT NULL DEFAULT CURRENT_TIMESTAMP, + -- 约束 + CONSTRAINT fk_group_member_permissions_group FOREIGN KEY (group_uuid) REFERENCES groups(uuid) ON DELETE CASCADE, + CONSTRAINT fk_group_member_permissions_workspace FOREIGN KEY (workspace_uuid) REFERENCES workspaces(uuid) ON DELETE CASCADE, + CONSTRAINT fk_group_member_permissions_team FOREIGN KEY (team_uuid) REFERENCES teams(uuid) ON DELETE CASCADE, + CONSTRAINT fk_group_member_permissions_user FOREIGN KEY (user_uuid) REFERENCES users(uuid) ON DELETE CASCADE, + CONSTRAINT fk_group_member_permissions_granted_by FOREIGN KEY (granted_by) REFERENCES users(uuid), + -- 唯一约束:一个用户对一个分组只能有一条权限记录 + CONSTRAINT uk_group_member_permissions UNIQUE (group_uuid, user_uuid), + -- 检查约束:权限类型必须是 read/write/manage 之一 + CONSTRAINT ck_group_member_permissions_type CHECK (permission_type IN ('read', 'write', 'manage')) +); + +-- 创建索引 +CREATE INDEX idx_group_member_permissions_group_uuid ON group_member_permissions(group_uuid); +CREATE INDEX idx_group_member_permissions_user_uuid ON group_member_permissions(user_uuid); +CREATE INDEX idx_group_member_permissions_workspace_uuid ON group_member_permissions(workspace_uuid); +CREATE INDEX idx_group_member_permissions_team_uuid ON group_member_permissions(team_uuid); + +-- 创建更新时间触发器 +CREATE TRIGGER update_group_member_permissions_updated_at BEFORE UPDATE ON group_member_permissions + FOR EACH ROW EXECUTE FUNCTION update_updated_at_column(); + +-- 列注释 +COMMENT ON TABLE group_member_permissions IS '分组权限表,控制团队成员对分组的访问权限'; +COMMENT ON COLUMN group_member_permissions.group_uuid IS '分组 UUID'; +COMMENT ON COLUMN group_member_permissions.workspace_uuid IS '工作空间 UUID(冗余,便于查询)'; +COMMENT ON COLUMN group_member_permissions.team_uuid IS '团队 UUID(冗余,便于查询)'; +COMMENT ON COLUMN group_member_permissions.user_uuid IS '用户 UUID'; +COMMENT ON COLUMN group_member_permissions.permission_type IS '权限类型:read/write/manage'; +COMMENT ON COLUMN group_member_permissions.granted_by IS '授权者 UUID'; +COMMENT ON COLUMN group_member_permissions.created_at IS '创建时间'; +COMMENT ON COLUMN group_member_permissions.updated_at IS '更新时间'; + diff --git a/migrations/20250125000005_alter_teams_add_workspace.sql b/migrations/20250125000005_alter_teams_add_workspace.sql new file mode 100644 index 0000000..7c06755 --- /dev/null +++ b/migrations/20250125000005_alter_teams_add_workspace.sql @@ -0,0 +1,19 @@ +-- 修改 teams 表,添加工作空间支持 +-- 添加 workspace_uuid,移除配额相关字段和默认代理 + +-- 添加 workspace_uuid 列(先允许 NULL,数据迁移后再设置为 NOT NULL) +ALTER TABLE teams ADD COLUMN IF NOT EXISTS workspace_uuid UUID; + +-- 创建索引 +CREATE INDEX IF NOT EXISTS idx_teams_workspace_uuid ON teams(workspace_uuid); + +-- 移除配额相关字段(配额移至 workspace_quotas) +ALTER TABLE teams DROP COLUMN IF EXISTS max_members; +ALTER TABLE teams DROP COLUMN IF EXISTS max_environments; +ALTER TABLE teams DROP COLUMN IF EXISTS max_proxies; + +-- 移除默认代理字段(不再需要默认代理) +ALTER TABLE teams DROP COLUMN IF EXISTS default_proxy_uuid; + +-- 注意:workspace_uuid 的外键约束和数据填充将在数据迁移脚本中完成 + diff --git a/migrations/20250125000006_alter_team_members_add_workspace.sql b/migrations/20250125000006_alter_team_members_add_workspace.sql new file mode 100644 index 0000000..ea5c086 --- /dev/null +++ b/migrations/20250125000006_alter_team_members_add_workspace.sql @@ -0,0 +1,21 @@ +-- 修改 team_members 表,添加工作空间支持 +-- 添加 workspace_uuid(冗余字段),移除统计字段 + +-- 添加 workspace_uuid 列(先允许 NULL,数据迁移后再设置为 NOT NULL) +ALTER TABLE team_members ADD COLUMN IF NOT EXISTS workspace_uuid UUID; + +-- 创建索引 +CREATE INDEX IF NOT EXISTS idx_team_members_workspace_uuid ON team_members(workspace_uuid); + +-- 移除统计字段(可通过查询计算) +ALTER TABLE team_members DROP COLUMN IF EXISTS environment_count; +ALTER TABLE team_members DROP COLUMN IF EXISTS group_count; + +-- 删除旧的唯一约束 +ALTER TABLE team_members DROP CONSTRAINT IF EXISTS uk_team_members; + +-- 添加新的唯一约束(包含 workspace_uuid) +ALTER TABLE team_members ADD CONSTRAINT uk_team_members UNIQUE (team_uuid, user_uuid, workspace_uuid); + +-- 注意:workspace_uuid 的外键约束和数据填充将在数据迁移脚本中完成 + diff --git a/migrations/20250125000007_alter_groups_add_workspace.sql b/migrations/20250125000007_alter_groups_add_workspace.sql new file mode 100644 index 0000000..96e83bd --- /dev/null +++ b/migrations/20250125000007_alter_groups_add_workspace.sql @@ -0,0 +1,25 @@ +-- 修改 groups 表,添加工作空间支持 +-- 添加 workspace_uuid,移除 user_uuid, default_proxy_uuid, color,确保 team_uuid NOT NULL + +-- 添加 workspace_uuid 列(先允许 NULL,数据迁移后再设置为 NOT NULL) +ALTER TABLE groups ADD COLUMN IF NOT EXISTS workspace_uuid UUID; + +-- 创建索引 +CREATE INDEX IF NOT EXISTS idx_groups_workspace_uuid ON groups(workspace_uuid); + +-- 移除 user_uuid(分组属于团队,不再直接属于用户) +ALTER TABLE groups DROP COLUMN IF EXISTS user_uuid; + +-- 移除默认代理字段(不再需要默认代理) +ALTER TABLE groups DROP COLUMN IF EXISTS default_proxy_uuid; + +-- 移除 color 字段(简化设计) +ALTER TABLE groups DROP COLUMN IF EXISTS color; + +-- 删除旧的索引 +DROP INDEX IF EXISTS idx_groups_user_uuid; + +-- 注意: +-- 1. workspace_uuid 的外键约束和数据填充将在数据迁移脚本中完成 +-- 2. team_uuid 的 NOT NULL 约束将在数据迁移后添加(确保所有数据都有 team_uuid) + diff --git a/migrations/20250125000008_alter_environments_add_workspace.sql b/migrations/20250125000008_alter_environments_add_workspace.sql new file mode 100644 index 0000000..92819d7 --- /dev/null +++ b/migrations/20250125000008_alter_environments_add_workspace.sql @@ -0,0 +1,13 @@ +-- 修改 environments 表,添加工作空间支持 +-- 添加 workspace_uuid,确保 team_uuid NOT NULL + +-- 添加 workspace_uuid 列(先允许 NULL,数据迁移后再设置为 NOT NULL) +ALTER TABLE environments ADD COLUMN IF NOT EXISTS workspace_uuid UUID; + +-- 创建索引 +CREATE INDEX IF NOT EXISTS idx_environments_workspace_uuid ON environments(workspace_uuid); + +-- 注意: +-- 1. workspace_uuid 的外键约束和数据填充将在数据迁移脚本中完成 +-- 2. team_uuid 的 NOT NULL 约束将在数据迁移后添加(确保所有数据都有 team_uuid) + diff --git a/migrations/20250125000009_alter_proxies_add_workspace.sql b/migrations/20250125000009_alter_proxies_add_workspace.sql new file mode 100644 index 0000000..4fcedf0 --- /dev/null +++ b/migrations/20250125000009_alter_proxies_add_workspace.sql @@ -0,0 +1,29 @@ +-- 修改 proxies 表,添加工作空间支持 +-- 添加 workspace_uuid 和 owner_uuid(重命名自 user_uuid),移除 team_uuid, usage_count + +-- 添加 workspace_uuid 列(先允许 NULL,数据迁移后再设置为 NOT NULL) +ALTER TABLE proxies ADD COLUMN IF NOT EXISTS workspace_uuid UUID; + +-- 添加 owner_uuid 列(先允许 NULL,数据迁移后再设置为 NOT NULL) +ALTER TABLE proxies ADD COLUMN IF NOT EXISTS owner_uuid UUID; + +-- 创建索引 +CREATE INDEX IF NOT EXISTS idx_proxies_workspace_uuid ON proxies(workspace_uuid); +CREATE INDEX IF NOT EXISTS idx_proxies_owner_uuid ON proxies(owner_uuid); + +-- 将 user_uuid 的数据复制到 owner_uuid(如果 owner_uuid 为空) +UPDATE proxies SET owner_uuid = user_uuid WHERE owner_uuid IS NULL; + +-- 移除 team_uuid(代理属于工作空间,不属于团队) +ALTER TABLE proxies DROP COLUMN IF EXISTS team_uuid; + +-- 移除 usage_count(可通过查询计算) +ALTER TABLE proxies DROP COLUMN IF EXISTS usage_count; + +-- 删除旧的索引 +DROP INDEX IF EXISTS idx_proxies_team_uuid; + +-- 注意: +-- 1. workspace_uuid 和 owner_uuid 的外键约束将在数据迁移脚本中完成 +-- 2. user_uuid 列将在数据迁移后删除(迁移到 owner_uuid) + diff --git a/migrations/20250125000010_alter_subscriptions_add_workspace.sql b/migrations/20250125000010_alter_subscriptions_add_workspace.sql new file mode 100644 index 0000000..eb6a47d --- /dev/null +++ b/migrations/20250125000010_alter_subscriptions_add_workspace.sql @@ -0,0 +1,13 @@ +-- 修改 subscriptions 表,添加工作空间支持 +-- 添加 workspace_uuid,保留 user_uuid(用于记录订阅者) + +-- 添加 workspace_uuid 列(先允许 NULL,数据迁移后再设置为 NOT NULL) +ALTER TABLE subscriptions ADD COLUMN IF NOT EXISTS workspace_uuid UUID; + +-- 创建索引 +CREATE INDEX IF NOT EXISTS idx_subscriptions_workspace_uuid ON subscriptions(workspace_uuid); + +-- 注意: +-- 1. workspace_uuid 的外键约束和数据填充将在数据迁移脚本中完成 +-- 2. 唯一约束(确保一个工作空间只有一个活跃订阅)将在数据迁移后添加 + diff --git a/migrations/20250125000011_deprecate_user_quotas.sql b/migrations/20250125000011_deprecate_user_quotas.sql new file mode 100644 index 0000000..294fc7a --- /dev/null +++ b/migrations/20250125000011_deprecate_user_quotas.sql @@ -0,0 +1,8 @@ +-- 废弃 user_quotas 表 +-- 保留表结构,但标记为废弃,数据将迁移到 workspace_quotas + +-- 添加注释标记为废弃 +COMMENT ON TABLE user_quotas IS '已废弃:配额已迁移到 workspace_quotas 表,此表保留仅用于历史数据查询'; + +-- 注意:表结构保持不变,数据迁移将在数据迁移脚本中完成 + diff --git a/migrations/20250125000012_migrate_to_workspaces.sql b/migrations/20250125000012_migrate_to_workspaces.sql new file mode 100644 index 0000000..3140a0c --- /dev/null +++ b/migrations/20250125000012_migrate_to_workspaces.sql @@ -0,0 +1,416 @@ +-- 数据迁移脚本:将现有数据迁移到工作空间架构 +-- 此脚本将: +-- 1. 为每个用户创建默认工作空间(personal 类型) +-- 2. 为每个现有团队创建对应的工作空间(team 类型) +-- 3. 迁移配额数据从 user_quotas 到 workspace_quotas +-- 4. 更新所有相关表的外键关系 + +BEGIN; + +-- ============================================ +-- 步骤 1: 为每个用户创建默认工作空间 +-- ============================================ +INSERT INTO workspaces (uuid, name, owner_uuid, workspace_type, created_at, updated_at) +SELECT + gen_random_uuid(), + COALESCE(ui.nickname, ui.email, '我的工作空间') || ' 的工作空间', + u.uuid, + 'personal', + u.created_at, + u.updated_at +FROM users u +LEFT JOIN user_infos ui ON u.uuid = ui.user_uuid +WHERE u.deleted_at IS NULL + AND NOT EXISTS ( + SELECT 1 FROM workspaces w + WHERE w.owner_uuid = u.uuid + AND w.workspace_type = 'personal' + AND w.deleted_at IS NULL + ); + +-- ============================================ +-- 步骤 2: 为每个现有团队创建对应的工作空间 +-- ============================================ +INSERT INTO workspaces (uuid, name, owner_uuid, workspace_type, created_at, updated_at) +SELECT + gen_random_uuid(), + t.name || ' 的工作空间', + t.owner_uuid, + 'team', + t.created_at, + t.updated_at +FROM teams t +WHERE t.deleted_at IS NULL + AND NOT EXISTS ( + SELECT 1 FROM workspaces w + WHERE w.owner_uuid = t.owner_uuid + AND w.name = t.name || ' 的工作空间' + AND w.deleted_at IS NULL + ); + +-- ============================================ +-- 步骤 3: 更新 teams 表的 workspace_uuid +-- ============================================ +-- 为每个团队分配工作空间(优先使用团队对应的工作空间,如果没有则使用团队所有者的个人工作空间) +UPDATE teams t +SET workspace_uuid = COALESCE( + (SELECT w.uuid FROM workspaces w + WHERE w.owner_uuid = t.owner_uuid + AND w.workspace_type = 'team' + AND w.name = t.name || ' 的工作空间' + AND w.deleted_at IS NULL + LIMIT 1), + (SELECT w.uuid FROM workspaces w + WHERE w.owner_uuid = t.owner_uuid + AND w.workspace_type = 'personal' + AND w.deleted_at IS NULL + LIMIT 1) +) +WHERE t.workspace_uuid IS NULL + AND t.deleted_at IS NULL; + +-- 设置 workspace_uuid 为 NOT NULL +ALTER TABLE teams ALTER COLUMN workspace_uuid SET NOT NULL; + +-- 添加外键约束 +ALTER TABLE teams +ADD CONSTRAINT fk_teams_workspace +FOREIGN KEY (workspace_uuid) REFERENCES workspaces(uuid) ON DELETE CASCADE; + +-- ============================================ +-- 步骤 4: 更新 team_members 表的 workspace_uuid +-- ============================================ +UPDATE team_members tm +SET workspace_uuid = t.workspace_uuid +FROM teams t +WHERE tm.team_uuid = t.uuid + AND tm.workspace_uuid IS NULL + AND tm.deleted_at IS NULL; + +-- 设置 workspace_uuid 为 NOT NULL +ALTER TABLE team_members ALTER COLUMN workspace_uuid SET NOT NULL; + +-- 添加外键约束 +ALTER TABLE team_members +ADD CONSTRAINT fk_team_members_workspace +FOREIGN KEY (workspace_uuid) REFERENCES workspaces(uuid) ON DELETE CASCADE; + +-- ============================================ +-- 步骤 5: 更新 groups 表的 workspace_uuid +-- ============================================ +-- 首先更新有 team_uuid 的分组 +UPDATE groups g +SET workspace_uuid = t.workspace_uuid +FROM teams t +WHERE g.team_uuid = t.uuid + AND g.workspace_uuid IS NULL + AND g.deleted_at IS NULL; + +-- 对于没有 team_uuid 的分组,先分配一个默认团队 +UPDATE groups g +SET team_uuid = ( + SELECT t.uuid FROM teams t + INNER JOIN workspaces w ON t.workspace_uuid = w.uuid + WHERE w.owner_uuid = ( + -- 尝试从 environments 表找到关联的用户 + SELECT e.user_uuid FROM environments e + WHERE e.group_uuid = g.uuid + AND e.deleted_at IS NULL + LIMIT 1 + ) + AND t.deleted_at IS NULL + LIMIT 1 +) +WHERE g.team_uuid IS NULL + AND g.deleted_at IS NULL; + +-- 再次更新 workspace_uuid(包括刚刚分配了 team_uuid 的分组) +UPDATE groups g +SET workspace_uuid = t.workspace_uuid +FROM teams t +WHERE g.team_uuid = t.uuid + AND g.workspace_uuid IS NULL + AND g.deleted_at IS NULL; + +-- 对于仍然没有 workspace_uuid 的分组,使用用户的个人工作空间 +UPDATE groups g +SET workspace_uuid = ( + SELECT w.uuid FROM workspaces w + WHERE w.owner_uuid = ( + SELECT e.user_uuid FROM environments e + WHERE e.group_uuid = g.uuid + AND e.deleted_at IS NULL + LIMIT 1 + ) + AND w.workspace_type = 'personal' + AND w.deleted_at IS NULL + LIMIT 1 +) +WHERE g.workspace_uuid IS NULL + AND g.deleted_at IS NULL; + +-- 如果还有 NULL 值,使用第一个可用的工作空间(兜底方案) +UPDATE groups g +SET workspace_uuid = ( + SELECT w.uuid FROM workspaces w + WHERE w.deleted_at IS NULL + ORDER BY w.created_at + LIMIT 1 +) +WHERE g.workspace_uuid IS NULL + AND g.deleted_at IS NULL; + +-- 验证:确保所有分组都有 workspace_uuid +DO $$ +DECLARE + null_count INTEGER; +BEGIN + SELECT COUNT(*) INTO null_count + FROM groups + WHERE workspace_uuid IS NULL + AND deleted_at IS NULL; + + IF null_count > 0 THEN + RAISE EXCEPTION '仍有 % 个分组的 workspace_uuid 为 NULL,请手动处理', null_count; + END IF; +END $$; + +-- 设置 workspace_uuid 为 NOT NULL +ALTER TABLE groups ALTER COLUMN workspace_uuid SET NOT NULL; + +-- 确保 team_uuid 为 NOT NULL(删除没有 team_uuid 的分组或分配默认团队) +-- 注意:这里假设所有分组都应该有 team_uuid,如果没有则可能需要手动处理 +UPDATE groups g +SET team_uuid = ( + SELECT t.uuid FROM teams t + INNER JOIN workspaces w ON t.workspace_uuid = w.uuid + WHERE w.owner_uuid = ( + SELECT owner_uuid FROM workspaces WHERE uuid = g.workspace_uuid + ) + AND t.deleted_at IS NULL + LIMIT 1 +) +WHERE g.team_uuid IS NULL +AND g.deleted_at IS NULL; + +-- 设置 team_uuid 为 NOT NULL(如果还有 NULL 值,可能需要手动处理) +-- ALTER TABLE groups ALTER COLUMN team_uuid SET NOT NULL; + +-- 添加外键约束 +ALTER TABLE groups +ADD CONSTRAINT fk_groups_workspace +FOREIGN KEY (workspace_uuid) REFERENCES workspaces(uuid) ON DELETE CASCADE; + +-- ============================================ +-- 步骤 6: 更新 environments 表的 workspace_uuid +-- ============================================ +UPDATE environments e +SET workspace_uuid = t.workspace_uuid +FROM teams t +WHERE e.team_uuid = t.uuid + AND e.workspace_uuid IS NULL + AND e.deleted_at IS NULL; + +-- 对于没有 team_uuid 的环境,使用用户的个人工作空间 +UPDATE environments e +SET workspace_uuid = ( + SELECT w.uuid FROM workspaces w + WHERE w.owner_uuid = e.user_uuid + AND w.workspace_type = 'personal' + AND w.deleted_at IS NULL + LIMIT 1 +) +WHERE e.workspace_uuid IS NULL + AND e.deleted_at IS NULL; + +-- 设置 workspace_uuid 为 NOT NULL +ALTER TABLE environments ALTER COLUMN workspace_uuid SET NOT NULL; + +-- 确保 team_uuid 为 NOT NULL(为没有 team_uuid 的环境分配默认团队) +UPDATE environments e +SET team_uuid = ( + SELECT t.uuid FROM teams t + INNER JOIN workspaces w ON t.workspace_uuid = w.uuid + WHERE w.uuid = e.workspace_uuid + AND t.deleted_at IS NULL + LIMIT 1 +) +WHERE e.team_uuid IS NULL + AND e.deleted_at IS NULL; + +-- 设置 team_uuid 为 NOT NULL(如果还有 NULL 值,可能需要手动处理) +-- ALTER TABLE environments ALTER COLUMN team_uuid SET NOT NULL; + +-- 添加外键约束 +ALTER TABLE environments +ADD CONSTRAINT fk_environments_workspace +FOREIGN KEY (workspace_uuid) REFERENCES workspaces(uuid) ON DELETE CASCADE; + +-- ============================================ +-- 步骤 7: 更新 proxies 表的 workspace_uuid 和 owner_uuid +-- ============================================ +-- 确保 owner_uuid 已填充(从 user_uuid 复制) +UPDATE proxies p +SET owner_uuid = p.user_uuid +WHERE p.owner_uuid IS NULL + AND p.deleted_at IS NULL; + +-- 为代理分配工作空间(优先使用团队的工作空间,如果没有则使用用户的个人工作空间) +UPDATE proxies p +SET workspace_uuid = COALESCE( + (SELECT t.workspace_uuid FROM teams t + WHERE t.uuid = ( + SELECT e.team_uuid FROM environments e + WHERE e.proxy_uuid = p.uuid + AND e.deleted_at IS NULL + LIMIT 1 + ) + AND t.deleted_at IS NULL + LIMIT 1), + (SELECT w.uuid FROM workspaces w + WHERE w.owner_uuid = p.owner_uuid + AND w.workspace_type = 'personal' + AND w.deleted_at IS NULL + LIMIT 1) +) +WHERE p.workspace_uuid IS NULL + AND p.deleted_at IS NULL; + +-- 设置 workspace_uuid 和 owner_uuid 为 NOT NULL +ALTER TABLE proxies ALTER COLUMN workspace_uuid SET NOT NULL; +ALTER TABLE proxies ALTER COLUMN owner_uuid SET NOT NULL; + +-- 添加外键约束 +ALTER TABLE proxies +ADD CONSTRAINT fk_proxies_workspace +FOREIGN KEY (workspace_uuid) REFERENCES workspaces(uuid) ON DELETE CASCADE; + +ALTER TABLE proxies +ADD CONSTRAINT fk_proxies_owner +FOREIGN KEY (owner_uuid) REFERENCES users(uuid) ON DELETE CASCADE; + +-- 删除旧的 user_uuid 列(如果存在) +ALTER TABLE proxies DROP COLUMN IF EXISTS user_uuid; + +-- ============================================ +-- 步骤 8: 更新 subscriptions 表的 workspace_uuid +-- ============================================ +-- 为订阅分配工作空间(使用订阅者的个人工作空间) +UPDATE subscriptions s +SET workspace_uuid = ( + SELECT w.uuid FROM workspaces w + WHERE w.owner_uuid = s.user_uuid + AND w.workspace_type = 'personal' + AND w.deleted_at IS NULL + LIMIT 1 +) +WHERE s.workspace_uuid IS NULL; + +-- 设置 workspace_uuid 为 NOT NULL +ALTER TABLE subscriptions ALTER COLUMN workspace_uuid SET NOT NULL; + +-- 添加外键约束 +ALTER TABLE subscriptions +ADD CONSTRAINT fk_subscriptions_workspace +FOREIGN KEY (workspace_uuid) REFERENCES workspaces(uuid) ON DELETE CASCADE; + +-- ============================================ +-- 步骤 9: 迁移配额数据从 user_quotas 到 workspace_quotas +-- ============================================ +INSERT INTO workspace_quotas ( + workspace_uuid, + max_environments, + used_environments, + max_team_members, + used_team_members, + max_proxies, + used_proxies, + max_rpa_tasks, + used_rpa_tasks, + created_at, + updated_at +) +SELECT + w.uuid, + COALESCE(uq.max_environments, 10), + COALESCE(uq.used_environments, 0), + COALESCE(uq.max_team_members, 5), + 0, -- used_team_members: user_quotas 表中没有此字段,使用默认值 0 + COALESCE(uq.max_proxies, 10), + COALESCE(uq.used_proxies, 0), + COALESCE(uq.max_rpa_tasks, 5), + COALESCE(uq.used_rpa_tasks, 0), + COALESCE(uq.created_at, CURRENT_TIMESTAMP), + COALESCE(uq.updated_at, CURRENT_TIMESTAMP) +FROM workspaces w +LEFT JOIN user_quotas uq ON w.owner_uuid = uq.user_uuid +WHERE w.deleted_at IS NULL + AND NOT EXISTS ( + SELECT 1 FROM workspace_quotas wq + WHERE wq.workspace_uuid = w.uuid + ); + +-- ============================================ +-- 步骤 10: 更新配额使用情况统计 +-- ============================================ +-- 更新环境使用数 +UPDATE workspace_quotas wq +SET used_environments = ( + SELECT COUNT(*) FROM environments e + WHERE e.workspace_uuid = wq.workspace_uuid + AND e.deleted_at IS NULL +); + +-- 更新代理使用数 +UPDATE workspace_quotas wq +SET used_proxies = ( + SELECT COUNT(*) FROM proxies p + WHERE p.workspace_uuid = wq.workspace_uuid + AND p.deleted_at IS NULL +); + +-- 更新团队成员使用数(所有团队总和) +UPDATE workspace_quotas wq +SET used_team_members = ( + SELECT COUNT(DISTINCT tm.user_uuid) FROM team_members tm + INNER JOIN teams t ON tm.team_uuid = t.uuid + WHERE t.workspace_uuid = wq.workspace_uuid + AND tm.deleted_at IS NULL + AND t.deleted_at IS NULL +); + +-- 更新 RPA 任务使用数(通过 team_uuid 关联到工作空间) +UPDATE workspace_quotas wq +SET used_rpa_tasks = ( + SELECT COUNT(*) FROM rpa_tasks rt + INNER JOIN teams t ON rt.team_uuid = t.uuid + WHERE t.workspace_uuid = wq.workspace_uuid + AND rt.deleted_at IS NULL + AND t.deleted_at IS NULL +) +WHERE EXISTS ( + SELECT 1 FROM information_schema.tables + WHERE table_name = 'rpa_tasks' +); + +COMMIT; + +-- 迁移完成后的验证查询(可选,用于检查迁移结果) +-- SELECT +-- 'workspaces' as table_name, +-- COUNT(*) as total_count, +-- COUNT(*) FILTER (WHERE deleted_at IS NULL) as active_count +-- FROM workspaces +-- UNION ALL +-- SELECT +-- 'teams', +-- COUNT(*), +-- COUNT(*) FILTER (WHERE deleted_at IS NULL) +-- FROM teams +-- UNION ALL +-- SELECT +-- 'workspace_quotas', +-- COUNT(*), +-- COUNT(*) +-- FROM workspace_quotas; + diff --git a/migrations/20250125000013_alter_user_infos_add_current_workspace.sql b/migrations/20250125000013_alter_user_infos_add_current_workspace.sql new file mode 100644 index 0000000..9ccc763 --- /dev/null +++ b/migrations/20250125000013_alter_user_infos_add_current_workspace.sql @@ -0,0 +1,44 @@ +-- 修改 user_infos 表,添加当前工作空间字段 +-- 用于工作空间切换功能 + +ALTER TABLE user_infos +ADD COLUMN IF NOT EXISTS current_workspace_uuid UUID; + +-- 添加外键约束(如果不存在) +DO $$ +BEGIN + IF NOT EXISTS ( + SELECT 1 FROM information_schema.table_constraints + WHERE constraint_name = 'fk_user_infos_current_workspace' + ) THEN + ALTER TABLE user_infos + ADD CONSTRAINT fk_user_infos_current_workspace + FOREIGN KEY (current_workspace_uuid) REFERENCES workspaces(uuid) ON DELETE SET NULL; + END IF; +END $$; + +-- 创建索引 +CREATE INDEX IF NOT EXISTS idx_user_infos_current_workspace ON user_infos(current_workspace_uuid); + +-- 从当前团队的工作空间初始化 current_workspace_uuid +UPDATE user_infos ui +SET current_workspace_uuid = ( + SELECT t.workspace_uuid FROM teams t + WHERE t.uuid = ui.current_team_uuid + AND t.deleted_at IS NULL + LIMIT 1 +) +WHERE ui.current_team_uuid IS NOT NULL + AND ui.current_workspace_uuid IS NULL; + +-- 对于没有当前团队的用户,使用其个人工作空间 +UPDATE user_infos ui +SET current_workspace_uuid = ( + SELECT w.uuid FROM workspaces w + WHERE w.owner_uuid = ui.user_uuid + AND w.workspace_type = 'personal' + AND w.deleted_at IS NULL + LIMIT 1 +) +WHERE ui.current_workspace_uuid IS NULL; + diff --git a/migrations/20250126000001_insert_default_plans_with_features.sql b/migrations/20250126000001_insert_default_plans_with_features.sql new file mode 100644 index 0000000..e2366bb --- /dev/null +++ b/migrations/20250126000001_insert_default_plans_with_features.sql @@ -0,0 +1,432 @@ +-- 插入默认套餐数据和特性 +-- 删除现有套餐数据(包括关联的特性数据,因为有外键约束会自动删除) + +-- 1. 先删除 invoices 表中引用 subscriptions 的数据 +-- 由于 invoices 表有外键约束引用 subscriptions,需要先删除发票数据 +-- 注意:这会删除所有现有发票记录,请确保这是预期的行为 +DELETE FROM invoices; + +-- 2. 删除 subscriptions 表中引用 plans 的数据 +-- 由于 subscriptions 表有外键约束(没有 ON DELETE CASCADE),需要先删除订阅数据 +-- 注意:这会删除所有现有订阅记录,请确保这是预期的行为 +DELETE FROM subscriptions; + +-- 3. 删除现有套餐数据(会级联删除 plan_features 数据) +DELETE FROM plans; + +-- 4. 插入新的套餐数据 +-- 套餐:免费套餐、基础套餐、专业套餐、商业套餐、企业套餐 +-- 月付价格:0, 21.4, 11.4, 758.4, 3558.4 +-- 年付价格:按月付*10计算(相当于2个月免费) +-- 最大环境:8, 100, 1000, 10000, 100000 +-- 最大团队:1, 5, 10, 18, 30 +-- 代理数量:所有都是99999 +-- max_rpa_tasks:所有都是99999 + +INSERT INTO plans (uuid, name, description, price_per_month, price_per_year, currency, max_environments, max_team_members, max_proxies, max_rpa_tasks, is_recommended, sort_order, status) +VALUES + -- 免费套餐:月付 0,年付 0,环境 8,团队 1 + (gen_random_uuid(), '免费套餐', '适合个人试用,体验基础功能', 0.00, 0.00, 'USD', 8, 1, 99999, 99999, FALSE, 1, 'active'), + -- 基础套餐:月付 21.4,年付 214.00,环境 100,团队 5 + (gen_random_uuid(), '基础套餐', '适合个人用户和小型项目', 21.40, 214.00, 'USD', 100, 5, 99999, 99999, FALSE, 2, 'active'), + -- 专业套餐:月付 11.4,年付 114.00,环境 1000,团队 10 + (gen_random_uuid(), '专业套餐', '适合小型团队和成长型企业', 108.40, 1084.00, 'USD', 1000, 10, 99999, 99999, TRUE, 3, 'active'), + -- 商业套餐:月付 758.4,年付 7584.00,环境 10000,团队 18 + (gen_random_uuid(), '商业套餐', '适合中大型企业和专业团队', 758.40, 7584.00, 'USD', 10000, 18, 99999, 99999, FALSE, 4, 'active'), + -- 企业套餐:月付 3558.4,年付 35584.00,环境 100000,团队 30 + (gen_random_uuid(), '企业套餐', '适合大型企业,提供最高级别的服务和支持', 3558.40, 35584.00, 'USD', 100000, 30, 99999, 99999, FALSE, 5, 'active') +ON CONFLICT DO NOTHING; + +-- 5. 为每个套餐插入特性数据 +-- 使用 CTE 来获取套餐 UUID,然后插入特性 + +-- 免费套餐特性 +INSERT INTO plan_features (plan_uuid, feature_key, feature_name, feature_value, is_included, sort_order) +SELECT + p.uuid, + 'env_limit', + '环境数量', + '8 个环境', + TRUE, + 1 +FROM plans p WHERE p.name = '免费套餐' +ON CONFLICT (plan_uuid, feature_key) DO NOTHING; + +INSERT INTO plan_features (plan_uuid, feature_key, feature_name, feature_value, is_included, sort_order) +SELECT + p.uuid, + 'team_members', + '团队成员', + '1 人', + TRUE, + 2 +FROM plans p WHERE p.name = '免费套餐' +ON CONFLICT (plan_uuid, feature_key) DO NOTHING; + +INSERT INTO plan_features (plan_uuid, feature_key, feature_name, feature_value, is_included, sort_order) +SELECT + p.uuid, + 'basic_support', + '基础支持', + '社区支持', + TRUE, + 3 +FROM plans p WHERE p.name = '免费套餐' +ON CONFLICT (plan_uuid, feature_key) DO NOTHING; + +-- 基础套餐特性 +INSERT INTO plan_features (plan_uuid, feature_key, feature_name, feature_value, is_included, sort_order) +SELECT + p.uuid, + 'env_limit', + '环境数量', + '100 个环境', + TRUE, + 1 +FROM plans p WHERE p.name = '基础套餐' +ON CONFLICT (plan_uuid, feature_key) DO NOTHING; + +INSERT INTO plan_features (plan_uuid, feature_key, feature_name, feature_value, is_included, sort_order) +SELECT + p.uuid, + 'team_members', + '团队成员', + '5 人', + TRUE, + 2 +FROM plans p WHERE p.name = '基础套餐' +ON CONFLICT (plan_uuid, feature_key) DO NOTHING; + +INSERT INTO plan_features (plan_uuid, feature_key, feature_name, feature_value, is_included, sort_order) +SELECT + p.uuid, + 'proxy_access', + '代理访问', + '99999 个代理', + TRUE, + 3 +FROM plans p WHERE p.name = '基础套餐' +ON CONFLICT (plan_uuid, feature_key) DO NOTHING; + +INSERT INTO plan_features (plan_uuid, feature_key, feature_name, feature_value, is_included, sort_order) +SELECT + p.uuid, + 'rpa_tasks', + 'RPA 任务', + '99999 个任务', + TRUE, + 4 +FROM plans p WHERE p.name = '基础套餐' +ON CONFLICT (plan_uuid, feature_key) DO NOTHING; + +INSERT INTO plan_features (plan_uuid, feature_key, feature_name, feature_value, is_included, sort_order) +SELECT + p.uuid, + 'email_support', + '邮件支持', + '工作日支持', + TRUE, + 5 +FROM plans p WHERE p.name = '基础套餐' +ON CONFLICT (plan_uuid, feature_key) DO NOTHING; + +-- 专业套餐特性(包含基础套餐的所有特性,并增加更多) +INSERT INTO plan_features (plan_uuid, feature_key, feature_name, feature_value, is_included, sort_order) +SELECT + p.uuid, + 'env_limit', + '环境数量', + '1000 个环境', + TRUE, + 1 +FROM plans p WHERE p.name = '专业套餐' +ON CONFLICT (plan_uuid, feature_key) DO NOTHING; + +INSERT INTO plan_features (plan_uuid, feature_key, feature_name, feature_value, is_included, sort_order) +SELECT + p.uuid, + 'team_members', + '团队成员', + '10 人', + TRUE, + 2 +FROM plans p WHERE p.name = '专业套餐' +ON CONFLICT (plan_uuid, feature_key) DO NOTHING; + +INSERT INTO plan_features (plan_uuid, feature_key, feature_name, feature_value, is_included, sort_order) +SELECT + p.uuid, + 'proxy_access', + '代理访问', + '99999 个代理', + TRUE, + 3 +FROM plans p WHERE p.name = '专业套餐' +ON CONFLICT (plan_uuid, feature_key) DO NOTHING; + +INSERT INTO plan_features (plan_uuid, feature_key, feature_name, feature_value, is_included, sort_order) +SELECT + p.uuid, + 'rpa_tasks', + 'RPA 任务', + '99999 个任务', + TRUE, + 4 +FROM plans p WHERE p.name = '专业套餐' +ON CONFLICT (plan_uuid, feature_key) DO NOTHING; + +INSERT INTO plan_features (plan_uuid, feature_key, feature_name, feature_value, is_included, sort_order) +SELECT + p.uuid, + 'priority_support', + '优先支持', + '工作日优先响应', + TRUE, + 5 +FROM plans p WHERE p.name = '专业套餐' +ON CONFLICT (plan_uuid, feature_key) DO NOTHING; + +INSERT INTO plan_features (plan_uuid, feature_key, feature_name, feature_value, is_included, sort_order) +SELECT + p.uuid, + 'api_access', + 'API 访问', + '完整 API 权限', + TRUE, + 6 +FROM plans p WHERE p.name = '专业套餐' +ON CONFLICT (plan_uuid, feature_key) DO NOTHING; + +INSERT INTO plan_features (plan_uuid, feature_key, feature_name, feature_value, is_included, sort_order) +SELECT + p.uuid, + 'advanced_features', + '高级功能', + '指纹管理、Cookie 同步等', + TRUE, + 7 +FROM plans p WHERE p.name = '专业套餐' +ON CONFLICT (plan_uuid, feature_key) DO NOTHING; + +-- 商业套餐特性(包含专业套餐的所有特性,并增加更多) +INSERT INTO plan_features (plan_uuid, feature_key, feature_name, feature_value, is_included, sort_order) +SELECT + p.uuid, + 'env_limit', + '环境数量', + '10000 个环境', + TRUE, + 1 +FROM plans p WHERE p.name = '商业套餐' +ON CONFLICT (plan_uuid, feature_key) DO NOTHING; + +INSERT INTO plan_features (plan_uuid, feature_key, feature_name, feature_value, is_included, sort_order) +SELECT + p.uuid, + 'team_members', + '团队成员', + '18 人', + TRUE, + 2 +FROM plans p WHERE p.name = '商业套餐' +ON CONFLICT (plan_uuid, feature_key) DO NOTHING; + +INSERT INTO plan_features (plan_uuid, feature_key, feature_name, feature_value, is_included, sort_order) +SELECT + p.uuid, + 'proxy_access', + '代理访问', + '99999 个代理', + TRUE, + 3 +FROM plans p WHERE p.name = '商业套餐' +ON CONFLICT (plan_uuid, feature_key) DO NOTHING; + +INSERT INTO plan_features (plan_uuid, feature_key, feature_name, feature_value, is_included, sort_order) +SELECT + p.uuid, + 'rpa_tasks', + 'RPA 任务', + '99999 个任务', + TRUE, + 4 +FROM plans p WHERE p.name = '商业套餐' +ON CONFLICT (plan_uuid, feature_key) DO NOTHING; + +INSERT INTO plan_features (plan_uuid, feature_key, feature_name, feature_value, is_included, sort_order) +SELECT + p.uuid, + 'dedicated_support', + '专属支持', + '7x24 小时专属支持', + TRUE, + 5 +FROM plans p WHERE p.name = '商业套餐' +ON CONFLICT (plan_uuid, feature_key) DO NOTHING; + +INSERT INTO plan_features (plan_uuid, feature_key, feature_name, feature_value, is_included, sort_order) +SELECT + p.uuid, + 'api_access', + 'API 访问', + '完整 API 权限 + 高级 API', + TRUE, + 6 +FROM plans p WHERE p.name = '商业套餐' +ON CONFLICT (plan_uuid, feature_key) DO NOTHING; + +INSERT INTO plan_features (plan_uuid, feature_key, feature_name, feature_value, is_included, sort_order) +SELECT + p.uuid, + 'advanced_features', + '高级功能', + '所有高级功能', + TRUE, + 7 +FROM plans p WHERE p.name = '商业套餐' +ON CONFLICT (plan_uuid, feature_key) DO NOTHING; + +INSERT INTO plan_features (plan_uuid, feature_key, feature_name, feature_value, is_included, sort_order) +SELECT + p.uuid, + 'custom_integration', + '定制集成', + '支持定制化集成', + TRUE, + 8 +FROM plans p WHERE p.name = '商业套餐' +ON CONFLICT (plan_uuid, feature_key) DO NOTHING; + +INSERT INTO plan_features (plan_uuid, feature_key, feature_name, feature_value, is_included, sort_order) +SELECT + p.uuid, + 'sla_guarantee', + 'SLA 保障', + '99.9% 可用性保障', + TRUE, + 9 +FROM plans p WHERE p.name = '商业套餐' +ON CONFLICT (plan_uuid, feature_key) DO NOTHING; + +-- 企业套餐特性(包含商业套餐的所有特性,并增加更多) +INSERT INTO plan_features (plan_uuid, feature_key, feature_name, feature_value, is_included, sort_order) +SELECT + p.uuid, + 'env_limit', + '环境数量', + '100000 个环境', + TRUE, + 1 +FROM plans p WHERE p.name = '企业套餐' +ON CONFLICT (plan_uuid, feature_key) DO NOTHING; + +INSERT INTO plan_features (plan_uuid, feature_key, feature_name, feature_value, is_included, sort_order) +SELECT + p.uuid, + 'team_members', + '团队成员', + '30 人', + TRUE, + 2 +FROM plans p WHERE p.name = '企业套餐' +ON CONFLICT (plan_uuid, feature_key) DO NOTHING; + +INSERT INTO plan_features (plan_uuid, feature_key, feature_name, feature_value, is_included, sort_order) +SELECT + p.uuid, + 'proxy_access', + '代理访问', + '99999 个代理', + TRUE, + 3 +FROM plans p WHERE p.name = '企业套餐' +ON CONFLICT (plan_uuid, feature_key) DO NOTHING; + +INSERT INTO plan_features (plan_uuid, feature_key, feature_name, feature_value, is_included, sort_order) +SELECT + p.uuid, + 'rpa_tasks', + 'RPA 任务', + '99999 个任务', + TRUE, + 4 +FROM plans p WHERE p.name = '企业套餐' +ON CONFLICT (plan_uuid, feature_key) DO NOTHING; + +INSERT INTO plan_features (plan_uuid, feature_key, feature_name, feature_value, is_included, sort_order) +SELECT + p.uuid, + 'premium_support', + '高级支持', + '7x24 小时专属支持 + 专属客户经理', + TRUE, + 5 +FROM plans p WHERE p.name = '企业套餐' +ON CONFLICT (plan_uuid, feature_key) DO NOTHING; + +INSERT INTO plan_features (plan_uuid, feature_key, feature_name, feature_value, is_included, sort_order) +SELECT + p.uuid, + 'api_access', + 'API 访问', + '完整 API 权限 + 高级 API + 定制 API', + TRUE, + 6 +FROM plans p WHERE p.name = '企业套餐' +ON CONFLICT (plan_uuid, feature_key) DO NOTHING; + +INSERT INTO plan_features (plan_uuid, feature_key, feature_name, feature_value, is_included, sort_order) +SELECT + p.uuid, + 'advanced_features', + '高级功能', + '所有高级功能 + 企业级功能', + TRUE, + 7 +FROM plans p WHERE p.name = '企业套餐' +ON CONFLICT (plan_uuid, feature_key) DO NOTHING; + +INSERT INTO plan_features (plan_uuid, feature_key, feature_name, feature_value, is_included, sort_order) +SELECT + p.uuid, + 'custom_integration', + '定制集成', + '完全定制化集成 + 专属技术支持', + TRUE, + 8 +FROM plans p WHERE p.name = '企业套餐' +ON CONFLICT (plan_uuid, feature_key) DO NOTHING; + +INSERT INTO plan_features (plan_uuid, feature_key, feature_name, feature_value, is_included, sort_order) +SELECT + p.uuid, + 'sla_guarantee', + 'SLA 保障', + '99.99% 可用性保障', + TRUE, + 9 +FROM plans p WHERE p.name = '企业套餐' +ON CONFLICT (plan_uuid, feature_key) DO NOTHING; + +INSERT INTO plan_features (plan_uuid, feature_key, feature_name, feature_value, is_included, sort_order) +SELECT + p.uuid, + 'dedicated_infrastructure', + '专属基础设施', + '专属服务器和资源', + TRUE, + 10 +FROM plans p WHERE p.name = '企业套餐' +ON CONFLICT (plan_uuid, feature_key) DO NOTHING; + +INSERT INTO plan_features (plan_uuid, feature_key, feature_name, feature_value, is_included, sort_order) +SELECT + p.uuid, + 'training_sessions', + '培训服务', + '定期培训和最佳实践指导', + TRUE, + 11 +FROM plans p WHERE p.name = '企业套餐' +ON CONFLICT (plan_uuid, feature_key) DO NOTHING; + diff --git a/migrations/20250126000002_create_user_coupons.sql b/migrations/20250126000002_create_user_coupons.sql new file mode 100644 index 0000000..24d65e5 --- /dev/null +++ b/migrations/20250126000002_create_user_coupons.sql @@ -0,0 +1,27 @@ +-- 创建 user_coupons 表 +-- 用户优惠券表 + +CREATE TABLE IF NOT EXISTS user_coupons ( + id SERIAL PRIMARY KEY, + user_uuid UUID NOT NULL, + coupon_uuid UUID NOT NULL, + -- 状态: unused, used, expired + status VARCHAR(50) NOT NULL DEFAULT 'unused', + -- 发放时间 + issued_at TIMESTAMP WITH TIME ZONE NOT NULL DEFAULT CURRENT_TIMESTAMP, + -- 使用时间 + used_at TIMESTAMP WITH TIME ZONE, + -- 过期时间(可选,继承自优惠券或自定义) + expires_at TIMESTAMP WITH TIME ZONE, + -- 约束 + CONSTRAINT fk_user_coupons_user FOREIGN KEY (user_uuid) REFERENCES users(uuid), + CONSTRAINT fk_user_coupons_coupon FOREIGN KEY (coupon_uuid) REFERENCES coupons(uuid), + -- 唯一约束:同一用户不能重复拥有同一优惠券 + UNIQUE(user_uuid, coupon_uuid) +); + +-- 创建索引 +CREATE INDEX idx_user_coupons_user_uuid ON user_coupons(user_uuid); +CREATE INDEX idx_user_coupons_status ON user_coupons(status); +CREATE INDEX idx_user_coupons_coupon_uuid ON user_coupons(coupon_uuid); +CREATE INDEX idx_user_coupons_expires_at ON user_coupons(expires_at); diff --git a/migrations/20250126000003_insert_default_coupons.sql b/migrations/20250126000003_insert_default_coupons.sql new file mode 100644 index 0000000..1b35f65 --- /dev/null +++ b/migrations/20250126000003_insert_default_coupons.sql @@ -0,0 +1,37 @@ +-- 插入默认优惠券数据 + +-- 1. 新用户专享优惠券(10%折扣,无使用次数限制,适用于所有订单) +INSERT INTO coupons (uuid, code, discount_type, discount_value, min_amount, max_discount, max_uses, max_uses_per_user, valid_from, valid_until, applicable_to, status) +VALUES + (gen_random_uuid(), 'WELCOME10', 'percentage', 10.00, 0.00, NULL, NULL, 1, CURRENT_TIMESTAMP, NULL, 'all', 'active') +ON CONFLICT (code) DO NOTHING; + +-- 2. 年付优惠券(固定金额 $20,最低消费 $100,适用于订阅) +INSERT INTO coupons (uuid, code, discount_type, discount_value, min_amount, max_discount, max_uses, max_uses_per_user, valid_from, valid_until, applicable_to, status) +VALUES + (gen_random_uuid(), 'YEARLY20', 'fixed', 20.00, 100.00, NULL, NULL, 1, CURRENT_TIMESTAMP, NULL, 'subscription', 'active') +ON CONFLICT (code) DO NOTHING; + +-- 3. 限时促销优惠券(15%折扣,最大折扣 $50,使用次数限制 100 次) +INSERT INTO coupons (uuid, code, discount_type, discount_value, min_amount, max_discount, max_uses, max_uses_per_user, valid_from, valid_until, applicable_to, status) +VALUES + (gen_random_uuid(), 'PROMO15', 'percentage', 15.00, 50.00, 50.00, 100, 1, CURRENT_TIMESTAMP, (CURRENT_TIMESTAMP + INTERVAL '30 days'), 'all', 'active') +ON CONFLICT (code) DO NOTHING; + +-- 4. 大额订单优惠券(固定金额 $50,最低消费 $200) +INSERT INTO coupons (uuid, code, discount_type, discount_value, min_amount, max_discount, max_uses, max_uses_per_user, valid_from, valid_until, applicable_to, status) +VALUES + (gen_random_uuid(), 'BIG50', 'fixed', 50.00, 200.00, NULL, NULL, 1, CURRENT_TIMESTAMP, NULL, 'all', 'active') +ON CONFLICT (code) DO NOTHING; + +-- 5. 年付专属优惠券(20%折扣,仅适用于年付订阅,最大折扣 $200) +INSERT INTO coupons (uuid, code, discount_type, discount_value, min_amount, max_discount, max_uses, max_uses_per_user, valid_from, valid_until, applicable_to, status) +VALUES + (gen_random_uuid(), 'YEARLY20PCT', 'percentage', 20.00, 100.00, 200.00, NULL, 1, CURRENT_TIMESTAMP, NULL, 'subscription', 'active') +ON CONFLICT (code) DO NOTHING; + +-- 6. 首次购买优惠券(固定金额 $10,无最低消费,每人限用1次) +INSERT INTO coupons (uuid, code, discount_type, discount_value, min_amount, max_discount, max_uses, max_uses_per_user, valid_from, valid_until, applicable_to, status) +VALUES + (gen_random_uuid(), 'FIRST10', 'fixed', 10.00, 0.00, NULL, NULL, 1, CURRENT_TIMESTAMP, NULL, 'all', 'active') +ON CONFLICT (code) DO NOTHING; diff --git a/migrations/20250127000001_add_coupon_name_description.sql b/migrations/20250127000001_add_coupon_name_description.sql new file mode 100644 index 0000000..f8f7b98 --- /dev/null +++ b/migrations/20250127000001_add_coupon_name_description.sql @@ -0,0 +1,13 @@ +-- 为优惠券表添加名称和描述字段 + +ALTER TABLE coupons +ADD COLUMN IF NOT EXISTS name VARCHAR(100), +ADD COLUMN IF NOT EXISTS description TEXT; + +-- 为现有优惠券更新名称和描述 +UPDATE coupons SET name = '新用户专享', description = '新用户注册专享优惠,享受10%折扣' WHERE code = 'WELCOME10'; +UPDATE coupons SET name = '年付优惠', description = '年付订阅专享,立减$20,最低消费$100' WHERE code = 'YEARLY20'; +UPDATE coupons SET name = '限时促销', description = '限时促销活动,享受15%折扣,最大折扣$50' WHERE code = 'PROMO15'; +UPDATE coupons SET name = '大额订单优惠', description = '大额订单专享,立减$50,最低消费$200' WHERE code = 'BIG50'; +UPDATE coupons SET name = '年付专属', description = '年付订阅专属优惠,享受20%折扣,最大折扣$200' WHERE code = 'YEARLY20PCT'; +UPDATE coupons SET name = '首次购买', description = '首次购买专享,立减$10,无最低消费限制' WHERE code = 'FIRST10'; diff --git a/migrations/20250127000002_reset_referral_default_tiers.sql b/migrations/20250127000002_reset_referral_default_tiers.sql new file mode 100644 index 0000000..eeb7fcc --- /dev/null +++ b/migrations/20250127000002_reset_referral_default_tiers.sql @@ -0,0 +1,25 @@ +-- 重置推广链接层级默认数据 +-- 说明: +-- - 该迁移用于在开发 / 测试环境中统一推广层级配置 +-- - 按产品设计保留 4 个层级,按照邀请人数逐级解锁 +-- - 为避免脏数据干扰,这里会清空依赖 referral_link_tiers 的相关表 + +-- 1. 清空依赖表(注意:这会删除所有历史推广数据) +TRUNCATE TABLE redeem_records RESTART IDENTITY CASCADE; +TRUNCATE TABLE referral_rewards RESTART IDENTITY CASCADE; +TRUNCATE TABLE user_referrals RESTART IDENTITY CASCADE; +TRUNCATE TABLE referral_links RESTART IDENTITY CASCADE; +TRUNCATE TABLE referral_link_tiers RESTART IDENTITY CASCADE; + +-- 2. 重新插入 4 个默认推广层级 +INSERT INTO referral_link_tiers (uuid, name, unlock_threshold, reward_rate, discount_rate, description, sort_order) +VALUES + -- 第一档:默认开放 + (gen_random_uuid(), '青铜', 0, 10.00, 5.00, '默认层级,绑定第一个推广链接', 1), + -- 第二档:达到一定推广人数后解锁 + (gen_random_uuid(), '白银', 10, 15.00, 8.00, '推广满 10 人后解锁第二个推广链接', 2), + -- 第三档 + (gen_random_uuid(), '黄金', 50, 20.00, 10.00, '推广满 50 人后解锁第三个推广链接', 3), + -- 第四档 + (gen_random_uuid(), '钻石', 100, 25.00, 15.00, '推广满 100 人后解锁第四个推广链接', 4); + diff --git a/rustfmt.toml b/rustfmt.toml new file mode 100644 index 0000000..e79c5a6 --- /dev/null +++ b/rustfmt.toml @@ -0,0 +1,17 @@ +# rustfmt 配置文件 +# 注意:某些配置选项需要 nightly 版本,这里只使用稳定版本支持的选项 +edition = "2021" +max_width = 100 +tab_spaces = 4 +newline_style = "Unix" +use_small_heuristics = "Default" +hard_tabs = false +chain_width = 80 +# 以下选项需要 nightly 版本,已注释 +# wrap_comments = true +# format_code_in_doc_comments = true +# format_strings = true +# format_macro_matchers = true +# format_macro_bodies = true +# format_macro_definitions = false + diff --git a/src/caches/mod.rs b/src/caches/mod.rs new file mode 100644 index 0000000..d095889 --- /dev/null +++ b/src/caches/mod.rs @@ -0,0 +1,3 @@ +pub mod user; + +pub use user::*; diff --git a/src/caches/user.rs b/src/caches/user.rs new file mode 100644 index 0000000..d56a70f --- /dev/null +++ b/src/caches/user.rs @@ -0,0 +1,72 @@ +use crate::svc_ctx::SvcCtx; +use redis::AsyncCommands; +use uuid::Uuid; + +pub(crate) const REGISTER_CODE_CACHE_KEY: &str = "verification:code:register:send"; +pub(crate) const RESET_PASSWORD_CODE_CACHE_KEY: &str = "verification:code:reset_password:send"; +pub(crate) const CODE_EXPIRATION: u64 = 60 * 5; // 5 分钟过期 +pub(crate) const USER_PUBLIC_KEY_CACHE_KEY: &str = "user:public_key"; +pub(crate) const PUBLIC_KEY_EXPIRATION: u64 = 60 * 60 * 24 * 7; // 7 天过期(与 refresh token 一致) + +/// 设置用户注册的验证码 +pub async fn set_register_code( + svc_ctx: &SvcCtx, + email: &str, + code: &str, +) -> Result<(), anyhow::Error> { + let key = format!("{}:{}", REGISTER_CODE_CACHE_KEY, email); + let _: () = svc_ctx.redis.clone().set_ex(key, code, CODE_EXPIRATION).await?; + Ok(()) +} + +/// 获取用户注册的验证码 +pub async fn get_register_code( + svc_ctx: &SvcCtx, + email: &str, +) -> Result, anyhow::Error> { + let key = format!("{}:{}", REGISTER_CODE_CACHE_KEY, email); + let result: Option = svc_ctx.redis.clone().get(key).await?; + Ok(result) +} + +/// 设置重置密码的验证码 +pub async fn set_reset_password_code( + svc_ctx: &SvcCtx, + email: &str, + code: &str, +) -> Result<(), anyhow::Error> { + let key = format!("{}:{}", RESET_PASSWORD_CODE_CACHE_KEY, email); + let _: () = svc_ctx.redis.clone().set_ex(key, code, CODE_EXPIRATION).await?; + Ok(()) +} + +/// 获取重置密码的验证码 +pub async fn get_reset_password_code( + svc_ctx: &SvcCtx, + email: &str, +) -> Result, anyhow::Error> { + let key = format!("{}:{}", RESET_PASSWORD_CODE_CACHE_KEY, email); + let result: Option = svc_ctx.redis.clone().get(key).await?; + Ok(result) +} + +/// 设置用户公钥 +pub async fn set_user_public_key( + svc_ctx: &SvcCtx, + user_uuid: &Uuid, + public_key: &str, +) -> Result<(), anyhow::Error> { + let key = format!("{}:{}", USER_PUBLIC_KEY_CACHE_KEY, user_uuid); + let _: () = svc_ctx.redis.clone().set_ex(key, public_key, PUBLIC_KEY_EXPIRATION).await?; + Ok(()) +} + +/// 获取用户公钥 +pub async fn get_user_public_key( + svc_ctx: &SvcCtx, + user_uuid: &Uuid, +) -> Result, anyhow::Error> { + let key = format!("{}:{}", USER_PUBLIC_KEY_CACHE_KEY, user_uuid); + let result: Option = svc_ctx.redis.clone().get(key).await?; + Ok(result) +} diff --git a/src/dto/accounts.rs b/src/dto/accounts.rs new file mode 100644 index 0000000..86219df --- /dev/null +++ b/src/dto/accounts.rs @@ -0,0 +1,35 @@ +use chrono::{DateTime, Utc}; +use serde::{Deserialize, Serialize}; +use sqlx::FromRow; +use uuid::Uuid; + +/// 平台账号 DTO +#[derive(Debug, Clone, FromRow, Serialize, Deserialize)] +pub struct PlatformAccountDto { + pub id: i32, + pub uuid: Uuid, + pub user_uuid: Uuid, + pub team_uuid: Option, + pub platform_url: String, + pub platform_name: Option, + pub account: String, + pub password_encrypted: Option, + pub status: String, + pub remark: Option, + pub usage_count: Option, + pub environments_count: Option, + pub last_used_at: Option>, + pub created_at: DateTime, + pub updated_at: DateTime, + pub deleted_at: Option>, +} + +/// 环境账号关联 DTO +#[derive(Debug, Clone, FromRow, Serialize)] +pub struct EnvironmentAccountDto { + pub id: i32, + pub environment_uuid: Uuid, + pub account_uuid: Uuid, + pub sort_order: Option, + pub created_at: DateTime, +} diff --git a/src/dto/audit.rs b/src/dto/audit.rs new file mode 100644 index 0000000..3ec1b06 --- /dev/null +++ b/src/dto/audit.rs @@ -0,0 +1,26 @@ +use chrono::{DateTime, Utc}; +use serde::Serialize; +use sqlx::FromRow; +use uuid::Uuid; + +/// 审计日志 DTO +#[derive(Debug, Clone, FromRow, Serialize)] +pub struct AuditLogDto { + pub id: i64, + pub uuid: Uuid, + pub user_uuid: Uuid, + pub team_uuid: Option, + pub action: String, + pub target_type: String, + pub target_uuid: Option, + pub target_name: Option, + pub details: Option, + pub changes: Option, + pub ip_address: Option, + pub user_agent: Option, + pub request_id: Option, + pub created_at: DateTime, + // 用户信息(联表查询) + pub user_name: Option, + pub user_email: Option, +} diff --git a/src/dto/billing.rs b/src/dto/billing.rs new file mode 100644 index 0000000..9ca1a2b --- /dev/null +++ b/src/dto/billing.rs @@ -0,0 +1,236 @@ +use chrono::{DateTime, NaiveDate, Utc}; +use rust_decimal::Decimal; +use serde::Serialize; +use sqlx::FromRow; +use uuid::Uuid; + +/// 套餐 DTO +#[derive(Debug, Clone, FromRow, Serialize)] +pub struct PlanDto { + pub id: i32, + pub uuid: Uuid, + pub name: String, + pub description: Option, + pub price_per_month: Decimal, + pub price_per_year: Decimal, + pub currency: String, + pub discount_monthly: Option, + pub discount_yearly: Option, + pub max_environments: i32, + pub max_team_members: i32, + pub max_proxies: i32, + pub max_rpa_tasks: i32, + pub is_recommended: Option, + pub sort_order: Option, + pub status: String, + pub created_at: DateTime, + pub updated_at: DateTime, +} + +/// 套餐特性 DTO +#[derive(Debug, Clone, FromRow, Serialize)] +pub struct PlanFeatureDto { + pub id: i32, + pub plan_uuid: Uuid, + pub feature_key: String, + pub feature_name: String, + pub feature_value: Option, + pub is_included: Option, + pub sort_order: Option, + pub created_at: DateTime, +} + +/// 订阅 DTO +#[derive(Debug, Clone, FromRow, Serialize)] +pub struct SubscriptionDto { + pub id: i32, + pub uuid: Uuid, + pub workspace_uuid: Uuid, + pub user_uuid: Uuid, + pub plan_uuid: Uuid, + pub billing_period: String, + pub price: Decimal, + pub currency: String, + pub started_at: DateTime, + pub expires_at: DateTime, + pub next_billing_date: Option, + pub auto_renew: Option, + pub status: String, + pub cancelled_at: Option>, + pub created_at: DateTime, + pub updated_at: DateTime, +} + +/// 用户钱包 DTO +#[derive(Debug, Clone, FromRow, Serialize)] +pub struct UserWalletDto { + pub id: i32, + pub user_uuid: Uuid, + pub balance: Decimal, + pub currency: String, + pub frozen_amount: Decimal, + pub auto_renewal_combined: Decimal, + pub created_at: DateTime, + pub updated_at: DateTime, +} + +/// 钱包交易 DTO +#[derive(Debug, Clone, FromRow, Serialize)] +pub struct WalletTransactionDto { + pub id: i64, + pub uuid: Uuid, + pub user_uuid: Uuid, + pub transaction_type: String, + pub amount: Decimal, + pub currency: String, + pub balance_before: Decimal, + pub balance_after: Decimal, + pub description: Option, + pub order_uuid: Option, + pub status: String, + pub created_at: DateTime, +} + +/// 发票 DTO +#[derive(Debug, Clone, FromRow, Serialize)] +pub struct InvoiceDto { + pub id: i32, + pub uuid: Uuid, + pub user_uuid: Uuid, + pub invoice_number: String, + pub amount: Decimal, + pub currency: String, + pub subscription_uuid: Option, + pub order_uuid: Option, + pub invoice_type: String, + pub status: String, + pub issued_at: Option>, + pub due_at: Option>, + pub paid_at: Option>, + pub invoice_url: Option, + pub created_at: DateTime, + pub updated_at: DateTime, +} + +/// 用户配额 DTO +#[derive(Debug, Clone, FromRow, Serialize)] +pub struct UserQuotaDto { + pub id: i32, + pub user_uuid: Uuid, + pub max_environments: i32, + pub used_environments: i32, + pub max_team_members: i32, + pub max_proxies: i32, + pub used_proxies: i32, + pub max_rpa_tasks: i32, + pub used_rpa_tasks: i32, + pub created_at: DateTime, + pub updated_at: DateTime, +} + +/// 优惠券 DTO +#[derive(Debug, Clone, FromRow, Serialize)] +pub struct CouponDto { + pub id: i32, + pub uuid: Uuid, + pub code: String, + pub name: Option, + pub description: Option, + pub discount_type: String, + pub discount_value: Decimal, + pub min_amount: Option, + pub max_discount: Option, + pub max_uses: Option, + pub used_count: i32, + pub max_uses_per_user: Option, + pub valid_from: DateTime, + pub valid_until: Option>, + pub applicable_to: String, + pub status: String, + pub created_at: DateTime, + pub updated_at: DateTime, +} + +/// 优惠券使用记录 DTO +#[derive(Debug, Clone, FromRow, Serialize)] +pub struct CouponUsageDto { + pub id: i32, + pub coupon_uuid: Uuid, + pub user_uuid: Uuid, + pub order_uuid: Option, + pub discount_amount: Decimal, + pub used_at: DateTime, +} + +/// 用户优惠券 DTO +#[derive(Debug, Clone, FromRow, Serialize)] +pub struct UserCouponDto { + pub id: i32, + pub user_uuid: Uuid, + pub coupon_uuid: Uuid, + pub status: String, + pub issued_at: DateTime, + pub used_at: Option>, + pub expires_at: Option>, +} + +/// 用户优惠券详细信息 DTO(包含优惠券详情) +#[derive(Debug, Clone, Serialize)] +pub struct UserCouponWithDetailsDto { + pub id: i32, + pub user_uuid: Uuid, + pub coupon_uuid: Uuid, + pub status: String, + pub issued_at: DateTime, + pub used_at: Option>, + pub expires_at: Option>, + // 优惠券详细信息 + pub code: String, + pub name: Option, + pub description: Option, + pub discount_type: String, + pub discount_value: Decimal, + pub min_amount: Option, + pub max_discount: Option, +} + +/// 支付订单 DTO +#[derive(Debug, Clone, FromRow, Serialize)] +pub struct PaymentOrderDto { + pub id: i64, + pub uuid: Uuid, + pub order_no: String, + pub user_uuid: Uuid, + pub order_type: String, + pub amount: Decimal, + pub currency: String, + pub status: String, + pub payment_channel: Option, + pub external_order_id: Option, + pub description: Option, + pub subscription_uuid: Option, + pub coupon_uuid: Option, + pub original_amount: Option, + pub discount_amount: Option, + pub paid_at: Option>, + pub refunded_at: Option>, + pub created_at: DateTime, + pub updated_at: DateTime, +} + +/// 自动续费服务 DTO +#[derive(Debug, Clone, FromRow, Serialize)] +pub struct AutoRenewalServiceDto { + pub id: i32, + pub uuid: Uuid, + pub user_uuid: Uuid, + pub service_type: String, + pub service_uuid: Option, + pub service_name: String, + pub renewal_price: Decimal, + pub currency: String, + pub next_bill_date: NaiveDate, + pub status: String, + pub created_at: DateTime, + pub updated_at: DateTime, +} diff --git a/src/dto/console_admin.rs b/src/dto/console_admin.rs new file mode 100644 index 0000000..299c158 --- /dev/null +++ b/src/dto/console_admin.rs @@ -0,0 +1,41 @@ +//! Console Gateway 管理员和权限 DTO + +use chrono::{DateTime, Utc}; +use sqlx::FromRow; +use uuid::Uuid; + +/// 管理员 DTO +#[derive(Debug, Clone, FromRow)] +pub struct ConsoleAdminDto { + pub id: i32, + pub uuid: Uuid, + pub user_uuid: Uuid, + pub is_active: bool, + pub created_at: DateTime, + pub updated_at: DateTime, + pub deleted_at: Option>, +} + +/// 权限 DTO +#[derive(Debug, Clone, FromRow)] +pub struct ConsolePermissionDto { + pub id: i32, + pub uuid: Uuid, + pub route_path: String, + pub method: String, + pub description: Option, + pub created_at: DateTime, + pub updated_at: DateTime, + pub deleted_at: Option>, +} + +/// 管理员权限关联 DTO +#[derive(Debug, Clone, FromRow)] +pub struct ConsoleAdminPermissionDto { + pub id: i32, + pub uuid: Uuid, + pub admin_id: i32, + pub permission_id: i32, + pub granted_at: DateTime, + pub granted_by: Option, +} diff --git a/src/dto/console_apikey.rs b/src/dto/console_apikey.rs new file mode 100644 index 0000000..7522fb4 --- /dev/null +++ b/src/dto/console_apikey.rs @@ -0,0 +1,23 @@ +//! Console Gateway API 密钥 DTO + +use chrono::{DateTime, Utc}; +use sqlx::FromRow; +use uuid::Uuid; + +/// API 密钥 DTO +#[derive(Debug, Clone, FromRow)] +pub struct ConsoleApiKeyDto { + pub id: i32, + pub uuid: Uuid, + pub key_id: String, + pub key_secret: String, + pub name: String, + pub description: Option, + pub is_active: bool, + pub created_at: DateTime, + pub updated_at: DateTime, + pub expires_at: Option>, + pub last_used_at: Option>, + pub created_by: Option, + pub deleted_at: Option>, +} diff --git a/src/dto/environments.rs b/src/dto/environments.rs new file mode 100644 index 0000000..b8a0e36 --- /dev/null +++ b/src/dto/environments.rs @@ -0,0 +1,281 @@ +use chrono::{DateTime, Utc}; +use serde::{Deserialize, Serialize}; +use sqlx::FromRow; +use uuid::Uuid; + +/// 分组 DTO +#[derive(Debug, Clone, FromRow, Serialize)] +pub struct GroupDto { + pub id: i32, + pub uuid: Uuid, + pub workspace_uuid: Uuid, + pub team_uuid: Uuid, + pub team_name: Option, + pub name: String, + pub description: Option, + pub sort_order: Option, + pub created_by: Option, + pub created_by_name: Option, + pub environments_count: Option, + pub created_at: DateTime, + pub updated_at: DateTime, + pub deleted_at: Option>, +} + +/// 标签 DTO +#[derive(Debug, Clone, FromRow, Serialize, Deserialize)] +pub struct TagDto { + pub id: i32, + pub uuid: Uuid, + pub user_uuid: Uuid, + pub team_uuid: Option, + pub name: String, + pub color: Option, + pub sort_order: Option, + pub environments_count: Option, + pub created_at: DateTime, + pub updated_at: DateTime, + pub deleted_at: Option>, +} + +/// 环境 DTO +#[derive(Debug, Clone, FromRow, Serialize, Deserialize)] +pub struct EnvironmentDto { + pub id: i32, + pub uuid: Uuid, + pub workspace_uuid: Uuid, + pub user_uuid: Uuid, + pub team_uuid: Uuid, + pub name: String, + pub description: Option, + pub status: String, + pub group_uuid: Option, + pub proxy_uuid: Option, + pub system_info: Option, + pub kernel_info: Option, + pub fingerprint_summary: Option, + pub last_opened_at: Option>, + pub created_at: DateTime, + pub updated_at: DateTime, + pub deleted_at: Option>, +} + +/// 环境列表行(基础查询结果) +#[derive(Debug, Clone, sqlx::FromRow)] +pub struct EnvironmentRowDto { + pub id: i32, + pub uuid: Uuid, + pub workspace_uuid: Uuid, + pub user_uuid: Uuid, + pub team_uuid: Uuid, + pub name: String, + pub description: Option, + pub status: String, + pub system_info: Option, + pub kernel_info: Option, + pub fingerprint_summary: Option, + pub group_uuid: Option, + pub proxy_uuid: Option, + pub last_opened_at: Option>, + pub created_at: DateTime, + pub updated_at: DateTime, +} + +/// 环境标签关联行(用于批量查询) +#[derive(Debug, Clone, sqlx::FromRow)] +pub struct EnvironmentTagRowDto { + pub environment_uuid: Uuid, + pub tag_id: i32, + pub tag_uuid: Uuid, + pub tag_name: String, + pub tag_color: Option, + pub tag_sort_order: Option, + pub tag_user_uuid: Uuid, + pub tag_team_uuid: Option, + pub tag_environments_count: Option, + pub tag_created_at: chrono::DateTime, + pub tag_updated_at: chrono::DateTime, + pub tag_deleted_at: Option>, +} + +/// 环境账号关联行(用于批量查询) +#[derive(Debug, Clone, sqlx::FromRow)] +pub struct EnvironmentAccountRowDto { + pub environment_uuid: Uuid, + pub account_id: i32, + pub account_uuid: Uuid, + pub platform_url: String, + pub platform_name: Option, + pub account: String, + pub account_status: String, + pub remark: Option, +} + +/// 分组查询行(用于批量查询) +#[derive(Debug, Clone, sqlx::FromRow)] +pub struct GroupRowDto { + pub id: i32, + pub uuid: Uuid, + pub name: String, + pub description: Option, + pub sort_order: Option, +} + +/// 代理查询行(用于批量查询,排除敏感数据) +#[derive(Debug, Clone, sqlx::FromRow)] +pub struct ProxyRowDto { + pub id: i32, + pub uuid: Uuid, + pub name: String, + pub host: String, + pub port: i32, + pub proxy_type: String, + pub country: Option, + pub city: Option, + pub status: String, + pub latency: Option, + pub last_check_ip: Option, +} + +/// 代理摘要 DTO(用于环境列表,排除敏感数据) +#[derive(Debug, Clone, Serialize, Deserialize)] +pub struct ProxySummaryDto { + pub id: i32, + pub uuid: Uuid, + pub name: String, + pub host: String, + pub port: i32, + pub proxy_type: String, + pub country: Option, + pub city: Option, + pub status: String, + pub latency: Option, + pub last_check_ip: Option, +} + +/// 分组摘要 DTO(用于环境列表) +#[derive(Debug, Clone, Serialize, Deserialize)] +pub struct GroupSummaryDto { + pub id: i32, + pub uuid: Uuid, + pub name: String, + pub description: Option, + pub sort_order: Option, +} + +/// 标签摘要 DTO(用于环境列表) +#[derive(Debug, Clone, Serialize)] +pub struct TagSummaryDto { + pub id: i32, + pub uuid: Uuid, + pub name: String, + pub color: Option, + pub sort_order: Option, +} + +/// 账号摘要 DTO(用于环境列表,排除敏感数据) +#[derive(Debug, Clone, Serialize)] +pub struct AccountSummaryDto { + pub id: i32, + pub uuid: Uuid, + pub platform_url: String, + pub platform_name: Option, + pub account: String, + pub status: String, + pub remark: Option, +} + +/// 环境列表项 DTO(包含完整关联数据) +#[derive(Debug, Clone, Serialize)] +pub struct EnvironmentListItemDto { + // 基础信息 + pub id: i32, + pub uuid: Uuid, + pub name: String, + pub description: Option, + pub status: String, + pub system_info: Option, + pub kernel_info: Option, + pub fingerprint_summary: Option, + pub last_opened_at: Option>, + pub created_at: DateTime, + pub updated_at: DateTime, + // 分组详情(完整对象) + pub group: Option, + // 代理详情(完整对象) + pub proxy: Option, + // 标签列表(完整对象列表,与环境详情接口保持一致) + pub tags: Vec, + // 账号列表(完整对象列表) + pub accounts: Vec, +} + +/// 环境配置 DTO +#[derive(Debug, Clone, FromRow, Serialize, Deserialize)] +pub struct EnvironmentConfigDto { + pub id: i32, + pub environment_uuid: Uuid, + pub window_info: serde_json::Value, + pub basic_settings: serde_json::Value, + pub fingerprint_settings: serde_json::Value, + pub device_settings: serde_json::Value, + pub preference_settings: serde_json::Value, + pub project_metadata: serde_json::Value, + pub created_at: DateTime, + pub updated_at: DateTime, +} + +/// 环境标签关联 DTO +#[derive(Debug, Clone, FromRow)] +pub struct EnvironmentTagDto { + pub id: i32, + pub environment_uuid: Uuid, + pub tag_uuid: Uuid, + pub created_at: DateTime, +} + +/// 环境 URL DTO +#[derive(Debug, Clone, FromRow, Serialize)] +pub struct EnvironmentUrlDto { + pub id: i32, + pub environment_uuid: Uuid, + pub url: String, + pub title: Option, + pub sort_order: Option, + pub created_at: DateTime, +} + +/// 环境 Cookie DTO +#[derive(Debug, Clone, FromRow, Serialize)] +pub struct EnvironmentCookieDto { + pub id: i32, + pub environment_uuid: Uuid, + pub domain: String, + pub name: String, + pub value: String, + pub path: Option, + pub expires_at: Option>, + pub http_only: Option, + pub secure: Option, + pub same_site: Option, + pub created_at: DateTime, +} + +/// 模板 DTO +#[derive(Debug, Clone, FromRow, Serialize)] +pub struct TemplateDto { + pub id: i32, + pub uuid: Uuid, + pub user_uuid: Uuid, + pub team_uuid: Option, + pub name: String, + pub description: Option, + pub is_public: Option, + pub system_info: Option, + pub kernel_info: Option, + pub config_json: serde_json::Value, + pub usage_count: Option, + pub created_at: DateTime, + pub updated_at: DateTime, + pub deleted_at: Option>, +} diff --git a/src/dto/extensions.rs b/src/dto/extensions.rs new file mode 100644 index 0000000..eb1a54e --- /dev/null +++ b/src/dto/extensions.rs @@ -0,0 +1,133 @@ +use chrono::{DateTime, Utc}; +use rust_decimal::Decimal; +use serde::Serialize; +use sqlx::FromRow; +use uuid::Uuid; + +use crate::utils::minios::get_objects; + +/// 扩展 DTO +#[derive(Debug, Clone, FromRow, Serialize)] +pub struct ExtensionDto { + pub id: i32, + pub uuid: Uuid, + pub extension_id: String, + pub name: String, + pub description: Option, + pub version: String, + pub category: String, + pub browser: String, + pub developer: Option, + pub homepage: Option, + pub icon_url: Option, + pub download_url: Option, + pub file_size: Option, + pub downloads_count: Option, + pub rating: Option, + pub permissions: Option, + pub status: String, + pub changelog: Option, + pub published_at: Option>, + pub created_at: DateTime, + pub updated_at: DateTime, +} + +impl ExtensionDto { + /// 将 object path 转换为完整 URL + /// + /// 数据库中存储的是 MinIO object path(如 `ext_id/version/hash.crx`), + /// 返回给客户端时需要组装为完整 URL。 + /// + /// 如果 URL 已经是完整的 http(s) 地址,则不进行转换。 + pub fn transform_urls( + &mut self, + resource_url: &Option, + extension_bucket: &Option, + ) { + // 转换图标 URL + if let Some(path) = &self.icon_url { + if !path.is_empty() && !path.starts_with("http") { + self.icon_url = + get_objects::get_extension_icon_url(resource_url, extension_bucket, path); + } + } + + // 转换下载 URL + if let Some(path) = &self.download_url { + if !path.is_empty() && !path.starts_with("http") { + if let (Some(resource_url), Some(extension_bucket)) = + (resource_url, extension_bucket) + { + self.download_url = Some(get_objects::get_extension_crx_url( + resource_url, + extension_bucket, + path, + )); + } else { + self.download_url = None; + } + } + } + } + + /// 批量转换 ExtensionDto 列表中的 object path 为完整 URL + pub fn transform_urls_batch( + extensions: &mut [ExtensionDto], + resource_url: &Option, + extension_bucket: &Option, + ) { + for ext in extensions.iter_mut() { + ext.transform_urls(resource_url, extension_bucket); + } + } +} + +/// 用户扩展 DTO +#[derive(Debug, Clone, FromRow, Serialize)] +pub struct UserExtensionDto { + pub id: i32, + pub user_uuid: Uuid, + pub extension_id: String, + pub installed_version: String, + pub status: String, + pub installed_at: DateTime, + pub updated_at: DateTime, +} + +/// 团队扩展 DTO +#[derive(Debug, Clone, FromRow, Serialize)] +pub struct TeamExtensionDto { + pub id: i32, + pub team_uuid: Uuid, + pub extension_id: String, + pub installed_version: String, + pub installed_by: Uuid, + pub status: String, + pub installed_at: DateTime, + pub updated_at: DateTime, +} + +/// 分组扩展 DTO +#[derive(Debug, Clone, FromRow, Serialize)] +pub struct GroupExtensionDto { + pub id: i32, + pub group_uuid: Uuid, + pub extension_id: String, + pub installed_version: String, + pub installed_by: Uuid, + pub status: String, + pub installed_at: DateTime, + pub updated_at: DateTime, +} + +/// 环境扩展 DTO +#[derive(Debug, Clone, FromRow, Serialize)] +pub struct EnvironmentExtensionDto { + pub id: i32, + pub environment_uuid: Uuid, + pub extension_id: String, + pub installed_version: String, + pub status: String, + pub installed_at: DateTime, + pub updated_at: DateTime, +} diff --git a/src/dto/gray_releases.rs b/src/dto/gray_releases.rs new file mode 100644 index 0000000..65035b6 --- /dev/null +++ b/src/dto/gray_releases.rs @@ -0,0 +1,22 @@ +use chrono::DateTime; +use serde::{Deserialize, Serialize}; +use sqlx::FromRow; + +/// 灰度发布 DTO +#[derive(Debug, Clone, FromRow, Serialize, Deserialize)] +pub struct GrayRelease { + pub id: i32, + pub name: String, + pub description: Option, + pub platform: String, + pub status: String, + pub start_time: DateTime, + pub end_time: Option>, + pub max_machines: Option, + pub allocated_count: i32, + pub priority: i32, + pub strategy_type: Option, + pub strategy_config: Option, + pub created_at: DateTime, + pub updated_at: Option>, +} diff --git a/src/dto/gray_resources.rs b/src/dto/gray_resources.rs new file mode 100644 index 0000000..968877a --- /dev/null +++ b/src/dto/gray_resources.rs @@ -0,0 +1,11 @@ +use serde::{Deserialize, Serialize}; +use sqlx::FromRow; + +/// 灰度资源 DTO +#[derive(Debug, Clone, FromRow, Serialize, Deserialize)] +pub struct GrayResource { + pub id: i32, + pub gray_release_id: i32, + pub version_id: i32, + pub sort_order: i32, +} diff --git a/src/dto/group_member_permissions.rs b/src/dto/group_member_permissions.rs new file mode 100644 index 0000000..356b82d --- /dev/null +++ b/src/dto/group_member_permissions.rs @@ -0,0 +1,32 @@ +use chrono::{DateTime, Utc}; +use serde::Serialize; +use sqlx::FromRow; +use uuid::Uuid; + +/// 分组权限 DTO +#[derive(Debug, Clone, FromRow, Serialize)] +pub struct GroupMemberPermissionDto { + pub group_uuid: Uuid, + pub workspace_uuid: Uuid, + pub team_uuid: Uuid, + pub user_uuid: Uuid, + pub permission_type: String, + pub granted_by: Uuid, + pub created_at: DateTime, + pub updated_at: DateTime, +} + +/// 分组权限详情 DTO(包含用户信息) +#[derive(Debug, Clone, Serialize)] +pub struct GroupMemberPermissionDetailDto { + pub group_uuid: Uuid, + pub workspace_uuid: Uuid, + pub team_uuid: Uuid, + pub user_uuid: Uuid, + pub permission_type: String, + pub granted_by: Uuid, + pub user_name: Option, + pub user_email: Option, + pub created_at: DateTime, + pub updated_at: DateTime, +} diff --git a/src/dto/machine_gray_allocations.rs b/src/dto/machine_gray_allocations.rs new file mode 100644 index 0000000..a95786e --- /dev/null +++ b/src/dto/machine_gray_allocations.rs @@ -0,0 +1,16 @@ +use chrono::DateTime; +use serde::{Deserialize, Serialize}; +use sqlx::FromRow; + +/// 机器灰度分配 DTO +#[derive(Debug, Clone, FromRow, Serialize, Deserialize)] +pub struct MachineGrayAllocation { + pub id: i32, + pub machine_code: String, + pub gray_release_id: i32, + pub allocated_at: DateTime, + pub effective_time: Option>, + pub status: String, + pub notes: Option, + pub created_at: DateTime, +} diff --git a/src/dto/machine_users.rs b/src/dto/machine_users.rs new file mode 100644 index 0000000..b5be002 --- /dev/null +++ b/src/dto/machine_users.rs @@ -0,0 +1,21 @@ +use chrono::DateTime; +use serde::{Deserialize, Serialize}; +use sqlx::FromRow; +use uuid::Uuid; + +/// 机器用户绑定 +#[derive(Debug, Deserialize, Serialize, FromRow, Clone, Default)] +pub struct MachineUser { + pub id: i32, + pub machine_code: String, + pub user_uuid: Option, + pub platform: Option, + pub bind_time: DateTime, + pub status: String, + pub version_info: Option, // 版本信息(JSON格式) + pub hardware_hash: Option, // 硬件哈希 + pub hardware_raw: Option, // 硬件原始信息 + pub allow: bool, // 是否允许使用 + pub created_at: DateTime, + pub updated_at: Option>, +} diff --git a/src/dto/maintenance.rs b/src/dto/maintenance.rs new file mode 100644 index 0000000..5aa8566 --- /dev/null +++ b/src/dto/maintenance.rs @@ -0,0 +1,49 @@ +use chrono::DateTime; +use serde::{Deserialize, Serialize}; +use std::str::FromStr; + +#[derive(Debug, Clone, Serialize, Deserialize, PartialEq)] +pub enum MaintenanceType { + #[serde(rename = "scheduled")] + Scheduled, + #[serde(rename = "emergency")] + Emergency, + #[serde(rename = "upgrade")] + Upgrade, +} + +impl FromStr for MaintenanceType { + type Err = String; + + fn from_str(s: &str) -> Result { + match s { + "scheduled" => Ok(MaintenanceType::Scheduled), + "emergency" => Ok(MaintenanceType::Emergency), + "upgrade" => Ok(MaintenanceType::Upgrade), + _ => Err(format!("无效的维护类型: {}", s)), + } + } +} + +impl From for String { + fn from(maintenance_type: MaintenanceType) -> Self { + match maintenance_type { + MaintenanceType::Scheduled => "scheduled".to_string(), + MaintenanceType::Emergency => "emergency".to_string(), + MaintenanceType::Upgrade => "upgrade".to_string(), + } + } +} + +#[derive(Debug, Clone, Serialize, Deserialize, sqlx::FromRow)] +pub struct Maintenance { + pub id: i64, + pub name: String, + pub description: Option, + pub status: String, + pub start_time: DateTime, + pub end_time: DateTime, + pub maintenance_type: String, + pub created_at: DateTime, + pub updated_at: DateTime, +} diff --git a/src/dto/messages.rs b/src/dto/messages.rs new file mode 100644 index 0000000..1227f26 --- /dev/null +++ b/src/dto/messages.rs @@ -0,0 +1,60 @@ +use chrono::{DateTime, Utc}; +use serde::Serialize; +use sqlx::FromRow; +use uuid::Uuid; + +/// 消息 DTO +#[derive(Debug, Clone, FromRow, Serialize)] +pub struct MessageDto { + pub id: i32, + pub uuid: Uuid, + pub message_type: String, + pub title: String, + pub content: Option, + pub sender_uuid: Option, + pub recipient_type: String, + pub related_type: Option, + pub related_uuid: Option, + pub metadata: Option, + pub status: String, + pub priority: String, + pub created_at: DateTime, + pub updated_at: DateTime, + pub deleted_at: Option>, +} + +/// 用户消息关联 DTO(包含消息详情和用户状态) +#[derive(Debug, Clone, FromRow, Serialize)] +pub struct UserMessageDto { + // 消息基本信息 + pub message_uuid: Uuid, + pub message_type: String, + pub title: String, + pub content: Option, + pub sender_uuid: Option, + pub related_type: Option, + pub related_uuid: Option, + pub metadata: Option, + pub priority: String, + pub message_created_at: DateTime, + + // 用户消息状态 + pub is_read: bool, + pub read_at: Option>, + pub action_status: Option, + pub action_at: Option>, + + // 发送者信息(可选,通过 JOIN 获取) + #[sqlx(default)] + pub sender_name: Option, + #[sqlx(default)] + pub sender_email: Option, +} + +/// 消息统计 DTO +#[derive(Debug, Clone, Serialize)] +pub struct MessageStatsDto { + pub total: i64, + pub unread: i64, + pub by_type: std::collections::HashMap, +} diff --git a/src/dto/mod.rs b/src/dto/mod.rs new file mode 100644 index 0000000..f428128 --- /dev/null +++ b/src/dto/mod.rs @@ -0,0 +1,57 @@ +pub mod gray_releases; +pub mod gray_resources; +pub mod machine_gray_allocations; +pub mod machine_users; +pub mod maintenance; +pub mod strategy_types; +pub mod user; +pub mod version_types; +pub mod versions; + +// 新增模块 +pub mod accounts; +pub mod audit; +pub mod billing; +pub mod console_admin; +pub mod console_apikey; +pub mod environments; +pub mod extensions; +pub mod group_member_permissions; +pub mod messages; +pub mod proxies; +pub mod proxy_visible_teams; +pub mod referral; +pub mod rpa; +pub mod system; +pub mod teams; +pub mod workspace_quotas; +pub mod workspaces; + +pub use gray_releases::*; +pub use gray_resources::*; +pub use machine_gray_allocations::*; +pub use machine_users::*; +pub use maintenance::*; +pub use strategy_types::*; +pub use user::*; +pub use version_types::*; +pub use versions::*; + +// 新增导出 +pub use accounts::*; +pub use audit::*; +pub use billing::*; +pub use console_admin::*; +pub use console_apikey::*; +pub use environments::*; +pub use extensions::*; +pub use group_member_permissions::*; +pub use messages::*; +pub use proxies::*; +pub use proxy_visible_teams::*; +pub use referral::*; +pub use rpa::*; +pub use system::*; +pub use teams::*; +pub use workspace_quotas::*; +pub use workspaces::*; diff --git a/src/dto/proxies.rs b/src/dto/proxies.rs new file mode 100644 index 0000000..3ea2544 --- /dev/null +++ b/src/dto/proxies.rs @@ -0,0 +1,43 @@ +use chrono::{DateTime, Utc}; +use serde::Serialize; +use sqlx::FromRow; +use uuid::Uuid; + +/// 代理 DTO +#[derive(Debug, Clone, FromRow, Serialize)] +pub struct ProxyDto { + pub id: i32, + pub uuid: Uuid, + pub workspace_uuid: Uuid, + pub owner_uuid: Uuid, + pub name: String, + pub host: String, + pub port: i32, + pub proxy_type: String, + pub username: Option, + pub password_encrypted: Option, + pub ssh_key_encrypted: Option, + pub ssh_passphrase_encrypted: Option, + pub country: Option, + pub city: Option, + pub status: String, + pub latency: Option, + pub last_check_ip: Option, + pub last_checked_at: Option>, + pub environments_count: Option, + pub created_at: DateTime, + pub updated_at: DateTime, + pub deleted_at: Option>, +} + +/// 代理健康检查 DTO +#[derive(Debug, Clone, FromRow)] +pub struct ProxyHealthCheckDto { + pub id: i64, + pub proxy_uuid: Uuid, + pub status: String, + pub latency: Option, + pub ip_address: Option, + pub error_message: Option, + pub checked_at: DateTime, +} diff --git a/src/dto/proxy_visible_teams.rs b/src/dto/proxy_visible_teams.rs new file mode 100644 index 0000000..92c6730 --- /dev/null +++ b/src/dto/proxy_visible_teams.rs @@ -0,0 +1,23 @@ +use chrono::{DateTime, Utc}; +use serde::Serialize; +use sqlx::FromRow; +use uuid::Uuid; + +/// 代理可见团队 DTO +#[derive(Debug, Clone, FromRow, Serialize)] +pub struct ProxyVisibleTeamDto { + pub proxy_uuid: Uuid, + pub workspace_uuid: Uuid, + pub team_uuid: Uuid, + pub created_at: DateTime, +} + +/// 代理可见团队详情 DTO(包含团队信息) +#[derive(Debug, Clone, Serialize)] +pub struct ProxyVisibleTeamDetailDto { + pub proxy_uuid: Uuid, + pub workspace_uuid: Uuid, + pub team_uuid: Uuid, + pub team_name: Option, + pub created_at: DateTime, +} diff --git a/src/dto/referral.rs b/src/dto/referral.rs new file mode 100644 index 0000000..8a31a45 --- /dev/null +++ b/src/dto/referral.rs @@ -0,0 +1,153 @@ +use chrono::{DateTime, Utc}; +use rust_decimal::Decimal; +use serde::Serialize; +use sqlx::FromRow; +use uuid::Uuid; + +/// 推广链接层级 DTO +#[derive(Debug, Clone, FromRow, Serialize)] +pub struct ReferralLinkTierDto { + pub id: i32, + pub uuid: Uuid, + pub name: String, + pub unlock_threshold: i32, + pub reward_rate: Decimal, + pub discount_rate: Decimal, + pub description: Option, + pub sort_order: Option, + pub created_at: DateTime, + pub updated_at: DateTime, +} + +/// 推广链接 DTO +#[derive(Debug, Clone, FromRow, Serialize)] +pub struct ReferralLinkDto { + pub id: i32, + pub uuid: Uuid, + pub user_uuid: Uuid, + pub code: String, + pub url: Option, + pub tier_uuid: Option, + pub unlocked: Option, + pub is_current: Option, + pub reward_rate: Decimal, + pub discount_rate: Decimal, + pub registered_users: Option, + pub paid_users: Option, + pub total_consumption: Option, + pub last_30_days_consumption: Option, + pub created_at: DateTime, + pub updated_at: DateTime, +} + +/// 用户邀请关系 DTO +#[derive(Debug, Clone, FromRow, Serialize)] +pub struct UserReferralDto { + pub id: i32, + pub inviter_uuid: Uuid, + pub invitee_uuid: Uuid, + pub link_uuid: Option, + pub status: String, + pub total_consumption: Option, + pub last_30_days_consumption: Option, + pub registered_at: DateTime, + pub activated_at: Option>, + pub first_paid_at: Option>, +} + +/// 被邀请用户列表查询行(用于 `referral/users` 的 JOIN 查询) +/// +/// 注意:这是 SQL 行映射结构,属于 DTO/数据结构层(而不是 models 业务逻辑层)。 +#[derive(Debug, Clone, FromRow)] +pub struct ReferredUserRow { + pub id: i32, + pub email: String, + pub status: String, + pub link_uuid: Option, + pub total_consumption: Option, + pub last_30_days_consumption: Option, + pub registered_at: DateTime, +} + +/// 被邀请用户列表项(对齐前端 `ReferredUser`:camelCase + number) +/// +/// 前端表格字段: +/// - id +/// - email +/// - registeredAt +/// - status +/// - totalConsumption +/// - last30DaysConsumption +/// - linkId +#[derive(Debug, Clone, Serialize)] +#[serde(rename_all = "camelCase")] +pub struct ReferredUserItemDto { + pub id: String, + pub email: String, + pub registered_at: DateTime, + pub status: String, + pub total_consumption: f64, + pub last_30_days_consumption: f64, + pub link_id: String, +} + +/// 推荐奖励 DTO +#[derive(Debug, Clone, FromRow, Serialize)] +pub struct ReferralRewardDto { + pub id: i64, + pub uuid: Uuid, + pub user_uuid: Uuid, + pub reward_type: String, + pub points: i32, + pub description: Option, + pub referred_user_uuid: Option, + pub link_uuid: Option, + pub status: String, + pub created_at: DateTime, +} + +/// 用户推荐积分 DTO +#[derive(Debug, Clone, FromRow, Serialize)] +pub struct UserReferralPointsDto { + pub id: i32, + pub user_uuid: Uuid, + pub total_points: i32, + pub available_points: i32, + pub used_points: i32, + pub pending_points: i32, + pub created_at: DateTime, + pub updated_at: DateTime, +} + +/// 兑换选项 DTO +#[derive(Debug, Clone, FromRow, Serialize)] +pub struct RedeemOptionDto { + pub id: i32, + pub uuid: Uuid, + pub redeem_type: String, + pub name: String, + pub description: Option, + pub points_required: i32, + pub value: Decimal, + pub currency: Option, + pub exchange_rate: i32, + pub status: String, + pub sort_order: Option, + pub created_at: DateTime, + pub updated_at: DateTime, +} + +/// 兑换记录 DTO +#[derive(Debug, Clone, FromRow, Serialize)] +pub struct RedeemRecordDto { + pub id: i64, + pub uuid: Uuid, + pub user_uuid: Uuid, + pub option_uuid: Uuid, + pub points_used: i32, + pub value: Decimal, + pub currency: Option, + pub status: String, + pub created_at: DateTime, + pub completed_at: Option>, +} diff --git a/src/dto/rpa.rs b/src/dto/rpa.rs new file mode 100644 index 0000000..f65db79 --- /dev/null +++ b/src/dto/rpa.rs @@ -0,0 +1,82 @@ +use chrono::{DateTime, Utc}; +use serde::Serialize; +use sqlx::FromRow; +use uuid::Uuid; + +/// RPA 任务 DTO +#[derive(Debug, Clone, FromRow, Serialize)] +pub struct RpaTaskDto { + pub id: i32, + pub uuid: Uuid, + pub user_uuid: Uuid, + pub team_uuid: Option, + pub name: String, + pub description: Option, + pub tags: Option, + pub trigger_type: String, + pub schedule: Option, + pub cron_expression: Option, + pub run_mode: String, + pub retry_count: Option, + pub retry_interval: Option, + pub timeout: Option, + pub concurrency: Option, + pub stop_on_error: Option, + pub notify_on_complete: Option, + pub notify_on_error: Option, + pub status: String, + pub run_count: Option, + pub success_count: Option, + pub last_run_at: Option>, + pub next_run_at: Option>, + pub created_at: DateTime, + pub updated_at: DateTime, + pub deleted_at: Option>, +} + +/// RPA 任务步骤 DTO +#[derive(Debug, Clone, FromRow, Serialize)] +pub struct RpaTaskStepDto { + pub id: i32, + pub uuid: Uuid, + pub task_uuid: Uuid, + pub step_type: String, + pub name: String, + pub config: serde_json::Value, + pub enabled: Option, + pub position_x: Option, + pub position_y: Option, + pub sort_order: Option, + pub next_step_uuid: Option, + pub branch_config: Option, + pub created_at: DateTime, + pub updated_at: DateTime, +} + +/// RPA 任务环境关联 DTO +#[derive(Debug, Clone, FromRow, Serialize)] +pub struct RpaTaskEnvironmentDto { + pub id: i32, + pub task_uuid: Uuid, + pub environment_uuid: Uuid, + pub sort_order: Option, + pub created_at: DateTime, +} + +/// RPA 任务执行记录 DTO +#[derive(Debug, Clone, FromRow, Serialize)] +pub struct RpaTaskRunDto { + pub id: i64, + pub uuid: Uuid, + pub task_uuid: Uuid, + pub status: String, + pub total_steps: i32, + pub completed_steps: i32, + pub failed_steps: i32, + pub started_at: DateTime, + pub finished_at: Option>, + pub duration_ms: Option, + pub result_summary: Option, + pub error_message: Option, + pub logs: Option, +} diff --git a/src/dto/strategy_types.rs b/src/dto/strategy_types.rs new file mode 100644 index 0000000..321a4c3 --- /dev/null +++ b/src/dto/strategy_types.rs @@ -0,0 +1,15 @@ +use serde::{Deserialize, Serialize}; +use sqlx::FromRow; + +/// 策略类型 +#[derive(Debug, Deserialize, Serialize, FromRow, Clone)] +pub struct StrategyType { + pub id: i32, + pub code: String, + pub name: String, + pub category: Option, + pub description: Option, + pub processor_type: String, + pub config_schema: Option, + pub is_active: bool, +} diff --git a/src/dto/system.rs b/src/dto/system.rs new file mode 100644 index 0000000..e92cb0e --- /dev/null +++ b/src/dto/system.rs @@ -0,0 +1,49 @@ +use chrono::{DateTime, Utc}; +use serde::Serialize; +use sqlx::FromRow; +use uuid::Uuid; + +/// API 密钥 DTO +#[derive(Debug, Clone, FromRow, Serialize)] +pub struct ApiKeyDto { + pub id: i32, + pub uuid: Uuid, + pub user_uuid: Uuid, + pub name: String, + pub key_hash: String, + pub key_prefix: String, + pub permissions: serde_json::Value, + pub rate_limit: Option, + pub daily_limit: Option, + pub ip_whitelist: Option, + pub expires_at: Option>, + pub usage_count: Option, + pub daily_usage: Option, + pub last_used_at: Option>, + pub status: String, + pub created_at: DateTime, + pub updated_at: DateTime, +} + +/// 用户偏好设置 DTO +#[derive(Debug, Clone, FromRow, Serialize)] +pub struct UserPreferenceDto { + pub id: i32, + pub user_uuid: Uuid, + pub theme: String, + pub language: String, + pub notifications_enabled: bool, + pub created_at: DateTime, + pub updated_at: DateTime, +} + +/// 系统配置 DTO +#[derive(Debug, Clone, FromRow, Serialize)] +pub struct SystemConfigDto { + pub id: i32, + pub config_key: String, + pub config_value: serde_json::Value, + pub description: Option, + pub created_at: DateTime, + pub updated_at: DateTime, +} diff --git a/src/dto/teams.rs b/src/dto/teams.rs new file mode 100644 index 0000000..7e0f27d --- /dev/null +++ b/src/dto/teams.rs @@ -0,0 +1,84 @@ +use chrono::{DateTime, Utc}; +use serde::Serialize; +use sqlx::FromRow; +use uuid::Uuid; + +/// 团队 DTO +#[derive(Debug, Clone, FromRow, Serialize)] +pub struct TeamDto { + pub id: i32, + pub uuid: Uuid, + pub workspace_uuid: Uuid, + pub name: String, + pub description: Option, + pub owner_uuid: Uuid, + pub avatar_hash: Option, + pub status: String, + pub created_at: DateTime, + pub updated_at: DateTime, + pub deleted_at: Option>, +} + +/// 团队摘要 DTO(用于登录响应等场景,只包含基本信息) +#[derive(Debug, Clone, Serialize)] +pub struct TeamSummaryDto { + pub uuid: Uuid, + pub name: String, + pub description: Option, +} + +/// 团队成员 DTO(包含用户信息) +#[derive(Debug, Clone, FromRow, Serialize)] +pub struct TeamMemberDto { + pub id: i32, + pub team_uuid: Uuid, + pub workspace_uuid: Uuid, + pub user_uuid: Uuid, + pub role: String, + pub joined_at: DateTime, + pub invited_by: Option, + pub status: String, + pub created_at: DateTime, + pub updated_at: DateTime, + pub deleted_at: Option>, + // 从 user_infos 表关联的用户信息 + #[serde(skip_serializing_if = "Option::is_none")] + pub name: Option, + #[serde(skip_serializing_if = "Option::is_none")] + pub email: Option, + #[serde(skip_serializing_if = "Option::is_none")] + pub avatar: Option, +} + +/// 团队邀请 DTO +#[derive(Debug, Clone, FromRow, Serialize)] +pub struct TeamInvitationDto { + pub id: i32, + pub uuid: Uuid, + pub team_uuid: Uuid, + pub email: String, + pub role: String, + pub invited_by: Uuid, + pub token: String, + pub expires_at: DateTime, + pub status: String, + pub accepted_at: Option>, + pub created_at: DateTime, + pub updated_at: DateTime, +} + +/// 登录历史 DTO +#[derive(Debug, Clone, FromRow)] +pub struct LoginHistoryDto { + pub id: i64, + pub user_uuid: Uuid, + pub ip_address: String, + pub device_info: Option, + pub user_agent: Option, + pub location: Option, + pub country: Option, + pub city: Option, + pub success: bool, + pub failure_reason: Option, + pub created_at: DateTime, +} diff --git a/src/dto/user.rs b/src/dto/user.rs new file mode 100644 index 0000000..e49baa0 --- /dev/null +++ b/src/dto/user.rs @@ -0,0 +1,31 @@ +use chrono::{DateTime, Utc}; +use sqlx::FromRow; +use uuid::Uuid; + +/// 用户基础信息 DTO +#[derive(Debug, Clone, FromRow)] +pub struct UserDto { + pub uuid: Uuid, + pub id: String, + pub created_at: DateTime, + pub updated_at: DateTime, + pub deleted_at: Option>, +} + +/// 用户详细信息 DTO +#[derive(Debug, Clone, FromRow)] +pub struct UserInfoDto { + pub id: i32, + pub user_uuid: Uuid, + pub nickname: Option, + pub email: String, + pub phone: Option, + pub password: String, + pub avatar_hash: Option, + pub status: String, + pub current_team_uuid: Option, + pub current_workspace_uuid: Option, + pub created_at: DateTime, + pub updated_at: DateTime, + pub deleted_at: Option>, +} diff --git a/src/dto/version_types.rs b/src/dto/version_types.rs new file mode 100644 index 0000000..238d595 --- /dev/null +++ b/src/dto/version_types.rs @@ -0,0 +1,13 @@ +use serde::{Deserialize, Serialize}; +use sqlx::FromRow; + +/// 版本类型 DTO +#[derive(Debug, Clone, FromRow, Serialize, Deserialize)] +pub struct VersionType { + pub id: i32, + pub type_code: String, + pub type_name: String, + pub description: Option, + pub sort_order: Option, + pub is_active: bool, +} diff --git a/src/dto/versions.rs b/src/dto/versions.rs new file mode 100644 index 0000000..b986294 --- /dev/null +++ b/src/dto/versions.rs @@ -0,0 +1,26 @@ +use chrono::DateTime; +use serde::{Deserialize, Serialize}; +use sqlx::FromRow; + +/// 版本 DTO +#[derive(Debug, Clone, FromRow, Serialize, Deserialize)] +pub struct Version { + pub id: i32, + pub type_id: i32, + pub resource_name: String, + pub version: String, + pub name: Option, + pub notes: Option, + pub platform: Option, + pub url: Option, + pub hash: Option, + pub signature: Option, + pub install_path: Option, + pub file_size: Option, + pub is_latest: bool, + pub status: String, + pub pub_date: Option>, + pub created_at: DateTime, + pub updated_at: Option>, + pub deleted_at: Option>, +} diff --git a/src/dto/workspace_quotas.rs b/src/dto/workspace_quotas.rs new file mode 100644 index 0000000..9aaf60e --- /dev/null +++ b/src/dto/workspace_quotas.rs @@ -0,0 +1,20 @@ +use chrono::{DateTime, Utc}; +use serde::Serialize; +use sqlx::FromRow; +use uuid::Uuid; + +/// 工作空间配额 DTO +#[derive(Debug, Clone, FromRow, Serialize)] +pub struct WorkspaceQuotaDto { + pub workspace_uuid: Uuid, + pub max_environments: i32, + pub used_environments: i32, + pub max_team_members: i32, + pub used_team_members: i32, + pub max_proxies: i32, + pub used_proxies: i32, + pub max_rpa_tasks: i32, + pub used_rpa_tasks: i32, + pub created_at: DateTime, + pub updated_at: DateTime, +} diff --git a/src/dto/workspaces.rs b/src/dto/workspaces.rs new file mode 100644 index 0000000..4058def --- /dev/null +++ b/src/dto/workspaces.rs @@ -0,0 +1,25 @@ +use chrono::{DateTime, Utc}; +use serde::Serialize; +use sqlx::FromRow; +use uuid::Uuid; + +/// 工作空间 DTO +#[derive(Debug, Clone, FromRow, Serialize)] +pub struct WorkspaceDto { + pub uuid: Uuid, + pub name: String, + pub owner_uuid: Uuid, + pub workspace_type: String, + pub created_at: DateTime, + pub updated_at: DateTime, + pub deleted_at: Option>, +} + +/// 工作空间摘要 DTO(用于列表显示等场景) +#[derive(Debug, Clone, Serialize)] +pub struct WorkspaceSummaryDto { + pub uuid: Uuid, + pub name: String, + pub workspace_type: String, + pub owner_uuid: Uuid, +} diff --git a/src/entitys/accounts.rs b/src/entitys/accounts.rs new file mode 100644 index 0000000..700855d --- /dev/null +++ b/src/entitys/accounts.rs @@ -0,0 +1,77 @@ +use serde::{Deserialize, Serialize}; +use uuid::Uuid; + +use super::Pagination; + +/// 查询账号列表请求 +#[derive(Debug, Clone, Deserialize, Serialize)] +pub struct ListAccountsRequest { + #[serde(flatten)] + pub pagination: Pagination, + pub filters: Option, +} + +/// 账号筛选条件 +#[derive(Debug, Clone, Deserialize, Serialize)] +pub struct AccountFilters { + pub keyword: Option, + pub platform_name: Option, + pub status: Option, +} + +/// 创建账号请求 +#[derive(Debug, Clone, Deserialize, Serialize)] +pub struct CreateAccountRequest { + pub platform_url: String, + pub platform_name: Option, + pub account: String, + pub password: Option, + pub remark: Option, +} + +/// 更新账号请求 +#[derive(Debug, Clone, Deserialize, Serialize)] +pub struct UpdateAccountRequest { + pub uuid: Uuid, + pub platform_url: Option, + pub platform_name: Option, + pub account: Option, + pub password: Option, + pub remark: Option, + pub status: Option, +} + +/// 批量导入账号项(客户端已解析好的结构化数据) +#[derive(Debug, Clone, Deserialize, Serialize)] +pub struct BatchImportAccountItem { + pub platform_url: String, + pub platform_name: Option, + pub account: String, + pub password: Option, + pub remark: Option, +} + +/// 批量导入账号请求 +#[derive(Debug, Clone, Deserialize, Serialize)] +pub struct BatchImportAccountsRequest { + pub accounts: Vec, +} + +/// 导出账号请求 +#[derive(Debug, Clone, Deserialize, Serialize)] +pub struct ExportAccountsRequest { + pub uuids: Option>, + pub format: String, + pub include_password: bool, +} + +// ========== 响应结构体 ========== + +/// 账号列表响应 +#[derive(Debug, Clone, Serialize)] +pub struct AccountListResponse { + pub items: Vec, + pub total: i64, + pub page: i64, + pub page_size: i64, +} diff --git a/src/entitys/api_service.rs b/src/entitys/api_service.rs new file mode 100644 index 0000000..73ae5a4 --- /dev/null +++ b/src/entitys/api_service.rs @@ -0,0 +1,115 @@ +use chrono::{DateTime, Utc}; +use serde::{Deserialize, Serialize}; +use uuid::Uuid; + +/// 创建 API 密钥请求 +#[derive(Debug, Clone, Deserialize, Serialize)] +pub struct CreateApiKeyRequest { + pub name: String, + pub permissions: Vec, + pub rate_limit: Option, + pub daily_limit: Option, + pub ip_whitelist: Option>, + pub expires_at: Option>, +} + +/// 重置 API 密钥请求 +#[derive(Debug, Clone, Deserialize, Serialize)] +pub struct ResetApiKeyRequest { + pub uuid: Uuid, +} + +/// 删除 API 密钥请求 +#[derive(Debug, Clone, Deserialize, Serialize)] +pub struct DeleteApiKeyRequest { + pub uuid: Uuid, +} + +/// API 使用统计请求 +#[derive(Debug, Clone, Deserialize, Serialize)] +pub struct ApiStatsRequest { + pub date_from: Option, + pub date_to: Option, +} + +use super::Pagination; + +/// 查询 API 密钥列表请求 +#[derive(Debug, Clone, Deserialize, Serialize)] +pub struct ListApiKeysRequest { + #[serde(flatten)] + pub pagination: Pagination, + pub status: Option, +} + +/// 更新 API 密钥请求 +#[derive(Debug, Clone, Deserialize, Serialize)] +pub struct UpdateApiKeyRequest { + pub uuid: Uuid, + pub name: Option, + pub permissions: Option>, + pub rate_limit: Option, + pub daily_limit: Option, + pub ip_whitelist: Option>, +} + +/// 撤销 API 密钥请求 +#[derive(Debug, Clone, Deserialize, Serialize)] +pub struct RevokeApiKeyRequest { + pub uuid: Uuid, +} + +// ========== 响应结构体 ========== + +/// API 密钥列表响应 +#[derive(Debug, Clone, Serialize)] +pub struct ApiKeysListResponse { + pub items: Vec, + pub total: i64, + pub page: i64, + pub page_size: i64, +} + +/// API 密钥项(不包含敏感信息) +#[derive(Debug, Clone, Serialize)] +pub struct ApiKeyItem { + pub uuid: Uuid, + pub name: String, + pub key_prefix: String, + pub permissions: serde_json::Value, + pub rate_limit: Option, + pub daily_limit: Option, + pub ip_whitelist: Option, + pub expires_at: Option>, + pub usage_count: Option, + pub last_used_at: Option>, + pub status: String, + pub created_at: DateTime, +} + +/// 创建 API 密钥响应 +#[derive(Debug, Clone, Serialize)] +pub struct CreateApiKeyResponse { + pub uuid: Uuid, + pub api_key: String, + pub key_prefix: String, +} + +impl From for ApiKeyItem { + fn from(dto: crate::dto::ApiKeyDto) -> Self { + Self { + uuid: dto.uuid, + name: dto.name, + key_prefix: dto.key_prefix, + permissions: dto.permissions, + rate_limit: dto.rate_limit, + daily_limit: dto.daily_limit, + ip_whitelist: dto.ip_whitelist, + expires_at: dto.expires_at, + usage_count: dto.usage_count, + last_used_at: dto.last_used_at, + status: dto.status, + created_at: dto.created_at, + } + } +} diff --git a/src/entitys/audit.rs b/src/entitys/audit.rs new file mode 100644 index 0000000..403c5f2 --- /dev/null +++ b/src/entitys/audit.rs @@ -0,0 +1,83 @@ +use serde::{Deserialize, Serialize}; +use uuid::Uuid; + +use super::Pagination; +use crate::dto::AuditLogDto; + +/// 查询审计日志请求 +#[derive(Debug, Clone, Deserialize, Serialize)] +pub struct ListAuditLogsRequest { + #[serde(flatten)] + pub pagination: Pagination, + pub filters: Option, +} + +// ========== 响应结构体 ========== + +/// 审计日志列表响应 +#[derive(Debug, Clone, Serialize)] +pub struct AuditLogsListResponse { + pub items: Vec, + pub total: i64, + pub page: i64, + pub page_size: i64, +} + +/// 导出响应 +#[derive(Debug, Clone, Serialize)] +pub struct ExportResponse { + pub content: String, + pub filename: String, + pub mime_type: String, +} + +/// 审计统计响应 +#[derive(Debug, Clone, Serialize)] +pub struct AuditStatsResponse { + pub total_logs: i64, + pub logs_today: i64, + pub logs_this_week: i64, + pub logs_this_month: i64, + pub top_actions: Vec, + pub top_target_types: Vec, +} + +/// 操作计数 +#[derive(Debug, Clone, Serialize)] +pub struct ActionCount { + pub action: String, + pub count: i64, +} + +/// 目标类型计数 +#[derive(Debug, Clone, Serialize)] +pub struct TargetTypeCount { + pub target_type: String, + pub count: i64, +} + +/// 审计日志筛选条件 +#[derive(Debug, Clone, Deserialize, Serialize)] +pub struct AuditLogFilters { + pub keyword: Option, + pub action: Option, + pub target_type: Option, + pub user_uuid: Option, + pub date_from: Option, + pub date_to: Option, +} + +/// 导出审计日志请求 +#[derive(Debug, Clone, Deserialize, Serialize)] +pub struct ExportAuditLogsRequest { + pub format: String, + pub filters: Option, + pub max_records: Option, +} + +/// 审计统计请求 +#[derive(Debug, Clone, Deserialize, Serialize)] +pub struct AuditStatsRequest { + pub date_from: Option, + pub date_to: Option, +} diff --git a/src/entitys/billing.rs b/src/entitys/billing.rs new file mode 100644 index 0000000..ccec45d --- /dev/null +++ b/src/entitys/billing.rs @@ -0,0 +1,247 @@ +use rust_decimal::Decimal; +use serde::{Deserialize, Serialize}; +use uuid::Uuid; + +use super::Pagination; +use crate::dto::{ + CouponDto, InvoiceDto, PaymentOrderDto, PlanDto, PlanFeatureDto, + UserCouponWithDetailsDto, WalletTransactionDto, +}; + +/// 订阅套餐请求 +#[derive(Debug, Clone, Deserialize, Serialize)] +pub struct SubscribePlanRequest { + pub plan_uuid: Uuid, + pub billing_period: String, + pub coupon_code: Option, + /// 支付方式:wallet(钱包)、alipay(支付宝)、wechat(微信) + pub payment_method: Option, +} + +/// 取消订阅请求 +#[derive(Debug, Clone, Deserialize, Serialize)] +pub struct CancelSubscriptionRequest { + pub subscription_uuid: Uuid, +} + +/// 恢复订阅请求 +#[derive(Debug, Clone, Deserialize, Serialize)] +pub struct ResumeSubscriptionRequest { + pub subscription_uuid: Uuid, +} + +/// 切换自动续费请求 +#[derive(Debug, Clone, Deserialize, Serialize)] +pub struct ToggleAutoRenewRequest { + pub subscription_uuid: Uuid, + pub auto_renew: bool, +} + +/// 查询交易记录请求 +#[derive(Debug, Clone, Deserialize, Serialize)] +pub struct ListTransactionsRequest { + #[serde(flatten)] + pub pagination: Pagination, + pub transaction_type: Option, +} + +/// 查询发票列表请求 +#[derive(Debug, Clone, Deserialize, Serialize)] +pub struct ListInvoicesRequest { + #[serde(flatten)] + pub pagination: Pagination, + pub status: Option, +} + +/// 验证优惠券请求 +#[derive(Debug, Clone, Deserialize, Serialize)] +pub struct VerifyCouponRequest { + pub code: String, + pub amount: Decimal, +} + +/// 创建充值订单请求 +#[derive(Debug, Clone, Deserialize, Serialize)] +pub struct CreateRechargeOrderRequest { + pub amount: Decimal, + pub payment_channel: String, +} + +/// 查询支付订单请求 +#[derive(Debug, Clone, Deserialize, Serialize)] +pub struct ListPaymentOrdersRequest { + #[serde(flatten)] + pub pagination: Pagination, + pub order_type: Option, + pub status: Option, +} + +/// 获取用户优惠券列表请求 +#[derive(Debug, Clone, Deserialize, Serialize)] +pub struct GetUserCouponsRequest { + #[serde(flatten)] + pub pagination: Pagination, + pub status: Option, // unused, used, expired +} + +/// 发放优惠券请求(管理员功能) +#[derive(Debug, Clone, Deserialize, Serialize)] +pub struct IssueCouponRequest { + pub coupon_uuid: Uuid, + pub user_uuid: Uuid, + pub expires_at: Option>, // 可选的自定义过期时间 +} + +/// 批量发放优惠券请求(管理员功能) +#[derive(Debug, Clone, Deserialize, Serialize)] +pub struct BatchIssueCouponRequest { + pub coupon_uuid: Uuid, + pub user_uuids: Vec, + pub expires_at: Option>, // 可选的自定义过期时间 +} + +/// 获取套餐价格请求 +#[derive(Debug, Clone, Deserialize, Serialize)] +pub struct GetPlanPriceRequest { + pub plan_uuid: Uuid, + pub billing_period: String, // monthly, yearly + pub coupon_code: Option, +} + +/// 获取套餐列表请求 +#[derive(Debug, Clone, Deserialize, Serialize)] +pub struct GetPlansRequest { + /// 优惠券代码(可选) + pub coupon_code: Option, + /// 计费周期(用于计算价格,默认为 monthly) + #[serde(default = "default_billing_period")] + pub billing_period: String, +} + +fn default_billing_period() -> String { + "monthly".to_string() +} + +// ========== 响应结构体 ========== + +/// 带特性的套餐结构 +#[derive(Debug, Clone, Serialize)] +pub struct PlanWithFeatures { + pub plan: PlanDto, + pub features: Vec, + /// 计算后的价格信息(如果提供了优惠券代码) + #[serde(skip_serializing_if = "Option::is_none")] + pub calculated_price: Option, +} + +/// 套餐价格信息(用于套餐列表响应) +#[derive(Debug, Clone, Serialize)] +pub struct PlanPriceInfo { + pub original_price: Decimal, + pub plan_discount: Decimal, + pub coupon_discount: Decimal, + pub final_price: Decimal, + pub total_saved: Decimal, + pub billing_period: String, // monthly, yearly +} + +/// 套餐列表响应 +#[derive(Debug, Clone, Serialize)] +pub struct PlansResponse { + pub plans: Vec, +} + +/// 套餐详情响应 +#[derive(Debug, Clone, Serialize)] +pub struct PlanDetailResponse { + pub plan: PlanDto, + pub features: Vec, +} + +/// 订阅响应 +#[derive(Debug, Clone, Serialize)] +pub struct SubscribeResponse { + pub subscription_uuid: Uuid, +} + +/// 交易记录列表响应 +#[derive(Debug, Clone, Serialize)] +pub struct TransactionsListResponse { + pub items: Vec, + pub total: i64, + pub page: i64, + pub page_size: i64, +} + +/// 发票列表响应 +#[derive(Debug, Clone, Serialize)] +pub struct InvoicesListResponse { + pub items: Vec, + pub total: i64, + pub page: i64, + pub page_size: i64, +} + +/// 优惠券验证响应 +#[derive(Debug, Clone, Serialize)] +pub struct VerifyCouponResponse { + pub coupon_uuid: Uuid, + pub discount_type: String, + pub discount_value: rust_decimal::Decimal, + pub discount_amount: rust_decimal::Decimal, +} + +/// 创建订单响应 +#[derive(Debug, Clone, Serialize)] +pub struct CreateOrderResponse { + pub order_uuid: Uuid, + pub order_no: String, +} + +/// 支付订单列表响应 +#[derive(Debug, Clone, Serialize)] +pub struct PaymentOrdersListResponse { + pub items: Vec, + pub total: i64, + pub page: i64, + pub page_size: i64, +} + +/// 优惠券验证结果 +#[derive(Debug, Clone, Serialize)] +pub struct CouponValidationResult { + pub coupon: CouponDto, + pub discount_amount: rust_decimal::Decimal, +} + +/// 账户信息响应 +#[derive(Debug, Clone, Serialize)] +pub struct AccountInfoResponse { + pub email: String, + pub wallet_balance: rust_decimal::Decimal, + pub gift_balance: rust_decimal::Decimal, + pub currency: String, + pub subscription: Option, + pub quota: crate::dto::WorkspaceQuotaDto, + pub monthly_billing: rust_decimal::Decimal, +} + +/// 用户优惠券列表响应 +#[derive(Debug, Clone, Serialize)] +pub struct UserCouponsListResponse { + pub items: Vec, + pub total: i64, + pub page: i64, + pub page_size: i64, +} + +/// 套餐价格响应 +#[derive(Debug, Clone, Serialize)] +pub struct PlanPriceResponse { + pub original_price: Decimal, + pub plan_discount: Decimal, // 套餐级折扣金额 + pub coupon_discount: Decimal, // 优惠券折扣金额 + pub final_price: Decimal, // 最终价格 + pub total_saved: Decimal, // 总节省金额 + pub coupon_info: Option, // 优惠券信息(如果使用了优惠券) +} diff --git a/src/entitys/common.rs b/src/entitys/common.rs new file mode 100644 index 0000000..b5259dc --- /dev/null +++ b/src/entitys/common.rs @@ -0,0 +1,65 @@ +use serde::{Deserialize, Serialize}; +use uuid::Uuid; + +// ========== 请求参数 ========== + +/// 分页请求参数 +#[derive(Debug, Clone, Deserialize, Serialize, Default)] +pub struct Pagination { + #[serde(default = "default_page")] + pub page: i64, + #[serde(default = "default_page_size")] + pub page_size: i64, + #[serde(default)] + pub sort_by: Option, + #[serde(default)] + pub sort_order: Option, +} + +fn default_page() -> i64 { + 1 +} + +fn default_page_size() -> i64 { + 20 +} + +/// UUID 请求参数 +#[derive(Debug, Clone, Deserialize, Serialize)] +pub struct UuidRequest { + pub uuid: Uuid, +} + +/// 批量 UUID 请求参数 +#[derive(Debug, Clone, Deserialize, Serialize)] +pub struct BatchUuidRequest { + pub uuids: Vec, +} + +// ========== 响应结构体 ========== + +/// 创建资源响应(返回新创建资源的 UUID) +#[derive(Debug, Clone, Serialize)] +pub struct CreateResponse { + pub uuid: Uuid, +} + +/// 创建资源响应(返回新创建资源的数字 ID) +#[derive(Debug, Clone, Serialize)] +pub struct IdResponse { + pub id: i32, +} + +/// 邀请响应 +#[derive(Debug, Clone, Serialize)] +pub struct InviteResponse { + pub invitation_uuid: Uuid, +} + +/// 批量导入响应 +#[derive(Debug, Clone, Serialize)] +pub struct BatchImportResponse { + pub success_count: i32, + pub failed_count: i32, + pub errors: Vec, +} diff --git a/src/entitys/console_admin.rs b/src/entitys/console_admin.rs new file mode 100644 index 0000000..25a60e2 --- /dev/null +++ b/src/entitys/console_admin.rs @@ -0,0 +1,33 @@ +//! Console Gateway 管理员相关请求/响应实体 + +use serde::{Deserialize, Serialize}; +use uuid::Uuid; + +/// 创建管理员请求 +#[derive(Debug, Clone, Deserialize, Serialize)] +pub struct CreateConsoleAdminRequest { + pub user_uuid: Uuid, +} + +/// 更新管理员请求 +#[derive(Debug, Clone, Deserialize, Serialize, Default)] +pub struct UpdateConsoleAdminRequest { + #[serde(skip_serializing_if = "Option::is_none")] + pub is_active: Option, +} + +/// 创建权限请求 +#[derive(Debug, Clone, Deserialize, Serialize)] +pub struct CreateConsolePermissionRequest { + pub route_path: String, + pub method: String, + #[serde(skip_serializing_if = "Option::is_none")] + pub description: Option, +} + +/// 授予权限请求 +#[derive(Debug, Clone, Deserialize, Serialize)] +pub struct GrantConsolePermissionRequest { + pub admin_id: i32, + pub permission_id: i32, +} diff --git a/src/entitys/console_apikey.rs b/src/entitys/console_apikey.rs new file mode 100644 index 0000000..27159c8 --- /dev/null +++ b/src/entitys/console_apikey.rs @@ -0,0 +1,14 @@ +//! Console Gateway API 密钥相关请求/响应实体 + +use chrono::{DateTime, Utc}; +use serde::{Deserialize, Serialize}; + +/// 创建 API 密钥请求 +#[derive(Debug, Clone, Deserialize, Serialize)] +pub struct CreateConsoleApiKeyRequest { + pub name: String, + #[serde(skip_serializing_if = "Option::is_none")] + pub description: Option, + #[serde(skip_serializing_if = "Option::is_none")] + pub expires_at: Option>, +} diff --git a/src/entitys/environments.rs b/src/entitys/environments.rs new file mode 100644 index 0000000..18fc4e6 --- /dev/null +++ b/src/entitys/environments.rs @@ -0,0 +1,237 @@ +use serde::{Deserialize, Serialize}; +use uuid::Uuid; + +use super::Pagination; + +/// 查询环境列表请求 +#[derive(Debug, Clone, Deserialize, Serialize)] +pub struct ListEnvironmentsRequest { + #[serde(flatten)] + pub pagination: Pagination, + pub filters: Option, +} + +/// 环境筛选条件 +#[derive(Debug, Clone, Deserialize, Serialize)] +pub struct EnvironmentFilters { + pub keyword: Option, + pub status: Option, + pub group_uuid: Option, + pub tag_uuids: Option>, + pub created_from: Option, + pub created_to: Option, +} + +/// 创建环境请求 +#[derive(Debug, Clone, Deserialize, Serialize)] +pub struct CreateEnvironmentRequest { + pub name: String, + pub description: Option, + pub group_uuid: Option, + pub tag_uuids: Option>, + pub account_uuids: Option>, + pub proxy_uuid: Option, // 代理 UUID(单个,可选) + pub cookies: Option>, // Cookie 字符串数组(如 "name=value" 或 "name=value; domain=example.com") + pub config: EnvironmentConfigRequest, +} + +/// 环境配置请求(对应 WindowConfig) +#[derive(Debug, Clone, Deserialize, Serialize)] +pub struct EnvironmentConfigRequest { + pub window_info: serde_json::Value, + pub basic_settings: serde_json::Value, + pub fingerprint_settings: serde_json::Value, + pub device_settings: serde_json::Value, + pub preference_settings: serde_json::Value, + #[serde(default)] + pub project_metadata: serde_json::Value, +} + +/// 批量创建环境请求 +#[derive(Debug, Clone, Deserialize, Serialize)] +pub struct BatchCreateEnvironmentRequest { + pub environments: Vec, // 环境创建请求数组 +} + +/// 更新环境请求 +#[derive(Debug, Clone, Deserialize, Serialize)] +pub struct UpdateEnvironmentRequest { + pub uuid: Uuid, + pub name: Option, + pub description: Option, + pub group_uuid: Option, + pub config: Option, +} + +/// 设置环境代理请求 +#[derive(Debug, Clone, Deserialize, Serialize)] +pub struct SetEnvironmentProxyRequest { + pub uuid: Uuid, + pub proxy_uuid: Option, +} + +/// 设置环境账号请求 +#[derive(Debug, Clone, Deserialize, Serialize)] +pub struct SetEnvironmentAccountsRequest { + pub uuid: Uuid, + pub account_uuids: Vec, +} + +/// 分配标签请求 +#[derive(Debug, Clone, Deserialize, Serialize)] +pub struct AssignTagsRequest { + pub uuid: Uuid, + pub tag_uuids: Vec, +} + +/// 批量分配标签请求 +#[derive(Debug, Clone, Deserialize, Serialize)] +pub struct BatchAssignTagRequest { + pub env_uuids: Vec, + pub tag_uuid: Uuid, +} + +/// 批量移除标签请求 +#[derive(Debug, Clone, Deserialize, Serialize)] +pub struct BatchRemoveTagsRequest { + pub env_uuids: Vec, + pub tag_uuid: Option, +} + +/// 移除标签请求 +#[derive(Debug, Clone, Deserialize, Serialize)] +pub struct RemoveTagRequest { + pub uuid: Uuid, + pub tag_uuid: Uuid, +} + +/// 移动到分组请求 +#[derive(Debug, Clone, Deserialize, Serialize)] +pub struct MoveToGroupRequest { + pub uuid: Uuid, + pub group_uuid: Option, +} + +/// 批量移动到分组请求 +#[derive(Debug, Clone, Deserialize, Serialize)] +pub struct BatchMoveToGroupRequest { + pub env_uuids: Vec, + pub group_uuid: Uuid, +} + +// ============ Environment URLs ============ + +/// 添加环境 URL 请求 +#[derive(Debug, Clone, Deserialize, Serialize)] +pub struct AddEnvironmentUrlRequest { + pub environment_uuid: Uuid, + pub url: String, + pub title: Option, + pub sort_order: Option, +} + +/// 批量添加环境 URL 请求 +#[derive(Debug, Clone, Deserialize, Serialize)] +pub struct BatchAddEnvironmentUrlsRequest { + pub environment_uuid: Uuid, + pub urls: Vec, +} + +/// URL 输入 +#[derive(Debug, Clone, Deserialize, Serialize)] +pub struct UrlInput { + pub url: String, + pub title: Option, +} + +/// 删除环境 URL 请求 +#[derive(Debug, Clone, Deserialize, Serialize)] +pub struct DeleteEnvironmentUrlRequest { + pub id: i32, +} + +/// 清空环境 URL 请求 +#[derive(Debug, Clone, Deserialize, Serialize)] +pub struct ClearEnvironmentUrlsRequest { + pub environment_uuid: Uuid, +} + +// ============ Environment Cookies ============ + +/// 添加环境 Cookie 请求 +#[derive(Debug, Clone, Deserialize, Serialize)] +pub struct AddEnvironmentCookieRequest { + pub environment_uuid: Uuid, + pub domain: String, + pub name: String, + pub value: String, + pub path: Option, + pub http_only: Option, + pub secure: Option, + pub same_site: Option, +} + +/// 批量添加环境 Cookie 请求 +#[derive(Debug, Clone, Deserialize, Serialize)] +pub struct BatchAddEnvironmentCookiesRequest { + pub environment_uuid: Uuid, + pub cookies: Vec, +} + +/// Cookie 输入 +#[derive(Debug, Clone, Deserialize, Serialize)] +pub struct CookieInputRequest { + pub domain: String, + pub name: String, + pub value: String, + pub path: Option, + pub http_only: Option, + pub secure: Option, + pub same_site: Option, +} + +/// 删除环境 Cookie 请求 +#[derive(Debug, Clone, Deserialize, Serialize)] +pub struct DeleteEnvironmentCookieRequest { + pub id: i32, +} + +/// 清空环境 Cookie 请求 +#[derive(Debug, Clone, Deserialize, Serialize)] +pub struct ClearEnvironmentCookiesRequest { + pub environment_uuid: Uuid, +} + +/// Cookie 输入结构(用于批量添加) +#[derive(Debug, Clone, Deserialize, Serialize)] +pub struct CookieInput { + pub domain: String, + pub name: String, + pub value: String, + pub path: Option, + pub http_only: Option, + pub secure: Option, + pub same_site: Option, +} + +// ========== 响应结构体 ========== + +/// 环境列表响应(使用与环境详情一致的数据结构) +#[derive(Debug, Clone, Serialize)] +pub struct EnvironmentListResponse { + pub items: Vec, + pub total: i64, + pub page: i64, + pub page_size: i64, +} + +/// 环境详情响应(包含完整配置) +#[derive(Debug, Clone, Serialize, Deserialize)] +pub struct EnvironmentDetailResponse { + pub environment: crate::dto::EnvironmentDto, + pub config: Option, + pub tags: Vec, + pub accounts: Vec, + pub group: Option, // 分组完整信息 + pub proxy: Option, // 代理完整信息 +} diff --git a/src/entitys/extensions.rs b/src/entitys/extensions.rs new file mode 100644 index 0000000..4c512a2 --- /dev/null +++ b/src/entitys/extensions.rs @@ -0,0 +1,216 @@ +use chrono::{DateTime, Utc}; +use rust_decimal::Decimal; +use serde::{Deserialize, Serialize}; +use uuid::Uuid; + +use super::Pagination; + +/// 创建扩展参数(模型层入参) +#[derive(Debug, Clone, Serialize, Deserialize)] +pub struct CreateExtensionParams { + pub extension_id: String, + pub name: String, + pub description: Option, + pub version: String, + pub category: String, + pub browser: String, + pub developer: Option, + pub homepage: Option, + pub icon_url: Option, + pub download_url: Option, + pub file_size: Option, + pub downloads_count: Option, + pub permissions: Option, + pub rating: Option, + pub changelog: Option, + pub published_at: Option>, +} + +/// 更新扩展参数(模型层入参) +#[derive(Debug, Clone, Serialize, Deserialize)] +pub struct UpdateExtensionParams { + pub name: Option, + pub description: Option, + pub version: Option, + pub category: Option, + pub developer: Option, + pub homepage: Option, + pub icon_url: Option, + pub download_url: Option, + pub file_size: Option, + pub downloads_count: Option, + pub permissions: Option, + pub rating: Option, + pub changelog: Option, + pub published_at: Option>, +} + +/// 查询扩展列表请求 +#[derive(Debug, Clone, Deserialize, Serialize)] +pub struct ListExtensionsRequest { + #[serde(flatten)] + pub pagination: Pagination, + pub filters: Option, +} + +/// 同步扩展响应 +#[derive(Debug, Clone, Serialize)] +pub struct SyncExtensionResponse { + /// 扩展 UUID + pub uuid: Uuid, + /// 扩展 ID + pub extension_id: String, + /// 是否为新创建 + pub created: bool, +} + +/// 扩展筛选条件 +#[derive(Debug, Clone, Deserialize, Serialize)] +pub struct ExtensionFilters { + pub keyword: Option, + pub category: Option, + pub installed_only: Option, +} + +/// 安装扩展请求 +#[derive(Debug, Clone, Deserialize, Serialize)] +pub struct InstallExtensionRequest { + pub extension_id: String, + /// 安装目标: user, team, group, environment + pub target_type: Option, + /// 分组 UUID 数组(用于安装到分组,即使只有一个分组也需要传入数组) + #[serde(skip_serializing_if = "Option::is_none")] + pub group_ids: Option>, + /// 环境 UUID(用于安装到环境) + #[serde(skip_serializing_if = "Option::is_none")] + pub env_uuid: Option, +} + +/// 卸载扩展请求 +#[derive(Debug, Clone, Deserialize, Serialize)] +pub struct UninstallExtensionRequest { + pub extension_id: String, + /// 卸载类型:user、team、environment + /// 如果不指定,默认为 user + /// 注意:无论 target_type 是什么,后端都会自动删除所有相关的分组记录 + pub target_type: Option, + /// 目标 UUID(用于 team 和 environment 类型) + #[serde(skip_serializing_if = "Option::is_none")] + pub target_uuid: Option, +} + +/// 更新扩展请求 +#[derive(Debug, Clone, Deserialize, Serialize)] +pub struct UpdateExtensionRequest { + pub extension_id: String, +} + +/// 批量更新扩展请求 +#[derive(Debug, Clone, Deserialize, Serialize)] +pub struct BatchUpdateExtensionsRequest { + pub extension_ids: Vec, +} + +/// 扩展 ID 请求 +#[derive(Debug, Clone, Deserialize, Serialize)] +pub struct ExtensionIdRequest { + pub extension_id: String, +} + +// ========== 响应结构体 ========== + +use crate::dto::ExtensionDto; + +/// 扩展列表响应 +#[derive(Debug, Clone, Serialize)] +pub struct ExtensionsListResponse { + pub items: Vec, + pub total: i64, + pub page: i64, + pub page_size: i64, +} + +/// 获取已安装扩展请求 +#[derive(Debug, Clone, Deserialize, Serialize)] +pub struct GetInstalledExtensionsRequest { + /// 范围过滤:all, user, team + #[serde(default = "default_scope")] + pub scope: String, +} + +fn default_scope() -> String { + "all".to_string() +} + +/// 已安装扩展响应 +#[derive(Debug, Clone, Serialize)] +pub struct InstalledExtensionsResponse { + pub user_extensions: Vec, + pub team_extensions: Vec, +} + +/// 已安装扩展项(包含完整扩展详情) +#[derive(Debug, Clone, Serialize)] +pub struct InstalledExtensionItem { + pub extension_id: String, + pub name: String, + pub version: String, + pub installed_version: String, + pub has_update: bool, + pub status: String, + pub installed_at: chrono::DateTime, + /// 扩展主页 URL + #[serde(skip_serializing_if = "Option::is_none")] + pub homepage: Option, + /// 扩展图标 URL + #[serde(skip_serializing_if = "Option::is_none")] + pub icon_url: Option, + /// 团队 UUID(如果是团队安装的) + #[serde(skip_serializing_if = "Option::is_none")] + pub team_uuid: Option, + /// 安装范围:user 或 team + pub scope: String, + /// 扩展描述 + #[serde(skip_serializing_if = "Option::is_none")] + pub description: Option, + /// 扩展分类 + #[serde(skip_serializing_if = "Option::is_none")] + pub category: Option, + /// 浏览器类型 + #[serde(skip_serializing_if = "Option::is_none")] + pub browser: Option, + /// 开发者/作者 + #[serde(skip_serializing_if = "Option::is_none")] + pub developer: Option, + /// 下载量 + #[serde(skip_serializing_if = "Option::is_none")] + pub downloads_count: Option, + /// 评分 + #[serde(skip_serializing_if = "Option::is_none")] + pub rating: Option, + /// 权限列表 + #[serde(skip_serializing_if = "Option::is_none")] + pub permissions: Option, + /// 文件大小 + #[serde(skip_serializing_if = "Option::is_none")] + pub file_size: Option, + /// 更新时间 + #[serde(skip_serializing_if = "Option::is_none")] + pub updated_at: Option>, + /// 关联的分组列表 + #[serde(skip_serializing_if = "Option::is_none")] + pub groups: Option>, +} + +/// 扩展关联的分组信息 +#[derive(Debug, Clone, Serialize)] +pub struct ExtensionGroup { + pub uuid: Uuid, + pub name: String, +} + +/// 批量更新响应 +#[derive(Debug, Clone, Serialize)] +pub struct BatchUpdateResponse { + pub updated: u64, +} diff --git a/src/entitys/gray_releases.rs b/src/entitys/gray_releases.rs new file mode 100644 index 0000000..3695ca3 --- /dev/null +++ b/src/entitys/gray_releases.rs @@ -0,0 +1,59 @@ +use chrono::DateTime; +use serde::{Deserialize, Serialize}; + +/// 创建灰度发布请求 +#[derive(Debug, Deserialize, Serialize)] +pub struct CreateGrayReleaseRequest { + pub name: String, + pub description: Option, + pub platform: String, + pub start_time: DateTime, + pub end_time: Option>, + pub max_machines: Option, + pub priority: Option, + pub strategy_type: Option, + pub strategy_config: Option, +} + +/// 更新灰度发布请求 +#[derive(Debug, Deserialize, Serialize)] +pub struct UpdateGrayReleaseRequest { + pub id: i32, + pub name: Option, + pub description: Option, + pub status: Option, + pub end_time: Option>, + pub max_machines: Option, + pub priority: Option, + pub strategy_type: Option, + pub strategy_config: Option, +} + +/// 灰度发布列表响应 +#[derive(Debug, Deserialize, Serialize)] +pub struct GrayReleaseListResponse { + pub total: i64, + pub list: Vec, +} + +/// 查询灰度发布参数 +#[derive(Debug, Deserialize, Serialize)] +pub struct QueryGrayReleaseParams { + pub platform: Option, + pub status: Option, +} + +/// 获取灰度发布参数 +#[derive(Debug, Deserialize, Serialize)] +pub struct GetGrayReleaseParams { + pub id: i32, +} + +/// 查询灰度发布列表参数(带分页) +#[derive(Debug, Deserialize, Serialize)] +pub struct QueryGrayReleasesParams { + pub platform: Option, + pub status: Option, + pub page_num: Option, + pub page_size: Option, +} diff --git a/src/entitys/gray_resources.rs b/src/entitys/gray_resources.rs new file mode 100644 index 0000000..212cf5e --- /dev/null +++ b/src/entitys/gray_resources.rs @@ -0,0 +1,34 @@ +use serde::{Deserialize, Serialize}; + +/// 创建灰度资源请求 +#[derive(Debug, Deserialize, Serialize)] +pub struct CreateGrayResourceRequest { + pub gray_release_id: i32, + pub version_id: i32, + pub sort_order: Option, +} + +/// 批量创建灰度资源请求 +#[derive(Debug, Deserialize, Serialize)] +pub struct BatchCreateGrayResourceRequest { + pub gray_release_id: i32, + pub version_ids: Vec, +} + +/// 灰度资源列表响应 +#[derive(Debug, Deserialize, Serialize)] +pub struct GrayResourceListResponse { + pub list: Vec, +} + +/// 获取灰度资源参数 +#[derive(Debug, Deserialize, Serialize)] +pub struct GetGrayResourceParams { + pub gray_release_id: i32, +} + +/// 移除灰度资源参数 +#[derive(Debug, Deserialize, Serialize)] +pub struct RemoveGrayResourceParams { + pub id: i32, +} diff --git a/src/entitys/group_member_permissions.rs b/src/entitys/group_member_permissions.rs new file mode 100644 index 0000000..2954e66 --- /dev/null +++ b/src/entitys/group_member_permissions.rs @@ -0,0 +1,54 @@ +use serde::{Deserialize, Serialize}; +use uuid::Uuid; + +use super::Pagination; + +/// 授予分组权限请求 +#[derive(Debug, Clone, Deserialize, Serialize)] +pub struct GrantGroupPermissionRequest { + pub group_uuid: Uuid, + pub user_uuid: Uuid, + pub permission_type: String, // 'read', 'write', 'manage' +} + +/// 撤销分组权限请求 +#[derive(Debug, Clone, Deserialize, Serialize)] +pub struct RevokeGroupPermissionRequest { + pub group_uuid: Uuid, + pub user_uuid: Uuid, +} + +/// 查询用户分组权限请求 +#[derive(Debug, Clone, Deserialize, Serialize)] +pub struct ListUserGroupPermissionsRequest { + #[serde(flatten)] + pub pagination: Pagination, + pub user_uuid: Uuid, + pub group_uuid: Option, +} + +/// 检查分组权限请求 +#[derive(Debug, Clone, Deserialize, Serialize)] +pub struct CheckGroupPermissionRequest { + pub group_uuid: Uuid, + pub user_uuid: Uuid, + pub permission_type: String, // 'read', 'write', 'manage' +} + +// ========== 响应结构体 ========== + +/// 分组权限列表响应 +#[derive(Debug, Clone, Serialize)] +pub struct GroupPermissionListResponse { + pub items: Vec, + pub total: i64, + pub page: i64, + pub page_size: i64, +} + +/// 检查权限响应 +#[derive(Debug, Clone, Serialize)] +pub struct CheckPermissionResponse { + pub has_permission: bool, + pub permission_type: Option, +} diff --git a/src/entitys/groups.rs b/src/entitys/groups.rs new file mode 100644 index 0000000..5856f3f --- /dev/null +++ b/src/entitys/groups.rs @@ -0,0 +1,36 @@ +use serde::{Deserialize, Serialize}; +use uuid::Uuid; + +use super::Pagination; + +/// 查询分组列表请求 +#[derive(Debug, Clone, Deserialize, Serialize)] +pub struct ListGroupsRequest { + #[serde(flatten)] + pub pagination: Pagination, + pub keyword: Option, +} + +/// 创建分组请求 +#[derive(Debug, Clone, Deserialize, Serialize)] +pub struct CreateGroupRequest { + pub name: String, + pub description: Option, +} + +/// 更新分组请求 +#[derive(Debug, Clone, Deserialize, Serialize)] +pub struct UpdateGroupRequest { + pub uuid: Uuid, + pub name: Option, + pub description: Option, + pub sort_order: Option, +} + +/// 分配分组到团队请求(已废弃,分组创建时即指定团队) +#[derive(Debug, Clone, Deserialize, Serialize)] +#[deprecated(note = "分组创建时即指定团队,不再需要分配")] +pub struct AssignGroupToTeamRequest { + pub uuid: Uuid, + pub team_uuid: Uuid, +} diff --git a/src/entitys/machine_gray_allocations.rs b/src/entitys/machine_gray_allocations.rs new file mode 100644 index 0000000..7c1f5d2 --- /dev/null +++ b/src/entitys/machine_gray_allocations.rs @@ -0,0 +1,64 @@ +use chrono::DateTime; +use serde::{Deserialize, Serialize}; + +/// 分配机器到灰度请求 +#[derive(Debug, Deserialize, Serialize)] +pub struct AllocateMachineRequest { + pub machine_code: String, + pub gray_release_id: i32, + pub effective_time: Option>, + pub notes: Option, +} + +/// 批量分配机器请求 +#[derive(Debug, Deserialize, Serialize)] +pub struct BatchAllocateMachineRequest { + pub machine_codes: Vec, + pub gray_release_id: i32, + pub effective_time: Option>, + pub notes: Option, +} + +/// 分配列表响应 +#[derive(Debug, Deserialize, Serialize)] +pub struct AllocationListResponse { + pub total: i64, + pub list: Vec, +} + +/// 移除分配请求 +#[derive(Debug, Deserialize, Serialize)] +pub struct RemoveAllocationRequest { + pub machine_code: String, + pub gray_release_id: i32, +} + +/// 查询分配参数 +#[derive(Debug, Deserialize, Serialize)] +pub struct GetAllocationParams { + pub machine_code: Option, + pub gray_release_id: Option, + pub page_num: Option, + pub page_size: Option, +} + +/// 查询机器是否在灰度中请求 +#[derive(Debug, Deserialize, Serialize)] +pub struct IsMachineInGrayRequest { + pub machine_code: String, + pub gray_release_id: i32, +} + +/// 查询机器所有分配请求 +#[derive(Debug, Deserialize, Serialize)] +pub struct GetMachineAllocationsRequest { + pub machine_code: String, +} + +/// 查询灰度发布所有分配请求 +#[derive(Debug, Deserialize, Serialize)] +pub struct GetReleaseAllocationsRequest { + pub gray_release_id: i32, + pub page_num: Option, + pub page_size: Option, +} diff --git a/src/entitys/machine_users.rs b/src/entitys/machine_users.rs new file mode 100644 index 0000000..1b031ea --- /dev/null +++ b/src/entitys/machine_users.rs @@ -0,0 +1,97 @@ +use serde::{Deserialize, Serialize}; + +use uuid::Uuid; + +/// 创建/绑定机器用户请求 +#[derive(Debug, Deserialize, Serialize)] +pub struct CreateMachineUserRequest { + pub machine_code: String, + #[serde(skip_serializing_if = "Option::is_none")] + pub user_uuid: Option, + pub platform: Option, + pub hardware_hash: Option, // 硬件哈希 + pub hardware_raw: Option, // 硬件原始信息 + pub version_info: Option, // JSON格式的版本信息 +} + +/// 更新机器用户请求 +#[derive(Debug, Deserialize, Serialize)] +pub struct UpdateMachineUserRequest { + pub id: i32, + #[serde(skip_serializing_if = "Option::is_none")] + pub user_uuid: Option, + pub platform: Option, + pub status: Option, + #[serde(skip_serializing_if = "Option::is_none")] + pub hardware_hash: Option, // 硬件哈希 + #[serde(skip_serializing_if = "Option::is_none")] + pub hardware_raw: Option, // 硬件原始信息 +} + +/// 机器用户列表响应 +#[derive(Debug, Deserialize, Serialize)] +pub struct MachineUserListResponse { + pub total: i64, + pub list: Vec, +} + +/// 查询机器用户参数 +#[derive(Debug, Deserialize, Serialize)] +pub struct QueryMachineUserParams { + pub user_uuid: Option, // UUID字符串格式 + pub platform: Option, + pub status: Option, +} + +/// 绑定机器请求 +#[derive(Debug, Deserialize, Serialize)] +pub struct BindMachineRequest { + pub machine_code: String, + pub user_uuid: Uuid, +} + +/// 更新机器版本信息请求 +#[derive(Debug, Deserialize, Serialize)] +pub struct UpdateMachineVersionRequest { + pub machine_code: String, + pub user_uuid: Uuid, + pub version_info: serde_json::Value, +} + +/// 拉黑/取消拉黑机器请求 +#[derive(Debug, Deserialize, Serialize)] +pub struct AllowOrBlacklistMachineRequest { + pub machine_code: String, + pub allow: bool, +} + +/// 获取机器参数 +#[derive(Debug, Deserialize, Serialize)] +pub struct GetMachineParams { + pub id: Option, + pub machine_code: Option, +} + +/// 查询机器列表参数(带分页) +#[derive(Debug, Deserialize, Serialize)] +pub struct QueryMachinesParams { + pub user_uuid: Option, + pub platform: Option, + pub status: Option, + pub page_num: Option, + pub page_size: Option, +} + +/// 绑定机器参数(用于查询字符串) +#[derive(Debug, Deserialize, Serialize)] +pub struct BindMachineParams { + pub machine_code: String, + pub user_uuid: String, +} + +/// 更新机器版本参数(用于查询字符串) +#[derive(Debug, Deserialize, Serialize)] +pub struct UpdateMachineVersionParams { + pub machine_code: String, + pub user_uuid: String, +} diff --git a/src/entitys/maintenance.rs b/src/entitys/maintenance.rs new file mode 100644 index 0000000..09761d5 --- /dev/null +++ b/src/entitys/maintenance.rs @@ -0,0 +1,48 @@ +use chrono::{DateTime, Utc}; +use serde::{Deserialize, Serialize}; + +use crate::dto::maintenance::MaintenanceType; + +/// 创建维护请求 +#[derive(Debug, Deserialize, Serialize)] +pub struct CreateMaintenanceRequest { + pub name: String, + pub description: Option, + pub status: String, + pub start_time: DateTime, + pub end_time: DateTime, + pub maintenance_type: MaintenanceType, +} + +/// 获取维护参数 +#[derive(Debug, Deserialize, Serialize)] +pub struct GetMaintenanceParams { + pub id: i64, +} + +/// 查询维护列表参数 +#[derive(Debug, Deserialize, Serialize)] +pub struct QueryMaintenancesParams { + pub limit: Option, + pub offset: Option, +} + +/// 更新维护状态请求 +#[derive(Debug, Deserialize, Serialize)] +pub struct UpdateMaintenanceStatusRequest { + pub id: i64, + pub status: String, +} + +/// 维护列表响应 +#[derive(Debug, Deserialize, Serialize)] +pub struct MaintenanceListResponse { + pub list: Vec, +} + +/// 健康检查响应 +#[derive(Debug, Deserialize, Serialize)] +pub struct HealthResponse { + pub status: String, + pub timestamp: String, +} diff --git a/src/entitys/messages.rs b/src/entitys/messages.rs new file mode 100644 index 0000000..6d9832a --- /dev/null +++ b/src/entitys/messages.rs @@ -0,0 +1,71 @@ +use serde::{Deserialize, Serialize}; +use uuid::Uuid; + +use super::Pagination; + +/// 创建消息请求 +#[derive(Debug, Clone, Deserialize, Serialize)] +pub struct CreateMessageRequest { + pub message_type: String, // team_invitation, system_notification, etc. + pub title: String, + pub content: Option, // JSON 格式的扩展数据 + pub recipient_uuids: Vec, // 接收者列表(recipient_type='single' 或 'multiple' 时使用) + pub recipient_type: String, // single, multiple, team, all + pub related_type: Option, + pub related_uuid: Option, + pub priority: Option, // low, normal, high, urgent + pub metadata: Option, // JSON 格式的扩展信息 +} + +/// 查询消息列表请求 +#[derive(Debug, Clone, Deserialize, Serialize)] +pub struct ListMessagesRequest { + #[serde(flatten)] + pub pagination: Pagination, + pub filters: Option, +} + +/// 消息筛选条件 +#[derive(Debug, Clone, Deserialize, Serialize)] +pub struct MessageFilters { + pub message_type: Option, + pub is_read: Option, + pub action_status: Option, + pub priority: Option, +} + +/// 标记消息为已读请求 +#[derive(Debug, Clone, Deserialize, Serialize)] +pub struct MarkMessageReadRequest { + pub message_uuid: Uuid, +} + +/// 批量标记已读请求 +#[derive(Debug, Clone, Deserialize, Serialize)] +pub struct BatchMarkReadRequest { + pub message_uuids: Vec, +} + +/// 处理消息请求(用于邀请类消息) +#[derive(Debug, Clone, Deserialize, Serialize)] +pub struct HandleMessageRequest { + pub message_uuid: Uuid, + pub action: String, // accept, reject +} + +/// 消息列表响应 +#[derive(Debug, Clone, Serialize)] +pub struct MessageListResponse { + pub items: Vec, + pub total: i64, + pub page: i64, + pub page_size: i64, +} + +/// 消息统计响应 +#[derive(Debug, Clone, Serialize)] +pub struct MessageStatsResponse { + pub total: i64, + pub unread: i64, + pub by_type: std::collections::HashMap, +} diff --git a/src/entitys/mod.rs b/src/entitys/mod.rs new file mode 100644 index 0000000..7487994 --- /dev/null +++ b/src/entitys/mod.rs @@ -0,0 +1,67 @@ +pub mod gray_releases; +pub mod gray_resources; +pub mod machine_gray_allocations; +pub mod machine_users; +pub mod maintenance; +pub mod strategy_types; +pub mod user; +pub mod version_types; +pub mod versions; + +// 新增模块 +pub mod accounts; +pub mod api_service; +pub mod audit; +pub mod billing; +pub mod common; +pub mod console_admin; +pub mod console_apikey; +pub mod environments; +pub mod extensions; +pub mod group_member_permissions; +pub mod groups; +pub mod messages; +pub mod proxies; +pub mod proxy_visible_teams; +pub mod referral; +pub mod rpa; +pub mod settings; +pub mod tags; +pub mod teams; +pub mod templates; +pub mod workspace_quotas; +pub mod workspaces; + +pub use gray_releases::*; +pub use gray_resources::*; +pub use machine_gray_allocations::*; +pub use machine_users::*; +pub use maintenance::*; +pub use strategy_types::*; +pub use user::*; +pub use version_types::*; +pub use versions::*; + +// 新增导出 +pub use accounts::*; +pub use api_service::*; +pub use audit::*; +pub use billing::*; +pub use common::*; +pub use console_admin::*; +pub use console_apikey::*; +pub use environments::*; +pub use extensions::*; +pub use group_member_permissions::*; +pub use groups::*; +pub use messages::*; +pub use proxies::*; +pub use proxy_visible_teams::*; +pub use referral::*; +pub use rpa::*; +pub use settings::*; +pub use tags::*; +pub use teams::*; +pub use templates::*; +pub use workspace_quotas::*; +pub use workspaces::*; diff --git a/src/entitys/proxies.rs b/src/entitys/proxies.rs new file mode 100644 index 0000000..aa3a221 --- /dev/null +++ b/src/entitys/proxies.rs @@ -0,0 +1,74 @@ +use serde::{Deserialize, Serialize}; +use uuid::Uuid; + +use super::Pagination; + +/// 查询代理列表请求 +#[derive(Debug, Clone, Deserialize, Serialize)] +pub struct ListProxiesRequest { + #[serde(flatten)] + pub pagination: Pagination, + pub filters: Option, +} + +/// 代理筛选条件 +#[derive(Debug, Clone, Deserialize, Serialize)] +pub struct ProxyFilters { + pub keyword: Option, + pub proxy_type: Option, + pub status: Option, + pub country: Option, +} + +/// 创建代理请求 +#[derive(Debug, Clone, Deserialize, Serialize)] +pub struct CreateProxyRequest { + pub name: String, + pub host: String, + pub port: i32, + pub proxy_type: String, + pub username: Option, + pub password: Option, + pub ssh_key: Option, + pub ssh_passphrase: Option, +} + +/// 更新代理请求 +#[derive(Debug, Clone, Deserialize, Serialize)] +pub struct UpdateProxyRequest { + pub uuid: Uuid, + pub name: Option, + pub host: Option, + pub port: Option, + pub proxy_type: Option, + pub username: Option, + pub password: Option, +} + +/// 批量导入代理项(客户端已解析好的结构化数据) +#[derive(Debug, Clone, Deserialize, Serialize)] +pub struct BatchImportProxyItem { + pub name: String, + pub host: String, + pub port: i32, + pub proxy_type: String, + pub username: Option, + pub password: Option, +} + +/// 批量导入代理请求 +#[derive(Debug, Clone, Deserialize, Serialize)] +pub struct BatchImportProxiesRequest { + pub proxies: Vec, +} + +// ========== 响应结构体 ========== + +/// 代理列表响应 +#[derive(Debug, Clone, Serialize)] +pub struct ProxyListResponse { + pub items: Vec, + pub total: i64, + pub page: i64, + pub page_size: i64, +} diff --git a/src/entitys/proxy_visible_teams.rs b/src/entitys/proxy_visible_teams.rs new file mode 100644 index 0000000..687e4b5 --- /dev/null +++ b/src/entitys/proxy_visible_teams.rs @@ -0,0 +1,50 @@ +use serde::{Deserialize, Serialize}; +use uuid::Uuid; + +/// 设置代理可见性请求 +#[derive(Debug, Clone, Deserialize, Serialize)] +pub struct SetProxyVisibleRequest { + pub proxy_uuid: Uuid, + pub team_uuid: Uuid, +} + +/// 移除代理可见性请求 +#[derive(Debug, Clone, Deserialize, Serialize)] +pub struct RemoveProxyVisibleRequest { + pub proxy_uuid: Uuid, + pub team_uuid: Uuid, +} + +/// 批量设置代理可见性请求 +#[derive(Debug, Clone, Deserialize, Serialize)] +pub struct BatchSetProxyVisibleRequest { + pub proxy_uuid: Uuid, + pub team_uuids: Vec, +} + +/// 查询代理可见团队请求 +#[derive(Debug, Clone, Deserialize, Serialize)] +pub struct ListProxyVisibleTeamsRequest { + pub proxy_uuid: Uuid, +} + +/// 查询可见代理请求 +#[derive(Debug, Clone, Deserialize, Serialize)] +pub struct ListVisibleProxiesRequest { + pub workspace_uuid: Uuid, + pub team_uuid: Option, +} + +// ========== 响应结构体 ========== + +/// 代理可见团队列表响应 +#[derive(Debug, Clone, Serialize)] +pub struct ProxyVisibleTeamListResponse { + pub items: Vec, +} + +/// 可见代理列表响应 +#[derive(Debug, Clone, Serialize)] +pub struct VisibleProxyListResponse { + pub items: Vec, +} diff --git a/src/entitys/referral.rs b/src/entitys/referral.rs new file mode 100644 index 0000000..07874e6 --- /dev/null +++ b/src/entitys/referral.rs @@ -0,0 +1,139 @@ +use serde::{Deserialize, Serialize}; +use uuid::Uuid; + +use super::Pagination; + +/// 切换推广链接请求 +#[derive(Debug, Clone, Deserialize, Serialize)] +pub struct SwitchReferralLinkRequest { + pub link_uuid: Uuid, +} + +/// 查询奖励记录请求 +#[derive(Debug, Clone, Deserialize, Serialize)] +pub struct ListReferralRewardsRequest { + #[serde(flatten)] + pub pagination: Pagination, + pub reward_type: Option, + pub status: Option, +} + +/// 查询被邀请用户请求 +#[derive(Debug, Clone, Deserialize, Serialize)] +pub struct ListReferredUsersRequest { + #[serde(flatten)] + pub pagination: Pagination, + pub status: Option, +} + +/// 兑换积分请求 +#[derive(Debug, Clone, Deserialize, Serialize)] +pub struct RedeemPointsRequest { + pub option_uuid: Uuid, + pub points: i32, +} + +// ========== 响应结构体 ========== + +use crate::dto::{ + RedeemOptionDto, RedeemRecordDto, ReferralLinkDto, ReferralLinkTierDto, ReferralRewardDto, + ReferredUserItemDto, UserReferralPointsDto, +}; +use rust_decimal::Decimal; + +/// 推广统计响应 +#[derive(Debug, Clone, Serialize)] +pub struct ReferralStatsResponse { + pub total_referrals: i32, + pub paid_referrals: i32, + pub total_consumption: Decimal, + pub last_30_days_consumption: Decimal, + pub total_rewards: i32, + pub available_points: i32, + pub current_tier: Option, + pub next_tier: Option, + pub upgrade_progress: i32, +} + +/// 推广积分摘要(用于看板聚合) +#[derive(Debug, Clone, Serialize)] +pub struct ReferralPointsSummary { + pub available_points: i32, + pub pending_points: i32, + pub total_rewards: i32, +} + +/// 推广看板聚合响应 +#[derive(Debug, Clone, Serialize)] +pub struct ReferralDashboardResponse { + pub stats: ReferralStatsResponse, + pub links: Vec, + pub current_link: Option, + pub tiers: Vec, + pub points: ReferralPointsSummary, +} + +/// 套餐页推广摘要响应 +#[derive(Debug, Clone, Serialize)] +pub struct ReferralPlanSummaryResponse { + /// 最近 30 天推广产生的消费金额对应的预估收益(按照当前层级 reward_rate 估算) + pub referral_value_last_30_days: Decimal, + /// 当前订阅套餐的月度价格(如果有订阅) + pub current_plan_monthly_price: Option, + /// 推广收益覆盖当前套餐费用的比例(0-1) + pub coverage_ratio: Option, +} + +/// 推广链接列表响应 +#[derive(Debug, Clone, Serialize)] +pub struct ReferralLinksListResponse { + pub items: Vec, + pub current_link: Option, +} + +/// 奖励记录列表响应 +#[derive(Debug, Clone, Serialize)] +pub struct ReferralRewardsListResponse { + pub items: Vec, + pub total: i64, + pub page: i64, + pub page_size: i64, +} + +/// 被邀请用户列表响应 +#[derive(Debug, Clone, Serialize)] +pub struct ReferredUsersListResponse { + pub items: Vec, + pub total: i64, + pub page: i64, + pub page_size: i64, +} + +/// 兑换选项列表响应 +#[derive(Debug, Clone, Serialize)] +pub struct RedeemOptionsListResponse { + pub items: Vec, +} + +/// 兑换响应 +#[derive(Debug, Clone, Serialize)] +pub struct RedeemResponse { + pub record_uuid: Uuid, + pub points_used: i32, + pub value: Decimal, +} + +/// 用户积分响应 +#[derive(Debug, Clone, Serialize)] +pub struct UserPointsResponse { + pub points: UserReferralPointsDto, +} + +/// 兑换记录列表响应 +#[derive(Debug, Clone, Serialize)] +pub struct RedeemRecordsListResponse { + pub items: Vec, + pub total: i64, + pub page: i64, + pub page_size: i64, +} diff --git a/src/entitys/rpa.rs b/src/entitys/rpa.rs new file mode 100644 index 0000000..ebc076e --- /dev/null +++ b/src/entitys/rpa.rs @@ -0,0 +1,149 @@ +use serde::{Deserialize, Serialize}; +use uuid::Uuid; + +use super::Pagination; + +/// 查询 RPA 任务列表请求 +#[derive(Debug, Clone, Deserialize, Serialize)] +pub struct ListRpaTasksRequest { + #[serde(flatten)] + pub pagination: Pagination, + pub filters: Option, +} + +/// RPA 任务筛选条件 +#[derive(Debug, Clone, Deserialize, Serialize)] +pub struct RpaTaskFilters { + pub keyword: Option, + pub status: Option, + pub trigger_type: Option, + pub tags: Option>, +} + +/// 创建 RPA 任务请求 +#[derive(Debug, Clone, Deserialize, Serialize)] +pub struct CreateRpaTaskRequest { + pub name: String, + pub description: Option, + pub tags: Option>, + pub trigger_type: String, + pub schedule: Option, + pub cron_expression: Option, + pub run_mode: String, + pub retry_count: Option, + pub retry_interval: Option, + pub timeout: Option, + pub concurrency: Option, + pub stop_on_error: Option, + pub notify_on_complete: Option, + pub notify_on_error: Option, + pub environment_uuids: Option>, + pub steps: Option>, +} + +/// RPA 任务步骤请求 +#[derive(Debug, Clone, Deserialize, Serialize)] +pub struct RpaTaskStepRequest { + pub step_type: String, + pub name: String, + pub config: serde_json::Value, + pub enabled: Option, + pub position_x: Option, + pub position_y: Option, + pub sort_order: Option, +} + +/// 更新 RPA 任务请求 +#[derive(Debug, Clone, Deserialize, Serialize)] +pub struct UpdateRpaTaskRequest { + pub uuid: Uuid, + pub name: Option, + pub description: Option, + pub tags: Option>, + pub trigger_type: Option, + pub schedule: Option, + pub cron_expression: Option, + pub run_mode: Option, + pub retry_count: Option, + pub retry_interval: Option, + pub timeout: Option, + pub concurrency: Option, + pub stop_on_error: Option, + pub notify_on_complete: Option, + pub notify_on_error: Option, + pub environment_uuids: Option>, + pub steps: Option>, +} + +/// 运行 RPA 任务请求 +#[derive(Debug, Clone, Deserialize, Serialize)] +pub struct RunRpaTaskRequest { + pub uuid: Uuid, + pub environment_uuids: Option>, +} + +/// 复制 RPA 任务请求 +#[derive(Debug, Clone, Deserialize, Serialize)] +pub struct DuplicateRpaTaskRequest { + pub uuid: Uuid, + pub new_name: Option, +} + +/// 导出 RPA 任务请求 +#[derive(Debug, Clone, Deserialize, Serialize)] +pub struct ExportRpaTaskRequest { + pub uuid: Uuid, +} + +/// 导入 RPA 任务请求 +#[derive(Debug, Clone, Deserialize, Serialize)] +pub struct ImportRpaTaskRequest { + pub import_data: String, + pub name: Option, +} + +/// 查询执行记录请求 +#[derive(Debug, Clone, Deserialize, Serialize)] +pub struct ListRpaRunsRequest { + pub task_uuid: Uuid, + #[serde(flatten)] + pub pagination: Pagination, + pub status: Option, +} + +// ========== 响应结构体 ========== + +use crate::dto::{RpaTaskDto, RpaTaskRunDto, RpaTaskStepDto}; + +/// RPA 任务列表响应 +#[derive(Debug, Clone, Serialize)] +pub struct RpaTaskListResponse { + pub items: Vec, + pub total: i64, + pub page: i64, + pub page_size: i64, +} + +/// RPA 任务详情响应 +#[derive(Debug, Clone, Serialize)] +pub struct RpaTaskDetailResponse { + pub task: RpaTaskDto, + pub steps: Vec, + pub environment_uuids: Vec, +} + +/// RPA 任务执行记录列表响应 +#[derive(Debug, Clone, Serialize)] +pub struct RpaRunsListResponse { + pub items: Vec, + pub total: i64, + pub page: i64, + pub page_size: i64, +} + +/// 导出 RPA 任务响应 +#[derive(Debug, Clone, Serialize)] +pub struct ExportRpaTaskResponse { + pub content: String, + pub filename: String, +} diff --git a/src/entitys/settings.rs b/src/entitys/settings.rs new file mode 100644 index 0000000..7786cc1 --- /dev/null +++ b/src/entitys/settings.rs @@ -0,0 +1,30 @@ +use serde::{Deserialize, Serialize}; + +/// 更新用户偏好请求 +#[derive(Debug, Clone, Deserialize, Serialize)] +pub struct UpdatePreferencesRequest { + pub theme: Option, + pub language: Option, + pub notifications_enabled: Option, +} + +/// 更新用户信息请求 +#[derive(Debug, Clone, Deserialize, Serialize)] +pub struct UpdateProfileRequest { + pub nickname: Option, + pub avatar_hash: Option, +} + +/// 修改密码请求 +#[derive(Debug, Clone, Deserialize, Serialize)] +pub struct ChangePasswordRequest { + pub old_password: String, + pub new_password: String, +} + +/// 查询登录历史请求 +#[derive(Debug, Clone, Deserialize, Serialize)] +pub struct ListLoginHistoryRequest { + pub page: i32, + pub page_size: i32, +} diff --git a/src/entitys/strategy_types.rs b/src/entitys/strategy_types.rs new file mode 100644 index 0000000..77be9f0 --- /dev/null +++ b/src/entitys/strategy_types.rs @@ -0,0 +1,20 @@ +use serde::{Deserialize, Serialize}; + +/// 获取策略类型参数 +#[derive(Debug, Deserialize, Serialize)] +pub struct GetStrategyTypeParams { + pub id: Option, + pub code: Option, +} + +/// 查询策略类型列表参数 +#[derive(Debug, Deserialize, Serialize)] +pub struct QueryStrategyTypesParams { + pub category: Option, +} + +/// 策略类型列表响应 +#[derive(Debug, Deserialize, Serialize)] +pub struct StrategyTypeListResponse { + pub list: Vec, +} diff --git a/src/entitys/tags.rs b/src/entitys/tags.rs new file mode 100644 index 0000000..49e6d29 --- /dev/null +++ b/src/entitys/tags.rs @@ -0,0 +1,28 @@ +use serde::{Deserialize, Serialize}; +use uuid::Uuid; + +use super::Pagination; + +/// 查询标签列表请求 +#[derive(Debug, Clone, Deserialize, Serialize)] +pub struct ListTagsRequest { + #[serde(flatten)] + pub pagination: Pagination, + pub keyword: Option, +} + +/// 创建标签请求 +#[derive(Debug, Clone, Deserialize, Serialize)] +pub struct CreateTagRequest { + pub name: String, + pub color: Option, +} + +/// 更新标签请求 +#[derive(Debug, Clone, Deserialize, Serialize)] +pub struct UpdateTagRequest { + pub uuid: Uuid, + pub name: Option, + pub color: Option, + pub sort_order: Option, +} diff --git a/src/entitys/teams.rs b/src/entitys/teams.rs new file mode 100644 index 0000000..d28ced2 --- /dev/null +++ b/src/entitys/teams.rs @@ -0,0 +1,130 @@ +use serde::{Deserialize, Serialize}; +use uuid::Uuid; + +use super::Pagination; + +/// 创建团队请求 +#[derive(Debug, Clone, Deserialize, Serialize)] +pub struct CreateTeamRequest { + pub workspace_uuid: Uuid, + pub name: String, + pub description: Option, +} + +/// 更新团队请求 +#[derive(Debug, Clone, Deserialize, Serialize)] +pub struct UpdateTeamRequest { + pub uuid: Uuid, + pub name: Option, + pub description: Option, + pub avatar_hash: Option, +} + +/// 切换团队请求 +#[derive(Debug, Clone, Deserialize, Serialize)] +pub struct SwitchTeamRequest { + pub team_uuid: Uuid, +} + +/// 查询团队成员请求 +#[derive(Debug, Clone, Deserialize, Serialize)] +pub struct ListTeamMembersRequest { + pub workspace_uuid: Uuid, + #[serde(flatten)] + pub pagination: Pagination, + pub filters: Option, +} + +/// 团队成员筛选条件 +#[derive(Debug, Clone, Deserialize, Serialize)] +pub struct TeamMemberFilters { + pub keyword: Option, + pub role: Option, + pub status: Option, +} + +/// 邀请成员请求 +#[derive(Debug, Clone, Deserialize, Serialize)] +pub struct InviteMemberRequest { + pub email: String, + pub role: String, +} + +/// 取消邀请请求 +#[derive(Debug, Clone, Deserialize, Serialize)] +pub struct CancelInviteRequest { + pub invitation_uuid: Uuid, +} + +/// 更新成员角色请求 +#[derive(Debug, Clone, Deserialize, Serialize)] +pub struct UpdateMemberRoleRequest { + pub member_uuid: Uuid, + pub role: String, +} + +/// 更新成员状态请求 +#[derive(Debug, Clone, Deserialize, Serialize)] +pub struct UpdateMemberStatusRequest { + pub member_uuid: Uuid, + pub status: String, +} + +/// 移除成员请求 +#[derive(Debug, Clone, Deserialize, Serialize)] +pub struct RemoveMemberRequest { + pub member_uuid: Uuid, +} + +/// 批量移除成员请求 +#[derive(Debug, Clone, Deserialize, Serialize)] +pub struct BatchRemoveMembersRequest { + pub member_uuids: Vec, +} + +/// 接受邀请请求 +#[derive(Debug, Clone, Deserialize, Serialize)] +pub struct AcceptInvitationRequest { + pub token: String, +} + +/// 拒绝邀请请求 +#[derive(Debug, Clone, Deserialize, Serialize)] +pub struct RejectInvitationRequest { + pub token: String, +} + +// ========== 响应结构体 ========== + +/// 团队列表响应 +#[derive(Debug, Clone, Serialize)] +pub struct TeamListResponse { + pub current_team_uuid: Option, + pub teams: Vec, +} + +/// 团队列表项 +#[derive(Debug, Clone, Serialize)] +pub struct TeamItem { + pub uuid: Uuid, + pub name: String, + pub description: Option, + pub role: String, + pub members_count: i64, + pub is_current: bool, +} + +/// 团队成员列表响应 +#[derive(Debug, Clone, Serialize)] +pub struct MemberListResponse { + pub items: Vec, + pub total: i64, + pub page: i64, + pub page_size: i64, +} + +/// 接受邀请响应 +#[derive(Debug, Clone, Serialize)] +pub struct AcceptInvitationResponse { + pub team_uuid: Uuid, +} diff --git a/src/entitys/templates.rs b/src/entitys/templates.rs new file mode 100644 index 0000000..66cdf32 --- /dev/null +++ b/src/entitys/templates.rs @@ -0,0 +1,94 @@ +use serde::{Deserialize, Serialize}; +use uuid::Uuid; + +use super::Pagination; + +/// 查询模板列表请求 +#[derive(Debug, Clone, Deserialize, Serialize)] +pub struct ListTemplatesRequest { + #[serde(flatten)] + pub pagination: Pagination, + pub keyword: Option, + pub is_public: Option, +} + +/// 创建模板请求 +#[derive(Debug, Clone, Deserialize, Serialize)] +pub struct CreateTemplateRequest { + pub name: String, + pub description: Option, + pub is_public: Option, + /// 完整的环境详情数据(EnvironmentDetailResponse 的 JSON 格式) + /// 如果提供了 environment_uuid,则此字段会被忽略,后端会自动获取环境详情 + pub environment_data: Option, + /// 环境 UUID(如果提供,后端会自动获取该环境的完整详情数据) + pub environment_uuid: Option, +} + +/// 更新模板请求 +#[derive(Debug, Clone, Deserialize, Serialize)] +pub struct UpdateTemplateRequest { + pub uuid: Uuid, + pub name: Option, + pub description: Option, + pub is_public: Option, + pub config_json: Option, +} + +/// 应用模板请求(更新现有环境) +#[derive(Debug, Clone, Deserialize, Serialize)] +pub struct ApplyTemplateRequest { + pub template_uuid: Uuid, + pub environment_uuid: Uuid, +} + +/// 从模板创建环境请求 +#[derive(Debug, Clone, Deserialize, Serialize)] +pub struct CreateFromTemplateRequest { + pub template_uuid: Uuid, + pub name: Option, // 如果提供,将覆盖模板中的名称 + pub description: Option, // 如果提供,将覆盖模板中的描述 + pub group_uuid: Option, // 如果提供,将覆盖模板中的分组 +} + +/// 获取模板详情请求 +#[derive(Debug, Clone, Deserialize, Serialize)] +pub struct GetTemplateRequest { + pub uuid: Uuid, + /// 是否用于创建环境(如果为 true,将检查关联数据是否存在) + pub for_create: Option, +} + +// ========== 响应结构体 ========== + +/// 模板列表响应 +#[derive(Debug, Clone, Serialize)] +pub struct TemplateListResponse { + pub items: Vec, + pub total: i64, + pub page: i64, + pub page_size: i64, +} + +/// 关联数据状态 +#[derive(Debug, Clone, Serialize, Deserialize)] +pub struct AssociationsStatus { + /// 分组是否存在 + pub group_exists: bool, + /// 标签是否存在(按 UUID 映射) + pub tags_exist: std::collections::HashMap, + /// 账号是否存在(按 UUID 映射) + pub accounts_exist: std::collections::HashMap, + /// 代理是否存在 + pub proxy_exists: bool, +} + +/// 模板详情响应(包含关联数据状态) +#[derive(Debug, Clone, Serialize)] +pub struct TemplateDetailResponse { + /// 模板数据 + #[serde(flatten)] + pub template: crate::dto::TemplateDto, + /// 关联数据状态(仅在 for_create=true 时返回) + pub associations_status: Option, +} diff --git a/src/entitys/user.rs b/src/entitys/user.rs new file mode 100644 index 0000000..2239085 --- /dev/null +++ b/src/entitys/user.rs @@ -0,0 +1,135 @@ +use chrono::{DateTime, Utc}; +use serde::{Deserialize, Serialize}; +use uuid::Uuid; + +/// 机器信息(用于注册和登录) +#[derive(Debug, Deserialize, Serialize, Default, Clone)] +pub struct MachineInfo { + pub machine_code: String, + #[serde(skip_serializing_if = "Option::is_none")] + pub platform: Option, + #[serde(skip_serializing_if = "Option::is_none")] + pub hardware_hash: Option, // 硬件哈希 + #[serde(skip_serializing_if = "Option::is_none")] + pub hardware_raw: Option, // 硬件原始信息 + #[serde(skip_serializing_if = "Option::is_none")] + pub version_info: Option, // JSON格式的版本信息 +} + +/// 注册请求 +#[derive(Debug, Deserialize, Serialize, Default)] +pub struct RegisterRequest { + pub email: String, + pub password: String, + pub code: String, + #[serde(skip_serializing_if = "Option::is_none")] + pub nickname: Option, // 昵称(可选) + #[serde(skip_serializing_if = "Option::is_none")] + pub public_secret_key: Option, // 用户公钥(可选) + #[serde(skip_serializing_if = "Option::is_none")] + pub machine_info: Option, // 机器信息(可选) + #[serde(skip_serializing_if = "Option::is_none")] + pub referral_code: Option, // 推荐码(可选) +} + +/// 基本登录请求(邮箱 + 密码) +#[derive(Debug, Deserialize, Serialize)] +pub struct BasicLoginData { + pub email: String, + pub password: String, + #[serde(skip_serializing_if = "Option::is_none")] + pub machine_info: Option, + #[serde(skip_serializing_if = "Option::is_none")] + pub public_secret_key: Option, +} + +/// 记住密码登录请求(邮箱 + refresh_token) +#[derive(Debug, Deserialize, Serialize)] +pub struct RememberLoginData { + pub email: String, + pub refresh_token: String, + #[serde(skip_serializing_if = "Option::is_none")] + pub machine_info: Option, + #[serde(skip_serializing_if = "Option::is_none")] + pub public_secret_key: Option, +} + +/// 登录请求(统一结构,通过枚举区分两种登录方式) +/// +/// 使用 serde 的 tag 特性,根据 "login_type" 字段自动反序列化为对应的变体 +#[derive(Debug, Deserialize, Serialize)] +#[serde(tag = "login_type", rename_all = "snake_case")] +pub enum LoginRequest { + /// 基本登录(邮箱 + 密码) + Basic(BasicLoginData), + /// 记住密码登录(邮箱 + refresh_token) + Remember(RememberLoginData), +} + +/// 刷新 Token 请求 +#[derive(Debug, Deserialize, Serialize, Default)] +pub struct RefreshTokenRequest { + pub refresh_token: String, +} + +/// 更新用户信息请求 +#[derive(Debug, Deserialize, Serialize, Default)] +pub struct UpdateUserRequest { + pub nickname: Option, +} + +/// 修改密码请求 +#[derive(Debug, Deserialize, Serialize, Default)] +pub struct UpdatePasswordRequest { + pub old_password: String, + pub new_password: String, +} + +/// 重置密码请求 +#[derive(Debug, Deserialize, Serialize, Default)] +pub struct ResetPasswordRequest { + pub email: String, + pub code: String, + pub new_password: String, +} + +/// 发送验证码请求 +#[derive(Debug, Deserialize, Serialize, Default)] +pub struct SendCodeRequest { + pub email: String, + pub r#type: String, // register 或 reset_password +} + +/// 用户信息响应 +#[derive(Debug, Serialize)] +pub struct UserResponse { + pub uuid: Uuid, + pub id: String, + pub nickname: Option, + pub email: String, + pub phone: Option, + pub avatar_hash: Option, + pub status: String, + pub created_at: DateTime, + pub updated_at: DateTime, + /// 当前团队详细信息(如果存在) + pub current_team: Option, + /// 当前工作空间详细信息(如果存在) + pub current_workspace: Option, +} + +/// 登录响应 +#[derive(Debug, Serialize)] +pub struct LoginResponse { + pub access_token: String, + pub refresh_token: String, + pub user_info: Option, +} + +/// 注册响应 +#[derive(Debug, Serialize)] +pub struct RegisterResponse { + pub access_token: String, + pub refresh_token: String, + pub user_info: Option, +} diff --git a/src/entitys/version_types.rs b/src/entitys/version_types.rs new file mode 100644 index 0000000..3e163e4 --- /dev/null +++ b/src/entitys/version_types.rs @@ -0,0 +1,38 @@ +use serde::{Deserialize, Serialize}; + +/// 创建版本类型请求 +#[derive(Debug, Deserialize, Serialize)] +pub struct CreateVersionTypeRequest { + pub type_code: String, + pub type_name: String, + pub description: Option, + pub sort_order: Option, +} + +/// 更新版本类型请求 +#[derive(Debug, Deserialize, Serialize)] +pub struct UpdateVersionTypeRequest { + pub id: i32, + pub type_name: Option, + pub description: Option, + pub sort_order: Option, + pub is_active: Option, +} + +/// 版本类型列表响应 +#[derive(Debug, Deserialize, Serialize)] +pub struct VersionTypeListResponse { + pub list: Vec, +} + +/// 获取版本类型参数 +#[derive(Debug, Deserialize, Serialize)] +pub struct GetVersionTypeParams { + pub id: i32, +} + +/// 删除版本类型请求 +#[derive(Debug, Deserialize, Serialize)] +pub struct DeleteVersionTypeRequest { + pub id: i32, +} diff --git a/src/entitys/versions.rs b/src/entitys/versions.rs new file mode 100644 index 0000000..24241ad --- /dev/null +++ b/src/entitys/versions.rs @@ -0,0 +1,97 @@ +use chrono::DateTime; +use serde::{Deserialize, Serialize}; +use std::collections::HashMap; + +/// 创建版本请求 +#[derive(Debug, Deserialize, Serialize)] +pub struct CreateVersionRequest { + pub type_id: i32, + pub resource_name: String, + pub version: String, + pub name: Option, + pub notes: Option, + pub platform: Option, + pub url: Option, + pub hash: Option, + pub signature: Option, + pub install_path: Option, + pub file_size: Option, + pub pub_date: Option>, +} + +/// 更新版本请求 +#[derive(Debug, Deserialize, Serialize)] +pub struct UpdateVersionRequest { + pub id: i32, + pub name: Option, + pub notes: Option, + pub platform: Option, + pub url: Option, + pub hash: Option, + pub signature: Option, + pub install_path: Option, + pub file_size: Option, + pub status: Option, + pub is_latest: Option, +} + +/// 版本列表响应 +#[derive(Debug, Deserialize, Serialize)] +pub struct VersionListResponse { + pub total: i64, + pub list: Vec, +} + +/// 查询版本参数 +#[derive(Debug, Deserialize, Serialize)] +pub struct QueryVersionParams { + pub resource_name: Option, + pub platform: Option, + pub status: Option, +} + +/// 获取版本参数(用于查询字符串) +#[derive(Debug, Deserialize, Serialize)] +pub struct GetVersionParams { + pub id: Option, + pub resource_name: Option, + pub version: Option, + pub platform: Option, +} + +/// 查询版本列表参数(带分页) +#[derive(Debug, Deserialize, Serialize)] +pub struct QueryVersionsParams { + pub resource_name: Option, + pub platform: Option, + pub status: Option, + pub page_num: Option, + pub page_size: Option, +} + +/// 设置最新版本请求 +#[derive(Debug, Deserialize, Serialize)] +pub struct SetLatestVersionRequest { + pub type_id: i32, + pub resource_name: String, + pub version_id: i32, +} + +/// 删除版本请求 +#[derive(Debug, Deserialize, Serialize)] +pub struct DeleteVersionRequest { + pub id: i32, +} + +/// 版本检查请求参数 +#[derive(Debug, Deserialize, Serialize)] +pub struct CheckVersionRequestParam { + pub machine_code: Option, // 机器码可选,用于灰度判定 +} + +/// 版本检查响应 +#[derive(Debug, Deserialize, Serialize, Default)] +pub struct CheckVersionResponse { + pub is_gray: bool, + pub versions: HashMap>, // key: type_code, value: 版本列表 +} diff --git a/src/entitys/workspace_quotas.rs b/src/entitys/workspace_quotas.rs new file mode 100644 index 0000000..5bc6606 --- /dev/null +++ b/src/entitys/workspace_quotas.rs @@ -0,0 +1,33 @@ +use serde::{Deserialize, Serialize}; +use uuid::Uuid; + +/// 获取工作空间配额请求 +#[derive(Debug, Clone, Deserialize, Serialize)] +pub struct GetWorkspaceQuotaRequest { + pub workspace_uuid: Uuid, +} + +/// 更新配额使用情况请求 +#[derive(Debug, Clone, Deserialize, Serialize)] +pub struct UpdateQuotaUsageRequest { + pub workspace_uuid: Uuid, + pub quota_type: String, // 'environments', 'proxies', 'team_members', 'rpa_tasks' + pub increment: bool, // true 为增加,false 为减少 + pub amount: i32, // 增加或减少的数量 +} + +// ========== 响应结构体 ========== + +/// 工作空间配额响应 +#[derive(Debug, Clone, Serialize)] +pub struct WorkspaceQuotaResponse { + pub workspace_uuid: Uuid, + pub max_environments: i32, + pub used_environments: i32, + pub max_team_members: i32, + pub used_team_members: i32, + pub max_proxies: i32, + pub used_proxies: i32, + pub max_rpa_tasks: i32, + pub used_rpa_tasks: i32, +} diff --git a/src/entitys/workspaces.rs b/src/entitys/workspaces.rs new file mode 100644 index 0000000..72f4367 --- /dev/null +++ b/src/entitys/workspaces.rs @@ -0,0 +1,57 @@ +use serde::{Deserialize, Serialize}; +use uuid::Uuid; + +use super::Pagination; + +/// 创建工作空间请求 +#[derive(Debug, Clone, Deserialize, Serialize)] +pub struct CreateWorkspaceRequest { + pub name: String, + pub workspace_type: Option, +} + +/// 更新工作空间请求 +#[derive(Debug, Clone, Deserialize, Serialize)] +pub struct UpdateWorkspaceRequest { + pub uuid: Uuid, + pub name: Option, +} + +/// 切换工作空间请求 +#[derive(Debug, Clone, Deserialize, Serialize)] +pub struct SwitchWorkspaceRequest { + pub workspace_uuid: Uuid, +} + +/// 查询工作空间列表请求 +#[derive(Debug, Clone, Deserialize, Serialize)] +pub struct ListWorkspacesRequest { + #[serde(flatten)] + pub pagination: Pagination, + pub filters: Option, +} + +/// 工作空间筛选条件 +#[derive(Debug, Clone, Deserialize, Serialize)] +pub struct WorkspaceFilters { + pub workspace_type: Option, + pub keyword: Option, +} + +// ========== 响应结构体 ========== + +/// 工作空间列表响应 +#[derive(Debug, Clone, Serialize)] +pub struct WorkspaceListResponse { + pub current_workspace_uuid: Option, + pub workspaces: Vec, +} + +/// 工作空间列表项 +#[derive(Debug, Clone, Serialize)] +pub struct WorkspaceItem { + pub uuid: Uuid, + pub name: String, + pub workspace_type: String, + pub is_current: bool, +} diff --git a/src/errors.rs b/src/errors.rs new file mode 100644 index 0000000..6d22fa1 --- /dev/null +++ b/src/errors.rs @@ -0,0 +1,105 @@ +use thiserror::Error; + +/// Simprint Server 错误类型 +#[derive(Error, Debug)] +pub enum SimprintError { + /// 用户不存在 + #[error("用户不存在")] + UserNotFound, + + /// 邮箱已被注册 + #[error("邮箱已被注册")] + EmailAlreadyExists, + + /// 邮箱或密码错误 + #[error("邮箱或密码错误")] + InvalidCredentials, + + /// 用户已被禁用 + #[error("用户已被禁用")] + UserDisabled, + + /// 验证码错误或已过期 + #[error("验证码错误或已过期")] + VerificationCodeExpired, + + /// 机器不存在 + #[error("机器不存在")] + MachineNotFound, + + /// 机器已被绑定 + #[error("机器已被绑定")] + MachineAlreadyBound, + + /// 用户未绑定到机器 + #[error("用户未绑定到机器")] + MachineNotBound, + + /// 版本不存在 + #[error("版本不存在")] + VersionNotFound, + + /// 版本已存在 + #[error("版本已存在")] + VersionAlreadyExists, + + /// 版本类型不存在 + #[error("版本类型不存在")] + VersionTypeNotFound, + + /// 版本号为空 + #[error("版本号不能为空")] + VersionEmpty, + + /// 资源名称为空 + #[error("资源名称不能为空")] + ResourceNameEmpty, + + /// 灰度发布不存在 + #[error("灰度发布不存在")] + GrayReleaseNotFound, + + /// 灰度分配失败 + #[error("灰度分配失败")] + GrayAllocationFailed, + + /// 策略类型不存在 + #[error("策略类型不存在")] + StrategyTypeNotFound, + + /// 策略配置无效 + #[error("策略配置无效")] + InvalidStrategyConfig, + + /// 维护不存在 + #[error("维护不存在")] + MaintenanceNotFound, + + /// 数据库操作失败 + #[error("数据库操作失败: {0}")] + DatabaseError(#[from] sqlx::Error), + + /// Anyhow错误 + #[error("操作失败: {0}")] + AnyhowError(#[from] anyhow::Error), + + /// JSON序列化错误 + #[error("JSON序列化错误: {0}")] + JsonError(#[from] serde_json::Error), + + /// 错误的请求 + #[error("错误的请求: {0}")] + InvalidRequest(String), + + /// 其他错误 + #[error("{0}")] + Other(String), +} + +impl From<&str> for SimprintError { + fn from(err: &str) -> Self { + SimprintError::Other(err.to_string()) + } +} + +// Note: Error conversion to Response is handled in handlers layer via map_err diff --git a/src/handlers.rs b/src/handlers.rs new file mode 100644 index 0000000..a20da48 --- /dev/null +++ b/src/handlers.rs @@ -0,0 +1,47 @@ +mod health; +mod secret; +mod time; +mod users; + +// 新增模块 +pub mod accounts; +pub mod api; +pub mod audit; +pub mod billing; +pub mod environments; +pub mod extensions; +pub mod group_permissions; +pub mod messages; +pub mod preferences; +pub mod proxies; +pub mod proxy_visibility; +pub mod referral; +pub mod rpa; +pub mod teams; +pub mod templates; +pub mod workspace_quotas; +pub mod workspaces; + +pub use health::*; +pub use secret::*; +pub use time::*; +pub use users::*; + +// 新增导出 +pub use accounts::*; +pub use api::*; +pub use audit::*; +pub use billing::*; +pub use environments::*; +pub use extensions::*; +pub use group_permissions::*; +pub use messages::*; +pub use preferences::*; +pub use proxies::*; +pub use proxy_visibility::*; +pub use referral::*; +pub use rpa::*; +pub use teams::*; +pub use templates::*; +pub use workspace_quotas::*; +pub use workspaces::*; diff --git a/src/handlers/accounts.rs b/src/handlers/accounts.rs new file mode 100644 index 0000000..a463b58 --- /dev/null +++ b/src/handlers/accounts.rs @@ -0,0 +1,142 @@ +use axum::{extract::Extension, extract::State}; + +use crate::entitys::{ + AccountListResponse, BatchImportAccountsRequest, BatchImportResponse, BatchUuidRequest, + CreateAccountRequest, CreateResponse, ListAccountsRequest, UpdateAccountRequest, UuidRequest, +}; +use crate::services; +use crate::state::RequestContext; +use crate::svc_ctx::SvcCtx; +use crate::utils::{Json, Response, Result}; + +/// 获取账号列表 +pub async fn get_accounts_handler( + State(svc_ctx): State, + Extension(ctx): Extension, + Json(payload): Json, +) -> Result { + let team_uuid = services::teams::get_current_team_service(&svc_ctx, ctx.user_uuid_unwrap()) + .await + .map_err(|e| Response::fail(Some(&e)))?; + + let (accounts, total) = services::accounts::get_accounts_service( + &svc_ctx, + ctx.user_uuid_unwrap(), + team_uuid, + &payload, + ) + .await + .map_err(|e| Response::fail(Some(&e)))?; + + Ok(Response::success( + Some("获取成功"), + Some(AccountListResponse { + items: accounts, + total, + page: payload.pagination.page, + page_size: payload.pagination.page_size, + }), + )) +} + +/// 获取账号详情 +pub async fn get_account_handler( + State(svc_ctx): State, + Json(payload): Json, +) -> Result { + let account = services::accounts::get_account_service(&svc_ctx, payload.uuid) + .await + .map_err(|e| Response::fail(Some(&e)))?; + + Ok(Response::success(Some("获取成功"), Some(account))) +} + +/// 创建账号 +pub async fn create_account_handler( + State(svc_ctx): State, + Extension(ctx): Extension, + Json(payload): Json, +) -> Result { + let team_uuid = services::teams::get_current_team_service(&svc_ctx, ctx.user_uuid_unwrap()) + .await + .map_err(|e| Response::fail(Some(&e)))?; + + let account_uuid = services::accounts::create_account_service( + &svc_ctx, + ctx.user_uuid_unwrap(), + team_uuid, + &payload, + ) + .await + .map_err(|e| Response::fail(Some(&e)))?; + + Ok(Response::success( + Some("创建成功"), + Some(CreateResponse { uuid: account_uuid }), + )) +} + +/// 更新账号 +pub async fn update_account_handler( + State(svc_ctx): State, + Json(payload): Json, +) -> Result<()> { + services::accounts::update_account_service(&svc_ctx, &payload) + .await + .map_err(|e| Response::fail(Some(&e)))?; + + Ok(Response::success(Some("更新成功"), None)) +} + +/// 删除账号 +pub async fn delete_account_handler( + State(svc_ctx): State, + Json(payload): Json, +) -> Result<()> { + services::accounts::delete_account_service(&svc_ctx, payload.uuid) + .await + .map_err(|e| Response::fail(Some(&e)))?; + + Ok(Response::success(Some("删除成功"), None)) +} + +/// 批量删除账号 +pub async fn batch_delete_accounts_handler( + State(svc_ctx): State, + Json(payload): Json, +) -> Result { + let count = services::accounts::batch_delete_accounts_service(&svc_ctx, &payload.uuids) + .await + .map_err(|e| Response::fail(Some(&e)))?; + + Ok(Response::success(Some("删除成功"), Some(count))) +} + +/// 批量导入账号 +pub async fn batch_import_accounts_handler( + State(svc_ctx): State, + Extension(ctx): Extension, + Json(payload): Json, +) -> Result { + let team_uuid = services::teams::get_current_team_service(&svc_ctx, ctx.user_uuid_unwrap()) + .await + .map_err(|e| Response::fail(Some(&e)))?; + + let result = services::accounts::batch_import_accounts_service( + &svc_ctx, + ctx.user_uuid_unwrap(), + team_uuid, + &payload, + ) + .await + .map_err(|e| Response::fail(Some(&e)))?; + + Ok(Response::success( + Some("导入完成"), + Some(BatchImportResponse { + success_count: result.success_count, + failed_count: result.failed_count, + errors: result.errors, + }), + )) +} diff --git a/src/handlers/api.rs b/src/handlers/api.rs new file mode 100644 index 0000000..d526777 --- /dev/null +++ b/src/handlers/api.rs @@ -0,0 +1,79 @@ +use axum::extract::{Extension, State}; + +use crate::entitys::api_service::{ + ApiKeysListResponse, CreateApiKeyRequest, CreateApiKeyResponse, ListApiKeysRequest, + RevokeApiKeyRequest, UpdateApiKeyRequest, +}; +use crate::services; +use crate::state::RequestContext; +use crate::svc_ctx::SvcCtx; +use crate::utils::{Json, Response, Result}; + +/// 获取 API 密钥列表 +pub async fn get_api_keys_handler( + State(svc_ctx): State, + Extension(ctx): Extension, + Json(payload): Json, +) -> Result { + let (items, total) = + services::api::get_api_keys_service(&svc_ctx, ctx.user_uuid_unwrap(), &payload) + .await + .map_err(|e| Response::fail(Some(&e)))?; + + Ok(Response::success( + Some("获取成功"), + Some(ApiKeysListResponse { + items, + total, + page: payload.pagination.page, + page_size: payload.pagination.page_size, + }), + )) +} + +/// 创建 API 密钥 +pub async fn create_api_key_handler( + State(svc_ctx): State, + Extension(ctx): Extension, + Json(payload): Json, +) -> Result { + let (uuid, api_key, key_prefix) = + services::api::create_api_key_service(&svc_ctx, ctx.user_uuid_unwrap(), &payload) + .await + .map_err(|e| Response::fail(Some(&e)))?; + + Ok(Response::success( + Some("创建成功,请妥善保存密钥"), + Some(CreateApiKeyResponse { + uuid, + api_key, + key_prefix, + }), + )) +} + +/// 更新 API 密钥 +pub async fn update_api_key_handler( + State(svc_ctx): State, + Extension(ctx): Extension, + Json(payload): Json, +) -> Result<()> { + services::api::update_api_key_service(&svc_ctx, ctx.user_uuid_unwrap(), &payload) + .await + .map_err(|e| Response::fail(Some(&e)))?; + + Ok(Response::success(Some("更新成功"), None)) +} + +/// 撤销 API 密钥 +pub async fn revoke_api_key_handler( + State(svc_ctx): State, + Extension(ctx): Extension, + Json(payload): Json, +) -> Result<()> { + services::api::revoke_api_key_service(&svc_ctx, ctx.user_uuid_unwrap(), payload.uuid) + .await + .map_err(|e| Response::fail(Some(&e)))?; + + Ok(Response::success(Some("撤销成功"), None)) +} diff --git a/src/handlers/audit.rs b/src/handlers/audit.rs new file mode 100644 index 0000000..676d4dc --- /dev/null +++ b/src/handlers/audit.rs @@ -0,0 +1,98 @@ +use axum::extract::{Extension, State}; + +use crate::dto::AuditLogDto; +use crate::entitys::{ + AuditLogsListResponse, AuditStatsRequest, AuditStatsResponse, ExportAuditLogsRequest, + ExportResponse, ListAuditLogsRequest, UuidRequest, +}; +use crate::services; +use crate::state::RequestContext; +use crate::svc_ctx::SvcCtx; +use crate::utils::{Json, Response, Result}; + +/// 获取审计日志列表 +pub async fn get_audit_logs_handler( + State(svc_ctx): State, + Extension(ctx): Extension, + Json(payload): Json, +) -> Result { + let current_user_uuid = ctx.user_uuid_unwrap(); + let team_uuid = services::teams::get_current_team_service(&svc_ctx, current_user_uuid) + .await + .map_err(|e| Response::fail(Some(&e)))?; + + let (items, total) = + services::audit::get_audit_logs_service(&svc_ctx, current_user_uuid, team_uuid, &payload) + .await + .map_err(|e| Response::fail(Some(&e)))?; + + Ok(Response::success( + Some("获取成功"), + Some(AuditLogsListResponse { + items, + total, + page: payload.pagination.page, + page_size: payload.pagination.page_size, + }), + )) +} + +/// 获取审计日志详情 +pub async fn get_audit_log_detail_handler( + State(svc_ctx): State, + Json(payload): Json, +) -> Result { + let log = services::audit::get_audit_log_service(&svc_ctx, payload.uuid) + .await + .map_err(|e| Response::fail(Some(&e)))?; + + Ok(Response::success(Some("获取成功"), Some(log))) +} + +/// 导出审计日志 +pub async fn export_audit_logs_handler( + State(svc_ctx): State, + Extension(ctx): Extension, + Json(payload): Json, +) -> Result { + let current_user_uuid = ctx.user_uuid_unwrap(); + let team_uuid = services::teams::get_current_team_service(&svc_ctx, current_user_uuid) + .await + .map_err(|e| Response::fail(Some(&e)))?; + + let (content, filename, mime_type) = services::audit::export_audit_logs_service( + &svc_ctx, + current_user_uuid, + team_uuid, + &payload, + ) + .await + .map_err(|e| Response::fail(Some(&e)))?; + + Ok(Response::success( + Some("导出成功"), + Some(ExportResponse { + content, + filename, + mime_type, + }), + )) +} + +/// 获取审计统计 +pub async fn get_audit_stats_handler( + State(svc_ctx): State, + Extension(ctx): Extension, + Json(_payload): Json, +) -> Result { + let current_user_uuid = ctx.user_uuid_unwrap(); + let team_uuid = services::teams::get_current_team_service(&svc_ctx, current_user_uuid) + .await + .map_err(|e| Response::fail(Some(&e)))?; + + let stats = services::audit::get_audit_stats_service(&svc_ctx, current_user_uuid, team_uuid) + .await + .map_err(|e| Response::fail(Some(&e)))?; + + Ok(Response::success(Some("获取成功"), Some(stats))) +} diff --git a/src/handlers/billing.rs b/src/handlers/billing.rs new file mode 100644 index 0000000..12ff98d --- /dev/null +++ b/src/handlers/billing.rs @@ -0,0 +1,407 @@ +use axum::extract::{Extension, State}; + +use crate::dto::{ + AutoRenewalServiceDto, PaymentOrderDto, SubscriptionDto, UserWalletDto, +}; +use crate::entitys::{ + AccountInfoResponse, CancelSubscriptionRequest, CreateOrderResponse, CreateRechargeOrderRequest, + GetPlanPriceRequest, GetPlansRequest, GetUserCouponsRequest, InvoicesListResponse, ListInvoicesRequest, + ListPaymentOrdersRequest, ListTransactionsRequest, PaymentOrdersListResponse, + PlanDetailResponse, PlanPriceResponse, PlansResponse, ResumeSubscriptionRequest, + SubscribePlanRequest, SubscribeResponse, ToggleAutoRenewRequest, TransactionsListResponse, + UserCouponsListResponse, UuidRequest, VerifyCouponRequest, VerifyCouponResponse, +}; +use crate::services; +use crate::state::RequestContext; +use crate::svc_ctx::SvcCtx; +use crate::utils::{Json, Response, Result}; + +// ============ Plans ============ + +/// 获取套餐列表(包含特性) +pub async fn get_plans_handler( + State(svc_ctx): State, + Extension(ctx): Extension, + Json(payload): Json, +) -> Result { + let user_uuid = ctx.user_uuid_unwrap(); + let plans_with_features = services::plans::get_plans_service( + &svc_ctx, + user_uuid, + payload.coupon_code.as_deref(), + &payload.billing_period, + ) + .await + .map_err(|e| Response::fail(Some(&e)))?; + + let plans = plans_with_features + .into_iter() + .map(|pwf| crate::entitys::PlanWithFeatures { + plan: pwf.plan, + features: pwf.features, + calculated_price: pwf.calculated_price, + }) + .collect(); + + Ok(Response::success( + Some("获取成功"), + Some(PlansResponse { plans }), + )) +} + +/// 获取套餐详情 +pub async fn get_plan_detail_handler( + State(svc_ctx): State, + Json(payload): Json, +) -> Result { + let (plan, features) = services::plans::get_plan_detail_service(&svc_ctx, payload.uuid) + .await + .map_err(|e| Response::fail(Some(&e)))?; + + Ok(Response::success( + Some("获取成功"), + Some(PlanDetailResponse { plan, features }), + )) +} + +// ============ Subscriptions ============ + +/// 获取当前订阅 +pub async fn get_current_subscription_handler( + State(svc_ctx): State, + Extension(ctx): Extension, +) -> Result> { + let workspace_uuid = ctx + .current_workspace_uuid + .ok_or_else(|| Response::fail(Some("请先选择工作空间")))?; + + let subscription = + services::subscriptions::get_workspace_subscription_service(&svc_ctx, workspace_uuid) + .await + .map_err(|e| Response::fail(Some(&e)))?; + + Ok(Response::success(Some("获取成功"), Some(subscription))) +} + +/// 订阅套餐 +pub async fn subscribe_plan_handler( + State(svc_ctx): State, + Extension(ctx): Extension, + Json(payload): Json, +) -> Result { + let workspace_uuid = ctx + .current_workspace_uuid + .ok_or_else(|| Response::fail(Some("请先选择工作空间")))?; + + let subscription_uuid = services::subscriptions::subscribe_plan_service( + &svc_ctx, + workspace_uuid, + ctx.user_uuid_unwrap(), + &payload, + ) + .await + .map_err(|e| Response::fail(Some(&e)))?; + + Ok(Response::success( + Some("订阅成功"), + Some(SubscribeResponse { subscription_uuid }), + )) +} + +/// 取消订阅 +pub async fn cancel_subscription_handler( + State(svc_ctx): State, + Extension(ctx): Extension, + Json(payload): Json, +) -> Result<()> { + services::subscriptions::cancel_subscription_service( + &svc_ctx, + ctx.user_uuid_unwrap(), + payload.subscription_uuid, + ) + .await + .map_err(|e| Response::fail(Some(&e)))?; + + Ok(Response::success(Some("取消成功"), None)) +} + +/// 恢复订阅 +pub async fn resume_subscription_handler( + State(svc_ctx): State, + Extension(ctx): Extension, + Json(payload): Json, +) -> Result<()> { + services::subscriptions::resume_subscription_service( + &svc_ctx, + ctx.user_uuid_unwrap(), + payload.subscription_uuid, + ) + .await + .map_err(|e| Response::fail(Some(&e)))?; + + Ok(Response::success(Some("恢复成功"), None)) +} + +/// 切换自动续费 +pub async fn toggle_auto_renew_handler( + State(svc_ctx): State, + Extension(ctx): Extension, + Json(payload): Json, +) -> Result<()> { + services::subscriptions::toggle_auto_renew_service( + &svc_ctx, + ctx.user_uuid_unwrap(), + payload.subscription_uuid, + payload.auto_renew, + ) + .await + .map_err(|e| Response::fail(Some(&e)))?; + + Ok(Response::success(Some("设置成功"), None)) +} + +// ============ Wallet ============ + +/// 获取钱包信息 +pub async fn get_wallet_handler( + State(svc_ctx): State, + Extension(ctx): Extension, +) -> Result { + let wallet = services::wallet::get_wallet_service(&svc_ctx, ctx.user_uuid_unwrap()) + .await + .map_err(|e| Response::fail(Some(&e)))?; + + Ok(Response::success(Some("获取成功"), Some(wallet))) +} + +/// 获取交易记录 +pub async fn get_transactions_handler( + State(svc_ctx): State, + Extension(ctx): Extension, + Json(payload): Json, +) -> Result { + let (items, total) = + services::wallet::get_transactions_service(&svc_ctx, ctx.user_uuid_unwrap(), &payload) + .await + .map_err(|e| Response::fail(Some(&e)))?; + + Ok(Response::success( + Some("获取成功"), + Some(TransactionsListResponse { + items, + total, + page: payload.pagination.page, + page_size: payload.pagination.page_size, + }), + )) +} + +// ============ Invoices ============ + +/// 获取发票列表 +pub async fn get_invoices_handler( + State(svc_ctx): State, + Extension(ctx): Extension, + Json(payload): Json, +) -> Result { + let (items, total) = + services::billing::get_invoices_service(&svc_ctx, ctx.user_uuid_unwrap(), &payload) + .await + .map_err(|e| Response::fail(Some(&e)))?; + + Ok(Response::success( + Some("获取成功"), + Some(InvoicesListResponse { + items, + total, + page: payload.pagination.page, + page_size: payload.pagination.page_size, + }), + )) +} + +// ============ Quotas ============ + +/// 获取工作空间配额 +pub async fn get_quota_handler( + State(svc_ctx): State, + Extension(ctx): Extension, +) -> Result { + let workspace_uuid = ctx + .current_workspace_uuid + .ok_or_else(|| Response::fail(Some("请先选择工作空间")))?; + + let quota = services::workspace_quotas::get_workspace_quota_service( + &svc_ctx, + &crate::entitys::GetWorkspaceQuotaRequest { workspace_uuid }, + ) + .await + .map_err(|e| Response::fail(Some(&e)))?; + + Ok(Response::success(Some("获取成功"), Some(quota))) +} + +// ============ Coupons ============ + +/// 验证优惠券 +pub async fn verify_coupon_handler( + State(svc_ctx): State, + Extension(ctx): Extension, + Json(payload): Json, +) -> Result { + let result = + services::coupons::validate_coupon_service(&svc_ctx, ctx.user_uuid_unwrap(), &payload) + .await + .map_err(|e| Response::fail(Some(&e)))?; + + Ok(Response::success( + Some("优惠券有效"), + Some(VerifyCouponResponse { + coupon_uuid: result.coupon.uuid, + discount_type: result.coupon.discount_type, + discount_value: result.coupon.discount_value, + discount_amount: result.discount_amount, + }), + )) +} + +/// 获取用户优惠券列表 +pub async fn get_user_coupons_handler( + State(svc_ctx): State, + Extension(ctx): Extension, + Json(payload): Json, +) -> Result { + let result = services::coupons::get_user_coupons_service( + &svc_ctx, + ctx.user_uuid_unwrap(), + &payload, + ) + .await + .map_err(|e| Response::fail(Some(&e)))?; + + Ok(Response::success(Some("获取成功"), Some(result))) +} + +/// 获取用户可用优惠券 +pub async fn get_available_coupons_handler( + State(svc_ctx): State, + Extension(ctx): Extension, +) -> Result> { + let result = services::coupons::get_available_coupons_service(&svc_ctx, ctx.user_uuid_unwrap()) + .await + .map_err(|e| Response::fail(Some(&e)))?; + + Ok(Response::success(Some("获取成功"), Some(result))) +} + +/// 获取套餐价格(包含折扣计算) +pub async fn get_plan_price_handler( + State(svc_ctx): State, + Extension(ctx): Extension, + Json(payload): Json, +) -> Result { + let result = services::plans::calculate_plan_price_service( + &svc_ctx, + ctx.user_uuid_unwrap(), + &payload, + ) + .await + .map_err(|e| Response::fail(Some(&e)))?; + + Ok(Response::success(Some("计算成功"), Some(result))) +} + +// ============ Payment Orders ============ + +/// 创建充值订单 +pub async fn create_recharge_order_handler( + State(svc_ctx): State, + Extension(ctx): Extension, + Json(payload): Json, +) -> Result { + let (order_uuid, order_no) = + services::orders::create_recharge_order_service(&svc_ctx, ctx.user_uuid_unwrap(), &payload) + .await + .map_err(|e| Response::fail(Some(&e)))?; + + Ok(Response::success( + Some("订单创建成功"), + Some(CreateOrderResponse { + order_uuid, + order_no, + }), + )) +} + +/// 获取支付订单列表 +pub async fn get_payment_orders_handler( + State(svc_ctx): State, + Extension(ctx): Extension, + Json(payload): Json, +) -> Result { + let (items, total) = + services::orders::get_payment_orders_service(&svc_ctx, ctx.user_uuid_unwrap(), &payload) + .await + .map_err(|e| Response::fail(Some(&e)))?; + + Ok(Response::success( + Some("获取成功"), + Some(PaymentOrdersListResponse { + items, + total, + page: payload.pagination.page, + page_size: payload.pagination.page_size, + }), + )) +} + +/// 查询订单状态 +pub async fn get_order_status_handler( + State(svc_ctx): State, + Extension(ctx): Extension, + Json(payload): Json, +) -> Result { + let order = + services::orders::get_order_status_service(&svc_ctx, ctx.user_uuid_unwrap(), payload.uuid) + .await + .map_err(|e| Response::fail(Some(&e)))?; + + Ok(Response::success(Some("获取成功"), Some(order))) +} + +// ============ Auto Renewal Services ============ + +/// 获取自动续费服务列表 +pub async fn get_auto_renewal_services_handler( + State(svc_ctx): State, + Extension(ctx): Extension, +) -> Result> { + let services = + services::billing::get_auto_renewal_services_service(&svc_ctx, ctx.user_uuid_unwrap()) + .await + .map_err(|e| Response::fail(Some(&e)))?; + + Ok(Response::success(Some("获取成功"), Some(services))) +} + +// ============ Account Info ============ + +/// 获取账户信息 +pub async fn get_account_info_handler( + State(svc_ctx): State, + Extension(ctx): Extension, +) -> Result { + let workspace_uuid = ctx + .current_workspace_uuid + .ok_or_else(|| Response::fail(Some("请先选择工作空间")))?; + + let account_info = services::billing::get_account_info_service( + &svc_ctx, + workspace_uuid, + ctx.user_uuid_unwrap(), + ) + .await + .map_err(|e| Response::fail(Some(&e)))?; + + Ok(Response::success(Some("获取成功"), Some(account_info))) +} diff --git a/src/handlers/environments.rs b/src/handlers/environments.rs new file mode 100644 index 0000000..e561211 --- /dev/null +++ b/src/handlers/environments.rs @@ -0,0 +1,665 @@ +use axum::{extract::Extension, extract::State}; + +use crate::audit_log; +use crate::entitys::{ + AddEnvironmentCookieRequest, AddEnvironmentUrlRequest, AssignTagsRequest, + BatchAssignTagRequest, BatchCreateEnvironmentRequest, BatchMoveToGroupRequest, + BatchRemoveTagsRequest, BatchUuidRequest, ClearEnvironmentCookiesRequest, + ClearEnvironmentUrlsRequest, CreateEnvironmentRequest, CreateGroupRequest, CreateResponse, + CreateTagRequest, DeleteEnvironmentCookieRequest, DeleteEnvironmentUrlRequest, + EnvironmentDetailResponse, EnvironmentListResponse, IdResponse, ListEnvironmentsRequest, + MoveToGroupRequest, RemoveTagRequest, SetEnvironmentAccountsRequest, + SetEnvironmentProxyRequest, UpdateEnvironmentRequest, UpdateGroupRequest, UpdateTagRequest, + UuidRequest, +}; +use crate::services; +use crate::state::RequestContext; +use crate::svc_ctx::SvcCtx; +use crate::utils::{Json, Response, Result}; + +// ============ Groups ============ + +/// 创建分组 +pub async fn create_group_handler( + State(svc_ctx): State, + Extension(ctx): Extension, + Json(payload): Json, +) -> Result { + let workspace_uuid = ctx + .current_workspace_uuid + .ok_or_else(|| Response::fail(Some("请先选择工作空间")))?; + let team_uuid = ctx.current_team_uuid.ok_or_else(|| Response::fail(Some("请先选择团队")))?; + + let group_uuid = services::groups::create_group_service( + &svc_ctx, + ctx.user_uuid_unwrap(), + workspace_uuid, + team_uuid, + &payload, + ) + .await + .map_err(|e| Response::fail(Some(&e)))?; + + audit_log!( + &svc_ctx, + &ctx, + "create", + "group", + group_uuid, + &payload.name, + "创建分组" + ) + .await; + + Ok(Response::success( + Some("创建成功"), + Some(CreateResponse { uuid: group_uuid }), + )) +} + +/// 获取分组列表 +pub async fn get_groups_handler( + State(svc_ctx): State, + Extension(ctx): Extension, +) -> Result> { + // 从当前团队获取工作空间和团队 UUID + let team_uuid = services::teams::get_current_team_service(&svc_ctx, ctx.user_uuid_unwrap()) + .await + .map_err(|e| Response::fail(Some(&e)))? + .ok_or_else(|| Response::fail(Some("请先选择团队")))?; + + let team = services::teams::get_team_service(&svc_ctx, team_uuid) + .await + .map_err(|e| Response::fail(Some(&e)))?; + + let groups = + services::groups::get_groups_service(&svc_ctx, team.workspace_uuid, team_uuid, 1, 100) + .await + .map_err(|e| Response::fail(Some(&e)))?; + + Ok(Response::success(Some("获取成功"), Some(groups))) +} + +/// 更新分组 +pub async fn update_group_handler( + State(svc_ctx): State, + Extension(ctx): Extension, + Json(payload): Json, +) -> Result<()> { + let workspace_uuid = ctx + .current_workspace_uuid + .ok_or_else(|| Response::fail(Some("请先选择工作空间")))?; + let team_uuid = ctx.current_team_uuid.ok_or_else(|| Response::fail(Some("请先选择团队")))?; + + services::groups::update_group_service( + &svc_ctx, + workspace_uuid, + team_uuid, + ctx.user_uuid_unwrap(), + &payload, + ) + .await + .map_err(|e| Response::fail(Some(&e)))?; + + Ok(Response::success(Some("更新成功"), None)) +} + +/// 删除分组 +pub async fn delete_group_handler( + State(svc_ctx): State, + Extension(ctx): Extension, + Json(payload): Json, +) -> Result<()> { + let workspace_uuid = ctx + .current_workspace_uuid + .ok_or_else(|| Response::fail(Some("请先选择工作空间")))?; + let team_uuid = ctx.current_team_uuid.ok_or_else(|| Response::fail(Some("请先选择团队")))?; + + services::groups::delete_group_service( + &svc_ctx, + workspace_uuid, + team_uuid, + ctx.user_uuid_unwrap(), + payload.uuid, + ) + .await + .map_err(|e| Response::fail(Some(&e)))?; + + audit_log!(&svc_ctx, &ctx, "delete", "group", payload.uuid, "删除分组").await; + + Ok(Response::success(Some("删除成功"), None)) +} + +// ============ Tags ============ + +/// 创建标签 +pub async fn create_tag_handler( + State(svc_ctx): State, + Extension(ctx): Extension, + Json(payload): Json, +) -> Result { + let team_uuid = services::teams::get_current_team_service(&svc_ctx, ctx.user_uuid_unwrap()) + .await + .map_err(|e| Response::fail(Some(&e)))?; + + let tag_uuid = + services::tags::create_tag_service(&svc_ctx, ctx.user_uuid_unwrap(), team_uuid, &payload) + .await + .map_err(|e| Response::fail(Some(&e)))?; + + audit_log!( + &svc_ctx, + &ctx, + "create", + "tag", + tag_uuid, + &payload.name, + "创建标签" + ) + .await; + + Ok(Response::success( + Some("创建成功"), + Some(CreateResponse { uuid: tag_uuid }), + )) +} + +/// 获取标签列表 +pub async fn get_tags_handler( + State(svc_ctx): State, + Extension(ctx): Extension, +) -> Result> { + let team_uuid = services::teams::get_current_team_service(&svc_ctx, ctx.user_uuid_unwrap()) + .await + .map_err(|e| Response::fail(Some(&e)))?; + + let tags = services::tags::get_tags_service(&svc_ctx, ctx.user_uuid_unwrap(), team_uuid) + .await + .map_err(|e| Response::fail(Some(&e)))?; + + Ok(Response::success(Some("获取成功"), Some(tags))) +} + +/// 更新标签 +pub async fn update_tag_handler( + State(svc_ctx): State, + Json(payload): Json, +) -> Result<()> { + services::tags::update_tag_service(&svc_ctx, &payload) + .await + .map_err(|e| Response::fail(Some(&e)))?; + + Ok(Response::success(Some("更新成功"), None)) +} + +/// 删除标签 +pub async fn delete_tag_handler( + State(svc_ctx): State, + Extension(ctx): Extension, + Json(payload): Json, +) -> Result<()> { + services::tags::delete_tag_service(&svc_ctx, payload.uuid) + .await + .map_err(|e| Response::fail(Some(&e)))?; + + audit_log!(&svc_ctx, &ctx, "delete", "tag", payload.uuid, "删除标签").await; + + Ok(Response::success(Some("删除成功"), None)) +} + +// ============ Environments ============ + +/// 获取环境列表 +pub async fn get_environments_handler( + State(svc_ctx): State, + Extension(ctx): Extension, + Json(payload): Json, +) -> Result { + let workspace_uuid = ctx + .current_workspace_uuid + .ok_or_else(|| Response::fail(Some("请先选择工作空间")))?; + let team_uuid = ctx.current_team_uuid.ok_or_else(|| Response::fail(Some("请先选择团队")))?; + + let (environments, total) = services::environments::get_environments_service( + &svc_ctx, + ctx.user_uuid_unwrap(), + workspace_uuid, + team_uuid, + &payload, + ) + .await + .map_err(|e| Response::fail(Some(&e)))?; + + Ok(Response::success( + Some("获取成功"), + Some(EnvironmentListResponse { + items: environments, + total, + page: payload.pagination.page, + page_size: payload.pagination.page_size, + }), + )) +} + +/// 获取环境详情 +pub async fn get_environment_handler( + State(svc_ctx): State, + Extension(ctx): Extension, + Json(payload): Json, +) -> Result { + let workspace_uuid = ctx + .current_workspace_uuid + .ok_or_else(|| Response::fail(Some("请先选择工作空间")))?; + let team_uuid = ctx.current_team_uuid.ok_or_else(|| Response::fail(Some("请先选择团队")))?; + + let environment = services::environments::get_environment_service( + &svc_ctx, + workspace_uuid, + team_uuid, + ctx.user_uuid_unwrap(), + payload.uuid, + ) + .await + .map_err(|e| Response::fail(Some(&e)))?; + + let config = services::environments::get_environment_config_service(&svc_ctx, payload.uuid) + .await + .ok(); + + let tags = services::environments::get_environment_tags_service(&svc_ctx, payload.uuid) + .await + .map_err(|e| Response::fail(Some(&e)))?; + + let accounts = services::accounts::get_environment_accounts_service(&svc_ctx, payload.uuid) + .await + .unwrap_or_default(); + + // 获取分组信息 + let group = if let Some(group_uuid) = environment.group_uuid { + services::environments::get_group_summary_service(&svc_ctx, group_uuid) + .await + .ok() + } else { + None + }; + + // 获取代理信息 + let proxy = if let Some(proxy_uuid) = environment.proxy_uuid { + services::environments::get_proxy_summary_service(&svc_ctx, proxy_uuid) + .await + .ok() + } else { + None + }; + + Ok(Response::success( + Some("获取成功"), + Some(EnvironmentDetailResponse { + environment, + config, + tags, + accounts, + group, + proxy, + }), + )) +} + +/// 创建环境 +pub async fn create_environment_handler( + State(svc_ctx): State, + Extension(ctx): Extension, + Json(payload): Json, +) -> Result { + let workspace_uuid = ctx + .current_workspace_uuid + .ok_or_else(|| Response::fail(Some("请先选择工作空间")))?; + let team_uuid = ctx.current_team_uuid.ok_or_else(|| Response::fail(Some("请先选择团队")))?; + + let env_uuid = services::environments::create_environment_service( + &svc_ctx, + ctx.user_uuid_unwrap(), + workspace_uuid, + team_uuid, + &payload, + ) + .await + .map_err(|e| Response::fail(Some(&e)))?; + + audit_log!( + &svc_ctx, + &ctx, + "create", + "environment", + env_uuid, + &payload.name, + "创建环境" + ) + .await; + + Ok(Response::success( + Some("创建成功"), + Some(CreateResponse { uuid: env_uuid }), + )) +} + +/// 批量创建环境 +pub async fn batch_create_environments_handler( + State(svc_ctx): State, + Extension(ctx): Extension, + Json(payload): Json, +) -> Result> { + let workspace_uuid = ctx + .current_workspace_uuid + .ok_or_else(|| Response::fail(Some("请先选择工作空间")))?; + let team_uuid = ctx.current_team_uuid.ok_or_else(|| Response::fail(Some("请先选择团队")))?; + + let env_uuids = services::environments::batch_create_environments_service( + &svc_ctx, + ctx.user_uuid_unwrap(), + workspace_uuid, + team_uuid, + &payload, + ) + .await + .map_err(|e| Response::fail(Some(&e)))?; + + let count = env_uuids.len(); + let responses: Vec = + env_uuids.into_iter().map(|uuid| CreateResponse { uuid }).collect(); + + audit_log!( + &svc_ctx, + &ctx, + "batch_create", + "environment", + &format!("批量创建 {} 个环境", count) + ) + .await; + + Ok(Response::success(Some("批量创建成功"), Some(responses))) +} + +/// 更新环境 +pub async fn update_environment_handler( + State(svc_ctx): State, + Extension(ctx): Extension, + Json(payload): Json, +) -> Result<()> { + let workspace_uuid = ctx + .current_workspace_uuid + .ok_or_else(|| Response::fail(Some("请先选择工作空间")))?; + let team_uuid = ctx.current_team_uuid.ok_or_else(|| Response::fail(Some("请先选择团队")))?; + + services::environments::update_environment_service( + &svc_ctx, + workspace_uuid, + team_uuid, + ctx.user_uuid_unwrap(), + &payload, + ) + .await + .map_err(|e| Response::fail(Some(&e)))?; + + Ok(Response::success(Some("更新成功"), None)) +} + +/// 删除环境 +pub async fn delete_environment_handler( + State(svc_ctx): State, + Extension(ctx): Extension, + Json(payload): Json, +) -> Result<()> { + let workspace_uuid = ctx + .current_workspace_uuid + .ok_or_else(|| Response::fail(Some("请先选择工作空间")))?; + let team_uuid = ctx.current_team_uuid.ok_or_else(|| Response::fail(Some("请先选择团队")))?; + + services::environments::delete_environment_service( + &svc_ctx, + workspace_uuid, + team_uuid, + ctx.user_uuid_unwrap(), + payload.uuid, + ) + .await + .map_err(|e| Response::fail(Some(&e)))?; + + audit_log!( + &svc_ctx, + &ctx, + "delete", + "environment", + payload.uuid, + "删除环境" + ) + .await; + + Ok(Response::success(Some("删除成功"), None)) +} + +/// 批量删除环境 +pub async fn batch_delete_environments_handler( + State(svc_ctx): State, + Extension(ctx): Extension, + Json(payload): Json, +) -> Result { + let count = services::environments::batch_delete_environments_service(&svc_ctx, &payload.uuids) + .await + .map_err(|e| Response::fail(Some(&e)))?; + + audit_log!( + &svc_ctx, + &ctx, + "batch_delete", + "environment", + &format!("批量删除 {} 个环境", count) + ) + .await; + + Ok(Response::success(Some("删除成功"), Some(count))) +} + +/// 设置环境代理 +pub async fn set_proxy_handler( + State(svc_ctx): State, + Json(payload): Json, +) -> Result<()> { + services::environments::set_environment_proxy_service(&svc_ctx, &payload) + .await + .map_err(|e| Response::fail(Some(&e)))?; + + Ok(Response::success(Some("设置成功"), None)) +} + +/// 分配标签 +pub async fn assign_tags_handler( + State(svc_ctx): State, + Json(payload): Json, +) -> Result<()> { + services::environments::assign_tags_service(&svc_ctx, &payload) + .await + .map_err(|e| Response::fail(Some(&e)))?; + + Ok(Response::success(Some("分配成功"), None)) +} + +/// 移除标签 +pub async fn remove_tag_handler( + State(svc_ctx): State, + Json(payload): Json, +) -> Result<()> { + services::environments::remove_tag_service(&svc_ctx, payload.uuid, payload.tag_uuid) + .await + .map_err(|e| Response::fail(Some(&e)))?; + + Ok(Response::success(Some("移除成功"), None)) +} + +/// 移动到分组 +pub async fn move_to_group_handler( + State(svc_ctx): State, + Json(payload): Json, +) -> Result<()> { + services::environments::move_to_group_service(&svc_ctx, &payload) + .await + .map_err(|e| Response::fail(Some(&e)))?; + + Ok(Response::success(Some("移动成功"), None)) +} + +/// 批量移动到分组 +pub async fn batch_move_to_group_handler( + State(svc_ctx): State, + Json(payload): Json, +) -> Result<()> { + services::environments::batch_move_to_group_service(&svc_ctx, &payload) + .await + .map_err(|e| Response::fail(Some(&e)))?; + + Ok(Response::success(Some("移动成功"), None)) +} + +/// 设置环境账号 +pub async fn set_environment_accounts_handler( + State(svc_ctx): State, + Json(payload): Json, +) -> Result<()> { + services::accounts::set_environment_accounts_service( + &svc_ctx, + payload.uuid, + &payload.account_uuids, + ) + .await + .map_err(|e| Response::fail(Some(&e)))?; + + Ok(Response::success(Some("设置成功"), None)) +} + +/// 批量分配标签 +pub async fn batch_assign_tags_handler( + State(svc_ctx): State, + Json(payload): Json, +) -> Result<()> { + services::environments::batch_assign_tags_service(&svc_ctx, &payload) + .await + .map_err(|e| Response::fail(Some(&e)))?; + + Ok(Response::success(Some("分配成功"), None)) +} + +/// 批量移除标签 +pub async fn batch_remove_tags_handler( + State(svc_ctx): State, + Json(payload): Json, +) -> Result<()> { + services::environments::batch_remove_tags_service(&svc_ctx, &payload) + .await + .map_err(|e| Response::fail(Some(&e)))?; + + Ok(Response::success(Some("移除成功"), None)) +} + +// ============ Environment URLs ============ + +/// 添加环境 URL +pub async fn add_environment_url_handler( + State(svc_ctx): State, + Json(payload): Json, +) -> Result { + let id = services::environments::add_environment_url_service(&svc_ctx, &payload) + .await + .map_err(|e| Response::fail(Some(&e)))?; + + Ok(Response::success(Some("添加成功"), Some(IdResponse { id }))) +} + +/// 获取环境 URL 列表 +pub async fn get_environment_urls_handler( + State(svc_ctx): State, + Json(payload): Json, +) -> Result> { + let urls = services::environments::get_environment_urls_service(&svc_ctx, payload.uuid) + .await + .map_err(|e| Response::fail(Some(&e)))?; + + Ok(Response::success(Some("获取成功"), Some(urls))) +} + +/// 删除环境 URL +pub async fn delete_environment_url_handler( + State(svc_ctx): State, + Json(payload): Json, +) -> Result<()> { + services::environments::delete_environment_url_service(&svc_ctx, payload.id) + .await + .map_err(|e| Response::fail(Some(&e)))?; + + Ok(Response::success(Some("删除成功"), None)) +} + +/// 清空环境 URL +pub async fn clear_environment_urls_handler( + State(svc_ctx): State, + Json(payload): Json, +) -> Result { + let count = + services::environments::clear_environment_urls_service(&svc_ctx, payload.environment_uuid) + .await + .map_err(|e| Response::fail(Some(&e)))?; + + Ok(Response::success(Some("清空成功"), Some(count))) +} + +// ============ Environment Cookies ============ + +/// 添加环境 Cookie +pub async fn add_environment_cookie_handler( + State(svc_ctx): State, + Json(payload): Json, +) -> Result { + let id = services::environments::add_environment_cookie_service(&svc_ctx, &payload) + .await + .map_err(|e| Response::fail(Some(&e)))?; + + Ok(Response::success(Some("添加成功"), Some(IdResponse { id }))) +} + +/// 获取环境 Cookie 列表 +pub async fn get_environment_cookies_handler( + State(svc_ctx): State, + Json(payload): Json, +) -> Result> { + let cookies = services::environments::get_environment_cookies_service(&svc_ctx, payload.uuid) + .await + .map_err(|e| Response::fail(Some(&e)))?; + + Ok(Response::success(Some("获取成功"), Some(cookies))) +} + +/// 删除环境 Cookie +pub async fn delete_environment_cookie_handler( + State(svc_ctx): State, + Json(payload): Json, +) -> Result<()> { + services::environments::delete_environment_cookie_service(&svc_ctx, payload.id) + .await + .map_err(|e| Response::fail(Some(&e)))?; + + Ok(Response::success(Some("删除成功"), None)) +} + +/// 清空环境 Cookies +pub async fn clear_environment_cookies_handler( + State(svc_ctx): State, + Json(payload): Json, +) -> Result { + let count = services::environments::clear_environment_cookies_service( + &svc_ctx, + payload.environment_uuid, + ) + .await + .map_err(|e| Response::fail(Some(&e)))?; + + Ok(Response::success(Some("清空成功"), Some(count))) +} diff --git a/src/handlers/extensions.rs b/src/handlers/extensions.rs new file mode 100644 index 0000000..86c6bb9 --- /dev/null +++ b/src/handlers/extensions.rs @@ -0,0 +1,194 @@ +use axum::extract::{Extension, State}; + +use crate::dto::ExtensionDto; +use crate::entitys::{ + BatchUpdateExtensionsRequest, BatchUpdateResponse, ExtensionIdRequest, ExtensionsListResponse, + GetInstalledExtensionsRequest, InstallExtensionRequest, InstalledExtensionsResponse, + ListExtensionsRequest, UninstallExtensionRequest, UpdateExtensionRequest, +}; +use crate::services; +use crate::state::RequestContext; +use crate::svc_ctx::SvcCtx; +use crate::utils::{Json, Response, Result}; + +/// 获取扩展列表 +pub async fn get_extensions_handler( + State(svc_ctx): State, + Json(payload): Json, +) -> Result { + let (items, total) = services::extensions::get_extensions_service(&svc_ctx, &payload) + .await + .map_err(|e| Response::fail(Some(&e)))?; + + Ok(Response::success( + Some("获取成功"), + Some(ExtensionsListResponse { + items, + total, + page: payload.pagination.page, + page_size: payload.pagination.page_size, + }), + )) +} + +/// 获取扩展详情 +pub async fn get_extension_detail_handler( + State(svc_ctx): State, + Json(payload): Json, +) -> Result { + let extension = services::extensions::get_extension_service(&svc_ctx, &payload.extension_id) + .await + .map_err(|e| Response::fail(Some(&e)))?; + + Ok(Response::success(Some("获取成功"), Some(extension))) +} + +/// 获取扩展分类 +pub async fn get_extension_categories_handler( + State(svc_ctx): State, +) -> Result> { + let categories = services::extensions::get_extension_categories_service(&svc_ctx) + .await + .map_err(|e| Response::fail(Some(&e)))?; + + Ok(Response::success(Some("获取成功"), Some(categories))) +} + +/// 获取已安装的扩展 +pub async fn get_installed_extensions_handler( + State(svc_ctx): State, + Extension(ctx): Extension, + Json(payload): Json, +) -> Result { + let team_uuid = services::teams::get_current_team_service(&svc_ctx, ctx.user_uuid_unwrap()) + .await + .map_err(|e| Response::fail(Some(&e)))?; + + let (user_extensions, team_extensions) = match payload.scope.as_str() { + "user" | "personal" => { + let user_extensions = services::extensions::get_user_installed_extensions_service( + &svc_ctx, + ctx.user_uuid_unwrap(), + team_uuid, + ) + .await + .map_err(|e| Response::fail(Some(&e)))?; + (user_extensions, Vec::new()) + } + "team" => { + let team_uuid = team_uuid.ok_or_else(|| Response::fail(Some("未指定团队")))?; + let team_extensions = + services::extensions::get_team_installed_extensions_service(&svc_ctx, team_uuid) + .await + .map_err(|e| Response::fail(Some(&e)))?; + (Vec::new(), team_extensions) + } + _ => { + // "all" 或其他值,返回全部 + let user_extensions = services::extensions::get_user_installed_extensions_service( + &svc_ctx, + ctx.user_uuid_unwrap(), + team_uuid, + ) + .await + .map_err(|e| Response::fail(Some(&e)))?; + let team_extensions = if let Some(team_uuid) = team_uuid { + services::extensions::get_team_installed_extensions_service(&svc_ctx, team_uuid) + .await + .map_err(|e| Response::fail(Some(&e)))? + } else { + Vec::new() + }; + (user_extensions, team_extensions) + } + }; + + Ok(Response::success( + Some("获取成功"), + Some(InstalledExtensionsResponse { + user_extensions, + team_extensions, + }), + )) +} + +/// 安装扩展 +pub async fn install_extension_handler( + State(svc_ctx): State, + Extension(ctx): Extension, + Json(payload): Json, +) -> Result<()> { + let team_uuid = services::teams::get_current_team_service(&svc_ctx, ctx.user_uuid_unwrap()) + .await + .map_err(|e| Response::fail(Some(&e)))?; + + services::extensions::install_extension_service( + &svc_ctx, + ctx.user_uuid_unwrap(), + team_uuid, + &payload, + ) + .await + .map_err(|e| Response::fail(Some(&e)))?; + + Ok(Response::success(Some("安装成功"), None)) +} + +/// 卸载扩展 +pub async fn uninstall_extension_handler( + State(svc_ctx): State, + Extension(ctx): Extension, + Json(payload): Json, +) -> Result<()> { + let team_uuid = services::teams::get_current_team_service(&svc_ctx, ctx.user_uuid_unwrap()) + .await + .map_err(|e| Response::fail(Some(&e)))?; + + services::extensions::uninstall_extension_service( + &svc_ctx, + ctx.user_uuid_unwrap(), + team_uuid, + &payload, + ) + .await + .map_err(|e| Response::fail(Some(&e)))?; + + Ok(Response::success(Some("卸载成功"), None)) +} + +/// 更新扩展 +pub async fn update_extension_handler( + State(svc_ctx): State, + Extension(ctx): Extension, + Json(payload): Json, +) -> Result<()> { + services::extensions::update_extension_service( + &svc_ctx, + ctx.user_uuid_unwrap(), + &payload.extension_id, + ) + .await + .map_err(|e| Response::fail(Some(&e)))?; + + Ok(Response::success(Some("更新成功"), None)) +} + +/// 批量更新扩展 +pub async fn batch_update_extensions_handler( + State(svc_ctx): State, + Extension(ctx): Extension, + Json(payload): Json, +) -> Result { + let updated = services::extensions::batch_update_extensions_service( + &svc_ctx, + ctx.user_uuid_unwrap(), + &payload.extension_ids, + ) + .await + .map_err(|e| Response::fail(Some(&e)))?; + + Ok(Response::success( + Some("批量更新完成"), + Some(BatchUpdateResponse { updated }), + )) +} diff --git a/src/handlers/group_permissions.rs b/src/handlers/group_permissions.rs new file mode 100644 index 0000000..f9234b3 --- /dev/null +++ b/src/handlers/group_permissions.rs @@ -0,0 +1,138 @@ +use axum::{extract::Extension, extract::State}; + +use crate::audit_log; +use crate::entitys::{ + CheckGroupPermissionRequest, GrantGroupPermissionRequest, ListUserGroupPermissionsRequest, + RevokeGroupPermissionRequest, +}; +use crate::services; +use crate::state::RequestContext; +use crate::svc_ctx::SvcCtx; +use crate::utils::{Json, Response, Result}; + +/// 授予分组权限 +pub async fn grant_group_permission_handler( + State(svc_ctx): State, + Extension(ctx): Extension, + Json(payload): Json, +) -> Result<()> { + services::group_permissions::grant_group_permission_service( + &svc_ctx, + ctx.user_uuid_unwrap(), + &payload, + ) + .await + .map_err(|e| Response::fail(Some(&e)))?; + + audit_log!( + &svc_ctx, + &ctx, + "grant", + "group_permission", + payload.group_uuid, + &payload.permission_type, + "授予分组权限" + ) + .await; + + Ok(Response::success(Some("授权成功"), None)) +} + +/// 撤销分组权限 +pub async fn revoke_group_permission_handler( + State(svc_ctx): State, + Extension(ctx): Extension, + Json(payload): Json, +) -> Result<()> { + services::group_permissions::revoke_group_permission_service( + &svc_ctx, + ctx.user_uuid_unwrap(), + &payload, + ) + .await + .map_err(|e| Response::fail(Some(&e)))?; + + audit_log!( + &svc_ctx, + &ctx, + "revoke", + "group_permission", + payload.group_uuid, + "", + "撤销分组权限" + ) + .await; + + Ok(Response::success(Some("撤销成功"), None)) +} + +/// 检查分组权限 +pub async fn check_group_permission_handler( + State(svc_ctx): State, + Extension(ctx): Extension, + Json(payload): Json, +) -> Result { + let workspace_uuid = ctx.current_workspace_uuid.ok_or_else(|| Response::fail(Some("请先选择工作空间")))?; + let has_permission = + services::group_permissions::check_group_permission_service(&svc_ctx, workspace_uuid, &payload) + .await + .map_err(|e| Response::fail(Some(&e)))?; + + Ok(Response::success( + None, + Some(crate::entitys::CheckPermissionResponse { + has_permission, + permission_type: if has_permission { + Some(payload.permission_type.clone()) + } else { + None + }, + }), + )) +} + +/// 查询用户的分组权限列表 +pub async fn list_user_group_permissions_handler( + State(svc_ctx): State, + Extension(_ctx): Extension, + Json(payload): Json, +) -> Result { + let permissions = + services::group_permissions::list_user_group_permissions_service(&svc_ctx, &payload) + .await + .map_err(|e| Response::fail(Some(&e)))?; + + let total = permissions.len() as i64; + + // 转换为详情 DTO(包含用户信息) + let mut permission_details = vec![]; + for perm in permissions { + let user_info = crate::models::user::fetch_user_info_by_uuid(&svc_ctx.db, perm.user_uuid) + .await + .ok() + .flatten(); + + permission_details.push(crate::dto::GroupMemberPermissionDetailDto { + group_uuid: perm.group_uuid, + workspace_uuid: perm.workspace_uuid, + team_uuid: perm.team_uuid, + user_uuid: perm.user_uuid, + permission_type: perm.permission_type, + granted_by: perm.granted_by, + user_name: user_info.as_ref().and_then(|u| u.nickname.clone()), + user_email: user_info.as_ref().map(|u| u.email.clone()), + created_at: perm.created_at, + updated_at: perm.updated_at, + }); + } + + Ok(Response::success( + None, + Some(crate::entitys::GroupPermissionListResponse { + items: permission_details, + total, + page: payload.pagination.page, + page_size: payload.pagination.page_size, + }), + )) +} diff --git a/src/handlers/health.rs b/src/handlers/health.rs new file mode 100644 index 0000000..d10baa0 --- /dev/null +++ b/src/handlers/health.rs @@ -0,0 +1,15 @@ +use chrono::Utc; + +use crate::entitys::maintenance::HealthResponse; +use crate::utils::{Response, Result}; + +/// 健康检查 +pub async fn health_check_handler() -> Result { + Ok(Response::success( + Some("服务正常"), + Some(HealthResponse { + status: "ok".to_string(), + timestamp: Utc::now().to_rfc3339(), + }), + )) +} diff --git a/src/handlers/messages.rs b/src/handlers/messages.rs new file mode 100644 index 0000000..fed1553 --- /dev/null +++ b/src/handlers/messages.rs @@ -0,0 +1,133 @@ +use axum::{extract::Extension, extract::State}; + +use crate::entitys::{ + BatchMarkReadRequest, CreateMessageRequest, HandleMessageRequest, ListMessagesRequest, + MarkMessageReadRequest, MessageListResponse, MessageStatsResponse, +}; +use crate::services; +use crate::state::RequestContext; +use crate::svc_ctx::SvcCtx; +use crate::utils::{Json, Response, Result}; +use sqlx; + +/// 创建消息 +pub async fn create_message_handler( + State(svc_ctx): State, + Extension(ctx): Extension, + Json(payload): Json, +) -> Result { + let message_uuid = services::messages::create_message_service( + &svc_ctx, + Some(ctx.user_uuid_unwrap()), + &payload, + ) + .await + .map_err(|e| Response::fail(Some(&e)))?; + + Ok(Response::success( + Some("消息创建成功"), + Some(crate::entitys::CreateResponse { uuid: message_uuid }), + )) +} + +/// 获取用户消息列表 +pub async fn get_user_messages_handler( + State(svc_ctx): State, + Extension(ctx): Extension, + Json(payload): Json, +) -> Result { + let response = + services::messages::get_user_messages_service(&svc_ctx, ctx.user_uuid_unwrap(), &payload) + .await + .map_err(|e| Response::fail(Some(&e)))?; + + Ok(Response::success(Some("获取成功"), Some(response))) +} + +/// 标记消息为已读 +pub async fn mark_message_read_handler( + State(svc_ctx): State, + Extension(ctx): Extension, + Json(payload): Json, +) -> Result<()> { + services::messages::mark_message_read_service(&svc_ctx, ctx.user_uuid_unwrap(), &payload) + .await + .map_err(|e| Response::fail(Some(&e)))?; + + Ok(Response::success(Some("标记成功"), None)) +} + +/// 批量标记消息为已读 +pub async fn batch_mark_messages_read_handler( + State(svc_ctx): State, + Extension(ctx): Extension, + Json(payload): Json, +) -> Result<()> { + services::messages::batch_mark_messages_read_service( + &svc_ctx, + ctx.user_uuid_unwrap(), + &payload, + ) + .await + .map_err(|e| Response::fail(Some(&e)))?; + + Ok(Response::success(Some("批量标记成功"), None)) +} + +/// 处理消息(接受/拒绝) +pub async fn handle_message_handler( + State(svc_ctx): State, + Extension(ctx): Extension, + Json(payload): Json, +) -> Result<()> { + services::messages::handle_message_service(&svc_ctx, ctx.user_uuid_unwrap(), &payload) + .await + .map_err(|e| Response::fail(Some(&e)))?; + + Ok(Response::success(Some("处理成功"), None)) +} + +/// 获取用户消息统计 +pub async fn get_user_message_stats_handler( + State(svc_ctx): State, + Extension(ctx): Extension, +) -> Result { + let response = + services::messages::get_user_message_stats_service(&svc_ctx, ctx.user_uuid_unwrap()) + .await + .map_err(|e| Response::fail(Some(&e)))?; + + Ok(Response::success(Some("获取成功"), Some(response))) +} + +/// 删除消息 +pub async fn delete_message_handler( + State(svc_ctx): State, + Extension(ctx): Extension, + Json(payload): Json, +) -> Result<()> { + // 验证消息是否属于当前用户(通过查询 user_messages 表) + let user_message_exists: bool = sqlx::query_scalar( + r#" + SELECT EXISTS( + SELECT 1 FROM user_messages + WHERE message_uuid = $1 AND user_uuid = $2 + ) + "#, + ) + .bind(payload.uuid) + .bind(ctx.user_uuid_unwrap()) + .fetch_one(&svc_ctx.db) + .await + .map_err(|e| Response::fail(Some(&e.to_string())))?; + + if !user_message_exists { + return Err(Response::fail(Some("消息不存在或无权限"))); + } + + services::messages::delete_message_service(&svc_ctx, payload.uuid) + .await + .map_err(|e| Response::fail(Some(&e)))?; + + Ok(Response::success(Some("删除成功"), None)) +} diff --git a/src/handlers/preferences.rs b/src/handlers/preferences.rs new file mode 100644 index 0000000..3b3f5a0 --- /dev/null +++ b/src/handlers/preferences.rs @@ -0,0 +1,38 @@ +use axum::extract::{Extension, State}; + +use crate::dto::UserPreferenceDto; +use crate::entitys::settings::UpdatePreferencesRequest; +use crate::services; +use crate::state::RequestContext; +use crate::svc_ctx::SvcCtx; +use crate::utils::{Json, Response, Result}; + +/// 获取用户偏好设置 +pub async fn get_preferences_handler( + State(svc_ctx): State, + Extension(ctx): Extension, +) -> Result { + let preferences = + services::preferences::get_preferences_service(&svc_ctx, ctx.user_uuid_unwrap()) + .await + .map_err(|e| Response::fail(Some(&e)))?; + + Ok(Response::success(Some("获取成功"), Some(preferences))) +} + +/// 更新用户偏好设置 +pub async fn update_preferences_handler( + State(svc_ctx): State, + Extension(ctx): Extension, + Json(payload): Json, +) -> Result { + let preferences = services::preferences::update_preferences_service( + &svc_ctx, + ctx.user_uuid_unwrap(), + &payload, + ) + .await + .map_err(|e| Response::fail(Some(&e)))?; + + Ok(Response::success(Some("更新成功"), Some(preferences))) +} diff --git a/src/handlers/proxies.rs b/src/handlers/proxies.rs new file mode 100644 index 0000000..eaf7272 --- /dev/null +++ b/src/handlers/proxies.rs @@ -0,0 +1,167 @@ +use axum::{extract::Extension, extract::State}; + +use crate::audit_log; +use crate::entitys::{ + BatchImportProxiesRequest, BatchImportResponse, BatchUuidRequest, CreateProxyRequest, + CreateResponse, ListProxiesRequest, ProxyListResponse, UpdateProxyRequest, UuidRequest, +}; +use crate::services; +use crate::state::RequestContext; +use crate::svc_ctx::SvcCtx; +use crate::utils::{Json, Response, Result}; + +/// 获取代理列表 +pub async fn get_proxies_handler( + State(svc_ctx): State, + Extension(ctx): Extension, + Json(payload): Json, +) -> Result { + let workspace_uuid = ctx + .current_workspace_uuid + .ok_or_else(|| Response::fail(Some("请先选择工作空间")))?; + + let (proxies, total) = + services::proxies::get_proxies_service(&svc_ctx, ctx.user_uuid_unwrap(), workspace_uuid, &payload) + .await + .map_err(|e| Response::fail(Some(&e)))?; + + Ok(Response::success( + Some("获取成功"), + Some(ProxyListResponse { + items: proxies, + total, + page: payload.pagination.page, + page_size: payload.pagination.page_size, + }), + )) +} + +/// 获取代理详情 +pub async fn get_proxy_handler( + State(svc_ctx): State, + Json(payload): Json, +) -> Result { + let proxy = services::proxies::get_proxy_service(&svc_ctx, payload.uuid) + .await + .map_err(|e| Response::fail(Some(&e)))?; + + Ok(Response::success(Some("获取成功"), Some(proxy))) +} + +/// 创建代理 +pub async fn create_proxy_handler( + State(svc_ctx): State, + Extension(ctx): Extension, + Json(payload): Json, +) -> Result { + let workspace_uuid = ctx.current_workspace_uuid.ok_or_else(|| Response::fail(Some("请先选择工作空间")))?; + let proxy_uuid = + services::proxies::create_proxy_service(&svc_ctx, ctx.user_uuid_unwrap(), workspace_uuid, &payload) + .await + .map_err(|e| Response::fail(Some(&e)))?; + + audit_log!( + &svc_ctx, + &ctx, + "create", + "proxy", + proxy_uuid, + &payload.name, + "创建代理" + ) + .await; + + Ok(Response::success( + Some("创建成功"), + Some(CreateResponse { uuid: proxy_uuid }), + )) +} + +/// 更新代理 +pub async fn update_proxy_handler( + State(svc_ctx): State, + Json(payload): Json, +) -> Result<()> { + services::proxies::update_proxy_service(&svc_ctx, &payload) + .await + .map_err(|e| Response::fail(Some(&e)))?; + + Ok(Response::success(Some("更新成功"), None)) +} + +/// 删除代理 +pub async fn delete_proxy_handler( + State(svc_ctx): State, + Extension(ctx): Extension, + Json(payload): Json, +) -> Result<()> { + services::proxies::delete_proxy_service(&svc_ctx, payload.uuid) + .await + .map_err(|e| Response::fail(Some(&e)))?; + + audit_log!(&svc_ctx, &ctx, "delete", "proxy", payload.uuid, "删除代理").await; + + Ok(Response::success(Some("删除成功"), None)) +} + +/// 批量删除代理 +pub async fn batch_delete_proxies_handler( + State(svc_ctx): State, + Extension(ctx): Extension, + Json(payload): Json, +) -> Result { + let count = services::proxies::batch_delete_proxies_service(&svc_ctx, &payload.uuids) + .await + .map_err(|e| Response::fail(Some(&e)))?; + + audit_log!( + &svc_ctx, + &ctx, + "batch_delete", + "proxy", + &format!("批量删除 {} 个代理", count) + ) + .await; + + Ok(Response::success(Some("删除成功"), Some(count))) +} + +/// 批量导入代理 +pub async fn batch_import_proxies_handler( + State(svc_ctx): State, + Extension(ctx): Extension, + Json(payload): Json, +) -> Result { + // 获取工作空间 UUID(从请求或当前工作空间) + let workspace_uuid = ctx.current_workspace_uuid.ok_or_else(|| Response::fail(Some("请先选择工作空间")))?; + + let result = services::proxies::batch_import_proxies_service( + &svc_ctx, + ctx.user_uuid_unwrap(), + workspace_uuid, + &payload, + ) + .await + .map_err(|e| Response::fail(Some(&e)))?; + + audit_log!( + &svc_ctx, + &ctx, + "batch_import", + "proxy", + &format!( + "批量导入代理: 成功 {} 个, 失败 {} 个", + result.success_count, result.failed_count + ) + ) + .await; + + Ok(Response::success( + Some("导入完成"), + Some(BatchImportResponse { + success_count: result.success_count, + failed_count: result.failed_count, + errors: result.errors, + }), + )) +} diff --git a/src/handlers/proxy_visibility.rs b/src/handlers/proxy_visibility.rs new file mode 100644 index 0000000..1d37997 --- /dev/null +++ b/src/handlers/proxy_visibility.rs @@ -0,0 +1,150 @@ +use axum::{extract::Extension, extract::State}; + +use crate::audit_log; +use crate::entitys::{ + BatchSetProxyVisibleRequest, ListProxyVisibleTeamsRequest, ListVisibleProxiesRequest, + RemoveProxyVisibleRequest, SetProxyVisibleRequest, +}; +use crate::services; +use crate::state::RequestContext; +use crate::svc_ctx::SvcCtx; +use crate::utils::{Json, Response, Result}; + +/// 设置代理对团队可见 +pub async fn set_proxy_visible_handler( + State(svc_ctx): State, + Extension(ctx): Extension, + Json(payload): Json, +) -> Result<()> { + services::proxy_visibility::set_proxy_visible_to_team_service( + &svc_ctx, + ctx.user_uuid_unwrap(), + &payload, + ) + .await + .map_err(|e| Response::fail(Some(&e)))?; + + audit_log!( + &svc_ctx, + &ctx, + "update", + "proxy_visibility", + payload.proxy_uuid, + "", + "设置代理可见性" + ) + .await; + + Ok(Response::success(Some("设置成功"), None)) +} + +/// 移除代理对团队的可见性 +pub async fn remove_proxy_visible_handler( + State(svc_ctx): State, + Extension(ctx): Extension, + Json(payload): Json, +) -> Result<()> { + services::proxy_visibility::remove_proxy_visible_from_team_service( + &svc_ctx, + ctx.user_uuid_unwrap(), + &payload, + ) + .await + .map_err(|e| Response::fail(Some(&e)))?; + + audit_log!( + &svc_ctx, + &ctx, + "update", + "proxy_visibility", + payload.proxy_uuid, + "", + "移除代理可见性" + ) + .await; + + Ok(Response::success(Some("移除成功"), None)) +} + +/// 批量设置代理可见性 +pub async fn batch_set_proxy_visible_handler( + State(svc_ctx): State, + Extension(ctx): Extension, + Json(payload): Json, +) -> Result<()> { + services::proxy_visibility::batch_set_proxy_visible_service( + &svc_ctx, + ctx.user_uuid_unwrap(), + &payload, + ) + .await + .map_err(|e| Response::fail(Some(&e)))?; + + audit_log!( + &svc_ctx, + &ctx, + "update", + "proxy_visibility", + payload.proxy_uuid, + "", + "批量设置代理可见性" + ) + .await; + + Ok(Response::success(Some("设置成功"), None)) +} + +/// 获取可见的代理列表 +pub async fn get_visible_proxies_handler( + State(svc_ctx): State, + Extension(ctx): Extension, + Json(payload): Json, +) -> Result { + let proxies = services::proxy_visibility::get_visible_proxies_service( + &svc_ctx, + ctx.user_uuid_unwrap(), + &payload, + ) + .await + .map_err(|e| Response::fail(Some(&e)))?; + + Ok(Response::success( + None, + Some(crate::entitys::VisibleProxyListResponse { items: proxies }), + )) +} + +/// 获取代理的可见团队列表 +pub async fn get_proxy_visible_teams_handler( + State(svc_ctx): State, + Extension(_ctx): Extension, + Json(payload): Json, +) -> Result { + let teams = crate::models::fetch_visible_teams_by_proxy(&svc_ctx.db, payload.proxy_uuid) + .await + .map_err(|e| Response::fail(Some(&e.to_string())))?; + + // 转换为详情 DTO(包含团队名称) + let mut team_details = vec![]; + for team in teams { + let team_info = crate::models::fetch_team_by_uuid(&svc_ctx.db, team.team_uuid) + .await + .ok() + .flatten(); + + team_details.push(crate::dto::ProxyVisibleTeamDetailDto { + proxy_uuid: team.proxy_uuid, + workspace_uuid: team.workspace_uuid, + team_uuid: team.team_uuid, + team_name: team_info.map(|t| t.name), + created_at: team.created_at, + }); + } + + Ok(Response::success( + None, + Some(crate::entitys::ProxyVisibleTeamListResponse { + items: team_details, + }), + )) +} diff --git a/src/handlers/referral.rs b/src/handlers/referral.rs new file mode 100644 index 0000000..d1eb8fa --- /dev/null +++ b/src/handlers/referral.rs @@ -0,0 +1,210 @@ +use axum::extract::{Extension, State}; + +use crate::dto::{RedeemOptionDto, ReferralLinkTierDto, UserReferralPointsDto}; +use crate::entitys::{ + ListReferralRewardsRequest, ListReferredUsersRequest, Pagination, RedeemPointsRequest, + RedeemRecordsListResponse, RedeemResponse, ReferralDashboardResponse, + ReferralLinksListResponse, ReferralPlanSummaryResponse, ReferralRewardsListResponse, + ReferralStatsResponse, ReferredUsersListResponse, SwitchReferralLinkRequest, +}; +use crate::services; +use crate::state::RequestContext; +use crate::svc_ctx::SvcCtx; +use crate::utils::{Json, Response, Result}; + +/// 获取推广统计 +pub async fn get_referral_stats_handler( + State(svc_ctx): State, + Extension(ctx): Extension, +) -> Result { + let stats = services::referral::get_referral_stats_service(&svc_ctx, ctx.user_uuid_unwrap()) + .await + .map_err(|e| Response::fail(Some(&e)))?; + + Ok(Response::success(Some("获取成功"), Some(stats))) +} + +/// 获取推广看板聚合数据 +pub async fn get_referral_dashboard_handler( + State(svc_ctx): State, + Extension(ctx): Extension, +) -> Result { + let dashboard = services::referral::get_referral_dashboard_service(&svc_ctx, ctx.user_uuid_unwrap()) + .await + .map_err(|e| Response::fail(Some(&e)))?; + + Ok(Response::success(Some("获取成功"), Some(dashboard))) +} + +/// 获取套餐页推广摘要信息 +pub async fn get_referral_plan_summary_handler( + State(svc_ctx): State, + Extension(ctx): Extension, +) -> Result { + let summary = + services::referral::get_referral_plan_summary_service(&svc_ctx, ctx.user_uuid_unwrap()) + .await + .map_err(|e| Response::fail(Some(&e)))?; + + Ok(Response::success(Some("获取成功"), Some(summary))) +} + +/// 获取推广链接层级配置 +pub async fn get_referral_tiers_handler( + State(svc_ctx): State, +) -> Result> { + let tiers = services::referral::get_referral_tiers_service(&svc_ctx) + .await + .map_err(|e| Response::fail(Some(&e)))?; + + Ok(Response::success(Some("获取成功"), Some(tiers))) +} + +/// 获取推广链接列表 +pub async fn get_referral_links_handler( + State(svc_ctx): State, + Extension(ctx): Extension, +) -> Result { + let (items, current_link) = + services::referral::get_referral_links_service(&svc_ctx, ctx.user_uuid_unwrap()) + .await + .map_err(|e| Response::fail(Some(&e)))?; + + Ok(Response::success( + Some("获取成功"), + Some(ReferralLinksListResponse { + items, + current_link, + }), + )) +} + +/// 切换当前推广链接 +pub async fn switch_referral_link_handler( + State(svc_ctx): State, + Extension(ctx): Extension, + Json(payload): Json, +) -> Result<()> { + services::referral::switch_referral_link_service( + &svc_ctx, + ctx.user_uuid_unwrap(), + payload.link_uuid, + ) + .await + .map_err(|e| Response::fail(Some(&e)))?; + + Ok(Response::success(Some("切换成功"), None)) +} + +/// 获取奖励记录列表 +pub async fn get_referral_rewards_handler( + State(svc_ctx): State, + Extension(ctx): Extension, + Json(payload): Json, +) -> Result { + let (items, total) = services::referral::get_referral_rewards_service( + &svc_ctx, + ctx.user_uuid_unwrap(), + &payload, + ) + .await + .map_err(|e| Response::fail(Some(&e)))?; + + Ok(Response::success( + Some("获取成功"), + Some(ReferralRewardsListResponse { + items, + total, + page: payload.pagination.page, + page_size: payload.pagination.page_size, + }), + )) +} + +/// 获取被邀请用户列表 +pub async fn get_referred_users_handler( + State(svc_ctx): State, + Extension(ctx): Extension, + Json(payload): Json, +) -> Result { + let (items, total) = + services::referral::get_referred_users_service(&svc_ctx, ctx.user_uuid_unwrap(), &payload) + .await + .map_err(|e| Response::fail(Some(&e)))?; + + Ok(Response::success( + Some("获取成功"), + Some(ReferredUsersListResponse { + items, + total, + page: payload.pagination.page, + page_size: payload.pagination.page_size, + }), + )) +} + +/// 获取用户积分 +pub async fn get_user_points_handler( + State(svc_ctx): State, + Extension(ctx): Extension, +) -> Result { + let points = services::referral::get_user_points_service(&svc_ctx, ctx.user_uuid_unwrap()) + .await + .map_err(|e| Response::fail(Some(&e)))?; + + Ok(Response::success(Some("获取成功"), Some(points))) +} + +/// 获取兑换选项 +pub async fn get_redeem_options_handler( + State(svc_ctx): State, +) -> Result> { + let options = services::referral::get_redeem_options_service(&svc_ctx) + .await + .map_err(|e| Response::fail(Some(&e)))?; + + Ok(Response::success(Some("获取成功"), Some(options))) +} + +/// 执行积分兑换 +pub async fn redeem_points_handler( + State(svc_ctx): State, + Extension(ctx): Extension, + Json(payload): Json, +) -> Result { + let (record_uuid, points_used, value) = + services::referral::redeem_points_service(&svc_ctx, ctx.user_uuid_unwrap(), &payload) + .await + .map_err(|e| Response::fail(Some(&e)))?; + + Ok(Response::success( + Some("兑换成功"), + Some(RedeemResponse { + record_uuid, + points_used, + value, + }), + )) +} + +/// 获取兑换记录 +pub async fn get_redeem_records_handler( + State(svc_ctx): State, + Extension(ctx): Extension, + Json(payload): Json, +) -> Result { + let (items, total) = + services::referral::get_redeem_records_service(&svc_ctx, ctx.user_uuid_unwrap(), &payload) + .await + .map_err(|e| Response::fail(Some(&e)))?; + + Ok(Response::success( + Some("获取成功"), + Some(RedeemRecordsListResponse { + items, + total, + page: payload.page, + page_size: payload.page_size, + }), + )) +} diff --git a/src/handlers/rpa.rs b/src/handlers/rpa.rs new file mode 100644 index 0000000..329e08e --- /dev/null +++ b/src/handlers/rpa.rs @@ -0,0 +1,219 @@ +use axum::extract::{Extension, State}; + +use crate::dto::RpaTaskRunDto; +use crate::entitys::{ + BatchUuidRequest, CreateResponse, CreateRpaTaskRequest, DuplicateRpaTaskRequest, + ExportRpaTaskRequest, ExportRpaTaskResponse, ListRpaRunsRequest, ListRpaTasksRequest, + RpaRunsListResponse, RpaTaskDetailResponse, RpaTaskListResponse, RunRpaTaskRequest, + UpdateRpaTaskRequest, UuidRequest, +}; +use crate::services; +use crate::state::RequestContext; +use crate::svc_ctx::SvcCtx; +use crate::utils::{Json, Response, Result}; + +/// 获取 RPA 任务列表 +pub async fn get_rpa_tasks_handler( + State(svc_ctx): State, + Extension(ctx): Extension, + Json(payload): Json, +) -> Result { + let team_uuid = services::teams::get_current_team_service(&svc_ctx, ctx.user_uuid_unwrap()) + .await + .map_err(|e| Response::fail(Some(&e)))?; + + let (items, total) = + services::rpa::get_rpa_tasks_service(&svc_ctx, ctx.user_uuid_unwrap(), team_uuid, &payload) + .await + .map_err(|e| Response::fail(Some(&e)))?; + + Ok(Response::success( + Some("获取成功"), + Some(RpaTaskListResponse { + items, + total, + page: payload.pagination.page, + page_size: payload.pagination.page_size, + }), + )) +} + +/// 获取 RPA 任务详情 +pub async fn get_rpa_task_handler( + State(svc_ctx): State, + Json(payload): Json, +) -> Result { + let (task, steps, environment_uuids) = + services::rpa::get_rpa_task_service(&svc_ctx, payload.uuid) + .await + .map_err(|e| Response::fail(Some(&e)))?; + + Ok(Response::success( + Some("获取成功"), + Some(RpaTaskDetailResponse { + task, + steps, + environment_uuids, + }), + )) +} + +/// 创建 RPA 任务 +pub async fn create_rpa_task_handler( + State(svc_ctx): State, + Extension(ctx): Extension, + Json(payload): Json, +) -> Result { + let team_uuid = services::teams::get_current_team_service(&svc_ctx, ctx.user_uuid_unwrap()) + .await + .map_err(|e| Response::fail(Some(&e)))?; + + let uuid = services::rpa::create_rpa_task_service( + &svc_ctx, + ctx.user_uuid_unwrap(), + team_uuid, + &payload, + ) + .await + .map_err(|e| Response::fail(Some(&e)))?; + + Ok(Response::success( + Some("创建成功"), + Some(CreateResponse { uuid }), + )) +} + +/// 更新 RPA 任务 +pub async fn update_rpa_task_handler( + State(svc_ctx): State, + Json(payload): Json, +) -> Result<()> { + services::rpa::update_rpa_task_service(&svc_ctx, &payload) + .await + .map_err(|e| Response::fail(Some(&e)))?; + + Ok(Response::success(Some("更新成功"), None)) +} + +/// 删除 RPA 任务 +pub async fn delete_rpa_task_handler( + State(svc_ctx): State, + Json(payload): Json, +) -> Result<()> { + services::rpa::delete_rpa_task_service(&svc_ctx, payload.uuid) + .await + .map_err(|e| Response::fail(Some(&e)))?; + + Ok(Response::success(Some("删除成功"), None)) +} + +/// 批量删除 RPA 任务 +pub async fn batch_delete_rpa_tasks_handler( + State(svc_ctx): State, + Json(payload): Json, +) -> Result<()> { + services::rpa::batch_delete_rpa_tasks_service(&svc_ctx, &payload.uuids) + .await + .map_err(|e| Response::fail(Some(&e)))?; + + Ok(Response::success(Some("批量删除成功"), None)) +} + +/// 运行 RPA 任务 +pub async fn run_rpa_task_handler( + State(svc_ctx): State, + Json(payload): Json, +) -> Result { + let run_uuid = services::rpa::run_rpa_task_service(&svc_ctx, payload.uuid) + .await + .map_err(|e| Response::fail(Some(&e)))?; + + Ok(Response::success( + Some("任务已启动"), + Some(CreateResponse { uuid: run_uuid }), + )) +} + +/// 停止 RPA 任务 +pub async fn stop_rpa_task_handler( + State(svc_ctx): State, + Json(payload): Json, +) -> Result<()> { + services::rpa::stop_rpa_task_service(&svc_ctx, payload.uuid) + .await + .map_err(|e| Response::fail(Some(&e)))?; + + Ok(Response::success(Some("任务已停止"), None)) +} + +/// 复制 RPA 任务 +pub async fn duplicate_rpa_task_handler( + State(svc_ctx): State, + Extension(ctx): Extension, + Json(payload): Json, +) -> Result { + let team_uuid = services::teams::get_current_team_service(&svc_ctx, ctx.user_uuid_unwrap()) + .await + .map_err(|e| Response::fail(Some(&e)))?; + + let uuid = services::rpa::duplicate_rpa_task_service( + &svc_ctx, + ctx.user_uuid_unwrap(), + team_uuid, + &payload, + ) + .await + .map_err(|e| Response::fail(Some(&e)))?; + + Ok(Response::success( + Some("复制成功"), + Some(CreateResponse { uuid }), + )) +} + +/// 导出 RPA 任务 +pub async fn export_rpa_task_handler( + State(svc_ctx): State, + Json(payload): Json, +) -> Result { + let (content, filename) = services::rpa::export_rpa_task_service(&svc_ctx, payload.uuid) + .await + .map_err(|e| Response::fail(Some(&e)))?; + + Ok(Response::success( + Some("导出成功"), + Some(ExportRpaTaskResponse { content, filename }), + )) +} + +/// 获取执行记录列表 +pub async fn get_rpa_runs_handler( + State(svc_ctx): State, + Json(payload): Json, +) -> Result { + let (items, total) = services::rpa::get_rpa_runs_service(&svc_ctx, &payload) + .await + .map_err(|e| Response::fail(Some(&e)))?; + + Ok(Response::success( + Some("获取成功"), + Some(RpaRunsListResponse { + items, + total, + page: payload.pagination.page, + page_size: payload.pagination.page_size, + }), + )) +} + +/// 获取执行记录详情 +pub async fn get_rpa_run_handler( + State(svc_ctx): State, + Json(payload): Json, +) -> Result { + let run = services::rpa::get_rpa_run_service(&svc_ctx, payload.uuid) + .await + .map_err(|e| Response::fail(Some(&e)))?; + + Ok(Response::success(Some("获取成功"), Some(run))) +} diff --git a/src/handlers/secret.rs b/src/handlers/secret.rs new file mode 100644 index 0000000..09d968f --- /dev/null +++ b/src/handlers/secret.rs @@ -0,0 +1,15 @@ +use axum::http::StatusCode; +use axum::response::IntoResponse; + +use crate::utils::get_rsa_secret_instance; + +/// 获取服务器公钥 +/// +/// 返回服务器的 RSA 公钥(PEM 格式),用于客户端加密数据 +/// +/// # Returns +/// 返回纯文本格式的 PEM 公钥字符串 +pub async fn get_public_key_handler() -> impl IntoResponse { + let public_key = get_rsa_secret_instance().get_public_key(); + (StatusCode::OK, public_key).into_response() +} diff --git a/src/handlers/teams.rs b/src/handlers/teams.rs new file mode 100644 index 0000000..63c27f0 --- /dev/null +++ b/src/handlers/teams.rs @@ -0,0 +1,364 @@ +use axum::{extract::Extension, extract::State}; + +use crate::audit_log; +use crate::entitys::{ + AcceptInvitationRequest, AcceptInvitationResponse, CreateResponse, CreateTeamRequest, + InviteMemberRequest, InviteResponse, ListTeamMembersRequest, MemberListResponse, + RejectInvitationRequest, RemoveMemberRequest, SwitchTeamRequest, TeamItem, TeamListResponse, + UpdateMemberRoleRequest, UpdateTeamRequest, UuidRequest, +}; +use crate::services; +use crate::state::RequestContext; +use crate::svc_ctx::SvcCtx; +use crate::utils::{Json, Response, Result}; + +/// 创建团队 +pub async fn create_team_handler( + State(svc_ctx): State, + Extension(ctx): Extension, + Json(payload): Json, +) -> Result { + let team_uuid = + services::teams::create_team_service(&svc_ctx, ctx.user_uuid_unwrap(), &payload) + .await + .map_err(|e| Response::fail(Some(&e)))?; + + audit_log!( + &svc_ctx, + &ctx, + "create", + "team", + team_uuid, + &payload.name, + "创建团队" + ) + .await; + + Ok(Response::success( + Some("创建成功"), + Some(CreateResponse { uuid: team_uuid }), + )) +} + +/// 获取用户的所有团队 +pub async fn get_my_teams_handler( + State(svc_ctx): State, + Extension(ctx): Extension, +) -> Result { + let workspace_uuid = ctx.current_workspace_uuid.ok_or_else(|| Response::fail(Some("请先选择工作空间")))?; + let teams = services::teams::get_user_teams_service(&svc_ctx, workspace_uuid, ctx.user_uuid_unwrap()) + .await + .map_err(|e| Response::fail(Some(&e)))?; + + let current_team_uuid = + services::teams::get_current_team_service(&svc_ctx, ctx.user_uuid_unwrap()) + .await + .map_err(|e| Response::fail(Some(&e)))?; + + let mut team_items: Vec = vec![]; + + for t in &teams { + // 获取用户在该团队的角色 + let role = if t.owner_uuid == ctx.user_uuid_unwrap() { + "owner".to_string() + } else { + crate::models::fetch_team_member(&svc_ctx.db, workspace_uuid, t.uuid, ctx.user_uuid_unwrap()) + .await + .ok() + .flatten() + .map(|m| m.role) + .unwrap_or_else(|| "member".to_string()) + }; + + // 获取成员数量(不使用筛选,统计所有活跃成员) + let members_count = + crate::models::fetch_team_member_count(&svc_ctx.db, t.uuid, None, None, None) + .await + .unwrap_or(0); + + team_items.push(TeamItem { + uuid: t.uuid, + name: t.name.clone(), + description: t.description.clone(), + role, + members_count, + is_current: current_team_uuid == Some(t.uuid), + }); + } + + Ok(Response::success( + Some("获取成功"), + Some(TeamListResponse { + current_team_uuid, + teams: team_items, + }), + )) +} + +/// 切换团队 +pub async fn switch_team_handler( + State(svc_ctx): State, + Extension(ctx): Extension, + Json(payload): Json, +) -> Result<()> { + let workspace_uuid = ctx.current_workspace_uuid.ok_or_else(|| Response::fail(Some("请先选择工作空间")))?; + services::teams::switch_team_service(&svc_ctx, workspace_uuid, ctx.user_uuid_unwrap(), &payload) + .await + .map_err(|e| Response::fail(Some(&e)))?; + + Ok(Response::success(Some("切换成功"), None)) +} + +/// 获取团队详情 +pub async fn get_team_handler( + State(svc_ctx): State, + Json(payload): Json, +) -> Result { + let team = services::teams::get_team_service(&svc_ctx, payload.uuid) + .await + .map_err(|e| Response::fail(Some(&e)))?; + + Ok(Response::success(Some("获取成功"), Some(team))) +} + +/// 更新团队信息 +pub async fn update_team_handler( + State(svc_ctx): State, + Extension(ctx): Extension, + Json(payload): Json, +) -> Result<()> { + let workspace_uuid = ctx.current_workspace_uuid.ok_or_else(|| Response::fail(Some("请先选择工作空间")))?; + services::teams::update_team_service(&svc_ctx, workspace_uuid, ctx.user_uuid_unwrap(), &payload) + .await + .map_err(|e| Response::fail(Some(&e)))?; + + Ok(Response::success(Some("更新成功"), None)) +} + +/// 获取团队成员列表 +pub async fn get_team_members_handler( + State(svc_ctx): State, + Extension(ctx): Extension, + Json(payload): Json, +) -> Result { + // 获取用户当前团队 + let team_uuid = services::teams::get_current_team_service(&svc_ctx, ctx.user_uuid_unwrap()) + .await + .map_err(|e| Response::fail(Some(&e)))? + .ok_or_else(|| Response::fail(Some("请先选择团队")))?; + + let (members, total) = services::teams::get_team_members_service(&svc_ctx, team_uuid, &payload) + .await + .map_err(|e| Response::fail(Some(&e)))?; + + Ok(Response::success( + Some("获取成功"), + Some(MemberListResponse { + items: members, + total, + page: payload.pagination.page, + page_size: payload.pagination.page_size, + }), + )) +} + +/// 邀请成员 +pub async fn invite_member_handler( + State(svc_ctx): State, + Extension(ctx): Extension, + Json(payload): Json, +) -> Result { + let team_uuid = services::teams::get_current_team_service(&svc_ctx, ctx.user_uuid_unwrap()) + .await + .map_err(|e| Response::fail(Some(&e)))? + .ok_or_else(|| Response::fail(Some("请先选择团队")))?; + + let workspace_uuid = ctx.current_workspace_uuid.ok_or_else(|| Response::fail(Some("请先选择工作空间")))?; + let invitation_uuid = services::teams::invite_member_service( + &svc_ctx, + workspace_uuid, + team_uuid, + ctx.user_uuid_unwrap(), + &payload, + ) + .await + .map_err(|e| Response::fail(Some(&e)))?; + + audit_log!( + &svc_ctx, + &ctx, + "invite", + "team_member", + &format!("邀请成员: {}", payload.email) + ) + .await; + + Ok(Response::success( + Some("邀请已发送"), + Some(InviteResponse { invitation_uuid }), + )) +} + +/// 更新成员角色 +pub async fn update_member_role_handler( + State(svc_ctx): State, + Extension(ctx): Extension, + Json(payload): Json, +) -> Result { + let team_uuid = services::teams::get_current_team_service(&svc_ctx, ctx.user_uuid_unwrap()) + .await + .map_err(|e| Response::fail(Some(&e)))? + .ok_or_else(|| Response::fail(Some("请先选择团队")))?; + + let workspace_uuid = ctx.current_workspace_uuid.ok_or_else(|| Response::fail(Some("请先选择工作空间")))?; + let member = services::teams::update_member_role_service( + &svc_ctx, + workspace_uuid, + team_uuid, + ctx.user_uuid_unwrap(), + &payload, + ) + .await + .map_err(|e| Response::fail(Some(&e)))?; + + Ok(Response::success(Some("更新成功"), Some(member))) +} + +/// 移除成员 +pub async fn remove_member_handler( + State(svc_ctx): State, + Extension(ctx): Extension, + Json(payload): Json, +) -> Result<()> { + let team_uuid = services::teams::get_current_team_service(&svc_ctx, ctx.user_uuid_unwrap()) + .await + .map_err(|e| Response::fail(Some(&e)))? + .ok_or_else(|| Response::fail(Some("请先选择团队")))?; + + let workspace_uuid = ctx.current_workspace_uuid.ok_or_else(|| Response::fail(Some("请先选择工作空间")))?; + services::teams::remove_member_service( + &svc_ctx, + workspace_uuid, + team_uuid, + ctx.user_uuid_unwrap(), + payload.member_uuid, + ) + .await + .map_err(|e| Response::fail(Some(&e)))?; + + audit_log!( + &svc_ctx, + &ctx, + "remove", + "team_member", + payload.member_uuid, + "移除成员" + ) + .await; + + Ok(Response::success(Some("移除成功"), None)) +} + +/// 获取待处理邀请列表 +pub async fn get_pending_invitations_handler( + State(svc_ctx): State, + Extension(ctx): Extension, +) -> Result> { + let team_uuid = services::teams::get_current_team_service(&svc_ctx, ctx.user_uuid_unwrap()) + .await + .map_err(|e| Response::fail(Some(&e)))? + .ok_or_else(|| Response::fail(Some("请先选择团队")))?; + + let invitations = services::teams::get_pending_invitations_service(&svc_ctx, team_uuid) + .await + .map_err(|e| Response::fail(Some(&e)))?; + + Ok(Response::success(Some("获取成功"), Some(invitations))) +} + +/// 取消邀请 +pub async fn cancel_invitation_handler( + State(svc_ctx): State, + Json(payload): Json, +) -> Result<()> { + services::teams::cancel_invitation_service(&svc_ctx, payload.uuid) + .await + .map_err(|e| Response::fail(Some(&e)))?; + + Ok(Response::success(Some("邀请已取消"), None)) +} + +/// 接受邀请 +pub async fn accept_invitation_handler( + State(svc_ctx): State, + Extension(ctx): Extension, + Json(payload): Json, +) -> Result { + let team_uuid = services::teams::accept_invitation_service( + &svc_ctx, + ctx.user_uuid_unwrap(), + &payload.token, + ) + .await + .map_err(|e| Response::fail(Some(&e)))?; + + audit_log!( + &svc_ctx, + &ctx, + "accept_invitation", + "team", + team_uuid, + "接受团队邀请" + ) + .await; + + Ok(Response::success( + Some("已加入团队"), + Some(AcceptInvitationResponse { team_uuid }), + )) +} + +/// 拒绝邀请 +pub async fn reject_invitation_handler( + State(svc_ctx): State, + Extension(ctx): Extension, + Json(payload): Json, +) -> Result<()> { + services::teams::reject_invitation_service(&svc_ctx, ctx.user_uuid_unwrap(), &payload.token) + .await + .map_err(|e| Response::fail(Some(&e)))?; + + audit_log!(&svc_ctx, &ctx, "reject_invitation", "team", "拒绝团队邀请").await; + + Ok(Response::success(Some("已拒绝邀请"), None)) +} + +/// 退出团队 +pub async fn leave_team_handler( + State(svc_ctx): State, + Extension(ctx): Extension, +) -> Result<()> { + // 获取当前团队 + let current_team_uuid = + services::teams::get_current_team_service(&svc_ctx, ctx.user_uuid_unwrap()) + .await + .map_err(|e| Response::fail(Some(&e)))? + .ok_or_else(|| Response::fail(Some("当前没有选择团队")))?; + + let workspace_uuid = ctx.current_workspace_uuid.ok_or_else(|| Response::fail(Some("请先选择工作空间")))?; + services::teams::leave_team_service(&svc_ctx, workspace_uuid, current_team_uuid, ctx.user_uuid_unwrap()) + .await + .map_err(|e| Response::fail(Some(&e)))?; + + audit_log!( + &svc_ctx, + &ctx, + "leave", + "team", + current_team_uuid, + "退出团队" + ) + .await; + + Ok(Response::success(Some("已退出团队"), None)) +} diff --git a/src/handlers/templates.rs b/src/handlers/templates.rs new file mode 100644 index 0000000..d67d584 --- /dev/null +++ b/src/handlers/templates.rs @@ -0,0 +1,157 @@ +use axum::{extract::Extension, extract::State}; + +use crate::entitys::{ + CreateFromTemplateRequest, CreateResponse, CreateTemplateRequest, GetTemplateRequest, + ListTemplatesRequest, TemplateDetailResponse, TemplateListResponse, UpdateTemplateRequest, + UuidRequest, +}; +use crate::services; +use crate::state::RequestContext; +use crate::svc_ctx::SvcCtx; +use crate::utils::{Json, Response, Result}; + +/// 获取模板列表 +pub async fn get_templates_handler( + State(svc_ctx): State, + Extension(ctx): Extension, + Json(payload): Json, +) -> Result { + let team_uuid = services::teams::get_current_team_service(&svc_ctx, ctx.user_uuid_unwrap()) + .await + .map_err(|e| Response::fail(Some(&e)))?; + + let (templates, total) = services::templates::get_templates_service( + &svc_ctx, + ctx.user_uuid_unwrap(), + team_uuid, + payload.is_public, + payload.pagination.page, + payload.pagination.page_size, + ) + .await + .map_err(|e| Response::fail(Some(&e)))?; + + Ok(Response::success( + Some("获取成功"), + Some(TemplateListResponse { + items: templates, + total, + page: payload.pagination.page, + page_size: payload.pagination.page_size, + }), + )) +} + +/// 获取模板详情 +pub async fn get_template_handler( + State(svc_ctx): State, + Json(payload): Json, +) -> Result { + let result = services::templates::get_template_service( + &svc_ctx, + payload.uuid, + payload.for_create.unwrap_or(false), + ) + .await + .map_err(|e| Response::fail(Some(&e)))?; + + Ok(Response::success(Some("获取成功"), Some(result))) +} + +/// 创建模板 +pub async fn create_template_handler( + State(svc_ctx): State, + Extension(ctx): Extension, + Json(payload): Json, +) -> Result { + let team_uuid = services::teams::get_current_team_service(&svc_ctx, ctx.user_uuid_unwrap()) + .await + .map_err(|e| Response::fail(Some(&e)))?; + + let template_uuid = services::templates::create_template_service( + &svc_ctx, + ctx.user_uuid_unwrap(), + team_uuid, + &payload, + ) + .await + .map_err(|e| Response::fail(Some(&e)))?; + + Ok(Response::success( + Some("创建成功"), + Some(CreateResponse { + uuid: template_uuid, + }), + )) +} + +/// 更新模板 +pub async fn update_template_handler( + State(svc_ctx): State, + Json(payload): Json, +) -> Result<()> { + services::templates::update_template_service(&svc_ctx, &payload) + .await + .map_err(|e| Response::fail(Some(&e)))?; + + Ok(Response::success(Some("更新成功"), None)) +} + +/// 删除模板 +pub async fn delete_template_handler( + State(svc_ctx): State, + Json(payload): Json, +) -> Result<()> { + services::templates::delete_template_service(&svc_ctx, payload.uuid) + .await + .map_err(|e| Response::fail(Some(&e)))?; + + Ok(Response::success(Some("删除成功"), None)) +} + +/// 应用模板到环境(更新环境配置为模板配置) +pub async fn apply_template_handler( + State(svc_ctx): State, + Extension(ctx): Extension, + Json(payload): Json, +) -> Result<()> { + let workspace_uuid = ctx.current_workspace_uuid.ok_or_else(|| Response::fail(Some("请先选择工作空间")))?; + let team_uuid = ctx.current_team_uuid.ok_or_else(|| Response::fail(Some("请先选择团队")))?; + + services::templates::apply_template_service( + &svc_ctx, + workspace_uuid, + team_uuid, + ctx.user_uuid_unwrap(), + &payload, + ) + .await + .map_err(|e| Response::fail(Some(&e)))?; + + Ok(Response::success(Some("应用成功"), None)) +} + +/// 从模板创建环境 +pub async fn create_from_template_handler( + State(svc_ctx): State, + Extension(ctx): Extension, + Json(payload): Json, +) -> Result { + let workspace_uuid = ctx.current_workspace_uuid.ok_or_else(|| Response::fail(Some("请先选择工作空间")))?; + let team_uuid = ctx.current_team_uuid.ok_or_else(|| Response::fail(Some("请先选择团队")))?; + + let env_uuid = services::templates::create_from_template_service( + &svc_ctx, + ctx.user_uuid_unwrap(), + workspace_uuid, + team_uuid, + &payload, + ) + .await + .map_err(|e| Response::fail(Some(&e)))?; + + Ok(Response::success( + Some("创建成功"), + Some(CreateResponse { uuid: env_uuid }), + )) +} diff --git a/src/handlers/time.rs b/src/handlers/time.rs new file mode 100644 index 0000000..7663890 --- /dev/null +++ b/src/handlers/time.rs @@ -0,0 +1,10 @@ +use crate::{ + services::now_service, + utils::{Response, Result}, +}; + +pub async fn now_handle() -> Result { + let now = now_service(); + + Ok(Response::::success(Some("获取时间成功"), Some(now))) +} diff --git a/src/handlers/users.rs b/src/handlers/users.rs new file mode 100644 index 0000000..fc0ae8f --- /dev/null +++ b/src/handlers/users.rs @@ -0,0 +1,137 @@ +use axum::{extract::Extension, extract::State}; + +use crate::audit_log; +use crate::entitys::{ + LoginRequest, LoginResponse, RegisterRequest, RegisterResponse, ResetPasswordRequest, + SendCodeRequest, UpdatePasswordRequest, UpdateUserRequest, UserResponse, +}; +use crate::services::audit::log_audit_anonymous; +use crate::services::users::{ + get_current_user_service, login_service, refresh_token_service, register_service, + reset_password_service, send_verification_code_service, update_password_service, + update_user_service, +}; +use crate::state::RequestContext; +use crate::svc_ctx::SvcCtx; +use crate::utils::{Json, Response, Result}; + +/// 用户注册处理 +pub async fn register_handler( + State(svc_ctx): State, + Extension(ctx): Extension, + Json(payload): Json, +) -> Result { + let result = register_service(&svc_ctx, &payload) + .await + .map_err(|e| Response::fail(Some(&e.to_string())))?; + + // 记录审计日志(注册时使用返回的 user_uuid) + if let Some(ref user_info) = result.user_info { + log_audit_anonymous( + &svc_ctx, + &ctx, + user_info.uuid, + "register", + "user", + "用户注册", + ) + .await; + } + + Ok(Response::success(Some("注册成功"), Some(result))) +} + +/// 用户登录处理 +pub async fn login_handler( + State(svc_ctx): State, + Extension(ctx): Extension, + Json(payload): Json, +) -> Result { + let result = login_service(&svc_ctx, payload) + .await + .map_err(|e| Response::fail(Some(&e.to_string())))?; + + // 记录审计日志(登录时使用返回的 user_uuid) + if let Some(ref user_info) = result.user_info { + log_audit_anonymous(&svc_ctx, &ctx, user_info.uuid, "login", "user", "用户登录").await; + } + + Ok(Response::success(Some("登录成功"), Some(result))) +} + +/// 刷新 Token 处理 +pub async fn refresh_token_handler( + State(svc_ctx): State, + Json(payload): Json, +) -> Result { + let result = refresh_token_service(&svc_ctx, &payload.refresh_token) + .await + .map_err(|e| Response::fail(Some(&e.to_string())))?; + + Ok(Response::success(Some("刷新成功"), Some(result))) +} + +/// 获取当前用户信息处理 +pub async fn get_current_user_handler( + State(svc_ctx): State, + Extension(ctx): Extension, +) -> Result { + let user_info = get_current_user_service(&svc_ctx, ctx.user_uuid_unwrap()) + .await + .map_err(|e| Response::fail(Some(&e.to_string())))?; + + Ok(Response::success(Some("获取成功"), Some(user_info))) +} + +/// 更新用户信息处理 +pub async fn update_user_handler( + State(svc_ctx): State, + Extension(ctx): Extension, + Json(payload): Json, +) -> Result<()> { + update_user_service(&svc_ctx, ctx.user_uuid_unwrap(), &payload) + .await + .map_err(|e| Response::fail(Some(&e.to_string())))?; + + Ok(Response::success(Some("更新成功"), None)) +} + +/// 修改密码处理 +pub async fn update_password_handler( + State(svc_ctx): State, + Extension(ctx): Extension, + Json(payload): Json, +) -> Result<()> { + update_password_service(&svc_ctx, ctx.user_uuid_unwrap(), &payload) + .await + .map_err(|e| Response::fail(Some(&e.to_string())))?; + + // 记录审计日志 + audit_log!(&svc_ctx, &ctx, "update_password", "user", "修改密码").await; + + Ok(Response::success(Some("修改成功"), None)) +} + +/// 重置密码处理 +pub async fn reset_password_handler( + State(svc_ctx): State, + Json(payload): Json, +) -> Result<()> { + reset_password_service(&svc_ctx, &payload) + .await + .map_err(|e| Response::fail(Some(&e.to_string())))?; + + Ok(Response::success(Some("重置成功"), None)) +} + +/// 发送验证码处理 +pub async fn send_code_handler( + State(svc_ctx): State, + Json(payload): Json, +) -> Result<()> { + send_verification_code_service(&svc_ctx, &payload.email, &payload.r#type) + .await + .map_err(|e| Response::fail(Some(&e.to_string())))?; + + Ok(Response::success(Some("发送成功"), None)) +} diff --git a/src/handlers/workspace_quotas.rs b/src/handlers/workspace_quotas.rs new file mode 100644 index 0000000..db63159 --- /dev/null +++ b/src/handlers/workspace_quotas.rs @@ -0,0 +1,33 @@ +use axum::{extract::Extension, extract::State}; + +use crate::entitys::{GetWorkspaceQuotaRequest, UpdateQuotaUsageRequest}; +use crate::services; +use crate::state::RequestContext; +use crate::svc_ctx::SvcCtx; +use crate::utils::{Json, Response, Result}; + +/// 获取工作空间配额 +pub async fn get_workspace_quota_handler( + State(svc_ctx): State, + Extension(_ctx): Extension, + Json(payload): Json, +) -> Result { + let quota = services::workspace_quotas::get_workspace_quota_service(&svc_ctx, &payload) + .await + .map_err(|e| Response::fail(Some(&e)))?; + + Ok(Response::success(None, Some(quota))) +} + +/// 更新配额使用情况 +pub async fn update_quota_usage_handler( + State(svc_ctx): State, + Extension(_ctx): Extension, + Json(payload): Json, +) -> Result<()> { + services::workspace_quotas::update_quota_usage_service(&svc_ctx, &payload) + .await + .map_err(|e| Response::fail(Some(&e)))?; + + Ok(Response::success(Some("更新成功"), None)) +} diff --git a/src/handlers/workspaces.rs b/src/handlers/workspaces.rs new file mode 100644 index 0000000..b6fbae3 --- /dev/null +++ b/src/handlers/workspaces.rs @@ -0,0 +1,180 @@ +use axum::{extract::Extension, extract::State}; + +use crate::audit_log; +use crate::entitys::{ + CreateResponse, CreateWorkspaceRequest, SwitchWorkspaceRequest, UpdateWorkspaceRequest, + WorkspaceItem, WorkspaceListResponse, +}; +use crate::services; +use crate::state::RequestContext; +use crate::svc_ctx::SvcCtx; +use crate::utils::{Json, Response, Result}; + +/// 创建工作空间 +pub async fn create_workspace_handler( + State(svc_ctx): State, + Extension(ctx): Extension, + Json(payload): Json, +) -> Result { + let workspace_uuid = + services::workspaces::create_workspace_service(&svc_ctx, ctx.user_uuid_unwrap(), &payload) + .await + .map_err(|e| Response::fail(Some(&e)))?; + + audit_log!( + &svc_ctx, + &ctx, + "create", + "workspace", + workspace_uuid, + &payload.name, + "创建工作空间" + ) + .await; + + Ok(Response::success( + Some("创建成功"), + Some(CreateResponse { + uuid: workspace_uuid, + }), + )) +} + +/// 获取用户的所有工作空间 +pub async fn get_my_workspaces_handler( + State(svc_ctx): State, + Extension(ctx): Extension, +) -> Result { + let user_uuid = ctx.user_uuid_unwrap(); + let workspaces = services::workspaces::get_user_workspaces_service(&svc_ctx, user_uuid) + .await + .map_err(|e| Response::fail(Some(&e)))?; + + // 从 RequestContext 获取当前工作空间 UUID(已在 auth 中间件中设置) + let mut current_workspace_uuid = ctx.current_workspace_uuid; + + // 如果用户没有设置当前工作空间,但有工作空间列表,自动设置为第一个工作空间 + if current_workspace_uuid.is_none() && !workspaces.is_empty() { + let first_workspace_uuid = workspaces[0].uuid; + // 更新数据库 + if let Err(e) = crate::models::user::set_user_current_workspace( + &svc_ctx.db, + user_uuid, + first_workspace_uuid, + ) + .await + { + tracing::warn!("Failed to set default workspace: {}", e); + } else { + current_workspace_uuid = Some(first_workspace_uuid); + } + } + + let workspace_items: Vec = workspaces + .iter() + .map(|w| WorkspaceItem { + uuid: w.uuid, + name: w.name.clone(), + workspace_type: w.workspace_type.clone(), + is_current: current_workspace_uuid == Some(w.uuid), + }) + .collect(); + + Ok(Response::success( + None, + Some(WorkspaceListResponse { + current_workspace_uuid, + workspaces: workspace_items, + }), + )) +} + +/// 获取工作空间详情 +pub async fn get_workspace_handler( + State(svc_ctx): State, + Extension(_ctx): Extension, + Json(payload): Json, +) -> Result { + let workspace = services::workspaces::get_workspace_service(&svc_ctx, payload.uuid) + .await + .map_err(|e| Response::fail(Some(&e)))?; + + Ok(Response::success(None, Some(workspace))) +} + +/// 更新工作空间 +pub async fn update_workspace_handler( + State(svc_ctx): State, + Extension(ctx): Extension, + Json(payload): Json, +) -> Result<()> { + services::workspaces::update_workspace_service(&svc_ctx, ctx.user_uuid_unwrap(), &payload) + .await + .map_err(|e| Response::fail(Some(&e)))?; + + audit_log!( + &svc_ctx, + &ctx, + "update", + "workspace", + payload.uuid, + &payload.name.as_deref().unwrap_or(""), + "更新工作空间" + ) + .await; + + Ok(Response::success(Some("更新成功"), None)) +} + +/// 删除工作空间 +pub async fn delete_workspace_handler( + State(svc_ctx): State, + Extension(ctx): Extension, + Json(payload): Json, +) -> Result<()> { + services::workspaces::delete_workspace_service(&svc_ctx, ctx.user_uuid_unwrap(), payload.uuid) + .await + .map_err(|e| Response::fail(Some(&e)))?; + + audit_log!( + &svc_ctx, + &ctx, + "delete", + "workspace", + payload.uuid, + "", + "删除工作空间" + ) + .await; + + Ok(Response::success(Some("删除成功"), None)) +} + +/// 切换工作空间 +pub async fn switch_workspace_handler( + State(svc_ctx): State, + Extension(ctx): Extension, + Json(payload): Json, +) -> Result<()> { + let user_uuid = ctx.user_uuid_unwrap(); + + // 检查用户是否是工作空间所有者 + let is_owner = services::workspaces::check_workspace_owner_service( + &svc_ctx, + payload.workspace_uuid, + user_uuid, + ) + .await + .map_err(|e| Response::fail(Some(&e)))?; + + if !is_owner { + return Err(Response::fail(Some("您不是该工作空间的所有者"))); + } + + // 更新用户当前工作空间 + crate::models::user::set_user_current_workspace(&svc_ctx.db, user_uuid, payload.workspace_uuid) + .await + .map_err(|e| Response::fail(Some(&e.to_string())))?; + + Ok(Response::success(Some("切换成功"), None)) +} diff --git a/src/lib.rs b/src/lib.rs new file mode 100644 index 0000000..fe4e401 --- /dev/null +++ b/src/lib.rs @@ -0,0 +1,46 @@ +use crate::utils::IConfig; + +pub mod caches; +pub mod dto; +pub mod entitys; +pub mod errors; +pub mod handlers; +pub mod middlewares; +pub mod models; +pub mod routes; +pub mod services; +pub mod state; +pub mod svc_ctx; +pub mod utils; + +/// 初始化加密密钥 +pub async fn init_encrypt_secret(config: &IConfig) { + let key_path = &config.app.encrypt_secret_location; + utils::init_rsa_secret(key_path).await; +} + +/// 初始化minio +pub async fn init_minio(config: &IConfig) { + let minio_config = &config.clone().minio.expect("minio配置不存在"); + + let client = { + utils::init_minio( + &minio_config.server_url, + &minio_config.access_key, + &minio_config.secret_access_key, + None, + None, + ) + .await + .expect("初始化Minio客户端失败") + }; + + // 初始化存储桶 + client + .init_bucket(vec![ + minio_config.avatar_bucket.clone(), + minio_config.extension_bucket.clone(), + ]) + .await + .expect("初始化Minio桶失败"); +} diff --git a/src/main.rs b/src/main.rs new file mode 100644 index 0000000..cabc34b --- /dev/null +++ b/src/main.rs @@ -0,0 +1,116 @@ +use std::env; + +use axum::{Router, middleware}; +use simprint_server::{init_encrypt_secret, init_minio}; +use simprint_server::{ + middlewares, + routes::{ + accounts, api, audit, billing, environments, extensions, group_permissions, messages, + preferences, proxies, proxy_visibility, referral, rpa, secret, teams, templates, time, + users, workspace_quotas, workspaces, + }, + routes::route::MetaRoute, + svc_ctx::SvcCtx, + utils::IConfig, +}; + +#[tokio::main] +async fn main() -> Result<(), Box> { + // 支持接收-f参数,指定配置文件路径 + let config_path = env::args() + .find(|arg| arg.starts_with("-f")) + .map(|arg| arg.split("=").nth(1).unwrap().to_string()) + .expect("config path is required"); + + let config = IConfig::build_by_filepath(&config_path).expect("failed to build config"); + + if let Err(e) = run_server(config).await { + return Err(e.into()); + } + + Ok(()) +} + +async fn run_server(config: IConfig) -> Result<(), Box> { + // 启用更详细的日志以调试中间件问题 + tracing_subscriber::fmt() + .with_max_level(tracing::Level::INFO) + .with_target(true) + .with_thread_ids(true) + .init(); + + let svc_ctx = SvcCtx::new(&config).await?; + + // init resources + init_encrypt_secret(&config).await; + init_minio(&config).await; + + let port = &config.app.port; + let listener = tokio::net::TcpListener::bind(format!("0.0.0.0:{}", port)) + .await + .expect("Failed to create listener"); + + let app = register_all_routes(&svc_ctx); + let app = register_middlewares(&svc_ctx, app); + let app = app.with_state(svc_ctx); + + tracing::info!("Starting server on port {}", port); + + axum::serve(listener, app).await?; + Ok(()) +} + +/// 注册所有路由 +fn register_all_routes(svc_ctx: &SvcCtx) -> Router { + let mut meta_route = MetaRoute::new(svc_ctx.config.app.prefix.clone()); + + secret::register_routes(&mut meta_route); + time::register_routes(&mut meta_route); + users::register_routes(&mut meta_route); + + // 新增路由 + workspaces::register_routes(&mut meta_route); + workspace_quotas::register_routes(&mut meta_route); + teams::register_routes(&mut meta_route); + environments::register_routes(&mut meta_route); + proxies::register_routes(&mut meta_route); + proxy_visibility::register_routes(&mut meta_route); + group_permissions::register_routes(&mut meta_route); + accounts::register_routes(&mut meta_route); + templates::register_routes(&mut meta_route); + billing::register_routes(&mut meta_route); + audit::register_routes(&mut meta_route); + rpa::register_routes(&mut meta_route); + referral::register_routes(&mut meta_route); + extensions::register_routes(&mut meta_route); + api::register_routes(&mut meta_route); + preferences::register_routes(&mut meta_route); + messages::register_routes(&mut meta_route); + + tracing::info!("---------- {:?} ----------", meta_route.count()); + meta_route.build() +} + +/// 注册中间件 +/// 中间件顺序保持与原 routes.rs 一致 +fn register_middlewares(svc_ctx: &SvcCtx, app: Router) -> Router { + app.route_layer(middleware::from_fn_with_state( + svc_ctx.clone(), + middlewares::encrypt, + )) + .route_layer(middleware::from_fn_with_state( + svc_ctx.clone(), + middlewares::auth, + )) + .route_layer(middleware::from_fn_with_state( + svc_ctx.clone(), + middlewares::machine_auth, + )) + .route_layer(middleware::from_fn_with_state( + svc_ctx.clone(), + middlewares::decrypt, + )) + .route_layer(middleware::from_fn(middlewares::real_ip)) + .route_layer(middleware::from_fn(middlewares::logger)) + .layer(middlewares::cors()) +} diff --git a/src/middlewares.rs b/src/middlewares.rs new file mode 100644 index 0000000..0fb4b93 --- /dev/null +++ b/src/middlewares.rs @@ -0,0 +1,33 @@ +mod admin_permission; +mod auth; +mod cors; +mod decrypt; +mod dual_auth; +mod encrypt; +mod logger; +mod machine_auth; +mod real_ip; + +pub use admin_permission::*; +pub use auth::*; +pub use cors::*; +pub use decrypt::*; +pub use dual_auth::*; +pub use encrypt::*; +pub use logger::*; +pub use machine_auth::*; +pub use real_ip::*; + +use axum::extract::{MatchedPath, Request}; + +/// 获取请求路径和请求方法 +pub fn get_request_path_and_method(req: &Request) -> (&str, &str) { + let path = req + .extensions() + .get::() + .map_or_else(|| req.uri().path(), |path| path.as_str()); + + let method = req.method().as_str(); + + (method, path) +} diff --git a/src/middlewares/admin_permission.rs b/src/middlewares/admin_permission.rs new file mode 100644 index 0000000..dc1a192 --- /dev/null +++ b/src/middlewares/admin_permission.rs @@ -0,0 +1,73 @@ +//! 管理员权限检查中间件 +//! +//! 对通过 JWT 认证的用户进行细粒度权限检查 +//! API Key 认证的请求直接通过(不进行权限检查) + +use axum::{ + extract::{Request, State}, + http::StatusCode, + middleware::Next, + response::Response, +}; + +use crate::{ + middlewares::get_request_path_and_method, models::admin, state::CurrentUser, svc_ctx::SvcCtx, +}; + +/// 管理员权限检查中间件 +/// +/// 认证逻辑: +/// 1. 检查是否为 OPTIONS 请求,直接放行 +/// 2. 检查是否有 CurrentUser 扩展(只有 JWT 认证才会有) +/// - 有:检查用户是否为管理员且拥有访问该路由的权限 +/// - 无:说明是 API Key 认证,直接放行 +/// +/// 权限检查: +/// - 通过 user_uuid 查询 console_admins 表确认是否为管理员 +/// - 通过 console_admin_permissions 和 console_permissions 表检查权限 +pub async fn admin_permission_check( + State(state): State, + req: Request, + next: Next, +) -> Result { + let (method, path) = get_request_path_and_method(&req); + + // OPTIONS 请求直接通过 + if method.eq("OPTIONS") { + return Ok(next.run(req).await); + } + + // 检查是否有当前用户信息(只有 JWT 认证的请求才会有) + if let Some(current_user) = req.extensions().get::() { + let user_uuid = current_user.user_uuid; + + // 检查用户是否为管理员以及是否有访问权限 + match admin::check_admin_permission(&state.db, user_uuid, path, method).await { + Ok(has_permission) => { + if !has_permission { + tracing::warn!( + "Admin permission denied for user {} on {} {}", + user_uuid, + method, + path + ); + return Err(StatusCode::FORBIDDEN); + } + } + Err(e) => { + tracing::error!("Failed to check admin permission: {}", e); + return Err(StatusCode::INTERNAL_SERVER_ERROR); + } + } + + tracing::debug!( + "Admin permission granted for user {} on {} {}", + user_uuid, + method, + path + ); + } + // 如果没有 CurrentUser 扩展,说明是 API Key 认证,直接通过 + + Ok(next.run(req).await) +} diff --git a/src/middlewares/auth.rs b/src/middlewares/auth.rs new file mode 100644 index 0000000..166d253 --- /dev/null +++ b/src/middlewares/auth.rs @@ -0,0 +1,104 @@ +use std::str::FromStr; + +use axum::{ + extract::{Request, State}, + http::{StatusCode, header}, + middleware::Next, + response::Response, +}; +use uuid::Uuid; + +use crate::{ + middlewares::get_request_path_and_method, + services, + state::{CurrentUser, RequestContext}, + svc_ctx::SvcCtx, +}; + +pub async fn auth( + State(state): State, + mut req: Request, + next: Next, +) -> Result { + let (resource_method, resource_path) = get_request_path_and_method(&req); + + if resource_method.eq("OPTIONS") { + return Ok(next.run(req).await); + } + + // 判断请求的是否在白名单,如果是白名单就直接允许访问。 + { + let combine = format!("{}+{}", resource_method, resource_path); + let whitelists = &state.config.app.route_whitelists; + + if whitelists.contains(&combine) { + return Ok(next.run(req).await); + } + } + + let auth_header = req + .headers() + .get(header::AUTHORIZATION) + .and_then(|header| header.to_str().ok()) + .ok_or_else(|| StatusCode::UNAUTHORIZED)?; + + let token_str = if auth_header.starts_with("Bearer ") { + &auth_header[7..] + } else { + return Err(StatusCode::UNAUTHORIZED); + }; + + // 先判断token是否正常,如果正常再去与远程的匹配。匹配成功后返回uuid. + let secret = state.config.app.secret.as_bytes(); + let verify_token_response = services::verify_token_service(token_str, secret); + + if let Err(_e) = verify_token_response { + return Err(StatusCode::UNAUTHORIZED); + } + + let verify_token_response = verify_token_response.unwrap(); + + // TODO: 根据获取到的uuid可以获取到用户并调用检查权限的方法。 + // 2025-08-27 10:05:00: 权限检查可能移除, 该网关服务只面向用户 + let uuid = if let Ok(uuid) = Uuid::from_str(&verify_token_response) { + uuid + } else { + return Err(StatusCode::UNAUTHORIZED); + }; + + // 更新 RequestContext 中的 current_user、current_workspace 和 current_team + if let Some(ctx) = req.extensions_mut().get_mut::() { + let ip = ctx.ip_or_unknown(); + tracing::info!("auth user uuid: {}, ip: {}", uuid, ip); + + ctx.current_user = Some(CurrentUser { user_uuid: uuid }); + + // 获取用户信息(包含 current_workspace_uuid 和 current_team_uuid) + let user_info = crate::models::user::fetch_user_info_by_uuid(&state.db, uuid) + .await + .ok() + .flatten(); + + // 初始化 current_team_uuid 和 current_workspace_uuid + if let Some(user_info) = user_info { + ctx.current_team_uuid = user_info.current_team_uuid; + + // 优先使用 current_workspace_uuid + // 如果不存在,尝试从当前团队获取工作空间 + ctx.current_workspace_uuid = if let Some(ws_uuid) = user_info.current_workspace_uuid { + Some(ws_uuid) + } else if let Some(team_uuid) = user_info.current_team_uuid { + // 从当前团队获取工作空间 + crate::models::teams::fetch_team_by_uuid(&state.db, team_uuid) + .await + .ok() + .flatten() + .map(|team| team.workspace_uuid) + } else { + None + }; + } + } + + Ok(next.run(req).await) +} diff --git a/src/middlewares/cors.rs b/src/middlewares/cors.rs new file mode 100644 index 0000000..b409a28 --- /dev/null +++ b/src/middlewares/cors.rs @@ -0,0 +1,28 @@ +use axum::http::{ + HeaderName, Method, + header::{self}, +}; +use tower_http::cors::CorsLayer; + +pub fn cors() -> CorsLayer { + CorsLayer::new() + .allow_headers([ + header::CONTENT_TYPE, + header::AUTHORIZATION, + HeaderName::from_static("x-custom-header"), + ]) + // allow `GET` and `POST` when accessing the resource + .allow_methods([ + Method::GET, + Method::POST, + Method::PUT, + Method::DELETE, + Method::OPTIONS, + ]) + // allow requests from any origin + .allow_origin([ + "http://localhost:1420".parse().unwrap(), + "http://localhost:5173".parse().unwrap(), + "http://tauri.localhost".parse().unwrap(), + ]) +} diff --git a/src/middlewares/decrypt.rs b/src/middlewares/decrypt.rs new file mode 100644 index 0000000..6a21121 --- /dev/null +++ b/src/middlewares/decrypt.rs @@ -0,0 +1,152 @@ +use axum::{ + body::{self, Body}, + extract::{Request, State}, + http::{HeaderMap, StatusCode, header}, + middleware::Next, + response::{IntoResponse, Response}, +}; +use serde::Deserialize; + +use crate::{ + svc_ctx::SvcCtx, + utils::{self, AesSecret, get_rsa_secret_instance}, +}; + +/// 被加密的请求的结构体 +#[derive(Debug, Deserialize)] +struct EncryptedRequest { + /// 是否加密 + encrypted: bool, + /// 加密的内容 + #[serde(default)] + data: String, + /// 使用服务器公钥加密后的AES密钥 + #[serde(default)] + key: String, +} + +/// 解密中间件 +pub async fn decrypt( + State(state): State, + req: Request, + next: Next, +) -> Result { + // 获取请求路径和请求方法 + { + // if state + // .config + // .client_gateway_service_config + // .resources + // .whitelists + // .contains(&combine) + // { + // return Ok(next.run(req).await); + // } + } + + // 检查内容是否是json + if !is_json_content_type(req.headers()) { + return Ok(next.run(req).await); + } + + // 读取请求体 + let (parts, body) = req.into_parts(); + let body_bytes = match body::to_bytes(body, 1024 * 1024 * 10).await { + // 限制请求体大小为10MB + Ok(bytes) => bytes, + Err(e) => { + tracing::error!("读取请求体失败: {}", e); + return Err(StatusCode::BAD_REQUEST); + } + }; + + match serde_json::from_slice::(&body_bytes) { + Ok(encrypted_request) => { + if !encrypted_request.encrypted { + // let req = Request::from_parts(parts, Body::from(body_bytes)); + // return Ok(next.run(req).await); + tracing::error!("请求未加密, 拒绝请求"); + return Err(StatusCode::BAD_REQUEST); + } + + // 获取服务器RSA实例以解密AES密钥 + let rsa_secret = get_rsa_secret_instance(); + + // 解密aes密钥(解密后是base64编码的字符串对应的字节数组) + // 解密失败后返回正常的json信息并设置状态码为400 + let aes_key = if let Ok(aes_key) = rsa_secret.decrypt(&encrypted_request.key) { + aes_key + } else { + let mut response = utils::Response::<()>::fail(Some("LASDE")).into_response(); + *response.status_mut() = StatusCode::UNPROCESSABLE_ENTITY; + return Ok(response); + }; + + let aes_key = std::str::from_utf8(&aes_key).map_err(|_| { + tracing::error!("AES密钥转换为字符串失败"); + StatusCode::BAD_REQUEST + })?; + + // 使用解密后的AES密钥创建AES实例 + let aes_secret = AesSecret::try_from(aes_key).map_err(|_| { + tracing::error!("创建AES实例失败"); + StatusCode::BAD_REQUEST + })?; + + // 使用aes密钥解密请求体 + let decrypted_data = aes_secret.decrypt(&encrypted_request.data).map_err(|e| { + tracing::error!("解密请求体失败: {:?}", e); + StatusCode::BAD_REQUEST + })?; + + // 将解密后的请求体转换为JSON + let mut decrypted_json_value: serde_json::Value = + serde_json::from_slice(&decrypted_data).map_err(|_| { + tracing::error!("解析解密数据为JSON失败"); + StatusCode::BAD_REQUEST + })?; + + // 获取api_secret + if let Some(api_secret) = decrypted_json_value.get("api_secret") { + // 检查api_secret是否与配置中的一致 + let app_secret = &state.config.app.secret; + if api_secret != &serde_json::Value::String(app_secret.clone()) { + tracing::error!("API密钥不匹配"); + return Err(StatusCode::UNAUTHORIZED); + } + + // 移除api_secret字段 + decrypted_json_value.as_object_mut().and_then(|obj| obj.remove("api_secret")); + } else { + tracing::error!("请求中缺少api_secret字段"); + return Err(StatusCode::BAD_REQUEST); + } + + // 重建请求体 + let json_bytes = serde_json::to_vec(&decrypted_json_value).map_err(|_| { + tracing::error!("序列化解密数据为JSON失败"); + StatusCode::BAD_REQUEST + })?; + + // 将解密后的请求体放入请求的扩展中 + let req = Request::from_parts(parts, Body::from(json_bytes)); + Ok(next.run(req).await) + } + Err(e) => { + // 如果解析失败,可能不是加密请求,直接通过 + // let req = Request::from_parts(parts, Body::from(body_bytes)); + // Ok(next.run(req).await) + tracing::error!("解析加密请求失败: {:?}", e); + return Err(StatusCode::BAD_REQUEST); + } + } +} + +/// 检查请求头是否为JSON内容类型 +fn is_json_content_type(headers: &HeaderMap) -> bool { + headers + .get(header::CONTENT_TYPE) + .and_then(|value| value.to_str().ok()) + .map(|content_type| content_type.starts_with("application/json")) + .unwrap_or(false) +} diff --git a/src/middlewares/dual_auth.rs b/src/middlewares/dual_auth.rs new file mode 100644 index 0000000..1223c3e --- /dev/null +++ b/src/middlewares/dual_auth.rs @@ -0,0 +1,141 @@ +//! 双重认证中间件 +//! +//! 支持 API Key 认证和 JWT Token 认证 +//! - 优先检查 X-API-KEY header +//! - 如果没有 API Key,则使用 JWT 认证 + +use std::str::FromStr; + +use axum::{ + extract::{Request, State}, + http::{StatusCode, header}, + middleware::Next, + response::{IntoResponse, Response}, +}; +use tracing::warn; +use uuid::Uuid; + +use crate::{ + middlewares::get_request_path_and_method, + models::apikey, + services, + state::{CurrentIpAddr, CurrentUser}, + svc_ctx::SvcCtx, +}; + +/// 双重认证中间件:支持 JWT 认证和 API Key 认证 +/// +/// 认证流程: +/// 1. 检查是否为 OPTIONS 请求或白名单路由,直接放行 +/// 2. 检查 X-API-KEY header,验证 API Key +/// 3. 如果没有 API Key,则使用 JWT 认证 +/// +/// API Key 格式:`key_id:key_secret` +pub async fn dual_auth(State(state): State, mut req: Request, next: Next) -> Response { + let (resource_method, resource_path) = get_request_path_and_method(&req); + + // OPTIONS 请求直接通过 + if resource_method.eq("OPTIONS") { + return next.run(req).await; + } + + // 判断请求的是否在白名单,如果是白名单就直接允许访问 + { + let combine = format!("{}+{}", resource_method, resource_path); + let whitelists = &state.config.app.route_whitelists; + + if whitelists.contains(&combine) { + return next.run(req).await; + } + } + + // 首先检查是否有 API Key + if let Some(api_key_header) = req.headers().get("X-API-KEY") { + if let Ok(key_str) = api_key_header.to_str() { + let parts: Vec<&str> = key_str.split(':').collect(); + + if parts.len() == 2 { + let key_id = parts[0]; + let key_secret = parts[1]; + + match apikey::validate_api_key(&state.db, key_id, key_secret).await { + Ok(Some(api_key)) => { + tracing::info!( + "API Key authentication successful: {} ({})", + api_key.name, + key_id + ); + // API Key 验证成功,直接继续(不注入 CurrentUser) + return next.run(req).await; + } + Ok(None) => { + warn!("Invalid or expired API key: {}", key_id); + return (StatusCode::UNAUTHORIZED, "Invalid or expired API key") + .into_response(); + } + Err(e) => { + warn!("Database error during API key validation: {:?}", e); + return ( + StatusCode::INTERNAL_SERVER_ERROR, + "Authentication service error", + ) + .into_response(); + } + } + } else { + warn!("Invalid API key format - should be key_id:key_secret"); + return (StatusCode::UNAUTHORIZED, "Invalid API key format").into_response(); + } + } else { + warn!("Invalid API key header encoding"); + return (StatusCode::UNAUTHORIZED, "Invalid API key format").into_response(); + } + } + + // 如果没有 API Key,则使用 JWT 认证 + let auth_header = + match req.headers().get(header::AUTHORIZATION).and_then(|header| header.to_str().ok()) { + Some(header) => header, + None => { + return (StatusCode::UNAUTHORIZED, "Missing authentication").into_response(); + } + }; + + let token_str = if auth_header.starts_with("Bearer ") { + &auth_header[7..] + } else { + return ( + StatusCode::UNAUTHORIZED, + "Invalid authorization header format", + ) + .into_response(); + }; + + // 验证 JWT Token + let secret = state.config.app.secret.as_bytes(); + let verify_token_response = services::verify_token_service(token_str, secret); + + if let Err(_e) = verify_token_response { + return (StatusCode::UNAUTHORIZED, "Invalid or expired token").into_response(); + } + + let verify_token_response = verify_token_response.unwrap(); + + // 解析用户 UUID + let uuid = match Uuid::from_str(&verify_token_response) { + Ok(uuid) => uuid, + Err(_) => { + return (StatusCode::UNAUTHORIZED, "Invalid user identifier").into_response(); + } + }; + + // 记录认证信息 + if let Some(current_ip) = req.extensions().get::() { + let ip = current_ip.real_ip.as_str(); + tracing::info!("JWT auth user uuid: {}, ip: {}", uuid, ip); + } + + // 注入当前用户信息(只有 JWT 认证才会注入) + req.extensions_mut().insert(CurrentUser { user_uuid: uuid }); + next.run(req).await +} diff --git a/src/middlewares/encrypt.rs b/src/middlewares/encrypt.rs new file mode 100644 index 0000000..aec3286 --- /dev/null +++ b/src/middlewares/encrypt.rs @@ -0,0 +1,126 @@ +// use arcadia_codegen::user_services::GetUserPublicKeyRequest; +// use arcadia_utils::secret::{aes::AesSecret, rsa}; + +use axum::{ + body, + extract::{Request, State}, + http::{StatusCode, header}, + middleware::Next, + response::{IntoResponse, Response}, +}; +use bytes::Bytes; +use serde_json::json; + +use crate::{ + services::get_user_public_key_service, + state::CurrentUser, + svc_ctx::SvcCtx, + utils::{AesSecret, get_rsa_secret_instance}, +}; + +/// 加密中间件 +pub async fn encrypt( + State(state): State, + req: Request, + next: Next, +) -> Result { + // 从请求中获取 CurrentUser(如果存在) + let current_user = req.extensions().get::().cloned(); + + // 获取请求路径和请求方法 + // { + // let (resource_method, resource_path) = get_request_path_and_method(&req); + // let combine = format!("{}+{}", resource_method, resource_path); + // if state.config.resources.whitelists.contains(&combine) { + // return Ok(next.run(req).await); + // } + // } + + let response = next.run(req).await; + + if let Some(current_user) = current_user { + match get_user_public_key_service(&state, ¤t_user.user_uuid).await { + Ok(public_key) => { + // 创建AES密钥实例 + let aes_secret = AesSecret::new(); + + // 读取响应体 + let (parts, body) = response.into_parts(); + + // 将整个响应体转换为字节 + let body_bytes = match body::to_bytes(body, usize::MAX).await { + Ok(bytes) => bytes, + Err(err) => { + // 无法读取响应体,返回错误 + tracing::error!("Failed to read response body: {}", err); + return Err(StatusCode::INTERNAL_SERVER_ERROR); + } + }; + + // 解析原始JSON响应 + if let Ok(json_value) = serde_json::from_slice::(&body_bytes) { + // 加密JSON内容 + if let Ok(encrypted_content) = + aes_secret.encrypt(json_value.to_string().as_bytes()) + { + // 获取AES密钥的Base64表示 + let aes_key_base64 = aes_secret.get_key_as_base64(); + + // 使用用户的公钥加密AES密钥 + if let Ok(encrypted_key) = get_rsa_secret_instance() + .encrypt_with_public_key(aes_key_base64.as_bytes(), &public_key) + { + // 构建加密响应 + let secure_response = json!({ + "data": encrypted_content, + "encrypted": true, + "key": encrypted_key + }); + + // 创建新的响应 + let mut new_response = axum::Json(secure_response).into_response(); + + // 复制原始响应的状态码 + *new_response.status_mut() = parts.status; + + // 添加内容类型头 + new_response.headers_mut().insert( + header::CONTENT_TYPE, + header::HeaderValue::from_static("application/json"), + ); + + return Ok(new_response); + } + } + } + + // 如果加密失败,回退到原始响应 + let fallback = fallback_to_unencrypted_response(body_bytes, parts.status); + return Ok(fallback); + } + Err(_) => { + return Ok(response); + } + } + } + + Ok(response) +} + +// 当加密失败时回退到未加密响应 +fn fallback_to_unencrypted_response(body: Bytes, status: StatusCode) -> Response { + // 尝试解析原始响应为JSON + let json_value = serde_json::from_slice::(&body) + .unwrap_or_else(|_| json!({"raw": String::from_utf8_lossy(&body).to_string()})); + + // 添加未加密标志 + let unencrypted_response = json!({ + "encrypted": false, + "data": json_value + }); + + let mut response = axum::Json(unencrypted_response).into_response(); + *response.status_mut() = status; + + response +} diff --git a/src/middlewares/logger.rs b/src/middlewares/logger.rs new file mode 100644 index 0000000..045f09c --- /dev/null +++ b/src/middlewares/logger.rs @@ -0,0 +1,67 @@ +use axum::{extract::Request, http::StatusCode, middleware::Next, response::Response}; +use once_cell::sync::Lazy; +use tokio::{sync::mpsc, time::Instant}; + +#[derive(Debug)] +struct LogEntry { + method: String, + path: String, + status: u16, + latency: u64, +} + +/// 日志通道, 该通道用于发送日志信息 +static LOG_CHANNEL: Lazy> = Lazy::new(|| { + let (tx, rx) = mpsc::channel::(100_000); + spawn_log_processor(rx); + tx +}); + +fn spawn_log_processor(mut rx: mpsc::Receiver) { + // 使用线程而非tokio spawn + // tokio线程用于处理短暂的非阻塞的任务, 如果使用tokio spawn, 会导致整个异步运行时的性能 + std::thread::Builder::new() + .name("log-processor".to_string()) + .spawn(move || { + loop { + match rx.blocking_recv() { + Some(LogEntry { + method, + path, + status, + latency, + }) => { + tracing::info!( + "Request completed: {} {} status={} latency={:?}ms", + method, + path, + status, + latency + ); + } + _ => continue, + } + } + }) + .expect("Failed to spawn log processor thread"); +} + +pub async fn logger(req: Request, next: Next) -> Result { + let start = Instant::now(); + let method = req.method().to_string(); + let path = req.uri().path().to_string(); + + // 处理请求 + let response = next.run(req).await; + + let status = response.status().as_u16(); + + let latency = start.elapsed().as_millis() as u64; + let _ = LOG_CHANNEL.try_send(LogEntry { + method, + path, + status, + latency, + }); + Ok(response) +} diff --git a/src/middlewares/machine_auth.rs b/src/middlewares/machine_auth.rs new file mode 100644 index 0000000..6a21d7f --- /dev/null +++ b/src/middlewares/machine_auth.rs @@ -0,0 +1,70 @@ +use crate::services::{get_machine_by_code_service, machine_not_allow_service}; +use crate::state::{CurrentMachineUser, RequestContext}; +use crate::svc_ctx::SvcCtx; +use axum::{extract::Request, extract::State, middleware::Next, response::Response}; + +/// 机器码认证中间件 +/// 从请求头 x-machine-code 中提取机器码,并验证机器是否允许使用 +pub async fn machine_auth( + State(svc_ctx): State, + mut request: Request, + next: Next, +) -> Response { + // 从请求头获取机器码 + let machine_code = request + .headers() + .get("x-machine-code") + .and_then(|v| v.to_str().ok()) + .map(|s| s.to_string()); + + if let Some(code) = machine_code { + // 检查机器是否被拉黑 + let current_machine = match machine_not_allow_service(&svc_ctx, code.clone()).await { + Ok(true) => { + // 机器被拉黑,但继续处理(业务层决定如何处理) + CurrentMachineUser { + machine_code: code, + platform: None, + user_uuid: None, + is_allowed: false, + } + } + Ok(false) => { + // 机器未被拉黑,尝试获取机器信息 + match get_machine_by_code_service(&svc_ctx, code.clone()).await { + Ok(machine) => CurrentMachineUser { + machine_code: code, + platform: machine.platform, + user_uuid: machine.user_uuid, + is_allowed: true, + }, + Err(_) => { + // 机器不存在,创建默认的机器用户信息 + CurrentMachineUser { + machine_code: code, + platform: None, + user_uuid: None, + is_allowed: true, + } + } + } + } + Err(_) => { + // 查询出错,创建默认的机器用户信息 + CurrentMachineUser { + machine_code: code, + platform: None, + user_uuid: None, + is_allowed: true, + } + } + }; + + // 更新 RequestContext 中的 current_machine_user + if let Some(ctx) = request.extensions_mut().get_mut::() { + ctx.current_machine_user = Some(current_machine); + } + } + + next.run(request).await +} diff --git a/src/middlewares/real_ip.rs b/src/middlewares/real_ip.rs new file mode 100644 index 0000000..5cae48a --- /dev/null +++ b/src/middlewares/real_ip.rs @@ -0,0 +1,47 @@ +use std::net::SocketAddr; + +use axum::{ + extract::{ConnectInfo, Request}, + http::StatusCode, + middleware::Next, + response::Response, +}; + +use crate::state::{CurrentIpAddr, RequestContext}; + +pub async fn real_ip(mut req: Request, next: Next) -> Result { + // 验证来源 + let client_ip_opt = req + .extensions() + .get::>() + .map(|ConnectInfo(addr)| addr.ip()); + + // 检查ip地址是否是通过nginx转发(通过nginx就是127.0.0.1)和是否是本地地址 + if let Some(client_ip) = client_ip_opt { + let client_ip_str = client_ip.to_string(); + if &client_ip_str != "127.0.0.1" && !client_ip.is_loopback() { + return Err(StatusCode::FORBIDDEN); + } + } + + // 提取客户端的真实IP + let real_ip = req + .headers() + .get("x-forwarded-for") + .and_then(|v| v.to_str().ok()) + .and_then(|s| s.split(',').next()) + .map(str::trim) + .map(str::to_string) + .or_else(|| { + req.headers().get("x-real-ip").and_then(|v| v.to_str().ok()).map(str::to_string) + }); + + // 初始化 RequestContext 并设置 IP + let mut ctx = RequestContext::default(); + if let Some(ip) = real_ip { + ctx.current_ip_addr = Some(CurrentIpAddr { real_ip: ip }); + } + req.extensions_mut().insert(ctx); + + Ok(next.run(req).await) +} diff --git a/src/models/accounts.rs b/src/models/accounts.rs new file mode 100644 index 0000000..dbf9e1d --- /dev/null +++ b/src/models/accounts.rs @@ -0,0 +1,299 @@ +use sqlx::{Error, Pool, Postgres}; +use uuid::Uuid; + +use crate::dto::PlatformAccountDto; + +/// 创建平台账号 +pub async fn insert_platform_account( + pool: &Pool, + user_uuid: Uuid, + team_uuid: Option, + platform_url: &str, + platform_name: Option<&str>, + account: &str, + password_encrypted: Option<&str>, + remark: Option<&str>, +) -> Result { + let uuid: Uuid = sqlx::query_scalar( + r#" + INSERT INTO platform_accounts (user_uuid, team_uuid, platform_url, platform_name, + account, password_encrypted, remark) + VALUES ($1, $2, $3, $4, $5, $6, $7) + RETURNING uuid; + "#, + ) + .bind(user_uuid) + .bind(team_uuid) + .bind(platform_url) + .bind(platform_name) + .bind(account) + .bind(password_encrypted) + .bind(remark) + .fetch_one(pool) + .await?; + + Ok(uuid) +} + +/// 查询平台账号列表 +pub async fn fetch_platform_accounts( + pool: &Pool, + team_uuid: Option, + user_uuid: Uuid, + platform_name: Option<&str>, + status: Option<&str>, + offset: i64, + limit: i64, +) -> Result, Error> { + let recs = sqlx::query_as::<_, PlatformAccountDto>( + r#" + SELECT pa.id, pa.uuid, pa.user_uuid, pa.team_uuid, pa.platform_url, pa.platform_name, + pa.account, pa.password_encrypted, pa.status, pa.remark, pa.usage_count, + (SELECT COUNT(*) FROM environment_accounts ea WHERE ea.account_uuid = pa.uuid) AS environments_count, + pa.last_used_at, pa.created_at, pa.updated_at, pa.deleted_at + FROM platform_accounts pa + WHERE (pa.team_uuid = $1 OR (pa.team_uuid IS NULL AND pa.user_uuid = $2)) + AND ($3::varchar IS NULL OR pa.platform_name = $3) + AND ($4::varchar IS NULL OR pa.status = $4) + AND pa.deleted_at IS NULL + ORDER BY pa.created_at DESC + LIMIT $5 OFFSET $6 + "#, + ) + .bind(team_uuid) + .bind(user_uuid) + .bind(platform_name) + .bind(status) + .bind(limit) + .bind(offset) + .fetch_all(pool) + .await?; + + Ok(recs) +} + +/// 查询平台账号总数 +pub async fn fetch_platform_accounts_count( + pool: &Pool, + team_uuid: Option, + user_uuid: Uuid, + platform_name: Option<&str>, + status: Option<&str>, +) -> Result { + let count: i64 = sqlx::query_scalar( + r#" + SELECT COUNT(*) FROM platform_accounts + WHERE (team_uuid = $1 OR (team_uuid IS NULL AND user_uuid = $2)) + AND ($3::varchar IS NULL OR platform_name = $3) + AND ($4::varchar IS NULL OR status = $4) + AND deleted_at IS NULL + "#, + ) + .bind(team_uuid) + .bind(user_uuid) + .bind(platform_name) + .bind(status) + .fetch_one(pool) + .await?; + + Ok(count) +} + +/// 根据 UUID 查询平台账号 +pub async fn fetch_platform_account_by_uuid( + pool: &Pool, + account_uuid: Uuid, +) -> Result, Error> { + let rec = sqlx::query_as::<_, PlatformAccountDto>( + r#" + SELECT pa.id, pa.uuid, pa.user_uuid, pa.team_uuid, pa.platform_url, pa.platform_name, + pa.account, pa.password_encrypted, pa.status, pa.remark, pa.usage_count, + (SELECT COUNT(*) FROM environment_accounts ea WHERE ea.account_uuid = pa.uuid) AS environments_count, + pa.last_used_at, pa.created_at, pa.updated_at, pa.deleted_at + FROM platform_accounts pa + WHERE pa.uuid = $1 AND pa.deleted_at IS NULL + "#, + ) + .bind(account_uuid) + .fetch_optional(pool) + .await?; + + Ok(rec) +} + +/// 更新平台账号 +pub async fn update_platform_account( + pool: &Pool, + account_uuid: Uuid, + platform_url: Option<&str>, + platform_name: Option<&str>, + account: Option<&str>, + password_encrypted: Option<&str>, + remark: Option<&str>, + status: Option<&str>, +) -> Result<(), Error> { + sqlx::query( + r#" + UPDATE platform_accounts + SET platform_url = COALESCE($1, platform_url), + platform_name = COALESCE($2, platform_name), + account = COALESCE($3, account), + password_encrypted = COALESCE($4, password_encrypted), + remark = COALESCE($5, remark), + status = COALESCE($6, status) + WHERE uuid = $7 AND deleted_at IS NULL + "#, + ) + .bind(platform_url) + .bind(platform_name) + .bind(account) + .bind(password_encrypted) + .bind(remark) + .bind(status) + .bind(account_uuid) + .execute(pool) + .await?; + + Ok(()) +} + +/// 增加账号使用次数 +pub async fn increment_account_usage( + pool: &Pool, + account_uuid: Uuid, +) -> Result<(), Error> { + sqlx::query( + r#" + UPDATE platform_accounts + SET usage_count = usage_count + 1, last_used_at = CURRENT_TIMESTAMP + WHERE uuid = $1 AND deleted_at IS NULL + "#, + ) + .bind(account_uuid) + .execute(pool) + .await?; + + Ok(()) +} + +/// 软删除平台账号 +pub async fn delete_platform_account( + pool: &Pool, + account_uuid: Uuid, +) -> Result<(), Error> { + sqlx::query( + r#" + UPDATE platform_accounts SET deleted_at = CURRENT_TIMESTAMP + WHERE uuid = $1 AND deleted_at IS NULL + "#, + ) + .bind(account_uuid) + .execute(pool) + .await?; + + Ok(()) +} + +/// 批量软删除平台账号 +pub async fn batch_delete_platform_accounts( + pool: &Pool, + account_uuids: &[Uuid], +) -> Result { + let result = sqlx::query( + r#" + UPDATE platform_accounts SET deleted_at = CURRENT_TIMESTAMP + WHERE uuid = ANY($1) AND deleted_at IS NULL + "#, + ) + .bind(account_uuids) + .execute(pool) + .await?; + + Ok(result.rows_affected()) +} + +// ============ Environment Accounts ============ + +/// 关联环境和账号 +pub async fn insert_environment_account( + pool: &Pool, + env_uuid: Uuid, + account_uuid: Uuid, + sort_order: i32, +) -> Result<(), Error> { + sqlx::query( + r#" + INSERT INTO environment_accounts (environment_uuid, account_uuid, sort_order) + VALUES ($1, $2, $3) + ON CONFLICT (environment_uuid, account_uuid) DO UPDATE SET sort_order = $3; + "#, + ) + .bind(env_uuid) + .bind(account_uuid) + .bind(sort_order) + .execute(pool) + .await?; + + Ok(()) +} + +/// 移除环境和账号的关联 +pub async fn remove_environment_account( + pool: &Pool, + env_uuid: Uuid, + account_uuid: Uuid, +) -> Result<(), Error> { + sqlx::query( + r#" + DELETE FROM environment_accounts + WHERE environment_uuid = $1 AND account_uuid = $2; + "#, + ) + .bind(env_uuid) + .bind(account_uuid) + .execute(pool) + .await?; + + Ok(()) +} + +/// 清空环境的所有账号关联 +pub async fn clear_environment_accounts( + pool: &Pool, + env_uuid: Uuid, +) -> Result<(), Error> { + sqlx::query( + r#" + DELETE FROM environment_accounts WHERE environment_uuid = $1; + "#, + ) + .bind(env_uuid) + .execute(pool) + .await?; + + Ok(()) +} + +/// 查询环境关联的所有账号 +pub async fn fetch_environment_accounts( + pool: &Pool, + env_uuid: Uuid, +) -> Result, Error> { + let recs = sqlx::query_as::<_, PlatformAccountDto>( + r#" + SELECT pa.id, pa.uuid, pa.user_uuid, pa.team_uuid, pa.platform_url, pa.platform_name, + pa.account, pa.password_encrypted, pa.status, pa.remark, pa.usage_count, + (SELECT COUNT(*) FROM environment_accounts ea2 WHERE ea2.account_uuid = pa.uuid) AS environments_count, + pa.last_used_at, pa.created_at, pa.updated_at, pa.deleted_at + FROM platform_accounts pa + INNER JOIN environment_accounts ea ON pa.uuid = ea.account_uuid + WHERE ea.environment_uuid = $1 AND pa.deleted_at IS NULL + ORDER BY ea.sort_order + "#, + ) + .bind(env_uuid) + .fetch_all(pool) + .await?; + + Ok(recs) +} diff --git a/src/models/admin.rs b/src/models/admin.rs new file mode 100644 index 0000000..58fa547 --- /dev/null +++ b/src/models/admin.rs @@ -0,0 +1,370 @@ +//! Console Gateway 管理员和权限数据库操作 + +use sqlx::{Error, Pool, Postgres}; +use uuid::Uuid; + +use crate::dto::{ConsoleAdminDto, ConsoleAdminPermissionDto, ConsolePermissionDto}; +use crate::entitys::{ + CreateConsoleAdminRequest, CreateConsolePermissionRequest, GrantConsolePermissionRequest, + UpdateConsoleAdminRequest, +}; + +// ==================== 管理员操作 ==================== + +/// 创建管理员 +pub async fn create_admin( + pool: &Pool, + request: CreateConsoleAdminRequest, +) -> Result { + let admin = sqlx::query_as::<_, ConsoleAdminDto>( + r#" + INSERT INTO console_admins (user_uuid, is_active) + VALUES ($1, TRUE) + RETURNING id, uuid, user_uuid, is_active, created_at, updated_at, deleted_at + "#, + ) + .bind(request.user_uuid) + .fetch_one(pool) + .await?; + + Ok(admin) +} + +/// 根据用户 UUID 获取管理员 +pub async fn get_admin_by_user_uuid( + pool: &Pool, + user_uuid: Uuid, +) -> Result, Error> { + let admin = sqlx::query_as::<_, ConsoleAdminDto>( + r#" + SELECT id, uuid, user_uuid, is_active, created_at, updated_at, deleted_at + FROM console_admins + WHERE user_uuid = $1 AND is_active = TRUE AND deleted_at IS NULL + "#, + ) + .bind(user_uuid) + .fetch_optional(pool) + .await?; + + Ok(admin) +} + +/// 根据 ID 获取管理员 +pub async fn get_admin_by_id( + pool: &Pool, + admin_id: i32, +) -> Result, Error> { + let admin = sqlx::query_as::<_, ConsoleAdminDto>( + r#" + SELECT id, uuid, user_uuid, is_active, created_at, updated_at, deleted_at + FROM console_admins + WHERE id = $1 AND deleted_at IS NULL + "#, + ) + .bind(admin_id) + .fetch_optional(pool) + .await?; + + Ok(admin) +} + +/// 列出所有管理员 +pub async fn list_admins( + pool: &Pool, + limit: Option, + offset: Option, +) -> Result, Error> { + let limit = limit.unwrap_or(50); + let offset = offset.unwrap_or(0); + + let admins = sqlx::query_as::<_, ConsoleAdminDto>( + r#" + SELECT id, uuid, user_uuid, is_active, created_at, updated_at, deleted_at + FROM console_admins + WHERE deleted_at IS NULL + ORDER BY created_at DESC + LIMIT $1 OFFSET $2 + "#, + ) + .bind(limit) + .bind(offset) + .fetch_all(pool) + .await?; + + Ok(admins) +} + +/// 更新管理员 +pub async fn update_admin( + pool: &Pool, + admin_id: i32, + request: UpdateConsoleAdminRequest, +) -> Result { + if let Some(is_active) = request.is_active { + let result = sqlx::query( + r#" + UPDATE console_admins + SET is_active = $1, updated_at = CURRENT_TIMESTAMP + WHERE id = $2 AND deleted_at IS NULL + "#, + ) + .bind(is_active) + .bind(admin_id) + .execute(pool) + .await?; + + Ok(result.rows_affected() > 0) + } else { + Ok(false) + } +} + +/// 删除管理员(软删除) +pub async fn delete_admin(pool: &Pool, admin_id: i32) -> Result { + let result = sqlx::query( + r#" + UPDATE console_admins + SET deleted_at = CURRENT_TIMESTAMP, is_active = FALSE + WHERE id = $1 AND deleted_at IS NULL + "#, + ) + .bind(admin_id) + .execute(pool) + .await?; + + Ok(result.rows_affected() > 0) +} + +// ==================== 权限操作 ==================== + +/// 创建权限 +pub async fn create_permission( + pool: &Pool, + request: CreateConsolePermissionRequest, +) -> Result { + let permission = sqlx::query_as::<_, ConsolePermissionDto>( + r#" + INSERT INTO console_permissions (route_path, method, description) + VALUES ($1, $2, $3) + RETURNING id, uuid, route_path, method, description, created_at, updated_at, deleted_at + "#, + ) + .bind(request.route_path) + .bind(request.method) + .bind(request.description) + .fetch_one(pool) + .await?; + + Ok(permission) +} + +/// 根据路由和方法获取权限 +pub async fn get_permission_by_route( + pool: &Pool, + route_path: &str, + method: &str, +) -> Result, Error> { + let permission = sqlx::query_as::<_, ConsolePermissionDto>( + r#" + SELECT id, uuid, route_path, method, description, created_at, updated_at, deleted_at + FROM console_permissions + WHERE route_path = $1 AND method = $2 AND deleted_at IS NULL + "#, + ) + .bind(route_path) + .bind(method) + .fetch_optional(pool) + .await?; + + Ok(permission) +} + +/// 列出所有权限 +pub async fn list_permissions( + pool: &Pool, + limit: Option, + offset: Option, +) -> Result, Error> { + let limit = limit.unwrap_or(9999); + let offset = offset.unwrap_or(0); + + let permissions = sqlx::query_as::<_, ConsolePermissionDto>( + r#" + SELECT id, uuid, route_path, method, description, created_at, updated_at, deleted_at + FROM console_permissions + WHERE deleted_at IS NULL + ORDER BY route_path, method + LIMIT $1 OFFSET $2 + "#, + ) + .bind(limit) + .bind(offset) + .fetch_all(pool) + .await?; + + Ok(permissions) +} + +/// 授予权限给管理员 +pub async fn grant_permission( + pool: &Pool, + request: GrantConsolePermissionRequest, +) -> Result { + let admin_permission = sqlx::query_as::<_, ConsoleAdminPermissionDto>( + r#" + INSERT INTO console_admin_permissions (admin_id, permission_id) + VALUES ($1, $2) + RETURNING id, uuid, admin_id, permission_id, granted_at, granted_by + "#, + ) + .bind(request.admin_id) + .bind(request.permission_id) + .fetch_one(pool) + .await?; + + Ok(admin_permission) +} + +/// 撤销管理员的权限 +pub async fn revoke_permission( + pool: &Pool, + admin_id: i32, + permission_id: i32, +) -> Result { + let result = sqlx::query( + r#" + DELETE FROM console_admin_permissions + WHERE admin_id = $1 AND permission_id = $2 + "#, + ) + .bind(admin_id) + .bind(permission_id) + .execute(pool) + .await?; + + Ok(result.rows_affected() > 0) +} + +/// 检查管理员是否有某个权限 +pub async fn check_admin_permission( + pool: &Pool, + user_uuid: Uuid, + route_path: &str, + method: &str, +) -> Result { + let result: Option = sqlx::query_scalar( + r#" + SELECT COUNT(*) + FROM console_admins a + JOIN console_admin_permissions ap ON a.id = ap.admin_id + JOIN console_permissions p ON ap.permission_id = p.id + WHERE a.user_uuid = $1 + AND a.is_active = TRUE + AND a.deleted_at IS NULL + AND p.route_path = $2 + AND p.method = $3 + AND p.deleted_at IS NULL + "#, + ) + .bind(user_uuid) + .bind(route_path) + .bind(method) + .fetch_optional(pool) + .await?; + + Ok(result.unwrap_or(0) > 0) +} + +/// 获取管理员的所有权限 +pub async fn get_admin_permissions( + pool: &Pool, + admin_id: i32, +) -> Result, Error> { + let permissions = sqlx::query_as::<_, ConsolePermissionDto>( + r#" + SELECT p.id, p.uuid, p.route_path, p.method, p.description, p.created_at, p.updated_at, p.deleted_at + FROM console_permissions p + JOIN console_admin_permissions ap ON p.id = ap.permission_id + WHERE ap.admin_id = $1 AND p.deleted_at IS NULL + ORDER BY p.route_path, p.method + "#, + ) + .bind(admin_id) + .fetch_all(pool) + .await?; + + Ok(permissions) +} + +/// 从路由同步权限到数据库 +pub async fn sync_permissions_from_routes( + pool: &Pool, + routes: Vec<(String, String, Option)>, // (route_path, method, description) +) -> Result<(), Error> { + let mut tx = pool.begin().await?; + + for (route_path, method, description) in routes { + let existing: Option = sqlx::query_scalar( + r#" + SELECT COUNT(*) FROM console_permissions + WHERE route_path = $1 AND method = $2 AND deleted_at IS NULL + "#, + ) + .bind(&route_path) + .bind(&method) + .fetch_optional(&mut *tx) + .await?; + + if existing.unwrap_or(0) == 0 { + sqlx::query( + r#" + INSERT INTO console_permissions (route_path, method, description) + VALUES ($1, $2, $3) + "#, + ) + .bind(route_path) + .bind(method) + .bind(description) + .execute(&mut *tx) + .await?; + } + } + + tx.commit().await?; + Ok(()) +} + +/// 从 MetaRoute 同步权限到数据库 +pub async fn sync_route_permissions( + pool: &Pool, + meta_route: &crate::routes::route::MetaRoute, +) -> Result<(), Error> { + use crate::routes::route::{RequestMethod, RouteGroup}; + + let mut routes = Vec::new(); + + for route_group in &meta_route.routes { + let RouteGroup { + prefix, + routes: route_items, + } = route_group; + + for route_item in route_items { + let full_path = format!("{}{}{}", meta_route.prefix, prefix, route_item.path); + let method = match route_item.method { + RequestMethod::GET => "GET", + RequestMethod::POST => "POST", + RequestMethod::PUT => "PUT", + RequestMethod::DELETE => "DELETE", + RequestMethod::PATCH => "PATCH", + } + .to_string(); + + let description = Some(format!("{} {}", method, full_path)); + routes.push((full_path, method, description)); + } + } + + sync_permissions_from_routes(pool, routes).await?; + Ok(()) +} diff --git a/src/models/api.rs b/src/models/api.rs new file mode 100644 index 0000000..fa6355d --- /dev/null +++ b/src/models/api.rs @@ -0,0 +1,201 @@ +use chrono::{DateTime, Utc}; +use sqlx::{Error, Pool, Postgres}; +use uuid::Uuid; + +use crate::dto::ApiKeyDto; + +/// 创建 API 密钥 +pub async fn insert_api_key( + pool: &Pool, + user_uuid: Uuid, + name: &str, + key_hash: &str, + key_prefix: &str, + permissions: &serde_json::Value, + rate_limit: Option, + daily_limit: Option, + ip_whitelist: Option<&serde_json::Value>, + expires_at: Option>, +) -> Result { + let uuid: Uuid = sqlx::query_scalar( + r#" + INSERT INTO api_keys (user_uuid, name, key_hash, key_prefix, permissions, + rate_limit, daily_limit, ip_whitelist, expires_at) + VALUES ($1, $2, $3, $4, $5, $6, $7, $8, $9) + RETURNING uuid; + "#, + ) + .bind(user_uuid) + .bind(name) + .bind(key_hash) + .bind(key_prefix) + .bind(permissions) + .bind(rate_limit) + .bind(daily_limit) + .bind(ip_whitelist) + .bind(expires_at) + .fetch_one(pool) + .await?; + + Ok(uuid) +} + +/// 查询用户的 API 密钥列表 +pub async fn fetch_api_keys( + pool: &Pool, + user_uuid: Uuid, + status: Option<&str>, + offset: i64, + limit: i64, +) -> Result, Error> { + let recs = sqlx::query_as::<_, ApiKeyDto>( + r#" + SELECT id, uuid, user_uuid, name, key_hash, key_prefix, permissions, + rate_limit, daily_limit, ip_whitelist, expires_at, usage_count, + daily_usage, last_used_at, status, created_at, updated_at + FROM api_keys + WHERE user_uuid = $1 + AND ($2::varchar IS NULL OR status = $2) + ORDER BY created_at DESC + LIMIT $3 OFFSET $4 + "#, + ) + .bind(user_uuid) + .bind(status) + .bind(limit) + .bind(offset) + .fetch_all(pool) + .await?; + + Ok(recs) +} + +/// 查询用户的 API 密钥总数 +pub async fn fetch_api_keys_count( + pool: &Pool, + user_uuid: Uuid, + status: Option<&str>, +) -> Result { + let count: i64 = sqlx::query_scalar( + r#" + SELECT COUNT(*) FROM api_keys + WHERE user_uuid = $1 + AND ($2::varchar IS NULL OR status = $2) + "#, + ) + .bind(user_uuid) + .bind(status) + .fetch_one(pool) + .await?; + + Ok(count) +} + +/// 根据 UUID 查询 API 密钥 +pub async fn fetch_api_key_by_uuid( + pool: &Pool, + key_uuid: Uuid, +) -> Result, Error> { + let rec = sqlx::query_as::<_, ApiKeyDto>( + r#" + SELECT id, uuid, user_uuid, name, key_hash, key_prefix, permissions, + rate_limit, daily_limit, ip_whitelist, expires_at, usage_count, + daily_usage, last_used_at, status, created_at, updated_at + FROM api_keys + WHERE uuid = $1 + "#, + ) + .bind(key_uuid) + .fetch_optional(pool) + .await?; + + Ok(rec) +} + +/// 更新 API 密钥 +pub async fn update_api_key( + pool: &Pool, + key_uuid: Uuid, + name: Option<&str>, + permissions: Option<&serde_json::Value>, + rate_limit: Option, + daily_limit: Option, + ip_whitelist: Option<&serde_json::Value>, +) -> Result<(), Error> { + sqlx::query( + r#" + UPDATE api_keys + SET name = COALESCE($1, name), + permissions = COALESCE($2, permissions), + rate_limit = COALESCE($3, rate_limit), + daily_limit = COALESCE($4, daily_limit), + ip_whitelist = COALESCE($5, ip_whitelist), + updated_at = CURRENT_TIMESTAMP + WHERE uuid = $6 + "#, + ) + .bind(name) + .bind(permissions) + .bind(rate_limit) + .bind(daily_limit) + .bind(ip_whitelist) + .bind(key_uuid) + .execute(pool) + .await?; + + Ok(()) +} + +/// 撤销 API 密钥 +pub async fn revoke_api_key(pool: &Pool, key_uuid: Uuid) -> Result<(), Error> { + sqlx::query( + r#" + UPDATE api_keys SET status = 'revoked', updated_at = CURRENT_TIMESTAMP + WHERE uuid = $1 + "#, + ) + .bind(key_uuid) + .execute(pool) + .await?; + + Ok(()) +} + +/// 验证 API 密钥(根据 key_hash 查询) +pub async fn fetch_api_key_by_hash( + pool: &Pool, + key_hash: &str, +) -> Result, Error> { + let rec = sqlx::query_as::<_, ApiKeyDto>( + r#" + SELECT id, uuid, user_uuid, name, key_hash, key_prefix, permissions, + rate_limit, daily_limit, ip_whitelist, expires_at, usage_count, + daily_usage, last_used_at, status, created_at, updated_at + FROM api_keys + WHERE key_hash = $1 AND status = 'active' + "#, + ) + .bind(key_hash) + .fetch_optional(pool) + .await?; + + Ok(rec) +} + +/// 更新 API 密钥使用统计 +pub async fn update_api_key_usage(pool: &Pool, key_uuid: Uuid) -> Result<(), Error> { + sqlx::query( + r#" + UPDATE api_keys + SET usage_count = COALESCE(usage_count, 0) + 1, + daily_usage = COALESCE(daily_usage, 0) + 1, + last_used_at = CURRENT_TIMESTAMP + WHERE uuid = $1 + "#, + ) + .bind(key_uuid) + .execute(pool) + .await?; + + Ok(()) +} diff --git a/src/models/apikey.rs b/src/models/apikey.rs new file mode 100644 index 0000000..51bbd32 --- /dev/null +++ b/src/models/apikey.rs @@ -0,0 +1,179 @@ +//! Console Gateway API 密钥数据库操作 + +use rand::Rng; +use sqlx::{Error, Pool, Postgres}; +use uuid::Uuid; + +use crate::dto::ConsoleApiKeyDto; +use crate::entitys::CreateConsoleApiKeyRequest; + +/// 创建 API 密钥 +pub async fn create_api_key( + pool: &Pool, + request: CreateConsoleApiKeyRequest, +) -> Result { + let key_id = Uuid::new_v4().to_string(); + let key_secret = generate_api_secret(); + + let api_key = sqlx::query_as::<_, ConsoleApiKeyDto>( + r#" + INSERT INTO console_api_keys (key_id, key_secret, name, description, is_active, expires_at) + VALUES ($1, $2, $3, $4, TRUE, $5) + RETURNING id, uuid, key_id, key_secret, name, description, is_active, + created_at, updated_at, expires_at, last_used_at, created_by, deleted_at + "#, + ) + .bind(&key_id) + .bind(&key_secret) + .bind(&request.name) + .bind(&request.description) + .bind(request.expires_at) + .fetch_one(pool) + .await?; + + Ok(api_key) +} + +/// 列出 API 密钥 +pub async fn list_api_keys( + pool: &Pool, + limit: Option, + offset: Option, +) -> Result, Error> { + let limit = limit.unwrap_or(50); + let offset = offset.unwrap_or(0); + + let api_keys = sqlx::query_as::<_, ConsoleApiKeyDto>( + r#" + SELECT id, uuid, key_id, key_secret, name, description, is_active, + created_at, updated_at, expires_at, last_used_at, created_by, deleted_at + FROM console_api_keys + WHERE deleted_at IS NULL + ORDER BY created_at DESC + LIMIT $1 OFFSET $2 + "#, + ) + .bind(limit) + .bind(offset) + .fetch_all(pool) + .await?; + + Ok(api_keys) +} + +/// 停用 API 密钥 +pub async fn deactivate_api_key(pool: &Pool, key_id: &str) -> Result { + let result = sqlx::query( + r#" + UPDATE console_api_keys + SET is_active = FALSE, updated_at = CURRENT_TIMESTAMP + WHERE key_id = $1 AND deleted_at IS NULL + "#, + ) + .bind(key_id) + .execute(pool) + .await?; + + Ok(result.rows_affected() > 0) +} + +/// 验证 API 密钥 +pub async fn validate_api_key( + pool: &Pool, + key_id: &str, + key_secret: &str, +) -> Result, Error> { + let api_key = sqlx::query_as::<_, ConsoleApiKeyDto>( + r#" + SELECT id, uuid, key_id, key_secret, name, description, is_active, + created_at, updated_at, expires_at, last_used_at, created_by, deleted_at + FROM console_api_keys + WHERE key_id = $1 AND key_secret = $2 AND is_active = TRUE AND deleted_at IS NULL + "#, + ) + .bind(key_id) + .bind(key_secret) + .fetch_optional(pool) + .await?; + + if let Some(ref api_key) = api_key { + // 检查是否过期 + if let Some(expires_at) = api_key.expires_at { + if chrono::Utc::now() > expires_at { + return Ok(None); + } + } + + // 更新最后使用时间 + update_last_used(pool, api_key.id).await?; + } + + Ok(api_key) +} + +/// 更新最后使用时间 +async fn update_last_used(pool: &Pool, api_key_id: i32) -> Result<(), Error> { + sqlx::query( + r#" + UPDATE console_api_keys + SET last_used_at = CURRENT_TIMESTAMP + WHERE id = $1 + "#, + ) + .bind(api_key_id) + .execute(pool) + .await?; + + Ok(()) +} + +/// 根据 key_id 获取 API 密钥 +pub async fn get_api_key_by_key_id( + pool: &Pool, + key_id: &str, +) -> Result, Error> { + let api_key = sqlx::query_as::<_, ConsoleApiKeyDto>( + r#" + SELECT id, uuid, key_id, key_secret, name, description, is_active, + created_at, updated_at, expires_at, last_used_at, created_by, deleted_at + FROM console_api_keys + WHERE key_id = $1 AND deleted_at IS NULL + "#, + ) + .bind(key_id) + .fetch_optional(pool) + .await?; + + Ok(api_key) +} + +/// 删除 API 密钥(软删除) +pub async fn delete_api_key(pool: &Pool, key_id: &str) -> Result { + let result = sqlx::query( + r#" + UPDATE console_api_keys + SET deleted_at = CURRENT_TIMESTAMP, is_active = FALSE + WHERE key_id = $1 AND deleted_at IS NULL + "#, + ) + .bind(key_id) + .execute(pool) + .await?; + + Ok(result.rows_affected() > 0) +} + +/// 生成 API 密钥密文 +fn generate_api_secret() -> String { + const CHARSET: &[u8] = b"ABCDEFGHIJKLMNOPQRSTUVWXYZ\ + abcdefghijklmnopqrstuvwxyz\ + 0123456789"; + let mut rng = rand::rng(); + + (0..64) + .map(|_| { + let idx = rng.random_range(0..CHARSET.len()); + CHARSET[idx] as char + }) + .collect() +} diff --git a/src/models/audit.rs b/src/models/audit.rs new file mode 100644 index 0000000..0c494e2 --- /dev/null +++ b/src/models/audit.rs @@ -0,0 +1,234 @@ +use sqlx::{Error, Pool, Postgres}; +use uuid::Uuid; + +use crate::dto::AuditLogDto; + +/// 记录审计日志 +pub async fn insert_audit_log( + pool: &Pool, + user_uuid: Uuid, + team_uuid: Option, + action: &str, + target_type: &str, + target_uuid: Option, + target_name: Option<&str>, + details: Option<&str>, + changes: Option<&serde_json::Value>, + ip_address: Option<&str>, + user_agent: Option<&str>, + request_id: Option<&str>, +) -> Result { + let id: i64 = sqlx::query_scalar( + r#" + INSERT INTO audit_logs (user_uuid, team_uuid, action, target_type, target_uuid, + target_name, details, changes, ip_address, user_agent, request_id) + VALUES ($1, $2, $3, $4, $5, $6, $7, $8, $9, $10, $11) + RETURNING id; + "#, + ) + .bind(user_uuid) + .bind(team_uuid) + .bind(action) + .bind(target_type) + .bind(target_uuid) + .bind(target_name) + .bind(details) + .bind(changes) + .bind(ip_address) + .bind(user_agent) + .bind(request_id) + .fetch_one(pool) + .await?; + + Ok(id) +} + +/// 查询审计日志列表 +/// +/// 查询逻辑: +/// - 当前团队的所有审计日志 +/// - 加上当前用户的个人操作日志(team_uuid 为空的,如登录、注册等) +pub async fn fetch_audit_logs( + pool: &Pool, + current_user_uuid: Uuid, + team_uuid: Option, + user_uuid_filter: Option, + action: Option<&str>, + target_type: Option<&str>, + offset: i64, + limit: i64, +) -> Result, Error> { + let recs = sqlx::query_as::<_, AuditLogDto>( + r#" + SELECT + a.id, a.uuid, a.user_uuid, a.team_uuid, a.action, a.target_type, a.target_uuid, + a.target_name, a.details, a.changes, a.ip_address, a.user_agent, a.request_id, a.created_at, + ui.nickname AS user_name, ui.email AS user_email + FROM audit_logs a + LEFT JOIN user_infos ui ON a.user_uuid = ui.user_uuid + WHERE ( + ($2::uuid IS NULL OR a.team_uuid = $2) + OR (a.team_uuid IS NULL AND a.user_uuid = $1) + ) + AND ($3::uuid IS NULL OR a.user_uuid = $3) + AND ($4::varchar IS NULL OR a.action = $4) + AND ($5::varchar IS NULL OR a.target_type = $5) + ORDER BY a.created_at DESC + LIMIT $6 OFFSET $7 + "#, + ) + .bind(current_user_uuid) + .bind(team_uuid) + .bind(user_uuid_filter) + .bind(action) + .bind(target_type) + .bind(limit) + .bind(offset) + .fetch_all(pool) + .await?; + + Ok(recs) +} + +/// 查询审计日志总数 +pub async fn fetch_audit_logs_count( + pool: &Pool, + current_user_uuid: Uuid, + team_uuid: Option, + user_uuid_filter: Option, + action: Option<&str>, + target_type: Option<&str>, +) -> Result { + let count: i64 = sqlx::query_scalar( + r#" + SELECT COUNT(*) FROM audit_logs + WHERE ( + ($2::uuid IS NULL OR team_uuid = $2) + OR (team_uuid IS NULL AND user_uuid = $1) + ) + AND ($3::uuid IS NULL OR user_uuid = $3) + AND ($4::varchar IS NULL OR action = $4) + AND ($5::varchar IS NULL OR target_type = $5) + "#, + ) + .bind(current_user_uuid) + .bind(team_uuid) + .bind(user_uuid_filter) + .bind(action) + .bind(target_type) + .fetch_one(pool) + .await?; + + Ok(count) +} + +/// 根据 UUID 查询审计日志 +pub async fn fetch_audit_log_by_uuid( + pool: &Pool, + log_uuid: Uuid, +) -> Result, Error> { + let rec = sqlx::query_as::<_, AuditLogDto>( + r#" + SELECT + a.id, a.uuid, a.user_uuid, a.team_uuid, a.action, a.target_type, a.target_uuid, + a.target_name, a.details, a.changes, a.ip_address, a.user_agent, a.request_id, a.created_at, + ui.nickname AS user_name, ui.email AS user_email + FROM audit_logs a + LEFT JOIN user_infos ui ON a.user_uuid = ui.user_uuid + WHERE a.uuid = $1 + "#, + ) + .bind(log_uuid) + .fetch_optional(pool) + .await?; + + Ok(rec) +} + +/// 查询指定日期的审计日志数量 +pub async fn fetch_audit_logs_count_by_date( + pool: &Pool, + team_uuid: Option, + date: chrono::NaiveDate, +) -> Result { + let count: i64 = sqlx::query_scalar( + r#" + SELECT COUNT(*) FROM audit_logs + WHERE ($1::uuid IS NULL OR team_uuid = $1) + AND created_at::date = $2 + "#, + ) + .bind(team_uuid) + .bind(date) + .fetch_one(pool) + .await?; + + Ok(count) +} + +/// 查询指定日期之后的审计日志数量 +pub async fn fetch_audit_logs_count_since_date( + pool: &Pool, + team_uuid: Option, + since_date: chrono::NaiveDate, +) -> Result { + let count: i64 = sqlx::query_scalar( + r#" + SELECT COUNT(*) FROM audit_logs + WHERE ($1::uuid IS NULL OR team_uuid = $1) + AND created_at::date >= $2 + "#, + ) + .bind(team_uuid) + .bind(since_date) + .fetch_one(pool) + .await?; + + Ok(count) +} + +/// 查询热门操作类型 +pub async fn fetch_top_actions( + pool: &Pool, + team_uuid: Option, + limit: i64, +) -> Result, Error> { + let rows: Vec<(String, i64)> = sqlx::query_as( + r#" + SELECT action, COUNT(*) as count FROM audit_logs + WHERE ($1::uuid IS NULL OR team_uuid = $1) + GROUP BY action + ORDER BY count DESC + LIMIT $2 + "#, + ) + .bind(team_uuid) + .bind(limit) + .fetch_all(pool) + .await?; + + Ok(rows) +} + +/// 查询热门目标类型 +pub async fn fetch_top_target_types( + pool: &Pool, + team_uuid: Option, + limit: i64, +) -> Result, Error> { + let rows: Vec<(String, i64)> = sqlx::query_as( + r#" + SELECT target_type, COUNT(*) as count FROM audit_logs + WHERE ($1::uuid IS NULL OR team_uuid = $1) + GROUP BY target_type + ORDER BY count DESC + LIMIT $2 + "#, + ) + .bind(team_uuid) + .bind(limit) + .fetch_all(pool) + .await?; + + Ok(rows) +} diff --git a/src/models/billing.rs b/src/models/billing.rs new file mode 100644 index 0000000..d5813c2 --- /dev/null +++ b/src/models/billing.rs @@ -0,0 +1,1071 @@ +use chrono::NaiveDate; +use rust_decimal::Decimal; +use sqlx::{Error, Pool, Postgres, Row}; +use uuid::Uuid; + +use crate::dto::{ + AutoRenewalServiceDto, CouponDto, InvoiceDto, PaymentOrderDto, PlanDto, PlanFeatureDto, + SubscriptionDto, UserQuotaDto, UserWalletDto, WalletTransactionDto, +}; + +// ============ Plans ============ + +/// 查询所有可用套餐 +pub async fn fetch_plans(pool: &Pool) -> Result, Error> { + let recs = sqlx::query_as::<_, PlanDto>( + r#" + SELECT id, uuid, name, description, price_per_month, price_per_year, + currency, discount_monthly, discount_yearly, max_environments, + max_team_members, max_proxies, max_rpa_tasks, is_recommended, + sort_order, status, created_at, updated_at + FROM plans + WHERE status = 'active' + ORDER BY sort_order ASC, price_per_month ASC + "#, + ) + .fetch_all(pool) + .await?; + + Ok(recs) +} + +/// 根据 UUID 查询套餐 +pub async fn fetch_plan_by_uuid( + pool: &Pool, + plan_uuid: Uuid, +) -> Result, Error> { + let rec = sqlx::query_as::<_, PlanDto>( + r#" + SELECT id, uuid, name, description, price_per_month, price_per_year, + currency, discount_monthly, discount_yearly, max_environments, + max_team_members, max_proxies, max_rpa_tasks, is_recommended, + sort_order, status, created_at, updated_at + FROM plans + WHERE uuid = $1 + "#, + ) + .bind(plan_uuid) + .fetch_optional(pool) + .await?; + + Ok(rec) +} + +/// 查询套餐特性 +pub async fn fetch_plan_features( + pool: &Pool, + plan_uuid: Uuid, +) -> Result, Error> { + let recs = sqlx::query_as::<_, PlanFeatureDto>( + r#" + SELECT id, plan_uuid, feature_key, feature_name, feature_value, + is_included, sort_order, created_at + FROM plan_features + WHERE plan_uuid = $1 + ORDER BY sort_order ASC + "#, + ) + .bind(plan_uuid) + .fetch_all(pool) + .await?; + + Ok(recs) +} + +// ============ Subscriptions ============ + +/// 查询用户当前订阅(按用户) +pub async fn fetch_active_subscription( + pool: &Pool, + user_uuid: Uuid, +) -> Result, Error> { + let rec = sqlx::query_as::<_, SubscriptionDto>( + r#" + SELECT id, uuid, workspace_uuid, user_uuid, plan_uuid, billing_period, price, currency, + started_at, expires_at, next_billing_date, auto_renew, status, + cancelled_at, created_at, updated_at + FROM subscriptions + WHERE user_uuid = $1 AND status IN ('active', 'paused') + ORDER BY created_at DESC + LIMIT 1 + "#, + ) + .bind(user_uuid) + .fetch_optional(pool) + .await?; + + Ok(rec) +} + +/// 查询工作空间当前订阅 +pub async fn fetch_workspace_active_subscription( + pool: &Pool, + workspace_uuid: Uuid, +) -> Result, Error> { + let rec = sqlx::query_as::<_, SubscriptionDto>( + r#" + SELECT id, uuid, workspace_uuid, user_uuid, plan_uuid, billing_period, price, currency, + started_at, expires_at, next_billing_date, auto_renew, status, + cancelled_at, created_at, updated_at + FROM subscriptions + WHERE workspace_uuid = $1 AND status IN ('active', 'paused') + ORDER BY created_at DESC + LIMIT 1 + "#, + ) + .bind(workspace_uuid) + .fetch_optional(pool) + .await?; + + Ok(rec) +} + +/// 根据 UUID 查询订阅 +pub async fn fetch_subscription_by_uuid( + pool: &Pool, + subscription_uuid: Uuid, +) -> Result, Error> { + let rec = sqlx::query_as::<_, SubscriptionDto>( + r#" + SELECT id, uuid, workspace_uuid, user_uuid, plan_uuid, billing_period, price, currency, + started_at, expires_at, next_billing_date, auto_renew, status, + cancelled_at, created_at, updated_at + FROM subscriptions + WHERE uuid = $1 + "#, + ) + .bind(subscription_uuid) + .fetch_optional(pool) + .await?; + + Ok(rec) +} + +/// 创建订阅 +pub async fn insert_subscription( + pool: &Pool, + workspace_uuid: Uuid, + user_uuid: Uuid, + plan_uuid: Uuid, + billing_period: &str, + price: Decimal, + currency: &str, + expires_at: chrono::DateTime, + next_billing_date: NaiveDate, +) -> Result { + let uuid: Uuid = sqlx::query_scalar( + r#" + INSERT INTO subscriptions (workspace_uuid, user_uuid, plan_uuid, billing_period, price, currency, + started_at, expires_at, next_billing_date, auto_renew, status) + VALUES ($1, $2, $3, $4, $5, $6, CURRENT_TIMESTAMP, $7, $8, true, 'active') + RETURNING uuid; + "#, + ) + .bind(workspace_uuid) + .bind(user_uuid) + .bind(plan_uuid) + .bind(billing_period) + .bind(price) + .bind(currency) + .bind(expires_at) + .bind(next_billing_date) + .fetch_one(pool) + .await?; + + Ok(uuid) +} + +/// 取消订阅 +pub async fn cancel_subscription( + pool: &Pool, + subscription_uuid: Uuid, +) -> Result<(), Error> { + sqlx::query( + r#" + UPDATE subscriptions + SET status = 'cancelled', cancelled_at = CURRENT_TIMESTAMP, updated_at = CURRENT_TIMESTAMP + WHERE uuid = $1 + "#, + ) + .bind(subscription_uuid) + .execute(pool) + .await?; + + Ok(()) +} + +/// 恢复订阅 +pub async fn resume_subscription( + pool: &Pool, + subscription_uuid: Uuid, +) -> Result<(), Error> { + sqlx::query( + r#" + UPDATE subscriptions + SET status = 'active', cancelled_at = NULL, updated_at = CURRENT_TIMESTAMP + WHERE uuid = $1 + "#, + ) + .bind(subscription_uuid) + .execute(pool) + .await?; + + Ok(()) +} + +/// 切换自动续费 +pub async fn toggle_auto_renew( + pool: &Pool, + subscription_uuid: Uuid, + auto_renew: bool, +) -> Result<(), Error> { + sqlx::query( + r#" + UPDATE subscriptions + SET auto_renew = $1, updated_at = CURRENT_TIMESTAMP + WHERE uuid = $2 + "#, + ) + .bind(auto_renew) + .bind(subscription_uuid) + .execute(pool) + .await?; + + Ok(()) +} + +// ============ User Wallet ============ + +/// 获取用户钱包 +pub async fn fetch_user_wallet( + pool: &Pool, + user_uuid: Uuid, +) -> Result, Error> { + let rec = sqlx::query_as::<_, UserWalletDto>( + r#" + SELECT id, user_uuid, balance, currency, frozen_amount, auto_renewal_combined, + created_at, updated_at + FROM user_wallets + WHERE user_uuid = $1 + "#, + ) + .bind(user_uuid) + .fetch_optional(pool) + .await?; + + Ok(rec) +} + +/// 创建用户钱包 +pub async fn insert_user_wallet( + pool: &Pool, + user_uuid: Uuid, + currency: &str, +) -> Result { + let id: i32 = sqlx::query_scalar( + r#" + INSERT INTO user_wallets (user_uuid, balance, currency, frozen_amount, auto_renewal_combined) + VALUES ($1, 0, $2, 0, 0) + ON CONFLICT (user_uuid) DO NOTHING + RETURNING id; + "#, + ) + .bind(user_uuid) + .bind(currency) + .fetch_one(pool) + .await?; + + Ok(id) +} + +/// 更新钱包余额 +pub async fn update_wallet_balance( + pool: &Pool, + user_uuid: Uuid, + amount: Decimal, +) -> Result<(), Error> { + sqlx::query( + r#" + UPDATE user_wallets + SET balance = balance + $1, updated_at = CURRENT_TIMESTAMP + WHERE user_uuid = $2 + "#, + ) + .bind(amount) + .bind(user_uuid) + .execute(pool) + .await?; + + Ok(()) +} + +// ============ Wallet Transactions ============ + +/// 查询钱包交易记录 +pub async fn fetch_wallet_transactions( + pool: &Pool, + user_uuid: Uuid, + transaction_type: Option<&str>, + offset: i64, + limit: i64, +) -> Result, Error> { + let recs = sqlx::query_as::<_, WalletTransactionDto>( + r#" + SELECT id, uuid, user_uuid, transaction_type, amount, currency, + balance_before, balance_after, description, order_uuid, status, created_at + FROM wallet_transactions + WHERE user_uuid = $1 + AND ($2::varchar IS NULL OR transaction_type = $2) + ORDER BY created_at DESC + LIMIT $3 OFFSET $4 + "#, + ) + .bind(user_uuid) + .bind(transaction_type) + .bind(limit) + .bind(offset) + .fetch_all(pool) + .await?; + + Ok(recs) +} + +/// 查询交易记录总数 +pub async fn fetch_wallet_transactions_count( + pool: &Pool, + user_uuid: Uuid, + transaction_type: Option<&str>, +) -> Result { + let count: i64 = sqlx::query_scalar( + r#" + SELECT COUNT(*) FROM wallet_transactions + WHERE user_uuid = $1 + AND ($2::varchar IS NULL OR transaction_type = $2) + "#, + ) + .bind(user_uuid) + .bind(transaction_type) + .fetch_one(pool) + .await?; + + Ok(count) +} + +/// 插入钱包交易记录 +pub async fn insert_wallet_transaction( + pool: &Pool, + user_uuid: Uuid, + transaction_type: &str, + amount: Decimal, + currency: &str, + balance_before: Decimal, + balance_after: Decimal, + description: Option<&str>, + order_uuid: Option, +) -> Result { + let uuid: Uuid = sqlx::query_scalar( + r#" + INSERT INTO wallet_transactions (user_uuid, transaction_type, amount, currency, + balance_before, balance_after, description, order_uuid, status) + VALUES ($1, $2, $3, $4, $5, $6, $7, $8, 'completed') + RETURNING uuid; + "#, + ) + .bind(user_uuid) + .bind(transaction_type) + .bind(amount) + .bind(currency) + .bind(balance_before) + .bind(balance_after) + .bind(description) + .bind(order_uuid) + .fetch_one(pool) + .await?; + + Ok(uuid) +} + +// ============ Invoices ============ + +/// 查询发票列表 +pub async fn fetch_invoices( + pool: &Pool, + user_uuid: Uuid, + status: Option<&str>, + offset: i64, + limit: i64, +) -> Result, Error> { + let recs = sqlx::query_as::<_, InvoiceDto>( + r#" + SELECT id, uuid, user_uuid, invoice_number, amount, currency, + subscription_uuid, order_uuid, invoice_type, status, + issued_at, due_at, paid_at, invoice_url, created_at, updated_at + FROM invoices + WHERE user_uuid = $1 + AND ($2::varchar IS NULL OR status = $2) + ORDER BY created_at DESC + LIMIT $3 OFFSET $4 + "#, + ) + .bind(user_uuid) + .bind(status) + .bind(limit) + .bind(offset) + .fetch_all(pool) + .await?; + + Ok(recs) +} + +/// 查询发票总数 +pub async fn fetch_invoices_count( + pool: &Pool, + user_uuid: Uuid, + status: Option<&str>, +) -> Result { + let count: i64 = sqlx::query_scalar( + r#" + SELECT COUNT(*) FROM invoices + WHERE user_uuid = $1 + AND ($2::varchar IS NULL OR status = $2) + "#, + ) + .bind(user_uuid) + .bind(status) + .fetch_one(pool) + .await?; + + Ok(count) +} + +/// 创建发票 +pub async fn insert_invoice( + pool: &Pool, + user_uuid: Uuid, + invoice_number: &str, + amount: Decimal, + currency: &str, + subscription_uuid: Option, + order_uuid: Option, + invoice_type: &str, +) -> Result { + let uuid: Uuid = sqlx::query_scalar( + r#" + INSERT INTO invoices (user_uuid, invoice_number, amount, currency, + subscription_uuid, order_uuid, invoice_type, status, issued_at) + VALUES ($1, $2, $3, $4, $5, $6, $7, 'pending', CURRENT_TIMESTAMP) + RETURNING uuid; + "#, + ) + .bind(user_uuid) + .bind(invoice_number) + .bind(amount) + .bind(currency) + .bind(subscription_uuid) + .bind(order_uuid) + .bind(invoice_type) + .fetch_one(pool) + .await?; + + Ok(uuid) +} + +// ============ User Quotas ============ + +/// 获取用户配额 +pub async fn fetch_user_quota( + pool: &Pool, + user_uuid: Uuid, +) -> Result, Error> { + let rec = sqlx::query_as::<_, UserQuotaDto>( + r#" + SELECT id, user_uuid, max_environments, used_environments, max_team_members, + max_proxies, used_proxies, max_rpa_tasks, used_rpa_tasks, + created_at, updated_at + FROM user_quotas + WHERE user_uuid = $1 + "#, + ) + .bind(user_uuid) + .fetch_optional(pool) + .await?; + + Ok(rec) +} + +/// 创建或更新用户配额 +pub async fn upsert_user_quota( + pool: &Pool, + user_uuid: Uuid, + max_environments: i32, + max_team_members: i32, + max_proxies: i32, + max_rpa_tasks: i32, +) -> Result<(), Error> { + sqlx::query( + r#" + INSERT INTO user_quotas (user_uuid, max_environments, max_team_members, max_proxies, max_rpa_tasks) + VALUES ($1, $2, $3, $4, $5) + ON CONFLICT (user_uuid) DO UPDATE SET + max_environments = $2, + max_team_members = $3, + max_proxies = $4, + max_rpa_tasks = $5, + updated_at = CURRENT_TIMESTAMP + "#, + ) + .bind(user_uuid) + .bind(max_environments) + .bind(max_team_members) + .bind(max_proxies) + .bind(max_rpa_tasks) + .execute(pool) + .await?; + + Ok(()) +} + +// ============ Coupons ============ + +/// 根据优惠码查询优惠券 +pub async fn fetch_coupon_by_code( + pool: &Pool, + code: &str, +) -> Result, Error> { + let rec = sqlx::query_as::<_, CouponDto>( + r#" + SELECT id, uuid, code, name, description, discount_type, discount_value, min_amount, max_discount, + max_uses, used_count, max_uses_per_user, valid_from, valid_until, + applicable_to, status, created_at, updated_at + FROM coupons + WHERE code = $1 AND status = 'active' + "#, + ) + .bind(code) + .fetch_optional(pool) + .await?; + + Ok(rec) +} + +/// 根据 UUID 查询优惠券 +pub async fn fetch_coupon_by_uuid( + pool: &Pool, + coupon_uuid: Uuid, +) -> Result, Error> { + let rec = sqlx::query_as::<_, CouponDto>( + r#" + SELECT id, uuid, code, name, description, discount_type, discount_value, min_amount, max_discount, + max_uses, used_count, max_uses_per_user, valid_from, valid_until, + applicable_to, status, created_at, updated_at + FROM coupons + WHERE uuid = $1 AND status = 'active' + "#, + ) + .bind(coupon_uuid) + .fetch_optional(pool) + .await?; + + Ok(rec) +} + +/// 查询用户优惠券使用次数 +pub async fn fetch_coupon_user_usage_count( + pool: &Pool, + coupon_uuid: Uuid, + user_uuid: Uuid, +) -> Result { + let count: i64 = sqlx::query_scalar( + r#" + SELECT COUNT(*) FROM coupon_usages + WHERE coupon_uuid = $1 AND user_uuid = $2 + "#, + ) + .bind(coupon_uuid) + .bind(user_uuid) + .fetch_one(pool) + .await?; + + Ok(count as i32) +} + +/// 记录优惠券使用 +pub async fn insert_coupon_usage( + pool: &Pool, + coupon_uuid: Uuid, + user_uuid: Uuid, + order_uuid: Option, + discount_amount: Decimal, +) -> Result { + let id: i32 = sqlx::query_scalar( + r#" + INSERT INTO coupon_usages (coupon_uuid, user_uuid, order_uuid, discount_amount) + VALUES ($1, $2, $3, $4) + RETURNING id; + "#, + ) + .bind(coupon_uuid) + .bind(user_uuid) + .bind(order_uuid) + .bind(discount_amount) + .fetch_one(pool) + .await?; + + // 更新优惠券使用次数 + sqlx::query("UPDATE coupons SET used_count = used_count + 1 WHERE uuid = $1") + .bind(coupon_uuid) + .execute(pool) + .await?; + + Ok(id) +} + +// ============ User Coupons ============ + +/// 查询用户优惠券列表 +pub async fn fetch_user_coupons( + pool: &Pool, + user_uuid: Uuid, + status: Option<&str>, + page: i64, + page_size: i64, +) -> Result<(Vec, i64), Error> { + let offset = (page - 1) * page_size; + + let (items, total) = if let Some(s) = status { + let rows = sqlx::query( + r#" + SELECT + uc.id, uc.user_uuid, uc.coupon_uuid, uc.status, + uc.issued_at, uc.used_at, uc.expires_at, + c.code, c.name, c.description, c.discount_type, c.discount_value, c.min_amount, c.max_discount + FROM user_coupons uc + INNER JOIN coupons c ON uc.coupon_uuid = c.uuid + WHERE uc.user_uuid = $1 AND uc.status = $2 + ORDER BY uc.issued_at DESC + LIMIT $3 OFFSET $4 + "#, + ) + .bind(user_uuid) + .bind(s) + .bind(page_size) + .bind(offset) + .fetch_all(pool) + .await?; + + let items: Vec = rows + .into_iter() + .map(|row| crate::dto::UserCouponWithDetailsDto { + id: row.get(0), + user_uuid: row.get(1), + coupon_uuid: row.get(2), + status: row.get(3), + issued_at: row.get(4), + used_at: row.get(5), + expires_at: row.get(6), + code: row.get(7), + name: row.get(8), + description: row.get(9), + discount_type: row.get(10), + discount_value: row.get(11), + min_amount: row.get(12), + max_discount: row.get(13), + }) + .collect(); + + let total: i64 = sqlx::query_scalar( + r#" + SELECT COUNT(*) FROM user_coupons + WHERE user_uuid = $1 AND status = $2 + "#, + ) + .bind(user_uuid) + .bind(s) + .fetch_one(pool) + .await?; + + (items, total) + } else { + let rows = sqlx::query( + r#" + SELECT + uc.id, uc.user_uuid, uc.coupon_uuid, uc.status, + uc.issued_at, uc.used_at, uc.expires_at, + c.code, c.name, c.description, c.discount_type, c.discount_value, c.min_amount, c.max_discount + FROM user_coupons uc + INNER JOIN coupons c ON uc.coupon_uuid = c.uuid + WHERE uc.user_uuid = $1 + ORDER BY uc.issued_at DESC + LIMIT $2 OFFSET $3 + "#, + ) + .bind(user_uuid) + .bind(page_size) + .bind(offset) + .fetch_all(pool) + .await?; + + let items: Vec = rows + .into_iter() + .map(|row| crate::dto::UserCouponWithDetailsDto { + id: row.get(0), + user_uuid: row.get(1), + coupon_uuid: row.get(2), + status: row.get(3), + issued_at: row.get(4), + used_at: row.get(5), + expires_at: row.get(6), + code: row.get(7), + name: row.get(8), + description: row.get(9), + discount_type: row.get(10), + discount_value: row.get(11), + min_amount: row.get(12), + max_discount: row.get(13), + }) + .collect(); + + let total: i64 = sqlx::query_scalar( + r#" + SELECT COUNT(*) FROM user_coupons + WHERE user_uuid = $1 + "#, + ) + .bind(user_uuid) + .fetch_one(pool) + .await?; + + (items, total) + }; + + Ok((items, total)) +} + +/// 根据 UUID 查询用户优惠券 +pub async fn fetch_user_coupon_by_uuid( + pool: &Pool, + user_coupon_uuid: i32, +) -> Result, Error> { + let rec = sqlx::query_as::<_, crate::dto::UserCouponDto>( + r#" + SELECT id, user_uuid, coupon_uuid, status, issued_at, used_at, expires_at + FROM user_coupons + WHERE id = $1 + "#, + ) + .bind(user_coupon_uuid) + .fetch_optional(pool) + .await?; + + Ok(rec) +} + +/// 发放优惠券给用户 +pub async fn insert_user_coupon( + pool: &Pool, + user_uuid: Uuid, + coupon_uuid: Uuid, + expires_at: Option>, +) -> Result { + let id: i32 = sqlx::query_scalar( + r#" + INSERT INTO user_coupons (user_uuid, coupon_uuid, expires_at) + VALUES ($1, $2, $3) + ON CONFLICT (user_uuid, coupon_uuid) DO NOTHING + RETURNING id; + "#, + ) + .bind(user_uuid) + .bind(coupon_uuid) + .bind(expires_at) + .fetch_optional(pool) + .await? + .ok_or_else(|| { + Error::RowNotFound // 如果已存在,返回错误 + })?; + + Ok(id) +} + +/// 更新用户优惠券状态 +pub async fn update_user_coupon_status( + pool: &Pool, + user_uuid: Uuid, + coupon_uuid: Uuid, + status: &str, + used_at: Option>, +) -> Result<(), Error> { + sqlx::query( + r#" + UPDATE user_coupons + SET status = $1, used_at = $2, updated_at = CURRENT_TIMESTAMP + WHERE user_uuid = $3 AND coupon_uuid = $4 + "#, + ) + .bind(status) + .bind(used_at) + .bind(user_uuid) + .bind(coupon_uuid) + .execute(pool) + .await?; + + Ok(()) +} + +/// 批量发放优惠券 +pub async fn batch_insert_user_coupons( + pool: &Pool, + coupon_uuid: Uuid, + user_uuids: &[Uuid], + expires_at: Option>, +) -> Result { + let mut tx = pool.begin().await?; + let mut count = 0; + + for user_uuid in user_uuids { + let result = sqlx::query_scalar::<_, i32>( + r#" + INSERT INTO user_coupons (user_uuid, coupon_uuid, expires_at) + VALUES ($1, $2, $3) + ON CONFLICT (user_uuid, coupon_uuid) DO NOTHING + RETURNING id; + "#, + ) + .bind(user_uuid) + .bind(coupon_uuid) + .bind(expires_at) + .fetch_optional(&mut *tx) + .await?; + + if result.is_some() { + count += 1; + } + } + + tx.commit().await?; + Ok(count) +} + +/// 查询用户可用优惠券(未使用且未过期,包含优惠券详细信息) +pub async fn fetch_available_user_coupons( + pool: &Pool, + user_uuid: Uuid, +) -> Result, Error> { + let now = chrono::Utc::now(); + + let rows = sqlx::query( + r#" + SELECT + uc.id, uc.user_uuid, uc.coupon_uuid, uc.status, + uc.issued_at, uc.used_at, uc.expires_at, + c.code, c.name, c.description, c.discount_type, c.discount_value, c.min_amount, c.max_discount + FROM user_coupons uc + INNER JOIN coupons c ON uc.coupon_uuid = c.uuid + WHERE uc.user_uuid = $1 + AND uc.status = 'unused' + AND c.status = 'active' + AND (uc.expires_at IS NULL OR uc.expires_at > $2) + AND (c.valid_until IS NULL OR c.valid_until > $2) + AND c.valid_from <= $2 + ORDER BY uc.issued_at DESC + "#, + ) + .bind(user_uuid) + .bind(now) + .fetch_all(pool) + .await?; + + let items = rows + .into_iter() + .map(|row| crate::dto::UserCouponWithDetailsDto { + id: row.get(0), + user_uuid: row.get(1), + coupon_uuid: row.get(2), + status: row.get(3), + issued_at: row.get(4), + used_at: row.get(5), + expires_at: row.get(6), + code: row.get(7), + name: row.get(8), + description: row.get(9), + discount_type: row.get(10), + discount_value: row.get(11), + min_amount: row.get(12), + max_discount: row.get(13), + }) + .collect(); + + Ok(items) +} + +// ============ Payment Orders ============ + +/// 创建支付订单 +pub async fn insert_payment_order( + pool: &Pool, + order_no: &str, + user_uuid: Uuid, + order_type: &str, + amount: Decimal, + currency: &str, + payment_channel: Option<&str>, + description: Option<&str>, + subscription_uuid: Option, + coupon_uuid: Option, + original_amount: Option, + discount_amount: Option, +) -> Result { + let uuid: Uuid = sqlx::query_scalar( + r#" + INSERT INTO payment_orders (order_no, user_uuid, order_type, amount, currency, + status, payment_channel, description, subscription_uuid, + coupon_uuid, original_amount, discount_amount) + VALUES ($1, $2, $3, $4, $5, 'pending', $6, $7, $8, $9, $10, $11) + RETURNING uuid; + "#, + ) + .bind(order_no) + .bind(user_uuid) + .bind(order_type) + .bind(amount) + .bind(currency) + .bind(payment_channel) + .bind(description) + .bind(subscription_uuid) + .bind(coupon_uuid) + .bind(original_amount) + .bind(discount_amount) + .fetch_one(pool) + .await?; + + Ok(uuid) +} + +/// 查询支付订单列表 +pub async fn fetch_payment_orders( + pool: &Pool, + user_uuid: Uuid, + order_type: Option<&str>, + status: Option<&str>, + offset: i64, + limit: i64, +) -> Result, Error> { + let recs = sqlx::query_as::<_, PaymentOrderDto>( + r#" + SELECT id, uuid, order_no, user_uuid, order_type, amount, currency, status, + payment_channel, external_order_id, description, subscription_uuid, + coupon_uuid, original_amount, discount_amount, paid_at, refunded_at, + created_at, updated_at + FROM payment_orders + WHERE user_uuid = $1 + AND ($2::varchar IS NULL OR order_type = $2) + AND ($3::varchar IS NULL OR status = $3) + ORDER BY created_at DESC + LIMIT $4 OFFSET $5 + "#, + ) + .bind(user_uuid) + .bind(order_type) + .bind(status) + .bind(limit) + .bind(offset) + .fetch_all(pool) + .await?; + + Ok(recs) +} + +/// 查询支付订单总数 +pub async fn fetch_payment_orders_count( + pool: &Pool, + user_uuid: Uuid, + order_type: Option<&str>, + status: Option<&str>, +) -> Result { + let count: i64 = sqlx::query_scalar( + r#" + SELECT COUNT(*) FROM payment_orders + WHERE user_uuid = $1 + AND ($2::varchar IS NULL OR order_type = $2) + AND ($3::varchar IS NULL OR status = $3) + "#, + ) + .bind(user_uuid) + .bind(order_type) + .bind(status) + .fetch_one(pool) + .await?; + + Ok(count) +} + +/// 根据 UUID 查询支付订单 +pub async fn fetch_payment_order_by_uuid( + pool: &Pool, + order_uuid: Uuid, +) -> Result, Error> { + let rec = sqlx::query_as::<_, PaymentOrderDto>( + r#" + SELECT id, uuid, order_no, user_uuid, order_type, amount, currency, status, + payment_channel, external_order_id, description, subscription_uuid, + coupon_uuid, original_amount, discount_amount, paid_at, refunded_at, + created_at, updated_at + FROM payment_orders + WHERE uuid = $1 + "#, + ) + .bind(order_uuid) + .fetch_optional(pool) + .await?; + + Ok(rec) +} + +/// 更新订单状态 +pub async fn update_payment_order_status( + pool: &Pool, + order_uuid: Uuid, + status: &str, + external_order_id: Option<&str>, +) -> Result<(), Error> { + sqlx::query( + r#" + UPDATE payment_orders + SET status = $1, + external_order_id = COALESCE($2, external_order_id), + paid_at = CASE WHEN $1 = 'paid' THEN CURRENT_TIMESTAMP ELSE paid_at END, + updated_at = CURRENT_TIMESTAMP + WHERE uuid = $3 + "#, + ) + .bind(status) + .bind(external_order_id) + .bind(order_uuid) + .execute(pool) + .await?; + + Ok(()) +} + +// ============ Auto Renewal Services ============ + +/// 查询用户自动续费服务列表 +pub async fn fetch_auto_renewal_services( + pool: &Pool, + user_uuid: Uuid, +) -> Result, Error> { + let recs = sqlx::query_as::<_, AutoRenewalServiceDto>( + r#" + SELECT id, uuid, user_uuid, service_type, service_uuid, service_name, + renewal_price, currency, next_bill_date, status, created_at, updated_at + FROM auto_renewal_services + WHERE user_uuid = $1 AND status = 'active' + ORDER BY next_bill_date ASC + "#, + ) + .bind(user_uuid) + .fetch_all(pool) + .await?; + + Ok(recs) +} diff --git a/src/models/environments.rs b/src/models/environments.rs new file mode 100644 index 0000000..9effdb6 --- /dev/null +++ b/src/models/environments.rs @@ -0,0 +1,1229 @@ +use sqlx::{Error, Pool, Postgres}; +use uuid::Uuid; + +use crate::dto::{ + EnvironmentAccountRowDto, EnvironmentConfigDto, EnvironmentCookieDto, EnvironmentDto, + EnvironmentRowDto, EnvironmentTagRowDto, EnvironmentUrlDto, GroupDto, GroupRowDto, ProxyRowDto, + TagDto, TemplateDto, +}; +use crate::entitys::CookieInput; + +// ============ Groups ============ + +/// 创建分组 +pub async fn insert_group( + pool: &Pool, + workspace_uuid: Uuid, + team_uuid: Uuid, + name: &str, + description: Option<&str>, + created_by: Uuid, +) -> Result { + let uuid: Uuid = sqlx::query_scalar( + r#" + INSERT INTO groups (workspace_uuid, team_uuid, name, description, created_by) + VALUES ($1, $2, $3, $4, $5) + RETURNING uuid; + "#, + ) + .bind(workspace_uuid) + .bind(team_uuid) + .bind(name) + .bind(description) + .bind(created_by) + .fetch_one(pool) + .await?; + + Ok(uuid) +} + +/// 查询分组列表(工作空间级别) +pub async fn fetch_groups( + pool: &Pool, + workspace_uuid: Uuid, + team_uuid: Uuid, + offset: i64, + limit: i64, +) -> Result, Error> { + let recs = sqlx::query_as::<_, GroupDto>( + r#" + SELECT g.id, g.uuid, g.workspace_uuid, g.team_uuid, t.name AS team_name, + g.name, g.description, g.sort_order, + g.created_by, ui.nickname AS created_by_name, + (SELECT COUNT(*) FROM environments e WHERE e.group_uuid = g.uuid AND e.deleted_at IS NULL) AS environments_count, + g.created_at, g.updated_at, g.deleted_at + FROM groups g + LEFT JOIN teams t ON g.team_uuid = t.uuid + LEFT JOIN user_infos ui ON g.created_by = ui.user_uuid + WHERE g.workspace_uuid = $1 AND g.team_uuid = $2 AND g.deleted_at IS NULL + ORDER BY g.sort_order, g.name + LIMIT $3 OFFSET $4 + "#, + ) + .bind(workspace_uuid) + .bind(team_uuid) + .bind(limit) + .bind(offset) + .fetch_all(pool) + .await?; + + Ok(recs) +} + +/// 根据 UUID 查询分组 +pub async fn fetch_group_by_uuid( + pool: &Pool, + group_uuid: Uuid, +) -> Result, Error> { + let rec = sqlx::query_as::<_, GroupDto>( + r#" + SELECT g.id, g.uuid, g.workspace_uuid, g.team_uuid, t.name AS team_name, + g.name, g.description, g.sort_order, + g.created_by, ui.nickname AS created_by_name, + (SELECT COUNT(*) FROM environments e WHERE e.group_uuid = g.uuid AND e.deleted_at IS NULL) AS environments_count, + g.created_at, g.updated_at, g.deleted_at + FROM groups g + LEFT JOIN teams t ON g.team_uuid = t.uuid + LEFT JOIN user_infos ui ON g.created_by = ui.user_uuid + WHERE g.uuid = $1 AND g.deleted_at IS NULL + "#, + ) + .bind(group_uuid) + .fetch_optional(pool) + .await?; + + Ok(rec) +} + +/// 更新分组 +pub async fn update_group( + pool: &Pool, + group_uuid: Uuid, + name: Option<&str>, + description: Option<&str>, + sort_order: Option, +) -> Result<(), Error> { + sqlx::query( + r#" + UPDATE groups + SET name = COALESCE($1, name), + description = COALESCE($2, description), + sort_order = COALESCE($3, sort_order) + WHERE uuid = $4 AND deleted_at IS NULL + "#, + ) + .bind(name) + .bind(description) + .bind(sort_order) + .bind(group_uuid) + .execute(pool) + .await?; + + Ok(()) +} + +/// 软删除分组 +pub async fn delete_group(pool: &Pool, group_uuid: Uuid) -> Result<(), Error> { + sqlx::query( + r#" + UPDATE groups SET deleted_at = CURRENT_TIMESTAMP + WHERE uuid = $1 AND deleted_at IS NULL + "#, + ) + .bind(group_uuid) + .execute(pool) + .await?; + + Ok(()) +} + +// ============ Tags ============ + +/// 创建标签 +pub async fn insert_tag( + pool: &Pool, + user_uuid: Uuid, + team_uuid: Option, + name: &str, + color: Option<&str>, +) -> Result { + let uuid: Uuid = sqlx::query_scalar( + r#" + INSERT INTO tags (user_uuid, team_uuid, name, color) + VALUES ($1, $2, $3, $4) + RETURNING uuid; + "#, + ) + .bind(user_uuid) + .bind(team_uuid) + .bind(name) + .bind(color.unwrap_or("gray")) + .fetch_one(pool) + .await?; + + Ok(uuid) +} + +/// 查询标签列表 +pub async fn fetch_tags( + pool: &Pool, + team_uuid: Option, + user_uuid: Uuid, +) -> Result, Error> { + let recs = sqlx::query_as::<_, TagDto>( + r#" + SELECT id, uuid, user_uuid, team_uuid, name, color, sort_order, + environments_count, created_at, updated_at, deleted_at + FROM tags + WHERE (team_uuid = $1 OR (team_uuid IS NULL AND user_uuid = $2)) + AND deleted_at IS NULL + ORDER BY sort_order, name + "#, + ) + .bind(team_uuid) + .bind(user_uuid) + .fetch_all(pool) + .await?; + + Ok(recs) +} + +/// 根据 UUID 查询标签 +pub async fn fetch_tag_by_uuid( + pool: &Pool, + tag_uuid: Uuid, +) -> Result, Error> { + let rec = sqlx::query_as::<_, TagDto>( + r#" + SELECT id, uuid, user_uuid, team_uuid, name, color, sort_order, + environments_count, created_at, updated_at, deleted_at + FROM tags + WHERE uuid = $1 AND deleted_at IS NULL + "#, + ) + .bind(tag_uuid) + .fetch_optional(pool) + .await?; + + Ok(rec) +} + +/// 更新标签 +pub async fn update_tag( + pool: &Pool, + tag_uuid: Uuid, + name: Option<&str>, + color: Option<&str>, + sort_order: Option, +) -> Result<(), Error> { + sqlx::query( + r#" + UPDATE tags + SET name = COALESCE($1, name), + color = COALESCE($2, color), + sort_order = COALESCE($3, sort_order) + WHERE uuid = $4 AND deleted_at IS NULL + "#, + ) + .bind(name) + .bind(color) + .bind(sort_order) + .bind(tag_uuid) + .execute(pool) + .await?; + + Ok(()) +} + +/// 软删除标签 +pub async fn delete_tag(pool: &Pool, tag_uuid: Uuid) -> Result<(), Error> { + sqlx::query( + r#" + UPDATE tags SET deleted_at = CURRENT_TIMESTAMP + WHERE uuid = $1 AND deleted_at IS NULL + "#, + ) + .bind(tag_uuid) + .execute(pool) + .await?; + + Ok(()) +} + +// ============ Environments ============ + +/// 创建环境 +pub async fn insert_environment( + pool: &Pool, + workspace_uuid: Uuid, + user_uuid: Uuid, + team_uuid: Uuid, + name: &str, + description: Option<&str>, + group_uuid: Option, + proxy_uuid: Option, + system_info: Option<&str>, + kernel_info: Option<&str>, +) -> Result { + let uuid: Uuid = sqlx::query_scalar( + r#" + INSERT INTO environments (workspace_uuid, user_uuid, team_uuid, name, description, + group_uuid, proxy_uuid, system_info, kernel_info) + VALUES ($1, $2, $3, $4, $5, $6, $7, $8, $9) + RETURNING uuid; + "#, + ) + .bind(workspace_uuid) + .bind(user_uuid) + .bind(team_uuid) + .bind(name) + .bind(description) + .bind(group_uuid) + .bind(proxy_uuid) + .bind(system_info) + .bind(kernel_info) + .fetch_one(pool) + .await?; + + Ok(uuid) +} + +/// 查询环境列表(基础信息) +pub async fn fetch_environments_base( + pool: &Pool, + workspace_uuid: Uuid, + team_uuid: Uuid, + group_uuid: Option, + status: Option<&str>, + offset: i64, + limit: i64, +) -> Result, Error> { + let recs = sqlx::query_as::<_, EnvironmentRowDto>( + r#" + SELECT + id, uuid, workspace_uuid, user_uuid, team_uuid, name, description, status, + system_info, kernel_info, fingerprint_summary, + group_uuid, proxy_uuid, + last_opened_at, created_at, updated_at + FROM environments + WHERE workspace_uuid = $1 + AND team_uuid = $2 + AND ($3::uuid IS NULL OR group_uuid = $3) + AND ($4::varchar IS NULL OR status = $4) + AND deleted_at IS NULL + ORDER BY created_at DESC + LIMIT $5 OFFSET $6 + "#, + ) + .bind(workspace_uuid) + .bind(team_uuid) + .bind(group_uuid) + .bind(status) + .bind(limit) + .bind(offset) + .fetch_all(pool) + .await?; + + Ok(recs) +} + +/// 查询环境列表(基础) +pub async fn fetch_environments( + pool: &Pool, + workspace_uuid: Uuid, + team_uuid: Uuid, + group_uuid: Option, + status: Option<&str>, + offset: i64, + limit: i64, +) -> Result, Error> { + let recs = sqlx::query_as::<_, EnvironmentDto>( + r#" + SELECT id, uuid, workspace_uuid, user_uuid, team_uuid, name, description, + status, group_uuid, proxy_uuid, system_info, kernel_info, fingerprint_summary, + last_opened_at, created_at, updated_at, deleted_at + FROM environments + WHERE workspace_uuid = $1 AND team_uuid = $2 + AND ($3::uuid IS NULL OR group_uuid = $3) + AND ($4::varchar IS NULL OR status = $4) + AND deleted_at IS NULL + ORDER BY created_at DESC + LIMIT $5 OFFSET $6 + "#, + ) + .bind(workspace_uuid) + .bind(team_uuid) + .bind(group_uuid) + .bind(status) + .bind(limit) + .bind(offset) + .fetch_all(pool) + .await?; + + Ok(recs) +} + +/// 查询环境总数 +pub async fn fetch_environments_count( + pool: &Pool, + workspace_uuid: Uuid, + team_uuid: Uuid, + group_uuid: Option, + status: Option<&str>, +) -> Result { + let count: i64 = sqlx::query_scalar( + r#" + SELECT COUNT(*) FROM environments + WHERE workspace_uuid = $1 AND team_uuid = $2 + AND ($3::uuid IS NULL OR group_uuid = $3) + AND ($4::varchar IS NULL OR status = $4) + AND deleted_at IS NULL + "#, + ) + .bind(workspace_uuid) + .bind(team_uuid) + .bind(group_uuid) + .bind(status) + .fetch_one(pool) + .await?; + + Ok(count) +} + +/// 根据 UUID 查询环境(不带工作空间过滤,用于内部查询) +pub async fn fetch_environment_by_uuid_unfiltered( + pool: &Pool, + env_uuid: Uuid, +) -> Result, Error> { + let rec = sqlx::query_as::<_, EnvironmentDto>( + r#" + SELECT id, uuid, workspace_uuid, user_uuid, team_uuid, name, description, + status, group_uuid, proxy_uuid, system_info, kernel_info, fingerprint_summary, + last_opened_at, created_at, updated_at, deleted_at + FROM environments + WHERE uuid = $1 AND deleted_at IS NULL + "#, + ) + .bind(env_uuid) + .fetch_optional(pool) + .await?; + + Ok(rec) +} + +/// 根据 UUID 查询环境(带工作空间过滤) +pub async fn fetch_environment_by_uuid( + pool: &Pool, + workspace_uuid: Uuid, + env_uuid: Uuid, +) -> Result, Error> { + let rec = sqlx::query_as::<_, EnvironmentDto>( + r#" + SELECT id, uuid, workspace_uuid, user_uuid, team_uuid, name, description, + status, group_uuid, proxy_uuid, system_info, kernel_info, fingerprint_summary, + last_opened_at, created_at, updated_at, deleted_at + FROM environments + WHERE uuid = $1 AND workspace_uuid = $2 AND deleted_at IS NULL + "#, + ) + .bind(env_uuid) + .bind(workspace_uuid) + .fetch_optional(pool) + .await?; + + Ok(rec) +} + +/// 更新环境基础信息 +pub async fn update_environment( + pool: &Pool, + env_uuid: Uuid, + name: Option<&str>, + description: Option<&str>, + group_uuid: Option, +) -> Result<(), Error> { + sqlx::query( + r#" + UPDATE environments + SET name = COALESCE($1, name), + description = COALESCE($2, description), + group_uuid = $3 + WHERE uuid = $4 AND deleted_at IS NULL + "#, + ) + .bind(name) + .bind(description) + .bind(group_uuid) + .bind(env_uuid) + .execute(pool) + .await?; + + Ok(()) +} + +/// 更新环境状态 +pub async fn update_environment_status( + pool: &Pool, + env_uuid: Uuid, + status: &str, +) -> Result<(), Error> { + sqlx::query( + r#" + UPDATE environments SET status = $1 + WHERE uuid = $2 AND deleted_at IS NULL + "#, + ) + .bind(status) + .bind(env_uuid) + .execute(pool) + .await?; + + Ok(()) +} + +/// 更新环境代理 +pub async fn update_environment_proxy( + pool: &Pool, + env_uuid: Uuid, + proxy_uuid: Option, +) -> Result<(), Error> { + sqlx::query( + r#" + UPDATE environments SET proxy_uuid = $1 + WHERE uuid = $2 AND deleted_at IS NULL + "#, + ) + .bind(proxy_uuid) + .bind(env_uuid) + .execute(pool) + .await?; + + Ok(()) +} + +/// 更新环境最后打开时间 +pub async fn update_environment_last_opened( + pool: &Pool, + env_uuid: Uuid, +) -> Result<(), Error> { + sqlx::query( + r#" + UPDATE environments SET last_opened_at = CURRENT_TIMESTAMP + WHERE uuid = $1 AND deleted_at IS NULL + "#, + ) + .bind(env_uuid) + .execute(pool) + .await?; + + Ok(()) +} + +/// 软删除环境 +pub async fn delete_environment(pool: &Pool, env_uuid: Uuid) -> Result<(), Error> { + sqlx::query( + r#" + UPDATE environments SET deleted_at = CURRENT_TIMESTAMP + WHERE uuid = $1 AND deleted_at IS NULL + "#, + ) + .bind(env_uuid) + .execute(pool) + .await?; + + Ok(()) +} + +/// 批量软删除环境 +pub async fn batch_delete_environments( + pool: &Pool, + env_uuids: &[Uuid], +) -> Result { + let result = sqlx::query( + r#" + UPDATE environments SET deleted_at = CURRENT_TIMESTAMP + WHERE uuid = ANY($1) AND deleted_at IS NULL + "#, + ) + .bind(env_uuids) + .execute(pool) + .await?; + + Ok(result.rows_affected()) +} + +// ============ Environment Configs ============ + +/// 创建或更新环境配置 +pub async fn upsert_environment_config( + pool: &Pool, + env_uuid: Uuid, + window_info: &serde_json::Value, + basic_settings: &serde_json::Value, + fingerprint_settings: &serde_json::Value, + device_settings: &serde_json::Value, + preference_settings: &serde_json::Value, + project_metadata: &serde_json::Value, +) -> Result { + let id: i32 = sqlx::query_scalar( + r#" + INSERT INTO environment_configs (environment_uuid, window_info, basic_settings, + fingerprint_settings, device_settings, preference_settings, + project_metadata) + VALUES ($1, $2, $3, $4, $5, $6, $7) + ON CONFLICT (environment_uuid) DO UPDATE SET + window_info = $2, + basic_settings = $3, + fingerprint_settings = $4, + device_settings = $5, + preference_settings = $6, + project_metadata = $7 + RETURNING id; + "#, + ) + .bind(env_uuid) + .bind(window_info) + .bind(basic_settings) + .bind(fingerprint_settings) + .bind(device_settings) + .bind(preference_settings) + .bind(project_metadata) + .fetch_one(pool) + .await?; + + Ok(id) +} + +/// 查询环境配置 +pub async fn fetch_environment_config( + pool: &Pool, + env_uuid: Uuid, +) -> Result, Error> { + let rec = sqlx::query_as::<_, EnvironmentConfigDto>( + r#" + SELECT id, environment_uuid, window_info, basic_settings, fingerprint_settings, + device_settings, preference_settings, project_metadata, created_at, updated_at + FROM environment_configs + WHERE environment_uuid = $1 + "#, + ) + .bind(env_uuid) + .fetch_optional(pool) + .await?; + + Ok(rec) +} + +/// 批量查询环境配置 +pub async fn fetch_environment_configs_by_uuids( + pool: &Pool, + env_uuids: &[Uuid], +) -> Result, Error> { + if env_uuids.is_empty() { + return Ok(vec![]); + } + + let recs = sqlx::query_as::<_, EnvironmentConfigDto>( + r#" + SELECT id, environment_uuid, window_info, basic_settings, fingerprint_settings, + device_settings, preference_settings, project_metadata, created_at, updated_at + FROM environment_configs + WHERE environment_uuid = ANY($1) + "#, + ) + .bind(env_uuids) + .fetch_all(pool) + .await?; + + Ok(recs) +} + +// ============ Environment Tags ============ + +/// 为环境添加标签 +pub async fn insert_environment_tag( + pool: &Pool, + env_uuid: Uuid, + tag_uuid: Uuid, +) -> Result<(), Error> { + sqlx::query( + r#" + INSERT INTO environment_tags (environment_uuid, tag_uuid) + VALUES ($1, $2) + ON CONFLICT DO NOTHING; + "#, + ) + .bind(env_uuid) + .bind(tag_uuid) + .execute(pool) + .await?; + + Ok(()) +} + +/// 移除环境的标签 +pub async fn remove_environment_tag( + pool: &Pool, + env_uuid: Uuid, + tag_uuid: Uuid, +) -> Result<(), Error> { + sqlx::query( + r#" + DELETE FROM environment_tags + WHERE environment_uuid = $1 AND tag_uuid = $2; + "#, + ) + .bind(env_uuid) + .bind(tag_uuid) + .execute(pool) + .await?; + + Ok(()) +} + +/// 清除环境的所有标签 +pub async fn clear_environment_tags(pool: &Pool, env_uuid: Uuid) -> Result<(), Error> { + sqlx::query( + r#" + DELETE FROM environment_tags + WHERE environment_uuid = $1; + "#, + ) + .bind(env_uuid) + .execute(pool) + .await?; + + Ok(()) +} + +/// 查询环境的所有标签 +pub async fn fetch_environment_tags( + pool: &Pool, + env_uuid: Uuid, +) -> Result, Error> { + let recs = sqlx::query_as::<_, TagDto>( + r#" + SELECT t.id, t.uuid, t.user_uuid, t.team_uuid, t.name, t.color, t.sort_order, + t.environments_count, t.created_at, t.updated_at, t.deleted_at + FROM tags t + INNER JOIN environment_tags et ON t.uuid = et.tag_uuid + WHERE et.environment_uuid = $1 AND t.deleted_at IS NULL + ORDER BY t.sort_order, t.name + "#, + ) + .bind(env_uuid) + .fetch_all(pool) + .await?; + + Ok(recs) +} + +/// 批量查询环境标签(完整标签信息) +pub async fn fetch_tags_for_environments( + pool: &Pool, + env_uuids: &[Uuid], +) -> Result, Error> { + if env_uuids.is_empty() { + return Ok(vec![]); + } + + let recs = sqlx::query_as::<_, EnvironmentTagRowDto>( + r#" + SELECT + et.environment_uuid, + t.id as tag_id, + t.uuid as tag_uuid, + t.name as tag_name, + t.color as tag_color, + t.sort_order as tag_sort_order, + t.user_uuid as tag_user_uuid, + t.team_uuid as tag_team_uuid, + t.environments_count as tag_environments_count, + t.created_at as tag_created_at, + t.updated_at as tag_updated_at, + t.deleted_at as tag_deleted_at + FROM environment_tags et + INNER JOIN tags t ON et.tag_uuid = t.uuid + WHERE et.environment_uuid = ANY($1) AND t.deleted_at IS NULL + ORDER BY t.sort_order, t.name + "#, + ) + .bind(env_uuids) + .fetch_all(pool) + .await?; + + Ok(recs) +} + +/// 批量查询环境账号(完整账号信息,排除敏感数据) +pub async fn fetch_accounts_for_environments( + pool: &Pool, + env_uuids: &[Uuid], +) -> Result, Error> { + if env_uuids.is_empty() { + return Ok(vec![]); + } + + let recs = sqlx::query_as::<_, EnvironmentAccountRowDto>( + r#" + SELECT + ea.environment_uuid, + pa.id as account_id, + pa.uuid as account_uuid, + pa.platform_url, + pa.platform_name, + pa.account, + pa.status as account_status, + pa.remark + FROM environment_accounts ea + INNER JOIN platform_accounts pa ON ea.account_uuid = pa.uuid + WHERE ea.environment_uuid = ANY($1) AND pa.deleted_at IS NULL + ORDER BY ea.sort_order + "#, + ) + .bind(env_uuids) + .fetch_all(pool) + .await?; + + Ok(recs) +} + +/// 批量查询分组 +pub async fn fetch_groups_by_uuids( + pool: &Pool, + group_uuids: &[Uuid], +) -> Result, Error> { + if group_uuids.is_empty() { + return Ok(vec![]); + } + + let recs = sqlx::query_as::<_, GroupRowDto>( + r#" + SELECT id, uuid, name, description, sort_order + FROM groups + WHERE uuid = ANY($1) AND deleted_at IS NULL + "#, + ) + .bind(group_uuids) + .fetch_all(pool) + .await?; + + Ok(recs) +} + +/// 批量查询代理 +pub async fn fetch_proxies_by_uuids( + pool: &Pool, + proxy_uuids: &[Uuid], +) -> Result, Error> { + if proxy_uuids.is_empty() { + return Ok(vec![]); + } + + let recs = sqlx::query_as::<_, ProxyRowDto>( + r#" + SELECT id, uuid, name, host, port, proxy_type, + country, city, status, latency, last_check_ip + FROM proxies + WHERE uuid = ANY($1) AND deleted_at IS NULL + "#, + ) + .bind(proxy_uuids) + .fetch_all(pool) + .await?; + + Ok(recs) +} + +// ============ Templates ============ + +/// 创建模板 +pub async fn insert_template( + pool: &Pool, + user_uuid: Uuid, + team_uuid: Option, + name: &str, + description: Option<&str>, + is_public: bool, + system_info: Option<&str>, + kernel_info: Option<&str>, + config_json: &serde_json::Value, +) -> Result { + let uuid: Uuid = sqlx::query_scalar( + r#" + INSERT INTO templates (user_uuid, team_uuid, name, description, is_public, + system_info, kernel_info, config_json) + VALUES ($1, $2, $3, $4, $5, $6, $7, $8) + RETURNING uuid; + "#, + ) + .bind(user_uuid) + .bind(team_uuid) + .bind(name) + .bind(description) + .bind(is_public) + .bind(system_info) + .bind(kernel_info) + .bind(config_json) + .fetch_one(pool) + .await?; + + Ok(uuid) +} + +/// 查询模板列表 +pub async fn fetch_templates( + pool: &Pool, + team_uuid: Option, + user_uuid: Uuid, + is_public: Option, + offset: i64, + limit: i64, +) -> Result, Error> { + let recs = sqlx::query_as::<_, TemplateDto>( + r#" + SELECT id, uuid, user_uuid, team_uuid, name, description, is_public, + system_info, kernel_info, config_json, usage_count, created_at, updated_at, deleted_at + FROM templates + WHERE (team_uuid = $1 OR (team_uuid IS NULL AND user_uuid = $2) OR is_public = TRUE) + AND ($3::boolean IS NULL OR is_public = $3) + AND deleted_at IS NULL + ORDER BY usage_count DESC, created_at DESC + LIMIT $4 OFFSET $5 + "#, + ) + .bind(team_uuid) + .bind(user_uuid) + .bind(is_public) + .bind(limit) + .bind(offset) + .fetch_all(pool) + .await?; + + Ok(recs) +} + +/// 查询模板总数 +pub async fn fetch_templates_count( + pool: &Pool, + team_uuid: Option, + user_uuid: Uuid, + is_public: Option, +) -> Result { + let count: i64 = sqlx::query_scalar( + r#" + SELECT COUNT(*) + FROM templates + WHERE (team_uuid = $1 OR (team_uuid IS NULL AND user_uuid = $2) OR is_public = TRUE) + AND ($3::boolean IS NULL OR is_public = $3) + AND deleted_at IS NULL + "#, + ) + .bind(team_uuid) + .bind(user_uuid) + .bind(is_public) + .fetch_one(pool) + .await?; + + Ok(count) +} + +/// 根据 UUID 查询模板 +pub async fn fetch_template_by_uuid( + pool: &Pool, + template_uuid: Uuid, +) -> Result, Error> { + let rec = sqlx::query_as::<_, TemplateDto>( + r#" + SELECT id, uuid, user_uuid, team_uuid, name, description, is_public, + system_info, kernel_info, config_json, usage_count, created_at, updated_at, deleted_at + FROM templates + WHERE uuid = $1 AND deleted_at IS NULL + "#, + ) + .bind(template_uuid) + .fetch_optional(pool) + .await?; + + Ok(rec) +} + +/// 更新模板 +pub async fn update_template( + pool: &Pool, + template_uuid: Uuid, + name: Option<&str>, + description: Option<&str>, + is_public: Option, + config_json: Option<&serde_json::Value>, +) -> Result<(), Error> { + sqlx::query( + r#" + UPDATE templates + SET name = COALESCE($1, name), + description = COALESCE($2, description), + is_public = COALESCE($3, is_public), + config_json = COALESCE($4, config_json) + WHERE uuid = $5 AND deleted_at IS NULL + "#, + ) + .bind(name) + .bind(description) + .bind(is_public) + .bind(config_json) + .bind(template_uuid) + .execute(pool) + .await?; + + Ok(()) +} + +/// 增加模板使用次数 +pub async fn increment_template_usage( + pool: &Pool, + template_uuid: Uuid, +) -> Result<(), Error> { + sqlx::query( + r#" + UPDATE templates SET usage_count = usage_count + 1 + WHERE uuid = $1 AND deleted_at IS NULL + "#, + ) + .bind(template_uuid) + .execute(pool) + .await?; + + Ok(()) +} + +/// 软删除模板 +pub async fn delete_template(pool: &Pool, template_uuid: Uuid) -> Result<(), Error> { + sqlx::query( + r#" + UPDATE templates SET deleted_at = CURRENT_TIMESTAMP + WHERE uuid = $1 AND deleted_at IS NULL + "#, + ) + .bind(template_uuid) + .execute(pool) + .await?; + + Ok(()) +} + +// ============ Environment URLs ============ + +/// 添加环境 URL +pub async fn insert_environment_url( + pool: &Pool, + env_uuid: Uuid, + url: &str, + title: Option<&str>, + sort_order: Option, +) -> Result { + let id: i32 = sqlx::query_scalar( + r#" + INSERT INTO environment_urls (environment_uuid, url, title, sort_order) + VALUES ($1, $2, $3, $4) + RETURNING id; + "#, + ) + .bind(env_uuid) + .bind(url) + .bind(title) + .bind(sort_order.unwrap_or(0)) + .fetch_one(pool) + .await?; + + Ok(id) +} + +/// 批量添加环境 URL +pub async fn batch_insert_environment_urls( + pool: &Pool, + env_uuid: Uuid, + urls: &[(String, Option)], +) -> Result { + let mut count = 0; + for (idx, (url, title)) in urls.iter().enumerate() { + sqlx::query( + r#" + INSERT INTO environment_urls (environment_uuid, url, title, sort_order) + VALUES ($1, $2, $3, $4) + ON CONFLICT DO NOTHING; + "#, + ) + .bind(env_uuid) + .bind(url) + .bind(title.as_deref()) + .bind(idx as i32) + .execute(pool) + .await?; + count += 1; + } + Ok(count) +} + +/// 查询环境的所有 URL +pub async fn fetch_environment_urls( + pool: &Pool, + env_uuid: Uuid, +) -> Result, Error> { + let recs = sqlx::query_as::<_, EnvironmentUrlDto>( + r#" + SELECT id, environment_uuid, url, title, sort_order, created_at + FROM environment_urls + WHERE environment_uuid = $1 + ORDER BY sort_order, id + "#, + ) + .bind(env_uuid) + .fetch_all(pool) + .await?; + + Ok(recs) +} + +/// 删除环境的 URL +pub async fn delete_environment_url(pool: &Pool, url_id: i32) -> Result<(), Error> { + sqlx::query( + r#" + DELETE FROM environment_urls WHERE id = $1 + "#, + ) + .bind(url_id) + .execute(pool) + .await?; + + Ok(()) +} + +/// 清空环境的所有 URL +pub async fn clear_environment_urls(pool: &Pool, env_uuid: Uuid) -> Result { + let result = sqlx::query( + r#" + DELETE FROM environment_urls WHERE environment_uuid = $1 + "#, + ) + .bind(env_uuid) + .execute(pool) + .await?; + + Ok(result.rows_affected()) +} + +// ============ Environment Cookies ============ + +/// 添加环境 Cookie +pub async fn insert_environment_cookie( + pool: &Pool, + env_uuid: Uuid, + domain: &str, + name: &str, + value: &str, + path: Option<&str>, + http_only: Option, + secure: Option, + same_site: Option<&str>, +) -> Result { + let id: i32 = sqlx::query_scalar( + r#" + INSERT INTO environment_cookies (environment_uuid, domain, name, value, path, http_only, secure, same_site) + VALUES ($1, $2, $3, $4, $5, $6, $7, $8) + RETURNING id; + "#, + ) + .bind(env_uuid) + .bind(domain) + .bind(name) + .bind(value) + .bind(path.unwrap_or("/")) + .bind(http_only.unwrap_or(false)) + .bind(secure.unwrap_or(false)) + .bind(same_site.unwrap_or("Lax")) + .fetch_one(pool) + .await?; + + Ok(id) +} + +/// 批量添加环境 Cookies +pub async fn batch_insert_environment_cookies( + pool: &Pool, + env_uuid: Uuid, + cookies: &[CookieInput], +) -> Result { + let mut count = 0; + for cookie in cookies { + sqlx::query( + r#" + INSERT INTO environment_cookies (environment_uuid, domain, name, value, path, http_only, secure, same_site) + VALUES ($1, $2, $3, $4, $5, $6, $7, $8) + ON CONFLICT DO NOTHING; + "#, + ) + .bind(env_uuid) + .bind(&cookie.domain) + .bind(&cookie.name) + .bind(&cookie.value) + .bind(cookie.path.as_deref().unwrap_or("/")) + .bind(cookie.http_only.unwrap_or(false)) + .bind(cookie.secure.unwrap_or(false)) + .bind(cookie.same_site.as_deref().unwrap_or("Lax")) + .execute(pool) + .await?; + count += 1; + } + Ok(count) +} + +/// 查询环境的所有 Cookies +pub async fn fetch_environment_cookies( + pool: &Pool, + env_uuid: Uuid, +) -> Result, Error> { + let recs = sqlx::query_as::<_, EnvironmentCookieDto>( + r#" + SELECT id, environment_uuid, domain, name, value, path, expires_at, http_only, secure, same_site, created_at + FROM environment_cookies + WHERE environment_uuid = $1 + ORDER BY domain, name + "#, + ) + .bind(env_uuid) + .fetch_all(pool) + .await?; + + Ok(recs) +} + +/// 删除环境的 Cookie +pub async fn delete_environment_cookie(pool: &Pool, cookie_id: i32) -> Result<(), Error> { + sqlx::query( + r#" + DELETE FROM environment_cookies WHERE id = $1 + "#, + ) + .bind(cookie_id) + .execute(pool) + .await?; + + Ok(()) +} + +/// 清空环境的所有 Cookies +pub async fn clear_environment_cookies( + pool: &Pool, + env_uuid: Uuid, +) -> Result { + let result = sqlx::query( + r#" + DELETE FROM environment_cookies WHERE environment_uuid = $1 + "#, + ) + .bind(env_uuid) + .execute(pool) + .await?; + + Ok(result.rows_affected()) +} diff --git a/src/models/extensions.rs b/src/models/extensions.rs new file mode 100644 index 0000000..99e0d8d --- /dev/null +++ b/src/models/extensions.rs @@ -0,0 +1,564 @@ +use sqlx::{Error, Pool, Postgres}; +use uuid::Uuid; + +use crate::entitys::extensions::{CreateExtensionParams, UpdateExtensionParams}; +use crate::dto::{ + EnvironmentExtensionDto, ExtensionDto, GroupExtensionDto, TeamExtensionDto, UserExtensionDto, +}; + +// ============ Extensions CRUD ============ + +/// 创建扩展 +pub async fn create_extension( + pool: &Pool, + params: CreateExtensionParams, +) -> Result { + let rec = sqlx::query_as::<_, ExtensionDto>( + r#" + INSERT INTO extensions ( + extension_id, name, description, version, category, browser, + developer, homepage, icon_url, download_url, file_size, downloads_count, + permissions, rating, changelog, published_at, status + ) + VALUES ($1, $2, $3, $4, $5, $6, $7, $8, $9, $10, $11, $12, $13, $14, $15, $16, 'active') + RETURNING id, uuid, extension_id, name, description, version, category, browser, + developer, homepage, icon_url, download_url, file_size, downloads_count, + rating, permissions, status, changelog, published_at, created_at, updated_at + "#, + ) + .bind(¶ms.extension_id) + .bind(¶ms.name) + .bind(¶ms.description) + .bind(¶ms.version) + .bind(¶ms.category) + .bind(¶ms.browser) + .bind(¶ms.developer) + .bind(¶ms.homepage) + .bind(¶ms.icon_url) + .bind(¶ms.download_url) + .bind(params.file_size) + .bind(params.downloads_count) + .bind(¶ms.permissions) + .bind(params.rating) + .bind(¶ms.changelog) + .bind(params.published_at) + .fetch_one(pool) + .await?; + + Ok(rec) +} + +/// 更新扩展 +pub async fn update_extension( + pool: &Pool, + extension_id: &str, + params: UpdateExtensionParams, +) -> Result<(), Error> { + sqlx::query( + r#" + UPDATE extensions SET + name = COALESCE($2, name), + description = COALESCE($3, description), + version = COALESCE($4, version), + category = COALESCE($5, category), + developer = COALESCE($6, developer), + homepage = COALESCE($7, homepage), + icon_url = COALESCE($8, icon_url), + download_url = COALESCE($9, download_url), + file_size = COALESCE($10, file_size), + downloads_count = COALESCE($11, downloads_count), + permissions = COALESCE($12, permissions), + rating = COALESCE($13, rating), + changelog = COALESCE($14, changelog), + published_at = COALESCE($15, published_at), + updated_at = CURRENT_TIMESTAMP + WHERE extension_id = $1 + "#, + ) + .bind(extension_id) + .bind(params.name) + .bind(params.description) + .bind(params.version) + .bind(params.category) + .bind(params.developer) + .bind(params.homepage) + .bind(params.icon_url) + .bind(params.download_url) + .bind(params.file_size) + .bind(params.downloads_count) + .bind(params.permissions) + .bind(params.rating) + .bind(params.changelog) + .bind(params.published_at) + .execute(pool) + .await?; + + Ok(()) +} + +/// 根据 extension_id 获取扩展(别名函数,用于同步检查) +pub async fn get_extension_by_extension_id( + pool: &Pool, + extension_id: &str, +) -> Result, Error> { + fetch_extension_by_id(pool, extension_id).await +} + +// ============ Extensions Query ============ + +/// 查询扩展列表 +pub async fn fetch_extensions( + pool: &Pool, + keyword: Option<&str>, + category: Option<&str>, + offset: i64, + limit: i64, +) -> Result, Error> { + let recs = sqlx::query_as::<_, ExtensionDto>( + r#" + SELECT id, uuid, extension_id, name, description, version, category, browser, + developer, homepage, icon_url, download_url, file_size, downloads_count, + rating, permissions, status, changelog, published_at, created_at, updated_at + FROM extensions + WHERE status = 'active' + AND ($1::varchar IS NULL OR name ILIKE '%' || $1 || '%' OR description ILIKE '%' || $1 || '%') + AND ($2::varchar IS NULL OR category = $2) + ORDER BY downloads_count DESC NULLS LAST + LIMIT $3 OFFSET $4 + "#, + ) + .bind(keyword) + .bind(category) + .bind(limit) + .bind(offset) + .fetch_all(pool) + .await?; + + Ok(recs) +} + +/// 查询扩展总数 +pub async fn fetch_extensions_count( + pool: &Pool, + keyword: Option<&str>, + category: Option<&str>, +) -> Result { + let count: i64 = sqlx::query_scalar( + r#" + SELECT COUNT(*) FROM extensions + WHERE status = 'active' + AND ($1::varchar IS NULL OR name ILIKE '%' || $1 || '%' OR description ILIKE '%' || $1 || '%') + AND ($2::varchar IS NULL OR category = $2) + "#, + ) + .bind(keyword) + .bind(category) + .fetch_one(pool) + .await?; + + Ok(count) +} + +/// 根据 extension_id 查询扩展 +pub async fn fetch_extension_by_id( + pool: &Pool, + extension_id: &str, +) -> Result, Error> { + let rec = sqlx::query_as::<_, ExtensionDto>( + r#" + SELECT id, uuid, extension_id, name, description, version, category, browser, + developer, homepage, icon_url, download_url, file_size, downloads_count, + rating, permissions, status, changelog, published_at, created_at, updated_at + FROM extensions + WHERE extension_id = $1 + "#, + ) + .bind(extension_id) + .fetch_optional(pool) + .await?; + + Ok(rec) +} + +/// 获取扩展分类列表 +pub async fn fetch_extension_categories(pool: &Pool) -> Result, Error> { + let categories: Vec<(String,)> = sqlx::query_as( + r#" + SELECT DISTINCT category FROM extensions + WHERE status = 'active' + ORDER BY category + "#, + ) + .fetch_all(pool) + .await?; + + Ok(categories.into_iter().map(|(c,)| c).collect()) +} + +// ============ User Extensions ============ + +/// 查询用户已安装的扩展 +pub async fn fetch_user_extensions( + pool: &Pool, + user_uuid: Uuid, +) -> Result, Error> { + let recs = sqlx::query_as::<_, UserExtensionDto>( + r#" + SELECT id, user_uuid, extension_id, installed_version, status, installed_at, updated_at + FROM user_extensions + WHERE user_uuid = $1 AND status = 'active' + ORDER BY installed_at DESC + "#, + ) + .bind(user_uuid) + .fetch_all(pool) + .await?; + + Ok(recs) +} + +/// 安装用户扩展 +pub async fn insert_user_extension( + pool: &Pool, + user_uuid: Uuid, + extension_id: &str, + version: &str, +) -> Result { + let id: i32 = sqlx::query_scalar( + r#" + INSERT INTO user_extensions (user_uuid, extension_id, installed_version, status) + VALUES ($1, $2, $3, 'active') + ON CONFLICT (user_uuid, extension_id) DO UPDATE SET + installed_version = $3, + status = 'active', + updated_at = CURRENT_TIMESTAMP + RETURNING id; + "#, + ) + .bind(user_uuid) + .bind(extension_id) + .bind(version) + .fetch_one(pool) + .await?; + + Ok(id) +} + +/// 卸载用户扩展 +pub async fn delete_user_extension( + pool: &Pool, + user_uuid: Uuid, + extension_id: &str, +) -> Result<(), Error> { + sqlx::query( + r#" + UPDATE user_extensions SET status = 'inactive', updated_at = CURRENT_TIMESTAMP + WHERE user_uuid = $1 AND extension_id = $2 + "#, + ) + .bind(user_uuid) + .bind(extension_id) + .execute(pool) + .await?; + + Ok(()) +} + +// ============ Team Extensions ============ + +/// 查询团队已安装的扩展 +pub async fn fetch_team_extensions( + pool: &Pool, + team_uuid: Uuid, +) -> Result, Error> { + let recs = sqlx::query_as::<_, TeamExtensionDto>( + r#" + SELECT id, team_uuid, extension_id, installed_version, installed_by, status, installed_at, updated_at + FROM team_extensions + WHERE team_uuid = $1 AND status = 'active' + ORDER BY installed_at DESC + "#, + ) + .bind(team_uuid) + .fetch_all(pool) + .await?; + + Ok(recs) +} + +/// 安装团队扩展 +pub async fn insert_team_extension( + pool: &Pool, + team_uuid: Uuid, + extension_id: &str, + version: &str, + installed_by: Uuid, +) -> Result { + let id: i32 = sqlx::query_scalar( + r#" + INSERT INTO team_extensions (team_uuid, extension_id, installed_version, installed_by, status) + VALUES ($1, $2, $3, $4, 'active') + ON CONFLICT (team_uuid, extension_id) DO UPDATE SET + installed_version = $3, + installed_by = $4, + status = 'active', + updated_at = CURRENT_TIMESTAMP + RETURNING id; + "#, + ) + .bind(team_uuid) + .bind(extension_id) + .bind(version) + .bind(installed_by) + .fetch_one(pool) + .await?; + + Ok(id) +} + +/// 卸载团队扩展 +pub async fn delete_team_extension( + pool: &Pool, + team_uuid: Uuid, + extension_id: &str, +) -> Result<(), Error> { + sqlx::query( + r#" + UPDATE team_extensions SET status = 'inactive', updated_at = CURRENT_TIMESTAMP + WHERE team_uuid = $1 AND extension_id = $2 + "#, + ) + .bind(team_uuid) + .bind(extension_id) + .execute(pool) + .await?; + + Ok(()) +} + +// ============ Group Extensions ============ + +/// 查询分组已安装的扩展 +pub async fn fetch_group_extensions( + pool: &Pool, + group_uuid: Uuid, +) -> Result, Error> { + let recs = sqlx::query_as::<_, GroupExtensionDto>( + r#" + SELECT id, group_uuid, extension_id, installed_version, installed_by, status, installed_at, updated_at + FROM group_extensions + WHERE group_uuid = $1 AND status = 'active' + ORDER BY installed_at DESC + "#, + ) + .bind(group_uuid) + .fetch_all(pool) + .await?; + + Ok(recs) +} + +/// 安装分组扩展 +pub async fn insert_group_extension( + pool: &Pool, + group_uuid: Uuid, + extension_id: &str, + version: &str, + installed_by: Uuid, +) -> Result { + let id: i32 = sqlx::query_scalar( + r#" + INSERT INTO group_extensions (group_uuid, extension_id, installed_version, installed_by, status) + VALUES ($1, $2, $3, $4, 'active') + ON CONFLICT (group_uuid, extension_id) DO UPDATE SET + installed_version = $3, + installed_by = $4, + status = 'active', + updated_at = CURRENT_TIMESTAMP + RETURNING id; + "#, + ) + .bind(group_uuid) + .bind(extension_id) + .bind(version) + .bind(installed_by) + .fetch_one(pool) + .await?; + + Ok(id) +} + +/// 卸载分组扩展(指定分组) +pub async fn delete_group_extension( + pool: &Pool, + group_uuid: Uuid, + extension_id: &str, +) -> Result<(), Error> { + sqlx::query( + r#" + UPDATE group_extensions SET status = 'inactive', updated_at = CURRENT_TIMESTAMP + WHERE group_uuid = $1 AND extension_id = $2 + "#, + ) + .bind(group_uuid) + .bind(extension_id) + .execute(pool) + .await?; + + Ok(()) +} + +/// 卸载分组扩展(根据扩展 ID 删除所有相关分组记录) +pub async fn delete_group_extensions_by_extension_id( + pool: &Pool, + extension_id: &str, +) -> Result<(), Error> { + sqlx::query( + r#" + UPDATE group_extensions SET status = 'inactive', updated_at = CURRENT_TIMESTAMP + WHERE extension_id = $1 AND status = 'active' + "#, + ) + .bind(extension_id) + .execute(pool) + .await?; + + Ok(()) +} + +/// 根据扩展 ID 查询所有关联的分组 UUID +pub async fn fetch_group_uuids_by_extension_id( + pool: &Pool, + extension_id: &str, +) -> Result, Error> { + let recs = sqlx::query_scalar::<_, Uuid>( + r#" + SELECT DISTINCT group_uuid + FROM group_extensions + WHERE extension_id = $1 AND status = 'active' + "#, + ) + .bind(extension_id) + .fetch_all(pool) + .await?; + + Ok(recs) +} + +/// 查询用户相关的分组中安装的扩展 +/// 包括用户个人分组(无 team_uuid,由用户创建)和用户所在团队的分组 +pub async fn fetch_user_group_extensions( + pool: &Pool, + user_uuid: Uuid, + team_uuid: Option, +) -> Result, Error> { + let recs = sqlx::query_as::<_, GroupExtensionDto>( + r#" + SELECT ge.id, ge.group_uuid, ge.extension_id, ge.installed_version, ge.installed_by, ge.status, ge.installed_at, ge.updated_at + FROM group_extensions ge + INNER JOIN groups g ON ge.group_uuid = g.uuid + WHERE ge.status = 'active' + AND g.deleted_at IS NULL + AND (g.team_uuid = $1 OR (g.team_uuid IS NULL AND g.created_by = $2)) + ORDER BY ge.installed_at DESC + "#, + ) + .bind(team_uuid) + .bind(user_uuid) + .fetch_all(pool) + .await?; + + Ok(recs) +} + +/// 查询团队相关的分组中安装的扩展 +pub async fn fetch_team_group_extensions( + pool: &Pool, + team_uuid: Uuid, +) -> Result, Error> { + let recs = sqlx::query_as::<_, GroupExtensionDto>( + r#" + SELECT ge.id, ge.group_uuid, ge.extension_id, ge.installed_version, ge.installed_by, ge.status, ge.installed_at, ge.updated_at + FROM group_extensions ge + INNER JOIN groups g ON ge.group_uuid = g.uuid + WHERE ge.status = 'active' + AND g.deleted_at IS NULL + AND g.team_uuid = $1 + ORDER BY ge.installed_at DESC + "#, + ) + .bind(team_uuid) + .fetch_all(pool) + .await?; + + Ok(recs) +} + +// ============ Environment Extensions ============ + +/// 查询环境已安装的扩展 +pub async fn fetch_environment_extensions( + pool: &Pool, + environment_uuid: Uuid, +) -> Result, Error> { + let recs = sqlx::query_as::<_, EnvironmentExtensionDto>( + r#" + SELECT id, environment_uuid, extension_id, installed_version, status, installed_at, updated_at + FROM environment_extensions + WHERE environment_uuid = $1 AND status = 'active' + ORDER BY installed_at DESC + "#, + ) + .bind(environment_uuid) + .fetch_all(pool) + .await?; + + Ok(recs) +} + +/// 安装环境扩展 +pub async fn insert_environment_extension( + pool: &Pool, + environment_uuid: Uuid, + extension_id: &str, + version: &str, +) -> Result { + let id: i32 = sqlx::query_scalar( + r#" + INSERT INTO environment_extensions (environment_uuid, extension_id, installed_version, status) + VALUES ($1, $2, $3, 'active') + ON CONFLICT (environment_uuid, extension_id) DO UPDATE SET + installed_version = $3, + status = 'active', + updated_at = CURRENT_TIMESTAMP + RETURNING id; + "#, + ) + .bind(environment_uuid) + .bind(extension_id) + .bind(version) + .fetch_one(pool) + .await?; + + Ok(id) +} + +/// 卸载环境扩展 +pub async fn delete_environment_extension( + pool: &Pool, + environment_uuid: Uuid, + extension_id: &str, +) -> Result<(), Error> { + sqlx::query( + r#" + UPDATE environment_extensions SET status = 'inactive', updated_at = CURRENT_TIMESTAMP + WHERE environment_uuid = $1 AND extension_id = $2 + "#, + ) + .bind(environment_uuid) + .bind(extension_id) + .execute(pool) + .await?; + + Ok(()) +} diff --git a/src/models/gray_releases.rs b/src/models/gray_releases.rs new file mode 100644 index 0000000..37fbd59 --- /dev/null +++ b/src/models/gray_releases.rs @@ -0,0 +1,249 @@ +use crate::dto::gray_releases::GrayRelease; +use crate::entitys::gray_releases::{CreateGrayReleaseRequest, UpdateGrayReleaseRequest}; +use sqlx::{Error, Pool, Postgres}; + +/// 创建灰度发布 +pub async fn create_gray_release( + pool: &Pool, + request: &CreateGrayReleaseRequest, +) -> Result { + let sql = " + INSERT INTO gray_releases ( + name, description, platform, status, start_time, end_time, + max_machines, priority, strategy_type, strategy_config, created_at + ) VALUES ( + $1, $2, $3, 'pending', $4, $5, $6, $7, $8, $9, NOW() + ) RETURNING id + "; + + let result: (i32,) = sqlx::query_as(sql) + .bind(&request.name) + .bind(&request.description) + .bind(&request.platform) + .bind(request.start_time) + .bind(request.end_time) + .bind(request.max_machines) + .bind(request.priority.unwrap_or(0)) + .bind(&request.strategy_type) + .bind(&request.strategy_config) + .fetch_one(pool) + .await?; + + Ok(result.0) +} + +/// 查询灰度发布 +pub async fn query_gray_release_by_id( + pool: &Pool, + id: i32, +) -> Result { + let release: GrayRelease = sqlx::query_as("SELECT * FROM gray_releases WHERE id = $1") + .bind(id) + .fetch_one(pool) + .await?; + + Ok(release) +} + +/// 查询灰度发布列表 +pub async fn query_gray_releases( + pool: &Pool, + platform: Option<&str>, + status: Option<&str>, + page_num: i32, + page_size: i32, +) -> Result<(i64, Vec), Error> { + let mut where_conditions = vec![]; + let mut param_index = 1; + + if let Some(_plat) = platform { + where_conditions.push(format!("platform = ${}", param_index)); + param_index += 1; + } + if let Some(_st) = status { + where_conditions.push(format!("status = ${}", param_index)); + param_index += 1; + } + + let where_clause = if where_conditions.is_empty() { + "".to_string() + } else { + format!("WHERE {}", where_conditions.join(" AND ")) + }; + + // 获取总数 + let count_sql = format!("SELECT COUNT(*) FROM gray_releases {}", where_clause); + let count_query = match (platform, status) { + (Some(plat), Some(st)) => sqlx::query_as(&count_sql).bind(plat).bind(st), + (Some(plat), None) => sqlx::query_as(&count_sql).bind(plat), + (None, Some(st)) => sqlx::query_as(&count_sql).bind(st), + (None, None) => sqlx::query_as(&count_sql), + }; + let total: (i64,) = count_query.fetch_one(pool).await?; + + // 获取分页列表 + let list_sql = format!( + "SELECT * FROM gray_releases {} ORDER BY created_at DESC LIMIT ${} OFFSET ${}", + where_clause, + param_index, + param_index + 1 + ); + let list_query = match (platform, status) { + (Some(plat), Some(st)) => sqlx::query_as(&list_sql) + .bind(plat) + .bind(st) + .bind(page_size) + .bind((page_num - 1) * page_size), + (Some(plat), None) => sqlx::query_as(&list_sql) + .bind(plat) + .bind(page_size) + .bind((page_num - 1) * page_size), + (None, Some(st)) => sqlx::query_as(&list_sql) + .bind(st) + .bind(page_size) + .bind((page_num - 1) * page_size), + (None, None) => sqlx::query_as(&list_sql).bind(page_size).bind((page_num - 1) * page_size), + }; + let releases: Vec = list_query.fetch_all(pool).await?; + + Ok((total.0, releases)) +} + +/// 更新灰度发布 +pub async fn update_gray_release( + pool: &Pool, + id: i32, + request: &UpdateGrayReleaseRequest, +) -> Result { + let sql = " + UPDATE gray_releases SET + name = COALESCE($1, name), + description = COALESCE($2, description), + status = COALESCE($3, status), + end_time = COALESCE($4, end_time), + max_machines = COALESCE($5, max_machines), + priority = COALESCE($6, priority), + strategy_type = COALESCE($7, strategy_type), + strategy_config = COALESCE($8, strategy_config), + updated_at = NOW() + WHERE id = $9 + "; + + let row = sqlx::query(sql) + .bind(&request.name) + .bind(&request.description) + .bind(&request.status) + .bind(request.end_time) + .bind(request.max_machines) + .bind(request.priority) + .bind(&request.strategy_type) + .bind(&request.strategy_config) + .bind(id) + .execute(pool) + .await?; + + Ok(row.rows_affected() == 1) +} + +/// 更新配额计数 +pub async fn update_allocated_count( + pool: &Pool, + id: i32, + increment: i32, +) -> Result { + let sql = " + UPDATE gray_releases + SET allocated_count = allocated_count + $1, updated_at = NOW() + WHERE id = $2 + "; + + let row = sqlx::query(sql).bind(increment).bind(id).execute(pool).await?; + + Ok(row.rows_affected() == 1) +} + +/// 增加分配计数 +pub async fn increment_allocated_count(pool: &Pool, id: i32) -> Result { + update_allocated_count(pool, id, 1).await +} + +/// 减少分配计数 +pub async fn decrement_allocated_count(pool: &Pool, id: i32) -> Result { + update_allocated_count(pool, id, -1).await +} + +/// 查询活跃的灰度发布 +pub async fn query_active_gray_releases( + pool: &Pool, + machine_code: &str, +) -> Result, Error> { + let releases: Vec = sqlx::query_as( + r#" + SELECT gr.* FROM gray_releases gr + INNER JOIN machine_gray_allocations mga ON gr.id = mga.gray_release_id + WHERE mga.machine_code = $1 + AND mga.status = 'active' + AND gr.status = 'active' + AND NOW() >= gr.start_time + AND (gr.end_time IS NULL OR NOW() <= gr.end_time) + ORDER BY gr.priority DESC, gr.created_at DESC + "#, + ) + .bind(machine_code) + .fetch_all(pool) + .await?; + + Ok(releases) +} + +/// 删除灰度发布(物理删除) +pub async fn delete_gray_release(pool: &Pool, id: i32) -> Result { + let sql = "DELETE FROM gray_releases WHERE id = $1"; + let row = sqlx::query(sql).bind(id).execute(pool).await?; + Ok(row.rows_affected() == 1) +} + +/// 检查白名单策略(直接 JOIN 查询,单次查询 O(1)) +pub async fn check_whitelist_strategy( + pool: &Pool, + machine_code: &str, +) -> Result, Error> { + let release = sqlx::query_as::<_, GrayRelease>( + r#" + SELECT gr.* FROM gray_releases gr + INNER JOIN machine_gray_allocations mga ON gr.id = mga.gray_release_id + WHERE mga.machine_code = $1 + AND mga.status = 'active' + AND gr.status = 'active' + AND gr.start_time <= NOW() + AND (gr.end_time IS NULL OR gr.end_time >= NOW()) + ORDER BY gr.priority DESC, gr.created_at DESC + LIMIT 1 + "#, + ) + .bind(machine_code) + .fetch_optional(pool) + .await?; + + Ok(release) +} + +/// 查询需要动态计算的灰度发布(百分比、随机策略) +pub async fn query_dynamic_strategy_releases( + pool: &Pool, +) -> Result, Error> { + let releases = sqlx::query_as::<_, GrayRelease>( + r#" + SELECT * FROM gray_releases + WHERE status = 'active' + AND strategy_type IN ('filter_percentage', 'filter_random') + AND start_time <= NOW() + AND (end_time IS NULL OR end_time >= NOW()) + ORDER BY priority DESC, created_at DESC + "#, + ) + .fetch_all(pool) + .await?; + + Ok(releases) +} diff --git a/src/models/gray_resources.rs b/src/models/gray_resources.rs new file mode 100644 index 0000000..7038261 --- /dev/null +++ b/src/models/gray_resources.rs @@ -0,0 +1,107 @@ +use crate::dto::gray_resources::GrayResource; +use crate::entitys::gray_resources::CreateGrayResourceRequest; +use sqlx::{Error, Pool, Postgres}; + +/// 创建灰度资源 +pub async fn create_gray_resource( + pool: &Pool, + request: &CreateGrayResourceRequest, +) -> Result { + let sql = " + INSERT INTO gray_resources ( + gray_release_id, version_id, sort_order + ) VALUES ( + $1, $2, $3 + ) RETURNING id + "; + + let result: (i32,) = sqlx::query_as(sql) + .bind(request.gray_release_id) + .bind(request.version_id) + .bind(request.sort_order.unwrap_or(0)) + .fetch_one(pool) + .await?; + + Ok(result.0) +} + +/// 批量创建灰度资源 +pub async fn batch_create_gray_resources( + pool: &Pool, + gray_release_id: i32, + version_ids: &[i32], +) -> Result { + if version_ids.is_empty() { + return Ok(0); + } + + let mut tx = pool.begin().await?; + let mut count = 0; + + for (index, version_id) in version_ids.iter().enumerate() { + let sql = " + INSERT INTO gray_resources ( + gray_release_id, version_id, sort_order + ) VALUES ( + $1, $2, $3 + ) ON CONFLICT DO NOTHING + "; + + sqlx::query(sql) + .bind(gray_release_id) + .bind(version_id) + .bind(index as i32) + .execute(&mut *tx) + .await?; + + count += 1; + } + + tx.commit().await?; + Ok(count) +} + +/// 查询灰度资源 +pub async fn query_gray_resource_by_id( + pool: &Pool, + id: i32, +) -> Result { + let resource: GrayResource = sqlx::query_as("SELECT * FROM gray_resources WHERE id = $1") + .bind(id) + .fetch_one(pool) + .await?; + + Ok(resource) +} + +/// 查询灰度发布的所有资源 +pub async fn query_gray_resources_by_release_id( + pool: &Pool, + gray_release_id: i32, +) -> Result, Error> { + let resources: Vec = sqlx::query_as( + "SELECT * FROM gray_resources WHERE gray_release_id = $1 ORDER BY sort_order ASC, id ASC", + ) + .bind(gray_release_id) + .fetch_all(pool) + .await?; + + Ok(resources) +} + +/// 删除灰度资源 +pub async fn delete_gray_resource(pool: &Pool, id: i32) -> Result { + let sql = "DELETE FROM gray_resources WHERE id = $1"; + let row = sqlx::query(sql).bind(id).execute(pool).await?; + Ok(row.rows_affected() == 1) +} + +/// 删除灰度发布的所有资源 +pub async fn delete_gray_resources_by_release_id( + pool: &Pool, + gray_release_id: i32, +) -> Result { + let sql = "DELETE FROM gray_resources WHERE gray_release_id = $1"; + let row = sqlx::query(sql).bind(gray_release_id).execute(pool).await?; + Ok(row.rows_affected() as usize) +} diff --git a/src/models/group_member_permissions.rs b/src/models/group_member_permissions.rs new file mode 100644 index 0000000..8a33b4b --- /dev/null +++ b/src/models/group_member_permissions.rs @@ -0,0 +1,197 @@ +use sqlx::{Error, Pool, Postgres}; +use uuid::Uuid; + +use crate::dto::GroupMemberPermissionDto; + +/// 授予分组权限 +pub async fn grant_group_permission( + pool: &Pool, + group_uuid: Uuid, + workspace_uuid: Uuid, + team_uuid: Uuid, + user_uuid: Uuid, + permission_type: &str, + granted_by: Uuid, +) -> Result<(), Error> { + sqlx::query( + r#" + INSERT INTO group_member_permissions ( + group_uuid, workspace_uuid, team_uuid, user_uuid, permission_type, granted_by + ) + VALUES ($1, $2, $3, $4, $5, $6) + ON CONFLICT (group_uuid, user_uuid) DO UPDATE SET + permission_type = EXCLUDED.permission_type, + granted_by = EXCLUDED.granted_by, + updated_at = CURRENT_TIMESTAMP + "#, + ) + .bind(group_uuid) + .bind(workspace_uuid) + .bind(team_uuid) + .bind(user_uuid) + .bind(permission_type) + .bind(granted_by) + .execute(pool) + .await?; + + Ok(()) +} + +/// 撤销分组权限 +pub async fn revoke_group_permission( + pool: &Pool, + group_uuid: Uuid, + user_uuid: Uuid, +) -> Result<(), Error> { + sqlx::query( + r#" + DELETE FROM group_member_permissions + WHERE group_uuid = $1 AND user_uuid = $2 + "#, + ) + .bind(group_uuid) + .bind(user_uuid) + .execute(pool) + .await?; + + Ok(()) +} + +/// 查询用户的分组权限列表 +pub async fn fetch_user_group_permissions( + pool: &Pool, + user_uuid: Uuid, + workspace_uuid: Option, + group_uuid: Option, +) -> Result, Error> { + let recs = sqlx::query_as::<_, GroupMemberPermissionDto>( + r#" + SELECT group_uuid, workspace_uuid, team_uuid, user_uuid, permission_type, granted_by, + created_at, updated_at + FROM group_member_permissions + WHERE user_uuid = $1 + AND ($2::uuid IS NULL OR workspace_uuid = $2) + AND ($3::uuid IS NULL OR group_uuid = $3) + ORDER BY created_at DESC + "#, + ) + .bind(user_uuid) + .bind(workspace_uuid) + .bind(group_uuid) + .fetch_all(pool) + .await?; + + Ok(recs) +} + +/// 检查用户是否有分组权限(工作空间级别) +pub async fn check_group_permission( + pool: &Pool, + workspace_uuid: Uuid, + group_uuid: Uuid, + user_uuid: Uuid, + permission_type: &str, +) -> Result { + // 首先检查用户是否是团队成员,以及是否是 Owner/Admin(自动拥有所有权限) + let is_owner_or_admin: bool = sqlx::query_scalar( + r#" + SELECT EXISTS ( + SELECT 1 FROM team_members tm + INNER JOIN groups g ON tm.team_uuid = g.team_uuid AND tm.workspace_uuid = g.workspace_uuid + WHERE g.uuid = $1 + AND g.workspace_uuid = $2 + AND tm.workspace_uuid = $2 + AND tm.user_uuid = $3 + AND tm.role IN ('owner', 'admin') + AND tm.deleted_at IS NULL + ) + "#, + ) + .bind(group_uuid) + .bind(workspace_uuid) + .bind(user_uuid) + .fetch_one(pool) + .await?; + + if is_owner_or_admin { + return Ok(true); + } + + // 检查显式权限(工作空间级别) + let has_permission = match permission_type { + "read" => { + // read 权限:检查是否有 read/write/manage 任一权限 + sqlx::query_scalar::<_, bool>( + r#" + SELECT EXISTS ( + SELECT 1 FROM group_member_permissions + WHERE group_uuid = $1 AND workspace_uuid = $2 AND user_uuid = $3 + ) + "#, + ) + .bind(group_uuid) + .bind(workspace_uuid) + .bind(user_uuid) + .fetch_one(pool) + .await? + } + "write" => { + // write 权限:检查是否有 write/manage 权限 + sqlx::query_scalar::<_, bool>( + r#" + SELECT EXISTS ( + SELECT 1 FROM group_member_permissions + WHERE group_uuid = $1 AND workspace_uuid = $2 AND user_uuid = $3 + AND permission_type IN ('write', 'manage') + ) + "#, + ) + .bind(group_uuid) + .bind(workspace_uuid) + .bind(user_uuid) + .fetch_one(pool) + .await? + } + "manage" => { + // manage 权限:检查是否有 manage 权限 + sqlx::query_scalar::<_, bool>( + r#" + SELECT EXISTS ( + SELECT 1 FROM group_member_permissions + WHERE group_uuid = $1 AND workspace_uuid = $2 AND user_uuid = $3 + AND permission_type = 'manage' + ) + "#, + ) + .bind(group_uuid) + .bind(workspace_uuid) + .bind(user_uuid) + .fetch_one(pool) + .await? + } + _ => false, + }; + + Ok(has_permission) +} + +/// 查询分组的所有权限 +pub async fn fetch_group_permissions( + pool: &Pool, + group_uuid: Uuid, +) -> Result, Error> { + let recs = sqlx::query_as::<_, GroupMemberPermissionDto>( + r#" + SELECT group_uuid, workspace_uuid, team_uuid, user_uuid, permission_type, granted_by, + created_at, updated_at + FROM group_member_permissions + WHERE group_uuid = $1 + ORDER BY created_at DESC + "#, + ) + .bind(group_uuid) + .fetch_all(pool) + .await?; + + Ok(recs) +} diff --git a/src/models/machine_gray_allocations.rs b/src/models/machine_gray_allocations.rs new file mode 100644 index 0000000..1272dc4 --- /dev/null +++ b/src/models/machine_gray_allocations.rs @@ -0,0 +1,167 @@ +use crate::dto::machine_gray_allocations::MachineGrayAllocation; +use sqlx::{Error, Pool, Postgres}; + +/// 分配机器到灰度发布 +pub async fn allocate_machine_to_gray( + pool: &Pool, + machine_code: &str, + gray_release_id: i32, + effective_time: Option>, + notes: Option<&str>, +) -> Result { + let sql = " + INSERT INTO machine_gray_allocations ( + machine_code, gray_release_id, allocated_at, effective_time, status, notes, created_at + ) VALUES ( + $1, $2, NOW(), $3, 'active', $4, NOW() + ) + ON CONFLICT (machine_code, gray_release_id) + DO UPDATE SET + status = 'active', + effective_time = COALESCE(EXCLUDED.effective_time, machine_gray_allocations.effective_time), + notes = COALESCE(EXCLUDED.notes, machine_gray_allocations.notes), + allocated_at = NOW() + RETURNING id + "; + + let result: (i32,) = sqlx::query_as(sql) + .bind(machine_code) + .bind(gray_release_id) + .bind(effective_time) + .bind(notes) + .fetch_one(pool) + .await?; + + Ok(result.0) +} + +/// 批量分配机器到灰度发布 +pub async fn batch_allocate_machines_to_gray( + pool: &Pool, + machine_codes: &[String], + gray_release_id: i32, + effective_time: Option>, + notes: Option<&str>, +) -> Result { + if machine_codes.is_empty() { + return Ok(0); + } + + let mut tx = pool.begin().await?; + let mut count = 0; + + for machine_code in machine_codes { + let sql = " + INSERT INTO machine_gray_allocations ( + machine_code, gray_release_id, allocated_at, effective_time, status, notes, created_at + ) VALUES ( + $1, $2, NOW(), $3, 'active', $4, NOW() + ) + ON CONFLICT (machine_code, gray_release_id) + DO UPDATE SET + status = 'active', + effective_time = COALESCE(EXCLUDED.effective_time, machine_gray_allocations.effective_time), + notes = COALESCE(EXCLUDED.notes, machine_gray_allocations.notes), + allocated_at = NOW() + "; + + sqlx::query(sql) + .bind(machine_code) + .bind(gray_release_id) + .bind(effective_time) + .bind(notes) + .execute(&mut *tx) + .await?; + + count += 1; + } + + tx.commit().await?; + Ok(count) +} + +/// 查询分配记录 +pub async fn query_allocation_by_id( + pool: &Pool, + id: i32, +) -> Result { + let allocation: MachineGrayAllocation = + sqlx::query_as("SELECT * FROM machine_gray_allocations WHERE id = $1") + .bind(id) + .fetch_one(pool) + .await?; + + Ok(allocation) +} + +/// 查询机器的所有分配记录 +pub async fn query_allocations_by_machine_code( + pool: &Pool, + machine_code: &str, +) -> Result, Error> { + let allocations: Vec = sqlx::query_as( + "SELECT * FROM machine_gray_allocations WHERE machine_code = $1 ORDER BY allocated_at DESC", + ) + .bind(machine_code) + .fetch_all(pool) + .await?; + + Ok(allocations) +} + +/// 查询灰度发布的所有分配记录 +pub async fn query_allocations_by_gray_release_id( + pool: &Pool, + gray_release_id: i32, + page_num: i32, + page_size: i32, +) -> Result<(i64, Vec), Error> { + // 获取总数 + let count_sql = "SELECT COUNT(*) FROM machine_gray_allocations WHERE gray_release_id = $1"; + let total: (i64,) = sqlx::query_as(count_sql).bind(gray_release_id).fetch_one(pool).await?; + + // 获取分页列表 + let list_sql = "SELECT * FROM machine_gray_allocations WHERE gray_release_id = $1 ORDER BY allocated_at DESC LIMIT $2 OFFSET $3"; + let allocations: Vec = sqlx::query_as(list_sql) + .bind(gray_release_id) + .bind(page_size) + .bind((page_num - 1) * page_size) + .fetch_all(pool) + .await?; + + Ok((total.0, allocations)) +} + +/// 取消分配(软删除) +pub async fn deallocate_machine( + pool: &Pool, + machine_code: &str, + gray_release_id: i32, +) -> Result { + let sql = " + UPDATE machine_gray_allocations + SET status = 'inactive' + WHERE machine_code = $1 AND gray_release_id = $2 + "; + + let row = sqlx::query(sql).bind(machine_code).bind(gray_release_id).execute(pool).await?; + + Ok(row.rows_affected() > 0) +} + +/// 检查机器是否已分配到灰度发布 +pub async fn check_machine_allocated( + pool: &Pool, + machine_code: &str, + gray_release_id: i32, +) -> Result { + let exists: bool = sqlx::query_scalar( + "SELECT EXISTS(SELECT 1 FROM machine_gray_allocations WHERE machine_code = $1 AND gray_release_id = $2 AND status = 'active')", + ) + .bind(machine_code) + .bind(gray_release_id) + .fetch_one(pool) + .await?; + + Ok(exists) +} diff --git a/src/models/machine_users.rs b/src/models/machine_users.rs new file mode 100644 index 0000000..3eeebe4 --- /dev/null +++ b/src/models/machine_users.rs @@ -0,0 +1,425 @@ +use crate::dto::machine_users::MachineUser; +use crate::entitys::machine_users::{CreateMachineUserRequest, UpdateMachineUserRequest}; +use sqlx::{Error, Pool, Postgres}; +use uuid::Uuid; + +/// 插入新机器用户(支持 version_info 一次性插入) +pub async fn insert_machine_user( + pool: &Pool, + request: &CreateMachineUserRequest, +) -> Result { + let sql = r#" + INSERT INTO machine_users ( + machine_code, user_uuid, platform, status, version_info, hardware_hash, hardware_raw, bind_time, created_at + ) VALUES ( + $1, $2, $3, 'active', $4, $5, $6, NOW(), NOW() + ) RETURNING id + "#; + + // 解析 version_info JSON 字符串 + let version_info_value = request + .version_info + .as_ref() + .and_then(|v| serde_json::from_str::(v).ok()); + + let result: (i32,) = sqlx::query_as(sql) + .bind(&request.machine_code) + .bind(&request.user_uuid) + .bind(&request.platform) + .bind(&version_info_value) + .bind(&request.hardware_hash) + .bind(&request.hardware_raw) + .fetch_one(pool) + .await?; + + Ok(result.0) +} + +/// 根据ID查询机器用户 +pub async fn query_machine_user_by_id( + pool: &Pool, + id: i32, +) -> Result, Error> { + let machine_user: Option = + sqlx::query_as(r#"SELECT * FROM machine_users WHERE id = $1"#) + .bind(id) + .fetch_optional(pool) + .await?; + + Ok(machine_user) +} + +/// 根据机器码查询机器用户(返回多个记录中的第一个) +pub async fn query_machine_user_by_code( + pool: &Pool, + machine_code: &str, +) -> Result { + let machine_user: MachineUser = sqlx::query_as( + r#"SELECT * FROM machine_users WHERE machine_code = $1 ORDER BY bind_time DESC LIMIT 1"#, + ) + .bind(machine_code) + .fetch_one(pool) + .await?; + + Ok(machine_user) +} + +/// 根据机器码查询所有机器用户记录 +pub async fn query_machines_by_code( + pool: &Pool, + machine_code: &str, +) -> Result, Error> { + let machines: Vec = sqlx::query_as( + r#"SELECT * FROM machine_users WHERE machine_code = $1 ORDER BY bind_time DESC"#, + ) + .bind(machine_code) + .fetch_all(pool) + .await?; + + Ok(machines) +} + +/// 根据机器码和用户UUID查询机器用户 +pub async fn query_machine_user_by_code_and_user( + pool: &Pool, + machine_code: &str, + user_uuid: &Uuid, +) -> Result, Error> { + let machine_user: Option = + sqlx::query_as(r#"SELECT * FROM machine_users WHERE machine_code = $1 AND user_uuid = $2"#) + .bind(machine_code) + .bind(user_uuid) + .fetch_optional(pool) + .await?; + + Ok(machine_user) +} + +/// 判断机器是否允许 +pub async fn machine_not_allow(pool: &Pool, machine_code: &str) -> Result { + let allow: Option = sqlx::query_scalar( + r#"SELECT EXISTS(SELECT 1 FROM machine_users WHERE machine_code = $1 AND allow = false)"#, + ) + .bind(machine_code) + .fetch_optional(pool) + .await?; + + Ok(allow.unwrap_or(false)) +} + +/// 拉黑或取消拉黑机器 +pub async fn allow_or_blacklist_machine( + pool: &Pool, + machine_code: &str, + allow: bool, +) -> Result { + let row = sqlx::query(r#"UPDATE machine_users SET allow = $1 WHERE machine_code = $2"#) + .bind(allow) + .bind(machine_code) + .execute(pool) + .await?; + + Ok(row.rows_affected() > 0) +} + +/// 根据用户UUID查询机器列表 +pub async fn query_machines_by_user_uuid( + pool: &Pool, + user_uuid: &Uuid, +) -> Result, Error> { + let machines: Vec = sqlx::query_as( + r#"SELECT * FROM machine_users WHERE user_uuid = $1 ORDER BY bind_time DESC"#, + ) + .bind(user_uuid) + .fetch_all(pool) + .await?; + + Ok(machines) +} + +/// 查询机器用户列表(分页,简化版本,支持可选的过滤条件) +pub async fn query_machine_users( + pool: &Pool, + user_uuid: Option<&Uuid>, + platform: Option<&str>, + status: Option<&str>, + page_num: i32, + page_size: i32, +) -> Result<(i64, Vec), Error> { + // 构建基础查询 + let mut count_sql = r#"SELECT COUNT(*) FROM machine_users WHERE 1=1"#.to_string(); + let mut list_sql = r#"SELECT * FROM machine_users WHERE 1=1"#.to_string(); + + if user_uuid.is_some() { + count_sql.push_str(" AND user_uuid = $1"); + list_sql.push_str(" AND user_uuid = $1"); + } + if platform.is_some() { + let idx = if user_uuid.is_some() { 2 } else { 1 }; + count_sql.push_str(&format!(" AND platform = ${}", idx)); + list_sql.push_str(&format!(" AND platform = ${}", idx)); + } + if status.is_some() { + let idx = match (user_uuid.is_some(), platform.is_some()) { + (true, true) => 3, + (true, false) | (false, true) => 2, + (false, false) => 1, + }; + count_sql.push_str(&format!(" AND status = ${}", idx)); + list_sql.push_str(&format!(" AND status = ${}", idx)); + } + + list_sql.push_str(" ORDER BY created_at DESC"); + let limit_idx = match (user_uuid.is_some(), platform.is_some(), status.is_some()) { + (true, true, true) => 4, + (true, true, false) | (true, false, true) | (false, true, true) => 3, + (true, false, false) | (false, true, false) | (false, false, true) => 2, + (false, false, false) => 1, + }; + let offset_idx = limit_idx + 1; + list_sql.push_str(&format!(" LIMIT ${} OFFSET ${}", limit_idx, offset_idx)); + + // 获取总数 + let count_result: (i64,) = match (user_uuid, platform, status) { + (Some(u), Some(p), Some(s)) => { + sqlx::query_as(&count_sql).bind(u).bind(p).bind(s).fetch_one(pool).await? + } + (Some(u), Some(p), None) => { + sqlx::query_as(&count_sql).bind(u).bind(p).fetch_one(pool).await? + } + (Some(u), None, Some(s)) => { + sqlx::query_as(&count_sql).bind(u).bind(s).fetch_one(pool).await? + } + (Some(u), None, None) => sqlx::query_as(&count_sql).bind(u).fetch_one(pool).await?, + (None, Some(p), Some(s)) => { + sqlx::query_as(&count_sql).bind(p).bind(s).fetch_one(pool).await? + } + (None, Some(p), None) => sqlx::query_as(&count_sql).bind(p).fetch_one(pool).await?, + (None, None, Some(s)) => sqlx::query_as(&count_sql).bind(s).fetch_one(pool).await?, + (None, None, None) => sqlx::query_as(&count_sql).fetch_one(pool).await?, + }; + + // 获取分页列表 + let machines: Vec = match (user_uuid, platform, status) { + (Some(u), Some(p), Some(s)) => { + sqlx::query_as(&list_sql) + .bind(u) + .bind(p) + .bind(s) + .bind(page_size) + .bind((page_num - 1) * page_size) + .fetch_all(pool) + .await? + } + (Some(u), Some(p), None) => { + sqlx::query_as(&list_sql) + .bind(u) + .bind(p) + .bind(page_size) + .bind((page_num - 1) * page_size) + .fetch_all(pool) + .await? + } + (Some(u), None, Some(s)) => { + sqlx::query_as(&list_sql) + .bind(u) + .bind(s) + .bind(page_size) + .bind((page_num - 1) * page_size) + .fetch_all(pool) + .await? + } + (Some(u), None, None) => { + sqlx::query_as(&list_sql) + .bind(u) + .bind(page_size) + .bind((page_num - 1) * page_size) + .fetch_all(pool) + .await? + } + (None, Some(p), Some(s)) => { + sqlx::query_as(&list_sql) + .bind(p) + .bind(s) + .bind(page_size) + .bind((page_num - 1) * page_size) + .fetch_all(pool) + .await? + } + (None, Some(p), None) => { + sqlx::query_as(&list_sql) + .bind(p) + .bind(page_size) + .bind((page_num - 1) * page_size) + .fetch_all(pool) + .await? + } + (None, None, Some(s)) => { + sqlx::query_as(&list_sql) + .bind(s) + .bind(page_size) + .bind((page_num - 1) * page_size) + .fetch_all(pool) + .await? + } + (None, None, None) => { + sqlx::query_as(&list_sql) + .bind(page_size) + .bind((page_num - 1) * page_size) + .fetch_all(pool) + .await? + } + }; + + Ok((count_result.0, machines)) +} + +/// 更新机器用户 +pub async fn update_machine_user( + pool: &Pool, + id: i32, + request: &UpdateMachineUserRequest, +) -> Result { + let sql = r#" + UPDATE machine_users SET + user_uuid = COALESCE($1, user_uuid), + platform = COALESCE($2, platform), + status = COALESCE($3, status), + hardware_hash = COALESCE($4, hardware_hash), + hardware_raw = COALESCE($5, hardware_raw), + updated_at = NOW() + WHERE id = $6 + "#; + + let row = sqlx::query(sql) + .bind(&request.user_uuid) + .bind(&request.platform) + .bind(&request.status) + .bind(&request.hardware_hash) + .bind(&request.hardware_raw) + .bind(id) + .execute(pool) + .await?; + + Ok(row.rows_affected() == 1) +} + +/// 更新机器版本信息(整机更新,不更新 updated_at) +pub async fn update_machine_version_info( + pool: &Pool, + machine_code: &str, + version_info: &serde_json::Value, +) -> Result { + let sql = r#"UPDATE machine_users SET version_info = $1 WHERE machine_code = $2"#; + + let row = sqlx::query(sql).bind(version_info).bind(machine_code).execute(pool).await?; + + Ok(row.rows_affected() > 0) +} + +/// 一次性更新机器绑定时间和版本信息(用于已存在记录的更新场景) +pub async fn update_machine_bind_and_version( + pool: &Pool, + machine_code: &str, + user_uuid: &Uuid, + version_info: Option<&serde_json::Value>, +) -> Result { + let sql = r#" + UPDATE machine_users SET + bind_time = NOW(), + updated_at = NOW(), + version_info = COALESCE($3, version_info) + WHERE machine_code = $1 AND user_uuid = $2 + "#; + + let row = sqlx::query(sql) + .bind(machine_code) + .bind(user_uuid) + .bind(version_info) + .execute(pool) + .await?; + + Ok(row.rows_affected() > 0) +} + +/// 更新用户的 updated_at(仅更新指定用户的绑定时间) +pub async fn update_user_bind_time( + pool: &Pool, + machine_code: &str, + user_uuid: &Uuid, +) -> Result { + let sql = + r#"UPDATE machine_users SET updated_at = NOW() WHERE machine_code = $1 AND user_uuid = $2"#; + + let row = sqlx::query(sql).bind(machine_code).bind(user_uuid).execute(pool).await?; + + Ok(row.rows_affected() > 0) +} + +/// 绑定用户到机器(如果已存在则更新,否则创建新记录) +pub async fn bind_user_to_machine( + pool: &Pool, + machine_code: &str, + user_uuid: &Uuid, +) -> Result { + // 先检查是否已有绑定记录 + let existing = query_machine_user_by_code_and_user(pool, machine_code, user_uuid).await?; + + if existing.is_some() { + // 已存在,只更新绑定时间 + let sql = r#" + UPDATE machine_users SET + bind_time = NOW(), + updated_at = NOW() + WHERE machine_code = $1 AND user_uuid = $2 + "#; + + let row = sqlx::query(sql).bind(machine_code).bind(user_uuid).execute(pool).await?; + + Ok(row.rows_affected() > 0) + } else { + // 不存在,创建新记录 + let sql = r#" + INSERT INTO machine_users ( + machine_code, user_uuid, status, bind_time, created_at + ) VALUES ( + $1, $2, 'active', NOW(), NOW() + ) + "#; + + let row = sqlx::query(sql).bind(machine_code).bind(user_uuid).execute(pool).await?; + + Ok(row.rows_affected() > 0) + } +} + +/// 解绑用户(删除绑定记录) +pub async fn unbind_user_from_machine( + pool: &Pool, + machine_code: &str, + user_uuid: &Uuid, +) -> Result { + let sql = r#"DELETE FROM machine_users WHERE machine_code = $1 AND user_uuid = $2"#; + + let row = sqlx::query(sql).bind(machine_code).bind(user_uuid).execute(pool).await?; + + Ok(row.rows_affected() > 0) +} + +/// 更新机器绑定时间(当用户重新登录已存在的机器时) +pub async fn update_machine_bind_time( + pool: &Pool, + machine_code: &str, + user_uuid: &Uuid, +) -> Result { + let sql = r#" + UPDATE machine_users SET + updated_at = NOW(), + bind_time = NOW() + WHERE machine_code = $1 AND user_uuid = $2 + "#; + + let row = sqlx::query(sql).bind(machine_code).bind(user_uuid).execute(pool).await?; + + Ok(row.rows_affected() > 0) +} diff --git a/src/models/maintenance.rs b/src/models/maintenance.rs new file mode 100644 index 0000000..b51b7e7 --- /dev/null +++ b/src/models/maintenance.rs @@ -0,0 +1,159 @@ +use chrono::Utc; +use sqlx::{Pool, Postgres}; + +use crate::{dto::maintenance::Maintenance, entitys::maintenance::CreateMaintenanceRequest}; + +/// 创建维护 +pub async fn create_maintenance( + pool: &Pool, + request: CreateMaintenanceRequest, +) -> Result { + let now = Utc::now(); + + // 开始事务 + let mut tx = pool.begin().await?; + + // 关闭所有其他活跃的维护 + sqlx::query( + r#" + UPDATE maintenances + SET status = 'inactive', updated_at = $1 + WHERE status = 'active' + "#, + ) + .bind(now) + .execute(&mut *tx) + .await?; + + // 创建新的维护记录 + let maintenance = sqlx::query_as::<_, Maintenance>( + r#" + INSERT INTO maintenances (name, description, status, start_time, end_time, maintenance_type, created_at, updated_at) + VALUES ($1, $2, $3, $4, $5, $6, $7, $8) + RETURNING id, name, description, status, start_time, end_time, maintenance_type, created_at, updated_at + "#, + ) + .bind(request.name) + .bind(request.description) + .bind(request.status) + .bind(request.start_time) + .bind(request.end_time) + .bind(String::from(request.maintenance_type)) + .bind(now) + .bind(now) + .fetch_one(&mut *tx) + .await?; + + // 提交事务 + tx.commit().await?; + + Ok(maintenance) +} + +/// 查询维护列表 +pub async fn list_maintenances( + pool: &Pool, + limit: Option, + offset: Option, +) -> Result, sqlx::Error> { + let limit = limit.unwrap_or(50); + let offset = offset.unwrap_or(0); + + let maintenances = sqlx::query_as::<_, Maintenance>( + r#" + SELECT id, name, description, status, start_time, end_time, maintenance_type, + created_at, updated_at + FROM maintenances + ORDER BY created_at DESC + LIMIT $1 OFFSET $2 + "#, + ) + .bind(limit) + .bind(offset) + .fetch_all(pool) + .await?; + + Ok(maintenances) +} + +/// 结束维护 +pub async fn end_maintenance(pool: &Pool) -> Result { + let now = Utc::now(); + + // 关闭所有其他活跃的维护 + sqlx::query( + r#" + UPDATE maintenances + SET status = 'inactive', updated_at = $1 + WHERE status = 'active' + "#, + ) + .bind(now) + .execute(pool) + .await?; + + Ok(true) +} + +/// 更新维护状态 +pub async fn update_maintenance_status( + pool: &Pool, + id: i64, + status: &str, +) -> Result { + let now = Utc::now(); + let result = sqlx::query( + r#" + UPDATE maintenances + SET status = $1, updated_at = $2 + WHERE id = $3 + "#, + ) + .bind(status) + .bind(now) + .bind(id) + .execute(pool) + .await?; + + Ok(result.rows_affected() > 0) +} + +/// 根据ID查询维护 +pub async fn get_maintenance_by_id( + pool: &Pool, + id: i64, +) -> Result, sqlx::Error> { + let maintenance = sqlx::query_as::<_, Maintenance>( + r#" + SELECT id, name, description, status, start_time, end_time, maintenance_type, + created_at, updated_at + FROM maintenances + WHERE id = $1 + "#, + ) + .bind(id) + .fetch_optional(pool) + .await?; + + Ok(maintenance) +} + +/// 获取当前活跃维护 +pub async fn get_active_maintenances( + pool: &Pool, +) -> Result, sqlx::Error> { + let maintenance = sqlx::query_as::<_, Maintenance>( + r#" + SELECT id, name, description, status, start_time, end_time, maintenance_type, + created_at, updated_at + FROM maintenances + WHERE status = 'active' AND start_time <= NOW() AND end_time >= NOW() + ORDER BY created_at DESC + LIMIT 1 + "#, + ) + .fetch_optional(pool) + .await?; + + Ok(maintenance) +} diff --git a/src/models/messages.rs b/src/models/messages.rs new file mode 100644 index 0000000..db1a92f --- /dev/null +++ b/src/models/messages.rs @@ -0,0 +1,411 @@ +use sqlx::{Error, Pool, Postgres}; +use uuid::Uuid; + +use crate::dto::{MessageDto, UserMessageDto}; + +// ============ Messages ============ + +/// 创建消息 +pub async fn create_message( + pool: &Pool, + sender_uuid: Option, + message_type: &str, + title: &str, + content: Option<&str>, + recipient_type: &str, + related_type: Option<&str>, + related_uuid: Option, + priority: &str, + metadata: Option, +) -> Result { + let message_uuid: Uuid = sqlx::query_scalar( + r#" + INSERT INTO messages (message_type, title, content, sender_uuid, recipient_type, related_type, related_uuid, priority, metadata, status) + VALUES ($1, $2, $3, $4, $5, $6, $7, $8, $9, 'active') + RETURNING uuid; + "#, + ) + .bind(message_type) + .bind(title) + .bind(content) + .bind(sender_uuid) + .bind(recipient_type) + .bind(related_type) + .bind(related_uuid) + .bind(priority) + .bind(metadata) + .fetch_one(pool) + .await?; + + Ok(message_uuid) +} + +/// 添加消息接收者 +pub async fn add_message_recipient( + pool: &Pool, + message_uuid: Uuid, + user_uuid: Uuid, + action_status: Option<&str>, +) -> Result<(), Error> { + sqlx::query( + r#" + INSERT INTO user_messages (message_uuid, user_uuid, is_read, action_status) + VALUES ($1, $2, FALSE, $3) + ON CONFLICT (message_uuid, user_uuid) DO NOTHING; + "#, + ) + .bind(message_uuid) + .bind(user_uuid) + .bind(action_status) + .execute(pool) + .await?; + + Ok(()) +} + +/// 批量添加消息接收者 +pub async fn add_message_recipients( + pool: &Pool, + message_uuid: Uuid, + user_uuids: &[Uuid], + action_status: Option<&str>, +) -> Result<(), Error> { + let mut tx = pool.begin().await?; + + for user_uuid in user_uuids { + sqlx::query( + r#" + INSERT INTO user_messages (message_uuid, user_uuid, is_read, action_status) + VALUES ($1, $2, FALSE, $3) + ON CONFLICT (message_uuid, user_uuid) DO NOTHING; + "#, + ) + .bind(message_uuid) + .bind(user_uuid) + .bind(action_status) + .execute(&mut *tx) + .await?; + } + + tx.commit().await?; + + Ok(()) +} + +/// 查询用户消息列表 +pub async fn fetch_user_messages( + pool: &Pool, + user_uuid: Uuid, + offset: i64, + limit: i64, + message_type: Option<&str>, + is_read: Option, + action_status: Option<&str>, + priority: Option<&str>, +) -> Result, Error> { + let mut query = String::from( + r#" + SELECT + m.uuid AS message_uuid, + m.message_type, + m.title, + m.content, + m.sender_uuid, + m.related_type, + m.related_uuid, + m.metadata, + m.priority, + m.created_at AS message_created_at, + um.is_read, + um.read_at, + um.action_status, + um.action_at, + ui.nickname AS sender_name, + ui.email AS sender_email + FROM user_messages um + INNER JOIN messages m ON um.message_uuid = m.uuid + LEFT JOIN user_infos ui ON m.sender_uuid = ui.user_uuid + WHERE um.user_uuid = $1 AND m.deleted_at IS NULL + "#, + ); + + let mut param_count = 1; + + if message_type.is_some() { + param_count += 1; + query.push_str(&format!(" AND m.message_type = ${}", param_count)); + } + + if is_read.is_some() { + param_count += 1; + query.push_str(&format!(" AND um.is_read = ${}", param_count)); + } + + if action_status.is_some() { + param_count += 1; + query.push_str(&format!(" AND um.action_status = ${}", param_count)); + } + + if priority.is_some() { + param_count += 1; + query.push_str(&format!(" AND m.priority = ${}", param_count)); + } + + query.push_str(" ORDER BY m.created_at DESC"); + param_count += 1; + query.push_str(&format!(" LIMIT ${}", param_count)); + param_count += 1; + query.push_str(&format!(" OFFSET ${}", param_count)); + + // 构建查询参数 + let mut query_builder = sqlx::query_as::<_, UserMessageDto>(&query); + query_builder = query_builder.bind(user_uuid); + + if let Some(mt) = message_type { + query_builder = query_builder.bind(mt); + } + if let Some(ir) = is_read { + query_builder = query_builder.bind(ir); + } + if let Some(as_) = action_status { + query_builder = query_builder.bind(as_); + } + if let Some(p) = priority { + query_builder = query_builder.bind(p); + } + + query_builder = query_builder.bind(limit); + query_builder = query_builder.bind(offset); + + let recs = query_builder.fetch_all(pool).await?; + + Ok(recs) +} + +/// 查询用户消息总数 +pub async fn fetch_user_messages_count( + pool: &Pool, + user_uuid: Uuid, + message_type: Option<&str>, + is_read: Option, + action_status: Option<&str>, + priority: Option<&str>, +) -> Result { + let mut query = String::from( + r#" + SELECT COUNT(*) + FROM user_messages um + INNER JOIN messages m ON um.message_uuid = m.uuid + WHERE um.user_uuid = $1 AND m.deleted_at IS NULL + "#, + ); + + let mut param_count = 1; + + if message_type.is_some() { + param_count += 1; + query.push_str(&format!(" AND m.message_type = ${}", param_count)); + } + + if is_read.is_some() { + param_count += 1; + query.push_str(&format!(" AND um.is_read = ${}", param_count)); + } + + if action_status.is_some() { + param_count += 1; + query.push_str(&format!(" AND um.action_status = ${}", param_count)); + } + + if priority.is_some() { + param_count += 1; + query.push_str(&format!(" AND m.priority = ${}", param_count)); + } + + let mut query_builder = sqlx::query_scalar::<_, i64>(&query); + query_builder = query_builder.bind(user_uuid); + + if let Some(mt) = message_type { + query_builder = query_builder.bind(mt); + } + if let Some(ir) = is_read { + query_builder = query_builder.bind(ir); + } + if let Some(as_) = action_status { + query_builder = query_builder.bind(as_); + } + if let Some(p) = priority { + query_builder = query_builder.bind(p); + } + + let count = query_builder.fetch_one(pool).await?; + + Ok(count) +} + +/// 标记消息为已读 +pub async fn mark_message_read( + pool: &Pool, + message_uuid: Uuid, + user_uuid: Uuid, +) -> Result<(), Error> { + sqlx::query( + r#" + UPDATE user_messages + SET is_read = TRUE, read_at = NOW(), updated_at = NOW() + WHERE message_uuid = $1 AND user_uuid = $2 AND is_read = FALSE + "#, + ) + .bind(message_uuid) + .bind(user_uuid) + .execute(pool) + .await?; + + Ok(()) +} + +/// 批量标记消息为已读 +pub async fn batch_mark_messages_read( + pool: &Pool, + message_uuids: &[Uuid], + user_uuid: Uuid, +) -> Result<(), Error> { + let mut tx = pool.begin().await?; + + for message_uuid in message_uuids { + sqlx::query( + r#" + UPDATE user_messages + SET is_read = TRUE, read_at = NOW(), updated_at = NOW() + WHERE message_uuid = $1 AND user_uuid = $2 AND is_read = FALSE + "#, + ) + .bind(message_uuid) + .bind(user_uuid) + .execute(&mut *tx) + .await?; + } + + tx.commit().await?; + + Ok(()) +} + +/// 处理消息(接受/拒绝) +pub async fn handle_message( + pool: &Pool, + message_uuid: Uuid, + user_uuid: Uuid, + action: &str, +) -> Result<(), Error> { + let action_status = match action { + "accept" => "accepted", + "reject" => "rejected", + _ => return Err(Error::RowNotFound), + }; + + sqlx::query( + r#" + UPDATE user_messages + SET action_status = $1, action_at = NOW(), updated_at = NOW() + WHERE message_uuid = $2 AND user_uuid = $3 + "#, + ) + .bind(action_status) + .bind(message_uuid) + .bind(user_uuid) + .execute(pool) + .await?; + + Ok(()) +} + +/// 获取用户消息统计 +pub async fn fetch_user_message_stats( + pool: &Pool, + user_uuid: Uuid, +) -> Result<(i64, i64, std::collections::HashMap), Error> { + // 总消息数 + let total: i64 = sqlx::query_scalar( + r#" + SELECT COUNT(*) + FROM user_messages um + INNER JOIN messages m ON um.message_uuid = m.uuid + WHERE um.user_uuid = $1 AND m.deleted_at IS NULL + "#, + ) + .bind(user_uuid) + .fetch_one(pool) + .await?; + + // 未读消息数 + let unread: i64 = sqlx::query_scalar( + r#" + SELECT COUNT(*) + FROM user_messages um + INNER JOIN messages m ON um.message_uuid = m.uuid + WHERE um.user_uuid = $1 AND um.is_read = FALSE AND m.deleted_at IS NULL + "#, + ) + .bind(user_uuid) + .fetch_one(pool) + .await?; + + // 按类型统计 + let type_stats: Vec<(String, i64)> = sqlx::query_as( + r#" + SELECT m.message_type, COUNT(*) as count + FROM user_messages um + INNER JOIN messages m ON um.message_uuid = m.uuid + WHERE um.user_uuid = $1 AND m.deleted_at IS NULL + GROUP BY m.message_type + "#, + ) + .bind(user_uuid) + .fetch_all(pool) + .await?; + + let mut by_type = std::collections::HashMap::new(); + for (msg_type, count) in type_stats { + by_type.insert(msg_type, count); + } + + Ok((total, unread, by_type)) +} + +/// 删除消息(软删除) +pub async fn delete_message(pool: &Pool, message_uuid: Uuid) -> Result<(), Error> { + sqlx::query( + r#" + UPDATE messages + SET deleted_at = NOW(), status = 'deleted', updated_at = NOW() + WHERE uuid = $1 AND deleted_at IS NULL + "#, + ) + .bind(message_uuid) + .execute(pool) + .await?; + + Ok(()) +} + +/// 根据 UUID 查询消息 +pub async fn fetch_message_by_uuid( + pool: &Pool, + message_uuid: Uuid, +) -> Result, Error> { + let rec = sqlx::query_as::<_, MessageDto>( + r#" + SELECT id, uuid, message_type, title, content, sender_uuid, recipient_type, + related_type, related_uuid, metadata, status, priority, + created_at, updated_at, deleted_at + FROM messages + WHERE uuid = $1 AND deleted_at IS NULL + "#, + ) + .bind(message_uuid) + .fetch_optional(pool) + .await?; + + Ok(rec) +} diff --git a/src/models/mod.rs b/src/models/mod.rs new file mode 100644 index 0000000..9ca0e0f --- /dev/null +++ b/src/models/mod.rs @@ -0,0 +1,54 @@ +pub mod gray_releases; +pub mod gray_resources; +pub mod machine_gray_allocations; +pub mod machine_users; +pub mod maintenance; +pub mod strategy_types; +pub mod user; +pub mod version_types; +pub mod versions; + +// 新增模块 +pub mod accounts; +pub mod api; +pub mod audit; +pub mod billing; +pub mod environments; +pub mod extensions; +pub mod group_member_permissions; +pub mod messages; +pub mod preferences; +pub mod proxies; +pub mod proxy_visible_teams; +pub mod referral; +pub mod rpa; +pub mod teams; +pub mod workspace_quotas; +pub mod workspaces; + +// Console Gateway 模块 +pub mod admin; +pub mod apikey; + +pub use gray_releases::*; +pub use gray_resources::*; +pub use machine_gray_allocations::*; +pub use machine_users::*; +pub use maintenance::*; +pub use strategy_types::*; +pub use user::*; +pub use version_types::*; +pub use versions::*; + +// 新增导出 +pub use accounts::*; +pub use audit::*; +pub use billing::*; +pub use environments::*; +pub use group_member_permissions::*; +pub use messages::*; +pub use proxies::*; +pub use proxy_visible_teams::*; +pub use teams::*; +pub use workspace_quotas::*; +pub use workspaces::*; diff --git a/src/models/preferences.rs b/src/models/preferences.rs new file mode 100644 index 0000000..ed4d3c0 --- /dev/null +++ b/src/models/preferences.rs @@ -0,0 +1,52 @@ +use sqlx::{Error, Pool, Postgres}; +use uuid::Uuid; + +use crate::dto::UserPreferenceDto; + +/// 获取用户偏好设置 +pub async fn fetch_user_preferences( + pool: &Pool, + user_uuid: Uuid, +) -> Result, Error> { + let rec = sqlx::query_as::<_, UserPreferenceDto>( + r#" + SELECT id, user_uuid, theme, language, notifications_enabled, created_at, updated_at + FROM user_preferences + WHERE user_uuid = $1 + "#, + ) + .bind(user_uuid) + .fetch_optional(pool) + .await?; + + Ok(rec) +} + +/// 创建或更新用户偏好设置 +pub async fn upsert_user_preferences( + pool: &Pool, + user_uuid: Uuid, + theme: Option<&str>, + language: Option<&str>, + notifications_enabled: Option, +) -> Result<(), Error> { + sqlx::query( + r#" + INSERT INTO user_preferences (user_uuid, theme, language, notifications_enabled) + VALUES ($1, COALESCE($2, 'system'), COALESCE($3, 'zh-CN'), COALESCE($4, true)) + ON CONFLICT (user_uuid) DO UPDATE SET + theme = COALESCE($2, user_preferences.theme), + language = COALESCE($3, user_preferences.language), + notifications_enabled = COALESCE($4, user_preferences.notifications_enabled), + updated_at = CURRENT_TIMESTAMP + "#, + ) + .bind(user_uuid) + .bind(theme) + .bind(language) + .bind(notifications_enabled) + .execute(pool) + .await?; + + Ok(()) +} diff --git a/src/models/proxies.rs b/src/models/proxies.rs new file mode 100644 index 0000000..df092b0 --- /dev/null +++ b/src/models/proxies.rs @@ -0,0 +1,292 @@ +use sqlx::{Error, Pool, Postgres}; +use uuid::Uuid; + +use crate::dto::{ProxyDto, ProxyHealthCheckDto}; + +/// 创建代理 +pub async fn insert_proxy( + pool: &Pool, + workspace_uuid: Uuid, + owner_uuid: Uuid, + name: &str, + host: &str, + port: i32, + proxy_type: &str, + username: Option<&str>, + password_encrypted: Option<&str>, +) -> Result { + let uuid: Uuid = sqlx::query_scalar( + r#" + INSERT INTO proxies (workspace_uuid, owner_uuid, name, host, port, proxy_type, + username, password_encrypted) + VALUES ($1, $2, $3, $4, $5, $6, $7, $8) + RETURNING uuid; + "#, + ) + .bind(workspace_uuid) + .bind(owner_uuid) + .bind(name) + .bind(host) + .bind(port) + .bind(proxy_type) + .bind(username) + .bind(password_encrypted) + .fetch_one(pool) + .await?; + + Ok(uuid) +} + +/// 查询代理列表 +pub async fn fetch_proxies( + pool: &Pool, + workspace_uuid: Uuid, + proxy_type: Option<&str>, + status: Option<&str>, + offset: i64, + limit: i64, +) -> Result, Error> { + let recs = sqlx::query_as::<_, ProxyDto>( + r#" + SELECT p.id, p.uuid, p.workspace_uuid, p.owner_uuid, p.name, p.host, p.port, p.proxy_type, + p.username, p.password_encrypted, p.ssh_key_encrypted, p.ssh_passphrase_encrypted, + p.country, p.city, p.status, p.latency, p.last_check_ip, p.last_checked_at, + (SELECT COUNT(*) FROM environments e WHERE e.proxy_uuid = p.uuid AND e.deleted_at IS NULL) AS environments_count, + p.created_at, p.updated_at, p.deleted_at + FROM proxies p + WHERE p.workspace_uuid = $1 + AND ($2::varchar IS NULL OR p.proxy_type = $2) + AND ($3::varchar IS NULL OR p.status = $3) + AND p.deleted_at IS NULL + ORDER BY p.created_at DESC + LIMIT $5 OFFSET $6 + "#, + ) + .bind(workspace_uuid) + .bind(proxy_type) + .bind(status) + .bind(limit) + .bind(offset) + .fetch_all(pool) + .await?; + + Ok(recs) +} + +/// 查询代理总数 +pub async fn fetch_proxies_count( + pool: &Pool, + workspace_uuid: Uuid, + proxy_type: Option<&str>, + status: Option<&str>, +) -> Result { + let count: i64 = sqlx::query_scalar( + r#" + SELECT COUNT(*) FROM proxies + WHERE workspace_uuid = $1 + AND ($2::varchar IS NULL OR proxy_type = $2) + AND ($3::varchar IS NULL OR status = $3) + AND deleted_at IS NULL + "#, + ) + .bind(workspace_uuid) + .bind(proxy_type) + .bind(status) + .fetch_one(pool) + .await?; + + Ok(count) +} + +/// 根据 UUID 查询代理 +pub async fn fetch_proxy_by_uuid( + pool: &Pool, + proxy_uuid: Uuid, +) -> Result, Error> { + let rec = sqlx::query_as::<_, ProxyDto>( + r#" + SELECT p.id, p.uuid, p.workspace_uuid, p.owner_uuid, p.name, p.host, p.port, p.proxy_type, + p.username, p.password_encrypted, p.ssh_key_encrypted, p.ssh_passphrase_encrypted, + p.country, p.city, p.status, p.latency, p.last_check_ip, p.last_checked_at, + (SELECT COUNT(*) FROM environments e WHERE e.proxy_uuid = p.uuid AND e.deleted_at IS NULL) AS environments_count, + p.created_at, p.updated_at, p.deleted_at + FROM proxies p + WHERE p.uuid = $1 AND p.deleted_at IS NULL + "#, + ) + .bind(proxy_uuid) + .fetch_optional(pool) + .await?; + + Ok(rec) +} + +/// 更新代理 +pub async fn update_proxy( + pool: &Pool, + proxy_uuid: Uuid, + name: Option<&str>, + host: Option<&str>, + port: Option, + proxy_type: Option<&str>, + username: Option<&str>, + password_encrypted: Option<&str>, +) -> Result<(), Error> { + sqlx::query( + r#" + UPDATE proxies + SET name = COALESCE($1, name), + host = COALESCE($2, host), + port = COALESCE($3, port), + proxy_type = COALESCE($4, proxy_type), + username = COALESCE($5, username), + password_encrypted = COALESCE($6, password_encrypted) + WHERE uuid = $7 AND deleted_at IS NULL + "#, + ) + .bind(name) + .bind(host) + .bind(port) + .bind(proxy_type) + .bind(username) + .bind(password_encrypted) + .bind(proxy_uuid) + .execute(pool) + .await?; + + Ok(()) +} + +/// 更新代理检测结果 +pub async fn update_proxy_check_result( + pool: &Pool, + proxy_uuid: Uuid, + status: &str, + latency: Option, + ip_address: Option<&str>, + country: Option<&str>, + city: Option<&str>, +) -> Result<(), Error> { + sqlx::query( + r#" + UPDATE proxies + SET status = $1, + latency = $2, + last_check_ip = $3, + country = $4, + city = $5, + last_checked_at = CURRENT_TIMESTAMP + WHERE uuid = $6 AND deleted_at IS NULL + "#, + ) + .bind(status) + .bind(latency) + .bind(ip_address) + .bind(country) + .bind(city) + .bind(proxy_uuid) + .execute(pool) + .await?; + + Ok(()) +} + +/// 增加代理使用次数 +pub async fn increment_proxy_usage(pool: &Pool, proxy_uuid: Uuid) -> Result<(), Error> { + sqlx::query( + r#" + UPDATE proxies SET usage_count = usage_count + 1 + WHERE uuid = $1 AND deleted_at IS NULL + "#, + ) + .bind(proxy_uuid) + .execute(pool) + .await?; + + Ok(()) +} + +/// 软删除代理 +pub async fn delete_proxy(pool: &Pool, proxy_uuid: Uuid) -> Result<(), Error> { + sqlx::query( + r#" + UPDATE proxies SET deleted_at = CURRENT_TIMESTAMP + WHERE uuid = $1 AND deleted_at IS NULL + "#, + ) + .bind(proxy_uuid) + .execute(pool) + .await?; + + Ok(()) +} + +/// 批量软删除代理 +pub async fn batch_delete_proxies( + pool: &Pool, + proxy_uuids: &[Uuid], +) -> Result { + let result = sqlx::query( + r#" + UPDATE proxies SET deleted_at = CURRENT_TIMESTAMP + WHERE uuid = ANY($1) AND deleted_at IS NULL + "#, + ) + .bind(proxy_uuids) + .execute(pool) + .await?; + + Ok(result.rows_affected()) +} + +// ============ Proxy Health Checks ============ + +/// 记录代理健康检查 +pub async fn insert_proxy_health_check( + pool: &Pool, + proxy_uuid: Uuid, + status: &str, + latency: Option, + ip_address: Option<&str>, + error_message: Option<&str>, +) -> Result { + let id: i64 = sqlx::query_scalar( + r#" + INSERT INTO proxy_health_checks (proxy_uuid, status, latency, ip_address, error_message) + VALUES ($1, $2, $3, $4, $5) + RETURNING id; + "#, + ) + .bind(proxy_uuid) + .bind(status) + .bind(latency) + .bind(ip_address) + .bind(error_message) + .fetch_one(pool) + .await?; + + Ok(id) +} + +/// 查询代理健康检查历史 +pub async fn fetch_proxy_health_checks( + pool: &Pool, + proxy_uuid: Uuid, + limit: i64, +) -> Result, Error> { + let recs = sqlx::query_as::<_, ProxyHealthCheckDto>( + r#" + SELECT id, proxy_uuid, status, latency, ip_address, error_message, checked_at + FROM proxy_health_checks + WHERE proxy_uuid = $1 + ORDER BY checked_at DESC + LIMIT $2 + "#, + ) + .bind(proxy_uuid) + .bind(limit) + .fetch_all(pool) + .await?; + + Ok(recs) +} diff --git a/src/models/proxy_visible_teams.rs b/src/models/proxy_visible_teams.rs new file mode 100644 index 0000000..ffd1879 --- /dev/null +++ b/src/models/proxy_visible_teams.rs @@ -0,0 +1,168 @@ +use sqlx::{Error, Pool, Postgres}; +use uuid::Uuid; + +use crate::dto::{ProxyDto, ProxyVisibleTeamDto}; + +/// 添加代理可见团队 +pub async fn insert_proxy_visible_team( + pool: &Pool, + proxy_uuid: Uuid, + workspace_uuid: Uuid, + team_uuid: Uuid, +) -> Result<(), Error> { + sqlx::query( + r#" + INSERT INTO proxy_visible_teams (proxy_uuid, workspace_uuid, team_uuid) + VALUES ($1, $2, $3) + ON CONFLICT (proxy_uuid, team_uuid) DO NOTHING + "#, + ) + .bind(proxy_uuid) + .bind(workspace_uuid) + .bind(team_uuid) + .execute(pool) + .await?; + + Ok(()) +} + +/// 移除代理可见团队 +pub async fn remove_proxy_visible_team( + pool: &Pool, + proxy_uuid: Uuid, + team_uuid: Uuid, +) -> Result<(), Error> { + sqlx::query( + r#" + DELETE FROM proxy_visible_teams + WHERE proxy_uuid = $1 AND team_uuid = $2 + "#, + ) + .bind(proxy_uuid) + .bind(team_uuid) + .execute(pool) + .await?; + + Ok(()) +} + +/// 查询代理的可见团队列表 +pub async fn fetch_visible_teams_by_proxy( + pool: &Pool, + proxy_uuid: Uuid, +) -> Result, Error> { + let recs = sqlx::query_as::<_, ProxyVisibleTeamDto>( + r#" + SELECT proxy_uuid, workspace_uuid, team_uuid, created_at + FROM proxy_visible_teams + WHERE proxy_uuid = $1 + ORDER BY created_at + "#, + ) + .bind(proxy_uuid) + .fetch_all(pool) + .await?; + + Ok(recs) +} + +/// 查询团队可见的代理列表 +pub async fn fetch_visible_proxies_by_team( + pool: &Pool, + workspace_uuid: Uuid, + team_uuid: Uuid, +) -> Result, Error> { + let recs = sqlx::query_as::<_, ProxyDto>( + r#" + SELECT p.id, p.uuid, p.workspace_uuid, p.owner_uuid, p.name, p.host, p.port, p.proxy_type, + p.username, p.password_encrypted, p.ssh_key_encrypted, p.ssh_passphrase_encrypted, + p.country, p.city, p.status, p.latency, p.last_check_ip, p.last_checked_at, + (SELECT COUNT(*) FROM environments e WHERE e.proxy_uuid = p.uuid AND e.deleted_at IS NULL) AS environments_count, + p.created_at, p.updated_at, p.deleted_at + FROM proxies p + INNER JOIN proxy_visible_teams pvt ON p.uuid = pvt.proxy_uuid + WHERE pvt.workspace_uuid = $1 AND pvt.team_uuid = $2 + AND p.deleted_at IS NULL + ORDER BY p.created_at DESC + "#, + ) + .bind(workspace_uuid) + .bind(team_uuid) + .fetch_all(pool) + .await?; + + Ok(recs) +} + +/// 检查代理对团队是否可见 +pub async fn check_proxy_visibility( + pool: &Pool, + proxy_uuid: Uuid, + workspace_uuid: Uuid, + team_uuid: Uuid, +) -> Result { + let count: i64 = sqlx::query_scalar( + r#" + SELECT COUNT(*) FROM proxy_visible_teams + WHERE proxy_uuid = $1 AND workspace_uuid = $2 AND team_uuid = $3 + "#, + ) + .bind(proxy_uuid) + .bind(workspace_uuid) + .bind(team_uuid) + .fetch_one(pool) + .await?; + + Ok(count > 0) +} + +/// 查询工作空间所有可见的代理(包括工作空间 Owner 和代理所有者的代理) +pub async fn fetch_visible_proxies_for_user( + pool: &Pool, + workspace_uuid: Uuid, + user_uuid: Uuid, + team_uuid: Option, +) -> Result, Error> { + // 工作空间 Owner 可以看到所有代理 + // 代理所有者可以看到自己的代理 + // 团队成员可以看到 proxy_visible_teams 中包含其团队的代理 + let recs = sqlx::query_as::<_, ProxyDto>( + r#" + SELECT DISTINCT p.id, p.uuid, p.workspace_uuid, p.owner_uuid, p.name, p.host, p.port, p.proxy_type, + p.username, p.password_encrypted, p.ssh_key_encrypted, p.ssh_passphrase_encrypted, + p.country, p.city, p.status, p.latency, p.last_check_ip, p.last_checked_at, + (SELECT COUNT(*) FROM environments e WHERE e.proxy_uuid = p.uuid AND e.deleted_at IS NULL) AS environments_count, + p.created_at, p.updated_at, p.deleted_at + FROM proxies p + WHERE p.workspace_uuid = $1 + AND p.deleted_at IS NULL + AND ( + -- 工作空间 Owner + EXISTS ( + SELECT 1 FROM workspaces w + WHERE w.uuid = $1 AND w.owner_uuid = $2 AND w.deleted_at IS NULL + ) + -- 代理所有者 + OR p.owner_uuid = $2 + -- 团队成员可见的代理 + OR ( + $3 IS NOT NULL + AND EXISTS ( + SELECT 1 FROM proxy_visible_teams pvt + WHERE pvt.proxy_uuid = p.uuid + AND pvt.workspace_uuid = $1 + AND pvt.team_uuid = $3 + ) + ) + ) + ORDER BY p.created_at DESC + "#, + ) + .bind(workspace_uuid) + .bind(user_uuid) + .bind(team_uuid) + .fetch_all(pool) + .await?; + + Ok(recs) +} diff --git a/src/models/referral.rs b/src/models/referral.rs new file mode 100644 index 0000000..464c97a --- /dev/null +++ b/src/models/referral.rs @@ -0,0 +1,553 @@ +use chrono::{DateTime, Utc}; +use rust_decimal::Decimal; +use sqlx::{Error, Pool, Postgres}; +use uuid::Uuid; + +use crate::dto::{ + RedeemOptionDto, RedeemRecordDto, ReferralLinkDto, ReferralLinkTierDto, ReferralRewardDto, + UserReferralPointsDto, +}; +use crate::dto::ReferredUserRow; + +// ============ Referral Link Tiers ============ + +/// 查询所有层级 +pub async fn fetch_referral_tiers( + pool: &Pool, +) -> Result, Error> { + let recs = sqlx::query_as::<_, ReferralLinkTierDto>( + r#" + SELECT id, uuid, name, unlock_threshold, reward_rate, discount_rate, + description, sort_order, created_at, updated_at + FROM referral_link_tiers + ORDER BY unlock_threshold ASC + "#, + ) + .fetch_all(pool) + .await?; + + Ok(recs) +} + +/// 根据用户当前已解锁的层级,更新其推广链接的解锁状态。 +/// +/// 约定: +/// - 同一用户的所有推广链接默认创建完成(最多 4 条),分别对应不同 tier_uuid; +/// - 这里根据传入的 unlocked_tier_uuids,将属于这些层级的链接标记为 unlocked = TRUE, +/// 其他链接统一标记为 FALSE。 +pub async fn update_referral_links_unlock_status_for_user( + pool: &Pool, + user_uuid: Uuid, + unlocked_tier_uuids: &[Uuid], +) -> Result<(), Error> { + // 先将该用户所有链接标记为未解锁 + sqlx::query( + r#" + UPDATE referral_links + SET unlocked = FALSE + WHERE user_uuid = $1; + "#, + ) + .bind(user_uuid) + .execute(pool) + .await?; + + // 再根据传入的 tier_uuid 集合解锁对应链接 + if !unlocked_tier_uuids.is_empty() { + sqlx::query( + r#" + UPDATE referral_links + SET unlocked = TRUE + WHERE user_uuid = $1 + AND tier_uuid = ANY($2::uuid[]); + "#, + ) + .bind(user_uuid) + .bind(unlocked_tier_uuids) + .execute(pool) + .await?; + } + + Ok(()) +} + +/// 根据解锁阈值查询层级 +pub async fn fetch_tier_by_threshold( + pool: &Pool, + threshold: i32, +) -> Result, Error> { + let rec = sqlx::query_as::<_, ReferralLinkTierDto>( + r#" + SELECT id, uuid, name, unlock_threshold, reward_rate, discount_rate, + description, sort_order, created_at, updated_at + FROM referral_link_tiers + WHERE unlock_threshold <= $1 + ORDER BY unlock_threshold DESC + LIMIT 1 + "#, + ) + .bind(threshold) + .fetch_optional(pool) + .await?; + + Ok(rec) +} + +/// 查询下一个层级 +pub async fn fetch_next_tier( + pool: &Pool, + current_threshold: i32, +) -> Result, Error> { + let rec = sqlx::query_as::<_, ReferralLinkTierDto>( + r#" + SELECT id, uuid, name, unlock_threshold, reward_rate, discount_rate, + description, sort_order, created_at, updated_at + FROM referral_link_tiers + WHERE unlock_threshold > $1 + ORDER BY unlock_threshold ASC + LIMIT 1 + "#, + ) + .bind(current_threshold) + .fetch_optional(pool) + .await?; + + Ok(rec) +} + +// ============ Referral Links ============ + +/// 查询用户的推广链接列表 +pub async fn fetch_user_referral_links( + pool: &Pool, + user_uuid: Uuid, +) -> Result, Error> { + // 为了保证前端展示顺序与层级解锁顺序一致,这里按对应层级的 unlock_threshold 排序, + // 对于没有 tier_uuid 的链接,则退而按创建时间排序并排在最后。 + let recs = sqlx::query_as::<_, ReferralLinkDto>( + r#" + SELECT rl.id, + rl.uuid, + rl.user_uuid, + rl.code, + rl.url, + rl.tier_uuid, + rl.unlocked, + rl.is_current, + rl.reward_rate, + rl.discount_rate, + rl.registered_users, + rl.paid_users, + rl.total_consumption, + rl.last_30_days_consumption, + rl.created_at, + rl.updated_at + FROM referral_links rl + LEFT JOIN referral_link_tiers t + ON rl.tier_uuid = t.uuid + WHERE rl.user_uuid = $1 + ORDER BY + t.unlock_threshold ASC NULLS LAST, + rl.created_at ASC + "#, + ) + .bind(user_uuid) + .fetch_all(pool) + .await?; + + Ok(recs) +} + +/// 查询当前推广链接 +pub async fn fetch_current_referral_link( + pool: &Pool, + user_uuid: Uuid, +) -> Result, Error> { + let rec = sqlx::query_as::<_, ReferralLinkDto>( + r#" + SELECT id, uuid, user_uuid, code, url, tier_uuid, unlocked, is_current, + reward_rate, discount_rate, registered_users, paid_users, + total_consumption, last_30_days_consumption, created_at, updated_at + FROM referral_links + WHERE user_uuid = $1 AND is_current = true + "#, + ) + .bind(user_uuid) + .fetch_optional(pool) + .await?; + + Ok(rec) +} + +/// 切换当前推广链接 +pub async fn switch_current_referral_link( + pool: &Pool, + user_uuid: Uuid, + link_uuid: Uuid, +) -> Result<(), Error> { + // 先取消所有当前链接 + sqlx::query("UPDATE referral_links SET is_current = false WHERE user_uuid = $1") + .bind(user_uuid) + .execute(pool) + .await?; + + // 设置新的当前链接 + sqlx::query("UPDATE referral_links SET is_current = true WHERE uuid = $1 AND user_uuid = $2") + .bind(link_uuid) + .bind(user_uuid) + .execute(pool) + .await?; + + Ok(()) +} + +// ============ User Referrals ============ + +/// 查询被邀请用户列表 +pub async fn fetch_referred_users( + pool: &Pool, + inviter_uuid: Uuid, + status: Option<&str>, + offset: i64, + limit: i64, +) -> Result, Error> { + let recs = sqlx::query_as::<_, ReferredUserRow>( + r#" + SELECT + ur.id, + ui.email, + ur.status, + ur.link_uuid, + ur.total_consumption, + ur.last_30_days_consumption, + ur.registered_at + FROM user_referrals ur + JOIN user_infos ui ON ui.user_uuid = ur.invitee_uuid + WHERE ur.inviter_uuid = $1 + AND ($2::varchar IS NULL OR ur.status = $2) + ORDER BY registered_at DESC + LIMIT $3 OFFSET $4 + "#, + ) + .bind(inviter_uuid) + .bind(status) + .bind(limit) + .bind(offset) + .fetch_all(pool) + .await?; + + Ok(recs) +} + +/// 查询被邀请用户总数 +pub async fn fetch_referred_users_count( + pool: &Pool, + inviter_uuid: Uuid, + status: Option<&str>, +) -> Result { + let count: i64 = sqlx::query_scalar( + r#" + SELECT COUNT(*) FROM user_referrals + WHERE inviter_uuid = $1 + AND ($2::varchar IS NULL OR status = $2) + "#, + ) + .bind(inviter_uuid) + .bind(status) + .fetch_one(pool) + .await?; + + Ok(count) +} + +/// 获取推广统计 +pub async fn fetch_referral_stats( + pool: &Pool, + user_uuid: Uuid, +) -> Result<(i32, i32, Decimal, Decimal), Error> { + let stats: (i64, i64, Option, Option) = sqlx::query_as( + r#" + SELECT + COUNT(*) as total_referrals, + COUNT(CASE WHEN status = 'paid' THEN 1 END) as paid_referrals, + COALESCE(SUM(total_consumption), 0) as total_consumption, + COALESCE(SUM(last_30_days_consumption), 0) as last_30_days_consumption + FROM user_referrals + WHERE inviter_uuid = $1 + "#, + ) + .bind(user_uuid) + .fetch_one(pool) + .await?; + + Ok(( + stats.0 as i32, + stats.1 as i32, + stats.2.unwrap_or(Decimal::ZERO), + stats.3.unwrap_or(Decimal::ZERO), + )) +} + +// ============ Referral Rewards ============ + +/// 查询奖励记录 +pub async fn fetch_referral_rewards( + pool: &Pool, + user_uuid: Uuid, + reward_type: Option<&str>, + status: Option<&str>, + offset: i64, + limit: i64, +) -> Result, Error> { + let recs = sqlx::query_as::<_, ReferralRewardDto>( + r#" + SELECT id, uuid, user_uuid, reward_type, points, description, + referred_user_uuid, link_uuid, status, created_at + FROM referral_rewards + WHERE user_uuid = $1 + AND ($2::varchar IS NULL OR reward_type = $2) + AND ($3::varchar IS NULL OR status = $3) + ORDER BY created_at DESC + LIMIT $4 OFFSET $5 + "#, + ) + .bind(user_uuid) + .bind(reward_type) + .bind(status) + .bind(limit) + .bind(offset) + .fetch_all(pool) + .await?; + + Ok(recs) +} + +/// 查询奖励记录总数 +pub async fn fetch_referral_rewards_count( + pool: &Pool, + user_uuid: Uuid, + reward_type: Option<&str>, + status: Option<&str>, +) -> Result { + let count: i64 = sqlx::query_scalar( + r#" + SELECT COUNT(*) FROM referral_rewards + WHERE user_uuid = $1 + AND ($2::varchar IS NULL OR reward_type = $2) + AND ($3::varchar IS NULL OR status = $3) + "#, + ) + .bind(user_uuid) + .bind(reward_type) + .bind(status) + .fetch_one(pool) + .await?; + + Ok(count) +} + +/// 获取总奖励积分 +pub async fn fetch_total_reward_points( + pool: &Pool, + user_uuid: Uuid, +) -> Result { + let total: i64 = sqlx::query_scalar( + r#" + SELECT COALESCE(SUM(points), 0) FROM referral_rewards + WHERE user_uuid = $1 AND status = 'completed' + "#, + ) + .bind(user_uuid) + .fetch_one(pool) + .await?; + + Ok(total as i32) +} + +// ============ User Referral Points ============ + +/// 获取用户积分 +pub async fn fetch_user_points( + pool: &Pool, + user_uuid: Uuid, +) -> Result, Error> { + let rec = sqlx::query_as::<_, UserReferralPointsDto>( + r#" + SELECT id, user_uuid, total_points, available_points, used_points, + pending_points, created_at, updated_at + FROM user_referral_points + WHERE user_uuid = $1 + "#, + ) + .bind(user_uuid) + .fetch_optional(pool) + .await?; + + Ok(rec) +} + +/// 创建或更新用户积分 +pub async fn upsert_user_points( + pool: &Pool, + user_uuid: Uuid, + total_points: i32, + available_points: i32, + used_points: i32, + pending_points: i32, +) -> Result<(), Error> { + sqlx::query( + r#" + INSERT INTO user_referral_points (user_uuid, total_points, available_points, used_points, pending_points) + VALUES ($1, $2, $3, $4, $5) + ON CONFLICT (user_uuid) DO UPDATE SET + total_points = $2, + available_points = $3, + used_points = $4, + pending_points = $5, + updated_at = CURRENT_TIMESTAMP + "#, + ) + .bind(user_uuid) + .bind(total_points) + .bind(available_points) + .bind(used_points) + .bind(pending_points) + .execute(pool) + .await?; + + Ok(()) +} + +/// 扣减积分 +pub async fn deduct_user_points( + pool: &Pool, + user_uuid: Uuid, + points: i32, +) -> Result<(), Error> { + sqlx::query( + r#" + UPDATE user_referral_points + SET available_points = available_points - $1, + used_points = used_points + $1, + updated_at = CURRENT_TIMESTAMP + WHERE user_uuid = $2 AND available_points >= $1 + "#, + ) + .bind(points) + .bind(user_uuid) + .execute(pool) + .await?; + + Ok(()) +} + +// ============ Redeem Options ============ + +/// 查询兑换选项 +pub async fn fetch_redeem_options(pool: &Pool) -> Result, Error> { + let recs = sqlx::query_as::<_, RedeemOptionDto>( + r#" + SELECT id, uuid, redeem_type, name, description, points_required, + value, currency, exchange_rate, status, sort_order, created_at, updated_at + FROM redeem_options + WHERE status = 'active' + ORDER BY sort_order ASC + "#, + ) + .fetch_all(pool) + .await?; + + Ok(recs) +} + +/// 根据 UUID 查询兑换选项 +pub async fn fetch_redeem_option_by_uuid( + pool: &Pool, + option_uuid: Uuid, +) -> Result, Error> { + let rec = sqlx::query_as::<_, RedeemOptionDto>( + r#" + SELECT id, uuid, redeem_type, name, description, points_required, + value, currency, exchange_rate, status, sort_order, created_at, updated_at + FROM redeem_options + WHERE uuid = $1 + "#, + ) + .bind(option_uuid) + .fetch_optional(pool) + .await?; + + Ok(rec) +} + +// ============ Redeem Records ============ + +/// 创建兑换记录 +pub async fn insert_redeem_record( + pool: &Pool, + user_uuid: Uuid, + option_uuid: Uuid, + points_used: i32, + value: Decimal, + currency: Option<&str>, +) -> Result { + let uuid: Uuid = sqlx::query_scalar( + r#" + INSERT INTO redeem_records (user_uuid, option_uuid, points_used, value, currency, status) + VALUES ($1, $2, $3, $4, $5, 'completed') + RETURNING uuid; + "#, + ) + .bind(user_uuid) + .bind(option_uuid) + .bind(points_used) + .bind(value) + .bind(currency) + .fetch_one(pool) + .await?; + + Ok(uuid) +} + +/// 查询兑换记录 +pub async fn fetch_redeem_records( + pool: &Pool, + user_uuid: Uuid, + offset: i64, + limit: i64, +) -> Result, Error> { + let recs = sqlx::query_as::<_, RedeemRecordDto>( + r#" + SELECT id, uuid, user_uuid, option_uuid, points_used, value, + currency, status, created_at, completed_at + FROM redeem_records + WHERE user_uuid = $1 + ORDER BY created_at DESC + LIMIT $2 OFFSET $3 + "#, + ) + .bind(user_uuid) + .bind(limit) + .bind(offset) + .fetch_all(pool) + .await?; + + Ok(recs) +} + +/// 查询兑换记录总数 +pub async fn fetch_redeem_records_count( + pool: &Pool, + user_uuid: Uuid, +) -> Result { + let count: i64 = sqlx::query_scalar( + r#" + SELECT COUNT(*) FROM redeem_records WHERE user_uuid = $1 + "#, + ) + .bind(user_uuid) + .fetch_one(pool) + .await?; + + Ok(count) +} diff --git a/src/models/rpa.rs b/src/models/rpa.rs new file mode 100644 index 0000000..8de2fb9 --- /dev/null +++ b/src/models/rpa.rs @@ -0,0 +1,527 @@ +use sqlx::{Error, Pool, Postgres}; +use uuid::Uuid; + +use crate::dto::{RpaTaskDto, RpaTaskEnvironmentDto, RpaTaskRunDto, RpaTaskStepDto}; + +// ============ RPA Tasks ============ + +/// 创建 RPA 任务 +pub async fn insert_rpa_task( + pool: &Pool, + user_uuid: Uuid, + team_uuid: Option, + name: &str, + description: Option<&str>, + tags: Option<&serde_json::Value>, + trigger_type: &str, + schedule: Option<&str>, + cron_expression: Option<&str>, + run_mode: &str, + retry_count: Option, + retry_interval: Option, + timeout: Option, + concurrency: Option, + stop_on_error: Option, + notify_on_complete: Option, + notify_on_error: Option, +) -> Result { + let uuid: Uuid = sqlx::query_scalar( + r#" + INSERT INTO rpa_tasks (user_uuid, team_uuid, name, description, tags, trigger_type, + schedule, cron_expression, run_mode, retry_count, retry_interval, + timeout, concurrency, stop_on_error, notify_on_complete, notify_on_error) + VALUES ($1, $2, $3, $4, $5, $6, $7, $8, $9, $10, $11, $12, $13, $14, $15, $16) + RETURNING uuid; + "#, + ) + .bind(user_uuid) + .bind(team_uuid) + .bind(name) + .bind(description) + .bind(tags) + .bind(trigger_type) + .bind(schedule) + .bind(cron_expression) + .bind(run_mode) + .bind(retry_count) + .bind(retry_interval) + .bind(timeout) + .bind(concurrency) + .bind(stop_on_error) + .bind(notify_on_complete) + .bind(notify_on_error) + .fetch_one(pool) + .await?; + + Ok(uuid) +} + +/// 查询 RPA 任务列表 +pub async fn fetch_rpa_tasks( + pool: &Pool, + team_uuid: Option, + user_uuid: Uuid, + status: Option<&str>, + trigger_type: Option<&str>, + offset: i64, + limit: i64, +) -> Result, Error> { + let recs = sqlx::query_as::<_, RpaTaskDto>( + r#" + SELECT id, uuid, user_uuid, team_uuid, name, description, tags, trigger_type, + schedule, cron_expression, run_mode, retry_count, retry_interval, timeout, + concurrency, stop_on_error, notify_on_complete, notify_on_error, status, + run_count, success_count, last_run_at, next_run_at, created_at, updated_at, deleted_at + FROM rpa_tasks + WHERE (team_uuid = $1 OR (team_uuid IS NULL AND user_uuid = $2)) + AND ($3::varchar IS NULL OR status = $3) + AND ($4::varchar IS NULL OR trigger_type = $4) + AND deleted_at IS NULL + ORDER BY created_at DESC + LIMIT $5 OFFSET $6 + "#, + ) + .bind(team_uuid) + .bind(user_uuid) + .bind(status) + .bind(trigger_type) + .bind(limit) + .bind(offset) + .fetch_all(pool) + .await?; + + Ok(recs) +} + +/// 查询 RPA 任务总数 +pub async fn fetch_rpa_tasks_count( + pool: &Pool, + team_uuid: Option, + user_uuid: Uuid, + status: Option<&str>, + trigger_type: Option<&str>, +) -> Result { + let count: i64 = sqlx::query_scalar( + r#" + SELECT COUNT(*) FROM rpa_tasks + WHERE (team_uuid = $1 OR (team_uuid IS NULL AND user_uuid = $2)) + AND ($3::varchar IS NULL OR status = $3) + AND ($4::varchar IS NULL OR trigger_type = $4) + AND deleted_at IS NULL + "#, + ) + .bind(team_uuid) + .bind(user_uuid) + .bind(status) + .bind(trigger_type) + .fetch_one(pool) + .await?; + + Ok(count) +} + +/// 根据 UUID 查询 RPA 任务 +pub async fn fetch_rpa_task_by_uuid( + pool: &Pool, + task_uuid: Uuid, +) -> Result, Error> { + let rec = sqlx::query_as::<_, RpaTaskDto>( + r#" + SELECT id, uuid, user_uuid, team_uuid, name, description, tags, trigger_type, + schedule, cron_expression, run_mode, retry_count, retry_interval, timeout, + concurrency, stop_on_error, notify_on_complete, notify_on_error, status, + run_count, success_count, last_run_at, next_run_at, created_at, updated_at, deleted_at + FROM rpa_tasks + WHERE uuid = $1 AND deleted_at IS NULL + "#, + ) + .bind(task_uuid) + .fetch_optional(pool) + .await?; + + Ok(rec) +} + +/// 更新 RPA 任务 +pub async fn update_rpa_task( + pool: &Pool, + task_uuid: Uuid, + name: Option<&str>, + description: Option<&str>, + tags: Option<&serde_json::Value>, + trigger_type: Option<&str>, + schedule: Option<&str>, + cron_expression: Option<&str>, + run_mode: Option<&str>, + retry_count: Option, + retry_interval: Option, + timeout: Option, + concurrency: Option, + stop_on_error: Option, + notify_on_complete: Option, + notify_on_error: Option, +) -> Result<(), Error> { + sqlx::query( + r#" + UPDATE rpa_tasks + SET name = COALESCE($1, name), + description = COALESCE($2, description), + tags = COALESCE($3, tags), + trigger_type = COALESCE($4, trigger_type), + schedule = COALESCE($5, schedule), + cron_expression = COALESCE($6, cron_expression), + run_mode = COALESCE($7, run_mode), + retry_count = COALESCE($8, retry_count), + retry_interval = COALESCE($9, retry_interval), + timeout = COALESCE($10, timeout), + concurrency = COALESCE($11, concurrency), + stop_on_error = COALESCE($12, stop_on_error), + notify_on_complete = COALESCE($13, notify_on_complete), + notify_on_error = COALESCE($14, notify_on_error), + updated_at = CURRENT_TIMESTAMP + WHERE uuid = $15 AND deleted_at IS NULL + "#, + ) + .bind(name) + .bind(description) + .bind(tags) + .bind(trigger_type) + .bind(schedule) + .bind(cron_expression) + .bind(run_mode) + .bind(retry_count) + .bind(retry_interval) + .bind(timeout) + .bind(concurrency) + .bind(stop_on_error) + .bind(notify_on_complete) + .bind(notify_on_error) + .bind(task_uuid) + .execute(pool) + .await?; + + Ok(()) +} + +/// 更新任务状态 +pub async fn update_rpa_task_status( + pool: &Pool, + task_uuid: Uuid, + status: &str, +) -> Result<(), Error> { + sqlx::query( + r#" + UPDATE rpa_tasks + SET status = $1, updated_at = CURRENT_TIMESTAMP + WHERE uuid = $2 AND deleted_at IS NULL + "#, + ) + .bind(status) + .bind(task_uuid) + .execute(pool) + .await?; + + Ok(()) +} + +/// 软删除 RPA 任务 +pub async fn delete_rpa_task(pool: &Pool, task_uuid: Uuid) -> Result<(), Error> { + sqlx::query( + r#" + UPDATE rpa_tasks SET deleted_at = CURRENT_TIMESTAMP + WHERE uuid = $1 AND deleted_at IS NULL + "#, + ) + .bind(task_uuid) + .execute(pool) + .await?; + + Ok(()) +} + +/// 批量软删除 RPA 任务 +pub async fn batch_delete_rpa_tasks( + pool: &Pool, + task_uuids: &[Uuid], +) -> Result { + let result = sqlx::query( + r#" + UPDATE rpa_tasks SET deleted_at = CURRENT_TIMESTAMP + WHERE uuid = ANY($1) AND deleted_at IS NULL + "#, + ) + .bind(task_uuids) + .execute(pool) + .await?; + + Ok(result.rows_affected()) +} + +// ============ RPA Task Steps ============ + +/// 插入任务步骤 +pub async fn insert_rpa_task_step( + pool: &Pool, + task_uuid: Uuid, + step_type: &str, + name: &str, + config: &serde_json::Value, + enabled: Option, + position_x: Option, + position_y: Option, + sort_order: Option, +) -> Result { + let uuid: Uuid = sqlx::query_scalar( + r#" + INSERT INTO rpa_task_steps (task_uuid, step_type, name, config, enabled, + position_x, position_y, sort_order) + VALUES ($1, $2, $3, $4, $5, $6, $7, $8) + RETURNING uuid; + "#, + ) + .bind(task_uuid) + .bind(step_type) + .bind(name) + .bind(config) + .bind(enabled.unwrap_or(true)) + .bind(position_x) + .bind(position_y) + .bind(sort_order) + .fetch_one(pool) + .await?; + + Ok(uuid) +} + +/// 查询任务步骤列表 +pub async fn fetch_rpa_task_steps( + pool: &Pool, + task_uuid: Uuid, +) -> Result, Error> { + let recs = sqlx::query_as::<_, RpaTaskStepDto>( + r#" + SELECT id, uuid, task_uuid, step_type, name, config, enabled, + position_x, position_y, sort_order, next_step_uuid, branch_config, + created_at, updated_at + FROM rpa_task_steps + WHERE task_uuid = $1 + ORDER BY sort_order ASC, id ASC + "#, + ) + .bind(task_uuid) + .fetch_all(pool) + .await?; + + Ok(recs) +} + +/// 删除任务所有步骤 +pub async fn delete_rpa_task_steps(pool: &Pool, task_uuid: Uuid) -> Result<(), Error> { + sqlx::query("DELETE FROM rpa_task_steps WHERE task_uuid = $1") + .bind(task_uuid) + .execute(pool) + .await?; + + Ok(()) +} + +// ============ RPA Task Environments ============ + +/// 添加任务环境关联 +pub async fn insert_rpa_task_environment( + pool: &Pool, + task_uuid: Uuid, + environment_uuid: Uuid, + sort_order: Option, +) -> Result { + let id: i32 = sqlx::query_scalar( + r#" + INSERT INTO rpa_task_environments (task_uuid, environment_uuid, sort_order) + VALUES ($1, $2, $3) + RETURNING id; + "#, + ) + .bind(task_uuid) + .bind(environment_uuid) + .bind(sort_order) + .fetch_one(pool) + .await?; + + Ok(id) +} + +/// 查询任务环境关联 +pub async fn fetch_rpa_task_environments( + pool: &Pool, + task_uuid: Uuid, +) -> Result, Error> { + let recs = sqlx::query_as::<_, RpaTaskEnvironmentDto>( + r#" + SELECT id, task_uuid, environment_uuid, sort_order, created_at + FROM rpa_task_environments + WHERE task_uuid = $1 + ORDER BY sort_order ASC, id ASC + "#, + ) + .bind(task_uuid) + .fetch_all(pool) + .await?; + + Ok(recs) +} + +/// 删除任务所有环境关联 +pub async fn delete_rpa_task_environments( + pool: &Pool, + task_uuid: Uuid, +) -> Result<(), Error> { + sqlx::query("DELETE FROM rpa_task_environments WHERE task_uuid = $1") + .bind(task_uuid) + .execute(pool) + .await?; + + Ok(()) +} + +// ============ RPA Task Runs ============ + +/// 创建任务执行记录 +pub async fn insert_rpa_task_run( + pool: &Pool, + task_uuid: Uuid, + total_steps: i32, +) -> Result { + let uuid: Uuid = sqlx::query_scalar( + r#" + INSERT INTO rpa_task_runs (task_uuid, status, total_steps, completed_steps, failed_steps) + VALUES ($1, 'running', $2, 0, 0) + RETURNING uuid; + "#, + ) + .bind(task_uuid) + .bind(total_steps) + .fetch_one(pool) + .await?; + + // 更新任务最后运行时间和运行次数 + sqlx::query( + r#" + UPDATE rpa_tasks + SET last_run_at = CURRENT_TIMESTAMP, + run_count = COALESCE(run_count, 0) + 1 + WHERE uuid = $1 + "#, + ) + .bind(task_uuid) + .execute(pool) + .await?; + + Ok(uuid) +} + +/// 查询任务执行记录列表 +pub async fn fetch_rpa_task_runs( + pool: &Pool, + task_uuid: Uuid, + status: Option<&str>, + offset: i64, + limit: i64, +) -> Result, Error> { + let recs = sqlx::query_as::<_, RpaTaskRunDto>( + r#" + SELECT id, uuid, task_uuid, status, total_steps, completed_steps, failed_steps, + started_at, finished_at, duration_ms, result_summary, error_message, logs + FROM rpa_task_runs + WHERE task_uuid = $1 + AND ($2::varchar IS NULL OR status = $2) + ORDER BY started_at DESC + LIMIT $3 OFFSET $4 + "#, + ) + .bind(task_uuid) + .bind(status) + .bind(limit) + .bind(offset) + .fetch_all(pool) + .await?; + + Ok(recs) +} + +/// 查询任务执行记录总数 +pub async fn fetch_rpa_task_runs_count( + pool: &Pool, + task_uuid: Uuid, + status: Option<&str>, +) -> Result { + let count: i64 = sqlx::query_scalar( + r#" + SELECT COUNT(*) FROM rpa_task_runs + WHERE task_uuid = $1 + AND ($2::varchar IS NULL OR status = $2) + "#, + ) + .bind(task_uuid) + .bind(status) + .fetch_one(pool) + .await?; + + Ok(count) +} + +/// 根据 UUID 查询执行记录 +pub async fn fetch_rpa_task_run_by_uuid( + pool: &Pool, + run_uuid: Uuid, +) -> Result, Error> { + let rec = sqlx::query_as::<_, RpaTaskRunDto>( + r#" + SELECT id, uuid, task_uuid, status, total_steps, completed_steps, failed_steps, + started_at, finished_at, duration_ms, result_summary, error_message, logs + FROM rpa_task_runs + WHERE uuid = $1 + "#, + ) + .bind(run_uuid) + .fetch_optional(pool) + .await?; + + Ok(rec) +} + +/// 更新执行记录状态 +pub async fn update_rpa_task_run_status( + pool: &Pool, + run_uuid: Uuid, + status: &str, + completed_steps: i32, + failed_steps: i32, + result_summary: Option<&str>, + error_message: Option<&str>, +) -> Result<(), Error> { + sqlx::query( + r#" + UPDATE rpa_task_runs + SET status = $1, + completed_steps = $2, + failed_steps = $3, + result_summary = $4, + error_message = $5, + finished_at = CASE WHEN $1 IN ('completed', 'failed', 'stopped') THEN CURRENT_TIMESTAMP ELSE finished_at END, + duration_ms = CASE WHEN $1 IN ('completed', 'failed', 'stopped') + THEN EXTRACT(EPOCH FROM (CURRENT_TIMESTAMP - started_at)) * 1000 + ELSE duration_ms END + WHERE uuid = $6 + "#, + ) + .bind(status) + .bind(completed_steps) + .bind(failed_steps) + .bind(result_summary) + .bind(error_message) + .bind(run_uuid) + .execute(pool) + .await?; + + Ok(()) +} diff --git a/src/models/strategy_types.rs b/src/models/strategy_types.rs new file mode 100644 index 0000000..abc9281 --- /dev/null +++ b/src/models/strategy_types.rs @@ -0,0 +1,56 @@ +use crate::dto::strategy_types::StrategyType; +use sqlx::{Error, Pool, Postgres}; + +/// 根据ID查询策略类型 +pub async fn query_strategy_type_by_id( + pool: &Pool, + id: i32, +) -> Result { + let strategy_type: StrategyType = sqlx::query_as("SELECT * FROM strategy_types WHERE id = $1") + .bind(id) + .fetch_one(pool) + .await?; + + Ok(strategy_type) +} + +/// 根据code查询策略类型 +pub async fn query_strategy_type_by_code( + pool: &Pool, + code: &str, +) -> Result { + let strategy_type: StrategyType = + sqlx::query_as("SELECT * FROM strategy_types WHERE code = $1") + .bind(code) + .fetch_one(pool) + .await?; + + Ok(strategy_type) +} + +/// 查询所有可用的策略类型(只查询激活的) +pub async fn query_available_strategy_types( + pool: &Pool, +) -> Result, Error> { + let strategy_types: Vec = + sqlx::query_as("SELECT * FROM strategy_types WHERE is_active = true ORDER BY code") + .fetch_all(pool) + .await?; + + Ok(strategy_types) +} + +/// 根据分类查询策略类型 +pub async fn query_strategy_types_by_category( + pool: &Pool, + category: &str, +) -> Result, Error> { + let strategy_types: Vec = sqlx::query_as( + "SELECT * FROM strategy_types WHERE category = $1 AND is_active = true ORDER BY code", + ) + .bind(category) + .fetch_all(pool) + .await?; + + Ok(strategy_types) +} diff --git a/src/models/teams.rs b/src/models/teams.rs new file mode 100644 index 0000000..f43ed4f --- /dev/null +++ b/src/models/teams.rs @@ -0,0 +1,835 @@ +use sqlx::{Error, Pool, Postgres}; +use uuid::Uuid; + +use crate::dto::{LoginHistoryDto, TeamDto, TeamInvitationDto, TeamMemberDto}; +use crate::entitys::CreateTeamRequest; + +// ============ Teams ============ + +/// 创建团队 +pub async fn insert_team( + pool: &Pool, + owner_uuid: Uuid, + payload: &CreateTeamRequest, +) -> Result { + let mut tx = pool.begin().await?; + + // 1. 创建团队 + let team_uuid: Uuid = sqlx::query_scalar( + r#" + INSERT INTO teams (workspace_uuid, name, description, owner_uuid) + VALUES ($1, $2, $3, $4) + RETURNING uuid; + "#, + ) + .bind(payload.workspace_uuid) + .bind(&payload.name) + .bind(&payload.description) + .bind(owner_uuid) + .fetch_one(&mut *tx) + .await?; + + // 2. 添加所有者为成员 + sqlx::query( + r#" + INSERT INTO team_members (team_uuid, workspace_uuid, user_uuid, role, status) + VALUES ($1, $2, $3, 'owner', 'active'); + "#, + ) + .bind(team_uuid) + .bind(payload.workspace_uuid) + .bind(owner_uuid) + .execute(&mut *tx) + .await?; + + // 3. 设置为用户当前团队 + sqlx::query( + r#" + UPDATE user_infos SET current_team_uuid = $1 WHERE user_uuid = $2; + "#, + ) + .bind(team_uuid) + .bind(owner_uuid) + .execute(&mut *tx) + .await?; + + tx.commit().await?; + + Ok(team_uuid) +} + +/// 根据 UUID 查询团队 +pub async fn fetch_team_by_uuid( + pool: &Pool, + team_uuid: Uuid, +) -> Result, Error> { + let rec = sqlx::query_as::<_, TeamDto>( + r#" + SELECT id, uuid, workspace_uuid, name, description, owner_uuid, avatar_hash, + status, created_at, updated_at, deleted_at + FROM teams + WHERE uuid = $1 AND deleted_at IS NULL + "#, + ) + .bind(team_uuid) + .fetch_optional(pool) + .await?; + + Ok(rec) +} + +/// 查询用户在工作空间中所属的所有团队(工作空间级别) +pub async fn fetch_user_teams( + pool: &Pool, + workspace_uuid: Uuid, + user_uuid: Uuid, +) -> Result, Error> { + let recs = sqlx::query_as::<_, TeamDto>( + r#" + SELECT t.id, t.uuid, t.workspace_uuid, t.name, t.description, t.owner_uuid, t.avatar_hash, + t.status, t.created_at, t.updated_at, t.deleted_at + FROM teams t + INNER JOIN team_members tm ON t.uuid = tm.team_uuid + WHERE tm.workspace_uuid = $1 AND tm.user_uuid = $2 AND t.deleted_at IS NULL AND tm.deleted_at IS NULL + ORDER BY t.created_at + "#, + ) + .bind(workspace_uuid) + .bind(user_uuid) + .fetch_all(pool) + .await?; + + Ok(recs) +} + +/// 查询用户当前团队 +pub async fn fetch_user_current_team( + pool: &Pool, + user_uuid: Uuid, +) -> Result, Error> { + let rec: Option = sqlx::query_scalar( + r#" + SELECT current_team_uuid FROM user_infos + WHERE user_uuid = $1 + "#, + ) + .bind(user_uuid) + .fetch_optional(pool) + .await?; + + Ok(rec) +} + +/// 设置用户当前团队 +pub async fn set_user_current_team( + pool: &Pool, + user_uuid: Uuid, + team_uuid: Uuid, +) -> Result<(), Error> { + sqlx::query( + r#" + UPDATE user_infos SET current_team_uuid = $1 WHERE user_uuid = $2 + "#, + ) + .bind(team_uuid) + .bind(user_uuid) + .execute(pool) + .await?; + + Ok(()) +} + +/// 清除用户当前团队 +pub async fn clear_user_current_team(pool: &Pool, user_uuid: Uuid) -> Result<(), Error> { + sqlx::query( + r#" + UPDATE user_infos SET current_team_uuid = NULL WHERE user_uuid = $1 + "#, + ) + .bind(user_uuid) + .execute(pool) + .await?; + + Ok(()) +} + +/// 更新团队信息 +pub async fn update_team( + pool: &Pool, + team_uuid: Uuid, + name: Option<&str>, + description: Option<&str>, + avatar_hash: Option<&str>, +) -> Result<(), Error> { + let mut query = String::from("UPDATE teams SET updated_at = NOW()"); + let mut params: Vec + Send + Sync>> = vec![]; + + if let Some(n) = name { + query.push_str(", name = $"); + params.push(Box::new(n)); + query.push_str(&format!("{}", params.len())); + } + + if let Some(d) = description { + query.push_str(", description = $"); + params.push(Box::new(d)); + query.push_str(&format!("{}", params.len())); + } + + if let Some(a) = avatar_hash { + query.push_str(", avatar_hash = $"); + params.push(Box::new(a)); + query.push_str(&format!("{}", params.len())); + } + + query.push_str(" WHERE uuid = $"); + params.push(Box::new(team_uuid)); + query.push_str(&format!("{}", params.len())); + + // 这里需要动态构建查询,但 sqlx 不支持动态查询,所以使用条件分支 + if name.is_some() + && description.is_some() + && avatar_hash.is_some() + { + sqlx::query( + r#" + UPDATE teams + SET name = $1, description = $2, avatar_hash = $3, updated_at = NOW() + WHERE uuid = $4 + "#, + ) + .bind(name.unwrap()) + .bind(description.unwrap()) + .bind(avatar_hash.unwrap()) + .bind(team_uuid) + .execute(pool) + .await?; + } else if name.is_some() && description.is_some() { + sqlx::query( + r#" + UPDATE teams + SET name = $1, description = $2, updated_at = NOW() + WHERE uuid = $3 + "#, + ) + .bind(name.unwrap()) + .bind(description.unwrap()) + .bind(team_uuid) + .execute(pool) + .await?; + } else if name.is_some() { + sqlx::query( + r#" + UPDATE teams + SET name = $1, updated_at = NOW() + WHERE uuid = $2 + "#, + ) + .bind(name.unwrap()) + .bind(team_uuid) + .execute(pool) + .await?; + } + + Ok(()) +} + +// ============ Team Members ============ + +/// 获取团队成员数量 +pub async fn fetch_team_member_count( + pool: &Pool, + team_uuid: Uuid, + keyword: Option<&str>, + role: Option<&str>, + status: Option<&str>, +) -> Result { + let mut query = String::from( + r#" + SELECT COUNT(*) FROM team_members tm + LEFT JOIN user_infos ui ON tm.user_uuid = ui.user_uuid + WHERE tm.team_uuid = $1 AND tm.deleted_at IS NULL + "#, + ); + + let mut param_index = 2; + + // 默认只查询 active 状态 + if status.is_some() { + query.push_str(&format!(" AND tm.status = ${}", param_index)); + param_index += 1; + } else { + query.push_str(" AND tm.status = 'active'"); + } + + if role.is_some() { + query.push_str(&format!(" AND tm.role = ${}", param_index)); + param_index += 1; + } + + if keyword.is_some() { + query.push_str(&format!( + " AND (ui.nickname ILIKE ${} OR ui.email ILIKE ${})", + param_index, param_index + )); + } + + let mut query_builder = sqlx::query_scalar::<_, i64>(&query).bind(team_uuid); + + if let Some(s) = status { + query_builder = query_builder.bind(s); + } + + if let Some(r) = role { + query_builder = query_builder.bind(r); + } + + if let Some(k) = keyword { + let keyword_pattern = format!("%{}%", k); + query_builder = query_builder.bind(keyword_pattern); + } + + let count = query_builder.fetch_one(pool).await?; + + Ok(count) +} + +/// 查询团队成员列表(关联用户信息,支持筛选) +pub async fn fetch_team_members( + pool: &Pool, + team_uuid: Uuid, + offset: i64, + limit: i64, + keyword: Option<&str>, + role: Option<&str>, + status: Option<&str>, +) -> Result, Error> { + let mut query = String::from( + r#" + SELECT + tm.id, + tm.team_uuid, + tm.workspace_uuid, + tm.user_uuid, + tm.role, + tm.joined_at, + tm.invited_by, + tm.status, + tm.created_at, + tm.updated_at, + tm.deleted_at, + ui.nickname AS name, + ui.email AS email, + ui.avatar_hash AS avatar + FROM team_members tm + LEFT JOIN user_infos ui ON tm.user_uuid = ui.user_uuid + WHERE tm.team_uuid = $1 AND tm.deleted_at IS NULL + "#, + ); + + let mut param_index = 2; + + // 默认只查询 active 状态 + if status.is_some() { + query.push_str(&format!(" AND tm.status = ${}", param_index)); + param_index += 1; + } else { + query.push_str(" AND tm.status = 'active'"); + } + + if role.is_some() { + query.push_str(&format!(" AND tm.role = ${}", param_index)); + param_index += 1; + } + + if keyword.is_some() { + query.push_str(&format!( + " AND (ui.nickname ILIKE ${} OR ui.email ILIKE ${})", + param_index, param_index + )); + param_index += 1; + } + + query.push_str(&format!( + r#" + ORDER BY + CASE tm.role + WHEN 'owner' THEN 1 + WHEN 'admin' THEN 2 + WHEN 'editor' THEN 3 + ELSE 4 + END, + tm.joined_at + LIMIT ${} OFFSET ${} + "#, + param_index, + param_index + 1 + )); + + let mut query_builder = sqlx::query_as::<_, TeamMemberDto>(&query).bind(team_uuid); + + if let Some(s) = status { + query_builder = query_builder.bind(s); + } + + if let Some(r) = role { + query_builder = query_builder.bind(r); + } + + if let Some(k) = keyword { + let keyword_pattern = format!("%{}%", k); + query_builder = query_builder.bind(keyword_pattern); + } + + query_builder = query_builder.bind(limit).bind(offset); + + let recs = query_builder.fetch_all(pool).await?; + + Ok(recs) +} + +/// 查询用户在团队中的成员信息(工作空间级别) +pub async fn fetch_team_member( + pool: &Pool, + workspace_uuid: Uuid, + team_uuid: Uuid, + user_uuid: Uuid, +) -> Result, Error> { + let rec = sqlx::query_as::<_, TeamMemberDto>( + r#" + SELECT + tm.id, + tm.team_uuid, + tm.workspace_uuid, + tm.user_uuid, + tm.role, + tm.joined_at, + tm.invited_by, + tm.status, + tm.created_at, + tm.updated_at, + tm.deleted_at, + ui.nickname AS name, + ui.email AS email, + ui.avatar_hash AS avatar + FROM team_members tm + LEFT JOIN user_infos ui ON tm.user_uuid = ui.user_uuid + WHERE tm.workspace_uuid = $1 AND tm.team_uuid = $2 AND tm.user_uuid = $3 AND tm.deleted_at IS NULL + "#, + ) + .bind(workspace_uuid) + .bind(team_uuid) + .bind(user_uuid) + .fetch_optional(pool) + .await?; + + Ok(rec) +} + +/// 添加团队成员 +pub async fn insert_team_member( + pool: &Pool, + team_uuid: Uuid, + user_uuid: Uuid, + role: &str, + invited_by: Option, +) -> Result { + let id: i32 = sqlx::query_scalar( + r#" + INSERT INTO team_members (team_uuid, workspace_uuid, user_uuid, role, invited_by, status) + VALUES ($1, (SELECT workspace_uuid FROM teams WHERE uuid = $1), $2, $3, $4, 'active') + RETURNING id; + "#, + ) + .bind(team_uuid) + .bind(user_uuid) + .bind(role) + .bind(invited_by) + .fetch_one(pool) + .await?; + + Ok(id) +} + +/// 更新成员角色 +pub async fn update_member_role( + pool: &Pool, + team_uuid: Uuid, + user_uuid: Uuid, + role: &str, +) -> Result<(), Error> { + sqlx::query( + r#" + UPDATE team_members + SET role = $1, updated_at = NOW() + WHERE team_uuid = $2 AND user_uuid = $3 AND deleted_at IS NULL + "#, + ) + .bind(role) + .bind(team_uuid) + .bind(user_uuid) + .execute(pool) + .await?; + + Ok(()) +} + +/// 更新成员状态 +pub async fn update_member_status( + pool: &Pool, + team_uuid: Uuid, + user_uuid: Uuid, + status: &str, +) -> Result<(), Error> { + sqlx::query( + r#" + UPDATE team_members + SET status = $1 + WHERE team_uuid = $2 AND user_uuid = $3 AND deleted_at IS NULL + "#, + ) + .bind(status) + .bind(team_uuid) + .bind(user_uuid) + .execute(pool) + .await?; + + Ok(()) +} + +/// 移除成员(软删除) +pub async fn remove_team_member( + pool: &Pool, + team_uuid: Uuid, + user_uuid: Uuid, +) -> Result<(), Error> { + sqlx::query( + r#" + UPDATE team_members + SET deleted_at = NOW(), status = 'inactive' + WHERE team_uuid = $1 AND user_uuid = $2 AND deleted_at IS NULL + "#, + ) + .bind(team_uuid) + .bind(user_uuid) + .execute(pool) + .await?; + + Ok(()) +} + +// ============ Team Invitations ============ + +/// 创建团队邀请 +pub async fn insert_team_invitation( + pool: &Pool, + team_uuid: Uuid, + email: &str, + role: &str, + invited_by: Uuid, + token: &str, + expires_at: chrono::DateTime, +) -> Result { + let uuid: Uuid = sqlx::query_scalar( + r#" + INSERT INTO team_invitations (team_uuid, email, role, invited_by, token, expires_at, status) + VALUES ($1, $2, $3, $4, $5, $6, 'pending') + RETURNING uuid; + "#, + ) + .bind(team_uuid) + .bind(email) + .bind(role) + .bind(invited_by) + .bind(token) + .bind(expires_at) + .fetch_one(pool) + .await?; + + Ok(uuid) +} + +/// 查询团队邀请 +pub async fn fetch_team_invitation_by_token( + pool: &Pool, + token: &str, +) -> Result, Error> { + let rec = sqlx::query_as::<_, TeamInvitationDto>( + r#" + SELECT id, uuid, team_uuid, email, role, invited_by, token, expires_at, status, accepted_at, created_at, updated_at + FROM team_invitations + WHERE token = $1 AND deleted_at IS NULL + "#, + ) + .bind(token) + .fetch_optional(pool) + .await?; + + Ok(rec) +} + +/// 查询团队的待处理邀请 +pub async fn fetch_pending_invitations( + pool: &Pool, + team_uuid: Uuid, +) -> Result, Error> { + let recs = sqlx::query_as::<_, TeamInvitationDto>( + r#" + SELECT id, uuid, team_uuid, email, role, invited_by, token, expires_at, status, accepted_at, created_at, updated_at + FROM team_invitations + WHERE team_uuid = $1 AND status = 'pending' AND deleted_at IS NULL + ORDER BY created_at DESC + "#, + ) + .bind(team_uuid) + .fetch_all(pool) + .await?; + + Ok(recs) +} + +/// 检查是否有待处理的邀请 +pub async fn has_pending_invitation( + pool: &Pool, + team_uuid: Uuid, + email: &str, +) -> Result { + let count: i64 = sqlx::query_scalar( + r#" + SELECT COUNT(*) FROM team_invitations + WHERE team_uuid = $1 AND email = $2 AND status = 'pending' AND deleted_at IS NULL + "#, + ) + .bind(team_uuid) + .bind(email) + .fetch_one(pool) + .await?; + + Ok(count > 0) +} + +/// 更新邀请状态 +pub async fn update_invitation_status( + pool: &Pool, + invitation_uuid: Uuid, + status: &str, +) -> Result<(), Error> { + sqlx::query( + r#" + UPDATE team_invitations + SET status = $1, updated_at = NOW() + WHERE uuid = $2 AND deleted_at IS NULL + "#, + ) + .bind(status) + .bind(invitation_uuid) + .execute(pool) + .await?; + + Ok(()) +} + +/// 查询邀请(通过 token,别名函数) +pub async fn fetch_invitation_by_token( + pool: &Pool, + token: &str, +) -> Result, Error> { + fetch_team_invitation_by_token(pool, token).await +} + +/// 取消邀请 +pub async fn cancel_team_invitation( + pool: &Pool, + invitation_uuid: Uuid, +) -> Result<(), Error> { + sqlx::query( + r#" + UPDATE team_invitations + SET status = 'cancelled', deleted_at = NOW() + WHERE uuid = $1 AND deleted_at IS NULL + "#, + ) + .bind(invitation_uuid) + .execute(pool) + .await?; + + Ok(()) +} + +/// 接受邀请 +pub async fn accept_team_invitation( + pool: &Pool, + invitation_uuid: Uuid, + user_uuid: Uuid, +) -> Result { + let mut tx = pool.begin().await?; + + // 1. 获取邀请信息 + let invitation = sqlx::query_as::<_, TeamInvitationDto>( + r#" + SELECT id, uuid, team_uuid, email, role, invited_by, token, expires_at, status, accepted_at, created_at, updated_at + FROM team_invitations + WHERE uuid = $1 AND deleted_at IS NULL + FOR UPDATE + "#, + ) + .bind(invitation_uuid) + .fetch_optional(&mut *tx) + .await?; + + let invitation = invitation.ok_or_else(|| Error::RowNotFound)?; + + // 2. 检查邀请状态 + if invitation.status != "pending" { + return Err(Error::RowNotFound); + } + + // 3. 检查是否过期 + if invitation.expires_at < chrono::Utc::now() { + return Err(Error::RowNotFound); + } + + // 4. 添加成员(如果已存在则更新状态为 active) + sqlx::query( + r#" + INSERT INTO team_members (team_uuid, user_uuid, role, invited_by, status) + VALUES ($1, $2, $3, $4, 'active') + ON CONFLICT (team_uuid, user_uuid) DO UPDATE SET + role = EXCLUDED.role, + invited_by = EXCLUDED.invited_by, + status = 'active', + deleted_at = NULL, + updated_at = NOW() + "#, + ) + .bind(invitation.team_uuid) + .bind(user_uuid) + .bind(&invitation.role) + .bind(invitation.invited_by) + .execute(&mut *tx) + .await?; + + // 5. 更新邀请状态 + sqlx::query( + r#" + UPDATE team_invitations + SET status = 'accepted', accepted_at = NOW() + WHERE uuid = $1 + "#, + ) + .bind(invitation_uuid) + .execute(&mut *tx) + .await?; + + tx.commit().await?; + + Ok(invitation.team_uuid) +} + +/// 拒绝邀请 +pub async fn reject_team_invitation( + pool: &Pool, + invitation_uuid: Uuid, + _user_uuid: Uuid, +) -> Result<(), Error> { + let mut tx = pool.begin().await?; + + // 1. 获取邀请信息 + let invitation = sqlx::query_as::<_, TeamInvitationDto>( + r#" + SELECT id, uuid, team_uuid, email, role, invited_by, token, expires_at, status, accepted_at, created_at, updated_at + FROM team_invitations + WHERE uuid = $1 AND deleted_at IS NULL + FOR UPDATE + "#, + ) + .bind(invitation_uuid) + .fetch_optional(&mut *tx) + .await?; + + let invitation = invitation.ok_or_else(|| Error::RowNotFound)?; + + // 2. 检查邀请状态(只有 pending 状态的邀请才能被拒绝) + if invitation.status != "pending" { + return Err(Error::RowNotFound); + } + + // 3. 检查是否过期(过期的邀请也可以标记为拒绝) + // 这里不检查过期时间,允许拒绝已过期的邀请 + + // 4. 更新邀请状态为 rejected + sqlx::query( + r#" + UPDATE team_invitations + SET status = 'rejected', updated_at = NOW() + WHERE uuid = $1 + "#, + ) + .bind(invitation_uuid) + .execute(&mut *tx) + .await?; + + tx.commit().await?; + + Ok(()) +} + +// ============ Login History ============ + +/// 记录登录历史 +pub async fn insert_login_history( + pool: &Pool, + user_uuid: Uuid, + ip_address: &str, + device_info: Option<&str>, + user_agent: Option<&str>, + location: Option<&str>, + country: Option<&str>, + city: Option<&str>, + success: bool, + failure_reason: Option<&str>, +) -> Result { + let id: i64 = sqlx::query_scalar( + r#" + INSERT INTO login_history (user_uuid, ip_address, device_info, user_agent, location, country, city, success, failure_reason) + VALUES ($1, $2, $3, $4, $5, $6, $7, $8, $9) + RETURNING id; + "#, + ) + .bind(user_uuid) + .bind(ip_address) + .bind(device_info) + .bind(user_agent) + .bind(location) + .bind(country) + .bind(city) + .bind(success) + .bind(failure_reason) + .fetch_one(pool) + .await?; + + Ok(id) +} + +/// 查询用户登录历史 +pub async fn fetch_user_login_history( + pool: &Pool, + user_uuid: Uuid, + limit: i64, +) -> Result, Error> { + let recs = sqlx::query_as::<_, LoginHistoryDto>( + r#" + SELECT id, user_uuid, ip_address, device_info, user_agent, location, country, city, success, failure_reason, created_at + FROM login_history + WHERE user_uuid = $1 + ORDER BY created_at DESC + LIMIT $2 + "#, + ) + .bind(user_uuid) + .bind(limit) + .fetch_all(pool) + .await?; + + Ok(recs) +} diff --git a/src/models/user.rs b/src/models/user.rs new file mode 100644 index 0000000..884a935 --- /dev/null +++ b/src/models/user.rs @@ -0,0 +1,415 @@ +use rust_decimal::Decimal; +use sqlx::{Error, Pool, Postgres}; +use uuid::Uuid; + +use crate::dto::{UserDto, UserInfoDto}; +use crate::entitys::{RegisterRequest, UpdateUserRequest}; + +/// 插入用户基础信息 +pub async fn insert_user(pool: &Pool, user_id: String) -> Result { + let uuid: Uuid = sqlx::query_scalar("INSERT INTO users (id) VALUES ($1) RETURNING uuid;") + .bind(user_id) + .fetch_one(pool) + .await?; + + Ok(uuid) +} + +/// 插入用户详细信息 +pub async fn insert_user_info( + pool: &Pool, + user_uuid: Uuid, + payload: &RegisterRequest, + password_hash: &str, +) -> Result { + let id: i32 = sqlx::query_scalar( + r#" + INSERT INTO user_infos (user_uuid, email, password, nickname, status) + VALUES ($1, $2, $3, $4, 'active') + RETURNING id; + "#, + ) + .bind(user_uuid) + .bind(&payload.email) + .bind(password_hash) + .bind(&payload.nickname) + .fetch_one(pool) + .await?; + + Ok(id) +} + +/// 使用事务创建用户(users + user_infos + 初始化数据) +/// +/// 初始化数据包括: +/// - user_wallets: 用户钱包 +/// - user_quotas: 用户配额(免费套餐) +/// - user_preferences: 用户偏好设置 +/// - user_referral_points: 推广积分 +/// - referral_links: 推广链接(关联青铜层级) +/// - teams: 个人团队(每个用户自动创建一个团队) +pub async fn create_user_with_info( + pool: &Pool, + user_id: String, + payload: &RegisterRequest, + password_hash: &str, + quota: &crate::utils::WorkspaceQuotaValues, +) -> Result { + // 为用户初始化推广链接时,需要读取所有推广层级配置。 + // 这里复用 referral 模块中已经定义好的 DTO / 查询函数,避免在 models 中新增本地 struct 类型。 + let tiers = crate::models::referral::fetch_referral_tiers(pool).await?; + + let mut tx = pool.begin().await?; + + // 1. 创建用户基础记录 + let user_uuid: Uuid = sqlx::query_scalar("INSERT INTO users (id) VALUES ($1) RETURNING uuid;") + .bind(&user_id) + .fetch_one(&mut *tx) + .await?; + + // 2. 创建用户详细信息 + sqlx::query( + r#" + INSERT INTO user_infos (user_uuid, email, password, nickname, status) + VALUES ($1, $2, $3, $4, 'active'); + "#, + ) + .bind(user_uuid) + .bind(&payload.email) + .bind(password_hash) + .bind(&payload.nickname) + .execute(&mut *tx) + .await?; + + // 3. 初始化用户钱包 + sqlx::query( + r#" + INSERT INTO user_wallets (user_uuid, balance, currency, frozen_amount) + VALUES ($1, 0, 'CNY', 0); + "#, + ) + .bind(user_uuid) + .execute(&mut *tx) + .await?; + + // 4. 初始化用户配额(已废弃,配额移至 workspace_quotas,在工作空间创建时初始化) + + // 5. 初始化用户偏好设置 + sqlx::query( + r#" + INSERT INTO user_preferences (user_uuid, theme, language, notifications_enabled) + VALUES ($1, 'system', 'zh-CN', TRUE); + "#, + ) + .bind(user_uuid) + .execute(&mut *tx) + .await?; + + // 6. 初始化推广积分 + sqlx::query( + r#" + INSERT INTO user_referral_points (user_uuid, total_points, available_points, used_points, pending_points) + VALUES ($1, 0, 0, 0, 0); + "#, + ) + .bind(user_uuid) + .execute(&mut *tx) + .await?; + + // 7. 创建推广链接(根据层级配置创建最多 4 个链接) + // + // 规则: + // - 始终为用户预创建所有层级对应的推广链接(默认最多 4 个) + // - 只有第一个层级的链接默认解锁并设为当前链接 + // - 奖励比例 / 折扣比例从对应层级配置中继承,便于后续统一调整 + // + // 生成基础邀请码(第一个链接使用此码,其余在此基础上追加序号后缀) + let base_invite_code = format!("INV{}", &user_uuid.to_string()[..8].to_uppercase()); + + // 根据层级配置为用户预创建推广链接(最多 4 个) + for (idx, tier) in tiers.iter().enumerate() { + if idx >= 4 { + // 目前产品形态只需要 4 个推广链接,多余层级忽略 + break; + } + + let code = if idx == 0 { + base_invite_code.clone() + } else { + // 保证唯一性的同时,便于用户识别不同链接 + format!("{}-{}", base_invite_code, idx + 1) + }; + + // 只有第一个层级默认解锁并设为当前链接,其余待后续根据阈值自动解锁 + let is_current = idx == 0; + let unlocked = idx == 0; + + sqlx::query( + r#" + INSERT INTO referral_links ( + user_uuid, + code, + tier_uuid, + is_current, + unlocked, + reward_rate, + discount_rate + ) + VALUES ($1, $2, $3, $4, $5, $6, $7); + "#, + ) + .bind(user_uuid) + .bind(&code) + .bind(tier.uuid) + .bind(is_current) + .bind(unlocked) + .bind(tier.reward_rate) + .bind(tier.discount_rate) + .execute(&mut *tx) + .await?; + } + + // 8. 处理推荐人关系(如果有邀请码) + if let Some(ref referrer_code) = payload.referral_code { + // 查找推荐人 + let referrer: Option<(Uuid, Uuid)> = sqlx::query_as( + r#" + SELECT user_uuid, uuid FROM referral_links WHERE code = $1 AND is_current = TRUE; + "#, + ) + .bind(referrer_code) + .fetch_optional(&mut *tx) + .await?; + + if let Some((referrer_uuid, referral_link_uuid)) = referrer { + // 创建推荐关系(表列名:inviter_uuid=推荐人, invitee_uuid=被邀请人, link_uuid=推广链接) + sqlx::query( + r#" + INSERT INTO user_referrals (invitee_uuid, inviter_uuid, link_uuid, status) + VALUES ($1, $2, $3, 'registered'); + "#, + ) + .bind(user_uuid) + .bind(referrer_uuid) + .bind(referral_link_uuid) + .execute(&mut *tx) + .await?; + + // 更新推荐链接统计 + sqlx::query( + r#" + UPDATE referral_links SET registered_users = registered_users + 1 WHERE uuid = $1; + "#, + ) + .bind(referral_link_uuid) + .execute(&mut *tx) + .await?; + } + } + + // 9. 创建个人团队(每个用户都应该有一个团队) + // 团队名称使用用户昵称,如果没有昵称则使用邮箱前缀 + let team_name = + payload.nickname.as_ref().map(|n| format!("{} 的团队", n)).unwrap_or_else(|| { + format!( + "{} 的团队", + payload.email.split('@').next().unwrap_or("用户") + ) + }); + + // 9. 创建个人工作空间 + let workspace_name = format!( + "{} 的工作空间", + payload.nickname.as_deref().unwrap_or("用户") + ); + let workspace_uuid: Uuid = sqlx::query_scalar( + r#" + INSERT INTO workspaces (name, owner_uuid, workspace_type) + VALUES ($1, $2, 'personal') + RETURNING uuid; + "#, + ) + .bind(&workspace_name) + .bind(user_uuid) + .fetch_one(&mut *tx) + .await?; + + // 10. 创建工作空间配额(使用传入的配额配置) + sqlx::query( + r#" + INSERT INTO workspace_quotas (workspace_uuid, max_environments, max_team_members, max_proxies, max_rpa_tasks) + VALUES ($1, $2, $3, $4, $5); + "#, + ) + .bind(workspace_uuid) + .bind(quota.max_environments) + .bind(quota.max_team_members) + .bind(quota.max_proxies) + .bind(quota.max_rpa_tasks) + .execute(&mut *tx) + .await?; + + // 11. 创建个人团队(每个用户自动创建一个团队) + let team_uuid: Uuid = sqlx::query_scalar( + r#" + INSERT INTO teams (workspace_uuid, name, description, owner_uuid) + VALUES ($1, $2, $3, $4) + RETURNING uuid; + "#, + ) + .bind(workspace_uuid) + .bind(&team_name) + .bind(Some("个人团队")) + .bind(user_uuid) + .fetch_one(&mut *tx) + .await?; + + // 12. 添加用户为团队成员(owner 角色) + sqlx::query( + r#" + INSERT INTO team_members (team_uuid, workspace_uuid, user_uuid, role, status) + VALUES ($1, $2, $3, 'owner', 'active'); + "#, + ) + .bind(team_uuid) + .bind(workspace_uuid) + .bind(user_uuid) + .execute(&mut *tx) + .await?; + + // 13. 设置为用户当前团队和工作空间 + sqlx::query( + r#" + UPDATE user_infos SET current_team_uuid = $1, current_workspace_uuid = $2 WHERE user_uuid = $3; + "#, + ) + .bind(team_uuid) + .bind(workspace_uuid) + .bind(user_uuid) + .execute(&mut *tx) + .await?; + + tx.commit().await?; + + Ok(user_uuid) +} + +/// 根据 UUID 查询用户 +pub async fn fetch_user_by_uuid( + pool: &Pool, + user_uuid: Uuid, +) -> Result, Error> { + let rec = sqlx::query_as::<_, UserDto>( + r#" + SELECT uuid, id, created_at, updated_at, deleted_at + FROM users + WHERE uuid = $1 AND deleted_at IS NULL + "#, + ) + .bind(user_uuid) + .fetch_optional(pool) + .await?; + + Ok(rec) +} + +/// 查询用户详细信息 +pub async fn fetch_user_info_by_uuid( + pool: &Pool, + user_uuid: Uuid, +) -> Result, Error> { + let rec = sqlx::query_as::<_, UserInfoDto>( + r#" + SELECT id, user_uuid, nickname, email, phone, password, avatar_hash, status, + current_team_uuid, current_workspace_uuid, created_at, updated_at, deleted_at + FROM user_infos + WHERE user_uuid = $1 AND deleted_at IS NULL + "#, + ) + .bind(user_uuid) + .fetch_optional(pool) + .await?; + + Ok(rec) +} + +/// 根据邮箱查询用户详细信息 +pub async fn fetch_user_info_by_email( + pool: &Pool, + email: &str, +) -> Result, Error> { + let rec = sqlx::query_as::<_, UserInfoDto>( + r#" + SELECT id, user_uuid, nickname, email, phone, password, avatar_hash, status, + current_team_uuid, current_workspace_uuid, created_at, updated_at, deleted_at + FROM user_infos + WHERE email = $1 AND deleted_at IS NULL + "#, + ) + .bind(email) + .fetch_optional(pool) + .await?; + + Ok(rec) +} + +/// 更新用户信息 +pub async fn update_user_info( + pool: &Pool, + user_uuid: Uuid, + payload: &UpdateUserRequest, +) -> Result<(), Error> { + sqlx::query( + r#" + UPDATE user_infos + SET nickname = $1 + WHERE user_uuid = $2 AND deleted_at IS NULL + "#, + ) + .bind(&payload.nickname) + .bind(user_uuid) + .execute(pool) + .await?; + + Ok(()) +} + +/// 更新密码 +pub async fn update_password( + pool: &Pool, + user_uuid: Uuid, + password_hash: &str, +) -> Result<(), Error> { + sqlx::query( + r#" + UPDATE user_infos + SET password = $1 + WHERE user_uuid = $2 AND deleted_at IS NULL + "#, + ) + .bind(password_hash) + .bind(user_uuid) + .execute(pool) + .await?; + + Ok(()) +} + +/// 设置用户当前工作空间 +pub async fn set_user_current_workspace( + pool: &Pool, + user_uuid: Uuid, + workspace_uuid: Uuid, +) -> Result<(), Error> { + sqlx::query( + r#" + UPDATE user_infos SET current_workspace_uuid = $1 WHERE user_uuid = $2 + "#, + ) + .bind(workspace_uuid) + .bind(user_uuid) + .execute(pool) + .await?; + + Ok(()) +} diff --git a/src/models/version_types.rs b/src/models/version_types.rs new file mode 100644 index 0000000..bca0ee6 --- /dev/null +++ b/src/models/version_types.rs @@ -0,0 +1,110 @@ +use crate::dto::version_types::VersionType; +use crate::entitys::version_types::{CreateVersionTypeRequest, UpdateVersionTypeRequest}; +use sqlx::{Error, Pool, Postgres}; + +/// 插入版本类型 +pub async fn insert_version_type( + pool: &Pool, + request: &CreateVersionTypeRequest, +) -> Result { + let sql = r#" + INSERT INTO version_types (type_code, type_name, description, sort_order, is_active) + VALUES ($1, $2, $3, $4, true) + RETURNING id + "#; + + let result: (i32,) = sqlx::query_as(sql) + .bind(&request.type_code) + .bind(&request.type_name) + .bind(&request.description) + .bind(&request.sort_order.unwrap_or(0)) + .fetch_one(pool) + .await?; + + Ok(result.0) +} + +/// 根据ID查询版本类型 +pub async fn query_version_type_by_id( + pool: &Pool, + id: i32, +) -> Result, Error> { + let version_type: Option = + sqlx::query_as(r#"SELECT * FROM version_types WHERE id = $1"#) + .bind(id) + .fetch_optional(pool) + .await?; + + Ok(version_type) +} + +/// 根据类型代码查询版本类型 +pub async fn query_version_type_by_code( + pool: &Pool, + type_code: &str, +) -> Result, Error> { + let version_type: Option = + sqlx::query_as(r#"SELECT * FROM version_types WHERE type_code = $1"#) + .bind(type_code) + .fetch_optional(pool) + .await?; + + Ok(version_type) +} + +/// 查询所有激活的版本类型 +pub async fn query_active_version_types(pool: &Pool) -> Result, Error> { + let version_types: Vec = sqlx::query_as( + r#"SELECT * FROM version_types WHERE is_active = true ORDER BY sort_order ASC, id ASC"#, + ) + .fetch_all(pool) + .await?; + + Ok(version_types) +} + +/// 查询所有版本类型 +pub async fn query_all_version_types(pool: &Pool) -> Result, Error> { + let version_types: Vec = + sqlx::query_as(r#"SELECT * FROM version_types ORDER BY sort_order ASC, id ASC"#) + .fetch_all(pool) + .await?; + + Ok(version_types) +} + +/// 更新版本类型 +pub async fn update_version_type( + pool: &Pool, + id: i32, + request: &UpdateVersionTypeRequest, +) -> Result { + let sql = r#" + UPDATE version_types SET + type_name = COALESCE($1, type_name), + description = COALESCE($2, description), + sort_order = COALESCE($3, sort_order), + is_active = COALESCE($4, is_active) + WHERE id = $5 + "#; + + let row = sqlx::query(sql) + .bind(&request.type_name) + .bind(&request.description) + .bind(&request.sort_order) + .bind(&request.is_active) + .bind(id) + .execute(pool) + .await?; + + Ok(row.rows_affected() == 1) +} + +/// 删除版本类型(软删除,实际上标记为非激活) +pub async fn delete_version_type(pool: &Pool, id: i32) -> Result { + let sql = r#"UPDATE version_types SET is_active = false WHERE id = $1"#; + + let row = sqlx::query(sql).bind(id).execute(pool).await?; + + Ok(row.rows_affected() == 1) +} diff --git a/src/models/versions.rs b/src/models/versions.rs new file mode 100644 index 0000000..7e12283 --- /dev/null +++ b/src/models/versions.rs @@ -0,0 +1,392 @@ +use crate::dto::versions::Version; +use crate::entitys::versions::{CreateVersionRequest, UpdateVersionRequest}; +use chrono::{DateTime, Utc}; +use sqlx::{Error, Pool, Postgres}; + +/// 插入新版本 +pub async fn insert_version( + pool: &Pool, + request: &CreateVersionRequest, +) -> Result { + let sql = r#" + INSERT INTO versions ( + type_id, resource_name, version, name, notes, platform, url, hash, + signature, install_path, file_size, pub_date, created_at + ) VALUES ( + $1, $2, $3, $4, $5, $6, $7, $8, $9, $10, $11, $12, NOW() + ) RETURNING id + "#; + + let result: (i32,) = sqlx::query_as(sql) + .bind(request.type_id) + .bind(&request.resource_name) + .bind(&request.version) + .bind(&request.name) + .bind(&request.notes) + .bind(&request.platform) + .bind(&request.url) + .bind(&request.hash) + .bind(&request.signature) + .bind(&request.install_path) + .bind(request.file_size) + .bind(request.pub_date) + .fetch_one(pool) + .await?; + + Ok(result.0) +} + +/// 根据ID查询版本 +pub async fn query_version_by_id(pool: &Pool, id: i32) -> Result, Error> { + let version: Option = sqlx::query_as(r#"SELECT * FROM versions WHERE id = $1"#) + .bind(id) + .fetch_optional(pool) + .await?; + + Ok(version) +} + +/// 根据资源名称和版本号查询 +pub async fn query_version_by_name_and_version( + pool: &Pool, + resource_name: &str, + version: &str, +) -> Result, Error> { + let version_data: Option = sqlx::query_as( + r#"SELECT * FROM versions WHERE resource_name = $1 AND version = $2 AND deleted_at IS NULL"#, + ) + .bind(resource_name) + .bind(version) + .fetch_optional(pool) + .await?; + + Ok(version_data) +} + +/// 查询最新版本 +pub async fn query_latest_version( + pool: &Pool, + resource_name: &str, + platform: &str, +) -> Result, Error> { + let version: Option = sqlx::query_as( + r#" + SELECT * FROM versions + WHERE resource_name = $1 AND platform = $2 AND status = 'active' AND deleted_at IS NULL + ORDER BY is_latest DESC, pub_date DESC NULLS LAST, id DESC LIMIT 1 + "#, + ) + .bind(resource_name) + .bind(platform) + .fetch_optional(pool) + .await?; + + Ok(version) +} + +/// 查询版本列表(分页,简化版本) +pub async fn query_versions( + pool: &Pool, + resource_name: Option<&str>, + platform: Option<&str>, + status: Option<&str>, + page_num: i32, + page_size: i32, +) -> Result<(i64, Vec), Error> { + // 构建基础查询 + let mut count_sql = r#"SELECT COUNT(*) FROM versions WHERE deleted_at IS NULL"#.to_string(); + let mut list_sql = r#"SELECT * FROM versions WHERE deleted_at IS NULL"#.to_string(); + + if resource_name.is_some() { + count_sql.push_str(" AND resource_name = $1"); + list_sql.push_str(" AND resource_name = $1"); + } + if platform.is_some() { + let idx = if resource_name.is_some() { 2 } else { 1 }; + count_sql.push_str(&format!(" AND platform = ${}", idx)); + list_sql.push_str(&format!(" AND platform = ${}", idx)); + } + if status.is_some() { + let idx = match (resource_name.is_some(), platform.is_some()) { + (true, true) => 3, + (true, false) | (false, true) => 2, + (false, false) => 1, + }; + count_sql.push_str(&format!(" AND status = ${}", idx)); + list_sql.push_str(&format!(" AND status = ${}", idx)); + } + + list_sql.push_str(" ORDER BY created_at DESC"); + let limit_idx = match ( + resource_name.is_some(), + platform.is_some(), + status.is_some(), + ) { + (true, true, true) => 4, + (true, true, false) | (true, false, true) | (false, true, true) => 3, + (true, false, false) | (false, true, false) | (false, false, true) => 2, + (false, false, false) => 1, + }; + let offset_idx = limit_idx + 1; + list_sql.push_str(&format!(" LIMIT ${} OFFSET ${}", limit_idx, offset_idx)); + + // 获取总数 + let count_result: (i64,) = match (resource_name, platform, status) { + (Some(n), Some(p), Some(s)) => { + sqlx::query_as(&count_sql).bind(n).bind(p).bind(s).fetch_one(pool).await? + } + (Some(n), Some(p), None) => { + sqlx::query_as(&count_sql).bind(n).bind(p).fetch_one(pool).await? + } + (Some(n), None, Some(s)) => { + sqlx::query_as(&count_sql).bind(n).bind(s).fetch_one(pool).await? + } + (Some(n), None, None) => sqlx::query_as(&count_sql).bind(n).fetch_one(pool).await?, + (None, Some(p), Some(s)) => { + sqlx::query_as(&count_sql).bind(p).bind(s).fetch_one(pool).await? + } + (None, Some(p), None) => sqlx::query_as(&count_sql).bind(p).fetch_one(pool).await?, + (None, None, Some(s)) => sqlx::query_as(&count_sql).bind(s).fetch_one(pool).await?, + (None, None, None) => sqlx::query_as(&count_sql).fetch_one(pool).await?, + }; + + // 获取分页列表 + let versions: Vec = match (resource_name, platform, status) { + (Some(n), Some(p), Some(s)) => { + sqlx::query_as(&list_sql) + .bind(n) + .bind(p) + .bind(s) + .bind(page_size) + .bind((page_num - 1) * page_size) + .fetch_all(pool) + .await? + } + (Some(n), Some(p), None) => { + sqlx::query_as(&list_sql) + .bind(n) + .bind(p) + .bind(page_size) + .bind((page_num - 1) * page_size) + .fetch_all(pool) + .await? + } + (Some(n), None, Some(s)) => { + sqlx::query_as(&list_sql) + .bind(n) + .bind(s) + .bind(page_size) + .bind((page_num - 1) * page_size) + .fetch_all(pool) + .await? + } + (Some(n), None, None) => { + sqlx::query_as(&list_sql) + .bind(n) + .bind(page_size) + .bind((page_num - 1) * page_size) + .fetch_all(pool) + .await? + } + (None, Some(p), Some(s)) => { + sqlx::query_as(&list_sql) + .bind(p) + .bind(s) + .bind(page_size) + .bind((page_num - 1) * page_size) + .fetch_all(pool) + .await? + } + (None, Some(p), None) => { + sqlx::query_as(&list_sql) + .bind(p) + .bind(page_size) + .bind((page_num - 1) * page_size) + .fetch_all(pool) + .await? + } + (None, None, Some(s)) => { + sqlx::query_as(&list_sql) + .bind(s) + .bind(page_size) + .bind((page_num - 1) * page_size) + .fetch_all(pool) + .await? + } + (None, None, None) => { + sqlx::query_as(&list_sql) + .bind(page_size) + .bind((page_num - 1) * page_size) + .fetch_all(pool) + .await? + } + }; + + Ok((count_result.0, versions)) +} + +/// 更新版本 +pub async fn update_version( + pool: &Pool, + id: i32, + request: &UpdateVersionRequest, +) -> Result { + let sql = r#" + UPDATE versions SET + name = COALESCE($1, name), + notes = COALESCE($2, notes), + platform = COALESCE($3, platform), + url = COALESCE($4, url), + hash = COALESCE($5, hash), + signature = COALESCE($6, signature), + install_path = COALESCE($7, install_path), + file_size = COALESCE($8, file_size), + status = COALESCE($9, status), + is_latest = COALESCE($10, is_latest), + updated_at = NOW() + WHERE id = $11 AND deleted_at IS NULL + "#; + + let row = sqlx::query(sql) + .bind(&request.name) + .bind(&request.notes) + .bind(&request.platform) + .bind(&request.url) + .bind(&request.hash) + .bind(&request.signature) + .bind(&request.install_path) + .bind(request.file_size) + .bind(&request.status) + .bind(request.is_latest) + .bind(id) + .execute(pool) + .await?; + + Ok(row.rows_affected() == 1) +} + +/// 软删除版本 +pub async fn delete_version(pool: &Pool, id: i32) -> Result { + let sql = r#"UPDATE versions SET deleted_at = NOW() WHERE id = $1"#; + let row = sqlx::query(sql).bind(id).execute(pool).await?; + Ok(row.rows_affected() == 1) +} + +/// 设置某个资源为最新版本 +pub async fn set_as_latest_version( + pool: &Pool, + type_id: i32, + resource_name: &str, + version_id: i32, +) -> Result { + let mut tx = pool.begin().await?; + + // 先取消其他版本的最新状态 + sqlx::query( + r#" + UPDATE versions SET is_latest = false, updated_at = NOW() + WHERE type_id = $1 AND resource_name = $2 AND id != $3 AND deleted_at IS NULL + "#, + ) + .bind(type_id) + .bind(resource_name) + .bind(version_id) + .execute(&mut *tx) + .await?; + + // 设置当前版本为最新 + let row = sqlx::query( + r#"UPDATE versions SET is_latest = true, updated_at = NOW() WHERE id = $1 AND deleted_at IS NULL"#, + ) + .bind(version_id) + .execute(&mut *tx) + .await?; + + tx.commit().await?; + Ok(row.rows_affected() == 1) +} + +/// 查询所有激活版本类型对应平台的最新版本 +/// 返回 (type_code, resource_name, Version) 元组列表 +pub async fn query_all_active_latest_versions( + pool: &Pool, + platform: &str, +) -> Result, Error> { + // 自定义结构体用于接收查询结果 + #[derive(sqlx::FromRow)] + struct VersionWithTypeCode { + type_code: String, + id: i32, + type_id: i32, + resource_name: String, + version: String, + name: Option, + notes: Option, + platform: Option, + url: Option, + hash: Option, + signature: Option, + install_path: Option, + file_size: Option, + is_latest: bool, + status: String, + pub_date: Option>, + created_at: DateTime, + updated_at: Option>, + deleted_at: Option>, + } + + let results: Vec = sqlx::query_as( + r#" + SELECT DISTINCT ON (vt.type_code, v.resource_name) + vt.type_code, + v.id, v.type_id, v.resource_name, v.version, v.name, v.notes, + v.platform, v.url, v.hash, v.signature, v.install_path, + v.file_size, v.is_latest, v.status, v.pub_date, + v.created_at, v.updated_at, v.deleted_at + FROM versions v + INNER JOIN version_types vt ON v.type_id = vt.id + WHERE vt.is_active = true + AND v.platform = $1 + AND v.status = 'active' + AND v.deleted_at IS NULL + ORDER BY vt.type_code, v.resource_name, v.is_latest DESC, v.id DESC + "#, + ) + .bind(platform) + .fetch_all(pool) + .await?; + + // 转换为 (String, String, Version) 元组 + let converted: Vec<(String, String, Version)> = results + .into_iter() + .map(|r| { + ( + r.type_code.clone(), + r.resource_name.clone(), + Version { + id: r.id, + type_id: r.type_id, + resource_name: r.resource_name, + version: r.version, + name: r.name.clone(), + notes: r.notes, + platform: r.platform, + url: r.url, + hash: r.hash, + signature: r.signature, + install_path: r.install_path, + file_size: r.file_size, + is_latest: r.is_latest, + status: r.status, + pub_date: r.pub_date, + created_at: r.created_at, + updated_at: r.updated_at, + deleted_at: r.deleted_at, + }, + ) + }) + .collect(); + + Ok(converted) +} diff --git a/src/models/workspace_quotas.rs b/src/models/workspace_quotas.rs new file mode 100644 index 0000000..6104f1a --- /dev/null +++ b/src/models/workspace_quotas.rs @@ -0,0 +1,233 @@ +use sqlx::{Error, Pool, Postgres}; +use uuid::Uuid; + +use crate::dto::WorkspaceQuotaDto; + +/// 创建或更新工作空间配额 +pub async fn insert_or_update_workspace_quota( + pool: &Pool, + workspace_uuid: Uuid, + max_environments: i32, + max_team_members: i32, + max_proxies: i32, + max_rpa_tasks: i32, +) -> Result<(), Error> { + sqlx::query( + r#" + INSERT INTO workspace_quotas ( + workspace_uuid, max_environments, max_team_members, max_proxies, max_rpa_tasks + ) + VALUES ($1, $2, $3, $4, $5) + ON CONFLICT (workspace_uuid) DO UPDATE SET + max_environments = EXCLUDED.max_environments, + max_team_members = EXCLUDED.max_team_members, + max_proxies = EXCLUDED.max_proxies, + max_rpa_tasks = EXCLUDED.max_rpa_tasks, + updated_at = CURRENT_TIMESTAMP + "#, + ) + .bind(workspace_uuid) + .bind(max_environments) + .bind(max_team_members) + .bind(max_proxies) + .bind(max_rpa_tasks) + .execute(pool) + .await?; + + Ok(()) +} + +/// 查询工作空间配额 +pub async fn fetch_workspace_quota( + pool: &Pool, + workspace_uuid: Uuid, +) -> Result, Error> { + let rec = sqlx::query_as::<_, WorkspaceQuotaDto>( + r#" + SELECT workspace_uuid, max_environments, used_environments, + max_team_members, used_team_members, + max_proxies, used_proxies, + max_rpa_tasks, used_rpa_tasks, + created_at, updated_at + FROM workspace_quotas + WHERE workspace_uuid = $1 + "#, + ) + .bind(workspace_uuid) + .fetch_optional(pool) + .await?; + + Ok(rec) +} + +/// 增加环境使用数 +pub async fn increment_used_environments( + pool: &Pool, + workspace_uuid: Uuid, + amount: i32, +) -> Result<(), Error> { + sqlx::query( + r#" + UPDATE workspace_quotas + SET used_environments = used_environments + $1, + updated_at = CURRENT_TIMESTAMP + WHERE workspace_uuid = $2 + "#, + ) + .bind(amount) + .bind(workspace_uuid) + .execute(pool) + .await?; + + Ok(()) +} + +/// 减少环境使用数 +pub async fn decrement_used_environments( + pool: &Pool, + workspace_uuid: Uuid, + amount: i32, +) -> Result<(), Error> { + sqlx::query( + r#" + UPDATE workspace_quotas + SET used_environments = GREATEST(0, used_environments - $1), + updated_at = CURRENT_TIMESTAMP + WHERE workspace_uuid = $2 + "#, + ) + .bind(amount) + .bind(workspace_uuid) + .execute(pool) + .await?; + + Ok(()) +} + +/// 增加代理使用数 +pub async fn increment_used_proxies( + pool: &Pool, + workspace_uuid: Uuid, + amount: i32, +) -> Result<(), Error> { + sqlx::query( + r#" + UPDATE workspace_quotas + SET used_proxies = used_proxies + $1, + updated_at = CURRENT_TIMESTAMP + WHERE workspace_uuid = $2 + "#, + ) + .bind(amount) + .bind(workspace_uuid) + .execute(pool) + .await?; + + Ok(()) +} + +/// 减少代理使用数 +pub async fn decrement_used_proxies( + pool: &Pool, + workspace_uuid: Uuid, + amount: i32, +) -> Result<(), Error> { + sqlx::query( + r#" + UPDATE workspace_quotas + SET used_proxies = GREATEST(0, used_proxies - $1), + updated_at = CURRENT_TIMESTAMP + WHERE workspace_uuid = $2 + "#, + ) + .bind(amount) + .bind(workspace_uuid) + .execute(pool) + .await?; + + Ok(()) +} + +/// 更新团队成员使用数(统计所有团队的活跃成员) +pub async fn update_used_team_members( + pool: &Pool, + workspace_uuid: Uuid, +) -> Result<(), Error> { + sqlx::query( + r#" + UPDATE workspace_quotas wq + SET used_team_members = ( + SELECT COUNT(DISTINCT tm.user_uuid) + FROM team_members tm + INNER JOIN teams t ON tm.team_uuid = t.uuid + WHERE t.workspace_uuid = wq.workspace_uuid + AND tm.deleted_at IS NULL + AND t.deleted_at IS NULL + ), + updated_at = CURRENT_TIMESTAMP + WHERE wq.workspace_uuid = $1 + "#, + ) + .bind(workspace_uuid) + .execute(pool) + .await?; + + Ok(()) +} + +/// 检查配额是否充足 +pub async fn check_quota( + pool: &Pool, + workspace_uuid: Uuid, + quota_type: &str, +) -> Result { + let result = match quota_type { + "environments" => sqlx::query_scalar::<_, bool>( + r#" + SELECT used_environments < max_environments + FROM workspace_quotas + WHERE workspace_uuid = $1 + "#, + ) + .bind(workspace_uuid) + .fetch_optional(pool) + .await? + .unwrap_or(false), + "proxies" => sqlx::query_scalar::<_, bool>( + r#" + SELECT used_proxies < max_proxies + FROM workspace_quotas + WHERE workspace_uuid = $1 + "#, + ) + .bind(workspace_uuid) + .fetch_optional(pool) + .await? + .unwrap_or(false), + "team_members" => sqlx::query_scalar::<_, bool>( + r#" + SELECT used_team_members < max_team_members + FROM workspace_quotas + WHERE workspace_uuid = $1 + "#, + ) + .bind(workspace_uuid) + .fetch_optional(pool) + .await? + .unwrap_or(false), + "rpa_tasks" => sqlx::query_scalar::<_, bool>( + r#" + SELECT used_rpa_tasks < max_rpa_tasks + FROM workspace_quotas + WHERE workspace_uuid = $1 + "#, + ) + .bind(workspace_uuid) + .fetch_optional(pool) + .await? + .unwrap_or(false), + _ => false, + }; + + Ok(result) +} diff --git a/src/models/workspaces.rs b/src/models/workspaces.rs new file mode 100644 index 0000000..169a268 --- /dev/null +++ b/src/models/workspaces.rs @@ -0,0 +1,121 @@ +use sqlx::{Error, Pool, Postgres}; +use uuid::Uuid; + +use crate::dto::WorkspaceDto; +use crate::entitys::CreateWorkspaceRequest; + +/// 创建工作空间 +pub async fn insert_workspace( + pool: &Pool, + owner_uuid: Uuid, + payload: &CreateWorkspaceRequest, +) -> Result { + let workspace_uuid: Uuid = sqlx::query_scalar( + r#" + INSERT INTO workspaces (name, owner_uuid, workspace_type) + VALUES ($1, $2, $3) + RETURNING uuid; + "#, + ) + .bind(&payload.name) + .bind(owner_uuid) + .bind(payload.workspace_type.as_deref().unwrap_or("personal")) + .fetch_one(pool) + .await?; + + Ok(workspace_uuid) +} + +/// 根据 UUID 查询工作空间 +pub async fn fetch_workspace_by_uuid( + pool: &Pool, + workspace_uuid: Uuid, +) -> Result, Error> { + let rec = sqlx::query_as::<_, WorkspaceDto>( + r#" + SELECT uuid, name, owner_uuid, workspace_type, created_at, updated_at, deleted_at + FROM workspaces + WHERE uuid = $1 AND deleted_at IS NULL + "#, + ) + .bind(workspace_uuid) + .fetch_optional(pool) + .await?; + + Ok(rec) +} + +/// 查询用户所属的所有工作空间 +pub async fn fetch_user_workspaces( + pool: &Pool, + user_uuid: Uuid, +) -> Result, Error> { + let recs = sqlx::query_as::<_, WorkspaceDto>( + r#" + SELECT uuid, name, owner_uuid, workspace_type, created_at, updated_at, deleted_at + FROM workspaces + WHERE owner_uuid = $1 AND deleted_at IS NULL + ORDER BY created_at + "#, + ) + .bind(user_uuid) + .fetch_all(pool) + .await?; + + Ok(recs) +} + +/// 更新工作空间 +pub async fn update_workspace( + pool: &Pool, + workspace_uuid: Uuid, + name: Option<&str>, +) -> Result<(), Error> { + if let Some(name) = name { + sqlx::query( + r#" + UPDATE workspaces SET name = $1 WHERE uuid = $2 AND deleted_at IS NULL + "#, + ) + .bind(name) + .bind(workspace_uuid) + .execute(pool) + .await?; + } + + Ok(()) +} + +/// 删除工作空间(软删除) +pub async fn delete_workspace(pool: &Pool, workspace_uuid: Uuid) -> Result<(), Error> { + sqlx::query( + r#" + UPDATE workspaces SET deleted_at = CURRENT_TIMESTAMP WHERE uuid = $1 + "#, + ) + .bind(workspace_uuid) + .execute(pool) + .await?; + + Ok(()) +} + +/// 检查用户是否是工作空间所有者 +pub async fn check_workspace_owner( + pool: &Pool, + workspace_uuid: Uuid, + user_uuid: Uuid, +) -> Result { + let count: i64 = sqlx::query_scalar( + r#" + SELECT COUNT(*) FROM workspaces + WHERE uuid = $1 AND owner_uuid = $2 AND deleted_at IS NULL + "#, + ) + .bind(workspace_uuid) + .bind(user_uuid) + .fetch_one(pool) + .await?; + + Ok(count > 0) +} diff --git a/src/routes.rs b/src/routes.rs new file mode 100644 index 0000000..eb9a7e5 --- /dev/null +++ b/src/routes.rs @@ -0,0 +1,168 @@ +pub mod health; +pub mod secret; +pub mod time; +pub mod users; + +// 新增模块 +pub mod accounts; +pub mod api; +pub mod audit; +pub mod billing; +pub mod environments; +pub mod extensions; +pub mod group_permissions; +pub mod messages; +pub mod preferences; +pub mod proxies; +pub mod proxy_visibility; +pub mod referral; +pub mod rpa; +pub mod teams; +pub mod templates; +pub mod workspace_quotas; +pub mod workspaces; + +// route core +pub mod route { + use std::borrow::Cow; + + use axum::{Router, routing::MethodRouter}; + + use crate::svc_ctx::SvcCtx; + + // can support request methods enum + #[derive(Debug, Clone)] + pub enum RequestMethod { + GET, + POST, + PUT, + DELETE, + PATCH, + } + + /// route item + pub struct RouteItem { + pub path: &'static str, + pub method: RequestMethod, + pub handler: MethodRouter, + } + + /// route group + pub struct RouteGroup { + pub prefix: &'static str, + pub routes: Vec, + } + + /// meta route + pub struct MetaRoute { + pub prefix: Cow<'static, str>, + pub routes: Vec, + } + + impl MetaRoute { + pub fn new(prefix: String) -> Self { + Self { + prefix: Cow::Owned(prefix), + routes: vec![], + } + } + + pub fn add_route_group(&mut self, group: RouteGroup) -> () { + self.routes.push(group); + } + + pub fn count(&self) -> usize { + self.routes.iter().map(|group| group.routes.len()).sum() + } + + pub fn build(&self) -> Router { + let mut root_router_child = Router::new(); + + for contain in &self.routes { + let RouteGroup { prefix, routes } = contain; + + let parent_prefix = prefix.to_string(); + let mut child_router = Router::new(); + + for item in routes { + let RouteItem { + path, + method, + handler, + } = item; + + tracing::info!("{:?}+{}{}{}", method, self.prefix, parent_prefix, path,); + match method { + RequestMethod::GET => { + child_router = child_router.route(path, handler.clone()); + } + RequestMethod::POST => { + child_router = child_router.route(path, handler.clone()); + } + RequestMethod::PUT => { + child_router = child_router.route(path, handler.clone()); + } + RequestMethod::DELETE => { + child_router = child_router.route(path, handler.clone()); + } + RequestMethod::PATCH => { + child_router = child_router.route(path, handler.clone()); + } + } + } + + root_router_child = + root_router_child.merge(Router::new().nest(prefix, child_router)); + } + + Router::new().nest(self.prefix.as_ref(), root_router_child) + } + } + + impl RouteGroup { + pub fn new(prefix: &'static str) -> Self { + Self { + prefix, + routes: vec![], + } + } + + pub fn add_route_item(&mut self, item: RouteItem) { + self.routes.push(item); + } + } + + impl RouteItem { + pub fn new( + path: &'static str, + method: RequestMethod, + handler: MethodRouter, + ) -> Self { + Self { + path, + method, + handler, + } + } + + pub fn get(path: &'static str, handler: MethodRouter) -> Self { + Self::new(path, RequestMethod::GET, handler) + } + + pub fn post(path: &'static str, handler: MethodRouter) -> Self { + Self::new(path, RequestMethod::POST, handler) + } + + pub fn put(path: &'static str, handler: MethodRouter) -> Self { + Self::new(path, RequestMethod::PUT, handler) + } + + pub fn delete(path: &'static str, handler: MethodRouter) -> Self { + Self::new(path, RequestMethod::DELETE, handler) + } + + pub fn patch(path: &'static str, handler: MethodRouter) -> Self { + Self::new(path, RequestMethod::PATCH, handler) + } + } +} diff --git a/src/routes/accounts.rs b/src/routes/accounts.rs new file mode 100644 index 0000000..27b9f86 --- /dev/null +++ b/src/routes/accounts.rs @@ -0,0 +1,37 @@ +use axum::routing::post; + +use crate::handlers::{ + batch_delete_accounts_handler, batch_import_accounts_handler, create_account_handler, + delete_account_handler, get_account_handler, get_accounts_handler, update_account_handler, +}; +use crate::routes::route::{self, MetaRoute}; + +/// 注册账号相关的路由 +pub fn register_routes(meta_route: &mut MetaRoute) { + let mut account_route = route::RouteGroup::new("/accounts"); + + account_route.add_route_item(route::RouteItem::post("/list", post(get_accounts_handler))); + account_route.add_route_item(route::RouteItem::post("/detail", post(get_account_handler))); + account_route.add_route_item(route::RouteItem::post( + "/create", + post(create_account_handler), + )); + account_route.add_route_item(route::RouteItem::post( + "/update", + post(update_account_handler), + )); + account_route.add_route_item(route::RouteItem::post( + "/delete", + post(delete_account_handler), + )); + account_route.add_route_item(route::RouteItem::post( + "/batch-delete", + post(batch_delete_accounts_handler), + )); + account_route.add_route_item(route::RouteItem::post( + "/batch-import", + post(batch_import_accounts_handler), + )); + + meta_route.add_route_group(account_route); +} diff --git a/src/routes/api.rs b/src/routes/api.rs new file mode 100644 index 0000000..882ed26 --- /dev/null +++ b/src/routes/api.rs @@ -0,0 +1,27 @@ +use axum::routing::post; + +use crate::handlers::{ + create_api_key_handler, get_api_keys_handler, revoke_api_key_handler, update_api_key_handler, +}; +use crate::routes::route::{self, MetaRoute}; + +/// 注册 API 服务相关的路由 +pub fn register_routes(meta_route: &mut MetaRoute) { + let mut api_route = route::RouteGroup::new("/api-keys"); + + api_route.add_route_item(route::RouteItem::post("/list", post(get_api_keys_handler))); + api_route.add_route_item(route::RouteItem::post( + "/create", + post(create_api_key_handler), + )); + api_route.add_route_item(route::RouteItem::post( + "/update", + post(update_api_key_handler), + )); + api_route.add_route_item(route::RouteItem::post( + "/revoke", + post(revoke_api_key_handler), + )); + + meta_route.add_route_group(api_route); +} diff --git a/src/routes/audit.rs b/src/routes/audit.rs new file mode 100644 index 0000000..66f462c --- /dev/null +++ b/src/routes/audit.rs @@ -0,0 +1,31 @@ +use axum::routing::post; + +use crate::handlers::{ + export_audit_logs_handler, get_audit_log_detail_handler, get_audit_logs_handler, + get_audit_stats_handler, +}; +use crate::routes::route::{self, MetaRoute}; + +/// 注册审计日志相关的路由 +pub fn register_routes(meta_route: &mut MetaRoute) { + let mut audit_route = route::RouteGroup::new("/audit"); + + audit_route.add_route_item(route::RouteItem::post( + "/logs", + post(get_audit_logs_handler), + )); + audit_route.add_route_item(route::RouteItem::post( + "/logs/detail", + post(get_audit_log_detail_handler), + )); + audit_route.add_route_item(route::RouteItem::post( + "/logs/export", + post(export_audit_logs_handler), + )); + audit_route.add_route_item(route::RouteItem::post( + "/stats", + post(get_audit_stats_handler), + )); + + meta_route.add_route_group(audit_route); +} diff --git a/src/routes/billing.rs b/src/routes/billing.rs new file mode 100644 index 0000000..226a6e1 --- /dev/null +++ b/src/routes/billing.rs @@ -0,0 +1,108 @@ +use axum::routing::post; + +use crate::handlers::{ + cancel_subscription_handler, create_recharge_order_handler, get_account_info_handler, + get_auto_renewal_services_handler, get_available_coupons_handler, + get_current_subscription_handler, get_invoices_handler, get_order_status_handler, + get_payment_orders_handler, get_plan_detail_handler, get_plan_price_handler, + get_plans_handler, get_quota_handler, get_transactions_handler, get_user_coupons_handler, + get_wallet_handler, resume_subscription_handler, subscribe_plan_handler, + toggle_auto_renew_handler, verify_coupon_handler, +}; +use crate::routes::route::{self, MetaRoute}; + +/// 注册计费相关的路由 +pub fn register_routes(meta_route: &mut MetaRoute) { + let mut billing_route = route::RouteGroup::new("/billing"); + + // 套餐 + billing_route.add_route_item(route::RouteItem::post("/plans", post(get_plans_handler))); + billing_route.add_route_item(route::RouteItem::post( + "/plans/detail", + post(get_plan_detail_handler), + )); + billing_route.add_route_item(route::RouteItem::post( + "/plans/price", + post(get_plan_price_handler), + )); + + // 订阅 + billing_route.add_route_item(route::RouteItem::post( + "/subscription", + post(get_current_subscription_handler), + )); + billing_route.add_route_item(route::RouteItem::post( + "/subscription/subscribe", + post(subscribe_plan_handler), + )); + billing_route.add_route_item(route::RouteItem::post( + "/subscription/cancel", + post(cancel_subscription_handler), + )); + billing_route.add_route_item(route::RouteItem::post( + "/subscription/resume", + post(resume_subscription_handler), + )); + billing_route.add_route_item(route::RouteItem::post( + "/subscription/toggle-auto-renew", + post(toggle_auto_renew_handler), + )); + + // 钱包 + billing_route.add_route_item(route::RouteItem::post("/wallet", post(get_wallet_handler))); + billing_route.add_route_item(route::RouteItem::post( + "/wallet/transactions", + post(get_transactions_handler), + )); + + // 发票 + billing_route.add_route_item(route::RouteItem::post( + "/invoices", + post(get_invoices_handler), + )); + + // 配额 + billing_route.add_route_item(route::RouteItem::post("/quota", post(get_quota_handler))); + + // 优惠券 + billing_route.add_route_item(route::RouteItem::post( + "/coupon/verify", + post(verify_coupon_handler), + )); + billing_route.add_route_item(route::RouteItem::post( + "/coupons/my-coupons", + post(get_user_coupons_handler), + )); + billing_route.add_route_item(route::RouteItem::post( + "/coupons/available", + post(get_available_coupons_handler), + )); + + // 支付订单 + billing_route.add_route_item(route::RouteItem::post( + "/orders", + post(get_payment_orders_handler), + )); + billing_route.add_route_item(route::RouteItem::post( + "/orders/create-recharge", + post(create_recharge_order_handler), + )); + billing_route.add_route_item(route::RouteItem::post( + "/orders/status", + post(get_order_status_handler), + )); + + // 自动续费服务 + billing_route.add_route_item(route::RouteItem::post( + "/auto-renewal-services", + post(get_auto_renewal_services_handler), + )); + + // 账户信息 + billing_route.add_route_item(route::RouteItem::post( + "/account-info", + post(get_account_info_handler), + )); + + meta_route.add_route_group(billing_route); +} diff --git a/src/routes/environments.rs b/src/routes/environments.rs new file mode 100644 index 0000000..689ce25 --- /dev/null +++ b/src/routes/environments.rs @@ -0,0 +1,141 @@ +use axum::routing::post; + +use crate::handlers::{ + add_environment_cookie_handler, add_environment_url_handler, assign_tags_handler, + batch_assign_tags_handler, batch_create_environments_handler, + batch_delete_environments_handler, batch_move_to_group_handler, batch_remove_tags_handler, + clear_environment_cookies_handler, clear_environment_urls_handler, create_environment_handler, + create_group_handler, create_tag_handler, delete_environment_cookie_handler, + delete_environment_handler, delete_environment_url_handler, delete_group_handler, + delete_tag_handler, get_environment_cookies_handler, get_environment_handler, + get_environment_urls_handler, get_environments_handler, get_groups_handler, get_tags_handler, + move_to_group_handler, remove_tag_handler, set_environment_accounts_handler, set_proxy_handler, + update_environment_handler, update_group_handler, update_tag_handler, +}; +use crate::routes::route::{self, MetaRoute}; + +/// 注册环境相关的路由 +pub fn register_routes(meta_route: &mut MetaRoute) { + // 分组路由 + let mut group_route = route::RouteGroup::new("/groups"); + group_route.add_route_item(route::RouteItem::post("/list", post(get_groups_handler))); + group_route.add_route_item(route::RouteItem::post( + "/create", + post(create_group_handler), + )); + group_route.add_route_item(route::RouteItem::post( + "/update", + post(update_group_handler), + )); + group_route.add_route_item(route::RouteItem::post( + "/delete", + post(delete_group_handler), + )); + meta_route.add_route_group(group_route); + + // 标签路由 + let mut tag_route = route::RouteGroup::new("/tags"); + tag_route.add_route_item(route::RouteItem::post("/list", post(get_tags_handler))); + tag_route.add_route_item(route::RouteItem::post("/create", post(create_tag_handler))); + tag_route.add_route_item(route::RouteItem::post("/update", post(update_tag_handler))); + tag_route.add_route_item(route::RouteItem::post("/delete", post(delete_tag_handler))); + meta_route.add_route_group(tag_route); + + // 环境路由 + let mut env_route = route::RouteGroup::new("/environments"); + env_route.add_route_item(route::RouteItem::post( + "/list", + post(get_environments_handler), + )); + env_route.add_route_item(route::RouteItem::post( + "/detail", + post(get_environment_handler), + )); + env_route.add_route_item(route::RouteItem::post( + "/create", + post(create_environment_handler), + )); + env_route.add_route_item(route::RouteItem::post( + "/batch-create", + post(batch_create_environments_handler), + )); + env_route.add_route_item(route::RouteItem::post( + "/update", + post(update_environment_handler), + )); + env_route.add_route_item(route::RouteItem::post( + "/delete", + post(delete_environment_handler), + )); + env_route.add_route_item(route::RouteItem::post( + "/batch-delete", + post(batch_delete_environments_handler), + )); + env_route.add_route_item(route::RouteItem::post( + "/set-proxy", + post(set_proxy_handler), + )); + env_route.add_route_item(route::RouteItem::post( + "/assign-tags", + post(assign_tags_handler), + )); + env_route.add_route_item(route::RouteItem::post( + "/remove-tag", + post(remove_tag_handler), + )); + env_route.add_route_item(route::RouteItem::post( + "/move-to-group", + post(move_to_group_handler), + )); + env_route.add_route_item(route::RouteItem::post( + "/batch-move-to-group", + post(batch_move_to_group_handler), + )); + env_route.add_route_item(route::RouteItem::post( + "/set-accounts", + post(set_environment_accounts_handler), + )); + env_route.add_route_item(route::RouteItem::post( + "/batch-assign-tags", + post(batch_assign_tags_handler), + )); + env_route.add_route_item(route::RouteItem::post( + "/batch-remove-tags", + post(batch_remove_tags_handler), + )); + // URL 管理 + env_route.add_route_item(route::RouteItem::post( + "/urls/list", + post(get_environment_urls_handler), + )); + env_route.add_route_item(route::RouteItem::post( + "/urls/add", + post(add_environment_url_handler), + )); + env_route.add_route_item(route::RouteItem::post( + "/urls/delete", + post(delete_environment_url_handler), + )); + env_route.add_route_item(route::RouteItem::post( + "/urls/clear", + post(clear_environment_urls_handler), + )); + // Cookie 管理 + env_route.add_route_item(route::RouteItem::post( + "/cookies/list", + post(get_environment_cookies_handler), + )); + env_route.add_route_item(route::RouteItem::post( + "/cookies/add", + post(add_environment_cookie_handler), + )); + env_route.add_route_item(route::RouteItem::post( + "/cookies/delete", + post(delete_environment_cookie_handler), + )); + env_route.add_route_item(route::RouteItem::post( + "/cookies/clear", + post(clear_environment_cookies_handler), + )); + meta_route.add_route_group(env_route); +} diff --git a/src/routes/extensions.rs b/src/routes/extensions.rs new file mode 100644 index 0000000..3343834 --- /dev/null +++ b/src/routes/extensions.rs @@ -0,0 +1,53 @@ +use axum::routing::post; + +use crate::handlers::{ + batch_update_extensions_handler, get_extension_categories_handler, + get_extension_detail_handler, get_extensions_handler, get_installed_extensions_handler, + install_extension_handler, uninstall_extension_handler, update_extension_handler, +}; +use crate::routes::route::{self, MetaRoute}; + +/// 注册扩展管理相关的路由 +pub fn register_routes(meta_route: &mut MetaRoute) { + let mut ext_route = route::RouteGroup::new("/extensions"); + + // 扩展市场 + ext_route.add_route_item(route::RouteItem::post( + "/list", + post(get_extensions_handler), + )); + ext_route.add_route_item(route::RouteItem::post( + "/detail", + post(get_extension_detail_handler), + )); + ext_route.add_route_item(route::RouteItem::post( + "/categories", + post(get_extension_categories_handler), + )); + + // 已安装扩展 + ext_route.add_route_item(route::RouteItem::post( + "/installed", + post(get_installed_extensions_handler), + )); + + // 安装/卸载/更新 + ext_route.add_route_item(route::RouteItem::post( + "/install", + post(install_extension_handler), + )); + ext_route.add_route_item(route::RouteItem::post( + "/uninstall", + post(uninstall_extension_handler), + )); + ext_route.add_route_item(route::RouteItem::post( + "/update", + post(update_extension_handler), + )); + ext_route.add_route_item(route::RouteItem::post( + "/batch-update", + post(batch_update_extensions_handler), + )); + + meta_route.add_route_group(ext_route); +} diff --git a/src/routes/group_permissions.rs b/src/routes/group_permissions.rs new file mode 100644 index 0000000..5ffcc53 --- /dev/null +++ b/src/routes/group_permissions.rs @@ -0,0 +1,28 @@ +use axum::routing::post; + +use crate::handlers::group_permissions; +use crate::routes::route::{self, MetaRoute}; + +/// 注册分组权限相关的路由 +pub fn register_routes(meta_route: &mut MetaRoute) { + let mut group_permission_route = route::RouteGroup::new("/group-permissions"); + + group_permission_route.add_route_item(route::RouteItem::post( + "/grant", + post(group_permissions::grant_group_permission_handler), + )); + group_permission_route.add_route_item(route::RouteItem::post( + "/revoke", + post(group_permissions::revoke_group_permission_handler), + )); + group_permission_route.add_route_item(route::RouteItem::post( + "/check", + post(group_permissions::check_group_permission_handler), + )); + group_permission_route.add_route_item(route::RouteItem::post( + "/list", + post(group_permissions::list_user_group_permissions_handler), + )); + + meta_route.add_route_group(group_permission_route); +} diff --git a/src/routes/health.rs b/src/routes/health.rs new file mode 100644 index 0000000..415ca9d --- /dev/null +++ b/src/routes/health.rs @@ -0,0 +1,13 @@ +use axum::routing::get; + +use crate::handlers::health_check_handler; +use crate::routes::route::{self, MetaRoute}; + +/// 注册健康检查路由 +pub fn register_routes(meta_route: &mut MetaRoute) -> () { + let mut health_route = route::RouteGroup::new("/health"); + + health_route.add_route_item(route::RouteItem::get("/", get(health_check_handler))); + + meta_route.add_route_group(health_route); +} diff --git a/src/routes/messages.rs b/src/routes/messages.rs new file mode 100644 index 0000000..fb1a13b --- /dev/null +++ b/src/routes/messages.rs @@ -0,0 +1,45 @@ +use axum::routing::post; + +use crate::handlers::{ + batch_mark_messages_read_handler, create_message_handler, delete_message_handler, + get_user_message_stats_handler, get_user_messages_handler, handle_message_handler, + mark_message_read_handler, +}; +use crate::routes::route::{self, MetaRoute}; + +/// 注册消息相关的路由 +pub fn register_routes(meta_route: &mut MetaRoute) { + let mut message_route = route::RouteGroup::new("/messages"); + + // 消息管理 + message_route.add_route_item(route::RouteItem::post( + "/create", + post(create_message_handler), + )); + message_route.add_route_item(route::RouteItem::post( + "/list", + post(get_user_messages_handler), + )); + message_route.add_route_item(route::RouteItem::post( + "/stats", + post(get_user_message_stats_handler), + )); + message_route.add_route_item(route::RouteItem::post( + "/read", + post(mark_message_read_handler), + )); + message_route.add_route_item(route::RouteItem::post( + "/batch-read", + post(batch_mark_messages_read_handler), + )); + message_route.add_route_item(route::RouteItem::post( + "/handle", + post(handle_message_handler), + )); + message_route.add_route_item(route::RouteItem::post( + "/delete", + post(delete_message_handler), + )); + + meta_route.add_route_group(message_route); +} diff --git a/src/routes/preferences.rs b/src/routes/preferences.rs new file mode 100644 index 0000000..a813823 --- /dev/null +++ b/src/routes/preferences.rs @@ -0,0 +1,20 @@ +use axum::routing::post; + +use crate::handlers::{get_preferences_handler, update_preferences_handler}; +use crate::routes::route::{self, MetaRoute}; + +/// 注册用户偏好设置相关的路由 +pub fn register_routes(meta_route: &mut MetaRoute) { + let mut pref_route = route::RouteGroup::new("/preferences"); + + pref_route.add_route_item(route::RouteItem::post( + "/get", + post(get_preferences_handler), + )); + pref_route.add_route_item(route::RouteItem::post( + "/update", + post(update_preferences_handler), + )); + + meta_route.add_route_group(pref_route); +} diff --git a/src/routes/proxies.rs b/src/routes/proxies.rs new file mode 100644 index 0000000..5428403 --- /dev/null +++ b/src/routes/proxies.rs @@ -0,0 +1,37 @@ +use axum::routing::post; + +use crate::handlers::{ + batch_delete_proxies_handler, batch_import_proxies_handler, create_proxy_handler, + delete_proxy_handler, get_proxies_handler, get_proxy_handler, update_proxy_handler, +}; +use crate::routes::route::{self, MetaRoute}; + +/// 注册代理相关的路由 +pub fn register_routes(meta_route: &mut MetaRoute) { + let mut proxy_route = route::RouteGroup::new("/proxies"); + + proxy_route.add_route_item(route::RouteItem::post("/list", post(get_proxies_handler))); + proxy_route.add_route_item(route::RouteItem::post("/detail", post(get_proxy_handler))); + proxy_route.add_route_item(route::RouteItem::post( + "/create", + post(create_proxy_handler), + )); + proxy_route.add_route_item(route::RouteItem::post( + "/update", + post(update_proxy_handler), + )); + proxy_route.add_route_item(route::RouteItem::post( + "/delete", + post(delete_proxy_handler), + )); + proxy_route.add_route_item(route::RouteItem::post( + "/batch-delete", + post(batch_delete_proxies_handler), + )); + proxy_route.add_route_item(route::RouteItem::post( + "/batch-import", + post(batch_import_proxies_handler), + )); + + meta_route.add_route_group(proxy_route); +} diff --git a/src/routes/proxy_visibility.rs b/src/routes/proxy_visibility.rs new file mode 100644 index 0000000..7720c88 --- /dev/null +++ b/src/routes/proxy_visibility.rs @@ -0,0 +1,32 @@ +use axum::routing::post; + +use crate::handlers::proxy_visibility; +use crate::routes::route::{self, MetaRoute}; + +/// 注册代理可见性相关的路由 +pub fn register_routes(meta_route: &mut MetaRoute) { + let mut proxy_visibility_route = route::RouteGroup::new("/proxy-visibility"); + + proxy_visibility_route.add_route_item(route::RouteItem::post( + "/set", + post(proxy_visibility::set_proxy_visible_handler), + )); + proxy_visibility_route.add_route_item(route::RouteItem::post( + "/remove", + post(proxy_visibility::remove_proxy_visible_handler), + )); + proxy_visibility_route.add_route_item(route::RouteItem::post( + "/batch-set", + post(proxy_visibility::batch_set_proxy_visible_handler), + )); + proxy_visibility_route.add_route_item(route::RouteItem::post( + "/list-visible", + post(proxy_visibility::get_visible_proxies_handler), + )); + proxy_visibility_route.add_route_item(route::RouteItem::post( + "/list-teams", + post(proxy_visibility::get_proxy_visible_teams_handler), + )); + + meta_route.add_route_group(proxy_visibility_route); +} diff --git a/src/routes/referral.rs b/src/routes/referral.rs new file mode 100644 index 0000000..e16fd3d --- /dev/null +++ b/src/routes/referral.rs @@ -0,0 +1,82 @@ +use axum::routing::post; + +use crate::handlers::{ + get_redeem_options_handler, get_redeem_records_handler, get_referral_dashboard_handler, + get_referral_links_handler, get_referral_plan_summary_handler, get_referral_rewards_handler, + get_referral_stats_handler, get_referral_tiers_handler, get_referred_users_handler, + get_user_points_handler, redeem_points_handler, switch_referral_link_handler, +}; +use crate::routes::route::{self, MetaRoute}; + +/// 注册推荐计划相关的路由 +pub fn register_routes(meta_route: &mut MetaRoute) { + let mut referral_route = route::RouteGroup::new("/referral"); + + // 统计 + referral_route.add_route_item(route::RouteItem::post( + "/stats", + post(get_referral_stats_handler), + )); + + // 统计看板聚合 + referral_route.add_route_item(route::RouteItem::post( + "/dashboard", + post(get_referral_dashboard_handler), + )); + + // 套餐页推广摘要 + referral_route.add_route_item(route::RouteItem::post( + "/summary-for-plan", + post(get_referral_plan_summary_handler), + )); + + // 层级配置 + referral_route.add_route_item(route::RouteItem::post( + "/tiers", + post(get_referral_tiers_handler), + )); + + // 推广链接 + referral_route.add_route_item(route::RouteItem::post( + "/links", + post(get_referral_links_handler), + )); + referral_route.add_route_item(route::RouteItem::post( + "/links/switch", + post(switch_referral_link_handler), + )); + + // 奖励记录 + referral_route.add_route_item(route::RouteItem::post( + "/rewards", + post(get_referral_rewards_handler), + )); + + // 被邀请用户 + referral_route.add_route_item(route::RouteItem::post( + "/users", + post(get_referred_users_handler), + )); + + // 积分 + referral_route.add_route_item(route::RouteItem::post( + "/points", + post(get_user_points_handler), + )); + + // 兑换选项 + referral_route.add_route_item(route::RouteItem::post( + "/redeem/options", + post(get_redeem_options_handler), + )); + referral_route.add_route_item(route::RouteItem::post( + "/redeem", + post(redeem_points_handler), + )); + referral_route.add_route_item(route::RouteItem::post( + "/redeem/records", + post(get_redeem_records_handler), + )); + + meta_route.add_route_group(referral_route); +} diff --git a/src/routes/rpa.rs b/src/routes/rpa.rs new file mode 100644 index 0000000..a2874d2 --- /dev/null +++ b/src/routes/rpa.rs @@ -0,0 +1,67 @@ +use axum::routing::post; + +use crate::handlers::{ + batch_delete_rpa_tasks_handler, create_rpa_task_handler, delete_rpa_task_handler, + duplicate_rpa_task_handler, export_rpa_task_handler, get_rpa_run_handler, get_rpa_runs_handler, + get_rpa_task_handler, get_rpa_tasks_handler, run_rpa_task_handler, stop_rpa_task_handler, + update_rpa_task_handler, +}; +use crate::routes::route::{self, MetaRoute}; + +/// 注册 RPA 工作流相关的路由 +pub fn register_routes(meta_route: &mut MetaRoute) { + let mut rpa_route = route::RouteGroup::new("/rpa"); + + // 任务管理 + rpa_route.add_route_item(route::RouteItem::post( + "/tasks", + post(get_rpa_tasks_handler), + )); + rpa_route.add_route_item(route::RouteItem::post( + "/tasks/detail", + post(get_rpa_task_handler), + )); + rpa_route.add_route_item(route::RouteItem::post( + "/tasks/create", + post(create_rpa_task_handler), + )); + rpa_route.add_route_item(route::RouteItem::post( + "/tasks/update", + post(update_rpa_task_handler), + )); + rpa_route.add_route_item(route::RouteItem::post( + "/tasks/delete", + post(delete_rpa_task_handler), + )); + rpa_route.add_route_item(route::RouteItem::post( + "/tasks/batch-delete", + post(batch_delete_rpa_tasks_handler), + )); + + // 任务操作 + rpa_route.add_route_item(route::RouteItem::post( + "/tasks/run", + post(run_rpa_task_handler), + )); + rpa_route.add_route_item(route::RouteItem::post( + "/tasks/stop", + post(stop_rpa_task_handler), + )); + rpa_route.add_route_item(route::RouteItem::post( + "/tasks/duplicate", + post(duplicate_rpa_task_handler), + )); + rpa_route.add_route_item(route::RouteItem::post( + "/tasks/export", + post(export_rpa_task_handler), + )); + + // 执行记录 + rpa_route.add_route_item(route::RouteItem::post("/runs", post(get_rpa_runs_handler))); + rpa_route.add_route_item(route::RouteItem::post( + "/runs/detail", + post(get_rpa_run_handler), + )); + + meta_route.add_route_group(rpa_route); +} diff --git a/src/routes/secret.rs b/src/routes/secret.rs new file mode 100644 index 0000000..f2f6b0c --- /dev/null +++ b/src/routes/secret.rs @@ -0,0 +1,16 @@ +use axum::routing::get; + +use crate::handlers::get_public_key_handler; +use crate::routes::route::{self, MetaRoute}; + +/// 注册 secret 相关的路由 +pub fn register_routes(meta_route: &mut MetaRoute) -> () { + let mut secret_route = route::RouteGroup::new("/secret"); + + secret_route.add_route_item(route::RouteItem::get( + "/public/key", + get(get_public_key_handler), + )); + + meta_route.add_route_group(secret_route); +} diff --git a/src/routes/teams.rs b/src/routes/teams.rs new file mode 100644 index 0000000..01018d8 --- /dev/null +++ b/src/routes/teams.rs @@ -0,0 +1,61 @@ +use axum::routing::post; + +use crate::handlers::{ + accept_invitation_handler, cancel_invitation_handler, create_team_handler, + get_my_teams_handler, get_pending_invitations_handler, get_team_handler, + get_team_members_handler, invite_member_handler, leave_team_handler, reject_invitation_handler, + remove_member_handler, switch_team_handler, update_member_role_handler, update_team_handler, +}; +use crate::routes::route::{self, MetaRoute}; + +/// 注册团队相关的路由 +pub fn register_routes(meta_route: &mut MetaRoute) { + let mut team_route = route::RouteGroup::new("/teams"); + + // 团队管理 + team_route.add_route_item(route::RouteItem::post("/create", post(create_team_handler))); + team_route.add_route_item(route::RouteItem::post( + "/my-teams", + post(get_my_teams_handler), + )); + team_route.add_route_item(route::RouteItem::post("/switch", post(switch_team_handler))); + team_route.add_route_item(route::RouteItem::post("/detail", post(get_team_handler))); + team_route.add_route_item(route::RouteItem::post("/update", post(update_team_handler))); + team_route.add_route_item(route::RouteItem::post("/leave", post(leave_team_handler))); + + // 成员管理 + team_route.add_route_item(route::RouteItem::post( + "/members", + post(get_team_members_handler), + )); + team_route.add_route_item(route::RouteItem::post( + "/invite", + post(invite_member_handler), + )); + team_route.add_route_item(route::RouteItem::post( + "/invitations", + post(get_pending_invitations_handler), + )); + team_route.add_route_item(route::RouteItem::post( + "/invitation/cancel", + post(cancel_invitation_handler), + )); + team_route.add_route_item(route::RouteItem::post( + "/invitation/accept", + post(accept_invitation_handler), + )); + team_route.add_route_item(route::RouteItem::post( + "/invitation/reject", + post(reject_invitation_handler), + )); + team_route.add_route_item(route::RouteItem::post( + "/member/role", + post(update_member_role_handler), + )); + team_route.add_route_item(route::RouteItem::post( + "/member/remove", + post(remove_member_handler), + )); + + meta_route.add_route_group(team_route); +} diff --git a/src/routes/templates.rs b/src/routes/templates.rs new file mode 100644 index 0000000..36ad572 --- /dev/null +++ b/src/routes/templates.rs @@ -0,0 +1,40 @@ +use axum::routing::post; + +use crate::handlers::{ + apply_template_handler, create_from_template_handler, create_template_handler, + delete_template_handler, get_template_handler, get_templates_handler, update_template_handler, +}; +use crate::routes::route::{self, MetaRoute}; + +/// 注册模板相关的路由 +pub fn register_routes(meta_route: &mut MetaRoute) { + let mut template_route = route::RouteGroup::new("/templates"); + + template_route.add_route_item(route::RouteItem::post("/list", post(get_templates_handler))); + template_route.add_route_item(route::RouteItem::post( + "/detail", + post(get_template_handler), + )); + template_route.add_route_item(route::RouteItem::post( + "/create", + post(create_template_handler), + )); + template_route.add_route_item(route::RouteItem::post( + "/update", + post(update_template_handler), + )); + template_route.add_route_item(route::RouteItem::post( + "/delete", + post(delete_template_handler), + )); + template_route.add_route_item(route::RouteItem::post( + "/apply", + post(apply_template_handler), + )); + template_route.add_route_item(route::RouteItem::post( + "/create-from", + post(create_from_template_handler), + )); + + meta_route.add_route_group(template_route); +} diff --git a/src/routes/time.rs b/src/routes/time.rs new file mode 100644 index 0000000..6045d39 --- /dev/null +++ b/src/routes/time.rs @@ -0,0 +1,12 @@ +use axum::routing::get; + +use crate::handlers::now_handle; +use crate::routes::route::{self, MetaRoute}; + +pub fn register_routes(meta_route: &mut MetaRoute) -> () { + let mut time_route = route::RouteGroup::new("/time"); + + time_route.add_route_item(route::RouteItem::get("/now", get(now_handle))); + + meta_route.add_route_group(time_route); +} diff --git a/src/routes/users.rs b/src/routes/users.rs new file mode 100644 index 0000000..5462d17 --- /dev/null +++ b/src/routes/users.rs @@ -0,0 +1,50 @@ +use axum::routing::post; + +use crate::handlers::{ + get_current_user_handler, login_handler, refresh_token_handler, register_handler, + reset_password_handler, send_code_handler, update_password_handler, update_user_handler, +}; +use crate::routes::route::{self, MetaRoute}; + +/// 注册用户相关的路由 +pub fn register_routes(meta_route: &mut MetaRoute) -> () { + let mut user_route = route::RouteGroup::new("/users"); + + user_route.add_route_item(route::RouteItem::post("/register", post(register_handler))); + + user_route.add_route_item(route::RouteItem::post("/login", post(login_handler))); + + user_route.add_route_item(route::RouteItem::post( + "/refresh-credentials", + post(refresh_token_handler), + )); + + user_route.add_route_item(route::RouteItem::post( + "/me", + post(get_current_user_handler), + )); + + user_route.add_route_item(route::RouteItem::post("/update", post(update_user_handler))); + + user_route.add_route_item(route::RouteItem::post( + "/password", + post(update_password_handler), + )); + + user_route.add_route_item(route::RouteItem::post( + "/reset-password", + post(reset_password_handler), + )); + + user_route.add_route_item(route::RouteItem::post( + "/register-send-code", + post(send_code_handler), + )); + + user_route.add_route_item(route::RouteItem::post( + "/reset-password-send-code", + post(send_code_handler), + )); + + meta_route.add_route_group(user_route); +} diff --git a/src/routes/workspace_quotas.rs b/src/routes/workspace_quotas.rs new file mode 100644 index 0000000..fef9771 --- /dev/null +++ b/src/routes/workspace_quotas.rs @@ -0,0 +1,20 @@ +use axum::routing::post; + +use crate::handlers::workspace_quotas; +use crate::routes::route::{self, MetaRoute}; + +/// 注册工作空间配额相关的路由 +pub fn register_routes(meta_route: &mut MetaRoute) { + let mut quota_route = route::RouteGroup::new("/workspace-quotas"); + + quota_route.add_route_item(route::RouteItem::post( + "/get", + post(workspace_quotas::get_workspace_quota_handler), + )); + quota_route.add_route_item(route::RouteItem::post( + "/update", + post(workspace_quotas::update_quota_usage_handler), + )); + + meta_route.add_route_group(quota_route); +} diff --git a/src/routes/workspaces.rs b/src/routes/workspaces.rs new file mode 100644 index 0000000..2b57dd2 --- /dev/null +++ b/src/routes/workspaces.rs @@ -0,0 +1,36 @@ +use axum::routing::post; + +use crate::handlers::workspaces; +use crate::routes::route::{self, MetaRoute}; + +/// 注册工作空间相关的路由 +pub fn register_routes(meta_route: &mut MetaRoute) { + let mut workspace_route = route::RouteGroup::new("/workspaces"); + + workspace_route.add_route_item(route::RouteItem::post( + "/create", + post(workspaces::create_workspace_handler), + )); + workspace_route.add_route_item(route::RouteItem::post( + "/list", + post(workspaces::get_my_workspaces_handler), + )); + workspace_route.add_route_item(route::RouteItem::post( + "/get", + post(workspaces::get_workspace_handler), + )); + workspace_route.add_route_item(route::RouteItem::post( + "/update", + post(workspaces::update_workspace_handler), + )); + workspace_route.add_route_item(route::RouteItem::post( + "/delete", + post(workspaces::delete_workspace_handler), + )); + workspace_route.add_route_item(route::RouteItem::post( + "/switch", + post(workspaces::switch_workspace_handler), + )); + + meta_route.add_route_group(workspace_route); +} diff --git a/src/services.rs b/src/services.rs new file mode 100644 index 0000000..0900398 --- /dev/null +++ b/src/services.rs @@ -0,0 +1,68 @@ +mod gray_releases; +mod gray_resources; +mod machine_gray_allocations; +mod machine_users; +mod maintenance; +mod strategy_types; +mod time; +pub mod users; +mod version_types; +mod versions; + +// 新增模块 +pub mod accounts; +pub mod api; +pub mod audit; +pub mod billing; +pub mod coupons; +pub mod environments; +pub mod extensions; +pub mod group_permissions; +pub mod groups; +pub mod messages; +pub mod orders; +pub mod plans; +pub mod preferences; +pub mod proxies; +pub mod proxy_visibility; +pub mod referral; +pub mod rpa; +pub mod subscriptions; +pub mod tags; +pub mod teams; +pub mod templates; +pub mod wallet; +pub mod workspace_quotas; +pub mod workspaces; + +pub use gray_releases::*; +pub use gray_resources::*; +pub use machine_gray_allocations::*; +pub use machine_users::*; +pub use maintenance::*; +pub use strategy_types::*; +pub use time::*; +pub use users::*; +pub use version_types::*; +pub use versions::*; + +// 新增导出 +pub use accounts::*; +pub use audit::*; +pub use billing::*; +pub use coupons::*; +pub use environments::*; +pub use group_permissions::*; +pub use groups::*; +pub use messages::*; +pub use orders::*; +pub use plans::*; +pub use proxies::*; +pub use proxy_visibility::*; +pub use subscriptions::*; +pub use tags::*; +pub use teams::*; +pub use templates::*; +pub use wallet::*; +pub use workspace_quotas::*; +pub use workspaces::*; diff --git a/src/services/accounts.rs b/src/services/accounts.rs new file mode 100644 index 0000000..06215ad --- /dev/null +++ b/src/services/accounts.rs @@ -0,0 +1,223 @@ +use uuid::Uuid; + +use crate::dto::PlatformAccountDto; +use crate::entitys::{ + BatchImportAccountsRequest, CreateAccountRequest, ListAccountsRequest, UpdateAccountRequest, +}; +use crate::models; +use crate::svc_ctx::SvcCtx; +use crate::utils::encryption::{encrypt_password, get_encryption_key}; + +/// 创建账号 +pub async fn create_account_service( + svc_ctx: &SvcCtx, + user_uuid: Uuid, + team_uuid: Option, + payload: &CreateAccountRequest, +) -> Result { + // 加密密码 + let password_encrypted = if let Some(password) = &payload.password { + let key = get_encryption_key(); + Some(encrypt_password(password, &key)?) + } else { + None + }; + + models::insert_platform_account( + &svc_ctx.db, + user_uuid, + team_uuid, + &payload.platform_url, + payload.platform_name.as_deref(), + &payload.account, + password_encrypted.as_deref(), + payload.remark.as_deref(), + ) + .await + .map_err(|e| e.to_string()) +} + +/// 获取账号列表 +pub async fn get_accounts_service( + svc_ctx: &SvcCtx, + user_uuid: Uuid, + team_uuid: Option, + payload: &ListAccountsRequest, +) -> Result<(Vec, i64), String> { + let offset = (payload.pagination.page - 1) * payload.pagination.page_size; + + let platform_name = payload.filters.as_ref().and_then(|f| f.platform_name.as_deref()); + let status = payload.filters.as_ref().and_then(|f| f.status.as_deref()); + + let accounts = models::fetch_platform_accounts( + &svc_ctx.db, + team_uuid, + user_uuid, + platform_name, + status, + offset, + payload.pagination.page_size, + ) + .await + .map_err(|e| e.to_string())?; + + let total = models::fetch_platform_accounts_count( + &svc_ctx.db, + team_uuid, + user_uuid, + platform_name, + status, + ) + .await + .map_err(|e| e.to_string())?; + + Ok((accounts, total)) +} + +/// 获取账号详情 +pub async fn get_account_service( + svc_ctx: &SvcCtx, + account_uuid: Uuid, +) -> Result { + models::fetch_platform_account_by_uuid(&svc_ctx.db, account_uuid) + .await + .map_err(|e| e.to_string())? + .ok_or_else(|| "账号不存在".to_string()) +} + +/// 更新账号 +pub async fn update_account_service( + svc_ctx: &SvcCtx, + payload: &UpdateAccountRequest, +) -> Result<(), String> { + // 加密密码 + let password_encrypted = if let Some(password) = &payload.password { + let key = get_encryption_key(); + Some(encrypt_password(password, &key)?) + } else { + None + }; + + models::update_platform_account( + &svc_ctx.db, + payload.uuid, + payload.platform_url.as_deref(), + payload.platform_name.as_deref(), + payload.account.as_deref(), + password_encrypted.as_deref(), + payload.remark.as_deref(), + payload.status.as_deref(), + ) + .await + .map_err(|e| e.to_string()) +} + +/// 删除账号 +pub async fn delete_account_service(svc_ctx: &SvcCtx, account_uuid: Uuid) -> Result<(), String> { + models::delete_platform_account(&svc_ctx.db, account_uuid) + .await + .map_err(|e| e.to_string()) +} + +/// 批量删除账号 +pub async fn batch_delete_accounts_service( + svc_ctx: &SvcCtx, + account_uuids: &[Uuid], +) -> Result { + models::batch_delete_platform_accounts(&svc_ctx.db, account_uuids) + .await + .map_err(|e| e.to_string()) +} + +/// 获取环境关联的账号 +pub async fn get_environment_accounts_service( + svc_ctx: &SvcCtx, + env_uuid: Uuid, +) -> Result, String> { + models::fetch_environment_accounts(&svc_ctx.db, env_uuid) + .await + .map_err(|e| e.to_string()) +} + +/// 设置环境关联的账号 +pub async fn set_environment_accounts_service( + svc_ctx: &SvcCtx, + env_uuid: Uuid, + account_uuids: &[Uuid], +) -> Result<(), String> { + // 清空现有关联 + models::accounts::clear_environment_accounts(&svc_ctx.db, env_uuid) + .await + .map_err(|e| e.to_string())?; + + // 添加新关联 + for (idx, account_uuid) in account_uuids.iter().enumerate() { + models::accounts::insert_environment_account( + &svc_ctx.db, + env_uuid, + *account_uuid, + idx as i32, + ) + .await + .map_err(|e| e.to_string())?; + } + + Ok(()) +} + +/// 批量导入账号 +/// +/// 接收客户端已解析好的账号列表,直接保存到数据库 +pub async fn batch_import_accounts_service( + svc_ctx: &SvcCtx, + user_uuid: Uuid, + team_uuid: Option, + payload: &BatchImportAccountsRequest, +) -> Result { + let mut success_count = 0; + let mut failed_count = 0; + let mut errors: Vec = vec![]; + let key = get_encryption_key(); + + for (index, account) in payload.accounts.iter().enumerate() { + // 加密密码 + let password_encrypted = if let Some(pwd) = &account.password { + match encrypt_password(pwd, &key) { + Ok(encrypted) => Some(encrypted), + Err(e) => { + failed_count += 1; + errors.push(format!("第 {} 项密码加密失败: {}", index + 1, e)); + continue; + } + } + } else { + None + }; + + let result = models::insert_platform_account( + &svc_ctx.db, + user_uuid, + team_uuid, + &account.platform_url, + account.platform_name.as_deref(), + &account.account, + password_encrypted.as_deref(), + account.remark.as_deref(), + ) + .await; + + match result { + Ok(_) => success_count += 1, + Err(e) => { + failed_count += 1; + errors.push(format!("第 {} 项: {}", index + 1, e)); + } + } + } + + Ok(crate::entitys::BatchImportResponse { + success_count, + failed_count, + errors, + }) +} diff --git a/src/services/api.rs b/src/services/api.rs new file mode 100644 index 0000000..fcbc59c --- /dev/null +++ b/src/services/api.rs @@ -0,0 +1,171 @@ +use chrono::Utc; +use sha2::{Digest, Sha256}; +use uuid::Uuid; + +use crate::dto::ApiKeyDto; +use crate::entitys::api_service::{ + ApiKeyItem, CreateApiKeyRequest, ListApiKeysRequest, UpdateApiKeyRequest, +}; +use crate::models; +use crate::svc_ctx::SvcCtx; + +/// 生成随机 API 密钥 +fn generate_api_key() -> String { + use rand::Rng; + let mut rng = rand::rng(); + let bytes: Vec = (0..32).map(|_| rng.random()).collect(); + hex::encode(bytes) +} + +/// 计算密钥哈希 +fn hash_api_key(key: &str) -> String { + let mut hasher = Sha256::new(); + hasher.update(key.as_bytes()); + hex::encode(hasher.finalize()) +} + +/// 获取密钥前缀 +fn get_key_prefix(key: &str) -> String { + format!("sp_{}", &key[..8]) +} + +/// 获取 API 密钥列表 +pub async fn get_api_keys_service( + svc_ctx: &SvcCtx, + user_uuid: Uuid, + payload: &ListApiKeysRequest, +) -> Result<(Vec, i64), String> { + let offset = (payload.pagination.page - 1) * payload.pagination.page_size; + + let keys = models::api::fetch_api_keys( + &svc_ctx.db, + user_uuid, + payload.status.as_deref(), + offset, + payload.pagination.page_size, + ) + .await + .map_err(|e| e.to_string())?; + + let total = + models::api::fetch_api_keys_count(&svc_ctx.db, user_uuid, payload.status.as_deref()) + .await + .map_err(|e| e.to_string())?; + + let items = keys.into_iter().map(ApiKeyItem::from).collect(); + + Ok((items, total)) +} + +/// 创建 API 密钥 +pub async fn create_api_key_service( + svc_ctx: &SvcCtx, + user_uuid: Uuid, + payload: &CreateApiKeyRequest, +) -> Result<(Uuid, String, String), String> { + // 生成密钥 + let api_key = generate_api_key(); + let key_hash = hash_api_key(&api_key); + let key_prefix = get_key_prefix(&api_key); + + // 处理权限 + let permissions = serde_json::json!(payload.permissions); + + // 处理 IP 白名单 + let ip_whitelist = payload.ip_whitelist.as_ref().map(|ips| serde_json::json!(ips)); + + let uuid = models::api::insert_api_key( + &svc_ctx.db, + user_uuid, + &payload.name, + &key_hash, + &key_prefix, + &permissions, + payload.rate_limit, + payload.daily_limit, + ip_whitelist.as_ref(), + payload.expires_at, + ) + .await + .map_err(|e| e.to_string())?; + + Ok((uuid, api_key, key_prefix)) +} + +/// 更新 API 密钥 +pub async fn update_api_key_service( + svc_ctx: &SvcCtx, + user_uuid: Uuid, + payload: &UpdateApiKeyRequest, +) -> Result<(), String> { + // 验证密钥所有权 + let key = models::api::fetch_api_key_by_uuid(&svc_ctx.db, payload.uuid) + .await + .map_err(|e| e.to_string())? + .ok_or_else(|| "API 密钥不存在".to_string())?; + + if key.user_uuid != user_uuid { + return Err("无权操作此密钥".to_string()); + } + + let permissions = payload.permissions.as_ref().map(|p| serde_json::json!(p)); + let ip_whitelist = payload.ip_whitelist.as_ref().map(|ips| serde_json::json!(ips)); + + models::api::update_api_key( + &svc_ctx.db, + payload.uuid, + payload.name.as_deref(), + permissions.as_ref(), + payload.rate_limit, + payload.daily_limit, + ip_whitelist.as_ref(), + ) + .await + .map_err(|e| e.to_string()) +} + +/// 撤销 API 密钥 +pub async fn revoke_api_key_service( + svc_ctx: &SvcCtx, + user_uuid: Uuid, + key_uuid: Uuid, +) -> Result<(), String> { + // 验证密钥所有权 + let key = models::api::fetch_api_key_by_uuid(&svc_ctx.db, key_uuid) + .await + .map_err(|e| e.to_string())? + .ok_or_else(|| "API 密钥不存在".to_string())?; + + if key.user_uuid != user_uuid { + return Err("无权操作此密钥".to_string()); + } + + models::api::revoke_api_key(&svc_ctx.db, key_uuid) + .await + .map_err(|e| e.to_string()) +} + +/// 验证 API 密钥 +pub async fn validate_api_key_service( + svc_ctx: &SvcCtx, + api_key: &str, +) -> Result { + let key_hash = hash_api_key(api_key); + + let key = models::api::fetch_api_key_by_hash(&svc_ctx.db, &key_hash) + .await + .map_err(|e| e.to_string())? + .ok_or_else(|| "无效的 API 密钥".to_string())?; + + // 检查是否过期 + if let Some(expires_at) = key.expires_at { + if expires_at < Utc::now() { + return Err("API 密钥已过期".to_string()); + } + } + + // 更新使用统计 + let _ = models::api::update_api_key_usage(&svc_ctx.db, key.uuid).await; + + Ok(key) +} diff --git a/src/services/audit.rs b/src/services/audit.rs new file mode 100644 index 0000000..c096f89 --- /dev/null +++ b/src/services/audit.rs @@ -0,0 +1,341 @@ +use chrono::Datelike; +use uuid::Uuid; + +use crate::dto::AuditLogDto; +use crate::entitys::{ + ActionCount, AuditStatsResponse, ExportAuditLogsRequest, ListAuditLogsRequest, TargetTypeCount, +}; +use crate::models; +use crate::state::RequestContext; +use crate::svc_ctx::SvcCtx; + +/// 审计日志宏 - 简化审计日志记录 +/// +/// # 用法 +/// ```rust +/// // 基础用法: action, target_type, detail +/// audit_log!(svc_ctx, ctx, "login", "user", "用户登录"); +/// +/// // 带目标 UUID +/// audit_log!(svc_ctx, ctx, "delete", "environment", env_uuid, "删除环境"); +/// +/// // 带目标 UUID 和名称 +/// audit_log!(svc_ctx, ctx, "delete", "environment", env_uuid, "生产环境", "删除环境"); +/// ``` +#[macro_export] +macro_rules! audit_log { + // 基础: action, target_type, detail + ($svc_ctx:expr, $ctx:expr, $action:expr, $target_type:expr, $detail:expr) => { + $crate::services::audit::log_audit( + $svc_ctx, + $ctx, + $action, + $target_type, + None, + None, + $detail, + ) + }; + // 带目标 UUID: action, target_type, target_uuid, detail + ($svc_ctx:expr, $ctx:expr, $action:expr, $target_type:expr, $target_uuid:expr, $detail:expr) => { + $crate::services::audit::log_audit( + $svc_ctx, + $ctx, + $action, + $target_type, + Some($target_uuid), + None, + $detail, + ) + }; + // 完整: action, target_type, target_uuid, target_name, detail + ($svc_ctx:expr, $ctx:expr, $action:expr, $target_type:expr, $target_uuid:expr, $target_name:expr, $detail:expr) => { + $crate::services::audit::log_audit( + $svc_ctx, + $ctx, + $action, + $target_type, + Some($target_uuid), + Some($target_name), + $detail, + ) + }; +} + +/// 记录审计日志(宏的内部实现) +/// +/// 自动从 RequestContext 中提取 user_uuid、team_uuid、ip_address +pub async fn log_audit( + svc_ctx: &SvcCtx, + ctx: &RequestContext, + action: &str, + target_type: &str, + target_uuid: Option, + target_name: Option<&str>, + detail: &str, +) { + // 从 ctx 中提取数据 + let user_uuid = match ctx.user_uuid() { + Some(uuid) => uuid, + None => { + tracing::warn!("audit_log: user_uuid is None, skipping audit log"); + return; + } + }; + + let team_uuid = ctx.current_team_uuid; + let ip_address = ctx.ip(); + + // 异步记录,忽略错误(审计失败不应影响业务) + if let Err(e) = log_action_service( + svc_ctx, + user_uuid, + team_uuid, + action, + target_type, + target_uuid, + target_name, + Some(detail), + None, // changes + ip_address, + None, // user_agent + None, // request_id + ) + .await + { + tracing::error!("audit_log failed: {}", e); + } +} + +/// 记录审计日志(允许无用户,用于登录/注册等场景) +pub async fn log_audit_anonymous( + svc_ctx: &SvcCtx, + ctx: &RequestContext, + user_uuid: Uuid, + action: &str, + target_type: &str, + detail: &str, +) { + let ip_address = ctx.ip(); + + if let Err(e) = log_action_service( + svc_ctx, + user_uuid, + None, + action, + target_type, + Some(user_uuid), + None, + Some(detail), + None, + ip_address, + None, + None, + ) + .await + { + tracing::error!("audit_log_anonymous failed: {}", e); + } +} + +/// 记录审计日志 +pub async fn log_action_service( + svc_ctx: &SvcCtx, + user_uuid: Uuid, + team_uuid: Option, + action: &str, + target_type: &str, + target_uuid: Option, + target_name: Option<&str>, + details: Option<&str>, + changes: Option<&serde_json::Value>, + ip_address: Option<&str>, + user_agent: Option<&str>, + request_id: Option<&str>, +) -> Result { + models::insert_audit_log( + &svc_ctx.db, + user_uuid, + team_uuid, + action, + target_type, + target_uuid, + target_name, + details, + changes, + ip_address, + user_agent, + request_id, + ) + .await + .map_err(|e| e.to_string()) +} + +/// 获取审计日志列表 +pub async fn get_audit_logs_service( + svc_ctx: &SvcCtx, + current_user_uuid: Uuid, + team_uuid: Option, + payload: &ListAuditLogsRequest, +) -> Result<(Vec, i64), String> { + let offset = (payload.pagination.page - 1) * payload.pagination.page_size; + + let user_uuid_filter = payload.filters.as_ref().and_then(|f| f.user_uuid); + let action = payload.filters.as_ref().and_then(|f| f.action.as_deref()); + let target_type = payload.filters.as_ref().and_then(|f| f.target_type.as_deref()); + + let logs = models::fetch_audit_logs( + &svc_ctx.db, + current_user_uuid, + team_uuid, + user_uuid_filter, + action, + target_type, + offset, + payload.pagination.page_size, + ) + .await + .map_err(|e| e.to_string())?; + + let total = models::fetch_audit_logs_count( + &svc_ctx.db, + current_user_uuid, + team_uuid, + user_uuid_filter, + action, + target_type, + ) + .await + .map_err(|e| e.to_string())?; + + Ok((logs, total)) +} + +/// 获取审计日志详情 +pub async fn get_audit_log_service( + svc_ctx: &SvcCtx, + log_uuid: Uuid, +) -> Result { + models::fetch_audit_log_by_uuid(&svc_ctx.db, log_uuid) + .await + .map_err(|e| e.to_string())? + .ok_or_else(|| "审计日志不存在".to_string()) +} + +/// 获取审计统计 +pub async fn get_audit_stats_service( + svc_ctx: &SvcCtx, + current_user_uuid: Uuid, + team_uuid: Option, +) -> Result { + // 总数 + let total_logs = + models::fetch_audit_logs_count(&svc_ctx.db, current_user_uuid, team_uuid, None, None, None) + .await + .map_err(|e| e.to_string())?; + + // 今日数量 + let today = chrono::Utc::now().date_naive(); + let logs_today = models::fetch_audit_logs_count_by_date(&svc_ctx.db, team_uuid, today) + .await + .map_err(|e| e.to_string())?; + + // 本周数量 + let week_start = today - chrono::Duration::days(today.weekday().num_days_from_monday() as i64); + let logs_this_week = + models::fetch_audit_logs_count_since_date(&svc_ctx.db, team_uuid, week_start) + .await + .map_err(|e| e.to_string())?; + + // 本月数量 + let month_start = chrono::NaiveDate::from_ymd_opt(today.year(), today.month(), 1).unwrap(); + let logs_this_month = + models::fetch_audit_logs_count_since_date(&svc_ctx.db, team_uuid, month_start) + .await + .map_err(|e| e.to_string())?; + + // 热门操作(Top 5) + let top_actions_raw = models::fetch_top_actions(&svc_ctx.db, team_uuid, 5) + .await + .map_err(|e| e.to_string())?; + + // 热门目标类型(Top 5) + let top_target_types_raw = models::fetch_top_target_types(&svc_ctx.db, team_uuid, 5) + .await + .map_err(|e| e.to_string())?; + + Ok(AuditStatsResponse { + total_logs, + logs_today, + logs_this_week, + logs_this_month, + top_actions: top_actions_raw + .into_iter() + .map(|(action, count)| ActionCount { action, count }) + .collect(), + top_target_types: top_target_types_raw + .into_iter() + .map(|(target_type, count)| TargetTypeCount { target_type, count }) + .collect(), + }) +} + +/// 导出审计日志 +pub async fn export_audit_logs_service( + svc_ctx: &SvcCtx, + current_user_uuid: Uuid, + team_uuid: Option, + payload: &ExportAuditLogsRequest, +) -> Result<(String, String, String), String> { + // 构建查询请求 + let list_request = ListAuditLogsRequest { + pagination: crate::entitys::Pagination { + page: 1, + page_size: payload.max_records.unwrap_or(1000) as i64, + sort_by: None, + sort_order: None, + }, + filters: payload.filters.clone(), + }; + + let (logs, _total) = + get_audit_logs_service(svc_ctx, current_user_uuid, team_uuid, &list_request).await?; + + // 根据格式生成导出内容 + let content = match payload.format.as_str() { + "csv" => export_to_csv(&logs), + "json" => serde_json::to_string_pretty(&logs).unwrap_or_default(), + _ => return Err("不支持的导出格式".to_string()), + }; + + let filename = format!( + "audit_logs_{}.{}", + chrono::Utc::now().format("%Y%m%d%H%M%S"), + payload.format + ); + + let mime_type = match payload.format.as_str() { + "csv" => "text/csv".to_string(), + "json" => "application/json".to_string(), + _ => "text/plain".to_string(), + }; + + Ok((content, filename, mime_type)) +} + +fn export_to_csv(logs: &[AuditLogDto]) -> String { + let mut csv = String::from("时间,用户UUID,操作,目标类型,目标名称,详情,IP地址\n"); + for log in logs { + csv.push_str(&format!( + "{},{},{},{},{},{},{}\n", + log.created_at.format("%Y-%m-%d %H:%M:%S"), + log.user_uuid, + log.action, + log.target_type, + log.target_name.as_deref().unwrap_or(""), + log.details.as_deref().unwrap_or("").replace(',', ";"), + log.ip_address.as_deref().unwrap_or(""), + )); + } + csv +} diff --git a/src/services/billing.rs b/src/services/billing.rs new file mode 100644 index 0000000..cccf4cb --- /dev/null +++ b/src/services/billing.rs @@ -0,0 +1,122 @@ +use rust_decimal::Decimal; +use uuid::Uuid; + +use crate::dto::{AutoRenewalServiceDto, InvoiceDto, UserQuotaDto}; +use crate::entitys::{AccountInfoResponse, ListInvoicesRequest}; +use crate::models; +use crate::svc_ctx::SvcCtx; + +// ============ Invoices ============ + +/// 获取发票列表 +pub async fn get_invoices_service( + svc_ctx: &SvcCtx, + user_uuid: Uuid, + payload: &ListInvoicesRequest, +) -> Result<(Vec, i64), String> { + let offset = (payload.pagination.page - 1) * payload.pagination.page_size; + + let invoices = models::billing::fetch_invoices( + &svc_ctx.db, + user_uuid, + payload.status.as_deref(), + offset, + payload.pagination.page_size, + ) + .await + .map_err(|e| e.to_string())?; + + let total = + models::billing::fetch_invoices_count(&svc_ctx.db, user_uuid, payload.status.as_deref()) + .await + .map_err(|e| e.to_string())?; + + Ok((invoices, total)) +} + +// ============ Quotas ============ + +/// 获取用户配额 +pub async fn get_quota_service(svc_ctx: &SvcCtx, user_uuid: Uuid) -> Result { + models::billing::fetch_user_quota(&svc_ctx.db, user_uuid) + .await + .map_err(|e| e.to_string())? + .ok_or_else(|| "用户配额不存在".to_string()) +} + +// ============ Auto Renewal Services ============ + +/// 获取自动续费服务列表 +pub async fn get_auto_renewal_services_service( + svc_ctx: &SvcCtx, + user_uuid: Uuid, +) -> Result, String> { + models::billing::fetch_auto_renewal_services(&svc_ctx.db, user_uuid) + .await + .map_err(|e| e.to_string()) +} + +/// 获取账户信息(聚合钱包、配额、订阅、用户信息) +pub async fn get_account_info_service( + svc_ctx: &SvcCtx, + workspace_uuid: Uuid, + user_uuid: Uuid, +) -> Result { + // 1. 获取用户信息(邮箱等) + let user_info = models::user::fetch_user_info_by_uuid(&svc_ctx.db, user_uuid) + .await + .map_err(|e| e.to_string())? + .ok_or_else(|| "用户不存在".to_string())?; + + // 2. 获取钱包信息 + let wallet = models::billing::fetch_user_wallet(&svc_ctx.db, user_uuid) + .await + .map_err(|e| e.to_string())?; + + // 如果钱包不存在,创建一个 + let wallet = if wallet.is_none() { + models::billing::insert_user_wallet(&svc_ctx.db, user_uuid, "CNY") + .await + .map_err(|e| e.to_string())?; + + models::billing::fetch_user_wallet(&svc_ctx.db, user_uuid) + .await + .map_err(|e| e.to_string())? + .ok_or_else(|| "创建钱包失败".to_string())? + } else { + wallet.ok_or_else(|| "钱包不存在".to_string())? + }; + + // 3. 获取工作空间配额 + let quota = models::fetch_workspace_quota(&svc_ctx.db, workspace_uuid) + .await + .map_err(|e| e.to_string())? + .ok_or_else(|| "工作空间配额不存在".to_string())?; + + // 4. 获取工作空间订阅 + let subscription = models::billing::fetch_workspace_active_subscription(&svc_ctx.db, workspace_uuid) + .await + .map_err(|e| e.to_string())?; + + // 5. 计算月结费用(订阅价格) + let monthly_billing = subscription + .as_ref() + .map(|s| { + if s.billing_period == "yearly" { + s.price / Decimal::from(12) + } else { + s.price + } + }) + .unwrap_or(Decimal::ZERO); + + Ok(AccountInfoResponse { + email: user_info.email, + wallet_balance: wallet.balance, + gift_balance: Decimal::ZERO, // TODO: 如果有赠送金字段,从钱包获取 + currency: wallet.currency, + subscription, + quota, + monthly_billing, + }) +} diff --git a/src/services/coupons.rs b/src/services/coupons.rs new file mode 100644 index 0000000..8c43101 --- /dev/null +++ b/src/services/coupons.rs @@ -0,0 +1,171 @@ +use chrono::Utc; +use rust_decimal::Decimal; +use uuid::Uuid; + +use crate::entitys::{ + BatchIssueCouponRequest, CouponValidationResult, GetUserCouponsRequest, IssueCouponRequest, + UserCouponsListResponse, VerifyCouponRequest, +}; +use crate::models; +use crate::svc_ctx::SvcCtx; + +/// 验证优惠券 +pub async fn validate_coupon_service( + svc_ctx: &SvcCtx, + user_uuid: Uuid, + payload: &VerifyCouponRequest, +) -> Result { + let coupon = models::billing::fetch_coupon_by_code(&svc_ctx.db, &payload.code) + .await + .map_err(|e| e.to_string())? + .ok_or_else(|| "优惠券不存在".to_string())?; + + // 检查有效期 + let now = Utc::now(); + if coupon.valid_from > now { + return Err("优惠券尚未生效".to_string()); + } + if let Some(valid_until) = coupon.valid_until { + if valid_until < now { + return Err("优惠券已过期".to_string()); + } + } + + // 检查使用次数限制 + if let Some(max_uses) = coupon.max_uses { + if coupon.used_count >= max_uses { + return Err("优惠券已达使用上限".to_string()); + } + } + + // 检查单用户使用次数限制 + if let Some(max_uses_per_user) = coupon.max_uses_per_user { + let user_usage = + models::billing::fetch_coupon_user_usage_count(&svc_ctx.db, coupon.uuid, user_uuid) + .await + .map_err(|e| e.to_string())?; + if user_usage >= max_uses_per_user { + return Err("您已达该优惠券使用上限".to_string()); + } + } + + // 检查最低消费 + if let Some(min_amount) = coupon.min_amount { + if payload.amount < min_amount { + return Err(format!("订单金额需满 {} 元才可使用此优惠券", min_amount)); + } + } + + // 计算折扣金额 + let mut discount_amount = match coupon.discount_type.as_str() { + "percentage" => payload.amount * coupon.discount_value / Decimal::from(100), + "fixed" => coupon.discount_value, + _ => Decimal::ZERO, + }; + + // 检查最大折扣限制 + if let Some(max_discount) = coupon.max_discount { + if discount_amount > max_discount { + discount_amount = max_discount; + } + } + + Ok(CouponValidationResult { + coupon, + discount_amount, + }) +} + +/// 获取用户优惠券列表 +pub async fn get_user_coupons_service( + svc_ctx: &SvcCtx, + user_uuid: Uuid, + payload: &GetUserCouponsRequest, +) -> Result { + let (items, total) = models::billing::fetch_user_coupons( + &svc_ctx.db, + user_uuid, + payload.status.as_deref(), + payload.pagination.page, + payload.pagination.page_size, + ) + .await + .map_err(|e| e.to_string())?; + + Ok(UserCouponsListResponse { + items, + total, + page: payload.pagination.page, + page_size: payload.pagination.page_size, + }) +} + +/// 给单个用户发放优惠券 +pub async fn issue_coupon_to_user_service( + svc_ctx: &SvcCtx, + payload: &IssueCouponRequest, +) -> Result { + // 验证优惠券存在且有效 + let coupon = models::billing::fetch_coupon_by_uuid(&svc_ctx.db, payload.coupon_uuid) + .await + .map_err(|e| e.to_string())? + .ok_or_else(|| "优惠券不存在或已失效".to_string())?; + + // 如果提供了过期时间,使用它;否则从优惠券继承 + let expires_at = payload.expires_at.or(coupon.valid_until); + + let id = models::billing::insert_user_coupon( + &svc_ctx.db, + payload.user_uuid, + payload.coupon_uuid, + expires_at, + ) + .await + .map_err(|e| { + if e.to_string().contains("RowNotFound") { + "该用户已拥有此优惠券".to_string() + } else { + e.to_string() + } + })?; + + Ok(id) +} + +/// 批量发放优惠券 +pub async fn batch_issue_coupons_service( + svc_ctx: &SvcCtx, + payload: &BatchIssueCouponRequest, +) -> Result { + // 验证优惠券存在且有效 + let coupon = models::billing::fetch_coupon_by_uuid(&svc_ctx.db, payload.coupon_uuid) + .await + .map_err(|e| e.to_string())? + .ok_or_else(|| "优惠券不存在或已失效".to_string())?; + + // 如果提供了过期时间,使用它;否则从优惠券继承 + let expires_at = payload.expires_at.or(coupon.valid_until); + + let count = models::billing::batch_insert_user_coupons( + &svc_ctx.db, + payload.coupon_uuid, + &payload.user_uuids, + expires_at, + ) + .await + .map_err(|e| e.to_string())?; + + Ok(count) +} + +/// 获取用户可用优惠券(自动过滤过期和已使用的) +pub async fn get_available_coupons_service( + svc_ctx: &SvcCtx, + user_uuid: Uuid, +) -> Result, String> { + let items = models::billing::fetch_available_user_coupons(&svc_ctx.db, user_uuid) + .await + .map_err(|e| e.to_string())?; + + Ok(items) +} diff --git a/src/services/environments.rs b/src/services/environments.rs new file mode 100644 index 0000000..fe2f584 --- /dev/null +++ b/src/services/environments.rs @@ -0,0 +1,981 @@ +use std::collections::HashMap; +use uuid::Uuid; + +use crate::dto::{ + EnvironmentConfigDto, EnvironmentCookieDto, EnvironmentDto, EnvironmentUrlDto, GroupSummaryDto, + PlatformAccountDto, ProxySummaryDto, TagDto, +}; +use crate::entitys::{ + AddEnvironmentCookieRequest, AddEnvironmentUrlRequest, AssignTagsRequest, + BatchAssignTagRequest, BatchCreateEnvironmentRequest, BatchMoveToGroupRequest, + BatchRemoveTagsRequest, CookieInput, CreateEnvironmentRequest, ListEnvironmentsRequest, + MoveToGroupRequest, SetEnvironmentProxyRequest, UpdateEnvironmentRequest, +}; +use crate::models; +use crate::services::accounts; +use crate::svc_ctx::SvcCtx; + +// ============ Environments ============ + +/// 创建环境 +pub async fn create_environment_service( + svc_ctx: &SvcCtx, + user_uuid: Uuid, + workspace_uuid: Uuid, + team_uuid: Uuid, + payload: &CreateEnvironmentRequest, +) -> Result { + // 1. 检查用户是否在当前工作空间的团队中(工作空间级别) + let team_member = models::fetch_team_member(&svc_ctx.db, workspace_uuid, team_uuid, user_uuid) + .await + .map_err(|e| e.to_string())? + .ok_or_else(|| "您不是该团队的成员".to_string())?; + + // 2. 权限检查 + if let Some(group_uuid) = payload.group_uuid { + // 如果指定了分组,检查用户是否有目标分组的 write 或 manage 权限 + let has_write = models::check_group_permission( + &svc_ctx.db, + workspace_uuid, + group_uuid, + user_uuid, + "write", + ) + .await + .map_err(|e| e.to_string())?; + + let has_manage = models::check_group_permission( + &svc_ctx.db, + workspace_uuid, + group_uuid, + user_uuid, + "manage", + ) + .await + .map_err(|e| e.to_string())?; + + if !has_write && !has_manage { + return Err("您没有在该分组中创建环境的权限".to_string()); + } + } else { + // 如果未指定分组,检查用户是否有团队级别的环境创建权限(Editor/Admin/Owner) + let can_create = matches!(team_member.role.as_str(), "owner" | "admin" | "editor"); + if !can_create { + return Err("您没有创建环境的权限,需要 Editor 及以上角色".to_string()); + } + } + + // 3. 检查工作空间配额是否充足 + let quota_available = models::check_quota(&svc_ctx.db, workspace_uuid, "environments") + .await + .map_err(|e| e.to_string())?; + if !quota_available { + return Err("工作空间环境配额不足,无法创建新环境".to_string()); + } + + // 提取系统信息 + let system_info = payload + .config + .window_info + .get("system") + .and_then(|v| v.as_str()) + .map(|s| s.to_string()); + let kernel_info = payload + .config + .window_info + .get("kernel") + .and_then(|v| v.as_str()) + .map(|s| s.to_string()); + + // 创建环境 + let env_uuid = models::insert_environment( + &svc_ctx.db, + workspace_uuid, + user_uuid, + team_uuid, + &payload.name, + payload.description.as_deref(), + payload.group_uuid, + payload.proxy_uuid, + system_info.as_deref(), + kernel_info.as_deref(), + ) + .await + .map_err(|e| e.to_string())?; + + // 创建环境配置 + models::upsert_environment_config( + &svc_ctx.db, + env_uuid, + &payload.config.window_info, + &payload.config.basic_settings, + &payload.config.fingerprint_settings, + &payload.config.device_settings, + &payload.config.preference_settings, + &payload.config.project_metadata, + ) + .await + .map_err(|e| e.to_string())?; + + // 分配标签 + if let Some(tag_uuids) = &payload.tag_uuids { + for tag_uuid in tag_uuids { + let _ = models::insert_environment_tag(&svc_ctx.db, env_uuid, *tag_uuid).await; + } + } + + // 关联账号 + if let Some(account_uuids) = &payload.account_uuids { + for (idx, account_uuid) in account_uuids.iter().enumerate() { + let _ = models::accounts::insert_environment_account( + &svc_ctx.db, + env_uuid, + *account_uuid, + idx as i32, + ) + .await; + } + } + + // 添加 Cookies + if let Some(cookie_strings) = &payload.cookies { + let parsed_cookies = parse_cookie_strings(cookie_strings)?; + if !parsed_cookies.is_empty() { + let _ = + models::batch_insert_environment_cookies(&svc_ctx.db, env_uuid, &parsed_cookies) + .await + .map_err(|e| format!("添加 Cookies 失败: {}", e))?; + } + } + + // 4. 更新工作空间配额(创建后增加使用数) + models::increment_used_environments(&svc_ctx.db, workspace_uuid, 1) + .await + .map_err(|e| format!("更新配额失败: {}", e))?; + + Ok(env_uuid) +} + +/// 解析 Cookie 字符串数组为 CookieInput 数组 +/// Cookie 字符串格式支持: +/// - 简单格式: "name=value" +/// - 完整格式: "name=value; domain=example.com; path=/; secure; httpOnly" +fn parse_cookie_strings(cookie_strings: &[String]) -> Result, String> { + let mut cookies = Vec::new(); + + for cookie_str in cookie_strings { + let cookie_str = cookie_str.trim(); + if cookie_str.is_empty() { + continue; + } + + // 解析 cookie 字符串 + let parts: Vec<&str> = cookie_str.split(';').map(|s| s.trim()).collect(); + if parts.is_empty() { + continue; + } + + // 解析 name=value + let name_value = parts[0]; + let (name, value) = if let Some(eq_pos) = name_value.find('=') { + (name_value[..eq_pos].trim(), name_value[eq_pos + 1..].trim()) + } else { + return Err(format!("无效的 Cookie 格式: {}", cookie_str)); + }; + + if name.is_empty() { + return Err(format!("Cookie 名称不能为空: {}", cookie_str)); + } + + // 解析其他属性 + let mut domain = None; + let mut path = Some("/".to_string()); + let mut http_only = Some(false); + let mut secure = Some(false); + let mut same_site = Some("Lax".to_string()); + + for part in parts.iter().skip(1) { + let part_lower = part.to_lowercase(); + if part_lower.starts_with("domain=") { + domain = Some(part[7..].trim().to_string()); + } else if part_lower.starts_with("path=") { + path = Some(part[5..].trim().to_string()); + } else if part_lower == "secure" { + secure = Some(true); + } else if part_lower == "httponly" || part_lower == "http-only" { + http_only = Some(true); + } else if part_lower.starts_with("samesite=") { + same_site = Some(part[9..].trim().to_string()); + } + } + + // 如果没有指定 domain,尝试从 cookie 值中推断(如果可能) + // 这里简化处理,如果没有 domain 则使用空字符串或默认值 + let domain = domain.unwrap_or_else(|| "".to_string()); + + cookies.push(CookieInput { + domain, + name: name.to_string(), + value: value.to_string(), + path, + http_only, + secure, + same_site, + }); + } + + Ok(cookies) +} + +/// 获取环境列表(包含完整关联数据) +pub async fn get_environments_service( + svc_ctx: &SvcCtx, + user_uuid: Uuid, + workspace_uuid: Uuid, + team_uuid: Uuid, + payload: &ListEnvironmentsRequest, +) -> Result<(Vec, i64), String> { + // 1. 检查用户是否在当前工作空间的团队中 + let team_member = models::fetch_team_member(&svc_ctx.db, workspace_uuid, team_uuid, user_uuid) + .await + .map_err(|e| e.to_string())? + .ok_or_else(|| "您不是该团队的成员".to_string())?; + + let offset = (payload.pagination.page - 1) * payload.pagination.page_size; + + let group_uuid = payload.filters.as_ref().and_then(|f| f.group_uuid); + let status = payload.filters.as_ref().and_then(|f| f.status.as_deref()); + + // 2. 查询环境基础列表 + let env_rows = models::fetch_environments_base( + &svc_ctx.db, + workspace_uuid, + team_uuid, + group_uuid, + status, + offset, + payload.pagination.page_size, + ) + .await + .map_err(|e| e.to_string())?; + + // 注意:这里不查询总数,因为权限过滤后的实际数量需要根据过滤结果计算 + // 如果需要精确的分页总数,需要在权限过滤后重新计算 + + // 2. 权限过滤:根据分组权限过滤环境 + let is_owner_or_admin = matches!(team_member.role.as_str(), "owner" | "admin"); + + // 收集所有需要检查权限的分组 UUID(去重) + let unique_group_uuids: Vec = env_rows + .iter() + .filter_map(|row| row.group_uuid) + .collect::>() + .into_iter() + .collect(); + + // 批量检查分组权限(如果不是 Owner/Admin) + let mut group_permissions_cache: std::collections::HashMap = + std::collections::HashMap::new(); + if !is_owner_or_admin && !unique_group_uuids.is_empty() { + for group_uuid in unique_group_uuids { + let has_permission = models::check_group_permission( + &svc_ctx.db, + workspace_uuid, + group_uuid, + user_uuid, + "read", + ) + .await + .map_err(|e| e.to_string())?; + group_permissions_cache.insert(group_uuid, has_permission); + } + } + + // 8. 过滤无权限的环境 + let filtered_env_rows: Vec<_> = env_rows + .into_iter() + .filter(|row| { + if let Some(group_uuid) = row.group_uuid { + // 如果环境有分组,检查用户是否有分组的 read 权限 + if is_owner_or_admin { + true // Owner/Admin 自动拥有所有分组权限 + } else { + *group_permissions_cache.get(&group_uuid).unwrap_or(&false) + } + } else { + // 如果环境无分组,所有团队成员都可以查看(已在团队成员检查中验证) + true + } + }) + .collect(); + + // 9. 重新收集关联 UUID(基于过滤后的环境) + let env_uuids: Vec = filtered_env_rows.iter().map(|e| e.uuid).collect(); + let group_uuids: Vec = filtered_env_rows.iter().filter_map(|e| e.group_uuid).collect(); + let proxy_uuids: Vec = filtered_env_rows.iter().filter_map(|e| e.proxy_uuid).collect(); + + // 10. 重新查询关联数据(基于过滤后的环境) + // 批量查询环境配置 + let mut configs_map: HashMap = HashMap::new(); + if !env_uuids.is_empty() { + let config_rows = models::fetch_environment_configs_by_uuids(&svc_ctx.db, &env_uuids) + .await + .map_err(|e| e.to_string())?; + for config in config_rows { + configs_map.insert(config.environment_uuid, config); + } + } + + // 批量查询分组 + let group_rows = if !group_uuids.is_empty() { + models::fetch_groups_by_uuids(&svc_ctx.db, &group_uuids) + .await + .map_err(|e| e.to_string())? + } else { + vec![] + }; + let groups_map: HashMap = group_rows.into_iter().map(|g| (g.uuid, g)).collect(); + + // 批量查询代理 + let proxy_rows = if !proxy_uuids.is_empty() { + models::fetch_proxies_by_uuids(&svc_ctx.db, &proxy_uuids) + .await + .map_err(|e| e.to_string())? + } else { + vec![] + }; + let proxies_map: HashMap = proxy_rows.into_iter().map(|p| (p.uuid, p)).collect(); + + // 批量查询标签 + let tag_rows = if !env_uuids.is_empty() { + models::fetch_tags_for_environments(&svc_ctx.db, &env_uuids) + .await + .map_err(|e| e.to_string())? + } else { + vec![] + }; + + // 按环境分组标签 + let mut tags_map: HashMap> = HashMap::new(); + for tag_row in tag_rows { + tags_map.entry(tag_row.environment_uuid).or_default().push(TagDto { + id: tag_row.tag_id, + uuid: tag_row.tag_uuid, + user_uuid: tag_row.tag_user_uuid, + team_uuid: tag_row.tag_team_uuid, + name: tag_row.tag_name, + color: tag_row.tag_color, + sort_order: tag_row.tag_sort_order, + environments_count: tag_row.tag_environments_count, + created_at: tag_row.tag_created_at, + updated_at: tag_row.tag_updated_at, + deleted_at: tag_row.tag_deleted_at, + }); + } + + // 批量查询账号 + let mut accounts_map: HashMap> = HashMap::new(); + for env_uuid in &env_uuids { + let accounts = accounts::get_environment_accounts_service(&svc_ctx, *env_uuid) + .await + .unwrap_or_default(); + accounts_map.insert(*env_uuid, accounts); + } + + // 11. 组装完整数据(使用与环境详情一致的数据结构) + let environments: Vec = filtered_env_rows + .into_iter() + .map(|row| { + // 构建 EnvironmentDto + let environment = EnvironmentDto { + id: row.id, + uuid: row.uuid, + workspace_uuid: row.workspace_uuid, + user_uuid: row.user_uuid, + team_uuid: row.team_uuid, + name: row.name, + description: row.description, + status: row.status, + group_uuid: row.group_uuid, + proxy_uuid: row.proxy_uuid, + system_info: row.system_info, + kernel_info: row.kernel_info, + fingerprint_summary: row.fingerprint_summary, + last_opened_at: row.last_opened_at, + created_at: row.created_at, + updated_at: row.updated_at, + deleted_at: None, + }; + + // 分组详情 + let group = row.group_uuid.and_then(|uuid| { + groups_map.get(&uuid).map(|g| GroupSummaryDto { + id: g.id, + uuid: g.uuid, + name: g.name.clone(), + description: g.description.clone(), + sort_order: g.sort_order, + }) + }); + + // 代理详情 + let proxy = row.proxy_uuid.and_then(|uuid| { + proxies_map.get(&uuid).map(|p| ProxySummaryDto { + id: p.id, + uuid: p.uuid, + name: p.name.clone(), + host: p.host.clone(), + port: p.port, + proxy_type: p.proxy_type.clone(), + country: p.country.clone(), + city: p.city.clone(), + status: p.status.clone(), + latency: p.latency, + last_check_ip: p.last_check_ip.clone(), + }) + }); + + crate::entitys::EnvironmentDetailResponse { + environment, + config: configs_map.remove(&row.uuid), // 返回配置信息,用于传递给指纹浏览器内核 + tags: tags_map.remove(&row.uuid).unwrap_or_default(), + accounts: accounts_map.remove(&row.uuid).unwrap_or_default(), + group, + proxy, + } + }) + .collect(); + + // 返回过滤后的环境列表和实际数量(用于分页显示) + // 注意:total 是查询时的总数,实际返回的数量是过滤后的 + let filtered_total = environments.len() as i64; + Ok((environments, filtered_total)) +} + +/// 获取环境详情 +pub async fn get_environment_service( + svc_ctx: &SvcCtx, + workspace_uuid: Uuid, + team_uuid: Uuid, + user_uuid: Uuid, + env_uuid: Uuid, +) -> Result { + // 1. 检查用户是否在当前工作空间的团队中 + let _team_member = models::fetch_team_member(&svc_ctx.db, workspace_uuid, team_uuid, user_uuid) + .await + .map_err(|e| e.to_string())? + .ok_or_else(|| "您不是该团队的成员".to_string())?; + + // 2. 查询环境(带工作空间过滤) + let environment = models::fetch_environment_by_uuid(&svc_ctx.db, workspace_uuid, env_uuid) + .await + .map_err(|e| e.to_string())? + .ok_or_else(|| "环境不存在或不属于当前工作空间".to_string())?; + + // 3. 验证环境属于指定团队 + if environment.team_uuid != team_uuid { + return Err("环境不属于指定团队".to_string()); + } + + // 4. 权限检查 + if let Some(group_uuid) = environment.group_uuid { + // 如果环境有分组,检查用户是否有分组的 read/write/manage 权限 + // Owner/Admin 自动拥有所有分组权限(已在 check_group_permission 中处理) + let has_permission = models::check_group_permission( + &svc_ctx.db, + workspace_uuid, + group_uuid, + user_uuid, + "read", + ) + .await + .map_err(|e| e.to_string())?; + + if !has_permission { + return Err("您没有查看该环境的权限".to_string()); + } + } + // 如果环境无分组,所有团队成员都可以查看(已在团队成员检查中验证) + + Ok(environment) +} + +/// 获取环境配置 +pub async fn get_environment_config_service( + svc_ctx: &SvcCtx, + env_uuid: Uuid, +) -> Result { + models::fetch_environment_config(&svc_ctx.db, env_uuid) + .await + .map_err(|e| e.to_string())? + .ok_or_else(|| "环境配置不存在".to_string()) +} + +/// 获取环境的标签 +pub async fn get_environment_tags_service( + svc_ctx: &SvcCtx, + env_uuid: Uuid, +) -> Result, String> { + models::fetch_environment_tags(&svc_ctx.db, env_uuid) + .await + .map_err(|e| e.to_string()) +} + +/// 获取环境详情(包含完整关联数据) +pub async fn get_environment_detail_service( + svc_ctx: &SvcCtx, + workspace_uuid: Uuid, + team_uuid: Uuid, + user_uuid: Uuid, + env_uuid: Uuid, +) -> Result { + let environment = + get_environment_service(svc_ctx, workspace_uuid, team_uuid, user_uuid, env_uuid).await?; + + let config = get_environment_config_service(svc_ctx, env_uuid).await.ok(); + + let tags = get_environment_tags_service(svc_ctx, env_uuid).await?; + + let accounts = accounts::get_environment_accounts_service(svc_ctx, env_uuid) + .await + .unwrap_or_default(); + + // 获取分组信息 + let group = if let Some(group_uuid) = environment.group_uuid { + get_group_summary_service(svc_ctx, group_uuid).await.ok() + } else { + None + }; + + // 获取代理信息 + let proxy = if let Some(proxy_uuid) = environment.proxy_uuid { + get_proxy_summary_service(svc_ctx, proxy_uuid).await.ok() + } else { + None + }; + + Ok(crate::entitys::EnvironmentDetailResponse { + environment, + config, + tags, + accounts, + group, + proxy, + }) +} + +/// 获取分组摘要信息 +pub async fn get_group_summary_service( + svc_ctx: &SvcCtx, + group_uuid: Uuid, +) -> Result { + let group = models::fetch_group_by_uuid(&svc_ctx.db, group_uuid) + .await + .map_err(|e| e.to_string())? + .ok_or_else(|| "分组不存在".to_string())?; + + Ok(crate::dto::GroupSummaryDto { + id: group.id, + uuid: group.uuid, + name: group.name, + description: group.description, + sort_order: group.sort_order, + }) +} + +/// 获取代理摘要信息 +pub async fn get_proxy_summary_service( + svc_ctx: &SvcCtx, + proxy_uuid: Uuid, +) -> Result { + let proxy = crate::models::proxies::fetch_proxy_by_uuid(&svc_ctx.db, proxy_uuid) + .await + .map_err(|e| e.to_string())? + .ok_or_else(|| "代理不存在".to_string())?; + + Ok(crate::dto::ProxySummaryDto { + id: proxy.id, + uuid: proxy.uuid, + name: proxy.name, + host: proxy.host, + port: proxy.port, + proxy_type: proxy.proxy_type, + country: proxy.country, + city: proxy.city, + status: proxy.status, + latency: proxy.latency, + last_check_ip: proxy.last_check_ip, + }) +} + +/// 更新环境 +pub async fn update_environment_service( + svc_ctx: &SvcCtx, + workspace_uuid: Uuid, + team_uuid: Uuid, + user_uuid: Uuid, + payload: &UpdateEnvironmentRequest, +) -> Result<(), String> { + // 1. 检查用户是否在当前工作空间的团队中 + let team_member = models::fetch_team_member(&svc_ctx.db, workspace_uuid, team_uuid, user_uuid) + .await + .map_err(|e| e.to_string())? + .ok_or_else(|| "您不是该团队的成员".to_string())?; + + // 2. 查询环境(带工作空间过滤) + let environment = models::fetch_environment_by_uuid(&svc_ctx.db, workspace_uuid, payload.uuid) + .await + .map_err(|e| e.to_string())? + .ok_or_else(|| "环境不存在或不属于当前工作空间".to_string())?; + + // 3. 验证环境属于指定团队 + if environment.team_uuid != team_uuid { + return Err("环境不属于指定团队".to_string()); + } + + // 4. 权限检查 + if let Some(group_uuid) = environment.group_uuid { + // 如果环境有分组,检查用户是否有分组的 write 或 manage 权限 + let has_write = models::check_group_permission( + &svc_ctx.db, + workspace_uuid, + group_uuid, + user_uuid, + "write", + ) + .await + .map_err(|e| e.to_string())?; + + let has_manage = models::check_group_permission( + &svc_ctx.db, + workspace_uuid, + group_uuid, + user_uuid, + "manage", + ) + .await + .map_err(|e| e.to_string())?; + + if !has_write && !has_manage { + return Err("您没有编辑该环境的权限".to_string()); + } + } else { + // 如果环境无分组,检查用户是否有团队级别的编辑权限(Editor/Admin/Owner) + let can_edit = matches!(team_member.role.as_str(), "owner" | "admin" | "editor"); + if !can_edit { + return Err("您没有编辑环境的权限,需要 Editor 及以上角色".to_string()); + } + } + + models::update_environment( + &svc_ctx.db, + payload.uuid, + payload.name.as_deref(), + payload.description.as_deref(), + payload.group_uuid, + ) + .await + .map_err(|e| e.to_string())?; + + // 更新配置 + if let Some(config) = &payload.config { + models::upsert_environment_config( + &svc_ctx.db, + payload.uuid, + &config.window_info, + &config.basic_settings, + &config.fingerprint_settings, + &config.device_settings, + &config.preference_settings, + &config.project_metadata, + ) + .await + .map_err(|e| e.to_string())?; + } + + Ok(()) +} + +/// 设置环境代理 +pub async fn set_environment_proxy_service( + svc_ctx: &SvcCtx, + payload: &SetEnvironmentProxyRequest, +) -> Result<(), String> { + models::update_environment_proxy(&svc_ctx.db, payload.uuid, payload.proxy_uuid) + .await + .map_err(|e| e.to_string()) +} + +/// 分配标签 +pub async fn assign_tags_service( + svc_ctx: &SvcCtx, + payload: &AssignTagsRequest, +) -> Result<(), String> { + for tag_uuid in &payload.tag_uuids { + models::insert_environment_tag(&svc_ctx.db, payload.uuid, *tag_uuid) + .await + .map_err(|e| e.to_string())?; + } + Ok(()) +} + +/// 移除标签 +pub async fn remove_tag_service( + svc_ctx: &SvcCtx, + env_uuid: Uuid, + tag_uuid: Uuid, +) -> Result<(), String> { + models::remove_environment_tag(&svc_ctx.db, env_uuid, tag_uuid) + .await + .map_err(|e| e.to_string()) +} + +/// 移动到分组 +pub async fn move_to_group_service( + svc_ctx: &SvcCtx, + payload: &MoveToGroupRequest, +) -> Result<(), String> { + models::update_environment(&svc_ctx.db, payload.uuid, None, None, payload.group_uuid) + .await + .map_err(|e| e.to_string()) +} + +/// 批量移动到分组 +pub async fn batch_move_to_group_service( + svc_ctx: &SvcCtx, + payload: &BatchMoveToGroupRequest, +) -> Result<(), String> { + for env_uuid in &payload.env_uuids { + models::update_environment(&svc_ctx.db, *env_uuid, None, None, Some(payload.group_uuid)) + .await + .map_err(|e| e.to_string())?; + } + Ok(()) +} + +/// 批量分配标签 +pub async fn batch_assign_tags_service( + svc_ctx: &SvcCtx, + payload: &BatchAssignTagRequest, +) -> Result<(), String> { + for env_uuid in &payload.env_uuids { + models::insert_environment_tag(&svc_ctx.db, *env_uuid, payload.tag_uuid) + .await + .map_err(|e| e.to_string())?; + } + Ok(()) +} + +/// 批量移除标签 +pub async fn batch_remove_tags_service( + svc_ctx: &SvcCtx, + payload: &BatchRemoveTagsRequest, +) -> Result<(), String> { + if let Some(tag_uuid) = payload.tag_uuid { + // 移除指定的标签 + for env_uuid in &payload.env_uuids { + models::remove_environment_tag(&svc_ctx.db, *env_uuid, tag_uuid) + .await + .map_err(|e| e.to_string())?; + } + } else { + // 移除所有标签 + for env_uuid in &payload.env_uuids { + models::clear_environment_tags(&svc_ctx.db, *env_uuid) + .await + .map_err(|e| e.to_string())?; + } + } + Ok(()) +} + +/// 删除环境 +pub async fn delete_environment_service( + svc_ctx: &SvcCtx, + workspace_uuid: Uuid, + team_uuid: Uuid, + user_uuid: Uuid, + env_uuid: Uuid, +) -> Result<(), String> { + // 1. 检查用户是否在当前工作空间的团队中 + let team_member = models::fetch_team_member(&svc_ctx.db, workspace_uuid, team_uuid, user_uuid) + .await + .map_err(|e| e.to_string())? + .ok_or_else(|| "您不是该团队的成员".to_string())?; + + // 2. 查询环境(带工作空间过滤) + let environment = models::fetch_environment_by_uuid(&svc_ctx.db, workspace_uuid, env_uuid) + .await + .map_err(|e| e.to_string())? + .ok_or_else(|| "环境不存在或不属于当前工作空间".to_string())?; + + // 3. 验证环境属于指定团队 + if environment.team_uuid != team_uuid { + return Err("环境不属于指定团队".to_string()); + } + + // 4. 权限检查 + if let Some(group_uuid) = environment.group_uuid { + // 如果环境有分组,检查用户是否有分组的 manage 权限 + let has_manage = models::check_group_permission( + &svc_ctx.db, + workspace_uuid, + group_uuid, + user_uuid, + "manage", + ) + .await + .map_err(|e| e.to_string())?; + + if !has_manage { + return Err("您没有删除该环境的权限".to_string()); + } + } else { + // 如果环境无分组,检查用户是否有团队级别的删除权限(Owner/Admin) + let can_delete = matches!(team_member.role.as_str(), "owner" | "admin"); + if !can_delete { + return Err("您没有删除环境的权限,需要 Owner 或 Admin 角色".to_string()); + } + } + + // 5. 删除环境 + models::delete_environment(&svc_ctx.db, env_uuid) + .await + .map_err(|e| e.to_string())?; + + // 6. 更新工作空间配额(删除后减少使用数) + models::decrement_used_environments(&svc_ctx.db, workspace_uuid, 1) + .await + .map_err(|e| format!("更新配额失败: {}", e))?; + + Ok(()) +} + +/// 批量删除环境 +pub async fn batch_delete_environments_service( + svc_ctx: &SvcCtx, + env_uuids: &[Uuid], +) -> Result { + models::batch_delete_environments(&svc_ctx.db, env_uuids) + .await + .map_err(|e| e.to_string()) +} + +// ============ Environment URLs ============ + +/// 添加环境 URL +pub async fn add_environment_url_service( + svc_ctx: &SvcCtx, + payload: &AddEnvironmentUrlRequest, +) -> Result { + models::insert_environment_url( + &svc_ctx.db, + payload.environment_uuid, + &payload.url, + payload.title.as_deref(), + payload.sort_order, + ) + .await + .map_err(|e| e.to_string()) +} + +/// 获取环境的所有 URL +pub async fn get_environment_urls_service( + svc_ctx: &SvcCtx, + env_uuid: Uuid, +) -> Result, String> { + models::fetch_environment_urls(&svc_ctx.db, env_uuid) + .await + .map_err(|e| e.to_string()) +} + +/// 删除环境 URL +pub async fn delete_environment_url_service(svc_ctx: &SvcCtx, url_id: i32) -> Result<(), String> { + models::delete_environment_url(&svc_ctx.db, url_id) + .await + .map_err(|e| e.to_string()) +} + +/// 清空环境的所有 URL +pub async fn clear_environment_urls_service( + svc_ctx: &SvcCtx, + env_uuid: Uuid, +) -> Result { + models::clear_environment_urls(&svc_ctx.db, env_uuid) + .await + .map_err(|e| e.to_string()) +} + +// ============ Environment Cookies ============ + +/// 添加环境 Cookie +pub async fn add_environment_cookie_service( + svc_ctx: &SvcCtx, + payload: &AddEnvironmentCookieRequest, +) -> Result { + models::insert_environment_cookie( + &svc_ctx.db, + payload.environment_uuid, + &payload.domain, + &payload.name, + &payload.value, + payload.path.as_deref(), + payload.http_only, + payload.secure, + payload.same_site.as_deref(), + ) + .await + .map_err(|e| e.to_string()) +} + +/// 获取环境的所有 Cookies +pub async fn get_environment_cookies_service( + svc_ctx: &SvcCtx, + env_uuid: Uuid, +) -> Result, String> { + models::fetch_environment_cookies(&svc_ctx.db, env_uuid) + .await + .map_err(|e| e.to_string()) +} + +/// 删除环境 Cookie +pub async fn delete_environment_cookie_service( + svc_ctx: &SvcCtx, + cookie_id: i32, +) -> Result<(), String> { + models::delete_environment_cookie(&svc_ctx.db, cookie_id) + .await + .map_err(|e| e.to_string()) +} + +/// 清空环境的所有 Cookies +pub async fn clear_environment_cookies_service( + svc_ctx: &SvcCtx, + env_uuid: Uuid, +) -> Result { + models::clear_environment_cookies(&svc_ctx.db, env_uuid) + .await + .map_err(|e| e.to_string()) +} + +/// 批量创建环境 +pub async fn batch_create_environments_service( + svc_ctx: &SvcCtx, + user_uuid: Uuid, + workspace_uuid: Uuid, + team_uuid: Uuid, + payload: &BatchCreateEnvironmentRequest, +) -> Result, String> { + let mut created_uuids = Vec::new(); + + for env_request in &payload.environments { + let env_uuid = + create_environment_service(svc_ctx, user_uuid, workspace_uuid, team_uuid, env_request) + .await + .map_err(|e| format!("创建环境 '{}' 失败: {}", env_request.name, e))?; + + created_uuids.push(env_uuid); + } + + Ok(created_uuids) +} diff --git a/src/services/extensions.rs b/src/services/extensions.rs new file mode 100644 index 0000000..7384414 --- /dev/null +++ b/src/services/extensions.rs @@ -0,0 +1,570 @@ +use uuid::Uuid; + +use crate::dto::ExtensionDto; +use crate::entitys::{ + ExtensionGroup, InstallExtensionRequest, InstalledExtensionItem, ListExtensionsRequest, + UninstallExtensionRequest, +}; +use crate::models; +use crate::models::environments as env_models; +use crate::svc_ctx::SvcCtx; +use crate::utils::minios::get_objects; + +/// 获取扩展列表 +pub async fn get_extensions_service( + svc_ctx: &SvcCtx, + payload: &ListExtensionsRequest, +) -> Result<(Vec, i64), String> { + let offset = (payload.pagination.page - 1) * payload.pagination.page_size; + + let keyword = payload.filters.as_ref().and_then(|f| f.keyword.as_deref()); + let category = payload.filters.as_ref().and_then(|f| f.category.as_deref()); + + let mut extensions = models::extensions::fetch_extensions( + &svc_ctx.db, + keyword, + category, + offset, + payload.pagination.page_size, + ) + .await + .map_err(|e| e.to_string())?; + + // 将 object path 转换为完整 URL + let resource_url = svc_ctx.config.minio.as_ref().map(|c| c.resource_url.clone()); + let extension_bucket = svc_ctx.config.minio.as_ref().map(|c| c.extension_bucket.clone()); + ExtensionDto::transform_urls_batch(&mut extensions, &resource_url, &extension_bucket); + + let total = models::extensions::fetch_extensions_count(&svc_ctx.db, keyword, category) + .await + .map_err(|e| e.to_string())?; + + Ok((extensions, total)) +} + +/// 获取扩展详情 +pub async fn get_extension_service( + svc_ctx: &SvcCtx, + extension_id: &str, +) -> Result { + let mut extension = models::extensions::fetch_extension_by_id(&svc_ctx.db, extension_id) + .await + .map_err(|e| e.to_string())? + .ok_or_else(|| "扩展不存在".to_string())?; + + // 将 object path 转换为完整 URL + let resource_url = svc_ctx.config.minio.as_ref().map(|c| c.resource_url.clone()); + let extension_bucket = svc_ctx.config.minio.as_ref().map(|c| c.extension_bucket.clone()); + extension.transform_urls(&resource_url, &extension_bucket); + + Ok(extension) +} + +/// 获取扩展分类 +pub async fn get_extension_categories_service(svc_ctx: &SvcCtx) -> Result, String> { + models::extensions::fetch_extension_categories(&svc_ctx.db) + .await + .map_err(|e| e.to_string()) +} + +/// 获取用户已安装的扩展 +pub async fn get_user_installed_extensions_service( + svc_ctx: &SvcCtx, + user_uuid: Uuid, + team_uuid: Option, +) -> Result, String> { + let resource_url = svc_ctx.config.minio.as_ref().map(|c| c.resource_url.clone()); + let extension_bucket = svc_ctx.config.minio.as_ref().map(|c| c.extension_bucket.clone()); + + // 查询用户直接安装的扩展 + let user_installed = models::extensions::fetch_user_extensions(&svc_ctx.db, user_uuid) + .await + .map_err(|e| e.to_string())?; + + // 查询用户相关的分组中安装的扩展 + let group_installed = + models::extensions::fetch_user_group_extensions(&svc_ctx.db, user_uuid, team_uuid) + .await + .map_err(|e| e.to_string())?; + + // 使用 HashMap 去重,以 extension_id 为 key + use std::collections::HashMap; + let mut extension_map: HashMap = HashMap::new(); + + // 处理用户直接安装的扩展 + for ue in user_installed { + if let Ok(Some(ext)) = + models::extensions::fetch_extension_by_id(&svc_ctx.db, &ue.extension_id).await + { + // 转换图标 URL + let mut icon_url = ext.icon_url.clone(); + if let Some(path) = &icon_url { + if !path.is_empty() && !path.starts_with("http") { + icon_url = + get_objects::get_extension_icon_url(&resource_url, &extension_bucket, path); + } + } + + // 查询关联的分组 + let group_uuids = models::extensions::fetch_group_uuids_by_extension_id( + &svc_ctx.db, + &ue.extension_id, + ) + .await + .unwrap_or_default(); + + let mut groups = Vec::new(); + for group_uuid in group_uuids { + if let Ok(Some(group)) = + env_models::fetch_group_by_uuid(&svc_ctx.db, group_uuid).await + { + groups.push(ExtensionGroup { + uuid: group.uuid, + name: group.name, + }); + } + } + + let has_update = ext.version != ue.installed_version; + extension_map.insert( + ue.extension_id.clone(), + InstalledExtensionItem { + extension_id: ue.extension_id, + name: ext.name, + version: ext.version, + installed_version: ue.installed_version, + has_update, + status: ue.status, + installed_at: ue.installed_at, + homepage: ext.homepage, + icon_url, + team_uuid: None, + scope: "user".to_string(), + description: ext.description, + category: Some(ext.category), + browser: Some(ext.browser), + developer: ext.developer, + downloads_count: ext.downloads_count, + rating: ext.rating, + permissions: ext.permissions, + file_size: ext.file_size, + updated_at: Some(ext.updated_at), + groups: if groups.is_empty() { + None + } else { + Some(groups) + }, + }, + ); + } + } + + // 处理分组安装的扩展 + for ge in group_installed { + // 如果已存在(用户直接安装),分组信息已经包含在内,直接跳过 + if extension_map.contains_key(&ge.extension_id) { + continue; + } + + // 只处理新扩展(仅安装在分组中,用户未直接安装) + if let Ok(Some(ext)) = + models::extensions::fetch_extension_by_id(&svc_ctx.db, &ge.extension_id).await + { + // 转换图标 URL + let mut icon_url = ext.icon_url.clone(); + if let Some(path) = &icon_url { + if !path.is_empty() && !path.starts_with("http") { + icon_url = + get_objects::get_extension_icon_url(&resource_url, &extension_bucket, path); + } + } + + // 查询关联的分组 + let group_uuids = models::extensions::fetch_group_uuids_by_extension_id( + &svc_ctx.db, + &ge.extension_id, + ) + .await + .unwrap_or_default(); + + let mut groups = Vec::new(); + for group_uuid in group_uuids { + if let Ok(Some(group)) = + env_models::fetch_group_by_uuid(&svc_ctx.db, group_uuid).await + { + groups.push(ExtensionGroup { + uuid: group.uuid, + name: group.name, + }); + } + } + + let has_update = ext.version != ge.installed_version; + extension_map.insert( + ge.extension_id.clone(), + InstalledExtensionItem { + extension_id: ge.extension_id, + name: ext.name, + version: ext.version, + installed_version: ge.installed_version, + has_update, + status: ge.status, + installed_at: ge.installed_at, + homepage: ext.homepage, + icon_url, + team_uuid: None, + scope: "group".to_string(), + description: ext.description, + category: Some(ext.category), + browser: Some(ext.browser), + developer: ext.developer, + downloads_count: ext.downloads_count, + rating: ext.rating, + permissions: ext.permissions, + file_size: ext.file_size, + updated_at: Some(ext.updated_at), + groups: if groups.is_empty() { + None + } else { + Some(groups) + }, + }, + ); + } + } + + Ok(extension_map.into_values().collect()) +} + +/// 获取团队已安装的扩展 +pub async fn get_team_installed_extensions_service( + svc_ctx: &SvcCtx, + team_uuid: Uuid, +) -> Result, String> { + let resource_url = svc_ctx.config.minio.as_ref().map(|c| c.resource_url.clone()); + let extension_bucket = svc_ctx.config.minio.as_ref().map(|c| c.extension_bucket.clone()); + + // 查询团队直接安装的扩展 + let team_installed = models::extensions::fetch_team_extensions(&svc_ctx.db, team_uuid) + .await + .map_err(|e| e.to_string())?; + + // 查询团队相关的分组中安装的扩展 + let group_installed = models::extensions::fetch_team_group_extensions(&svc_ctx.db, team_uuid) + .await + .map_err(|e| e.to_string())?; + + // 使用 HashMap 去重,以 extension_id 为 key + use std::collections::HashMap; + let mut extension_map: HashMap = HashMap::new(); + + // 处理团队直接安装的扩展 + for te in team_installed { + if let Ok(Some(ext)) = + models::extensions::fetch_extension_by_id(&svc_ctx.db, &te.extension_id).await + { + // 转换图标 URL + let mut icon_url = ext.icon_url.clone(); + if let Some(path) = &icon_url { + if !path.is_empty() && !path.starts_with("http") { + icon_url = + get_objects::get_extension_icon_url(&resource_url, &extension_bucket, path); + } + } + + // 查询关联的分组 + let group_uuids = models::extensions::fetch_group_uuids_by_extension_id( + &svc_ctx.db, + &te.extension_id, + ) + .await + .unwrap_or_default(); + + let mut groups = Vec::new(); + for group_uuid in group_uuids { + if let Ok(Some(group)) = + env_models::fetch_group_by_uuid(&svc_ctx.db, group_uuid).await + { + groups.push(ExtensionGroup { + uuid: group.uuid, + name: group.name, + }); + } + } + + let has_update = ext.version != te.installed_version; + extension_map.insert( + te.extension_id.clone(), + InstalledExtensionItem { + extension_id: te.extension_id, + name: ext.name, + version: ext.version, + installed_version: te.installed_version, + has_update, + status: te.status, + installed_at: te.installed_at, + homepage: ext.homepage, + icon_url, + team_uuid: Some(team_uuid), + scope: "team".to_string(), + description: ext.description, + category: Some(ext.category), + browser: Some(ext.browser), + developer: ext.developer, + downloads_count: ext.downloads_count, + rating: ext.rating, + permissions: ext.permissions, + file_size: ext.file_size, + updated_at: Some(ext.updated_at), + groups: if groups.is_empty() { + None + } else { + Some(groups) + }, + }, + ); + } + } + + // 处理分组安装的扩展 + for ge in group_installed { + // 如果已存在(团队直接安装),分组信息已经包含在内,直接跳过 + if extension_map.contains_key(&ge.extension_id) { + continue; + } + + // 只处理新扩展(仅安装在分组中,团队未直接安装) + if let Ok(Some(ext)) = + models::extensions::fetch_extension_by_id(&svc_ctx.db, &ge.extension_id).await + { + // 转换图标 URL + let mut icon_url = ext.icon_url.clone(); + if let Some(path) = &icon_url { + if !path.is_empty() && !path.starts_with("http") { + icon_url = + get_objects::get_extension_icon_url(&resource_url, &extension_bucket, path); + } + } + + // 查询关联的分组 + let group_uuids = models::extensions::fetch_group_uuids_by_extension_id( + &svc_ctx.db, + &ge.extension_id, + ) + .await + .unwrap_or_default(); + + let mut groups = Vec::new(); + for group_uuid in group_uuids { + if let Ok(Some(group)) = + env_models::fetch_group_by_uuid(&svc_ctx.db, group_uuid).await + { + groups.push(ExtensionGroup { + uuid: group.uuid, + name: group.name, + }); + } + } + + let has_update = ext.version != ge.installed_version; + extension_map.insert( + ge.extension_id.clone(), + InstalledExtensionItem { + extension_id: ge.extension_id, + name: ext.name, + version: ext.version, + installed_version: ge.installed_version, + has_update, + status: ge.status, + installed_at: ge.installed_at, + homepage: ext.homepage, + icon_url, + team_uuid: Some(team_uuid), + scope: "group".to_string(), + description: ext.description, + category: Some(ext.category), + browser: Some(ext.browser), + developer: ext.developer, + downloads_count: ext.downloads_count, + rating: ext.rating, + permissions: ext.permissions, + file_size: ext.file_size, + updated_at: Some(ext.updated_at), + groups: if groups.is_empty() { + None + } else { + Some(groups) + }, + }, + ); + } + } + + Ok(extension_map.into_values().collect()) +} + +/// 安装扩展 +pub async fn install_extension_service( + svc_ctx: &SvcCtx, + user_uuid: Uuid, + team_uuid: Option, + payload: &InstallExtensionRequest, +) -> Result<(), String> { + // 获取扩展信息 + let extension = models::extensions::fetch_extension_by_id(&svc_ctx.db, &payload.extension_id) + .await + .map_err(|e| e.to_string())? + .ok_or_else(|| "扩展不存在".to_string())?; + + let target_type = payload.target_type.as_deref().unwrap_or("user"); + + match target_type { + "user" => { + models::extensions::insert_user_extension( + &svc_ctx.db, + user_uuid, + &payload.extension_id, + &extension.version, + ) + .await + .map_err(|e| e.to_string())?; + } + "team" => { + let team_uuid = team_uuid.ok_or_else(|| "未指定团队".to_string())?; + models::extensions::insert_team_extension( + &svc_ctx.db, + team_uuid, + &payload.extension_id, + &extension.version, + user_uuid, + ) + .await + .map_err(|e| e.to_string())?; + } + "group" => { + // 必须提供分组数组,即使只有一个分组也需要传入数组 + let group_ids = payload.group_ids.as_ref().ok_or_else(|| "未指定分组".to_string())?; + if group_ids.is_empty() { + return Err("分组列表不能为空".to_string()); + } + for group_uuid in group_ids { + models::extensions::insert_group_extension( + &svc_ctx.db, + *group_uuid, + &payload.extension_id, + &extension.version, + user_uuid, + ) + .await + .map_err(|e| e.to_string())?; + } + } + "environment" => { + let env_uuid = payload.env_uuid.ok_or_else(|| "未指定环境".to_string())?; + models::extensions::insert_environment_extension( + &svc_ctx.db, + env_uuid, + &payload.extension_id, + &extension.version, + ) + .await + .map_err(|e| e.to_string())?; + } + _ => return Err("无效的安装目标类型".to_string()), + } + + Ok(()) +} + +/// 卸载扩展 +pub async fn uninstall_extension_service( + svc_ctx: &SvcCtx, + user_uuid: Uuid, + team_uuid: Option, + payload: &UninstallExtensionRequest, +) -> Result<(), String> { + // 无论 target_type 是什么,都先删除所有相关的分组记录 + models::extensions::delete_group_extensions_by_extension_id(&svc_ctx.db, &payload.extension_id) + .await + .map_err(|e| e.to_string())?; + + // 然后根据 target_type 删除对应的记录 + let target_type = payload.target_type.as_deref().unwrap_or("user"); + + match target_type { + "user" => { + models::extensions::delete_user_extension( + &svc_ctx.db, + user_uuid, + &payload.extension_id, + ) + .await + .map_err(|e| e.to_string())?; + } + "team" => { + let team_uuid = team_uuid.ok_or_else(|| "未指定团队".to_string())?; + models::extensions::delete_team_extension( + &svc_ctx.db, + team_uuid, + &payload.extension_id, + ) + .await + .map_err(|e| e.to_string())?; + } + "environment" => { + let env_uuid = payload.target_uuid.ok_or_else(|| "未指定环境".to_string())?; + models::extensions::delete_environment_extension( + &svc_ctx.db, + env_uuid, + &payload.extension_id, + ) + .await + .map_err(|e| e.to_string())?; + } + _ => return Err("无效的卸载目标类型".to_string()), + } + + Ok(()) +} + +/// 更新扩展 +pub async fn update_extension_service( + svc_ctx: &SvcCtx, + user_uuid: Uuid, + extension_id: &str, +) -> Result<(), String> { + // 获取最新扩展信息 + let extension = models::extensions::fetch_extension_by_id(&svc_ctx.db, extension_id) + .await + .map_err(|e| e.to_string())? + .ok_or_else(|| "扩展不存在".to_string())?; + + // 更新用户扩展版本 + models::extensions::insert_user_extension( + &svc_ctx.db, + user_uuid, + extension_id, + &extension.version, + ) + .await + .map_err(|e| e.to_string())?; + + Ok(()) +} + +/// 批量更新扩展 +pub async fn batch_update_extensions_service( + svc_ctx: &SvcCtx, + user_uuid: Uuid, + extension_ids: &[String], +) -> Result { + let mut updated = 0u64; + + for extension_id in extension_ids { + if update_extension_service(svc_ctx, user_uuid, extension_id).await.is_ok() { + updated += 1; + } + } + + Ok(updated) +} diff --git a/src/services/gray_releases.rs b/src/services/gray_releases.rs new file mode 100644 index 0000000..68190ed --- /dev/null +++ b/src/services/gray_releases.rs @@ -0,0 +1,285 @@ +use crate::{ + dto::gray_releases::GrayRelease, entitys::gray_releases::*, errors::SimprintError, + svc_ctx::SvcCtx, +}; + +/// 创建灰度发布 +pub async fn create_gray_release_service( + svc_ctx: &SvcCtx, + request: CreateGrayReleaseRequest, +) -> Result { + let pool = &svc_ctx.db; + let id = crate::models::gray_releases::create_gray_release(pool, &request).await?; + + Ok(id) +} + +/// 查询灰度发布详情 +pub async fn get_gray_release_by_id_service( + svc_ctx: &SvcCtx, + payload: GetGrayReleaseParams, +) -> Result { + let pool = &svc_ctx.db; + let release = crate::models::gray_releases::query_gray_release_by_id(pool, payload.id) + .await + .map_err(|_| SimprintError::GrayReleaseNotFound)?; + + Ok(release) +} + +/// 查询灰度发布列表 +pub async fn query_gray_releases_service( + svc_ctx: &SvcCtx, + params: QueryGrayReleasesParams, +) -> Result { + let pool = &svc_ctx.db; + let (total, list) = crate::models::gray_releases::query_gray_releases( + pool, + params.platform.as_deref(), + params.status.as_deref(), + params.page_num.unwrap_or(1), + params.page_size.unwrap_or(20), + ) + .await?; + + Ok(GrayReleaseListResponse { total, list }) +} + +/// 更新灰度发布 +pub async fn update_gray_release_service( + svc_ctx: &SvcCtx, + request: UpdateGrayReleaseRequest, +) -> Result { + let pool = &svc_ctx.db; + // 检查灰度发布是否存在 + crate::models::gray_releases::query_gray_release_by_id(pool, request.id) + .await + .map_err(|_| SimprintError::GrayReleaseNotFound)?; + + // 更新灰度发布 + let success = + crate::models::gray_releases::update_gray_release(pool, request.id, &request).await?; + + if !success { + return Err(SimprintError::GrayReleaseNotFound); + } + + Ok(true) +} + +/// 启动灰度发布 +pub async fn start_gray_release_service( + svc_ctx: &SvcCtx, + payload: GetGrayReleaseParams, +) -> Result { + let pool = &svc_ctx.db; + let release = crate::models::gray_releases::query_gray_release_by_id(pool, payload.id) + .await + .map_err(|_| SimprintError::GrayReleaseNotFound)?; + + // 检查状态 + if release.status == "active" { + return Ok(true); // 已经是活跃状态 + } + + if release.status != "pending" { + return Err(SimprintError::InvalidRequest(format!( + "灰度发布状态为 {},无法启动", + release.status + ))); + } + + // 更新状态为 active + let request = UpdateGrayReleaseRequest { + id: payload.id, + name: None, + description: None, + status: Some("active".to_string()), + end_time: None, + max_machines: None, + priority: None, + strategy_type: None, + strategy_config: None, + }; + + let success = + crate::models::gray_releases::update_gray_release(pool, payload.id, &request).await?; + + if !success { + return Err(SimprintError::GrayReleaseNotFound); + } + + Ok(true) +} + +/// 暂停灰度发布 +pub async fn pause_gray_release_service( + svc_ctx: &SvcCtx, + payload: GetGrayReleaseParams, +) -> Result { + let pool = &svc_ctx.db; + let release = crate::models::gray_releases::query_gray_release_by_id(pool, payload.id) + .await + .map_err(|_| SimprintError::GrayReleaseNotFound)?; + + // 检查状态 + if release.status != "active" { + return Err(SimprintError::InvalidRequest(format!( + "灰度发布状态为 {},无法暂停", + release.status + ))); + } + + // 更新状态为 paused + let request = UpdateGrayReleaseRequest { + id: payload.id, + name: None, + description: None, + status: Some("paused".to_string()), + end_time: None, + max_machines: None, + priority: None, + strategy_type: None, + strategy_config: None, + }; + + let success = + crate::models::gray_releases::update_gray_release(pool, payload.id, &request).await?; + + if !success { + return Err(SimprintError::GrayReleaseNotFound); + } + + Ok(true) +} + +/// 结束灰度发布 +pub async fn finish_gray_release_service( + svc_ctx: &SvcCtx, + payload: GetGrayReleaseParams, +) -> Result { + let pool = &svc_ctx.db; + let release = crate::models::gray_releases::query_gray_release_by_id(pool, payload.id) + .await + .map_err(|_| SimprintError::GrayReleaseNotFound)?; + + // 检查状态 + if release.status == "finished" { + return Ok(true); // 已经结束 + } + + if !["active", "paused"].contains(&release.status.as_str()) { + return Err(SimprintError::InvalidRequest(format!( + "灰度发布状态为 {},无法结束", + release.status + ))); + } + + // 更新状态为 finished,并设置结束时间 + let request = UpdateGrayReleaseRequest { + id: payload.id, + name: None, + description: None, + status: Some("finished".to_string()), + end_time: Some(chrono::Utc::now()), + max_machines: None, + priority: None, + strategy_type: None, + strategy_config: None, + }; + + let success = + crate::models::gray_releases::update_gray_release(pool, payload.id, &request).await?; + + if !success { + return Err(SimprintError::GrayReleaseNotFound); + } + + Ok(true) +} + +/// 取消灰度发布 +pub async fn cancel_gray_release_service( + svc_ctx: &SvcCtx, + payload: GetGrayReleaseParams, +) -> Result { + let pool = &svc_ctx.db; + let release = crate::models::gray_releases::query_gray_release_by_id(pool, payload.id) + .await + .map_err(|_| SimprintError::GrayReleaseNotFound)?; + + // 检查状态 + if release.status == "cancelled" { + return Ok(true); // 已经取消 + } + + if !["pending", "active", "paused"].contains(&release.status.as_str()) { + return Err(SimprintError::InvalidRequest(format!( + "灰度发布状态为 {},无法取消", + release.status + ))); + } + + // 更新状态为 cancelled + let request = UpdateGrayReleaseRequest { + id: payload.id, + name: None, + description: None, + status: Some("cancelled".to_string()), + end_time: Some(chrono::Utc::now()), + max_machines: None, + priority: None, + strategy_type: None, + strategy_config: None, + }; + + let success = + crate::models::gray_releases::update_gray_release(pool, payload.id, &request).await?; + + if !success { + return Err(SimprintError::GrayReleaseNotFound); + } + + Ok(true) +} + +/// 删除灰度发布(物理删除) +pub async fn delete_gray_release_service( + svc_ctx: &SvcCtx, + payload: GetGrayReleaseParams, +) -> Result { + let pool = &svc_ctx.db; + // 先检查是否存在 + let _release = crate::models::gray_releases::query_gray_release_by_id(pool, payload.id) + .await + .map_err(|_| SimprintError::GrayReleaseNotFound)?; + + // 物理删除(关联的 gray_resources 会通过 ON DELETE CASCADE 自动删除) + let success = crate::models::gray_releases::delete_gray_release(pool, payload.id).await?; + + if !success { + return Err(SimprintError::GrayReleaseNotFound); + } + + Ok(true) +} + +/// 检查白名单策略 +pub async fn check_whitelist_strategy_service( + svc_ctx: &SvcCtx, + machine_code: String, +) -> Result, SimprintError> { + let pool = &svc_ctx.db; + let release = + crate::models::gray_releases::check_whitelist_strategy(pool, &machine_code).await?; + Ok(release) +} + +/// 查询动态策略灰度发布 +pub async fn query_dynamic_strategy_releases_service( + svc_ctx: &SvcCtx, +) -> Result, SimprintError> { + let pool = &svc_ctx.db; + let releases = crate::models::gray_releases::query_dynamic_strategy_releases(pool).await?; + Ok(releases) +} diff --git a/src/services/gray_resources.rs b/src/services/gray_resources.rs new file mode 100644 index 0000000..6819faa --- /dev/null +++ b/src/services/gray_resources.rs @@ -0,0 +1,119 @@ +use crate::{ + dto::gray_resources::GrayResource, entitys::gray_resources::*, errors::SimprintError, + svc_ctx::SvcCtx, +}; + +/// 添加灰度资源 +pub async fn add_gray_resource_service( + svc_ctx: &SvcCtx, + request: CreateGrayResourceRequest, +) -> Result { + let pool = &svc_ctx.db; + // 验证灰度发布是否存在 + crate::models::gray_releases::query_gray_release_by_id(pool, request.gray_release_id) + .await + .map_err(|_| SimprintError::GrayReleaseNotFound)?; + + // 验证版本是否存在 + crate::models::versions::query_version_by_id(pool, request.version_id) + .await + .map_err(|_| SimprintError::VersionNotFound)?; + + // 检查是否已存在该资源 + let existing_resources = crate::models::gray_resources::query_gray_resources_by_release_id( + pool, + request.gray_release_id, + ) + .await?; + + for resource in existing_resources { + if resource.version_id == request.version_id { + return Err(SimprintError::InvalidRequest( + "该版本已存在于灰度资源中".to_string(), + )); + } + } + + let id = crate::models::gray_resources::create_gray_resource(pool, &request).await?; + + Ok(id) +} + +/// 批量添加灰度资源 +pub async fn batch_add_gray_resources_service( + svc_ctx: &SvcCtx, + payload: BatchCreateGrayResourceRequest, +) -> Result { + let pool = &svc_ctx.db; + // 验证灰度发布是否存在 + crate::models::gray_releases::query_gray_release_by_id(pool, payload.gray_release_id) + .await + .map_err(|_| SimprintError::GrayReleaseNotFound)?; + + // 验证所有版本是否存在 + for version_id in &payload.version_ids { + crate::models::versions::query_version_by_id(pool, *version_id) + .await + .map_err(|_| SimprintError::VersionNotFound)?; + } + + // 检查是否有重复的版本ID + let mut seen = std::collections::HashSet::new(); + for version_id in &payload.version_ids { + if !seen.insert(version_id) { + return Err(SimprintError::InvalidRequest(format!( + "版本ID {} 重复", + version_id + ))); + } + } + + let count = crate::models::gray_resources::batch_create_gray_resources( + pool, + payload.gray_release_id, + &payload.version_ids, + ) + .await?; + + Ok(count) +} + +/// 移除灰度资源 +pub async fn remove_gray_resource_service( + svc_ctx: &SvcCtx, + payload: RemoveGrayResourceParams, +) -> Result { + let pool = &svc_ctx.db; + // 检查资源是否存在 + let _resource = crate::models::gray_resources::query_gray_resource_by_id(pool, payload.id) + .await + .map_err(|_| SimprintError::InvalidRequest("灰度资源不存在".to_string()))?; + + let success = crate::models::gray_resources::delete_gray_resource(pool, payload.id).await?; + + if !success { + return Err(SimprintError::InvalidRequest("删除失败".to_string())); + } + + Ok(true) +} + +/// 查询灰度资源列表 +pub async fn query_gray_resources_service( + svc_ctx: &SvcCtx, + payload: GetGrayResourceParams, +) -> Result, SimprintError> { + let pool = &svc_ctx.db; + // 验证灰度发布是否存在 + crate::models::gray_releases::query_gray_release_by_id(pool, payload.gray_release_id) + .await + .map_err(|_| SimprintError::GrayReleaseNotFound)?; + + let resources = crate::models::gray_resources::query_gray_resources_by_release_id( + pool, + payload.gray_release_id, + ) + .await?; + + Ok(resources) +} diff --git a/src/services/group_permissions.rs b/src/services/group_permissions.rs new file mode 100644 index 0000000..697af9a --- /dev/null +++ b/src/services/group_permissions.rs @@ -0,0 +1,139 @@ +use uuid::Uuid; + +use crate::dto::GroupMemberPermissionDto; +use crate::entitys::{ + CheckGroupPermissionRequest, GrantGroupPermissionRequest, ListUserGroupPermissionsRequest, + RevokeGroupPermissionRequest, +}; +use crate::models; +use crate::svc_ctx::SvcCtx; + +/// 授予分组权限 +pub async fn grant_group_permission_service( + svc_ctx: &SvcCtx, + user_uuid: Uuid, + payload: &GrantGroupPermissionRequest, +) -> Result<(), String> { + // 检查权限:只有团队 Owner/Admin 或拥有分组 manage 权限的用户可以授权 + let group = models::fetch_group_by_uuid(&svc_ctx.db, payload.group_uuid) + .await + .map_err(|e| e.to_string())? + .ok_or_else(|| "分组不存在".to_string())?; + + // 检查用户是否是团队成员(工作空间级别) + let team_member = models::fetch_team_member( + &svc_ctx.db, + group.workspace_uuid, + group.team_uuid, + user_uuid, + ) + .await + .map_err(|e| e.to_string())? + .ok_or_else(|| "您不是该团队的成员".to_string())?; + + // Owner/Admin 自动拥有所有权限 + let can_grant = team_member.role == "owner" + || team_member.role == "admin" + || models::check_group_permission( + &svc_ctx.db, + group.workspace_uuid, + payload.group_uuid, + user_uuid, + "manage", + ) + .await + .map_err(|e| e.to_string())?; + + if !can_grant { + return Err("您没有权限授予分组权限".to_string()); + } + + models::grant_group_permission( + &svc_ctx.db, + payload.group_uuid, + group.workspace_uuid, + group.team_uuid, + payload.user_uuid, + &payload.permission_type, + user_uuid, + ) + .await + .map_err(|e| e.to_string()) +} + +/// 撤销分组权限 +pub async fn revoke_group_permission_service( + svc_ctx: &SvcCtx, + user_uuid: Uuid, + payload: &RevokeGroupPermissionRequest, +) -> Result<(), String> { + // 检查权限:只有团队 Owner/Admin 或拥有分组 manage 权限的用户可以撤销权限 + let group = models::fetch_group_by_uuid(&svc_ctx.db, payload.group_uuid) + .await + .map_err(|e| e.to_string())? + .ok_or_else(|| "分组不存在".to_string())?; + + // 检查用户是否是团队成员(工作空间级别) + let team_member = models::fetch_team_member( + &svc_ctx.db, + group.workspace_uuid, + group.team_uuid, + user_uuid, + ) + .await + .map_err(|e| e.to_string())? + .ok_or_else(|| "您不是该团队的成员".to_string())?; + + // Owner/Admin 自动拥有所有权限 + let can_revoke = team_member.role == "owner" + || team_member.role == "admin" + || models::check_group_permission( + &svc_ctx.db, + group.workspace_uuid, + payload.group_uuid, + user_uuid, + "manage", + ) + .await + .map_err(|e| e.to_string())?; + + if !can_revoke { + return Err("您没有权限撤销分组权限".to_string()); + } + + models::revoke_group_permission(&svc_ctx.db, payload.group_uuid, payload.user_uuid) + .await + .map_err(|e| e.to_string()) +} + +/// 检查分组权限 +pub async fn check_group_permission_service( + svc_ctx: &SvcCtx, + workspace_uuid: Uuid, + payload: &CheckGroupPermissionRequest, +) -> Result { + models::check_group_permission( + &svc_ctx.db, + workspace_uuid, + payload.group_uuid, + payload.user_uuid, + &payload.permission_type, + ) + .await + .map_err(|e| e.to_string()) +} + +/// 查询用户的分组权限列表 +pub async fn list_user_group_permissions_service( + svc_ctx: &SvcCtx, + payload: &ListUserGroupPermissionsRequest, +) -> Result, String> { + models::fetch_user_group_permissions( + &svc_ctx.db, + payload.user_uuid, + None, // workspace_uuid 可以从 payload 中获取,如果需要的话 + payload.group_uuid, + ) + .await + .map_err(|e| e.to_string()) +} diff --git a/src/services/groups.rs b/src/services/groups.rs new file mode 100644 index 0000000..fefbf3d --- /dev/null +++ b/src/services/groups.rs @@ -0,0 +1,158 @@ +use uuid::Uuid; + +use crate::dto::GroupDto; +use crate::entitys::{CreateGroupRequest, UpdateGroupRequest}; +use crate::models; +use crate::svc_ctx::SvcCtx; + +/// 创建分组 +pub async fn create_group_service( + svc_ctx: &SvcCtx, + user_uuid: Uuid, + workspace_uuid: Uuid, + team_uuid: Uuid, + payload: &CreateGroupRequest, +) -> Result { + // 1. 检查用户是否在当前工作空间的团队中 + let team_member = models::fetch_team_member(&svc_ctx.db, workspace_uuid, team_uuid, user_uuid) + .await + .map_err(|e| e.to_string())? + .ok_or_else(|| "您不是该团队的成员".to_string())?; + + // 2. 检查用户是否是团队 Owner/Admin(只有 Owner/Admin 可以创建分组) + let can_create = matches!(team_member.role.as_str(), "owner" | "admin"); + if !can_create { + return Err("您没有创建分组的权限,需要 Owner 或 Admin 角色".to_string()); + } + + models::insert_group( + &svc_ctx.db, + workspace_uuid, + team_uuid, + &payload.name, + payload.description.as_deref(), + user_uuid, + ) + .await + .map_err(|e| e.to_string()) +} + +/// 获取分组列表 +pub async fn get_groups_service( + svc_ctx: &SvcCtx, + workspace_uuid: Uuid, + team_uuid: Uuid, + page: i64, + page_size: i64, +) -> Result, String> { + let offset = (page - 1) * page_size; + models::fetch_groups(&svc_ctx.db, workspace_uuid, team_uuid, offset, page_size) + .await + .map_err(|e| e.to_string()) +} + +/// 获取分组详情 +pub async fn get_group_service(svc_ctx: &SvcCtx, group_uuid: Uuid) -> Result { + models::fetch_group_by_uuid(&svc_ctx.db, group_uuid) + .await + .map_err(|e| e.to_string())? + .ok_or_else(|| "分组不存在".to_string()) +} + +/// 更新分组 +pub async fn update_group_service( + svc_ctx: &SvcCtx, + workspace_uuid: Uuid, + team_uuid: Uuid, + user_uuid: Uuid, + payload: &UpdateGroupRequest, +) -> Result<(), String> { + // 1. 检查用户是否在当前工作空间的团队中 + let team_member = models::fetch_team_member(&svc_ctx.db, workspace_uuid, team_uuid, user_uuid) + .await + .map_err(|e| e.to_string())? + .ok_or_else(|| "您不是该团队的成员".to_string())?; + + // 2. 查询分组 + let group = models::fetch_group_by_uuid(&svc_ctx.db, payload.uuid) + .await + .map_err(|e| e.to_string())? + .ok_or_else(|| "分组不存在".to_string())?; + + // 3. 验证分组属于指定工作空间和团队 + if group.workspace_uuid != workspace_uuid || group.team_uuid != team_uuid { + return Err("分组不属于指定工作空间或团队".to_string()); + } + + // 4. 检查用户是否是团队 Owner/Admin 或拥有分组的 manage 权限 + let is_owner_or_admin = matches!(team_member.role.as_str(), "owner" | "admin"); + let has_manage = if !is_owner_or_admin { + models::check_group_permission( + &svc_ctx.db, + workspace_uuid, + payload.uuid, + user_uuid, + "manage", + ) + .await + .map_err(|e| e.to_string())? + } else { + true + }; + + if !has_manage { + return Err("您没有管理该分组的权限".to_string()); + } + + models::update_group( + &svc_ctx.db, + payload.uuid, + payload.name.as_deref(), + payload.description.as_deref(), + payload.sort_order, + ) + .await + .map_err(|e| e.to_string()) +} + +/// 删除分组 +pub async fn delete_group_service( + svc_ctx: &SvcCtx, + workspace_uuid: Uuid, + team_uuid: Uuid, + user_uuid: Uuid, + group_uuid: Uuid, +) -> Result<(), String> { + // 1. 检查用户是否在当前工作空间的团队中 + let team_member = models::fetch_team_member(&svc_ctx.db, workspace_uuid, team_uuid, user_uuid) + .await + .map_err(|e| e.to_string())? + .ok_or_else(|| "您不是该团队的成员".to_string())?; + + // 2. 查询分组 + let group = models::fetch_group_by_uuid(&svc_ctx.db, group_uuid) + .await + .map_err(|e| e.to_string())? + .ok_or_else(|| "分组不存在".to_string())?; + + // 3. 验证分组属于指定工作空间和团队 + if group.workspace_uuid != workspace_uuid || group.team_uuid != team_uuid { + return Err("分组不属于指定工作空间或团队".to_string()); + } + + // 4. 检查用户是否是团队 Owner/Admin 或拥有分组的 manage 权限 + let is_owner_or_admin = matches!(team_member.role.as_str(), "owner" | "admin"); + let has_manage = if !is_owner_or_admin { + models::check_group_permission(&svc_ctx.db, workspace_uuid, group_uuid, user_uuid, "manage") + .await + .map_err(|e| e.to_string())? + } else { + true + }; + + if !has_manage { + return Err("您没有删除该分组的权限".to_string()); + } + + models::delete_group(&svc_ctx.db, group_uuid).await.map_err(|e| e.to_string()) +} diff --git a/src/services/machine_gray_allocations.rs b/src/services/machine_gray_allocations.rs new file mode 100644 index 0000000..a7f316a --- /dev/null +++ b/src/services/machine_gray_allocations.rs @@ -0,0 +1,176 @@ +use crate::{ + dto::machine_gray_allocations::MachineGrayAllocation, entitys::machine_gray_allocations::*, + errors::SimprintError, svc_ctx::SvcCtx, +}; + +/// 分配机器到灰度 +pub async fn allocate_machine_service( + svc_ctx: &SvcCtx, + request: AllocateMachineRequest, +) -> Result { + let pool = &svc_ctx.db; + + // 验证机器码 + if request.machine_code.is_empty() { + return Err(SimprintError::InvalidRequest("机器码不能为空".to_string())); + } + + // 检查灰度发布是否存在 + crate::models::gray_releases::query_gray_release_by_id(pool, request.gray_release_id) + .await + .map_err(|_| SimprintError::GrayReleaseNotFound)?; + + // 检查是否已达到最大配额 + let release = + crate::models::gray_releases::query_gray_release_by_id(pool, request.gray_release_id) + .await?; + + if let Some(max_machines) = release.max_machines { + if release.allocated_count >= max_machines { + return Err(SimprintError::InvalidRequest( + "灰度发布已达到最大配额".to_string(), + )); + } + } + + // 分配机器 + let id = crate::models::machine_gray_allocations::allocate_machine_to_gray( + pool, + &request.machine_code, + request.gray_release_id, + request.effective_time, + request.notes.as_deref(), + ) + .await?; + + // 更新分配计数(如果之前不存在) + let existed = crate::models::machine_gray_allocations::check_machine_allocated( + pool, + &request.machine_code, + request.gray_release_id, + ) + .await?; + + if !existed { + let _ = + crate::models::gray_releases::increment_allocated_count(pool, request.gray_release_id) + .await?; + } + + Ok(id) +} + +/// 批量分配机器 +pub async fn batch_allocate_machines_service( + svc_ctx: &SvcCtx, + request: BatchAllocateMachineRequest, +) -> Result { + let pool = &svc_ctx.db; + + if request.machine_codes.is_empty() { + return Err(SimprintError::InvalidRequest( + "机器码列表不能为空".to_string(), + )); + } + + // 检查灰度发布是否存在 + crate::models::gray_releases::query_gray_release_by_id(pool, request.gray_release_id) + .await + .map_err(|_| SimprintError::GrayReleaseNotFound)?; + + // 分配机器 + let count = crate::models::machine_gray_allocations::batch_allocate_machines_to_gray( + pool, + &request.machine_codes, + request.gray_release_id, + request.effective_time, + request.notes.as_deref(), + ) + .await?; + + // 更新分配计数 + for _ in 0..count { + let _ = + crate::models::gray_releases::increment_allocated_count(pool, request.gray_release_id) + .await?; + } + + Ok(count) +} + +/// 取消机器分配 +pub async fn remove_allocation_service( + svc_ctx: &SvcCtx, + payload: RemoveAllocationRequest, +) -> Result { + let pool = &svc_ctx.db; + + // 移除分配 + let success = crate::models::machine_gray_allocations::deallocate_machine( + pool, + &payload.machine_code, + payload.gray_release_id, + ) + .await?; + + if success { + // 减少分配计数 + let _ = + crate::models::gray_releases::decrement_allocated_count(pool, payload.gray_release_id) + .await?; + } + + Ok(success) +} + +/// 查询机器是否在灰度中 +pub async fn is_machine_in_gray_service( + svc_ctx: &SvcCtx, + payload: IsMachineInGrayRequest, +) -> Result { + let pool = &svc_ctx.db; + + let is_in_gray = crate::models::machine_gray_allocations::check_machine_allocated( + pool, + &payload.machine_code, + payload.gray_release_id, + ) + .await?; + + Ok(is_in_gray) +} + +/// 查询机器的所有活跃分配 +pub async fn get_machine_allocations_service( + svc_ctx: &SvcCtx, + payload: GetMachineAllocationsRequest, +) -> Result, SimprintError> { + let pool = &svc_ctx.db; + + let allocations = crate::models::machine_gray_allocations::query_allocations_by_machine_code( + pool, + &payload.machine_code, + ) + .await?; + + Ok(allocations) +} + +/// 查询灰度发布的所有分配 +pub async fn get_release_allocations_service( + svc_ctx: &SvcCtx, + payload: GetReleaseAllocationsRequest, +) -> Result { + let pool = &svc_ctx.db; + + let (total, list) = + crate::models::machine_gray_allocations::query_allocations_by_gray_release_id( + pool, + payload.gray_release_id, + payload.page_num.unwrap_or(1), + payload.page_size.unwrap_or(20), + ) + .await?; + + Ok(AllocationListResponse { total, list }) +} diff --git a/src/services/machine_users.rs b/src/services/machine_users.rs new file mode 100644 index 0000000..ccaad9a --- /dev/null +++ b/src/services/machine_users.rs @@ -0,0 +1,281 @@ +use crate::{ + dto::machine_users::MachineUser, entitys::machine_users::*, errors::SimprintError, + svc_ctx::SvcCtx, +}; +use serde_json; +use uuid::Uuid; + +/// 注册机器 +pub async fn register_machine_service( + svc_ctx: &SvcCtx, + request: CreateMachineUserRequest, +) -> Result { + let pool = &svc_ctx.db; + if request.machine_code.is_empty() { + return Err(SimprintError::InvalidRequest("机器码不能为空".to_string())); + } + + // 如果提供了用户UUID,检查是否已存在绑定 + if let Some(user_uuid) = request.user_uuid { + let existing = crate::models::machine_users::query_machine_user_by_code_and_user( + pool, + &request.machine_code, + &user_uuid, + ) + .await?; + + if let Some(machine) = existing { + // 已存在,一次性更新绑定时间和版本信息 + let version_info = request + .version_info + .as_ref() + .and_then(|v| serde_json::from_str::(v).ok()); + + crate::models::machine_users::update_machine_bind_and_version( + pool, + &request.machine_code, + &user_uuid, + version_info.as_ref(), + ) + .await?; + + return Ok(machine.id); + } + } + + // 不存在,一次性插入所有信息(包括 version_info) + let id = crate::models::machine_users::insert_machine_user(pool, &request).await?; + Ok(id) +} + +/// 根据机器码查询机器信息 +pub async fn get_machine_by_code_service( + svc_ctx: &SvcCtx, + machine_code: String, +) -> Result { + let pool = &svc_ctx.db; + let machine = crate::models::machine_users::query_machine_user_by_code(pool, &machine_code) + .await + .map_err(|_| SimprintError::MachineNotFound)?; + + Ok(machine) +} + +/// 根据ID查询机器信息 +pub async fn get_machine_by_id_service( + svc_ctx: &SvcCtx, + id: i32, +) -> Result { + let pool = &svc_ctx.db; + let machine = crate::models::machine_users::query_machine_user_by_id(pool, id) + .await? + .ok_or(SimprintError::MachineNotFound)?; + + Ok(machine) +} + +/// 查询用户的机器列表 +pub async fn get_machines_by_user_uuid_service( + svc_ctx: &SvcCtx, + user_uuid: Uuid, +) -> Result, SimprintError> { + let pool = &svc_ctx.db; + + let machines = + crate::models::machine_users::query_machines_by_user_uuid(pool, &user_uuid).await?; + + Ok(machines) +} + +/// 查询机器列表 +pub async fn query_machines_service( + svc_ctx: &SvcCtx, + params: QueryMachinesParams, +) -> Result { + let pool = &svc_ctx.db; + + let user_uuid = params + .user_uuid + .as_ref() + .map(|s| Uuid::parse_str(s)) + .transpose() + .map_err(|_| SimprintError::InvalidRequest("无效的用户UUID格式".to_string()))?; + + let (total, list) = crate::models::machine_users::query_machine_users( + pool, + user_uuid.as_ref(), + params.platform.as_deref(), + params.status.as_deref(), + params.page_num.unwrap_or(1), + params.page_size.unwrap_or(20), + ) + .await?; + + Ok(MachineUserListResponse { total, list }) +} + +/// 更新机器信息 +pub async fn update_machine_info_service( + svc_ctx: &SvcCtx, + request: UpdateMachineUserRequest, +) -> Result { + let pool = &svc_ctx.db; + + // 检查机器是否存在 + crate::models::machine_users::query_machine_user_by_id(pool, request.id) + .await? + .ok_or(SimprintError::MachineNotFound)?; + + // 更新机器信息 + let success = + crate::models::machine_users::update_machine_user(pool, request.id, &request).await?; + + if !success { + return Err(SimprintError::MachineNotFound); + } + + Ok(true) +} + +/// 绑定用户到机器 +pub async fn bind_user_service( + svc_ctx: &SvcCtx, + payload: BindMachineRequest, +) -> Result { + let pool = &svc_ctx.db; + + // 检查机器是否存在 + crate::models::machine_users::query_machine_user_by_code(pool, &payload.machine_code) + .await + .map_err(|_| SimprintError::MachineNotFound)?; + + // 绑定用户 + let success = crate::models::machine_users::bind_user_to_machine( + pool, + &payload.machine_code, + &payload.user_uuid, + ) + .await?; + + if !success { + return Err(SimprintError::MachineNotFound); + } + + Ok(true) +} + +/// 解绑用户 +pub async fn unbind_user_service( + svc_ctx: &SvcCtx, + payload: BindMachineRequest, +) -> Result { + let pool = &svc_ctx.db; + + // 检查绑定是否存在 + let binding = crate::models::machine_users::query_machine_user_by_code_and_user( + pool, + &payload.machine_code, + &payload.user_uuid, + ) + .await?; + + if binding.is_none() { + return Err(SimprintError::MachineNotBound); + } + + // 解绑用户 + let success = crate::models::machine_users::unbind_user_from_machine( + pool, + &payload.machine_code, + &payload.user_uuid, + ) + .await?; + + if !success { + return Err(SimprintError::MachineNotFound); + } + + Ok(true) +} + +/// 获取机器版本信息 +pub async fn get_machine_version_info_service( + svc_ctx: &SvcCtx, + machine_code: String, + user_uuid: Uuid, +) -> Result { + let pool = &svc_ctx.db; + + // 查询特定用户的机器记录 + let machine = crate::models::machine_users::query_machine_user_by_code_and_user( + pool, + &machine_code, + &user_uuid, + ) + .await? + .ok_or(SimprintError::MachineNotBound)?; + + Ok(machine.version_info.unwrap_or(serde_json::json!({}))) +} + +/// 拉黑机器 +pub async fn allow_or_blacklist_machine_service( + svc_ctx: &SvcCtx, + payload: AllowOrBlacklistMachineRequest, +) -> Result { + let pool = &svc_ctx.db; + let success = crate::models::machine_users::allow_or_blacklist_machine( + pool, + &payload.machine_code, + payload.allow, + ) + .await?; + + Ok(success) +} + +/// 判断机器是否允许 +pub async fn machine_not_allow_service( + svc_ctx: &SvcCtx, + machine_code: String, +) -> Result { + let pool = &svc_ctx.db; + let allow = crate::models::machine_users::machine_not_allow(pool, &machine_code).await?; + + Ok(allow) +} + +/// 更新机器版本信息 +pub async fn update_machine_version_info_service( + svc_ctx: &SvcCtx, + payload: UpdateMachineVersionRequest, +) -> Result { + let pool = &svc_ctx.db; + + // 检查机器是否存在 + crate::models::machine_users::query_machine_user_by_code(pool, &payload.machine_code) + .await + .map_err(|_| SimprintError::MachineNotFound)?; + + // 1. 更新整机的 version_info(所有用户记录) + let success = crate::models::machine_users::update_machine_version_info( + pool, + &payload.machine_code, + &payload.version_info, + ) + .await?; + + if !success { + return Err(SimprintError::MachineNotFound); + } + + // 2. 只更新当前用户的 updated_at + let _ = crate::models::machine_users::update_user_bind_time( + pool, + &payload.machine_code, + &payload.user_uuid, + ) + .await?; + + Ok(true) +} diff --git a/src/services/maintenance.rs b/src/services/maintenance.rs new file mode 100644 index 0000000..2099f27 --- /dev/null +++ b/src/services/maintenance.rs @@ -0,0 +1,74 @@ +use crate::{ + dto::maintenance::Maintenance, entitys::maintenance::*, errors::SimprintError, svc_ctx::SvcCtx, +}; + +/// 创建维护 +pub async fn create_maintenance_service( + svc_ctx: &SvcCtx, + request: CreateMaintenanceRequest, +) -> Result { + let pool = &svc_ctx.db; + let maintenance = crate::models::maintenance::create_maintenance(pool, request).await?; + + Ok(maintenance.id) +} + +/// 根据ID查询维护 +pub async fn get_maintenance_by_id_service( + svc_ctx: &SvcCtx, + payload: GetMaintenanceParams, +) -> Result { + let pool = &svc_ctx.db; + let maintenance = crate::models::maintenance::get_maintenance_by_id(pool, payload.id) + .await? + .ok_or(SimprintError::MaintenanceNotFound)?; + + Ok(maintenance) +} + +/// 查询维护列表 +pub async fn list_maintenances_service( + svc_ctx: &SvcCtx, + payload: QueryMaintenancesParams, +) -> Result { + let pool = &svc_ctx.db; + let maintenances = crate::models::maintenance::list_maintenances( + pool, + payload.limit.map(|l| l as i64), + payload.offset.map(|o| o as i64), + ) + .await?; + + Ok(MaintenanceListResponse { list: maintenances }) +} + +/// 更新维护状态 +pub async fn update_maintenance_status_service( + svc_ctx: &SvcCtx, + payload: UpdateMaintenanceStatusRequest, +) -> Result { + let pool = &svc_ctx.db; + let success = + crate::models::maintenance::update_maintenance_status(pool, payload.id, &payload.status) + .await?; + + Ok(success) +} + +/// 结束维护 +pub async fn end_maintenance_service(svc_ctx: &SvcCtx) -> Result { + let pool = &svc_ctx.db; + let success = crate::models::maintenance::end_maintenance(pool).await?; + + Ok(success) +} + +/// 获取当前活跃维护 +pub async fn get_active_maintenance_service( + svc_ctx: &SvcCtx, +) -> Result, SimprintError> { + let pool = &svc_ctx.db; + let maintenance = crate::models::maintenance::get_active_maintenances(pool).await?; + + Ok(maintenance) +} diff --git a/src/services/messages.rs b/src/services/messages.rs new file mode 100644 index 0000000..a5cf2f5 --- /dev/null +++ b/src/services/messages.rs @@ -0,0 +1,187 @@ +use uuid::Uuid; + +// DTOs are used through entitys +use crate::entitys::{ + BatchMarkReadRequest, CreateMessageRequest, HandleMessageRequest, ListMessagesRequest, + MarkMessageReadRequest, MessageListResponse, MessageStatsResponse, +}; +use crate::models; +use crate::svc_ctx::SvcCtx; + +/// 创建消息 +pub async fn create_message_service( + svc_ctx: &SvcCtx, + sender_uuid: Option, + payload: &CreateMessageRequest, +) -> Result { + let priority = payload.priority.as_deref().unwrap_or("normal"); + + // 创建消息 + let message_uuid = models::create_message( + &svc_ctx.db, + sender_uuid, + &payload.message_type, + &payload.title, + payload.content.as_deref(), + &payload.recipient_type, + payload.related_type.as_deref(), + payload.related_uuid, + priority, + payload.metadata.clone(), + ) + .await + .map_err(|e| e.to_string())?; + + // 根据接收者类型添加接收者 + match payload.recipient_type.as_str() { + "single" | "multiple" => { + if payload.recipient_uuids.is_empty() { + return Err("接收者列表不能为空".to_string()); + } + + // 对于邀请类消息,设置 action_status 为 pending + let action_status = if payload.message_type == "team_invitation" { + Some("pending") + } else { + None + }; + + models::add_message_recipients( + &svc_ctx.db, + message_uuid, + &payload.recipient_uuids, + action_status, + ) + .await + .map_err(|e| e.to_string())?; + } + "team" => { + // 团队消息由数据库触发器自动分发,无需手动添加 + if payload.related_type.as_deref() != Some("team") || payload.related_uuid.is_none() { + return Err("团队消息必须指定 related_type='team' 和 related_uuid".to_string()); + } + } + "all" => { + // 系统广播消息,需要为所有用户创建关联记录 + // 这里可以通过应用层逻辑实现,或者使用数据库触发器 + // 暂时返回错误,提示需要特殊处理 + return Err("系统广播消息暂不支持".to_string()); + } + _ => { + return Err(format!("不支持的接收者类型: {}", payload.recipient_type)); + } + } + + Ok(message_uuid) +} + +/// 获取用户消息列表 +pub async fn get_user_messages_service( + svc_ctx: &SvcCtx, + user_uuid: Uuid, + payload: &ListMessagesRequest, +) -> Result { + let offset = (payload.pagination.page - 1) * payload.pagination.page_size; + + let filters = payload.filters.as_ref(); + + let messages = models::fetch_user_messages( + &svc_ctx.db, + user_uuid, + offset, + payload.pagination.page_size, + filters.and_then(|f| f.message_type.as_deref()), + filters.and_then(|f| f.is_read), + filters.and_then(|f| f.action_status.as_deref()), + filters.and_then(|f| f.priority.as_deref()), + ) + .await + .map_err(|e| e.to_string())?; + + let total = models::fetch_user_messages_count( + &svc_ctx.db, + user_uuid, + filters.and_then(|f| f.message_type.as_deref()), + filters.and_then(|f| f.is_read), + filters.and_then(|f| f.action_status.as_deref()), + filters.and_then(|f| f.priority.as_deref()), + ) + .await + .map_err(|e| e.to_string())?; + + Ok(MessageListResponse { + items: messages, + total, + page: payload.pagination.page, + page_size: payload.pagination.page_size, + }) +} + +/// 标记消息为已读 +pub async fn mark_message_read_service( + svc_ctx: &SvcCtx, + user_uuid: Uuid, + payload: &MarkMessageReadRequest, +) -> Result<(), String> { + models::mark_message_read(&svc_ctx.db, payload.message_uuid, user_uuid) + .await + .map_err(|e| e.to_string()) +} + +/// 批量标记消息为已读 +pub async fn batch_mark_messages_read_service( + svc_ctx: &SvcCtx, + user_uuid: Uuid, + payload: &BatchMarkReadRequest, +) -> Result<(), String> { + if payload.message_uuids.is_empty() { + return Err("消息列表不能为空".to_string()); + } + + models::batch_mark_messages_read(&svc_ctx.db, &payload.message_uuids, user_uuid) + .await + .map_err(|e| e.to_string()) +} + +/// 处理消息(接受/拒绝) +pub async fn handle_message_service( + svc_ctx: &SvcCtx, + user_uuid: Uuid, + payload: &HandleMessageRequest, +) -> Result<(), String> { + if payload.action != "accept" && payload.action != "reject" { + return Err("操作类型必须是 'accept' 或 'reject'".to_string()); + } + + models::handle_message( + &svc_ctx.db, + payload.message_uuid, + user_uuid, + &payload.action, + ) + .await + .map_err(|e| e.to_string()) +} + +/// 获取用户消息统计 +pub async fn get_user_message_stats_service( + svc_ctx: &SvcCtx, + user_uuid: Uuid, +) -> Result { + let (total, unread, by_type) = models::fetch_user_message_stats(&svc_ctx.db, user_uuid) + .await + .map_err(|e| e.to_string())?; + + Ok(MessageStatsResponse { + total, + unread, + by_type, + }) +} + +/// 删除消息 +pub async fn delete_message_service(svc_ctx: &SvcCtx, message_uuid: Uuid) -> Result<(), String> { + models::delete_message(&svc_ctx.db, message_uuid) + .await + .map_err(|e| e.to_string()) +} diff --git a/src/services/orders.rs b/src/services/orders.rs new file mode 100644 index 0000000..49d44b4 --- /dev/null +++ b/src/services/orders.rs @@ -0,0 +1,83 @@ +use uuid::Uuid; + +use crate::dto::PaymentOrderDto; +use crate::entitys::{CreateRechargeOrderRequest, ListPaymentOrdersRequest}; +use crate::models; +use crate::svc_ctx::SvcCtx; + +/// 创建充值订单 +pub async fn create_recharge_order_service( + svc_ctx: &SvcCtx, + user_uuid: Uuid, + payload: &CreateRechargeOrderRequest, +) -> Result<(Uuid, String), String> { + let order_no = format!("RCH-{}", chrono::Utc::now().format("%Y%m%d%H%M%S%f")); + + let order_uuid = models::billing::insert_payment_order( + &svc_ctx.db, + &order_no, + user_uuid, + "recharge", + payload.amount, + "CNY", + Some(&payload.payment_channel), + Some("钱包充值"), + None, + None, + None, + None, + ) + .await + .map_err(|e| e.to_string())?; + + Ok((order_uuid, order_no)) +} + +/// 获取支付订单列表 +pub async fn get_payment_orders_service( + svc_ctx: &SvcCtx, + user_uuid: Uuid, + payload: &ListPaymentOrdersRequest, +) -> Result<(Vec, i64), String> { + let offset = (payload.pagination.page - 1) * payload.pagination.page_size; + + let orders = models::billing::fetch_payment_orders( + &svc_ctx.db, + user_uuid, + payload.order_type.as_deref(), + payload.status.as_deref(), + offset, + payload.pagination.page_size, + ) + .await + .map_err(|e| e.to_string())?; + + let total = models::billing::fetch_payment_orders_count( + &svc_ctx.db, + user_uuid, + payload.order_type.as_deref(), + payload.status.as_deref(), + ) + .await + .map_err(|e| e.to_string())?; + + Ok((orders, total)) +} + +/// 查询订单状态 +pub async fn get_order_status_service( + svc_ctx: &SvcCtx, + user_uuid: Uuid, + order_uuid: Uuid, +) -> Result { + let order = models::billing::fetch_payment_order_by_uuid(&svc_ctx.db, order_uuid) + .await + .map_err(|e| e.to_string())? + .ok_or_else(|| "订单不存在".to_string())?; + + if order.user_uuid != user_uuid { + return Err("无权查看此订单".to_string()); + } + + Ok(order) +} diff --git a/src/services/plans.rs b/src/services/plans.rs new file mode 100644 index 0000000..be6f081 --- /dev/null +++ b/src/services/plans.rs @@ -0,0 +1,152 @@ +use rust_decimal::Decimal; +use uuid::Uuid; + +use crate::dto::{CouponDto, PlanDto, PlanFeatureDto}; +use crate::entitys::{GetPlanPriceRequest, PlanPriceResponse, VerifyCouponRequest}; +use crate::models; +use crate::services::coupons::validate_coupon_service; +use crate::svc_ctx::SvcCtx; + +/// 带特性的套餐结构 +#[derive(Debug, Clone)] +pub struct PlanWithFeatures { + pub plan: PlanDto, + pub features: Vec, + pub calculated_price: Option, +} + +/// 获取套餐列表(包含特性) +pub async fn get_plans_service( + svc_ctx: &SvcCtx, + user_uuid: Uuid, + coupon_code: Option<&str>, + billing_period: &str, +) -> Result, String> { + let plans = models::billing::fetch_plans(&svc_ctx.db).await.map_err(|e| e.to_string())?; + + let mut plans_with_features = Vec::new(); + + for plan in plans { + let features = models::billing::fetch_plan_features(&svc_ctx.db, plan.uuid) + .await + .map_err(|e| e.to_string())?; + + // 如果提供了优惠券代码,计算价格 + let calculated_price = if let Some(code) = coupon_code { + let price_result = calculate_plan_price_service( + svc_ctx, + user_uuid, + &GetPlanPriceRequest { + plan_uuid: plan.uuid, + billing_period: billing_period.to_string(), + coupon_code: Some(code.to_string()), + }, + ) + .await + .ok(); // 如果计算失败,返回 None,不影响套餐列表返回 + + price_result.map(|price| crate::entitys::PlanPriceInfo { + original_price: price.original_price, + plan_discount: price.plan_discount, + coupon_discount: price.coupon_discount, + final_price: price.final_price, + total_saved: price.total_saved, + billing_period: billing_period.to_string(), + }) + } else { + None + }; + + plans_with_features.push(PlanWithFeatures { + plan, + features, + calculated_price, + }); + } + + Ok(plans_with_features) +} + +/// 获取套餐详情(包含特性) +pub async fn get_plan_detail_service( + svc_ctx: &SvcCtx, + plan_uuid: Uuid, +) -> Result<(PlanDto, Vec), String> { + let plan = models::billing::fetch_plan_by_uuid(&svc_ctx.db, plan_uuid) + .await + .map_err(|e| e.to_string())? + .ok_or_else(|| "套餐不存在".to_string())?; + + let features = models::billing::fetch_plan_features(&svc_ctx.db, plan_uuid) + .await + .map_err(|e| e.to_string())?; + + Ok((plan, features)) +} + +/// 计算套餐最终价格 +pub async fn calculate_plan_price_service( + svc_ctx: &SvcCtx, + user_uuid: Uuid, + payload: &GetPlanPriceRequest, +) -> Result { + // 1. 获取套餐信息 + let plan = models::billing::fetch_plan_by_uuid(&svc_ctx.db, payload.plan_uuid) + .await + .map_err(|e| e.to_string())? + .ok_or_else(|| "套餐不存在".to_string())?; + + if plan.status != "active" { + return Err("该套餐已下架".to_string()); + } + + // 2. 计算基础价格(考虑套餐级折扣) + let (base_price, plan_discount_percent) = match payload.billing_period.as_str() { + "monthly" => ( + plan.price_per_month, + plan.discount_monthly.unwrap_or(Decimal::ZERO), + ), + "yearly" => ( + plan.price_per_year, + plan.discount_yearly.unwrap_or(Decimal::ZERO), + ), + _ => return Err("无效的计费周期".to_string()), + }; + + // 3. 应用套餐级折扣 + let price_after_plan_discount = + base_price * (Decimal::from(100) - plan_discount_percent) / Decimal::from(100); + let plan_discount = base_price - price_after_plan_discount; + + // 4. 验证并应用优惠券(如果有) + let mut coupon_discount = Decimal::ZERO; + let mut coupon_info: Option = None; + + if let Some(coupon_code) = &payload.coupon_code { + let coupon_result = validate_coupon_service( + svc_ctx, + user_uuid, + &VerifyCouponRequest { + code: coupon_code.clone(), + amount: price_after_plan_discount, // 在套餐折扣后的价格上应用优惠券 + }, + ) + .await?; + + coupon_discount = coupon_result.discount_amount; + coupon_info = Some(coupon_result.coupon); + } + + // 5. 计算最终价格 + let final_price = price_after_plan_discount - coupon_discount; + let total_saved = plan_discount + coupon_discount; + + Ok(PlanPriceResponse { + original_price: base_price, + plan_discount, + coupon_discount, + final_price: final_price.max(Decimal::ZERO), // 确保价格不为负 + total_saved, + coupon_info, + }) +} diff --git a/src/services/preferences.rs b/src/services/preferences.rs new file mode 100644 index 0000000..eb08873 --- /dev/null +++ b/src/services/preferences.rs @@ -0,0 +1,49 @@ +use uuid::Uuid; + +use crate::dto::UserPreferenceDto; +use crate::entitys::settings::UpdatePreferencesRequest; +use crate::models; +use crate::svc_ctx::SvcCtx; + +/// 获取用户偏好设置 +pub async fn get_preferences_service( + svc_ctx: &SvcCtx, + user_uuid: Uuid, +) -> Result { + let preferences = models::preferences::fetch_user_preferences(&svc_ctx.db, user_uuid) + .await + .map_err(|e| e.to_string())?; + + // 如果不存在,创建默认设置 + if preferences.is_none() { + models::preferences::upsert_user_preferences(&svc_ctx.db, user_uuid, None, None, None) + .await + .map_err(|e| e.to_string())?; + + return models::preferences::fetch_user_preferences(&svc_ctx.db, user_uuid) + .await + .map_err(|e| e.to_string())? + .ok_or_else(|| "创建偏好设置失败".to_string()); + } + + preferences.ok_or_else(|| "偏好设置不存在".to_string()) +} + +/// 更新用户偏好设置 +pub async fn update_preferences_service( + svc_ctx: &SvcCtx, + user_uuid: Uuid, + payload: &UpdatePreferencesRequest, +) -> Result { + models::preferences::upsert_user_preferences( + &svc_ctx.db, + user_uuid, + payload.theme.as_deref(), + payload.language.as_deref(), + payload.notifications_enabled, + ) + .await + .map_err(|e| e.to_string())?; + + get_preferences_service(svc_ctx, user_uuid).await +} diff --git a/src/services/proxies.rs b/src/services/proxies.rs new file mode 100644 index 0000000..65ae97d --- /dev/null +++ b/src/services/proxies.rs @@ -0,0 +1,249 @@ +use uuid::Uuid; + +use crate::dto::ProxyDto; +use crate::entitys::{ + BatchImportProxiesRequest, CreateProxyRequest, ListProxiesRequest, UpdateProxyRequest, +}; +use crate::models; +use crate::svc_ctx::SvcCtx; +use crate::utils::encryption::{encrypt_password, get_encryption_key}; + +/// 创建代理 +pub async fn create_proxy_service( + svc_ctx: &SvcCtx, + owner_uuid: Uuid, + workspace_uuid: Uuid, + payload: &CreateProxyRequest, +) -> Result { + // 1. 检查工作空间代理配额是否充足 + let quota_available = models::check_quota(&svc_ctx.db, workspace_uuid, "proxies") + .await + .map_err(|e| e.to_string())?; + if !quota_available { + return Err("工作空间代理配额不足,无法创建新代理".to_string()); + } + + // 2. 加密密码 + let password_encrypted = if let Some(password) = &payload.password { + let key = get_encryption_key(); + Some(encrypt_password(password, &key)?) + } else { + None + }; + + // 3. 创建代理 + let proxy_uuid = models::insert_proxy( + &svc_ctx.db, + workspace_uuid, + owner_uuid, + &payload.name, + &payload.host, + payload.port, + &payload.proxy_type, + payload.username.as_deref(), + password_encrypted.as_deref(), + ) + .await + .map_err(|e| e.to_string())?; + + // 4. 更新工作空间配额(创建后增加使用数) + models::increment_used_proxies(&svc_ctx.db, workspace_uuid, 1) + .await + .map_err(|e| format!("更新配额失败: {}", e))?; + + Ok(proxy_uuid) +} + +/// 获取代理列表(根据可见性过滤) +pub async fn get_proxies_service( + svc_ctx: &SvcCtx, + user_uuid: Uuid, + workspace_uuid: Uuid, + payload: &ListProxiesRequest, +) -> Result<(Vec, i64), String> { + // 获取用户当前团队(用于过滤可见的代理) + let current_team_uuid = models::fetch_user_current_team(&svc_ctx.db, user_uuid) + .await + .map_err(|e| e.to_string())?; + + // 使用可见性过滤获取代理列表 + let proxies = models::fetch_visible_proxies_for_user( + &svc_ctx.db, + workspace_uuid, + user_uuid, + current_team_uuid, + ) + .await + .map_err(|e| e.to_string())?; + + // 应用类型和状态过滤 + let proxy_type = payload.filters.as_ref().and_then(|f| f.proxy_type.as_deref()); + let status = payload.filters.as_ref().and_then(|f| f.status.as_deref()); + + let mut filtered_proxies: Vec = proxies + .into_iter() + .filter(|p| { + (proxy_type.is_none() || p.proxy_type == proxy_type.unwrap()) + && (status.is_none() || p.status == status.unwrap()) + }) + .collect(); + + // 应用分页 + let total = filtered_proxies.len() as i64; + let offset = ((payload.pagination.page - 1) * payload.pagination.page_size) as usize; + let limit = payload.pagination.page_size as usize; + let paginated_proxies = if offset < filtered_proxies.len() { + filtered_proxies + .drain(offset..std::cmp::min(offset + limit, filtered_proxies.len())) + .collect() + } else { + vec![] + }; + + Ok((paginated_proxies, total)) +} + +/// 获取代理详情 +pub async fn get_proxy_service(svc_ctx: &SvcCtx, proxy_uuid: Uuid) -> Result { + models::fetch_proxy_by_uuid(&svc_ctx.db, proxy_uuid) + .await + .map_err(|e| e.to_string())? + .ok_or_else(|| "代理不存在".to_string()) +} + +/// 更新代理 +pub async fn update_proxy_service( + svc_ctx: &SvcCtx, + payload: &UpdateProxyRequest, +) -> Result<(), String> { + // 加密密码 + let password_encrypted = if let Some(password) = &payload.password { + let key = get_encryption_key(); + Some(encrypt_password(password, &key)?) + } else { + None + }; + + models::update_proxy( + &svc_ctx.db, + payload.uuid, + payload.name.as_deref(), + payload.host.as_deref(), + payload.port, + payload.proxy_type.as_deref(), + payload.username.as_deref(), + password_encrypted.as_deref(), + ) + .await + .map_err(|e| e.to_string()) +} + +/// 删除代理 +pub async fn delete_proxy_service(svc_ctx: &SvcCtx, proxy_uuid: Uuid) -> Result<(), String> { + // 1. 获取代理信息(用于获取 workspace_uuid) + let proxy = models::fetch_proxy_by_uuid(&svc_ctx.db, proxy_uuid) + .await + .map_err(|e| e.to_string())? + .ok_or_else(|| "代理不存在".to_string())?; + + let workspace_uuid = proxy.workspace_uuid; + + // 2. 删除代理 + models::delete_proxy(&svc_ctx.db, proxy_uuid).await.map_err(|e| e.to_string())?; + + // 3. 更新工作空间配额(删除后减少使用数) + models::decrement_used_proxies(&svc_ctx.db, workspace_uuid, 1) + .await + .map_err(|e| format!("更新配额失败: {}", e))?; + + Ok(()) +} + +/// 批量删除代理 +pub async fn batch_delete_proxies_service( + svc_ctx: &SvcCtx, + proxy_uuids: &[Uuid], +) -> Result { + models::batch_delete_proxies(&svc_ctx.db, proxy_uuids) + .await + .map_err(|e| e.to_string()) +} + +/// 批量导入代理 +/// +/// 接收客户端已解析好的代理列表,直接保存到数据库 +pub async fn batch_import_proxies_service( + svc_ctx: &SvcCtx, + owner_uuid: Uuid, + workspace_uuid: Uuid, + payload: &BatchImportProxiesRequest, +) -> Result { + // 1. 检查工作空间代理配额是否充足(检查是否有足够配额导入所有代理) + let import_count = payload.proxies.len() as i32; + let quota = models::fetch_workspace_quota(&svc_ctx.db, workspace_uuid) + .await + .map_err(|e| e.to_string())? + .ok_or_else(|| "工作空间配额不存在".to_string())?; + + if quota.used_proxies + import_count > quota.max_proxies { + return Err(format!( + "工作空间代理配额不足,当前已使用 {}/{},无法导入 {} 个代理", + quota.used_proxies, quota.max_proxies, import_count + )); + } + + let mut success_count = 0; + let mut failed_count = 0; + let mut errors: Vec = vec![]; + let key = get_encryption_key(); + + for (index, proxy) in payload.proxies.iter().enumerate() { + // 加密密码 + let password_encrypted = if let Some(pwd) = &proxy.password { + match encrypt_password(pwd, &key) { + Ok(encrypted) => Some(encrypted), + Err(e) => { + failed_count += 1; + errors.push(format!("第 {} 项密码加密失败: {}", index + 1, e)); + continue; + } + } + } else { + None + }; + + let result = models::insert_proxy( + &svc_ctx.db, + workspace_uuid, + owner_uuid, + &proxy.name, + &proxy.host, + proxy.port, + &proxy.proxy_type, + proxy.username.as_deref(), + password_encrypted.as_deref(), + ) + .await; + + match result { + Ok(_) => { + success_count += 1; + // 更新配额(每成功导入一个代理就增加配额使用数) + if let Err(e) = models::increment_used_proxies(&svc_ctx.db, workspace_uuid, 1).await + { + errors.push(format!("第 {} 项导入成功但更新配额失败: {}", index + 1, e)); + } + } + Err(e) => { + failed_count += 1; + errors.push(format!("第 {} 项: {}", index + 1, e)); + } + } + } + + Ok(crate::entitys::BatchImportResponse { + success_count, + failed_count, + errors, + }) +} diff --git a/src/services/proxy_visibility.rs b/src/services/proxy_visibility.rs new file mode 100644 index 0000000..7c126fd --- /dev/null +++ b/src/services/proxy_visibility.rs @@ -0,0 +1,143 @@ +use uuid::Uuid; + +use crate::dto::ProxyDto; +use crate::entitys::{ + BatchSetProxyVisibleRequest, ListVisibleProxiesRequest, + RemoveProxyVisibleRequest, SetProxyVisibleRequest, +}; +use crate::models; +use crate::svc_ctx::SvcCtx; + +/// 设置代理对团队可见 +pub async fn set_proxy_visible_to_team_service( + svc_ctx: &SvcCtx, + user_uuid: Uuid, + payload: &SetProxyVisibleRequest, +) -> Result<(), String> { + // 检查权限:只有代理所有者或工作空间所有者可以设置可见性 + let proxy = models::fetch_proxy_by_uuid(&svc_ctx.db, payload.proxy_uuid) + .await + .map_err(|e| e.to_string())? + .ok_or_else(|| "代理不存在".to_string())?; + + let is_owner = proxy.owner_uuid == user_uuid; + let is_workspace_owner = + models::check_workspace_owner(&svc_ctx.db, proxy.workspace_uuid, user_uuid) + .await + .map_err(|e| e.to_string())?; + + if !is_owner && !is_workspace_owner { + return Err("只有代理所有者或工作空间所有者可以设置可见性".to_string()); + } + + // 获取团队的工作空间 + let team = models::fetch_team_by_uuid(&svc_ctx.db, payload.team_uuid) + .await + .map_err(|e| e.to_string())? + .ok_or_else(|| "团队不存在".to_string())?; + + if team.workspace_uuid != proxy.workspace_uuid { + return Err("团队和工作空间不匹配".to_string()); + } + + models::insert_proxy_visible_team( + &svc_ctx.db, + payload.proxy_uuid, + proxy.workspace_uuid, + payload.team_uuid, + ) + .await + .map_err(|e| e.to_string()) +} + +/// 移除代理对团队的可见性 +pub async fn remove_proxy_visible_from_team_service( + svc_ctx: &SvcCtx, + user_uuid: Uuid, + payload: &RemoveProxyVisibleRequest, +) -> Result<(), String> { + // 检查权限:只有代理所有者或工作空间所有者可以移除可见性 + let proxy = models::fetch_proxy_by_uuid(&svc_ctx.db, payload.proxy_uuid) + .await + .map_err(|e| e.to_string())? + .ok_or_else(|| "代理不存在".to_string())?; + + let is_owner = proxy.owner_uuid == user_uuid; + let is_workspace_owner = + models::check_workspace_owner(&svc_ctx.db, proxy.workspace_uuid, user_uuid) + .await + .map_err(|e| e.to_string())?; + + if !is_owner && !is_workspace_owner { + return Err("只有代理所有者或工作空间所有者可以移除可见性".to_string()); + } + + models::remove_proxy_visible_team(&svc_ctx.db, payload.proxy_uuid, payload.team_uuid) + .await + .map_err(|e| e.to_string()) +} + +/// 批量设置代理可见性 +pub async fn batch_set_proxy_visible_service( + svc_ctx: &SvcCtx, + user_uuid: Uuid, + payload: &BatchSetProxyVisibleRequest, +) -> Result<(), String> { + // 检查权限 + let proxy = models::fetch_proxy_by_uuid(&svc_ctx.db, payload.proxy_uuid) + .await + .map_err(|e| e.to_string())? + .ok_or_else(|| "代理不存在".to_string())?; + + let is_owner = proxy.owner_uuid == user_uuid; + let is_workspace_owner = + models::check_workspace_owner(&svc_ctx.db, proxy.workspace_uuid, user_uuid) + .await + .map_err(|e| e.to_string())?; + + if !is_owner && !is_workspace_owner { + return Err("只有代理所有者或工作空间所有者可以设置可见性".to_string()); + } + + // 批量设置可见性 + for team_uuid in &payload.team_uuids { + models::insert_proxy_visible_team( + &svc_ctx.db, + payload.proxy_uuid, + proxy.workspace_uuid, + *team_uuid, + ) + .await + .map_err(|e| e.to_string())?; + } + + Ok(()) +} + +/// 获取可见的代理列表 +pub async fn get_visible_proxies_service( + svc_ctx: &SvcCtx, + user_uuid: Uuid, + payload: &ListVisibleProxiesRequest, +) -> Result, String> { + models::fetch_visible_proxies_for_user( + &svc_ctx.db, + payload.workspace_uuid, + user_uuid, + payload.team_uuid, + ) + .await + .map_err(|e| e.to_string()) +} + +/// 检查代理可见性 +pub async fn check_proxy_visibility_service( + svc_ctx: &SvcCtx, + proxy_uuid: Uuid, + workspace_uuid: Uuid, + team_uuid: Uuid, +) -> Result { + models::check_proxy_visibility(&svc_ctx.db, proxy_uuid, workspace_uuid, team_uuid) + .await + .map_err(|e| e.to_string()) +} diff --git a/src/services/referral.rs b/src/services/referral.rs new file mode 100644 index 0000000..beaa1ff --- /dev/null +++ b/src/services/referral.rs @@ -0,0 +1,416 @@ +use rust_decimal::prelude::ToPrimitive; +use rust_decimal::Decimal; +use uuid::Uuid; + +use crate::dto::{ + RedeemOptionDto, RedeemRecordDto, ReferralLinkDto, ReferralLinkTierDto, ReferralRewardDto, + ReferredUserItemDto, UserReferralPointsDto, +}; +use crate::entitys::{ + ListReferralRewardsRequest, ListReferredUsersRequest, Pagination, RedeemPointsRequest, + ReferralDashboardResponse, ReferralPointsSummary, ReferralStatsResponse, +}; +use crate::models; +use crate::svc_ctx::SvcCtx; +use crate::services::subscriptions::get_current_subscription_service; + +/// 获取推广统计 +pub async fn get_referral_stats_service( + svc_ctx: &SvcCtx, + user_uuid: Uuid, +) -> Result { + // 获取基本统计数据 + let (total_referrals, paid_referrals, total_consumption, last_30_days_consumption) = + models::referral::fetch_referral_stats(&svc_ctx.db, user_uuid) + .await + .map_err(|e| e.to_string())?; + + // 获取总奖励积分 + let total_rewards = models::referral::fetch_total_reward_points(&svc_ctx.db, user_uuid) + .await + .map_err(|e| e.to_string())?; + + // 获取用户积分 + let points = models::referral::fetch_user_points(&svc_ctx.db, user_uuid) + .await + .map_err(|e| e.to_string())?; + let available_points = points.map(|p| p.available_points).unwrap_or(0); + + // 获取当前层级和下一层级 + let current_tier = models::referral::fetch_tier_by_threshold(&svc_ctx.db, paid_referrals) + .await + .map_err(|e| e.to_string())?; + + let current_threshold = current_tier.as_ref().map(|t| t.unlock_threshold).unwrap_or(0); + let next_tier = models::referral::fetch_next_tier(&svc_ctx.db, current_threshold) + .await + .map_err(|e| e.to_string())?; + + // 计算升级进度 + let upgrade_progress = if let Some(ref next) = next_tier { + let progress = (paid_referrals - current_threshold) as f64 + / (next.unlock_threshold - current_threshold) as f64 + * 100.0; + progress as i32 + } else { + 100 + }; + + Ok(ReferralStatsResponse { + total_referrals, + paid_referrals, + total_consumption, + last_30_days_consumption, + total_rewards, + available_points, + current_tier, + next_tier, + upgrade_progress, + }) +} + +/// 获取推广链接层级配置 +pub async fn get_referral_tiers_service( + svc_ctx: &SvcCtx, +) -> Result, String> { + models::referral::fetch_referral_tiers(&svc_ctx.db) + .await + .map_err(|e| e.to_string()) +} + +/// 获取推广链接列表 +pub async fn get_referral_links_service( + svc_ctx: &SvcCtx, + user_uuid: Uuid, +) -> Result<(Vec, Option), String> { + // 根据当前统计数据计算已解锁层级,并同步更新该用户所有推广链接的 unlocked 状态 + // + // 解锁规则: + // - 对于 unlock_threshold <= paid_referrals 的层级视为已解锁 + // - 这些层级对应的推广链接(按 tier_uuid 关联)统一标记为 unlocked = TRUE + // - 其余链接统一标记为 unlocked = FALSE + let (_total_referrals, paid_referrals, _total_consumption, _last_30_days_consumption) = + models::referral::fetch_referral_stats(&svc_ctx.db, user_uuid) + .await + .map_err(|e| e.to_string())?; + + let tiers = models::referral::fetch_referral_tiers(&svc_ctx.db) + .await + .map_err(|e| e.to_string())?; + + let unlocked_tier_uuids: Vec = tiers + .into_iter() + .filter(|t| t.unlock_threshold <= paid_referrals) + .map(|t| t.uuid) + .collect(); + + models::referral::update_referral_links_unlock_status_for_user( + &svc_ctx.db, + user_uuid, + &unlocked_tier_uuids, + ) + .await + .map_err(|e| e.to_string())?; + + let mut links = models::referral::fetch_user_referral_links(&svc_ctx.db, user_uuid) + .await + .map_err(|e| e.to_string())?; + + let mut current_link = models::referral::fetch_current_referral_link(&svc_ctx.db, user_uuid) + .await + .map_err(|e| e.to_string())?; + + // 如果配置了推广链接前缀,则在返回前为每个链接拼接完整 URL + if let Some(prefix) = svc_ctx.config.app.referral_link_prefix.as_deref() { + let prefix = prefix.trim_end_matches('/'); + + for link in &mut links { + link.url = Some(format!("{prefix}?referral_code={}", link.code)); + } + + if let Some(ref mut cl) = current_link { + cl.url = Some(format!("{prefix}?referral_code={}", cl.code)); + } + } + + Ok((links, current_link)) +} + +/// 获取推广看板聚合数据 +pub async fn get_referral_dashboard_service( + svc_ctx: &SvcCtx, + user_uuid: Uuid, +) -> Result { + // 基础统计(含 available_points 和 total_rewards) + let stats = get_referral_stats_service(svc_ctx, user_uuid).await?; + + // 推广链接及当前链接 + let (links, current_link) = get_referral_links_service(svc_ctx, user_uuid).await?; + + // 层级配置 + let tiers = get_referral_tiers_service(svc_ctx).await?; + + // 读取 pending_points,用于前端展示“审核中”积分 + let pending_points = models::referral::fetch_user_points(&svc_ctx.db, user_uuid) + .await + .ok() + .flatten() + .map(|p| p.pending_points) + .unwrap_or(0); + + let points = ReferralPointsSummary { + available_points: stats.available_points, + pending_points, + total_rewards: stats.total_rewards, + }; + + Ok(ReferralDashboardResponse { + stats, + links, + current_link, + tiers, + points, + }) +} + +/// 计算最近 30 天推广带来的预估收益,以及与当前套餐价格的覆盖比例 +pub async fn get_referral_plan_summary_service( + svc_ctx: &SvcCtx, + user_uuid: Uuid, +) -> Result { + // 基于推广统计获取最近 30 天消费金额和当前层级 + let stats = get_referral_stats_service(svc_ctx, user_uuid).await?; + + let last_30_days_consumption = stats.last_30_days_consumption; + + // 使用当前层级的 reward_rate 估算可得收益(如果没有当前层级,则按 0 估算) + let reward_rate = stats + .current_tier + .as_ref() + .map(|tier| tier.reward_rate) + .unwrap_or(Decimal::ZERO); + + // reward_rate 以百分比存储,例如 10 表示 10% + let referral_value_last_30_days = + last_30_days_consumption * reward_rate / Decimal::from(100u32); + + // 获取当前订阅,估算月度套餐价格 + let current_subscription = + get_current_subscription_service(svc_ctx, user_uuid).await.unwrap_or(None); + + let current_plan_monthly_price = current_subscription.as_ref().map(|sub| sub.price); + + // 计算覆盖比例 + let coverage_ratio = current_plan_monthly_price.map(|price| { + if price.is_zero() { + Decimal::ZERO + } else { + (referral_value_last_30_days / price).max(Decimal::ZERO) + } + }); + + Ok(crate::entitys::ReferralPlanSummaryResponse { + referral_value_last_30_days, + current_plan_monthly_price, + coverage_ratio, + }) +} + +/// 切换当前推广链接 +pub async fn switch_referral_link_service( + svc_ctx: &SvcCtx, + user_uuid: Uuid, + link_uuid: Uuid, +) -> Result<(), String> { + models::referral::switch_current_referral_link(&svc_ctx.db, user_uuid, link_uuid) + .await + .map_err(|e| e.to_string()) +} + +/// 获取奖励记录列表 +pub async fn get_referral_rewards_service( + svc_ctx: &SvcCtx, + user_uuid: Uuid, + payload: &ListReferralRewardsRequest, +) -> Result<(Vec, i64), String> { + let offset = (payload.pagination.page - 1) * payload.pagination.page_size; + + let rewards = models::referral::fetch_referral_rewards( + &svc_ctx.db, + user_uuid, + payload.reward_type.as_deref(), + payload.status.as_deref(), + offset, + payload.pagination.page_size, + ) + .await + .map_err(|e| e.to_string())?; + + let total = models::referral::fetch_referral_rewards_count( + &svc_ctx.db, + user_uuid, + payload.reward_type.as_deref(), + payload.status.as_deref(), + ) + .await + .map_err(|e| e.to_string())?; + + Ok((rewards, total)) +} + +/// 获取被邀请用户列表 +pub async fn get_referred_users_service( + svc_ctx: &SvcCtx, + user_uuid: Uuid, + payload: &ListReferredUsersRequest, +) -> Result<(Vec, i64), String> { + let offset = (payload.pagination.page - 1) * payload.pagination.page_size; + + let rows = models::referral::fetch_referred_users( + &svc_ctx.db, + user_uuid, + payload.status.as_deref(), + offset, + payload.pagination.page_size, + ) + .await + .map_err(|e| e.to_string())?; + + let users: Vec = rows + .into_iter() + .map(|r| ReferredUserItemDto { + id: r.id.to_string(), + email: r.email, + registered_at: r.registered_at, + status: r.status, + total_consumption: r.total_consumption.and_then(|d| d.to_f64()).unwrap_or(0.0), + last_30_days_consumption: r + .last_30_days_consumption + .and_then(|d| d.to_f64()) + .unwrap_or(0.0), + link_id: r.link_uuid.map(|u| u.to_string()).unwrap_or_default(), + }) + .collect(); + + let total = models::referral::fetch_referred_users_count( + &svc_ctx.db, + user_uuid, + payload.status.as_deref(), + ) + .await + .map_err(|e| e.to_string())?; + + Ok((users, total)) +} + +/// 获取用户积分 +pub async fn get_user_points_service( + svc_ctx: &SvcCtx, + user_uuid: Uuid, +) -> Result { + let points = models::referral::fetch_user_points(&svc_ctx.db, user_uuid) + .await + .map_err(|e| e.to_string())?; + + // 如果不存在则创建 + if points.is_none() { + models::referral::upsert_user_points(&svc_ctx.db, user_uuid, 0, 0, 0, 0) + .await + .map_err(|e| e.to_string())?; + + return models::referral::fetch_user_points(&svc_ctx.db, user_uuid) + .await + .map_err(|e| e.to_string())? + .ok_or_else(|| "创建积分记录失败".to_string()); + } + + points.ok_or_else(|| "积分记录不存在".to_string()) +} + +/// 获取兑换选项 +pub async fn get_redeem_options_service(svc_ctx: &SvcCtx) -> Result, String> { + models::referral::fetch_redeem_options(&svc_ctx.db) + .await + .map_err(|e| e.to_string()) +} + +/// 执行积分兑换 +pub async fn redeem_points_service( + svc_ctx: &SvcCtx, + user_uuid: Uuid, + payload: &RedeemPointsRequest, +) -> Result<(Uuid, i32, Decimal), String> { + // 获取兑换选项 + let option = models::referral::fetch_redeem_option_by_uuid(&svc_ctx.db, payload.option_uuid) + .await + .map_err(|e| e.to_string())? + .ok_or_else(|| "兑换选项不存在".to_string())?; + + if option.status != "active" { + return Err("该兑换选项已下架".to_string()); + } + + // 检查积分是否满足最低要求 + if payload.points < option.points_required { + return Err(format!( + "积分不足,最低需要 {} 积分", + option.points_required + )); + } + + // 获取用户积分 + let points = models::referral::fetch_user_points(&svc_ctx.db, user_uuid) + .await + .map_err(|e| e.to_string())? + .ok_or_else(|| "积分记录不存在".to_string())?; + + if points.available_points < payload.points { + return Err("可用积分不足".to_string()); + } + + // 计算兑换价值 + let value = Decimal::from(payload.points) / Decimal::from(option.exchange_rate); + + // 扣减积分 + models::referral::deduct_user_points(&svc_ctx.db, user_uuid, payload.points) + .await + .map_err(|e| e.to_string())?; + + // 创建兑换记录 + let record_uuid = models::referral::insert_redeem_record( + &svc_ctx.db, + user_uuid, + payload.option_uuid, + payload.points, + value, + option.currency.as_deref(), + ) + .await + .map_err(|e| e.to_string())?; + + Ok((record_uuid, payload.points, value)) +} + +/// 获取兑换记录 +pub async fn get_redeem_records_service( + svc_ctx: &SvcCtx, + user_uuid: Uuid, + pagination: &Pagination, +) -> Result<(Vec, i64), String> { + let offset = (pagination.page - 1) * pagination.page_size; + + let records = models::referral::fetch_redeem_records( + &svc_ctx.db, + user_uuid, + offset, + pagination.page_size, + ) + .await + .map_err(|e| e.to_string())?; + + let total = models::referral::fetch_redeem_records_count(&svc_ctx.db, user_uuid) + .await + .map_err(|e| e.to_string())?; + + Ok((records, total)) +} diff --git a/src/services/rpa.rs b/src/services/rpa.rs new file mode 100644 index 0000000..18e7d70 --- /dev/null +++ b/src/services/rpa.rs @@ -0,0 +1,408 @@ +use uuid::Uuid; + +use crate::dto::{RpaTaskDto, RpaTaskRunDto, RpaTaskStepDto}; +use crate::entitys::{ + CreateRpaTaskRequest, DuplicateRpaTaskRequest, ListRpaRunsRequest, ListRpaTasksRequest, + UpdateRpaTaskRequest, +}; +use crate::models; +use crate::svc_ctx::SvcCtx; + +/// 获取 RPA 任务列表 +pub async fn get_rpa_tasks_service( + svc_ctx: &SvcCtx, + user_uuid: Uuid, + team_uuid: Option, + payload: &ListRpaTasksRequest, +) -> Result<(Vec, i64), String> { + let offset = (payload.pagination.page - 1) * payload.pagination.page_size; + + let status = payload.filters.as_ref().and_then(|f| f.status.as_deref()); + let trigger_type = payload.filters.as_ref().and_then(|f| f.trigger_type.as_deref()); + + let tasks = models::rpa::fetch_rpa_tasks( + &svc_ctx.db, + team_uuid, + user_uuid, + status, + trigger_type, + offset, + payload.pagination.page_size, + ) + .await + .map_err(|e| e.to_string())?; + + let total = + models::rpa::fetch_rpa_tasks_count(&svc_ctx.db, team_uuid, user_uuid, status, trigger_type) + .await + .map_err(|e| e.to_string())?; + + Ok((tasks, total)) +} + +/// 获取 RPA 任务详情 +pub async fn get_rpa_task_service( + svc_ctx: &SvcCtx, + task_uuid: Uuid, +) -> Result<(RpaTaskDto, Vec, Vec), String> { + let task = models::rpa::fetch_rpa_task_by_uuid(&svc_ctx.db, task_uuid) + .await + .map_err(|e| e.to_string())? + .ok_or_else(|| "RPA 任务不存在".to_string())?; + + let steps = models::rpa::fetch_rpa_task_steps(&svc_ctx.db, task_uuid) + .await + .map_err(|e| e.to_string())?; + + let environments = models::rpa::fetch_rpa_task_environments(&svc_ctx.db, task_uuid) + .await + .map_err(|e| e.to_string())?; + + let environment_uuids: Vec = + environments.into_iter().map(|e| e.environment_uuid).collect(); + + Ok((task, steps, environment_uuids)) +} + +/// 创建 RPA 任务 +pub async fn create_rpa_task_service( + svc_ctx: &SvcCtx, + user_uuid: Uuid, + team_uuid: Option, + payload: &CreateRpaTaskRequest, +) -> Result { + let tags_json = payload.tags.as_ref().map(|t| serde_json::json!(t)); + + let task_uuid = models::rpa::insert_rpa_task( + &svc_ctx.db, + user_uuid, + team_uuid, + &payload.name, + payload.description.as_deref(), + tags_json.as_ref(), + &payload.trigger_type, + payload.schedule.as_deref(), + payload.cron_expression.as_deref(), + &payload.run_mode, + payload.retry_count, + payload.retry_interval, + payload.timeout, + payload.concurrency, + payload.stop_on_error, + payload.notify_on_complete, + payload.notify_on_error, + ) + .await + .map_err(|e| e.to_string())?; + + // 添加步骤 + if let Some(steps) = &payload.steps { + for (i, step) in steps.iter().enumerate() { + models::rpa::insert_rpa_task_step( + &svc_ctx.db, + task_uuid, + &step.step_type, + &step.name, + &step.config, + step.enabled, + step.position_x, + step.position_y, + Some(step.sort_order.unwrap_or(i as i32)), + ) + .await + .map_err(|e| e.to_string())?; + } + } + + // 添加环境关联 + if let Some(env_uuids) = &payload.environment_uuids { + for (i, env_uuid) in env_uuids.iter().enumerate() { + models::rpa::insert_rpa_task_environment( + &svc_ctx.db, + task_uuid, + *env_uuid, + Some(i as i32), + ) + .await + .map_err(|e| e.to_string())?; + } + } + + Ok(task_uuid) +} + +/// 更新 RPA 任务 +pub async fn update_rpa_task_service( + svc_ctx: &SvcCtx, + payload: &UpdateRpaTaskRequest, +) -> Result<(), String> { + let tags_json = payload.tags.as_ref().map(|t| serde_json::json!(t)); + + models::rpa::update_rpa_task( + &svc_ctx.db, + payload.uuid, + payload.name.as_deref(), + payload.description.as_deref(), + tags_json.as_ref(), + payload.trigger_type.as_deref(), + payload.schedule.as_deref(), + payload.cron_expression.as_deref(), + payload.run_mode.as_deref(), + payload.retry_count, + payload.retry_interval, + payload.timeout, + payload.concurrency, + payload.stop_on_error, + payload.notify_on_complete, + payload.notify_on_error, + ) + .await + .map_err(|e| e.to_string())?; + + // 更新步骤(如果提供) + if let Some(steps) = &payload.steps { + // 先删除旧步骤 + models::rpa::delete_rpa_task_steps(&svc_ctx.db, payload.uuid) + .await + .map_err(|e| e.to_string())?; + + // 添加新步骤 + for (i, step) in steps.iter().enumerate() { + models::rpa::insert_rpa_task_step( + &svc_ctx.db, + payload.uuid, + &step.step_type, + &step.name, + &step.config, + step.enabled, + step.position_x, + step.position_y, + Some(step.sort_order.unwrap_or(i as i32)), + ) + .await + .map_err(|e| e.to_string())?; + } + } + + // 更新环境关联(如果提供) + if let Some(env_uuids) = &payload.environment_uuids { + // 先删除旧关联 + models::rpa::delete_rpa_task_environments(&svc_ctx.db, payload.uuid) + .await + .map_err(|e| e.to_string())?; + + // 添加新关联 + for (i, env_uuid) in env_uuids.iter().enumerate() { + models::rpa::insert_rpa_task_environment( + &svc_ctx.db, + payload.uuid, + *env_uuid, + Some(i as i32), + ) + .await + .map_err(|e| e.to_string())?; + } + } + + Ok(()) +} + +/// 删除 RPA 任务 +pub async fn delete_rpa_task_service(svc_ctx: &SvcCtx, task_uuid: Uuid) -> Result<(), String> { + models::rpa::delete_rpa_task(&svc_ctx.db, task_uuid) + .await + .map_err(|e| e.to_string()) +} + +/// 批量删除 RPA 任务 +pub async fn batch_delete_rpa_tasks_service( + svc_ctx: &SvcCtx, + task_uuids: &[Uuid], +) -> Result { + models::rpa::batch_delete_rpa_tasks(&svc_ctx.db, task_uuids) + .await + .map_err(|e| e.to_string()) +} + +/// 运行 RPA 任务 +pub async fn run_rpa_task_service(svc_ctx: &SvcCtx, task_uuid: Uuid) -> Result { + // 获取任务信息 + let task = models::rpa::fetch_rpa_task_by_uuid(&svc_ctx.db, task_uuid) + .await + .map_err(|e| e.to_string())? + .ok_or_else(|| "RPA 任务不存在".to_string())?; + + if task.status == "running" { + return Err("任务正在运行中".to_string()); + } + + // 获取步骤数 + let steps = models::rpa::fetch_rpa_task_steps(&svc_ctx.db, task_uuid) + .await + .map_err(|e| e.to_string())?; + + // 更新任务状态为运行中 + models::rpa::update_rpa_task_status(&svc_ctx.db, task_uuid, "running") + .await + .map_err(|e| e.to_string())?; + + // 创建执行记录 + let run_uuid = models::rpa::insert_rpa_task_run(&svc_ctx.db, task_uuid, steps.len() as i32) + .await + .map_err(|e| e.to_string())?; + + Ok(run_uuid) +} + +/// 停止 RPA 任务 +pub async fn stop_rpa_task_service(svc_ctx: &SvcCtx, task_uuid: Uuid) -> Result<(), String> { + models::rpa::update_rpa_task_status(&svc_ctx.db, task_uuid, "idle") + .await + .map_err(|e| e.to_string()) +} + +/// 复制 RPA 任务 +pub async fn duplicate_rpa_task_service( + svc_ctx: &SvcCtx, + user_uuid: Uuid, + team_uuid: Option, + payload: &DuplicateRpaTaskRequest, +) -> Result { + // 获取原任务信息 + let (task, steps, environment_uuids) = get_rpa_task_service(svc_ctx, payload.uuid).await?; + + let new_name = payload.new_name.clone().unwrap_or_else(|| format!("{} (副本)", task.name)); + + // 创建新任务 + let new_task_uuid = models::rpa::insert_rpa_task( + &svc_ctx.db, + user_uuid, + team_uuid, + &new_name, + task.description.as_deref(), + task.tags.as_ref(), + &task.trigger_type, + task.schedule.as_deref(), + task.cron_expression.as_deref(), + &task.run_mode, + task.retry_count, + task.retry_interval, + task.timeout, + task.concurrency, + task.stop_on_error, + task.notify_on_complete, + task.notify_on_error, + ) + .await + .map_err(|e| e.to_string())?; + + // 复制步骤 + for step in steps { + models::rpa::insert_rpa_task_step( + &svc_ctx.db, + new_task_uuid, + &step.step_type, + &step.name, + &step.config, + step.enabled, + step.position_x, + step.position_y, + step.sort_order, + ) + .await + .map_err(|e| e.to_string())?; + } + + // 复制环境关联 + for (i, env_uuid) in environment_uuids.iter().enumerate() { + models::rpa::insert_rpa_task_environment( + &svc_ctx.db, + new_task_uuid, + *env_uuid, + Some(i as i32), + ) + .await + .map_err(|e| e.to_string())?; + } + + Ok(new_task_uuid) +} + +/// 导出 RPA 任务 +pub async fn export_rpa_task_service( + svc_ctx: &SvcCtx, + task_uuid: Uuid, +) -> Result<(String, String), String> { + let (task, steps, environment_uuids) = get_rpa_task_service(svc_ctx, task_uuid).await?; + + let export_data = serde_json::json!({ + "name": task.name, + "description": task.description, + "tags": task.tags, + "trigger_type": task.trigger_type, + "schedule": task.schedule, + "cron_expression": task.cron_expression, + "run_mode": task.run_mode, + "retry_count": task.retry_count, + "retry_interval": task.retry_interval, + "timeout": task.timeout, + "concurrency": task.concurrency, + "stop_on_error": task.stop_on_error, + "notify_on_complete": task.notify_on_complete, + "notify_on_error": task.notify_on_error, + "steps": steps.iter().map(|s| serde_json::json!({ + "step_type": s.step_type, + "name": s.name, + "config": s.config, + "enabled": s.enabled, + "position_x": s.position_x, + "position_y": s.position_y, + "sort_order": s.sort_order, + })).collect::>(), + "environment_uuids": environment_uuids, + }); + + let content = serde_json::to_string_pretty(&export_data).map_err(|e| e.to_string())?; + let filename = format!("rpa_task_{}.json", task_uuid); + + Ok((content, filename)) +} + +/// 获取执行记录列表 +pub async fn get_rpa_runs_service( + svc_ctx: &SvcCtx, + payload: &ListRpaRunsRequest, +) -> Result<(Vec, i64), String> { + let offset = (payload.pagination.page - 1) * payload.pagination.page_size; + + let runs = models::rpa::fetch_rpa_task_runs( + &svc_ctx.db, + payload.task_uuid, + payload.status.as_deref(), + offset, + payload.pagination.page_size, + ) + .await + .map_err(|e| e.to_string())?; + + let total = models::rpa::fetch_rpa_task_runs_count( + &svc_ctx.db, + payload.task_uuid, + payload.status.as_deref(), + ) + .await + .map_err(|e| e.to_string())?; + + Ok((runs, total)) +} + +/// 获取执行记录详情 +pub async fn get_rpa_run_service( + svc_ctx: &SvcCtx, + run_uuid: Uuid, +) -> Result { + models::rpa::fetch_rpa_task_run_by_uuid(&svc_ctx.db, run_uuid) + .await + .map_err(|e| e.to_string())? + .ok_or_else(|| "执行记录不存在".to_string()) +} diff --git a/src/services/strategy_types.rs b/src/services/strategy_types.rs new file mode 100644 index 0000000..eeeb1c5 --- /dev/null +++ b/src/services/strategy_types.rs @@ -0,0 +1,57 @@ +use crate::{ + dto::strategy_types::StrategyType, entitys::strategy_types::*, errors::SimprintError, + svc_ctx::SvcCtx, +}; + +/// 根据ID查询策略类型 +pub async fn get_strategy_type_by_id_service( + svc_ctx: &SvcCtx, + payload: GetStrategyTypeParams, +) -> Result { + let pool = &svc_ctx.db; + let id = payload.id.ok_or(SimprintError::StrategyTypeNotFound)?; + let strategy_type = crate::models::strategy_types::query_strategy_type_by_id(pool, id) + .await + .map_err(|_| SimprintError::StrategyTypeNotFound)?; + + Ok(strategy_type) +} + +/// 根据code查询策略类型 +pub async fn get_strategy_type_by_code_service( + svc_ctx: &SvcCtx, + payload: GetStrategyTypeParams, +) -> Result { + let pool = &svc_ctx.db; + let code = payload.code.ok_or(SimprintError::StrategyTypeNotFound)?; + let strategy_type = crate::models::strategy_types::query_strategy_type_by_code(pool, &code) + .await + .map_err(|_| SimprintError::StrategyTypeNotFound)?; + + Ok(strategy_type) +} + +/// 查询所有可用策略类型 +pub async fn query_available_strategy_types_service( + svc_ctx: &SvcCtx, +) -> Result { + let pool = &svc_ctx.db; + let list = crate::models::strategy_types::query_available_strategy_types(pool).await?; + + Ok(StrategyTypeListResponse { list }) +} + +/// 根据分类查询策略类型 +pub async fn query_strategy_types_by_category_service( + svc_ctx: &SvcCtx, + payload: QueryStrategyTypesParams, +) -> Result { + let pool = &svc_ctx.db; + let category = payload.category.ok_or(SimprintError::InvalidRequest( + "分类参数不能为空".to_string(), + ))?; + let list = + crate::models::strategy_types::query_strategy_types_by_category(pool, &category).await?; + + Ok(StrategyTypeListResponse { list }) +} diff --git a/src/services/subscriptions.rs b/src/services/subscriptions.rs new file mode 100644 index 0000000..4752ece --- /dev/null +++ b/src/services/subscriptions.rs @@ -0,0 +1,275 @@ +use chrono::Utc; +use rust_decimal::Decimal; +use uuid::Uuid; + +use crate::dto::SubscriptionDto; +use crate::entitys::{SubscribePlanRequest, VerifyCouponRequest}; +use crate::models; +use crate::svc_ctx::SvcCtx; + +use super::coupons::validate_coupon_service; +use super::wallet::deduct_wallet_service; +use crate::models::workspace_quotas; + +/// 获取当前订阅(按用户) +pub async fn get_current_subscription_service( + svc_ctx: &SvcCtx, + user_uuid: Uuid, +) -> Result, String> { + models::billing::fetch_active_subscription(&svc_ctx.db, user_uuid) + .await + .map_err(|e| e.to_string()) +} + +/// 获取工作空间当前订阅 +pub async fn get_workspace_subscription_service( + svc_ctx: &SvcCtx, + workspace_uuid: Uuid, +) -> Result, String> { + models::billing::fetch_workspace_active_subscription(&svc_ctx.db, workspace_uuid) + .await + .map_err(|e| e.to_string()) +} + +/// 订阅套餐 +pub async fn subscribe_plan_service( + svc_ctx: &SvcCtx, + workspace_uuid: Uuid, + user_uuid: Uuid, + payload: &SubscribePlanRequest, +) -> Result { + // 1. 获取套餐信息 + let plan = models::billing::fetch_plan_by_uuid(&svc_ctx.db, payload.plan_uuid) + .await + .map_err(|e| e.to_string())? + .ok_or_else(|| "套餐不存在".to_string())?; + + if plan.status != "active" { + return Err("该套餐已下架".to_string()); + } + + // 2. 计算基础价格和套餐折扣 + let (base_price, plan_discount_percent, duration_days) = match payload.billing_period.as_str() { + "monthly" => ( + plan.price_per_month, + plan.discount_monthly.unwrap_or(Decimal::ZERO), + 30, + ), + "yearly" => ( + plan.price_per_year, + plan.discount_yearly.unwrap_or(Decimal::ZERO), + 365, + ), + _ => return Err("无效的计费周期".to_string()), + }; + + // 应用套餐级折扣 + let price_after_plan_discount = + base_price * (Decimal::from(100) - plan_discount_percent) / Decimal::from(100); + + // 3. 验证优惠券(如果有) + let mut final_price = price_after_plan_discount; + let mut coupon_uuid: Option = None; + let mut coupon_discount_amount = Decimal::ZERO; + + if let Some(coupon_code) = &payload.coupon_code { + let coupon_result = validate_coupon_service( + svc_ctx, + user_uuid, + &VerifyCouponRequest { + code: coupon_code.clone(), + amount: price_after_plan_discount, // 在套餐折扣后的价格上应用优惠券 + }, + ) + .await?; + + coupon_uuid = Some(coupon_result.coupon.uuid); + coupon_discount_amount = coupon_result.discount_amount; + final_price = price_after_plan_discount - coupon_discount_amount; + } + + // 4. 检查支付方式并验证钱包余额(仅钱包支付需要检查) + let payment_method = payload.payment_method.as_deref().unwrap_or("wallet"); + let is_wallet_payment = payment_method == "wallet"; + + if is_wallet_payment { + let wallet = models::billing::fetch_user_wallet(&svc_ctx.db, user_uuid) + .await + .map_err(|e| e.to_string())?; + + let wallet_balance = wallet.map(|w| w.balance).unwrap_or(Decimal::ZERO); + if wallet_balance < final_price { + return Err("钱包余额不足,请先充值".to_string()); + } + } + + // 5. 取消工作空间现有订阅 + if let Some(existing) = models::billing::fetch_workspace_active_subscription(&svc_ctx.db, workspace_uuid) + .await + .map_err(|e| e.to_string())? + { + models::billing::cancel_subscription(&svc_ctx.db, existing.uuid) + .await + .map_err(|e| e.to_string())?; + } + + // 6. 计算到期时间 + let expires_at = Utc::now() + chrono::Duration::days(duration_days); + let next_billing_date = expires_at.date_naive(); + + // 7. 创建订阅 + let subscription_uuid = models::billing::insert_subscription( + &svc_ctx.db, + workspace_uuid, + user_uuid, + payload.plan_uuid, + &payload.billing_period, + final_price, + &plan.currency, + expires_at, + next_billing_date, + ) + .await + .map_err(|e| e.to_string())?; + + // 7.1. 记录优惠券使用(如果有) + if let Some(coupon_uuid_val) = coupon_uuid { + // 记录优惠券使用 + models::billing::insert_coupon_usage( + &svc_ctx.db, + coupon_uuid_val, + user_uuid, + Some(subscription_uuid), + coupon_discount_amount, + ) + .await + .map_err(|e| e.to_string())?; + + // 更新用户优惠券状态为 used(如果是从 user_coupons 表发放的) + models::billing::update_user_coupon_status( + &svc_ctx.db, + user_uuid, + coupon_uuid_val, + "used", + Some(Utc::now()), + ) + .await + .map_err(|e| e.to_string())?; + } + + // 8. 扣减钱包余额(仅钱包支付需要扣减) + if is_wallet_payment { + deduct_wallet_service( + svc_ctx, + user_uuid, + final_price, + "订阅套餐", + Some(subscription_uuid), + ) + .await?; + } + + // 9. 更新工作空间配额 + workspace_quotas::insert_or_update_workspace_quota( + &svc_ctx.db, + workspace_uuid, + plan.max_environments, + plan.max_team_members, + plan.max_proxies, + plan.max_rpa_tasks, + ) + .await + .map_err(|e| e.to_string())?; + + // 10. 创建发票 + let invoice_number = format!("INV-{}", chrono::Utc::now().format("%Y%m%d%H%M%S")); + models::billing::insert_invoice( + &svc_ctx.db, + user_uuid, + &invoice_number, + final_price, + &plan.currency, + Some(subscription_uuid), + None, + "subscription", + ) + .await + .map_err(|e| e.to_string())?; + + Ok(subscription_uuid) +} + +/// 取消订阅 +pub async fn cancel_subscription_service( + svc_ctx: &SvcCtx, + user_uuid: Uuid, + subscription_uuid: Uuid, +) -> Result<(), String> { + // 验证订阅属于当前用户 + let subscription = models::billing::fetch_subscription_by_uuid(&svc_ctx.db, subscription_uuid) + .await + .map_err(|e| e.to_string())? + .ok_or_else(|| "订阅不存在".to_string())?; + + if subscription.user_uuid != user_uuid { + return Err("无权操作此订阅".to_string()); + } + + if subscription.status == "cancelled" { + return Err("订阅已取消".to_string()); + } + + models::billing::cancel_subscription(&svc_ctx.db, subscription_uuid) + .await + .map_err(|e| e.to_string()) +} + +/// 恢复订阅 +pub async fn resume_subscription_service( + svc_ctx: &SvcCtx, + user_uuid: Uuid, + subscription_uuid: Uuid, +) -> Result<(), String> { + let subscription = models::billing::fetch_subscription_by_uuid(&svc_ctx.db, subscription_uuid) + .await + .map_err(|e| e.to_string())? + .ok_or_else(|| "订阅不存在".to_string())?; + + if subscription.user_uuid != user_uuid { + return Err("无权操作此订阅".to_string()); + } + + if subscription.status != "cancelled" { + return Err("只能恢复已取消的订阅".to_string()); + } + + // 检查是否过期 + if subscription.expires_at < Utc::now() { + return Err("订阅已过期,请重新订阅".to_string()); + } + + models::billing::resume_subscription(&svc_ctx.db, subscription_uuid) + .await + .map_err(|e| e.to_string()) +} + +/// 切换自动续费 +pub async fn toggle_auto_renew_service( + svc_ctx: &SvcCtx, + user_uuid: Uuid, + subscription_uuid: Uuid, + auto_renew: bool, +) -> Result<(), String> { + let subscription = models::billing::fetch_subscription_by_uuid(&svc_ctx.db, subscription_uuid) + .await + .map_err(|e| e.to_string())? + .ok_or_else(|| "订阅不存在".to_string())?; + + if subscription.user_uuid != user_uuid { + return Err("无权操作此订阅".to_string()); + } + + models::billing::toggle_auto_renew(&svc_ctx.db, subscription_uuid, auto_renew) + .await + .map_err(|e| e.to_string()) +} diff --git a/src/services/tags.rs b/src/services/tags.rs new file mode 100644 index 0000000..55ad148 --- /dev/null +++ b/src/services/tags.rs @@ -0,0 +1,64 @@ +use uuid::Uuid; + +use crate::dto::TagDto; +use crate::entitys::{CreateTagRequest, UpdateTagRequest}; +use crate::models; +use crate::svc_ctx::SvcCtx; + +/// 创建标签 +pub async fn create_tag_service( + svc_ctx: &SvcCtx, + user_uuid: Uuid, + team_uuid: Option, + payload: &CreateTagRequest, +) -> Result { + models::insert_tag( + &svc_ctx.db, + user_uuid, + team_uuid, + &payload.name, + payload.color.as_deref(), + ) + .await + .map_err(|e| e.to_string()) +} + +/// 获取标签列表 +pub async fn get_tags_service( + svc_ctx: &SvcCtx, + user_uuid: Uuid, + team_uuid: Option, +) -> Result, String> { + models::fetch_tags(&svc_ctx.db, team_uuid, user_uuid) + .await + .map_err(|e| e.to_string()) +} + +/// 获取标签详情 +pub async fn get_tag_service(svc_ctx: &SvcCtx, tag_uuid: Uuid) -> Result { + models::fetch_tag_by_uuid(&svc_ctx.db, tag_uuid) + .await + .map_err(|e| e.to_string())? + .ok_or_else(|| "标签不存在".to_string()) +} + +/// 更新标签 +pub async fn update_tag_service( + svc_ctx: &SvcCtx, + payload: &UpdateTagRequest, +) -> Result<(), String> { + models::update_tag( + &svc_ctx.db, + payload.uuid, + payload.name.as_deref(), + payload.color.as_deref(), + payload.sort_order, + ) + .await + .map_err(|e| e.to_string()) +} + +/// 删除标签 +pub async fn delete_tag_service(svc_ctx: &SvcCtx, tag_uuid: Uuid) -> Result<(), String> { + models::delete_tag(&svc_ctx.db, tag_uuid).await.map_err(|e| e.to_string()) +} diff --git a/src/services/teams.rs b/src/services/teams.rs new file mode 100644 index 0000000..7d837bd --- /dev/null +++ b/src/services/teams.rs @@ -0,0 +1,497 @@ +use uuid::Uuid; + +use crate::dto::{TeamDto, TeamInvitationDto, TeamMemberDto}; +use crate::entitys::{ + CreateTeamRequest, InviteMemberRequest, ListTeamMembersRequest, SwitchTeamRequest, + UpdateMemberRoleRequest, UpdateTeamRequest, +}; +use crate::models; +use crate::svc_ctx::SvcCtx; + +/// 创建团队 +pub async fn create_team_service( + svc_ctx: &SvcCtx, + user_uuid: Uuid, + payload: &CreateTeamRequest, +) -> Result { + models::insert_team(&svc_ctx.db, user_uuid, payload) + .await + .map_err(|e| e.to_string()) +} + +/// 获取团队详情 +pub async fn get_team_service(svc_ctx: &SvcCtx, team_uuid: Uuid) -> Result { + models::fetch_team_by_uuid(&svc_ctx.db, team_uuid) + .await + .map_err(|e| e.to_string())? + .ok_or_else(|| "团队不存在".to_string()) +} + +/// 获取用户所属的所有团队(工作空间级别) +pub async fn get_user_teams_service( + svc_ctx: &SvcCtx, + workspace_uuid: Uuid, + user_uuid: Uuid, +) -> Result, String> { + models::fetch_user_teams(&svc_ctx.db, workspace_uuid, user_uuid) + .await + .map_err(|e| e.to_string()) +} + +/// 获取用户当前团队 +pub async fn get_current_team_service( + svc_ctx: &SvcCtx, + user_uuid: Uuid, +) -> Result, String> { + models::fetch_user_current_team(&svc_ctx.db, user_uuid) + .await + .map_err(|e| e.to_string()) +} + +/// 切换团队 +pub async fn switch_team_service( + svc_ctx: &SvcCtx, + workspace_uuid: Uuid, + user_uuid: Uuid, + payload: &SwitchTeamRequest, +) -> Result<(), String> { + // 验证用户是否是团队成员(工作空间级别) + let member = models::fetch_team_member(&svc_ctx.db, workspace_uuid, payload.team_uuid, user_uuid) + .await + .map_err(|e| e.to_string())?; + + if member.is_none() { + return Err("您不是该团队的成员".to_string()); + } + + models::set_user_current_team(&svc_ctx.db, user_uuid, payload.team_uuid) + .await + .map_err(|e| e.to_string()) +} + +/// 更新团队信息 +pub async fn update_team_service( + svc_ctx: &SvcCtx, + workspace_uuid: Uuid, + user_uuid: Uuid, + payload: &UpdateTeamRequest, +) -> Result<(), String> { + // 检查权限(仅所有者和管理员可以更新) + let member = models::fetch_team_member(&svc_ctx.db, workspace_uuid, payload.uuid, user_uuid) + .await + .map_err(|e| e.to_string())? + .ok_or_else(|| "您不是该团队的成员".to_string())?; + + if member.role != "owner" && member.role != "admin" { + return Err("权限不足".to_string()); + } + + models::update_team( + &svc_ctx.db, + payload.uuid, + payload.name.as_deref(), + payload.description.as_deref(), + payload.avatar_hash.as_deref(), + ) + .await + .map_err(|e| e.to_string()) +} + +/// 获取团队成员列表 +pub async fn get_team_members_service( + svc_ctx: &SvcCtx, + team_uuid: Uuid, + payload: &ListTeamMembersRequest, +) -> Result<(Vec, i64), String> { + let offset = (payload.pagination.page - 1) * payload.pagination.page_size; + + // 提取筛选条件 + let keyword = payload.filters.as_ref().and_then(|f| f.keyword.as_deref()); + let role = payload.filters.as_ref().and_then(|f| f.role.as_deref()); + let status = payload.filters.as_ref().and_then(|f| f.status.as_deref()); + + let members = models::fetch_team_members( + &svc_ctx.db, + team_uuid, + offset, + payload.pagination.page_size, + keyword, + role, + status, + ) + .await + .map_err(|e| e.to_string())?; + + let total = models::fetch_team_member_count(&svc_ctx.db, team_uuid, keyword, role, status) + .await + .map_err(|e| e.to_string())?; + + Ok((members, total)) +} + +/// 邀请成员 +pub async fn invite_member_service( + svc_ctx: &SvcCtx, + workspace_uuid: Uuid, + team_uuid: Uuid, + inviter_uuid: Uuid, + payload: &InviteMemberRequest, +) -> Result { + // 检查邀请者权限(工作空间级别) + let member = models::fetch_team_member(&svc_ctx.db, workspace_uuid, team_uuid, inviter_uuid) + .await + .map_err(|e| e.to_string())? + .ok_or_else(|| "您不是该团队的成员".to_string())?; + + if member.role != "owner" && member.role != "admin" { + return Err("权限不足".to_string()); + } + + // 检查是否已有待处理的邀请 + if models::has_pending_invitation(&svc_ctx.db, team_uuid, &payload.email) + .await + .map_err(|e| e.to_string())? + { + return Err("该邮箱已有待处理的邀请".to_string()); + } + + // 检查用户是否已是成员 + let invited_user_info = + crate::models::user::fetch_user_info_by_email(&svc_ctx.db, &payload.email) + .await + .map_err(|e| e.to_string())?; + + if let Some(ref user_info) = invited_user_info { + if models::fetch_team_member(&svc_ctx.db, workspace_uuid, team_uuid, user_info.user_uuid) + .await + .map_err(|e| e.to_string())? + .is_some() + { + return Err("该用户已是团队成员".to_string()); + } + } + + // 获取团队信息 + let team = models::fetch_team_by_uuid(&svc_ctx.db, team_uuid) + .await + .map_err(|e| e.to_string())? + .ok_or_else(|| "团队不存在".to_string())?; + + // 获取邀请者信息 + let inviter_info = crate::models::user::fetch_user_info_by_uuid(&svc_ctx.db, inviter_uuid) + .await + .map_err(|e| e.to_string())? + .ok_or_else(|| "邀请者信息不存在".to_string())?; + + // 生成邀请 token + let token = uuid::Uuid::new_v4().to_string(); + let expires_at = chrono::Utc::now() + chrono::Duration::days(7); + + // 插入邀请记录 + let invitation_uuid = models::insert_team_invitation( + &svc_ctx.db, + team_uuid, + &payload.email, + &payload.role, + inviter_uuid, + &token, + expires_at, + ) + .await + .map_err(|e| e.to_string())?; + + // 1. 发送邮件通知 + let smtp_config = svc_ctx.config.clone().smtp.ok_or_else(|| "邮箱配置不存在".to_string())?; + + let inviter_name: &str = + inviter_info.nickname.as_deref().unwrap_or_else(|| inviter_info.email.as_str()); + let inviter_email: &str = inviter_info.email.as_str(); + + let email_title = format!("团队邀请:{} 邀请您加入团队", inviter_name); + let email_body = format!( + "

团队邀请

您好,

{}({})邀请您加入团队 {}

邀请角色:{}

邀请有效期:7 天

接受邀请

如果您不想接受此邀请,可以忽略此邮件。

", + inviter_name, inviter_email, team.name, payload.role + ); + + // 发送邮件(失败不影响邀请流程) + let _ = crate::utils::send_email( + &smtp_config.smtp_username, + &smtp_config.smtp_password, + &smtp_config.smtp_server, + &payload.email, + &email_title, + &email_body, + ); + + // 2. 如果用户已注册,发送团队邀请消息通知 + if let Some(ref user_info) = invited_user_info { + let _ = crate::services::messages::create_message_service( + svc_ctx, + Some(inviter_uuid), + &crate::entitys::CreateMessageRequest { + message_type: "team_invitation".to_string(), + title: format!("{} 邀请您加入团队 {}", inviter_name, team.name), + content: Some(format!( + "{}({})邀请您加入团队 {},角色:{}", + inviter_name, inviter_email, team.name, payload.role + )), + recipient_uuids: vec![user_info.user_uuid], + recipient_type: "single".to_string(), + related_type: Some("team".to_string()), + related_uuid: Some(team_uuid), + priority: Some("normal".to_string()), + metadata: Some(serde_json::json!({ + "invitation_uuid": invitation_uuid.to_string(), + "team_name": team.name, + "role": payload.role, + "inviter_name": inviter_name, + "inviter_email": inviter_email, + "token": token, + })), + }, + ) + .await; + // 消息创建失败不影响邀请流程,使用 _ 忽略错误 + } + + Ok(invitation_uuid) +} + +/// 取消邀请 +pub async fn cancel_invitation_service( + svc_ctx: &SvcCtx, + invitation_uuid: Uuid, +) -> Result<(), String> { + models::cancel_team_invitation(&svc_ctx.db, invitation_uuid) + .await + .map_err(|e| e.to_string()) +} + +/// 接受邀请 +pub async fn accept_invitation_service( + svc_ctx: &SvcCtx, + user_uuid: Uuid, + token: &str, +) -> Result { + // 查找邀请 + let invitation = models::fetch_team_invitation_by_token(&svc_ctx.db, token) + .await + .map_err(|e| e.to_string())? + .ok_or_else(|| "邀请不存在或已过期".to_string())?; + + // 获取团队信息(用于获取 workspace_uuid) + let team = models::fetch_team_by_uuid(&svc_ctx.db, invitation.team_uuid) + .await + .map_err(|e| e.to_string())? + .ok_or_else(|| "团队不存在".to_string())?; + + let workspace_uuid = team.workspace_uuid; + + // 检查用户是否已经是该工作空间的团队成员(如果是,则不需要检查配额) + let existing_member = models::fetch_team_member( + &svc_ctx.db, + workspace_uuid, + invitation.team_uuid, + user_uuid, + ) + .await + .map_err(|e| e.to_string())?; + + // 如果用户还不是该工作空间的任何团队的成员,需要检查配额 + if existing_member.is_none() { + // 检查工作空间成员配额是否充足 + let quota_available = models::check_quota(&svc_ctx.db, workspace_uuid, "team_members") + .await + .map_err(|e| e.to_string())?; + if !quota_available { + return Err("工作空间成员配额不足,无法接受邀请".to_string()); + } + } + + // 使用 accept_team_invitation 函数处理整个流程 + let team_uuid = models::accept_team_invitation(&svc_ctx.db, invitation.uuid, user_uuid) + .await + .map_err(|e| e.to_string())?; + + // 更新成员配额(重新计算所有团队的活跃成员总数) + // 注意:只有在用户之前不是该工作空间的成员时才需要更新配额 + if existing_member.is_none() { + models::update_used_team_members(&svc_ctx.db, workspace_uuid) + .await + .map_err(|e| format!("更新配额失败: {}", e))?; + } + + Ok(team_uuid) +} + +/// 拒绝邀请 +pub async fn reject_invitation_service( + svc_ctx: &SvcCtx, + user_uuid: Uuid, + token: &str, +) -> Result<(), String> { + // 查找邀请 + let invitation = models::fetch_team_invitation_by_token(&svc_ctx.db, token) + .await + .map_err(|e| e.to_string())? + .ok_or_else(|| "邀请不存在或已过期".to_string())?; + + // 使用 reject_team_invitation 函数处理整个流程 + models::reject_team_invitation(&svc_ctx.db, invitation.uuid, user_uuid) + .await + .map_err(|e| e.to_string()) +} + +/// 更新成员角色 +pub async fn update_member_role_service( + svc_ctx: &SvcCtx, + workspace_uuid: Uuid, + team_uuid: Uuid, + operator_uuid: Uuid, + payload: &UpdateMemberRoleRequest, +) -> Result { + // 检查操作者权限(工作空间级别) + let operator = models::fetch_team_member(&svc_ctx.db, workspace_uuid, team_uuid, operator_uuid) + .await + .map_err(|e| e.to_string())? + .ok_or_else(|| "您不是该团队的成员".to_string())?; + + if operator.role != "owner" && operator.role != "admin" { + return Err("权限不足".to_string()); + } + + // 不能修改所有者角色 + let target = models::fetch_team_member(&svc_ctx.db, workspace_uuid, team_uuid, payload.member_uuid) + .await + .map_err(|e| e.to_string())? + .ok_or_else(|| "成员不存在".to_string())?; + + if target.role == "owner" { + return Err("不能修改所有者角色".to_string()); + } + + // 管理员不能设置其他管理员 + if operator.role == "admin" && payload.role == "admin" { + return Err("管理员不能设置其他管理员".to_string()); + } + + models::update_member_role(&svc_ctx.db, team_uuid, payload.member_uuid, &payload.role) + .await + .map_err(|e| e.to_string())?; + + // 返回更新后的成员信息 + models::fetch_team_member(&svc_ctx.db, workspace_uuid, team_uuid, payload.member_uuid) + .await + .map_err(|e| e.to_string())? + .ok_or_else(|| "获取更新后的成员信息失败".to_string()) +} + +/// 移除成员 +pub async fn remove_member_service( + svc_ctx: &SvcCtx, + workspace_uuid: Uuid, + team_uuid: Uuid, + operator_uuid: Uuid, + member_uuid: Uuid, +) -> Result<(), String> { + // 检查操作者权限(工作空间级别) + let operator = models::fetch_team_member(&svc_ctx.db, workspace_uuid, team_uuid, operator_uuid) + .await + .map_err(|e| e.to_string())? + .ok_or_else(|| "您不是该团队的成员".to_string())?; + + if operator.role != "owner" && operator.role != "admin" { + return Err("权限不足".to_string()); + } + + // 不能移除所有者 + let target = models::fetch_team_member(&svc_ctx.db, workspace_uuid, team_uuid, member_uuid) + .await + .map_err(|e| e.to_string())? + .ok_or_else(|| "成员不存在".to_string())?; + + if target.role == "owner" { + return Err("不能移除所有者".to_string()); + } + + // 管理员不能移除其他管理员 + if operator.role == "admin" && target.role == "admin" { + return Err("管理员不能移除其他管理员".to_string()); + } + + models::remove_team_member(&svc_ctx.db, team_uuid, member_uuid) + .await + .map_err(|e| e.to_string())?; + + // 更新成员配额(重新计算所有团队的活跃成员总数) + models::update_used_team_members(&svc_ctx.db, workspace_uuid) + .await + .map_err(|e| format!("更新配额失败: {}", e))?; + + Ok(()) +} + +/// 退出团队 +pub async fn leave_team_service( + svc_ctx: &SvcCtx, + workspace_uuid: Uuid, + team_uuid: Uuid, + user_uuid: Uuid, +) -> Result<(), String> { + // 检查用户是否是团队成员(工作空间级别) + let member = models::fetch_team_member(&svc_ctx.db, workspace_uuid, team_uuid, user_uuid) + .await + .map_err(|e| e.to_string())? + .ok_or_else(|| "您不是该团队的成员".to_string())?; + + // 所有者不能退出团队 + if member.role == "owner" { + return Err("团队所有者不能退出团队,请先转移所有权或解散团队".to_string()); + } + + // 移除成员 + models::remove_team_member(&svc_ctx.db, team_uuid, user_uuid) + .await + .map_err(|e| e.to_string())?; + + // 更新成员配额(重新计算所有团队的活跃成员总数) + models::update_used_team_members(&svc_ctx.db, workspace_uuid) + .await + .map_err(|e| format!("更新配额失败: {}", e))?; + + // 如果当前团队是用户的活跃团队,则需要切换到其他团队 + let current_team = models::fetch_user_current_team(&svc_ctx.db, user_uuid) + .await + .map_err(|e| e.to_string())?; + + if current_team == Some(team_uuid) { + // 获取用户的其他团队(工作空间级别) + let teams = models::fetch_user_teams(&svc_ctx.db, workspace_uuid, user_uuid) + .await + .map_err(|e| e.to_string())?; + + if let Some(first_team) = teams.first() { + // 切换到第一个可用团队 + models::set_user_current_team(&svc_ctx.db, user_uuid, first_team.uuid) + .await + .map_err(|e| e.to_string())?; + } else { + // 没有其他团队,清除当前团队 + models::clear_user_current_team(&svc_ctx.db, user_uuid) + .await + .map_err(|e| e.to_string())?; + } + } + + Ok(()) +} + +/// 获取待处理邀请列表 +pub async fn get_pending_invitations_service( + svc_ctx: &SvcCtx, + team_uuid: Uuid, +) -> Result, String> { + models::fetch_pending_invitations(&svc_ctx.db, team_uuid) + .await + .map_err(|e| e.to_string()) +} diff --git a/src/services/templates.rs b/src/services/templates.rs new file mode 100644 index 0000000..bd03e7b --- /dev/null +++ b/src/services/templates.rs @@ -0,0 +1,340 @@ +use uuid::Uuid; + +use crate::dto::TemplateDto; +use crate::entitys::{ + ApplyTemplateRequest, AssociationsStatus, CreateFromTemplateRequest, CreateTemplateRequest, + TemplateDetailResponse, UpdateTemplateRequest, +}; +use crate::models; +use crate::services; +use crate::svc_ctx::SvcCtx; + +/// 创建模板 +pub async fn create_template_service( + svc_ctx: &SvcCtx, + user_uuid: Uuid, + team_uuid: Option, + payload: &CreateTemplateRequest, +) -> Result { + // 确定要存储的完整数据 + let environment_data_json: serde_json::Value = if let Some(env_uuid) = payload.environment_uuid + { + // 如果提供了环境 UUID,先查询环境获取 workspace_uuid 和 team_uuid + let env = models::fetch_environment_by_uuid_unfiltered(&svc_ctx.db, env_uuid) + .await + .map_err(|e| format!("查询环境失败: {}", e))? + .ok_or_else(|| "环境不存在".to_string())?; + + // 获取完整的环境详情(带权限检查) + let env_detail = services::environments::get_environment_detail_service( + svc_ctx, + env.workspace_uuid, + env.team_uuid, + user_uuid, + env_uuid, + ) + .await + .map_err(|e| format!("获取环境详情失败: {}", e))?; + serde_json::to_value(&env_detail).map_err(|e| format!("序列化环境详情失败: {}", e))? + } else if let Some(ref env_data) = payload.environment_data { + // 如果直接提供了环境详情数据,使用它 + env_data.clone() + } else { + return Err("必须提供 environment_uuid 或 environment_data 之一".to_string()); + }; + + // 从环境数据中提取摘要信息(从标准结构 config.window_info 中提取) + let system_info = environment_data_json + .get("config") + .and_then(|v| v.get("window_info")) + .and_then(|v| v.get("system")) + .and_then(|v| v.as_str()) + .map(|s| s.to_string()); + + let kernel_info = environment_data_json + .get("config") + .and_then(|v| v.get("window_info")) + .and_then(|v| v.get("kernel")) + .and_then(|v| v.as_str()) + .map(|s| s.to_string()); + + models::insert_template( + &svc_ctx.db, + user_uuid, + team_uuid, + &payload.name, + payload.description.as_deref(), + payload.is_public.unwrap_or(false), + system_info.as_deref(), + kernel_info.as_deref(), + &environment_data_json, + ) + .await + .map_err(|e| e.to_string()) +} + +/// 获取模板列表 +pub async fn get_templates_service( + svc_ctx: &SvcCtx, + user_uuid: Uuid, + team_uuid: Option, + is_public: Option, + page: i64, + page_size: i64, +) -> Result<(Vec, i64), String> { + let offset = (page - 1) * page_size; + + let templates = models::fetch_templates( + &svc_ctx.db, + team_uuid, + user_uuid, + is_public, + offset, + page_size, + ) + .await + .map_err(|e| e.to_string())?; + + let total = models::fetch_templates_count(&svc_ctx.db, team_uuid, user_uuid, is_public) + .await + .map_err(|e| e.to_string())?; + + Ok((templates, total)) +} + +/// 获取模板详情 +pub async fn get_template_service( + svc_ctx: &SvcCtx, + template_uuid: Uuid, + for_create: bool, +) -> Result { + let template = models::fetch_template_by_uuid(&svc_ctx.db, template_uuid) + .await + .map_err(|e| e.to_string())? + .ok_or_else(|| "模板不存在".to_string())?; + + // 如果 for_create 为 true,检查关联数据是否存在 + let associations_status = if for_create { + Some(check_template_associations(svc_ctx, &template).await?) + } else { + None + }; + + Ok(TemplateDetailResponse { + template, + associations_status, + }) +} + +/// 检查模板关联数据是否存在 +async fn check_template_associations( + svc_ctx: &SvcCtx, + template: &TemplateDto, +) -> Result { + // 解析模板中的环境详情数据 + let env_detail: crate::entitys::EnvironmentDetailResponse = + serde_json::from_value(template.config_json.clone()) + .map_err(|e| format!("解析模板数据失败: {}", e))?; + + // 检查分组是否存在 + let group_exists = if let Some(group_uuid) = env_detail.environment.group_uuid { + models::fetch_group_by_uuid(&svc_ctx.db, group_uuid) + .await + .map_err(|e| e.to_string())? + .is_some() + } else { + false + }; + + // 检查标签是否存在 + let mut tags_exist = std::collections::HashMap::new(); + for tag in &env_detail.tags { + let exists = models::fetch_tag_by_uuid(&svc_ctx.db, tag.uuid) + .await + .map_err(|e| e.to_string())? + .is_some(); + tags_exist.insert(tag.uuid, exists); + } + + // 检查账号是否存在 + let mut accounts_exist = std::collections::HashMap::new(); + for account in &env_detail.accounts { + let exists = models::fetch_platform_account_by_uuid(&svc_ctx.db, account.uuid) + .await + .map_err(|e| e.to_string())? + .is_some(); + accounts_exist.insert(account.uuid, exists); + } + + // 检查代理是否存在 + let proxy_exists = if let Some(proxy_uuid) = env_detail.environment.proxy_uuid { + models::fetch_proxy_by_uuid(&svc_ctx.db, proxy_uuid) + .await + .map_err(|e| e.to_string())? + .is_some() + } else { + false + }; + + Ok(AssociationsStatus { + group_exists, + tags_exist, + accounts_exist, + proxy_exists, + }) +} + +/// 更新模板 +pub async fn update_template_service( + svc_ctx: &SvcCtx, + payload: &UpdateTemplateRequest, +) -> Result<(), String> { + models::update_template( + &svc_ctx.db, + payload.uuid, + payload.name.as_deref(), + payload.description.as_deref(), + payload.is_public, + payload.config_json.as_ref(), + ) + .await + .map_err(|e| e.to_string()) +} + +/// 删除模板 +pub async fn delete_template_service(svc_ctx: &SvcCtx, template_uuid: Uuid) -> Result<(), String> { + models::delete_template(&svc_ctx.db, template_uuid) + .await + .map_err(|e| e.to_string()) +} + +/// 应用模板到现有环境(更新环境配置) +pub async fn apply_template_service( + svc_ctx: &SvcCtx, + workspace_uuid: Uuid, + team_uuid: Uuid, + user_uuid: Uuid, + payload: &ApplyTemplateRequest, +) -> Result<(), String> { + // 获取模板配置(不需要检查关联数据) + let template_response = get_template_service(svc_ctx, payload.template_uuid, false).await?; + let template = &template_response.template; + + // 解析模板中的环境详情数据 + // 先转换为字符串再反序列化,确保类型正确 + let env_detail: crate::entitys::EnvironmentDetailResponse = serde_json::from_str( + &serde_json::to_string(&template.config_json) + .map_err(|e| format!("序列化模板数据失败: {}", e))?, + ) + .map_err(|e| format!("解析模板数据失败: {}", e))?; + + // 更新环境配置 + let update_req = crate::entitys::UpdateEnvironmentRequest { + uuid: payload.environment_uuid, + name: None, + description: None, + group_uuid: None, + config: env_detail.config.map(|config| crate::entitys::EnvironmentConfigRequest { + window_info: config.window_info, + basic_settings: config.basic_settings, + fingerprint_settings: config.fingerprint_settings, + device_settings: config.device_settings, + preference_settings: config.preference_settings, + project_metadata: config.project_metadata, + }), + }; + + services::environments::update_environment_service( + svc_ctx, + workspace_uuid, + team_uuid, + user_uuid, + &update_req, + ) + .await + .map_err(|e| e.to_string())?; + + // 增加模板使用次数 + let _ = models::increment_template_usage(&svc_ctx.db, payload.template_uuid).await; + + Ok(()) +} + +/// 从模板创建环境 +pub async fn create_from_template_service( + svc_ctx: &SvcCtx, + user_uuid: Uuid, + workspace_uuid: Uuid, + team_uuid: Uuid, + payload: &CreateFromTemplateRequest, +) -> Result { + // 获取模板(不需要检查关联数据,因为这是直接创建,不经过前端表单) + let template_response = get_template_service(svc_ctx, payload.template_uuid, false).await?; + let template = &template_response.template; + + // 解析模板中的环境详情数据 + // 先转换为字符串再反序列化,确保类型正确 + let env_detail: crate::entitys::EnvironmentDetailResponse = serde_json::from_str( + &serde_json::to_string(&template.config_json) + .map_err(|e| format!("序列化模板数据失败: {}", e))?, + ) + .map_err(|e| format!("解析模板数据失败: {}", e))?; + + // 使用提供的参数覆盖模板中的值 + let env_name = payload.name.as_deref().unwrap_or(&env_detail.environment.name); + let env_description = + payload.description.as_ref().or(env_detail.environment.description.as_ref()); + let group_uuid = payload.group_uuid.or(env_detail.environment.group_uuid); + let proxy_uuid = env_detail.environment.proxy_uuid; + + // 提取标签 UUIDs + let tag_uuids: Vec = env_detail.tags.iter().map(|tag| tag.uuid).collect(); + + // 提取账号 UUIDs + let account_uuids: Vec = env_detail.accounts.iter().map(|acc| acc.uuid).collect(); + + // 构建创建环境请求 + let config = env_detail.config.ok_or_else(|| "模板中缺少配置信息".to_string())?; + + let create_req = crate::entitys::CreateEnvironmentRequest { + name: env_name.to_string(), + description: env_description.cloned(), + group_uuid, + tag_uuids: if tag_uuids.is_empty() { + None + } else { + Some(tag_uuids) + }, + account_uuids: if account_uuids.is_empty() { + None + } else { + Some(account_uuids) + }, + proxy_uuid, + cookies: None, // 模板中不包含 cookies + config: crate::entitys::EnvironmentConfigRequest { + window_info: config.window_info, + basic_settings: config.basic_settings, + fingerprint_settings: config.fingerprint_settings, + device_settings: config.device_settings, + preference_settings: config.preference_settings, + project_metadata: config.project_metadata, + }, + }; + + // 创建环境 + let env_uuid = services::environments::create_environment_service( + svc_ctx, + user_uuid, + workspace_uuid, + team_uuid, + &create_req, + ) + .await + .map_err(|e| e.to_string())?; + + // 增加模板使用次数 + let _ = models::increment_template_usage(&svc_ctx.db, payload.template_uuid).await; + + Ok(env_uuid) +} diff --git a/src/services/time.rs b/src/services/time.rs new file mode 100644 index 0000000..2782dde --- /dev/null +++ b/src/services/time.rs @@ -0,0 +1,4 @@ +pub fn now_service() -> String { + let now = chrono::Utc::now().to_string(); + now +} diff --git a/src/services/users.rs b/src/services/users.rs new file mode 100644 index 0000000..c4e5282 --- /dev/null +++ b/src/services/users.rs @@ -0,0 +1,492 @@ +use uuid::Uuid; + +use crate::caches::{ + get_register_code, get_reset_password_code, get_user_public_key, set_register_code, + set_reset_password_code, set_user_public_key, +}; +use crate::entitys::{ + LoginRequest, LoginResponse, MachineInfo, RegisterRequest, RegisterResponse, + ResetPasswordRequest, UpdatePasswordRequest, UpdateUserRequest, UserResponse, +}; +use crate::models::{ + billing, + user::{ + create_user_with_info, fetch_user_by_uuid, fetch_user_info_by_email, fetch_user_info_by_uuid, + update_password, update_user_info, + }, +}; +use crate::services::messages; +use crate::svc_ctx::SvcCtx; +use crate::utils::{ + encryption_password, generate_token, random_six_number_code, send_email, verify_password, +}; + +/// 通用登录逻辑(内部辅助函数) +/// +/// 处理登录、注册、记住密码登录的共同逻辑: +/// - 生成 Token +/// - 保存公钥到缓存 +/// - 注册/绑定机器用户 +/// - 获取用户信息 +async fn common_login_logic( + svc_ctx: &SvcCtx, + user_uuid: Uuid, + public_key: Option<&String>, + machine_info: Option<&MachineInfo>, +) -> Result<(String, String, Option), anyhow::Error> { + let secret = svc_ctx.config.app.secret.as_bytes(); + + // 1. 生成 Token + let access_token = generate_token(&user_uuid.to_string(), 60 * 60 * 2, secret)?; // 2 小时 + let refresh_token = generate_token(&user_uuid.to_string(), 60 * 60 * 24 * 7, secret)?; // 7 天 + + // 2. 如果提供了公钥,保存到缓存 + if let Some(ref public_key) = public_key { + if !public_key.is_empty() { + let _ = set_user_public_key(svc_ctx, &user_uuid, public_key).await; + // 保存失败不影响登录流程,只记录错误 + } + } + + // 3. 如果提供了机器信息,注册/绑定机器用户 + if let Some(ref machine_info) = machine_info { + if !machine_info.machine_code.is_empty() { + let _ = crate::services::machine_users::register_machine_service( + svc_ctx, + crate::entitys::machine_users::CreateMachineUserRequest { + machine_code: machine_info.machine_code.clone(), + user_uuid: Some(user_uuid), + platform: machine_info.platform.clone(), + hardware_hash: machine_info.hardware_hash.clone(), + hardware_raw: machine_info.hardware_raw.clone(), + version_info: machine_info.version_info.clone(), + }, + ) + .await; + // 注册失败不影响登录流程,只记录错误 + } + } + + // 4. 获取用户信息 + let user_info = get_current_user_service(svc_ctx, user_uuid).await.ok(); + + Ok((access_token, refresh_token, user_info)) +} + +/// 用户注册服务 +/// +/// # Arguments +/// * `svc_ctx` - 服务上下文 +/// * `payload` - 注册请求数据 +/// +/// # Returns +/// 返回注册响应 +pub async fn register_service( + svc_ctx: &SvcCtx, + payload: &RegisterRequest, +) -> Result { + let pool = &svc_ctx.db; + // 1. 验证验证码 + let cached_code = get_register_code(svc_ctx, &payload.email).await?; + if cached_code != Some(payload.code.clone()) { + return Err(anyhow::anyhow!("验证码错误或已过期")); + } + + // 2. 检查邮箱是否已存在 + let existing_user = fetch_user_info_by_email(pool, &payload.email).await?; + if existing_user.is_some() { + return Err(anyhow::anyhow!("邮箱已被注册")); + } + + // 3. 加密密码 + let password_hash = encryption_password(&payload.password)?; + + // 4. 生成用户 ID(使用 UUID 的前8位作为用户 ID) + let user_id = format!("USER{}", Uuid::new_v4().to_string()[..8].to_uppercase()); + + // 5. 创建用户(传入默认配额配置) + let quota = &svc_ctx.config.workspace_quota.default; + let user_uuid = create_user_with_info(pool, user_id, payload, &password_hash, quota).await?; + + // 5.1. 自动发放新用户优惠券(失败不影响注册流程) + let _ = issue_welcome_coupons(svc_ctx, user_uuid).await; + + // 6. 执行通用登录逻辑 + let (access_token, refresh_token, user_info) = common_login_logic( + svc_ctx, + user_uuid, + payload.public_secret_key.as_ref(), + payload.machine_info.as_ref(), + ) + .await?; + + // 7. 发送欢迎系统通知 + let _ = messages::create_message_service( + svc_ctx, + None, // 系统消息,无发送者 + &crate::entitys::CreateMessageRequest { + message_type: "system_notification".to_string(), + title: "欢迎加入".to_string(), + content: Some("感谢您的注册!我们很高兴您能加入我们。祝您使用愉快!".to_string()), + recipient_uuids: vec![user_uuid], + recipient_type: "single".to_string(), + related_type: None, + related_uuid: None, + priority: Some("normal".to_string()), + metadata: None, + }, + ) + .await; + // 注意:消息创建失败不影响注册流程,使用 _ 忽略错误 + + Ok(RegisterResponse { + access_token, + refresh_token, + user_info, + }) +} + +/// 自动发放新用户优惠券 +/// +/// 在用户注册时自动发放欢迎优惠券,失败不影响注册流程 +async fn issue_welcome_coupons(svc_ctx: &SvcCtx, user_uuid: Uuid) -> Result<(), anyhow::Error> { + // 新用户优惠券代码列表(可根据需要配置) + let welcome_coupon_codes = vec!["WELCOME10", "FIRST10"]; + + for coupon_code in welcome_coupon_codes { + // 查询优惠券 + if let Some(coupon) = billing::fetch_coupon_by_code(&svc_ctx.db, coupon_code).await? { + // 发放优惠券给用户(继承优惠券的过期时间) + let _ = billing::insert_user_coupon( + &svc_ctx.db, + user_uuid, + coupon.uuid, + coupon.valid_until, + ) + .await; + // 注意:如果用户已拥有该优惠券(重复发放),忽略错误 + } + } + + Ok(()) +} + +/// 用户登录服务(统一处理两种登录方式) +/// +/// # Arguments +/// * `svc_ctx` - 服务上下文 +/// * `payload` - 登录请求数据 +/// +/// # Returns +/// 返回登录响应 +pub async fn login_service( + svc_ctx: &SvcCtx, + payload: LoginRequest, +) -> Result { + let pool = &svc_ctx.db; + let (user_uuid, machine_info, public_secret_key) = match payload { + LoginRequest::Basic(data) => { + // 基本登录:验证邮箱和密码 + // 1. 查询用户 + let user_info = fetch_user_info_by_email(pool, &data.email) + .await? + .ok_or_else(|| anyhow::anyhow!("账号不存在"))?; + + // 2. 验证密码 + if !verify_password(&data.password, &user_info.password) { + return Err(anyhow::anyhow!("密码错误")); + } + + // 3. 检查用户状态 + if user_info.status != "active" { + return Err(anyhow::anyhow!("用户已被禁用")); + } + + ( + user_info.user_uuid, + data.machine_info, + data.public_secret_key, + ) + } + LoginRequest::Remember(data) => { + // 记住密码登录:验证 refresh_token + // 1. 验证 refresh_token + let secret = svc_ctx.config.app.secret.as_bytes(); + let user_uuid_str = verify_token_service(&data.refresh_token, secret)?; + let user_uuid = Uuid::parse_str(&user_uuid_str)?; + + // 2. 查询用户信息 + let user_info = fetch_user_info_by_uuid(pool, user_uuid) + .await? + .ok_or_else(|| anyhow::anyhow!("账号不存在"))?; + + // 3. 验证邮箱是否匹配 + if user_info.email != data.email { + return Err(anyhow::anyhow!("邮箱不匹配")); + } + + // 4. 检查用户状态 + if user_info.status != "active" { + return Err(anyhow::anyhow!("用户已被禁用")); + } + + (user_uuid, data.machine_info, data.public_secret_key) + } + }; + + // 执行通用登录逻辑 + let (access_token, refresh_token, user_response) = common_login_logic( + svc_ctx, + user_uuid, + public_secret_key.as_ref(), + machine_info.as_ref(), + ) + .await?; + + Ok(LoginResponse { + access_token, + refresh_token, + user_info: user_response, + }) +} + +/// 验证 Token 服务 +/// +/// # Arguments +/// * `token` - 要验证的 token +/// * `secret` - JWT 密钥 +/// +/// # Returns +/// 返回用户 UUID +pub fn verify_token_service(token: &str, secret: &[u8]) -> Result { + crate::utils::verify_token(token, secret) +} + +/// 刷新 Token 服务 +/// +/// # Arguments +/// * `pool` - 数据库连接池 +/// * `svc_ctx` - 服务上下文 +/// * `refresh_token` - 刷新令牌 +/// +/// # Returns +/// 返回登录响应 +pub async fn refresh_token_service( + svc_ctx: &SvcCtx, + refresh_token: &str, +) -> Result { + let pool = &svc_ctx.db; + // 1. 验证 refresh_token + let secret = svc_ctx.config.app.secret.as_bytes(); + let user_uuid_str = verify_token_service(refresh_token, secret)?; + + // 2. 验证用户是否存在 + let user_uuid = Uuid::parse_str(&user_uuid_str)?; + let user = fetch_user_by_uuid(pool, user_uuid).await?; + if user.is_none() { + return Err(anyhow::anyhow!("用户不存在")); + } + + // 3. 生成新的 Token + let access_token = generate_token(&user_uuid_str, 60 * 60 * 2, secret)?; // 2 小时 + let new_refresh_token = generate_token(&user_uuid_str, 60 * 60 * 24 * 7, secret)?; // 7 天 + + Ok(LoginResponse { + access_token, + refresh_token: new_refresh_token, + user_info: None, + }) +} + +/// 获取当前用户信息服务 +/// +/// # Arguments +/// * `svc_ctx` - 服务上下文 +/// * `user_uuid` - 用户 UUID +/// +/// # Returns +/// 返回用户信息响应(包含当前团队信息) +pub async fn get_current_user_service( + svc_ctx: &SvcCtx, + user_uuid: Uuid, +) -> Result { + let pool = &svc_ctx.db; + let user = fetch_user_by_uuid(pool, user_uuid) + .await? + .ok_or_else(|| anyhow::anyhow!("用户不存在"))?; + + let user_info = fetch_user_info_by_uuid(pool, user_uuid) + .await? + .ok_or_else(|| anyhow::anyhow!("用户信息不存在"))?; + + // 获取当前团队详细信息 + let current_team = if let Some(team_uuid) = user_info.current_team_uuid { + // 查询团队详细信息 + crate::models::teams::fetch_team_by_uuid(pool, team_uuid).await.ok().flatten() + } else { + None + }; + + // 获取当前工作空间详细信息 + let current_workspace = if let Some(workspace_uuid) = user_info.current_workspace_uuid { + // 查询工作空间详细信息 + crate::models::workspaces::fetch_workspace_by_uuid(pool, workspace_uuid) + .await + .ok() + .flatten() + } else { + None + }; + + Ok(UserResponse { + uuid: user.uuid, + id: user.id, + nickname: user_info.nickname, + email: user_info.email, + phone: user_info.phone, + avatar_hash: user_info.avatar_hash, + status: user_info.status, + created_at: user.created_at, + updated_at: user.updated_at, + current_team, + current_workspace, + }) +} + +/// 更新用户信息服务 +/// +/// # Arguments +/// * `pool` - 数据库连接池 +/// * `user_uuid` - 用户 UUID +/// * `payload` - 更新请求数据 +pub async fn update_user_service( + svc_ctx: &SvcCtx, + user_uuid: Uuid, + payload: &UpdateUserRequest, +) -> Result<(), anyhow::Error> { + let pool = &svc_ctx.db; + update_user_info(pool, user_uuid, payload).await?; + Ok(()) +} + +/// 修改密码服务 +/// +/// # Arguments +/// * `pool` - 数据库连接池 +/// * `user_uuid` - 用户 UUID +/// * `payload` - 修改密码请求数据 +pub async fn update_password_service( + svc_ctx: &SvcCtx, + user_uuid: Uuid, + payload: &UpdatePasswordRequest, +) -> Result<(), anyhow::Error> { + let pool = &svc_ctx.db; + // 1. 查询用户 + let user_info = fetch_user_info_by_uuid(pool, user_uuid) + .await? + .ok_or_else(|| anyhow::anyhow!("用户不存在"))?; + + // 2. 验证旧密码 + if !verify_password(&payload.old_password, &user_info.password) { + return Err(anyhow::anyhow!("原密码错误")); + } + + // 3. 加密新密码 + let password_hash = encryption_password(&payload.new_password)?; + + // 4. 更新密码 + update_password(pool, user_uuid, &password_hash).await?; + + Ok(()) +} + +/// 重置密码服务 +/// +/// # Arguments +/// * `pool` - 数据库连接池 +/// * `svc_ctx` - 服务上下文 +/// * `payload` - 重置密码请求数据 +pub async fn reset_password_service( + svc_ctx: &SvcCtx, + payload: &ResetPasswordRequest, +) -> Result<(), anyhow::Error> { + let pool = &svc_ctx.db; + // 1. 验证验证码 + let cached_code = get_reset_password_code(svc_ctx, &payload.email).await?; + if cached_code != Some(payload.code.clone()) { + return Err(anyhow::anyhow!("验证码错误或已过期")); + } + + // 2. 查询用户 + let user_info = fetch_user_info_by_email(pool, &payload.email) + .await? + .ok_or_else(|| anyhow::anyhow!("用户不存在"))?; + + // 3. 加密新密码 + let password_hash = encryption_password(&payload.new_password)?; + + // 4. 更新密码 + update_password(pool, user_info.user_uuid, &password_hash).await?; + + Ok(()) +} + +/// 发送验证码服务 +/// +/// # Arguments +/// * `svc_ctx` - 服务上下文 +/// * `email` - 邮箱地址 +/// * `code_type` - 验证码类型(register 或 reset_password) +pub async fn send_verification_code_service( + svc_ctx: &SvcCtx, + email: &str, + code_type: &str, +) -> Result<(), anyhow::Error> { + // 1. 生成验证码 + let code = random_six_number_code(); + + // 2. 存储验证码到 Redis + match code_type { + "register" => set_register_code(svc_ctx, email, &code).await?, + "reset_password" => set_reset_password_code(svc_ctx, email, &code).await?, + _ => return Err(anyhow::anyhow!("无效的验证码类型")), + } + + // 3. 发送邮件 + let smtp_config = svc_ctx.config.clone().smtp.expect("邮箱配置不存在"); + let title = match code_type { + "register" => "注册验证码", + "reset_password" => "重置密码验证码", + _ => "验证码", + }; + let body = format!("您的验证码是:{},有效期 5 分钟。", code); + + send_email( + &smtp_config.smtp_username, + &smtp_config.smtp_password, + &smtp_config.smtp_server, + email, + title, + &body, + )?; + + Ok(()) +} + +/// 获取用户公钥 +/// +/// # Arguments +/// * `svc_ctx` - 服务上下文 +/// * `user_uuid` - 用户对应的UUID +/// +/// # Returns +/// 返回用户公钥,如果获取成功则返回公钥,否则返回错误 +pub async fn get_user_public_key_service( + svc_ctx: &SvcCtx, + user_uuid: &Uuid, +) -> Result { + let public_key = get_user_public_key(svc_ctx, user_uuid) + .await? + .ok_or_else(|| anyhow::anyhow!("用户公钥不存在"))?; + Ok(public_key) +} diff --git a/src/services/version_types.rs b/src/services/version_types.rs new file mode 100644 index 0000000..df1090f --- /dev/null +++ b/src/services/version_types.rs @@ -0,0 +1,103 @@ +use crate::{ + dto::version_types::VersionType, entitys::version_types::*, errors::SimprintError, + svc_ctx::SvcCtx, +}; + +/// 创建版本类型 +pub async fn create_version_type_service( + svc_ctx: &SvcCtx, + request: CreateVersionTypeRequest, +) -> Result { + let pool = &svc_ctx.db; + if request.type_code.is_empty() { + return Err(SimprintError::InvalidRequest( + "类型代码不能为空".to_string(), + )); + } + + // 检查类型代码是否已存在 + let existing = + crate::models::version_types::query_version_type_by_code(pool, &request.type_code) + .await + .ok(); + + if existing.is_some() { + return Err(SimprintError::InvalidRequest( + "版本类型代码已存在".to_string(), + )); + } + + let id = crate::models::version_types::insert_version_type(pool, &request).await?; + + Ok(id) +} + +/// 根据ID查询版本类型 +pub async fn get_version_type_by_id_service( + svc_ctx: &SvcCtx, + payload: GetVersionTypeParams, +) -> Result { + let pool = &svc_ctx.db; + let version_type = crate::models::version_types::query_version_type_by_id(pool, payload.id) + .await? + .ok_or(SimprintError::VersionTypeNotFound)?; + + Ok(version_type) +} + +/// 查询所有激活的版本类型 +pub async fn get_active_version_types_service( + svc_ctx: &SvcCtx, +) -> Result { + let pool = &svc_ctx.db; + let list = crate::models::version_types::query_active_version_types(pool).await?; + + Ok(VersionTypeListResponse { list }) +} + +/// 查询所有版本类型 +pub async fn get_all_version_types_service( + svc_ctx: &SvcCtx, +) -> Result { + let pool = &svc_ctx.db; + let list = crate::models::version_types::query_all_version_types(pool).await?; + + Ok(VersionTypeListResponse { list }) +} + +/// 更新版本类型 +pub async fn update_version_type_service( + svc_ctx: &SvcCtx, + request: UpdateVersionTypeRequest, +) -> Result { + let pool = &svc_ctx.db; + // 检查版本类型是否存在 + crate::models::version_types::query_version_type_by_id(pool, request.id) + .await? + .ok_or(SimprintError::VersionTypeNotFound)?; + + let success = + crate::models::version_types::update_version_type(pool, request.id, &request).await?; + + if !success { + return Err(SimprintError::VersionTypeNotFound); + } + + Ok(true) +} + +/// 删除版本类型 +pub async fn delete_version_type_service( + svc_ctx: &SvcCtx, + payload: DeleteVersionTypeRequest, +) -> Result { + let pool = &svc_ctx.db; + // 检查版本类型是否存在 + crate::models::version_types::query_version_type_by_id(pool, payload.id) + .await? + .ok_or(SimprintError::VersionTypeNotFound)?; + + let success = crate::models::version_types::delete_version_type(pool, payload.id).await?; + + Ok(success) +} diff --git a/src/services/versions.rs b/src/services/versions.rs new file mode 100644 index 0000000..e858e8d --- /dev/null +++ b/src/services/versions.rs @@ -0,0 +1,186 @@ +use crate::{dto::versions::Version, entitys::versions::*, errors::SimprintError, svc_ctx::SvcCtx}; + +/// 创建版本 +pub async fn create_version_service( + svc_ctx: &SvcCtx, + request: CreateVersionRequest, +) -> Result { + let pool = &svc_ctx.db; + + // 验证版本号 + if request.version.is_empty() { + return Err(SimprintError::VersionEmpty); + } + + // 验证资源名称 + if request.resource_name.is_empty() { + return Err(SimprintError::ResourceNameEmpty); + } + + // 检查版本类型是否存在 + crate::models::version_types::query_version_type_by_id(pool, request.type_id) + .await? + .ok_or(SimprintError::VersionTypeNotFound)?; + + // 检查版本是否已存在 + let exists = crate::models::versions::query_version_by_name_and_version( + pool, + &request.resource_name, + &request.version, + ) + .await + .ok(); + + if exists.is_some() { + return Err(SimprintError::VersionAlreadyExists); + } + + // 创建版本 + let id = crate::models::versions::insert_version(pool, &request).await?; + + Ok(id) +} + +/// 根据ID查询版本 +pub async fn get_version_by_id_service( + svc_ctx: &SvcCtx, + id: i32, +) -> Result { + let pool = &svc_ctx.db; + let version = crate::models::versions::query_version_by_id(pool, id) + .await? + .ok_or(SimprintError::VersionNotFound)?; + + Ok(version) +} + +/// 根据资源名称和版本号查询版本 +pub async fn get_version_by_name_and_version_service( + svc_ctx: &SvcCtx, + resource_name: String, + version: String, +) -> Result { + let pool = &svc_ctx.db; + let version_data = + crate::models::versions::query_version_by_name_and_version(pool, &resource_name, &version) + .await? + .ok_or(SimprintError::VersionNotFound)?; + + Ok(version_data) +} + +/// 查询最新版本 +pub async fn get_latest_version_service( + svc_ctx: &SvcCtx, + resource_name: String, + platform: String, +) -> Result { + let pool = &svc_ctx.db; + let version = crate::models::versions::query_latest_version(pool, &resource_name, &platform) + .await? + .ok_or(SimprintError::VersionNotFound)?; + + Ok(version) +} + +/// 查询版本列表 +pub async fn query_versions_service( + svc_ctx: &SvcCtx, + params: QueryVersionsParams, +) -> Result { + let pool = &svc_ctx.db; + let (total, list) = crate::models::versions::query_versions( + pool, + params.resource_name.as_deref(), + params.platform.as_deref(), + params.status.as_deref(), + params.page_num.unwrap_or(1), + params.page_size.unwrap_or(20), + ) + .await?; + + Ok(VersionListResponse { total, list }) +} + +/// 更新版本 +pub async fn update_version_service( + svc_ctx: &SvcCtx, + request: UpdateVersionRequest, +) -> Result { + let pool = &svc_ctx.db; + // 检查版本是否存在 + crate::models::versions::query_version_by_id(pool, request.id) + .await? + .ok_or(SimprintError::VersionNotFound)?; + + let success = crate::models::versions::update_version(pool, request.id, &request).await?; + + if !success { + return Err(SimprintError::VersionNotFound); + } + + Ok(true) +} + +/// 删除版本 +pub async fn delete_version_service( + svc_ctx: &SvcCtx, + payload: DeleteVersionRequest, +) -> Result { + let pool = &svc_ctx.db; + // 检查版本是否存在 + crate::models::versions::query_version_by_id(pool, payload.id) + .await? + .ok_or(SimprintError::VersionNotFound)?; + + let success = crate::models::versions::delete_version(pool, payload.id).await?; + + Ok(success) +} + +/// 设置最新版本 +pub async fn set_latest_version_service( + svc_ctx: &SvcCtx, + payload: SetLatestVersionRequest, +) -> Result { + let pool = &svc_ctx.db; + // 检查版本是否存在 + let version = crate::models::versions::query_version_by_id(pool, payload.version_id) + .await? + .ok_or(SimprintError::VersionNotFound)?; + + if version.type_id != payload.type_id || version.resource_name != payload.resource_name { + return Err(SimprintError::InvalidRequest( + "版本类型或资源名称不匹配".to_string(), + )); + } + + let success = crate::models::versions::set_as_latest_version( + pool, + payload.type_id, + &payload.resource_name, + payload.version_id, + ) + .await?; + + Ok(success) +} + +/// 查询所有激活版本类型的最新版本 +pub async fn get_all_active_latest_versions_service( + svc_ctx: &SvcCtx, + platform: String, +) -> Result>, SimprintError> { + let pool = &svc_ctx.db; + let results = + crate::models::versions::query_all_active_latest_versions(pool, &platform).await?; + + // 转换为 HashMap> + let mut map: std::collections::HashMap> = std::collections::HashMap::new(); + + for (type_code, _resource_name, version) in results { + map.entry(type_code).or_insert_with(Vec::new).push(version); + } + + Ok(map) +} diff --git a/src/services/wallet.rs b/src/services/wallet.rs new file mode 100644 index 0000000..b7c4fa7 --- /dev/null +++ b/src/services/wallet.rs @@ -0,0 +1,103 @@ +use rust_decimal::Decimal; +use uuid::Uuid; + +use crate::dto::{UserWalletDto, WalletTransactionDto}; +use crate::entitys::ListTransactionsRequest; +use crate::models; +use crate::svc_ctx::SvcCtx; + +/// 获取钱包信息 +pub async fn get_wallet_service( + svc_ctx: &SvcCtx, + user_uuid: Uuid, +) -> Result { + let wallet = models::billing::fetch_user_wallet(&svc_ctx.db, user_uuid) + .await + .map_err(|e| e.to_string())?; + + // 如果钱包不存在,创建一个 + if wallet.is_none() { + models::billing::insert_user_wallet(&svc_ctx.db, user_uuid, "CNY") + .await + .map_err(|e| e.to_string())?; + + return models::billing::fetch_user_wallet(&svc_ctx.db, user_uuid) + .await + .map_err(|e| e.to_string())? + .ok_or_else(|| "创建钱包失败".to_string()); + } + + wallet.ok_or_else(|| "钱包不存在".to_string()) +} + +/// 扣减钱包余额 +pub async fn deduct_wallet_service( + svc_ctx: &SvcCtx, + user_uuid: Uuid, + amount: Decimal, + description: &str, + order_uuid: Option, +) -> Result<(), String> { + let wallet = models::billing::fetch_user_wallet(&svc_ctx.db, user_uuid) + .await + .map_err(|e| e.to_string())? + .ok_or_else(|| "钱包不存在".to_string())?; + + let balance_before = wallet.balance; + let balance_after = balance_before - amount; + + if balance_after < Decimal::ZERO { + return Err("余额不足".to_string()); + } + + // 扣减余额 + models::billing::update_wallet_balance(&svc_ctx.db, user_uuid, -amount) + .await + .map_err(|e| e.to_string())?; + + // 记录交易 + models::billing::insert_wallet_transaction( + &svc_ctx.db, + user_uuid, + "debit", + -amount, + &wallet.currency, + balance_before, + balance_after, + Some(description), + order_uuid, + ) + .await + .map_err(|e| e.to_string())?; + + Ok(()) +} + +/// 获取交易记录 +pub async fn get_transactions_service( + svc_ctx: &SvcCtx, + user_uuid: Uuid, + payload: &ListTransactionsRequest, +) -> Result<(Vec, i64), String> { + let offset = (payload.pagination.page - 1) * payload.pagination.page_size; + + let transactions = models::billing::fetch_wallet_transactions( + &svc_ctx.db, + user_uuid, + payload.transaction_type.as_deref(), + offset, + payload.pagination.page_size, + ) + .await + .map_err(|e| e.to_string())?; + + let total = models::billing::fetch_wallet_transactions_count( + &svc_ctx.db, + user_uuid, + payload.transaction_type.as_deref(), + ) + .await + .map_err(|e| e.to_string())?; + + Ok((transactions, total)) +} diff --git a/src/services/workspace_quotas.rs b/src/services/workspace_quotas.rs new file mode 100644 index 0000000..6411a75 --- /dev/null +++ b/src/services/workspace_quotas.rs @@ -0,0 +1,68 @@ +use uuid::Uuid; + +use crate::dto::WorkspaceQuotaDto; +use crate::entitys::{GetWorkspaceQuotaRequest, UpdateQuotaUsageRequest}; +use crate::models; +use crate::svc_ctx::SvcCtx; + +/// 获取工作空间配额 +pub async fn get_workspace_quota_service( + svc_ctx: &SvcCtx, + payload: &GetWorkspaceQuotaRequest, +) -> Result { + models::fetch_workspace_quota(&svc_ctx.db, payload.workspace_uuid) + .await + .map_err(|e| e.to_string())? + .ok_or_else(|| "配额不存在".to_string()) +} + +/// 检查配额是否充足 +pub async fn check_quota_service( + svc_ctx: &SvcCtx, + workspace_uuid: Uuid, + quota_type: &str, +) -> Result { + models::check_quota(&svc_ctx.db, workspace_uuid, quota_type) + .await + .map_err(|e| e.to_string()) +} + +/// 更新配额使用情况 +pub async fn update_quota_usage_service( + svc_ctx: &SvcCtx, + payload: &UpdateQuotaUsageRequest, +) -> Result<(), String> { + match payload.quota_type.as_str() { + "environments" => { + if payload.increment { + models::increment_used_environments( + &svc_ctx.db, + payload.workspace_uuid, + payload.amount, + ) + .await + } else { + models::decrement_used_environments( + &svc_ctx.db, + payload.workspace_uuid, + payload.amount, + ) + .await + } + } + "proxies" => { + if payload.increment { + models::increment_used_proxies(&svc_ctx.db, payload.workspace_uuid, payload.amount) + .await + } else { + models::decrement_used_proxies(&svc_ctx.db, payload.workspace_uuid, payload.amount) + .await + } + } + "team_members" => { + models::update_used_team_members(&svc_ctx.db, payload.workspace_uuid).await + } + _ => return Err("不支持的配额类型".to_string()), + } + .map_err(|e| e.to_string()) +} diff --git a/src/services/workspaces.rs b/src/services/workspaces.rs new file mode 100644 index 0000000..bb0fac5 --- /dev/null +++ b/src/services/workspaces.rs @@ -0,0 +1,153 @@ +use uuid::Uuid; + +use crate::dto::WorkspaceDto; +use crate::entitys::{CreateTeamRequest, CreateWorkspaceRequest, UpdateWorkspaceRequest}; +use crate::models; +use crate::svc_ctx::SvcCtx; + +/// 创建工作空间 +pub async fn create_workspace_service( + svc_ctx: &SvcCtx, + user_uuid: Uuid, + payload: &CreateWorkspaceRequest, +) -> Result { + // 获取用户信息,用于生成团队名称 + let user_info = models::user::fetch_user_info_by_uuid(&svc_ctx.db, user_uuid) + .await + .map_err(|e| e.to_string())? + .ok_or_else(|| "用户不存在".to_string())?; + + // 生成团队名称 + let team_name = user_info + .nickname + .as_ref() + .map(|n| format!("{} 的团队", n)) + .unwrap_or_else(|| { + format!( + "{} 的团队", + user_info + .email + .split('@') + .next() + .unwrap_or("用户") + ) + }); + + // 创建工作空间 + let workspace_uuid = models::insert_workspace(&svc_ctx.db, user_uuid, payload) + .await + .map_err(|e| e.to_string())?; + + // 创建默认配额(从配置读取) + let quota = &svc_ctx.config.workspace_quota.default; + models::insert_or_update_workspace_quota( + &svc_ctx.db, + workspace_uuid, + quota.max_environments, + quota.max_team_members, + quota.max_proxies, + quota.max_rpa_tasks, + ) + .await + .map_err(|e| e.to_string())?; + + // 创建默认团队(每个工作空间自动创建一个团队) + let team_request = CreateTeamRequest { + workspace_uuid, + name: team_name, + description: Some("默认团队".to_string()), + }; + models::insert_team(&svc_ctx.db, user_uuid, &team_request) + .await + .map_err(|e| e.to_string())?; + + // 设置用户当前工作空间(与注册时的逻辑保持一致) + models::user::set_user_current_workspace(&svc_ctx.db, user_uuid, workspace_uuid) + .await + .map_err(|e| e.to_string())?; + + Ok(workspace_uuid) +} + +/// 获取工作空间详情 +pub async fn get_workspace_service( + svc_ctx: &SvcCtx, + workspace_uuid: Uuid, +) -> Result { + models::fetch_workspace_by_uuid(&svc_ctx.db, workspace_uuid) + .await + .map_err(|e| e.to_string())? + .ok_or_else(|| "工作空间不存在".to_string()) +} + +/// 获取用户所属的所有工作空间 +pub async fn get_user_workspaces_service( + svc_ctx: &SvcCtx, + user_uuid: Uuid, +) -> Result, String> { + models::fetch_user_workspaces(&svc_ctx.db, user_uuid) + .await + .map_err(|e| e.to_string()) +} + +/// 更新工作空间 +pub async fn update_workspace_service( + svc_ctx: &SvcCtx, + user_uuid: Uuid, + payload: &UpdateWorkspaceRequest, +) -> Result<(), String> { + // 检查权限(只有所有者可以更新) + let workspace = models::fetch_workspace_by_uuid(&svc_ctx.db, payload.uuid) + .await + .map_err(|e| e.to_string())? + .ok_or_else(|| "工作空间不存在".to_string())?; + + if workspace.owner_uuid != user_uuid { + return Err("只有工作空间所有者可以更新".to_string()); + } + + models::update_workspace(&svc_ctx.db, payload.uuid, payload.name.as_deref()) + .await + .map_err(|e| e.to_string()) +} + +/// 删除工作空间(软删除) +pub async fn delete_workspace_service( + svc_ctx: &SvcCtx, + user_uuid: Uuid, + workspace_uuid: Uuid, +) -> Result<(), String> { + // 检查权限(只有所有者可以删除) + let workspace = models::fetch_workspace_by_uuid(&svc_ctx.db, workspace_uuid) + .await + .map_err(|e| e.to_string())? + .ok_or_else(|| "工作空间不存在".to_string())?; + + if workspace.owner_uuid != user_uuid { + return Err("只有工作空间所有者可以删除".to_string()); + } + + // 检查用户是否只有一个工作空间,如果是则不允许删除 + let user_workspaces = models::fetch_user_workspaces(&svc_ctx.db, user_uuid) + .await + .map_err(|e| e.to_string())?; + + if user_workspaces.len() <= 1 { + return Err("至少需要保留一个工作空间,无法删除最后一个工作空间".to_string()); + } + + models::delete_workspace(&svc_ctx.db, workspace_uuid) + .await + .map_err(|e| e.to_string()) +} + +/// 检查用户是否是工作空间所有者 +pub async fn check_workspace_owner_service( + svc_ctx: &SvcCtx, + workspace_uuid: Uuid, + user_uuid: Uuid, +) -> Result { + models::check_workspace_owner(&svc_ctx.db, workspace_uuid, user_uuid) + .await + .map_err(|e| e.to_string()) +} diff --git a/src/state.rs b/src/state.rs new file mode 100644 index 0000000..1c75408 --- /dev/null +++ b/src/state.rs @@ -0,0 +1,77 @@ +use uuid::Uuid; + +/// 当前用户 +#[derive(Debug, Clone)] +pub struct CurrentUser { + pub user_uuid: Uuid, +} + +/// 当前工作空间 +#[derive(Debug, Clone)] +pub struct CurrentWorkspace { + pub workspace_uuid: Uuid, +} + +/// 当前 IP 地址 +#[derive(Debug, Clone)] +pub struct CurrentIpAddr { + pub real_ip: String, +} + +/// 当前机器用户 +#[derive(Debug, Clone)] +pub struct CurrentMachineUser { + pub machine_code: String, + pub platform: Option, + pub user_uuid: Option, + pub is_allowed: bool, // 是否允许使用 +} + +/// 请求上下文 - 包含所有请求相关的上下文信息 +/// +/// 在中间件中逐步填充,handler 中通过 Extension 获取 +#[derive(Debug, Clone, Default)] +pub struct RequestContext { + /// 当前用户信息 + pub current_user: Option, + /// 当前 IP 地址 + pub current_ip_addr: Option, + /// 当前机器用户 + pub current_machine_user: Option, + /// 当前团队 UUID + pub current_team_uuid: Option, + /// 当前工作空间 UUID + pub current_workspace_uuid: Option, +} + +impl RequestContext { + /// 获取用户 UUID(如果已认证) + pub fn user_uuid(&self) -> Option { + self.current_user.as_ref().map(|u| u.user_uuid) + } + + /// 获取用户 UUID,如果未认证则 panic + pub fn user_uuid_unwrap(&self) -> Uuid { + self.current_user.as_ref().expect("用户未认证").user_uuid + } + + /// 获取 IP 地址 + pub fn ip(&self) -> Option<&str> { + self.current_ip_addr.as_ref().map(|i| i.real_ip.as_str()) + } + + /// 获取 IP 地址,如果不存在则返回 "unknown" + pub fn ip_or_unknown(&self) -> &str { + self.ip().unwrap_or("unknown") + } + + /// 获取工作空间 UUID + pub fn workspace_uuid(&self) -> Option { + self.current_workspace_uuid + } + + /// 获取工作空间 UUID,如果不存在则 panic + pub fn workspace_uuid_unwrap(&self) -> Uuid { + self.current_workspace_uuid.expect("工作空间未设置") + } +} diff --git a/src/svc_ctx.rs b/src/svc_ctx.rs new file mode 100644 index 0000000..159634c --- /dev/null +++ b/src/svc_ctx.rs @@ -0,0 +1,52 @@ +use redis::aio::ConnectionManager; +use sqlx::{Pool, Postgres, postgres::PgPoolOptions}; + +use crate::utils::{DatabaseConfig, IConfig, RedisConfig}; + +/// 服务上下文 - 管理共享资源 +#[derive(Clone)] +pub struct SvcCtx { + pub config: IConfig, + pub db: Pool, + pub redis: ConnectionManager, +} + +impl SvcCtx { + /// 创建服务上下文 + pub async fn new(config: &IConfig) -> Result { + // 创建数据库连接池 + let db = Self::create_db(&config.database).await?; + let redis = Self::create_redis(&config.redis).await?; + + Ok(Self { + config: config.clone(), + db, + redis, + }) + } + + /// 创建数据库连接 + pub async fn create_db(config: &DatabaseConfig) -> Result, anyhow::Error> { + let pool = PgPoolOptions::new() + .max_lifetime(std::time::Duration::from_secs(config.max_lifetime as u64)) // 连接最大生存时间, 5 分钟 + .idle_timeout(std::time::Duration::from_secs(config.idle_timeout as u64)) // 空闲超时时间, 30秒 + .acquire_timeout(std::time::Duration::from_secs( + config.acquire_timeout as u64, + )) // 添加获取连接的超时时间 + .max_connections(config.max_connections) + .min_connections(config.min_connections) + .connect(&config.url) + .await?; + + Ok(pool) + } + + /// 创建redis连接 + pub async fn create_redis(config: &RedisConfig) -> Result { + let client = redis::Client::open(config.url.as_str())?; + + let connection = ConnectionManager::new(client).await?; + + Ok(connection) + } +} diff --git a/src/utils.rs b/src/utils.rs new file mode 100644 index 0000000..1d0f239 --- /dev/null +++ b/src/utils.rs @@ -0,0 +1,53 @@ +mod config; +pub mod encryption; +mod extractor; +pub mod gray_checker; +mod jwt; +pub mod minios; +mod password; +mod responses; +mod secret; + +pub use config::*; +pub use encryption::*; +pub use extractor::*; +pub use jwt::*; +pub use minios::*; +pub use password::*; +pub use responses::*; +pub use secret::*; + +/// 向指定邮箱发送验证码 +pub fn send_email( + smtp_username: &str, + smtp_password: &str, + smtp_server: &str, + to: &str, + title: &str, + body: &str, +) -> std::result::Result { + use lettre::message::header::ContentType; + use lettre::transport::smtp::authentication::Credentials; + use lettre::{Message, SmtpTransport, Transport}; + + let email = Message::builder() + .from(smtp_username.parse().map_err(|_| anyhow::anyhow!("from email is failed."))?) + .to(to.parse().map_err(|_| anyhow::anyhow!("receive email is failed."))?) + .subject(title) + .header(ContentType::TEXT_HTML) + .body(String::from(body))?; + let creds = Credentials::new(smtp_username.to_string(), smtp_password.to_string()); + let mailer = SmtpTransport::relay(smtp_server).unwrap().credentials(creds).build(); + + match mailer.send(&email) { + Ok(_) => Ok(true), + Err(_e) => Err(anyhow::anyhow!("email send failed.")), + } +} + +/// 随机生成6位的数字编码 +pub fn random_six_number_code() -> String { + let mut rng = rand::rng(); + let random_number: i32 = rand::Rng::random_range(&mut rng, 100000..1000000); + format!("{}", random_number) +} diff --git a/src/utils/config.rs b/src/utils/config.rs new file mode 100644 index 0000000..a40c57d --- /dev/null +++ b/src/utils/config.rs @@ -0,0 +1,113 @@ +use config::{Config, ConfigError}; +use serde::Deserialize; + +/// 数据库配置 +#[derive(Debug, Clone, Deserialize)] +pub struct DatabaseConfig { + pub url: String, + pub max_connections: u32, + pub min_connections: u32, + pub max_lifetime: u64, + pub acquire_timeout: u64, + pub idle_timeout: u64, +} + +/// Redis配置 +#[derive(Debug, Clone, Deserialize)] +pub struct RedisConfig { + pub url: String, +} + +/// MINIO 配置 +#[derive(Debug, Clone, Deserialize)] +pub struct MinioConfig { + pub server_url: String, + pub resource_url: String, + pub access_key: String, + pub secret_access_key: String, + pub avatar_bucket: String, + /// 扩展插件存储桶 + #[serde(default = "default_extension_bucket")] + pub extension_bucket: String, +} + +fn default_extension_bucket() -> String { + "extensions".to_string() +} + +/// SMTP 配置 +#[derive(Debug, Clone, Deserialize)] +pub struct SmtpConfig { + pub smtp_server: String, + pub smtp_username: String, + pub smtp_password: String, +} + +/// 工作空间默认配额配置 +#[derive(Debug, Clone, Deserialize)] +pub struct WorkspaceQuotaConfig { + /// 默认配额(新用户注册和手动创建工作空间都使用此配置) + #[serde(default = "default_workspace_quota")] + pub default: WorkspaceQuotaValues, +} + +/// 工作空间配额值 +#[derive(Debug, Clone, Deserialize)] +pub struct WorkspaceQuotaValues { + pub max_environments: i32, + pub max_team_members: i32, + pub max_proxies: i32, + pub max_rpa_tasks: i32, +} + +fn default_workspace_quota() -> WorkspaceQuotaValues { + WorkspaceQuotaValues { + max_environments: 8, + max_team_members: 1, + max_proxies: 99999, + max_rpa_tasks: 99999, + } +} + +/// 应用配置 +#[derive(Debug, Clone, Deserialize)] +pub struct AppConfig { + pub name: String, + pub port: u16, + pub secret: String, + pub prefix: String, + pub encrypt_secret_location: String, + pub route_whitelists: Vec, + /// 推广链接前缀,例如: https://www.example.com/register + /// 实际推广链接将拼接为: {referral_link_prefix}?referral_code={code} + pub referral_link_prefix: Option, +} + +#[derive(Debug, Clone, Deserialize)] +pub struct IConfig { + pub app: AppConfig, + pub database: DatabaseConfig, + pub redis: RedisConfig, + pub minio: Option, + pub smtp: Option, + #[serde(default = "default_workspace_quota_config")] + pub workspace_quota: WorkspaceQuotaConfig, +} + +fn default_workspace_quota_config() -> WorkspaceQuotaConfig { + WorkspaceQuotaConfig { + default: default_workspace_quota(), + } +} + +impl IConfig { + pub fn build_by_filepath(config_path: &str) -> Result { + let config = Config::builder() + .add_source(config::File::with_name(config_path)) + .add_source(config::File::with_name(".").required(false)) + .add_source(config::Environment::with_prefix("APP")) + .build()?; + + config.try_deserialize() + } +} diff --git a/src/utils/encryption.rs b/src/utils/encryption.rs new file mode 100644 index 0000000..25cfd64 --- /dev/null +++ b/src/utils/encryption.rs @@ -0,0 +1,109 @@ +use aes_gcm::{ + Aes256Gcm, Nonce, + aead::{Aead, KeyInit}, +}; +use base64::{Engine, engine::general_purpose::STANDARD as BASE64}; +use rand::RngCore; +use sha2::{Digest, Sha256}; + +const NONCE_SIZE: usize = 12; + +/// 应用程序固定标识符,用于派生加密密钥 +/// 注意:此值一旦确定后不可修改,否则已加密的数据将无法解密 +const APP_SECRET_SALT: &str = "simprint-fingerprint-browser-v1-secure-salt-2024"; + +/// 加密密码 +/// +/// 使用 AES-256-GCM 加密,返回 base64 编码的密文 +pub fn encrypt_password(password: &str, key: &[u8; 32]) -> Result { + let cipher = Aes256Gcm::new_from_slice(key).map_err(|e| e.to_string())?; + + // 生成随机 nonce + let mut nonce_bytes = [0u8; NONCE_SIZE]; + rand::rng().fill_bytes(&mut nonce_bytes); + let nonce = Nonce::from_slice(&nonce_bytes); + + // 加密 + let ciphertext = cipher.encrypt(nonce, password.as_bytes()).map_err(|e| e.to_string())?; + + // 组合 nonce + ciphertext + let mut result = nonce_bytes.to_vec(); + result.extend(ciphertext); + + Ok(BASE64.encode(result)) +} + +/// 解密密码 +/// +/// 接收 base64 编码的密文,返回原始密码 +pub fn decrypt_password(encrypted: &str, key: &[u8; 32]) -> Result { + let cipher = Aes256Gcm::new_from_slice(key).map_err(|e| e.to_string())?; + + // Base64 解码 + let data = BASE64.decode(encrypted).map_err(|e| e.to_string())?; + + if data.len() < NONCE_SIZE { + return Err("Invalid encrypted data".to_string()); + } + + // 分离 nonce 和 ciphertext + let (nonce_bytes, ciphertext) = data.split_at(NONCE_SIZE); + let nonce = Nonce::from_slice(nonce_bytes); + + // 解密 + let plaintext = + cipher.decrypt(nonce, ciphertext).map_err(|_| "Decryption failed".to_string())?; + + String::from_utf8(plaintext).map_err(|e| e.to_string()) +} + +/// 获取加密密钥 +/// +/// 使用确定性算法从固定盐值派生 32 字节 AES-256 密钥 +/// 每次调用都会返回相同的密钥,无需外部配置 +pub fn get_encryption_key() -> [u8; 32] { + // 使用 SHA-256 从固定盐值派生密钥 + let mut hasher = Sha256::new(); + hasher.update(APP_SECRET_SALT.as_bytes()); + let result = hasher.finalize(); + + let mut key = [0u8; 32]; + key.copy_from_slice(&result); + key +} + +#[cfg(test)] +mod tests { + use super::*; + + #[test] + fn test_encrypt_decrypt() { + let key = get_encryption_key(); + let password = "my_secret_password"; + + let encrypted = encrypt_password(password, &key).unwrap(); + let decrypted = decrypt_password(&encrypted, &key).unwrap(); + + assert_eq!(password, decrypted); + } + + #[test] + fn test_key_deterministic() { + // 确保多次调用返回相同的密钥 + let key1 = get_encryption_key(); + let key2 = get_encryption_key(); + + assert_eq!(key1, key2); + } + + #[test] + fn test_encrypt_decrypt_special_chars() { + let key = get_encryption_key(); + let password = "P@ssw0rd!#$%^&*()_+-=[]{}|;':\",./<>?中文密码"; + + let encrypted = encrypt_password(password, &key).unwrap(); + let decrypted = decrypt_password(&encrypted, &key).unwrap(); + + assert_eq!(password, decrypted); + } +} diff --git a/src/utils/extractor.rs b/src/utils/extractor.rs new file mode 100644 index 0000000..78f8b82 --- /dev/null +++ b/src/utils/extractor.rs @@ -0,0 +1,43 @@ +use axum::{ + extract::{self, FromRequest, Request, rejection::JsonRejection}, + http::StatusCode, +}; +use serde::{Serialize, de::DeserializeOwned}; + +use crate::utils::Response; + +/// 自定义Extractor +/// +/// 不使用默认的JSON extractor, 通过该extract提取可以在提取成功或失败时完成额外的操作。 +pub struct Json(pub T); + +impl FromRequest for Json +where + T: DeserializeOwned + Serialize, + S: Send + Sync, +{ + type Rejection = Response; + + async fn from_request(req: Request, state: &S) -> Result { + match extract::Json::::from_request(req, state).await { + Ok(body) => Ok(Self(body.0)), + Err(rejection) => { + let (status, message) = match rejection { + JsonRejection::JsonDataError(e) => { + eprintln!("{:?}", e); + (StatusCode::BAD_REQUEST, "请求参数错误") + } + JsonRejection::JsonSyntaxError(_) => { + (StatusCode::BAD_REQUEST, "请求参数语法错误") + } + JsonRejection::MissingJsonContentType(_) => { + (StatusCode::BAD_REQUEST, "缺少请求参数") + } + _ => (StatusCode::OK, ""), + }; + + Err(Response::fail_with_statu_code(Some(message), status)) + } + } + } +} diff --git a/src/utils/gray_checker.rs b/src/utils/gray_checker.rs new file mode 100644 index 0000000..eab52b8 --- /dev/null +++ b/src/utils/gray_checker.rs @@ -0,0 +1,88 @@ +use crate::dto::gray_releases::GrayRelease; +use crate::errors::SimprintError; + +/// 根据策略类型检查机器是否匹配 +/// 注意:filter_whitelist 策略已在数据库层实现,此函数仅处理 filter_percentage 和 filter_random +pub fn check_machine_by_strategy( + machine_code: &str, + _user_uuid: Option<&uuid::Uuid>, + release: &GrayRelease, +) -> Result { + // 如果没有配置策略,默认返回 false + let strategy_type = match &release.strategy_type { + Some(st) => st, + None => return Ok(false), + }; + + // 如果没有配置参数,返回 false + let config = match &release.strategy_config { + Some(cfg) => cfg, + None => return Ok(false), + }; + + match strategy_type.as_str() { + "filter_percentage" => { + let percent: f64 = config + .get("percent") + .and_then(|v| v.as_f64()) + .ok_or_else(|| SimprintError::InvalidStrategyConfig)?; + is_in_percentage_range(machine_code, release.id, percent) + } + "filter_random" => { + let seed = config.get("seed").and_then(|v| v.as_u64()).unwrap_or(12345); + is_in_random_selection(machine_code, release.id, seed) + } + "filter_user_tag" | "filter_whitelist" => { + // 这些策略已在数据库查询阶段处理,不应该到达这里 + Ok(false) + } + _ => Err(SimprintError::InvalidStrategyConfig), + } +} + +/// 检查是否在百分比范围内 +fn is_in_percentage_range( + machine_code: &str, + release_id: i32, + percent: f64, +) -> Result { + if percent < 0.0 || percent > 100.0 { + return Err(SimprintError::InvalidStrategyConfig); + } + + // 使用机器码和灰度发布ID的哈希值计算 + let combined = format!("{}:{}", machine_code, release_id); + let hash = calculate_hash(&combined); + + // 将哈希值映射到 0-100 范围 + let value = (hash % 10000) as f64 / 100.0; + + Ok(value < percent) +} + +/// 检查是否在随机选择中 +fn is_in_random_selection( + machine_code: &str, + release_id: i32, + seed: u64, +) -> Result { + // 使用机器码、灰度发布ID和种子值的哈希值计算 + let combined = format!("{}:{}:{}", machine_code, release_id, seed); + let hash = calculate_hash(&combined); + + // 将哈希值映射到 0-1 范围 + let value = (hash % 10000) as f64 / 10000.0; + + // 默认概率为 0.5(50%) + Ok(value < 0.5) +} + +/// 计算字符串的哈希值 +fn calculate_hash(s: &str) -> u64 { + use std::collections::hash_map::DefaultHasher; + use std::hash::{Hash, Hasher}; + + let mut hasher = DefaultHasher::new(); + s.hash(&mut hasher); + hasher.finish() +} diff --git a/src/utils/jwt.rs b/src/utils/jwt.rs new file mode 100644 index 0000000..9bea4b8 --- /dev/null +++ b/src/utils/jwt.rs @@ -0,0 +1,59 @@ +use jwt::Token; + +// 生成token +pub fn generate_token( + user_uuid: &str, + expiration: u64, + secret: &[u8], +) -> Result { + use aes_gcm::KeyInit; + use hmac::Hmac; + use jwt::SignWithKey; + use std::collections::BTreeMap; + + let key: Hmac = Hmac::new_from_slice(secret).unwrap(); + + let mut claims = BTreeMap::new(); + claims.insert("uuid", format!("{}", user_uuid)); + claims.insert( + "exp", + format!("{}", chrono::Utc::now().timestamp() + expiration as i64), + ); + claims.insert("iat", format!("{}", chrono::Utc::now().timestamp())); + + let header = jwt::Header { + algorithm: jwt::AlgorithmType::Hs256, + ..Default::default() + }; + + Token::new(header, claims).sign_with_key(&key).map(|v| v.as_str().to_string()) +} + +// 校验token +pub fn verify_token(token_str: &str, secret: &[u8]) -> Result { + use aes_gcm::KeyInit; + use hmac::Hmac; + use jwt::VerifyWithKey; + use sha2::Sha256; + use std::collections::BTreeMap; + + let key: Hmac = Hmac::new_from_slice(secret)?; + let claims: BTreeMap = token_str.verify_with_key(&key)?; + let exp_str = claims.get("exp"); + + // 检查过期时间 + let exp = exp_str + .ok_or_else(|| anyhow::anyhow!("token expired."))? + .parse::() + .map_err(|_| anyhow::anyhow!("token expired."))?; + let current_time = chrono::Utc::now().timestamp() as u64; + if current_time > exp { + return Err(anyhow::anyhow!("token expired.")); + } + + let uuid = claims.get("uuid"); + match uuid { + Some(uuid) => return Ok(uuid.to_string()), + None => Err(anyhow::anyhow!("token parse failed.")), + } +} diff --git a/src/utils/minios.rs b/src/utils/minios.rs new file mode 100644 index 0000000..dd83ebb --- /dev/null +++ b/src/utils/minios.rs @@ -0,0 +1,111 @@ +use std::{path::Path, sync::Arc}; + +pub mod get_objects; +pub mod put_objects; +pub mod remove_objects; + +use minio::s3::types::S3Api; +use minio::s3::{client::ClientBuilder, creds::StaticProvider, http::BaseUrl}; +use tokio::sync::OnceCell; + +/// Minio client for interacting with MinIO server +pub struct Minio { + pub client: minio::s3::client::Client, +} + +impl Minio { + /// 创建一个新的Minio客户端 + /// + /// # Arguments + /// - `base_url`: MinIO服务器的基础URL。 + /// - `access_key`: MinIO认证的访问密钥。 + /// - `secret_key`: MinIO认证的密钥。 + /// - `session_token`: 可选的临时凭证会话令牌。 + /// - `cert_path`: 可选的SSL证书文件路径。 + pub fn new( + base_url: &str, + access_key: &str, + secret_key: &str, + session_token: Option<&str>, + cert_path: Option<&Path>, + ) -> Result { + let base_url = base_url.parse::()?; + let static_provider = StaticProvider::new(access_key, secret_key, session_token); + + let client = ClientBuilder::new(base_url) + .provider(Some(Box::new(static_provider))) + .ssl_cert_file(cert_path) + .build()?; + + Ok(Self { client }) + } + + /// 初始化bucket + pub async fn init_bucket(&self, buckets: Vec) -> Result<(), anyhow::Error> { + for bucket_name in &buckets { + let resp = self.client.bucket_exists(bucket_name).send().await?; + + if !resp.exists { + self.client.create_bucket(bucket_name).send().await?; + // 自定义桶策略 + // 这里设置桶策略为公开读取 + let config = format!( + r#"{{ + "Version": "2012-10-17", + "Statement": [ + {{ + "Effect": "Allow", + "Principal": {{"AWS": "*"}}, + "Action": "s3:GetObject", + "Resource": "arn:aws:s3:::{}/*" + }} + ] + }}"#, + bucket_name + ); + self.client.put_bucket_policy(bucket_name).config(config).send().await?; + } + } + + Ok(()) + } + + // 获取Minio客户端 + pub fn get_client(&self) -> &minio::s3::client::Client { + &self.client + } +} + +static MINIO: OnceCell> = OnceCell::const_new(); + +/// 初始化Minio客户端 +pub async fn init_minio( + base_url: &str, + access_key: &str, + secret_key: &str, + session_token: Option<&str>, + cert_path: Option<&Path>, +) -> Result<&'static Arc, anyhow::Error> { + Ok(MINIO + .get_or_init(|| async { + let minio = match Minio::new(base_url, access_key, secret_key, session_token, cert_path) + { + Ok(v) => v, + Err(e) => { + tracing::error!("Failed to initialize the MinIO client: {:?}", e); + std::process::exit(-1); + } + }; + + Arc::new(minio) + }) + .await) +} + +/// 获取Minio客户端 +pub fn get_minio_client() -> Result<&'static minio::s3::client::Client, anyhow::Error> { + match MINIO.get() { + Some(minio) => Ok(minio.get_client()), + None => Err(anyhow::anyhow!("MinIO client not initialized")), + } +} diff --git a/src/utils/minios/get_objects.rs b/src/utils/minios/get_objects.rs new file mode 100644 index 0000000..891aaaf --- /dev/null +++ b/src/utils/minios/get_objects.rs @@ -0,0 +1,56 @@ +//! MinIO 对象 URL 获取模块 +//! +//! 提供获取 MinIO 存储对象访问 URL 的功能 + +/// 获取对象的完整 URL +/// +/// # Arguments +/// - `resource_url`: MinIO 资源访问基础 URL(如 `http://localhost:9000`) +/// - `bucket_name`: 存储桶名称 +/// - `object_path`: 对象路径 +/// +/// # Returns +/// 返回完整的访问 URL +pub fn get_object_url(resource_url: &str, bucket_name: &str, object_path: &str) -> String { + format!("{}/{}/{}", resource_url, bucket_name, object_path) +} + +/// 获取扩展 CRX 文件的完整 URL +/// +/// # Arguments +/// - `resource_url`: MinIO 资源访问基础 URL +/// - `bucket_name`: 扩展存储桶名称 +/// - `object_path`: CRX 对象路径(数据库中存储的路径) +pub fn get_extension_crx_url(resource_url: &str, bucket_name: &str, object_path: &str) -> String { + get_object_url(resource_url, bucket_name, object_path) +} + +/// 获取扩展图标的完整 URL +/// +/// # Arguments +/// - `resource_url`: MinIO 资源访问基础 URL +/// - `bucket_name`: 扩展存储桶名称 +/// - `object_path`: 图标对象路径(数据库中存储的路径) +// pub fn get_extension_icon_url(resource_url: &str, bucket_name: &str, object_path: &str) -> String { +pub fn get_extension_icon_url( + resource_url: &Option, + bucket_name: &Option, + object_path: &str, +) -> Option { + match (resource_url, bucket_name) { + (Some(resource_url), Some(bucket_name)) => { + Some(get_object_url(resource_url, bucket_name, object_path)) + } + _ => None, + } +} + +/// 获取头像的完整 URL +/// +/// # Arguments +/// - `resource_url`: MinIO 资源访问基础 URL +/// - `bucket_name`: 头像存储桶名称 +/// - `resource_hash`: 头像文件哈希 +pub fn get_avatar_url(resource_url: &str, bucket_name: &str, resource_hash: &str) -> String { + get_object_url(resource_url, bucket_name, resource_hash) +} diff --git a/src/utils/minios/put_objects.rs b/src/utils/minios/put_objects.rs new file mode 100644 index 0000000..c05eefb --- /dev/null +++ b/src/utils/minios/put_objects.rs @@ -0,0 +1,133 @@ +//! MinIO 对象上传模块 +//! +//! 提供各类资源上传到 MinIO 的功能 + +use bytes::Bytes; +use minio::s3::{segmented_bytes::SegmentedBytes, types::S3Api}; +use sha2::{Digest, Sha256}; + +use super::get_minio_client; + +/// 上传通用对象到 MinIO +/// +/// # Arguments +/// - `bucket_name`: 存储桶名称 +/// - `object_path`: 对象路径(如 `hash/version/filename`) +/// - `data`: 对象数据 +pub async fn put_object( + bucket_name: &str, + object_path: &str, + data: Bytes, +) -> Result<(), anyhow::Error> { + let client = get_minio_client()?; + let data: SegmentedBytes = SegmentedBytes::from(data); + client.put_object(bucket_name, object_path, data).send().await?; + Ok(()) +} + +/// 上传扩展 CRX 文件到 MinIO +/// +/// 存储路径格式:`{extension_id_hash}/{version}/{crx_hash}.crx` +/// +/// # Arguments +/// - `bucket_name`: 扩展存储桶名称 +/// - `extension_id`: 扩展 ID(如 Chrome 扩展 ID) +/// - `version`: 扩展版本号 +/// - `crx_hash`: CRX 文件内容的哈希值 +/// - `data`: CRX 文件数据 +/// +/// # Returns +/// 返回存储的对象路径(不包含 bucket 名称) +pub async fn put_extension_crx( + bucket_name: &str, + extension_id: &str, + version: &str, + crx_hash: &str, + data: Bytes, +) -> Result { + let client = get_minio_client()?; + + // 计算扩展 ID 的哈希作为目录名 + let extension_id_hash = calculate_short_hash(extension_id); + + // 构建对象路径:{extension_id_hash}/{version}/{crx_hash}.crx + let object_path = format!("{}/{}/{}.crx", extension_id_hash, version, crx_hash); + + let data: SegmentedBytes = SegmentedBytes::from(data); + client.put_object(bucket_name, &object_path, data).send().await?; + + tracing::info!( + "Extension CRX uploaded: bucket={}, path={}", + bucket_name, + object_path + ); + + Ok(object_path) +} + +/// 上传扩展图标到 MinIO +/// +/// 存储路径格式:`{extension_id_hash}/icons/{icon_hash}.{ext}` +/// +/// # Arguments +/// - `bucket_name`: 扩展存储桶名称 +/// - `extension_id`: 扩展 ID +/// - `icon_hash`: 图标文件的哈希值 +/// - `extension`: 文件扩展名(如 `png`、`svg`) +/// - `data`: 图标数据 +/// +/// # Returns +/// 返回存储的对象路径 +pub async fn put_extension_icon( + bucket_name: &str, + extension_id: &str, + icon_hash: &str, + extension: &str, + data: Bytes, +) -> Result { + let client = get_minio_client()?; + + let extension_id_hash = calculate_short_hash(extension_id); + let object_path = format!("{}/icons/{}.{}", extension_id_hash, icon_hash, extension); + + let data: SegmentedBytes = SegmentedBytes::from(data); + client.put_object(bucket_name, &object_path, data).send().await?; + + tracing::info!( + "Extension icon uploaded: bucket={}, path={}", + bucket_name, + object_path + ); + + Ok(object_path) +} + +/// 上传头像对象到 MinIO +pub async fn put_avatar_object( + bucket_name: &str, + object: &str, + data: Bytes, +) -> Result<(), anyhow::Error> { + let client = get_minio_client()?; + let data: SegmentedBytes = SegmentedBytes::from(data); + client.put_object(bucket_name, object, data).send().await?; + Ok(()) +} + +/// 计算字符串的短哈希值(用于目录命名) +/// +/// 使用 SHA256 取前 16 个字符 +fn calculate_short_hash(input: &str) -> String { + let mut hasher = Sha256::new(); + hasher.update(input.as_bytes()); + let result = hasher.finalize(); + hex::encode(&result[..8]) // 取前 8 字节 = 16 个十六进制字符 +} + +/// 计算文件内容的哈希值(SHA256) +pub fn calculate_file_hash(data: &[u8]) -> String { + let mut hasher = Sha256::new(); + hasher.update(data); + let result = hasher.finalize(); + hex::encode(result) +} diff --git a/src/utils/minios/remove_objects.rs b/src/utils/minios/remove_objects.rs new file mode 100644 index 0000000..a999ab4 --- /dev/null +++ b/src/utils/minios/remove_objects.rs @@ -0,0 +1,13 @@ +// use minio::s3::types::S3Api; + +// /// 从 MinIO 中删除对象 +// pub async fn remove_csgo_mvp_music_object( +// bucket_name: &str, +// object: &str, +// ) -> Result<(), anyhow::Error> { +// let client = crate::minios::get_minio_client()?; + +// client.delete_object(bucket_name, object).send().await?; + +// Ok(()) +// } diff --git a/src/utils/password.rs b/src/utils/password.rs new file mode 100644 index 0000000..89fd423 --- /dev/null +++ b/src/utils/password.rs @@ -0,0 +1,26 @@ +use argon2::{ + Argon2, + password_hash::{PasswordHash, PasswordHasher, PasswordVerifier, SaltString, rand_core::OsRng}, +}; + +/// 对密码进行加密 +pub fn encryption_password(password: &str) -> Result { + let salt = SaltString::generate(&mut OsRng); + + let argon2 = Argon2::default(); + + match argon2.hash_password(password.as_bytes(), &salt) { + Ok(password_hash) => Ok(password_hash.to_string()), + Err(_) => Err(anyhow::anyhow!("密码异常")), + } +} + +pub fn verify_password(password: &str, password_hash: &str) -> bool { + let parsed_hash = if let Ok(parsed_hash) = PasswordHash::new(&password_hash) { + parsed_hash + } else { + return false; + }; + + Argon2::default().verify_password(password.as_bytes(), &parsed_hash).is_ok() +} diff --git a/src/utils/responses.rs b/src/utils/responses.rs new file mode 100644 index 0000000..24c2239 --- /dev/null +++ b/src/utils/responses.rs @@ -0,0 +1,104 @@ +use axum::{ + extract::multipart::MultipartError, + http::StatusCode, + response::{IntoResponse, Json}, +}; +use serde::{Deserialize, Serialize}; +use serde_json::json; +use std::fmt::{Debug, Display}; + +const SUCCESS_CODE: i32 = 1; +const FAIL_CODE: i32 = 0; + +pub type Result = axum::response::Result, Response>; + +/// 统一响应类型 +#[derive(Debug, Serialize, Clone, Deserialize)] +pub struct Response { + pub status_code: u16, + pub code: i32, + pub data: Option, + pub message: Option, +} + +impl Response { + /// 操作成功对应的响应类型 + pub fn success(message: Option<&str>, data: Option) -> Self { + Response { + code: SUCCESS_CODE, + message: message.map(|v| v.to_string()), + data, + status_code: 200, + } + } + + /// 操作失败对应的响应类型 + pub fn fail(message: Option<&str>) -> Self { + Response { + code: FAIL_CODE, + message: message.map(|v| v.to_string()), + data: None, + status_code: 200, + } + } + + /// 操作失败对应的响应类型 + pub fn fail_with_statu_code(message: Option<&str>, statu_code: StatusCode) -> Self { + Response { + code: FAIL_CODE, + message: message.map(|v| v.to_string()), + data: None, + status_code: statu_code.as_u16(), + } + } + + /// with status code + pub fn with_status_code(self, status_code: StatusCode) -> Self { + let mut response = self; + response.status_code = status_code.as_u16(); + response + } +} + +// 允许直接打印和to_string +impl Display for Response { + fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result { + write!(f, "{}", self) + } +} + +// 当实现该类型后可以直接将Response作为axum路由处理函数的返回值,会自动调用该trait的into_response方法最终返回json. +impl IntoResponse for Response +where + T: Serialize, +{ + fn into_response(self) -> axum::response::Response { + let mut content_json = json!({ "code": self.code }); + + // 如果 message 存在,添加到 content_json + if let Some(message) = &self.message { + content_json["message"] = json!(message); + } + + // 如果 data 存在,添加到 content_json + if let Some(data) = &self.data { + content_json["data"] = json!(data); + } + + match StatusCode::from_u16(self.status_code) { + Ok(status) => (status, Json(content_json)), + Err(_) => (StatusCode::BAD_REQUEST, Json(content_json)), + } + .into_response() + } +} + +/// 实现From +impl From for Response<()> { + fn from(value: MultipartError) -> Self { + Response::fail_with_statu_code( + Some(format!("请求参数错误: {:?}", value.body_text()).as_ref()), + value.status(), + ) + } +} diff --git a/src/utils/secret/aes.rs b/src/utils/secret/aes.rs new file mode 100644 index 0000000..6191103 --- /dev/null +++ b/src/utils/secret/aes.rs @@ -0,0 +1,215 @@ +use aes_gcm::{ + Aes256Gcm, Key, Nonce, + aead::{Aead, AeadCore, KeyInit, OsRng}, +}; +use anyhow::Context; +use base64::{Engine as _, engine::general_purpose::STANDARD as BASE64_STANDARD}; +use std::fmt::Debug; + +const AES_KEY_SIZE: usize = 32; +const NONCE_SIZE: usize = 12; + +/// key本身是字节数组, 先将字节数组转换成base64编码得字符串,然后再通过非对称公钥加密再转换成base64编码的字符串 +#[derive(Debug, Clone, Default)] +pub struct AesSecret { + key: Key, // 直接存储 GenericArray 以避免重复转换 +} + +impl AesSecret { + /// 生成一个新的 AES 密钥实例 + pub fn new() -> Self { + let key_bytes = Aes256Gcm::generate_key(&mut OsRng); + AesSecret { key: key_bytes } + } + + /// 从已有的密钥字节创建 AesSecret 实例 + /// 输入的 key_bytes 必须是正确的长度 (例如 32 字节 for AES-256) + pub fn from_bytes(key_bytes: &[u8]) -> Result { + if key_bytes.len() != AES_KEY_SIZE { + return Err(anyhow::anyhow!( + "Invalid key size: expected {}, got {}", + AES_KEY_SIZE, + key_bytes.len() + )); + } + Ok(AesSecret { + key: Key::::clone_from_slice(key_bytes), + }) + } + + /// 获取 AES 密钥的 Base64 编码字符串 + pub fn get_key_as_base64(&self) -> String { + BASE64_STANDARD.encode(self.key.as_slice()) + } + + /// 使用 AES 密钥加密数据 + /// 返回: 加密后的数据 Vec与Nonce Vec拼接的 Vec + pub fn encrypt(&self, data: &[u8]) -> Result { + let cipher = Aes256Gcm::new(&self.key); + // 为每次加密生成新的、唯一的 Nonce + let nonce_bytes = Aes256Gcm::generate_nonce(&mut OsRng); // 生成随机 Nonce + let nonce_instance = Nonce::from_slice(nonce_bytes.as_slice()); + + let ciphertext = cipher.encrypt(nonce_instance, data).map_err(|e| { + tracing::error!("Failed to encrypt data: {:?}", e); + // 使用 context 来添加上下文信息 + anyhow::anyhow!("Failed to encrypt data: {:?}", e) + })?; + + let mut result = Vec::with_capacity(ciphertext.len() + NONCE_SIZE); + result.extend_from_slice(nonce_bytes.as_slice()); + result.extend_from_slice(&ciphertext); + + // 将结果转换为 Base64 编码字符串 + let result = BASE64_STANDARD.encode(&result); + + Ok(result) + } + + /// 使用 AES 密钥和提供的 Nonce 解密数据 + pub fn decrypt(&self, base64_data: &str) -> Result, anyhow::Error> { + // 先对Base64编码的字符串进行解码 + let data = BASE64_STANDARD.decode(base64_data).context("Failed to decode base64 data")?; + + if data.len() < NONCE_SIZE { + return Err(anyhow::anyhow!("Invalid data")); + } + + let (nonce, ciphertext) = data.split_at(NONCE_SIZE); + let cipher = Aes256Gcm::new(&self.key); + let nonce_instance = Nonce::from_slice(nonce); + + cipher.decrypt(nonce_instance, ciphertext).map_err(|e| { + tracing::error!("Failed to decrypt data: {:?}", e); + anyhow::anyhow!("Failed to decrypt data: {:?}", e) + }) + } +} + +/// 从 Base64 编码的密钥字符串创建 AesSecret +impl TryFrom<&str> for AesSecret { + type Error = anyhow::Error; + + fn try_from(key_base64: &str) -> Result { + let decoded_key_bytes = BASE64_STANDARD + .decode(key_base64) + .context("Failed to decode base64 key string")?; + + Self::from_bytes(&decoded_key_bytes) + } +} + +#[cfg(test)] +mod tests { + + use super::*; + + #[test] + fn test_encrypt_decrypt_workflow() { + let secret = AesSecret::new(); + let plaintext = b"Hello, secure world of AES-GCM!"; // 字节数组 + + // 加密 + let encrypt_str = secret.encrypt(plaintext).expect("Encryption failed"); + println!("encrypt_str: {}", encrypt_str); + + // 解密 + let decrypted_bytes = secret.decrypt(&encrypt_str).unwrap(); + + // 将解密后的字节数组转换回字符串 + let original_str = String::from_utf8(decrypted_bytes).unwrap(); + println!("解密后的数据: {}", original_str); + } + + #[test] + fn test_encrypt_decrypt_json_workflow() { + let secret = AesSecret::new(); + + // 创建一个要序列化的结构体 + #[derive(serde::Serialize, serde::Deserialize, Debug, PartialEq)] + struct TestData { + message: String, + value: i32, + } + + let test_data = TestData { + message: "Hello, secure world of AES-GCM!".to_string(), + value: 42, + }; + + // 序列化为JSON + let json_bytes = serde_json::to_vec(&test_data).expect("Serialization failed"); + + // 加密JSON数据 + let encrypted = secret.encrypt(&json_bytes).expect("Encryption failed"); + println!("Encrypted: {}", encrypted); + + // 解密 + let decrypted = secret.decrypt(&encrypted).expect("Decryption failed"); + + // 从JSON反序列化 + let restored_data: TestData = + serde_json::from_slice(&decrypted).expect("Deserialization failed"); + println!("Restored: {:?}", restored_data); + + // 确认数据一致 + assert_eq!(test_data, restored_data); + } + + /// 测试加密流程 + #[test] + fn test_login_payload_encrypt() { + use rsa::pkcs1::DecodeRsaPublicKey; + + let secret = AesSecret::new(); + let key = secret.get_key_as_base64(); + + // 测试通过base64编码的字符串创建AesSecret实例 + // let _ = AesSecret::try_from(key.as_str()).expect("创建AES实例失败"); + + #[derive(serde::Serialize, serde::Deserialize, Debug)] + struct LoginRequest { + email: String, + password: String, + public_secret_key: String, + } + let payload = LoginRequest { + email: "liusnew@gmail.com".to_string(), + password: "liusNew57~".to_string(), + public_secret_key: "-----BEGIN RSA PUBLIC KEY-----\nMIIBCgKCAQEAseI9vA7iTxOMb5Y2xCL7BOGr1by9qEH4EfP9Bj90gxDmY8yRsVK/\no2g+i95oQxzdvdvpPAocKlQv2FEbZaqFr2Q4vy1cLrM0B1NTZl1/hGcmPSofLT9g\nmnjzP60ikY40Dxq+YXAxXZ4s2M+9thNnFr4OydacHEPEkTklcBQBglopSXc1yqHU\nARyCQ3/VxQrfh215vIPgMg2f6PH741zXFaIJjucXR8wJVySo7aZhlBTOVz5GzV0b\naWh31zA47ivXh84OXIEI+CKDUSnvsa8SCRMRs8LgaO1Xktv4yCfHHpo8Zoy+KdW0\nLJxP11G+3f9RsYRpjdmsleyHuYYS07suqwIDAQAB\n-----END RSA PUBLIC KEY-----\n".to_string(), + }; + + let json_bytes = serde_json::to_vec(&payload).expect("Serialization failed"); + let encrypted = secret.encrypt(&json_bytes).expect("Encryption failed"); + + // 下面连续的代码对应: crate::secret::rsa::get_rsa_secret_instance() + let public_key_str = + std::fs::read("../../assets/secret/public_key.pem").expect("读取公钥失败"); + let public_key = String::from_utf8(public_key_str).expect("转换公钥失败"); + let public_key = rsa::RsaPublicKey::from_pkcs1_pem(&public_key) + .map_err(|e| { + tracing::error!("Failed to parse public key: {:?}", e); + anyhow::anyhow!("Failed to parse public key: {:?}", e) + }) + .unwrap(); + let mut rng = rsa::rand_core::OsRng::default(); + let encrypted_data = public_key + .encrypt(&mut rng, rsa::Pkcs1v15Encrypt, key.as_bytes()) + .map_err(|e| { + tracing::error!("Failed to encrypt data: {:?}", e); + anyhow::anyhow!("Failed to encrypt data: {:?}", e) + }) + .unwrap(); + + // 通过一个非对称公钥加密key + let encrypted_key = base64::engine::general_purpose::STANDARD.encode(&encrypted_data); + + let result = serde_json::json!({ + "data": encrypted, + "encrypted": true, + "key": encrypted_key, + }); + let result = serde_json::to_string_pretty(&result).unwrap(); + eprintln!("{}", result); + } +} diff --git a/src/utils/secret/mod.rs b/src/utils/secret/mod.rs new file mode 100644 index 0000000..e800b7d --- /dev/null +++ b/src/utils/secret/mod.rs @@ -0,0 +1,5 @@ +mod aes; +mod rsa; + +pub use aes::*; +pub use rsa::*; diff --git a/src/utils/secret/rsa.rs b/src/utils/secret/rsa.rs new file mode 100644 index 0000000..907236f --- /dev/null +++ b/src/utils/secret/rsa.rs @@ -0,0 +1,172 @@ +use std::{fmt::Debug, fs, path::Path}; + +use base64::Engine; +use rsa::{ + Pkcs1v15Encrypt, RsaPrivateKey, + pkcs1::{DecodeRsaPrivateKey, DecodeRsaPublicKey, EncodeRsaPrivateKey, EncodeRsaPublicKey}, +}; +use tokio::sync::OnceCell; + +#[derive(Debug, Clone)] +pub struct RsaSecret { + pub private_key: RsaPrivateKey, + pub public_key: String, +} + +impl RsaSecret { + pub fn new(key_path: &str) -> Result { + let key_dir = Path::new(key_path); + let private_key_path = key_dir.join("private_key.pem"); + let public_key_path = key_dir.join("public_key.pem"); + + // 如果已经有密钥文件,优先复用,避免每次启动刷新 + if private_key_path.exists() && public_key_path.exists() { + let private_key_pem = fs::read_to_string(&private_key_path)?; + let private_key = RsaPrivateKey::from_pkcs1_pem(&private_key_pem)?; + let public_key_pem = fs::read_to_string(&public_key_path)?; + + return Ok(RsaSecret { + private_key, + public_key: public_key_pem, + }); + } + + // 否则生成新的密钥对 + if !key_dir.exists() { + fs::create_dir_all(key_dir)?; + } + + let mut rng = rsa::rand_core::OsRng::default(); + let bits = 2048; + let private_key = RsaPrivateKey::new(&mut rng, bits)?; + let public_key = private_key.to_public_key(); + + let private_key_pem = private_key.to_pkcs1_pem(rsa::pkcs8::LineEnding::LF)?.to_string(); + let public_key_pem = public_key.to_pkcs1_pem(rsa::pkcs8::LineEnding::LF)?; + + fs::write(&private_key_path, &private_key_pem)?; + fs::write(&public_key_path, &public_key_pem)?; + + Ok(RsaSecret { + private_key, + public_key: public_key_pem, + }) + } + + /// 获取公钥对 + pub fn get_public_key(&self) -> String { + self.public_key.clone() + } + + /// 获取私钥对 + pub fn get_private_key(&self) -> RsaPrivateKey { + self.private_key.clone() + } + + /// 解密为默认的&[u8] + pub fn decrypt(&self, data: &str) -> Result, anyhow::Error> { + // base64 解码 + let data = base64::engine::general_purpose::STANDARD.decode(data).map_err(|e| { + tracing::error!("Failed to decode base64 data: {:?}", e); + anyhow::anyhow!("Failed to decode base64 data: {:?}", e) + })?; + + let private_key = &self.private_key; + let decrypted_data = private_key.decrypt(Pkcs1v15Encrypt, &data)?; + + Ok(decrypted_data) + } + + /// 使用公钥加密 + pub fn encrypt(&self, data: &[u8]) -> Result { + let public_key = &self.private_key.to_public_key(); + + let mut rng = rsa::rand_core::OsRng::default(); + let encrypted_data = public_key.encrypt(&mut rng, Pkcs1v15Encrypt, data).map_err(|e| { + tracing::error!("Failed to encrypt data: {:?}", e); + anyhow::anyhow!("Failed to encrypt data: {:?}", e) + })?; + + let encoded_data = base64::engine::general_purpose::STANDARD.encode(&encrypted_data); + + Ok(encoded_data) + } + + /// 使用公钥加密, 公钥接收到的公钥 + pub fn encrypt_with_public_key( + &self, + data: &[u8], + public_key: &str, + ) -> Result { + let public_key = rsa::RsaPublicKey::from_pkcs1_pem(public_key).map_err(|e| { + tracing::error!("Failed to parse public key: {:?}", e); + anyhow::anyhow!("Failed to parse public key: {:?}", e) + })?; + + let mut rng = rsa::rand_core::OsRng::default(); + let encrypted_data = public_key.encrypt(&mut rng, Pkcs1v15Encrypt, data).map_err(|e| { + tracing::error!("Failed to encrypt data: {:?}", e); + anyhow::anyhow!("Failed to encrypt data: {:?}", e) + })?; + + let encoded_data = base64::engine::general_purpose::STANDARD.encode(&encrypted_data); + + Ok(encoded_data) + } +} + +/// 全局的 RSA 密钥对 +pub static RSA_SECRET_INSTANCE: OnceCell = OnceCell::const_new(); + +/// 初始化全局的 RSA 密钥对 +pub async fn init_rsa_secret(key_path: &str) -> &'static RsaSecret { + RSA_SECRET_INSTANCE + .get_or_init(|| async { RsaSecret::new(key_path).unwrap() }) + .await +} + +/// 获取全局的 RSA 密钥管理实例 +pub fn get_rsa_secret_instance() -> &'static RsaSecret { + match RSA_SECRET_INSTANCE.get() { + Some(secret_instance) => secret_instance, + None => { + tracing::debug!("RSA_SECRET_INSTANCE is not initialized"); + std::process::exit(-1); + } + } +} + +#[cfg(test)] +mod tests { + + use serde::{Deserialize, Serialize}; + + use super::*; + + #[tokio::test] + async fn test_rsa_secret_build() { + #[derive(Serialize, Deserialize, Clone, Debug)] + struct TestStruct { + name: String, + age: u32, + } + + init_rsa_secret("../../assets/secret").await; + + let secret_instance = get_rsa_secret_instance(); + + let data = TestStruct { + name: "test".to_string(), + age: 18, + }; + + let data = serde_json::to_value(data).unwrap().to_string(); + let data = data.as_bytes(); + + let encrypt_data = secret_instance.encrypt(&data).unwrap(); + eprintln!("public_key: {:?}", encrypt_data); + + let res = secret_instance.decrypt(&encrypt_data).unwrap(); + eprintln!("data: {:?}", res); + } +} From 89afd93a143725069b2a7d0cfc751e74974b3756 Mon Sep 17 00:00:00 2001 From: lius-new Date: Tue, 3 Feb 2026 20:45:19 +0800 Subject: [PATCH 02/60] =?UTF-8?q?chore:=20=E7=A7=BB=E9=99=A4=20console-gat?= =?UTF-8?q?eway=20=E9=85=8D=E7=BD=AE=E5=B9=B6=E6=9B=B4=E6=96=B0=E8=B7=AF?= =?UTF-8?q?=E7=94=B1?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit - 删除 config.console-gateway.*.toml - 新增 configs/README.md - 更新 lib、main 及 routes Co-authored-by: Cursor --- configs/README.md | 20 ++ configs/config.console-gateway.dev.toml | 36 --- configs/config.console-gateway.prod.toml | 35 --- configs/config.console-gateway.test.toml | 35 --- src/lib.rs | 92 ++++---- src/main.rs | 232 +++++++++---------- src/routes/accounts.rs | 74 +++--- src/routes/api.rs | 54 ++--- src/routes/audit.rs | 62 ++--- src/routes/environments.rs | 282 +++++++++++------------ src/routes/extensions.rs | 106 ++++----- src/routes/group_permissions.rs | 56 ++--- src/routes/health.rs | 26 +-- src/routes/messages.rs | 90 ++++---- src/routes/preferences.rs | 40 ++-- src/routes/proxies.rs | 74 +++--- src/routes/proxy_visibility.rs | 64 ++--- src/routes/rpa.rs | 134 +++++------ src/routes/secret.rs | 32 +-- src/routes/teams.rs | 122 +++++----- src/routes/templates.rs | 80 +++---- src/routes/time.rs | 24 +- src/routes/users.rs | 100 ++++---- src/routes/workspace_quotas.rs | 40 ++-- src/routes/workspaces.rs | 72 +++--- 25 files changed, 948 insertions(+), 1034 deletions(-) create mode 100644 configs/README.md delete mode 100644 configs/config.console-gateway.dev.toml delete mode 100644 configs/config.console-gateway.prod.toml delete mode 100644 configs/config.console-gateway.test.toml diff --git a/configs/README.md b/configs/README.md new file mode 100644 index 0000000..c675c9c --- /dev/null +++ b/configs/README.md @@ -0,0 +1,20 @@ +# 配置文件说明 + +本服务通过 `-f <配置文件路径>` 指定使用的配置,例如: + +```bash +# 主服务(客户端网关):用户、工作空间、计费、环境、代理等完整 API +cargo run -- -f configs/config.dev.toml + +# 更新网关:仅版本检查、维护状态、健康检查等 +cargo run -- -f configs/config.update-gateway.dev.toml +``` + +## 文件说明 + +| 文件 | 用途 | +|------|------| +| `config.dev.toml` / `config.test.toml` / `config.prod.toml` | 主服务(客户端网关),含数据库、Redis、MinIO、SMTP、工作空间配额等 | +| `config.update-gateway.dev.toml` / `.prod` / `.test` | 更新网关,仅版本与维护相关接口,配置项更少 | + +**说明**:控制台网关(console-gateway)已拆分为独立仓库 `simprint-console-server`,此处不再保留 `config.console-gateway.*` 配置。 diff --git a/configs/config.console-gateway.dev.toml b/configs/config.console-gateway.dev.toml deleted file mode 100644 index b7de3e5..0000000 --- a/configs/config.console-gateway.dev.toml +++ /dev/null @@ -1,36 +0,0 @@ -[app] -name = "console-gateway" -port = 40043 -secret = "p4y8uX1Jc22K7bAN52arpvJpVbduUvGNNNfo" -prefix = "/api/v1" -encrypt_secret_location = "./assets/secret" -route_whitelists = [ - "POST+/api/v1/users/login", - "POST+/api/v1/users/register", - "POST+/api/v1/users/register-send-code", - "POST+/api/v1/users/reset-password", - "POST+/api/v1/users/reset-password-send-code", - "POST+/api/v1/users/refresh-credentials", - "GET+/api/v1/secret/public/key", - "GET+/api/v1/time/now", - "GET+/api/v1/health", -] - -[database] -url = "postgres://lius:URxywfBRZZ1xpWcyPRbG@111.230.19.75:5432/simprintdb" -max_connections = 25 -min_connections = 5 -max_lifetime = 3000 -acquire_timeout = 30 -idle_timeout = 600 - -[redis] -url = "redis://111.230.19.75:6381?protocol=resp3" - -[minio] -server_url = "http://111.230.19.75:9000" -resource_url = "http://111.230.19.75:9000" -access_key = "5kSjAHnITsMZQEwzJN2g" -secret_access_key = "eGs3ntV5KvmVzMbLpQwj86w5b5vdSHxUcmqcYhay" -avatar_bucket = "avatars" -extension_bucket = "extensions" diff --git a/configs/config.console-gateway.prod.toml b/configs/config.console-gateway.prod.toml deleted file mode 100644 index 64f4117..0000000 --- a/configs/config.console-gateway.prod.toml +++ /dev/null @@ -1,35 +0,0 @@ -[app] -name = "console-gateway" -port = 40043 -secret = "p4y8uX1Jc22K7bAN52arpvJpVbduUvGNNNfo" -prefix = "/api/v1" -encrypt_secret_location = "./assets/secret" -route_whitelists = [ - "POST+/api/v1/users/login", - "POST+/api/v1/users/register", - "POST+/api/v1/users/register-send-code", - "POST+/api/v1/users/reset-password", - "POST+/api/v1/users/reset-password-send-code", - "POST+/api/v1/users/refresh-credentials", - "GET+/api/v1/secret/public/key", - "GET+/api/v1/time/now", - "GET+/api/v1/health", -] - -[database] -url = "postgres://lius:0gMAZGgriRK395Apzcb8@111.230.19.75:5433/simprintdb" -max_connections = 25 -min_connections = 5 -max_lifetime = 3000 -acquire_timeout = 30 -idle_timeout = 600 - -[redis] -url = "redis://111.230.19.75:6380?protocol=resp3" - -[minio] -server_url = "http://111.230.19.75:9000" -resource_url = "http://111.230.19.75:9000" -access_key = "5kSjAHnITsMZQEwzJN2g" -secret_access_key = "eGs3ntV5KvmVzMbLpQwj86w5b5vdSHxUcmqcYhay" -avatar_bucket = "avatars" diff --git a/configs/config.console-gateway.test.toml b/configs/config.console-gateway.test.toml deleted file mode 100644 index 1aa2d60..0000000 --- a/configs/config.console-gateway.test.toml +++ /dev/null @@ -1,35 +0,0 @@ -[app] -name = "console-gateway" -port = 40043 -secret = "p4y8uX1Jc22K7bAN52arpvJpVbduUvGNNNfo" -prefix = "/api/v1" -encrypt_secret_location = "./assets/secret" -route_whitelists = [ - "POST+/api/v1/users/login", - "POST+/api/v1/users/register", - "POST+/api/v1/users/register-send-code", - "POST+/api/v1/users/reset-password", - "POST+/api/v1/users/reset-password-send-code", - "POST+/api/v1/users/refresh-credentials", - "GET+/api/v1/secret/public/key", - "GET+/api/v1/time/now", - "GET+/api/v1/health", -] - -[database] -url = "postgres://admin-lius:ZZutdqfPAE4RFo5PYjgA@1.12.233.51:5433/l_g_p_db" -max_connections = 25 -min_connections = 5 -max_lifetime = 3000 -acquire_timeout = 30 -idle_timeout = 600 - -[redis] -url = "redis://1.12.233.51:6380?protocol=resp3" - -[minio] -server_url = "http://111.230.19.75:9000" -resource_url = "http://111.230.19.75:9000" -access_key = "5kSjAHnITsMZQEwzJN2g" -secret_access_key = "eGs3ntV5KvmVzMbLpQwj86w5b5vdSHxUcmqcYhay" -avatar_bucket = "avatars" diff --git a/src/lib.rs b/src/lib.rs index fe4e401..6e74ef1 100644 --- a/src/lib.rs +++ b/src/lib.rs @@ -1,46 +1,46 @@ -use crate::utils::IConfig; - -pub mod caches; -pub mod dto; -pub mod entitys; -pub mod errors; -pub mod handlers; -pub mod middlewares; -pub mod models; -pub mod routes; -pub mod services; -pub mod state; -pub mod svc_ctx; -pub mod utils; - -/// 初始化加密密钥 -pub async fn init_encrypt_secret(config: &IConfig) { - let key_path = &config.app.encrypt_secret_location; - utils::init_rsa_secret(key_path).await; -} - -/// 初始化minio -pub async fn init_minio(config: &IConfig) { - let minio_config = &config.clone().minio.expect("minio配置不存在"); - - let client = { - utils::init_minio( - &minio_config.server_url, - &minio_config.access_key, - &minio_config.secret_access_key, - None, - None, - ) - .await - .expect("初始化Minio客户端失败") - }; - - // 初始化存储桶 - client - .init_bucket(vec![ - minio_config.avatar_bucket.clone(), - minio_config.extension_bucket.clone(), - ]) - .await - .expect("初始化Minio桶失败"); -} +use crate::utils::IConfig; + +pub mod caches; +pub mod dto; +pub mod entitys; +pub mod errors; +pub mod handlers; +pub mod middlewares; +pub mod models; +pub mod routes; +pub mod services; +pub mod state; +pub mod svc_ctx; +pub mod utils; + +/// 初始化加密密钥 +pub async fn init_encrypt_secret(config: &IConfig) { + let key_path = &config.app.encrypt_secret_location; + utils::init_rsa_secret(key_path).await; +} + +/// 初始化minio +pub async fn init_minio(config: &IConfig) { + let minio_config = &config.clone().minio.expect("minio配置不存在"); + + let client = { + utils::init_minio( + &minio_config.server_url, + &minio_config.access_key, + &minio_config.secret_access_key, + None, + None, + ) + .await + .expect("初始化Minio客户端失败") + }; + + // 初始化存储桶 + client + .init_bucket(vec![ + minio_config.avatar_bucket.clone(), + minio_config.extension_bucket.clone(), + ]) + .await + .expect("初始化Minio桶失败"); +} diff --git a/src/main.rs b/src/main.rs index cabc34b..23b33d0 100644 --- a/src/main.rs +++ b/src/main.rs @@ -1,116 +1,116 @@ -use std::env; - -use axum::{Router, middleware}; -use simprint_server::{init_encrypt_secret, init_minio}; -use simprint_server::{ - middlewares, - routes::{ - accounts, api, audit, billing, environments, extensions, group_permissions, messages, - preferences, proxies, proxy_visibility, referral, rpa, secret, teams, templates, time, - users, workspace_quotas, workspaces, - }, - routes::route::MetaRoute, - svc_ctx::SvcCtx, - utils::IConfig, -}; - -#[tokio::main] -async fn main() -> Result<(), Box> { - // 支持接收-f参数,指定配置文件路径 - let config_path = env::args() - .find(|arg| arg.starts_with("-f")) - .map(|arg| arg.split("=").nth(1).unwrap().to_string()) - .expect("config path is required"); - - let config = IConfig::build_by_filepath(&config_path).expect("failed to build config"); - - if let Err(e) = run_server(config).await { - return Err(e.into()); - } - - Ok(()) -} - -async fn run_server(config: IConfig) -> Result<(), Box> { - // 启用更详细的日志以调试中间件问题 - tracing_subscriber::fmt() - .with_max_level(tracing::Level::INFO) - .with_target(true) - .with_thread_ids(true) - .init(); - - let svc_ctx = SvcCtx::new(&config).await?; - - // init resources - init_encrypt_secret(&config).await; - init_minio(&config).await; - - let port = &config.app.port; - let listener = tokio::net::TcpListener::bind(format!("0.0.0.0:{}", port)) - .await - .expect("Failed to create listener"); - - let app = register_all_routes(&svc_ctx); - let app = register_middlewares(&svc_ctx, app); - let app = app.with_state(svc_ctx); - - tracing::info!("Starting server on port {}", port); - - axum::serve(listener, app).await?; - Ok(()) -} - -/// 注册所有路由 -fn register_all_routes(svc_ctx: &SvcCtx) -> Router { - let mut meta_route = MetaRoute::new(svc_ctx.config.app.prefix.clone()); - - secret::register_routes(&mut meta_route); - time::register_routes(&mut meta_route); - users::register_routes(&mut meta_route); - - // 新增路由 - workspaces::register_routes(&mut meta_route); - workspace_quotas::register_routes(&mut meta_route); - teams::register_routes(&mut meta_route); - environments::register_routes(&mut meta_route); - proxies::register_routes(&mut meta_route); - proxy_visibility::register_routes(&mut meta_route); - group_permissions::register_routes(&mut meta_route); - accounts::register_routes(&mut meta_route); - templates::register_routes(&mut meta_route); - billing::register_routes(&mut meta_route); - audit::register_routes(&mut meta_route); - rpa::register_routes(&mut meta_route); - referral::register_routes(&mut meta_route); - extensions::register_routes(&mut meta_route); - api::register_routes(&mut meta_route); - preferences::register_routes(&mut meta_route); - messages::register_routes(&mut meta_route); - - tracing::info!("---------- {:?} ----------", meta_route.count()); - meta_route.build() -} - -/// 注册中间件 -/// 中间件顺序保持与原 routes.rs 一致 -fn register_middlewares(svc_ctx: &SvcCtx, app: Router) -> Router { - app.route_layer(middleware::from_fn_with_state( - svc_ctx.clone(), - middlewares::encrypt, - )) - .route_layer(middleware::from_fn_with_state( - svc_ctx.clone(), - middlewares::auth, - )) - .route_layer(middleware::from_fn_with_state( - svc_ctx.clone(), - middlewares::machine_auth, - )) - .route_layer(middleware::from_fn_with_state( - svc_ctx.clone(), - middlewares::decrypt, - )) - .route_layer(middleware::from_fn(middlewares::real_ip)) - .route_layer(middleware::from_fn(middlewares::logger)) - .layer(middlewares::cors()) -} +use std::env; + +use axum::{Router, middleware}; +use simprint_server::{init_encrypt_secret, init_minio}; +use simprint_server::{ + middlewares, + routes::{ + accounts, api, audit, billing, environments, extensions, group_permissions, messages, + preferences, proxies, proxy_visibility, referral, rpa, secret, teams, templates, time, + users, workspace_quotas, workspaces, + }, + routes::route::MetaRoute, + svc_ctx::SvcCtx, + utils::IConfig, +}; + +#[tokio::main] +async fn main() -> Result<(), Box> { + // 支持接收-f参数,指定配置文件路径 + let config_path = env::args() + .find(|arg| arg.starts_with("-f")) + .map(|arg| arg.split("=").nth(1).unwrap().to_string()) + .expect("config path is required"); + + let config = IConfig::build_by_filepath(&config_path).expect("failed to build config"); + + if let Err(e) = run_server(config).await { + return Err(e.into()); + } + + Ok(()) +} + +async fn run_server(config: IConfig) -> Result<(), Box> { + // 启用更详细的日志以调试中间件问题 + tracing_subscriber::fmt() + .with_max_level(tracing::Level::INFO) + .with_target(true) + .with_thread_ids(true) + .init(); + + let svc_ctx = SvcCtx::new(&config).await?; + + // init resources + init_encrypt_secret(&config).await; + init_minio(&config).await; + + let port = &config.app.port; + let listener = tokio::net::TcpListener::bind(format!("0.0.0.0:{}", port)) + .await + .expect("Failed to create listener"); + + let app = register_all_routes(&svc_ctx); + let app = register_middlewares(&svc_ctx, app); + let app = app.with_state(svc_ctx); + + tracing::info!("Starting server on port {}", port); + + axum::serve(listener, app).await?; + Ok(()) +} + +/// 注册所有路由 +fn register_all_routes(svc_ctx: &SvcCtx) -> Router { + let mut meta_route = MetaRoute::new(svc_ctx.config.app.prefix.clone()); + + secret::register_routes(&mut meta_route); + time::register_routes(&mut meta_route); + users::register_routes(&mut meta_route); + + // 新增路由 + workspaces::register_routes(&mut meta_route); + workspace_quotas::register_routes(&mut meta_route); + teams::register_routes(&mut meta_route); + environments::register_routes(&mut meta_route); + proxies::register_routes(&mut meta_route); + proxy_visibility::register_routes(&mut meta_route); + group_permissions::register_routes(&mut meta_route); + accounts::register_routes(&mut meta_route); + templates::register_routes(&mut meta_route); + billing::register_routes(&mut meta_route); + audit::register_routes(&mut meta_route); + rpa::register_routes(&mut meta_route); + referral::register_routes(&mut meta_route); + extensions::register_routes(&mut meta_route); + api::register_routes(&mut meta_route); + preferences::register_routes(&mut meta_route); + messages::register_routes(&mut meta_route); + + tracing::info!("---------- {:?} ----------", meta_route.count()); + meta_route.build() +} + +/// 注册中间件 +/// 中间件顺序保持与原 routes.rs 一致 +fn register_middlewares(svc_ctx: &SvcCtx, app: Router) -> Router { + app.route_layer(middleware::from_fn_with_state( + svc_ctx.clone(), + middlewares::encrypt, + )) + .route_layer(middleware::from_fn_with_state( + svc_ctx.clone(), + middlewares::auth, + )) + .route_layer(middleware::from_fn_with_state( + svc_ctx.clone(), + middlewares::machine_auth, + )) + .route_layer(middleware::from_fn_with_state( + svc_ctx.clone(), + middlewares::decrypt, + )) + .route_layer(middleware::from_fn(middlewares::real_ip)) + .route_layer(middleware::from_fn(middlewares::logger)) + .layer(middlewares::cors()) +} diff --git a/src/routes/accounts.rs b/src/routes/accounts.rs index 27b9f86..f503be0 100644 --- a/src/routes/accounts.rs +++ b/src/routes/accounts.rs @@ -1,37 +1,37 @@ -use axum::routing::post; - -use crate::handlers::{ - batch_delete_accounts_handler, batch_import_accounts_handler, create_account_handler, - delete_account_handler, get_account_handler, get_accounts_handler, update_account_handler, -}; -use crate::routes::route::{self, MetaRoute}; - -/// 注册账号相关的路由 -pub fn register_routes(meta_route: &mut MetaRoute) { - let mut account_route = route::RouteGroup::new("/accounts"); - - account_route.add_route_item(route::RouteItem::post("/list", post(get_accounts_handler))); - account_route.add_route_item(route::RouteItem::post("/detail", post(get_account_handler))); - account_route.add_route_item(route::RouteItem::post( - "/create", - post(create_account_handler), - )); - account_route.add_route_item(route::RouteItem::post( - "/update", - post(update_account_handler), - )); - account_route.add_route_item(route::RouteItem::post( - "/delete", - post(delete_account_handler), - )); - account_route.add_route_item(route::RouteItem::post( - "/batch-delete", - post(batch_delete_accounts_handler), - )); - account_route.add_route_item(route::RouteItem::post( - "/batch-import", - post(batch_import_accounts_handler), - )); - - meta_route.add_route_group(account_route); -} +use axum::routing::post; + +use crate::handlers::{ + batch_delete_accounts_handler, batch_import_accounts_handler, create_account_handler, + delete_account_handler, get_account_handler, get_accounts_handler, update_account_handler, +}; +use crate::routes::route::{self, MetaRoute}; + +/// 注册账号相关的路由 +pub fn register_routes(meta_route: &mut MetaRoute) { + let mut account_route = route::RouteGroup::new("/accounts"); + + account_route.add_route_item(route::RouteItem::post("/list", post(get_accounts_handler))); + account_route.add_route_item(route::RouteItem::post("/detail", post(get_account_handler))); + account_route.add_route_item(route::RouteItem::post( + "/create", + post(create_account_handler), + )); + account_route.add_route_item(route::RouteItem::post( + "/update", + post(update_account_handler), + )); + account_route.add_route_item(route::RouteItem::post( + "/delete", + post(delete_account_handler), + )); + account_route.add_route_item(route::RouteItem::post( + "/batch-delete", + post(batch_delete_accounts_handler), + )); + account_route.add_route_item(route::RouteItem::post( + "/batch-import", + post(batch_import_accounts_handler), + )); + + meta_route.add_route_group(account_route); +} diff --git a/src/routes/api.rs b/src/routes/api.rs index 882ed26..86a016b 100644 --- a/src/routes/api.rs +++ b/src/routes/api.rs @@ -1,27 +1,27 @@ -use axum::routing::post; - -use crate::handlers::{ - create_api_key_handler, get_api_keys_handler, revoke_api_key_handler, update_api_key_handler, -}; -use crate::routes::route::{self, MetaRoute}; - -/// 注册 API 服务相关的路由 -pub fn register_routes(meta_route: &mut MetaRoute) { - let mut api_route = route::RouteGroup::new("/api-keys"); - - api_route.add_route_item(route::RouteItem::post("/list", post(get_api_keys_handler))); - api_route.add_route_item(route::RouteItem::post( - "/create", - post(create_api_key_handler), - )); - api_route.add_route_item(route::RouteItem::post( - "/update", - post(update_api_key_handler), - )); - api_route.add_route_item(route::RouteItem::post( - "/revoke", - post(revoke_api_key_handler), - )); - - meta_route.add_route_group(api_route); -} +use axum::routing::post; + +use crate::handlers::{ + create_api_key_handler, get_api_keys_handler, revoke_api_key_handler, update_api_key_handler, +}; +use crate::routes::route::{self, MetaRoute}; + +/// 注册 API 服务相关的路由 +pub fn register_routes(meta_route: &mut MetaRoute) { + let mut api_route = route::RouteGroup::new("/api-keys"); + + api_route.add_route_item(route::RouteItem::post("/list", post(get_api_keys_handler))); + api_route.add_route_item(route::RouteItem::post( + "/create", + post(create_api_key_handler), + )); + api_route.add_route_item(route::RouteItem::post( + "/update", + post(update_api_key_handler), + )); + api_route.add_route_item(route::RouteItem::post( + "/revoke", + post(revoke_api_key_handler), + )); + + meta_route.add_route_group(api_route); +} diff --git a/src/routes/audit.rs b/src/routes/audit.rs index 66f462c..4cd5fde 100644 --- a/src/routes/audit.rs +++ b/src/routes/audit.rs @@ -1,31 +1,31 @@ -use axum::routing::post; - -use crate::handlers::{ - export_audit_logs_handler, get_audit_log_detail_handler, get_audit_logs_handler, - get_audit_stats_handler, -}; -use crate::routes::route::{self, MetaRoute}; - -/// 注册审计日志相关的路由 -pub fn register_routes(meta_route: &mut MetaRoute) { - let mut audit_route = route::RouteGroup::new("/audit"); - - audit_route.add_route_item(route::RouteItem::post( - "/logs", - post(get_audit_logs_handler), - )); - audit_route.add_route_item(route::RouteItem::post( - "/logs/detail", - post(get_audit_log_detail_handler), - )); - audit_route.add_route_item(route::RouteItem::post( - "/logs/export", - post(export_audit_logs_handler), - )); - audit_route.add_route_item(route::RouteItem::post( - "/stats", - post(get_audit_stats_handler), - )); - - meta_route.add_route_group(audit_route); -} +use axum::routing::post; + +use crate::handlers::{ + export_audit_logs_handler, get_audit_log_detail_handler, get_audit_logs_handler, + get_audit_stats_handler, +}; +use crate::routes::route::{self, MetaRoute}; + +/// 注册审计日志相关的路由 +pub fn register_routes(meta_route: &mut MetaRoute) { + let mut audit_route = route::RouteGroup::new("/audit"); + + audit_route.add_route_item(route::RouteItem::post( + "/logs", + post(get_audit_logs_handler), + )); + audit_route.add_route_item(route::RouteItem::post( + "/logs/detail", + post(get_audit_log_detail_handler), + )); + audit_route.add_route_item(route::RouteItem::post( + "/logs/export", + post(export_audit_logs_handler), + )); + audit_route.add_route_item(route::RouteItem::post( + "/stats", + post(get_audit_stats_handler), + )); + + meta_route.add_route_group(audit_route); +} diff --git a/src/routes/environments.rs b/src/routes/environments.rs index 689ce25..f86c10b 100644 --- a/src/routes/environments.rs +++ b/src/routes/environments.rs @@ -1,141 +1,141 @@ -use axum::routing::post; - -use crate::handlers::{ - add_environment_cookie_handler, add_environment_url_handler, assign_tags_handler, - batch_assign_tags_handler, batch_create_environments_handler, - batch_delete_environments_handler, batch_move_to_group_handler, batch_remove_tags_handler, - clear_environment_cookies_handler, clear_environment_urls_handler, create_environment_handler, - create_group_handler, create_tag_handler, delete_environment_cookie_handler, - delete_environment_handler, delete_environment_url_handler, delete_group_handler, - delete_tag_handler, get_environment_cookies_handler, get_environment_handler, - get_environment_urls_handler, get_environments_handler, get_groups_handler, get_tags_handler, - move_to_group_handler, remove_tag_handler, set_environment_accounts_handler, set_proxy_handler, - update_environment_handler, update_group_handler, update_tag_handler, -}; -use crate::routes::route::{self, MetaRoute}; - -/// 注册环境相关的路由 -pub fn register_routes(meta_route: &mut MetaRoute) { - // 分组路由 - let mut group_route = route::RouteGroup::new("/groups"); - group_route.add_route_item(route::RouteItem::post("/list", post(get_groups_handler))); - group_route.add_route_item(route::RouteItem::post( - "/create", - post(create_group_handler), - )); - group_route.add_route_item(route::RouteItem::post( - "/update", - post(update_group_handler), - )); - group_route.add_route_item(route::RouteItem::post( - "/delete", - post(delete_group_handler), - )); - meta_route.add_route_group(group_route); - - // 标签路由 - let mut tag_route = route::RouteGroup::new("/tags"); - tag_route.add_route_item(route::RouteItem::post("/list", post(get_tags_handler))); - tag_route.add_route_item(route::RouteItem::post("/create", post(create_tag_handler))); - tag_route.add_route_item(route::RouteItem::post("/update", post(update_tag_handler))); - tag_route.add_route_item(route::RouteItem::post("/delete", post(delete_tag_handler))); - meta_route.add_route_group(tag_route); - - // 环境路由 - let mut env_route = route::RouteGroup::new("/environments"); - env_route.add_route_item(route::RouteItem::post( - "/list", - post(get_environments_handler), - )); - env_route.add_route_item(route::RouteItem::post( - "/detail", - post(get_environment_handler), - )); - env_route.add_route_item(route::RouteItem::post( - "/create", - post(create_environment_handler), - )); - env_route.add_route_item(route::RouteItem::post( - "/batch-create", - post(batch_create_environments_handler), - )); - env_route.add_route_item(route::RouteItem::post( - "/update", - post(update_environment_handler), - )); - env_route.add_route_item(route::RouteItem::post( - "/delete", - post(delete_environment_handler), - )); - env_route.add_route_item(route::RouteItem::post( - "/batch-delete", - post(batch_delete_environments_handler), - )); - env_route.add_route_item(route::RouteItem::post( - "/set-proxy", - post(set_proxy_handler), - )); - env_route.add_route_item(route::RouteItem::post( - "/assign-tags", - post(assign_tags_handler), - )); - env_route.add_route_item(route::RouteItem::post( - "/remove-tag", - post(remove_tag_handler), - )); - env_route.add_route_item(route::RouteItem::post( - "/move-to-group", - post(move_to_group_handler), - )); - env_route.add_route_item(route::RouteItem::post( - "/batch-move-to-group", - post(batch_move_to_group_handler), - )); - env_route.add_route_item(route::RouteItem::post( - "/set-accounts", - post(set_environment_accounts_handler), - )); - env_route.add_route_item(route::RouteItem::post( - "/batch-assign-tags", - post(batch_assign_tags_handler), - )); - env_route.add_route_item(route::RouteItem::post( - "/batch-remove-tags", - post(batch_remove_tags_handler), - )); - // URL 管理 - env_route.add_route_item(route::RouteItem::post( - "/urls/list", - post(get_environment_urls_handler), - )); - env_route.add_route_item(route::RouteItem::post( - "/urls/add", - post(add_environment_url_handler), - )); - env_route.add_route_item(route::RouteItem::post( - "/urls/delete", - post(delete_environment_url_handler), - )); - env_route.add_route_item(route::RouteItem::post( - "/urls/clear", - post(clear_environment_urls_handler), - )); - // Cookie 管理 - env_route.add_route_item(route::RouteItem::post( - "/cookies/list", - post(get_environment_cookies_handler), - )); - env_route.add_route_item(route::RouteItem::post( - "/cookies/add", - post(add_environment_cookie_handler), - )); - env_route.add_route_item(route::RouteItem::post( - "/cookies/delete", - post(delete_environment_cookie_handler), - )); - env_route.add_route_item(route::RouteItem::post( - "/cookies/clear", - post(clear_environment_cookies_handler), - )); - meta_route.add_route_group(env_route); -} +use axum::routing::post; + +use crate::handlers::{ + add_environment_cookie_handler, add_environment_url_handler, assign_tags_handler, + batch_assign_tags_handler, batch_create_environments_handler, + batch_delete_environments_handler, batch_move_to_group_handler, batch_remove_tags_handler, + clear_environment_cookies_handler, clear_environment_urls_handler, create_environment_handler, + create_group_handler, create_tag_handler, delete_environment_cookie_handler, + delete_environment_handler, delete_environment_url_handler, delete_group_handler, + delete_tag_handler, get_environment_cookies_handler, get_environment_handler, + get_environment_urls_handler, get_environments_handler, get_groups_handler, get_tags_handler, + move_to_group_handler, remove_tag_handler, set_environment_accounts_handler, set_proxy_handler, + update_environment_handler, update_group_handler, update_tag_handler, +}; +use crate::routes::route::{self, MetaRoute}; + +/// 注册环境相关的路由 +pub fn register_routes(meta_route: &mut MetaRoute) { + // 分组路由 + let mut group_route = route::RouteGroup::new("/groups"); + group_route.add_route_item(route::RouteItem::post("/list", post(get_groups_handler))); + group_route.add_route_item(route::RouteItem::post( + "/create", + post(create_group_handler), + )); + group_route.add_route_item(route::RouteItem::post( + "/update", + post(update_group_handler), + )); + group_route.add_route_item(route::RouteItem::post( + "/delete", + post(delete_group_handler), + )); + meta_route.add_route_group(group_route); + + // 标签路由 + let mut tag_route = route::RouteGroup::new("/tags"); + tag_route.add_route_item(route::RouteItem::post("/list", post(get_tags_handler))); + tag_route.add_route_item(route::RouteItem::post("/create", post(create_tag_handler))); + tag_route.add_route_item(route::RouteItem::post("/update", post(update_tag_handler))); + tag_route.add_route_item(route::RouteItem::post("/delete", post(delete_tag_handler))); + meta_route.add_route_group(tag_route); + + // 环境路由 + let mut env_route = route::RouteGroup::new("/environments"); + env_route.add_route_item(route::RouteItem::post( + "/list", + post(get_environments_handler), + )); + env_route.add_route_item(route::RouteItem::post( + "/detail", + post(get_environment_handler), + )); + env_route.add_route_item(route::RouteItem::post( + "/create", + post(create_environment_handler), + )); + env_route.add_route_item(route::RouteItem::post( + "/batch-create", + post(batch_create_environments_handler), + )); + env_route.add_route_item(route::RouteItem::post( + "/update", + post(update_environment_handler), + )); + env_route.add_route_item(route::RouteItem::post( + "/delete", + post(delete_environment_handler), + )); + env_route.add_route_item(route::RouteItem::post( + "/batch-delete", + post(batch_delete_environments_handler), + )); + env_route.add_route_item(route::RouteItem::post( + "/set-proxy", + post(set_proxy_handler), + )); + env_route.add_route_item(route::RouteItem::post( + "/assign-tags", + post(assign_tags_handler), + )); + env_route.add_route_item(route::RouteItem::post( + "/remove-tag", + post(remove_tag_handler), + )); + env_route.add_route_item(route::RouteItem::post( + "/move-to-group", + post(move_to_group_handler), + )); + env_route.add_route_item(route::RouteItem::post( + "/batch-move-to-group", + post(batch_move_to_group_handler), + )); + env_route.add_route_item(route::RouteItem::post( + "/set-accounts", + post(set_environment_accounts_handler), + )); + env_route.add_route_item(route::RouteItem::post( + "/batch-assign-tags", + post(batch_assign_tags_handler), + )); + env_route.add_route_item(route::RouteItem::post( + "/batch-remove-tags", + post(batch_remove_tags_handler), + )); + // URL 管理 + env_route.add_route_item(route::RouteItem::post( + "/urls/list", + post(get_environment_urls_handler), + )); + env_route.add_route_item(route::RouteItem::post( + "/urls/add", + post(add_environment_url_handler), + )); + env_route.add_route_item(route::RouteItem::post( + "/urls/delete", + post(delete_environment_url_handler), + )); + env_route.add_route_item(route::RouteItem::post( + "/urls/clear", + post(clear_environment_urls_handler), + )); + // Cookie 管理 + env_route.add_route_item(route::RouteItem::post( + "/cookies/list", + post(get_environment_cookies_handler), + )); + env_route.add_route_item(route::RouteItem::post( + "/cookies/add", + post(add_environment_cookie_handler), + )); + env_route.add_route_item(route::RouteItem::post( + "/cookies/delete", + post(delete_environment_cookie_handler), + )); + env_route.add_route_item(route::RouteItem::post( + "/cookies/clear", + post(clear_environment_cookies_handler), + )); + meta_route.add_route_group(env_route); +} diff --git a/src/routes/extensions.rs b/src/routes/extensions.rs index 3343834..25bf092 100644 --- a/src/routes/extensions.rs +++ b/src/routes/extensions.rs @@ -1,53 +1,53 @@ -use axum::routing::post; - -use crate::handlers::{ - batch_update_extensions_handler, get_extension_categories_handler, - get_extension_detail_handler, get_extensions_handler, get_installed_extensions_handler, - install_extension_handler, uninstall_extension_handler, update_extension_handler, -}; -use crate::routes::route::{self, MetaRoute}; - -/// 注册扩展管理相关的路由 -pub fn register_routes(meta_route: &mut MetaRoute) { - let mut ext_route = route::RouteGroup::new("/extensions"); - - // 扩展市场 - ext_route.add_route_item(route::RouteItem::post( - "/list", - post(get_extensions_handler), - )); - ext_route.add_route_item(route::RouteItem::post( - "/detail", - post(get_extension_detail_handler), - )); - ext_route.add_route_item(route::RouteItem::post( - "/categories", - post(get_extension_categories_handler), - )); - - // 已安装扩展 - ext_route.add_route_item(route::RouteItem::post( - "/installed", - post(get_installed_extensions_handler), - )); - - // 安装/卸载/更新 - ext_route.add_route_item(route::RouteItem::post( - "/install", - post(install_extension_handler), - )); - ext_route.add_route_item(route::RouteItem::post( - "/uninstall", - post(uninstall_extension_handler), - )); - ext_route.add_route_item(route::RouteItem::post( - "/update", - post(update_extension_handler), - )); - ext_route.add_route_item(route::RouteItem::post( - "/batch-update", - post(batch_update_extensions_handler), - )); - - meta_route.add_route_group(ext_route); -} +use axum::routing::post; + +use crate::handlers::{ + batch_update_extensions_handler, get_extension_categories_handler, + get_extension_detail_handler, get_extensions_handler, get_installed_extensions_handler, + install_extension_handler, uninstall_extension_handler, update_extension_handler, +}; +use crate::routes::route::{self, MetaRoute}; + +/// 注册扩展管理相关的路由 +pub fn register_routes(meta_route: &mut MetaRoute) { + let mut ext_route = route::RouteGroup::new("/extensions"); + + // 扩展市场 + ext_route.add_route_item(route::RouteItem::post( + "/list", + post(get_extensions_handler), + )); + ext_route.add_route_item(route::RouteItem::post( + "/detail", + post(get_extension_detail_handler), + )); + ext_route.add_route_item(route::RouteItem::post( + "/categories", + post(get_extension_categories_handler), + )); + + // 已安装扩展 + ext_route.add_route_item(route::RouteItem::post( + "/installed", + post(get_installed_extensions_handler), + )); + + // 安装/卸载/更新 + ext_route.add_route_item(route::RouteItem::post( + "/install", + post(install_extension_handler), + )); + ext_route.add_route_item(route::RouteItem::post( + "/uninstall", + post(uninstall_extension_handler), + )); + ext_route.add_route_item(route::RouteItem::post( + "/update", + post(update_extension_handler), + )); + ext_route.add_route_item(route::RouteItem::post( + "/batch-update", + post(batch_update_extensions_handler), + )); + + meta_route.add_route_group(ext_route); +} diff --git a/src/routes/group_permissions.rs b/src/routes/group_permissions.rs index 5ffcc53..ea7b652 100644 --- a/src/routes/group_permissions.rs +++ b/src/routes/group_permissions.rs @@ -1,28 +1,28 @@ -use axum::routing::post; - -use crate::handlers::group_permissions; -use crate::routes::route::{self, MetaRoute}; - -/// 注册分组权限相关的路由 -pub fn register_routes(meta_route: &mut MetaRoute) { - let mut group_permission_route = route::RouteGroup::new("/group-permissions"); - - group_permission_route.add_route_item(route::RouteItem::post( - "/grant", - post(group_permissions::grant_group_permission_handler), - )); - group_permission_route.add_route_item(route::RouteItem::post( - "/revoke", - post(group_permissions::revoke_group_permission_handler), - )); - group_permission_route.add_route_item(route::RouteItem::post( - "/check", - post(group_permissions::check_group_permission_handler), - )); - group_permission_route.add_route_item(route::RouteItem::post( - "/list", - post(group_permissions::list_user_group_permissions_handler), - )); - - meta_route.add_route_group(group_permission_route); -} +use axum::routing::post; + +use crate::handlers::group_permissions; +use crate::routes::route::{self, MetaRoute}; + +/// 注册分组权限相关的路由 +pub fn register_routes(meta_route: &mut MetaRoute) { + let mut group_permission_route = route::RouteGroup::new("/group-permissions"); + + group_permission_route.add_route_item(route::RouteItem::post( + "/grant", + post(group_permissions::grant_group_permission_handler), + )); + group_permission_route.add_route_item(route::RouteItem::post( + "/revoke", + post(group_permissions::revoke_group_permission_handler), + )); + group_permission_route.add_route_item(route::RouteItem::post( + "/check", + post(group_permissions::check_group_permission_handler), + )); + group_permission_route.add_route_item(route::RouteItem::post( + "/list", + post(group_permissions::list_user_group_permissions_handler), + )); + + meta_route.add_route_group(group_permission_route); +} diff --git a/src/routes/health.rs b/src/routes/health.rs index 415ca9d..54507a4 100644 --- a/src/routes/health.rs +++ b/src/routes/health.rs @@ -1,13 +1,13 @@ -use axum::routing::get; - -use crate::handlers::health_check_handler; -use crate::routes::route::{self, MetaRoute}; - -/// 注册健康检查路由 -pub fn register_routes(meta_route: &mut MetaRoute) -> () { - let mut health_route = route::RouteGroup::new("/health"); - - health_route.add_route_item(route::RouteItem::get("/", get(health_check_handler))); - - meta_route.add_route_group(health_route); -} +use axum::routing::get; + +use crate::handlers::health_check_handler; +use crate::routes::route::{self, MetaRoute}; + +/// 注册健康检查路由 +pub fn register_routes(meta_route: &mut MetaRoute) -> () { + let mut health_route = route::RouteGroup::new("/health"); + + health_route.add_route_item(route::RouteItem::get("/", get(health_check_handler))); + + meta_route.add_route_group(health_route); +} diff --git a/src/routes/messages.rs b/src/routes/messages.rs index fb1a13b..09d0d4e 100644 --- a/src/routes/messages.rs +++ b/src/routes/messages.rs @@ -1,45 +1,45 @@ -use axum::routing::post; - -use crate::handlers::{ - batch_mark_messages_read_handler, create_message_handler, delete_message_handler, - get_user_message_stats_handler, get_user_messages_handler, handle_message_handler, - mark_message_read_handler, -}; -use crate::routes::route::{self, MetaRoute}; - -/// 注册消息相关的路由 -pub fn register_routes(meta_route: &mut MetaRoute) { - let mut message_route = route::RouteGroup::new("/messages"); - - // 消息管理 - message_route.add_route_item(route::RouteItem::post( - "/create", - post(create_message_handler), - )); - message_route.add_route_item(route::RouteItem::post( - "/list", - post(get_user_messages_handler), - )); - message_route.add_route_item(route::RouteItem::post( - "/stats", - post(get_user_message_stats_handler), - )); - message_route.add_route_item(route::RouteItem::post( - "/read", - post(mark_message_read_handler), - )); - message_route.add_route_item(route::RouteItem::post( - "/batch-read", - post(batch_mark_messages_read_handler), - )); - message_route.add_route_item(route::RouteItem::post( - "/handle", - post(handle_message_handler), - )); - message_route.add_route_item(route::RouteItem::post( - "/delete", - post(delete_message_handler), - )); - - meta_route.add_route_group(message_route); -} +use axum::routing::post; + +use crate::handlers::{ + batch_mark_messages_read_handler, create_message_handler, delete_message_handler, + get_user_message_stats_handler, get_user_messages_handler, handle_message_handler, + mark_message_read_handler, +}; +use crate::routes::route::{self, MetaRoute}; + +/// 注册消息相关的路由 +pub fn register_routes(meta_route: &mut MetaRoute) { + let mut message_route = route::RouteGroup::new("/messages"); + + // 消息管理 + message_route.add_route_item(route::RouteItem::post( + "/create", + post(create_message_handler), + )); + message_route.add_route_item(route::RouteItem::post( + "/list", + post(get_user_messages_handler), + )); + message_route.add_route_item(route::RouteItem::post( + "/stats", + post(get_user_message_stats_handler), + )); + message_route.add_route_item(route::RouteItem::post( + "/read", + post(mark_message_read_handler), + )); + message_route.add_route_item(route::RouteItem::post( + "/batch-read", + post(batch_mark_messages_read_handler), + )); + message_route.add_route_item(route::RouteItem::post( + "/handle", + post(handle_message_handler), + )); + message_route.add_route_item(route::RouteItem::post( + "/delete", + post(delete_message_handler), + )); + + meta_route.add_route_group(message_route); +} diff --git a/src/routes/preferences.rs b/src/routes/preferences.rs index a813823..a60f044 100644 --- a/src/routes/preferences.rs +++ b/src/routes/preferences.rs @@ -1,20 +1,20 @@ -use axum::routing::post; - -use crate::handlers::{get_preferences_handler, update_preferences_handler}; -use crate::routes::route::{self, MetaRoute}; - -/// 注册用户偏好设置相关的路由 -pub fn register_routes(meta_route: &mut MetaRoute) { - let mut pref_route = route::RouteGroup::new("/preferences"); - - pref_route.add_route_item(route::RouteItem::post( - "/get", - post(get_preferences_handler), - )); - pref_route.add_route_item(route::RouteItem::post( - "/update", - post(update_preferences_handler), - )); - - meta_route.add_route_group(pref_route); -} +use axum::routing::post; + +use crate::handlers::{get_preferences_handler, update_preferences_handler}; +use crate::routes::route::{self, MetaRoute}; + +/// 注册用户偏好设置相关的路由 +pub fn register_routes(meta_route: &mut MetaRoute) { + let mut pref_route = route::RouteGroup::new("/preferences"); + + pref_route.add_route_item(route::RouteItem::post( + "/get", + post(get_preferences_handler), + )); + pref_route.add_route_item(route::RouteItem::post( + "/update", + post(update_preferences_handler), + )); + + meta_route.add_route_group(pref_route); +} diff --git a/src/routes/proxies.rs b/src/routes/proxies.rs index 5428403..3d63c6d 100644 --- a/src/routes/proxies.rs +++ b/src/routes/proxies.rs @@ -1,37 +1,37 @@ -use axum::routing::post; - -use crate::handlers::{ - batch_delete_proxies_handler, batch_import_proxies_handler, create_proxy_handler, - delete_proxy_handler, get_proxies_handler, get_proxy_handler, update_proxy_handler, -}; -use crate::routes::route::{self, MetaRoute}; - -/// 注册代理相关的路由 -pub fn register_routes(meta_route: &mut MetaRoute) { - let mut proxy_route = route::RouteGroup::new("/proxies"); - - proxy_route.add_route_item(route::RouteItem::post("/list", post(get_proxies_handler))); - proxy_route.add_route_item(route::RouteItem::post("/detail", post(get_proxy_handler))); - proxy_route.add_route_item(route::RouteItem::post( - "/create", - post(create_proxy_handler), - )); - proxy_route.add_route_item(route::RouteItem::post( - "/update", - post(update_proxy_handler), - )); - proxy_route.add_route_item(route::RouteItem::post( - "/delete", - post(delete_proxy_handler), - )); - proxy_route.add_route_item(route::RouteItem::post( - "/batch-delete", - post(batch_delete_proxies_handler), - )); - proxy_route.add_route_item(route::RouteItem::post( - "/batch-import", - post(batch_import_proxies_handler), - )); - - meta_route.add_route_group(proxy_route); -} +use axum::routing::post; + +use crate::handlers::{ + batch_delete_proxies_handler, batch_import_proxies_handler, create_proxy_handler, + delete_proxy_handler, get_proxies_handler, get_proxy_handler, update_proxy_handler, +}; +use crate::routes::route::{self, MetaRoute}; + +/// 注册代理相关的路由 +pub fn register_routes(meta_route: &mut MetaRoute) { + let mut proxy_route = route::RouteGroup::new("/proxies"); + + proxy_route.add_route_item(route::RouteItem::post("/list", post(get_proxies_handler))); + proxy_route.add_route_item(route::RouteItem::post("/detail", post(get_proxy_handler))); + proxy_route.add_route_item(route::RouteItem::post( + "/create", + post(create_proxy_handler), + )); + proxy_route.add_route_item(route::RouteItem::post( + "/update", + post(update_proxy_handler), + )); + proxy_route.add_route_item(route::RouteItem::post( + "/delete", + post(delete_proxy_handler), + )); + proxy_route.add_route_item(route::RouteItem::post( + "/batch-delete", + post(batch_delete_proxies_handler), + )); + proxy_route.add_route_item(route::RouteItem::post( + "/batch-import", + post(batch_import_proxies_handler), + )); + + meta_route.add_route_group(proxy_route); +} diff --git a/src/routes/proxy_visibility.rs b/src/routes/proxy_visibility.rs index 7720c88..a4af89c 100644 --- a/src/routes/proxy_visibility.rs +++ b/src/routes/proxy_visibility.rs @@ -1,32 +1,32 @@ -use axum::routing::post; - -use crate::handlers::proxy_visibility; -use crate::routes::route::{self, MetaRoute}; - -/// 注册代理可见性相关的路由 -pub fn register_routes(meta_route: &mut MetaRoute) { - let mut proxy_visibility_route = route::RouteGroup::new("/proxy-visibility"); - - proxy_visibility_route.add_route_item(route::RouteItem::post( - "/set", - post(proxy_visibility::set_proxy_visible_handler), - )); - proxy_visibility_route.add_route_item(route::RouteItem::post( - "/remove", - post(proxy_visibility::remove_proxy_visible_handler), - )); - proxy_visibility_route.add_route_item(route::RouteItem::post( - "/batch-set", - post(proxy_visibility::batch_set_proxy_visible_handler), - )); - proxy_visibility_route.add_route_item(route::RouteItem::post( - "/list-visible", - post(proxy_visibility::get_visible_proxies_handler), - )); - proxy_visibility_route.add_route_item(route::RouteItem::post( - "/list-teams", - post(proxy_visibility::get_proxy_visible_teams_handler), - )); - - meta_route.add_route_group(proxy_visibility_route); -} +use axum::routing::post; + +use crate::handlers::proxy_visibility; +use crate::routes::route::{self, MetaRoute}; + +/// 注册代理可见性相关的路由 +pub fn register_routes(meta_route: &mut MetaRoute) { + let mut proxy_visibility_route = route::RouteGroup::new("/proxy-visibility"); + + proxy_visibility_route.add_route_item(route::RouteItem::post( + "/set", + post(proxy_visibility::set_proxy_visible_handler), + )); + proxy_visibility_route.add_route_item(route::RouteItem::post( + "/remove", + post(proxy_visibility::remove_proxy_visible_handler), + )); + proxy_visibility_route.add_route_item(route::RouteItem::post( + "/batch-set", + post(proxy_visibility::batch_set_proxy_visible_handler), + )); + proxy_visibility_route.add_route_item(route::RouteItem::post( + "/list-visible", + post(proxy_visibility::get_visible_proxies_handler), + )); + proxy_visibility_route.add_route_item(route::RouteItem::post( + "/list-teams", + post(proxy_visibility::get_proxy_visible_teams_handler), + )); + + meta_route.add_route_group(proxy_visibility_route); +} diff --git a/src/routes/rpa.rs b/src/routes/rpa.rs index a2874d2..51d980c 100644 --- a/src/routes/rpa.rs +++ b/src/routes/rpa.rs @@ -1,67 +1,67 @@ -use axum::routing::post; - -use crate::handlers::{ - batch_delete_rpa_tasks_handler, create_rpa_task_handler, delete_rpa_task_handler, - duplicate_rpa_task_handler, export_rpa_task_handler, get_rpa_run_handler, get_rpa_runs_handler, - get_rpa_task_handler, get_rpa_tasks_handler, run_rpa_task_handler, stop_rpa_task_handler, - update_rpa_task_handler, -}; -use crate::routes::route::{self, MetaRoute}; - -/// 注册 RPA 工作流相关的路由 -pub fn register_routes(meta_route: &mut MetaRoute) { - let mut rpa_route = route::RouteGroup::new("/rpa"); - - // 任务管理 - rpa_route.add_route_item(route::RouteItem::post( - "/tasks", - post(get_rpa_tasks_handler), - )); - rpa_route.add_route_item(route::RouteItem::post( - "/tasks/detail", - post(get_rpa_task_handler), - )); - rpa_route.add_route_item(route::RouteItem::post( - "/tasks/create", - post(create_rpa_task_handler), - )); - rpa_route.add_route_item(route::RouteItem::post( - "/tasks/update", - post(update_rpa_task_handler), - )); - rpa_route.add_route_item(route::RouteItem::post( - "/tasks/delete", - post(delete_rpa_task_handler), - )); - rpa_route.add_route_item(route::RouteItem::post( - "/tasks/batch-delete", - post(batch_delete_rpa_tasks_handler), - )); - - // 任务操作 - rpa_route.add_route_item(route::RouteItem::post( - "/tasks/run", - post(run_rpa_task_handler), - )); - rpa_route.add_route_item(route::RouteItem::post( - "/tasks/stop", - post(stop_rpa_task_handler), - )); - rpa_route.add_route_item(route::RouteItem::post( - "/tasks/duplicate", - post(duplicate_rpa_task_handler), - )); - rpa_route.add_route_item(route::RouteItem::post( - "/tasks/export", - post(export_rpa_task_handler), - )); - - // 执行记录 - rpa_route.add_route_item(route::RouteItem::post("/runs", post(get_rpa_runs_handler))); - rpa_route.add_route_item(route::RouteItem::post( - "/runs/detail", - post(get_rpa_run_handler), - )); - - meta_route.add_route_group(rpa_route); -} +use axum::routing::post; + +use crate::handlers::{ + batch_delete_rpa_tasks_handler, create_rpa_task_handler, delete_rpa_task_handler, + duplicate_rpa_task_handler, export_rpa_task_handler, get_rpa_run_handler, get_rpa_runs_handler, + get_rpa_task_handler, get_rpa_tasks_handler, run_rpa_task_handler, stop_rpa_task_handler, + update_rpa_task_handler, +}; +use crate::routes::route::{self, MetaRoute}; + +/// 注册 RPA 工作流相关的路由 +pub fn register_routes(meta_route: &mut MetaRoute) { + let mut rpa_route = route::RouteGroup::new("/rpa"); + + // 任务管理 + rpa_route.add_route_item(route::RouteItem::post( + "/tasks", + post(get_rpa_tasks_handler), + )); + rpa_route.add_route_item(route::RouteItem::post( + "/tasks/detail", + post(get_rpa_task_handler), + )); + rpa_route.add_route_item(route::RouteItem::post( + "/tasks/create", + post(create_rpa_task_handler), + )); + rpa_route.add_route_item(route::RouteItem::post( + "/tasks/update", + post(update_rpa_task_handler), + )); + rpa_route.add_route_item(route::RouteItem::post( + "/tasks/delete", + post(delete_rpa_task_handler), + )); + rpa_route.add_route_item(route::RouteItem::post( + "/tasks/batch-delete", + post(batch_delete_rpa_tasks_handler), + )); + + // 任务操作 + rpa_route.add_route_item(route::RouteItem::post( + "/tasks/run", + post(run_rpa_task_handler), + )); + rpa_route.add_route_item(route::RouteItem::post( + "/tasks/stop", + post(stop_rpa_task_handler), + )); + rpa_route.add_route_item(route::RouteItem::post( + "/tasks/duplicate", + post(duplicate_rpa_task_handler), + )); + rpa_route.add_route_item(route::RouteItem::post( + "/tasks/export", + post(export_rpa_task_handler), + )); + + // 执行记录 + rpa_route.add_route_item(route::RouteItem::post("/runs", post(get_rpa_runs_handler))); + rpa_route.add_route_item(route::RouteItem::post( + "/runs/detail", + post(get_rpa_run_handler), + )); + + meta_route.add_route_group(rpa_route); +} diff --git a/src/routes/secret.rs b/src/routes/secret.rs index f2f6b0c..7490276 100644 --- a/src/routes/secret.rs +++ b/src/routes/secret.rs @@ -1,16 +1,16 @@ -use axum::routing::get; - -use crate::handlers::get_public_key_handler; -use crate::routes::route::{self, MetaRoute}; - -/// 注册 secret 相关的路由 -pub fn register_routes(meta_route: &mut MetaRoute) -> () { - let mut secret_route = route::RouteGroup::new("/secret"); - - secret_route.add_route_item(route::RouteItem::get( - "/public/key", - get(get_public_key_handler), - )); - - meta_route.add_route_group(secret_route); -} +use axum::routing::get; + +use crate::handlers::get_public_key_handler; +use crate::routes::route::{self, MetaRoute}; + +/// 注册 secret 相关的路由 +pub fn register_routes(meta_route: &mut MetaRoute) -> () { + let mut secret_route = route::RouteGroup::new("/secret"); + + secret_route.add_route_item(route::RouteItem::get( + "/public/key", + get(get_public_key_handler), + )); + + meta_route.add_route_group(secret_route); +} diff --git a/src/routes/teams.rs b/src/routes/teams.rs index 01018d8..354b3d1 100644 --- a/src/routes/teams.rs +++ b/src/routes/teams.rs @@ -1,61 +1,61 @@ -use axum::routing::post; - -use crate::handlers::{ - accept_invitation_handler, cancel_invitation_handler, create_team_handler, - get_my_teams_handler, get_pending_invitations_handler, get_team_handler, - get_team_members_handler, invite_member_handler, leave_team_handler, reject_invitation_handler, - remove_member_handler, switch_team_handler, update_member_role_handler, update_team_handler, -}; -use crate::routes::route::{self, MetaRoute}; - -/// 注册团队相关的路由 -pub fn register_routes(meta_route: &mut MetaRoute) { - let mut team_route = route::RouteGroup::new("/teams"); - - // 团队管理 - team_route.add_route_item(route::RouteItem::post("/create", post(create_team_handler))); - team_route.add_route_item(route::RouteItem::post( - "/my-teams", - post(get_my_teams_handler), - )); - team_route.add_route_item(route::RouteItem::post("/switch", post(switch_team_handler))); - team_route.add_route_item(route::RouteItem::post("/detail", post(get_team_handler))); - team_route.add_route_item(route::RouteItem::post("/update", post(update_team_handler))); - team_route.add_route_item(route::RouteItem::post("/leave", post(leave_team_handler))); - - // 成员管理 - team_route.add_route_item(route::RouteItem::post( - "/members", - post(get_team_members_handler), - )); - team_route.add_route_item(route::RouteItem::post( - "/invite", - post(invite_member_handler), - )); - team_route.add_route_item(route::RouteItem::post( - "/invitations", - post(get_pending_invitations_handler), - )); - team_route.add_route_item(route::RouteItem::post( - "/invitation/cancel", - post(cancel_invitation_handler), - )); - team_route.add_route_item(route::RouteItem::post( - "/invitation/accept", - post(accept_invitation_handler), - )); - team_route.add_route_item(route::RouteItem::post( - "/invitation/reject", - post(reject_invitation_handler), - )); - team_route.add_route_item(route::RouteItem::post( - "/member/role", - post(update_member_role_handler), - )); - team_route.add_route_item(route::RouteItem::post( - "/member/remove", - post(remove_member_handler), - )); - - meta_route.add_route_group(team_route); -} +use axum::routing::post; + +use crate::handlers::{ + accept_invitation_handler, cancel_invitation_handler, create_team_handler, + get_my_teams_handler, get_pending_invitations_handler, get_team_handler, + get_team_members_handler, invite_member_handler, leave_team_handler, reject_invitation_handler, + remove_member_handler, switch_team_handler, update_member_role_handler, update_team_handler, +}; +use crate::routes::route::{self, MetaRoute}; + +/// 注册团队相关的路由 +pub fn register_routes(meta_route: &mut MetaRoute) { + let mut team_route = route::RouteGroup::new("/teams"); + + // 团队管理 + team_route.add_route_item(route::RouteItem::post("/create", post(create_team_handler))); + team_route.add_route_item(route::RouteItem::post( + "/my-teams", + post(get_my_teams_handler), + )); + team_route.add_route_item(route::RouteItem::post("/switch", post(switch_team_handler))); + team_route.add_route_item(route::RouteItem::post("/detail", post(get_team_handler))); + team_route.add_route_item(route::RouteItem::post("/update", post(update_team_handler))); + team_route.add_route_item(route::RouteItem::post("/leave", post(leave_team_handler))); + + // 成员管理 + team_route.add_route_item(route::RouteItem::post( + "/members", + post(get_team_members_handler), + )); + team_route.add_route_item(route::RouteItem::post( + "/invite", + post(invite_member_handler), + )); + team_route.add_route_item(route::RouteItem::post( + "/invitations", + post(get_pending_invitations_handler), + )); + team_route.add_route_item(route::RouteItem::post( + "/invitation/cancel", + post(cancel_invitation_handler), + )); + team_route.add_route_item(route::RouteItem::post( + "/invitation/accept", + post(accept_invitation_handler), + )); + team_route.add_route_item(route::RouteItem::post( + "/invitation/reject", + post(reject_invitation_handler), + )); + team_route.add_route_item(route::RouteItem::post( + "/member/role", + post(update_member_role_handler), + )); + team_route.add_route_item(route::RouteItem::post( + "/member/remove", + post(remove_member_handler), + )); + + meta_route.add_route_group(team_route); +} diff --git a/src/routes/templates.rs b/src/routes/templates.rs index 36ad572..c7a72c3 100644 --- a/src/routes/templates.rs +++ b/src/routes/templates.rs @@ -1,40 +1,40 @@ -use axum::routing::post; - -use crate::handlers::{ - apply_template_handler, create_from_template_handler, create_template_handler, - delete_template_handler, get_template_handler, get_templates_handler, update_template_handler, -}; -use crate::routes::route::{self, MetaRoute}; - -/// 注册模板相关的路由 -pub fn register_routes(meta_route: &mut MetaRoute) { - let mut template_route = route::RouteGroup::new("/templates"); - - template_route.add_route_item(route::RouteItem::post("/list", post(get_templates_handler))); - template_route.add_route_item(route::RouteItem::post( - "/detail", - post(get_template_handler), - )); - template_route.add_route_item(route::RouteItem::post( - "/create", - post(create_template_handler), - )); - template_route.add_route_item(route::RouteItem::post( - "/update", - post(update_template_handler), - )); - template_route.add_route_item(route::RouteItem::post( - "/delete", - post(delete_template_handler), - )); - template_route.add_route_item(route::RouteItem::post( - "/apply", - post(apply_template_handler), - )); - template_route.add_route_item(route::RouteItem::post( - "/create-from", - post(create_from_template_handler), - )); - - meta_route.add_route_group(template_route); -} +use axum::routing::post; + +use crate::handlers::{ + apply_template_handler, create_from_template_handler, create_template_handler, + delete_template_handler, get_template_handler, get_templates_handler, update_template_handler, +}; +use crate::routes::route::{self, MetaRoute}; + +/// 注册模板相关的路由 +pub fn register_routes(meta_route: &mut MetaRoute) { + let mut template_route = route::RouteGroup::new("/templates"); + + template_route.add_route_item(route::RouteItem::post("/list", post(get_templates_handler))); + template_route.add_route_item(route::RouteItem::post( + "/detail", + post(get_template_handler), + )); + template_route.add_route_item(route::RouteItem::post( + "/create", + post(create_template_handler), + )); + template_route.add_route_item(route::RouteItem::post( + "/update", + post(update_template_handler), + )); + template_route.add_route_item(route::RouteItem::post( + "/delete", + post(delete_template_handler), + )); + template_route.add_route_item(route::RouteItem::post( + "/apply", + post(apply_template_handler), + )); + template_route.add_route_item(route::RouteItem::post( + "/create-from", + post(create_from_template_handler), + )); + + meta_route.add_route_group(template_route); +} diff --git a/src/routes/time.rs b/src/routes/time.rs index 6045d39..0e2953a 100644 --- a/src/routes/time.rs +++ b/src/routes/time.rs @@ -1,12 +1,12 @@ -use axum::routing::get; - -use crate::handlers::now_handle; -use crate::routes::route::{self, MetaRoute}; - -pub fn register_routes(meta_route: &mut MetaRoute) -> () { - let mut time_route = route::RouteGroup::new("/time"); - - time_route.add_route_item(route::RouteItem::get("/now", get(now_handle))); - - meta_route.add_route_group(time_route); -} +use axum::routing::get; + +use crate::handlers::now_handle; +use crate::routes::route::{self, MetaRoute}; + +pub fn register_routes(meta_route: &mut MetaRoute) -> () { + let mut time_route = route::RouteGroup::new("/time"); + + time_route.add_route_item(route::RouteItem::get("/now", get(now_handle))); + + meta_route.add_route_group(time_route); +} diff --git a/src/routes/users.rs b/src/routes/users.rs index 5462d17..411f8f6 100644 --- a/src/routes/users.rs +++ b/src/routes/users.rs @@ -1,50 +1,50 @@ -use axum::routing::post; - -use crate::handlers::{ - get_current_user_handler, login_handler, refresh_token_handler, register_handler, - reset_password_handler, send_code_handler, update_password_handler, update_user_handler, -}; -use crate::routes::route::{self, MetaRoute}; - -/// 注册用户相关的路由 -pub fn register_routes(meta_route: &mut MetaRoute) -> () { - let mut user_route = route::RouteGroup::new("/users"); - - user_route.add_route_item(route::RouteItem::post("/register", post(register_handler))); - - user_route.add_route_item(route::RouteItem::post("/login", post(login_handler))); - - user_route.add_route_item(route::RouteItem::post( - "/refresh-credentials", - post(refresh_token_handler), - )); - - user_route.add_route_item(route::RouteItem::post( - "/me", - post(get_current_user_handler), - )); - - user_route.add_route_item(route::RouteItem::post("/update", post(update_user_handler))); - - user_route.add_route_item(route::RouteItem::post( - "/password", - post(update_password_handler), - )); - - user_route.add_route_item(route::RouteItem::post( - "/reset-password", - post(reset_password_handler), - )); - - user_route.add_route_item(route::RouteItem::post( - "/register-send-code", - post(send_code_handler), - )); - - user_route.add_route_item(route::RouteItem::post( - "/reset-password-send-code", - post(send_code_handler), - )); - - meta_route.add_route_group(user_route); -} +use axum::routing::post; + +use crate::handlers::{ + get_current_user_handler, login_handler, refresh_token_handler, register_handler, + reset_password_handler, send_code_handler, update_password_handler, update_user_handler, +}; +use crate::routes::route::{self, MetaRoute}; + +/// 注册用户相关的路由 +pub fn register_routes(meta_route: &mut MetaRoute) -> () { + let mut user_route = route::RouteGroup::new("/users"); + + user_route.add_route_item(route::RouteItem::post("/register", post(register_handler))); + + user_route.add_route_item(route::RouteItem::post("/login", post(login_handler))); + + user_route.add_route_item(route::RouteItem::post( + "/refresh-credentials", + post(refresh_token_handler), + )); + + user_route.add_route_item(route::RouteItem::post( + "/me", + post(get_current_user_handler), + )); + + user_route.add_route_item(route::RouteItem::post("/update", post(update_user_handler))); + + user_route.add_route_item(route::RouteItem::post( + "/password", + post(update_password_handler), + )); + + user_route.add_route_item(route::RouteItem::post( + "/reset-password", + post(reset_password_handler), + )); + + user_route.add_route_item(route::RouteItem::post( + "/register-send-code", + post(send_code_handler), + )); + + user_route.add_route_item(route::RouteItem::post( + "/reset-password-send-code", + post(send_code_handler), + )); + + meta_route.add_route_group(user_route); +} diff --git a/src/routes/workspace_quotas.rs b/src/routes/workspace_quotas.rs index fef9771..e4198dc 100644 --- a/src/routes/workspace_quotas.rs +++ b/src/routes/workspace_quotas.rs @@ -1,20 +1,20 @@ -use axum::routing::post; - -use crate::handlers::workspace_quotas; -use crate::routes::route::{self, MetaRoute}; - -/// 注册工作空间配额相关的路由 -pub fn register_routes(meta_route: &mut MetaRoute) { - let mut quota_route = route::RouteGroup::new("/workspace-quotas"); - - quota_route.add_route_item(route::RouteItem::post( - "/get", - post(workspace_quotas::get_workspace_quota_handler), - )); - quota_route.add_route_item(route::RouteItem::post( - "/update", - post(workspace_quotas::update_quota_usage_handler), - )); - - meta_route.add_route_group(quota_route); -} +use axum::routing::post; + +use crate::handlers::workspace_quotas; +use crate::routes::route::{self, MetaRoute}; + +/// 注册工作空间配额相关的路由 +pub fn register_routes(meta_route: &mut MetaRoute) { + let mut quota_route = route::RouteGroup::new("/workspace-quotas"); + + quota_route.add_route_item(route::RouteItem::post( + "/get", + post(workspace_quotas::get_workspace_quota_handler), + )); + quota_route.add_route_item(route::RouteItem::post( + "/update", + post(workspace_quotas::update_quota_usage_handler), + )); + + meta_route.add_route_group(quota_route); +} diff --git a/src/routes/workspaces.rs b/src/routes/workspaces.rs index 2b57dd2..0a9895d 100644 --- a/src/routes/workspaces.rs +++ b/src/routes/workspaces.rs @@ -1,36 +1,36 @@ -use axum::routing::post; - -use crate::handlers::workspaces; -use crate::routes::route::{self, MetaRoute}; - -/// 注册工作空间相关的路由 -pub fn register_routes(meta_route: &mut MetaRoute) { - let mut workspace_route = route::RouteGroup::new("/workspaces"); - - workspace_route.add_route_item(route::RouteItem::post( - "/create", - post(workspaces::create_workspace_handler), - )); - workspace_route.add_route_item(route::RouteItem::post( - "/list", - post(workspaces::get_my_workspaces_handler), - )); - workspace_route.add_route_item(route::RouteItem::post( - "/get", - post(workspaces::get_workspace_handler), - )); - workspace_route.add_route_item(route::RouteItem::post( - "/update", - post(workspaces::update_workspace_handler), - )); - workspace_route.add_route_item(route::RouteItem::post( - "/delete", - post(workspaces::delete_workspace_handler), - )); - workspace_route.add_route_item(route::RouteItem::post( - "/switch", - post(workspaces::switch_workspace_handler), - )); - - meta_route.add_route_group(workspace_route); -} +use axum::routing::post; + +use crate::handlers::workspaces; +use crate::routes::route::{self, MetaRoute}; + +/// 注册工作空间相关的路由 +pub fn register_routes(meta_route: &mut MetaRoute) { + let mut workspace_route = route::RouteGroup::new("/workspaces"); + + workspace_route.add_route_item(route::RouteItem::post( + "/create", + post(workspaces::create_workspace_handler), + )); + workspace_route.add_route_item(route::RouteItem::post( + "/list", + post(workspaces::get_my_workspaces_handler), + )); + workspace_route.add_route_item(route::RouteItem::post( + "/get", + post(workspaces::get_workspace_handler), + )); + workspace_route.add_route_item(route::RouteItem::post( + "/update", + post(workspaces::update_workspace_handler), + )); + workspace_route.add_route_item(route::RouteItem::post( + "/delete", + post(workspaces::delete_workspace_handler), + )); + workspace_route.add_route_item(route::RouteItem::post( + "/switch", + post(workspaces::switch_workspace_handler), + )); + + meta_route.add_route_group(workspace_route); +} From e5b87cd72698ba053f7ee183fc98868bb84d0eaa Mon Sep 17 00:00:00 2001 From: lius-new Date: Wed, 4 Feb 2026 02:18:25 +0800 Subject: [PATCH 03/60] =?UTF-8?q?chore(simprint-server):=20=E7=A7=BB?= =?UTF-8?q?=E9=99=A4=20update-gateway=20=E9=85=8D=E7=BD=AE=E3=80=81?= =?UTF-8?q?=E6=96=B0=E5=A2=9E=E6=9C=BA=E5=99=A8=E6=A0=87=E7=AD=BE=E8=BF=81?= =?UTF-8?q?=E7=A7=BB?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit - 删除 config.update-gateway.*.toml(update-gateway 独立为 simprint-update-server) - 新增 migrations/20250127000003_create_machine_tags_tables.sql Co-authored-by: Cursor --- configs/config.update-gateway.dev.toml | 23 -------- configs/config.update-gateway.prod.toml | 23 -------- configs/config.update-gateway.test.toml | 23 -------- ...50127000003_create_machine_tags_tables.sql | 53 +++++++++++++++++++ 4 files changed, 53 insertions(+), 69 deletions(-) delete mode 100644 configs/config.update-gateway.dev.toml delete mode 100644 configs/config.update-gateway.prod.toml delete mode 100644 configs/config.update-gateway.test.toml create mode 100644 migrations/20250127000003_create_machine_tags_tables.sql diff --git a/configs/config.update-gateway.dev.toml b/configs/config.update-gateway.dev.toml deleted file mode 100644 index ec85f75..0000000 --- a/configs/config.update-gateway.dev.toml +++ /dev/null @@ -1,23 +0,0 @@ -[app] -name = "update-gateway" -port = 40042 -secret = "p4y8uX1Jc22K7bAN52arpvJpVbduUvGNNNfo" -prefix = "/api/v1" -encrypt_secret_location = "./assets/secret" -route_whitelists = [ - "POST+/api/v1/versions/check", - "POST+/api/v1/maintenances/active", - "GET+/api/v1/health" -] - -[database] -url = "postgres://lius:URxywfBRZZ1xpWcyPRbG@111.230.19.75:5432/simprintdb" -max_connections = 25 -min_connections = 5 -max_lifetime = 3000 -acquire_timeout = 30 -idle_timeout = 600 - -[redis] -url = "redis://111.230.19.75:6381?protocol=resp3" - diff --git a/configs/config.update-gateway.prod.toml b/configs/config.update-gateway.prod.toml deleted file mode 100644 index 9d33f4c..0000000 --- a/configs/config.update-gateway.prod.toml +++ /dev/null @@ -1,23 +0,0 @@ -[app] -name = "update-gateway" -port = 40042 -secret = "p4y8uX1Jc22K7bAN52arpvJpVbduUvGNNNfo" -prefix = "/api/v1" -encrypt_secret_location = "./assets/secret" -route_whitelists = [ - "POST+/api/v1/versions/check", - "POST+/api/v1/maintenances/active", - "GET+/api/v1/health" -] - -[database] -url = "postgres://lius:0gMAZGgriRK395Apzcb8@111.230.19.75:5433/simprintdb" -max_connections = 25 -min_connections = 5 -max_lifetime = 3000 -acquire_timeout = 30 -idle_timeout = 600 - -[redis] -url = "redis://111.230.19.75:6380?protocol=resp3" - diff --git a/configs/config.update-gateway.test.toml b/configs/config.update-gateway.test.toml deleted file mode 100644 index 9e48ebb..0000000 --- a/configs/config.update-gateway.test.toml +++ /dev/null @@ -1,23 +0,0 @@ -[app] -name = "update-gateway" -port = 40042 -secret = "p4y8uX1Jc22K7bAN52arpvJpVbduUvGNNNfo" -prefix = "/api/v1" -encrypt_secret_location = "./assets/secret" -route_whitelists = [ - "POST+/api/v1/versions/check", - "POST+/api/v1/maintenances/active", - "GET+/api/v1/health" -] - -[database] -url = "postgres://admin-lius:ZZutdqfPAE4RFo5PYjgA@1.12.233.51:5433/l_g_p_db" -max_connections = 25 -min_connections = 5 -max_lifetime = 3000 -acquire_timeout = 30 -idle_timeout = 600 - -[redis] -url = "redis://1.12.233.51:6380?protocol=resp3" - diff --git a/migrations/20250127000003_create_machine_tags_tables.sql b/migrations/20250127000003_create_machine_tags_tables.sql new file mode 100644 index 0000000..0ff0f80 --- /dev/null +++ b/migrations/20250127000003_create_machine_tags_tables.sql @@ -0,0 +1,53 @@ +-- Add migration script here +-- Create machine_tags and machine_user_tags tables for tag management + +-- Drop tables (for development) +-- DROP TABLE public.machine_user_tags CASCADE; +-- DROP TABLE public.machine_tags CASCADE; + +-- Create machine_tags table +CREATE TABLE public.machine_tags ( + id int4 GENERATED BY DEFAULT AS IDENTITY( INCREMENT BY 1 MINVALUE 1 MAXVALUE 2147483647 START 1 CACHE 1 NO CYCLE) NOT NULL, + tag_name varchar(100) NOT NULL, -- 标签名称 + description text NULL, -- 标签描述 + category varchar(50) NULL, -- 标签分类 + is_active boolean DEFAULT true NOT NULL, -- 是否激活 + created_at timestamptz DEFAULT CURRENT_TIMESTAMP NOT NULL, -- 创建时间 + CONSTRAINT machine_tags_pk PRIMARY KEY (id), + CONSTRAINT machine_tags_tag_name_unique UNIQUE (tag_name) +); + +-- Create machine_user_tags table (association table) +CREATE TABLE public.machine_user_tags ( + id int4 GENERATED BY DEFAULT AS IDENTITY( INCREMENT BY 1 MINVALUE 1 MAXVALUE 2147483647 START 1 CACHE 1 NO CYCLE) NOT NULL, + machine_user_id int4 NOT NULL, -- 机器用户ID + tag_id int4 NOT NULL, -- 标签ID + created_at timestamptz DEFAULT CURRENT_TIMESTAMP NOT NULL, -- 创建时间 + CONSTRAINT machine_user_tags_pk PRIMARY KEY (id), + CONSTRAINT machine_user_tags_machine_user_id_fk FOREIGN KEY (machine_user_id) REFERENCES public.machine_users(id) ON DELETE CASCADE, + CONSTRAINT machine_user_tags_tag_id_fk FOREIGN KEY (tag_id) REFERENCES public.machine_tags(id) ON DELETE CASCADE, + CONSTRAINT machine_user_tags_unique UNIQUE (machine_user_id, tag_id) +); + +-- Create indexes for performance +CREATE INDEX machine_tags_tag_name_idx ON public.machine_tags USING btree (tag_name); +CREATE INDEX machine_tags_is_active_idx ON public.machine_tags USING btree (is_active); +CREATE INDEX machine_user_tags_machine_user_id_idx ON public.machine_user_tags USING btree (machine_user_id); +CREATE INDEX machine_user_tags_tag_id_idx ON public.machine_user_tags USING btree (tag_id); + +-- Column comments +COMMENT ON TABLE public.machine_tags IS '机器标签主表'; +COMMENT ON TABLE public.machine_user_tags IS '机器用户标签关联表'; + +COMMENT ON COLUMN public.machine_tags.id IS '主键ID'; +COMMENT ON COLUMN public.machine_tags.tag_name IS '标签名称(唯一)'; +COMMENT ON COLUMN public.machine_tags.description IS '标签描述'; +COMMENT ON COLUMN public.machine_tags.category IS '标签分类'; +COMMENT ON COLUMN public.machine_tags.is_active IS '是否激活'; +COMMENT ON COLUMN public.machine_tags.created_at IS '创建时间'; + +COMMENT ON COLUMN public.machine_user_tags.id IS '主键ID'; +COMMENT ON COLUMN public.machine_user_tags.machine_user_id IS '机器用户ID'; +COMMENT ON COLUMN public.machine_user_tags.tag_id IS '标签ID'; +COMMENT ON COLUMN public.machine_user_tags.created_at IS '创建时间'; + From 8db4520863d158689f33aa9472fbe97e820e1716 Mon Sep 17 00:00:00 2001 From: lius-new Date: Wed, 4 Feb 2026 20:35:09 +0800 Subject: [PATCH 04/60] =?UTF-8?q?feat:=20=E7=89=88=E6=9C=AC=E7=B1=BB?= =?UTF-8?q?=E5=9E=8B=E4=B8=8E=E7=89=88=E6=9C=AC=E8=A1=A8=E6=89=A9=E5=B1=95?= =?UTF-8?q?=E3=80=81=E6=9C=BA=E5=99=A8=E6=A0=87=E7=AD=BE=E6=A8=A1=E5=9D=97?= =?UTF-8?q?=E5=8F=8A=E7=81=B0=E5=BA=A6/=E7=BB=B4=E6=8A=A4=E7=AD=89?= =?UTF-8?q?=E9=87=8D=E6=9E=84?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit - 数据库迁移: version_types 增加 is_auto_download,versions 增加内核相关字段,插入默认版本类型 - 配置: minio 增加 version_resource_bucket - 新增: machine_tags 模块 (dto/entitys/models/services) - 重构: gray_releases、gray_resources、machine_gray_allocations、machine_users、maintenance、strategy_types、version_types、versions、extensions 等 - 调整: handlers、middlewares、utils 等 Co-authored-by: Cursor --- configs/config.dev.toml | 1 + configs/config.prod.toml | 1 + configs/config.test.toml | 1 + ...ter_version_types_add_is_auto_download.sql | 7 + ...00002_alter_versions_add_kernel_fields.sql | 16 + ...204000003_insert_default_version_types.sql | 8 + src/dto/extensions.rs | 33 +- src/dto/gray_releases.rs | 44 +- src/dto/gray_resources.rs | 22 +- src/dto/machine_gray_allocations.rs | 32 +- src/dto/machine_tags.rs | 38 + src/dto/machine_users.rs | 42 +- src/dto/maintenance.rs | 98 +- src/dto/mod.rs | 2 + src/dto/strategy_types.rs | 107 ++- src/dto/version_types.rs | 27 +- src/dto/versions.rs | 57 +- src/entitys/gray_releases.rs | 131 +-- src/entitys/gray_resources.rs | 69 +- src/entitys/machine_gray_allocations.rs | 120 ++- src/entitys/machine_tags.rs | 37 + src/entitys/machine_users.rs | 204 ++-- src/entitys/maintenance.rs | 90 +- src/entitys/mod.rs | 136 +-- src/entitys/strategy_types.rs | 52 +- src/entitys/version_types.rs | 107 ++- src/entitys/versions.rs | 229 +++-- src/handlers/environments.rs | 11 +- src/handlers/health.rs | 12 +- src/lib.rs | 2 +- src/middlewares/machine_auth.rs | 7 +- src/models/gray_releases.rs | 541 ++++++----- src/models/gray_resources.rs | 234 ++--- src/models/machine_gray_allocations.rs | 354 +++---- src/models/machine_tags.rs | 230 +++++ src/models/machine_users.rs | 906 ++++++++++-------- src/models/maintenance.rs | 339 ++++--- src/models/mod.rs | 2 + src/models/strategy_types.rs | 112 +-- src/models/version_types.rs | 234 ++--- src/models/versions.rs | 802 ++++++++-------- src/services.rs | 138 +-- src/services/extensions.rs | 46 +- src/services/gray_releases.rs | 539 +++++------ src/services/gray_resources.rs | 264 ++--- src/services/machine_gray_allocations.rs | 383 ++++---- src/services/machine_tags.rs | 55 ++ src/services/machine_users.rs | 546 +++++------ src/services/maintenance.rs | 152 +-- src/services/strategy_types.rs | 107 +-- src/services/tags.rs | 2 +- src/services/users.rs | 13 +- src/services/version_types.rs | 226 +++-- src/services/versions.rs | 431 +++++---- src/utils/config.rs | 5 +- src/utils/minios/get_objects.rs | 27 +- 56 files changed, 4646 insertions(+), 3785 deletions(-) create mode 100644 migrations/20250204000001_alter_version_types_add_is_auto_download.sql create mode 100644 migrations/20250204000002_alter_versions_add_kernel_fields.sql create mode 100644 migrations/20250204000003_insert_default_version_types.sql create mode 100644 src/dto/machine_tags.rs create mode 100644 src/entitys/machine_tags.rs create mode 100644 src/models/machine_tags.rs create mode 100644 src/services/machine_tags.rs diff --git a/configs/config.dev.toml b/configs/config.dev.toml index 1cd3361..31beda6 100644 --- a/configs/config.dev.toml +++ b/configs/config.dev.toml @@ -35,6 +35,7 @@ resource_url = "http://111.230.19.75:9000" access_key = "5kSjAHnITsMZQEwzJN2g" secret_access_key = "eGs3ntV5KvmVzMbLpQwj86w5b5vdSHxUcmqcYhay" avatar_bucket = "avatars" +version_resource_bucket = "versions" [smtp] smtp_server = "smtp.163.com" diff --git a/configs/config.prod.toml b/configs/config.prod.toml index 30720d6..dd6da28 100644 --- a/configs/config.prod.toml +++ b/configs/config.prod.toml @@ -35,6 +35,7 @@ resource_url = "http://111.230.19.75:9000" access_key = "5kSjAHnITsMZQEwzJN2g" secret_access_key = "eGs3ntV5KvmVzMbLpQwj86w5b5vdSHxUcmqcYhay" avatar_bucket = "avatars" +version_resource_bucket = "versions" [smtp] smtp_server = "smtp.163.com" diff --git a/configs/config.test.toml b/configs/config.test.toml index 3858c25..7439a3d 100644 --- a/configs/config.test.toml +++ b/configs/config.test.toml @@ -33,6 +33,7 @@ resource_url = "https://oss.csgot9.com" access_key = "5kSjAHnITsMZQEwzJN2g" secret_access_key = "eGs3ntV5KvmVzMbLpQwj86w5b5vdSHxUcmqcYhay" avatar_bucket = "avatars" +version_resource_bucket = "versions" [smtp] smtp_server = "smtp.163.com" diff --git a/migrations/20250204000001_alter_version_types_add_is_auto_download.sql b/migrations/20250204000001_alter_version_types_add_is_auto_download.sql new file mode 100644 index 0000000..e91548f --- /dev/null +++ b/migrations/20250204000001_alter_version_types_add_is_auto_download.sql @@ -0,0 +1,7 @@ +-- Add is_auto_download flag to version_types to control participation in auto-update flows + +ALTER TABLE public.version_types +ADD COLUMN IF NOT EXISTS is_auto_download bool DEFAULT true NOT NULL; + +COMMENT ON COLUMN public.version_types.is_auto_download IS '是否参与自动检查更新与自动下载'; + diff --git a/migrations/20250204000002_alter_versions_add_kernel_fields.sql b/migrations/20250204000002_alter_versions_add_kernel_fields.sql new file mode 100644 index 0000000..a1059ef --- /dev/null +++ b/migrations/20250204000002_alter_versions_add_kernel_fields.sql @@ -0,0 +1,16 @@ +-- Extend versions table with kernel-related metadata fields. +-- These fields are optional for普通资源,但对浏览器内核版本会被要求填充。 + +ALTER TABLE public.versions +ADD COLUMN IF NOT EXISTS arch text NULL, +ADD COLUMN IF NOT EXISTS package_format text NULL, +ADD COLUMN IF NOT EXISTS requires_extract bool DEFAULT false NOT NULL, +ADD COLUMN IF NOT EXISTS entrypoint_template text NULL, +ADD COLUMN IF NOT EXISTS extract_root text NULL; + +COMMENT ON COLUMN public.versions.arch IS '架构(如 x86_64、arm64)'; +COMMENT ON COLUMN public.versions.package_format IS '包格式(如 zip、exe)'; +COMMENT ON COLUMN public.versions.requires_extract IS '是否需要解压(zip 等资源为 true)'; +COMMENT ON COLUMN public.versions.entrypoint_template IS '入口相对路径模板(如 Simprint-Browser/chrome-{version}/chrome.exe)'; +COMMENT ON COLUMN public.versions.extract_root IS '可选的解压根目录描述'; + diff --git a/migrations/20250204000003_insert_default_version_types.sql b/migrations/20250204000003_insert_default_version_types.sql new file mode 100644 index 0000000..d999c95 --- /dev/null +++ b/migrations/20250204000003_insert_default_version_types.sql @@ -0,0 +1,8 @@ +-- 初始化版本类型默认数据 +-- 注意:使用 ON CONFLICT 以保证迁移可重复执行 + +INSERT INTO public.version_types (type_code, type_name, description, sort_order, is_active, is_auto_download) +VALUES + ('SIMPRINT_CLIENT_INSTALLER', 'Simprint 客户端安装包', 'Simprint 浏览器主程序安装包', 10, true, true), + ('SIMPRINT_KERNEL_CHROMIUM', 'Chromium 内核', 'Chromium 浏览器内核 zip 包', 30, true, false) +ON CONFLICT (type_code) DO NOTHING; \ No newline at end of file diff --git a/src/dto/extensions.rs b/src/dto/extensions.rs index eb1a54e..d4fb50a 100644 --- a/src/dto/extensions.rs +++ b/src/dto/extensions.rs @@ -39,33 +39,26 @@ impl ExtensionDto { /// 返回给客户端时需要组装为完整 URL。 /// /// 如果 URL 已经是完整的 http(s) 地址,则不进行转换。 - pub fn transform_urls( - &mut self, - resource_url: &Option, - extension_bucket: &Option, - ) { + pub fn transform_urls(&mut self, resource_url: &str, extension_bucket: &str) { // 转换图标 URL if let Some(path) = &self.icon_url { if !path.is_empty() && !path.starts_with("http") { - self.icon_url = - get_objects::get_extension_icon_url(resource_url, extension_bucket, path); + self.icon_url = Some(get_objects::get_extension_icon_url( + resource_url, + extension_bucket, + path, + )); } } // 转换下载 URL if let Some(path) = &self.download_url { if !path.is_empty() && !path.starts_with("http") { - if let (Some(resource_url), Some(extension_bucket)) = - (resource_url, extension_bucket) - { - self.download_url = Some(get_objects::get_extension_crx_url( - resource_url, - extension_bucket, - path, - )); - } else { - self.download_url = None; - } + self.download_url = Some(get_objects::get_extension_crx_url( + resource_url, + extension_bucket, + path, + )); } } } @@ -73,8 +66,8 @@ impl ExtensionDto { /// 批量转换 ExtensionDto 列表中的 object path 为完整 URL pub fn transform_urls_batch( extensions: &mut [ExtensionDto], - resource_url: &Option, - extension_bucket: &Option, + resource_url: &str, + extension_bucket: &str, ) { for ext in extensions.iter_mut() { ext.transform_urls(resource_url, extension_bucket); diff --git a/src/dto/gray_releases.rs b/src/dto/gray_releases.rs index 65035b6..1f02424 100644 --- a/src/dto/gray_releases.rs +++ b/src/dto/gray_releases.rs @@ -1,22 +1,22 @@ -use chrono::DateTime; -use serde::{Deserialize, Serialize}; -use sqlx::FromRow; - -/// 灰度发布 DTO -#[derive(Debug, Clone, FromRow, Serialize, Deserialize)] -pub struct GrayRelease { - pub id: i32, - pub name: String, - pub description: Option, - pub platform: String, - pub status: String, - pub start_time: DateTime, - pub end_time: Option>, - pub max_machines: Option, - pub allocated_count: i32, - pub priority: i32, - pub strategy_type: Option, - pub strategy_config: Option, - pub created_at: DateTime, - pub updated_at: Option>, -} +use chrono::DateTime; +use serde::{Deserialize, Serialize}; +use sqlx::FromRow; + +/// 灰度发布 +#[derive(Debug, Deserialize, Serialize, FromRow, Clone, Default)] +pub struct GrayRelease { + pub id: i32, + pub name: String, + pub description: Option, + pub platform: String, + pub status: String, + pub start_time: DateTime, + pub end_time: Option>, + pub max_machines: Option, + pub allocated_count: i32, + pub priority: i32, + pub strategy_type: Option, + pub strategy_config: Option, + pub created_at: DateTime, + pub updated_at: Option>, +} diff --git a/src/dto/gray_resources.rs b/src/dto/gray_resources.rs index 968877a..1b56409 100644 --- a/src/dto/gray_resources.rs +++ b/src/dto/gray_resources.rs @@ -1,11 +1,11 @@ -use serde::{Deserialize, Serialize}; -use sqlx::FromRow; - -/// 灰度资源 DTO -#[derive(Debug, Clone, FromRow, Serialize, Deserialize)] -pub struct GrayResource { - pub id: i32, - pub gray_release_id: i32, - pub version_id: i32, - pub sort_order: i32, -} +use serde::{Deserialize, Serialize}; +use sqlx::FromRow; + +/// 灰度资源关联 +#[derive(Debug, Deserialize, Serialize, FromRow, Clone, Default)] +pub struct GrayResource { + pub id: i32, + pub gray_release_id: i32, + pub version_id: i32, + pub sort_order: i32, +} diff --git a/src/dto/machine_gray_allocations.rs b/src/dto/machine_gray_allocations.rs index a95786e..8522bed 100644 --- a/src/dto/machine_gray_allocations.rs +++ b/src/dto/machine_gray_allocations.rs @@ -1,16 +1,16 @@ -use chrono::DateTime; -use serde::{Deserialize, Serialize}; -use sqlx::FromRow; - -/// 机器灰度分配 DTO -#[derive(Debug, Clone, FromRow, Serialize, Deserialize)] -pub struct MachineGrayAllocation { - pub id: i32, - pub machine_code: String, - pub gray_release_id: i32, - pub allocated_at: DateTime, - pub effective_time: Option>, - pub status: String, - pub notes: Option, - pub created_at: DateTime, -} +use chrono::DateTime; +use serde::{Deserialize, Serialize}; +use sqlx::FromRow; + +/// 机器灰度分配 +#[derive(Debug, Deserialize, Serialize, FromRow, Clone, Default)] +pub struct MachineGrayAllocation { + pub id: i32, + pub machine_code: String, + pub gray_release_id: i32, + pub allocated_at: DateTime, + pub effective_time: Option>, + pub status: String, + pub notes: Option, + pub created_at: DateTime, +} diff --git a/src/dto/machine_tags.rs b/src/dto/machine_tags.rs new file mode 100644 index 0000000..fe68b5b --- /dev/null +++ b/src/dto/machine_tags.rs @@ -0,0 +1,38 @@ +use chrono::DateTime; +use serde::{Deserialize, Serialize}; +use sqlx::FromRow; + +/// 机器标签 +#[derive(Debug, Deserialize, Serialize, FromRow, Clone)] +pub struct MachineTag { + pub id: i32, + pub tag_name: String, + pub description: Option, + pub category: Option, + pub is_active: bool, + pub created_at: DateTime, +} + +/// 机器用户标签关联 +#[derive(Debug, Deserialize, Serialize, FromRow, Clone)] +pub struct MachineUserTag { + pub id: i32, + pub machine_user_id: i32, + pub tag_id: i32, + pub created_at: DateTime, +} + +/// 带标签信息的机器用户(关联查询结果) +#[derive(Debug, Deserialize, Serialize, Clone)] +pub struct MachineUserWithTags { + pub id: i32, + pub machine_code: String, + pub user_uuid: Option, + pub platform: Option, + pub bind_time: DateTime, + pub status: String, + pub version_info: Option, + pub created_at: DateTime, + pub updated_at: Option>, + pub tags: Vec, +} diff --git a/src/dto/machine_users.rs b/src/dto/machine_users.rs index b5be002..52741b2 100644 --- a/src/dto/machine_users.rs +++ b/src/dto/machine_users.rs @@ -1,21 +1,21 @@ -use chrono::DateTime; -use serde::{Deserialize, Serialize}; -use sqlx::FromRow; -use uuid::Uuid; - -/// 机器用户绑定 -#[derive(Debug, Deserialize, Serialize, FromRow, Clone, Default)] -pub struct MachineUser { - pub id: i32, - pub machine_code: String, - pub user_uuid: Option, - pub platform: Option, - pub bind_time: DateTime, - pub status: String, - pub version_info: Option, // 版本信息(JSON格式) - pub hardware_hash: Option, // 硬件哈希 - pub hardware_raw: Option, // 硬件原始信息 - pub allow: bool, // 是否允许使用 - pub created_at: DateTime, - pub updated_at: Option>, -} +use chrono::DateTime; +use serde::{Deserialize, Serialize}; +use sqlx::FromRow; +use uuid::Uuid; + +/// 机器用户绑定 +#[derive(Debug, Deserialize, Serialize, FromRow, Clone, Default)] +pub struct MachineUser { + pub id: i32, + pub machine_code: String, + pub user_uuid: Option, + pub platform: Option, + pub bind_time: DateTime, + pub status: String, + pub version_info: Option, // 版本信息(JSON格式) + pub hardware_hash: Option, // 硬件哈希 + pub hardware_raw: Option, // 硬件原始信息 + pub allow: bool, // 是否允许使用 + pub created_at: DateTime, + pub updated_at: Option>, +} diff --git a/src/dto/maintenance.rs b/src/dto/maintenance.rs index 5aa8566..f66e156 100644 --- a/src/dto/maintenance.rs +++ b/src/dto/maintenance.rs @@ -1,49 +1,49 @@ -use chrono::DateTime; -use serde::{Deserialize, Serialize}; -use std::str::FromStr; - -#[derive(Debug, Clone, Serialize, Deserialize, PartialEq)] -pub enum MaintenanceType { - #[serde(rename = "scheduled")] - Scheduled, - #[serde(rename = "emergency")] - Emergency, - #[serde(rename = "upgrade")] - Upgrade, -} - -impl FromStr for MaintenanceType { - type Err = String; - - fn from_str(s: &str) -> Result { - match s { - "scheduled" => Ok(MaintenanceType::Scheduled), - "emergency" => Ok(MaintenanceType::Emergency), - "upgrade" => Ok(MaintenanceType::Upgrade), - _ => Err(format!("无效的维护类型: {}", s)), - } - } -} - -impl From for String { - fn from(maintenance_type: MaintenanceType) -> Self { - match maintenance_type { - MaintenanceType::Scheduled => "scheduled".to_string(), - MaintenanceType::Emergency => "emergency".to_string(), - MaintenanceType::Upgrade => "upgrade".to_string(), - } - } -} - -#[derive(Debug, Clone, Serialize, Deserialize, sqlx::FromRow)] -pub struct Maintenance { - pub id: i64, - pub name: String, - pub description: Option, - pub status: String, - pub start_time: DateTime, - pub end_time: DateTime, - pub maintenance_type: String, - pub created_at: DateTime, - pub updated_at: DateTime, -} +use chrono::{DateTime, Utc}; +use serde::{Deserialize, Serialize}; +use std::str::FromStr; + +#[derive(Debug, Clone, Serialize, Deserialize, PartialEq)] +pub enum MaintenanceType { + #[serde(rename = "scheduled")] + Scheduled, + #[serde(rename = "emergency")] + Emergency, + #[serde(rename = "upgrade")] + Upgrade, +} + +impl FromStr for MaintenanceType { + type Err = String; + + fn from_str(s: &str) -> Result { + match s { + "scheduled" => Ok(MaintenanceType::Scheduled), + "emergency" => Ok(MaintenanceType::Emergency), + "upgrade" => Ok(MaintenanceType::Upgrade), + _ => Err(format!("无效的维护类型: {}", s)), + } + } +} + +impl From for String { + fn from(maintenance_type: MaintenanceType) -> Self { + match maintenance_type { + MaintenanceType::Scheduled => "scheduled".to_string(), + MaintenanceType::Emergency => "emergency".to_string(), + MaintenanceType::Upgrade => "upgrade".to_string(), + } + } +} + +#[derive(Debug, Clone, Serialize, Deserialize, sqlx::FromRow)] +pub struct Maintenance { + pub id: i64, + pub name: String, + pub description: Option, + pub status: String, + pub start_time: DateTime, + pub end_time: DateTime, + pub maintenance_type: String, + pub created_at: DateTime, + pub updated_at: DateTime, +} diff --git a/src/dto/mod.rs b/src/dto/mod.rs index f428128..32a0c25 100644 --- a/src/dto/mod.rs +++ b/src/dto/mod.rs @@ -1,6 +1,7 @@ pub mod gray_releases; pub mod gray_resources; pub mod machine_gray_allocations; +pub mod machine_tags; pub mod machine_users; pub mod maintenance; pub mod strategy_types; @@ -30,6 +31,7 @@ pub mod workspaces; pub use gray_releases::*; pub use gray_resources::*; pub use machine_gray_allocations::*; +pub use machine_tags::*; pub use machine_users::*; pub use maintenance::*; pub use strategy_types::*; diff --git a/src/dto/strategy_types.rs b/src/dto/strategy_types.rs index 321a4c3..04494bb 100644 --- a/src/dto/strategy_types.rs +++ b/src/dto/strategy_types.rs @@ -1,15 +1,92 @@ -use serde::{Deserialize, Serialize}; -use sqlx::FromRow; - -/// 策略类型 -#[derive(Debug, Deserialize, Serialize, FromRow, Clone)] -pub struct StrategyType { - pub id: i32, - pub code: String, - pub name: String, - pub category: Option, - pub description: Option, - pub processor_type: String, - pub config_schema: Option, - pub is_active: bool, -} +use serde::{Deserialize, Serialize}; +use sqlx::FromRow; + +pub mod processor_config { + use super::*; + + /// 白名单过滤配置 + #[derive(Debug, Deserialize, Serialize, Clone, PartialEq)] + pub struct FilterWhitelistConfig { + pub machines: Vec, // 机器码列表 + } + + /// 百分比过滤配置 + #[derive(Debug, Deserialize, Serialize, Clone, PartialEq)] + pub struct FilterPercentageConfig { + pub percent: f64, // 百分比 (0-100) + } + + /// 随机过滤配置 + #[derive(Debug, Deserialize, Serialize, Clone, PartialEq)] + pub struct FilterRandomConfig { + pub count: usize, // 随机选择的数量 + } + + /// 用户标签过滤配置 + #[derive(Debug, Deserialize, Serialize, Clone, PartialEq)] + pub struct FilterUserTagConfig { + pub tags: Vec, // 标签列表 + pub match_all: bool, // true: 必须包含所有标签, false: 包含任意一个标签 + } + + /// 时间段转换配置 + #[derive(Debug, Deserialize, Serialize, Clone, PartialEq)] + pub struct TransformTimeRangeConfig { + pub start_hour: u8, // 开始小时 (0-23) + pub end_hour: u8, // 结束小时 (0-23) + pub days: Vec, // 生效星期 (0-6, 0=周日) + } + + /// 延迟分配配置 + #[derive(Debug, Deserialize, Serialize, Clone, PartialEq)] + pub struct TransformDelayConfig { + pub delay_seconds: u64, // 延迟秒数 + } + + /// 分步分配配置 + #[derive(Debug, Deserialize, Serialize, Clone, PartialEq)] + pub struct TransformStepConfig { + pub step_percentage: f64, // 每一步的百分比 + pub step_interval_hours: u32, // 每步间隔(小时) + } +} + +/// 处理器类型枚举 +#[derive(Debug, Clone, PartialEq)] +pub enum ProcessorType { + FilterWhitelist(processor_config::FilterWhitelistConfig), + FilterPercentage(processor_config::FilterPercentageConfig), + FilterRandom(processor_config::FilterRandomConfig), + FilterUserTag(processor_config::FilterUserTagConfig), + TransformTimeRange(processor_config::TransformTimeRangeConfig), + TransformDelay(processor_config::TransformDelayConfig), + TransformStep(processor_config::TransformStepConfig), +} + +impl ProcessorType { + /// 获取处理器类型名称 + pub fn type_name(&self) -> &'static str { + match self { + ProcessorType::FilterWhitelist(_) => "filter_whitelist", + ProcessorType::FilterPercentage(_) => "filter_percentage", + ProcessorType::FilterRandom(_) => "filter_random", + ProcessorType::FilterUserTag(_) => "filter_user_tag", + ProcessorType::TransformTimeRange(_) => "transform_time_range", + ProcessorType::TransformDelay(_) => "transform_delay", + ProcessorType::TransformStep(_) => "transform_step", + } + } +} + +/// 策略类型 +#[derive(Debug, Deserialize, Serialize, FromRow, Clone)] +pub struct StrategyType { + pub id: i32, + pub code: String, + pub name: String, + pub category: Option, + pub description: Option, + pub processor_type: String, + pub config_schema: Option, + pub is_active: bool, +} diff --git a/src/dto/version_types.rs b/src/dto/version_types.rs index 238d595..765e086 100644 --- a/src/dto/version_types.rs +++ b/src/dto/version_types.rs @@ -1,13 +1,14 @@ -use serde::{Deserialize, Serialize}; -use sqlx::FromRow; - -/// 版本类型 DTO -#[derive(Debug, Clone, FromRow, Serialize, Deserialize)] -pub struct VersionType { - pub id: i32, - pub type_code: String, - pub type_name: String, - pub description: Option, - pub sort_order: Option, - pub is_active: bool, -} +use serde::{Deserialize, Serialize}; +use sqlx::FromRow; + +/// 版本类型 +#[derive(Debug, Deserialize, Serialize, FromRow, Clone, Default)] +pub struct VersionType { + pub id: i32, + pub type_code: String, + pub type_name: String, + pub description: Option, + pub sort_order: i32, + pub is_active: bool, + pub is_auto_download: bool, +} diff --git a/src/dto/versions.rs b/src/dto/versions.rs index b986294..87d35cd 100644 --- a/src/dto/versions.rs +++ b/src/dto/versions.rs @@ -1,26 +1,31 @@ -use chrono::DateTime; -use serde::{Deserialize, Serialize}; -use sqlx::FromRow; - -/// 版本 DTO -#[derive(Debug, Clone, FromRow, Serialize, Deserialize)] -pub struct Version { - pub id: i32, - pub type_id: i32, - pub resource_name: String, - pub version: String, - pub name: Option, - pub notes: Option, - pub platform: Option, - pub url: Option, - pub hash: Option, - pub signature: Option, - pub install_path: Option, - pub file_size: Option, - pub is_latest: bool, - pub status: String, - pub pub_date: Option>, - pub created_at: DateTime, - pub updated_at: Option>, - pub deleted_at: Option>, -} +use chrono::DateTime; +use serde::{Deserialize, Serialize}; +use sqlx::FromRow; + +/// 统一版本表 +#[derive(Debug, Deserialize, Serialize, FromRow, Clone, Default)] +pub struct Version { + pub id: i32, + pub type_id: i32, + pub resource_name: String, + pub version: String, + pub name: Option, + pub notes: Option, + pub platform: Option, + pub url: Option, + pub hash: Option, + pub signature: Option, + pub install_path: Option, + pub file_size: Option, + pub is_latest: bool, + pub status: String, + pub pub_date: Option>, + pub created_at: DateTime, + pub updated_at: Option>, + pub deleted_at: Option>, + pub arch: Option, + pub package_format: Option, + pub requires_extract: bool, + pub entrypoint_template: Option, + pub extract_root: Option, +} diff --git a/src/entitys/gray_releases.rs b/src/entitys/gray_releases.rs index 3695ca3..8a224cd 100644 --- a/src/entitys/gray_releases.rs +++ b/src/entitys/gray_releases.rs @@ -1,59 +1,72 @@ -use chrono::DateTime; -use serde::{Deserialize, Serialize}; - -/// 创建灰度发布请求 -#[derive(Debug, Deserialize, Serialize)] -pub struct CreateGrayReleaseRequest { - pub name: String, - pub description: Option, - pub platform: String, - pub start_time: DateTime, - pub end_time: Option>, - pub max_machines: Option, - pub priority: Option, - pub strategy_type: Option, - pub strategy_config: Option, -} - -/// 更新灰度发布请求 -#[derive(Debug, Deserialize, Serialize)] -pub struct UpdateGrayReleaseRequest { - pub id: i32, - pub name: Option, - pub description: Option, - pub status: Option, - pub end_time: Option>, - pub max_machines: Option, - pub priority: Option, - pub strategy_type: Option, - pub strategy_config: Option, -} - -/// 灰度发布列表响应 -#[derive(Debug, Deserialize, Serialize)] -pub struct GrayReleaseListResponse { - pub total: i64, - pub list: Vec, -} - -/// 查询灰度发布参数 -#[derive(Debug, Deserialize, Serialize)] -pub struct QueryGrayReleaseParams { - pub platform: Option, - pub status: Option, -} - -/// 获取灰度发布参数 -#[derive(Debug, Deserialize, Serialize)] -pub struct GetGrayReleaseParams { - pub id: i32, -} - -/// 查询灰度发布列表参数(带分页) -#[derive(Debug, Deserialize, Serialize)] -pub struct QueryGrayReleasesParams { - pub platform: Option, - pub status: Option, - pub page_num: Option, - pub page_size: Option, -} +use chrono::DateTime; +use serde::{Deserialize, Serialize}; + +/// 创建灰度发布请求 +#[derive(Debug, Deserialize, Serialize)] +pub struct CreateGrayReleaseRequest { + pub name: String, + pub description: Option, + pub platform: String, + pub start_time: DateTime, + pub end_time: Option>, + pub max_machines: Option, + pub priority: Option, + pub strategy_type: Option, + pub strategy_config: Option, + pub version_ids: Vec, + pub auto_start: bool, +} + +/// 启动灰度发布请求 +#[derive(Debug, Deserialize, Serialize)] +pub struct StartGrayReleaseRequest { + pub gray_release_id: i32, +} + +/// 停止灰度发布请求 +#[derive(Debug, Deserialize, Serialize)] +pub struct StopGrayReleaseRequest { + pub gray_release_id: i32, +} + +/// 获取灰度发布详情请求 +#[derive(Debug, Deserialize, Serialize)] +pub struct GetGrayReleaseRequest { + pub gray_release_id: i32, +} + +/// 查询灰度发布列表请求 +#[derive(Debug, Deserialize, Serialize)] +pub struct ListGrayReleasesRequest { + pub platform: Option, + pub status: Option, + pub page: Option, + pub page_size: Option, +} + +/// 灰度发布列表响应 +#[derive(Debug, Deserialize, Serialize)] +pub struct GrayReleaseListResponse { + pub total: i64, + pub list: Vec, +} + +/// 更新灰度发布请求 +#[derive(Debug, Deserialize, Serialize)] +pub struct UpdateGrayReleaseRequest { + pub gray_release_id: i32, + pub name: Option, + pub description: Option, + pub status: Option, + pub end_time: Option>, + pub max_machines: Option, + pub priority: Option, + pub strategy_type: Option, + pub strategy_config: Option, +} + +/// 删除灰度发布请求 +#[derive(Debug, Deserialize, Serialize)] +pub struct DeleteGrayReleaseRequest { + pub gray_release_id: i32, +} diff --git a/src/entitys/gray_resources.rs b/src/entitys/gray_resources.rs index 212cf5e..941abac 100644 --- a/src/entitys/gray_resources.rs +++ b/src/entitys/gray_resources.rs @@ -1,34 +1,35 @@ -use serde::{Deserialize, Serialize}; - -/// 创建灰度资源请求 -#[derive(Debug, Deserialize, Serialize)] -pub struct CreateGrayResourceRequest { - pub gray_release_id: i32, - pub version_id: i32, - pub sort_order: Option, -} - -/// 批量创建灰度资源请求 -#[derive(Debug, Deserialize, Serialize)] -pub struct BatchCreateGrayResourceRequest { - pub gray_release_id: i32, - pub version_ids: Vec, -} - -/// 灰度资源列表响应 -#[derive(Debug, Deserialize, Serialize)] -pub struct GrayResourceListResponse { - pub list: Vec, -} - -/// 获取灰度资源参数 -#[derive(Debug, Deserialize, Serialize)] -pub struct GetGrayResourceParams { - pub gray_release_id: i32, -} - -/// 移除灰度资源参数 -#[derive(Debug, Deserialize, Serialize)] -pub struct RemoveGrayResourceParams { - pub id: i32, -} +use serde::{Deserialize, Serialize}; + +/// 创建灰度资源请求 +#[derive(Debug, Deserialize, Serialize)] +pub struct CreateGrayResourceRequest { + pub gray_release_id: i32, + pub version_id: i32, + pub sort_order: Option, +} + +/// 批量创建灰度资源请求 +#[derive(Debug, Deserialize, Serialize)] +pub struct BatchCreateGrayResourceRequest { + pub gray_release_id: i32, + pub version_ids: Vec, +} + +/// 移除灰度资源请求 +#[derive(Debug, Deserialize, Serialize)] +pub struct RemoveGrayResourceRequest { + pub id: i32, +} + +/// 更新资源排序请求 +#[derive(Debug, Deserialize, Serialize)] +pub struct UpdateResourceSortOrderRequest { + pub id: i32, + pub sort_order: i32, +} + +/// 查询灰度资源列表请求 +#[derive(Debug, Deserialize, Serialize)] +pub struct ListGrayResourcesRequest { + pub gray_release_id: i32, +} diff --git a/src/entitys/machine_gray_allocations.rs b/src/entitys/machine_gray_allocations.rs index 7c1f5d2..fcd50ab 100644 --- a/src/entitys/machine_gray_allocations.rs +++ b/src/entitys/machine_gray_allocations.rs @@ -1,64 +1,56 @@ -use chrono::DateTime; -use serde::{Deserialize, Serialize}; - -/// 分配机器到灰度请求 -#[derive(Debug, Deserialize, Serialize)] -pub struct AllocateMachineRequest { - pub machine_code: String, - pub gray_release_id: i32, - pub effective_time: Option>, - pub notes: Option, -} - -/// 批量分配机器请求 -#[derive(Debug, Deserialize, Serialize)] -pub struct BatchAllocateMachineRequest { - pub machine_codes: Vec, - pub gray_release_id: i32, - pub effective_time: Option>, - pub notes: Option, -} - -/// 分配列表响应 -#[derive(Debug, Deserialize, Serialize)] -pub struct AllocationListResponse { - pub total: i64, - pub list: Vec, -} - -/// 移除分配请求 -#[derive(Debug, Deserialize, Serialize)] -pub struct RemoveAllocationRequest { - pub machine_code: String, - pub gray_release_id: i32, -} - -/// 查询分配参数 -#[derive(Debug, Deserialize, Serialize)] -pub struct GetAllocationParams { - pub machine_code: Option, - pub gray_release_id: Option, - pub page_num: Option, - pub page_size: Option, -} - -/// 查询机器是否在灰度中请求 -#[derive(Debug, Deserialize, Serialize)] -pub struct IsMachineInGrayRequest { - pub machine_code: String, - pub gray_release_id: i32, -} - -/// 查询机器所有分配请求 -#[derive(Debug, Deserialize, Serialize)] -pub struct GetMachineAllocationsRequest { - pub machine_code: String, -} - -/// 查询灰度发布所有分配请求 -#[derive(Debug, Deserialize, Serialize)] -pub struct GetReleaseAllocationsRequest { - pub gray_release_id: i32, - pub page_num: Option, - pub page_size: Option, -} +use chrono::DateTime; +use serde::{Deserialize, Serialize}; + +/// 分配机器到灰度请求 +#[derive(Debug, Deserialize, Serialize)] +pub struct AllocateMachineRequest { + pub machine_code: String, + pub gray_release_id: i32, + pub effective_time: Option>, + pub notes: Option, +} + +/// 批量分配机器请求 +#[derive(Debug, Deserialize, Serialize)] +pub struct BatchAllocateMachineRequest { + pub machine_codes: Vec, + pub gray_release_id: i32, + pub effective_time: Option>, + pub notes: Option, +} + +/// 分配列表响应 +#[derive(Debug, Deserialize, Serialize)] +pub struct AllocationListResponse { + pub total: i64, + pub list: Vec, +} + +/// 取消机器分配请求 +#[derive(Debug, Deserialize, Serialize)] +pub struct DeallocateMachineRequest { + pub machine_code: String, + pub gray_release_id: i32, +} + +/// 查询灰度分配列表请求 +#[derive(Debug, Deserialize, Serialize)] +pub struct ListAllocationsByReleaseRequest { + pub gray_release_id: i32, + pub page_num: Option, + pub page_size: Option, +} + +/// 检查机器是否在灰度中请求 +#[derive(Debug, Deserialize, Serialize)] +pub struct CheckMachineInGrayRequest { + pub machine_code: String, +} + +/// 检查机器是否在灰度中响应 +#[derive(Debug, Deserialize, Serialize)] +pub struct CheckMachineInGrayResponse { + pub in_gray: bool, + pub gray_release_id: Option, + pub gray_release: Option, +} diff --git a/src/entitys/machine_tags.rs b/src/entitys/machine_tags.rs new file mode 100644 index 0000000..1a5344b --- /dev/null +++ b/src/entitys/machine_tags.rs @@ -0,0 +1,37 @@ +use serde::{Deserialize, Serialize}; + +/// 创建标签请求 +#[derive(Debug, Deserialize, Serialize)] +pub struct CreateTagRequest { + pub tag_name: String, + pub description: Option, + pub category: Option, +} + +/// 标签查询参数 +#[derive(Debug, Deserialize)] +pub struct QueryTagsParams { + pub category: Option, + pub is_active: Option, +} + +/// 标签列表响应 +#[derive(Debug, Deserialize, Serialize)] +pub struct TagListResponse { + pub total: i64, + pub list: Vec, +} + +/// 绑定标签到机器用户的请求 +#[derive(Debug, Deserialize, Serialize)] +pub struct BindTagsToMachineUserRequest { + pub machine_user_id: i32, + pub tag_names: Vec, +} + +/// 解绑标签的请求 +#[derive(Debug, Deserialize, Serialize)] +pub struct UnbindTagsFromMachineUserRequest { + pub machine_user_id: i32, + pub tag_ids: Vec, +} diff --git a/src/entitys/machine_users.rs b/src/entitys/machine_users.rs index 1b031ea..b1036cd 100644 --- a/src/entitys/machine_users.rs +++ b/src/entitys/machine_users.rs @@ -1,97 +1,107 @@ -use serde::{Deserialize, Serialize}; - -use uuid::Uuid; - -/// 创建/绑定机器用户请求 -#[derive(Debug, Deserialize, Serialize)] -pub struct CreateMachineUserRequest { - pub machine_code: String, - #[serde(skip_serializing_if = "Option::is_none")] - pub user_uuid: Option, - pub platform: Option, - pub hardware_hash: Option, // 硬件哈希 - pub hardware_raw: Option, // 硬件原始信息 - pub version_info: Option, // JSON格式的版本信息 -} - -/// 更新机器用户请求 -#[derive(Debug, Deserialize, Serialize)] -pub struct UpdateMachineUserRequest { - pub id: i32, - #[serde(skip_serializing_if = "Option::is_none")] - pub user_uuid: Option, - pub platform: Option, - pub status: Option, - #[serde(skip_serializing_if = "Option::is_none")] - pub hardware_hash: Option, // 硬件哈希 - #[serde(skip_serializing_if = "Option::is_none")] - pub hardware_raw: Option, // 硬件原始信息 -} - -/// 机器用户列表响应 -#[derive(Debug, Deserialize, Serialize)] -pub struct MachineUserListResponse { - pub total: i64, - pub list: Vec, -} - -/// 查询机器用户参数 -#[derive(Debug, Deserialize, Serialize)] -pub struct QueryMachineUserParams { - pub user_uuid: Option, // UUID字符串格式 - pub platform: Option, - pub status: Option, -} - -/// 绑定机器请求 -#[derive(Debug, Deserialize, Serialize)] -pub struct BindMachineRequest { - pub machine_code: String, - pub user_uuid: Uuid, -} - -/// 更新机器版本信息请求 -#[derive(Debug, Deserialize, Serialize)] -pub struct UpdateMachineVersionRequest { - pub machine_code: String, - pub user_uuid: Uuid, - pub version_info: serde_json::Value, -} - -/// 拉黑/取消拉黑机器请求 -#[derive(Debug, Deserialize, Serialize)] -pub struct AllowOrBlacklistMachineRequest { - pub machine_code: String, - pub allow: bool, -} - -/// 获取机器参数 -#[derive(Debug, Deserialize, Serialize)] -pub struct GetMachineParams { - pub id: Option, - pub machine_code: Option, -} - -/// 查询机器列表参数(带分页) -#[derive(Debug, Deserialize, Serialize)] -pub struct QueryMachinesParams { - pub user_uuid: Option, - pub platform: Option, - pub status: Option, - pub page_num: Option, - pub page_size: Option, -} - -/// 绑定机器参数(用于查询字符串) -#[derive(Debug, Deserialize, Serialize)] -pub struct BindMachineParams { - pub machine_code: String, - pub user_uuid: String, -} - -/// 更新机器版本参数(用于查询字符串) -#[derive(Debug, Deserialize, Serialize)] -pub struct UpdateMachineVersionParams { - pub machine_code: String, - pub user_uuid: String, -} +use serde::{Deserialize, Serialize}; + +use uuid::Uuid; + +/// 创建/绑定机器用户请求 +#[derive(Debug, Deserialize, Serialize)] +pub struct CreateMachineUserRequest { + pub machine_code: String, + #[serde(skip_serializing_if = "Option::is_none")] + pub user_uuid: Option, + pub platform: Option, + /// 标签列表(可选,注册成功后会绑定到机器) + #[serde(skip_serializing_if = "Option::is_none")] + pub tags: Option>, + pub hardware_hash: Option, // 硬件哈希 + pub hardware_raw: Option, // 硬件原始信息 + pub version_info: Option, // JSON格式的版本信息 +} + +/// 更新机器用户请求 +#[derive(Debug, Deserialize, Serialize)] +pub struct UpdateMachineUserRequest { + #[serde(skip_serializing_if = "Option::is_none")] + pub user_uuid: Option, + pub platform: Option, + pub status: Option, + #[serde(skip_serializing_if = "Option::is_none")] + pub hardware_hash: Option, // 硬件哈希 + #[serde(skip_serializing_if = "Option::is_none")] + pub hardware_raw: Option, // 硬件原始信息 +} + +/// 机器用户列表响应 +#[derive(Debug, Deserialize, Serialize)] +pub struct MachineUserListResponse { + pub total: i64, + pub list: Vec, +} + +/// 查询机器用户参数 +#[derive(Debug, Deserialize, Serialize)] +pub struct QueryMachineUserParams { + pub user_uuid: Option, // UUID字符串格式 + pub platform: Option, + pub status: Option, +} + +/// 获取机器信息请求(id 或 machine_code 二选一) +#[derive(Debug, Deserialize, Serialize)] +pub struct GetMachineRequest { + #[serde(skip_serializing_if = "Option::is_none")] + pub id: Option, + #[serde(skip_serializing_if = "Option::is_none")] + pub machine_code: Option, +} + +/// 查询机器列表请求 +#[derive(Debug, Deserialize, Serialize)] +pub struct QueryMachinesRequest { + pub params: QueryMachineUserParams, + #[serde(skip_serializing_if = "Option::is_none")] + pub page_num: Option, + #[serde(skip_serializing_if = "Option::is_none")] + pub page_size: Option, +} + +/// 更新机器信息请求 +#[derive(Debug, Deserialize, Serialize)] +pub struct UpdateMachineRequest { + pub id: i32, + pub request: UpdateMachineUserRequest, +} + +/// 绑定用户到机器请求 +#[derive(Debug, Deserialize, Serialize)] +pub struct BindUserRequest { + pub machine_code: String, + pub user_uuid: Uuid, +} + +/// 解绑用户请求 +#[derive(Debug, Deserialize, Serialize)] +pub struct UnbindUserRequest { + pub machine_code: String, + pub user_uuid: Uuid, +} + +/// 获取用户机器列表请求 +#[derive(Debug, Deserialize, Serialize)] +pub struct GetUserMachinesRequest { + pub user_uuid: Uuid, +} + +/// 更新机器版本信息请求 +#[derive(Debug, Deserialize, Serialize)] +pub struct UpdateMachineVersionRequest { + pub machine_code: String, + pub user_uuid: Uuid, + pub version_info: serde_json::Value, +} + +/// 拉黑/恢复机器请求 +#[derive(Debug, Deserialize, Serialize)] +pub struct AllowOrBlacklistMachineRequest { + pub machine_code: String, + pub allow: bool, +} diff --git a/src/entitys/maintenance.rs b/src/entitys/maintenance.rs index 09761d5..39106d0 100644 --- a/src/entitys/maintenance.rs +++ b/src/entitys/maintenance.rs @@ -1,48 +1,42 @@ -use chrono::{DateTime, Utc}; -use serde::{Deserialize, Serialize}; - -use crate::dto::maintenance::MaintenanceType; - -/// 创建维护请求 -#[derive(Debug, Deserialize, Serialize)] -pub struct CreateMaintenanceRequest { - pub name: String, - pub description: Option, - pub status: String, - pub start_time: DateTime, - pub end_time: DateTime, - pub maintenance_type: MaintenanceType, -} - -/// 获取维护参数 -#[derive(Debug, Deserialize, Serialize)] -pub struct GetMaintenanceParams { - pub id: i64, -} - -/// 查询维护列表参数 -#[derive(Debug, Deserialize, Serialize)] -pub struct QueryMaintenancesParams { - pub limit: Option, - pub offset: Option, -} - -/// 更新维护状态请求 -#[derive(Debug, Deserialize, Serialize)] -pub struct UpdateMaintenanceStatusRequest { - pub id: i64, - pub status: String, -} - -/// 维护列表响应 -#[derive(Debug, Deserialize, Serialize)] -pub struct MaintenanceListResponse { - pub list: Vec, -} - -/// 健康检查响应 -#[derive(Debug, Deserialize, Serialize)] -pub struct HealthResponse { - pub status: String, - pub timestamp: String, -} +use chrono::{DateTime, Utc}; +use serde::{Deserialize, Serialize}; + +use crate::dto::maintenance::MaintenanceType; + +#[derive(Debug, Clone, Deserialize, Serialize)] +pub struct CreateMaintenanceRequest { + pub name: String, + pub description: Option, + pub status: String, + pub start_time: DateTime, + pub end_time: DateTime, + pub maintenance_type: MaintenanceType, +} + +/// 获取维护详情请求 +#[derive(Debug, Deserialize, Serialize)] +pub struct GetMaintenanceRequest { + pub id: i64, +} + +/// 查询维护列表请求 +#[derive(Debug, Deserialize, Serialize)] +pub struct ListMaintenancesRequest { + pub limit: Option, + pub offset: Option, +} + +/// 更新维护状态请求 +#[derive(Debug, Deserialize, Serialize)] +pub struct UpdateMaintenanceStatusRequest { + pub id: i64, + pub status: String, +} + +/// 结束维护请求(可为空 body) +#[derive(Debug, Default, Deserialize, Serialize)] +pub struct EndMaintenanceRequest {} + +/// 获取当前活跃维护请求(可为空 body) +#[derive(Debug, Default, Deserialize, Serialize)] +pub struct GetActiveMaintenanceRequest {} diff --git a/src/entitys/mod.rs b/src/entitys/mod.rs index 7487994..17a2477 100644 --- a/src/entitys/mod.rs +++ b/src/entitys/mod.rs @@ -1,67 +1,69 @@ -pub mod gray_releases; -pub mod gray_resources; -pub mod machine_gray_allocations; -pub mod machine_users; -pub mod maintenance; -pub mod strategy_types; -pub mod user; -pub mod version_types; -pub mod versions; - -// 新增模块 -pub mod accounts; -pub mod api_service; -pub mod audit; -pub mod billing; -pub mod common; -pub mod console_admin; -pub mod console_apikey; -pub mod environments; -pub mod extensions; -pub mod group_member_permissions; -pub mod groups; -pub mod messages; -pub mod proxies; -pub mod proxy_visible_teams; -pub mod referral; -pub mod rpa; -pub mod settings; -pub mod tags; -pub mod teams; -pub mod templates; -pub mod workspace_quotas; -pub mod workspaces; - -pub use gray_releases::*; -pub use gray_resources::*; -pub use machine_gray_allocations::*; -pub use machine_users::*; -pub use maintenance::*; -pub use strategy_types::*; -pub use user::*; -pub use version_types::*; -pub use versions::*; - -// 新增导出 -pub use accounts::*; -pub use api_service::*; -pub use audit::*; -pub use billing::*; -pub use common::*; -pub use console_admin::*; -pub use console_apikey::*; -pub use environments::*; -pub use extensions::*; -pub use group_member_permissions::*; -pub use groups::*; -pub use messages::*; -pub use proxies::*; -pub use proxy_visible_teams::*; -pub use referral::*; -pub use rpa::*; -pub use settings::*; -pub use tags::*; -pub use teams::*; -pub use templates::*; -pub use workspace_quotas::*; -pub use workspaces::*; +pub mod gray_releases; +pub mod gray_resources; +pub mod machine_gray_allocations; +pub mod machine_tags; +pub mod machine_users; +pub mod maintenance; +pub mod strategy_types; +pub mod user; +pub mod version_types; +pub mod versions; + +// 新增模块 +pub mod accounts; +pub mod api_service; +pub mod audit; +pub mod billing; +pub mod common; +pub mod console_admin; +pub mod console_apikey; +pub mod environments; +pub mod extensions; +pub mod group_member_permissions; +pub mod groups; +pub mod messages; +pub mod proxies; +pub mod proxy_visible_teams; +pub mod referral; +pub mod rpa; +pub mod settings; +pub mod tags; +pub mod teams; +pub mod templates; +pub mod workspace_quotas; +pub mod workspaces; + +pub use gray_releases::*; +pub use gray_resources::*; +pub use machine_gray_allocations::*; +pub use machine_tags::*; +pub use machine_users::*; +pub use maintenance::*; +pub use strategy_types::*; +pub use user::*; +pub use version_types::*; +pub use versions::*; + +// 新增导出 +pub use accounts::*; +pub use api_service::*; +pub use audit::*; +pub use billing::*; +pub use common::*; +pub use console_admin::*; +pub use console_apikey::*; +pub use environments::*; +pub use extensions::*; +pub use group_member_permissions::*; +pub use groups::*; +pub use messages::*; +pub use proxies::*; +pub use proxy_visible_teams::*; +pub use referral::*; +pub use rpa::*; +pub use settings::*; +pub use tags::*; +pub use teams::*; +pub use templates::*; +pub use workspace_quotas::*; +pub use workspaces::*; diff --git a/src/entitys/strategy_types.rs b/src/entitys/strategy_types.rs index 77be9f0..2ce5716 100644 --- a/src/entitys/strategy_types.rs +++ b/src/entitys/strategy_types.rs @@ -1,20 +1,32 @@ -use serde::{Deserialize, Serialize}; - -/// 获取策略类型参数 -#[derive(Debug, Deserialize, Serialize)] -pub struct GetStrategyTypeParams { - pub id: Option, - pub code: Option, -} - -/// 查询策略类型列表参数 -#[derive(Debug, Deserialize, Serialize)] -pub struct QueryStrategyTypesParams { - pub category: Option, -} - -/// 策略类型列表响应 -#[derive(Debug, Deserialize, Serialize)] -pub struct StrategyTypeListResponse { - pub list: Vec, -} +use serde::{Deserialize, Serialize}; + +/// 根据ID获取策略类型请求 +#[derive(Debug, Deserialize, Serialize)] +pub struct GetStrategyTypeByIdRequest { + pub id: i32, +} + +/// 根据代码获取策略类型请求 +#[derive(Debug, Deserialize, Serialize)] +pub struct GetStrategyTypeByCodeRequest { + pub code: String, +} + +/// 根据分类查询策略类型请求 +#[derive(Debug, Deserialize, Serialize)] +pub struct ListStrategyTypesByCategoryRequest { + pub category: String, +} + +/// 策略类型列表响应 +#[derive(Debug, Deserialize, Serialize)] +pub struct StrategyTypeListResponse { + pub list: Vec, +} + +/// 策略类型详细信息响应(包含配置示例) +#[derive(Debug, Deserialize, Serialize)] +pub struct StrategyTypeDetailResponse { + pub strategy_type: crate::dto::strategy_types::StrategyType, + pub config_example: String, // JSON 配置示例 +} diff --git a/src/entitys/version_types.rs b/src/entitys/version_types.rs index 3e163e4..3f0c501 100644 --- a/src/entitys/version_types.rs +++ b/src/entitys/version_types.rs @@ -1,38 +1,69 @@ -use serde::{Deserialize, Serialize}; - -/// 创建版本类型请求 -#[derive(Debug, Deserialize, Serialize)] -pub struct CreateVersionTypeRequest { - pub type_code: String, - pub type_name: String, - pub description: Option, - pub sort_order: Option, -} - -/// 更新版本类型请求 -#[derive(Debug, Deserialize, Serialize)] -pub struct UpdateVersionTypeRequest { - pub id: i32, - pub type_name: Option, - pub description: Option, - pub sort_order: Option, - pub is_active: Option, -} - -/// 版本类型列表响应 -#[derive(Debug, Deserialize, Serialize)] -pub struct VersionTypeListResponse { - pub list: Vec, -} - -/// 获取版本类型参数 -#[derive(Debug, Deserialize, Serialize)] -pub struct GetVersionTypeParams { - pub id: i32, -} - -/// 删除版本类型请求 -#[derive(Debug, Deserialize, Serialize)] -pub struct DeleteVersionTypeRequest { - pub id: i32, -} +use serde::{Deserialize, Serialize}; + +/// 创建版本类型请求 +#[derive(Debug, Deserialize, Serialize)] +pub struct CreateVersionTypeRequest { + pub type_code: String, + pub type_name: String, + pub description: Option, + pub sort_order: Option, +} + +/// 更新版本类型请求 +#[derive(Debug, Deserialize, Serialize)] +pub struct UpdateVersionTypeRequest { + pub type_name: Option, + pub description: Option, + pub sort_order: Option, + pub is_active: Option, + pub is_auto_download: Option, +} + +/// 根据ID获取版本类型请求 +#[derive(Debug, Deserialize, Serialize)] +pub struct GetVersionTypeByIdRequest { + pub id: i32, +} + +/// 根据代码获取版本类型请求 +#[derive(Debug, Deserialize, Serialize)] +pub struct GetVersionTypeByCodeRequest { + pub type_code: String, +} + +/// 查询所有版本类型请求(可为空 body) +#[derive(Debug, Default, Deserialize, Serialize)] +pub struct ListAllVersionTypesRequest {} + +/// 查询激活的版本类型请求(可为空 body) +#[derive(Debug, Default, Deserialize, Serialize)] +pub struct ListActiveVersionTypesRequest {} + +/// 更新版本类型请求(含 id) +#[derive(Debug, Deserialize, Serialize)] +pub struct UpdateVersionTypeHandleRequest { + pub id: i32, + pub type_name: Option, + pub description: Option, + pub sort_order: Option, + pub is_active: Option, +} + +/// 删除版本类型请求 +#[derive(Debug, Deserialize, Serialize)] +pub struct DeleteVersionTypeRequest { + pub id: i32, +} + +/// 切换版本类型状态请求 +#[derive(Debug, Deserialize, Serialize)] +pub struct ToggleVersionTypeStatusRequest { + pub id: i32, + pub is_active: bool, +} + +/// 版本类型列表响应 +#[derive(Debug, Deserialize, Serialize)] +pub struct VersionTypeListResponse { + pub list: Vec, +} diff --git a/src/entitys/versions.rs b/src/entitys/versions.rs index 24241ad..434ffb8 100644 --- a/src/entitys/versions.rs +++ b/src/entitys/versions.rs @@ -1,97 +1,132 @@ -use chrono::DateTime; -use serde::{Deserialize, Serialize}; -use std::collections::HashMap; - -/// 创建版本请求 -#[derive(Debug, Deserialize, Serialize)] -pub struct CreateVersionRequest { - pub type_id: i32, - pub resource_name: String, - pub version: String, - pub name: Option, - pub notes: Option, - pub platform: Option, - pub url: Option, - pub hash: Option, - pub signature: Option, - pub install_path: Option, - pub file_size: Option, - pub pub_date: Option>, -} - -/// 更新版本请求 -#[derive(Debug, Deserialize, Serialize)] -pub struct UpdateVersionRequest { - pub id: i32, - pub name: Option, - pub notes: Option, - pub platform: Option, - pub url: Option, - pub hash: Option, - pub signature: Option, - pub install_path: Option, - pub file_size: Option, - pub status: Option, - pub is_latest: Option, -} - -/// 版本列表响应 -#[derive(Debug, Deserialize, Serialize)] -pub struct VersionListResponse { - pub total: i64, - pub list: Vec, -} - -/// 查询版本参数 -#[derive(Debug, Deserialize, Serialize)] -pub struct QueryVersionParams { - pub resource_name: Option, - pub platform: Option, - pub status: Option, -} - -/// 获取版本参数(用于查询字符串) -#[derive(Debug, Deserialize, Serialize)] -pub struct GetVersionParams { - pub id: Option, - pub resource_name: Option, - pub version: Option, - pub platform: Option, -} - -/// 查询版本列表参数(带分页) -#[derive(Debug, Deserialize, Serialize)] -pub struct QueryVersionsParams { - pub resource_name: Option, - pub platform: Option, - pub status: Option, - pub page_num: Option, - pub page_size: Option, -} - -/// 设置最新版本请求 -#[derive(Debug, Deserialize, Serialize)] -pub struct SetLatestVersionRequest { - pub type_id: i32, - pub resource_name: String, - pub version_id: i32, -} - -/// 删除版本请求 -#[derive(Debug, Deserialize, Serialize)] -pub struct DeleteVersionRequest { - pub id: i32, -} - -/// 版本检查请求参数 -#[derive(Debug, Deserialize, Serialize)] -pub struct CheckVersionRequestParam { - pub machine_code: Option, // 机器码可选,用于灰度判定 -} - -/// 版本检查响应 -#[derive(Debug, Deserialize, Serialize, Default)] -pub struct CheckVersionResponse { - pub is_gray: bool, - pub versions: HashMap>, // key: type_code, value: 版本列表 -} +use chrono::DateTime; +use serde::{Deserialize, Serialize}; + +/// 创建版本请求 +#[derive(Debug, Default, Deserialize, Serialize)] +pub struct CreateVersionRequest { + pub type_id: i32, + pub resource_name: String, + pub version: String, + pub name: Option, + pub notes: Option, + pub platform: Option, + pub url: Option, + pub hash: Option, + pub signature: Option, + pub install_path: Option, + pub file_size: Option, + pub pub_date: Option>, + pub arch: Option, + pub package_format: Option, + pub requires_extract: Option, + pub entrypoint_template: Option, + pub extract_root: Option, +} + +/// 更新版本请求 +#[derive(Debug, Deserialize, Serialize)] +pub struct UpdateVersionRequest { + pub name: Option, + pub notes: Option, + pub platform: Option, + pub url: Option, + pub hash: Option, + pub signature: Option, + pub install_path: Option, + pub file_size: Option, + pub status: Option, + pub is_latest: Option, + pub arch: Option, + pub package_format: Option, + pub requires_extract: Option, + pub entrypoint_template: Option, + pub extract_root: Option, +} + +/// 版本列表响应 +#[derive(Debug, Deserialize, Serialize)] +pub struct VersionListResponse { + pub total: i64, + pub list: Vec, +} + +/// 查询版本参数 +#[derive(Debug, Deserialize, Serialize)] +pub struct QueryVersionParams { + pub resource_name: Option, + pub platform: Option, + pub status: Option, +} + +/// 根据ID获取版本请求 +#[derive(Debug, Deserialize, Serialize)] +pub struct GetVersionByIdRequest { + pub id: i32, +} + +/// 根据资源名称和版本号获取版本请求 +#[derive(Debug, Deserialize, Serialize)] +pub struct GetVersionByNameAndVersionRequest { + pub resource_name: String, + pub version: String, +} + +/// 获取最新版本请求 +#[derive(Debug, Deserialize, Serialize)] +pub struct GetLatestVersionRequest { + pub resource_name: String, + pub platform: String, +} + +/// 查询版本列表请求 +#[derive(Debug, Deserialize, Serialize)] +pub struct ListVersionsRequest { + pub params: QueryVersionParams, + pub page_num: Option, + pub page_size: Option, +} + +/// 更新版本请求(含 id) +#[derive(Debug, Deserialize, Serialize)] +pub struct UpdateVersionHandleRequest { + pub id: i32, + pub name: Option, + pub notes: Option, + pub platform: Option, + pub url: Option, + pub hash: Option, + pub signature: Option, + pub install_path: Option, + pub file_size: Option, + pub status: Option, + pub is_latest: Option, +} + +/// 删除版本请求 +#[derive(Debug, Deserialize, Serialize)] +pub struct DeleteVersionRequest { + pub id: i32, +} + +/// 设置最新版本请求 +#[derive(Debug, Deserialize, Serialize)] +pub struct SetLatestVersionRequest { + pub type_id: i32, + pub resource_name: String, + pub version_id: i32, +} + +/// 版本回退请求 +#[derive(Debug, Deserialize, Serialize)] +pub struct RollbackVersionRequest { + pub type_id: i32, + pub resource_name: String, + pub target_version_id: i32, +} + +/// 版本对比请求 +#[derive(Debug, Deserialize, Serialize)] +pub struct CompareVersionsRequest { + pub version_id_1: i32, + pub version_id_2: i32, +} diff --git a/src/handlers/environments.rs b/src/handlers/environments.rs index e561211..0732813 100644 --- a/src/handlers/environments.rs +++ b/src/handlers/environments.rs @@ -6,11 +6,10 @@ use crate::entitys::{ BatchAssignTagRequest, BatchCreateEnvironmentRequest, BatchMoveToGroupRequest, BatchRemoveTagsRequest, BatchUuidRequest, ClearEnvironmentCookiesRequest, ClearEnvironmentUrlsRequest, CreateEnvironmentRequest, CreateGroupRequest, CreateResponse, - CreateTagRequest, DeleteEnvironmentCookieRequest, DeleteEnvironmentUrlRequest, - EnvironmentDetailResponse, EnvironmentListResponse, IdResponse, ListEnvironmentsRequest, - MoveToGroupRequest, RemoveTagRequest, SetEnvironmentAccountsRequest, - SetEnvironmentProxyRequest, UpdateEnvironmentRequest, UpdateGroupRequest, UpdateTagRequest, - UuidRequest, + DeleteEnvironmentCookieRequest, DeleteEnvironmentUrlRequest, EnvironmentDetailResponse, + EnvironmentListResponse, IdResponse, ListEnvironmentsRequest, MoveToGroupRequest, + RemoveTagRequest, SetEnvironmentAccountsRequest, SetEnvironmentProxyRequest, + UpdateEnvironmentRequest, UpdateGroupRequest, UpdateTagRequest, UuidRequest, }; use crate::services; use crate::state::RequestContext; @@ -136,7 +135,7 @@ pub async fn delete_group_handler( pub async fn create_tag_handler( State(svc_ctx): State, Extension(ctx): Extension, - Json(payload): Json, + Json(payload): Json, ) -> Result { let team_uuid = services::teams::get_current_team_service(&svc_ctx, ctx.user_uuid_unwrap()) .await diff --git a/src/handlers/health.rs b/src/handlers/health.rs index d10baa0..dd6d333 100644 --- a/src/handlers/health.rs +++ b/src/handlers/health.rs @@ -1,15 +1,15 @@ use chrono::Utc; +use serde_json::{Value, json}; -use crate::entitys::maintenance::HealthResponse; use crate::utils::{Response, Result}; /// 健康检查 -pub async fn health_check_handler() -> Result { +pub async fn health_check_handler() -> Result { Ok(Response::success( Some("服务正常"), - Some(HealthResponse { - status: "ok".to_string(), - timestamp: Utc::now().to_rfc3339(), - }), + Some(json!({ + "status": "ok", + "timestamp": Utc::now().to_rfc3339(), + })), )) } diff --git a/src/lib.rs b/src/lib.rs index 6e74ef1..54120ab 100644 --- a/src/lib.rs +++ b/src/lib.rs @@ -21,7 +21,7 @@ pub async fn init_encrypt_secret(config: &IConfig) { /// 初始化minio pub async fn init_minio(config: &IConfig) { - let minio_config = &config.clone().minio.expect("minio配置不存在"); + let minio_config = &config.clone().minio; let client = { utils::init_minio( diff --git a/src/middlewares/machine_auth.rs b/src/middlewares/machine_auth.rs index 6a21d7f..6115973 100644 --- a/src/middlewares/machine_auth.rs +++ b/src/middlewares/machine_auth.rs @@ -1,4 +1,5 @@ -use crate::services::{get_machine_by_code_service, machine_not_allow_service}; +use crate::services::{get_machine_by_code, machine_not_allow}; +// use crate::services::{get_machine_by_code_service, machine_not_allow_service}; use crate::state::{CurrentMachineUser, RequestContext}; use crate::svc_ctx::SvcCtx; use axum::{extract::Request, extract::State, middleware::Next, response::Response}; @@ -19,7 +20,7 @@ pub async fn machine_auth( if let Some(code) = machine_code { // 检查机器是否被拉黑 - let current_machine = match machine_not_allow_service(&svc_ctx, code.clone()).await { + let current_machine = match machine_not_allow(&svc_ctx, code.clone()).await { Ok(true) => { // 机器被拉黑,但继续处理(业务层决定如何处理) CurrentMachineUser { @@ -31,7 +32,7 @@ pub async fn machine_auth( } Ok(false) => { // 机器未被拉黑,尝试获取机器信息 - match get_machine_by_code_service(&svc_ctx, code.clone()).await { + match get_machine_by_code(&svc_ctx, code.clone()).await { Ok(machine) => CurrentMachineUser { machine_code: code, platform: machine.platform, diff --git a/src/models/gray_releases.rs b/src/models/gray_releases.rs index 37fbd59..cf665d6 100644 --- a/src/models/gray_releases.rs +++ b/src/models/gray_releases.rs @@ -1,249 +1,292 @@ -use crate::dto::gray_releases::GrayRelease; -use crate::entitys::gray_releases::{CreateGrayReleaseRequest, UpdateGrayReleaseRequest}; -use sqlx::{Error, Pool, Postgres}; - -/// 创建灰度发布 -pub async fn create_gray_release( - pool: &Pool, - request: &CreateGrayReleaseRequest, -) -> Result { - let sql = " - INSERT INTO gray_releases ( - name, description, platform, status, start_time, end_time, - max_machines, priority, strategy_type, strategy_config, created_at - ) VALUES ( - $1, $2, $3, 'pending', $4, $5, $6, $7, $8, $9, NOW() - ) RETURNING id - "; - - let result: (i32,) = sqlx::query_as(sql) - .bind(&request.name) - .bind(&request.description) - .bind(&request.platform) - .bind(request.start_time) - .bind(request.end_time) - .bind(request.max_machines) - .bind(request.priority.unwrap_or(0)) - .bind(&request.strategy_type) - .bind(&request.strategy_config) - .fetch_one(pool) - .await?; - - Ok(result.0) -} - -/// 查询灰度发布 -pub async fn query_gray_release_by_id( - pool: &Pool, - id: i32, -) -> Result { - let release: GrayRelease = sqlx::query_as("SELECT * FROM gray_releases WHERE id = $1") - .bind(id) - .fetch_one(pool) - .await?; - - Ok(release) -} - -/// 查询灰度发布列表 -pub async fn query_gray_releases( - pool: &Pool, - platform: Option<&str>, - status: Option<&str>, - page_num: i32, - page_size: i32, -) -> Result<(i64, Vec), Error> { - let mut where_conditions = vec![]; - let mut param_index = 1; - - if let Some(_plat) = platform { - where_conditions.push(format!("platform = ${}", param_index)); - param_index += 1; - } - if let Some(_st) = status { - where_conditions.push(format!("status = ${}", param_index)); - param_index += 1; - } - - let where_clause = if where_conditions.is_empty() { - "".to_string() - } else { - format!("WHERE {}", where_conditions.join(" AND ")) - }; - - // 获取总数 - let count_sql = format!("SELECT COUNT(*) FROM gray_releases {}", where_clause); - let count_query = match (platform, status) { - (Some(plat), Some(st)) => sqlx::query_as(&count_sql).bind(plat).bind(st), - (Some(plat), None) => sqlx::query_as(&count_sql).bind(plat), - (None, Some(st)) => sqlx::query_as(&count_sql).bind(st), - (None, None) => sqlx::query_as(&count_sql), - }; - let total: (i64,) = count_query.fetch_one(pool).await?; - - // 获取分页列表 - let list_sql = format!( - "SELECT * FROM gray_releases {} ORDER BY created_at DESC LIMIT ${} OFFSET ${}", - where_clause, - param_index, - param_index + 1 - ); - let list_query = match (platform, status) { - (Some(plat), Some(st)) => sqlx::query_as(&list_sql) - .bind(plat) - .bind(st) - .bind(page_size) - .bind((page_num - 1) * page_size), - (Some(plat), None) => sqlx::query_as(&list_sql) - .bind(plat) - .bind(page_size) - .bind((page_num - 1) * page_size), - (None, Some(st)) => sqlx::query_as(&list_sql) - .bind(st) - .bind(page_size) - .bind((page_num - 1) * page_size), - (None, None) => sqlx::query_as(&list_sql).bind(page_size).bind((page_num - 1) * page_size), - }; - let releases: Vec = list_query.fetch_all(pool).await?; - - Ok((total.0, releases)) -} - -/// 更新灰度发布 -pub async fn update_gray_release( - pool: &Pool, - id: i32, - request: &UpdateGrayReleaseRequest, -) -> Result { - let sql = " - UPDATE gray_releases SET - name = COALESCE($1, name), - description = COALESCE($2, description), - status = COALESCE($3, status), - end_time = COALESCE($4, end_time), - max_machines = COALESCE($5, max_machines), - priority = COALESCE($6, priority), - strategy_type = COALESCE($7, strategy_type), - strategy_config = COALESCE($8, strategy_config), - updated_at = NOW() - WHERE id = $9 - "; - - let row = sqlx::query(sql) - .bind(&request.name) - .bind(&request.description) - .bind(&request.status) - .bind(request.end_time) - .bind(request.max_machines) - .bind(request.priority) - .bind(&request.strategy_type) - .bind(&request.strategy_config) - .bind(id) - .execute(pool) - .await?; - - Ok(row.rows_affected() == 1) -} - -/// 更新配额计数 -pub async fn update_allocated_count( - pool: &Pool, - id: i32, - increment: i32, -) -> Result { - let sql = " - UPDATE gray_releases - SET allocated_count = allocated_count + $1, updated_at = NOW() - WHERE id = $2 - "; - - let row = sqlx::query(sql).bind(increment).bind(id).execute(pool).await?; - - Ok(row.rows_affected() == 1) -} - -/// 增加分配计数 -pub async fn increment_allocated_count(pool: &Pool, id: i32) -> Result { - update_allocated_count(pool, id, 1).await -} - -/// 减少分配计数 -pub async fn decrement_allocated_count(pool: &Pool, id: i32) -> Result { - update_allocated_count(pool, id, -1).await -} - -/// 查询活跃的灰度发布 -pub async fn query_active_gray_releases( - pool: &Pool, - machine_code: &str, -) -> Result, Error> { - let releases: Vec = sqlx::query_as( - r#" - SELECT gr.* FROM gray_releases gr - INNER JOIN machine_gray_allocations mga ON gr.id = mga.gray_release_id - WHERE mga.machine_code = $1 - AND mga.status = 'active' - AND gr.status = 'active' - AND NOW() >= gr.start_time - AND (gr.end_time IS NULL OR NOW() <= gr.end_time) - ORDER BY gr.priority DESC, gr.created_at DESC - "#, - ) - .bind(machine_code) - .fetch_all(pool) - .await?; - - Ok(releases) -} - -/// 删除灰度发布(物理删除) -pub async fn delete_gray_release(pool: &Pool, id: i32) -> Result { - let sql = "DELETE FROM gray_releases WHERE id = $1"; - let row = sqlx::query(sql).bind(id).execute(pool).await?; - Ok(row.rows_affected() == 1) -} - -/// 检查白名单策略(直接 JOIN 查询,单次查询 O(1)) -pub async fn check_whitelist_strategy( - pool: &Pool, - machine_code: &str, -) -> Result, Error> { - let release = sqlx::query_as::<_, GrayRelease>( - r#" - SELECT gr.* FROM gray_releases gr - INNER JOIN machine_gray_allocations mga ON gr.id = mga.gray_release_id - WHERE mga.machine_code = $1 - AND mga.status = 'active' - AND gr.status = 'active' - AND gr.start_time <= NOW() - AND (gr.end_time IS NULL OR gr.end_time >= NOW()) - ORDER BY gr.priority DESC, gr.created_at DESC - LIMIT 1 - "#, - ) - .bind(machine_code) - .fetch_optional(pool) - .await?; - - Ok(release) -} - -/// 查询需要动态计算的灰度发布(百分比、随机策略) -pub async fn query_dynamic_strategy_releases( - pool: &Pool, -) -> Result, Error> { - let releases = sqlx::query_as::<_, GrayRelease>( - r#" - SELECT * FROM gray_releases - WHERE status = 'active' - AND strategy_type IN ('filter_percentage', 'filter_random') - AND start_time <= NOW() - AND (end_time IS NULL OR end_time >= NOW()) - ORDER BY priority DESC, created_at DESC - "#, - ) - .fetch_all(pool) - .await?; - - Ok(releases) -} +use crate::dto::gray_releases::GrayRelease; +use crate::entitys::gray_releases::{CreateGrayReleaseRequest, UpdateGrayReleaseRequest}; +use sqlx::{Error, Pool, Postgres}; + +/// 创建灰度发布 +pub async fn create_gray_release( + pool: &Pool, + request: &CreateGrayReleaseRequest, +) -> Result { + let sql = " + INSERT INTO gray_releases ( + name, description, platform, status, start_time, end_time, + max_machines, priority, strategy_type, strategy_config, created_at + ) VALUES ( + $1, $2, $3, 'pending', $4, $5, $6, $7, $8, $9, NOW() + ) RETURNING id + "; + + let result: (i32,) = sqlx::query_as(sql) + .bind(&request.name) + .bind(&request.description) + .bind(&request.platform) + .bind(request.start_time) + .bind(request.end_time) + .bind(request.max_machines) + .bind(request.priority.unwrap_or(0)) + .bind(&request.strategy_type) + .bind(&request.strategy_config) + .fetch_one(pool) + .await?; + + Ok(result.0) +} + +/// 查询灰度发布 +pub async fn query_gray_release_by_id( + pool: &Pool, + id: i32, +) -> Result { + let release: GrayRelease = sqlx::query_as("SELECT * FROM gray_releases WHERE id = $1") + .bind(id) + .fetch_one(pool) + .await?; + + Ok(release) +} + +/// 查询灰度发布列表 +pub async fn query_gray_releases( + pool: &Pool, + platform: Option<&str>, + status: Option<&str>, + page_num: i32, + page_size: i32, +) -> Result<(i64, Vec), Error> { + let mut where_conditions = vec![]; + let mut param_index = 1; + + if let Some(_plat) = platform { + where_conditions.push(format!("platform = ${}", param_index)); + param_index += 1; + } + if let Some(_st) = status { + where_conditions.push(format!("status = ${}", param_index)); + param_index += 1; + } + + let where_clause = if where_conditions.is_empty() { + "".to_string() + } else { + format!("WHERE {}", where_conditions.join(" AND ")) + }; + + // 获取总数 + let count_sql = format!("SELECT COUNT(*) FROM gray_releases {}", where_clause); + let count_query = match (platform, status) { + (Some(plat), Some(st)) => sqlx::query_as(&count_sql).bind(plat).bind(st), + (Some(plat), None) => sqlx::query_as(&count_sql).bind(plat), + (None, Some(st)) => sqlx::query_as(&count_sql).bind(st), + (None, None) => sqlx::query_as(&count_sql), + }; + let total: (i64,) = count_query.fetch_one(pool).await?; + + // 获取分页列表 + let list_sql = format!( + "SELECT * FROM gray_releases {} ORDER BY created_at DESC LIMIT ${} OFFSET ${}", + where_clause, + param_index, + param_index + 1 + ); + let list_query = match (platform, status) { + (Some(plat), Some(st)) => sqlx::query_as(&list_sql) + .bind(plat) + .bind(st) + .bind(page_size) + .bind((page_num - 1) * page_size), + (Some(plat), None) => sqlx::query_as(&list_sql) + .bind(plat) + .bind(page_size) + .bind((page_num - 1) * page_size), + (None, Some(st)) => sqlx::query_as(&list_sql) + .bind(st) + .bind(page_size) + .bind((page_num - 1) * page_size), + (None, None) => sqlx::query_as(&list_sql) + .bind(page_size) + .bind((page_num - 1) * page_size), + }; + let releases: Vec = list_query.fetch_all(pool).await?; + + Ok((total.0, releases)) +} + +/// 更新灰度发布 +pub async fn update_gray_release( + pool: &Pool, + id: i32, + request: &UpdateGrayReleaseRequest, +) -> Result { + let sql = " + UPDATE gray_releases SET + name = COALESCE($1, name), + description = COALESCE($2, description), + status = COALESCE($3, status), + end_time = COALESCE($4, end_time), + max_machines = COALESCE($5, max_machines), + priority = COALESCE($6, priority), + strategy_type = COALESCE($7, strategy_type), + strategy_config = COALESCE($8, strategy_config), + updated_at = NOW() + WHERE id = $9 + "; + + let row = sqlx::query(sql) + .bind(&request.name) + .bind(&request.description) + .bind(&request.status) + .bind(request.end_time) + .bind(request.max_machines) + .bind(request.priority) + .bind(&request.strategy_type) + .bind(&request.strategy_config) + .bind(id) + .execute(pool) + .await?; + + Ok(row.rows_affected() == 1) +} + +/// 更新配额计数 +pub async fn update_allocated_count( + pool: &Pool, + id: i32, + increment: i32, +) -> Result { + let sql = " + UPDATE gray_releases + SET allocated_count = allocated_count + $1, updated_at = NOW() + WHERE id = $2 + "; + + let row = sqlx::query(sql) + .bind(increment) + .bind(id) + .execute(pool) + .await?; + + Ok(row.rows_affected() == 1) +} + +/// 增加分配计数 +pub async fn increment_allocated_count(pool: &Pool, id: i32) -> Result { + update_allocated_count(pool, id, 1).await +} + +/// 减少分配计数 +pub async fn decrement_allocated_count(pool: &Pool, id: i32) -> Result { + update_allocated_count(pool, id, -1).await +} + +/// 查询活跃的灰度发布 +pub async fn query_active_gray_releases( + pool: &Pool, + machine_code: &str, +) -> Result, Error> { + let releases: Vec = sqlx::query_as( + r#" + SELECT gr.* FROM gray_releases gr + INNER JOIN machine_gray_allocations mga ON gr.id = mga.gray_release_id + WHERE mga.machine_code = $1 + AND mga.status = 'active' + AND gr.status = 'active' + AND NOW() >= gr.start_time + AND (gr.end_time IS NULL OR NOW() <= gr.end_time) + ORDER BY gr.priority DESC, gr.created_at DESC + "#, + ) + .bind(machine_code) + .fetch_all(pool) + .await?; + + Ok(releases) +} + +/// 删除灰度发布(物理删除) +pub async fn delete_gray_release(pool: &Pool, id: i32) -> Result { + let sql = "DELETE FROM gray_releases WHERE id = $1"; + let row = sqlx::query(sql).bind(id).execute(pool).await?; + Ok(row.rows_affected() == 1) +} + +/// 检查白名单策略(直接 JOIN 查询,单次查询 O(1)) +pub async fn check_whitelist_strategy( + pool: &Pool, + machine_code: &str, +) -> Result, Error> { + let release = sqlx::query_as::<_, GrayRelease>( + r#" + SELECT gr.* FROM gray_releases gr + INNER JOIN machine_gray_allocations mga ON gr.id = mga.gray_release_id + WHERE mga.machine_code = $1 + AND mga.status = 'active' + AND gr.status = 'active' + AND gr.start_time <= NOW() + AND (gr.end_time IS NULL OR gr.end_time >= NOW()) + ORDER BY gr.priority DESC, gr.created_at DESC + LIMIT 1 + "#, + ) + .bind(machine_code) + .fetch_optional(pool) + .await?; + + Ok(release) +} + +/// 检查标签策略(数据库层面过滤,单次查询) +pub async fn check_tags_strategy( + pool: &Pool, + machine_code: &str, +) -> Result, Error> { + let release = sqlx::query_as::<_, GrayRelease>( + r#" + SELECT DISTINCT gr.* FROM gray_releases gr + INNER JOIN machine_users mu ON mu.machine_code = $1 + INNER JOIN machine_user_tags mut ON mut.machine_user_id = mu.id + INNER JOIN machine_tags mt ON mt.id = mut.tag_id + WHERE gr.status = 'active' + AND gr.strategy_type = 'filter_user_tag' + AND gr.start_time <= NOW() + AND (gr.end_time IS NULL OR gr.end_time >= NOW()) + AND mt.tag_name = ANY( + SELECT jsonb_array_elements_text(gr.strategy_config->'tags') + ) + GROUP BY gr.id + HAVING CASE + WHEN COALESCE((gr.strategy_config->>'match_all')::boolean, false) = true + THEN COUNT(DISTINCT mt.tag_name) >= ( + SELECT jsonb_array_length(gr.strategy_config->'tags') + ) + ELSE COUNT(DISTINCT mt.tag_name) >= 1 + END + ORDER BY gr.priority DESC, gr.created_at DESC + LIMIT 1 + "#, + ) + .bind(machine_code) + .fetch_optional(pool) + .await?; + + Ok(release) +} + +/// 查询需要动态计算的灰度发布(百分比、随机策略) +pub async fn query_dynamic_strategy_releases( + pool: &Pool, +) -> Result, Error> { + let releases = sqlx::query_as::<_, GrayRelease>( + r#" + SELECT * FROM gray_releases + WHERE status = 'active' + AND strategy_type IN ('filter_percentage', 'filter_random') + AND start_time <= NOW() + AND (end_time IS NULL OR end_time >= NOW()) + ORDER BY priority DESC, created_at DESC + "#, + ) + .fetch_all(pool) + .await?; + + Ok(releases) +} diff --git a/src/models/gray_resources.rs b/src/models/gray_resources.rs index 7038261..f4d4389 100644 --- a/src/models/gray_resources.rs +++ b/src/models/gray_resources.rs @@ -1,107 +1,127 @@ -use crate::dto::gray_resources::GrayResource; -use crate::entitys::gray_resources::CreateGrayResourceRequest; -use sqlx::{Error, Pool, Postgres}; - -/// 创建灰度资源 -pub async fn create_gray_resource( - pool: &Pool, - request: &CreateGrayResourceRequest, -) -> Result { - let sql = " - INSERT INTO gray_resources ( - gray_release_id, version_id, sort_order - ) VALUES ( - $1, $2, $3 - ) RETURNING id - "; - - let result: (i32,) = sqlx::query_as(sql) - .bind(request.gray_release_id) - .bind(request.version_id) - .bind(request.sort_order.unwrap_or(0)) - .fetch_one(pool) - .await?; - - Ok(result.0) -} - -/// 批量创建灰度资源 -pub async fn batch_create_gray_resources( - pool: &Pool, - gray_release_id: i32, - version_ids: &[i32], -) -> Result { - if version_ids.is_empty() { - return Ok(0); - } - - let mut tx = pool.begin().await?; - let mut count = 0; - - for (index, version_id) in version_ids.iter().enumerate() { - let sql = " - INSERT INTO gray_resources ( - gray_release_id, version_id, sort_order - ) VALUES ( - $1, $2, $3 - ) ON CONFLICT DO NOTHING - "; - - sqlx::query(sql) - .bind(gray_release_id) - .bind(version_id) - .bind(index as i32) - .execute(&mut *tx) - .await?; - - count += 1; - } - - tx.commit().await?; - Ok(count) -} - -/// 查询灰度资源 -pub async fn query_gray_resource_by_id( - pool: &Pool, - id: i32, -) -> Result { - let resource: GrayResource = sqlx::query_as("SELECT * FROM gray_resources WHERE id = $1") - .bind(id) - .fetch_one(pool) - .await?; - - Ok(resource) -} - -/// 查询灰度发布的所有资源 -pub async fn query_gray_resources_by_release_id( - pool: &Pool, - gray_release_id: i32, -) -> Result, Error> { - let resources: Vec = sqlx::query_as( - "SELECT * FROM gray_resources WHERE gray_release_id = $1 ORDER BY sort_order ASC, id ASC", - ) - .bind(gray_release_id) - .fetch_all(pool) - .await?; - - Ok(resources) -} - -/// 删除灰度资源 -pub async fn delete_gray_resource(pool: &Pool, id: i32) -> Result { - let sql = "DELETE FROM gray_resources WHERE id = $1"; - let row = sqlx::query(sql).bind(id).execute(pool).await?; - Ok(row.rows_affected() == 1) -} - -/// 删除灰度发布的所有资源 -pub async fn delete_gray_resources_by_release_id( - pool: &Pool, - gray_release_id: i32, -) -> Result { - let sql = "DELETE FROM gray_resources WHERE gray_release_id = $1"; - let row = sqlx::query(sql).bind(gray_release_id).execute(pool).await?; - Ok(row.rows_affected() as usize) -} +use crate::dto::gray_resources::GrayResource; +use crate::entitys::gray_resources::{BatchCreateGrayResourceRequest, CreateGrayResourceRequest}; +use sqlx::{Error, Pool, Postgres}; + +/// 创建灰度资源关联 +pub async fn create_gray_resource( + pool: &Pool, + request: &CreateGrayResourceRequest, +) -> Result { + let sql = " + INSERT INTO gray_resources (gray_release_id, version_id, sort_order) + VALUES ($1, $2, $3) RETURNING id + "; + + let result: (i32,) = sqlx::query_as(sql) + .bind(request.gray_release_id) + .bind(request.version_id) + .bind(request.sort_order.unwrap_or(0)) + .fetch_one(pool) + .await?; + + Ok(result.0) +} + +/// 批量创建灰度资源 +pub async fn batch_create_gray_resources( + pool: &Pool, + request: &BatchCreateGrayResourceRequest, +) -> Result { + let mut tx = pool.begin().await?; + + let mut count = 0; + for (index, version_id) in request.version_ids.iter().enumerate() { + let sql = " + INSERT INTO gray_resources (gray_release_id, version_id, sort_order) + VALUES ($1, $2, $3) + "; + + sqlx::query(sql) + .bind(request.gray_release_id) + .bind(version_id) + .bind(index as i32) + .execute(&mut *tx) + .await?; + + count += 1; + } + + tx.commit().await?; + Ok(count) +} + +/// 查询灰度发布的所有资源 +pub async fn query_gray_resources( + pool: &Pool, + gray_release_id: i32, +) -> Result, Error> { + let resources: Vec = sqlx::query_as( + "SELECT * FROM gray_resources WHERE gray_release_id = $1 ORDER BY sort_order", + ) + .bind(gray_release_id) + .fetch_all(pool) + .await?; + + Ok(resources) +} + +/// 根据ID查询灰度资源 +pub async fn query_gray_resource_by_id( + pool: &Pool, + id: i32, +) -> Result { + let resource: GrayResource = sqlx::query_as("SELECT * FROM gray_resources WHERE id = $1") + .bind(id) + .fetch_one(pool) + .await?; + + Ok(resource) +} + +/// 查询最大排序值 +pub async fn query_max_sort_order( + pool: &Pool, + gray_release_id: i32, +) -> Result { + let result: Option<(i32,)> = + sqlx::query_as("SELECT MAX(sort_order) FROM gray_resources WHERE gray_release_id = $1") + .bind(gray_release_id) + .fetch_optional(pool) + .await?; + + Ok(result.map(|r| r.0).unwrap_or(0)) +} + +/// 更新排序值 +pub async fn update_sort_order( + pool: &Pool, + id: i32, + sort_order: i32, +) -> Result { + let sql = "UPDATE gray_resources SET sort_order = $1 WHERE id = $2"; + let row = sqlx::query(sql) + .bind(sort_order) + .bind(id) + .execute(pool) + .await?; + Ok(row.rows_affected() == 1) +} + +/// 按灰度发布 ID 删除该灰度下所有资源关联(用于删除灰度前清理外键) +pub async fn delete_resources_by_gray_release_id( + pool: &Pool, + gray_release_id: i32, +) -> Result { + let row = sqlx::query("DELETE FROM gray_resources WHERE gray_release_id = $1") + .bind(gray_release_id) + .execute(pool) + .await?; + Ok(row.rows_affected()) +} + +/// 删除灰度资源 +pub async fn delete_gray_resource(pool: &Pool, id: i32) -> Result { + let sql = "DELETE FROM gray_resources WHERE id = $1"; + let row = sqlx::query(sql).bind(id).execute(pool).await?; + Ok(row.rows_affected() == 1) +} diff --git a/src/models/machine_gray_allocations.rs b/src/models/machine_gray_allocations.rs index 1272dc4..6278c63 100644 --- a/src/models/machine_gray_allocations.rs +++ b/src/models/machine_gray_allocations.rs @@ -1,167 +1,187 @@ -use crate::dto::machine_gray_allocations::MachineGrayAllocation; -use sqlx::{Error, Pool, Postgres}; - -/// 分配机器到灰度发布 -pub async fn allocate_machine_to_gray( - pool: &Pool, - machine_code: &str, - gray_release_id: i32, - effective_time: Option>, - notes: Option<&str>, -) -> Result { - let sql = " - INSERT INTO machine_gray_allocations ( - machine_code, gray_release_id, allocated_at, effective_time, status, notes, created_at - ) VALUES ( - $1, $2, NOW(), $3, 'active', $4, NOW() - ) - ON CONFLICT (machine_code, gray_release_id) - DO UPDATE SET - status = 'active', - effective_time = COALESCE(EXCLUDED.effective_time, machine_gray_allocations.effective_time), - notes = COALESCE(EXCLUDED.notes, machine_gray_allocations.notes), - allocated_at = NOW() - RETURNING id - "; - - let result: (i32,) = sqlx::query_as(sql) - .bind(machine_code) - .bind(gray_release_id) - .bind(effective_time) - .bind(notes) - .fetch_one(pool) - .await?; - - Ok(result.0) -} - -/// 批量分配机器到灰度发布 -pub async fn batch_allocate_machines_to_gray( - pool: &Pool, - machine_codes: &[String], - gray_release_id: i32, - effective_time: Option>, - notes: Option<&str>, -) -> Result { - if machine_codes.is_empty() { - return Ok(0); - } - - let mut tx = pool.begin().await?; - let mut count = 0; - - for machine_code in machine_codes { - let sql = " - INSERT INTO machine_gray_allocations ( - machine_code, gray_release_id, allocated_at, effective_time, status, notes, created_at - ) VALUES ( - $1, $2, NOW(), $3, 'active', $4, NOW() - ) - ON CONFLICT (machine_code, gray_release_id) - DO UPDATE SET - status = 'active', - effective_time = COALESCE(EXCLUDED.effective_time, machine_gray_allocations.effective_time), - notes = COALESCE(EXCLUDED.notes, machine_gray_allocations.notes), - allocated_at = NOW() - "; - - sqlx::query(sql) - .bind(machine_code) - .bind(gray_release_id) - .bind(effective_time) - .bind(notes) - .execute(&mut *tx) - .await?; - - count += 1; - } - - tx.commit().await?; - Ok(count) -} - -/// 查询分配记录 -pub async fn query_allocation_by_id( - pool: &Pool, - id: i32, -) -> Result { - let allocation: MachineGrayAllocation = - sqlx::query_as("SELECT * FROM machine_gray_allocations WHERE id = $1") - .bind(id) - .fetch_one(pool) - .await?; - - Ok(allocation) -} - -/// 查询机器的所有分配记录 -pub async fn query_allocations_by_machine_code( - pool: &Pool, - machine_code: &str, -) -> Result, Error> { - let allocations: Vec = sqlx::query_as( - "SELECT * FROM machine_gray_allocations WHERE machine_code = $1 ORDER BY allocated_at DESC", - ) - .bind(machine_code) - .fetch_all(pool) - .await?; - - Ok(allocations) -} - -/// 查询灰度发布的所有分配记录 -pub async fn query_allocations_by_gray_release_id( - pool: &Pool, - gray_release_id: i32, - page_num: i32, - page_size: i32, -) -> Result<(i64, Vec), Error> { - // 获取总数 - let count_sql = "SELECT COUNT(*) FROM machine_gray_allocations WHERE gray_release_id = $1"; - let total: (i64,) = sqlx::query_as(count_sql).bind(gray_release_id).fetch_one(pool).await?; - - // 获取分页列表 - let list_sql = "SELECT * FROM machine_gray_allocations WHERE gray_release_id = $1 ORDER BY allocated_at DESC LIMIT $2 OFFSET $3"; - let allocations: Vec = sqlx::query_as(list_sql) - .bind(gray_release_id) - .bind(page_size) - .bind((page_num - 1) * page_size) - .fetch_all(pool) - .await?; - - Ok((total.0, allocations)) -} - -/// 取消分配(软删除) -pub async fn deallocate_machine( - pool: &Pool, - machine_code: &str, - gray_release_id: i32, -) -> Result { - let sql = " - UPDATE machine_gray_allocations - SET status = 'inactive' - WHERE machine_code = $1 AND gray_release_id = $2 - "; - - let row = sqlx::query(sql).bind(machine_code).bind(gray_release_id).execute(pool).await?; - - Ok(row.rows_affected() > 0) -} - -/// 检查机器是否已分配到灰度发布 -pub async fn check_machine_allocated( - pool: &Pool, - machine_code: &str, - gray_release_id: i32, -) -> Result { - let exists: bool = sqlx::query_scalar( - "SELECT EXISTS(SELECT 1 FROM machine_gray_allocations WHERE machine_code = $1 AND gray_release_id = $2 AND status = 'active')", - ) - .bind(machine_code) - .bind(gray_release_id) - .fetch_one(pool) - .await?; - - Ok(exists) -} +use crate::dto::machine_gray_allocations::MachineGrayAllocation; +use crate::entitys::machine_gray_allocations::{ + AllocateMachineRequest, BatchAllocateMachineRequest, +}; +use sqlx::{Error, Pool, Postgres}; + +/// 分配机器到灰度 +pub async fn allocate_machine( + pool: &Pool, + request: &AllocateMachineRequest, +) -> Result { + let sql = " + INSERT INTO machine_gray_allocations ( + machine_code, gray_release_id, allocated_at, + effective_time, status, notes, created_at + ) VALUES ( + $1, $2, NOW(), $3, 'active', $4, NOW() + ) + ON CONFLICT (machine_code, gray_release_id) DO UPDATE SET + status = 'active', + effective_time = COALESCE(EXCLUDED.effective_time, machine_gray_allocations.effective_time), + notes = COALESCE(EXCLUDED.notes, machine_gray_allocations.notes) + "; + + let row = sqlx::query(sql) + .bind(&request.machine_code) + .bind(request.gray_release_id) + .bind(request.effective_time) + .bind(&request.notes) + .execute(pool) + .await?; + + Ok(row.rows_affected() > 0) +} + +/// 批量分配机器 +pub async fn batch_allocate_machines( + pool: &Pool, + request: &BatchAllocateMachineRequest, +) -> Result { + let mut tx = pool.begin().await?; + + let mut count = 0; + for machine_code in &request.machine_codes { + let sql = " + INSERT INTO machine_gray_allocations ( + machine_code, gray_release_id, allocated_at, + effective_time, status, notes, created_at + ) VALUES ( + $1, $2, NOW(), $3, 'active', $4, NOW() + ) + ON CONFLICT (machine_code, gray_release_id) DO NOTHING + "; + + let row = sqlx::query(sql) + .bind(machine_code) + .bind(request.gray_release_id) + .bind(request.effective_time) + .bind(&request.notes) + .execute(&mut *tx) + .await?; + + if row.rows_affected() > 0 { + count += 1; + } + } + + tx.commit().await?; + Ok(count) +} + +/// 按灰度发布 ID 删除该灰度下所有机器分配(用于删除灰度前清理外键) +pub async fn delete_allocations_by_gray_release_id( + pool: &Pool, + gray_release_id: i32, +) -> Result { + let row = sqlx::query("DELETE FROM machine_gray_allocations WHERE gray_release_id = $1") + .bind(gray_release_id) + .execute(pool) + .await?; + Ok(row.rows_affected()) +} + +/// 移除机器分配 +pub async fn remove_allocation( + pool: &Pool, + machine_code: &str, + gray_release_id: i32, +) -> Result { + let sql = " + UPDATE machine_gray_allocations + SET status = 'released' + WHERE machine_code = $1 AND gray_release_id = $2 + "; + + let row = sqlx::query(sql) + .bind(machine_code) + .bind(gray_release_id) + .execute(pool) + .await?; + + Ok(row.rows_affected() == 1) +} + +/// 查询机器分配 +pub async fn query_allocation( + pool: &Pool, + machine_code: &str, + gray_release_id: i32, +) -> Result, Error> { + let allocation: Option = sqlx::query_as( + "SELECT * FROM machine_gray_allocations + WHERE machine_code = $1 AND gray_release_id = $2", + ) + .bind(machine_code) + .bind(gray_release_id) + .fetch_optional(pool) + .await?; + + Ok(allocation) +} + +/// 查询机器的所有活跃分配 +pub async fn query_machine_allocations( + pool: &Pool, + machine_code: &str, +) -> Result, Error> { + let allocations: Vec = sqlx::query_as( + "SELECT * FROM machine_gray_allocations + WHERE machine_code = $1 AND status = 'active' + ORDER BY allocated_at DESC", + ) + .bind(machine_code) + .fetch_all(pool) + .await?; + + Ok(allocations) +} + +/// 查询灰度发布的所有分配 +pub async fn query_release_allocations( + pool: &Pool, + gray_release_id: i32, + page_num: i32, + page_size: i32, +) -> Result<(i64, Vec), Error> { + // 获取总数 + let total: (i64,) = + sqlx::query_as("SELECT COUNT(*) FROM machine_gray_allocations WHERE gray_release_id = $1") + .bind(gray_release_id) + .fetch_one(pool) + .await?; + + // 获取分页列表 + let allocations: Vec = sqlx::query_as( + "SELECT * FROM machine_gray_allocations + WHERE gray_release_id = $1 + ORDER BY allocated_at DESC + LIMIT $2 OFFSET $3", + ) + .bind(gray_release_id) + .bind(page_size) + .bind((page_num - 1) * page_size) + .fetch_all(pool) + .await?; + + Ok((total.0, allocations)) +} + +/// 检查机器是否在灰度中 +pub async fn is_machine_in_gray( + pool: &Pool, + machine_code: &str, + gray_release_id: i32, +) -> Result { + let result: Option<(i32,)> = sqlx::query_as( + "SELECT 1 FROM machine_gray_allocations + WHERE machine_code = $1 AND gray_release_id = $2 AND status = 'active' + LIMIT 1", + ) + .bind(machine_code) + .bind(gray_release_id) + .fetch_optional(pool) + .await?; + + Ok(result.is_some()) +} diff --git a/src/models/machine_tags.rs b/src/models/machine_tags.rs new file mode 100644 index 0000000..27cec82 --- /dev/null +++ b/src/models/machine_tags.rs @@ -0,0 +1,230 @@ +use crate::dto::machine_tags::MachineTag; +use crate::entitys::machine_tags::{CreateTagRequest, QueryTagsParams}; +use sqlx::{Error, Pool, Postgres}; + +/// 插入或获取标签(按名称去重) +pub async fn insert_or_get_tag( + pool: &Pool, + request: &CreateTagRequest, +) -> Result { + // 先尝试查询是否存在 + let existing_tag = + sqlx::query_as::<_, (i32,)>("SELECT id FROM machine_tags WHERE tag_name = $1") + .bind(&request.tag_name) + .fetch_optional(pool) + .await?; + + if let Some((id,)) = existing_tag { + return Ok(id); + } + + // 不存在则插入 + let result: (i32,) = sqlx::query_as( + " + INSERT INTO machine_tags (tag_name, description, category, is_active, created_at) + VALUES ($1, $2, $3, true, NOW()) + RETURNING id + ", + ) + .bind(&request.tag_name) + .bind(&request.description) + .bind(&request.category) + .fetch_one(pool) + .await?; + + Ok(result.0) +} + +/// 批量插入或获取标签(按名称列表批量去重) +pub async fn batch_insert_or_get_tags( + pool: &Pool, + tag_names: &[String], +) -> Result, Error> { + if tag_names.is_empty() { + return Ok(vec![]); + } + + let mut tag_ids = Vec::new(); + + // 查询已存在的标签 + let existing_tags = sqlx::query_as::<_, (String, i32)>( + " + SELECT tag_name, id FROM machine_tags + WHERE tag_name = ANY($1) + ", + ) + .bind(tag_names) + .fetch_all(pool) + .await?; + + // 建立已存在标签的映射 + let mut existing_map = std::collections::HashMap::new(); + for (name, id) in existing_tags { + existing_map.insert(name, id); + tag_ids.push(id); + } + + // 找出需要插入的标签 + let tags_to_insert: Vec = tag_names + .iter() + .filter(|name| !existing_map.contains_key(*name)) + .cloned() + .collect(); + + // 批量插入新标签 + if !tags_to_insert.is_empty() { + for tag_name in tags_to_insert { + let new_id = sqlx::query_as::<_, (i32,)>( + " + INSERT INTO machine_tags (tag_name, is_active, created_at) + VALUES ($1, true, NOW()) + RETURNING id + ", + ) + .bind(&tag_name) + .fetch_one(pool) + .await?; + + tag_ids.push(new_id.0); + } + } + + Ok(tag_ids) +} + +/// 查询所有去重标签 +pub async fn query_all_distinct_tags( + pool: &Pool, + params: &QueryTagsParams, +) -> Result, Error> { + let mut query = " + SELECT * FROM machine_tags + WHERE 1=1 + " + .to_string(); + + let _ = params; // 暂时忽略参数,直接查询所有激活标签 + + if let Some(_category) = ¶ms.category { + query.push_str(" AND category = $1"); + } + + if let Some(_is_active) = params.is_active { + query.push_str(" AND is_active = $2"); + } + + query.push_str(" ORDER BY created_at DESC"); + + let result = if params.category.is_some() || params.is_active.is_some() { + sqlx::query_as::<_, MachineTag>(&query) + .bind(¶ms.category) + .bind(¶ms.is_active) + .fetch_all(pool) + .await? + } else { + sqlx::query_as::<_, MachineTag>(&query) + .fetch_all(pool) + .await? + }; + + Ok(result) +} + +/// 根据机器用户ID查询标签 +pub async fn query_tags_by_machine_user_id( + pool: &Pool, + machine_user_id: i32, +) -> Result, Error> { + let tags = sqlx::query_as::<_, MachineTag>( + " + SELECT mt.* FROM machine_tags mt + INNER JOIN machine_user_tags mut ON mt.id = mut.tag_id + WHERE mut.machine_user_id = $1 + ORDER BY mt.tag_name + ", + ) + .bind(machine_user_id) + .fetch_all(pool) + .await?; + + Ok(tags) +} + +/// 绑定标签到机器用户 +pub async fn bind_tags_to_machine_user( + pool: &Pool, + machine_user_id: i32, + tag_ids: &[i32], +) -> Result { + if tag_ids.is_empty() { + return Ok(0); + } + + let mut count = 0; + + for &tag_id in tag_ids { + // 使用 ON CONFLICT DO NOTHING 避免重复插入 + let result = sqlx::query( + " + INSERT INTO machine_user_tags (machine_user_id, tag_id, created_at) + VALUES ($1, $2, NOW()) + ON CONFLICT (machine_user_id, tag_id) DO NOTHING + ", + ) + .bind(machine_user_id) + .bind(tag_id) + .execute(pool) + .await?; + + count += result.rows_affected() as usize; + } + + Ok(count) +} + +/// 解绑标签 +pub async fn unbind_tags_from_machine_user( + pool: &Pool, + machine_user_id: i32, + tag_ids: &[i32], +) -> Result { + if tag_ids.is_empty() { + return Ok(0); + } + + let result = sqlx::query( + " + DELETE FROM machine_user_tags + WHERE machine_user_id = $1 AND tag_id = ANY($2) + ", + ) + .bind(machine_user_id) + .bind(tag_ids) + .execute(pool) + .await?; + + Ok(result.rows_affected() as usize) +} + +/// 根据标签查询机器用户ID +pub async fn query_machine_user_ids_by_tag_names( + pool: &Pool, + tag_names: &[String], +) -> Result, Error> { + if tag_names.is_empty() { + return Ok(vec![]); + } + + let machine_user_ids = sqlx::query_as::<_, (i32,)>( + " + SELECT DISTINCT mut.machine_user_id FROM machine_user_tags mut + INNER JOIN machine_tags mt ON mt.id = mut.tag_id + WHERE mt.tag_name = ANY($1) + ", + ) + .bind(tag_names) + .fetch_all(pool) + .await?; + + Ok(machine_user_ids.into_iter().map(|(id,)| id).collect()) +} diff --git a/src/models/machine_users.rs b/src/models/machine_users.rs index 3eeebe4..2dcb142 100644 --- a/src/models/machine_users.rs +++ b/src/models/machine_users.rs @@ -1,425 +1,481 @@ -use crate::dto::machine_users::MachineUser; -use crate::entitys::machine_users::{CreateMachineUserRequest, UpdateMachineUserRequest}; -use sqlx::{Error, Pool, Postgres}; -use uuid::Uuid; - -/// 插入新机器用户(支持 version_info 一次性插入) -pub async fn insert_machine_user( - pool: &Pool, - request: &CreateMachineUserRequest, -) -> Result { - let sql = r#" - INSERT INTO machine_users ( - machine_code, user_uuid, platform, status, version_info, hardware_hash, hardware_raw, bind_time, created_at - ) VALUES ( - $1, $2, $3, 'active', $4, $5, $6, NOW(), NOW() - ) RETURNING id - "#; - - // 解析 version_info JSON 字符串 - let version_info_value = request - .version_info - .as_ref() - .and_then(|v| serde_json::from_str::(v).ok()); - - let result: (i32,) = sqlx::query_as(sql) - .bind(&request.machine_code) - .bind(&request.user_uuid) - .bind(&request.platform) - .bind(&version_info_value) - .bind(&request.hardware_hash) - .bind(&request.hardware_raw) - .fetch_one(pool) - .await?; - - Ok(result.0) -} - -/// 根据ID查询机器用户 -pub async fn query_machine_user_by_id( - pool: &Pool, - id: i32, -) -> Result, Error> { - let machine_user: Option = - sqlx::query_as(r#"SELECT * FROM machine_users WHERE id = $1"#) - .bind(id) - .fetch_optional(pool) - .await?; - - Ok(machine_user) -} - -/// 根据机器码查询机器用户(返回多个记录中的第一个) -pub async fn query_machine_user_by_code( - pool: &Pool, - machine_code: &str, -) -> Result { - let machine_user: MachineUser = sqlx::query_as( - r#"SELECT * FROM machine_users WHERE machine_code = $1 ORDER BY bind_time DESC LIMIT 1"#, - ) - .bind(machine_code) - .fetch_one(pool) - .await?; - - Ok(machine_user) -} - -/// 根据机器码查询所有机器用户记录 -pub async fn query_machines_by_code( - pool: &Pool, - machine_code: &str, -) -> Result, Error> { - let machines: Vec = sqlx::query_as( - r#"SELECT * FROM machine_users WHERE machine_code = $1 ORDER BY bind_time DESC"#, - ) - .bind(machine_code) - .fetch_all(pool) - .await?; - - Ok(machines) -} - -/// 根据机器码和用户UUID查询机器用户 -pub async fn query_machine_user_by_code_and_user( - pool: &Pool, - machine_code: &str, - user_uuid: &Uuid, -) -> Result, Error> { - let machine_user: Option = - sqlx::query_as(r#"SELECT * FROM machine_users WHERE machine_code = $1 AND user_uuid = $2"#) - .bind(machine_code) - .bind(user_uuid) - .fetch_optional(pool) - .await?; - - Ok(machine_user) -} - -/// 判断机器是否允许 -pub async fn machine_not_allow(pool: &Pool, machine_code: &str) -> Result { - let allow: Option = sqlx::query_scalar( - r#"SELECT EXISTS(SELECT 1 FROM machine_users WHERE machine_code = $1 AND allow = false)"#, - ) - .bind(machine_code) - .fetch_optional(pool) - .await?; - - Ok(allow.unwrap_or(false)) -} - -/// 拉黑或取消拉黑机器 -pub async fn allow_or_blacklist_machine( - pool: &Pool, - machine_code: &str, - allow: bool, -) -> Result { - let row = sqlx::query(r#"UPDATE machine_users SET allow = $1 WHERE machine_code = $2"#) - .bind(allow) - .bind(machine_code) - .execute(pool) - .await?; - - Ok(row.rows_affected() > 0) -} - -/// 根据用户UUID查询机器列表 -pub async fn query_machines_by_user_uuid( - pool: &Pool, - user_uuid: &Uuid, -) -> Result, Error> { - let machines: Vec = sqlx::query_as( - r#"SELECT * FROM machine_users WHERE user_uuid = $1 ORDER BY bind_time DESC"#, - ) - .bind(user_uuid) - .fetch_all(pool) - .await?; - - Ok(machines) -} - -/// 查询机器用户列表(分页,简化版本,支持可选的过滤条件) -pub async fn query_machine_users( - pool: &Pool, - user_uuid: Option<&Uuid>, - platform: Option<&str>, - status: Option<&str>, - page_num: i32, - page_size: i32, -) -> Result<(i64, Vec), Error> { - // 构建基础查询 - let mut count_sql = r#"SELECT COUNT(*) FROM machine_users WHERE 1=1"#.to_string(); - let mut list_sql = r#"SELECT * FROM machine_users WHERE 1=1"#.to_string(); - - if user_uuid.is_some() { - count_sql.push_str(" AND user_uuid = $1"); - list_sql.push_str(" AND user_uuid = $1"); - } - if platform.is_some() { - let idx = if user_uuid.is_some() { 2 } else { 1 }; - count_sql.push_str(&format!(" AND platform = ${}", idx)); - list_sql.push_str(&format!(" AND platform = ${}", idx)); - } - if status.is_some() { - let idx = match (user_uuid.is_some(), platform.is_some()) { - (true, true) => 3, - (true, false) | (false, true) => 2, - (false, false) => 1, - }; - count_sql.push_str(&format!(" AND status = ${}", idx)); - list_sql.push_str(&format!(" AND status = ${}", idx)); - } - - list_sql.push_str(" ORDER BY created_at DESC"); - let limit_idx = match (user_uuid.is_some(), platform.is_some(), status.is_some()) { - (true, true, true) => 4, - (true, true, false) | (true, false, true) | (false, true, true) => 3, - (true, false, false) | (false, true, false) | (false, false, true) => 2, - (false, false, false) => 1, - }; - let offset_idx = limit_idx + 1; - list_sql.push_str(&format!(" LIMIT ${} OFFSET ${}", limit_idx, offset_idx)); - - // 获取总数 - let count_result: (i64,) = match (user_uuid, platform, status) { - (Some(u), Some(p), Some(s)) => { - sqlx::query_as(&count_sql).bind(u).bind(p).bind(s).fetch_one(pool).await? - } - (Some(u), Some(p), None) => { - sqlx::query_as(&count_sql).bind(u).bind(p).fetch_one(pool).await? - } - (Some(u), None, Some(s)) => { - sqlx::query_as(&count_sql).bind(u).bind(s).fetch_one(pool).await? - } - (Some(u), None, None) => sqlx::query_as(&count_sql).bind(u).fetch_one(pool).await?, - (None, Some(p), Some(s)) => { - sqlx::query_as(&count_sql).bind(p).bind(s).fetch_one(pool).await? - } - (None, Some(p), None) => sqlx::query_as(&count_sql).bind(p).fetch_one(pool).await?, - (None, None, Some(s)) => sqlx::query_as(&count_sql).bind(s).fetch_one(pool).await?, - (None, None, None) => sqlx::query_as(&count_sql).fetch_one(pool).await?, - }; - - // 获取分页列表 - let machines: Vec = match (user_uuid, platform, status) { - (Some(u), Some(p), Some(s)) => { - sqlx::query_as(&list_sql) - .bind(u) - .bind(p) - .bind(s) - .bind(page_size) - .bind((page_num - 1) * page_size) - .fetch_all(pool) - .await? - } - (Some(u), Some(p), None) => { - sqlx::query_as(&list_sql) - .bind(u) - .bind(p) - .bind(page_size) - .bind((page_num - 1) * page_size) - .fetch_all(pool) - .await? - } - (Some(u), None, Some(s)) => { - sqlx::query_as(&list_sql) - .bind(u) - .bind(s) - .bind(page_size) - .bind((page_num - 1) * page_size) - .fetch_all(pool) - .await? - } - (Some(u), None, None) => { - sqlx::query_as(&list_sql) - .bind(u) - .bind(page_size) - .bind((page_num - 1) * page_size) - .fetch_all(pool) - .await? - } - (None, Some(p), Some(s)) => { - sqlx::query_as(&list_sql) - .bind(p) - .bind(s) - .bind(page_size) - .bind((page_num - 1) * page_size) - .fetch_all(pool) - .await? - } - (None, Some(p), None) => { - sqlx::query_as(&list_sql) - .bind(p) - .bind(page_size) - .bind((page_num - 1) * page_size) - .fetch_all(pool) - .await? - } - (None, None, Some(s)) => { - sqlx::query_as(&list_sql) - .bind(s) - .bind(page_size) - .bind((page_num - 1) * page_size) - .fetch_all(pool) - .await? - } - (None, None, None) => { - sqlx::query_as(&list_sql) - .bind(page_size) - .bind((page_num - 1) * page_size) - .fetch_all(pool) - .await? - } - }; - - Ok((count_result.0, machines)) -} - -/// 更新机器用户 -pub async fn update_machine_user( - pool: &Pool, - id: i32, - request: &UpdateMachineUserRequest, -) -> Result { - let sql = r#" - UPDATE machine_users SET - user_uuid = COALESCE($1, user_uuid), - platform = COALESCE($2, platform), - status = COALESCE($3, status), - hardware_hash = COALESCE($4, hardware_hash), - hardware_raw = COALESCE($5, hardware_raw), - updated_at = NOW() - WHERE id = $6 - "#; - - let row = sqlx::query(sql) - .bind(&request.user_uuid) - .bind(&request.platform) - .bind(&request.status) - .bind(&request.hardware_hash) - .bind(&request.hardware_raw) - .bind(id) - .execute(pool) - .await?; - - Ok(row.rows_affected() == 1) -} - -/// 更新机器版本信息(整机更新,不更新 updated_at) -pub async fn update_machine_version_info( - pool: &Pool, - machine_code: &str, - version_info: &serde_json::Value, -) -> Result { - let sql = r#"UPDATE machine_users SET version_info = $1 WHERE machine_code = $2"#; - - let row = sqlx::query(sql).bind(version_info).bind(machine_code).execute(pool).await?; - - Ok(row.rows_affected() > 0) -} - -/// 一次性更新机器绑定时间和版本信息(用于已存在记录的更新场景) -pub async fn update_machine_bind_and_version( - pool: &Pool, - machine_code: &str, - user_uuid: &Uuid, - version_info: Option<&serde_json::Value>, -) -> Result { - let sql = r#" - UPDATE machine_users SET - bind_time = NOW(), - updated_at = NOW(), - version_info = COALESCE($3, version_info) - WHERE machine_code = $1 AND user_uuid = $2 - "#; - - let row = sqlx::query(sql) - .bind(machine_code) - .bind(user_uuid) - .bind(version_info) - .execute(pool) - .await?; - - Ok(row.rows_affected() > 0) -} - -/// 更新用户的 updated_at(仅更新指定用户的绑定时间) -pub async fn update_user_bind_time( - pool: &Pool, - machine_code: &str, - user_uuid: &Uuid, -) -> Result { - let sql = - r#"UPDATE machine_users SET updated_at = NOW() WHERE machine_code = $1 AND user_uuid = $2"#; - - let row = sqlx::query(sql).bind(machine_code).bind(user_uuid).execute(pool).await?; - - Ok(row.rows_affected() > 0) -} - -/// 绑定用户到机器(如果已存在则更新,否则创建新记录) -pub async fn bind_user_to_machine( - pool: &Pool, - machine_code: &str, - user_uuid: &Uuid, -) -> Result { - // 先检查是否已有绑定记录 - let existing = query_machine_user_by_code_and_user(pool, machine_code, user_uuid).await?; - - if existing.is_some() { - // 已存在,只更新绑定时间 - let sql = r#" - UPDATE machine_users SET - bind_time = NOW(), - updated_at = NOW() - WHERE machine_code = $1 AND user_uuid = $2 - "#; - - let row = sqlx::query(sql).bind(machine_code).bind(user_uuid).execute(pool).await?; - - Ok(row.rows_affected() > 0) - } else { - // 不存在,创建新记录 - let sql = r#" - INSERT INTO machine_users ( - machine_code, user_uuid, status, bind_time, created_at - ) VALUES ( - $1, $2, 'active', NOW(), NOW() - ) - "#; - - let row = sqlx::query(sql).bind(machine_code).bind(user_uuid).execute(pool).await?; - - Ok(row.rows_affected() > 0) - } -} - -/// 解绑用户(删除绑定记录) -pub async fn unbind_user_from_machine( - pool: &Pool, - machine_code: &str, - user_uuid: &Uuid, -) -> Result { - let sql = r#"DELETE FROM machine_users WHERE machine_code = $1 AND user_uuid = $2"#; - - let row = sqlx::query(sql).bind(machine_code).bind(user_uuid).execute(pool).await?; - - Ok(row.rows_affected() > 0) -} - -/// 更新机器绑定时间(当用户重新登录已存在的机器时) -pub async fn update_machine_bind_time( - pool: &Pool, - machine_code: &str, - user_uuid: &Uuid, -) -> Result { - let sql = r#" - UPDATE machine_users SET - updated_at = NOW(), - bind_time = NOW() - WHERE machine_code = $1 AND user_uuid = $2 - "#; - - let row = sqlx::query(sql).bind(machine_code).bind(user_uuid).execute(pool).await?; - - Ok(row.rows_affected() > 0) -} +use crate::dto::machine_users::MachineUser; +use crate::entitys::machine_users::{CreateMachineUserRequest, UpdateMachineUserRequest}; +use sqlx::{Error, Pool, Postgres}; +use uuid::Uuid; + +/// 插入新机器用户(支持 version_info 一次性插入) +pub async fn insert_machine_user( + pool: &Pool, + request: &CreateMachineUserRequest, +) -> Result { + let sql = " + INSERT INTO machine_users ( + machine_code, user_uuid, platform, status, version_info, hardware_hash, hardware_raw, bind_time, created_at + ) VALUES ( + $1, $2, $3, 'active', $4, $5, $6, NOW(), NOW() + ) RETURNING id + "; + + // 解析 version_info JSON 字符串 + let version_info_value = request + .version_info + .as_ref() + .and_then(|v| serde_json::from_str::(v).ok()); + + let result: (i32,) = sqlx::query_as(sql) + .bind(&request.machine_code) + .bind(&request.user_uuid) + .bind(&request.platform) + .bind(&version_info_value) + .bind(&request.hardware_hash) + .bind(&request.hardware_raw) + .fetch_one(pool) + .await?; + + Ok(result.0) +} + +/// 根据ID查询机器用户 +pub async fn query_machine_user_by_id( + pool: &Pool, + id: i32, +) -> Result { + let machine_user: MachineUser = sqlx::query_as("SELECT * FROM machine_users WHERE id = $1") + .bind(id) + .fetch_one(pool) + .await?; + + Ok(machine_user) +} + +/// 根据机器码查询机器用户(返回多个记录中的第一个) +pub async fn query_machine_user_by_code( + pool: &Pool, + machine_code: &str, +) -> Result { + let machine_user: MachineUser = sqlx::query_as( + "SELECT * FROM machine_users WHERE machine_code = $1 ORDER BY bind_time DESC LIMIT 1", + ) + .bind(machine_code) + .fetch_one(pool) + .await?; + + Ok(machine_user) +} + +/// 根据机器码查询所有机器用户记录 +pub async fn query_machines_by_code( + pool: &Pool, + machine_code: &str, +) -> Result, Error> { + let machines: Vec = sqlx::query_as( + "SELECT * FROM machine_users WHERE machine_code = $1 ORDER BY bind_time DESC", + ) + .bind(machine_code) + .fetch_all(pool) + .await?; + + Ok(machines) +} + +/// 根据机器码和用户UUID查询机器用户 +pub async fn query_machine_user_by_code_and_user( + pool: &Pool, + machine_code: &str, + user_uuid: &Uuid, +) -> Result, Error> { + let machine_user: Option = + sqlx::query_as("SELECT * FROM machine_users WHERE machine_code = $1 AND user_uuid = $2") + .bind(machine_code) + .bind(user_uuid) + .fetch_optional(pool) + .await?; + + Ok(machine_user) +} + +/// 判断机器是否允许 +pub async fn machine_not_allow(pool: &Pool, machine_code: &str) -> Result { + let allow: bool = sqlx::query_scalar( + "SELECT EXISTS(SELECT 1 FROM machine_users WHERE machine_code = $1 AND allow = false) ", + ) + .bind(machine_code) + .fetch_one(pool) + .await?; + + Ok(allow) +} + +/// 拉黑或取消拉黑机器 +pub async fn allow_or_blacklist_machine(pool: &Pool, machine_code: &str, allow: bool) -> Result { + let row = sqlx::query("UPDATE machine_users SET allow = $1 WHERE machine_code = $2") + .bind(allow) + .bind(machine_code) + .execute(pool) + .await?; + + Ok(row.rows_affected() > 1) +} + +/// 根据用户UUID查询机器列表 +pub async fn query_machines_by_user_uuid( + pool: &Pool, + user_uuid: &Uuid, +) -> Result, Error> { + let machines: Vec = + sqlx::query_as("SELECT * FROM machine_users WHERE user_uuid = $1 ORDER BY bind_time DESC") + .bind(user_uuid) + .fetch_all(pool) + .await?; + + Ok(machines) +} + +/// 根据多个用户 UUID 批量查询机器列表(返回所有匹配的 machine_users,含 user_uuid 便于按用户分组) +pub async fn query_machines_by_user_uuids( + pool: &Pool, + user_uuids: &[Uuid], +) -> Result, Error> { + if user_uuids.is_empty() { + return Ok(vec![]); + } + let machines: Vec = sqlx::query_as( + "SELECT * FROM machine_users WHERE user_uuid = ANY($1) ORDER BY user_uuid, bind_time DESC", + ) + .bind(user_uuids) + .fetch_all(pool) + .await?; + + Ok(machines) +} + +/// 查询机器用户列表(分页) +pub async fn query_machine_users( + pool: &Pool, + user_uuid: Option<&Uuid>, + platform: Option<&str>, + status: Option<&str>, + page_num: i32, + page_size: i32, +) -> Result<(i64, Vec), Error> { + // 构建查询条件 + let mut where_clauses = vec!["1=1".to_string()]; + let mut param_index = 1; + + if let Some(_uuid) = user_uuid { + where_clauses.push(format!("user_uuid = ${}", param_index)); + param_index += 1; + } + if let Some(_plat) = platform { + where_clauses.push(format!("platform = ${}", param_index)); + param_index += 1; + } + if let Some(_st) = status { + where_clauses.push(format!("status = ${}", param_index)); + param_index += 1; + } + + let where_sql = where_clauses.join(" AND "); + + // 获取总数 + let count_sql = format!("SELECT COUNT(*) FROM machine_users WHERE {}", where_sql); + let count_result: (i64,) = match (user_uuid, platform, status) { + (Some(u), Some(p), Some(s)) => { + sqlx::query_as(&count_sql) + .bind(u) + .bind(p) + .bind(s) + .fetch_one(pool) + .await? + } + (Some(u), Some(p), None) => { + sqlx::query_as(&count_sql) + .bind(u) + .bind(p) + .fetch_one(pool) + .await? + } + (Some(u), None, Some(s)) => { + sqlx::query_as(&count_sql) + .bind(u) + .bind(s) + .fetch_one(pool) + .await? + } + (Some(u), None, None) => sqlx::query_as(&count_sql).bind(u).fetch_one(pool).await?, + (None, Some(p), Some(s)) => { + sqlx::query_as(&count_sql) + .bind(p) + .bind(s) + .fetch_one(pool) + .await? + } + (None, Some(p), None) => sqlx::query_as(&count_sql).bind(p).fetch_one(pool).await?, + (None, None, Some(s)) => sqlx::query_as(&count_sql).bind(s).fetch_one(pool).await?, + (None, None, None) => sqlx::query_as(&count_sql).fetch_one(pool).await?, + }; + + // 获取分页列表 + let list_sql = format!( + "SELECT * FROM machine_users WHERE {} ORDER BY created_at DESC LIMIT ${} OFFSET ${}", + where_sql, + param_index, + param_index + 1 + ); + let machines: Vec = match (user_uuid, platform, status) { + (Some(u), Some(p), Some(s)) => { + sqlx::query_as(&list_sql) + .bind(u) + .bind(p) + .bind(s) + .bind(page_size) + .bind((page_num - 1) * page_size) + .fetch_all(pool) + .await? + } + (Some(u), Some(p), None) => { + sqlx::query_as(&list_sql) + .bind(u) + .bind(p) + .bind(page_size) + .bind((page_num - 1) * page_size) + .fetch_all(pool) + .await? + } + (Some(u), None, Some(s)) => { + sqlx::query_as(&list_sql) + .bind(u) + .bind(s) + .bind(page_size) + .bind((page_num - 1) * page_size) + .fetch_all(pool) + .await? + } + (Some(u), None, None) => { + sqlx::query_as(&list_sql) + .bind(u) + .bind(page_size) + .bind((page_num - 1) * page_size) + .fetch_all(pool) + .await? + } + (None, Some(p), Some(s)) => { + sqlx::query_as(&list_sql) + .bind(p) + .bind(s) + .bind(page_size) + .bind((page_num - 1) * page_size) + .fetch_all(pool) + .await? + } + (None, Some(p), None) => { + sqlx::query_as(&list_sql) + .bind(p) + .bind(page_size) + .bind((page_num - 1) * page_size) + .fetch_all(pool) + .await? + } + (None, None, Some(s)) => { + sqlx::query_as(&list_sql) + .bind(s) + .bind(page_size) + .bind((page_num - 1) * page_size) + .fetch_all(pool) + .await? + } + (None, None, None) => { + sqlx::query_as(&list_sql) + .bind(page_size) + .bind((page_num - 1) * page_size) + .fetch_all(pool) + .await? + } + }; + + Ok((count_result.0, machines)) +} + +/// 更新机器用户 +pub async fn update_machine_user( + pool: &Pool, + id: i32, + request: &UpdateMachineUserRequest, +) -> Result { + let sql = " + UPDATE machine_users SET + user_uuid = COALESCE($1, user_uuid), + platform = COALESCE($2, platform), + status = COALESCE($3, status), + hardware_hash = COALESCE($4, hardware_hash), + hardware_raw = COALESCE($5, hardware_raw), + updated_at = NOW() + WHERE id = $6 + "; + + let row = sqlx::query(sql) + .bind(&request.user_uuid) + .bind(&request.platform) + .bind(&request.status) + .bind(&request.hardware_hash) + .bind(&request.hardware_raw) + .bind(id) + .execute(pool) + .await?; + + Ok(row.rows_affected() == 1) +} + +/// 更新机器版本信息(整机更新,不更新 updated_at) +pub async fn update_machine_version_info( + pool: &Pool, + machine_code: &str, + version_info: &serde_json::Value, +) -> Result { + let sql = " + UPDATE machine_users SET + version_info = $1 + WHERE machine_code = $2 + "; + + let row = sqlx::query(sql) + .bind(version_info) + .bind(machine_code) + .execute(pool) + .await?; + + Ok(row.rows_affected() > 0) +} + +/// 一次性更新机器绑定时间和版本信息(用于已存在记录的更新场景) +pub async fn update_machine_bind_and_version( + pool: &Pool, + machine_code: &str, + user_uuid: &Uuid, + version_info: Option<&serde_json::Value>, +) -> Result { + let sql = " + UPDATE machine_users SET + bind_time = NOW(), + updated_at = NOW(), + version_info = COALESCE($3, version_info) + WHERE machine_code = $1 AND user_uuid = $2 + "; + + let row = sqlx::query(sql) + .bind(machine_code) + .bind(user_uuid) + .bind(version_info) + .execute(pool) + .await?; + + Ok(row.rows_affected() > 0) +} + +/// 更新用户的 updated_at(仅更新指定用户的绑定时间) +pub async fn update_user_bind_time( + pool: &Pool, + machine_code: &str, + user_uuid: &Uuid, +) -> Result { + let sql = " + UPDATE machine_users SET + updated_at = NOW() + WHERE machine_code = $1 AND user_uuid = $2 + "; + + let row = sqlx::query(sql) + .bind(machine_code) + .bind(user_uuid) + .execute(pool) + .await?; + + Ok(row.rows_affected() > 0) +} + +/// 绑定用户到机器(如果已存在则更新,否则创建新记录) +pub async fn bind_user_to_machine( + pool: &Pool, + machine_code: &str, + user_uuid: &Uuid, +) -> Result { + // 先检查是否已有绑定记录 + let existing = query_machine_user_by_code_and_user(pool, machine_code, user_uuid).await?; + + if existing.is_some() { + // 已存在,只更新绑定时间 + let sql = " + UPDATE machine_users SET + bind_time = NOW(), + updated_at = NOW() + WHERE machine_code = $1 AND user_uuid = $2 + "; + + let row = sqlx::query(sql) + .bind(machine_code) + .bind(user_uuid) + .execute(pool) + .await?; + + Ok(row.rows_affected() > 0) + } else { + // 不存在,创建新记录 + let sql = " + INSERT INTO machine_users ( + machine_code, user_uuid, status, bind_time, created_at + ) VALUES ( + $1, $2, 'active', NOW(), NOW() + ) + "; + + let row = sqlx::query(sql) + .bind(machine_code) + .bind(user_uuid) + .execute(pool) + .await?; + + Ok(row.rows_affected() > 0) + } +} + +/// 解绑用户(删除绑定记录) +pub async fn unbind_user_from_machine( + pool: &Pool, + machine_code: &str, + user_uuid: &Uuid, +) -> Result { + let sql = " + DELETE FROM machine_users + WHERE machine_code = $1 AND user_uuid = $2 + "; + + let row = sqlx::query(sql) + .bind(machine_code) + .bind(user_uuid) + .execute(pool) + .await?; + + Ok(row.rows_affected() > 0) +} + +/// 更新机器绑定时间(当用户重新登录已存在的机器时) +pub async fn update_machine_bind_time( + pool: &Pool, + machine_code: &str, + user_uuid: &Uuid, +) -> Result { + let sql = " + UPDATE machine_users SET + updated_at = NOW(), + bind_time = NOW() + WHERE machine_code = $1 AND user_uuid = $2 + "; + + let row = sqlx::query(sql) + .bind(machine_code) + .bind(user_uuid) + .execute(pool) + .await?; + + Ok(row.rows_affected() > 0) +} diff --git a/src/models/maintenance.rs b/src/models/maintenance.rs index b51b7e7..35280fb 100644 --- a/src/models/maintenance.rs +++ b/src/models/maintenance.rs @@ -1,159 +1,180 @@ -use chrono::Utc; -use sqlx::{Pool, Postgres}; - -use crate::{dto::maintenance::Maintenance, entitys::maintenance::CreateMaintenanceRequest}; - -/// 创建维护 -pub async fn create_maintenance( - pool: &Pool, - request: CreateMaintenanceRequest, -) -> Result { - let now = Utc::now(); - - // 开始事务 - let mut tx = pool.begin().await?; - - // 关闭所有其他活跃的维护 - sqlx::query( - r#" - UPDATE maintenances - SET status = 'inactive', updated_at = $1 - WHERE status = 'active' - "#, - ) - .bind(now) - .execute(&mut *tx) - .await?; - - // 创建新的维护记录 - let maintenance = sqlx::query_as::<_, Maintenance>( - r#" - INSERT INTO maintenances (name, description, status, start_time, end_time, maintenance_type, created_at, updated_at) - VALUES ($1, $2, $3, $4, $5, $6, $7, $8) - RETURNING id, name, description, status, start_time, end_time, maintenance_type, created_at, updated_at - "#, - ) - .bind(request.name) - .bind(request.description) - .bind(request.status) - .bind(request.start_time) - .bind(request.end_time) - .bind(String::from(request.maintenance_type)) - .bind(now) - .bind(now) - .fetch_one(&mut *tx) - .await?; - - // 提交事务 - tx.commit().await?; - - Ok(maintenance) -} - -/// 查询维护列表 -pub async fn list_maintenances( - pool: &Pool, - limit: Option, - offset: Option, -) -> Result, sqlx::Error> { - let limit = limit.unwrap_or(50); - let offset = offset.unwrap_or(0); - - let maintenances = sqlx::query_as::<_, Maintenance>( - r#" - SELECT id, name, description, status, start_time, end_time, maintenance_type, - created_at, updated_at - FROM maintenances - ORDER BY created_at DESC - LIMIT $1 OFFSET $2 - "#, - ) - .bind(limit) - .bind(offset) - .fetch_all(pool) - .await?; - - Ok(maintenances) -} - -/// 结束维护 -pub async fn end_maintenance(pool: &Pool) -> Result { - let now = Utc::now(); - - // 关闭所有其他活跃的维护 - sqlx::query( - r#" - UPDATE maintenances - SET status = 'inactive', updated_at = $1 - WHERE status = 'active' - "#, - ) - .bind(now) - .execute(pool) - .await?; - - Ok(true) -} - -/// 更新维护状态 -pub async fn update_maintenance_status( - pool: &Pool, - id: i64, - status: &str, -) -> Result { - let now = Utc::now(); - let result = sqlx::query( - r#" - UPDATE maintenances - SET status = $1, updated_at = $2 - WHERE id = $3 - "#, - ) - .bind(status) - .bind(now) - .bind(id) - .execute(pool) - .await?; - - Ok(result.rows_affected() > 0) -} - -/// 根据ID查询维护 -pub async fn get_maintenance_by_id( - pool: &Pool, - id: i64, -) -> Result, sqlx::Error> { - let maintenance = sqlx::query_as::<_, Maintenance>( - r#" - SELECT id, name, description, status, start_time, end_time, maintenance_type, - created_at, updated_at - FROM maintenances - WHERE id = $1 - "#, - ) - .bind(id) - .fetch_optional(pool) - .await?; - - Ok(maintenance) -} - -/// 获取当前活跃维护 -pub async fn get_active_maintenances( - pool: &Pool, -) -> Result, sqlx::Error> { - let maintenance = sqlx::query_as::<_, Maintenance>( - r#" - SELECT id, name, description, status, start_time, end_time, maintenance_type, - created_at, updated_at - FROM maintenances - WHERE status = 'active' AND start_time <= NOW() AND end_time >= NOW() - ORDER BY created_at DESC - LIMIT 1 - "#, - ) - .fetch_optional(pool) - .await?; - - Ok(maintenance) -} +use chrono::Utc; +use sqlx::{Pool, Postgres}; + +use crate::{dto::maintenance::Maintenance, entitys::maintenance::CreateMaintenanceRequest}; + +pub async fn create_maintenance( + pool: &Pool, + request: CreateMaintenanceRequest, +) -> Result { + let now = Utc::now(); + + // 开始事务 + let mut tx = pool.begin().await?; + + // 关闭所有其他活跃的维护 + sqlx::query( + r#" + UPDATE maintenances + SET status = 'inactive', updated_at = $1 + WHERE status = 'active' + "#, + ) + .bind(now) + .execute(&mut *tx) + .await?; + + // 创建新的维护记录 + let maintenance = sqlx::query_as::<_, Maintenance>( + r#" + INSERT INTO maintenances (name, description, status, start_time, end_time, maintenance_type, created_at, updated_at) + VALUES ($1, $2, $3, $4, $5, $6, $7, $8) + RETURNING id, name, description, status, start_time, end_time, maintenance_type, created_at, updated_at + "# + ) + .bind(request.name) + .bind(request.description) + .bind(request.status) + .bind(request.start_time) + .bind(request.end_time) + .bind(String::from(request.maintenance_type)) + .bind(now) + .bind(now) + .fetch_one(&mut *tx) + .await?; + + // 提交事务 + tx.commit().await?; + + Ok(maintenance) +} + +pub async fn list_maintenances( + pool: &Pool, + limit: Option, + offset: Option, +) -> Result, sqlx::Error> { + let limit = limit.unwrap_or(50); + let offset = offset.unwrap_or(0); + + let maintenances = sqlx::query_as::<_, Maintenance>( + r#" + SELECT id, name, description, status, start_time, end_time, maintenance_type, + created_at, updated_at + FROM maintenances + ORDER BY created_at DESC + LIMIT $1 OFFSET $2 + "#, + ) + .bind(limit) + .bind(offset) + .fetch_all(pool) + .await?; + + Ok(maintenances) +} + +/// 结束维护 +pub async fn end_maintenance(pool: &Pool) -> Result { + let now = Utc::now(); + + // 关闭所有其他活跃的维护 + sqlx::query( + r#" + UPDATE maintenances + SET status = 'inactive', updated_at = $1 + WHERE status = 'active' + "#, + ) + .bind(now) + .execute(pool) + .await?; + + Ok(true) +} + +pub async fn update_maintenance_status( + pool: &Pool, + id: i64, + status: &str, +) -> Result { + let now = Utc::now(); + let result = sqlx::query( + r#" + UPDATE maintenances + SET status = $1, updated_at = $2 + WHERE id = $3 + "#, + ) + .bind(status) + .bind(now) + .bind(id) + .execute(pool) + .await?; + + Ok(result.rows_affected() > 0) +} + +pub async fn get_maintenance_by_id( + pool: &Pool, + id: i64, +) -> Result, anyhow::Error> { + let maintenance = sqlx::query_as::<_, Maintenance>( + r#" + SELECT id, name, description, status, start_time, end_time, maintenance_type, + created_at, updated_at + FROM maintenances + WHERE id = $1 + "#, + ) + .bind(id) + .fetch_optional(pool) + .await?; + + Ok(maintenance) +} + +pub async fn get_active_maintenances( + pool: &Pool, +) -> Result, anyhow::Error> { + let maintenance = sqlx::query_as::<_, Maintenance>( + r#" + SELECT id, name, description, status, start_time, end_time, maintenance_type, + created_at, updated_at + FROM maintenances + WHERE status = 'active' AND start_time <= NOW() AND end_time >= NOW() + ORDER BY created_at DESC + LIMIT 1 + "#, + ) + .fetch_optional(pool) + .await?; + + Ok(maintenance) +} + +pub async fn create_maintenances_table(pool: &Pool) -> Result<(), sqlx::Error> { + sqlx::query( + r#" + CREATE TABLE IF NOT EXISTS maintenances ( + id BIGSERIAL PRIMARY KEY, + name VARCHAR(100) NOT NULL, + description TEXT, + status VARCHAR(20) NOT NULL, + start_time TIMESTAMPTZ NOT NULL, + end_time TIMESTAMPTZ NOT NULL, + maintenance_type VARCHAR(20) NOT NULL, + created_at TIMESTAMPTZ DEFAULT NOW() NOT NULL, + updated_at TIMESTAMPTZ DEFAULT NOW() NOT NULL + ); + + CREATE INDEX IF NOT EXISTS idx_maintenances_status ON maintenances(status); + CREATE INDEX IF NOT EXISTS idx_maintenances_time ON maintenances(start_time, end_time); + CREATE INDEX IF NOT EXISTS idx_maintenances_type ON maintenances(maintenance_type); + "#, + ) + .execute(pool) + .await?; + + Ok(()) +} diff --git a/src/models/mod.rs b/src/models/mod.rs index 9ca0e0f..b0ba171 100644 --- a/src/models/mod.rs +++ b/src/models/mod.rs @@ -1,6 +1,7 @@ pub mod gray_releases; pub mod gray_resources; pub mod machine_gray_allocations; +pub mod machine_tags; pub mod machine_users; pub mod maintenance; pub mod strategy_types; @@ -33,6 +34,7 @@ pub mod apikey; pub use gray_releases::*; pub use gray_resources::*; pub use machine_gray_allocations::*; +pub use machine_tags::*; pub use machine_users::*; pub use maintenance::*; pub use strategy_types::*; diff --git a/src/models/strategy_types.rs b/src/models/strategy_types.rs index abc9281..323306c 100644 --- a/src/models/strategy_types.rs +++ b/src/models/strategy_types.rs @@ -1,56 +1,56 @@ -use crate::dto::strategy_types::StrategyType; -use sqlx::{Error, Pool, Postgres}; - -/// 根据ID查询策略类型 -pub async fn query_strategy_type_by_id( - pool: &Pool, - id: i32, -) -> Result { - let strategy_type: StrategyType = sqlx::query_as("SELECT * FROM strategy_types WHERE id = $1") - .bind(id) - .fetch_one(pool) - .await?; - - Ok(strategy_type) -} - -/// 根据code查询策略类型 -pub async fn query_strategy_type_by_code( - pool: &Pool, - code: &str, -) -> Result { - let strategy_type: StrategyType = - sqlx::query_as("SELECT * FROM strategy_types WHERE code = $1") - .bind(code) - .fetch_one(pool) - .await?; - - Ok(strategy_type) -} - -/// 查询所有可用的策略类型(只查询激活的) -pub async fn query_available_strategy_types( - pool: &Pool, -) -> Result, Error> { - let strategy_types: Vec = - sqlx::query_as("SELECT * FROM strategy_types WHERE is_active = true ORDER BY code") - .fetch_all(pool) - .await?; - - Ok(strategy_types) -} - -/// 根据分类查询策略类型 -pub async fn query_strategy_types_by_category( - pool: &Pool, - category: &str, -) -> Result, Error> { - let strategy_types: Vec = sqlx::query_as( - "SELECT * FROM strategy_types WHERE category = $1 AND is_active = true ORDER BY code", - ) - .bind(category) - .fetch_all(pool) - .await?; - - Ok(strategy_types) -} +use crate::dto::strategy_types::StrategyType; +use sqlx::{Error, Pool, Postgres}; + +/// 根据ID查询策略类型 +pub async fn query_strategy_type_by_id( + pool: &Pool, + id: i32, +) -> Result { + let strategy_type: StrategyType = sqlx::query_as("SELECT * FROM strategy_types WHERE id = $1") + .bind(id) + .fetch_one(pool) + .await?; + + Ok(strategy_type) +} + +/// 根据code查询策略类型 +pub async fn query_strategy_type_by_code( + pool: &Pool, + code: &str, +) -> Result { + let strategy_type: StrategyType = + sqlx::query_as("SELECT * FROM strategy_types WHERE code = $1") + .bind(code) + .fetch_one(pool) + .await?; + + Ok(strategy_type) +} + +/// 查询所有可用的策略类型(只查询激活的) +pub async fn query_available_strategy_types( + pool: &Pool, +) -> Result, Error> { + let strategy_types: Vec = + sqlx::query_as("SELECT * FROM strategy_types WHERE is_active = true ORDER BY code") + .fetch_all(pool) + .await?; + + Ok(strategy_types) +} + +/// 根据分类查询策略类型 +pub async fn query_strategy_types_by_category( + pool: &Pool, + category: &str, +) -> Result, Error> { + let strategy_types: Vec = sqlx::query_as( + "SELECT * FROM strategy_types WHERE category = $1 AND is_active = true ORDER BY code", + ) + .bind(category) + .fetch_all(pool) + .await?; + + Ok(strategy_types) +} diff --git a/src/models/version_types.rs b/src/models/version_types.rs index bca0ee6..d029e80 100644 --- a/src/models/version_types.rs +++ b/src/models/version_types.rs @@ -1,110 +1,124 @@ -use crate::dto::version_types::VersionType; -use crate::entitys::version_types::{CreateVersionTypeRequest, UpdateVersionTypeRequest}; -use sqlx::{Error, Pool, Postgres}; - -/// 插入版本类型 -pub async fn insert_version_type( - pool: &Pool, - request: &CreateVersionTypeRequest, -) -> Result { - let sql = r#" - INSERT INTO version_types (type_code, type_name, description, sort_order, is_active) - VALUES ($1, $2, $3, $4, true) - RETURNING id - "#; - - let result: (i32,) = sqlx::query_as(sql) - .bind(&request.type_code) - .bind(&request.type_name) - .bind(&request.description) - .bind(&request.sort_order.unwrap_or(0)) - .fetch_one(pool) - .await?; - - Ok(result.0) -} - -/// 根据ID查询版本类型 -pub async fn query_version_type_by_id( - pool: &Pool, - id: i32, -) -> Result, Error> { - let version_type: Option = - sqlx::query_as(r#"SELECT * FROM version_types WHERE id = $1"#) - .bind(id) - .fetch_optional(pool) - .await?; - - Ok(version_type) -} - -/// 根据类型代码查询版本类型 -pub async fn query_version_type_by_code( - pool: &Pool, - type_code: &str, -) -> Result, Error> { - let version_type: Option = - sqlx::query_as(r#"SELECT * FROM version_types WHERE type_code = $1"#) - .bind(type_code) - .fetch_optional(pool) - .await?; - - Ok(version_type) -} - -/// 查询所有激活的版本类型 -pub async fn query_active_version_types(pool: &Pool) -> Result, Error> { - let version_types: Vec = sqlx::query_as( - r#"SELECT * FROM version_types WHERE is_active = true ORDER BY sort_order ASC, id ASC"#, - ) - .fetch_all(pool) - .await?; - - Ok(version_types) -} - -/// 查询所有版本类型 -pub async fn query_all_version_types(pool: &Pool) -> Result, Error> { - let version_types: Vec = - sqlx::query_as(r#"SELECT * FROM version_types ORDER BY sort_order ASC, id ASC"#) - .fetch_all(pool) - .await?; - - Ok(version_types) -} - -/// 更新版本类型 -pub async fn update_version_type( - pool: &Pool, - id: i32, - request: &UpdateVersionTypeRequest, -) -> Result { - let sql = r#" - UPDATE version_types SET - type_name = COALESCE($1, type_name), - description = COALESCE($2, description), - sort_order = COALESCE($3, sort_order), - is_active = COALESCE($4, is_active) - WHERE id = $5 - "#; - - let row = sqlx::query(sql) - .bind(&request.type_name) - .bind(&request.description) - .bind(&request.sort_order) - .bind(&request.is_active) - .bind(id) - .execute(pool) - .await?; - - Ok(row.rows_affected() == 1) -} - -/// 删除版本类型(软删除,实际上标记为非激活) -pub async fn delete_version_type(pool: &Pool, id: i32) -> Result { - let sql = r#"UPDATE version_types SET is_active = false WHERE id = $1"#; - - let row = sqlx::query(sql).bind(id).execute(pool).await?; - - Ok(row.rows_affected() == 1) -} +use crate::dto::version_types::VersionType; +use crate::entitys::version_types::{CreateVersionTypeRequest, UpdateVersionTypeRequest}; +use sqlx::{Error, Pool, Postgres}; + +/// 插入新版本类型 +pub async fn insert_version_type( + pool: &Pool, + request: &CreateVersionTypeRequest, +) -> Result { + let sql = " + INSERT INTO version_types ( + type_code, type_name, description, sort_order, is_active + ) VALUES ( + $1, $2, $3, $4, true + ) RETURNING id + "; + + let result: (i32,) = sqlx::query_as(sql) + .bind(&request.type_code) + .bind(&request.type_name) + .bind(&request.description) + .bind(request.sort_order.unwrap_or(0)) + .fetch_one(pool) + .await?; + + Ok(result.0) +} + +/// 根据ID查询版本类型 +pub async fn query_version_type_by_id( + pool: &Pool, + id: i32, +) -> Result { + let version_type: VersionType = sqlx::query_as("SELECT * FROM version_types WHERE id = $1") + .bind(id) + .fetch_one(pool) + .await?; + + Ok(version_type) +} + +/// 根据代码查询版本类型 +pub async fn query_version_type_by_code( + pool: &Pool, + type_code: &str, +) -> Result { + let version_type: VersionType = + sqlx::query_as("SELECT * FROM version_types WHERE type_code = $1") + .bind(type_code) + .fetch_one(pool) + .await?; + + Ok(version_type) +} + +/// 查询所有版本类型 +pub async fn query_all_version_types(pool: &Pool) -> Result, Error> { + let version_types: Vec = + sqlx::query_as("SELECT * FROM version_types ORDER BY sort_order ASC, id ASC") + .fetch_all(pool) + .await?; + + Ok(version_types) +} + +/// 查询激活的版本类型 +pub async fn query_active_version_types(pool: &Pool) -> Result, Error> { + let version_types: Vec = sqlx::query_as( + "SELECT * FROM version_types WHERE is_active = true ORDER BY sort_order ASC, id ASC", + ) + .fetch_all(pool) + .await?; + + Ok(version_types) +} + +/// 更新版本类型 +pub async fn update_version_type( + pool: &Pool, + id: i32, + request: &UpdateVersionTypeRequest, +) -> Result { + let sql = " + UPDATE version_types SET + type_name = COALESCE($1, type_name), + description = COALESCE($2, description), + sort_order = COALESCE($3, sort_order), + is_active = COALESCE($4, is_active) + WHERE id = $5 + "; + + let row = sqlx::query(sql) + .bind(&request.type_name) + .bind(&request.description) + .bind(request.sort_order) + .bind(request.is_active) + .bind(id) + .execute(pool) + .await?; + + Ok(row.rows_affected() == 1) +} + +/// 删除版本类型 +pub async fn delete_version_type(pool: &Pool, id: i32) -> Result { + let sql = "DELETE FROM version_types WHERE id = $1"; + let row = sqlx::query(sql).bind(id).execute(pool).await?; + Ok(row.rows_affected() == 1) +} + +/// 激活/停用版本类型 +pub async fn toggle_version_type_status( + pool: &Pool, + id: i32, + is_active: bool, +) -> Result { + let sql = "UPDATE version_types SET is_active = $1 WHERE id = $2"; + let row = sqlx::query(sql) + .bind(is_active) + .bind(id) + .execute(pool) + .await?; + Ok(row.rows_affected() == 1) +} diff --git a/src/models/versions.rs b/src/models/versions.rs index 7e12283..c4d64d3 100644 --- a/src/models/versions.rs +++ b/src/models/versions.rs @@ -1,392 +1,410 @@ -use crate::dto::versions::Version; -use crate::entitys::versions::{CreateVersionRequest, UpdateVersionRequest}; -use chrono::{DateTime, Utc}; -use sqlx::{Error, Pool, Postgres}; - -/// 插入新版本 -pub async fn insert_version( - pool: &Pool, - request: &CreateVersionRequest, -) -> Result { - let sql = r#" - INSERT INTO versions ( - type_id, resource_name, version, name, notes, platform, url, hash, - signature, install_path, file_size, pub_date, created_at - ) VALUES ( - $1, $2, $3, $4, $5, $6, $7, $8, $9, $10, $11, $12, NOW() - ) RETURNING id - "#; - - let result: (i32,) = sqlx::query_as(sql) - .bind(request.type_id) - .bind(&request.resource_name) - .bind(&request.version) - .bind(&request.name) - .bind(&request.notes) - .bind(&request.platform) - .bind(&request.url) - .bind(&request.hash) - .bind(&request.signature) - .bind(&request.install_path) - .bind(request.file_size) - .bind(request.pub_date) - .fetch_one(pool) - .await?; - - Ok(result.0) -} - -/// 根据ID查询版本 -pub async fn query_version_by_id(pool: &Pool, id: i32) -> Result, Error> { - let version: Option = sqlx::query_as(r#"SELECT * FROM versions WHERE id = $1"#) - .bind(id) - .fetch_optional(pool) - .await?; - - Ok(version) -} - -/// 根据资源名称和版本号查询 -pub async fn query_version_by_name_and_version( - pool: &Pool, - resource_name: &str, - version: &str, -) -> Result, Error> { - let version_data: Option = sqlx::query_as( - r#"SELECT * FROM versions WHERE resource_name = $1 AND version = $2 AND deleted_at IS NULL"#, - ) - .bind(resource_name) - .bind(version) - .fetch_optional(pool) - .await?; - - Ok(version_data) -} - -/// 查询最新版本 -pub async fn query_latest_version( - pool: &Pool, - resource_name: &str, - platform: &str, -) -> Result, Error> { - let version: Option = sqlx::query_as( - r#" - SELECT * FROM versions - WHERE resource_name = $1 AND platform = $2 AND status = 'active' AND deleted_at IS NULL - ORDER BY is_latest DESC, pub_date DESC NULLS LAST, id DESC LIMIT 1 - "#, - ) - .bind(resource_name) - .bind(platform) - .fetch_optional(pool) - .await?; - - Ok(version) -} - -/// 查询版本列表(分页,简化版本) -pub async fn query_versions( - pool: &Pool, - resource_name: Option<&str>, - platform: Option<&str>, - status: Option<&str>, - page_num: i32, - page_size: i32, -) -> Result<(i64, Vec), Error> { - // 构建基础查询 - let mut count_sql = r#"SELECT COUNT(*) FROM versions WHERE deleted_at IS NULL"#.to_string(); - let mut list_sql = r#"SELECT * FROM versions WHERE deleted_at IS NULL"#.to_string(); - - if resource_name.is_some() { - count_sql.push_str(" AND resource_name = $1"); - list_sql.push_str(" AND resource_name = $1"); - } - if platform.is_some() { - let idx = if resource_name.is_some() { 2 } else { 1 }; - count_sql.push_str(&format!(" AND platform = ${}", idx)); - list_sql.push_str(&format!(" AND platform = ${}", idx)); - } - if status.is_some() { - let idx = match (resource_name.is_some(), platform.is_some()) { - (true, true) => 3, - (true, false) | (false, true) => 2, - (false, false) => 1, - }; - count_sql.push_str(&format!(" AND status = ${}", idx)); - list_sql.push_str(&format!(" AND status = ${}", idx)); - } - - list_sql.push_str(" ORDER BY created_at DESC"); - let limit_idx = match ( - resource_name.is_some(), - platform.is_some(), - status.is_some(), - ) { - (true, true, true) => 4, - (true, true, false) | (true, false, true) | (false, true, true) => 3, - (true, false, false) | (false, true, false) | (false, false, true) => 2, - (false, false, false) => 1, - }; - let offset_idx = limit_idx + 1; - list_sql.push_str(&format!(" LIMIT ${} OFFSET ${}", limit_idx, offset_idx)); - - // 获取总数 - let count_result: (i64,) = match (resource_name, platform, status) { - (Some(n), Some(p), Some(s)) => { - sqlx::query_as(&count_sql).bind(n).bind(p).bind(s).fetch_one(pool).await? - } - (Some(n), Some(p), None) => { - sqlx::query_as(&count_sql).bind(n).bind(p).fetch_one(pool).await? - } - (Some(n), None, Some(s)) => { - sqlx::query_as(&count_sql).bind(n).bind(s).fetch_one(pool).await? - } - (Some(n), None, None) => sqlx::query_as(&count_sql).bind(n).fetch_one(pool).await?, - (None, Some(p), Some(s)) => { - sqlx::query_as(&count_sql).bind(p).bind(s).fetch_one(pool).await? - } - (None, Some(p), None) => sqlx::query_as(&count_sql).bind(p).fetch_one(pool).await?, - (None, None, Some(s)) => sqlx::query_as(&count_sql).bind(s).fetch_one(pool).await?, - (None, None, None) => sqlx::query_as(&count_sql).fetch_one(pool).await?, - }; - - // 获取分页列表 - let versions: Vec = match (resource_name, platform, status) { - (Some(n), Some(p), Some(s)) => { - sqlx::query_as(&list_sql) - .bind(n) - .bind(p) - .bind(s) - .bind(page_size) - .bind((page_num - 1) * page_size) - .fetch_all(pool) - .await? - } - (Some(n), Some(p), None) => { - sqlx::query_as(&list_sql) - .bind(n) - .bind(p) - .bind(page_size) - .bind((page_num - 1) * page_size) - .fetch_all(pool) - .await? - } - (Some(n), None, Some(s)) => { - sqlx::query_as(&list_sql) - .bind(n) - .bind(s) - .bind(page_size) - .bind((page_num - 1) * page_size) - .fetch_all(pool) - .await? - } - (Some(n), None, None) => { - sqlx::query_as(&list_sql) - .bind(n) - .bind(page_size) - .bind((page_num - 1) * page_size) - .fetch_all(pool) - .await? - } - (None, Some(p), Some(s)) => { - sqlx::query_as(&list_sql) - .bind(p) - .bind(s) - .bind(page_size) - .bind((page_num - 1) * page_size) - .fetch_all(pool) - .await? - } - (None, Some(p), None) => { - sqlx::query_as(&list_sql) - .bind(p) - .bind(page_size) - .bind((page_num - 1) * page_size) - .fetch_all(pool) - .await? - } - (None, None, Some(s)) => { - sqlx::query_as(&list_sql) - .bind(s) - .bind(page_size) - .bind((page_num - 1) * page_size) - .fetch_all(pool) - .await? - } - (None, None, None) => { - sqlx::query_as(&list_sql) - .bind(page_size) - .bind((page_num - 1) * page_size) - .fetch_all(pool) - .await? - } - }; - - Ok((count_result.0, versions)) -} - -/// 更新版本 -pub async fn update_version( - pool: &Pool, - id: i32, - request: &UpdateVersionRequest, -) -> Result { - let sql = r#" - UPDATE versions SET - name = COALESCE($1, name), - notes = COALESCE($2, notes), - platform = COALESCE($3, platform), - url = COALESCE($4, url), - hash = COALESCE($5, hash), - signature = COALESCE($6, signature), - install_path = COALESCE($7, install_path), - file_size = COALESCE($8, file_size), - status = COALESCE($9, status), - is_latest = COALESCE($10, is_latest), - updated_at = NOW() - WHERE id = $11 AND deleted_at IS NULL - "#; - - let row = sqlx::query(sql) - .bind(&request.name) - .bind(&request.notes) - .bind(&request.platform) - .bind(&request.url) - .bind(&request.hash) - .bind(&request.signature) - .bind(&request.install_path) - .bind(request.file_size) - .bind(&request.status) - .bind(request.is_latest) - .bind(id) - .execute(pool) - .await?; - - Ok(row.rows_affected() == 1) -} - -/// 软删除版本 -pub async fn delete_version(pool: &Pool, id: i32) -> Result { - let sql = r#"UPDATE versions SET deleted_at = NOW() WHERE id = $1"#; - let row = sqlx::query(sql).bind(id).execute(pool).await?; - Ok(row.rows_affected() == 1) -} - -/// 设置某个资源为最新版本 -pub async fn set_as_latest_version( - pool: &Pool, - type_id: i32, - resource_name: &str, - version_id: i32, -) -> Result { - let mut tx = pool.begin().await?; - - // 先取消其他版本的最新状态 - sqlx::query( - r#" - UPDATE versions SET is_latest = false, updated_at = NOW() - WHERE type_id = $1 AND resource_name = $2 AND id != $3 AND deleted_at IS NULL - "#, - ) - .bind(type_id) - .bind(resource_name) - .bind(version_id) - .execute(&mut *tx) - .await?; - - // 设置当前版本为最新 - let row = sqlx::query( - r#"UPDATE versions SET is_latest = true, updated_at = NOW() WHERE id = $1 AND deleted_at IS NULL"#, - ) - .bind(version_id) - .execute(&mut *tx) - .await?; - - tx.commit().await?; - Ok(row.rows_affected() == 1) -} - -/// 查询所有激活版本类型对应平台的最新版本 -/// 返回 (type_code, resource_name, Version) 元组列表 -pub async fn query_all_active_latest_versions( - pool: &Pool, - platform: &str, -) -> Result, Error> { - // 自定义结构体用于接收查询结果 - #[derive(sqlx::FromRow)] - struct VersionWithTypeCode { - type_code: String, - id: i32, - type_id: i32, - resource_name: String, - version: String, - name: Option, - notes: Option, - platform: Option, - url: Option, - hash: Option, - signature: Option, - install_path: Option, - file_size: Option, - is_latest: bool, - status: String, - pub_date: Option>, - created_at: DateTime, - updated_at: Option>, - deleted_at: Option>, - } - - let results: Vec = sqlx::query_as( - r#" - SELECT DISTINCT ON (vt.type_code, v.resource_name) - vt.type_code, - v.id, v.type_id, v.resource_name, v.version, v.name, v.notes, - v.platform, v.url, v.hash, v.signature, v.install_path, - v.file_size, v.is_latest, v.status, v.pub_date, - v.created_at, v.updated_at, v.deleted_at - FROM versions v - INNER JOIN version_types vt ON v.type_id = vt.id - WHERE vt.is_active = true - AND v.platform = $1 - AND v.status = 'active' - AND v.deleted_at IS NULL - ORDER BY vt.type_code, v.resource_name, v.is_latest DESC, v.id DESC - "#, - ) - .bind(platform) - .fetch_all(pool) - .await?; - - // 转换为 (String, String, Version) 元组 - let converted: Vec<(String, String, Version)> = results - .into_iter() - .map(|r| { - ( - r.type_code.clone(), - r.resource_name.clone(), - Version { - id: r.id, - type_id: r.type_id, - resource_name: r.resource_name, - version: r.version, - name: r.name.clone(), - notes: r.notes, - platform: r.platform, - url: r.url, - hash: r.hash, - signature: r.signature, - install_path: r.install_path, - file_size: r.file_size, - is_latest: r.is_latest, - status: r.status, - pub_date: r.pub_date, - created_at: r.created_at, - updated_at: r.updated_at, - deleted_at: r.deleted_at, - }, - ) - }) - .collect(); - - Ok(converted) -} +use crate::dto::versions::Version; +use crate::entitys::versions::{CreateVersionRequest, UpdateVersionRequest}; +use chrono::{DateTime, Utc}; +use sqlx::{Error, Pool, Postgres}; + +/// 插入新版本 +pub async fn insert_version( + pool: &Pool, + request: &CreateVersionRequest, +) -> Result { + let sql = " + INSERT INTO versions ( + type_id, resource_name, version, name, notes, platform, url, hash, + signature, install_path, file_size, pub_date, + arch, package_format, requires_extract, created_at + ) VALUES ( + $1, $2, $3, $4, $5, $6, $7, $8, $9, $10, $11, $12, + $13, $14, $15, NOW() + ) RETURNING id + "; + + let result: (i32,) = sqlx::query_as(sql) + .bind(request.type_id) + .bind(&request.resource_name) + .bind(&request.version) + .bind(&request.name) + .bind(&request.notes) + .bind(&request.platform) + .bind(&request.url) + .bind(&request.hash) + .bind(&request.signature) + .bind(&request.install_path) + .bind(request.file_size) + .bind(request.pub_date) + .bind(&request.arch) + .bind(&request.package_format) + .bind(request.requires_extract.unwrap_or(true)) + .fetch_one(pool) + .await?; + + Ok(result.0) +} + +/// 根据ID查询版本 +pub async fn query_version_by_id(pool: &Pool, id: i32) -> Result { + let version: Version = sqlx::query_as("SELECT * FROM versions WHERE id = $1") + .bind(id) + .fetch_one(pool) + .await?; + + Ok(version) +} + +/// 根据资源名称和版本号查询 +pub async fn query_version_by_name_and_version( + pool: &Pool, + resource_name: &str, + version: &str, +) -> Result { + let version_data: Version = + sqlx::query_as("SELECT * FROM versions WHERE resource_name = $1 AND version = $2") + .bind(resource_name) + .bind(version) + .fetch_one(pool) + .await?; + + Ok(version_data) +} + +/// 查询最新版本 +pub async fn query_latest_version( + pool: &Pool, + resource_name: &str, + platform: &str, +) -> Result, Error> { + let version: Option = sqlx::query_as( + "SELECT * FROM versions + WHERE resource_name = $1 AND platform = $2 AND status = 'active' + ORDER BY pub_date DESC LIMIT 1", + ) + .bind(resource_name) + .bind(platform) + .fetch_optional(pool) + .await?; + + Ok(version) +} + +/// 查询版本列表(分页) +pub async fn query_versions( + pool: &Pool, + resource_name: Option<&str>, + platform: Option<&str>, + status: Option<&str>, + page_num: i32, + page_size: i32, +) -> Result<(i64, Vec), Error> { + // 构建查询条件 + let mut where_clauses = vec!["deleted_at IS NULL".to_string()]; + let mut param_index = 1; + + if let Some(_name) = resource_name { + where_clauses.push(format!("resource_name = ${}", param_index)); + param_index += 1; + } + if let Some(_plat) = platform { + where_clauses.push(format!("platform = ${}", param_index)); + param_index += 1; + } + if let Some(_st) = status { + where_clauses.push(format!("status = ${}", param_index)); + param_index += 1; + } + + let where_sql = where_clauses.join(" AND "); + + // 获取总数 + let count_sql = format!("SELECT COUNT(*) FROM versions WHERE {}", where_sql); + let count_result: (i64,) = match (resource_name, platform, status) { + (Some(n), Some(p), Some(s)) => { + sqlx::query_as(&count_sql) + .bind(n) + .bind(p) + .bind(s) + .fetch_one(pool) + .await? + } + (Some(n), Some(p), None) => { + sqlx::query_as(&count_sql) + .bind(n) + .bind(p) + .fetch_one(pool) + .await? + } + (Some(n), None, Some(s)) => { + sqlx::query_as(&count_sql) + .bind(n) + .bind(s) + .fetch_one(pool) + .await? + } + (Some(n), None, None) => sqlx::query_as(&count_sql).bind(n).fetch_one(pool).await?, + (None, Some(p), Some(s)) => { + sqlx::query_as(&count_sql) + .bind(p) + .bind(s) + .fetch_one(pool) + .await? + } + (None, Some(p), None) => sqlx::query_as(&count_sql).bind(p).fetch_one(pool).await?, + (None, None, Some(s)) => sqlx::query_as(&count_sql).bind(s).fetch_one(pool).await?, + (None, None, None) => sqlx::query_as(&count_sql).fetch_one(pool).await?, + }; + + // 获取分页列表 + let list_sql = format!( + "SELECT * FROM versions WHERE {} ORDER BY created_at DESC LIMIT ${} OFFSET ${}", + where_sql, + param_index, + param_index + 1 + ); + let versions: Vec = match (resource_name, platform, status) { + (Some(n), Some(p), Some(s)) => { + sqlx::query_as(&list_sql) + .bind(n) + .bind(p) + .bind(s) + .bind(page_size) + .bind((page_num - 1) * page_size) + .fetch_all(pool) + .await? + } + (Some(n), Some(p), None) => { + sqlx::query_as(&list_sql) + .bind(n) + .bind(p) + .bind(page_size) + .bind((page_num - 1) * page_size) + .fetch_all(pool) + .await? + } + (Some(n), None, Some(s)) => { + sqlx::query_as(&list_sql) + .bind(n) + .bind(s) + .bind(page_size) + .bind((page_num - 1) * page_size) + .fetch_all(pool) + .await? + } + (Some(n), None, None) => { + sqlx::query_as(&list_sql) + .bind(n) + .bind(page_size) + .bind((page_num - 1) * page_size) + .fetch_all(pool) + .await? + } + (None, Some(p), Some(s)) => { + sqlx::query_as(&list_sql) + .bind(p) + .bind(s) + .bind(page_size) + .bind((page_num - 1) * page_size) + .fetch_all(pool) + .await? + } + (None, Some(p), None) => { + sqlx::query_as(&list_sql) + .bind(p) + .bind(page_size) + .bind((page_num - 1) * page_size) + .fetch_all(pool) + .await? + } + (None, None, Some(s)) => { + sqlx::query_as(&list_sql) + .bind(s) + .bind(page_size) + .bind((page_num - 1) * page_size) + .fetch_all(pool) + .await? + } + (None, None, None) => { + sqlx::query_as(&list_sql) + .bind(page_size) + .bind((page_num - 1) * page_size) + .fetch_all(pool) + .await? + } + }; + + Ok((count_result.0, versions)) +} + +/// 更新版本 +pub async fn update_version( + pool: &Pool, + id: i32, + request: &UpdateVersionRequest, +) -> Result { + let sql = " + UPDATE versions SET + name = COALESCE($1, name), + notes = COALESCE($2, notes), + platform = COALESCE($3, platform), + url = COALESCE($4, url), + hash = COALESCE($5, hash), + signature = COALESCE($6, signature), + install_path = COALESCE($7, install_path), + file_size = COALESCE($8, file_size), + status = COALESCE($9, status), + is_latest = COALESCE($10, is_latest), + updated_at = NOW() + WHERE id = $11 + "; + + let row = sqlx::query(sql) + .bind(&request.name) + .bind(&request.notes) + .bind(&request.platform) + .bind(&request.url) + .bind(&request.hash) + .bind(&request.signature) + .bind(&request.install_path) + .bind(request.file_size) + .bind(&request.status) + .bind(request.is_latest) + .bind(id) + .execute(pool) + .await?; + + Ok(row.rows_affected() == 1) +} + +/// 软删除版本 +pub async fn delete_version(pool: &Pool, id: i32) -> Result { + // 非逻辑删除 + let sql = "DELETE FROM versions WHERE id = $1"; + let row = sqlx::query(sql).bind(id).execute(pool).await?; + Ok(row.rows_affected() == 1) +} + +/// 设置某个资源为最新版本 +pub async fn set_as_latest_version( + pool: &Pool, + type_id: i32, + resource_name: &str, + version_id: i32, +) -> Result { + let mut tx = pool.begin().await?; + + // 先取消其他版本的最新状态 + sqlx::query( + "UPDATE versions SET is_latest = false, updated_at = NOW() + WHERE type_id = $1 AND resource_name = $2 AND id != $3", + ) + .bind(type_id) + .bind(resource_name) + .bind(version_id) + .execute(&mut *tx) + .await?; + + // 设置当前版本为最新 + let row = sqlx::query("UPDATE versions SET is_latest = true, updated_at = NOW() WHERE id = $1") + .bind(version_id) + .execute(&mut *tx) + .await?; + + tx.commit().await?; + Ok(row.rows_affected() == 1) +} + +/// 查询所有激活版本类型对应平台的最新版本 +/// 使用 is_latest 字段优先,按 (type_code, resource_name) 分组 +/// 返回 (type_code, resource_name, Version) 元组列表 +pub async fn query_all_active_latest_versions( + pool: &Pool, + platform: &str, +) -> Result, Error> { + // 自定义结构体用于接收查询结果 + #[derive(sqlx::FromRow)] + struct VersionWithTypeCode { + type_code: String, + // Version 的所有字段 + id: i32, + type_id: i32, + resource_name: String, + version: String, + name: Option, + notes: Option, + platform: Option, + url: Option, + hash: Option, + signature: Option, + install_path: Option, + file_size: Option, + is_latest: bool, + status: String, + pub_date: Option>, + created_at: DateTime, + updated_at: Option>, + deleted_at: Option>, + arch: Option, + package_format: Option, + requires_extract: bool, + entrypoint_template: Option, + extract_root: Option, + } + + let results: Vec = sqlx::query_as( + r#" + SELECT DISTINCT ON (vt.type_code, v.resource_name) + vt.type_code, + v.id, v.type_id, v.resource_name, v.version, v.name, v.notes, + v.platform, v.url, v.hash, v.signature, v.install_path, + v.file_size, v.is_latest, v.status, v.pub_date, + v.created_at, v.updated_at, v.deleted_at, + v.arch, v.package_format, v.requires_extract, v.entrypoint_template, v.extract_root + FROM versions v + INNER JOIN version_types vt ON v.type_id = vt.id + WHERE vt.is_active = true + AND v.platform = $1 + AND v.status = 'active' + AND v.deleted_at IS NULL + ORDER BY vt.type_code, v.resource_name, v.is_latest DESC, v.id DESC + "#, + ) + .bind(platform) + .fetch_all(pool) + .await?; + + // 转换为 (String, String, Version) 元组 + let converted: Vec<(String, String, Version)> = results + .into_iter() + .map(|r| { + ( + r.type_code.clone(), + r.resource_name.clone(), + Version { + id: r.id, + type_id: r.type_id, + resource_name: r.resource_name, + version: r.version, + name: r.name.clone(), + notes: r.notes, + platform: r.platform, + url: r.name, + hash: r.hash, + signature: r.signature, + install_path: r.install_path, + file_size: r.file_size, + is_latest: r.is_latest, + status: r.status, + pub_date: r.pub_date, + created_at: r.created_at, + updated_at: r.updated_at, + deleted_at: r.deleted_at, + arch: r.arch, + package_format: r.package_format, + requires_extract: r.requires_extract, + entrypoint_template: r.entrypoint_template, + extract_root: r.extract_root, + }, + ) + }) + .collect(); + + Ok(converted) +} diff --git a/src/services.rs b/src/services.rs index 0900398..2238fb2 100644 --- a/src/services.rs +++ b/src/services.rs @@ -1,68 +1,70 @@ -mod gray_releases; -mod gray_resources; -mod machine_gray_allocations; -mod machine_users; -mod maintenance; -mod strategy_types; -mod time; -pub mod users; -mod version_types; -mod versions; - -// 新增模块 -pub mod accounts; -pub mod api; -pub mod audit; -pub mod billing; -pub mod coupons; -pub mod environments; -pub mod extensions; -pub mod group_permissions; -pub mod groups; -pub mod messages; -pub mod orders; -pub mod plans; -pub mod preferences; -pub mod proxies; -pub mod proxy_visibility; -pub mod referral; -pub mod rpa; -pub mod subscriptions; -pub mod tags; -pub mod teams; -pub mod templates; -pub mod wallet; -pub mod workspace_quotas; -pub mod workspaces; - -pub use gray_releases::*; -pub use gray_resources::*; -pub use machine_gray_allocations::*; -pub use machine_users::*; -pub use maintenance::*; -pub use strategy_types::*; -pub use time::*; -pub use users::*; -pub use version_types::*; -pub use versions::*; - -// 新增导出 -pub use accounts::*; -pub use audit::*; -pub use billing::*; -pub use coupons::*; -pub use environments::*; -pub use group_permissions::*; -pub use groups::*; -pub use messages::*; -pub use orders::*; -pub use plans::*; -pub use proxies::*; -pub use proxy_visibility::*; -pub use subscriptions::*; -pub use tags::*; -pub use teams::*; -pub use templates::*; -pub use wallet::*; -pub use workspace_quotas::*; -pub use workspaces::*; +mod gray_releases; +mod gray_resources; +mod machine_gray_allocations; +mod machine_tags; +mod machine_users; +mod maintenance; +mod strategy_types; +mod time; +pub mod users; +mod version_types; +mod versions; + +// 新增模块 +pub mod accounts; +pub mod api; +pub mod audit; +pub mod billing; +pub mod coupons; +pub mod environments; +pub mod extensions; +pub mod group_permissions; +pub mod groups; +pub mod messages; +pub mod orders; +pub mod plans; +pub mod preferences; +pub mod proxies; +pub mod proxy_visibility; +pub mod referral; +pub mod rpa; +pub mod subscriptions; +pub mod tags; +pub mod teams; +pub mod templates; +pub mod wallet; +pub mod workspace_quotas; +pub mod workspaces; + +pub use gray_releases::*; +pub use gray_resources::*; +pub use machine_gray_allocations::*; +pub use machine_tags::*; +pub use machine_users::*; +pub use maintenance::*; +pub use strategy_types::*; +pub use time::*; +pub use users::*; +pub use version_types::*; +pub use versions::*; + +// 新增导出 +pub use accounts::*; +pub use audit::*; +pub use billing::*; +pub use coupons::*; +pub use environments::*; +pub use group_permissions::*; +pub use groups::*; +pub use messages::*; +pub use orders::*; +pub use plans::*; +pub use proxies::*; +pub use proxy_visibility::*; +pub use subscriptions::*; +pub use tags::*; +pub use teams::*; +pub use templates::*; +pub use wallet::*; +pub use workspace_quotas::*; +pub use workspaces::*; diff --git a/src/services/extensions.rs b/src/services/extensions.rs index 7384414..1a85166 100644 --- a/src/services/extensions.rs +++ b/src/services/extensions.rs @@ -31,9 +31,9 @@ pub async fn get_extensions_service( .map_err(|e| e.to_string())?; // 将 object path 转换为完整 URL - let resource_url = svc_ctx.config.minio.as_ref().map(|c| c.resource_url.clone()); - let extension_bucket = svc_ctx.config.minio.as_ref().map(|c| c.extension_bucket.clone()); - ExtensionDto::transform_urls_batch(&mut extensions, &resource_url, &extension_bucket); + let resource_url = svc_ctx.config.minio.resource_url.as_str(); + let extension_bucket = svc_ctx.config.minio.extension_bucket.as_str(); + ExtensionDto::transform_urls_batch(&mut extensions, resource_url, extension_bucket); let total = models::extensions::fetch_extensions_count(&svc_ctx.db, keyword, category) .await @@ -53,8 +53,8 @@ pub async fn get_extension_service( .ok_or_else(|| "扩展不存在".to_string())?; // 将 object path 转换为完整 URL - let resource_url = svc_ctx.config.minio.as_ref().map(|c| c.resource_url.clone()); - let extension_bucket = svc_ctx.config.minio.as_ref().map(|c| c.extension_bucket.clone()); + let resource_url = svc_ctx.config.minio.resource_url.as_str(); + let extension_bucket = svc_ctx.config.minio.extension_bucket.as_str(); extension.transform_urls(&resource_url, &extension_bucket); Ok(extension) @@ -73,8 +73,8 @@ pub async fn get_user_installed_extensions_service( user_uuid: Uuid, team_uuid: Option, ) -> Result, String> { - let resource_url = svc_ctx.config.minio.as_ref().map(|c| c.resource_url.clone()); - let extension_bucket = svc_ctx.config.minio.as_ref().map(|c| c.extension_bucket.clone()); + let resource_url = svc_ctx.config.minio.resource_url.as_str(); + let extension_bucket = svc_ctx.config.minio.extension_bucket.as_str(); // 查询用户直接安装的扩展 let user_installed = models::extensions::fetch_user_extensions(&svc_ctx.db, user_uuid) @@ -100,8 +100,11 @@ pub async fn get_user_installed_extensions_service( let mut icon_url = ext.icon_url.clone(); if let Some(path) = &icon_url { if !path.is_empty() && !path.starts_with("http") { - icon_url = - get_objects::get_extension_icon_url(&resource_url, &extension_bucket, path); + icon_url = Some(get_objects::get_extension_icon_url( + &resource_url, + &extension_bucket, + path, + )); } } @@ -174,8 +177,11 @@ pub async fn get_user_installed_extensions_service( let mut icon_url = ext.icon_url.clone(); if let Some(path) = &icon_url { if !path.is_empty() && !path.starts_with("http") { - icon_url = - get_objects::get_extension_icon_url(&resource_url, &extension_bucket, path); + icon_url = Some(get_objects::get_extension_icon_url( + &resource_url, + &extension_bucket, + path, + )); } } @@ -241,8 +247,8 @@ pub async fn get_team_installed_extensions_service( svc_ctx: &SvcCtx, team_uuid: Uuid, ) -> Result, String> { - let resource_url = svc_ctx.config.minio.as_ref().map(|c| c.resource_url.clone()); - let extension_bucket = svc_ctx.config.minio.as_ref().map(|c| c.extension_bucket.clone()); + let resource_url = svc_ctx.config.minio.resource_url.as_str(); + let extension_bucket = svc_ctx.config.minio.extension_bucket.as_str(); // 查询团队直接安装的扩展 let team_installed = models::extensions::fetch_team_extensions(&svc_ctx.db, team_uuid) @@ -267,8 +273,11 @@ pub async fn get_team_installed_extensions_service( let mut icon_url = ext.icon_url.clone(); if let Some(path) = &icon_url { if !path.is_empty() && !path.starts_with("http") { - icon_url = - get_objects::get_extension_icon_url(&resource_url, &extension_bucket, path); + icon_url = Some(get_objects::get_extension_icon_url( + &resource_url, + &extension_bucket, + path, + )); } } @@ -341,8 +350,11 @@ pub async fn get_team_installed_extensions_service( let mut icon_url = ext.icon_url.clone(); if let Some(path) = &icon_url { if !path.is_empty() && !path.starts_with("http") { - icon_url = - get_objects::get_extension_icon_url(&resource_url, &extension_bucket, path); + icon_url = Some(get_objects::get_extension_icon_url( + &resource_url, + &extension_bucket, + path, + )); } } diff --git a/src/services/gray_releases.rs b/src/services/gray_releases.rs index 68190ed..aa6593e 100644 --- a/src/services/gray_releases.rs +++ b/src/services/gray_releases.rs @@ -1,285 +1,254 @@ -use crate::{ - dto::gray_releases::GrayRelease, entitys::gray_releases::*, errors::SimprintError, - svc_ctx::SvcCtx, -}; - -/// 创建灰度发布 -pub async fn create_gray_release_service( - svc_ctx: &SvcCtx, - request: CreateGrayReleaseRequest, -) -> Result { - let pool = &svc_ctx.db; - let id = crate::models::gray_releases::create_gray_release(pool, &request).await?; - - Ok(id) -} - -/// 查询灰度发布详情 -pub async fn get_gray_release_by_id_service( - svc_ctx: &SvcCtx, - payload: GetGrayReleaseParams, -) -> Result { - let pool = &svc_ctx.db; - let release = crate::models::gray_releases::query_gray_release_by_id(pool, payload.id) - .await - .map_err(|_| SimprintError::GrayReleaseNotFound)?; - - Ok(release) -} - -/// 查询灰度发布列表 -pub async fn query_gray_releases_service( - svc_ctx: &SvcCtx, - params: QueryGrayReleasesParams, -) -> Result { - let pool = &svc_ctx.db; - let (total, list) = crate::models::gray_releases::query_gray_releases( - pool, - params.platform.as_deref(), - params.status.as_deref(), - params.page_num.unwrap_or(1), - params.page_size.unwrap_or(20), - ) - .await?; - - Ok(GrayReleaseListResponse { total, list }) -} - -/// 更新灰度发布 -pub async fn update_gray_release_service( - svc_ctx: &SvcCtx, - request: UpdateGrayReleaseRequest, -) -> Result { - let pool = &svc_ctx.db; - // 检查灰度发布是否存在 - crate::models::gray_releases::query_gray_release_by_id(pool, request.id) - .await - .map_err(|_| SimprintError::GrayReleaseNotFound)?; - - // 更新灰度发布 - let success = - crate::models::gray_releases::update_gray_release(pool, request.id, &request).await?; - - if !success { - return Err(SimprintError::GrayReleaseNotFound); - } - - Ok(true) -} - -/// 启动灰度发布 -pub async fn start_gray_release_service( - svc_ctx: &SvcCtx, - payload: GetGrayReleaseParams, -) -> Result { - let pool = &svc_ctx.db; - let release = crate::models::gray_releases::query_gray_release_by_id(pool, payload.id) - .await - .map_err(|_| SimprintError::GrayReleaseNotFound)?; - - // 检查状态 - if release.status == "active" { - return Ok(true); // 已经是活跃状态 - } - - if release.status != "pending" { - return Err(SimprintError::InvalidRequest(format!( - "灰度发布状态为 {},无法启动", - release.status - ))); - } - - // 更新状态为 active - let request = UpdateGrayReleaseRequest { - id: payload.id, - name: None, - description: None, - status: Some("active".to_string()), - end_time: None, - max_machines: None, - priority: None, - strategy_type: None, - strategy_config: None, - }; - - let success = - crate::models::gray_releases::update_gray_release(pool, payload.id, &request).await?; - - if !success { - return Err(SimprintError::GrayReleaseNotFound); - } - - Ok(true) -} - -/// 暂停灰度发布 -pub async fn pause_gray_release_service( - svc_ctx: &SvcCtx, - payload: GetGrayReleaseParams, -) -> Result { - let pool = &svc_ctx.db; - let release = crate::models::gray_releases::query_gray_release_by_id(pool, payload.id) - .await - .map_err(|_| SimprintError::GrayReleaseNotFound)?; - - // 检查状态 - if release.status != "active" { - return Err(SimprintError::InvalidRequest(format!( - "灰度发布状态为 {},无法暂停", - release.status - ))); - } - - // 更新状态为 paused - let request = UpdateGrayReleaseRequest { - id: payload.id, - name: None, - description: None, - status: Some("paused".to_string()), - end_time: None, - max_machines: None, - priority: None, - strategy_type: None, - strategy_config: None, - }; - - let success = - crate::models::gray_releases::update_gray_release(pool, payload.id, &request).await?; - - if !success { - return Err(SimprintError::GrayReleaseNotFound); - } - - Ok(true) -} - -/// 结束灰度发布 -pub async fn finish_gray_release_service( - svc_ctx: &SvcCtx, - payload: GetGrayReleaseParams, -) -> Result { - let pool = &svc_ctx.db; - let release = crate::models::gray_releases::query_gray_release_by_id(pool, payload.id) - .await - .map_err(|_| SimprintError::GrayReleaseNotFound)?; - - // 检查状态 - if release.status == "finished" { - return Ok(true); // 已经结束 - } - - if !["active", "paused"].contains(&release.status.as_str()) { - return Err(SimprintError::InvalidRequest(format!( - "灰度发布状态为 {},无法结束", - release.status - ))); - } - - // 更新状态为 finished,并设置结束时间 - let request = UpdateGrayReleaseRequest { - id: payload.id, - name: None, - description: None, - status: Some("finished".to_string()), - end_time: Some(chrono::Utc::now()), - max_machines: None, - priority: None, - strategy_type: None, - strategy_config: None, - }; - - let success = - crate::models::gray_releases::update_gray_release(pool, payload.id, &request).await?; - - if !success { - return Err(SimprintError::GrayReleaseNotFound); - } - - Ok(true) -} - -/// 取消灰度发布 -pub async fn cancel_gray_release_service( - svc_ctx: &SvcCtx, - payload: GetGrayReleaseParams, -) -> Result { - let pool = &svc_ctx.db; - let release = crate::models::gray_releases::query_gray_release_by_id(pool, payload.id) - .await - .map_err(|_| SimprintError::GrayReleaseNotFound)?; - - // 检查状态 - if release.status == "cancelled" { - return Ok(true); // 已经取消 - } - - if !["pending", "active", "paused"].contains(&release.status.as_str()) { - return Err(SimprintError::InvalidRequest(format!( - "灰度发布状态为 {},无法取消", - release.status - ))); - } - - // 更新状态为 cancelled - let request = UpdateGrayReleaseRequest { - id: payload.id, - name: None, - description: None, - status: Some("cancelled".to_string()), - end_time: Some(chrono::Utc::now()), - max_machines: None, - priority: None, - strategy_type: None, - strategy_config: None, - }; - - let success = - crate::models::gray_releases::update_gray_release(pool, payload.id, &request).await?; - - if !success { - return Err(SimprintError::GrayReleaseNotFound); - } - - Ok(true) -} - -/// 删除灰度发布(物理删除) -pub async fn delete_gray_release_service( - svc_ctx: &SvcCtx, - payload: GetGrayReleaseParams, -) -> Result { - let pool = &svc_ctx.db; - // 先检查是否存在 - let _release = crate::models::gray_releases::query_gray_release_by_id(pool, payload.id) - .await - .map_err(|_| SimprintError::GrayReleaseNotFound)?; - - // 物理删除(关联的 gray_resources 会通过 ON DELETE CASCADE 自动删除) - let success = crate::models::gray_releases::delete_gray_release(pool, payload.id).await?; - - if !success { - return Err(SimprintError::GrayReleaseNotFound); - } - - Ok(true) -} - -/// 检查白名单策略 -pub async fn check_whitelist_strategy_service( - svc_ctx: &SvcCtx, - machine_code: String, -) -> Result, SimprintError> { - let pool = &svc_ctx.db; - let release = - crate::models::gray_releases::check_whitelist_strategy(pool, &machine_code).await?; - Ok(release) -} - -/// 查询动态策略灰度发布 -pub async fn query_dynamic_strategy_releases_service( - svc_ctx: &SvcCtx, -) -> Result, SimprintError> { - let pool = &svc_ctx.db; - let releases = crate::models::gray_releases::query_dynamic_strategy_releases(pool).await?; - Ok(releases) -} +use crate::{ + dto::gray_releases::GrayRelease, entitys::gray_releases::*, errors::SimprintError, + svc_ctx::SvcCtx, +}; + +/// 创建灰度发布 +pub async fn create_gray_release( + svc_ctx: &SvcCtx, + req: CreateGrayReleaseRequest, +) -> Result { + let id = crate::models::gray_releases::create_gray_release(&svc_ctx.db, &req).await?; + + Ok(id) +} + +/// 查询灰度发布详情 +pub async fn get_gray_release_by_id( + svc_ctx: &SvcCtx, + id: i32, +) -> Result { + let release = crate::models::gray_releases::query_gray_release_by_id(&svc_ctx.db, id) + .await + .map_err(|_| SimprintError::GrayReleaseNotFound)?; + + Ok(release) +} + +/// 查询灰度发布列表 +pub async fn query_gray_releases_service( + svc_ctx: &SvcCtx, + request: ListGrayReleasesRequest, + // params: QueryGrayReleaseParams, + // page_num: i32, + // page_size: i32, +) -> Result { + let (total, list) = crate::models::gray_releases::query_gray_releases( + &svc_ctx.db, + request.platform.as_deref(), + request.status.as_deref(), + request.page.unwrap_or(1), + request.page_size.unwrap_or(20), + ) + .await?; + + Ok(GrayReleaseListResponse { total, list }) +} + +/// 更新灰度发布 +pub async fn update_gray_release_service( + svc_ctx: &SvcCtx, + request: UpdateGrayReleaseRequest, +) -> Result { + let id = request.gray_release_id; + + // 检查灰度发布是否存在 + crate::models::gray_releases::query_gray_release_by_id(&svc_ctx.db, id) + .await + .map_err(|_| SimprintError::GrayReleaseNotFound)?; + + // 更新灰度发布 + let success = + crate::models::gray_releases::update_gray_release(&svc_ctx.db, id, &request).await?; + + if !success { + return Err(SimprintError::GrayReleaseNotFound); + } + + Ok(true) +} + +/// 启动灰度发布 +pub async fn start_gray_release_service(svc_ctx: &SvcCtx, id: i32) -> Result { + let release = crate::models::gray_releases::query_gray_release_by_id(&svc_ctx.db, id) + .await + .map_err(|_| SimprintError::GrayReleaseNotFound)?; + + // 检查状态 + if release.status == "active" { + return Ok(true); // 已经是活跃状态 + } + + if release.status != "pending" { + return Err(SimprintError::InvalidRequest(format!( + "灰度发布状态为 {},无法启动", + release.status + ))); + } + + // 更新状态为 active + let request = UpdateGrayReleaseRequest { + gray_release_id: id, + name: None, + description: None, + status: Some("active".to_string()), + end_time: None, + max_machines: None, + priority: None, + strategy_type: None, + strategy_config: None, + }; + + let success = + crate::models::gray_releases::update_gray_release(&svc_ctx.db, id, &request).await?; + + if !success { + return Err(SimprintError::GrayReleaseNotFound); + } + + Ok(true) +} + +/// 暂停灰度发布 +pub async fn pause_gray_release_service(svc_ctx: &SvcCtx, id: i32) -> Result { + let release = crate::models::gray_releases::query_gray_release_by_id(&svc_ctx.db, id) + .await + .map_err(|_| SimprintError::GrayReleaseNotFound)?; + + // 检查状态 + if release.status != "active" { + return Err(SimprintError::InvalidRequest(format!( + "灰度发布状态为 {},无法暂停", + release.status + ))); + } + + // 更新状态为 paused + let request = UpdateGrayReleaseRequest { + gray_release_id: id, + name: None, + description: None, + status: Some("paused".to_string()), + end_time: None, + max_machines: None, + priority: None, + strategy_type: None, + strategy_config: None, + }; + + let success = + crate::models::gray_releases::update_gray_release(&svc_ctx.db, id, &request).await?; + + if !success { + return Err(SimprintError::GrayReleaseNotFound); + } + + Ok(true) +} + +/// 结束灰度发布 +pub async fn finish_gray_release_service(svc_ctx: &SvcCtx, id: i32) -> Result { + let release = crate::models::gray_releases::query_gray_release_by_id(&svc_ctx.db, id) + .await + .map_err(|_| SimprintError::GrayReleaseNotFound)?; + + // 检查状态 + if release.status == "finished" { + return Ok(true); // 已经结束 + } + + if !["active", "paused"].contains(&release.status.as_str()) { + return Err(SimprintError::InvalidRequest(format!( + "灰度发布状态为 {},无法结束", + release.status + ))); + } + + // 更新状态为 finished,并设置结束时间 + let request = UpdateGrayReleaseRequest { + gray_release_id: id, + name: None, + description: None, + status: Some("finished".to_string()), + end_time: Some(chrono::Utc::now()), + max_machines: None, + priority: None, + strategy_type: None, + strategy_config: None, + }; + + let success = + crate::models::gray_releases::update_gray_release(&svc_ctx.db, id, &request).await?; + + if !success { + return Err(SimprintError::GrayReleaseNotFound); + } + + Ok(true) +} + +/// 取消灰度发布 +pub async fn cancel_gray_release_service(svc_ctx: &SvcCtx, id: i32) -> Result { + let release = crate::models::gray_releases::query_gray_release_by_id(&svc_ctx.db, id) + .await + .map_err(|_| SimprintError::GrayReleaseNotFound)?; + + // 检查状态 + if release.status == "cancelled" { + return Ok(true); // 已经取消 + } + + if !["pending", "active", "paused"].contains(&release.status.as_str()) { + return Err(SimprintError::InvalidRequest(format!( + "灰度发布状态为 {},无法取消", + release.status + ))); + } + + // 更新状态为 cancelled + let request = UpdateGrayReleaseRequest { + gray_release_id: id, + name: None, + description: None, + status: Some("cancelled".to_string()), + end_time: Some(chrono::Utc::now()), + max_machines: None, + priority: None, + strategy_type: None, + strategy_config: None, + }; + + let success = + crate::models::gray_releases::update_gray_release(&svc_ctx.db, id, &request).await?; + + if !success { + return Err(SimprintError::GrayReleaseNotFound); + } + + Ok(true) +} + +/// 删除灰度发布(物理删除) +pub async fn delete_gray_release_service(svc_ctx: &SvcCtx, id: i32) -> Result { + // 先检查是否存在 + let _release = crate::models::gray_releases::query_gray_release_by_id(&svc_ctx.db, id) + .await + .map_err(|_| SimprintError::GrayReleaseNotFound)?; + + // 先删除关联表,避免外键约束 + let _ = crate::models::machine_gray_allocations::delete_allocations_by_gray_release_id( + &svc_ctx.db, + id, + ) + .await?; + let _ = crate::models::gray_resources::delete_resources_by_gray_release_id(&svc_ctx.db, id) + .await?; + + let success = crate::models::gray_releases::delete_gray_release(&svc_ctx.db, id).await?; + + if !success { + return Err(SimprintError::GrayReleaseNotFound); + } + + Ok(true) +} diff --git a/src/services/gray_resources.rs b/src/services/gray_resources.rs index 6819faa..e7496fe 100644 --- a/src/services/gray_resources.rs +++ b/src/services/gray_resources.rs @@ -1,119 +1,145 @@ -use crate::{ - dto::gray_resources::GrayResource, entitys::gray_resources::*, errors::SimprintError, - svc_ctx::SvcCtx, -}; - -/// 添加灰度资源 -pub async fn add_gray_resource_service( - svc_ctx: &SvcCtx, - request: CreateGrayResourceRequest, -) -> Result { - let pool = &svc_ctx.db; - // 验证灰度发布是否存在 - crate::models::gray_releases::query_gray_release_by_id(pool, request.gray_release_id) - .await - .map_err(|_| SimprintError::GrayReleaseNotFound)?; - - // 验证版本是否存在 - crate::models::versions::query_version_by_id(pool, request.version_id) - .await - .map_err(|_| SimprintError::VersionNotFound)?; - - // 检查是否已存在该资源 - let existing_resources = crate::models::gray_resources::query_gray_resources_by_release_id( - pool, - request.gray_release_id, - ) - .await?; - - for resource in existing_resources { - if resource.version_id == request.version_id { - return Err(SimprintError::InvalidRequest( - "该版本已存在于灰度资源中".to_string(), - )); - } - } - - let id = crate::models::gray_resources::create_gray_resource(pool, &request).await?; - - Ok(id) -} - -/// 批量添加灰度资源 -pub async fn batch_add_gray_resources_service( - svc_ctx: &SvcCtx, - payload: BatchCreateGrayResourceRequest, -) -> Result { - let pool = &svc_ctx.db; - // 验证灰度发布是否存在 - crate::models::gray_releases::query_gray_release_by_id(pool, payload.gray_release_id) - .await - .map_err(|_| SimprintError::GrayReleaseNotFound)?; - - // 验证所有版本是否存在 - for version_id in &payload.version_ids { - crate::models::versions::query_version_by_id(pool, *version_id) - .await - .map_err(|_| SimprintError::VersionNotFound)?; - } - - // 检查是否有重复的版本ID - let mut seen = std::collections::HashSet::new(); - for version_id in &payload.version_ids { - if !seen.insert(version_id) { - return Err(SimprintError::InvalidRequest(format!( - "版本ID {} 重复", - version_id - ))); - } - } - - let count = crate::models::gray_resources::batch_create_gray_resources( - pool, - payload.gray_release_id, - &payload.version_ids, - ) - .await?; - - Ok(count) -} - -/// 移除灰度资源 -pub async fn remove_gray_resource_service( - svc_ctx: &SvcCtx, - payload: RemoveGrayResourceParams, -) -> Result { - let pool = &svc_ctx.db; - // 检查资源是否存在 - let _resource = crate::models::gray_resources::query_gray_resource_by_id(pool, payload.id) - .await - .map_err(|_| SimprintError::InvalidRequest("灰度资源不存在".to_string()))?; - - let success = crate::models::gray_resources::delete_gray_resource(pool, payload.id).await?; - - if !success { - return Err(SimprintError::InvalidRequest("删除失败".to_string())); - } - - Ok(true) -} - -/// 查询灰度资源列表 -pub async fn query_gray_resources_service( - svc_ctx: &SvcCtx, - payload: GetGrayResourceParams, -) -> Result, SimprintError> { - let pool = &svc_ctx.db; - // 验证灰度发布是否存在 - crate::models::gray_releases::query_gray_release_by_id(pool, payload.gray_release_id) - .await - .map_err(|_| SimprintError::GrayReleaseNotFound)?; - - let resources = crate::models::gray_resources::query_gray_resources_by_release_id( - pool, - payload.gray_release_id, - ) - .await?; - - Ok(resources) -} +use crate::{ + dto::gray_resources::GrayResource, entitys::gray_resources::*, errors::SimprintError, + svc_ctx::SvcCtx, +}; + +/// 添加灰度资源 +pub async fn add_gray_resource( + svc_ctx: &SvcCtx, + request: CreateGrayResourceRequest, +) -> Result { + // 验证灰度发布是否存在 + crate::models::gray_releases::query_gray_release_by_id(&svc_ctx.db, request.gray_release_id) + .await + .map_err(|_| SimprintError::GrayReleaseNotFound)?; + + // 验证版本是否存在 + crate::models::versions::query_version_by_id(&svc_ctx.db, request.version_id) + .await + .map_err(|_| SimprintError::VersionNotFound)?; + + // 检查是否已存在该资源 + let existing_resources = + crate::models::gray_resources::query_gray_resources(&svc_ctx.db, request.gray_release_id) + .await?; + + for resource in existing_resources { + if resource.version_id == request.version_id { + return Err(SimprintError::InvalidRequest( + "该版本已存在于灰度资源中".to_string(), + )); + } + } + + // 如果没有指定排序,使用最大排序值+1 + let sort_order = if request.sort_order.is_some() { + request.sort_order.unwrap() + } else { + let max_sort = crate::models::gray_resources::query_max_sort_order( + &svc_ctx.db, + request.gray_release_id, + ) + .await + .unwrap_or(0); + max_sort + 1 + }; + + let id = crate::models::gray_resources::create_gray_resource( + &svc_ctx.db, + &CreateGrayResourceRequest { + gray_release_id: request.gray_release_id, + version_id: request.version_id, + sort_order: Some(sort_order), + }, + ) + .await?; + + Ok(id) +} + +/// 批量添加灰度资源 +pub async fn batch_add_gray_resources( + svc_ctx: &SvcCtx, + request: BatchCreateGrayResourceRequest, +) -> Result { + // 验证灰度发布是否存在 + crate::models::gray_releases::query_gray_release_by_id(&svc_ctx.db, request.gray_release_id) + .await + .map_err(|_| SimprintError::GrayReleaseNotFound)?; + + // 验证所有版本是否存在 + for version_id in &request.version_ids { + crate::models::versions::query_version_by_id(&svc_ctx.db, *version_id) + .await + .map_err(|_| SimprintError::VersionNotFound)?; + } + + // 检查是否有重复的版本ID + let mut seen = std::collections::HashSet::new(); + for version_id in &request.version_ids { + if !seen.insert(version_id) { + return Err(SimprintError::InvalidRequest(format!( + "版本ID {} 重复", + version_id + ))); + } + } + + let count = + crate::models::gray_resources::batch_create_gray_resources(&svc_ctx.db, &request).await?; + + Ok(count) +} + +/// 移除灰度资源 +pub async fn remove_gray_resource(svc_ctx: &SvcCtx, id: i32) -> Result { + // 检查资源是否存在 + let _resource = crate::models::gray_resources::query_gray_resource_by_id(&svc_ctx.db, id) + .await + .map_err(|_| SimprintError::InvalidRequest("灰度资源不存在".to_string()))?; + + let success = crate::models::gray_resources::delete_gray_resource(&svc_ctx.db, id).await?; + + if !success { + return Err(SimprintError::InvalidRequest("删除失败".to_string())); + } + + Ok(true) +} + +/// 调整资源顺序 +pub async fn update_resource_sort_order( + svc_ctx: &SvcCtx, + id: i32, + sort_order: i32, +) -> Result { + // 检查资源是否存在 + let _resource = crate::models::gray_resources::query_gray_resource_by_id(&svc_ctx.db, id) + .await + .map_err(|_| SimprintError::InvalidRequest("灰度资源不存在".to_string()))?; + + let success = + crate::models::gray_resources::update_sort_order(&svc_ctx.db, id, sort_order).await?; + + if !success { + return Err(SimprintError::InvalidRequest("更新失败".to_string())); + } + + Ok(true) +} + +/// 查询灰度资源列表 +pub async fn query_gray_resources( + svc_ctx: &SvcCtx, + gray_release_id: i32, +) -> Result, SimprintError> { + // 验证灰度发布是否存在 + crate::models::gray_releases::query_gray_release_by_id(&svc_ctx.db, gray_release_id) + .await + .map_err(|_| SimprintError::GrayReleaseNotFound)?; + + let resources = + crate::models::gray_resources::query_gray_resources(&svc_ctx.db, gray_release_id).await?; + + Ok(resources) +} diff --git a/src/services/machine_gray_allocations.rs b/src/services/machine_gray_allocations.rs index a7f316a..d93eca9 100644 --- a/src/services/machine_gray_allocations.rs +++ b/src/services/machine_gray_allocations.rs @@ -1,176 +1,207 @@ -use crate::{ - dto::machine_gray_allocations::MachineGrayAllocation, entitys::machine_gray_allocations::*, - errors::SimprintError, svc_ctx::SvcCtx, -}; - -/// 分配机器到灰度 -pub async fn allocate_machine_service( - svc_ctx: &SvcCtx, - request: AllocateMachineRequest, -) -> Result { - let pool = &svc_ctx.db; - - // 验证机器码 - if request.machine_code.is_empty() { - return Err(SimprintError::InvalidRequest("机器码不能为空".to_string())); - } - - // 检查灰度发布是否存在 - crate::models::gray_releases::query_gray_release_by_id(pool, request.gray_release_id) - .await - .map_err(|_| SimprintError::GrayReleaseNotFound)?; - - // 检查是否已达到最大配额 - let release = - crate::models::gray_releases::query_gray_release_by_id(pool, request.gray_release_id) - .await?; - - if let Some(max_machines) = release.max_machines { - if release.allocated_count >= max_machines { - return Err(SimprintError::InvalidRequest( - "灰度发布已达到最大配额".to_string(), - )); - } - } - - // 分配机器 - let id = crate::models::machine_gray_allocations::allocate_machine_to_gray( - pool, - &request.machine_code, - request.gray_release_id, - request.effective_time, - request.notes.as_deref(), - ) - .await?; - - // 更新分配计数(如果之前不存在) - let existed = crate::models::machine_gray_allocations::check_machine_allocated( - pool, - &request.machine_code, - request.gray_release_id, - ) - .await?; - - if !existed { - let _ = - crate::models::gray_releases::increment_allocated_count(pool, request.gray_release_id) - .await?; - } - - Ok(id) -} - -/// 批量分配机器 -pub async fn batch_allocate_machines_service( - svc_ctx: &SvcCtx, - request: BatchAllocateMachineRequest, -) -> Result { - let pool = &svc_ctx.db; - - if request.machine_codes.is_empty() { - return Err(SimprintError::InvalidRequest( - "机器码列表不能为空".to_string(), - )); - } - - // 检查灰度发布是否存在 - crate::models::gray_releases::query_gray_release_by_id(pool, request.gray_release_id) - .await - .map_err(|_| SimprintError::GrayReleaseNotFound)?; - - // 分配机器 - let count = crate::models::machine_gray_allocations::batch_allocate_machines_to_gray( - pool, - &request.machine_codes, - request.gray_release_id, - request.effective_time, - request.notes.as_deref(), - ) - .await?; - - // 更新分配计数 - for _ in 0..count { - let _ = - crate::models::gray_releases::increment_allocated_count(pool, request.gray_release_id) - .await?; - } - - Ok(count) -} - -/// 取消机器分配 -pub async fn remove_allocation_service( - svc_ctx: &SvcCtx, - payload: RemoveAllocationRequest, -) -> Result { - let pool = &svc_ctx.db; - - // 移除分配 - let success = crate::models::machine_gray_allocations::deallocate_machine( - pool, - &payload.machine_code, - payload.gray_release_id, - ) - .await?; - - if success { - // 减少分配计数 - let _ = - crate::models::gray_releases::decrement_allocated_count(pool, payload.gray_release_id) - .await?; - } - - Ok(success) -} - -/// 查询机器是否在灰度中 -pub async fn is_machine_in_gray_service( - svc_ctx: &SvcCtx, - payload: IsMachineInGrayRequest, -) -> Result { - let pool = &svc_ctx.db; - - let is_in_gray = crate::models::machine_gray_allocations::check_machine_allocated( - pool, - &payload.machine_code, - payload.gray_release_id, - ) - .await?; - - Ok(is_in_gray) -} - -/// 查询机器的所有活跃分配 -pub async fn get_machine_allocations_service( - svc_ctx: &SvcCtx, - payload: GetMachineAllocationsRequest, -) -> Result, SimprintError> { - let pool = &svc_ctx.db; - - let allocations = crate::models::machine_gray_allocations::query_allocations_by_machine_code( - pool, - &payload.machine_code, - ) - .await?; - - Ok(allocations) -} - -/// 查询灰度发布的所有分配 -pub async fn get_release_allocations_service( - svc_ctx: &SvcCtx, - payload: GetReleaseAllocationsRequest, -) -> Result { - let pool = &svc_ctx.db; - - let (total, list) = - crate::models::machine_gray_allocations::query_allocations_by_gray_release_id( - pool, - payload.gray_release_id, - payload.page_num.unwrap_or(1), - payload.page_size.unwrap_or(20), - ) - .await?; - - Ok(AllocationListResponse { total, list }) -} +use crate::{ + entitys::machine_gray_allocations::*, errors::SimprintError, svc_ctx::SvcCtx, + utils::gray_checker::check_machine_by_strategy, +}; + +/// 分配机器到灰度 +pub async fn allocate_machine_service( + svc_ctx: &SvcCtx, + request: AllocateMachineRequest, +) -> Result { + // 验证机器码 + if request.machine_code.is_empty() { + return Err(SimprintError::InvalidRequest("机器码不能为空".to_string())); + } + + // 检查灰度发布是否存在 + crate::models::gray_releases::query_gray_release_by_id(&svc_ctx.db, request.gray_release_id) + .await + .map_err(|_| SimprintError::GrayReleaseNotFound)?; + + // 检查是否已达到最大配额 + let release = crate::models::gray_releases::query_gray_release_by_id( + &svc_ctx.db, + request.gray_release_id, + ) + .await?; + + if let Some(max_machines) = release.max_machines { + if release.allocated_count >= max_machines { + return Err(SimprintError::InvalidRequest( + "灰度发布已达到最大配额".to_string(), + )); + } + } + + // 分配机器 + let success = + crate::models::machine_gray_allocations::allocate_machine(&svc_ctx.db, &request).await?; + + if success { + // 更新分配计数 + let _ = crate::models::gray_releases::increment_allocated_count( + &svc_ctx.db, + request.gray_release_id, + ) + .await?; + } + + Ok(success) +} + +/// 批量分配机器 +pub async fn batch_allocate_machines_service( + svc_ctx: &SvcCtx, + request: BatchAllocateMachineRequest, +) -> Result { + if request.machine_codes.is_empty() { + return Err(SimprintError::InvalidRequest( + "机器码列表不能为空".to_string(), + )); + } + + // 检查灰度发布是否存在 + crate::models::gray_releases::query_gray_release_by_id(&svc_ctx.db, request.gray_release_id) + .await + .map_err(|_| SimprintError::GrayReleaseNotFound)?; + + // 分配机器 + let count = + crate::models::machine_gray_allocations::batch_allocate_machines(&svc_ctx.db, &request) + .await?; + + // 更新分配计数 + for _ in 0..count { + let _ = crate::models::gray_releases::increment_allocated_count( + &svc_ctx.db, + request.gray_release_id, + ) + .await?; + } + + Ok(count) +} + +/// 取消机器分配 +pub async fn remove_allocation_service( + svc_ctx: &SvcCtx, + machine_code: String, + gray_release_id: i32, +) -> Result { + // 检查分配是否存在 + let allocation = crate::models::machine_gray_allocations::query_allocation( + &svc_ctx.db, + &machine_code, + gray_release_id, + ) + .await?; + + if allocation.is_none() { + return Err(SimprintError::Other("分配不存在".to_string())); + } + + // 移除分配 + let success = crate::models::machine_gray_allocations::remove_allocation( + &svc_ctx.db, + &machine_code, + gray_release_id, + ) + .await?; + + if success { + // 减少分配计数 + let _ = + crate::models::gray_releases::decrement_allocated_count(&svc_ctx.db, gray_release_id) + .await?; + } + + Ok(success) +} + +/// 查询机器是否在灰度中(旧方法,仅用于白名单) +pub async fn is_machine_in_gray_service( + svc_ctx: &SvcCtx, + machine_code: String, + gray_release_id: i32, +) -> Result { + let is_in_gray = crate::models::machine_gray_allocations::is_machine_in_gray( + &svc_ctx.db, + &machine_code, + gray_release_id, + ) + .await?; + + Ok(is_in_gray) +} + +/// 检查机器是否在任意活跃灰度中(优化版:分策略类型查询) +pub async fn check_machine_in_any_gray_service( + svc_ctx: &SvcCtx, + machine_code: String, +) -> Result< + ( + bool, + Option, + Option, + ), + SimprintError, +> { + // 1. 优先检查白名单(最快,单次 JOIN 查询) + if let Some(release) = + crate::models::gray_releases::check_whitelist_strategy(&svc_ctx.db, &machine_code).await? + { + return Ok((true, Some(release.id), Some(release))); + } + + // 2. 检查标签策略(较快,单次数据库过滤) + if let Some(release) = + crate::models::gray_releases::check_tags_strategy(&svc_ctx.db, &machine_code).await? + { + return Ok((true, Some(release.id), Some(release))); + } + + // 3. 检查动态策略(百分比/随机,需要计算但数据量小) + let dynamic_releases = + crate::models::gray_releases::query_dynamic_strategy_releases(&svc_ctx.db).await?; + + for release in dynamic_releases { + if check_machine_by_strategy(&machine_code, None, &release)? { + return Ok((true, Some(release.id), Some(release))); + } + } + + // 4. 未命中任何灰度 + Ok((false, None, None)) +} + +/// 查询机器的所有活跃分配 +pub async fn get_machine_allocations_service( + svc_ctx: &SvcCtx, + machine_code: String, +) -> Result, SimprintError> { + let allocations = crate::models::machine_gray_allocations::query_machine_allocations( + &svc_ctx.db, + &machine_code, + ) + .await?; + + Ok(allocations) +} + +/// 查询灰度发布的所有分配 +pub async fn get_release_allocations_service( + svc_ctx: &SvcCtx, + gray_release_id: i32, + page_num: i32, + page_size: i32, +) -> Result { + let (total, list) = crate::models::machine_gray_allocations::query_release_allocations( + &svc_ctx.db, + gray_release_id, + page_num, + page_size, + ) + .await?; + + Ok(AllocationListResponse { total, list }) +} diff --git a/src/services/machine_tags.rs b/src/services/machine_tags.rs new file mode 100644 index 0000000..c75ac58 --- /dev/null +++ b/src/services/machine_tags.rs @@ -0,0 +1,55 @@ +use crate::{ + dto::machine_tags::MachineTag, entitys::machine_tags::QueryTagsParams, + errors::SimprintError, svc_ctx::SvcCtx, +}; + +/// 查询所有标签(去重) +pub async fn query_all_tags_service( + svc_ctx: &SvcCtx, +) -> Result, SimprintError> { + let params = QueryTagsParams { + category: None, + is_active: Some(true), + }; + + let tags = crate::models::machine_tags::query_all_distinct_tags(&svc_ctx.db, ¶ms).await?; + + Ok(tags) +} + +/// 根据机器用户ID查询标签 +pub async fn query_tags_by_machine_user_id_service( + svc_ctx: &SvcCtx, + machine_user_id: i32, +) -> Result, SimprintError> { + let tags = + crate::models::machine_tags::query_tags_by_machine_user_id(&svc_ctx.db, machine_user_id) + .await?; + + Ok(tags) +} + +/// 绑定标签到机器用户(内部使用,处理标签字符串) +pub async fn bind_tags_to_machine_user_service( + svc_ctx: &SvcCtx, + machine_user_id: i32, + tag_names: &[String], +) -> Result { + if tag_names.is_empty() { + return Ok(0); + } + + // 批量插入或获取标签 + let tag_ids = + crate::models::machine_tags::batch_insert_or_get_tags(&svc_ctx.db, tag_names).await?; + + // 绑定标签到机器用户 + let count = crate::models::machine_tags::bind_tags_to_machine_user( + &svc_ctx.db, + machine_user_id, + &tag_ids, + ) + .await?; + + Ok(count) +} diff --git a/src/services/machine_users.rs b/src/services/machine_users.rs index ccaad9a..ba850b3 100644 --- a/src/services/machine_users.rs +++ b/src/services/machine_users.rs @@ -1,281 +1,265 @@ -use crate::{ - dto::machine_users::MachineUser, entitys::machine_users::*, errors::SimprintError, - svc_ctx::SvcCtx, -}; -use serde_json; -use uuid::Uuid; - -/// 注册机器 -pub async fn register_machine_service( - svc_ctx: &SvcCtx, - request: CreateMachineUserRequest, -) -> Result { - let pool = &svc_ctx.db; - if request.machine_code.is_empty() { - return Err(SimprintError::InvalidRequest("机器码不能为空".to_string())); - } - - // 如果提供了用户UUID,检查是否已存在绑定 - if let Some(user_uuid) = request.user_uuid { - let existing = crate::models::machine_users::query_machine_user_by_code_and_user( - pool, - &request.machine_code, - &user_uuid, - ) - .await?; - - if let Some(machine) = existing { - // 已存在,一次性更新绑定时间和版本信息 - let version_info = request - .version_info - .as_ref() - .and_then(|v| serde_json::from_str::(v).ok()); - - crate::models::machine_users::update_machine_bind_and_version( - pool, - &request.machine_code, - &user_uuid, - version_info.as_ref(), - ) - .await?; - - return Ok(machine.id); - } - } - - // 不存在,一次性插入所有信息(包括 version_info) - let id = crate::models::machine_users::insert_machine_user(pool, &request).await?; - Ok(id) -} - -/// 根据机器码查询机器信息 -pub async fn get_machine_by_code_service( - svc_ctx: &SvcCtx, - machine_code: String, -) -> Result { - let pool = &svc_ctx.db; - let machine = crate::models::machine_users::query_machine_user_by_code(pool, &machine_code) - .await - .map_err(|_| SimprintError::MachineNotFound)?; - - Ok(machine) -} - -/// 根据ID查询机器信息 -pub async fn get_machine_by_id_service( - svc_ctx: &SvcCtx, - id: i32, -) -> Result { - let pool = &svc_ctx.db; - let machine = crate::models::machine_users::query_machine_user_by_id(pool, id) - .await? - .ok_or(SimprintError::MachineNotFound)?; - - Ok(machine) -} - -/// 查询用户的机器列表 -pub async fn get_machines_by_user_uuid_service( - svc_ctx: &SvcCtx, - user_uuid: Uuid, -) -> Result, SimprintError> { - let pool = &svc_ctx.db; - - let machines = - crate::models::machine_users::query_machines_by_user_uuid(pool, &user_uuid).await?; - - Ok(machines) -} - -/// 查询机器列表 -pub async fn query_machines_service( - svc_ctx: &SvcCtx, - params: QueryMachinesParams, -) -> Result { - let pool = &svc_ctx.db; - - let user_uuid = params - .user_uuid - .as_ref() - .map(|s| Uuid::parse_str(s)) - .transpose() - .map_err(|_| SimprintError::InvalidRequest("无效的用户UUID格式".to_string()))?; - - let (total, list) = crate::models::machine_users::query_machine_users( - pool, - user_uuid.as_ref(), - params.platform.as_deref(), - params.status.as_deref(), - params.page_num.unwrap_or(1), - params.page_size.unwrap_or(20), - ) - .await?; - - Ok(MachineUserListResponse { total, list }) -} - -/// 更新机器信息 -pub async fn update_machine_info_service( - svc_ctx: &SvcCtx, - request: UpdateMachineUserRequest, -) -> Result { - let pool = &svc_ctx.db; - - // 检查机器是否存在 - crate::models::machine_users::query_machine_user_by_id(pool, request.id) - .await? - .ok_or(SimprintError::MachineNotFound)?; - - // 更新机器信息 - let success = - crate::models::machine_users::update_machine_user(pool, request.id, &request).await?; - - if !success { - return Err(SimprintError::MachineNotFound); - } - - Ok(true) -} - -/// 绑定用户到机器 -pub async fn bind_user_service( - svc_ctx: &SvcCtx, - payload: BindMachineRequest, -) -> Result { - let pool = &svc_ctx.db; - - // 检查机器是否存在 - crate::models::machine_users::query_machine_user_by_code(pool, &payload.machine_code) - .await - .map_err(|_| SimprintError::MachineNotFound)?; - - // 绑定用户 - let success = crate::models::machine_users::bind_user_to_machine( - pool, - &payload.machine_code, - &payload.user_uuid, - ) - .await?; - - if !success { - return Err(SimprintError::MachineNotFound); - } - - Ok(true) -} - -/// 解绑用户 -pub async fn unbind_user_service( - svc_ctx: &SvcCtx, - payload: BindMachineRequest, -) -> Result { - let pool = &svc_ctx.db; - - // 检查绑定是否存在 - let binding = crate::models::machine_users::query_machine_user_by_code_and_user( - pool, - &payload.machine_code, - &payload.user_uuid, - ) - .await?; - - if binding.is_none() { - return Err(SimprintError::MachineNotBound); - } - - // 解绑用户 - let success = crate::models::machine_users::unbind_user_from_machine( - pool, - &payload.machine_code, - &payload.user_uuid, - ) - .await?; - - if !success { - return Err(SimprintError::MachineNotFound); - } - - Ok(true) -} - -/// 获取机器版本信息 -pub async fn get_machine_version_info_service( - svc_ctx: &SvcCtx, - machine_code: String, - user_uuid: Uuid, -) -> Result { - let pool = &svc_ctx.db; - - // 查询特定用户的机器记录 - let machine = crate::models::machine_users::query_machine_user_by_code_and_user( - pool, - &machine_code, - &user_uuid, - ) - .await? - .ok_or(SimprintError::MachineNotBound)?; - - Ok(machine.version_info.unwrap_or(serde_json::json!({}))) -} - -/// 拉黑机器 -pub async fn allow_or_blacklist_machine_service( - svc_ctx: &SvcCtx, - payload: AllowOrBlacklistMachineRequest, -) -> Result { - let pool = &svc_ctx.db; - let success = crate::models::machine_users::allow_or_blacklist_machine( - pool, - &payload.machine_code, - payload.allow, - ) - .await?; - - Ok(success) -} - -/// 判断机器是否允许 -pub async fn machine_not_allow_service( - svc_ctx: &SvcCtx, - machine_code: String, -) -> Result { - let pool = &svc_ctx.db; - let allow = crate::models::machine_users::machine_not_allow(pool, &machine_code).await?; - - Ok(allow) -} - -/// 更新机器版本信息 -pub async fn update_machine_version_info_service( - svc_ctx: &SvcCtx, - payload: UpdateMachineVersionRequest, -) -> Result { - let pool = &svc_ctx.db; - - // 检查机器是否存在 - crate::models::machine_users::query_machine_user_by_code(pool, &payload.machine_code) - .await - .map_err(|_| SimprintError::MachineNotFound)?; - - // 1. 更新整机的 version_info(所有用户记录) - let success = crate::models::machine_users::update_machine_version_info( - pool, - &payload.machine_code, - &payload.version_info, - ) - .await?; - - if !success { - return Err(SimprintError::MachineNotFound); - } - - // 2. 只更新当前用户的 updated_at - let _ = crate::models::machine_users::update_user_bind_time( - pool, - &payload.machine_code, - &payload.user_uuid, - ) - .await?; - - Ok(true) -} +use crate::{ + dto::machine_users::MachineUser, entitys::machine_users::*, errors::SimprintError, + svc_ctx::SvcCtx, +}; +use serde_json; +use uuid::Uuid; + +/// 注册机器 +pub async fn register_machine( + svc_ctx: &SvcCtx, + request: CreateMachineUserRequest, +) -> Result { + if let Err(e) = match (request.machine_code.is_empty(), request.user_uuid) { + (true, _) => Err("机器码不能为空".to_string()), + (_, None) => Err("用户UUID不能为空".to_string()), + _ => Ok(""), + } { + return Err(SimprintError::InvalidRequest(e)); + } + let user_uuid = request.user_uuid.unwrap_or_default(); + + // 如果提供了用户UUID,检查是否已存在绑定 + let existing = crate::models::machine_users::query_machine_user_by_code_and_user( + &svc_ctx.db, + &request.machine_code, + &user_uuid, + ) + .await?; + + if let Some(machine) = existing { + // 已存在,一次性更新绑定时间和版本信息 + let version_info = request + .version_info + .as_ref() + .and_then(|v| serde_json::from_str::(v).ok()); + + crate::models::machine_users::update_machine_bind_and_version( + &svc_ctx.db, + &request.machine_code, + &user_uuid, + version_info.as_ref(), + ) + .await?; + + return Ok(machine.id); + } + + // 不存在,一次性插入所有信息(包括 version_info) + let id = crate::models::machine_users::insert_machine_user(&svc_ctx.db, &request).await?; + Ok(id) +} + +/// 根据机器码查询机器信息 +pub async fn get_machine_by_code( + svc_ctx: &SvcCtx, + machine_code: String, +) -> Result { + let machine = + crate::models::machine_users::query_machine_user_by_code(&svc_ctx.db, &machine_code) + .await + .map_err(|_| SimprintError::MachineNotFound)?; + + Ok(machine) +} + +/// 根据ID查询机器信息 +pub async fn get_machine_by_id( + svc_ctx: &SvcCtx, + id: i32, +) -> Result { + let machine = crate::models::machine_users::query_machine_user_by_id(&svc_ctx.db, id) + .await + .map_err(|_| SimprintError::MachineNotFound)?; + + Ok(machine) +} + +/// 查询用户的机器列表 +pub async fn get_machines_by_user_uuid( + svc_ctx: &SvcCtx, + user_uuid: Uuid, +) -> Result, SimprintError> { + let machines = + crate::models::machine_users::query_machines_by_user_uuid(&svc_ctx.db, &user_uuid).await?; + + Ok(machines) +} + +/// 查询机器列表 +pub async fn query_machines_service( + svc_ctx: &SvcCtx, + params: QueryMachineUserParams, + page_num: i32, + page_size: i32, +) -> Result { + let user_uuid = params + .user_uuid + .as_ref() + .map(|s| Uuid::parse_str(s)) + .transpose() + .map_err(|_| SimprintError::InvalidRequest("无效的用户UUID格式".to_string()))?; + + let (total, list) = crate::models::machine_users::query_machine_users( + &svc_ctx.db, + user_uuid.as_ref(), + params.platform.as_deref(), + params.status.as_deref(), + page_num, + page_size, + ) + .await?; + + Ok(MachineUserListResponse { total, list }) +} + +/// 更新机器信息 +pub async fn update_machine_info_service( + svc_ctx: &SvcCtx, + id: i32, + request: UpdateMachineUserRequest, +) -> Result { + // 检查机器是否存在 + crate::models::machine_users::query_machine_user_by_id(&svc_ctx.db, id) + .await + .map_err(|_| SimprintError::MachineNotFound)?; + + // 更新机器信息 + let success = + crate::models::machine_users::update_machine_user(&svc_ctx.db, id, &request).await?; + + if !success { + return Err(SimprintError::MachineNotFound); + } + + Ok(true) +} + +/// 绑定用户到机器 +pub async fn bind_user_service( + svc_ctx: &SvcCtx, + machine_code: String, + user_uuid: Uuid, +) -> Result { + // 检查机器是否存在 + crate::models::machine_users::query_machine_user_by_code(&svc_ctx.db, &machine_code) + .await + .map_err(|_| SimprintError::MachineNotFound)?; + + // 绑定用户 + let success = + crate::models::machine_users::bind_user_to_machine(&svc_ctx.db, &machine_code, &user_uuid) + .await?; + + if !success { + return Err(SimprintError::MachineNotFound); + } + + Ok(true) +} + +/// 解绑用户 +pub async fn unbind_user_service( + svc_ctx: &SvcCtx, + machine_code: String, + user_uuid: Uuid, +) -> Result { + // 检查绑定是否存在 + let binding = crate::models::machine_users::query_machine_user_by_code_and_user( + &svc_ctx.db, + &machine_code, + &user_uuid, + ) + .await?; + + if binding.is_none() { + return Err(SimprintError::MachineNotBound); + } + + // 解绑用户 + let success = crate::models::machine_users::unbind_user_from_machine( + &svc_ctx.db, + &machine_code, + &user_uuid, + ) + .await?; + + if !success { + return Err(SimprintError::MachineNotFound); + } + + Ok(true) +} + +/// 获取机器版本信息 +pub async fn get_machine_version_info( + svc_ctx: &SvcCtx, + machine_code: String, + user_uuid: Uuid, +) -> Result { + // 查询特定用户的机器记录 + let machine = crate::models::machine_users::query_machine_user_by_code_and_user( + &svc_ctx.db, + &machine_code, + &user_uuid, + ) + .await? + .ok_or(SimprintError::MachineNotBound)?; + + Ok(machine.version_info.unwrap_or(serde_json::json!({}))) +} + +/// 拉黑机器 +pub async fn allow_or_blacklist_machine( + svc_ctx: &SvcCtx, + machine_code: String, + allow: bool, +) -> Result { + let success = + crate::models::machine_users::allow_or_blacklist_machine(&svc_ctx.db, &machine_code, allow) + .await?; + + Ok(success) +} + +/// 判断机器是否允许 +pub async fn machine_not_allow( + svc_ctx: &SvcCtx, + machine_code: String, +) -> Result { + let allow = crate::models::machine_users::machine_not_allow(&svc_ctx.db, &machine_code).await?; + + Ok(allow) +} + +/// 更新机器版本信息 +pub async fn update_machine_version_info_service( + svc_ctx: &SvcCtx, + machine_code: String, + user_uuid: Uuid, + version_info: serde_json::Value, +) -> Result { + // 检查机器是否存在 + crate::models::machine_users::query_machine_user_by_code(&svc_ctx.db, &machine_code) + .await + .map_err(|_| SimprintError::MachineNotFound)?; + + // 1. 更新整机的 version_info(所有用户记录) + let success = crate::models::machine_users::update_machine_version_info( + &svc_ctx.db, + &machine_code, + &version_info, + ) + .await?; + + if !success { + return Err(SimprintError::MachineNotFound); + } + + // 2. 只更新当前用户的 updated_at + let _ = + crate::models::machine_users::update_user_bind_time(&svc_ctx.db, &machine_code, &user_uuid) + .await?; + + Ok(true) +} diff --git a/src/services/maintenance.rs b/src/services/maintenance.rs index 2099f27..3862fe0 100644 --- a/src/services/maintenance.rs +++ b/src/services/maintenance.rs @@ -1,74 +1,78 @@ -use crate::{ - dto::maintenance::Maintenance, entitys::maintenance::*, errors::SimprintError, svc_ctx::SvcCtx, -}; - -/// 创建维护 -pub async fn create_maintenance_service( - svc_ctx: &SvcCtx, - request: CreateMaintenanceRequest, -) -> Result { - let pool = &svc_ctx.db; - let maintenance = crate::models::maintenance::create_maintenance(pool, request).await?; - - Ok(maintenance.id) -} - -/// 根据ID查询维护 -pub async fn get_maintenance_by_id_service( - svc_ctx: &SvcCtx, - payload: GetMaintenanceParams, -) -> Result { - let pool = &svc_ctx.db; - let maintenance = crate::models::maintenance::get_maintenance_by_id(pool, payload.id) - .await? - .ok_or(SimprintError::MaintenanceNotFound)?; - - Ok(maintenance) -} - -/// 查询维护列表 -pub async fn list_maintenances_service( - svc_ctx: &SvcCtx, - payload: QueryMaintenancesParams, -) -> Result { - let pool = &svc_ctx.db; - let maintenances = crate::models::maintenance::list_maintenances( - pool, - payload.limit.map(|l| l as i64), - payload.offset.map(|o| o as i64), - ) - .await?; - - Ok(MaintenanceListResponse { list: maintenances }) -} - -/// 更新维护状态 -pub async fn update_maintenance_status_service( - svc_ctx: &SvcCtx, - payload: UpdateMaintenanceStatusRequest, -) -> Result { - let pool = &svc_ctx.db; - let success = - crate::models::maintenance::update_maintenance_status(pool, payload.id, &payload.status) - .await?; - - Ok(success) -} - -/// 结束维护 -pub async fn end_maintenance_service(svc_ctx: &SvcCtx) -> Result { - let pool = &svc_ctx.db; - let success = crate::models::maintenance::end_maintenance(pool).await?; - - Ok(success) -} - -/// 获取当前活跃维护 -pub async fn get_active_maintenance_service( - svc_ctx: &SvcCtx, -) -> Result, SimprintError> { - let pool = &svc_ctx.db; - let maintenance = crate::models::maintenance::get_active_maintenances(pool).await?; - - Ok(maintenance) -} +use crate::{ + dto::maintenance::Maintenance, entitys::maintenance::CreateMaintenanceRequest, + errors::SimprintError, svc_ctx::SvcCtx, +}; + +/// 创建维护 +pub async fn create_maintenance_service( + svc_ctx: &SvcCtx, + request: CreateMaintenanceRequest, +) -> Result { + let maintenance = crate::models::maintenance::create_maintenance(&svc_ctx.db, request) + .await + .map_err(|e| SimprintError::InvalidRequest(format!("创建维护失败: {}", e)))?; + + Ok(maintenance.id) +} + +/// 根据ID查询维护 +pub async fn get_maintenance_by_id_service( + svc_ctx: &SvcCtx, + id: i64, +) -> Result, SimprintError> { + let maintenance = crate::models::maintenance::get_maintenance_by_id(&svc_ctx.db, id) + .await + .map_err(|e| SimprintError::InvalidRequest(format!("查询维护失败: {}", e)))?; + + Ok(maintenance) +} + +/// 查询维护列表 +pub async fn list_maintenances_service( + svc_ctx: &SvcCtx, + limit: Option, + offset: Option, +) -> Result, SimprintError> { + let maintenances = crate::models::maintenance::list_maintenances( + &svc_ctx.db, + limit.map(|l| l as i64), + offset.map(|o| o as i64), + ) + .await + .map_err(|e| SimprintError::InvalidRequest(format!("查询维护列表失败: {}", e)))?; + + Ok(maintenances) +} + +/// 更新维护状态 +pub async fn update_maintenance_status_service( + svc_ctx: &SvcCtx, + id: i64, + status: String, +) -> Result { + let success = crate::models::maintenance::update_maintenance_status(&svc_ctx.db, id, &status) + .await + .map_err(|e| SimprintError::InvalidRequest(format!("更新维护状态失败: {}", e)))?; + + Ok(success) +} + +/// 结束维护 +pub async fn end_maintenance_service(svc_ctx: &SvcCtx) -> Result { + let success = crate::models::maintenance::end_maintenance(&svc_ctx.db) + .await + .map_err(|e| SimprintError::InvalidRequest(format!("结束维护失败: {}", e)))?; + + Ok(success) +} + +/// 获取当前活跃维护 +pub async fn get_active_maintenance_service( + svc_ctx: &SvcCtx, +) -> Result, SimprintError> { + let maintenance = crate::models::maintenance::get_active_maintenances(&svc_ctx.db) + .await + .map_err(|e| SimprintError::InvalidRequest(format!("查询活跃维护失败: {}", e)))?; + + Ok(maintenance) +} diff --git a/src/services/strategy_types.rs b/src/services/strategy_types.rs index eeeb1c5..6aa9487 100644 --- a/src/services/strategy_types.rs +++ b/src/services/strategy_types.rs @@ -1,57 +1,50 @@ -use crate::{ - dto::strategy_types::StrategyType, entitys::strategy_types::*, errors::SimprintError, - svc_ctx::SvcCtx, -}; - -/// 根据ID查询策略类型 -pub async fn get_strategy_type_by_id_service( - svc_ctx: &SvcCtx, - payload: GetStrategyTypeParams, -) -> Result { - let pool = &svc_ctx.db; - let id = payload.id.ok_or(SimprintError::StrategyTypeNotFound)?; - let strategy_type = crate::models::strategy_types::query_strategy_type_by_id(pool, id) - .await - .map_err(|_| SimprintError::StrategyTypeNotFound)?; - - Ok(strategy_type) -} - -/// 根据code查询策略类型 -pub async fn get_strategy_type_by_code_service( - svc_ctx: &SvcCtx, - payload: GetStrategyTypeParams, -) -> Result { - let pool = &svc_ctx.db; - let code = payload.code.ok_or(SimprintError::StrategyTypeNotFound)?; - let strategy_type = crate::models::strategy_types::query_strategy_type_by_code(pool, &code) - .await - .map_err(|_| SimprintError::StrategyTypeNotFound)?; - - Ok(strategy_type) -} - -/// 查询所有可用策略类型 -pub async fn query_available_strategy_types_service( - svc_ctx: &SvcCtx, -) -> Result { - let pool = &svc_ctx.db; - let list = crate::models::strategy_types::query_available_strategy_types(pool).await?; - - Ok(StrategyTypeListResponse { list }) -} - -/// 根据分类查询策略类型 -pub async fn query_strategy_types_by_category_service( - svc_ctx: &SvcCtx, - payload: QueryStrategyTypesParams, -) -> Result { - let pool = &svc_ctx.db; - let category = payload.category.ok_or(SimprintError::InvalidRequest( - "分类参数不能为空".to_string(), - ))?; - let list = - crate::models::strategy_types::query_strategy_types_by_category(pool, &category).await?; - - Ok(StrategyTypeListResponse { list }) -} +use crate::{ + dto::strategy_types::StrategyType, entitys::strategy_types::*, errors::SimprintError, + svc_ctx::SvcCtx, +}; + +/// 根据ID查询策略类型 +pub async fn get_strategy_type_by_id( + svc_ctx: &SvcCtx, + id: i32, +) -> Result { + let strategy_type = crate::models::strategy_types::query_strategy_type_by_id(&svc_ctx.db, id) + .await + .map_err(|_| SimprintError::StrategyTypeNotFound)?; + + Ok(strategy_type) +} + +/// 根据code查询策略类型 +pub async fn get_strategy_type_by_code( + svc_ctx: &SvcCtx, + code: &str, +) -> Result { + let strategy_type = + crate::models::strategy_types::query_strategy_type_by_code(&svc_ctx.db, &code) + .await + .map_err(|_| SimprintError::StrategyTypeNotFound)?; + + Ok(strategy_type) +} + +/// 查询所有可用策略类型 +pub async fn query_available_strategy_types( + svc_ctx: &SvcCtx, +) -> Result { + let list = crate::models::strategy_types::query_available_strategy_types(&svc_ctx.db).await?; + + Ok(StrategyTypeListResponse { list }) +} + +/// 根据分类查询策略类型 +pub async fn query_strategy_types_by_category( + svc_ctx: &SvcCtx, + category: &str, +) -> Result { + let list = + crate::models::strategy_types::query_strategy_types_by_category(&svc_ctx.db, &category) + .await?; + + Ok(StrategyTypeListResponse { list }) +} diff --git a/src/services/tags.rs b/src/services/tags.rs index 55ad148..0f782a8 100644 --- a/src/services/tags.rs +++ b/src/services/tags.rs @@ -1,7 +1,7 @@ use uuid::Uuid; use crate::dto::TagDto; -use crate::entitys::{CreateTagRequest, UpdateTagRequest}; +use crate::entitys::tags::{CreateTagRequest, UpdateTagRequest}; use crate::models; use crate::svc_ctx::SvcCtx; diff --git a/src/services/users.rs b/src/services/users.rs index c4e5282..368db8a 100644 --- a/src/services/users.rs +++ b/src/services/users.rs @@ -5,14 +5,14 @@ use crate::caches::{ set_reset_password_code, set_user_public_key, }; use crate::entitys::{ - LoginRequest, LoginResponse, MachineInfo, RegisterRequest, RegisterResponse, - ResetPasswordRequest, UpdatePasswordRequest, UpdateUserRequest, UserResponse, + CreateMachineUserRequest, LoginRequest, LoginResponse, MachineInfo, RegisterRequest, + RegisterResponse, ResetPasswordRequest, UpdatePasswordRequest, UpdateUserRequest, UserResponse, }; use crate::models::{ billing, user::{ - create_user_with_info, fetch_user_by_uuid, fetch_user_info_by_email, fetch_user_info_by_uuid, - update_password, update_user_info, + create_user_with_info, fetch_user_by_uuid, fetch_user_info_by_email, + fetch_user_info_by_uuid, update_password, update_user_info, }, }; use crate::services::messages; @@ -51,15 +51,16 @@ async fn common_login_logic( // 3. 如果提供了机器信息,注册/绑定机器用户 if let Some(ref machine_info) = machine_info { if !machine_info.machine_code.is_empty() { - let _ = crate::services::machine_users::register_machine_service( + let _ = crate::services::machine_users::register_machine( svc_ctx, - crate::entitys::machine_users::CreateMachineUserRequest { + CreateMachineUserRequest { machine_code: machine_info.machine_code.clone(), user_uuid: Some(user_uuid), platform: machine_info.platform.clone(), hardware_hash: machine_info.hardware_hash.clone(), hardware_raw: machine_info.hardware_raw.clone(), version_info: machine_info.version_info.clone(), + tags: None, }, ) .await; diff --git a/src/services/version_types.rs b/src/services/version_types.rs index df1090f..324ecc7 100644 --- a/src/services/version_types.rs +++ b/src/services/version_types.rs @@ -1,103 +1,123 @@ -use crate::{ - dto::version_types::VersionType, entitys::version_types::*, errors::SimprintError, - svc_ctx::SvcCtx, -}; - -/// 创建版本类型 -pub async fn create_version_type_service( - svc_ctx: &SvcCtx, - request: CreateVersionTypeRequest, -) -> Result { - let pool = &svc_ctx.db; - if request.type_code.is_empty() { - return Err(SimprintError::InvalidRequest( - "类型代码不能为空".to_string(), - )); - } - - // 检查类型代码是否已存在 - let existing = - crate::models::version_types::query_version_type_by_code(pool, &request.type_code) - .await - .ok(); - - if existing.is_some() { - return Err(SimprintError::InvalidRequest( - "版本类型代码已存在".to_string(), - )); - } - - let id = crate::models::version_types::insert_version_type(pool, &request).await?; - - Ok(id) -} - -/// 根据ID查询版本类型 -pub async fn get_version_type_by_id_service( - svc_ctx: &SvcCtx, - payload: GetVersionTypeParams, -) -> Result { - let pool = &svc_ctx.db; - let version_type = crate::models::version_types::query_version_type_by_id(pool, payload.id) - .await? - .ok_or(SimprintError::VersionTypeNotFound)?; - - Ok(version_type) -} - -/// 查询所有激活的版本类型 -pub async fn get_active_version_types_service( - svc_ctx: &SvcCtx, -) -> Result { - let pool = &svc_ctx.db; - let list = crate::models::version_types::query_active_version_types(pool).await?; - - Ok(VersionTypeListResponse { list }) -} - -/// 查询所有版本类型 -pub async fn get_all_version_types_service( - svc_ctx: &SvcCtx, -) -> Result { - let pool = &svc_ctx.db; - let list = crate::models::version_types::query_all_version_types(pool).await?; - - Ok(VersionTypeListResponse { list }) -} - -/// 更新版本类型 -pub async fn update_version_type_service( - svc_ctx: &SvcCtx, - request: UpdateVersionTypeRequest, -) -> Result { - let pool = &svc_ctx.db; - // 检查版本类型是否存在 - crate::models::version_types::query_version_type_by_id(pool, request.id) - .await? - .ok_or(SimprintError::VersionTypeNotFound)?; - - let success = - crate::models::version_types::update_version_type(pool, request.id, &request).await?; - - if !success { - return Err(SimprintError::VersionTypeNotFound); - } - - Ok(true) -} - -/// 删除版本类型 -pub async fn delete_version_type_service( - svc_ctx: &SvcCtx, - payload: DeleteVersionTypeRequest, -) -> Result { - let pool = &svc_ctx.db; - // 检查版本类型是否存在 - crate::models::version_types::query_version_type_by_id(pool, payload.id) - .await? - .ok_or(SimprintError::VersionTypeNotFound)?; - - let success = crate::models::version_types::delete_version_type(pool, payload.id).await?; - - Ok(success) -} +use crate::{ + dto::version_types::VersionType, entitys::version_types::*, errors::SimprintError, + svc_ctx::SvcCtx, +}; + +/// 创建版本类型 +pub async fn create_version_type( + svc_ctx: &SvcCtx, + request: CreateVersionTypeRequest, +) -> Result { + // 验证类型代码 + if request.type_code.is_empty() { + return Err(SimprintError::Other("版本类型代码不能为空".to_string())); + } + + // 检查版本类型是否已存在 + let exists = + crate::models::version_types::query_version_type_by_code(&svc_ctx.db, &request.type_code) + .await + .ok(); + + if exists.is_some() { + return Err(SimprintError::Other("版本类型已存在".to_string())); + } + + // 创建版本类型 + let id = crate::models::version_types::insert_version_type(&svc_ctx.db, &request).await?; + + Ok(id) +} + +/// 根据ID查询版本类型 +pub async fn get_version_type_by_id( + svc_ctx: &SvcCtx, + id: i32, +) -> Result { + let version_type = crate::models::version_types::query_version_type_by_id(&svc_ctx.db, id) + .await + .map_err(|_| SimprintError::Other("版本类型不存在".to_string()))?; + + Ok(version_type) +} + +/// 根据代码查询版本类型 +pub async fn get_version_type_by_code( + svc_ctx: &SvcCtx, + type_code: String, +) -> Result { + let version_type = + crate::models::version_types::query_version_type_by_code(&svc_ctx.db, &type_code) + .await + .map_err(|_| SimprintError::Other("版本类型不存在".to_string()))?; + + Ok(version_type) +} + +/// 查询所有版本类型 +pub async fn query_all_version_types_service( + svc_ctx: &SvcCtx, +) -> Result { + let list = crate::models::version_types::query_all_version_types(&svc_ctx.db).await?; + + Ok(VersionTypeListResponse { list }) +} + +/// 查询激活的版本类型 +pub async fn query_active_version_types_service( + svc_ctx: &SvcCtx, +) -> Result { + let list = crate::models::version_types::query_active_version_types(&svc_ctx.db).await?; + + Ok(VersionTypeListResponse { list }) +} + +/// 更新版本类型 +pub async fn update_version_type_service( + svc_ctx: &SvcCtx, + id: i32, + request: UpdateVersionTypeRequest, +) -> Result { + // 检查版本类型是否存在 + crate::models::version_types::query_version_type_by_id(&svc_ctx.db, id) + .await + .map_err(|_| SimprintError::Other("版本类型不存在".to_string()))?; + + // 更新版本类型 + let success = + crate::models::version_types::update_version_type(&svc_ctx.db, id, &request).await?; + + if !success { + return Err(SimprintError::Other("更新版本类型失败".to_string())); + } + + Ok(true) +} + +/// 删除版本类型 +pub async fn delete_version_type_service(svc_ctx: &SvcCtx, id: i32) -> Result { + let success = crate::models::version_types::delete_version_type(&svc_ctx.db, id).await?; + + if !success { + return Err(SimprintError::Other("删除版本类型失败".to_string())); + } + + Ok(true) +} + +/// 激活/停用版本类型 +pub async fn toggle_version_type_status_service( + svc_ctx: &SvcCtx, + id: i32, + is_active: bool, +) -> Result { + let success = + crate::models::version_types::toggle_version_type_status(&svc_ctx.db, id, is_active) + .await?; + + if !success { + return Err(SimprintError::Other("激活/停用版本类型失败".to_string())); + } + + Ok(true) +} diff --git a/src/services/versions.rs b/src/services/versions.rs index e858e8d..cf2f3b6 100644 --- a/src/services/versions.rs +++ b/src/services/versions.rs @@ -1,186 +1,245 @@ -use crate::{dto::versions::Version, entitys::versions::*, errors::SimprintError, svc_ctx::SvcCtx}; - -/// 创建版本 -pub async fn create_version_service( - svc_ctx: &SvcCtx, - request: CreateVersionRequest, -) -> Result { - let pool = &svc_ctx.db; - - // 验证版本号 - if request.version.is_empty() { - return Err(SimprintError::VersionEmpty); - } - - // 验证资源名称 - if request.resource_name.is_empty() { - return Err(SimprintError::ResourceNameEmpty); - } - - // 检查版本类型是否存在 - crate::models::version_types::query_version_type_by_id(pool, request.type_id) - .await? - .ok_or(SimprintError::VersionTypeNotFound)?; - - // 检查版本是否已存在 - let exists = crate::models::versions::query_version_by_name_and_version( - pool, - &request.resource_name, - &request.version, - ) - .await - .ok(); - - if exists.is_some() { - return Err(SimprintError::VersionAlreadyExists); - } - - // 创建版本 - let id = crate::models::versions::insert_version(pool, &request).await?; - - Ok(id) -} - -/// 根据ID查询版本 -pub async fn get_version_by_id_service( - svc_ctx: &SvcCtx, - id: i32, -) -> Result { - let pool = &svc_ctx.db; - let version = crate::models::versions::query_version_by_id(pool, id) - .await? - .ok_or(SimprintError::VersionNotFound)?; - - Ok(version) -} - -/// 根据资源名称和版本号查询版本 -pub async fn get_version_by_name_and_version_service( - svc_ctx: &SvcCtx, - resource_name: String, - version: String, -) -> Result { - let pool = &svc_ctx.db; - let version_data = - crate::models::versions::query_version_by_name_and_version(pool, &resource_name, &version) - .await? - .ok_or(SimprintError::VersionNotFound)?; - - Ok(version_data) -} - -/// 查询最新版本 -pub async fn get_latest_version_service( - svc_ctx: &SvcCtx, - resource_name: String, - platform: String, -) -> Result { - let pool = &svc_ctx.db; - let version = crate::models::versions::query_latest_version(pool, &resource_name, &platform) - .await? - .ok_or(SimprintError::VersionNotFound)?; - - Ok(version) -} - -/// 查询版本列表 -pub async fn query_versions_service( - svc_ctx: &SvcCtx, - params: QueryVersionsParams, -) -> Result { - let pool = &svc_ctx.db; - let (total, list) = crate::models::versions::query_versions( - pool, - params.resource_name.as_deref(), - params.platform.as_deref(), - params.status.as_deref(), - params.page_num.unwrap_or(1), - params.page_size.unwrap_or(20), - ) - .await?; - - Ok(VersionListResponse { total, list }) -} - -/// 更新版本 -pub async fn update_version_service( - svc_ctx: &SvcCtx, - request: UpdateVersionRequest, -) -> Result { - let pool = &svc_ctx.db; - // 检查版本是否存在 - crate::models::versions::query_version_by_id(pool, request.id) - .await? - .ok_or(SimprintError::VersionNotFound)?; - - let success = crate::models::versions::update_version(pool, request.id, &request).await?; - - if !success { - return Err(SimprintError::VersionNotFound); - } - - Ok(true) -} - -/// 删除版本 -pub async fn delete_version_service( - svc_ctx: &SvcCtx, - payload: DeleteVersionRequest, -) -> Result { - let pool = &svc_ctx.db; - // 检查版本是否存在 - crate::models::versions::query_version_by_id(pool, payload.id) - .await? - .ok_or(SimprintError::VersionNotFound)?; - - let success = crate::models::versions::delete_version(pool, payload.id).await?; - - Ok(success) -} - -/// 设置最新版本 -pub async fn set_latest_version_service( - svc_ctx: &SvcCtx, - payload: SetLatestVersionRequest, -) -> Result { - let pool = &svc_ctx.db; - // 检查版本是否存在 - let version = crate::models::versions::query_version_by_id(pool, payload.version_id) - .await? - .ok_or(SimprintError::VersionNotFound)?; - - if version.type_id != payload.type_id || version.resource_name != payload.resource_name { - return Err(SimprintError::InvalidRequest( - "版本类型或资源名称不匹配".to_string(), - )); - } - - let success = crate::models::versions::set_as_latest_version( - pool, - payload.type_id, - &payload.resource_name, - payload.version_id, - ) - .await?; - - Ok(success) -} - -/// 查询所有激活版本类型的最新版本 -pub async fn get_all_active_latest_versions_service( - svc_ctx: &SvcCtx, - platform: String, -) -> Result>, SimprintError> { - let pool = &svc_ctx.db; - let results = - crate::models::versions::query_all_active_latest_versions(pool, &platform).await?; - - // 转换为 HashMap> - let mut map: std::collections::HashMap> = std::collections::HashMap::new(); - - for (type_code, _resource_name, version) in results { - map.entry(type_code).or_insert_with(Vec::new).push(version); - } - - Ok(map) -} +use crate::{ + dto::versions::Version, entitys::versions::*, errors::SimprintError, svc_ctx::SvcCtx, + utils::get_objects::get_version_resource_url, +}; +use std::collections::HashMap; + +/// 创建版本 +pub async fn create_version( + svc_ctx: &SvcCtx, + request: CreateVersionRequest, +) -> Result { + // 验证版本号 + if request.version.is_empty() { + return Err(SimprintError::VersionEmpty); + } + + // 验证资源名称 + if request.resource_name.is_empty() { + return Err(SimprintError::ResourceNameEmpty); + } + + // 检查版本是否已存在 + let exists = crate::models::versions::query_version_by_name_and_version( + &svc_ctx.db, + &request.resource_name, + &request.version, + ) + .await + .ok(); + + if exists.is_some() { + return Err(SimprintError::VersionAlreadyExists); + } + + // 创建版本 + let id = crate::models::versions::insert_version(&svc_ctx.db, &request).await?; + + Ok(id) +} + +/// 根据ID查询版本 +pub async fn get_version_by_id(svc_ctx: &SvcCtx, id: i32) -> Result { + let mut version = crate::models::versions::query_version_by_id(&svc_ctx.db, id) + .await + .map_err(|_| SimprintError::VersionNotFound)?; + + let minio_config = &svc_ctx.config.minio; + let (bucket_name, resource_url) = ( + &minio_config.version_resource_bucket, + &minio_config.resource_url, + ); + let name = version.name.clone().unwrap_or_default(); + + version.url = Some(get_version_resource_url(&bucket_name, &resource_url, &name)); + + Ok(version) +} + +/// 根据资源名称和版本号查询版本 +pub async fn get_version_by_name_and_version( + svc_ctx: &SvcCtx, + resource_name: String, + version: String, +) -> Result { + let version_data = crate::models::versions::query_version_by_name_and_version( + &svc_ctx.db, + &resource_name, + &version, + ) + .await + .map_err(|_| SimprintError::VersionNotFound)?; + + Ok(version_data) +} + +/// 查询最新版本 +pub async fn get_latest_version( + svc_ctx: &SvcCtx, + resource_name: String, + platform: String, +) -> Result { + let version = + crate::models::versions::query_latest_version(&svc_ctx.db, &resource_name, &platform) + .await + .map_err(|_| SimprintError::VersionNotFound)?; + + version.ok_or(SimprintError::VersionNotFound) +} + +/// 查询版本列表 +pub async fn query_versions_service( + svc_ctx: &SvcCtx, + params: QueryVersionParams, + page_num: i32, + page_size: i32, +) -> Result { + let (total, list) = crate::models::versions::query_versions( + &svc_ctx.db, + params.resource_name.as_deref(), + params.platform.as_deref(), + params.status.as_deref(), + page_num, + page_size, + ) + .await?; + + Ok(VersionListResponse { total, list }) +} + +/// 更新版本 +pub async fn update_version_service( + svc_ctx: &SvcCtx, + id: i32, + request: UpdateVersionRequest, +) -> Result { + // 检查版本是否存在 + crate::models::versions::query_version_by_id(&svc_ctx.db, id) + .await + .map_err(|_| SimprintError::VersionNotFound)?; + + // 更新版本 + let success = crate::models::versions::update_version(&svc_ctx.db, id, &request).await?; + + if !success { + return Err(SimprintError::VersionNotFound); + } + + Ok(true) +} + +/// 删除版本 +pub async fn delete_version_service(svc_ctx: &SvcCtx, id: i32) -> Result { + let success = crate::models::versions::delete_version(&svc_ctx.db, id).await?; + + if !success { + return Err(SimprintError::VersionNotFound); + } + + Ok(true) +} + +/// 设置某版本为最新版本 +pub async fn set_latest_version_service( + svc_ctx: &SvcCtx, + type_id: i32, + resource_name: String, + version_id: i32, +) -> Result { + // 检查版本是否存在 + crate::models::versions::query_version_by_id(&svc_ctx.db, version_id) + .await + .map_err(|_| SimprintError::VersionNotFound)?; + + // 设置为最新版本 + let success = crate::models::versions::set_as_latest_version( + &svc_ctx.db, + type_id, + &resource_name, + version_id, + ) + .await?; + + if !success { + return Err(SimprintError::Other("设置最新版本失败".to_string())); + } + + Ok(true) +} + +/// 版本回退到指定版本 +pub async fn rollback_version_service( + svc_ctx: &SvcCtx, + type_id: i32, + resource_name: String, + target_version_id: i32, +) -> Result { + // 检查目标版本是否存在 + let target_version = + crate::models::versions::query_version_by_id(&svc_ctx.db, target_version_id) + .await + .map_err(|_| SimprintError::VersionNotFound)?; + + // 检查版本是否属于同一资源 + if target_version.type_id != type_id || target_version.resource_name != resource_name { + return Err(SimprintError::VersionNotFound); + } + + // 设置目标版本为最新 + let success = + set_latest_version_service(svc_ctx, type_id, resource_name, target_version_id).await?; + + if !success { + return Err(SimprintError::Other("版本回退失败".to_string())); + } + + Ok(true) +} + +/// 版本差异对比 +pub async fn compare_versions_service( + svc_ctx: &SvcCtx, + version_id_1: i32, + version_id_2: i32, +) -> Result<(Version, Version), SimprintError> { + let version_1 = crate::models::versions::query_version_by_id(&svc_ctx.db, version_id_1) + .await + .map_err(|_| SimprintError::VersionNotFound)?; + + let version_2 = crate::models::versions::query_version_by_id(&svc_ctx.db, version_id_2) + .await + .map_err(|_| SimprintError::VersionNotFound)?; + + Ok((version_1, version_2)) +} + +/// 查询所有激活版本类型的最新版本 +pub async fn get_all_active_latest_versions_service( + svc_ctx: &SvcCtx, + platform: String, +) -> Result>, SimprintError> { + let results = + crate::models::versions::query_all_active_latest_versions(&svc_ctx.db, &platform).await?; + + // 转换为 HashMap> + let mut map: HashMap> = HashMap::new(); + + let minio_config = &svc_ctx.config.minio; + let (bucket_name, resource_url) = ( + &minio_config.version_resource_bucket, + &minio_config.resource_url, + ); + + for (type_code, _resource_name, version) in results { + map.entry(type_code).or_insert_with(Vec::new).push(Version { + url: Some(get_version_resource_url( + &bucket_name, + &resource_url, + &version.url.unwrap_or_default(), + )), + ..version + }); + } + + Ok(map) +} diff --git a/src/utils/config.rs b/src/utils/config.rs index a40c57d..22dba8f 100644 --- a/src/utils/config.rs +++ b/src/utils/config.rs @@ -26,9 +26,8 @@ pub struct MinioConfig { pub access_key: String, pub secret_access_key: String, pub avatar_bucket: String, - /// 扩展插件存储桶 - #[serde(default = "default_extension_bucket")] pub extension_bucket: String, + pub version_resource_bucket: String, } fn default_extension_bucket() -> String { @@ -88,7 +87,7 @@ pub struct IConfig { pub app: AppConfig, pub database: DatabaseConfig, pub redis: RedisConfig, - pub minio: Option, + pub minio: MinioConfig, pub smtp: Option, #[serde(default = "default_workspace_quota_config")] pub workspace_quota: WorkspaceQuotaConfig, diff --git a/src/utils/minios/get_objects.rs b/src/utils/minios/get_objects.rs index 891aaaf..57a22f7 100644 --- a/src/utils/minios/get_objects.rs +++ b/src/utils/minios/get_objects.rs @@ -32,17 +32,8 @@ pub fn get_extension_crx_url(resource_url: &str, bucket_name: &str, object_path: /// - `bucket_name`: 扩展存储桶名称 /// - `object_path`: 图标对象路径(数据库中存储的路径) // pub fn get_extension_icon_url(resource_url: &str, bucket_name: &str, object_path: &str) -> String { -pub fn get_extension_icon_url( - resource_url: &Option, - bucket_name: &Option, - object_path: &str, -) -> Option { - match (resource_url, bucket_name) { - (Some(resource_url), Some(bucket_name)) => { - Some(get_object_url(resource_url, bucket_name, object_path)) - } - _ => None, - } +pub fn get_extension_icon_url(resource_url: &str, bucket_name: &str, object_path: &str) -> String { + get_object_url(resource_url, bucket_name, object_path) } /// 获取头像的完整 URL @@ -54,3 +45,17 @@ pub fn get_extension_icon_url( pub fn get_avatar_url(resource_url: &str, bucket_name: &str, resource_hash: &str) -> String { get_object_url(resource_url, bucket_name, resource_hash) } + +/// 获取版本资源的完整 URL +/// +/// # Arguments +/// - `resource_url`: MinIO 资源访问基础 URL +/// - `bucket_name`: 版本资源存储桶名称 +/// - `object_path`: 版本资源对象路径(数据库中存储的路径) +pub fn get_version_resource_url( + resource_url: &str, + bucket_name: &str, + object_path: &str, +) -> String { + get_object_url(resource_url, bucket_name, object_path) +} From 9b8ac7a69699efeaa02dcf4289ac28bd6f9ca401 Mon Sep 17 00:00:00 2001 From: lius-new Date: Wed, 4 Feb 2026 21:07:04 +0800 Subject: [PATCH 05/60] =?UTF-8?q?feat(simprint-server):=20=E6=B7=BB?= =?UTF-8?q?=E5=8A=A0=E6=B5=8F=E8=A7=88=E5=99=A8=E5=86=85=E6=A0=B8=E5=88=97?= =?UTF-8?q?=E8=A1=A8=E6=9F=A5=E8=AF=A2=E6=8E=A5=E5=8F=A3?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit - 新增 browser_kernel service/handler/route 模块 - 新增 query_browser_kernel_latest_versions 模型查询 - 筛选 SIMPRINT_KERNEL_* 类型,按 resource_name 分组取最新版本 - 最新判定优先级:is_latest > pub_date/created_at > id - 支持可选 platform 参数过滤 - 路由: POST /browser-kernels/list Co-authored-by: Cursor --- src/handlers.rs | 2 + src/handlers/browser_kernel.rs | 26 +++++++ src/main.rs | 4 +- src/models/versions.rs | 127 +++++++++++++++++++++++++++++++++ src/routes.rs | 1 + src/routes/browser_kernel.rs | 16 +++++ src/services.rs | 2 + src/services/browser_kernel.rs | 43 +++++++++++ 8 files changed, 220 insertions(+), 1 deletion(-) create mode 100644 src/handlers/browser_kernel.rs create mode 100644 src/routes/browser_kernel.rs create mode 100644 src/services/browser_kernel.rs diff --git a/src/handlers.rs b/src/handlers.rs index a20da48..eb32183 100644 --- a/src/handlers.rs +++ b/src/handlers.rs @@ -8,6 +8,7 @@ pub mod accounts; pub mod api; pub mod audit; pub mod billing; +pub mod browser_kernel; pub mod environments; pub mod extensions; pub mod group_permissions; @@ -32,6 +33,7 @@ pub use accounts::*; pub use api::*; pub use audit::*; pub use billing::*; +pub use browser_kernel::*; pub use environments::*; pub use extensions::*; pub use group_permissions::*; diff --git a/src/handlers/browser_kernel.rs b/src/handlers/browser_kernel.rs new file mode 100644 index 0000000..e25babf --- /dev/null +++ b/src/handlers/browser_kernel.rs @@ -0,0 +1,26 @@ +use axum::extract::State; +use serde::{Deserialize, Serialize}; + +use crate::services::browser_kernel::get_browser_kernel_list_service; +use crate::svc_ctx::SvcCtx; +use crate::utils::{Json, Response, Result}; +use std::collections::HashMap; + +/// 查询浏览器内核列表请求 +#[derive(Debug, Default, Deserialize, Serialize)] +pub struct ListBrowserKernelsRequest { + /// 平台过滤,如 windows/darwin/linux,为空表示不过滤 + pub platform: Option, +} + +/// 查询浏览器内核最新版本列表 +pub async fn list_browser_kernels_handler( + State(svc_ctx): State, + Json(payload): Json, +) -> Result>> { + let kernels = get_browser_kernel_list_service(&svc_ctx, payload.platform) + .await + .map_err(|e| Response::fail(Some(&e.to_string())))?; + + Ok(Response::success(Some("获取浏览器内核列表成功"), Some(kernels))) +} diff --git a/src/main.rs b/src/main.rs index 23b33d0..b50b85a 100644 --- a/src/main.rs +++ b/src/main.rs @@ -5,7 +5,8 @@ use simprint_server::{init_encrypt_secret, init_minio}; use simprint_server::{ middlewares, routes::{ - accounts, api, audit, billing, environments, extensions, group_permissions, messages, + accounts, api, audit, billing, browser_kernel, environments, extensions, + group_permissions, messages, preferences, proxies, proxy_visibility, referral, rpa, secret, teams, templates, time, users, workspace_quotas, workspaces, }, @@ -72,6 +73,7 @@ fn register_all_routes(svc_ctx: &SvcCtx) -> Router { workspaces::register_routes(&mut meta_route); workspace_quotas::register_routes(&mut meta_route); teams::register_routes(&mut meta_route); + browser_kernel::register_routes(&mut meta_route); environments::register_routes(&mut meta_route); proxies::register_routes(&mut meta_route); proxy_visibility::register_routes(&mut meta_route); diff --git a/src/models/versions.rs b/src/models/versions.rs index c4d64d3..531e591 100644 --- a/src/models/versions.rs +++ b/src/models/versions.rs @@ -408,3 +408,130 @@ pub async fn query_all_active_latest_versions( Ok(converted) } + +/// 查询浏览器内核类型的最新版本列表 +/// 筛选 type_code 以 SIMPRINT_KERNEL_ 开头的版本类型 +/// 按 (type_code, resource_name) 分组,每组取最新版本 +/// 最新判定:is_latest 优先,其次 COALESCE(pub_date, created_at),最后 id +/// platform 为可选,None 表示不按平台过滤 +pub async fn query_browser_kernel_latest_versions( + pool: &Pool, + platform: Option<&str>, +) -> Result, Error> { + #[derive(sqlx::FromRow)] + struct VersionWithTypeCode { + type_code: String, + id: i32, + type_id: i32, + resource_name: String, + version: String, + name: Option, + notes: Option, + platform: Option, + url: Option, + hash: Option, + signature: Option, + install_path: Option, + file_size: Option, + is_latest: bool, + status: String, + pub_date: Option>, + created_at: DateTime, + updated_at: Option>, + deleted_at: Option>, + arch: Option, + package_format: Option, + requires_extract: bool, + entrypoint_template: Option, + extract_root: Option, + } + + let results: Vec = if let Some(plat) = platform { + sqlx::query_as( + r#" + SELECT DISTINCT ON (vt.type_code, v.resource_name) + vt.type_code, + v.id, v.type_id, v.resource_name, v.version, v.name, v.notes, + v.platform, v.url, v.hash, v.signature, v.install_path, + v.file_size, v.is_latest, v.status, v.pub_date, + v.created_at, v.updated_at, v.deleted_at, + v.arch, v.package_format, v.requires_extract, v.entrypoint_template, v.extract_root + FROM versions v + INNER JOIN version_types vt ON v.type_id = vt.id + WHERE vt.type_code LIKE 'SIMPRINT_KERNEL_%' + AND vt.is_active = true + AND v.platform = $1 + AND v.status = 'active' + AND v.deleted_at IS NULL + ORDER BY vt.type_code, v.resource_name, + v.is_latest DESC, + COALESCE(v.pub_date, v.created_at) DESC NULLS LAST, + v.id DESC + "#, + ) + .bind(plat) + .fetch_all(pool) + .await? + } else { + sqlx::query_as( + r#" + SELECT DISTINCT ON (vt.type_code, v.resource_name) + vt.type_code, + v.id, v.type_id, v.resource_name, v.version, v.name, v.notes, + v.platform, v.url, v.hash, v.signature, v.install_path, + v.file_size, v.is_latest, v.status, v.pub_date, + v.created_at, v.updated_at, v.deleted_at, + v.arch, v.package_format, v.requires_extract, v.entrypoint_template, v.extract_root + FROM versions v + INNER JOIN version_types vt ON v.type_id = vt.id + WHERE vt.type_code LIKE 'SIMPRINT_KERNEL_%' + AND vt.is_active = true + AND v.status = 'active' + AND v.deleted_at IS NULL + ORDER BY vt.type_code, v.resource_name, + v.is_latest DESC, + COALESCE(v.pub_date, v.created_at) DESC NULLS LAST, + v.id DESC + "#, + ) + .fetch_all(pool) + .await? + }; + + let converted: Vec<(String, String, Version)> = results + .into_iter() + .map(|r| { + ( + r.type_code.clone(), + r.resource_name.clone(), + Version { + id: r.id, + type_id: r.type_id, + resource_name: r.resource_name, + version: r.version, + name: r.name.clone(), + notes: r.notes, + platform: r.platform, + url: r.name, + hash: r.hash, + signature: r.signature, + install_path: r.install_path, + file_size: r.file_size, + is_latest: r.is_latest, + status: r.status, + pub_date: r.pub_date, + created_at: r.created_at, + updated_at: r.updated_at, + deleted_at: r.deleted_at, + arch: r.arch, + package_format: r.package_format, + requires_extract: r.requires_extract, + entrypoint_template: r.entrypoint_template, + extract_root: r.extract_root, + }, + ) + }) + .collect(); + + Ok(converted) +} diff --git a/src/routes.rs b/src/routes.rs index eb9a7e5..dad9f64 100644 --- a/src/routes.rs +++ b/src/routes.rs @@ -8,6 +8,7 @@ pub mod accounts; pub mod api; pub mod audit; pub mod billing; +pub mod browser_kernel; pub mod environments; pub mod extensions; pub mod group_permissions; diff --git a/src/routes/browser_kernel.rs b/src/routes/browser_kernel.rs new file mode 100644 index 0000000..38536ee --- /dev/null +++ b/src/routes/browser_kernel.rs @@ -0,0 +1,16 @@ +use axum::routing::post; + +use crate::handlers::browser_kernel::list_browser_kernels_handler; +use crate::routes::route::{self, MetaRoute}; + +/// 注册浏览器内核相关路由 +pub fn register_routes(meta_route: &mut MetaRoute) { + let mut browser_kernel_route = route::RouteGroup::new("/browser-kernels"); + + browser_kernel_route.add_route_item(route::RouteItem::post( + "/list", + post(list_browser_kernels_handler), + )); + + meta_route.add_route_group(browser_kernel_route); +} diff --git a/src/services.rs b/src/services.rs index 2238fb2..cfaa0d4 100644 --- a/src/services.rs +++ b/src/services.rs @@ -14,6 +14,7 @@ mod versions; pub mod accounts; pub mod api; pub mod audit; +pub mod browser_kernel; pub mod billing; pub mod coupons; pub mod environments; @@ -52,6 +53,7 @@ pub use versions::*; pub use accounts::*; pub use audit::*; pub use billing::*; +pub use browser_kernel::*; pub use coupons::*; pub use environments::*; pub use group_permissions::*; diff --git a/src/services/browser_kernel.rs b/src/services/browser_kernel.rs new file mode 100644 index 0000000..bdabd99 --- /dev/null +++ b/src/services/browser_kernel.rs @@ -0,0 +1,43 @@ +use crate::{ + dto::versions::Version, + errors::SimprintError, + svc_ctx::SvcCtx, + utils::get_objects::get_version_resource_url, +}; +use std::collections::HashMap; + +/// 查询浏览器内核最新版本列表 +/// 筛选 SIMPRINT_KERNEL_* 类型,按 resource_name 分组取最新 +/// 返回 HashMap>,已填充可下载 URL +pub async fn get_browser_kernel_list_service( + svc_ctx: &SvcCtx, + platform: Option, +) -> Result>, SimprintError> { + let results = crate::models::versions::query_browser_kernel_latest_versions( + &svc_ctx.db, + platform.as_deref(), + ) + .await + .map_err(|_| SimprintError::Other("查询浏览器内核列表失败".to_string()))?; + + let mut map: HashMap> = HashMap::new(); + + let minio_config = &svc_ctx.config.minio; + let (bucket_name, resource_url) = ( + &minio_config.version_resource_bucket, + &minio_config.resource_url, + ); + + for (type_code, _resource_name, version) in results { + map.entry(type_code).or_insert_with(Vec::new).push(Version { + url: Some(get_version_resource_url( + resource_url, + bucket_name, + &version.url.unwrap_or_default(), + )), + ..version + }); + } + + Ok(map) +} From b06fcc6045e8ea6486e82e3499edf96f72f8c8e1 Mon Sep 17 00:00:00 2001 From: lius-new Date: Wed, 4 Feb 2026 22:28:05 +0800 Subject: [PATCH 06/60] =?UTF-8?q?feat(browser-kernel):=20=E6=94=AF?= =?UTF-8?q?=E6=8C=81=20type=5Fcode=20=E5=8F=82=E6=95=B0=E4=B8=8E=20ListBro?= =?UTF-8?q?wserKernelsRequest=20=E7=8B=AC=E7=AB=8B=E6=A8=A1=E5=9D=97?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit - 将 ListBrowserKernelsRequest 移至 entitys/browser_kernel.rs - 查询接口新增 type_code 参数,支持按版本类型过滤 - models/versions 支持 type_code_filter 四种组合查询 - config: 新增 extension_bucket 配置 Co-authored-by: Cursor --- configs/config.dev.toml | 1 + configs/config.prod.toml | 1 + configs/config.test.toml | 1 + src/entitys/browser_kernel.rs | 10 +++ src/entitys/mod.rs | 2 + src/handlers/browser_kernel.rs | 17 ++--- src/models/versions.rs | 114 ++++++++++++++++++--------------- src/services/browser_kernel.rs | 4 +- 8 files changed, 88 insertions(+), 62 deletions(-) create mode 100644 src/entitys/browser_kernel.rs diff --git a/configs/config.dev.toml b/configs/config.dev.toml index 31beda6..f73a91a 100644 --- a/configs/config.dev.toml +++ b/configs/config.dev.toml @@ -35,6 +35,7 @@ resource_url = "http://111.230.19.75:9000" access_key = "5kSjAHnITsMZQEwzJN2g" secret_access_key = "eGs3ntV5KvmVzMbLpQwj86w5b5vdSHxUcmqcYhay" avatar_bucket = "avatars" +extension_bucket = "extensions" version_resource_bucket = "versions" [smtp] diff --git a/configs/config.prod.toml b/configs/config.prod.toml index dd6da28..e147897 100644 --- a/configs/config.prod.toml +++ b/configs/config.prod.toml @@ -35,6 +35,7 @@ resource_url = "http://111.230.19.75:9000" access_key = "5kSjAHnITsMZQEwzJN2g" secret_access_key = "eGs3ntV5KvmVzMbLpQwj86w5b5vdSHxUcmqcYhay" avatar_bucket = "avatars" +extension_bucket = "extensions" version_resource_bucket = "versions" [smtp] diff --git a/configs/config.test.toml b/configs/config.test.toml index 7439a3d..03635ac 100644 --- a/configs/config.test.toml +++ b/configs/config.test.toml @@ -33,6 +33,7 @@ resource_url = "https://oss.csgot9.com" access_key = "5kSjAHnITsMZQEwzJN2g" secret_access_key = "eGs3ntV5KvmVzMbLpQwj86w5b5vdSHxUcmqcYhay" avatar_bucket = "avatars" +extension_bucket = "extensions" version_resource_bucket = "versions" [smtp] diff --git a/src/entitys/browser_kernel.rs b/src/entitys/browser_kernel.rs new file mode 100644 index 0000000..edc1495 --- /dev/null +++ b/src/entitys/browser_kernel.rs @@ -0,0 +1,10 @@ +use serde::{Deserialize, Serialize}; + +/// 查询浏览器内核列表请求 +#[derive(Debug, Default, Deserialize, Serialize)] +pub struct ListBrowserKernelsRequest { + /// 平台过滤,如 windows/darwin/linux,为空表示不过滤 + pub platform: Option, + /// 版本类型过滤,如 SIMPRINT_KERNEL_CHROMIUM,为空表示查询所有 SIMPRINT_KERNEL_* 类型 + pub type_code: Option, +} diff --git a/src/entitys/mod.rs b/src/entitys/mod.rs index 17a2477..45a6b4b 100644 --- a/src/entitys/mod.rs +++ b/src/entitys/mod.rs @@ -14,6 +14,7 @@ pub mod accounts; pub mod api_service; pub mod audit; pub mod billing; +pub mod browser_kernel; pub mod common; pub mod console_admin; pub mod console_apikey; @@ -49,6 +50,7 @@ pub use accounts::*; pub use api_service::*; pub use audit::*; pub use billing::*; +pub use browser_kernel::*; pub use common::*; pub use console_admin::*; pub use console_apikey::*; diff --git a/src/handlers/browser_kernel.rs b/src/handlers/browser_kernel.rs index e25babf..da48f9b 100644 --- a/src/handlers/browser_kernel.rs +++ b/src/handlers/browser_kernel.rs @@ -1,24 +1,21 @@ use axum::extract::State; -use serde::{Deserialize, Serialize}; +use std::collections::HashMap; +use crate::entitys::browser_kernel::ListBrowserKernelsRequest; use crate::services::browser_kernel::get_browser_kernel_list_service; use crate::svc_ctx::SvcCtx; use crate::utils::{Json, Response, Result}; -use std::collections::HashMap; - -/// 查询浏览器内核列表请求 -#[derive(Debug, Default, Deserialize, Serialize)] -pub struct ListBrowserKernelsRequest { - /// 平台过滤,如 windows/darwin/linux,为空表示不过滤 - pub platform: Option, -} /// 查询浏览器内核最新版本列表 pub async fn list_browser_kernels_handler( State(svc_ctx): State, Json(payload): Json, ) -> Result>> { - let kernels = get_browser_kernel_list_service(&svc_ctx, payload.platform) + let kernels = get_browser_kernel_list_service( + &svc_ctx, + payload.platform, + payload.type_code, + ) .await .map_err(|e| Response::fail(Some(&e.to_string())))?; diff --git a/src/models/versions.rs b/src/models/versions.rs index 531e591..0afd466 100644 --- a/src/models/versions.rs +++ b/src/models/versions.rs @@ -410,13 +410,14 @@ pub async fn query_all_active_latest_versions( } /// 查询浏览器内核类型的最新版本列表 -/// 筛选 type_code 以 SIMPRINT_KERNEL_ 开头的版本类型 +/// type_code_filter 为 None 时筛选 type_code LIKE 'SIMPRINT_KERNEL_%',为 Some 时精确匹配 /// 按 (type_code, resource_name) 分组,每组取最新版本 /// 最新判定:is_latest 优先,其次 COALESCE(pub_date, created_at),最后 id /// platform 为可选,None 表示不按平台过滤 pub async fn query_browser_kernel_latest_versions( pool: &Pool, platform: Option<&str>, + type_code_filter: Option<&str>, ) -> Result, Error> { #[derive(sqlx::FromRow)] struct VersionWithTypeCode { @@ -446,56 +447,67 @@ pub async fn query_browser_kernel_latest_versions( extract_root: Option, } - let results: Vec = if let Some(plat) = platform { - sqlx::query_as( - r#" - SELECT DISTINCT ON (vt.type_code, v.resource_name) - vt.type_code, - v.id, v.type_id, v.resource_name, v.version, v.name, v.notes, - v.platform, v.url, v.hash, v.signature, v.install_path, - v.file_size, v.is_latest, v.status, v.pub_date, - v.created_at, v.updated_at, v.deleted_at, - v.arch, v.package_format, v.requires_extract, v.entrypoint_template, v.extract_root - FROM versions v - INNER JOIN version_types vt ON v.type_id = vt.id - WHERE vt.type_code LIKE 'SIMPRINT_KERNEL_%' - AND vt.is_active = true - AND v.platform = $1 - AND v.status = 'active' - AND v.deleted_at IS NULL - ORDER BY vt.type_code, v.resource_name, - v.is_latest DESC, - COALESCE(v.pub_date, v.created_at) DESC NULLS LAST, - v.id DESC - "#, - ) - .bind(plat) - .fetch_all(pool) - .await? - } else { - sqlx::query_as( - r#" - SELECT DISTINCT ON (vt.type_code, v.resource_name) - vt.type_code, - v.id, v.type_id, v.resource_name, v.version, v.name, v.notes, - v.platform, v.url, v.hash, v.signature, v.install_path, - v.file_size, v.is_latest, v.status, v.pub_date, - v.created_at, v.updated_at, v.deleted_at, - v.arch, v.package_format, v.requires_extract, v.entrypoint_template, v.extract_root - FROM versions v - INNER JOIN version_types vt ON v.type_id = vt.id - WHERE vt.type_code LIKE 'SIMPRINT_KERNEL_%' - AND vt.is_active = true - AND v.status = 'active' - AND v.deleted_at IS NULL - ORDER BY vt.type_code, v.resource_name, - v.is_latest DESC, - COALESCE(v.pub_date, v.created_at) DESC NULLS LAST, - v.id DESC - "#, - ) - .fetch_all(pool) - .await? + const SELECT_AND_ORDER: &str = r#" + SELECT DISTINCT ON (vt.type_code, v.resource_name) + vt.type_code, + v.id, v.type_id, v.resource_name, v.version, v.name, v.notes, + v.platform, v.url, v.hash, v.signature, v.install_path, + v.file_size, v.is_latest, v.status, v.pub_date, + v.created_at, v.updated_at, v.deleted_at, + v.arch, v.package_format, v.requires_extract, v.entrypoint_template, v.extract_root + FROM versions v + INNER JOIN version_types vt ON v.type_id = vt.id + "#; + + let results: Vec = match (platform, type_code_filter) { + (Some(plat), Some(tc)) => { + sqlx::query_as(&format!( + "{} WHERE vt.type_code = $1 AND vt.is_active = true AND v.platform = $2 \ + AND v.status = 'active' AND v.deleted_at IS NULL \ + ORDER BY vt.type_code, v.resource_name, v.is_latest DESC, \ + COALESCE(v.pub_date, v.created_at) DESC NULLS LAST, v.id DESC", + SELECT_AND_ORDER + )) + .bind(tc) + .bind(plat) + .fetch_all(pool) + .await? + } + (Some(plat), None) => { + sqlx::query_as(&format!( + "{} WHERE vt.type_code LIKE 'SIMPRINT_KERNEL_%' AND vt.is_active = true \ + AND v.platform = $1 AND v.status = 'active' AND v.deleted_at IS NULL \ + ORDER BY vt.type_code, v.resource_name, v.is_latest DESC, \ + COALESCE(v.pub_date, v.created_at) DESC NULLS LAST, v.id DESC", + SELECT_AND_ORDER + )) + .bind(plat) + .fetch_all(pool) + .await? + } + (None, Some(tc)) => { + sqlx::query_as(&format!( + "{} WHERE vt.type_code = $1 AND vt.is_active = true \ + AND v.status = 'active' AND v.deleted_at IS NULL \ + ORDER BY vt.type_code, v.resource_name, v.is_latest DESC, \ + COALESCE(v.pub_date, v.created_at) DESC NULLS LAST, v.id DESC", + SELECT_AND_ORDER + )) + .bind(tc) + .fetch_all(pool) + .await? + } + (None, None) => { + sqlx::query_as(&format!( + "{} WHERE vt.type_code LIKE 'SIMPRINT_KERNEL_%' AND vt.is_active = true \ + AND v.status = 'active' AND v.deleted_at IS NULL \ + ORDER BY vt.type_code, v.resource_name, v.is_latest DESC, \ + COALESCE(v.pub_date, v.created_at) DESC NULLS LAST, v.id DESC", + SELECT_AND_ORDER + )) + .fetch_all(pool) + .await? + } }; let converted: Vec<(String, String, Version)> = results diff --git a/src/services/browser_kernel.rs b/src/services/browser_kernel.rs index bdabd99..d7864cd 100644 --- a/src/services/browser_kernel.rs +++ b/src/services/browser_kernel.rs @@ -7,15 +7,17 @@ use crate::{ use std::collections::HashMap; /// 查询浏览器内核最新版本列表 -/// 筛选 SIMPRINT_KERNEL_* 类型,按 resource_name 分组取最新 +/// 筛选 SIMPRINT_KERNEL_* 类型(或指定 type_code),按 resource_name 分组取最新 /// 返回 HashMap>,已填充可下载 URL pub async fn get_browser_kernel_list_service( svc_ctx: &SvcCtx, platform: Option, + type_code: Option, ) -> Result>, SimprintError> { let results = crate::models::versions::query_browser_kernel_latest_versions( &svc_ctx.db, platform.as_deref(), + type_code.as_deref(), ) .await .map_err(|_| SimprintError::Other("查询浏览器内核列表失败".to_string()))?; From 912d397a37547e880c5cf31e34d9972439369fe8 Mon Sep 17 00:00:00 2001 From: lius-new Date: Thu, 5 Feb 2026 00:13:33 +0800 Subject: [PATCH 07/60] =?UTF-8?q?feat(users):=20=E6=94=AF=E6=8C=81?= =?UTF-8?q?=E7=BC=96=E8=BE=91=E8=B5=84=E6=96=99=20phone/email=EF=BC=8C?= =?UTF-8?q?=E5=93=8D=E5=BA=94=E5=A2=9E=E5=8A=A0=20avatar=5Furl?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit - UpdateUserRequest: 新增 phone、email 字段 - UserResponse: 新增 avatar_url 头像完整 URL - update_user_info: 支持更新 phone、email - get_current_user_service: 返回 avatar_url(基于 minio 拼接) Co-authored-by: Cursor --- src/entitys/user.rs | 5 +++++ src/models/user.rs | 11 ++++++++--- src/services/users.rs | 12 ++++++++++++ 3 files changed, 25 insertions(+), 3 deletions(-) diff --git a/src/entitys/user.rs b/src/entitys/user.rs index 2239085..0dcfb3b 100644 --- a/src/entitys/user.rs +++ b/src/entitys/user.rs @@ -76,6 +76,8 @@ pub struct RefreshTokenRequest { #[derive(Debug, Deserialize, Serialize, Default)] pub struct UpdateUserRequest { pub nickname: Option, + pub phone: Option, + pub email: Option, } /// 修改密码请求 @@ -109,6 +111,9 @@ pub struct UserResponse { pub email: String, pub phone: Option, pub avatar_hash: Option, + /// 头像完整 URL(当 avatar_hash 存在时由服务端拼接) + #[serde(skip_serializing_if = "Option::is_none")] + pub avatar_url: Option, pub status: String, pub created_at: DateTime, pub updated_at: DateTime, diff --git a/src/models/user.rs b/src/models/user.rs index 884a935..77348a2 100644 --- a/src/models/user.rs +++ b/src/models/user.rs @@ -362,11 +362,16 @@ pub async fn update_user_info( sqlx::query( r#" UPDATE user_infos - SET nickname = $1 - WHERE user_uuid = $2 AND deleted_at IS NULL + SET nickname = COALESCE($1, nickname), + phone = COALESCE($2, phone), + email = COALESCE($3, email), + updated_at = CURRENT_TIMESTAMP + WHERE user_uuid = $4 AND deleted_at IS NULL "#, ) - .bind(&payload.nickname) + .bind(payload.nickname.as_deref()) + .bind(payload.phone.as_deref()) + .bind(payload.email.as_deref()) .bind(user_uuid) .execute(pool) .await?; diff --git a/src/services/users.rs b/src/services/users.rs index 368db8a..59d45c5 100644 --- a/src/services/users.rs +++ b/src/services/users.rs @@ -339,6 +339,17 @@ pub async fn get_current_user_service( None }; + let avatar_url = user_info + .avatar_hash + .as_ref() + .map(|hash| { + crate::utils::minios::get_objects::get_avatar_url( + &svc_ctx.config.minio.resource_url, + &svc_ctx.config.minio.avatar_bucket, + hash, + ) + }); + Ok(UserResponse { uuid: user.uuid, id: user.id, @@ -346,6 +357,7 @@ pub async fn get_current_user_service( email: user_info.email, phone: user_info.phone, avatar_hash: user_info.avatar_hash, + avatar_url, status: user_info.status, created_at: user.created_at, updated_at: user.updated_at, From 16f933076ca5c56a9dbc9ede20ebb26f5f6c202d Mon Sep 17 00:00:00 2001 From: lius-new Date: Thu, 5 Feb 2026 23:49:33 +0800 Subject: [PATCH 08/60] =?UTF-8?q?feat(proxy):=20=E6=94=AF=E6=8C=81=20count?= =?UTF-8?q?ry=20=E5=92=8C=20city=20=E5=AD=97=E6=AE=B5=20-=20=E5=88=9B?= =?UTF-8?q?=E5=BB=BA=E3=80=81=E6=9B=B4=E6=96=B0=E3=80=81=E6=89=B9=E9=87=8F?= =?UTF-8?q?=E5=AF=BC=E5=85=A5=E5=8A=9F=E8=83=BD?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- src/entitys/proxies.rs | 6 ++++++ src/models/proxies.rs | 18 ++++++++++++++---- src/services/proxies.rs | 6 ++++++ 3 files changed, 26 insertions(+), 4 deletions(-) diff --git a/src/entitys/proxies.rs b/src/entitys/proxies.rs index aa3a221..40bafed 100644 --- a/src/entitys/proxies.rs +++ b/src/entitys/proxies.rs @@ -29,6 +29,8 @@ pub struct CreateProxyRequest { pub proxy_type: String, pub username: Option, pub password: Option, + pub country: Option, + pub city: Option, pub ssh_key: Option, pub ssh_passphrase: Option, } @@ -43,6 +45,8 @@ pub struct UpdateProxyRequest { pub proxy_type: Option, pub username: Option, pub password: Option, + pub country: Option, + pub city: Option, } /// 批量导入代理项(客户端已解析好的结构化数据) @@ -54,6 +58,8 @@ pub struct BatchImportProxyItem { pub proxy_type: String, pub username: Option, pub password: Option, + pub country: Option, + pub city: Option, } /// 批量导入代理请求 diff --git a/src/models/proxies.rs b/src/models/proxies.rs index df092b0..fa6cbad 100644 --- a/src/models/proxies.rs +++ b/src/models/proxies.rs @@ -14,12 +14,14 @@ pub async fn insert_proxy( proxy_type: &str, username: Option<&str>, password_encrypted: Option<&str>, + country: Option<&str>, + city: Option<&str>, ) -> Result { let uuid: Uuid = sqlx::query_scalar( r#" INSERT INTO proxies (workspace_uuid, owner_uuid, name, host, port, proxy_type, - username, password_encrypted) - VALUES ($1, $2, $3, $4, $5, $6, $7, $8) + username, password_encrypted, country, city) + VALUES ($1, $2, $3, $4, $5, $6, $7, $8, $9, $10) RETURNING uuid; "#, ) @@ -31,6 +33,8 @@ pub async fn insert_proxy( .bind(proxy_type) .bind(username) .bind(password_encrypted) + .bind(country) + .bind(city) .fetch_one(pool) .await?; @@ -131,6 +135,8 @@ pub async fn update_proxy( proxy_type: Option<&str>, username: Option<&str>, password_encrypted: Option<&str>, + country: Option<&str>, + city: Option<&str>, ) -> Result<(), Error> { sqlx::query( r#" @@ -140,8 +146,10 @@ pub async fn update_proxy( port = COALESCE($3, port), proxy_type = COALESCE($4, proxy_type), username = COALESCE($5, username), - password_encrypted = COALESCE($6, password_encrypted) - WHERE uuid = $7 AND deleted_at IS NULL + password_encrypted = COALESCE($6, password_encrypted), + country = COALESCE($7, country), + city = COALESCE($8, city) + WHERE uuid = $9 AND deleted_at IS NULL "#, ) .bind(name) @@ -150,6 +158,8 @@ pub async fn update_proxy( .bind(proxy_type) .bind(username) .bind(password_encrypted) + .bind(country) + .bind(city) .bind(proxy_uuid) .execute(pool) .await?; diff --git a/src/services/proxies.rs b/src/services/proxies.rs index 65ae97d..28c7076 100644 --- a/src/services/proxies.rs +++ b/src/services/proxies.rs @@ -42,6 +42,8 @@ pub async fn create_proxy_service( &payload.proxy_type, payload.username.as_deref(), password_encrypted.as_deref(), + payload.country.as_deref(), + payload.city.as_deref(), ) .await .map_err(|e| e.to_string())?; @@ -133,6 +135,8 @@ pub async fn update_proxy_service( payload.proxy_type.as_deref(), payload.username.as_deref(), password_encrypted.as_deref(), + payload.country.as_deref(), + payload.city.as_deref(), ) .await .map_err(|e| e.to_string()) @@ -222,6 +226,8 @@ pub async fn batch_import_proxies_service( &proxy.proxy_type, proxy.username.as_deref(), password_encrypted.as_deref(), + proxy.country.as_deref(), + proxy.city.as_deref(), ) .await; From 46c4aa34f710bcf500f7fe648ab4d9de6953ffd4 Mon Sep 17 00:00:00 2001 From: lius-new Date: Thu, 5 Feb 2026 23:51:36 +0800 Subject: [PATCH 09/60] =?UTF-8?q?chore:=20=E7=BB=9F=E4=B8=80=E6=8D=A2?= =?UTF-8?q?=E8=A1=8C=E7=AC=A6=E6=A0=BC=E5=BC=8F=20(LF)?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- src/entitys/user.rs | 280 +++++++++++++-------------- src/handlers.rs | 98 +++++----- src/routes.rs | 338 ++++++++++++++++----------------- src/services/browser_kernel.rs | 90 ++++----- 4 files changed, 403 insertions(+), 403 deletions(-) diff --git a/src/entitys/user.rs b/src/entitys/user.rs index 0dcfb3b..d9874dd 100644 --- a/src/entitys/user.rs +++ b/src/entitys/user.rs @@ -1,140 +1,140 @@ -use chrono::{DateTime, Utc}; -use serde::{Deserialize, Serialize}; -use uuid::Uuid; - -/// 机器信息(用于注册和登录) -#[derive(Debug, Deserialize, Serialize, Default, Clone)] -pub struct MachineInfo { - pub machine_code: String, - #[serde(skip_serializing_if = "Option::is_none")] - pub platform: Option, - #[serde(skip_serializing_if = "Option::is_none")] - pub hardware_hash: Option, // 硬件哈希 - #[serde(skip_serializing_if = "Option::is_none")] - pub hardware_raw: Option, // 硬件原始信息 - #[serde(skip_serializing_if = "Option::is_none")] - pub version_info: Option, // JSON格式的版本信息 -} - -/// 注册请求 -#[derive(Debug, Deserialize, Serialize, Default)] -pub struct RegisterRequest { - pub email: String, - pub password: String, - pub code: String, - #[serde(skip_serializing_if = "Option::is_none")] - pub nickname: Option, // 昵称(可选) - #[serde(skip_serializing_if = "Option::is_none")] - pub public_secret_key: Option, // 用户公钥(可选) - #[serde(skip_serializing_if = "Option::is_none")] - pub machine_info: Option, // 机器信息(可选) - #[serde(skip_serializing_if = "Option::is_none")] - pub referral_code: Option, // 推荐码(可选) -} - -/// 基本登录请求(邮箱 + 密码) -#[derive(Debug, Deserialize, Serialize)] -pub struct BasicLoginData { - pub email: String, - pub password: String, - #[serde(skip_serializing_if = "Option::is_none")] - pub machine_info: Option, - #[serde(skip_serializing_if = "Option::is_none")] - pub public_secret_key: Option, -} - -/// 记住密码登录请求(邮箱 + refresh_token) -#[derive(Debug, Deserialize, Serialize)] -pub struct RememberLoginData { - pub email: String, - pub refresh_token: String, - #[serde(skip_serializing_if = "Option::is_none")] - pub machine_info: Option, - #[serde(skip_serializing_if = "Option::is_none")] - pub public_secret_key: Option, -} - -/// 登录请求(统一结构,通过枚举区分两种登录方式) -/// -/// 使用 serde 的 tag 特性,根据 "login_type" 字段自动反序列化为对应的变体 -#[derive(Debug, Deserialize, Serialize)] -#[serde(tag = "login_type", rename_all = "snake_case")] -pub enum LoginRequest { - /// 基本登录(邮箱 + 密码) - Basic(BasicLoginData), - /// 记住密码登录(邮箱 + refresh_token) - Remember(RememberLoginData), -} - -/// 刷新 Token 请求 -#[derive(Debug, Deserialize, Serialize, Default)] -pub struct RefreshTokenRequest { - pub refresh_token: String, -} - -/// 更新用户信息请求 -#[derive(Debug, Deserialize, Serialize, Default)] -pub struct UpdateUserRequest { - pub nickname: Option, - pub phone: Option, - pub email: Option, -} - -/// 修改密码请求 -#[derive(Debug, Deserialize, Serialize, Default)] -pub struct UpdatePasswordRequest { - pub old_password: String, - pub new_password: String, -} - -/// 重置密码请求 -#[derive(Debug, Deserialize, Serialize, Default)] -pub struct ResetPasswordRequest { - pub email: String, - pub code: String, - pub new_password: String, -} - -/// 发送验证码请求 -#[derive(Debug, Deserialize, Serialize, Default)] -pub struct SendCodeRequest { - pub email: String, - pub r#type: String, // register 或 reset_password -} - -/// 用户信息响应 -#[derive(Debug, Serialize)] -pub struct UserResponse { - pub uuid: Uuid, - pub id: String, - pub nickname: Option, - pub email: String, - pub phone: Option, - pub avatar_hash: Option, - /// 头像完整 URL(当 avatar_hash 存在时由服务端拼接) - #[serde(skip_serializing_if = "Option::is_none")] - pub avatar_url: Option, - pub status: String, - pub created_at: DateTime, - pub updated_at: DateTime, - /// 当前团队详细信息(如果存在) - pub current_team: Option, - /// 当前工作空间详细信息(如果存在) - pub current_workspace: Option, -} - -/// 登录响应 -#[derive(Debug, Serialize)] -pub struct LoginResponse { - pub access_token: String, - pub refresh_token: String, - pub user_info: Option, -} - -/// 注册响应 -#[derive(Debug, Serialize)] -pub struct RegisterResponse { - pub access_token: String, - pub refresh_token: String, - pub user_info: Option, -} +use chrono::{DateTime, Utc}; +use serde::{Deserialize, Serialize}; +use uuid::Uuid; + +/// 机器信息(用于注册和登录) +#[derive(Debug, Deserialize, Serialize, Default, Clone)] +pub struct MachineInfo { + pub machine_code: String, + #[serde(skip_serializing_if = "Option::is_none")] + pub platform: Option, + #[serde(skip_serializing_if = "Option::is_none")] + pub hardware_hash: Option, // 硬件哈希 + #[serde(skip_serializing_if = "Option::is_none")] + pub hardware_raw: Option, // 硬件原始信息 + #[serde(skip_serializing_if = "Option::is_none")] + pub version_info: Option, // JSON格式的版本信息 +} + +/// 注册请求 +#[derive(Debug, Deserialize, Serialize, Default)] +pub struct RegisterRequest { + pub email: String, + pub password: String, + pub code: String, + #[serde(skip_serializing_if = "Option::is_none")] + pub nickname: Option, // 昵称(可选) + #[serde(skip_serializing_if = "Option::is_none")] + pub public_secret_key: Option, // 用户公钥(可选) + #[serde(skip_serializing_if = "Option::is_none")] + pub machine_info: Option, // 机器信息(可选) + #[serde(skip_serializing_if = "Option::is_none")] + pub referral_code: Option, // 推荐码(可选) +} + +/// 基本登录请求(邮箱 + 密码) +#[derive(Debug, Deserialize, Serialize)] +pub struct BasicLoginData { + pub email: String, + pub password: String, + #[serde(skip_serializing_if = "Option::is_none")] + pub machine_info: Option, + #[serde(skip_serializing_if = "Option::is_none")] + pub public_secret_key: Option, +} + +/// 记住密码登录请求(邮箱 + refresh_token) +#[derive(Debug, Deserialize, Serialize)] +pub struct RememberLoginData { + pub email: String, + pub refresh_token: String, + #[serde(skip_serializing_if = "Option::is_none")] + pub machine_info: Option, + #[serde(skip_serializing_if = "Option::is_none")] + pub public_secret_key: Option, +} + +/// 登录请求(统一结构,通过枚举区分两种登录方式) +/// +/// 使用 serde 的 tag 特性,根据 "login_type" 字段自动反序列化为对应的变体 +#[derive(Debug, Deserialize, Serialize)] +#[serde(tag = "login_type", rename_all = "snake_case")] +pub enum LoginRequest { + /// 基本登录(邮箱 + 密码) + Basic(BasicLoginData), + /// 记住密码登录(邮箱 + refresh_token) + Remember(RememberLoginData), +} + +/// 刷新 Token 请求 +#[derive(Debug, Deserialize, Serialize, Default)] +pub struct RefreshTokenRequest { + pub refresh_token: String, +} + +/// 更新用户信息请求 +#[derive(Debug, Deserialize, Serialize, Default)] +pub struct UpdateUserRequest { + pub nickname: Option, + pub phone: Option, + pub email: Option, +} + +/// 修改密码请求 +#[derive(Debug, Deserialize, Serialize, Default)] +pub struct UpdatePasswordRequest { + pub old_password: String, + pub new_password: String, +} + +/// 重置密码请求 +#[derive(Debug, Deserialize, Serialize, Default)] +pub struct ResetPasswordRequest { + pub email: String, + pub code: String, + pub new_password: String, +} + +/// 发送验证码请求 +#[derive(Debug, Deserialize, Serialize, Default)] +pub struct SendCodeRequest { + pub email: String, + pub r#type: String, // register 或 reset_password +} + +/// 用户信息响应 +#[derive(Debug, Serialize)] +pub struct UserResponse { + pub uuid: Uuid, + pub id: String, + pub nickname: Option, + pub email: String, + pub phone: Option, + pub avatar_hash: Option, + /// 头像完整 URL(当 avatar_hash 存在时由服务端拼接) + #[serde(skip_serializing_if = "Option::is_none")] + pub avatar_url: Option, + pub status: String, + pub created_at: DateTime, + pub updated_at: DateTime, + /// 当前团队详细信息(如果存在) + pub current_team: Option, + /// 当前工作空间详细信息(如果存在) + pub current_workspace: Option, +} + +/// 登录响应 +#[derive(Debug, Serialize)] +pub struct LoginResponse { + pub access_token: String, + pub refresh_token: String, + pub user_info: Option, +} + +/// 注册响应 +#[derive(Debug, Serialize)] +pub struct RegisterResponse { + pub access_token: String, + pub refresh_token: String, + pub user_info: Option, +} diff --git a/src/handlers.rs b/src/handlers.rs index eb32183..f533e8e 100644 --- a/src/handlers.rs +++ b/src/handlers.rs @@ -1,49 +1,49 @@ -mod health; -mod secret; -mod time; -mod users; - -// 新增模块 -pub mod accounts; -pub mod api; -pub mod audit; -pub mod billing; -pub mod browser_kernel; -pub mod environments; -pub mod extensions; -pub mod group_permissions; -pub mod messages; -pub mod preferences; -pub mod proxies; -pub mod proxy_visibility; -pub mod referral; -pub mod rpa; -pub mod teams; -pub mod templates; -pub mod workspace_quotas; -pub mod workspaces; - -pub use health::*; -pub use secret::*; -pub use time::*; -pub use users::*; - -// 新增导出 -pub use accounts::*; -pub use api::*; -pub use audit::*; -pub use billing::*; -pub use browser_kernel::*; -pub use environments::*; -pub use extensions::*; -pub use group_permissions::*; -pub use messages::*; -pub use preferences::*; -pub use proxies::*; -pub use proxy_visibility::*; -pub use referral::*; -pub use rpa::*; -pub use teams::*; -pub use templates::*; -pub use workspace_quotas::*; -pub use workspaces::*; +mod health; +mod secret; +mod time; +mod users; + +// 新增模块 +pub mod accounts; +pub mod api; +pub mod audit; +pub mod billing; +pub mod browser_kernel; +pub mod environments; +pub mod extensions; +pub mod group_permissions; +pub mod messages; +pub mod preferences; +pub mod proxies; +pub mod proxy_visibility; +pub mod referral; +pub mod rpa; +pub mod teams; +pub mod templates; +pub mod workspace_quotas; +pub mod workspaces; + +pub use health::*; +pub use secret::*; +pub use time::*; +pub use users::*; + +// 新增导出 +pub use accounts::*; +pub use api::*; +pub use audit::*; +pub use billing::*; +pub use browser_kernel::*; +pub use environments::*; +pub use extensions::*; +pub use group_permissions::*; +pub use messages::*; +pub use preferences::*; +pub use proxies::*; +pub use proxy_visibility::*; +pub use referral::*; +pub use rpa::*; +pub use teams::*; +pub use templates::*; +pub use workspace_quotas::*; +pub use workspaces::*; diff --git a/src/routes.rs b/src/routes.rs index dad9f64..7e209bd 100644 --- a/src/routes.rs +++ b/src/routes.rs @@ -1,169 +1,169 @@ -pub mod health; -pub mod secret; -pub mod time; -pub mod users; - -// 新增模块 -pub mod accounts; -pub mod api; -pub mod audit; -pub mod billing; -pub mod browser_kernel; -pub mod environments; -pub mod extensions; -pub mod group_permissions; -pub mod messages; -pub mod preferences; -pub mod proxies; -pub mod proxy_visibility; -pub mod referral; -pub mod rpa; -pub mod teams; -pub mod templates; -pub mod workspace_quotas; -pub mod workspaces; - -// route core -pub mod route { - use std::borrow::Cow; - - use axum::{Router, routing::MethodRouter}; - - use crate::svc_ctx::SvcCtx; - - // can support request methods enum - #[derive(Debug, Clone)] - pub enum RequestMethod { - GET, - POST, - PUT, - DELETE, - PATCH, - } - - /// route item - pub struct RouteItem { - pub path: &'static str, - pub method: RequestMethod, - pub handler: MethodRouter, - } - - /// route group - pub struct RouteGroup { - pub prefix: &'static str, - pub routes: Vec, - } - - /// meta route - pub struct MetaRoute { - pub prefix: Cow<'static, str>, - pub routes: Vec, - } - - impl MetaRoute { - pub fn new(prefix: String) -> Self { - Self { - prefix: Cow::Owned(prefix), - routes: vec![], - } - } - - pub fn add_route_group(&mut self, group: RouteGroup) -> () { - self.routes.push(group); - } - - pub fn count(&self) -> usize { - self.routes.iter().map(|group| group.routes.len()).sum() - } - - pub fn build(&self) -> Router { - let mut root_router_child = Router::new(); - - for contain in &self.routes { - let RouteGroup { prefix, routes } = contain; - - let parent_prefix = prefix.to_string(); - let mut child_router = Router::new(); - - for item in routes { - let RouteItem { - path, - method, - handler, - } = item; - - tracing::info!("{:?}+{}{}{}", method, self.prefix, parent_prefix, path,); - match method { - RequestMethod::GET => { - child_router = child_router.route(path, handler.clone()); - } - RequestMethod::POST => { - child_router = child_router.route(path, handler.clone()); - } - RequestMethod::PUT => { - child_router = child_router.route(path, handler.clone()); - } - RequestMethod::DELETE => { - child_router = child_router.route(path, handler.clone()); - } - RequestMethod::PATCH => { - child_router = child_router.route(path, handler.clone()); - } - } - } - - root_router_child = - root_router_child.merge(Router::new().nest(prefix, child_router)); - } - - Router::new().nest(self.prefix.as_ref(), root_router_child) - } - } - - impl RouteGroup { - pub fn new(prefix: &'static str) -> Self { - Self { - prefix, - routes: vec![], - } - } - - pub fn add_route_item(&mut self, item: RouteItem) { - self.routes.push(item); - } - } - - impl RouteItem { - pub fn new( - path: &'static str, - method: RequestMethod, - handler: MethodRouter, - ) -> Self { - Self { - path, - method, - handler, - } - } - - pub fn get(path: &'static str, handler: MethodRouter) -> Self { - Self::new(path, RequestMethod::GET, handler) - } - - pub fn post(path: &'static str, handler: MethodRouter) -> Self { - Self::new(path, RequestMethod::POST, handler) - } - - pub fn put(path: &'static str, handler: MethodRouter) -> Self { - Self::new(path, RequestMethod::PUT, handler) - } - - pub fn delete(path: &'static str, handler: MethodRouter) -> Self { - Self::new(path, RequestMethod::DELETE, handler) - } - - pub fn patch(path: &'static str, handler: MethodRouter) -> Self { - Self::new(path, RequestMethod::PATCH, handler) - } - } -} +pub mod health; +pub mod secret; +pub mod time; +pub mod users; + +// 新增模块 +pub mod accounts; +pub mod api; +pub mod audit; +pub mod billing; +pub mod browser_kernel; +pub mod environments; +pub mod extensions; +pub mod group_permissions; +pub mod messages; +pub mod preferences; +pub mod proxies; +pub mod proxy_visibility; +pub mod referral; +pub mod rpa; +pub mod teams; +pub mod templates; +pub mod workspace_quotas; +pub mod workspaces; + +// route core +pub mod route { + use std::borrow::Cow; + + use axum::{Router, routing::MethodRouter}; + + use crate::svc_ctx::SvcCtx; + + // can support request methods enum + #[derive(Debug, Clone)] + pub enum RequestMethod { + GET, + POST, + PUT, + DELETE, + PATCH, + } + + /// route item + pub struct RouteItem { + pub path: &'static str, + pub method: RequestMethod, + pub handler: MethodRouter, + } + + /// route group + pub struct RouteGroup { + pub prefix: &'static str, + pub routes: Vec, + } + + /// meta route + pub struct MetaRoute { + pub prefix: Cow<'static, str>, + pub routes: Vec, + } + + impl MetaRoute { + pub fn new(prefix: String) -> Self { + Self { + prefix: Cow::Owned(prefix), + routes: vec![], + } + } + + pub fn add_route_group(&mut self, group: RouteGroup) -> () { + self.routes.push(group); + } + + pub fn count(&self) -> usize { + self.routes.iter().map(|group| group.routes.len()).sum() + } + + pub fn build(&self) -> Router { + let mut root_router_child = Router::new(); + + for contain in &self.routes { + let RouteGroup { prefix, routes } = contain; + + let parent_prefix = prefix.to_string(); + let mut child_router = Router::new(); + + for item in routes { + let RouteItem { + path, + method, + handler, + } = item; + + tracing::info!("{:?}+{}{}{}", method, self.prefix, parent_prefix, path,); + match method { + RequestMethod::GET => { + child_router = child_router.route(path, handler.clone()); + } + RequestMethod::POST => { + child_router = child_router.route(path, handler.clone()); + } + RequestMethod::PUT => { + child_router = child_router.route(path, handler.clone()); + } + RequestMethod::DELETE => { + child_router = child_router.route(path, handler.clone()); + } + RequestMethod::PATCH => { + child_router = child_router.route(path, handler.clone()); + } + } + } + + root_router_child = + root_router_child.merge(Router::new().nest(prefix, child_router)); + } + + Router::new().nest(self.prefix.as_ref(), root_router_child) + } + } + + impl RouteGroup { + pub fn new(prefix: &'static str) -> Self { + Self { + prefix, + routes: vec![], + } + } + + pub fn add_route_item(&mut self, item: RouteItem) { + self.routes.push(item); + } + } + + impl RouteItem { + pub fn new( + path: &'static str, + method: RequestMethod, + handler: MethodRouter, + ) -> Self { + Self { + path, + method, + handler, + } + } + + pub fn get(path: &'static str, handler: MethodRouter) -> Self { + Self::new(path, RequestMethod::GET, handler) + } + + pub fn post(path: &'static str, handler: MethodRouter) -> Self { + Self::new(path, RequestMethod::POST, handler) + } + + pub fn put(path: &'static str, handler: MethodRouter) -> Self { + Self::new(path, RequestMethod::PUT, handler) + } + + pub fn delete(path: &'static str, handler: MethodRouter) -> Self { + Self::new(path, RequestMethod::DELETE, handler) + } + + pub fn patch(path: &'static str, handler: MethodRouter) -> Self { + Self::new(path, RequestMethod::PATCH, handler) + } + } +} diff --git a/src/services/browser_kernel.rs b/src/services/browser_kernel.rs index d7864cd..baee0df 100644 --- a/src/services/browser_kernel.rs +++ b/src/services/browser_kernel.rs @@ -1,45 +1,45 @@ -use crate::{ - dto::versions::Version, - errors::SimprintError, - svc_ctx::SvcCtx, - utils::get_objects::get_version_resource_url, -}; -use std::collections::HashMap; - -/// 查询浏览器内核最新版本列表 -/// 筛选 SIMPRINT_KERNEL_* 类型(或指定 type_code),按 resource_name 分组取最新 -/// 返回 HashMap>,已填充可下载 URL -pub async fn get_browser_kernel_list_service( - svc_ctx: &SvcCtx, - platform: Option, - type_code: Option, -) -> Result>, SimprintError> { - let results = crate::models::versions::query_browser_kernel_latest_versions( - &svc_ctx.db, - platform.as_deref(), - type_code.as_deref(), - ) - .await - .map_err(|_| SimprintError::Other("查询浏览器内核列表失败".to_string()))?; - - let mut map: HashMap> = HashMap::new(); - - let minio_config = &svc_ctx.config.minio; - let (bucket_name, resource_url) = ( - &minio_config.version_resource_bucket, - &minio_config.resource_url, - ); - - for (type_code, _resource_name, version) in results { - map.entry(type_code).or_insert_with(Vec::new).push(Version { - url: Some(get_version_resource_url( - resource_url, - bucket_name, - &version.url.unwrap_or_default(), - )), - ..version - }); - } - - Ok(map) -} +use crate::{ + dto::versions::Version, + errors::SimprintError, + svc_ctx::SvcCtx, + utils::get_objects::get_version_resource_url, +}; +use std::collections::HashMap; + +/// 查询浏览器内核最新版本列表 +/// 筛选 SIMPRINT_KERNEL_* 类型(或指定 type_code),按 resource_name 分组取最新 +/// 返回 HashMap>,已填充可下载 URL +pub async fn get_browser_kernel_list_service( + svc_ctx: &SvcCtx, + platform: Option, + type_code: Option, +) -> Result>, SimprintError> { + let results = crate::models::versions::query_browser_kernel_latest_versions( + &svc_ctx.db, + platform.as_deref(), + type_code.as_deref(), + ) + .await + .map_err(|_| SimprintError::Other("查询浏览器内核列表失败".to_string()))?; + + let mut map: HashMap> = HashMap::new(); + + let minio_config = &svc_ctx.config.minio; + let (bucket_name, resource_url) = ( + &minio_config.version_resource_bucket, + &minio_config.resource_url, + ); + + for (type_code, _resource_name, version) in results { + map.entry(type_code).or_insert_with(Vec::new).push(Version { + url: Some(get_version_resource_url( + resource_url, + bucket_name, + &version.url.unwrap_or_default(), + )), + ..version + }); + } + + Ok(map) +} From 9e90ca21c447e80528f1fffcfbe0444145a1599d Mon Sep 17 00:00:00 2001 From: lius-new Date: Wed, 11 Feb 2026 19:43:19 +0800 Subject: [PATCH 10/60] =?UTF-8?q?feat:=20=E5=AE=9E=E7=8E=B0=E4=BA=8B?= =?UTF-8?q?=E4=BB=B6=E9=A9=B1=E5=8A=A8=E7=9A=84=E7=BC=93=E5=AD=98=E5=A4=B1?= =?UTF-8?q?=E6=95=88=E6=9C=BA=E5=88=B6?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit 核心功能: - 实现 Redis Stream 事件发布系统 - 为所有数据变更操作添加事件通知 - 支持增量同步和全量刷新降级 - 事件包含 affected_apis 字段用于缓存失效 新增模块: - dto/events.rs: 事件数据结构定义 - services/events.rs: 事件发布服务(Redis Stream) - handlers/events.rs: 事件同步 API 处理器 - routes/events.rs: 事件同步路由 事件发布集成: - environments.rs: 23 个操作(环境/分组/标签 CRUD + 关联操作) - accounts.rs: 4 个操作(账号 CRUD) - proxies.rs: 4 个操作(代理 CRUD) 基础设施: - middlewares.rs: 添加路径提取函数(去除 /api/v*/ 前缀) - auth.rs: 在 RequestContext 中设置 resource_identifier - state.rs: 添加 resource_identifier 字段和访问方法 事件存储: - Redis Stream 存储,72 小时 TTL - 支持 last_event_id 增量同步 - 超过 72 小时触发 full_refresh 标志 --- src/dto/events.rs | 95 ++++ src/dto/mod.rs | 1 + src/handlers.rs | 1 + src/handlers/accounts.rs | 60 +++ src/handlers/api.rs | 158 +++---- src/handlers/audit.rs | 196 ++++---- src/handlers/browser_kernel.rs | 46 +- src/handlers/environments.rs | 338 ++++++++++++++ src/handlers/events.rs | 89 ++++ src/handlers/extensions.rs | 388 ++++++++-------- src/handlers/group_permissions.rs | 276 +++++------ src/handlers/health.rs | 30 +- src/handlers/messages.rs | 266 +++++------ src/handlers/preferences.rs | 76 ++-- src/handlers/proxies.rs | 58 +++ src/handlers/proxy_visibility.rs | 300 ++++++------ src/handlers/rpa.rs | 438 +++++++++--------- src/handlers/secret.rs | 30 +- src/handlers/teams.rs | 728 +++++++++++++++--------------- src/handlers/templates.rs | 314 ++++++------- src/handlers/time.rs | 20 +- src/handlers/users.rs | 274 +++++------ src/handlers/workspace_quotas.rs | 66 +-- src/handlers/workspaces.rs | 360 +++++++-------- src/main.rs | 3 +- src/middlewares.rs | 13 + src/middlewares/auth.rs | 10 +- src/routes.rs | 1 + src/routes/events.rs | 18 + src/services.rs | 1 + src/services/events.rs | 182 ++++++++ src/state.rs | 16 + 32 files changed, 2867 insertions(+), 1985 deletions(-) create mode 100644 src/dto/events.rs create mode 100644 src/handlers/events.rs create mode 100644 src/routes/events.rs create mode 100644 src/services/events.rs diff --git a/src/dto/events.rs b/src/dto/events.rs new file mode 100644 index 0000000..5574b07 --- /dev/null +++ b/src/dto/events.rs @@ -0,0 +1,95 @@ +use serde::{Deserialize, Serialize}; +use uuid::Uuid; + +/// 工作空间事件 +#[derive(Debug, Clone, Serialize, Deserialize)] +pub struct WorkspaceEvent { + /// 事件 ID(Redis Stream 自动生成) + pub event_id: String, + + /// 工作空间 ID + pub workspace_uuid: Uuid, + + /// 事件类型 + pub event_type: EventType, + + /// 实体类型 + pub entity_type: EntityType, + + /// 实体 ID(可选,如批量删除时为空) + pub entity_id: Option, + + /// 受影响的 API 列表 + pub affected_apis: Vec, + + /// 事件负载(可选,用于携带额外信息) + #[serde(skip_serializing_if = "Option::is_none")] + pub payload: Option, + + /// 创建时间戳(毫秒) + pub created_at: i64, +} + +/// 事件类型 +#[derive(Debug, Clone, Serialize, Deserialize)] +#[serde(rename_all = "snake_case")] +pub enum EventType { + /// 创建 + Created, + /// 更新 + Updated, + /// 删除 + Deleted, + /// 批量删除 + BatchDeleted, + /// 移动 + Moved, + /// 分配标签 + TagsAssigned, + /// 移除标签 + TagsRemoved, +} + +/// 实体类型 +#[derive(Debug, Clone, Serialize, Deserialize)] +#[serde(rename_all = "snake_case")] +pub enum EntityType { + /// 环境 + Environment, + /// 分组 + Group, + /// 标签 + Tag, + /// 代理 + Proxy, + /// 账号 + Account, +} + +impl WorkspaceEvent { + /// 创建新事件 + pub fn new( + workspace_uuid: Uuid, + event_type: EventType, + entity_type: EntityType, + entity_id: Option, + affected_apis: Vec, + ) -> Self { + Self { + event_id: String::new(), // Redis Stream 会自动生成 + workspace_uuid, + event_type, + entity_type, + entity_id, + affected_apis, + payload: None, + created_at: chrono::Utc::now().timestamp_millis(), + } + } + + /// 设置负载 + pub fn with_payload(mut self, payload: serde_json::Value) -> Self { + self.payload = Some(payload); + self + } +} diff --git a/src/dto/mod.rs b/src/dto/mod.rs index 32a0c25..176eea7 100644 --- a/src/dto/mod.rs +++ b/src/dto/mod.rs @@ -27,6 +27,7 @@ pub mod system; pub mod teams; pub mod workspace_quotas; pub mod workspaces; +pub mod events; pub use gray_releases::*; pub use gray_resources::*; diff --git a/src/handlers.rs b/src/handlers.rs index f533e8e..24fbb1a 100644 --- a/src/handlers.rs +++ b/src/handlers.rs @@ -22,6 +22,7 @@ pub mod teams; pub mod templates; pub mod workspace_quotas; pub mod workspaces; +pub mod events; pub use health::*; pub use secret::*; diff --git a/src/handlers/accounts.rs b/src/handlers/accounts.rs index a463b58..b24efce 100644 --- a/src/handlers/accounts.rs +++ b/src/handlers/accounts.rs @@ -1,5 +1,6 @@ use axum::{extract::Extension, extract::State}; +use crate::dto::events::{EntityType, EventType}; use crate::entitys::{ AccountListResponse, BatchImportAccountsRequest, BatchImportResponse, BatchUuidRequest, CreateAccountRequest, CreateResponse, ListAccountsRequest, UpdateAccountRequest, UuidRequest, @@ -70,6 +71,20 @@ pub async fn create_account_handler( .await .map_err(|e| Response::fail(Some(&e)))?; + // 发布账号创建事件 + if let Err(e) = services::events::EventService::publish_from_context( + &svc_ctx, + &ctx, + EventType::Created, + EntityType::Account, + Some(account_uuid), + vec!["accounts/list".to_string()], + ) + .await + { + tracing::error!("发布账号创建事件失败: {}", e); + } + Ok(Response::success( Some("创建成功"), Some(CreateResponse { uuid: account_uuid }), @@ -79,36 +94,81 @@ pub async fn create_account_handler( /// 更新账号 pub async fn update_account_handler( State(svc_ctx): State, + Extension(ctx): Extension, Json(payload): Json, ) -> Result<()> { services::accounts::update_account_service(&svc_ctx, &payload) .await .map_err(|e| Response::fail(Some(&e)))?; + // 发布账号更新事件 + if let Err(e) = services::events::EventService::publish_from_context( + &svc_ctx, + &ctx, + EventType::Updated, + EntityType::Account, + Some(payload.uuid), + vec!["accounts/list".to_string()], + ) + .await + { + tracing::error!("发布账号更新事件失败: {}", e); + } + Ok(Response::success(Some("更新成功"), None)) } /// 删除账号 pub async fn delete_account_handler( State(svc_ctx): State, + Extension(ctx): Extension, Json(payload): Json, ) -> Result<()> { services::accounts::delete_account_service(&svc_ctx, payload.uuid) .await .map_err(|e| Response::fail(Some(&e)))?; + // 发布账号删除事件 + if let Err(e) = services::events::EventService::publish_from_context( + &svc_ctx, + &ctx, + EventType::Deleted, + EntityType::Account, + Some(payload.uuid), + vec!["accounts/list".to_string()], + ) + .await + { + tracing::error!("发布账号删除事件失败: {}", e); + } + Ok(Response::success(Some("删除成功"), None)) } /// 批量删除账号 pub async fn batch_delete_accounts_handler( State(svc_ctx): State, + Extension(ctx): Extension, Json(payload): Json, ) -> Result { let count = services::accounts::batch_delete_accounts_service(&svc_ctx, &payload.uuids) .await .map_err(|e| Response::fail(Some(&e)))?; + // 发布批量删除账号事件 + if let Err(e) = services::events::EventService::publish_from_context( + &svc_ctx, + &ctx, + EventType::Deleted, + EntityType::Account, + None, + vec!["accounts/list".to_string()], + ) + .await + { + tracing::error!("发布批量删除账号事件失败: {}", e); + } + Ok(Response::success(Some("删除成功"), Some(count))) } diff --git a/src/handlers/api.rs b/src/handlers/api.rs index d526777..807a422 100644 --- a/src/handlers/api.rs +++ b/src/handlers/api.rs @@ -1,79 +1,79 @@ -use axum::extract::{Extension, State}; - -use crate::entitys::api_service::{ - ApiKeysListResponse, CreateApiKeyRequest, CreateApiKeyResponse, ListApiKeysRequest, - RevokeApiKeyRequest, UpdateApiKeyRequest, -}; -use crate::services; -use crate::state::RequestContext; -use crate::svc_ctx::SvcCtx; -use crate::utils::{Json, Response, Result}; - -/// 获取 API 密钥列表 -pub async fn get_api_keys_handler( - State(svc_ctx): State, - Extension(ctx): Extension, - Json(payload): Json, -) -> Result { - let (items, total) = - services::api::get_api_keys_service(&svc_ctx, ctx.user_uuid_unwrap(), &payload) - .await - .map_err(|e| Response::fail(Some(&e)))?; - - Ok(Response::success( - Some("获取成功"), - Some(ApiKeysListResponse { - items, - total, - page: payload.pagination.page, - page_size: payload.pagination.page_size, - }), - )) -} - -/// 创建 API 密钥 -pub async fn create_api_key_handler( - State(svc_ctx): State, - Extension(ctx): Extension, - Json(payload): Json, -) -> Result { - let (uuid, api_key, key_prefix) = - services::api::create_api_key_service(&svc_ctx, ctx.user_uuid_unwrap(), &payload) - .await - .map_err(|e| Response::fail(Some(&e)))?; - - Ok(Response::success( - Some("创建成功,请妥善保存密钥"), - Some(CreateApiKeyResponse { - uuid, - api_key, - key_prefix, - }), - )) -} - -/// 更新 API 密钥 -pub async fn update_api_key_handler( - State(svc_ctx): State, - Extension(ctx): Extension, - Json(payload): Json, -) -> Result<()> { - services::api::update_api_key_service(&svc_ctx, ctx.user_uuid_unwrap(), &payload) - .await - .map_err(|e| Response::fail(Some(&e)))?; - - Ok(Response::success(Some("更新成功"), None)) -} - -/// 撤销 API 密钥 -pub async fn revoke_api_key_handler( - State(svc_ctx): State, - Extension(ctx): Extension, - Json(payload): Json, -) -> Result<()> { - services::api::revoke_api_key_service(&svc_ctx, ctx.user_uuid_unwrap(), payload.uuid) - .await - .map_err(|e| Response::fail(Some(&e)))?; - - Ok(Response::success(Some("撤销成功"), None)) -} +use axum::extract::{Extension, State}; + +use crate::entitys::api_service::{ + ApiKeysListResponse, CreateApiKeyRequest, CreateApiKeyResponse, ListApiKeysRequest, + RevokeApiKeyRequest, UpdateApiKeyRequest, +}; +use crate::services; +use crate::state::RequestContext; +use crate::svc_ctx::SvcCtx; +use crate::utils::{Json, Response, Result}; + +/// 获取 API 密钥列表 +pub async fn get_api_keys_handler( + State(svc_ctx): State, + Extension(ctx): Extension, + Json(payload): Json, +) -> Result { + let (items, total) = + services::api::get_api_keys_service(&svc_ctx, ctx.user_uuid_unwrap(), &payload) + .await + .map_err(|e| Response::fail(Some(&e)))?; + + Ok(Response::success( + Some("获取成功"), + Some(ApiKeysListResponse { + items, + total, + page: payload.pagination.page, + page_size: payload.pagination.page_size, + }), + )) +} + +/// 创建 API 密钥 +pub async fn create_api_key_handler( + State(svc_ctx): State, + Extension(ctx): Extension, + Json(payload): Json, +) -> Result { + let (uuid, api_key, key_prefix) = + services::api::create_api_key_service(&svc_ctx, ctx.user_uuid_unwrap(), &payload) + .await + .map_err(|e| Response::fail(Some(&e)))?; + + Ok(Response::success( + Some("创建成功,请妥善保存密钥"), + Some(CreateApiKeyResponse { + uuid, + api_key, + key_prefix, + }), + )) +} + +/// 更新 API 密钥 +pub async fn update_api_key_handler( + State(svc_ctx): State, + Extension(ctx): Extension, + Json(payload): Json, +) -> Result<()> { + services::api::update_api_key_service(&svc_ctx, ctx.user_uuid_unwrap(), &payload) + .await + .map_err(|e| Response::fail(Some(&e)))?; + + Ok(Response::success(Some("更新成功"), None)) +} + +/// 撤销 API 密钥 +pub async fn revoke_api_key_handler( + State(svc_ctx): State, + Extension(ctx): Extension, + Json(payload): Json, +) -> Result<()> { + services::api::revoke_api_key_service(&svc_ctx, ctx.user_uuid_unwrap(), payload.uuid) + .await + .map_err(|e| Response::fail(Some(&e)))?; + + Ok(Response::success(Some("撤销成功"), None)) +} diff --git a/src/handlers/audit.rs b/src/handlers/audit.rs index 676d4dc..244cce2 100644 --- a/src/handlers/audit.rs +++ b/src/handlers/audit.rs @@ -1,98 +1,98 @@ -use axum::extract::{Extension, State}; - -use crate::dto::AuditLogDto; -use crate::entitys::{ - AuditLogsListResponse, AuditStatsRequest, AuditStatsResponse, ExportAuditLogsRequest, - ExportResponse, ListAuditLogsRequest, UuidRequest, -}; -use crate::services; -use crate::state::RequestContext; -use crate::svc_ctx::SvcCtx; -use crate::utils::{Json, Response, Result}; - -/// 获取审计日志列表 -pub async fn get_audit_logs_handler( - State(svc_ctx): State, - Extension(ctx): Extension, - Json(payload): Json, -) -> Result { - let current_user_uuid = ctx.user_uuid_unwrap(); - let team_uuid = services::teams::get_current_team_service(&svc_ctx, current_user_uuid) - .await - .map_err(|e| Response::fail(Some(&e)))?; - - let (items, total) = - services::audit::get_audit_logs_service(&svc_ctx, current_user_uuid, team_uuid, &payload) - .await - .map_err(|e| Response::fail(Some(&e)))?; - - Ok(Response::success( - Some("获取成功"), - Some(AuditLogsListResponse { - items, - total, - page: payload.pagination.page, - page_size: payload.pagination.page_size, - }), - )) -} - -/// 获取审计日志详情 -pub async fn get_audit_log_detail_handler( - State(svc_ctx): State, - Json(payload): Json, -) -> Result { - let log = services::audit::get_audit_log_service(&svc_ctx, payload.uuid) - .await - .map_err(|e| Response::fail(Some(&e)))?; - - Ok(Response::success(Some("获取成功"), Some(log))) -} - -/// 导出审计日志 -pub async fn export_audit_logs_handler( - State(svc_ctx): State, - Extension(ctx): Extension, - Json(payload): Json, -) -> Result { - let current_user_uuid = ctx.user_uuid_unwrap(); - let team_uuid = services::teams::get_current_team_service(&svc_ctx, current_user_uuid) - .await - .map_err(|e| Response::fail(Some(&e)))?; - - let (content, filename, mime_type) = services::audit::export_audit_logs_service( - &svc_ctx, - current_user_uuid, - team_uuid, - &payload, - ) - .await - .map_err(|e| Response::fail(Some(&e)))?; - - Ok(Response::success( - Some("导出成功"), - Some(ExportResponse { - content, - filename, - mime_type, - }), - )) -} - -/// 获取审计统计 -pub async fn get_audit_stats_handler( - State(svc_ctx): State, - Extension(ctx): Extension, - Json(_payload): Json, -) -> Result { - let current_user_uuid = ctx.user_uuid_unwrap(); - let team_uuid = services::teams::get_current_team_service(&svc_ctx, current_user_uuid) - .await - .map_err(|e| Response::fail(Some(&e)))?; - - let stats = services::audit::get_audit_stats_service(&svc_ctx, current_user_uuid, team_uuid) - .await - .map_err(|e| Response::fail(Some(&e)))?; - - Ok(Response::success(Some("获取成功"), Some(stats))) -} +use axum::extract::{Extension, State}; + +use crate::dto::AuditLogDto; +use crate::entitys::{ + AuditLogsListResponse, AuditStatsRequest, AuditStatsResponse, ExportAuditLogsRequest, + ExportResponse, ListAuditLogsRequest, UuidRequest, +}; +use crate::services; +use crate::state::RequestContext; +use crate::svc_ctx::SvcCtx; +use crate::utils::{Json, Response, Result}; + +/// 获取审计日志列表 +pub async fn get_audit_logs_handler( + State(svc_ctx): State, + Extension(ctx): Extension, + Json(payload): Json, +) -> Result { + let current_user_uuid = ctx.user_uuid_unwrap(); + let team_uuid = services::teams::get_current_team_service(&svc_ctx, current_user_uuid) + .await + .map_err(|e| Response::fail(Some(&e)))?; + + let (items, total) = + services::audit::get_audit_logs_service(&svc_ctx, current_user_uuid, team_uuid, &payload) + .await + .map_err(|e| Response::fail(Some(&e)))?; + + Ok(Response::success( + Some("获取成功"), + Some(AuditLogsListResponse { + items, + total, + page: payload.pagination.page, + page_size: payload.pagination.page_size, + }), + )) +} + +/// 获取审计日志详情 +pub async fn get_audit_log_detail_handler( + State(svc_ctx): State, + Json(payload): Json, +) -> Result { + let log = services::audit::get_audit_log_service(&svc_ctx, payload.uuid) + .await + .map_err(|e| Response::fail(Some(&e)))?; + + Ok(Response::success(Some("获取成功"), Some(log))) +} + +/// 导出审计日志 +pub async fn export_audit_logs_handler( + State(svc_ctx): State, + Extension(ctx): Extension, + Json(payload): Json, +) -> Result { + let current_user_uuid = ctx.user_uuid_unwrap(); + let team_uuid = services::teams::get_current_team_service(&svc_ctx, current_user_uuid) + .await + .map_err(|e| Response::fail(Some(&e)))?; + + let (content, filename, mime_type) = services::audit::export_audit_logs_service( + &svc_ctx, + current_user_uuid, + team_uuid, + &payload, + ) + .await + .map_err(|e| Response::fail(Some(&e)))?; + + Ok(Response::success( + Some("导出成功"), + Some(ExportResponse { + content, + filename, + mime_type, + }), + )) +} + +/// 获取审计统计 +pub async fn get_audit_stats_handler( + State(svc_ctx): State, + Extension(ctx): Extension, + Json(_payload): Json, +) -> Result { + let current_user_uuid = ctx.user_uuid_unwrap(); + let team_uuid = services::teams::get_current_team_service(&svc_ctx, current_user_uuid) + .await + .map_err(|e| Response::fail(Some(&e)))?; + + let stats = services::audit::get_audit_stats_service(&svc_ctx, current_user_uuid, team_uuid) + .await + .map_err(|e| Response::fail(Some(&e)))?; + + Ok(Response::success(Some("获取成功"), Some(stats))) +} diff --git a/src/handlers/browser_kernel.rs b/src/handlers/browser_kernel.rs index da48f9b..cd67121 100644 --- a/src/handlers/browser_kernel.rs +++ b/src/handlers/browser_kernel.rs @@ -1,23 +1,23 @@ -use axum::extract::State; -use std::collections::HashMap; - -use crate::entitys::browser_kernel::ListBrowserKernelsRequest; -use crate::services::browser_kernel::get_browser_kernel_list_service; -use crate::svc_ctx::SvcCtx; -use crate::utils::{Json, Response, Result}; - -/// 查询浏览器内核最新版本列表 -pub async fn list_browser_kernels_handler( - State(svc_ctx): State, - Json(payload): Json, -) -> Result>> { - let kernels = get_browser_kernel_list_service( - &svc_ctx, - payload.platform, - payload.type_code, - ) - .await - .map_err(|e| Response::fail(Some(&e.to_string())))?; - - Ok(Response::success(Some("获取浏览器内核列表成功"), Some(kernels))) -} +use axum::extract::State; +use std::collections::HashMap; + +use crate::entitys::browser_kernel::ListBrowserKernelsRequest; +use crate::services::browser_kernel::get_browser_kernel_list_service; +use crate::svc_ctx::SvcCtx; +use crate::utils::{Json, Response, Result}; + +/// 查询浏览器内核最新版本列表 +pub async fn list_browser_kernels_handler( + State(svc_ctx): State, + Json(payload): Json, +) -> Result>> { + let kernels = get_browser_kernel_list_service( + &svc_ctx, + payload.platform, + payload.type_code, + ) + .await + .map_err(|e| Response::fail(Some(&e.to_string())))?; + + Ok(Response::success(Some("获取浏览器内核列表成功"), Some(kernels))) +} diff --git a/src/handlers/environments.rs b/src/handlers/environments.rs index 0732813..9653795 100644 --- a/src/handlers/environments.rs +++ b/src/handlers/environments.rs @@ -1,6 +1,7 @@ use axum::{extract::Extension, extract::State}; use crate::audit_log; +use crate::dto::events::{EntityType, EventType}; use crate::entitys::{ AddEnvironmentCookieRequest, AddEnvironmentUrlRequest, AssignTagsRequest, BatchAssignTagRequest, BatchCreateEnvironmentRequest, BatchMoveToGroupRequest, @@ -126,6 +127,20 @@ pub async fn delete_group_handler( audit_log!(&svc_ctx, &ctx, "delete", "group", payload.uuid, "删除分组").await; + // 发布分组删除事件 + if let Err(e) = services::events::EventService::publish_from_context( + &svc_ctx, + &ctx, + EventType::Deleted, + EntityType::Group, + Some(payload.uuid), + vec!["groups/list".to_string()], + ) + .await + { + tracing::error!("发布分组删除事件失败: {}", e); + } + Ok(Response::success(Some("删除成功"), None)) } @@ -157,6 +172,20 @@ pub async fn create_tag_handler( ) .await; + // 发布标签创建事件 + if let Err(e) = services::events::EventService::publish_from_context( + &svc_ctx, + &ctx, + EventType::Created, + EntityType::Tag, + Some(tag_uuid), + vec!["tags/list".to_string()], + ) + .await + { + tracing::error!("发布标签创建事件失败: {}", e); + } + Ok(Response::success( Some("创建成功"), Some(CreateResponse { uuid: tag_uuid }), @@ -182,12 +211,27 @@ pub async fn get_tags_handler( /// 更新标签 pub async fn update_tag_handler( State(svc_ctx): State, + Extension(ctx): Extension, Json(payload): Json, ) -> Result<()> { services::tags::update_tag_service(&svc_ctx, &payload) .await .map_err(|e| Response::fail(Some(&e)))?; + // 发布标签更新事件 + if let Err(e) = services::events::EventService::publish_from_context( + &svc_ctx, + &ctx, + EventType::Updated, + EntityType::Tag, + Some(payload.uuid), + vec!["tags/list".to_string()], + ) + .await + { + tracing::error!("发布标签更新事件失败: {}", e); + } + Ok(Response::success(Some("更新成功"), None)) } @@ -203,6 +247,20 @@ pub async fn delete_tag_handler( audit_log!(&svc_ctx, &ctx, "delete", "tag", payload.uuid, "删除标签").await; + // 发布标签删除事件 + if let Err(e) = services::events::EventService::publish_from_context( + &svc_ctx, + &ctx, + EventType::Deleted, + EntityType::Tag, + Some(payload.uuid), + vec!["tags/list".to_string()], + ) + .await + { + tracing::error!("发布标签删除事件失败: {}", e); + } + Ok(Response::success(Some("删除成功"), None)) } @@ -336,6 +394,20 @@ pub async fn create_environment_handler( ) .await; + // 发布环境创建事件 + if let Err(e) = services::events::EventService::publish_from_context( + &svc_ctx, + &ctx, + EventType::Created, + EntityType::Environment, + Some(env_uuid), + vec!["environments/list".to_string()], + ) + .await + { + tracing::error!("发布环境创建事件失败: {}", e); + } + Ok(Response::success( Some("创建成功"), Some(CreateResponse { uuid: env_uuid }), @@ -376,6 +448,20 @@ pub async fn batch_create_environments_handler( ) .await; + // 发布批量创建环境事件 + if let Err(e) = services::events::EventService::publish_from_context( + &svc_ctx, + &ctx, + EventType::Created, + EntityType::Environment, + None, + vec!["environments/list".to_string()], + ) + .await + { + tracing::error!("发布批量创建环境事件失败: {}", e); + } + Ok(Response::success(Some("批量创建成功"), Some(responses))) } @@ -400,6 +486,20 @@ pub async fn update_environment_handler( .await .map_err(|e| Response::fail(Some(&e)))?; + // 发布环境更新事件 + if let Err(e) = services::events::EventService::publish_from_context( + &svc_ctx, + &ctx, + EventType::Updated, + EntityType::Environment, + Some(payload.uuid), + vec!["environments/list".to_string()], + ) + .await + { + tracing::error!("发布环境更新事件失败: {}", e); + } + Ok(Response::success(Some("更新成功"), None)) } @@ -434,6 +534,20 @@ pub async fn delete_environment_handler( ) .await; + // 发布环境删除事件 + if let Err(e) = services::events::EventService::publish_from_context( + &svc_ctx, + &ctx, + EventType::Deleted, + EntityType::Environment, + Some(payload.uuid), + vec!["environments/list".to_string()], + ) + .await + { + tracing::error!("发布环境删除事件失败: {}", e); + } + Ok(Response::success(Some("删除成功"), None)) } @@ -456,72 +570,162 @@ pub async fn batch_delete_environments_handler( ) .await; + // 发布批量删除环境事件 + if let Err(e) = services::events::EventService::publish_from_context( + &svc_ctx, + &ctx, + EventType::Deleted, + EntityType::Environment, + None, + vec!["environments/list".to_string()], + ) + .await + { + tracing::error!("发布批量删除环境事件失败: {}", e); + } + Ok(Response::success(Some("删除成功"), Some(count))) } /// 设置环境代理 pub async fn set_proxy_handler( State(svc_ctx): State, + Extension(ctx): Extension, Json(payload): Json, ) -> Result<()> { services::environments::set_environment_proxy_service(&svc_ctx, &payload) .await .map_err(|e| Response::fail(Some(&e)))?; + // 发布环境更新事件 + if let Err(e) = services::events::EventService::publish_from_context( + &svc_ctx, + &ctx, + EventType::Updated, + EntityType::Environment, + Some(payload.uuid), + vec!["environments/list".to_string()], + ) + .await + { + tracing::error!("发布环境更新事件失败: {}", e); + } + Ok(Response::success(Some("设置成功"), None)) } /// 分配标签 pub async fn assign_tags_handler( State(svc_ctx): State, + Extension(ctx): Extension, Json(payload): Json, ) -> Result<()> { services::environments::assign_tags_service(&svc_ctx, &payload) .await .map_err(|e| Response::fail(Some(&e)))?; + // 发布环境更新事件 + if let Err(e) = services::events::EventService::publish_from_context( + &svc_ctx, + &ctx, + EventType::Updated, + EntityType::Environment, + Some(payload.uuid), + vec!["environments/list".to_string()], + ) + .await + { + tracing::error!("发布环境更新事件失败: {}", e); + } + Ok(Response::success(Some("分配成功"), None)) } /// 移除标签 pub async fn remove_tag_handler( State(svc_ctx): State, + Extension(ctx): Extension, Json(payload): Json, ) -> Result<()> { services::environments::remove_tag_service(&svc_ctx, payload.uuid, payload.tag_uuid) .await .map_err(|e| Response::fail(Some(&e)))?; + // 发布环境更新事件 + if let Err(e) = services::events::EventService::publish_from_context( + &svc_ctx, + &ctx, + EventType::Updated, + EntityType::Environment, + Some(payload.uuid), + vec!["environments/list".to_string()], + ) + .await + { + tracing::error!("发布环境更新事件失败: {}", e); + } + Ok(Response::success(Some("移除成功"), None)) } /// 移动到分组 pub async fn move_to_group_handler( State(svc_ctx): State, + Extension(ctx): Extension, Json(payload): Json, ) -> Result<()> { services::environments::move_to_group_service(&svc_ctx, &payload) .await .map_err(|e| Response::fail(Some(&e)))?; + // 发布环境更新事件 + if let Err(e) = services::events::EventService::publish_from_context( + &svc_ctx, + &ctx, + EventType::Updated, + EntityType::Environment, + Some(payload.uuid), + vec!["environments/list".to_string()], + ) + .await + { + tracing::error!("发布环境更新事件失败: {}", e); + } + Ok(Response::success(Some("移动成功"), None)) } /// 批量移动到分组 pub async fn batch_move_to_group_handler( State(svc_ctx): State, + Extension(ctx): Extension, Json(payload): Json, ) -> Result<()> { services::environments::batch_move_to_group_service(&svc_ctx, &payload) .await .map_err(|e| Response::fail(Some(&e)))?; + // 发布批量环境更新事件 + if let Err(e) = services::events::EventService::publish_from_context( + &svc_ctx, + &ctx, + EventType::Updated, + EntityType::Environment, + None, + vec!["environments/list".to_string()], + ) + .await + { + tracing::error!("发布批量环境更新事件失败: {}", e); + } + Ok(Response::success(Some("移动成功"), None)) } /// 设置环境账号 pub async fn set_environment_accounts_handler( State(svc_ctx): State, + Extension(ctx): Extension, Json(payload): Json, ) -> Result<()> { services::accounts::set_environment_accounts_service( @@ -532,30 +736,74 @@ pub async fn set_environment_accounts_handler( .await .map_err(|e| Response::fail(Some(&e)))?; + // 发布环境更新事件 + if let Err(e) = services::events::EventService::publish_from_context( + &svc_ctx, + &ctx, + EventType::Updated, + EntityType::Environment, + Some(payload.uuid), + vec!["environments/list".to_string()], + ) + .await + { + tracing::error!("发布环境更新事件失败: {}", e); + } + Ok(Response::success(Some("设置成功"), None)) } /// 批量分配标签 pub async fn batch_assign_tags_handler( State(svc_ctx): State, + Extension(ctx): Extension, Json(payload): Json, ) -> Result<()> { services::environments::batch_assign_tags_service(&svc_ctx, &payload) .await .map_err(|e| Response::fail(Some(&e)))?; + // 发布批量环境更新事件 + if let Err(e) = services::events::EventService::publish_from_context( + &svc_ctx, + &ctx, + EventType::Updated, + EntityType::Environment, + None, + vec!["environments/list".to_string()], + ) + .await + { + tracing::error!("发布批量环境更新事件失败: {}", e); + } + Ok(Response::success(Some("分配成功"), None)) } /// 批量移除标签 pub async fn batch_remove_tags_handler( State(svc_ctx): State, + Extension(ctx): Extension, Json(payload): Json, ) -> Result<()> { services::environments::batch_remove_tags_service(&svc_ctx, &payload) .await .map_err(|e| Response::fail(Some(&e)))?; + // 发布批量环境更新事件 + if let Err(e) = services::events::EventService::publish_from_context( + &svc_ctx, + &ctx, + EventType::Updated, + EntityType::Environment, + None, + vec!["environments/list".to_string()], + ) + .await + { + tracing::error!("发布批量环境更新事件失败: {}", e); + } + Ok(Response::success(Some("移除成功"), None)) } @@ -564,12 +812,27 @@ pub async fn batch_remove_tags_handler( /// 添加环境 URL pub async fn add_environment_url_handler( State(svc_ctx): State, + Extension(ctx): Extension, Json(payload): Json, ) -> Result { let id = services::environments::add_environment_url_service(&svc_ctx, &payload) .await .map_err(|e| Response::fail(Some(&e)))?; + // 发布环境更新事件 + if let Err(e) = services::events::EventService::publish_from_context( + &svc_ctx, + &ctx, + EventType::Updated, + EntityType::Environment, + Some(payload.environment_uuid), + vec!["environments/list".to_string()], + ) + .await + { + tracing::error!("发布环境更新事件失败: {}", e); + } + Ok(Response::success(Some("添加成功"), Some(IdResponse { id }))) } @@ -588,18 +851,34 @@ pub async fn get_environment_urls_handler( /// 删除环境 URL pub async fn delete_environment_url_handler( State(svc_ctx): State, + Extension(ctx): Extension, Json(payload): Json, ) -> Result<()> { services::environments::delete_environment_url_service(&svc_ctx, payload.id) .await .map_err(|e| Response::fail(Some(&e)))?; + // 发布环境更新事件 + if let Err(e) = services::events::EventService::publish_from_context( + &svc_ctx, + &ctx, + EventType::Updated, + EntityType::Environment, + None, + vec!["environments/list".to_string()], + ) + .await + { + tracing::error!("发布环境更新事件失败: {}", e); + } + Ok(Response::success(Some("删除成功"), None)) } /// 清空环境 URL pub async fn clear_environment_urls_handler( State(svc_ctx): State, + Extension(ctx): Extension, Json(payload): Json, ) -> Result { let count = @@ -607,6 +886,20 @@ pub async fn clear_environment_urls_handler( .await .map_err(|e| Response::fail(Some(&e)))?; + // 发布环境更新事件 + if let Err(e) = services::events::EventService::publish_from_context( + &svc_ctx, + &ctx, + EventType::Updated, + EntityType::Environment, + Some(payload.environment_uuid), + vec!["environments/list".to_string()], + ) + .await + { + tracing::error!("发布环境更新事件失败: {}", e); + } + Ok(Response::success(Some("清空成功"), Some(count))) } @@ -615,12 +908,27 @@ pub async fn clear_environment_urls_handler( /// 添加环境 Cookie pub async fn add_environment_cookie_handler( State(svc_ctx): State, + Extension(ctx): Extension, Json(payload): Json, ) -> Result { let id = services::environments::add_environment_cookie_service(&svc_ctx, &payload) .await .map_err(|e| Response::fail(Some(&e)))?; + // 发布环境更新事件 + if let Err(e) = services::events::EventService::publish_from_context( + &svc_ctx, + &ctx, + EventType::Updated, + EntityType::Environment, + Some(payload.environment_uuid), + vec!["environments/list".to_string()], + ) + .await + { + tracing::error!("发布环境更新事件失败: {}", e); + } + Ok(Response::success(Some("添加成功"), Some(IdResponse { id }))) } @@ -639,18 +947,34 @@ pub async fn get_environment_cookies_handler( /// 删除环境 Cookie pub async fn delete_environment_cookie_handler( State(svc_ctx): State, + Extension(ctx): Extension, Json(payload): Json, ) -> Result<()> { services::environments::delete_environment_cookie_service(&svc_ctx, payload.id) .await .map_err(|e| Response::fail(Some(&e)))?; + // 发布环境更新事件 + if let Err(e) = services::events::EventService::publish_from_context( + &svc_ctx, + &ctx, + EventType::Updated, + EntityType::Environment, + None, + vec!["environments/list".to_string()], + ) + .await + { + tracing::error!("发布环境更新事件失败: {}", e); + } + Ok(Response::success(Some("删除成功"), None)) } /// 清空环境 Cookies pub async fn clear_environment_cookies_handler( State(svc_ctx): State, + Extension(ctx): Extension, Json(payload): Json, ) -> Result { let count = services::environments::clear_environment_cookies_service( @@ -660,5 +984,19 @@ pub async fn clear_environment_cookies_handler( .await .map_err(|e| Response::fail(Some(&e)))?; + // 发布环境更新事件 + if let Err(e) = services::events::EventService::publish_from_context( + &svc_ctx, + &ctx, + EventType::Updated, + EntityType::Environment, + Some(payload.environment_uuid), + vec!["environments/list".to_string()], + ) + .await + { + tracing::error!("发布环境更新事件失败: {}", e); + } + Ok(Response::success(Some("清空成功"), Some(count))) } diff --git a/src/handlers/events.rs b/src/handlers/events.rs new file mode 100644 index 0000000..e2b7952 --- /dev/null +++ b/src/handlers/events.rs @@ -0,0 +1,89 @@ +use axum::{extract::State, Extension}; +use serde::{Deserialize, Serialize}; + +use crate::dto::events::WorkspaceEvent; +use crate::services::events::EventService; +use crate::state::RequestContext; +use crate::svc_ctx::SvcCtx; +use crate::utils::{Json, Response, Result}; + +/// 同步事件请求 +#[derive(Debug, Serialize, Deserialize)] +pub struct SyncEventsRequest { + /// 上次同步的事件 ID(可选) + pub last_event_id: Option, +} + +/// 同步事件响应 +#[derive(Debug, Serialize)] +pub struct SyncEventsResponse { + /// 事件列表 + pub events: Vec, + + /// 是否需要全量刷新 + pub full_refresh: bool, + + /// 最新事件 ID + pub latest_event_id: Option, +} + +/// 同步事件 +/// +/// POST /api/v1/events/sync +#[axum::debug_handler] +pub async fn sync_events_handler( + State(svc_ctx): State, + Extension(ctx): Extension, + Json(req): Json, +) -> Result { + let workspace_uuid = ctx + .current_workspace_uuid + .ok_or_else(|| Response::fail(Some("未选择工作空间")))?; + + // 获取事件 + let events = EventService::get_events_since(&svc_ctx, workspace_uuid, req.last_event_id.clone()) + .await + .map_err(|e| { + tracing::error!("获取事件失败: {}", e); + Response::fail(Some("获取事件失败")) + })?; + + // 判断是否需要全量刷新 + let full_refresh = if let Some(last_id) = &req.last_event_id { + // 如果提供了 last_event_id 但没有获取到任何事件 + // 可能是因为事件已过期(> 72 小时) + if !last_id.is_empty() && events.is_empty() { + // 检查事件是否真的过期了 + match EventService::event_exists(&svc_ctx, workspace_uuid, last_id).await { + Ok(exists) => !exists, + Err(e) => { + tracing::error!("检查事件是否存在失败: {}", e); + false + } + } + } else { + false + } + } else { + false + }; + + let latest_event_id = events.last().map(|e| e.event_id.clone()); + + tracing::debug!( + "同步事件: workspace={}, last_id={:?}, count={}, full_refresh={}", + workspace_uuid, + req.last_event_id, + events.len(), + full_refresh + ); + + Ok(Response::success( + Some("同步成功"), + Some(SyncEventsResponse { + events, + full_refresh, + latest_event_id, + }), + )) +} diff --git a/src/handlers/extensions.rs b/src/handlers/extensions.rs index 86c6bb9..7a1fa54 100644 --- a/src/handlers/extensions.rs +++ b/src/handlers/extensions.rs @@ -1,194 +1,194 @@ -use axum::extract::{Extension, State}; - -use crate::dto::ExtensionDto; -use crate::entitys::{ - BatchUpdateExtensionsRequest, BatchUpdateResponse, ExtensionIdRequest, ExtensionsListResponse, - GetInstalledExtensionsRequest, InstallExtensionRequest, InstalledExtensionsResponse, - ListExtensionsRequest, UninstallExtensionRequest, UpdateExtensionRequest, -}; -use crate::services; -use crate::state::RequestContext; -use crate::svc_ctx::SvcCtx; -use crate::utils::{Json, Response, Result}; - -/// 获取扩展列表 -pub async fn get_extensions_handler( - State(svc_ctx): State, - Json(payload): Json, -) -> Result { - let (items, total) = services::extensions::get_extensions_service(&svc_ctx, &payload) - .await - .map_err(|e| Response::fail(Some(&e)))?; - - Ok(Response::success( - Some("获取成功"), - Some(ExtensionsListResponse { - items, - total, - page: payload.pagination.page, - page_size: payload.pagination.page_size, - }), - )) -} - -/// 获取扩展详情 -pub async fn get_extension_detail_handler( - State(svc_ctx): State, - Json(payload): Json, -) -> Result { - let extension = services::extensions::get_extension_service(&svc_ctx, &payload.extension_id) - .await - .map_err(|e| Response::fail(Some(&e)))?; - - Ok(Response::success(Some("获取成功"), Some(extension))) -} - -/// 获取扩展分类 -pub async fn get_extension_categories_handler( - State(svc_ctx): State, -) -> Result> { - let categories = services::extensions::get_extension_categories_service(&svc_ctx) - .await - .map_err(|e| Response::fail(Some(&e)))?; - - Ok(Response::success(Some("获取成功"), Some(categories))) -} - -/// 获取已安装的扩展 -pub async fn get_installed_extensions_handler( - State(svc_ctx): State, - Extension(ctx): Extension, - Json(payload): Json, -) -> Result { - let team_uuid = services::teams::get_current_team_service(&svc_ctx, ctx.user_uuid_unwrap()) - .await - .map_err(|e| Response::fail(Some(&e)))?; - - let (user_extensions, team_extensions) = match payload.scope.as_str() { - "user" | "personal" => { - let user_extensions = services::extensions::get_user_installed_extensions_service( - &svc_ctx, - ctx.user_uuid_unwrap(), - team_uuid, - ) - .await - .map_err(|e| Response::fail(Some(&e)))?; - (user_extensions, Vec::new()) - } - "team" => { - let team_uuid = team_uuid.ok_or_else(|| Response::fail(Some("未指定团队")))?; - let team_extensions = - services::extensions::get_team_installed_extensions_service(&svc_ctx, team_uuid) - .await - .map_err(|e| Response::fail(Some(&e)))?; - (Vec::new(), team_extensions) - } - _ => { - // "all" 或其他值,返回全部 - let user_extensions = services::extensions::get_user_installed_extensions_service( - &svc_ctx, - ctx.user_uuid_unwrap(), - team_uuid, - ) - .await - .map_err(|e| Response::fail(Some(&e)))?; - let team_extensions = if let Some(team_uuid) = team_uuid { - services::extensions::get_team_installed_extensions_service(&svc_ctx, team_uuid) - .await - .map_err(|e| Response::fail(Some(&e)))? - } else { - Vec::new() - }; - (user_extensions, team_extensions) - } - }; - - Ok(Response::success( - Some("获取成功"), - Some(InstalledExtensionsResponse { - user_extensions, - team_extensions, - }), - )) -} - -/// 安装扩展 -pub async fn install_extension_handler( - State(svc_ctx): State, - Extension(ctx): Extension, - Json(payload): Json, -) -> Result<()> { - let team_uuid = services::teams::get_current_team_service(&svc_ctx, ctx.user_uuid_unwrap()) - .await - .map_err(|e| Response::fail(Some(&e)))?; - - services::extensions::install_extension_service( - &svc_ctx, - ctx.user_uuid_unwrap(), - team_uuid, - &payload, - ) - .await - .map_err(|e| Response::fail(Some(&e)))?; - - Ok(Response::success(Some("安装成功"), None)) -} - -/// 卸载扩展 -pub async fn uninstall_extension_handler( - State(svc_ctx): State, - Extension(ctx): Extension, - Json(payload): Json, -) -> Result<()> { - let team_uuid = services::teams::get_current_team_service(&svc_ctx, ctx.user_uuid_unwrap()) - .await - .map_err(|e| Response::fail(Some(&e)))?; - - services::extensions::uninstall_extension_service( - &svc_ctx, - ctx.user_uuid_unwrap(), - team_uuid, - &payload, - ) - .await - .map_err(|e| Response::fail(Some(&e)))?; - - Ok(Response::success(Some("卸载成功"), None)) -} - -/// 更新扩展 -pub async fn update_extension_handler( - State(svc_ctx): State, - Extension(ctx): Extension, - Json(payload): Json, -) -> Result<()> { - services::extensions::update_extension_service( - &svc_ctx, - ctx.user_uuid_unwrap(), - &payload.extension_id, - ) - .await - .map_err(|e| Response::fail(Some(&e)))?; - - Ok(Response::success(Some("更新成功"), None)) -} - -/// 批量更新扩展 -pub async fn batch_update_extensions_handler( - State(svc_ctx): State, - Extension(ctx): Extension, - Json(payload): Json, -) -> Result { - let updated = services::extensions::batch_update_extensions_service( - &svc_ctx, - ctx.user_uuid_unwrap(), - &payload.extension_ids, - ) - .await - .map_err(|e| Response::fail(Some(&e)))?; - - Ok(Response::success( - Some("批量更新完成"), - Some(BatchUpdateResponse { updated }), - )) -} +use axum::extract::{Extension, State}; + +use crate::dto::ExtensionDto; +use crate::entitys::{ + BatchUpdateExtensionsRequest, BatchUpdateResponse, ExtensionIdRequest, ExtensionsListResponse, + GetInstalledExtensionsRequest, InstallExtensionRequest, InstalledExtensionsResponse, + ListExtensionsRequest, UninstallExtensionRequest, UpdateExtensionRequest, +}; +use crate::services; +use crate::state::RequestContext; +use crate::svc_ctx::SvcCtx; +use crate::utils::{Json, Response, Result}; + +/// 获取扩展列表 +pub async fn get_extensions_handler( + State(svc_ctx): State, + Json(payload): Json, +) -> Result { + let (items, total) = services::extensions::get_extensions_service(&svc_ctx, &payload) + .await + .map_err(|e| Response::fail(Some(&e)))?; + + Ok(Response::success( + Some("获取成功"), + Some(ExtensionsListResponse { + items, + total, + page: payload.pagination.page, + page_size: payload.pagination.page_size, + }), + )) +} + +/// 获取扩展详情 +pub async fn get_extension_detail_handler( + State(svc_ctx): State, + Json(payload): Json, +) -> Result { + let extension = services::extensions::get_extension_service(&svc_ctx, &payload.extension_id) + .await + .map_err(|e| Response::fail(Some(&e)))?; + + Ok(Response::success(Some("获取成功"), Some(extension))) +} + +/// 获取扩展分类 +pub async fn get_extension_categories_handler( + State(svc_ctx): State, +) -> Result> { + let categories = services::extensions::get_extension_categories_service(&svc_ctx) + .await + .map_err(|e| Response::fail(Some(&e)))?; + + Ok(Response::success(Some("获取成功"), Some(categories))) +} + +/// 获取已安装的扩展 +pub async fn get_installed_extensions_handler( + State(svc_ctx): State, + Extension(ctx): Extension, + Json(payload): Json, +) -> Result { + let team_uuid = services::teams::get_current_team_service(&svc_ctx, ctx.user_uuid_unwrap()) + .await + .map_err(|e| Response::fail(Some(&e)))?; + + let (user_extensions, team_extensions) = match payload.scope.as_str() { + "user" | "personal" => { + let user_extensions = services::extensions::get_user_installed_extensions_service( + &svc_ctx, + ctx.user_uuid_unwrap(), + team_uuid, + ) + .await + .map_err(|e| Response::fail(Some(&e)))?; + (user_extensions, Vec::new()) + } + "team" => { + let team_uuid = team_uuid.ok_or_else(|| Response::fail(Some("未指定团队")))?; + let team_extensions = + services::extensions::get_team_installed_extensions_service(&svc_ctx, team_uuid) + .await + .map_err(|e| Response::fail(Some(&e)))?; + (Vec::new(), team_extensions) + } + _ => { + // "all" 或其他值,返回全部 + let user_extensions = services::extensions::get_user_installed_extensions_service( + &svc_ctx, + ctx.user_uuid_unwrap(), + team_uuid, + ) + .await + .map_err(|e| Response::fail(Some(&e)))?; + let team_extensions = if let Some(team_uuid) = team_uuid { + services::extensions::get_team_installed_extensions_service(&svc_ctx, team_uuid) + .await + .map_err(|e| Response::fail(Some(&e)))? + } else { + Vec::new() + }; + (user_extensions, team_extensions) + } + }; + + Ok(Response::success( + Some("获取成功"), + Some(InstalledExtensionsResponse { + user_extensions, + team_extensions, + }), + )) +} + +/// 安装扩展 +pub async fn install_extension_handler( + State(svc_ctx): State, + Extension(ctx): Extension, + Json(payload): Json, +) -> Result<()> { + let team_uuid = services::teams::get_current_team_service(&svc_ctx, ctx.user_uuid_unwrap()) + .await + .map_err(|e| Response::fail(Some(&e)))?; + + services::extensions::install_extension_service( + &svc_ctx, + ctx.user_uuid_unwrap(), + team_uuid, + &payload, + ) + .await + .map_err(|e| Response::fail(Some(&e)))?; + + Ok(Response::success(Some("安装成功"), None)) +} + +/// 卸载扩展 +pub async fn uninstall_extension_handler( + State(svc_ctx): State, + Extension(ctx): Extension, + Json(payload): Json, +) -> Result<()> { + let team_uuid = services::teams::get_current_team_service(&svc_ctx, ctx.user_uuid_unwrap()) + .await + .map_err(|e| Response::fail(Some(&e)))?; + + services::extensions::uninstall_extension_service( + &svc_ctx, + ctx.user_uuid_unwrap(), + team_uuid, + &payload, + ) + .await + .map_err(|e| Response::fail(Some(&e)))?; + + Ok(Response::success(Some("卸载成功"), None)) +} + +/// 更新扩展 +pub async fn update_extension_handler( + State(svc_ctx): State, + Extension(ctx): Extension, + Json(payload): Json, +) -> Result<()> { + services::extensions::update_extension_service( + &svc_ctx, + ctx.user_uuid_unwrap(), + &payload.extension_id, + ) + .await + .map_err(|e| Response::fail(Some(&e)))?; + + Ok(Response::success(Some("更新成功"), None)) +} + +/// 批量更新扩展 +pub async fn batch_update_extensions_handler( + State(svc_ctx): State, + Extension(ctx): Extension, + Json(payload): Json, +) -> Result { + let updated = services::extensions::batch_update_extensions_service( + &svc_ctx, + ctx.user_uuid_unwrap(), + &payload.extension_ids, + ) + .await + .map_err(|e| Response::fail(Some(&e)))?; + + Ok(Response::success( + Some("批量更新完成"), + Some(BatchUpdateResponse { updated }), + )) +} diff --git a/src/handlers/group_permissions.rs b/src/handlers/group_permissions.rs index f9234b3..4de53a7 100644 --- a/src/handlers/group_permissions.rs +++ b/src/handlers/group_permissions.rs @@ -1,138 +1,138 @@ -use axum::{extract::Extension, extract::State}; - -use crate::audit_log; -use crate::entitys::{ - CheckGroupPermissionRequest, GrantGroupPermissionRequest, ListUserGroupPermissionsRequest, - RevokeGroupPermissionRequest, -}; -use crate::services; -use crate::state::RequestContext; -use crate::svc_ctx::SvcCtx; -use crate::utils::{Json, Response, Result}; - -/// 授予分组权限 -pub async fn grant_group_permission_handler( - State(svc_ctx): State, - Extension(ctx): Extension, - Json(payload): Json, -) -> Result<()> { - services::group_permissions::grant_group_permission_service( - &svc_ctx, - ctx.user_uuid_unwrap(), - &payload, - ) - .await - .map_err(|e| Response::fail(Some(&e)))?; - - audit_log!( - &svc_ctx, - &ctx, - "grant", - "group_permission", - payload.group_uuid, - &payload.permission_type, - "授予分组权限" - ) - .await; - - Ok(Response::success(Some("授权成功"), None)) -} - -/// 撤销分组权限 -pub async fn revoke_group_permission_handler( - State(svc_ctx): State, - Extension(ctx): Extension, - Json(payload): Json, -) -> Result<()> { - services::group_permissions::revoke_group_permission_service( - &svc_ctx, - ctx.user_uuid_unwrap(), - &payload, - ) - .await - .map_err(|e| Response::fail(Some(&e)))?; - - audit_log!( - &svc_ctx, - &ctx, - "revoke", - "group_permission", - payload.group_uuid, - "", - "撤销分组权限" - ) - .await; - - Ok(Response::success(Some("撤销成功"), None)) -} - -/// 检查分组权限 -pub async fn check_group_permission_handler( - State(svc_ctx): State, - Extension(ctx): Extension, - Json(payload): Json, -) -> Result { - let workspace_uuid = ctx.current_workspace_uuid.ok_or_else(|| Response::fail(Some("请先选择工作空间")))?; - let has_permission = - services::group_permissions::check_group_permission_service(&svc_ctx, workspace_uuid, &payload) - .await - .map_err(|e| Response::fail(Some(&e)))?; - - Ok(Response::success( - None, - Some(crate::entitys::CheckPermissionResponse { - has_permission, - permission_type: if has_permission { - Some(payload.permission_type.clone()) - } else { - None - }, - }), - )) -} - -/// 查询用户的分组权限列表 -pub async fn list_user_group_permissions_handler( - State(svc_ctx): State, - Extension(_ctx): Extension, - Json(payload): Json, -) -> Result { - let permissions = - services::group_permissions::list_user_group_permissions_service(&svc_ctx, &payload) - .await - .map_err(|e| Response::fail(Some(&e)))?; - - let total = permissions.len() as i64; - - // 转换为详情 DTO(包含用户信息) - let mut permission_details = vec![]; - for perm in permissions { - let user_info = crate::models::user::fetch_user_info_by_uuid(&svc_ctx.db, perm.user_uuid) - .await - .ok() - .flatten(); - - permission_details.push(crate::dto::GroupMemberPermissionDetailDto { - group_uuid: perm.group_uuid, - workspace_uuid: perm.workspace_uuid, - team_uuid: perm.team_uuid, - user_uuid: perm.user_uuid, - permission_type: perm.permission_type, - granted_by: perm.granted_by, - user_name: user_info.as_ref().and_then(|u| u.nickname.clone()), - user_email: user_info.as_ref().map(|u| u.email.clone()), - created_at: perm.created_at, - updated_at: perm.updated_at, - }); - } - - Ok(Response::success( - None, - Some(crate::entitys::GroupPermissionListResponse { - items: permission_details, - total, - page: payload.pagination.page, - page_size: payload.pagination.page_size, - }), - )) -} +use axum::{extract::Extension, extract::State}; + +use crate::audit_log; +use crate::entitys::{ + CheckGroupPermissionRequest, GrantGroupPermissionRequest, ListUserGroupPermissionsRequest, + RevokeGroupPermissionRequest, +}; +use crate::services; +use crate::state::RequestContext; +use crate::svc_ctx::SvcCtx; +use crate::utils::{Json, Response, Result}; + +/// 授予分组权限 +pub async fn grant_group_permission_handler( + State(svc_ctx): State, + Extension(ctx): Extension, + Json(payload): Json, +) -> Result<()> { + services::group_permissions::grant_group_permission_service( + &svc_ctx, + ctx.user_uuid_unwrap(), + &payload, + ) + .await + .map_err(|e| Response::fail(Some(&e)))?; + + audit_log!( + &svc_ctx, + &ctx, + "grant", + "group_permission", + payload.group_uuid, + &payload.permission_type, + "授予分组权限" + ) + .await; + + Ok(Response::success(Some("授权成功"), None)) +} + +/// 撤销分组权限 +pub async fn revoke_group_permission_handler( + State(svc_ctx): State, + Extension(ctx): Extension, + Json(payload): Json, +) -> Result<()> { + services::group_permissions::revoke_group_permission_service( + &svc_ctx, + ctx.user_uuid_unwrap(), + &payload, + ) + .await + .map_err(|e| Response::fail(Some(&e)))?; + + audit_log!( + &svc_ctx, + &ctx, + "revoke", + "group_permission", + payload.group_uuid, + "", + "撤销分组权限" + ) + .await; + + Ok(Response::success(Some("撤销成功"), None)) +} + +/// 检查分组权限 +pub async fn check_group_permission_handler( + State(svc_ctx): State, + Extension(ctx): Extension, + Json(payload): Json, +) -> Result { + let workspace_uuid = ctx.current_workspace_uuid.ok_or_else(|| Response::fail(Some("请先选择工作空间")))?; + let has_permission = + services::group_permissions::check_group_permission_service(&svc_ctx, workspace_uuid, &payload) + .await + .map_err(|e| Response::fail(Some(&e)))?; + + Ok(Response::success( + None, + Some(crate::entitys::CheckPermissionResponse { + has_permission, + permission_type: if has_permission { + Some(payload.permission_type.clone()) + } else { + None + }, + }), + )) +} + +/// 查询用户的分组权限列表 +pub async fn list_user_group_permissions_handler( + State(svc_ctx): State, + Extension(_ctx): Extension, + Json(payload): Json, +) -> Result { + let permissions = + services::group_permissions::list_user_group_permissions_service(&svc_ctx, &payload) + .await + .map_err(|e| Response::fail(Some(&e)))?; + + let total = permissions.len() as i64; + + // 转换为详情 DTO(包含用户信息) + let mut permission_details = vec![]; + for perm in permissions { + let user_info = crate::models::user::fetch_user_info_by_uuid(&svc_ctx.db, perm.user_uuid) + .await + .ok() + .flatten(); + + permission_details.push(crate::dto::GroupMemberPermissionDetailDto { + group_uuid: perm.group_uuid, + workspace_uuid: perm.workspace_uuid, + team_uuid: perm.team_uuid, + user_uuid: perm.user_uuid, + permission_type: perm.permission_type, + granted_by: perm.granted_by, + user_name: user_info.as_ref().and_then(|u| u.nickname.clone()), + user_email: user_info.as_ref().map(|u| u.email.clone()), + created_at: perm.created_at, + updated_at: perm.updated_at, + }); + } + + Ok(Response::success( + None, + Some(crate::entitys::GroupPermissionListResponse { + items: permission_details, + total, + page: payload.pagination.page, + page_size: payload.pagination.page_size, + }), + )) +} diff --git a/src/handlers/health.rs b/src/handlers/health.rs index dd6d333..03e05d6 100644 --- a/src/handlers/health.rs +++ b/src/handlers/health.rs @@ -1,15 +1,15 @@ -use chrono::Utc; -use serde_json::{Value, json}; - -use crate::utils::{Response, Result}; - -/// 健康检查 -pub async fn health_check_handler() -> Result { - Ok(Response::success( - Some("服务正常"), - Some(json!({ - "status": "ok", - "timestamp": Utc::now().to_rfc3339(), - })), - )) -} +use chrono::Utc; +use serde_json::{Value, json}; + +use crate::utils::{Response, Result}; + +/// 健康检查 +pub async fn health_check_handler() -> Result { + Ok(Response::success( + Some("服务正常"), + Some(json!({ + "status": "ok", + "timestamp": Utc::now().to_rfc3339(), + })), + )) +} diff --git a/src/handlers/messages.rs b/src/handlers/messages.rs index fed1553..d63010b 100644 --- a/src/handlers/messages.rs +++ b/src/handlers/messages.rs @@ -1,133 +1,133 @@ -use axum::{extract::Extension, extract::State}; - -use crate::entitys::{ - BatchMarkReadRequest, CreateMessageRequest, HandleMessageRequest, ListMessagesRequest, - MarkMessageReadRequest, MessageListResponse, MessageStatsResponse, -}; -use crate::services; -use crate::state::RequestContext; -use crate::svc_ctx::SvcCtx; -use crate::utils::{Json, Response, Result}; -use sqlx; - -/// 创建消息 -pub async fn create_message_handler( - State(svc_ctx): State, - Extension(ctx): Extension, - Json(payload): Json, -) -> Result { - let message_uuid = services::messages::create_message_service( - &svc_ctx, - Some(ctx.user_uuid_unwrap()), - &payload, - ) - .await - .map_err(|e| Response::fail(Some(&e)))?; - - Ok(Response::success( - Some("消息创建成功"), - Some(crate::entitys::CreateResponse { uuid: message_uuid }), - )) -} - -/// 获取用户消息列表 -pub async fn get_user_messages_handler( - State(svc_ctx): State, - Extension(ctx): Extension, - Json(payload): Json, -) -> Result { - let response = - services::messages::get_user_messages_service(&svc_ctx, ctx.user_uuid_unwrap(), &payload) - .await - .map_err(|e| Response::fail(Some(&e)))?; - - Ok(Response::success(Some("获取成功"), Some(response))) -} - -/// 标记消息为已读 -pub async fn mark_message_read_handler( - State(svc_ctx): State, - Extension(ctx): Extension, - Json(payload): Json, -) -> Result<()> { - services::messages::mark_message_read_service(&svc_ctx, ctx.user_uuid_unwrap(), &payload) - .await - .map_err(|e| Response::fail(Some(&e)))?; - - Ok(Response::success(Some("标记成功"), None)) -} - -/// 批量标记消息为已读 -pub async fn batch_mark_messages_read_handler( - State(svc_ctx): State, - Extension(ctx): Extension, - Json(payload): Json, -) -> Result<()> { - services::messages::batch_mark_messages_read_service( - &svc_ctx, - ctx.user_uuid_unwrap(), - &payload, - ) - .await - .map_err(|e| Response::fail(Some(&e)))?; - - Ok(Response::success(Some("批量标记成功"), None)) -} - -/// 处理消息(接受/拒绝) -pub async fn handle_message_handler( - State(svc_ctx): State, - Extension(ctx): Extension, - Json(payload): Json, -) -> Result<()> { - services::messages::handle_message_service(&svc_ctx, ctx.user_uuid_unwrap(), &payload) - .await - .map_err(|e| Response::fail(Some(&e)))?; - - Ok(Response::success(Some("处理成功"), None)) -} - -/// 获取用户消息统计 -pub async fn get_user_message_stats_handler( - State(svc_ctx): State, - Extension(ctx): Extension, -) -> Result { - let response = - services::messages::get_user_message_stats_service(&svc_ctx, ctx.user_uuid_unwrap()) - .await - .map_err(|e| Response::fail(Some(&e)))?; - - Ok(Response::success(Some("获取成功"), Some(response))) -} - -/// 删除消息 -pub async fn delete_message_handler( - State(svc_ctx): State, - Extension(ctx): Extension, - Json(payload): Json, -) -> Result<()> { - // 验证消息是否属于当前用户(通过查询 user_messages 表) - let user_message_exists: bool = sqlx::query_scalar( - r#" - SELECT EXISTS( - SELECT 1 FROM user_messages - WHERE message_uuid = $1 AND user_uuid = $2 - ) - "#, - ) - .bind(payload.uuid) - .bind(ctx.user_uuid_unwrap()) - .fetch_one(&svc_ctx.db) - .await - .map_err(|e| Response::fail(Some(&e.to_string())))?; - - if !user_message_exists { - return Err(Response::fail(Some("消息不存在或无权限"))); - } - - services::messages::delete_message_service(&svc_ctx, payload.uuid) - .await - .map_err(|e| Response::fail(Some(&e)))?; - - Ok(Response::success(Some("删除成功"), None)) -} +use axum::{extract::Extension, extract::State}; + +use crate::entitys::{ + BatchMarkReadRequest, CreateMessageRequest, HandleMessageRequest, ListMessagesRequest, + MarkMessageReadRequest, MessageListResponse, MessageStatsResponse, +}; +use crate::services; +use crate::state::RequestContext; +use crate::svc_ctx::SvcCtx; +use crate::utils::{Json, Response, Result}; +use sqlx; + +/// 创建消息 +pub async fn create_message_handler( + State(svc_ctx): State, + Extension(ctx): Extension, + Json(payload): Json, +) -> Result { + let message_uuid = services::messages::create_message_service( + &svc_ctx, + Some(ctx.user_uuid_unwrap()), + &payload, + ) + .await + .map_err(|e| Response::fail(Some(&e)))?; + + Ok(Response::success( + Some("消息创建成功"), + Some(crate::entitys::CreateResponse { uuid: message_uuid }), + )) +} + +/// 获取用户消息列表 +pub async fn get_user_messages_handler( + State(svc_ctx): State, + Extension(ctx): Extension, + Json(payload): Json, +) -> Result { + let response = + services::messages::get_user_messages_service(&svc_ctx, ctx.user_uuid_unwrap(), &payload) + .await + .map_err(|e| Response::fail(Some(&e)))?; + + Ok(Response::success(Some("获取成功"), Some(response))) +} + +/// 标记消息为已读 +pub async fn mark_message_read_handler( + State(svc_ctx): State, + Extension(ctx): Extension, + Json(payload): Json, +) -> Result<()> { + services::messages::mark_message_read_service(&svc_ctx, ctx.user_uuid_unwrap(), &payload) + .await + .map_err(|e| Response::fail(Some(&e)))?; + + Ok(Response::success(Some("标记成功"), None)) +} + +/// 批量标记消息为已读 +pub async fn batch_mark_messages_read_handler( + State(svc_ctx): State, + Extension(ctx): Extension, + Json(payload): Json, +) -> Result<()> { + services::messages::batch_mark_messages_read_service( + &svc_ctx, + ctx.user_uuid_unwrap(), + &payload, + ) + .await + .map_err(|e| Response::fail(Some(&e)))?; + + Ok(Response::success(Some("批量标记成功"), None)) +} + +/// 处理消息(接受/拒绝) +pub async fn handle_message_handler( + State(svc_ctx): State, + Extension(ctx): Extension, + Json(payload): Json, +) -> Result<()> { + services::messages::handle_message_service(&svc_ctx, ctx.user_uuid_unwrap(), &payload) + .await + .map_err(|e| Response::fail(Some(&e)))?; + + Ok(Response::success(Some("处理成功"), None)) +} + +/// 获取用户消息统计 +pub async fn get_user_message_stats_handler( + State(svc_ctx): State, + Extension(ctx): Extension, +) -> Result { + let response = + services::messages::get_user_message_stats_service(&svc_ctx, ctx.user_uuid_unwrap()) + .await + .map_err(|e| Response::fail(Some(&e)))?; + + Ok(Response::success(Some("获取成功"), Some(response))) +} + +/// 删除消息 +pub async fn delete_message_handler( + State(svc_ctx): State, + Extension(ctx): Extension, + Json(payload): Json, +) -> Result<()> { + // 验证消息是否属于当前用户(通过查询 user_messages 表) + let user_message_exists: bool = sqlx::query_scalar( + r#" + SELECT EXISTS( + SELECT 1 FROM user_messages + WHERE message_uuid = $1 AND user_uuid = $2 + ) + "#, + ) + .bind(payload.uuid) + .bind(ctx.user_uuid_unwrap()) + .fetch_one(&svc_ctx.db) + .await + .map_err(|e| Response::fail(Some(&e.to_string())))?; + + if !user_message_exists { + return Err(Response::fail(Some("消息不存在或无权限"))); + } + + services::messages::delete_message_service(&svc_ctx, payload.uuid) + .await + .map_err(|e| Response::fail(Some(&e)))?; + + Ok(Response::success(Some("删除成功"), None)) +} diff --git a/src/handlers/preferences.rs b/src/handlers/preferences.rs index 3b3f5a0..2c797d8 100644 --- a/src/handlers/preferences.rs +++ b/src/handlers/preferences.rs @@ -1,38 +1,38 @@ -use axum::extract::{Extension, State}; - -use crate::dto::UserPreferenceDto; -use crate::entitys::settings::UpdatePreferencesRequest; -use crate::services; -use crate::state::RequestContext; -use crate::svc_ctx::SvcCtx; -use crate::utils::{Json, Response, Result}; - -/// 获取用户偏好设置 -pub async fn get_preferences_handler( - State(svc_ctx): State, - Extension(ctx): Extension, -) -> Result { - let preferences = - services::preferences::get_preferences_service(&svc_ctx, ctx.user_uuid_unwrap()) - .await - .map_err(|e| Response::fail(Some(&e)))?; - - Ok(Response::success(Some("获取成功"), Some(preferences))) -} - -/// 更新用户偏好设置 -pub async fn update_preferences_handler( - State(svc_ctx): State, - Extension(ctx): Extension, - Json(payload): Json, -) -> Result { - let preferences = services::preferences::update_preferences_service( - &svc_ctx, - ctx.user_uuid_unwrap(), - &payload, - ) - .await - .map_err(|e| Response::fail(Some(&e)))?; - - Ok(Response::success(Some("更新成功"), Some(preferences))) -} +use axum::extract::{Extension, State}; + +use crate::dto::UserPreferenceDto; +use crate::entitys::settings::UpdatePreferencesRequest; +use crate::services; +use crate::state::RequestContext; +use crate::svc_ctx::SvcCtx; +use crate::utils::{Json, Response, Result}; + +/// 获取用户偏好设置 +pub async fn get_preferences_handler( + State(svc_ctx): State, + Extension(ctx): Extension, +) -> Result { + let preferences = + services::preferences::get_preferences_service(&svc_ctx, ctx.user_uuid_unwrap()) + .await + .map_err(|e| Response::fail(Some(&e)))?; + + Ok(Response::success(Some("获取成功"), Some(preferences))) +} + +/// 更新用户偏好设置 +pub async fn update_preferences_handler( + State(svc_ctx): State, + Extension(ctx): Extension, + Json(payload): Json, +) -> Result { + let preferences = services::preferences::update_preferences_service( + &svc_ctx, + ctx.user_uuid_unwrap(), + &payload, + ) + .await + .map_err(|e| Response::fail(Some(&e)))?; + + Ok(Response::success(Some("更新成功"), Some(preferences))) +} diff --git a/src/handlers/proxies.rs b/src/handlers/proxies.rs index eaf7272..bd3e220 100644 --- a/src/handlers/proxies.rs +++ b/src/handlers/proxies.rs @@ -1,6 +1,7 @@ use axum::{extract::Extension, extract::State}; use crate::audit_log; +use crate::dto::events::{EntityType, EventType}; use crate::entitys::{ BatchImportProxiesRequest, BatchImportResponse, BatchUuidRequest, CreateProxyRequest, CreateResponse, ListProxiesRequest, ProxyListResponse, UpdateProxyRequest, UuidRequest, @@ -71,6 +72,20 @@ pub async fn create_proxy_handler( ) .await; + // 发布代理创建事件 + if let Err(e) = services::events::EventService::publish_from_context( + &svc_ctx, + &ctx, + EventType::Created, + EntityType::Proxy, + Some(proxy_uuid), + vec!["proxies/list".to_string()], + ) + .await + { + tracing::error!("发布代理创建事件失败: {}", e); + } + Ok(Response::success( Some("创建成功"), Some(CreateResponse { uuid: proxy_uuid }), @@ -80,12 +95,27 @@ pub async fn create_proxy_handler( /// 更新代理 pub async fn update_proxy_handler( State(svc_ctx): State, + Extension(ctx): Extension, Json(payload): Json, ) -> Result<()> { services::proxies::update_proxy_service(&svc_ctx, &payload) .await .map_err(|e| Response::fail(Some(&e)))?; + // 发布代理更新事件 + if let Err(e) = services::events::EventService::publish_from_context( + &svc_ctx, + &ctx, + EventType::Updated, + EntityType::Proxy, + Some(payload.uuid), + vec!["proxies/list".to_string()], + ) + .await + { + tracing::error!("发布代理更新事件失败: {}", e); + } + Ok(Response::success(Some("更新成功"), None)) } @@ -101,6 +131,20 @@ pub async fn delete_proxy_handler( audit_log!(&svc_ctx, &ctx, "delete", "proxy", payload.uuid, "删除代理").await; + // 发布代理删除事件 + if let Err(e) = services::events::EventService::publish_from_context( + &svc_ctx, + &ctx, + EventType::Deleted, + EntityType::Proxy, + Some(payload.uuid), + vec!["proxies/list".to_string()], + ) + .await + { + tracing::error!("发布代理删除事件失败: {}", e); + } + Ok(Response::success(Some("删除成功"), None)) } @@ -123,6 +167,20 @@ pub async fn batch_delete_proxies_handler( ) .await; + // 发布批量删除代理事件 + if let Err(e) = services::events::EventService::publish_from_context( + &svc_ctx, + &ctx, + EventType::Deleted, + EntityType::Proxy, + None, + vec!["proxies/list".to_string()], + ) + .await + { + tracing::error!("发布批量删除代理事件失败: {}", e); + } + Ok(Response::success(Some("删除成功"), Some(count))) } diff --git a/src/handlers/proxy_visibility.rs b/src/handlers/proxy_visibility.rs index 1d37997..a27201f 100644 --- a/src/handlers/proxy_visibility.rs +++ b/src/handlers/proxy_visibility.rs @@ -1,150 +1,150 @@ -use axum::{extract::Extension, extract::State}; - -use crate::audit_log; -use crate::entitys::{ - BatchSetProxyVisibleRequest, ListProxyVisibleTeamsRequest, ListVisibleProxiesRequest, - RemoveProxyVisibleRequest, SetProxyVisibleRequest, -}; -use crate::services; -use crate::state::RequestContext; -use crate::svc_ctx::SvcCtx; -use crate::utils::{Json, Response, Result}; - -/// 设置代理对团队可见 -pub async fn set_proxy_visible_handler( - State(svc_ctx): State, - Extension(ctx): Extension, - Json(payload): Json, -) -> Result<()> { - services::proxy_visibility::set_proxy_visible_to_team_service( - &svc_ctx, - ctx.user_uuid_unwrap(), - &payload, - ) - .await - .map_err(|e| Response::fail(Some(&e)))?; - - audit_log!( - &svc_ctx, - &ctx, - "update", - "proxy_visibility", - payload.proxy_uuid, - "", - "设置代理可见性" - ) - .await; - - Ok(Response::success(Some("设置成功"), None)) -} - -/// 移除代理对团队的可见性 -pub async fn remove_proxy_visible_handler( - State(svc_ctx): State, - Extension(ctx): Extension, - Json(payload): Json, -) -> Result<()> { - services::proxy_visibility::remove_proxy_visible_from_team_service( - &svc_ctx, - ctx.user_uuid_unwrap(), - &payload, - ) - .await - .map_err(|e| Response::fail(Some(&e)))?; - - audit_log!( - &svc_ctx, - &ctx, - "update", - "proxy_visibility", - payload.proxy_uuid, - "", - "移除代理可见性" - ) - .await; - - Ok(Response::success(Some("移除成功"), None)) -} - -/// 批量设置代理可见性 -pub async fn batch_set_proxy_visible_handler( - State(svc_ctx): State, - Extension(ctx): Extension, - Json(payload): Json, -) -> Result<()> { - services::proxy_visibility::batch_set_proxy_visible_service( - &svc_ctx, - ctx.user_uuid_unwrap(), - &payload, - ) - .await - .map_err(|e| Response::fail(Some(&e)))?; - - audit_log!( - &svc_ctx, - &ctx, - "update", - "proxy_visibility", - payload.proxy_uuid, - "", - "批量设置代理可见性" - ) - .await; - - Ok(Response::success(Some("设置成功"), None)) -} - -/// 获取可见的代理列表 -pub async fn get_visible_proxies_handler( - State(svc_ctx): State, - Extension(ctx): Extension, - Json(payload): Json, -) -> Result { - let proxies = services::proxy_visibility::get_visible_proxies_service( - &svc_ctx, - ctx.user_uuid_unwrap(), - &payload, - ) - .await - .map_err(|e| Response::fail(Some(&e)))?; - - Ok(Response::success( - None, - Some(crate::entitys::VisibleProxyListResponse { items: proxies }), - )) -} - -/// 获取代理的可见团队列表 -pub async fn get_proxy_visible_teams_handler( - State(svc_ctx): State, - Extension(_ctx): Extension, - Json(payload): Json, -) -> Result { - let teams = crate::models::fetch_visible_teams_by_proxy(&svc_ctx.db, payload.proxy_uuid) - .await - .map_err(|e| Response::fail(Some(&e.to_string())))?; - - // 转换为详情 DTO(包含团队名称) - let mut team_details = vec![]; - for team in teams { - let team_info = crate::models::fetch_team_by_uuid(&svc_ctx.db, team.team_uuid) - .await - .ok() - .flatten(); - - team_details.push(crate::dto::ProxyVisibleTeamDetailDto { - proxy_uuid: team.proxy_uuid, - workspace_uuid: team.workspace_uuid, - team_uuid: team.team_uuid, - team_name: team_info.map(|t| t.name), - created_at: team.created_at, - }); - } - - Ok(Response::success( - None, - Some(crate::entitys::ProxyVisibleTeamListResponse { - items: team_details, - }), - )) -} +use axum::{extract::Extension, extract::State}; + +use crate::audit_log; +use crate::entitys::{ + BatchSetProxyVisibleRequest, ListProxyVisibleTeamsRequest, ListVisibleProxiesRequest, + RemoveProxyVisibleRequest, SetProxyVisibleRequest, +}; +use crate::services; +use crate::state::RequestContext; +use crate::svc_ctx::SvcCtx; +use crate::utils::{Json, Response, Result}; + +/// 设置代理对团队可见 +pub async fn set_proxy_visible_handler( + State(svc_ctx): State, + Extension(ctx): Extension, + Json(payload): Json, +) -> Result<()> { + services::proxy_visibility::set_proxy_visible_to_team_service( + &svc_ctx, + ctx.user_uuid_unwrap(), + &payload, + ) + .await + .map_err(|e| Response::fail(Some(&e)))?; + + audit_log!( + &svc_ctx, + &ctx, + "update", + "proxy_visibility", + payload.proxy_uuid, + "", + "设置代理可见性" + ) + .await; + + Ok(Response::success(Some("设置成功"), None)) +} + +/// 移除代理对团队的可见性 +pub async fn remove_proxy_visible_handler( + State(svc_ctx): State, + Extension(ctx): Extension, + Json(payload): Json, +) -> Result<()> { + services::proxy_visibility::remove_proxy_visible_from_team_service( + &svc_ctx, + ctx.user_uuid_unwrap(), + &payload, + ) + .await + .map_err(|e| Response::fail(Some(&e)))?; + + audit_log!( + &svc_ctx, + &ctx, + "update", + "proxy_visibility", + payload.proxy_uuid, + "", + "移除代理可见性" + ) + .await; + + Ok(Response::success(Some("移除成功"), None)) +} + +/// 批量设置代理可见性 +pub async fn batch_set_proxy_visible_handler( + State(svc_ctx): State, + Extension(ctx): Extension, + Json(payload): Json, +) -> Result<()> { + services::proxy_visibility::batch_set_proxy_visible_service( + &svc_ctx, + ctx.user_uuid_unwrap(), + &payload, + ) + .await + .map_err(|e| Response::fail(Some(&e)))?; + + audit_log!( + &svc_ctx, + &ctx, + "update", + "proxy_visibility", + payload.proxy_uuid, + "", + "批量设置代理可见性" + ) + .await; + + Ok(Response::success(Some("设置成功"), None)) +} + +/// 获取可见的代理列表 +pub async fn get_visible_proxies_handler( + State(svc_ctx): State, + Extension(ctx): Extension, + Json(payload): Json, +) -> Result { + let proxies = services::proxy_visibility::get_visible_proxies_service( + &svc_ctx, + ctx.user_uuid_unwrap(), + &payload, + ) + .await + .map_err(|e| Response::fail(Some(&e)))?; + + Ok(Response::success( + None, + Some(crate::entitys::VisibleProxyListResponse { items: proxies }), + )) +} + +/// 获取代理的可见团队列表 +pub async fn get_proxy_visible_teams_handler( + State(svc_ctx): State, + Extension(_ctx): Extension, + Json(payload): Json, +) -> Result { + let teams = crate::models::fetch_visible_teams_by_proxy(&svc_ctx.db, payload.proxy_uuid) + .await + .map_err(|e| Response::fail(Some(&e.to_string())))?; + + // 转换为详情 DTO(包含团队名称) + let mut team_details = vec![]; + for team in teams { + let team_info = crate::models::fetch_team_by_uuid(&svc_ctx.db, team.team_uuid) + .await + .ok() + .flatten(); + + team_details.push(crate::dto::ProxyVisibleTeamDetailDto { + proxy_uuid: team.proxy_uuid, + workspace_uuid: team.workspace_uuid, + team_uuid: team.team_uuid, + team_name: team_info.map(|t| t.name), + created_at: team.created_at, + }); + } + + Ok(Response::success( + None, + Some(crate::entitys::ProxyVisibleTeamListResponse { + items: team_details, + }), + )) +} diff --git a/src/handlers/rpa.rs b/src/handlers/rpa.rs index 329e08e..908733c 100644 --- a/src/handlers/rpa.rs +++ b/src/handlers/rpa.rs @@ -1,219 +1,219 @@ -use axum::extract::{Extension, State}; - -use crate::dto::RpaTaskRunDto; -use crate::entitys::{ - BatchUuidRequest, CreateResponse, CreateRpaTaskRequest, DuplicateRpaTaskRequest, - ExportRpaTaskRequest, ExportRpaTaskResponse, ListRpaRunsRequest, ListRpaTasksRequest, - RpaRunsListResponse, RpaTaskDetailResponse, RpaTaskListResponse, RunRpaTaskRequest, - UpdateRpaTaskRequest, UuidRequest, -}; -use crate::services; -use crate::state::RequestContext; -use crate::svc_ctx::SvcCtx; -use crate::utils::{Json, Response, Result}; - -/// 获取 RPA 任务列表 -pub async fn get_rpa_tasks_handler( - State(svc_ctx): State, - Extension(ctx): Extension, - Json(payload): Json, -) -> Result { - let team_uuid = services::teams::get_current_team_service(&svc_ctx, ctx.user_uuid_unwrap()) - .await - .map_err(|e| Response::fail(Some(&e)))?; - - let (items, total) = - services::rpa::get_rpa_tasks_service(&svc_ctx, ctx.user_uuid_unwrap(), team_uuid, &payload) - .await - .map_err(|e| Response::fail(Some(&e)))?; - - Ok(Response::success( - Some("获取成功"), - Some(RpaTaskListResponse { - items, - total, - page: payload.pagination.page, - page_size: payload.pagination.page_size, - }), - )) -} - -/// 获取 RPA 任务详情 -pub async fn get_rpa_task_handler( - State(svc_ctx): State, - Json(payload): Json, -) -> Result { - let (task, steps, environment_uuids) = - services::rpa::get_rpa_task_service(&svc_ctx, payload.uuid) - .await - .map_err(|e| Response::fail(Some(&e)))?; - - Ok(Response::success( - Some("获取成功"), - Some(RpaTaskDetailResponse { - task, - steps, - environment_uuids, - }), - )) -} - -/// 创建 RPA 任务 -pub async fn create_rpa_task_handler( - State(svc_ctx): State, - Extension(ctx): Extension, - Json(payload): Json, -) -> Result { - let team_uuid = services::teams::get_current_team_service(&svc_ctx, ctx.user_uuid_unwrap()) - .await - .map_err(|e| Response::fail(Some(&e)))?; - - let uuid = services::rpa::create_rpa_task_service( - &svc_ctx, - ctx.user_uuid_unwrap(), - team_uuid, - &payload, - ) - .await - .map_err(|e| Response::fail(Some(&e)))?; - - Ok(Response::success( - Some("创建成功"), - Some(CreateResponse { uuid }), - )) -} - -/// 更新 RPA 任务 -pub async fn update_rpa_task_handler( - State(svc_ctx): State, - Json(payload): Json, -) -> Result<()> { - services::rpa::update_rpa_task_service(&svc_ctx, &payload) - .await - .map_err(|e| Response::fail(Some(&e)))?; - - Ok(Response::success(Some("更新成功"), None)) -} - -/// 删除 RPA 任务 -pub async fn delete_rpa_task_handler( - State(svc_ctx): State, - Json(payload): Json, -) -> Result<()> { - services::rpa::delete_rpa_task_service(&svc_ctx, payload.uuid) - .await - .map_err(|e| Response::fail(Some(&e)))?; - - Ok(Response::success(Some("删除成功"), None)) -} - -/// 批量删除 RPA 任务 -pub async fn batch_delete_rpa_tasks_handler( - State(svc_ctx): State, - Json(payload): Json, -) -> Result<()> { - services::rpa::batch_delete_rpa_tasks_service(&svc_ctx, &payload.uuids) - .await - .map_err(|e| Response::fail(Some(&e)))?; - - Ok(Response::success(Some("批量删除成功"), None)) -} - -/// 运行 RPA 任务 -pub async fn run_rpa_task_handler( - State(svc_ctx): State, - Json(payload): Json, -) -> Result { - let run_uuid = services::rpa::run_rpa_task_service(&svc_ctx, payload.uuid) - .await - .map_err(|e| Response::fail(Some(&e)))?; - - Ok(Response::success( - Some("任务已启动"), - Some(CreateResponse { uuid: run_uuid }), - )) -} - -/// 停止 RPA 任务 -pub async fn stop_rpa_task_handler( - State(svc_ctx): State, - Json(payload): Json, -) -> Result<()> { - services::rpa::stop_rpa_task_service(&svc_ctx, payload.uuid) - .await - .map_err(|e| Response::fail(Some(&e)))?; - - Ok(Response::success(Some("任务已停止"), None)) -} - -/// 复制 RPA 任务 -pub async fn duplicate_rpa_task_handler( - State(svc_ctx): State, - Extension(ctx): Extension, - Json(payload): Json, -) -> Result { - let team_uuid = services::teams::get_current_team_service(&svc_ctx, ctx.user_uuid_unwrap()) - .await - .map_err(|e| Response::fail(Some(&e)))?; - - let uuid = services::rpa::duplicate_rpa_task_service( - &svc_ctx, - ctx.user_uuid_unwrap(), - team_uuid, - &payload, - ) - .await - .map_err(|e| Response::fail(Some(&e)))?; - - Ok(Response::success( - Some("复制成功"), - Some(CreateResponse { uuid }), - )) -} - -/// 导出 RPA 任务 -pub async fn export_rpa_task_handler( - State(svc_ctx): State, - Json(payload): Json, -) -> Result { - let (content, filename) = services::rpa::export_rpa_task_service(&svc_ctx, payload.uuid) - .await - .map_err(|e| Response::fail(Some(&e)))?; - - Ok(Response::success( - Some("导出成功"), - Some(ExportRpaTaskResponse { content, filename }), - )) -} - -/// 获取执行记录列表 -pub async fn get_rpa_runs_handler( - State(svc_ctx): State, - Json(payload): Json, -) -> Result { - let (items, total) = services::rpa::get_rpa_runs_service(&svc_ctx, &payload) - .await - .map_err(|e| Response::fail(Some(&e)))?; - - Ok(Response::success( - Some("获取成功"), - Some(RpaRunsListResponse { - items, - total, - page: payload.pagination.page, - page_size: payload.pagination.page_size, - }), - )) -} - -/// 获取执行记录详情 -pub async fn get_rpa_run_handler( - State(svc_ctx): State, - Json(payload): Json, -) -> Result { - let run = services::rpa::get_rpa_run_service(&svc_ctx, payload.uuid) - .await - .map_err(|e| Response::fail(Some(&e)))?; - - Ok(Response::success(Some("获取成功"), Some(run))) -} +use axum::extract::{Extension, State}; + +use crate::dto::RpaTaskRunDto; +use crate::entitys::{ + BatchUuidRequest, CreateResponse, CreateRpaTaskRequest, DuplicateRpaTaskRequest, + ExportRpaTaskRequest, ExportRpaTaskResponse, ListRpaRunsRequest, ListRpaTasksRequest, + RpaRunsListResponse, RpaTaskDetailResponse, RpaTaskListResponse, RunRpaTaskRequest, + UpdateRpaTaskRequest, UuidRequest, +}; +use crate::services; +use crate::state::RequestContext; +use crate::svc_ctx::SvcCtx; +use crate::utils::{Json, Response, Result}; + +/// 获取 RPA 任务列表 +pub async fn get_rpa_tasks_handler( + State(svc_ctx): State, + Extension(ctx): Extension, + Json(payload): Json, +) -> Result { + let team_uuid = services::teams::get_current_team_service(&svc_ctx, ctx.user_uuid_unwrap()) + .await + .map_err(|e| Response::fail(Some(&e)))?; + + let (items, total) = + services::rpa::get_rpa_tasks_service(&svc_ctx, ctx.user_uuid_unwrap(), team_uuid, &payload) + .await + .map_err(|e| Response::fail(Some(&e)))?; + + Ok(Response::success( + Some("获取成功"), + Some(RpaTaskListResponse { + items, + total, + page: payload.pagination.page, + page_size: payload.pagination.page_size, + }), + )) +} + +/// 获取 RPA 任务详情 +pub async fn get_rpa_task_handler( + State(svc_ctx): State, + Json(payload): Json, +) -> Result { + let (task, steps, environment_uuids) = + services::rpa::get_rpa_task_service(&svc_ctx, payload.uuid) + .await + .map_err(|e| Response::fail(Some(&e)))?; + + Ok(Response::success( + Some("获取成功"), + Some(RpaTaskDetailResponse { + task, + steps, + environment_uuids, + }), + )) +} + +/// 创建 RPA 任务 +pub async fn create_rpa_task_handler( + State(svc_ctx): State, + Extension(ctx): Extension, + Json(payload): Json, +) -> Result { + let team_uuid = services::teams::get_current_team_service(&svc_ctx, ctx.user_uuid_unwrap()) + .await + .map_err(|e| Response::fail(Some(&e)))?; + + let uuid = services::rpa::create_rpa_task_service( + &svc_ctx, + ctx.user_uuid_unwrap(), + team_uuid, + &payload, + ) + .await + .map_err(|e| Response::fail(Some(&e)))?; + + Ok(Response::success( + Some("创建成功"), + Some(CreateResponse { uuid }), + )) +} + +/// 更新 RPA 任务 +pub async fn update_rpa_task_handler( + State(svc_ctx): State, + Json(payload): Json, +) -> Result<()> { + services::rpa::update_rpa_task_service(&svc_ctx, &payload) + .await + .map_err(|e| Response::fail(Some(&e)))?; + + Ok(Response::success(Some("更新成功"), None)) +} + +/// 删除 RPA 任务 +pub async fn delete_rpa_task_handler( + State(svc_ctx): State, + Json(payload): Json, +) -> Result<()> { + services::rpa::delete_rpa_task_service(&svc_ctx, payload.uuid) + .await + .map_err(|e| Response::fail(Some(&e)))?; + + Ok(Response::success(Some("删除成功"), None)) +} + +/// 批量删除 RPA 任务 +pub async fn batch_delete_rpa_tasks_handler( + State(svc_ctx): State, + Json(payload): Json, +) -> Result<()> { + services::rpa::batch_delete_rpa_tasks_service(&svc_ctx, &payload.uuids) + .await + .map_err(|e| Response::fail(Some(&e)))?; + + Ok(Response::success(Some("批量删除成功"), None)) +} + +/// 运行 RPA 任务 +pub async fn run_rpa_task_handler( + State(svc_ctx): State, + Json(payload): Json, +) -> Result { + let run_uuid = services::rpa::run_rpa_task_service(&svc_ctx, payload.uuid) + .await + .map_err(|e| Response::fail(Some(&e)))?; + + Ok(Response::success( + Some("任务已启动"), + Some(CreateResponse { uuid: run_uuid }), + )) +} + +/// 停止 RPA 任务 +pub async fn stop_rpa_task_handler( + State(svc_ctx): State, + Json(payload): Json, +) -> Result<()> { + services::rpa::stop_rpa_task_service(&svc_ctx, payload.uuid) + .await + .map_err(|e| Response::fail(Some(&e)))?; + + Ok(Response::success(Some("任务已停止"), None)) +} + +/// 复制 RPA 任务 +pub async fn duplicate_rpa_task_handler( + State(svc_ctx): State, + Extension(ctx): Extension, + Json(payload): Json, +) -> Result { + let team_uuid = services::teams::get_current_team_service(&svc_ctx, ctx.user_uuid_unwrap()) + .await + .map_err(|e| Response::fail(Some(&e)))?; + + let uuid = services::rpa::duplicate_rpa_task_service( + &svc_ctx, + ctx.user_uuid_unwrap(), + team_uuid, + &payload, + ) + .await + .map_err(|e| Response::fail(Some(&e)))?; + + Ok(Response::success( + Some("复制成功"), + Some(CreateResponse { uuid }), + )) +} + +/// 导出 RPA 任务 +pub async fn export_rpa_task_handler( + State(svc_ctx): State, + Json(payload): Json, +) -> Result { + let (content, filename) = services::rpa::export_rpa_task_service(&svc_ctx, payload.uuid) + .await + .map_err(|e| Response::fail(Some(&e)))?; + + Ok(Response::success( + Some("导出成功"), + Some(ExportRpaTaskResponse { content, filename }), + )) +} + +/// 获取执行记录列表 +pub async fn get_rpa_runs_handler( + State(svc_ctx): State, + Json(payload): Json, +) -> Result { + let (items, total) = services::rpa::get_rpa_runs_service(&svc_ctx, &payload) + .await + .map_err(|e| Response::fail(Some(&e)))?; + + Ok(Response::success( + Some("获取成功"), + Some(RpaRunsListResponse { + items, + total, + page: payload.pagination.page, + page_size: payload.pagination.page_size, + }), + )) +} + +/// 获取执行记录详情 +pub async fn get_rpa_run_handler( + State(svc_ctx): State, + Json(payload): Json, +) -> Result { + let run = services::rpa::get_rpa_run_service(&svc_ctx, payload.uuid) + .await + .map_err(|e| Response::fail(Some(&e)))?; + + Ok(Response::success(Some("获取成功"), Some(run))) +} diff --git a/src/handlers/secret.rs b/src/handlers/secret.rs index 09d968f..73faffd 100644 --- a/src/handlers/secret.rs +++ b/src/handlers/secret.rs @@ -1,15 +1,15 @@ -use axum::http::StatusCode; -use axum::response::IntoResponse; - -use crate::utils::get_rsa_secret_instance; - -/// 获取服务器公钥 -/// -/// 返回服务器的 RSA 公钥(PEM 格式),用于客户端加密数据 -/// -/// # Returns -/// 返回纯文本格式的 PEM 公钥字符串 -pub async fn get_public_key_handler() -> impl IntoResponse { - let public_key = get_rsa_secret_instance().get_public_key(); - (StatusCode::OK, public_key).into_response() -} +use axum::http::StatusCode; +use axum::response::IntoResponse; + +use crate::utils::get_rsa_secret_instance; + +/// 获取服务器公钥 +/// +/// 返回服务器的 RSA 公钥(PEM 格式),用于客户端加密数据 +/// +/// # Returns +/// 返回纯文本格式的 PEM 公钥字符串 +pub async fn get_public_key_handler() -> impl IntoResponse { + let public_key = get_rsa_secret_instance().get_public_key(); + (StatusCode::OK, public_key).into_response() +} diff --git a/src/handlers/teams.rs b/src/handlers/teams.rs index 63c27f0..9cf35cc 100644 --- a/src/handlers/teams.rs +++ b/src/handlers/teams.rs @@ -1,364 +1,364 @@ -use axum::{extract::Extension, extract::State}; - -use crate::audit_log; -use crate::entitys::{ - AcceptInvitationRequest, AcceptInvitationResponse, CreateResponse, CreateTeamRequest, - InviteMemberRequest, InviteResponse, ListTeamMembersRequest, MemberListResponse, - RejectInvitationRequest, RemoveMemberRequest, SwitchTeamRequest, TeamItem, TeamListResponse, - UpdateMemberRoleRequest, UpdateTeamRequest, UuidRequest, -}; -use crate::services; -use crate::state::RequestContext; -use crate::svc_ctx::SvcCtx; -use crate::utils::{Json, Response, Result}; - -/// 创建团队 -pub async fn create_team_handler( - State(svc_ctx): State, - Extension(ctx): Extension, - Json(payload): Json, -) -> Result { - let team_uuid = - services::teams::create_team_service(&svc_ctx, ctx.user_uuid_unwrap(), &payload) - .await - .map_err(|e| Response::fail(Some(&e)))?; - - audit_log!( - &svc_ctx, - &ctx, - "create", - "team", - team_uuid, - &payload.name, - "创建团队" - ) - .await; - - Ok(Response::success( - Some("创建成功"), - Some(CreateResponse { uuid: team_uuid }), - )) -} - -/// 获取用户的所有团队 -pub async fn get_my_teams_handler( - State(svc_ctx): State, - Extension(ctx): Extension, -) -> Result { - let workspace_uuid = ctx.current_workspace_uuid.ok_or_else(|| Response::fail(Some("请先选择工作空间")))?; - let teams = services::teams::get_user_teams_service(&svc_ctx, workspace_uuid, ctx.user_uuid_unwrap()) - .await - .map_err(|e| Response::fail(Some(&e)))?; - - let current_team_uuid = - services::teams::get_current_team_service(&svc_ctx, ctx.user_uuid_unwrap()) - .await - .map_err(|e| Response::fail(Some(&e)))?; - - let mut team_items: Vec = vec![]; - - for t in &teams { - // 获取用户在该团队的角色 - let role = if t.owner_uuid == ctx.user_uuid_unwrap() { - "owner".to_string() - } else { - crate::models::fetch_team_member(&svc_ctx.db, workspace_uuid, t.uuid, ctx.user_uuid_unwrap()) - .await - .ok() - .flatten() - .map(|m| m.role) - .unwrap_or_else(|| "member".to_string()) - }; - - // 获取成员数量(不使用筛选,统计所有活跃成员) - let members_count = - crate::models::fetch_team_member_count(&svc_ctx.db, t.uuid, None, None, None) - .await - .unwrap_or(0); - - team_items.push(TeamItem { - uuid: t.uuid, - name: t.name.clone(), - description: t.description.clone(), - role, - members_count, - is_current: current_team_uuid == Some(t.uuid), - }); - } - - Ok(Response::success( - Some("获取成功"), - Some(TeamListResponse { - current_team_uuid, - teams: team_items, - }), - )) -} - -/// 切换团队 -pub async fn switch_team_handler( - State(svc_ctx): State, - Extension(ctx): Extension, - Json(payload): Json, -) -> Result<()> { - let workspace_uuid = ctx.current_workspace_uuid.ok_or_else(|| Response::fail(Some("请先选择工作空间")))?; - services::teams::switch_team_service(&svc_ctx, workspace_uuid, ctx.user_uuid_unwrap(), &payload) - .await - .map_err(|e| Response::fail(Some(&e)))?; - - Ok(Response::success(Some("切换成功"), None)) -} - -/// 获取团队详情 -pub async fn get_team_handler( - State(svc_ctx): State, - Json(payload): Json, -) -> Result { - let team = services::teams::get_team_service(&svc_ctx, payload.uuid) - .await - .map_err(|e| Response::fail(Some(&e)))?; - - Ok(Response::success(Some("获取成功"), Some(team))) -} - -/// 更新团队信息 -pub async fn update_team_handler( - State(svc_ctx): State, - Extension(ctx): Extension, - Json(payload): Json, -) -> Result<()> { - let workspace_uuid = ctx.current_workspace_uuid.ok_or_else(|| Response::fail(Some("请先选择工作空间")))?; - services::teams::update_team_service(&svc_ctx, workspace_uuid, ctx.user_uuid_unwrap(), &payload) - .await - .map_err(|e| Response::fail(Some(&e)))?; - - Ok(Response::success(Some("更新成功"), None)) -} - -/// 获取团队成员列表 -pub async fn get_team_members_handler( - State(svc_ctx): State, - Extension(ctx): Extension, - Json(payload): Json, -) -> Result { - // 获取用户当前团队 - let team_uuid = services::teams::get_current_team_service(&svc_ctx, ctx.user_uuid_unwrap()) - .await - .map_err(|e| Response::fail(Some(&e)))? - .ok_or_else(|| Response::fail(Some("请先选择团队")))?; - - let (members, total) = services::teams::get_team_members_service(&svc_ctx, team_uuid, &payload) - .await - .map_err(|e| Response::fail(Some(&e)))?; - - Ok(Response::success( - Some("获取成功"), - Some(MemberListResponse { - items: members, - total, - page: payload.pagination.page, - page_size: payload.pagination.page_size, - }), - )) -} - -/// 邀请成员 -pub async fn invite_member_handler( - State(svc_ctx): State, - Extension(ctx): Extension, - Json(payload): Json, -) -> Result { - let team_uuid = services::teams::get_current_team_service(&svc_ctx, ctx.user_uuid_unwrap()) - .await - .map_err(|e| Response::fail(Some(&e)))? - .ok_or_else(|| Response::fail(Some("请先选择团队")))?; - - let workspace_uuid = ctx.current_workspace_uuid.ok_or_else(|| Response::fail(Some("请先选择工作空间")))?; - let invitation_uuid = services::teams::invite_member_service( - &svc_ctx, - workspace_uuid, - team_uuid, - ctx.user_uuid_unwrap(), - &payload, - ) - .await - .map_err(|e| Response::fail(Some(&e)))?; - - audit_log!( - &svc_ctx, - &ctx, - "invite", - "team_member", - &format!("邀请成员: {}", payload.email) - ) - .await; - - Ok(Response::success( - Some("邀请已发送"), - Some(InviteResponse { invitation_uuid }), - )) -} - -/// 更新成员角色 -pub async fn update_member_role_handler( - State(svc_ctx): State, - Extension(ctx): Extension, - Json(payload): Json, -) -> Result { - let team_uuid = services::teams::get_current_team_service(&svc_ctx, ctx.user_uuid_unwrap()) - .await - .map_err(|e| Response::fail(Some(&e)))? - .ok_or_else(|| Response::fail(Some("请先选择团队")))?; - - let workspace_uuid = ctx.current_workspace_uuid.ok_or_else(|| Response::fail(Some("请先选择工作空间")))?; - let member = services::teams::update_member_role_service( - &svc_ctx, - workspace_uuid, - team_uuid, - ctx.user_uuid_unwrap(), - &payload, - ) - .await - .map_err(|e| Response::fail(Some(&e)))?; - - Ok(Response::success(Some("更新成功"), Some(member))) -} - -/// 移除成员 -pub async fn remove_member_handler( - State(svc_ctx): State, - Extension(ctx): Extension, - Json(payload): Json, -) -> Result<()> { - let team_uuid = services::teams::get_current_team_service(&svc_ctx, ctx.user_uuid_unwrap()) - .await - .map_err(|e| Response::fail(Some(&e)))? - .ok_or_else(|| Response::fail(Some("请先选择团队")))?; - - let workspace_uuid = ctx.current_workspace_uuid.ok_or_else(|| Response::fail(Some("请先选择工作空间")))?; - services::teams::remove_member_service( - &svc_ctx, - workspace_uuid, - team_uuid, - ctx.user_uuid_unwrap(), - payload.member_uuid, - ) - .await - .map_err(|e| Response::fail(Some(&e)))?; - - audit_log!( - &svc_ctx, - &ctx, - "remove", - "team_member", - payload.member_uuid, - "移除成员" - ) - .await; - - Ok(Response::success(Some("移除成功"), None)) -} - -/// 获取待处理邀请列表 -pub async fn get_pending_invitations_handler( - State(svc_ctx): State, - Extension(ctx): Extension, -) -> Result> { - let team_uuid = services::teams::get_current_team_service(&svc_ctx, ctx.user_uuid_unwrap()) - .await - .map_err(|e| Response::fail(Some(&e)))? - .ok_or_else(|| Response::fail(Some("请先选择团队")))?; - - let invitations = services::teams::get_pending_invitations_service(&svc_ctx, team_uuid) - .await - .map_err(|e| Response::fail(Some(&e)))?; - - Ok(Response::success(Some("获取成功"), Some(invitations))) -} - -/// 取消邀请 -pub async fn cancel_invitation_handler( - State(svc_ctx): State, - Json(payload): Json, -) -> Result<()> { - services::teams::cancel_invitation_service(&svc_ctx, payload.uuid) - .await - .map_err(|e| Response::fail(Some(&e)))?; - - Ok(Response::success(Some("邀请已取消"), None)) -} - -/// 接受邀请 -pub async fn accept_invitation_handler( - State(svc_ctx): State, - Extension(ctx): Extension, - Json(payload): Json, -) -> Result { - let team_uuid = services::teams::accept_invitation_service( - &svc_ctx, - ctx.user_uuid_unwrap(), - &payload.token, - ) - .await - .map_err(|e| Response::fail(Some(&e)))?; - - audit_log!( - &svc_ctx, - &ctx, - "accept_invitation", - "team", - team_uuid, - "接受团队邀请" - ) - .await; - - Ok(Response::success( - Some("已加入团队"), - Some(AcceptInvitationResponse { team_uuid }), - )) -} - -/// 拒绝邀请 -pub async fn reject_invitation_handler( - State(svc_ctx): State, - Extension(ctx): Extension, - Json(payload): Json, -) -> Result<()> { - services::teams::reject_invitation_service(&svc_ctx, ctx.user_uuid_unwrap(), &payload.token) - .await - .map_err(|e| Response::fail(Some(&e)))?; - - audit_log!(&svc_ctx, &ctx, "reject_invitation", "team", "拒绝团队邀请").await; - - Ok(Response::success(Some("已拒绝邀请"), None)) -} - -/// 退出团队 -pub async fn leave_team_handler( - State(svc_ctx): State, - Extension(ctx): Extension, -) -> Result<()> { - // 获取当前团队 - let current_team_uuid = - services::teams::get_current_team_service(&svc_ctx, ctx.user_uuid_unwrap()) - .await - .map_err(|e| Response::fail(Some(&e)))? - .ok_or_else(|| Response::fail(Some("当前没有选择团队")))?; - - let workspace_uuid = ctx.current_workspace_uuid.ok_or_else(|| Response::fail(Some("请先选择工作空间")))?; - services::teams::leave_team_service(&svc_ctx, workspace_uuid, current_team_uuid, ctx.user_uuid_unwrap()) - .await - .map_err(|e| Response::fail(Some(&e)))?; - - audit_log!( - &svc_ctx, - &ctx, - "leave", - "team", - current_team_uuid, - "退出团队" - ) - .await; - - Ok(Response::success(Some("已退出团队"), None)) -} +use axum::{extract::Extension, extract::State}; + +use crate::audit_log; +use crate::entitys::{ + AcceptInvitationRequest, AcceptInvitationResponse, CreateResponse, CreateTeamRequest, + InviteMemberRequest, InviteResponse, ListTeamMembersRequest, MemberListResponse, + RejectInvitationRequest, RemoveMemberRequest, SwitchTeamRequest, TeamItem, TeamListResponse, + UpdateMemberRoleRequest, UpdateTeamRequest, UuidRequest, +}; +use crate::services; +use crate::state::RequestContext; +use crate::svc_ctx::SvcCtx; +use crate::utils::{Json, Response, Result}; + +/// 创建团队 +pub async fn create_team_handler( + State(svc_ctx): State, + Extension(ctx): Extension, + Json(payload): Json, +) -> Result { + let team_uuid = + services::teams::create_team_service(&svc_ctx, ctx.user_uuid_unwrap(), &payload) + .await + .map_err(|e| Response::fail(Some(&e)))?; + + audit_log!( + &svc_ctx, + &ctx, + "create", + "team", + team_uuid, + &payload.name, + "创建团队" + ) + .await; + + Ok(Response::success( + Some("创建成功"), + Some(CreateResponse { uuid: team_uuid }), + )) +} + +/// 获取用户的所有团队 +pub async fn get_my_teams_handler( + State(svc_ctx): State, + Extension(ctx): Extension, +) -> Result { + let workspace_uuid = ctx.current_workspace_uuid.ok_or_else(|| Response::fail(Some("请先选择工作空间")))?; + let teams = services::teams::get_user_teams_service(&svc_ctx, workspace_uuid, ctx.user_uuid_unwrap()) + .await + .map_err(|e| Response::fail(Some(&e)))?; + + let current_team_uuid = + services::teams::get_current_team_service(&svc_ctx, ctx.user_uuid_unwrap()) + .await + .map_err(|e| Response::fail(Some(&e)))?; + + let mut team_items: Vec = vec![]; + + for t in &teams { + // 获取用户在该团队的角色 + let role = if t.owner_uuid == ctx.user_uuid_unwrap() { + "owner".to_string() + } else { + crate::models::fetch_team_member(&svc_ctx.db, workspace_uuid, t.uuid, ctx.user_uuid_unwrap()) + .await + .ok() + .flatten() + .map(|m| m.role) + .unwrap_or_else(|| "member".to_string()) + }; + + // 获取成员数量(不使用筛选,统计所有活跃成员) + let members_count = + crate::models::fetch_team_member_count(&svc_ctx.db, t.uuid, None, None, None) + .await + .unwrap_or(0); + + team_items.push(TeamItem { + uuid: t.uuid, + name: t.name.clone(), + description: t.description.clone(), + role, + members_count, + is_current: current_team_uuid == Some(t.uuid), + }); + } + + Ok(Response::success( + Some("获取成功"), + Some(TeamListResponse { + current_team_uuid, + teams: team_items, + }), + )) +} + +/// 切换团队 +pub async fn switch_team_handler( + State(svc_ctx): State, + Extension(ctx): Extension, + Json(payload): Json, +) -> Result<()> { + let workspace_uuid = ctx.current_workspace_uuid.ok_or_else(|| Response::fail(Some("请先选择工作空间")))?; + services::teams::switch_team_service(&svc_ctx, workspace_uuid, ctx.user_uuid_unwrap(), &payload) + .await + .map_err(|e| Response::fail(Some(&e)))?; + + Ok(Response::success(Some("切换成功"), None)) +} + +/// 获取团队详情 +pub async fn get_team_handler( + State(svc_ctx): State, + Json(payload): Json, +) -> Result { + let team = services::teams::get_team_service(&svc_ctx, payload.uuid) + .await + .map_err(|e| Response::fail(Some(&e)))?; + + Ok(Response::success(Some("获取成功"), Some(team))) +} + +/// 更新团队信息 +pub async fn update_team_handler( + State(svc_ctx): State, + Extension(ctx): Extension, + Json(payload): Json, +) -> Result<()> { + let workspace_uuid = ctx.current_workspace_uuid.ok_or_else(|| Response::fail(Some("请先选择工作空间")))?; + services::teams::update_team_service(&svc_ctx, workspace_uuid, ctx.user_uuid_unwrap(), &payload) + .await + .map_err(|e| Response::fail(Some(&e)))?; + + Ok(Response::success(Some("更新成功"), None)) +} + +/// 获取团队成员列表 +pub async fn get_team_members_handler( + State(svc_ctx): State, + Extension(ctx): Extension, + Json(payload): Json, +) -> Result { + // 获取用户当前团队 + let team_uuid = services::teams::get_current_team_service(&svc_ctx, ctx.user_uuid_unwrap()) + .await + .map_err(|e| Response::fail(Some(&e)))? + .ok_or_else(|| Response::fail(Some("请先选择团队")))?; + + let (members, total) = services::teams::get_team_members_service(&svc_ctx, team_uuid, &payload) + .await + .map_err(|e| Response::fail(Some(&e)))?; + + Ok(Response::success( + Some("获取成功"), + Some(MemberListResponse { + items: members, + total, + page: payload.pagination.page, + page_size: payload.pagination.page_size, + }), + )) +} + +/// 邀请成员 +pub async fn invite_member_handler( + State(svc_ctx): State, + Extension(ctx): Extension, + Json(payload): Json, +) -> Result { + let team_uuid = services::teams::get_current_team_service(&svc_ctx, ctx.user_uuid_unwrap()) + .await + .map_err(|e| Response::fail(Some(&e)))? + .ok_or_else(|| Response::fail(Some("请先选择团队")))?; + + let workspace_uuid = ctx.current_workspace_uuid.ok_or_else(|| Response::fail(Some("请先选择工作空间")))?; + let invitation_uuid = services::teams::invite_member_service( + &svc_ctx, + workspace_uuid, + team_uuid, + ctx.user_uuid_unwrap(), + &payload, + ) + .await + .map_err(|e| Response::fail(Some(&e)))?; + + audit_log!( + &svc_ctx, + &ctx, + "invite", + "team_member", + &format!("邀请成员: {}", payload.email) + ) + .await; + + Ok(Response::success( + Some("邀请已发送"), + Some(InviteResponse { invitation_uuid }), + )) +} + +/// 更新成员角色 +pub async fn update_member_role_handler( + State(svc_ctx): State, + Extension(ctx): Extension, + Json(payload): Json, +) -> Result { + let team_uuid = services::teams::get_current_team_service(&svc_ctx, ctx.user_uuid_unwrap()) + .await + .map_err(|e| Response::fail(Some(&e)))? + .ok_or_else(|| Response::fail(Some("请先选择团队")))?; + + let workspace_uuid = ctx.current_workspace_uuid.ok_or_else(|| Response::fail(Some("请先选择工作空间")))?; + let member = services::teams::update_member_role_service( + &svc_ctx, + workspace_uuid, + team_uuid, + ctx.user_uuid_unwrap(), + &payload, + ) + .await + .map_err(|e| Response::fail(Some(&e)))?; + + Ok(Response::success(Some("更新成功"), Some(member))) +} + +/// 移除成员 +pub async fn remove_member_handler( + State(svc_ctx): State, + Extension(ctx): Extension, + Json(payload): Json, +) -> Result<()> { + let team_uuid = services::teams::get_current_team_service(&svc_ctx, ctx.user_uuid_unwrap()) + .await + .map_err(|e| Response::fail(Some(&e)))? + .ok_or_else(|| Response::fail(Some("请先选择团队")))?; + + let workspace_uuid = ctx.current_workspace_uuid.ok_or_else(|| Response::fail(Some("请先选择工作空间")))?; + services::teams::remove_member_service( + &svc_ctx, + workspace_uuid, + team_uuid, + ctx.user_uuid_unwrap(), + payload.member_uuid, + ) + .await + .map_err(|e| Response::fail(Some(&e)))?; + + audit_log!( + &svc_ctx, + &ctx, + "remove", + "team_member", + payload.member_uuid, + "移除成员" + ) + .await; + + Ok(Response::success(Some("移除成功"), None)) +} + +/// 获取待处理邀请列表 +pub async fn get_pending_invitations_handler( + State(svc_ctx): State, + Extension(ctx): Extension, +) -> Result> { + let team_uuid = services::teams::get_current_team_service(&svc_ctx, ctx.user_uuid_unwrap()) + .await + .map_err(|e| Response::fail(Some(&e)))? + .ok_or_else(|| Response::fail(Some("请先选择团队")))?; + + let invitations = services::teams::get_pending_invitations_service(&svc_ctx, team_uuid) + .await + .map_err(|e| Response::fail(Some(&e)))?; + + Ok(Response::success(Some("获取成功"), Some(invitations))) +} + +/// 取消邀请 +pub async fn cancel_invitation_handler( + State(svc_ctx): State, + Json(payload): Json, +) -> Result<()> { + services::teams::cancel_invitation_service(&svc_ctx, payload.uuid) + .await + .map_err(|e| Response::fail(Some(&e)))?; + + Ok(Response::success(Some("邀请已取消"), None)) +} + +/// 接受邀请 +pub async fn accept_invitation_handler( + State(svc_ctx): State, + Extension(ctx): Extension, + Json(payload): Json, +) -> Result { + let team_uuid = services::teams::accept_invitation_service( + &svc_ctx, + ctx.user_uuid_unwrap(), + &payload.token, + ) + .await + .map_err(|e| Response::fail(Some(&e)))?; + + audit_log!( + &svc_ctx, + &ctx, + "accept_invitation", + "team", + team_uuid, + "接受团队邀请" + ) + .await; + + Ok(Response::success( + Some("已加入团队"), + Some(AcceptInvitationResponse { team_uuid }), + )) +} + +/// 拒绝邀请 +pub async fn reject_invitation_handler( + State(svc_ctx): State, + Extension(ctx): Extension, + Json(payload): Json, +) -> Result<()> { + services::teams::reject_invitation_service(&svc_ctx, ctx.user_uuid_unwrap(), &payload.token) + .await + .map_err(|e| Response::fail(Some(&e)))?; + + audit_log!(&svc_ctx, &ctx, "reject_invitation", "team", "拒绝团队邀请").await; + + Ok(Response::success(Some("已拒绝邀请"), None)) +} + +/// 退出团队 +pub async fn leave_team_handler( + State(svc_ctx): State, + Extension(ctx): Extension, +) -> Result<()> { + // 获取当前团队 + let current_team_uuid = + services::teams::get_current_team_service(&svc_ctx, ctx.user_uuid_unwrap()) + .await + .map_err(|e| Response::fail(Some(&e)))? + .ok_or_else(|| Response::fail(Some("当前没有选择团队")))?; + + let workspace_uuid = ctx.current_workspace_uuid.ok_or_else(|| Response::fail(Some("请先选择工作空间")))?; + services::teams::leave_team_service(&svc_ctx, workspace_uuid, current_team_uuid, ctx.user_uuid_unwrap()) + .await + .map_err(|e| Response::fail(Some(&e)))?; + + audit_log!( + &svc_ctx, + &ctx, + "leave", + "team", + current_team_uuid, + "退出团队" + ) + .await; + + Ok(Response::success(Some("已退出团队"), None)) +} diff --git a/src/handlers/templates.rs b/src/handlers/templates.rs index d67d584..de4ca17 100644 --- a/src/handlers/templates.rs +++ b/src/handlers/templates.rs @@ -1,157 +1,157 @@ -use axum::{extract::Extension, extract::State}; - -use crate::entitys::{ - CreateFromTemplateRequest, CreateResponse, CreateTemplateRequest, GetTemplateRequest, - ListTemplatesRequest, TemplateDetailResponse, TemplateListResponse, UpdateTemplateRequest, - UuidRequest, -}; -use crate::services; -use crate::state::RequestContext; -use crate::svc_ctx::SvcCtx; -use crate::utils::{Json, Response, Result}; - -/// 获取模板列表 -pub async fn get_templates_handler( - State(svc_ctx): State, - Extension(ctx): Extension, - Json(payload): Json, -) -> Result { - let team_uuid = services::teams::get_current_team_service(&svc_ctx, ctx.user_uuid_unwrap()) - .await - .map_err(|e| Response::fail(Some(&e)))?; - - let (templates, total) = services::templates::get_templates_service( - &svc_ctx, - ctx.user_uuid_unwrap(), - team_uuid, - payload.is_public, - payload.pagination.page, - payload.pagination.page_size, - ) - .await - .map_err(|e| Response::fail(Some(&e)))?; - - Ok(Response::success( - Some("获取成功"), - Some(TemplateListResponse { - items: templates, - total, - page: payload.pagination.page, - page_size: payload.pagination.page_size, - }), - )) -} - -/// 获取模板详情 -pub async fn get_template_handler( - State(svc_ctx): State, - Json(payload): Json, -) -> Result { - let result = services::templates::get_template_service( - &svc_ctx, - payload.uuid, - payload.for_create.unwrap_or(false), - ) - .await - .map_err(|e| Response::fail(Some(&e)))?; - - Ok(Response::success(Some("获取成功"), Some(result))) -} - -/// 创建模板 -pub async fn create_template_handler( - State(svc_ctx): State, - Extension(ctx): Extension, - Json(payload): Json, -) -> Result { - let team_uuid = services::teams::get_current_team_service(&svc_ctx, ctx.user_uuid_unwrap()) - .await - .map_err(|e| Response::fail(Some(&e)))?; - - let template_uuid = services::templates::create_template_service( - &svc_ctx, - ctx.user_uuid_unwrap(), - team_uuid, - &payload, - ) - .await - .map_err(|e| Response::fail(Some(&e)))?; - - Ok(Response::success( - Some("创建成功"), - Some(CreateResponse { - uuid: template_uuid, - }), - )) -} - -/// 更新模板 -pub async fn update_template_handler( - State(svc_ctx): State, - Json(payload): Json, -) -> Result<()> { - services::templates::update_template_service(&svc_ctx, &payload) - .await - .map_err(|e| Response::fail(Some(&e)))?; - - Ok(Response::success(Some("更新成功"), None)) -} - -/// 删除模板 -pub async fn delete_template_handler( - State(svc_ctx): State, - Json(payload): Json, -) -> Result<()> { - services::templates::delete_template_service(&svc_ctx, payload.uuid) - .await - .map_err(|e| Response::fail(Some(&e)))?; - - Ok(Response::success(Some("删除成功"), None)) -} - -/// 应用模板到环境(更新环境配置为模板配置) -pub async fn apply_template_handler( - State(svc_ctx): State, - Extension(ctx): Extension, - Json(payload): Json, -) -> Result<()> { - let workspace_uuid = ctx.current_workspace_uuid.ok_or_else(|| Response::fail(Some("请先选择工作空间")))?; - let team_uuid = ctx.current_team_uuid.ok_or_else(|| Response::fail(Some("请先选择团队")))?; - - services::templates::apply_template_service( - &svc_ctx, - workspace_uuid, - team_uuid, - ctx.user_uuid_unwrap(), - &payload, - ) - .await - .map_err(|e| Response::fail(Some(&e)))?; - - Ok(Response::success(Some("应用成功"), None)) -} - -/// 从模板创建环境 -pub async fn create_from_template_handler( - State(svc_ctx): State, - Extension(ctx): Extension, - Json(payload): Json, -) -> Result { - let workspace_uuid = ctx.current_workspace_uuid.ok_or_else(|| Response::fail(Some("请先选择工作空间")))?; - let team_uuid = ctx.current_team_uuid.ok_or_else(|| Response::fail(Some("请先选择团队")))?; - - let env_uuid = services::templates::create_from_template_service( - &svc_ctx, - ctx.user_uuid_unwrap(), - workspace_uuid, - team_uuid, - &payload, - ) - .await - .map_err(|e| Response::fail(Some(&e)))?; - - Ok(Response::success( - Some("创建成功"), - Some(CreateResponse { uuid: env_uuid }), - )) -} +use axum::{extract::Extension, extract::State}; + +use crate::entitys::{ + CreateFromTemplateRequest, CreateResponse, CreateTemplateRequest, GetTemplateRequest, + ListTemplatesRequest, TemplateDetailResponse, TemplateListResponse, UpdateTemplateRequest, + UuidRequest, +}; +use crate::services; +use crate::state::RequestContext; +use crate::svc_ctx::SvcCtx; +use crate::utils::{Json, Response, Result}; + +/// 获取模板列表 +pub async fn get_templates_handler( + State(svc_ctx): State, + Extension(ctx): Extension, + Json(payload): Json, +) -> Result { + let team_uuid = services::teams::get_current_team_service(&svc_ctx, ctx.user_uuid_unwrap()) + .await + .map_err(|e| Response::fail(Some(&e)))?; + + let (templates, total) = services::templates::get_templates_service( + &svc_ctx, + ctx.user_uuid_unwrap(), + team_uuid, + payload.is_public, + payload.pagination.page, + payload.pagination.page_size, + ) + .await + .map_err(|e| Response::fail(Some(&e)))?; + + Ok(Response::success( + Some("获取成功"), + Some(TemplateListResponse { + items: templates, + total, + page: payload.pagination.page, + page_size: payload.pagination.page_size, + }), + )) +} + +/// 获取模板详情 +pub async fn get_template_handler( + State(svc_ctx): State, + Json(payload): Json, +) -> Result { + let result = services::templates::get_template_service( + &svc_ctx, + payload.uuid, + payload.for_create.unwrap_or(false), + ) + .await + .map_err(|e| Response::fail(Some(&e)))?; + + Ok(Response::success(Some("获取成功"), Some(result))) +} + +/// 创建模板 +pub async fn create_template_handler( + State(svc_ctx): State, + Extension(ctx): Extension, + Json(payload): Json, +) -> Result { + let team_uuid = services::teams::get_current_team_service(&svc_ctx, ctx.user_uuid_unwrap()) + .await + .map_err(|e| Response::fail(Some(&e)))?; + + let template_uuid = services::templates::create_template_service( + &svc_ctx, + ctx.user_uuid_unwrap(), + team_uuid, + &payload, + ) + .await + .map_err(|e| Response::fail(Some(&e)))?; + + Ok(Response::success( + Some("创建成功"), + Some(CreateResponse { + uuid: template_uuid, + }), + )) +} + +/// 更新模板 +pub async fn update_template_handler( + State(svc_ctx): State, + Json(payload): Json, +) -> Result<()> { + services::templates::update_template_service(&svc_ctx, &payload) + .await + .map_err(|e| Response::fail(Some(&e)))?; + + Ok(Response::success(Some("更新成功"), None)) +} + +/// 删除模板 +pub async fn delete_template_handler( + State(svc_ctx): State, + Json(payload): Json, +) -> Result<()> { + services::templates::delete_template_service(&svc_ctx, payload.uuid) + .await + .map_err(|e| Response::fail(Some(&e)))?; + + Ok(Response::success(Some("删除成功"), None)) +} + +/// 应用模板到环境(更新环境配置为模板配置) +pub async fn apply_template_handler( + State(svc_ctx): State, + Extension(ctx): Extension, + Json(payload): Json, +) -> Result<()> { + let workspace_uuid = ctx.current_workspace_uuid.ok_or_else(|| Response::fail(Some("请先选择工作空间")))?; + let team_uuid = ctx.current_team_uuid.ok_or_else(|| Response::fail(Some("请先选择团队")))?; + + services::templates::apply_template_service( + &svc_ctx, + workspace_uuid, + team_uuid, + ctx.user_uuid_unwrap(), + &payload, + ) + .await + .map_err(|e| Response::fail(Some(&e)))?; + + Ok(Response::success(Some("应用成功"), None)) +} + +/// 从模板创建环境 +pub async fn create_from_template_handler( + State(svc_ctx): State, + Extension(ctx): Extension, + Json(payload): Json, +) -> Result { + let workspace_uuid = ctx.current_workspace_uuid.ok_or_else(|| Response::fail(Some("请先选择工作空间")))?; + let team_uuid = ctx.current_team_uuid.ok_or_else(|| Response::fail(Some("请先选择团队")))?; + + let env_uuid = services::templates::create_from_template_service( + &svc_ctx, + ctx.user_uuid_unwrap(), + workspace_uuid, + team_uuid, + &payload, + ) + .await + .map_err(|e| Response::fail(Some(&e)))?; + + Ok(Response::success( + Some("创建成功"), + Some(CreateResponse { uuid: env_uuid }), + )) +} diff --git a/src/handlers/time.rs b/src/handlers/time.rs index 7663890..cc77cd9 100644 --- a/src/handlers/time.rs +++ b/src/handlers/time.rs @@ -1,10 +1,10 @@ -use crate::{ - services::now_service, - utils::{Response, Result}, -}; - -pub async fn now_handle() -> Result { - let now = now_service(); - - Ok(Response::::success(Some("获取时间成功"), Some(now))) -} +use crate::{ + services::now_service, + utils::{Response, Result}, +}; + +pub async fn now_handle() -> Result { + let now = now_service(); + + Ok(Response::::success(Some("获取时间成功"), Some(now))) +} diff --git a/src/handlers/users.rs b/src/handlers/users.rs index fc0ae8f..9683b84 100644 --- a/src/handlers/users.rs +++ b/src/handlers/users.rs @@ -1,137 +1,137 @@ -use axum::{extract::Extension, extract::State}; - -use crate::audit_log; -use crate::entitys::{ - LoginRequest, LoginResponse, RegisterRequest, RegisterResponse, ResetPasswordRequest, - SendCodeRequest, UpdatePasswordRequest, UpdateUserRequest, UserResponse, -}; -use crate::services::audit::log_audit_anonymous; -use crate::services::users::{ - get_current_user_service, login_service, refresh_token_service, register_service, - reset_password_service, send_verification_code_service, update_password_service, - update_user_service, -}; -use crate::state::RequestContext; -use crate::svc_ctx::SvcCtx; -use crate::utils::{Json, Response, Result}; - -/// 用户注册处理 -pub async fn register_handler( - State(svc_ctx): State, - Extension(ctx): Extension, - Json(payload): Json, -) -> Result { - let result = register_service(&svc_ctx, &payload) - .await - .map_err(|e| Response::fail(Some(&e.to_string())))?; - - // 记录审计日志(注册时使用返回的 user_uuid) - if let Some(ref user_info) = result.user_info { - log_audit_anonymous( - &svc_ctx, - &ctx, - user_info.uuid, - "register", - "user", - "用户注册", - ) - .await; - } - - Ok(Response::success(Some("注册成功"), Some(result))) -} - -/// 用户登录处理 -pub async fn login_handler( - State(svc_ctx): State, - Extension(ctx): Extension, - Json(payload): Json, -) -> Result { - let result = login_service(&svc_ctx, payload) - .await - .map_err(|e| Response::fail(Some(&e.to_string())))?; - - // 记录审计日志(登录时使用返回的 user_uuid) - if let Some(ref user_info) = result.user_info { - log_audit_anonymous(&svc_ctx, &ctx, user_info.uuid, "login", "user", "用户登录").await; - } - - Ok(Response::success(Some("登录成功"), Some(result))) -} - -/// 刷新 Token 处理 -pub async fn refresh_token_handler( - State(svc_ctx): State, - Json(payload): Json, -) -> Result { - let result = refresh_token_service(&svc_ctx, &payload.refresh_token) - .await - .map_err(|e| Response::fail(Some(&e.to_string())))?; - - Ok(Response::success(Some("刷新成功"), Some(result))) -} - -/// 获取当前用户信息处理 -pub async fn get_current_user_handler( - State(svc_ctx): State, - Extension(ctx): Extension, -) -> Result { - let user_info = get_current_user_service(&svc_ctx, ctx.user_uuid_unwrap()) - .await - .map_err(|e| Response::fail(Some(&e.to_string())))?; - - Ok(Response::success(Some("获取成功"), Some(user_info))) -} - -/// 更新用户信息处理 -pub async fn update_user_handler( - State(svc_ctx): State, - Extension(ctx): Extension, - Json(payload): Json, -) -> Result<()> { - update_user_service(&svc_ctx, ctx.user_uuid_unwrap(), &payload) - .await - .map_err(|e| Response::fail(Some(&e.to_string())))?; - - Ok(Response::success(Some("更新成功"), None)) -} - -/// 修改密码处理 -pub async fn update_password_handler( - State(svc_ctx): State, - Extension(ctx): Extension, - Json(payload): Json, -) -> Result<()> { - update_password_service(&svc_ctx, ctx.user_uuid_unwrap(), &payload) - .await - .map_err(|e| Response::fail(Some(&e.to_string())))?; - - // 记录审计日志 - audit_log!(&svc_ctx, &ctx, "update_password", "user", "修改密码").await; - - Ok(Response::success(Some("修改成功"), None)) -} - -/// 重置密码处理 -pub async fn reset_password_handler( - State(svc_ctx): State, - Json(payload): Json, -) -> Result<()> { - reset_password_service(&svc_ctx, &payload) - .await - .map_err(|e| Response::fail(Some(&e.to_string())))?; - - Ok(Response::success(Some("重置成功"), None)) -} - -/// 发送验证码处理 -pub async fn send_code_handler( - State(svc_ctx): State, - Json(payload): Json, -) -> Result<()> { - send_verification_code_service(&svc_ctx, &payload.email, &payload.r#type) - .await - .map_err(|e| Response::fail(Some(&e.to_string())))?; - - Ok(Response::success(Some("发送成功"), None)) -} +use axum::{extract::Extension, extract::State}; + +use crate::audit_log; +use crate::entitys::{ + LoginRequest, LoginResponse, RegisterRequest, RegisterResponse, ResetPasswordRequest, + SendCodeRequest, UpdatePasswordRequest, UpdateUserRequest, UserResponse, +}; +use crate::services::audit::log_audit_anonymous; +use crate::services::users::{ + get_current_user_service, login_service, refresh_token_service, register_service, + reset_password_service, send_verification_code_service, update_password_service, + update_user_service, +}; +use crate::state::RequestContext; +use crate::svc_ctx::SvcCtx; +use crate::utils::{Json, Response, Result}; + +/// 用户注册处理 +pub async fn register_handler( + State(svc_ctx): State, + Extension(ctx): Extension, + Json(payload): Json, +) -> Result { + let result = register_service(&svc_ctx, &payload) + .await + .map_err(|e| Response::fail(Some(&e.to_string())))?; + + // 记录审计日志(注册时使用返回的 user_uuid) + if let Some(ref user_info) = result.user_info { + log_audit_anonymous( + &svc_ctx, + &ctx, + user_info.uuid, + "register", + "user", + "用户注册", + ) + .await; + } + + Ok(Response::success(Some("注册成功"), Some(result))) +} + +/// 用户登录处理 +pub async fn login_handler( + State(svc_ctx): State, + Extension(ctx): Extension, + Json(payload): Json, +) -> Result { + let result = login_service(&svc_ctx, payload) + .await + .map_err(|e| Response::fail(Some(&e.to_string())))?; + + // 记录审计日志(登录时使用返回的 user_uuid) + if let Some(ref user_info) = result.user_info { + log_audit_anonymous(&svc_ctx, &ctx, user_info.uuid, "login", "user", "用户登录").await; + } + + Ok(Response::success(Some("登录成功"), Some(result))) +} + +/// 刷新 Token 处理 +pub async fn refresh_token_handler( + State(svc_ctx): State, + Json(payload): Json, +) -> Result { + let result = refresh_token_service(&svc_ctx, &payload.refresh_token) + .await + .map_err(|e| Response::fail(Some(&e.to_string())))?; + + Ok(Response::success(Some("刷新成功"), Some(result))) +} + +/// 获取当前用户信息处理 +pub async fn get_current_user_handler( + State(svc_ctx): State, + Extension(ctx): Extension, +) -> Result { + let user_info = get_current_user_service(&svc_ctx, ctx.user_uuid_unwrap()) + .await + .map_err(|e| Response::fail(Some(&e.to_string())))?; + + Ok(Response::success(Some("获取成功"), Some(user_info))) +} + +/// 更新用户信息处理 +pub async fn update_user_handler( + State(svc_ctx): State, + Extension(ctx): Extension, + Json(payload): Json, +) -> Result<()> { + update_user_service(&svc_ctx, ctx.user_uuid_unwrap(), &payload) + .await + .map_err(|e| Response::fail(Some(&e.to_string())))?; + + Ok(Response::success(Some("更新成功"), None)) +} + +/// 修改密码处理 +pub async fn update_password_handler( + State(svc_ctx): State, + Extension(ctx): Extension, + Json(payload): Json, +) -> Result<()> { + update_password_service(&svc_ctx, ctx.user_uuid_unwrap(), &payload) + .await + .map_err(|e| Response::fail(Some(&e.to_string())))?; + + // 记录审计日志 + audit_log!(&svc_ctx, &ctx, "update_password", "user", "修改密码").await; + + Ok(Response::success(Some("修改成功"), None)) +} + +/// 重置密码处理 +pub async fn reset_password_handler( + State(svc_ctx): State, + Json(payload): Json, +) -> Result<()> { + reset_password_service(&svc_ctx, &payload) + .await + .map_err(|e| Response::fail(Some(&e.to_string())))?; + + Ok(Response::success(Some("重置成功"), None)) +} + +/// 发送验证码处理 +pub async fn send_code_handler( + State(svc_ctx): State, + Json(payload): Json, +) -> Result<()> { + send_verification_code_service(&svc_ctx, &payload.email, &payload.r#type) + .await + .map_err(|e| Response::fail(Some(&e.to_string())))?; + + Ok(Response::success(Some("发送成功"), None)) +} diff --git a/src/handlers/workspace_quotas.rs b/src/handlers/workspace_quotas.rs index db63159..5dd766b 100644 --- a/src/handlers/workspace_quotas.rs +++ b/src/handlers/workspace_quotas.rs @@ -1,33 +1,33 @@ -use axum::{extract::Extension, extract::State}; - -use crate::entitys::{GetWorkspaceQuotaRequest, UpdateQuotaUsageRequest}; -use crate::services; -use crate::state::RequestContext; -use crate::svc_ctx::SvcCtx; -use crate::utils::{Json, Response, Result}; - -/// 获取工作空间配额 -pub async fn get_workspace_quota_handler( - State(svc_ctx): State, - Extension(_ctx): Extension, - Json(payload): Json, -) -> Result { - let quota = services::workspace_quotas::get_workspace_quota_service(&svc_ctx, &payload) - .await - .map_err(|e| Response::fail(Some(&e)))?; - - Ok(Response::success(None, Some(quota))) -} - -/// 更新配额使用情况 -pub async fn update_quota_usage_handler( - State(svc_ctx): State, - Extension(_ctx): Extension, - Json(payload): Json, -) -> Result<()> { - services::workspace_quotas::update_quota_usage_service(&svc_ctx, &payload) - .await - .map_err(|e| Response::fail(Some(&e)))?; - - Ok(Response::success(Some("更新成功"), None)) -} +use axum::{extract::Extension, extract::State}; + +use crate::entitys::{GetWorkspaceQuotaRequest, UpdateQuotaUsageRequest}; +use crate::services; +use crate::state::RequestContext; +use crate::svc_ctx::SvcCtx; +use crate::utils::{Json, Response, Result}; + +/// 获取工作空间配额 +pub async fn get_workspace_quota_handler( + State(svc_ctx): State, + Extension(_ctx): Extension, + Json(payload): Json, +) -> Result { + let quota = services::workspace_quotas::get_workspace_quota_service(&svc_ctx, &payload) + .await + .map_err(|e| Response::fail(Some(&e)))?; + + Ok(Response::success(None, Some(quota))) +} + +/// 更新配额使用情况 +pub async fn update_quota_usage_handler( + State(svc_ctx): State, + Extension(_ctx): Extension, + Json(payload): Json, +) -> Result<()> { + services::workspace_quotas::update_quota_usage_service(&svc_ctx, &payload) + .await + .map_err(|e| Response::fail(Some(&e)))?; + + Ok(Response::success(Some("更新成功"), None)) +} diff --git a/src/handlers/workspaces.rs b/src/handlers/workspaces.rs index b6fbae3..f14f14b 100644 --- a/src/handlers/workspaces.rs +++ b/src/handlers/workspaces.rs @@ -1,180 +1,180 @@ -use axum::{extract::Extension, extract::State}; - -use crate::audit_log; -use crate::entitys::{ - CreateResponse, CreateWorkspaceRequest, SwitchWorkspaceRequest, UpdateWorkspaceRequest, - WorkspaceItem, WorkspaceListResponse, -}; -use crate::services; -use crate::state::RequestContext; -use crate::svc_ctx::SvcCtx; -use crate::utils::{Json, Response, Result}; - -/// 创建工作空间 -pub async fn create_workspace_handler( - State(svc_ctx): State, - Extension(ctx): Extension, - Json(payload): Json, -) -> Result { - let workspace_uuid = - services::workspaces::create_workspace_service(&svc_ctx, ctx.user_uuid_unwrap(), &payload) - .await - .map_err(|e| Response::fail(Some(&e)))?; - - audit_log!( - &svc_ctx, - &ctx, - "create", - "workspace", - workspace_uuid, - &payload.name, - "创建工作空间" - ) - .await; - - Ok(Response::success( - Some("创建成功"), - Some(CreateResponse { - uuid: workspace_uuid, - }), - )) -} - -/// 获取用户的所有工作空间 -pub async fn get_my_workspaces_handler( - State(svc_ctx): State, - Extension(ctx): Extension, -) -> Result { - let user_uuid = ctx.user_uuid_unwrap(); - let workspaces = services::workspaces::get_user_workspaces_service(&svc_ctx, user_uuid) - .await - .map_err(|e| Response::fail(Some(&e)))?; - - // 从 RequestContext 获取当前工作空间 UUID(已在 auth 中间件中设置) - let mut current_workspace_uuid = ctx.current_workspace_uuid; - - // 如果用户没有设置当前工作空间,但有工作空间列表,自动设置为第一个工作空间 - if current_workspace_uuid.is_none() && !workspaces.is_empty() { - let first_workspace_uuid = workspaces[0].uuid; - // 更新数据库 - if let Err(e) = crate::models::user::set_user_current_workspace( - &svc_ctx.db, - user_uuid, - first_workspace_uuid, - ) - .await - { - tracing::warn!("Failed to set default workspace: {}", e); - } else { - current_workspace_uuid = Some(first_workspace_uuid); - } - } - - let workspace_items: Vec = workspaces - .iter() - .map(|w| WorkspaceItem { - uuid: w.uuid, - name: w.name.clone(), - workspace_type: w.workspace_type.clone(), - is_current: current_workspace_uuid == Some(w.uuid), - }) - .collect(); - - Ok(Response::success( - None, - Some(WorkspaceListResponse { - current_workspace_uuid, - workspaces: workspace_items, - }), - )) -} - -/// 获取工作空间详情 -pub async fn get_workspace_handler( - State(svc_ctx): State, - Extension(_ctx): Extension, - Json(payload): Json, -) -> Result { - let workspace = services::workspaces::get_workspace_service(&svc_ctx, payload.uuid) - .await - .map_err(|e| Response::fail(Some(&e)))?; - - Ok(Response::success(None, Some(workspace))) -} - -/// 更新工作空间 -pub async fn update_workspace_handler( - State(svc_ctx): State, - Extension(ctx): Extension, - Json(payload): Json, -) -> Result<()> { - services::workspaces::update_workspace_service(&svc_ctx, ctx.user_uuid_unwrap(), &payload) - .await - .map_err(|e| Response::fail(Some(&e)))?; - - audit_log!( - &svc_ctx, - &ctx, - "update", - "workspace", - payload.uuid, - &payload.name.as_deref().unwrap_or(""), - "更新工作空间" - ) - .await; - - Ok(Response::success(Some("更新成功"), None)) -} - -/// 删除工作空间 -pub async fn delete_workspace_handler( - State(svc_ctx): State, - Extension(ctx): Extension, - Json(payload): Json, -) -> Result<()> { - services::workspaces::delete_workspace_service(&svc_ctx, ctx.user_uuid_unwrap(), payload.uuid) - .await - .map_err(|e| Response::fail(Some(&e)))?; - - audit_log!( - &svc_ctx, - &ctx, - "delete", - "workspace", - payload.uuid, - "", - "删除工作空间" - ) - .await; - - Ok(Response::success(Some("删除成功"), None)) -} - -/// 切换工作空间 -pub async fn switch_workspace_handler( - State(svc_ctx): State, - Extension(ctx): Extension, - Json(payload): Json, -) -> Result<()> { - let user_uuid = ctx.user_uuid_unwrap(); - - // 检查用户是否是工作空间所有者 - let is_owner = services::workspaces::check_workspace_owner_service( - &svc_ctx, - payload.workspace_uuid, - user_uuid, - ) - .await - .map_err(|e| Response::fail(Some(&e)))?; - - if !is_owner { - return Err(Response::fail(Some("您不是该工作空间的所有者"))); - } - - // 更新用户当前工作空间 - crate::models::user::set_user_current_workspace(&svc_ctx.db, user_uuid, payload.workspace_uuid) - .await - .map_err(|e| Response::fail(Some(&e.to_string())))?; - - Ok(Response::success(Some("切换成功"), None)) -} +use axum::{extract::Extension, extract::State}; + +use crate::audit_log; +use crate::entitys::{ + CreateResponse, CreateWorkspaceRequest, SwitchWorkspaceRequest, UpdateWorkspaceRequest, + WorkspaceItem, WorkspaceListResponse, +}; +use crate::services; +use crate::state::RequestContext; +use crate::svc_ctx::SvcCtx; +use crate::utils::{Json, Response, Result}; + +/// 创建工作空间 +pub async fn create_workspace_handler( + State(svc_ctx): State, + Extension(ctx): Extension, + Json(payload): Json, +) -> Result { + let workspace_uuid = + services::workspaces::create_workspace_service(&svc_ctx, ctx.user_uuid_unwrap(), &payload) + .await + .map_err(|e| Response::fail(Some(&e)))?; + + audit_log!( + &svc_ctx, + &ctx, + "create", + "workspace", + workspace_uuid, + &payload.name, + "创建工作空间" + ) + .await; + + Ok(Response::success( + Some("创建成功"), + Some(CreateResponse { + uuid: workspace_uuid, + }), + )) +} + +/// 获取用户的所有工作空间 +pub async fn get_my_workspaces_handler( + State(svc_ctx): State, + Extension(ctx): Extension, +) -> Result { + let user_uuid = ctx.user_uuid_unwrap(); + let workspaces = services::workspaces::get_user_workspaces_service(&svc_ctx, user_uuid) + .await + .map_err(|e| Response::fail(Some(&e)))?; + + // 从 RequestContext 获取当前工作空间 UUID(已在 auth 中间件中设置) + let mut current_workspace_uuid = ctx.current_workspace_uuid; + + // 如果用户没有设置当前工作空间,但有工作空间列表,自动设置为第一个工作空间 + if current_workspace_uuid.is_none() && !workspaces.is_empty() { + let first_workspace_uuid = workspaces[0].uuid; + // 更新数据库 + if let Err(e) = crate::models::user::set_user_current_workspace( + &svc_ctx.db, + user_uuid, + first_workspace_uuid, + ) + .await + { + tracing::warn!("Failed to set default workspace: {}", e); + } else { + current_workspace_uuid = Some(first_workspace_uuid); + } + } + + let workspace_items: Vec = workspaces + .iter() + .map(|w| WorkspaceItem { + uuid: w.uuid, + name: w.name.clone(), + workspace_type: w.workspace_type.clone(), + is_current: current_workspace_uuid == Some(w.uuid), + }) + .collect(); + + Ok(Response::success( + None, + Some(WorkspaceListResponse { + current_workspace_uuid, + workspaces: workspace_items, + }), + )) +} + +/// 获取工作空间详情 +pub async fn get_workspace_handler( + State(svc_ctx): State, + Extension(_ctx): Extension, + Json(payload): Json, +) -> Result { + let workspace = services::workspaces::get_workspace_service(&svc_ctx, payload.uuid) + .await + .map_err(|e| Response::fail(Some(&e)))?; + + Ok(Response::success(None, Some(workspace))) +} + +/// 更新工作空间 +pub async fn update_workspace_handler( + State(svc_ctx): State, + Extension(ctx): Extension, + Json(payload): Json, +) -> Result<()> { + services::workspaces::update_workspace_service(&svc_ctx, ctx.user_uuid_unwrap(), &payload) + .await + .map_err(|e| Response::fail(Some(&e)))?; + + audit_log!( + &svc_ctx, + &ctx, + "update", + "workspace", + payload.uuid, + &payload.name.as_deref().unwrap_or(""), + "更新工作空间" + ) + .await; + + Ok(Response::success(Some("更新成功"), None)) +} + +/// 删除工作空间 +pub async fn delete_workspace_handler( + State(svc_ctx): State, + Extension(ctx): Extension, + Json(payload): Json, +) -> Result<()> { + services::workspaces::delete_workspace_service(&svc_ctx, ctx.user_uuid_unwrap(), payload.uuid) + .await + .map_err(|e| Response::fail(Some(&e)))?; + + audit_log!( + &svc_ctx, + &ctx, + "delete", + "workspace", + payload.uuid, + "", + "删除工作空间" + ) + .await; + + Ok(Response::success(Some("删除成功"), None)) +} + +/// 切换工作空间 +pub async fn switch_workspace_handler( + State(svc_ctx): State, + Extension(ctx): Extension, + Json(payload): Json, +) -> Result<()> { + let user_uuid = ctx.user_uuid_unwrap(); + + // 检查用户是否是工作空间所有者 + let is_owner = services::workspaces::check_workspace_owner_service( + &svc_ctx, + payload.workspace_uuid, + user_uuid, + ) + .await + .map_err(|e| Response::fail(Some(&e)))?; + + if !is_owner { + return Err(Response::fail(Some("您不是该工作空间的所有者"))); + } + + // 更新用户当前工作空间 + crate::models::user::set_user_current_workspace(&svc_ctx.db, user_uuid, payload.workspace_uuid) + .await + .map_err(|e| Response::fail(Some(&e.to_string())))?; + + Ok(Response::success(Some("切换成功"), None)) +} diff --git a/src/main.rs b/src/main.rs index b50b85a..cf97f66 100644 --- a/src/main.rs +++ b/src/main.rs @@ -5,7 +5,7 @@ use simprint_server::{init_encrypt_secret, init_minio}; use simprint_server::{ middlewares, routes::{ - accounts, api, audit, billing, browser_kernel, environments, extensions, + accounts, api, audit, billing, browser_kernel, environments, events, extensions, group_permissions, messages, preferences, proxies, proxy_visibility, referral, rpa, secret, teams, templates, time, users, workspace_quotas, workspaces, @@ -88,6 +88,7 @@ fn register_all_routes(svc_ctx: &SvcCtx) -> Router { api::register_routes(&mut meta_route); preferences::register_routes(&mut meta_route); messages::register_routes(&mut meta_route); + events::register_routes(&mut meta_route); tracing::info!("---------- {:?} ----------", meta_route.count()); meta_route.build() diff --git a/src/middlewares.rs b/src/middlewares.rs index 0fb4b93..cbd500d 100644 --- a/src/middlewares.rs +++ b/src/middlewares.rs @@ -31,3 +31,16 @@ pub fn get_request_path_and_method(req: &Request) -> (&str, &str) { (method, path) } + +/// 从完整路径中提取业务路径(去除 /api/v*/ 前缀,保留前导斜杠) +/// 例如:/api/v1/environments -> /environments +pub fn extract_resource_path(path: &str) -> &str { + // 匹配 /api/v{数字}/ 格式的前缀 + if let Some(stripped) = path.strip_prefix("/api/v") { + // 找到第一个 / 后的内容(包含斜杠) + if let Some(pos) = stripped.find('/') { + return &stripped[pos..]; + } + } + path +} diff --git a/src/middlewares/auth.rs b/src/middlewares/auth.rs index 166d253..15320ff 100644 --- a/src/middlewares/auth.rs +++ b/src/middlewares/auth.rs @@ -9,7 +9,7 @@ use axum::{ use uuid::Uuid; use crate::{ - middlewares::get_request_path_and_method, + middlewares::{extract_resource_path, get_request_path_and_method}, services, state::{CurrentUser, RequestContext}, svc_ctx::SvcCtx, @@ -22,6 +22,10 @@ pub async fn auth( ) -> Result { let (resource_method, resource_path) = get_request_path_and_method(&req); + // 转换为拥有所有权的 String,避免借用冲突 + let resource_method = resource_method.to_string(); + let resource_path = resource_path.to_string(); + if resource_method.eq("OPTIONS") { return Ok(next.run(req).await); } @@ -73,6 +77,10 @@ pub async fn auth( ctx.current_user = Some(CurrentUser { user_uuid: uuid }); + // 设置资源标识符(method+业务路径) + let business_path = extract_resource_path(&resource_path); + ctx.resource_identifier = Some(format!("{}+{}", resource_method, business_path)); + // 获取用户信息(包含 current_workspace_uuid 和 current_team_uuid) let user_info = crate::models::user::fetch_user_info_by_uuid(&state.db, uuid) .await diff --git a/src/routes.rs b/src/routes.rs index 7e209bd..2067c49 100644 --- a/src/routes.rs +++ b/src/routes.rs @@ -22,6 +22,7 @@ pub mod teams; pub mod templates; pub mod workspace_quotas; pub mod workspaces; +pub mod events; // route core pub mod route { diff --git a/src/routes/events.rs b/src/routes/events.rs new file mode 100644 index 0000000..db9873c --- /dev/null +++ b/src/routes/events.rs @@ -0,0 +1,18 @@ +use axum::routing::post; + +use crate::handlers::events; +use crate::routes::route::{self, MetaRoute, RequestMethod}; + +/// 注册事件相关的路由 +pub fn register_routes(meta_route: &mut MetaRoute) { + let mut events_route = route::RouteGroup::new("/events"); + + // POST /events/sync - 同步事件 + events_route.add_route_item(route::RouteItem::new( + "/sync", + RequestMethod::POST, + post(events::sync_events_handler), + )); + + meta_route.add_route_group(events_route); +} diff --git a/src/services.rs b/src/services.rs index cfaa0d4..76ee30e 100644 --- a/src/services.rs +++ b/src/services.rs @@ -36,6 +36,7 @@ pub mod templates; pub mod wallet; pub mod workspace_quotas; pub mod workspaces; +pub mod events; pub use gray_releases::*; pub use gray_resources::*; diff --git a/src/services/events.rs b/src/services/events.rs new file mode 100644 index 0000000..59b4e61 --- /dev/null +++ b/src/services/events.rs @@ -0,0 +1,182 @@ +use redis::AsyncCommands; +use uuid::Uuid; + +use crate::dto::events::{EntityType, EventType, WorkspaceEvent}; +use crate::state::RequestContext; +use crate::svc_ctx::SvcCtx; + +/// 事件服务 +pub struct EventService; + +impl EventService { + /// 发布事件到 Redis Stream + /// + /// # Arguments + /// * `svc_ctx` - 服务上下文 + /// * `event` - 工作空间事件 + /// + /// # Returns + /// * `Ok(String)` - 事件 ID + /// * `Err` - 发布失败 + pub async fn publish_event( + svc_ctx: &SvcCtx, + event: WorkspaceEvent, + ) -> anyhow::Result { + let stream_key = format!("events:workspace:{}", event.workspace_uuid); + + // 序列化事件 + let event_json = serde_json::to_string(&event)?; + + // 写入 Redis Stream + let mut conn = svc_ctx.redis.clone(); + let event_id: String = conn + .xadd_maxlen( + &stream_key, + redis::streams::StreamMaxlen::Approx(15000), + "*", // 自动生成 ID + &[("data", event_json)], + ) + .await?; + + // 设置 TTL(72 小时 = 259200 秒) + let _: () = conn.expire(&stream_key, 259200).await?; + + tracing::debug!( + "事件已发布: stream={}, event_id={}, type={:?}, entity={:?}", + stream_key, + event_id, + event.event_type, + event.entity_type + ); + + Ok(event_id) + } + + /// 获取指定时间后的事件 + /// + /// # Arguments + /// * `svc_ctx` - 服务上下文 + /// * `workspace_uuid` - 工作空间 ID + /// * `since_id` - 起始事件 ID(可选,默认从头开始) + /// + /// # Returns + /// * `Ok(Vec)` - 事件列表 + /// * `Err` - 获取失败 + pub async fn get_events_since( + svc_ctx: &SvcCtx, + workspace_uuid: Uuid, + since_id: Option, + ) -> anyhow::Result> { + let stream_key = format!("events:workspace:{}", workspace_uuid); + let start_id = since_id.unwrap_or_else(|| "0".to_string()); + + let mut conn = svc_ctx.redis.clone(); + + // 读取事件 + let results: redis::streams::StreamReadReply = conn + .xread(&[&stream_key], &[&start_id]) + .await?; + + let mut events = Vec::new(); + for stream_key_result in results.keys { + for stream_id in stream_key_result.ids { + if let Some(data) = stream_id.map.get("data") { + // redis 0.31.0 使用 BulkString 而不是 Data + match data { + redis::Value::BulkString(bytes) => { + match serde_json::from_slice::(bytes) { + Ok(mut event) => { + // 设置 Redis Stream 生成的 event_id + event.event_id = stream_id.id.clone(); + events.push(event); + } + Err(e) => { + tracing::error!("反序列化事件失败: {}", e); + } + } + } + _ => { + tracing::warn!("意外的 Redis Value 类型: {:?}", data); + } + } + } + } + } + + tracing::debug!( + "获取事件: workspace={}, since={}, count={}", + workspace_uuid, + start_id, + events.len() + ); + + Ok(events) + } + + /// 检查事件 ID 是否仍然存在于 Stream 中 + /// + /// # Arguments + /// * `svc_ctx` - 服务上下文 + /// * `workspace_uuid` - 工作空间 ID + /// * `event_id` - 事件 ID + /// + /// # Returns + /// * `Ok(bool)` - true 表示存在,false 表示已过期 + pub async fn event_exists( + svc_ctx: &SvcCtx, + workspace_uuid: Uuid, + event_id: &str, + ) -> anyhow::Result { + let stream_key = format!("events:workspace:{}", workspace_uuid); + + let mut conn = svc_ctx.redis.clone(); + + // 尝试读取指定 ID 的事件 + let results: redis::streams::StreamReadReply = conn + .xread_options( + &[&stream_key], + &[event_id], + &redis::streams::StreamReadOptions::default().count(1), + ) + .await?; + + Ok(!results.keys.is_empty()) + } + + /// 便捷方法:从 RequestContext 发布事件 + /// + /// # Arguments + /// * `svc_ctx` - 服务上下文 + /// * `ctx` - 请求上下文(包含 workspace_uuid) + /// * `event_type` - 事件类型 + /// * `entity_type` - 实体类型 + /// * `entity_id` - 实体 ID(可选) + /// * `affected_apis` - 受影响的查询接口列表 + /// + /// # Returns + /// * `Ok(())` - 发布成功 + /// * `Err` - 发布失败 + pub async fn publish_from_context( + svc_ctx: &SvcCtx, + ctx: &RequestContext, + event_type: EventType, + entity_type: EntityType, + entity_id: Option, + affected_apis: Vec, + ) -> anyhow::Result<()> { + let workspace_uuid = ctx.workspace_uuid().ok_or_else(|| { + anyhow::anyhow!("工作空间未设置") + })?; + + let event = WorkspaceEvent::new( + workspace_uuid, + event_type, + entity_type, + entity_id, + affected_apis, + ); + + Self::publish_event(svc_ctx, event).await?; + Ok(()) + } +} diff --git a/src/state.rs b/src/state.rs index 1c75408..3e7db65 100644 --- a/src/state.rs +++ b/src/state.rs @@ -42,6 +42,9 @@ pub struct RequestContext { pub current_team_uuid: Option, /// 当前工作空间 UUID pub current_workspace_uuid: Option, + /// 资源标识符:method+path(去除 /api/v*/ 前缀,保留前导斜杠) + /// 例如:POST+/environments, GET+/proxies + pub resource_identifier: Option, } impl RequestContext { @@ -74,4 +77,17 @@ impl RequestContext { pub fn workspace_uuid_unwrap(&self) -> Uuid { self.current_workspace_uuid.expect("工作空间未设置") } + + /// 获取资源标识符 + pub fn resource_identifier(&self) -> Option<&str> { + self.resource_identifier.as_deref() + } + + /// 获取资源路径(从资源标识符中提取路径部分) + /// 例如:POST+/environments -> /environments + pub fn resource_path(&self) -> Option<&str> { + self.resource_identifier.as_ref().and_then(|id| { + id.split_once('+').map(|(_, path)| path) + }) + } } From 913a3edb57af15edf9f3e6c68f957bd54349cf65 Mon Sep 17 00:00:00 2001 From: lius-new Date: Thu, 12 Feb 2026 17:11:40 +0800 Subject: [PATCH 11/60] =?UTF-8?q?feat(environments):=20=E5=AE=9E=E7=8E=B0?= =?UTF-8?q?=E6=9C=8D=E5=8A=A1=E7=AB=AF=E5=88=86=E9=A1=B5=E5=92=8C=E8=BF=87?= =?UTF-8?q?=E6=BB=A4=E5=8A=9F=E8=83=BD?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit 修复问题: - 修复total字段返回错误(返回当前页数量而非数据库总数) - 实现keyword搜索功能(支持按环境名称和UUID搜索) - 实现tag_uuids过滤功能(支持按标签过滤) 主要改动: 1. services/environments.rs - 从payload.filters中提取keyword和tag_uuids参数 - 调用fetch_environments_count获取数据库总数 - 将keyword和tag_uuids传递给models层 - 修复total返回值(使用数据库总数而非过滤后数量) 2. models/environments.rs - fetch_environments_base: 添加keyword和tag_uuids参数 * keyword: 使用ILIKE进行模糊搜索(name和uuid字段) * tag_uuids: 使用LEFT JOIN environment_tags表进行过滤 * 使用动态SQL构建查询,支持可选参数 - fetch_environments_count: 添加相同的过滤条件 * 使用COUNT(DISTINCT e.id)避免JOIN导致的重复计数 技术细节: - 使用动态SQL构建,根据参数动态添加WHERE条件 - 标签过滤使用ANY操作符支持多标签查询 - 搜索使用ILIKE支持不区分大小写的模糊匹配 Co-Authored-By: Claude Sonnet 4.5 --- src/models/environments.rs | 206 +++++++++++++++++++++++++++++------ src/services/environments.rs | 30 +++-- 2 files changed, 192 insertions(+), 44 deletions(-) diff --git a/src/models/environments.rs b/src/models/environments.rs index 9effdb6..a4140e3 100644 --- a/src/models/environments.rs +++ b/src/models/environments.rs @@ -295,34 +295,103 @@ pub async fn fetch_environments_base( team_uuid: Uuid, group_uuid: Option, status: Option<&str>, + keyword: Option<&str>, + tag_uuids: Option<&[Uuid]>, offset: i64, limit: i64, ) -> Result, Error> { - let recs = sqlx::query_as::<_, EnvironmentRowDto>( + let mut query = String::from( r#" - SELECT - id, uuid, workspace_uuid, user_uuid, team_uuid, name, description, status, - system_info, kernel_info, fingerprint_summary, - group_uuid, proxy_uuid, - last_opened_at, created_at, updated_at - FROM environments - WHERE workspace_uuid = $1 - AND team_uuid = $2 - AND ($3::uuid IS NULL OR group_uuid = $3) - AND ($4::varchar IS NULL OR status = $4) - AND deleted_at IS NULL - ORDER BY created_at DESC - LIMIT $5 OFFSET $6 + SELECT DISTINCT + e.id, e.uuid, e.workspace_uuid, e.user_uuid, e.team_uuid, e.name, e.description, e.status, + e.system_info, e.kernel_info, e.fingerprint_summary, + e.group_uuid, e.proxy_uuid, + e.last_opened_at, e.created_at, e.updated_at + FROM environments e "#, - ) - .bind(workspace_uuid) - .bind(team_uuid) - .bind(group_uuid) - .bind(status) - .bind(limit) - .bind(offset) - .fetch_all(pool) - .await?; + ); + + // 如果有标签过滤,需要 JOIN environment_tags 表 + if tag_uuids.is_some() { + query.push_str(" LEFT JOIN environment_tags et ON e.uuid = et.environment_uuid "); + } + + query.push_str( + r#" + WHERE e.workspace_uuid = $1 + AND e.team_uuid = $2 + AND e.deleted_at IS NULL + "#, + ); + + let mut param_index = 3; + let mut conditions = Vec::new(); + + // 分组过滤 + if group_uuid.is_some() { + conditions.push(format!("e.group_uuid = ${}", param_index)); + param_index += 1; + } + + // 状态过滤 + if status.is_some() { + conditions.push(format!("e.status = ${}", param_index)); + param_index += 1; + } + + // 关键词搜索 + if keyword.is_some() { + conditions.push(format!( + "(e.name ILIKE ${} OR e.uuid::text ILIKE ${})", + param_index, param_index + )); + param_index += 1; + } + + // 标签过滤 + if let Some(tags) = tag_uuids { + if !tags.is_empty() { + conditions.push(format!("et.tag_uuid = ANY(${})", param_index)); + param_index += 1; + } + } + + // 添加所有条件 + for condition in conditions { + query.push_str(&format!(" AND {}", condition)); + } + + query.push_str(&format!( + " ORDER BY e.created_at DESC LIMIT ${} OFFSET ${}", + param_index, + param_index + 1 + )); + + // 构建查询 + let mut sql_query = sqlx::query_as::<_, EnvironmentRowDto>(&query) + .bind(workspace_uuid) + .bind(team_uuid); + + // 绑定参数 + if let Some(g) = group_uuid { + sql_query = sql_query.bind(g); + } + if let Some(s) = status { + sql_query = sql_query.bind(s); + } + if let Some(k) = keyword { + let search_pattern = format!("%{}%", k); + sql_query = sql_query.bind(search_pattern); + } + if let Some(tags) = tag_uuids { + if !tags.is_empty() { + sql_query = sql_query.bind(tags); + } + } + + sql_query = sql_query.bind(limit).bind(offset); + + let recs = sql_query.fetch_all(pool).await?; Ok(recs) } @@ -370,22 +439,87 @@ pub async fn fetch_environments_count( team_uuid: Uuid, group_uuid: Option, status: Option<&str>, + keyword: Option<&str>, + tag_uuids: Option<&[Uuid]>, ) -> Result { - let count: i64 = sqlx::query_scalar( + let mut query = String::from( r#" - SELECT COUNT(*) FROM environments - WHERE workspace_uuid = $1 AND team_uuid = $2 - AND ($3::uuid IS NULL OR group_uuid = $3) - AND ($4::varchar IS NULL OR status = $4) - AND deleted_at IS NULL + SELECT COUNT(DISTINCT e.id) FROM environments e "#, - ) - .bind(workspace_uuid) - .bind(team_uuid) - .bind(group_uuid) - .bind(status) - .fetch_one(pool) - .await?; + ); + + // 如果有标签过滤,需要 JOIN environment_tags 表 + if tag_uuids.is_some() { + query.push_str(" LEFT JOIN environment_tags et ON e.uuid = et.environment_uuid "); + } + + query.push_str( + r#" + WHERE e.workspace_uuid = $1 + AND e.team_uuid = $2 + AND e.deleted_at IS NULL + "#, + ); + + let mut param_index = 3; + let mut conditions = Vec::new(); + + // 分组过滤 + if group_uuid.is_some() { + conditions.push(format!("e.group_uuid = ${}", param_index)); + param_index += 1; + } + + // 状态过滤 + if status.is_some() { + conditions.push(format!("e.status = ${}", param_index)); + param_index += 1; + } + + // 关键词搜索 + if keyword.is_some() { + conditions.push(format!( + "(e.name ILIKE ${} OR e.uuid::text ILIKE ${})", + param_index, param_index + )); + param_index += 1; + } + + // 标签过滤 + if let Some(tags) = tag_uuids { + if !tags.is_empty() { + conditions.push(format!("et.tag_uuid = ANY(${})", param_index)); + } + } + + // 添加所有条件 + for condition in conditions { + query.push_str(&format!(" AND {}", condition)); + } + + // 构建查询 + let mut sql_query = sqlx::query_scalar::<_, i64>(&query) + .bind(workspace_uuid) + .bind(team_uuid); + + // 绑定参数 + if let Some(g) = group_uuid { + sql_query = sql_query.bind(g); + } + if let Some(s) = status { + sql_query = sql_query.bind(s); + } + if let Some(k) = keyword { + let search_pattern = format!("%{}%", k); + sql_query = sql_query.bind(search_pattern); + } + if let Some(tags) = tag_uuids { + if !tags.is_empty() { + sql_query = sql_query.bind(tags); + } + } + + let count = sql_query.fetch_one(pool).await?; Ok(count) } diff --git a/src/services/environments.rs b/src/services/environments.rs index fe2f584..befbbc7 100644 --- a/src/services/environments.rs +++ b/src/services/environments.rs @@ -245,23 +245,37 @@ pub async fn get_environments_service( let group_uuid = payload.filters.as_ref().and_then(|f| f.group_uuid); let status = payload.filters.as_ref().and_then(|f| f.status.as_deref()); + let keyword = payload.filters.as_ref().and_then(|f| f.keyword.as_deref()); + let tag_uuids = payload.filters.as_ref().and_then(|f| f.tag_uuids.as_deref()); - // 2. 查询环境基础列表 + // 2. 查询环境总数(用于分页) + let total_count = models::fetch_environments_count( + &svc_ctx.db, + workspace_uuid, + team_uuid, + group_uuid, + status, + keyword, + tag_uuids, + ) + .await + .map_err(|e| e.to_string())?; + + // 3. 查询环境基础列表 let env_rows = models::fetch_environments_base( &svc_ctx.db, workspace_uuid, team_uuid, group_uuid, status, + keyword, + tag_uuids, offset, payload.pagination.page_size, ) .await .map_err(|e| e.to_string())?; - // 注意:这里不查询总数,因为权限过滤后的实际数量需要根据过滤结果计算 - // 如果需要精确的分页总数,需要在权限过滤后重新计算 - // 2. 权限过滤:根据分组权限过滤环境 let is_owner_or_admin = matches!(team_member.role.as_str(), "owner" | "admin"); @@ -446,10 +460,10 @@ pub async fn get_environments_service( }) .collect(); - // 返回过滤后的环境列表和实际数量(用于分页显示) - // 注意:total 是查询时的总数,实际返回的数量是过滤后的 - let filtered_total = environments.len() as i64; - Ok((environments, filtered_total)) + // 返回过滤后的环境列表和数据库总数(用于分页显示) + // 注意:total 是数据库中符合条件的总数,不考虑权限过滤 + // 权限过滤只影响当前页返回的数据,不影响总数统计 + Ok((environments, total_count)) } /// 获取环境详情 From 9497a5b23b538e1534812d01b01039780969b1af Mon Sep 17 00:00:00 2001 From: lius-new Date: Fri, 13 Feb 2026 00:46:03 +0800 Subject: [PATCH 12/60] =?UTF-8?q?feat(environments):=20=E5=AE=9E=E7=8E=B0?= =?UTF-8?q?=E5=9B=9E=E6=94=B6=E7=AB=99=E5=8A=9F=E8=83=BD?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit - 添加回收站列表查询接口 - 实现环境恢复功能(单个和批量) - 实现永久删除功能(单个和批量) - 支持回收站数据的过滤和分页 - 注册回收站相关路由 Co-Authored-By: Claude Sonnet 4.5 --- src/handlers/environments.rs | 192 +++++++++++++++++++++++++++ src/models/environments.rs | 244 +++++++++++++++++++++++++++++++++++ src/routes/environments.rs | 42 ++++-- src/services/environments.rs | 233 +++++++++++++++++++++++++++++++++ 4 files changed, 703 insertions(+), 8 deletions(-) diff --git a/src/handlers/environments.rs b/src/handlers/environments.rs index 9653795..1d3d368 100644 --- a/src/handlers/environments.rs +++ b/src/handlers/environments.rs @@ -587,6 +587,198 @@ pub async fn batch_delete_environments_handler( Ok(Response::success(Some("删除成功"), Some(count))) } +// ============ Recycle Bin ============ + +/// 查询回收站环境列表 +pub async fn get_recycle_bin_environments_handler( + State(svc_ctx): State, + Extension(ctx): Extension, + Json(payload): Json, +) -> Result { + let workspace_uuid = ctx + .current_workspace_uuid + .ok_or_else(|| Response::fail(Some("请先选择工作空间")))?; + let team_uuid = ctx.current_team_uuid.ok_or_else(|| Response::fail(Some("请先选择团队")))?; + + let (environments, total) = services::environments::get_recycle_bin_environments_service( + &svc_ctx, + workspace_uuid, + team_uuid, + ctx.user_uuid_unwrap(), + &payload, + ) + .await + .map_err(|e| Response::fail(Some(&e)))?; + + Ok(Response::success( + None, + Some(EnvironmentListResponse { + items: environments, + total, + page: payload.pagination.page, + page_size: payload.pagination.page_size, + }), + )) +} + +/// 恢复环境 +pub async fn restore_environment_handler( + State(svc_ctx): State, + Extension(ctx): Extension, + Json(payload): Json, +) -> Result<()> { + services::environments::restore_environment_service(&svc_ctx, payload.uuid) + .await + .map_err(|e| Response::fail(Some(&e)))?; + + audit_log!( + &svc_ctx, + &ctx, + "restore", + "environment", + payload.uuid, + "恢复环境" + ) + .await; + + // 发布恢复环境事件 + if let Err(e) = services::events::EventService::publish_from_context( + &svc_ctx, + &ctx, + EventType::Updated, + EntityType::Environment, + Some(payload.uuid), + vec!["environments/list".to_string(), "recycle-bin/list".to_string()], + ) + .await + { + tracing::error!("发布恢复环境事件失败: {}", e); + } + + Ok(Response::success(Some("恢复成功"), None)) +} + +/// 批量恢复环境 +pub async fn batch_restore_environments_handler( + State(svc_ctx): State, + Extension(ctx): Extension, + Json(payload): Json, +) -> Result { + let count = services::environments::batch_restore_environments_service(&svc_ctx, &payload.uuids) + .await + .map_err(|e| Response::fail(Some(&e)))?; + + audit_log!( + &svc_ctx, + &ctx, + "batch_restore", + "environment", + &format!("批量恢复 {} 个环境", count) + ) + .await; + + // 发布批量恢复环境事件 + if let Err(e) = services::events::EventService::publish_from_context( + &svc_ctx, + &ctx, + EventType::Updated, + EntityType::Environment, + None, + vec!["environments/list".to_string(), "recycle-bin/list".to_string()], + ) + .await + { + tracing::error!("发布批量恢复环境事件失败: {}", e); + } + + Ok(Response::success(Some("恢复成功"), Some(count))) +} + +/// 永久删除环境 +pub async fn permanent_delete_environment_handler( + State(svc_ctx): State, + Extension(ctx): Extension, + Json(payload): Json, +) -> Result<()> { + let workspace_uuid = ctx + .current_workspace_uuid + .ok_or_else(|| Response::fail(Some("请先选择工作空间")))?; + + services::environments::permanent_delete_environment_service(&svc_ctx, payload.uuid, workspace_uuid) + .await + .map_err(|e| Response::fail(Some(&e)))?; + + audit_log!( + &svc_ctx, + &ctx, + "permanent_delete", + "environment", + payload.uuid, + "永久删除环境" + ) + .await; + + // 发布永久删除环境事件 + if let Err(e) = services::events::EventService::publish_from_context( + &svc_ctx, + &ctx, + EventType::Deleted, + EntityType::Environment, + Some(payload.uuid), + vec!["recycle-bin/list".to_string()], + ) + .await + { + tracing::error!("发布永久删除环境事件失败: {}", e); + } + + Ok(Response::success(Some("永久删除成功"), None)) +} + +/// 批量永久删除环境 +pub async fn batch_permanent_delete_environments_handler( + State(svc_ctx): State, + Extension(ctx): Extension, + Json(payload): Json, +) -> Result { + let workspace_uuid = ctx + .current_workspace_uuid + .ok_or_else(|| Response::fail(Some("请先选择工作空间")))?; + + let count = services::environments::batch_permanent_delete_environments_service( + &svc_ctx, + &payload.uuids, + workspace_uuid, + ) + .await + .map_err(|e| Response::fail(Some(&e)))?; + + audit_log!( + &svc_ctx, + &ctx, + "batch_permanent_delete", + "environment", + &format!("批量永久删除 {} 个环境", count) + ) + .await; + + // 发布批量永久删除环境事件 + if let Err(e) = services::events::EventService::publish_from_context( + &svc_ctx, + &ctx, + EventType::Deleted, + EntityType::Environment, + None, + vec!["recycle-bin/list".to_string()], + ) + .await + { + tracing::error!("发布批量永久删除环境事件失败: {}", e); + } + + Ok(Response::success(Some("永久删除成功"), Some(count))) +} + /// 设置环境代理 pub async fn set_proxy_handler( State(svc_ctx): State, diff --git a/src/models/environments.rs b/src/models/environments.rs index a4140e3..6880a76 100644 --- a/src/models/environments.rs +++ b/src/models/environments.rs @@ -686,6 +686,250 @@ pub async fn batch_delete_environments( Ok(result.rows_affected()) } +// ============ Recycle Bin ============ + +/// 查询回收站环境列表(已删除但未永久删除) +pub async fn fetch_deleted_environments_base( + pool: &Pool, + workspace_uuid: Uuid, + team_uuid: Uuid, + group_uuid: Option, + keyword: Option<&str>, + offset: i64, + limit: i64, +) -> Result, Error> { + let mut query = String::from( + r#" + SELECT DISTINCT + e.id, e.uuid, e.workspace_uuid, e.user_uuid, e.team_uuid, e.name, e.description, e.status, + e.system_info, e.kernel_info, e.fingerprint_summary, + e.group_uuid, e.proxy_uuid, + e.last_opened_at, e.created_at, e.updated_at, e.deleted_at + FROM environments e + WHERE e.workspace_uuid = $1 + AND e.team_uuid = $2 + AND e.deleted_at IS NOT NULL + "#, + ); + + let mut param_index = 3; + let mut conditions = Vec::new(); + + // 分组过滤 + if group_uuid.is_some() { + conditions.push(format!("e.group_uuid = ${}", param_index)); + param_index += 1; + } + + // 关键词搜索 + if keyword.is_some() { + conditions.push(format!("(e.name ILIKE ${} OR e.uuid::text ILIKE ${})", param_index, param_index)); + param_index += 1; + } + + if !conditions.is_empty() { + query.push_str(" AND "); + query.push_str(&conditions.join(" AND ")); + } + + query.push_str(" ORDER BY e.deleted_at DESC LIMIT $"); + query.push_str(¶m_index.to_string()); + param_index += 1; + query.push_str(" OFFSET $"); + query.push_str(¶m_index.to_string()); + + let mut q = sqlx::query_as::<_, EnvironmentRowDto>(&query) + .bind(workspace_uuid) + .bind(team_uuid); + + if let Some(gid) = group_uuid { + q = q.bind(gid); + } + + if let Some(kw) = keyword { + let pattern = format!("%{}%", kw); + q = q.bind(pattern); + } + + q = q.bind(limit).bind(offset); + + let recs = q.fetch_all(pool).await?; + Ok(recs) +} + +/// 统计回收站环境总数 +pub async fn fetch_deleted_environments_count( + pool: &Pool, + workspace_uuid: Uuid, + team_uuid: Uuid, + group_uuid: Option, + keyword: Option<&str>, +) -> Result { + let mut query = String::from( + r#" + SELECT COUNT(DISTINCT e.id) + FROM environments e + WHERE e.workspace_uuid = $1 + AND e.team_uuid = $2 + AND e.deleted_at IS NOT NULL + "#, + ); + + let mut param_index = 3; + let mut conditions = Vec::new(); + + if group_uuid.is_some() { + conditions.push(format!("e.group_uuid = ${}", param_index)); + param_index += 1; + } + + if keyword.is_some() { + conditions.push(format!("(e.name ILIKE ${} OR e.uuid::text ILIKE ${})", param_index, param_index)); + param_index += 1; + } + + if !conditions.is_empty() { + query.push_str(" AND "); + query.push_str(&conditions.join(" AND ")); + } + + let mut q = sqlx::query_scalar::<_, i64>(&query) + .bind(workspace_uuid) + .bind(team_uuid); + + if let Some(gid) = group_uuid { + q = q.bind(gid); + } + + if let Some(kw) = keyword { + let pattern = format!("%{}%", kw); + q = q.bind(pattern); + } + + let count = q.fetch_one(pool).await?; + Ok(count) +} + +/// 恢复环境(将 deleted_at 设为 NULL) +pub async fn restore_environment(pool: &Pool, env_uuid: Uuid) -> Result<(), Error> { + sqlx::query( + r#" + UPDATE environments SET deleted_at = NULL + WHERE uuid = $1 AND deleted_at IS NOT NULL + "#, + ) + .bind(env_uuid) + .execute(pool) + .await?; + + Ok(()) +} + +/// 批量恢复环境 +pub async fn batch_restore_environments( + pool: &Pool, + env_uuids: &[Uuid], +) -> Result { + let result = sqlx::query( + r#" + UPDATE environments SET deleted_at = NULL + WHERE uuid = ANY($1) AND deleted_at IS NOT NULL + "#, + ) + .bind(env_uuids) + .execute(pool) + .await?; + + Ok(result.rows_affected()) +} + +/// 永久删除环境(真正的 DELETE) +pub async fn permanent_delete_environment(pool: &Pool, env_uuid: Uuid) -> Result<(), Error> { + // 先删除关联数据 + sqlx::query("DELETE FROM environment_tags WHERE environment_uuid = $1") + .bind(env_uuid) + .execute(pool) + .await?; + + sqlx::query("DELETE FROM environment_urls WHERE environment_uuid = $1") + .bind(env_uuid) + .execute(pool) + .await?; + + sqlx::query("DELETE FROM environment_cookies WHERE environment_uuid = $1") + .bind(env_uuid) + .execute(pool) + .await?; + + sqlx::query("DELETE FROM environment_configs WHERE environment_uuid = $1") + .bind(env_uuid) + .execute(pool) + .await?; + + sqlx::query("DELETE FROM environment_accounts WHERE environment_uuid = $1") + .bind(env_uuid) + .execute(pool) + .await?; + + sqlx::query("DELETE FROM environment_extensions WHERE environment_uuid = $1") + .bind(env_uuid) + .execute(pool) + .await?; + + // 最后删除环境本身 + sqlx::query("DELETE FROM environments WHERE uuid = $1") + .bind(env_uuid) + .execute(pool) + .await?; + + Ok(()) +} + +/// 批量永久删除环境 +pub async fn batch_permanent_delete_environments( + pool: &Pool, + env_uuids: &[Uuid], +) -> Result { + // 先删除关联数据 + sqlx::query("DELETE FROM environment_tags WHERE environment_uuid = ANY($1)") + .bind(env_uuids) + .execute(pool) + .await?; + + sqlx::query("DELETE FROM environment_urls WHERE environment_uuid = ANY($1)") + .bind(env_uuids) + .execute(pool) + .await?; + + sqlx::query("DELETE FROM environment_cookies WHERE environment_uuid = ANY($1)") + .bind(env_uuids) + .execute(pool) + .await?; + + sqlx::query("DELETE FROM environment_configs WHERE environment_uuid = ANY($1)") + .bind(env_uuids) + .execute(pool) + .await?; + + sqlx::query("DELETE FROM environment_accounts WHERE environment_uuid = ANY($1)") + .bind(env_uuids) + .execute(pool) + .await?; + + sqlx::query("DELETE FROM environment_extensions WHERE environment_uuid = ANY($1)") + .bind(env_uuids) + .execute(pool) + .await?; + + // 最后删除环境本身 + let result = sqlx::query("DELETE FROM environments WHERE uuid = ANY($1)") + .bind(env_uuids) + .execute(pool) + .await?; + + Ok(result.rows_affected()) +} + // ============ Environment Configs ============ /// 创建或更新环境配置 diff --git a/src/routes/environments.rs b/src/routes/environments.rs index f86c10b..6345b4a 100644 --- a/src/routes/environments.rs +++ b/src/routes/environments.rs @@ -3,14 +3,17 @@ use axum::routing::post; use crate::handlers::{ add_environment_cookie_handler, add_environment_url_handler, assign_tags_handler, batch_assign_tags_handler, batch_create_environments_handler, - batch_delete_environments_handler, batch_move_to_group_handler, batch_remove_tags_handler, - clear_environment_cookies_handler, clear_environment_urls_handler, create_environment_handler, - create_group_handler, create_tag_handler, delete_environment_cookie_handler, - delete_environment_handler, delete_environment_url_handler, delete_group_handler, - delete_tag_handler, get_environment_cookies_handler, get_environment_handler, - get_environment_urls_handler, get_environments_handler, get_groups_handler, get_tags_handler, - move_to_group_handler, remove_tag_handler, set_environment_accounts_handler, set_proxy_handler, - update_environment_handler, update_group_handler, update_tag_handler, + batch_delete_environments_handler, batch_move_to_group_handler, + batch_permanent_delete_environments_handler, batch_remove_tags_handler, + batch_restore_environments_handler, clear_environment_cookies_handler, + clear_environment_urls_handler, create_environment_handler, create_group_handler, + create_tag_handler, delete_environment_cookie_handler, delete_environment_handler, + delete_environment_url_handler, delete_group_handler, delete_tag_handler, + get_environment_cookies_handler, get_environment_handler, get_environment_urls_handler, + get_environments_handler, get_groups_handler, get_recycle_bin_environments_handler, + get_tags_handler, move_to_group_handler, permanent_delete_environment_handler, + remove_tag_handler, restore_environment_handler, set_environment_accounts_handler, + set_proxy_handler, update_environment_handler, update_group_handler, update_tag_handler, }; use crate::routes::route::{self, MetaRoute}; @@ -137,5 +140,28 @@ pub fn register_routes(meta_route: &mut MetaRoute) { "/cookies/clear", post(clear_environment_cookies_handler), )); + + // 回收站路由(作为环境路由的子路由) + env_route.add_route_item(route::RouteItem::post( + "/recycle-bin/list", + post(get_recycle_bin_environments_handler), + )); + env_route.add_route_item(route::RouteItem::post( + "/recycle-bin/restore", + post(restore_environment_handler), + )); + env_route.add_route_item(route::RouteItem::post( + "/recycle-bin/batch-restore", + post(batch_restore_environments_handler), + )); + env_route.add_route_item(route::RouteItem::post( + "/recycle-bin/permanent-delete", + post(permanent_delete_environment_handler), + )); + env_route.add_route_item(route::RouteItem::post( + "/recycle-bin/batch-permanent-delete", + post(batch_permanent_delete_environments_handler), + )); + meta_route.add_route_group(env_route); } diff --git a/src/services/environments.rs b/src/services/environments.rs index befbbc7..60b34f1 100644 --- a/src/services/environments.rs +++ b/src/services/environments.rs @@ -875,6 +875,239 @@ pub async fn batch_delete_environments_service( .map_err(|e| e.to_string()) } +// ============ Recycle Bin ============ + +/// 查询回收站环境列表 +pub async fn get_recycle_bin_environments_service( + svc_ctx: &SvcCtx, + workspace_uuid: Uuid, + team_uuid: Uuid, + user_uuid: Uuid, + payload: &ListEnvironmentsRequest, +) -> Result<(Vec, i64), String> { + // 1. 提取过滤参数 + let group_uuid = payload.filters.as_ref().and_then(|f| f.group_uuid); + let keyword = payload.filters.as_ref().and_then(|f| f.keyword.as_deref()); + + // 2. 计算分页参数 + let page = payload.pagination.page; + let page_size = payload.pagination.page_size; + let offset = (page - 1) * page_size; + let limit = page_size; + + // 3. 查询回收站环境总数 + let total_count = models::fetch_deleted_environments_count( + &svc_ctx.db, + workspace_uuid, + team_uuid, + group_uuid, + keyword, + ) + .await + .map_err(|e| e.to_string())?; + + // 4. 查询回收站环境基础列表 + let env_rows = models::fetch_deleted_environments_base( + &svc_ctx.db, + workspace_uuid, + team_uuid, + group_uuid, + keyword, + offset, + limit, + ) + .await + .map_err(|e| e.to_string())?; + + if env_rows.is_empty() { + return Ok((vec![], total_count)); + } + + // 5. 获取所有环境的 UUID + let env_uuids: Vec = env_rows.iter().map(|e| e.uuid).collect(); + let group_uuids: Vec = env_rows.iter().filter_map(|e| e.group_uuid).collect(); + let proxy_uuids: Vec = env_rows.iter().filter_map(|e| e.proxy_uuid).collect(); + + // 6. 批量查询分组信息 + let group_rows = if !group_uuids.is_empty() { + models::fetch_groups_by_uuids(&svc_ctx.db, &group_uuids) + .await + .map_err(|e| e.to_string())? + } else { + vec![] + }; + let group_map: HashMap = group_rows + .into_iter() + .map(|g| (g.uuid, GroupSummaryDto { + id: g.id, + uuid: g.uuid, + name: g.name, + description: g.description, + sort_order: g.sort_order, + })) + .collect(); + + // 7. 批量查询代理信息 + let proxy_rows = if !proxy_uuids.is_empty() { + models::fetch_proxies_by_uuids(&svc_ctx.db, &proxy_uuids) + .await + .map_err(|e| e.to_string())? + } else { + vec![] + }; + let proxy_map: HashMap = proxy_rows + .into_iter() + .map(|p| (p.uuid, ProxySummaryDto { + id: p.id, + uuid: p.uuid, + name: p.name, + host: p.host, + port: p.port, + proxy_type: p.proxy_type, + country: p.country, + city: p.city, + status: p.status, + latency: p.latency, + last_check_ip: p.last_check_ip, + })) + .collect(); + + // 8. 批量查询标签信息 + let tag_rows = if !env_uuids.is_empty() { + models::fetch_tags_for_environments(&svc_ctx.db, &env_uuids) + .await + .map_err(|e| e.to_string())? + } else { + vec![] + }; + let mut env_tags_map: HashMap> = HashMap::new(); + for tag_row in tag_rows { + env_tags_map.entry(tag_row.environment_uuid).or_default().push(TagDto { + id: tag_row.tag_id, + uuid: tag_row.tag_uuid, + user_uuid: tag_row.tag_user_uuid, + team_uuid: tag_row.tag_team_uuid, + name: tag_row.tag_name, + color: tag_row.tag_color, + sort_order: tag_row.tag_sort_order, + environments_count: tag_row.tag_environments_count, + created_at: tag_row.tag_created_at, + updated_at: tag_row.tag_updated_at, + deleted_at: tag_row.tag_deleted_at, + }); + } + + // 9. 批量查询账号信息 + let mut env_accounts_map: HashMap> = HashMap::new(); + for env_uuid in &env_uuids { + let accounts = accounts::get_environment_accounts_service(svc_ctx, *env_uuid) + .await + .unwrap_or_default(); + if !accounts.is_empty() { + env_accounts_map.insert(*env_uuid, accounts); + } + } + + // 10. 组装完整的环境信息 + let environments: Vec = env_rows + .into_iter() + .map(|row| { + let group = row.group_uuid.and_then(|gid| group_map.get(&gid).cloned()); + let proxy = row.proxy_uuid.and_then(|pid| proxy_map.get(&pid).cloned()); + let tags = env_tags_map.get(&row.uuid).cloned().unwrap_or_default(); + let accounts = env_accounts_map.get(&row.uuid).cloned().unwrap_or_default(); + + crate::entitys::EnvironmentDetailResponse { + environment: EnvironmentDto { + id: row.id, + uuid: row.uuid, + workspace_uuid: row.workspace_uuid, + user_uuid: row.user_uuid, + team_uuid: row.team_uuid, + name: row.name, + description: row.description, + status: row.status, + group_uuid: row.group_uuid, + proxy_uuid: row.proxy_uuid, + system_info: row.system_info, + kernel_info: row.kernel_info, + fingerprint_summary: row.fingerprint_summary, + last_opened_at: row.last_opened_at, + created_at: row.created_at, + updated_at: row.updated_at, + deleted_at: None, + }, + config: None, + tags, + accounts, + group, + proxy, + } + }) + .collect(); + + Ok((environments, total_count)) +} + +/// 恢复环境 +pub async fn restore_environment_service( + svc_ctx: &SvcCtx, + env_uuid: Uuid, +) -> Result<(), String> { + models::restore_environment(&svc_ctx.db, env_uuid) + .await + .map_err(|e| e.to_string()) +} + +/// 批量恢复环境 +pub async fn batch_restore_environments_service( + svc_ctx: &SvcCtx, + env_uuids: &[Uuid], +) -> Result { + models::batch_restore_environments(&svc_ctx.db, env_uuids) + .await + .map_err(|e| e.to_string()) +} + +/// 永久删除环境 +pub async fn permanent_delete_environment_service( + svc_ctx: &SvcCtx, + env_uuid: Uuid, + workspace_uuid: Uuid, +) -> Result<(), String> { + // 永久删除环境 + models::permanent_delete_environment(&svc_ctx.db, env_uuid) + .await + .map_err(|e| e.to_string())?; + + // 更新工作空间配额(永久删除后减少使用数) + models::decrement_used_environments(&svc_ctx.db, workspace_uuid, 1) + .await + .map_err(|e| format!("更新配额失败: {}", e))?; + + Ok(()) +} + +/// 批量永久删除环境 +pub async fn batch_permanent_delete_environments_service( + svc_ctx: &SvcCtx, + env_uuids: &[Uuid], + workspace_uuid: Uuid, +) -> Result { + let count = models::batch_permanent_delete_environments(&svc_ctx.db, env_uuids) + .await + .map_err(|e| e.to_string())?; + + // 更新工作空间配额 + if count > 0 { + models::decrement_used_environments(&svc_ctx.db, workspace_uuid, count as i32) + .await + .map_err(|e| format!("更新配额失败: {}", e))?; + } + + Ok(count) +} + // ============ Environment URLs ============ /// 添加环境 URL From 2e8ae03656e988b72f05773ca2a3cd2634f62dab Mon Sep 17 00:00:00 2001 From: lius-new Date: Wed, 25 Feb 2026 18:35:11 +0800 Subject: [PATCH 13/60] =?UTF-8?q?feat:=20=E6=B7=BB=E5=8A=A0=E6=89=B9?= =?UTF-8?q?=E9=87=8F=E8=8E=B7=E5=8F=96=E7=8E=AF=E5=A2=83=E8=AF=A6=E6=83=85?= =?UTF-8?q?=E6=8E=A5=E5=8F=A3?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit - 实现 batch_get_environments_handler 接口 - 支持一次请求获取多个环境的完整详情 - 返回 HashMap 格式 - 对查询失败或不存在的环境自动跳过,不影响其他环境 - 注册路由 POST /environments/batch-detail - 优化前端批量启动性能,减少 API 调用次数 Co-Authored-By: Claude Opus 4.6 (1M context) --- configs/config.dev.toml | 4 +- ...3000001_add_updated_at_to_user_coupons.sql | 9 +++ src/entitys/workspace_quotas.rs | 3 +- src/handlers/billing.rs | 4 +- src/handlers/environments.rs | 74 +++++++++++++++++++ src/handlers/workspace_quotas.rs | 14 +++- src/routes/environments.rs | 15 ++-- src/services/workspace_quotas.rs | 6 +- 8 files changed, 117 insertions(+), 12 deletions(-) create mode 100644 migrations/20260213000001_add_updated_at_to_user_coupons.sql diff --git a/configs/config.dev.toml b/configs/config.dev.toml index f73a91a..a0fdba8 100644 --- a/configs/config.dev.toml +++ b/configs/config.dev.toml @@ -19,7 +19,7 @@ referral_link_prefix = "https://www.simprint.cc/download" [database] -url = "postgres://lius:URxywfBRZZ1xpWcyPRbG@111.230.19.75:5432/simprintdb" +url = "postgres://lius:URxywfBRZZ1xpWcyPRbG@192.168.224.129:5432/simprintdb" max_connections = 25 min_connections = 5 max_lifetime = 3000 @@ -27,7 +27,7 @@ acquire_timeout = 30 idle_timeout = 600 [redis] -url = "redis://111.230.19.75:6381?protocol=resp3" +url = "redis://192.168.224.129:6381?protocol=resp3" [minio] server_url = "http://111.230.19.75:9000" diff --git a/migrations/20260213000001_add_updated_at_to_user_coupons.sql b/migrations/20260213000001_add_updated_at_to_user_coupons.sql new file mode 100644 index 0000000..a97141c --- /dev/null +++ b/migrations/20260213000001_add_updated_at_to_user_coupons.sql @@ -0,0 +1,9 @@ +-- 为 user_coupons 表添加 updated_at 字段 +-- 修复订阅功能中缺少 updated_at 字段的问题 + +ALTER TABLE user_coupons +ADD COLUMN updated_at TIMESTAMP WITH TIME ZONE NOT NULL DEFAULT CURRENT_TIMESTAMP; + +-- 创建更新时间触发器 +CREATE TRIGGER update_user_coupons_updated_at BEFORE UPDATE ON user_coupons + FOR EACH ROW EXECUTE FUNCTION update_updated_at_column(); diff --git a/src/entitys/workspace_quotas.rs b/src/entitys/workspace_quotas.rs index 5bc6606..2346bf8 100644 --- a/src/entitys/workspace_quotas.rs +++ b/src/entitys/workspace_quotas.rs @@ -4,7 +4,8 @@ use uuid::Uuid; /// 获取工作空间配额请求 #[derive(Debug, Clone, Deserialize, Serialize)] pub struct GetWorkspaceQuotaRequest { - pub workspace_uuid: Uuid, + #[serde(skip_serializing_if = "Option::is_none")] + pub workspace_uuid: Option, } /// 更新配额使用情况请求 diff --git a/src/handlers/billing.rs b/src/handlers/billing.rs index 12ff98d..4e75f16 100644 --- a/src/handlers/billing.rs +++ b/src/handlers/billing.rs @@ -233,7 +233,9 @@ pub async fn get_quota_handler( let quota = services::workspace_quotas::get_workspace_quota_service( &svc_ctx, - &crate::entitys::GetWorkspaceQuotaRequest { workspace_uuid }, + &crate::entitys::GetWorkspaceQuotaRequest { + workspace_uuid: Some(workspace_uuid), + }, ) .await .map_err(|e| Response::fail(Some(&e)))?; diff --git a/src/handlers/environments.rs b/src/handlers/environments.rs index 1d3d368..1b149af 100644 --- a/src/handlers/environments.rs +++ b/src/handlers/environments.rs @@ -362,6 +362,80 @@ pub async fn get_environment_handler( )) } +/// 批量获取环境详情 +pub async fn batch_get_environments_handler( + State(svc_ctx): State, + Extension(ctx): Extension, + Json(payload): Json, +) -> Result> { + let workspace_uuid = ctx + .current_workspace_uuid + .ok_or_else(|| Response::fail(Some("请先选择工作空间")))?; + let team_uuid = ctx.current_team_uuid.ok_or_else(|| Response::fail(Some("请先选择团队")))?; + + let mut results = std::collections::HashMap::new(); + + for uuid in payload.uuids { + // 对每个 UUID 尝试获取环境详情,失败则跳过 + let detail = async { + let environment = services::environments::get_environment_service( + &svc_ctx, + workspace_uuid, + team_uuid, + ctx.user_uuid_unwrap(), + uuid, + ) + .await + .ok()?; + + let config = services::environments::get_environment_config_service(&svc_ctx, uuid) + .await + .ok(); + + let tags = services::environments::get_environment_tags_service(&svc_ctx, uuid) + .await + .ok()?; + + let accounts = services::accounts::get_environment_accounts_service(&svc_ctx, uuid) + .await + .unwrap_or_default(); + + let group = if let Some(group_uuid) = environment.group_uuid { + services::environments::get_group_summary_service(&svc_ctx, group_uuid) + .await + .ok() + } else { + None + }; + + let proxy = if let Some(proxy_uuid) = environment.proxy_uuid { + services::environments::get_proxy_summary_service(&svc_ctx, proxy_uuid) + .await + .ok() + } else { + None + }; + + Some(EnvironmentDetailResponse { + environment, + config, + tags, + accounts, + group, + proxy, + }) + } + .await; + + // 只有成功获取的环境才加入结果 + if let Some(detail) = detail { + results.insert(uuid.to_string(), detail); + } + } + + Ok(Response::success(Some("获取成功"), Some(results))) +} + /// 创建环境 pub async fn create_environment_handler( State(svc_ctx): State, diff --git a/src/handlers/workspace_quotas.rs b/src/handlers/workspace_quotas.rs index 5dd766b..9f79583 100644 --- a/src/handlers/workspace_quotas.rs +++ b/src/handlers/workspace_quotas.rs @@ -9,9 +9,19 @@ use crate::utils::{Json, Response, Result}; /// 获取工作空间配额 pub async fn get_workspace_quota_handler( State(svc_ctx): State, - Extension(_ctx): Extension, - Json(payload): Json, + Extension(ctx): Extension, + Json(mut payload): Json, ) -> Result { + // 如果没有传递 workspace_uuid,则从 RequestContext 中获取 + if payload.workspace_uuid.is_none() { + payload.workspace_uuid = ctx.current_workspace_uuid; + } + + // 验证 workspace_uuid 是否存在 + if payload.workspace_uuid.is_none() { + return Err(Response::fail(Some("未提供工作空间 UUID"))); + } + let quota = services::workspace_quotas::get_workspace_quota_service(&svc_ctx, &payload) .await .map_err(|e| Response::fail(Some(&e)))?; diff --git a/src/routes/environments.rs b/src/routes/environments.rs index 6345b4a..45c5e4c 100644 --- a/src/routes/environments.rs +++ b/src/routes/environments.rs @@ -3,11 +3,12 @@ use axum::routing::post; use crate::handlers::{ add_environment_cookie_handler, add_environment_url_handler, assign_tags_handler, batch_assign_tags_handler, batch_create_environments_handler, - batch_delete_environments_handler, batch_move_to_group_handler, - batch_permanent_delete_environments_handler, batch_remove_tags_handler, - batch_restore_environments_handler, clear_environment_cookies_handler, - clear_environment_urls_handler, create_environment_handler, create_group_handler, - create_tag_handler, delete_environment_cookie_handler, delete_environment_handler, + batch_delete_environments_handler, batch_get_environments_handler, + batch_move_to_group_handler, batch_permanent_delete_environments_handler, + batch_remove_tags_handler, batch_restore_environments_handler, + clear_environment_cookies_handler, clear_environment_urls_handler, + create_environment_handler, create_group_handler, create_tag_handler, + delete_environment_cookie_handler, delete_environment_handler, delete_environment_url_handler, delete_group_handler, delete_tag_handler, get_environment_cookies_handler, get_environment_handler, get_environment_urls_handler, get_environments_handler, get_groups_handler, get_recycle_bin_environments_handler, @@ -54,6 +55,10 @@ pub fn register_routes(meta_route: &mut MetaRoute) { "/detail", post(get_environment_handler), )); + env_route.add_route_item(route::RouteItem::post( + "/batch-detail", + post(batch_get_environments_handler), + )); env_route.add_route_item(route::RouteItem::post( "/create", post(create_environment_handler), diff --git a/src/services/workspace_quotas.rs b/src/services/workspace_quotas.rs index 6411a75..7fb0b85 100644 --- a/src/services/workspace_quotas.rs +++ b/src/services/workspace_quotas.rs @@ -10,7 +10,11 @@ pub async fn get_workspace_quota_service( svc_ctx: &SvcCtx, payload: &GetWorkspaceQuotaRequest, ) -> Result { - models::fetch_workspace_quota(&svc_ctx.db, payload.workspace_uuid) + let workspace_uuid = payload + .workspace_uuid + .ok_or_else(|| "工作空间 UUID 不能为空".to_string())?; + + models::fetch_workspace_quota(&svc_ctx.db, workspace_uuid) .await .map_err(|e| e.to_string())? .ok_or_else(|| "配额不存在".to_string()) From 422976d53024e093b788999ba8631c6c0fa89f55 Mon Sep 17 00:00:00 2001 From: lius-new Date: Fri, 6 Mar 2026 12:43:47 +0800 Subject: [PATCH 14/60] =?UTF-8?q?fix(teams):=20=E4=BF=AE=E5=A4=8D=E5=9B=A2?= =?UTF-8?q?=E9=98=9F=E9=82=80=E8=AF=B7=E5=92=8C=E6=8E=A5=E5=8F=97=E9=82=80?= =?UTF-8?q?=E8=AF=B7=E7=9A=84=E5=B7=A5=E4=BD=9C=E7=A9=BA=E9=97=B4=E9=80=BB?= =?UTF-8?q?=E8=BE=91?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit - 修复 ON CONFLICT 约束匹配问题,使用正确的唯一约束 (team_uuid, user_uuid, workspace_uuid) - 修改接受邀请逻辑:使用被邀请用户的工作空间而非团队所属工作空间 - 添加 fetch_user_current_workspace 函数获取用户当前工作空间 - 添加邀请用户存在性检测:只允许邀请已注册用户 - 简化邀请流程:移除不必要的 Option 判断 Co-Authored-By: Claude Opus 4.6 (1M context) --- src/models/teams.rs | 8 ++-- src/models/user.rs | 17 ++++++++ src/services/teams.rs | 90 +++++++++++++++++++++---------------------- 3 files changed, 66 insertions(+), 49 deletions(-) diff --git a/src/models/teams.rs b/src/models/teams.rs index f43ed4f..9b9ce29 100644 --- a/src/models/teams.rs +++ b/src/models/teams.rs @@ -661,6 +661,7 @@ pub async fn accept_team_invitation( pool: &Pool, invitation_uuid: Uuid, user_uuid: Uuid, + workspace_uuid: Uuid, ) -> Result { let mut tx = pool.begin().await?; @@ -692,9 +693,9 @@ pub async fn accept_team_invitation( // 4. 添加成员(如果已存在则更新状态为 active) sqlx::query( r#" - INSERT INTO team_members (team_uuid, user_uuid, role, invited_by, status) - VALUES ($1, $2, $3, $4, 'active') - ON CONFLICT (team_uuid, user_uuid) DO UPDATE SET + INSERT INTO team_members (team_uuid, workspace_uuid, user_uuid, role, invited_by, status) + VALUES ($1, $2, $3, $4, $5, 'active') + ON CONFLICT (team_uuid, user_uuid, workspace_uuid) DO UPDATE SET role = EXCLUDED.role, invited_by = EXCLUDED.invited_by, status = 'active', @@ -703,6 +704,7 @@ pub async fn accept_team_invitation( "#, ) .bind(invitation.team_uuid) + .bind(workspace_uuid) .bind(user_uuid) .bind(&invitation.role) .bind(invitation.invited_by) diff --git a/src/models/user.rs b/src/models/user.rs index 77348a2..e60b65f 100644 --- a/src/models/user.rs +++ b/src/models/user.rs @@ -401,6 +401,23 @@ pub async fn update_password( } /// 设置用户当前工作空间 +pub async fn fetch_user_current_workspace( + pool: &Pool, + user_uuid: Uuid, +) -> Result, Error> { + let rec: Option = sqlx::query_scalar( + r#" + SELECT current_workspace_uuid FROM user_infos + WHERE user_uuid = $1 + "#, + ) + .bind(user_uuid) + .fetch_optional(pool) + .await?; + + Ok(rec) +} + pub async fn set_user_current_workspace( pool: &Pool, user_uuid: Uuid, diff --git a/src/services/teams.rs b/src/services/teams.rs index 7d837bd..0859ef1 100644 --- a/src/services/teams.rs +++ b/src/services/teams.rs @@ -161,14 +161,16 @@ pub async fn invite_member_service( .await .map_err(|e| e.to_string())?; - if let Some(ref user_info) = invited_user_info { - if models::fetch_team_member(&svc_ctx.db, workspace_uuid, team_uuid, user_info.user_uuid) - .await - .map_err(|e| e.to_string())? - .is_some() - { - return Err("该用户已是团队成员".to_string()); - } + // 检查用户是否存在 + let user_info = invited_user_info.ok_or_else(|| "该邮箱未注册,请先注册账号".to_string())?; + + // 检查用户是否已是团队成员 + if models::fetch_team_member(&svc_ctx.db, workspace_uuid, team_uuid, user_info.user_uuid) + .await + .map_err(|e| e.to_string())? + .is_some() + { + return Err("该用户已是团队成员".to_string()); } // 获取团队信息 @@ -224,35 +226,33 @@ pub async fn invite_member_service( ); // 2. 如果用户已注册,发送团队邀请消息通知 - if let Some(ref user_info) = invited_user_info { - let _ = crate::services::messages::create_message_service( - svc_ctx, - Some(inviter_uuid), - &crate::entitys::CreateMessageRequest { - message_type: "team_invitation".to_string(), - title: format!("{} 邀请您加入团队 {}", inviter_name, team.name), - content: Some(format!( - "{}({})邀请您加入团队 {},角色:{}", - inviter_name, inviter_email, team.name, payload.role - )), - recipient_uuids: vec![user_info.user_uuid], - recipient_type: "single".to_string(), - related_type: Some("team".to_string()), - related_uuid: Some(team_uuid), - priority: Some("normal".to_string()), - metadata: Some(serde_json::json!({ - "invitation_uuid": invitation_uuid.to_string(), - "team_name": team.name, - "role": payload.role, - "inviter_name": inviter_name, - "inviter_email": inviter_email, - "token": token, - })), - }, - ) - .await; - // 消息创建失败不影响邀请流程,使用 _ 忽略错误 - } + let _ = crate::services::messages::create_message_service( + svc_ctx, + Some(inviter_uuid), + &crate::entitys::CreateMessageRequest { + message_type: "team_invitation".to_string(), + title: format!("{} 邀请您加入团队 {}", inviter_name, team.name), + content: Some(format!( + "{}({})邀请您加入团队 {},角色:{}", + inviter_name, inviter_email, team.name, payload.role + )), + recipient_uuids: vec![user_info.user_uuid], + recipient_type: "single".to_string(), + related_type: Some("team".to_string()), + related_uuid: Some(team_uuid), + priority: Some("normal".to_string()), + metadata: Some(serde_json::json!({ + "invitation_uuid": invitation_uuid.to_string(), + "team_name": team.name, + "role": payload.role, + "inviter_name": inviter_name, + "inviter_email": inviter_email, + "token": token, + })), + }, + ) + .await; + // 消息创建失败不影响邀请流程,使用 _ 忽略错误 Ok(invitation_uuid) } @@ -279,18 +279,16 @@ pub async fn accept_invitation_service( .map_err(|e| e.to_string())? .ok_or_else(|| "邀请不存在或已过期".to_string())?; - // 获取团队信息(用于获取 workspace_uuid) - let team = models::fetch_team_by_uuid(&svc_ctx.db, invitation.team_uuid) + // 获取被邀请用户的当前工作空间 + let user_workspace_uuid = crate::models::user::fetch_user_current_workspace(&svc_ctx.db, user_uuid) .await .map_err(|e| e.to_string())? - .ok_or_else(|| "团队不存在".to_string())?; - - let workspace_uuid = team.workspace_uuid; + .ok_or_else(|| "请先选择工作空间".to_string())?; // 检查用户是否已经是该工作空间的团队成员(如果是,则不需要检查配额) let existing_member = models::fetch_team_member( &svc_ctx.db, - workspace_uuid, + user_workspace_uuid, invitation.team_uuid, user_uuid, ) @@ -300,7 +298,7 @@ pub async fn accept_invitation_service( // 如果用户还不是该工作空间的任何团队的成员,需要检查配额 if existing_member.is_none() { // 检查工作空间成员配额是否充足 - let quota_available = models::check_quota(&svc_ctx.db, workspace_uuid, "team_members") + let quota_available = models::check_quota(&svc_ctx.db, user_workspace_uuid, "team_members") .await .map_err(|e| e.to_string())?; if !quota_available { @@ -309,14 +307,14 @@ pub async fn accept_invitation_service( } // 使用 accept_team_invitation 函数处理整个流程 - let team_uuid = models::accept_team_invitation(&svc_ctx.db, invitation.uuid, user_uuid) + let team_uuid = models::accept_team_invitation(&svc_ctx.db, invitation.uuid, user_uuid, user_workspace_uuid) .await .map_err(|e| e.to_string())?; // 更新成员配额(重新计算所有团队的活跃成员总数) // 注意:只有在用户之前不是该工作空间的成员时才需要更新配额 if existing_member.is_none() { - models::update_used_team_members(&svc_ctx.db, workspace_uuid) + models::update_used_team_members(&svc_ctx.db, user_workspace_uuid) .await .map_err(|e| format!("更新配额失败: {}", e))?; } From ce3066f155fa5e437356b73516c6ef8d396d50e3 Mon Sep 17 00:00:00 2001 From: lius-new Date: Fri, 6 Mar 2026 14:22:35 +0800 Subject: [PATCH 15/60] =?UTF-8?q?fix(migrations):=20=E4=BF=AE=E6=AD=A3?= =?UTF-8?q?=E6=89=A9=E5=B1=95=E8=A1=A8=E7=8A=B6=E6=80=81=E9=BB=98=E8=AE=A4?= =?UTF-8?q?=E5=80=BC=E5=92=8C=E6=B3=A8=E9=87=8A?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit 将扩展相关表的 status 字段默认值从 'installed' 改为 'active', 注释从 'installed, disabled' 改为 'active, inactive', 以对齐代码实现中使用的状态值。 影响的表: - user_extensions - team_extensions - group_extensions - environment_extensions Co-Authored-By: Claude Opus 4.6 (1M context) --- ...06000001_fix_extensions_status_comments.sql | 18 ++++++++++++++++++ 1 file changed, 18 insertions(+) create mode 100644 migrations/20260306000001_fix_extensions_status_comments.sql diff --git a/migrations/20260306000001_fix_extensions_status_comments.sql b/migrations/20260306000001_fix_extensions_status_comments.sql new file mode 100644 index 0000000..c46f477 --- /dev/null +++ b/migrations/20260306000001_fix_extensions_status_comments.sql @@ -0,0 +1,18 @@ +-- 修正扩展表的状态默认值和注释 +-- 将状态从 "installed, disabled" 改为 "active, inactive" 以对齐代码实现 + +-- user_extensions 表 +ALTER TABLE user_extensions ALTER COLUMN status SET DEFAULT 'active'; +COMMENT ON COLUMN user_extensions.status IS '状态: active, inactive'; + +-- team_extensions 表 +ALTER TABLE team_extensions ALTER COLUMN status SET DEFAULT 'active'; +COMMENT ON COLUMN team_extensions.status IS '状态: active, inactive'; + +-- group_extensions 表 +ALTER TABLE group_extensions ALTER COLUMN status SET DEFAULT 'active'; +COMMENT ON COLUMN group_extensions.status IS '状态: active, inactive'; + +-- environment_extensions 表 +ALTER TABLE environment_extensions ALTER COLUMN status SET DEFAULT 'active'; +COMMENT ON COLUMN environment_extensions.status IS '状态: active, inactive'; From 2945de99f394c4b0e8810d9ba8248353e1bf1b03 Mon Sep 17 00:00:00 2001 From: lius-new Date: Fri, 6 Mar 2026 17:27:57 +0800 Subject: [PATCH 16/60] =?UTF-8?q?feat(extensions):=20=E5=AE=9E=E7=8E=B0?= =?UTF-8?q?=E7=94=A8=E6=88=B7=E7=BA=A7=E5=88=AB=E7=A6=81=E7=94=A8=E5=9B=A2?= =?UTF-8?q?=E9=98=9F=E6=8F=92=E4=BB=B6=E5=8A=9F=E8=83=BD?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit ## 功能说明 - 普通用户可以禁用团队安装的插件(无需卸载权限) - 禁用后插件不会在浏览器中加载,但可随时重新启用 - 禁用状态仅对当前用户生效,不影响其他团队成员 ## 数据库变更 - 新增 user_team_extension_preferences 表存储用户对团队插件的偏好设置 - 添加 is_disabled 字段标识禁用状态 ## 后端变更 - 新增 DTO: UserTeamExtensionPreferenceDto - 新增 Model 函数: - fetch_user_disabled_team_extensions: 查询用户禁用的团队插件列表 - set_user_team_extension_preference: 设置禁用状态 - delete_user_team_extension_preference: 删除禁用设置 - fetch_team_shared_group_uuids_by_extension_id: 查询团队共享的分组UUID - 修改 Service: - disable_extension_service: 使用新表存储禁用状态 - enable_extension_service: 删除禁用设置 - get_team_installed_extensions_service: 查询并标记用户禁用的插件 - 修改 Handler: - disable_extension_handler: 获取team_uuid并传递 - enable_extension_handler: 获取team_uuid并传递 ## 修复问题 - 修复 fetch_team_member 函数调用缺少 workspace_uuid 参数的编译错误 - 修复团队插件查询时错误显示个人分组的问题(只显示团队共享分组) Co-Authored-By: Claude Opus 4.6 (1M context) --- .claude/settings.local.json | 9 + docs/extension-system-refactor.md | 256 +++++++++ ...0306000002_drop_environment_extensions.sql | 12 + ...add_is_team_shared_to_group_extensions.sql | 11 + ...date_extension_status_support_disabled.sql | 7 + ...create_user_team_extension_preferences.sql | 23 + src/dto/environments.rs | 13 + src/dto/extensions.rs | 13 +- src/entitys/environments.rs | 1 + src/entitys/extensions.rs | 20 +- src/handlers/environments.rs | 2 + src/handlers/extensions.rs | 54 +- src/models/extensions.rs | 176 ++++--- src/routes/extensions.rs | 17 +- src/services/environments.rs | 37 +- src/services/extensions.rs | 495 +++++++++++++++++- 16 files changed, 1021 insertions(+), 125 deletions(-) create mode 100644 .claude/settings.local.json create mode 100644 docs/extension-system-refactor.md create mode 100644 migrations/20260306000002_drop_environment_extensions.sql create mode 100644 migrations/20260306000003_add_is_team_shared_to_group_extensions.sql create mode 100644 migrations/20260306000004_update_extension_status_support_disabled.sql create mode 100644 migrations/20260306000005_create_user_team_extension_preferences.sql diff --git a/.claude/settings.local.json b/.claude/settings.local.json new file mode 100644 index 0000000..15fde62 --- /dev/null +++ b/.claude/settings.local.json @@ -0,0 +1,9 @@ +{ + "permissions": { + "allow": [ + "Bash(grep:*)", + "Bash(ls:*)", + "Bash(cargo check:*)" + ] + } +} diff --git a/docs/extension-system-refactor.md b/docs/extension-system-refactor.md new file mode 100644 index 0000000..6267ecd --- /dev/null +++ b/docs/extension-system-refactor.md @@ -0,0 +1,256 @@ +# 插件系统重构方案 + +## 插件绑定的核心思路 + +### 两个关键维度 + +1. **安装范围**:决定插件作用于哪些环境 + - 全局:所有环境 + - 指定分组:特定分组的环境 + +2. **可见性**:决定插件是个人使用还是团队共享 + - 为团队安装(勾选):团队所有成员可用 + - 不勾选:仅当前用户可用 + +### 四种绑定关系 + +| 安装范围 | 为团队安装 | 绑定关系 | 效果 | +|---------|-----------|---------|------| +| 全局 | ✅ | 插件 ↔ 团队 | 团队所有成员的所有环境都有此插件 | +| 全局 | ❌ | 插件 ↔ 用户 | 仅当前用户的所有环境有此插件 | +| 指定分组 | ✅ | 插件 ↔ 分组(团队共享) | 团队所有成员在该分组下的环境都有此插件 | +| 指定分组 | ❌ | 插件 ↔ 分组(个人私有) | 仅当前用户在该分组下的环境有此插件 | + +### 关键设计原则 + +1. **环境不直接绑定插件** - 环境通过用户/团队/分组间接获得插件 +2. **动态合并** - 环境列表查询时动态合并多个层级的插件 +3. **优先级规则**(从高到低): + - 分组团队插件 + - 分组个人插件 + - 团队全局插件 + - 用户全局插件 + +### 环境获取插件的逻辑 + +当查询某个环境的插件列表时,需要合并: +1. 用户个人全局插件 +2. 团队全局插件 +3. 该环境所属分组的个人插件(如果有) +4. 该环境所属分组的团队插件(如果有) + +### 实际场景举例 + +**场景1:公司统一插件** +- 全局 + 为团队安装 ✅ +- 所有人所有环境都有 + +**场景2:个人常用插件** +- 全局 + 为团队安装 ❌ +- 只有自己所有环境有 + +**场景3:测试分组的团队插件** +- 指定分组(测试分组)+ 为团队安装 ✅ +- 所有人在测试分组的环境都有 + +**场景4:个人在某分组的特殊插件** +- 指定分组(工作分组)+ 为团队安装 ❌ +- 只有自己在工作分组的环境有 + +## 需要补充考虑的场景 + +### 1. 插件卸载场景 + +**问题:** +- 如果用户卸载了一个"团队全局插件",是只对自己生效还是影响整个团队? +- 如果用户卸载了"分组团队插件",其他团队成员还能用吗? + +**建议方案:** +- 团队插件只有所有者/管理员能卸载,卸载后影响所有人 +- 普通用户不能禁用团队插件 +- 个人插件可以直接卸载 + +### 2. 插件更新场景 + +**问题:** +- 团队插件更新到新版本,是否自动更新所有成员? +- 个人插件和团队插件版本冲突怎么办? + +**建议方案:** +- 团队插件更新后,所有成员自动使用新版本 +- 如果个人安装了同一插件的不同版本,个人版本优先 + +### 3. 权限管理场景 + +**问题:** +- 谁可以为团队安装插件? +- 谁可以为分组安装团队共享的插件? + +**建议方案:** +- 团队全局插件:只有所有者/管理员可以安装/卸载 +- 分组团队插件:拥有该分组 manage 权限的用户可以安装/卸载 +- 个人插件:所有用户都可以安装/卸载 + +### 4. 分组变更场景 + +**问题:** +- 环境从分组A移动到分组B,插件如何变化? +- 环境从有分组变为无分组,插件如何变化? + +**建议方案:** +- 环境移动分组后,插件列表自动更新(因为是动态查询的) +- 分组A的插件失效,分组B的插件生效 +- 全局插件始终生效 + +### 5. 用户离开团队场景 + +**问题:** +- 用户离开团队后,之前安装的"分组个人插件"怎么办? +- 用户的环境还能访问吗? + +**建议方案:** +- 用户离开团队,其环境也应该被删除或转移 +- 分组个人插件随环境一起处理 + +### 6. 插件冲突场景 + +**问题:** +- 同一个插件,团队安装了v1.0,个人安装了v2.0,用哪个? +- 分组团队安装了v1.0,分组个人安装了v2.0,用哪个? + +**建议方案:** +- 按优先级规则:分组团队 > 分组个人 > 团队全局 > 用户全局 +- 同一层级只能有一个版本 + +### 7. 插件商店的"已安装"标识 + +**问题:** +- 插件商店显示某个插件"已安装",但实际上可能是: + - 用户个人全局安装 + - 团队全局安装 + - 某个分组安装 + - 多个层级都安装了 + +**建议方案:** +- 显示详细的安装信息: + - "已安装(全局-团队)" + - "已安装(工作分组-个人)" + - "已安装(多处)" - 点击查看详情 + +### 8. 批量操作场景 + +**问题:** +- 能否批量为多个分组安装同一个插件? +- 能否批量卸载某个插件的所有安装? + +**建议方案:** +- 支持多选分组批量安装 +- 已安装列表支持批量卸载(但要区分权限) + +### 9. 插件依赖场景 + +**问题:** +- 插件A依赖插件B,如何处理? +- 卸载插件B时,插件A怎么办? + +**建议方案:** +- 安装时自动检查并提示安装依赖 +- 卸载时检查是否有其他插件依赖,给出警告 + +### 10. 环境启动时的插件加载 + +**问题:** +- 环境启动时,如何知道要加载哪些插件? +- 插件数据是实时查询还是缓存? + +**建议方案:** +- 环境列表接口返回每个环境的插件列表 +- 浏览器内核启动时根据这个列表加载插件 +- 插件变更后,需要重启环境才能生效 + +### 11. 插件状态管理 + +**问题:** +- 插件可能有:已安装、已启用/禁用、待更新等状态 +- 团队插件被禁用,是全局禁用还是个人禁用? + +**建议方案:** +- 团队插件的状态是全局的(所有者/管理员控制) +- 普通用户不能禁用团队插件 +- 个人插件可以自由启用/禁用 + +### 12. 审计和追溯场景 + +**问题:** +- 谁在什么时候为团队安装了什么插件? +- 插件被卸载后,如何追溯? + +**建议方案:** +- 所有插件操作记录到审计日志 +- 包括:安装者、安装时间、安装范围、是否为团队安装 + +--- + +## 实现状态 + +### 已实现功能 + +#### 1. 数据库表结构 +- ✅ `user_extensions` - 用户全局插件 +- ✅ `team_extensions` - 团队全局插件 +- ✅ `group_extensions` - 分组插件(支持 `is_team_shared` 字段区分团队/个人) +- ❌ ~~`environment_extensions`~~ - 已删除(环境不直接绑定插件) + +#### 2. 核心功能 +- ✅ 安装扩展到 user/team/group +- ✅ 卸载扩展(按目标类型) +- ✅ 查询已安装扩展(用户维度、团队维度) +- ✅ 支持批量安装到多个分组 +- ✅ `is_team_shared` 字段区分分组团队插件和分组个人插件 + +#### 3. Scope 字段语义 +返回给前端的 `scope` 字段值: +- `user` - 用户全局插件 +- `team` - 团队全局插件 +- `group-personal` - 分组个人插件(`is_team_shared=false`) +- `group-team` - 分组团队插件(`is_team_shared=true`) + +#### 4. 权限检查 +- ✅ 团队全局插件:检查用户是否为 owner/admin +- ✅ 分组团队共享插件:检查用户是否有分组的"管理"权限 +- ✅ 分组个人插件:检查用户是否有分组的"编辑"权限 + +#### 5. 审计日志 +- ✅ 安装扩展时记录审计日志 +- ✅ 卸载扩展时记录审计日志 +- ✅ 使用现有的 `audit_logs` 表和 `insert_audit_log` 函数 + +### 未实现功能 + +#### 1. 环境获取插件的动态合并逻辑 +- ❌ 环境列表接口未返回每个环境的插件列表 +- ❌ 缺少"获取某个环境的插件列表"接口 +- 需要实现:查询环境时动态合并 4 个层级的插件 + - 用户个人全局插件 + - 团队全局插件 + - 该环境所属分组的个人插件 + - 该环境所属分组的团队插件 + +#### 2. 优先级规则和版本冲突处理 +- ❌ 未实现优先级覆盖逻辑 +- 当前阶段不考虑版本问题 + +#### 3. 插件更新机制 +- ❌ 只能更新用户扩展 +- ❌ 没有团队扩展更新逻辑 +- 当前阶段不考虑更新逻辑 + +#### 4. 插件状态管理 +- ⚠️ 只有 `active/inactive` 状态 +- ❌ 没有区分"团队插件禁用"和"个人禁用" + +### 与原方案的差异 + +1. **审计日志**:使用现有的 `audit_logs` 表,而不是创建新的 `extension_audit_logs` 表 +2. **权限字段**:使用现有的"管理、编辑、查看"权限,而不是新增 `manage` 权限 +3. **环境插件加载**:暂未实现环境启动时的插件动态合并逻辑 diff --git a/migrations/20260306000002_drop_environment_extensions.sql b/migrations/20260306000002_drop_environment_extensions.sql new file mode 100644 index 0000000..af9dfb1 --- /dev/null +++ b/migrations/20260306000002_drop_environment_extensions.sql @@ -0,0 +1,12 @@ +-- 删除 environment_extensions 表 +-- 环境不直接绑定插件,插件通过用户/团队/分组间接作用于环境 + +-- 删除触发器 +DROP TRIGGER IF EXISTS update_environment_extensions_updated_at ON environment_extensions; + +-- 删除索引 +DROP INDEX IF EXISTS idx_env_extensions_extension_id; +DROP INDEX IF EXISTS idx_env_extensions_env_uuid; + +-- 删除表 +DROP TABLE IF EXISTS environment_extensions; diff --git a/migrations/20260306000003_add_is_team_shared_to_group_extensions.sql b/migrations/20260306000003_add_is_team_shared_to_group_extensions.sql new file mode 100644 index 0000000..286a7b5 --- /dev/null +++ b/migrations/20260306000003_add_is_team_shared_to_group_extensions.sql @@ -0,0 +1,11 @@ +-- 为 group_extensions 表添加 is_team_shared 字段 +-- 用于区分"分组团队插件"和"分组个人插件" + +-- 添加 is_team_shared 字段 +ALTER TABLE group_extensions ADD COLUMN is_team_shared BOOLEAN NOT NULL DEFAULT false; + +-- 添加索引 +CREATE INDEX idx_group_extensions_is_team_shared ON group_extensions(is_team_shared); + +-- 更新注释 +COMMENT ON COLUMN group_extensions.is_team_shared IS '是否为团队共享: true=团队所有成员可用, false=仅创建者可用'; diff --git a/migrations/20260306000004_update_extension_status_support_disabled.sql b/migrations/20260306000004_update_extension_status_support_disabled.sql new file mode 100644 index 0000000..299781e --- /dev/null +++ b/migrations/20260306000004_update_extension_status_support_disabled.sql @@ -0,0 +1,7 @@ +-- 更新扩展状态支持 disabled +-- 允许用户禁用团队/分组插件 + +-- 更新状态注释 +COMMENT ON COLUMN user_extensions.status IS '状态: active=已启用, disabled=已禁用, inactive=已卸载'; +COMMENT ON COLUMN team_extensions.status IS '状态: active=已安装, inactive=已卸载'; +COMMENT ON COLUMN group_extensions.status IS '状态: active=已安装, inactive=已卸载'; diff --git a/migrations/20260306000005_create_user_team_extension_preferences.sql b/migrations/20260306000005_create_user_team_extension_preferences.sql new file mode 100644 index 0000000..badb26c --- /dev/null +++ b/migrations/20260306000005_create_user_team_extension_preferences.sql @@ -0,0 +1,23 @@ +-- 创建用户团队插件偏好设置表 +CREATE TABLE IF NOT EXISTS user_team_extension_preferences ( + id SERIAL PRIMARY KEY, + user_uuid UUID NOT NULL, + team_uuid UUID NOT NULL, + extension_id VARCHAR(255) NOT NULL, + is_disabled BOOLEAN DEFAULT false, + created_at TIMESTAMP DEFAULT CURRENT_TIMESTAMP, + updated_at TIMESTAMP DEFAULT CURRENT_TIMESTAMP, + UNIQUE(user_uuid, team_uuid, extension_id) +); + +-- 添加索引 +CREATE INDEX idx_user_team_extension_preferences_user_uuid ON user_team_extension_preferences(user_uuid); +CREATE INDEX idx_user_team_extension_preferences_team_uuid ON user_team_extension_preferences(team_uuid); +CREATE INDEX idx_user_team_extension_preferences_extension_id ON user_team_extension_preferences(extension_id); + +-- 添加注释 +COMMENT ON TABLE user_team_extension_preferences IS '用户对团队插件的偏好设置(如禁用)'; +COMMENT ON COLUMN user_team_extension_preferences.user_uuid IS '用户 UUID'; +COMMENT ON COLUMN user_team_extension_preferences.team_uuid IS '团队 UUID'; +COMMENT ON COLUMN user_team_extension_preferences.extension_id IS '插件 ID'; +COMMENT ON COLUMN user_team_extension_preferences.is_disabled IS '是否禁用(true 表示用户禁用了该团队插件)'; diff --git a/src/dto/environments.rs b/src/dto/environments.rs index b8a0e36..4269e7b 100644 --- a/src/dto/environments.rs +++ b/src/dto/environments.rs @@ -208,6 +208,19 @@ pub struct EnvironmentListItemDto { pub tags: Vec, // 账号列表(完整对象列表) pub accounts: Vec, + // 扩展列表(插件列表) + pub extensions: Vec, +} + +/// 扩展摘要 DTO(用于环境列表) +#[derive(Debug, Clone, Serialize, Deserialize)] +pub struct ExtensionSummaryDto { + pub extension_id: String, + pub name: String, + pub version: String, + pub icon_url: Option, + pub download_url: Option, + pub scope: String, // user, team, group-personal, group-team } /// 环境配置 DTO diff --git a/src/dto/extensions.rs b/src/dto/extensions.rs index d4fb50a..8b884d1 100644 --- a/src/dto/extensions.rs +++ b/src/dto/extensions.rs @@ -109,18 +109,19 @@ pub struct GroupExtensionDto { pub installed_version: String, pub installed_by: Uuid, pub status: String, + pub is_team_shared: bool, pub installed_at: DateTime, pub updated_at: DateTime, } -/// 环境扩展 DTO +/// 用户团队插件偏好设置 DTO #[derive(Debug, Clone, FromRow, Serialize)] -pub struct EnvironmentExtensionDto { +pub struct UserTeamExtensionPreferenceDto { pub id: i32, - pub environment_uuid: Uuid, + pub user_uuid: Uuid, + pub team_uuid: Uuid, pub extension_id: String, - pub installed_version: String, - pub status: String, - pub installed_at: DateTime, + pub is_disabled: bool, + pub created_at: DateTime, pub updated_at: DateTime, } diff --git a/src/entitys/environments.rs b/src/entitys/environments.rs index 18fc4e6..f7e8cbc 100644 --- a/src/entitys/environments.rs +++ b/src/entitys/environments.rs @@ -234,4 +234,5 @@ pub struct EnvironmentDetailResponse { pub accounts: Vec, pub group: Option, // 分组完整信息 pub proxy: Option, // 代理完整信息 + pub extensions: Vec, // 扩展列表 } diff --git a/src/entitys/extensions.rs b/src/entitys/extensions.rs index 4c512a2..7fe45fe 100644 --- a/src/entitys/extensions.rs +++ b/src/entitys/extensions.rs @@ -76,25 +76,27 @@ pub struct ExtensionFilters { #[derive(Debug, Clone, Deserialize, Serialize)] pub struct InstallExtensionRequest { pub extension_id: String, - /// 安装目标: user, team, group, environment + /// 安装目标: user, team, group pub target_type: Option, /// 分组 UUID 数组(用于安装到分组,即使只有一个分组也需要传入数组) #[serde(skip_serializing_if = "Option::is_none")] pub group_ids: Option>, - /// 环境 UUID(用于安装到环境) + /// 是否为团队共享(仅 target_type=group 时有效) + /// true: 团队所有成员可用,false: 仅创建者可用 #[serde(skip_serializing_if = "Option::is_none")] - pub env_uuid: Option, + pub is_team_shared: Option, } /// 卸载扩展请求 #[derive(Debug, Clone, Deserialize, Serialize)] pub struct UninstallExtensionRequest { pub extension_id: String, - /// 卸载类型:user、team、environment + /// 卸载类型:user、team、group /// 如果不指定,默认为 user - /// 注意:无论 target_type 是什么,后端都会自动删除所有相关的分组记录 pub target_type: Option, - /// 目标 UUID(用于 team 和 environment 类型) + /// 目标 UUID(用于 team 和 group 类型) + /// - target_type=team: 表示 team_uuid + /// - target_type=group: 表示 group_uuid #[serde(skip_serializing_if = "Option::is_none")] pub target_uuid: Option, } @@ -117,6 +119,12 @@ pub struct ExtensionIdRequest { pub extension_id: String, } +/// 禁用/启用扩展请求 +#[derive(Debug, Clone, Deserialize, Serialize)] +pub struct ToggleExtensionRequest { + pub extension_id: String, +} + // ========== 响应结构体 ========== use crate::dto::ExtensionDto; diff --git a/src/handlers/environments.rs b/src/handlers/environments.rs index 1b149af..5c26d59 100644 --- a/src/handlers/environments.rs +++ b/src/handlers/environments.rs @@ -358,6 +358,7 @@ pub async fn get_environment_handler( accounts, group, proxy, + extensions: vec![], // handlers 中不需要返回扩展,由 service 层处理 }), )) } @@ -423,6 +424,7 @@ pub async fn batch_get_environments_handler( accounts, group, proxy, + extensions: vec![], // handlers 中不需要返回扩展,由 service 层处理 }) } .await; diff --git a/src/handlers/extensions.rs b/src/handlers/extensions.rs index 7a1fa54..a899354 100644 --- a/src/handlers/extensions.rs +++ b/src/handlers/extensions.rs @@ -4,7 +4,7 @@ use crate::dto::ExtensionDto; use crate::entitys::{ BatchUpdateExtensionsRequest, BatchUpdateResponse, ExtensionIdRequest, ExtensionsListResponse, GetInstalledExtensionsRequest, InstallExtensionRequest, InstalledExtensionsResponse, - ListExtensionsRequest, UninstallExtensionRequest, UpdateExtensionRequest, + ListExtensionsRequest, ToggleExtensionRequest, UninstallExtensionRequest, UpdateExtensionRequest, }; use crate::services; use crate::state::RequestContext; @@ -78,7 +78,7 @@ pub async fn get_installed_extensions_handler( "team" => { let team_uuid = team_uuid.ok_or_else(|| Response::fail(Some("未指定团队")))?; let team_extensions = - services::extensions::get_team_installed_extensions_service(&svc_ctx, team_uuid) + services::extensions::get_team_installed_extensions_service(&svc_ctx, team_uuid, ctx.user_uuid_unwrap()) .await .map_err(|e| Response::fail(Some(&e)))?; (Vec::new(), team_extensions) @@ -93,7 +93,7 @@ pub async fn get_installed_extensions_handler( .await .map_err(|e| Response::fail(Some(&e)))?; let team_extensions = if let Some(team_uuid) = team_uuid { - services::extensions::get_team_installed_extensions_service(&svc_ctx, team_uuid) + services::extensions::get_team_installed_extensions_service(&svc_ctx, team_uuid, ctx.user_uuid_unwrap()) .await .map_err(|e| Response::fail(Some(&e)))? } else { @@ -118,6 +118,7 @@ pub async fn install_extension_handler( Extension(ctx): Extension, Json(payload): Json, ) -> Result<()> { + let workspace_uuid = ctx.current_workspace_uuid.ok_or_else(|| Response::fail(Some("请先选择工作空间")))?; let team_uuid = services::teams::get_current_team_service(&svc_ctx, ctx.user_uuid_unwrap()) .await .map_err(|e| Response::fail(Some(&e)))?; @@ -140,6 +141,7 @@ pub async fn uninstall_extension_handler( Extension(ctx): Extension, Json(payload): Json, ) -> Result<()> { + let workspace_uuid = ctx.current_workspace_uuid.ok_or_else(|| Response::fail(Some("请先选择工作空间")))?; let team_uuid = services::teams::get_current_team_service(&svc_ctx, ctx.user_uuid_unwrap()) .await .map_err(|e| Response::fail(Some(&e)))?; @@ -192,3 +194,49 @@ pub async fn batch_update_extensions_handler( Some(BatchUpdateResponse { updated }), )) } + +/// 禁用扩展(用户级别) +pub async fn disable_extension_handler( + State(svc_ctx): State, + Extension(ctx): Extension, + Json(payload): Json, +) -> Result<()> { + let team_uuid = services::teams::get_current_team_service(&svc_ctx, ctx.user_uuid_unwrap()) + .await + .map_err(|e| Response::fail(Some(&e)))? + .ok_or_else(|| Response::fail(Some("未指定团队")))?; + + services::extensions::disable_extension_service( + &svc_ctx, + ctx.user_uuid_unwrap(), + team_uuid, + &payload.extension_id, + ) + .await + .map_err(|e| Response::fail(Some(&e)))?; + + Ok(Response::success(Some("禁用成功"), None)) +} + +/// 启用扩展(用户级别) +pub async fn enable_extension_handler( + State(svc_ctx): State, + Extension(ctx): Extension, + Json(payload): Json, +) -> Result<()> { + let team_uuid = services::teams::get_current_team_service(&svc_ctx, ctx.user_uuid_unwrap()) + .await + .map_err(|e| Response::fail(Some(&e)))? + .ok_or_else(|| Response::fail(Some("未指定团队")))?; + + services::extensions::enable_extension_service( + &svc_ctx, + ctx.user_uuid_unwrap(), + team_uuid, + &payload.extension_id, + ) + .await + .map_err(|e| Response::fail(Some(&e)))?; + + Ok(Response::success(Some("启用成功"), None)) +} diff --git a/src/models/extensions.rs b/src/models/extensions.rs index 99e0d8d..4f0e61d 100644 --- a/src/models/extensions.rs +++ b/src/models/extensions.rs @@ -3,7 +3,7 @@ use uuid::Uuid; use crate::entitys::extensions::{CreateExtensionParams, UpdateExtensionParams}; use crate::dto::{ - EnvironmentExtensionDto, ExtensionDto, GroupExtensionDto, TeamExtensionDto, UserExtensionDto, + ExtensionDto, GroupExtensionDto, TeamExtensionDto, UserExtensionDto, }; // ============ Extensions CRUD ============ @@ -345,7 +345,7 @@ pub async fn fetch_group_extensions( ) -> Result, Error> { let recs = sqlx::query_as::<_, GroupExtensionDto>( r#" - SELECT id, group_uuid, extension_id, installed_version, installed_by, status, installed_at, updated_at + SELECT id, group_uuid, extension_id, installed_version, installed_by, status, is_team_shared, installed_at, updated_at FROM group_extensions WHERE group_uuid = $1 AND status = 'active' ORDER BY installed_at DESC @@ -365,14 +365,16 @@ pub async fn insert_group_extension( extension_id: &str, version: &str, installed_by: Uuid, + is_team_shared: bool, ) -> Result { let id: i32 = sqlx::query_scalar( r#" - INSERT INTO group_extensions (group_uuid, extension_id, installed_version, installed_by, status) - VALUES ($1, $2, $3, $4, 'active') + INSERT INTO group_extensions (group_uuid, extension_id, installed_version, installed_by, is_team_shared, status) + VALUES ($1, $2, $3, $4, $5, 'active') ON CONFLICT (group_uuid, extension_id) DO UPDATE SET installed_version = $3, installed_by = $4, + is_team_shared = $5, status = 'active', updated_at = CURRENT_TIMESTAMP RETURNING id; @@ -382,6 +384,7 @@ pub async fn insert_group_extension( .bind(extension_id) .bind(version) .bind(installed_by) + .bind(is_team_shared) .fetch_one(pool) .await?; @@ -445,6 +448,25 @@ pub async fn fetch_group_uuids_by_extension_id( Ok(recs) } +/// 根据扩展 ID 查询团队共享的分组 UUID +pub async fn fetch_team_shared_group_uuids_by_extension_id( + pool: &Pool, + extension_id: &str, +) -> Result, Error> { + let recs = sqlx::query_scalar::<_, Uuid>( + r#" + SELECT DISTINCT group_uuid + FROM group_extensions + WHERE extension_id = $1 AND status = 'active' AND is_team_shared = true + "#, + ) + .bind(extension_id) + .fetch_all(pool) + .await?; + + Ok(recs) +} + /// 查询用户相关的分组中安装的扩展 /// 包括用户个人分组(无 team_uuid,由用户创建)和用户所在团队的分组 pub async fn fetch_user_group_extensions( @@ -454,7 +476,7 @@ pub async fn fetch_user_group_extensions( ) -> Result, Error> { let recs = sqlx::query_as::<_, GroupExtensionDto>( r#" - SELECT ge.id, ge.group_uuid, ge.extension_id, ge.installed_version, ge.installed_by, ge.status, ge.installed_at, ge.updated_at + SELECT ge.id, ge.group_uuid, ge.extension_id, ge.installed_version, ge.installed_by, ge.status, ge.is_team_shared, ge.installed_at, ge.updated_at FROM group_extensions ge INNER JOIN groups g ON ge.group_uuid = g.uuid WHERE ge.status = 'active' @@ -478,10 +500,11 @@ pub async fn fetch_team_group_extensions( ) -> Result, Error> { let recs = sqlx::query_as::<_, GroupExtensionDto>( r#" - SELECT ge.id, ge.group_uuid, ge.extension_id, ge.installed_version, ge.installed_by, ge.status, ge.installed_at, ge.updated_at + SELECT ge.id, ge.group_uuid, ge.extension_id, ge.installed_version, ge.installed_by, ge.status, ge.is_team_shared, ge.installed_at, ge.updated_at FROM group_extensions ge INNER JOIN groups g ON ge.group_uuid = g.uuid WHERE ge.status = 'active' + AND ge.is_team_shared = true AND g.deleted_at IS NULL AND g.team_uuid = $1 ORDER BY ge.installed_at DESC @@ -493,72 +516,75 @@ pub async fn fetch_team_group_extensions( Ok(recs) } - -// ============ Environment Extensions ============ - -/// 查询环境已安装的扩展 -pub async fn fetch_environment_extensions( - pool: &Pool, - environment_uuid: Uuid, -) -> Result, Error> { - let recs = sqlx::query_as::<_, EnvironmentExtensionDto>( - r#" - SELECT id, environment_uuid, extension_id, installed_version, status, installed_at, updated_at - FROM environment_extensions - WHERE environment_uuid = $1 AND status = 'active' - ORDER BY installed_at DESC - "#, - ) - .bind(environment_uuid) - .fetch_all(pool) - .await?; - - Ok(recs) -} - -/// 安装环境扩展 -pub async fn insert_environment_extension( - pool: &Pool, - environment_uuid: Uuid, - extension_id: &str, - version: &str, -) -> Result { - let id: i32 = sqlx::query_scalar( - r#" - INSERT INTO environment_extensions (environment_uuid, extension_id, installed_version, status) - VALUES ($1, $2, $3, 'active') - ON CONFLICT (environment_uuid, extension_id) DO UPDATE SET - installed_version = $3, - status = 'active', - updated_at = CURRENT_TIMESTAMP - RETURNING id; - "#, - ) - .bind(environment_uuid) - .bind(extension_id) - .bind(version) - .fetch_one(pool) - .await?; - - Ok(id) -} - -/// 卸载环境扩展 -pub async fn delete_environment_extension( - pool: &Pool, - environment_uuid: Uuid, - extension_id: &str, -) -> Result<(), Error> { - sqlx::query( - r#" - UPDATE environment_extensions SET status = 'inactive', updated_at = CURRENT_TIMESTAMP - WHERE environment_uuid = $1 AND extension_id = $2 - "#, - ) - .bind(environment_uuid) - .bind(extension_id) - .execute(pool) - .await?; - - Ok(()) -} + +// ============ User Team Extension Preferences ============ + +/// 查询用户禁用的团队插件列表 +pub async fn fetch_user_disabled_team_extensions( + pool: &Pool, + user_uuid: Uuid, + team_uuid: Uuid, +) -> Result, Error> { + let recs = sqlx::query_scalar::<_, String>( + r#" + SELECT extension_id + FROM user_team_extension_preferences + WHERE user_uuid = $1 AND team_uuid = $2 AND is_disabled = true + "#, + ) + .bind(user_uuid) + .bind(team_uuid) + .fetch_all(pool) + .await?; + + Ok(recs) +} + +/// 设置用户对团队插件的禁用状态 +pub async fn set_user_team_extension_preference( + pool: &Pool, + user_uuid: Uuid, + team_uuid: Uuid, + extension_id: &str, + is_disabled: bool, +) -> Result<(), Error> { + sqlx::query( + r#" + INSERT INTO user_team_extension_preferences (user_uuid, team_uuid, extension_id, is_disabled) + VALUES ($1, $2, $3, $4) + ON CONFLICT (user_uuid, team_uuid, extension_id) DO UPDATE SET + is_disabled = $4, + updated_at = CURRENT_TIMESTAMP + "#, + ) + .bind(user_uuid) + .bind(team_uuid) + .bind(extension_id) + .bind(is_disabled) + .execute(pool) + .await?; + + Ok(()) +} + +/// 删除用户对团队插件的偏好设置 +pub async fn delete_user_team_extension_preference( + pool: &Pool, + user_uuid: Uuid, + team_uuid: Uuid, + extension_id: &str, +) -> Result<(), Error> { + sqlx::query( + r#" + DELETE FROM user_team_extension_preferences + WHERE user_uuid = $1 AND team_uuid = $2 AND extension_id = $3 + "#, + ) + .bind(user_uuid) + .bind(team_uuid) + .bind(extension_id) + .execute(pool) + .await?; + + Ok(()) +} diff --git a/src/routes/extensions.rs b/src/routes/extensions.rs index 25bf092..328a0e4 100644 --- a/src/routes/extensions.rs +++ b/src/routes/extensions.rs @@ -1,9 +1,10 @@ use axum::routing::post; use crate::handlers::{ - batch_update_extensions_handler, get_extension_categories_handler, - get_extension_detail_handler, get_extensions_handler, get_installed_extensions_handler, - install_extension_handler, uninstall_extension_handler, update_extension_handler, + batch_update_extensions_handler, disable_extension_handler, enable_extension_handler, + get_extension_categories_handler, get_extension_detail_handler, get_extensions_handler, + get_installed_extensions_handler, install_extension_handler, uninstall_extension_handler, + update_extension_handler, }; use crate::routes::route::{self, MetaRoute}; @@ -49,5 +50,15 @@ pub fn register_routes(meta_route: &mut MetaRoute) { post(batch_update_extensions_handler), )); + // 禁用/启用扩展 + ext_route.add_route_item(route::RouteItem::post( + "/disable", + post(disable_extension_handler), + )); + ext_route.add_route_item(route::RouteItem::post( + "/enable", + post(enable_extension_handler), + )); + meta_route.add_route_group(ext_route); } diff --git a/src/services/environments.rs b/src/services/environments.rs index 60b34f1..47fd9bc 100644 --- a/src/services/environments.rs +++ b/src/services/environments.rs @@ -26,7 +26,7 @@ pub async fn create_environment_service( payload: &CreateEnvironmentRequest, ) -> Result { // 1. 检查用户是否在当前工作空间的团队中(工作空间级别) - let team_member = models::fetch_team_member(&svc_ctx.db, workspace_uuid, team_uuid, user_uuid) + let team_member = models::teams::fetch_team_member(&svc_ctx.db, workspace_uuid, team_uuid, user_uuid) .await .map_err(|e| e.to_string())? .ok_or_else(|| "您不是该团队的成员".to_string())?; @@ -236,7 +236,7 @@ pub async fn get_environments_service( payload: &ListEnvironmentsRequest, ) -> Result<(Vec, i64), String> { // 1. 检查用户是否在当前工作空间的团队中 - let team_member = models::fetch_team_member(&svc_ctx.db, workspace_uuid, team_uuid, user_uuid) + let team_member = models::teams::fetch_team_member(&svc_ctx.db, workspace_uuid, team_uuid, user_uuid) .await .map_err(|e| e.to_string())? .ok_or_else(|| "您不是该团队的成员".to_string())?; @@ -396,6 +396,20 @@ pub async fn get_environments_service( accounts_map.insert(*env_uuid, accounts); } + // 批量查询扩展(为每个环境动态合并插件) + let mut extensions_map: HashMap> = HashMap::new(); + for row in &filtered_env_rows { + let extensions = crate::services::extensions::get_environment_extensions_service( + &svc_ctx, + user_uuid, + team_uuid, + row.group_uuid, + ) + .await + .unwrap_or_default(); + extensions_map.insert(row.uuid, extensions); + } + // 11. 组装完整数据(使用与环境详情一致的数据结构) let environments: Vec = filtered_env_rows .into_iter() @@ -456,6 +470,7 @@ pub async fn get_environments_service( accounts: accounts_map.remove(&row.uuid).unwrap_or_default(), group, proxy, + extensions: extensions_map.remove(&row.uuid).unwrap_or_default(), } }) .collect(); @@ -475,7 +490,7 @@ pub async fn get_environment_service( env_uuid: Uuid, ) -> Result { // 1. 检查用户是否在当前工作空间的团队中 - let _team_member = models::fetch_team_member(&svc_ctx.db, workspace_uuid, team_uuid, user_uuid) + let _team_member = models::teams::fetch_team_member(&svc_ctx.db, workspace_uuid, team_uuid, user_uuid) .await .map_err(|e| e.to_string())? .ok_or_else(|| "您不是该团队的成员".to_string())?; @@ -568,6 +583,16 @@ pub async fn get_environment_detail_service( None }; + // 获取扩展列表 + let extensions = crate::services::extensions::get_environment_extensions_service( + svc_ctx, + user_uuid, + team_uuid, + environment.group_uuid, + ) + .await + .unwrap_or_default(); + Ok(crate::entitys::EnvironmentDetailResponse { environment, config, @@ -575,6 +600,7 @@ pub async fn get_environment_detail_service( accounts, group, proxy, + extensions, }) } @@ -631,7 +657,7 @@ pub async fn update_environment_service( payload: &UpdateEnvironmentRequest, ) -> Result<(), String> { // 1. 检查用户是否在当前工作空间的团队中 - let team_member = models::fetch_team_member(&svc_ctx.db, workspace_uuid, team_uuid, user_uuid) + let team_member = models::teams::fetch_team_member(&svc_ctx.db, workspace_uuid, team_uuid, user_uuid) .await .map_err(|e| e.to_string())? .ok_or_else(|| "您不是该团队的成员".to_string())?; @@ -812,7 +838,7 @@ pub async fn delete_environment_service( env_uuid: Uuid, ) -> Result<(), String> { // 1. 检查用户是否在当前工作空间的团队中 - let team_member = models::fetch_team_member(&svc_ctx.db, workspace_uuid, team_uuid, user_uuid) + let team_member = models::teams::fetch_team_member(&svc_ctx.db, workspace_uuid, team_uuid, user_uuid) .await .map_err(|e| e.to_string())? .ok_or_else(|| "您不是该团队的成员".to_string())?; @@ -1042,6 +1068,7 @@ pub async fn get_recycle_bin_environments_service( accounts, group, proxy, + extensions: vec![], // 回收站不需要返回扩展数据 } }) .collect(); diff --git a/src/services/extensions.rs b/src/services/extensions.rs index 1a85166..4e860c1 100644 --- a/src/services/extensions.rs +++ b/src/services/extensions.rs @@ -194,10 +194,14 @@ pub async fn get_user_installed_extensions_service( .unwrap_or_default(); let mut groups = Vec::new(); + let mut is_team_group = false; for group_uuid in group_uuids { if let Ok(Some(group)) = env_models::fetch_group_by_uuid(&svc_ctx.db, group_uuid).await { + if !group.team_uuid.is_nil() { + is_team_group = true; + } groups.push(ExtensionGroup { uuid: group.uuid, name: group.name, @@ -206,6 +210,11 @@ pub async fn get_user_installed_extensions_service( } let has_update = ext.version != ge.installed_version; + let scope = if is_team_group { + "group-team".to_string() + } else { + "group-personal".to_string() + }; extension_map.insert( ge.extension_id.clone(), InstalledExtensionItem { @@ -219,7 +228,7 @@ pub async fn get_user_installed_extensions_service( homepage: ext.homepage, icon_url, team_uuid: None, - scope: "group".to_string(), + scope, description: ext.description, category: Some(ext.category), browser: Some(ext.browser), @@ -246,6 +255,7 @@ pub async fn get_user_installed_extensions_service( pub async fn get_team_installed_extensions_service( svc_ctx: &SvcCtx, team_uuid: Uuid, + user_uuid: Uuid, ) -> Result, String> { let resource_url = svc_ctx.config.minio.resource_url.as_str(); let extension_bucket = svc_ctx.config.minio.extension_bucket.as_str(); @@ -260,6 +270,14 @@ pub async fn get_team_installed_extensions_service( .await .map_err(|e| e.to_string())?; + // 查询用户禁用的团队插件列表 + let user_disabled_extensions = + models::extensions::fetch_user_disabled_team_extensions(&svc_ctx.db, user_uuid, team_uuid) + .await + .map_err(|e| e.to_string())?; + let user_disabled_set: std::collections::HashSet = + user_disabled_extensions.into_iter().collect(); + // 使用 HashMap 去重,以 extension_id 为 key use std::collections::HashMap; let mut extension_map: HashMap = HashMap::new(); @@ -281,8 +299,8 @@ pub async fn get_team_installed_extensions_service( } } - // 查询关联的分组 - let group_uuids = models::extensions::fetch_group_uuids_by_extension_id( + // 查询关联的分组(仅查询团队共享的分组) + let group_uuids = models::extensions::fetch_team_shared_group_uuids_by_extension_id( &svc_ctx.db, &te.extension_id, ) @@ -302,6 +320,14 @@ pub async fn get_team_installed_extensions_service( } let has_update = ext.version != te.installed_version; + + // 检查用户是否禁用了这个团队插件 + let status = if user_disabled_set.contains(&te.extension_id) { + "disabled".to_string() + } else { + te.status + }; + extension_map.insert( te.extension_id.clone(), InstalledExtensionItem { @@ -310,7 +336,7 @@ pub async fn get_team_installed_extensions_service( version: ext.version, installed_version: te.installed_version, has_update, - status: te.status, + status, installed_at: te.installed_at, homepage: ext.homepage, icon_url, @@ -379,6 +405,19 @@ pub async fn get_team_installed_extensions_service( } let has_update = ext.version != ge.installed_version; + let scope = if ge.is_team_shared { + "group-team".to_string() + } else { + "group-personal".to_string() + }; + + // 检查用户是否禁用了这个团队插件 + let status = if user_disabled_set.contains(&ge.extension_id) { + "disabled".to_string() + } else { + ge.status + }; + extension_map.insert( ge.extension_id.clone(), InstalledExtensionItem { @@ -387,12 +426,12 @@ pub async fn get_team_installed_extensions_service( version: ext.version, installed_version: ge.installed_version, has_update, - status: ge.status, + status, installed_at: ge.installed_at, homepage: ext.homepage, icon_url, team_uuid: Some(team_uuid), - scope: "group".to_string(), + scope, description: ext.description, category: Some(ext.category), browser: Some(ext.browser), @@ -442,7 +481,32 @@ pub async fn install_extension_service( .map_err(|e| e.to_string())?; } "team" => { + // 权限检查:检查用户是否为 owner/admin let team_uuid = team_uuid.ok_or_else(|| "未指定团队".to_string())?; + + // 获取工作空间 UUID + let team = crate::models::teams::fetch_team_by_uuid(&svc_ctx.db, team_uuid) + .await + .map_err(|e| e.to_string())? + .ok_or_else(|| "团队不存在".to_string())?; + let workspace_uuid = team.workspace_uuid; + + // 查询用户在团队中的角色 + let member = crate::models::teams::fetch_team_member( + &svc_ctx.db, + workspace_uuid, + team_uuid, + user_uuid, + ) + .await + .map_err(|e| e.to_string())? + .ok_or_else(|| "您不是该团队成员".to_string())?; + + // 检查角色权限 + if member.role != "owner" && member.role != "admin" { + return Err("权限不足:只有团队所有者或管理员可以安装团队插件".to_string()); + } + models::extensions::insert_team_extension( &svc_ctx.db, team_uuid, @@ -454,37 +518,91 @@ pub async fn install_extension_service( .map_err(|e| e.to_string())?; } "group" => { + // 权限检查 + let is_team_shared = payload.is_team_shared.unwrap_or(false); + // 必须提供分组数组,即使只有一个分组也需要传入数组 let group_ids = payload.group_ids.as_ref().ok_or_else(|| "未指定分组".to_string())?; if group_ids.is_empty() { return Err("分组列表不能为空".to_string()); } + for group_uuid in group_ids { + // 检查权限 + // - is_team_shared=true: 需要"管理"权限 + // - is_team_shared=false: 需要"编辑"权限 + let required_permission = if is_team_shared { "manage" } else { "write" }; + + // 获取工作空间 UUID(从团队获取) + let workspace_uuid = if let Some(team_uuid) = team_uuid { + let team = crate::models::teams::fetch_team_by_uuid(&svc_ctx.db, team_uuid) + .await + .map_err(|e| e.to_string())? + .ok_or_else(|| "团队不存在".to_string())?; + team.workspace_uuid + } else { + return Err("未指定团队".to_string()); + }; + + let has_permission = crate::models::group_member_permissions::check_group_permission( + &svc_ctx.db, + workspace_uuid, + *group_uuid, + user_uuid, + required_permission, + ) + .await + .map_err(|e| e.to_string())?; + + if !has_permission { + let msg = if is_team_shared { + "权限不足:安装团队共享插件需要分组的管理权限" + } else { + "权限不足:安装个人插件需要分组的编辑权限" + }; + return Err(msg.to_string()); + } + models::extensions::insert_group_extension( &svc_ctx.db, *group_uuid, &payload.extension_id, &extension.version, user_uuid, + is_team_shared, ) .await .map_err(|e| e.to_string())?; } } - "environment" => { - let env_uuid = payload.env_uuid.ok_or_else(|| "未指定环境".to_string())?; - models::extensions::insert_environment_extension( - &svc_ctx.db, - env_uuid, - &payload.extension_id, - &extension.version, - ) - .await - .map_err(|e| e.to_string())?; - } _ => return Err("无效的安装目标类型".to_string()), } + // 添加审计日志 + let details = format!("安装扩展: {}", payload.extension_id); + let changes = serde_json::json!({ + "extension_id": payload.extension_id, + "version": extension.version, + "target_type": target_type, + "is_team_shared": payload.is_team_shared, + }); + + let _ = crate::models::audit::insert_audit_log( + &svc_ctx.db, + user_uuid, + team_uuid, + "install_extension", + target_type, + None, + Some(&payload.extension_id), + Some(&details), + Some(&changes), + None, + None, + None, + ) + .await; + Ok(()) } @@ -495,12 +613,6 @@ pub async fn uninstall_extension_service( team_uuid: Option, payload: &UninstallExtensionRequest, ) -> Result<(), String> { - // 无论 target_type 是什么,都先删除所有相关的分组记录 - models::extensions::delete_group_extensions_by_extension_id(&svc_ctx.db, &payload.extension_id) - .await - .map_err(|e| e.to_string())?; - - // 然后根据 target_type 删除对应的记录 let target_type = payload.target_type.as_deref().unwrap_or("user"); match target_type { @@ -514,7 +626,32 @@ pub async fn uninstall_extension_service( .map_err(|e| e.to_string())?; } "team" => { + // 权限检查:检查用户是否为 owner/admin let team_uuid = team_uuid.ok_or_else(|| "未指定团队".to_string())?; + + // 获取工作空间 UUID + let team = crate::models::teams::fetch_team_by_uuid(&svc_ctx.db, team_uuid) + .await + .map_err(|e| e.to_string())? + .ok_or_else(|| "团队不存在".to_string())?; + let workspace_uuid = team.workspace_uuid; + + // 查询用户在团队中的角色 + let member = crate::models::teams::fetch_team_member( + &svc_ctx.db, + workspace_uuid, + team_uuid, + user_uuid, + ) + .await + .map_err(|e| e.to_string())? + .ok_or_else(|| "您不是该团队成员".to_string())?; + + // 检查角色权限 + if member.role != "owner" && member.role != "admin" { + return Err("权限不足:只有团队所有者或管理员可以卸载团队插件".to_string()); + } + models::extensions::delete_team_extension( &svc_ctx.db, team_uuid, @@ -523,11 +660,56 @@ pub async fn uninstall_extension_service( .await .map_err(|e| e.to_string())?; } - "environment" => { - let env_uuid = payload.target_uuid.ok_or_else(|| "未指定环境".to_string())?; - models::extensions::delete_environment_extension( + "group" => { + // 权限检查 + let group_uuid = payload.target_uuid.ok_or_else(|| "未指定分组".to_string())?; + + // 查询该分组扩展的 is_team_shared 状态 + let group_extensions = models::extensions::fetch_group_extensions(&svc_ctx.db, group_uuid) + .await + .map_err(|e| e.to_string())?; + + let group_ext = group_extensions + .iter() + .find(|ge| ge.extension_id == payload.extension_id) + .ok_or_else(|| "该分组未安装此扩展".to_string())?; + + // 根据 is_team_shared 检查权限 + let required_permission = if group_ext.is_team_shared { "manage" } else { "write" }; + + // 获取工作空间 UUID + let workspace_uuid = if let Some(team_uuid) = team_uuid { + let team = crate::models::teams::fetch_team_by_uuid(&svc_ctx.db, team_uuid) + .await + .map_err(|e| e.to_string())? + .ok_or_else(|| "团队不存在".to_string())?; + team.workspace_uuid + } else { + return Err("未指定团队".to_string()); + }; + + let has_permission = crate::models::group_member_permissions::check_group_permission( &svc_ctx.db, - env_uuid, + workspace_uuid, + group_uuid, + user_uuid, + required_permission, + ) + .await + .map_err(|e| e.to_string())?; + + if !has_permission { + let msg = if group_ext.is_team_shared { + "权限不足:卸载团队共享插件需要分组的管理权限" + } else { + "权限不足:卸载个人插件需要分组的编辑权限" + }; + return Err(msg.to_string()); + } + + models::extensions::delete_group_extension( + &svc_ctx.db, + group_uuid, &payload.extension_id, ) .await @@ -536,6 +718,29 @@ pub async fn uninstall_extension_service( _ => return Err("无效的卸载目标类型".to_string()), } + // 添加审计日志 + let details = format!("卸载扩展: {}", payload.extension_id); + let changes = serde_json::json!({ + "extension_id": payload.extension_id, + "target_type": target_type, + }); + + let _ = crate::models::audit::insert_audit_log( + &svc_ctx.db, + user_uuid, + team_uuid, + "uninstall_extension", + target_type, + payload.target_uuid, + Some(&payload.extension_id), + Some(&details), + Some(&changes), + None, + None, + None, + ) + .await; + Ok(()) } @@ -580,3 +785,239 @@ pub async fn batch_update_extensions_service( Ok(updated) } + +/// 获取环境的扩展列表(动态合并 4 个层级) +/// +/// 合并逻辑: +/// 1. 用户个人全局插件 +/// 2. 团队全局插件 +/// 3. 该环境所属分组的个人插件 +/// 4. 该环境所属分组的团队插件 +pub async fn get_environment_extensions_service( + svc_ctx: &SvcCtx, + user_uuid: Uuid, + team_uuid: Uuid, + group_uuid: Option, +) -> Result, String> { + use std::collections::HashMap; + + let resource_url = svc_ctx.config.minio.resource_url.as_str(); + let extension_bucket = svc_ctx.config.minio.extension_bucket.as_str(); + + // 使用 HashMap 去重,key 为 extension_id + let mut extension_map: HashMap = HashMap::new(); + + // 0. 查询用户禁用的插件列表 + let disabled_extensions: std::collections::HashSet = sqlx::query_scalar::<_, String>( + r#" + SELECT extension_id FROM user_extensions + WHERE user_uuid = $1 AND status = 'disabled' + "#, + ) + .bind(user_uuid) + .fetch_all(&svc_ctx.db) + .await + .map_err(|e| e.to_string())? + .into_iter() + .collect(); + + // 1. 查询用户全局插件(排除 disabled 状态) + let user_extensions = models::extensions::fetch_user_extensions(&svc_ctx.db, user_uuid) + .await + .map_err(|e| e.to_string())?; + + for ue in user_extensions { + if let Ok(Some(ext)) = models::extensions::fetch_extension_by_id(&svc_ctx.db, &ue.extension_id).await { + let mut icon_url = ext.icon_url.clone(); + if let Some(path) = &icon_url { + if !path.is_empty() && !path.starts_with("http") { + icon_url = Some(crate::utils::minios::get_objects::get_extension_icon_url( + resource_url, + extension_bucket, + path, + )); + } + } + + let mut download_url = ext.download_url.clone(); + if let Some(path) = &download_url { + if !path.is_empty() && !path.starts_with("http") { + download_url = Some(crate::utils::minios::get_objects::get_extension_crx_url( + resource_url, + extension_bucket, + path, + )); + } + } + + extension_map.insert( + ue.extension_id.clone(), + crate::dto::environments::ExtensionSummaryDto { + extension_id: ue.extension_id, + name: ext.name, + version: ext.version, + icon_url, + download_url, + scope: "user".to_string(), + }, + ); + } + } + + // 2. 查询团队全局插件 + let team_extensions = models::extensions::fetch_team_extensions(&svc_ctx.db, team_uuid) + .await + .map_err(|e| e.to_string())?; + + for te in team_extensions { + // 跳过用户禁用的插件 + if disabled_extensions.contains(&te.extension_id) { + continue; + } + + if let Ok(Some(ext)) = models::extensions::fetch_extension_by_id(&svc_ctx.db, &te.extension_id).await { + let mut icon_url = ext.icon_url.clone(); + if let Some(path) = &icon_url { + if !path.is_empty() && !path.starts_with("http") { + icon_url = Some(crate::utils::minios::get_objects::get_extension_icon_url( + resource_url, + extension_bucket, + path, + )); + } + } + + let mut download_url = ext.download_url.clone(); + if let Some(path) = &download_url { + if !path.is_empty() && !path.starts_with("http") { + download_url = Some(crate::utils::minios::get_objects::get_extension_crx_url( + resource_url, + extension_bucket, + path, + )); + } + } + + extension_map.insert( + te.extension_id.clone(), + crate::dto::environments::ExtensionSummaryDto { + extension_id: te.extension_id, + name: ext.name, + version: ext.version, + icon_url, + download_url, + scope: "team".to_string(), + }, + ); + } + } + + // 3. 如果环境有分组,查询分组插件 + if let Some(group_uuid) = group_uuid { + let group_extensions = models::extensions::fetch_group_extensions(&svc_ctx.db, group_uuid) + .await + .map_err(|e| e.to_string())?; + + for ge in group_extensions { + // 跳过用户禁用的插件 + if disabled_extensions.contains(&ge.extension_id) { + continue; + } + + if let Ok(Some(ext)) = models::extensions::fetch_extension_by_id(&svc_ctx.db, &ge.extension_id).await { + let mut icon_url = ext.icon_url.clone(); + if let Some(path) = &icon_url { + if !path.is_empty() && !path.starts_with("http") { + icon_url = Some(crate::utils::minios::get_objects::get_extension_icon_url( + resource_url, + extension_bucket, + path, + )); + } + } + + let mut download_url = ext.download_url.clone(); + if let Some(path) = &download_url { + if !path.is_empty() && !path.starts_with("http") { + download_url = Some(crate::utils::minios::get_objects::get_extension_crx_url( + resource_url, + extension_bucket, + path, + )); + } + } + + let scope = if ge.is_team_shared { + "group-team".to_string() + } else { + "group-personal".to_string() + }; + + extension_map.insert( + ge.extension_id.clone(), + crate::dto::environments::ExtensionSummaryDto { + extension_id: ge.extension_id, + name: ext.name, + version: ext.version, + icon_url, + download_url, + scope, + }, + ); + } + } + } + + Ok(extension_map.into_values().collect()) +} + +/// 禁用扩展(用户级别) +/// +/// 用户可以禁用团队插件,通过在 user_team_extension_preferences 中设置 is_disabled = true +pub async fn disable_extension_service( + svc_ctx: &SvcCtx, + user_uuid: Uuid, + team_uuid: Uuid, + extension_id: &str, +) -> Result<(), String> { + // 检查扩展是否存在 + let _extension = models::extensions::fetch_extension_by_id(&svc_ctx.db, extension_id) + .await + .map_err(|e| e.to_string())? + .ok_or_else(|| "扩展不存在".to_string())?; + + // 设置用户对团队插件的禁用状态 + models::extensions::set_user_team_extension_preference( + &svc_ctx.db, + user_uuid, + team_uuid, + extension_id, + true, + ) + .await + .map_err(|e| e.to_string())?; + + Ok(()) +} + +/// 启用扩展(用户级别) +/// +/// 删除用户对团队插件的禁用设置 +pub async fn enable_extension_service( + svc_ctx: &SvcCtx, + user_uuid: Uuid, + team_uuid: Uuid, + extension_id: &str, +) -> Result<(), String> { + // 删除用户对团队插件的偏好设置(或设置为 false) + models::extensions::delete_user_team_extension_preference( + &svc_ctx.db, + user_uuid, + team_uuid, + extension_id, + ) + .await + .map_err(|e| e.to_string())?; + + Ok(()) +} From eb3265a9684b609ca6c6bd59d0c58f9715d442ae Mon Sep 17 00:00:00 2001 From: lius-new Date: Sun, 8 Mar 2026 23:10:00 +0800 Subject: [PATCH 17/60] =?UTF-8?q?feat:=20=E4=B8=BA=20extensions=20?= =?UTF-8?q?=E8=A1=A8=E6=B7=BB=E5=8A=A0=20hash=20=E5=AD=97=E6=AE=B5?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit - 添加数据库迁移文件,为 extensions 表添加 hash 字段(VARCHAR(255),允许为空) - 更新 ExtensionDto 和 ExtensionSummaryDto,添加 hash 字段 - 更新 CreateExtensionParams 和 UpdateExtensionParams,支持 hash 字段 - 更新所有扩展相关的 SQL 查询(CREATE、UPDATE、SELECT) - 在 get_environment_extensions_service 中填充 hash 字段 - environments/list 接口返回的 extensions 现在包含 hash 字段 Co-Authored-By: Claude Opus 4.6 (1M context) --- .../20260308000001_add_hash_to_extensions.sql | 8 + src/dto/environments.rs | 1 + src/dto/extensions.rs | 1 + src/entitys/extensions.rs | 2 + src/models/extensions.rs | 157 +++++++++--------- src/services/extensions.rs | 3 + 6 files changed, 95 insertions(+), 77 deletions(-) create mode 100644 migrations/20260308000001_add_hash_to_extensions.sql diff --git a/migrations/20260308000001_add_hash_to_extensions.sql b/migrations/20260308000001_add_hash_to_extensions.sql new file mode 100644 index 0000000..bd2db84 --- /dev/null +++ b/migrations/20260308000001_add_hash_to_extensions.sql @@ -0,0 +1,8 @@ +-- 为 extensions 表添加 hash 字段 +-- 用于存储扩展文件的哈希值 + +ALTER TABLE extensions +ADD COLUMN hash VARCHAR(255) DEFAULT NULL; + +-- 为 hash 字段创建索引(可选,如果需要通过 hash 查询) +CREATE INDEX idx_extensions_hash ON extensions(hash); diff --git a/src/dto/environments.rs b/src/dto/environments.rs index 4269e7b..14b196e 100644 --- a/src/dto/environments.rs +++ b/src/dto/environments.rs @@ -220,6 +220,7 @@ pub struct ExtensionSummaryDto { pub version: String, pub icon_url: Option, pub download_url: Option, + pub hash: Option, pub scope: String, // user, team, group-personal, group-team } diff --git a/src/dto/extensions.rs b/src/dto/extensions.rs index 8b884d1..46eefd9 100644 --- a/src/dto/extensions.rs +++ b/src/dto/extensions.rs @@ -28,6 +28,7 @@ pub struct ExtensionDto { pub status: String, pub changelog: Option, pub published_at: Option>, + pub hash: Option, pub created_at: DateTime, pub updated_at: DateTime, } diff --git a/src/entitys/extensions.rs b/src/entitys/extensions.rs index 7fe45fe..6029c03 100644 --- a/src/entitys/extensions.rs +++ b/src/entitys/extensions.rs @@ -24,6 +24,7 @@ pub struct CreateExtensionParams { pub rating: Option, pub changelog: Option, pub published_at: Option>, + pub hash: Option, } /// 更新扩展参数(模型层入参) @@ -43,6 +44,7 @@ pub struct UpdateExtensionParams { pub rating: Option, pub changelog: Option, pub published_at: Option>, + pub hash: Option, } /// 查询扩展列表请求 diff --git a/src/models/extensions.rs b/src/models/extensions.rs index 4f0e61d..f092be1 100644 --- a/src/models/extensions.rs +++ b/src/models/extensions.rs @@ -18,12 +18,12 @@ pub async fn create_extension( INSERT INTO extensions ( extension_id, name, description, version, category, browser, developer, homepage, icon_url, download_url, file_size, downloads_count, - permissions, rating, changelog, published_at, status + permissions, rating, changelog, published_at, hash, status ) - VALUES ($1, $2, $3, $4, $5, $6, $7, $8, $9, $10, $11, $12, $13, $14, $15, $16, 'active') + VALUES ($1, $2, $3, $4, $5, $6, $7, $8, $9, $10, $11, $12, $13, $14, $15, $16, $17, 'active') RETURNING id, uuid, extension_id, name, description, version, category, browser, developer, homepage, icon_url, download_url, file_size, downloads_count, - rating, permissions, status, changelog, published_at, created_at, updated_at + rating, permissions, status, changelog, published_at, hash, created_at, updated_at "#, ) .bind(¶ms.extension_id) @@ -42,6 +42,7 @@ pub async fn create_extension( .bind(params.rating) .bind(¶ms.changelog) .bind(params.published_at) + .bind(¶ms.hash) .fetch_one(pool) .await?; @@ -71,6 +72,7 @@ pub async fn update_extension( rating = COALESCE($13, rating), changelog = COALESCE($14, changelog), published_at = COALESCE($15, published_at), + hash = COALESCE($16, hash), updated_at = CURRENT_TIMESTAMP WHERE extension_id = $1 "#, @@ -90,6 +92,7 @@ pub async fn update_extension( .bind(params.rating) .bind(params.changelog) .bind(params.published_at) + .bind(params.hash) .execute(pool) .await?; @@ -118,7 +121,7 @@ pub async fn fetch_extensions( r#" SELECT id, uuid, extension_id, name, description, version, category, browser, developer, homepage, icon_url, download_url, file_size, downloads_count, - rating, permissions, status, changelog, published_at, created_at, updated_at + rating, permissions, status, changelog, published_at, hash, created_at, updated_at FROM extensions WHERE status = 'active' AND ($1::varchar IS NULL OR name ILIKE '%' || $1 || '%' OR description ILIKE '%' || $1 || '%') @@ -168,7 +171,7 @@ pub async fn fetch_extension_by_id( r#" SELECT id, uuid, extension_id, name, description, version, category, browser, developer, homepage, icon_url, download_url, file_size, downloads_count, - rating, permissions, status, changelog, published_at, created_at, updated_at + rating, permissions, status, changelog, published_at, hash, created_at, updated_at FROM extensions WHERE extension_id = $1 "#, @@ -516,75 +519,75 @@ pub async fn fetch_team_group_extensions( Ok(recs) } - -// ============ User Team Extension Preferences ============ - -/// 查询用户禁用的团队插件列表 -pub async fn fetch_user_disabled_team_extensions( - pool: &Pool, - user_uuid: Uuid, - team_uuid: Uuid, -) -> Result, Error> { - let recs = sqlx::query_scalar::<_, String>( - r#" - SELECT extension_id - FROM user_team_extension_preferences - WHERE user_uuid = $1 AND team_uuid = $2 AND is_disabled = true - "#, - ) - .bind(user_uuid) - .bind(team_uuid) - .fetch_all(pool) - .await?; - - Ok(recs) -} - -/// 设置用户对团队插件的禁用状态 -pub async fn set_user_team_extension_preference( - pool: &Pool, - user_uuid: Uuid, - team_uuid: Uuid, - extension_id: &str, - is_disabled: bool, -) -> Result<(), Error> { - sqlx::query( - r#" - INSERT INTO user_team_extension_preferences (user_uuid, team_uuid, extension_id, is_disabled) - VALUES ($1, $2, $3, $4) - ON CONFLICT (user_uuid, team_uuid, extension_id) DO UPDATE SET - is_disabled = $4, - updated_at = CURRENT_TIMESTAMP - "#, - ) - .bind(user_uuid) - .bind(team_uuid) - .bind(extension_id) - .bind(is_disabled) - .execute(pool) - .await?; - - Ok(()) -} - -/// 删除用户对团队插件的偏好设置 -pub async fn delete_user_team_extension_preference( - pool: &Pool, - user_uuid: Uuid, - team_uuid: Uuid, - extension_id: &str, -) -> Result<(), Error> { - sqlx::query( - r#" - DELETE FROM user_team_extension_preferences - WHERE user_uuid = $1 AND team_uuid = $2 AND extension_id = $3 - "#, - ) - .bind(user_uuid) - .bind(team_uuid) - .bind(extension_id) - .execute(pool) - .await?; - - Ok(()) -} + +// ============ User Team Extension Preferences ============ + +/// 查询用户禁用的团队插件列表 +pub async fn fetch_user_disabled_team_extensions( + pool: &Pool, + user_uuid: Uuid, + team_uuid: Uuid, +) -> Result, Error> { + let recs = sqlx::query_scalar::<_, String>( + r#" + SELECT extension_id + FROM user_team_extension_preferences + WHERE user_uuid = $1 AND team_uuid = $2 AND is_disabled = true + "#, + ) + .bind(user_uuid) + .bind(team_uuid) + .fetch_all(pool) + .await?; + + Ok(recs) +} + +/// 设置用户对团队插件的禁用状态 +pub async fn set_user_team_extension_preference( + pool: &Pool, + user_uuid: Uuid, + team_uuid: Uuid, + extension_id: &str, + is_disabled: bool, +) -> Result<(), Error> { + sqlx::query( + r#" + INSERT INTO user_team_extension_preferences (user_uuid, team_uuid, extension_id, is_disabled) + VALUES ($1, $2, $3, $4) + ON CONFLICT (user_uuid, team_uuid, extension_id) DO UPDATE SET + is_disabled = $4, + updated_at = CURRENT_TIMESTAMP + "#, + ) + .bind(user_uuid) + .bind(team_uuid) + .bind(extension_id) + .bind(is_disabled) + .execute(pool) + .await?; + + Ok(()) +} + +/// 删除用户对团队插件的偏好设置 +pub async fn delete_user_team_extension_preference( + pool: &Pool, + user_uuid: Uuid, + team_uuid: Uuid, + extension_id: &str, +) -> Result<(), Error> { + sqlx::query( + r#" + DELETE FROM user_team_extension_preferences + WHERE user_uuid = $1 AND team_uuid = $2 AND extension_id = $3 + "#, + ) + .bind(user_uuid) + .bind(team_uuid) + .bind(extension_id) + .execute(pool) + .await?; + + Ok(()) +} diff --git a/src/services/extensions.rs b/src/services/extensions.rs index 4e860c1..414d977 100644 --- a/src/services/extensions.rs +++ b/src/services/extensions.rs @@ -858,6 +858,7 @@ pub async fn get_environment_extensions_service( version: ext.version, icon_url, download_url, + hash: ext.hash, scope: "user".to_string(), }, ); @@ -906,6 +907,7 @@ pub async fn get_environment_extensions_service( version: ext.version, icon_url, download_url, + hash: ext.hash, scope: "team".to_string(), }, ); @@ -961,6 +963,7 @@ pub async fn get_environment_extensions_service( version: ext.version, icon_url, download_url, + hash: ext.hash, scope, }, ); From 69525b386715ed1f176760cc4e3c3c3e4334a598 Mon Sep 17 00:00:00 2001 From: lius-new Date: Mon, 9 Mar 2026 01:57:10 +0800 Subject: [PATCH 18/60] =?UTF-8?q?feat(extensions):=20=E7=8E=AF=E5=A2=83?= =?UTF-8?q?=E8=AF=A6=E6=83=85=E6=8E=A5=E5=8F=A3=E8=BF=94=E5=9B=9E=E6=89=A9?= =?UTF-8?q?=E5=B1=95=E6=95=B0=E6=8D=AE?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit - get_environment_handler: 添加扩展列表查询 - batch_get_environments_handler: 批量查询时也返回扩展数据 - 调用 get_environment_extensions_service 获取用户、团队、分组的扩展 - 移除硬编码的空数组,返回实际扩展数据 Co-Authored-By: Claude Opus 4.6 (1M context) --- src/handlers/environments.rs | 24 ++++++++++++++++++++++-- 1 file changed, 22 insertions(+), 2 deletions(-) diff --git a/src/handlers/environments.rs b/src/handlers/environments.rs index 5c26d59..1c56f4c 100644 --- a/src/handlers/environments.rs +++ b/src/handlers/environments.rs @@ -349,6 +349,16 @@ pub async fn get_environment_handler( None }; + // 获取扩展列表 + let extensions = crate::services::extensions::get_environment_extensions_service( + &svc_ctx, + ctx.user_uuid_unwrap(), + team_uuid, + environment.group_uuid, + ) + .await + .unwrap_or_default(); + Ok(Response::success( Some("获取成功"), Some(EnvironmentDetailResponse { @@ -358,7 +368,7 @@ pub async fn get_environment_handler( accounts, group, proxy, - extensions: vec![], // handlers 中不需要返回扩展,由 service 层处理 + extensions, }), )) } @@ -417,6 +427,16 @@ pub async fn batch_get_environments_handler( None }; + // 获取扩展列表 + let extensions = crate::services::extensions::get_environment_extensions_service( + &svc_ctx, + ctx.user_uuid_unwrap(), + team_uuid, + environment.group_uuid, + ) + .await + .unwrap_or_default(); + Some(EnvironmentDetailResponse { environment, config, @@ -424,7 +444,7 @@ pub async fn batch_get_environments_handler( accounts, group, proxy, - extensions: vec![], // handlers 中不需要返回扩展,由 service 层处理 + extensions, }) } .await; From c00bdb8e625bca393e1f31f541491b5937d57679 Mon Sep 17 00:00:00 2001 From: lius-new Date: Mon, 9 Mar 2026 17:41:05 +0800 Subject: [PATCH 19/60] =?UTF-8?q?feat(environments):=20=E7=8E=AF=E5=A2=83?= =?UTF-8?q?=E8=AF=A6=E6=83=85=E6=8E=A5=E5=8F=A3=E8=BF=94=E5=9B=9E=E4=BB=A3?= =?UTF-8?q?=E7=90=86=E8=AE=A4=E8=AF=81=E4=BF=A1=E6=81=AF?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit 在环境详情和代理摘要接口中添加 username 和 password_encrypted 字段, 支持前端获取完整的代理配置信息用于启动浏览器。 Co-Authored-By: Claude Opus 4.6 (1M context) --- src/dto/environments.rs | 6 +++++- src/models/environments.rs | 3 ++- src/services/environments.rs | 6 ++++++ 3 files changed, 13 insertions(+), 2 deletions(-) diff --git a/src/dto/environments.rs b/src/dto/environments.rs index 14b196e..f08cfc0 100644 --- a/src/dto/environments.rs +++ b/src/dto/environments.rs @@ -130,6 +130,8 @@ pub struct ProxyRowDto { pub host: String, pub port: i32, pub proxy_type: String, + pub username: Option, + pub password_encrypted: Option, pub country: Option, pub city: Option, pub status: String, @@ -137,7 +139,7 @@ pub struct ProxyRowDto { pub last_check_ip: Option, } -/// 代理摘要 DTO(用于环境列表,排除敏感数据) +/// 代理摘要 DTO(用于环境列表) #[derive(Debug, Clone, Serialize, Deserialize)] pub struct ProxySummaryDto { pub id: i32, @@ -146,6 +148,8 @@ pub struct ProxySummaryDto { pub host: String, pub port: i32, pub proxy_type: String, + pub username: Option, + pub password_encrypted: Option, pub country: Option, pub city: Option, pub status: String, diff --git a/src/models/environments.rs b/src/models/environments.rs index 6880a76..2721070 100644 --- a/src/models/environments.rs +++ b/src/models/environments.rs @@ -1200,7 +1200,8 @@ pub async fn fetch_proxies_by_uuids( let recs = sqlx::query_as::<_, ProxyRowDto>( r#" - SELECT id, uuid, name, host, port, proxy_type, + SELECT id, uuid, name, host, port, proxy_type, + username, password_encrypted, country, city, status, latency, last_check_ip FROM proxies WHERE uuid = ANY($1) AND deleted_at IS NULL diff --git a/src/services/environments.rs b/src/services/environments.rs index 47fd9bc..14d3056 100644 --- a/src/services/environments.rs +++ b/src/services/environments.rs @@ -455,6 +455,8 @@ pub async fn get_environments_service( host: p.host.clone(), port: p.port, proxy_type: p.proxy_type.clone(), + username: p.username.clone(), + password_encrypted: p.password_encrypted.clone(), country: p.country.clone(), city: p.city.clone(), status: p.status.clone(), @@ -640,6 +642,8 @@ pub async fn get_proxy_summary_service( host: proxy.host, port: proxy.port, proxy_type: proxy.proxy_type, + username: proxy.username, + password_encrypted: proxy.password_encrypted, country: proxy.country, city: proxy.city, status: proxy.status, @@ -990,6 +994,8 @@ pub async fn get_recycle_bin_environments_service( host: p.host, port: p.port, proxy_type: p.proxy_type, + username: p.username, + password_encrypted: p.password_encrypted, country: p.country, city: p.city, status: p.status, From d2339414b402614fa02ad15d57aee27f0e1affda Mon Sep 17 00:00:00 2001 From: lius-new Date: Thu, 12 Mar 2026 16:15:43 +0800 Subject: [PATCH 20/60] refactor: simplify server-side rpa workflow APIs --- src/entitys/rpa.rs | 282 +++++++++++++++++++++----------------------- src/handlers/rpa.rs | 95 ++------------- src/models/rpa.rs | 8 +- src/routes/rpa.rs | 29 +---- src/services/rpa.rs | 139 ++++++---------------- 5 files changed, 192 insertions(+), 361 deletions(-) diff --git a/src/entitys/rpa.rs b/src/entitys/rpa.rs index ebc076e..ad830b4 100644 --- a/src/entitys/rpa.rs +++ b/src/entitys/rpa.rs @@ -1,149 +1,135 @@ -use serde::{Deserialize, Serialize}; -use uuid::Uuid; - -use super::Pagination; - -/// 查询 RPA 任务列表请求 -#[derive(Debug, Clone, Deserialize, Serialize)] -pub struct ListRpaTasksRequest { - #[serde(flatten)] - pub pagination: Pagination, - pub filters: Option, -} - -/// RPA 任务筛选条件 -#[derive(Debug, Clone, Deserialize, Serialize)] -pub struct RpaTaskFilters { - pub keyword: Option, - pub status: Option, - pub trigger_type: Option, - pub tags: Option>, -} - -/// 创建 RPA 任务请求 -#[derive(Debug, Clone, Deserialize, Serialize)] -pub struct CreateRpaTaskRequest { - pub name: String, - pub description: Option, - pub tags: Option>, - pub trigger_type: String, - pub schedule: Option, - pub cron_expression: Option, - pub run_mode: String, - pub retry_count: Option, - pub retry_interval: Option, - pub timeout: Option, - pub concurrency: Option, - pub stop_on_error: Option, - pub notify_on_complete: Option, - pub notify_on_error: Option, - pub environment_uuids: Option>, - pub steps: Option>, -} - -/// RPA 任务步骤请求 -#[derive(Debug, Clone, Deserialize, Serialize)] -pub struct RpaTaskStepRequest { - pub step_type: String, - pub name: String, - pub config: serde_json::Value, - pub enabled: Option, - pub position_x: Option, - pub position_y: Option, - pub sort_order: Option, -} - -/// 更新 RPA 任务请求 -#[derive(Debug, Clone, Deserialize, Serialize)] -pub struct UpdateRpaTaskRequest { - pub uuid: Uuid, - pub name: Option, - pub description: Option, - pub tags: Option>, - pub trigger_type: Option, - pub schedule: Option, - pub cron_expression: Option, - pub run_mode: Option, - pub retry_count: Option, - pub retry_interval: Option, - pub timeout: Option, - pub concurrency: Option, - pub stop_on_error: Option, - pub notify_on_complete: Option, - pub notify_on_error: Option, - pub environment_uuids: Option>, - pub steps: Option>, -} - -/// 运行 RPA 任务请求 -#[derive(Debug, Clone, Deserialize, Serialize)] -pub struct RunRpaTaskRequest { - pub uuid: Uuid, - pub environment_uuids: Option>, -} - -/// 复制 RPA 任务请求 -#[derive(Debug, Clone, Deserialize, Serialize)] -pub struct DuplicateRpaTaskRequest { - pub uuid: Uuid, - pub new_name: Option, -} - -/// 导出 RPA 任务请求 -#[derive(Debug, Clone, Deserialize, Serialize)] -pub struct ExportRpaTaskRequest { - pub uuid: Uuid, -} - -/// 导入 RPA 任务请求 -#[derive(Debug, Clone, Deserialize, Serialize)] -pub struct ImportRpaTaskRequest { - pub import_data: String, - pub name: Option, -} - -/// 查询执行记录请求 -#[derive(Debug, Clone, Deserialize, Serialize)] -pub struct ListRpaRunsRequest { - pub task_uuid: Uuid, - #[serde(flatten)] - pub pagination: Pagination, - pub status: Option, -} - -// ========== 响应结构体 ========== - -use crate::dto::{RpaTaskDto, RpaTaskRunDto, RpaTaskStepDto}; - -/// RPA 任务列表响应 -#[derive(Debug, Clone, Serialize)] -pub struct RpaTaskListResponse { - pub items: Vec, - pub total: i64, - pub page: i64, - pub page_size: i64, -} - -/// RPA 任务详情响应 -#[derive(Debug, Clone, Serialize)] -pub struct RpaTaskDetailResponse { - pub task: RpaTaskDto, - pub steps: Vec, - pub environment_uuids: Vec, -} - -/// RPA 任务执行记录列表响应 -#[derive(Debug, Clone, Serialize)] -pub struct RpaRunsListResponse { - pub items: Vec, - pub total: i64, - pub page: i64, - pub page_size: i64, -} - -/// 导出 RPA 任务响应 -#[derive(Debug, Clone, Serialize)] -pub struct ExportRpaTaskResponse { - pub content: String, - pub filename: String, +use serde::{Deserialize, Serialize}; +use uuid::Uuid; + +use super::Pagination; + +#[derive(Debug, Clone, Deserialize, Serialize)] +pub struct ListRpaTasksRequest { + #[serde(flatten)] + pub pagination: Pagination, + pub filters: Option, +} + +#[derive(Debug, Clone, Deserialize, Serialize)] +pub struct RpaTaskFilters { + pub keyword: Option, + pub status: Option, + pub trigger_type: Option, + pub tags: Option>, +} + +#[derive(Debug, Clone, Deserialize, Serialize)] +pub struct CreateRpaTaskRequest { + pub name: String, + pub description: Option, + pub tags: Option>, + pub trigger_type: String, + pub schedule: Option, + pub cron_expression: Option, + pub run_mode: String, + pub retry_count: Option, + pub retry_interval: Option, + pub timeout: Option, + pub concurrency: Option, + pub stop_on_error: Option, + pub notify_on_complete: Option, + pub notify_on_error: Option, + pub environment_uuids: Option>, + pub steps: Option>, +} + +#[derive(Debug, Clone, Deserialize, Serialize)] +pub struct RpaTaskStepRequest { + pub step_type: String, + pub name: String, + pub config: serde_json::Value, + pub enabled: Option, + pub position_x: Option, + pub position_y: Option, + pub sort_order: Option, + pub next_step_uuid: Option, + pub branch_config: Option, +} + +#[derive(Debug, Clone, Deserialize, Serialize)] +pub struct UpdateRpaTaskRequest { + pub uuid: Uuid, + pub name: Option, + pub description: Option, + pub tags: Option>, + pub trigger_type: Option, + pub schedule: Option, + pub cron_expression: Option, + pub run_mode: Option, + pub retry_count: Option, + pub retry_interval: Option, + pub timeout: Option, + pub concurrency: Option, + pub stop_on_error: Option, + pub notify_on_complete: Option, + pub notify_on_error: Option, + pub environment_uuids: Option>, + pub steps: Option>, +} + +#[derive(Debug, Clone, Deserialize, Serialize)] +pub struct RunRpaTaskRequest { + pub uuid: Uuid, + pub environment_uuids: Option>, +} + +#[derive(Debug, Clone, Deserialize, Serialize)] +pub struct DuplicateRpaTaskRequest { + pub uuid: Uuid, + pub new_name: Option, +} + +#[derive(Debug, Clone, Deserialize, Serialize)] +pub struct ExportRpaTaskRequest { + pub uuid: Uuid, +} + +#[derive(Debug, Clone, Deserialize, Serialize)] +pub struct ImportRpaTaskRequest { + pub import_data: String, + pub name: Option, +} + +#[derive(Debug, Clone, Deserialize, Serialize)] +pub struct ListRpaRunsRequest { + pub task_uuid: Uuid, + #[serde(flatten)] + pub pagination: Pagination, + pub status: Option, +} + +use crate::dto::{RpaTaskDto, RpaTaskRunDto, RpaTaskStepDto}; + +#[derive(Debug, Clone, Serialize)] +pub struct RpaTaskListResponse { + pub items: Vec, + pub total: i64, + pub page: i64, + pub page_size: i64, +} + +#[derive(Debug, Clone, Serialize)] +pub struct RpaTaskDetailResponse { + pub task: RpaTaskDto, + pub steps: Vec, + pub environment_uuids: Vec, +} + +#[derive(Debug, Clone, Serialize)] +pub struct RpaRunsListResponse { + pub items: Vec, + pub total: i64, + pub page: i64, + pub page_size: i64, +} + +#[derive(Debug, Clone, Serialize)] +pub struct ExportRpaTaskResponse { + pub content: String, + pub filename: String, } diff --git a/src/handlers/rpa.rs b/src/handlers/rpa.rs index 908733c..5347506 100644 --- a/src/handlers/rpa.rs +++ b/src/handlers/rpa.rs @@ -1,18 +1,15 @@ use axum::extract::{Extension, State}; -use crate::dto::RpaTaskRunDto; use crate::entitys::{ BatchUuidRequest, CreateResponse, CreateRpaTaskRequest, DuplicateRpaTaskRequest, - ExportRpaTaskRequest, ExportRpaTaskResponse, ListRpaRunsRequest, ListRpaTasksRequest, - RpaRunsListResponse, RpaTaskDetailResponse, RpaTaskListResponse, RunRpaTaskRequest, - UpdateRpaTaskRequest, UuidRequest, + ExportRpaTaskRequest, ExportRpaTaskResponse, ListRpaTasksRequest, RpaTaskDetailResponse, + RpaTaskListResponse, UpdateRpaTaskRequest, UuidRequest, }; use crate::services; use crate::state::RequestContext; use crate::svc_ctx::SvcCtx; use crate::utils::{Json, Response, Result}; -/// 获取 RPA 任务列表 pub async fn get_rpa_tasks_handler( State(svc_ctx): State, Extension(ctx): Extension, @@ -28,7 +25,7 @@ pub async fn get_rpa_tasks_handler( .map_err(|e| Response::fail(Some(&e)))?; Ok(Response::success( - Some("获取成功"), + Some("ok"), Some(RpaTaskListResponse { items, total, @@ -38,7 +35,6 @@ pub async fn get_rpa_tasks_handler( )) } -/// 获取 RPA 任务详情 pub async fn get_rpa_task_handler( State(svc_ctx): State, Json(payload): Json, @@ -49,7 +45,7 @@ pub async fn get_rpa_task_handler( .map_err(|e| Response::fail(Some(&e)))?; Ok(Response::success( - Some("获取成功"), + Some("ok"), Some(RpaTaskDetailResponse { task, steps, @@ -58,7 +54,6 @@ pub async fn get_rpa_task_handler( )) } -/// 创建 RPA 任务 pub async fn create_rpa_task_handler( State(svc_ctx): State, Extension(ctx): Extension, @@ -77,13 +72,9 @@ pub async fn create_rpa_task_handler( .await .map_err(|e| Response::fail(Some(&e)))?; - Ok(Response::success( - Some("创建成功"), - Some(CreateResponse { uuid }), - )) + Ok(Response::success(Some("created"), Some(CreateResponse { uuid }))) } -/// 更新 RPA 任务 pub async fn update_rpa_task_handler( State(svc_ctx): State, Json(payload): Json, @@ -92,10 +83,9 @@ pub async fn update_rpa_task_handler( .await .map_err(|e| Response::fail(Some(&e)))?; - Ok(Response::success(Some("更新成功"), None)) + Ok(Response::success(Some("updated"), None)) } -/// 删除 RPA 任务 pub async fn delete_rpa_task_handler( State(svc_ctx): State, Json(payload): Json, @@ -104,10 +94,9 @@ pub async fn delete_rpa_task_handler( .await .map_err(|e| Response::fail(Some(&e)))?; - Ok(Response::success(Some("删除成功"), None)) + Ok(Response::success(Some("deleted"), None)) } -/// 批量删除 RPA 任务 pub async fn batch_delete_rpa_tasks_handler( State(svc_ctx): State, Json(payload): Json, @@ -116,37 +105,9 @@ pub async fn batch_delete_rpa_tasks_handler( .await .map_err(|e| Response::fail(Some(&e)))?; - Ok(Response::success(Some("批量删除成功"), None)) -} - -/// 运行 RPA 任务 -pub async fn run_rpa_task_handler( - State(svc_ctx): State, - Json(payload): Json, -) -> Result { - let run_uuid = services::rpa::run_rpa_task_service(&svc_ctx, payload.uuid) - .await - .map_err(|e| Response::fail(Some(&e)))?; - - Ok(Response::success( - Some("任务已启动"), - Some(CreateResponse { uuid: run_uuid }), - )) + Ok(Response::success(Some("deleted"), None)) } -/// 停止 RPA 任务 -pub async fn stop_rpa_task_handler( - State(svc_ctx): State, - Json(payload): Json, -) -> Result<()> { - services::rpa::stop_rpa_task_service(&svc_ctx, payload.uuid) - .await - .map_err(|e| Response::fail(Some(&e)))?; - - Ok(Response::success(Some("任务已停止"), None)) -} - -/// 复制 RPA 任务 pub async fn duplicate_rpa_task_handler( State(svc_ctx): State, Extension(ctx): Extension, @@ -165,13 +126,9 @@ pub async fn duplicate_rpa_task_handler( .await .map_err(|e| Response::fail(Some(&e)))?; - Ok(Response::success( - Some("复制成功"), - Some(CreateResponse { uuid }), - )) + Ok(Response::success(Some("duplicated"), Some(CreateResponse { uuid }))) } -/// 导出 RPA 任务 pub async fn export_rpa_task_handler( State(svc_ctx): State, Json(payload): Json, @@ -181,39 +138,7 @@ pub async fn export_rpa_task_handler( .map_err(|e| Response::fail(Some(&e)))?; Ok(Response::success( - Some("导出成功"), + Some("exported"), Some(ExportRpaTaskResponse { content, filename }), )) } - -/// 获取执行记录列表 -pub async fn get_rpa_runs_handler( - State(svc_ctx): State, - Json(payload): Json, -) -> Result { - let (items, total) = services::rpa::get_rpa_runs_service(&svc_ctx, &payload) - .await - .map_err(|e| Response::fail(Some(&e)))?; - - Ok(Response::success( - Some("获取成功"), - Some(RpaRunsListResponse { - items, - total, - page: payload.pagination.page, - page_size: payload.pagination.page_size, - }), - )) -} - -/// 获取执行记录详情 -pub async fn get_rpa_run_handler( - State(svc_ctx): State, - Json(payload): Json, -) -> Result { - let run = services::rpa::get_rpa_run_service(&svc_ctx, payload.uuid) - .await - .map_err(|e| Response::fail(Some(&e)))?; - - Ok(Response::success(Some("获取成功"), Some(run))) -} diff --git a/src/models/rpa.rs b/src/models/rpa.rs index 8de2fb9..cd307aa 100644 --- a/src/models/rpa.rs +++ b/src/models/rpa.rs @@ -1,4 +1,4 @@ -use sqlx::{Error, Pool, Postgres}; +use sqlx::{Error, Pool, Postgres}; use uuid::Uuid; use crate::dto::{RpaTaskDto, RpaTaskEnvironmentDto, RpaTaskRunDto, RpaTaskStepDto}; @@ -270,6 +270,8 @@ pub async fn insert_rpa_task_step( position_x: Option, position_y: Option, sort_order: Option, + next_step_uuid: Option, + branch_config: Option<&serde_json::Value>, ) -> Result { let uuid: Uuid = sqlx::query_scalar( r#" @@ -287,6 +289,8 @@ pub async fn insert_rpa_task_step( .bind(position_x) .bind(position_y) .bind(sort_order) + .bind(next_step_uuid) + .bind(branch_config) .fetch_one(pool) .await?; @@ -525,3 +529,5 @@ pub async fn update_rpa_task_run_status( Ok(()) } + + diff --git a/src/routes/rpa.rs b/src/routes/rpa.rs index 51d980c..4e715ef 100644 --- a/src/routes/rpa.rs +++ b/src/routes/rpa.rs @@ -2,21 +2,15 @@ use axum::routing::post; use crate::handlers::{ batch_delete_rpa_tasks_handler, create_rpa_task_handler, delete_rpa_task_handler, - duplicate_rpa_task_handler, export_rpa_task_handler, get_rpa_run_handler, get_rpa_runs_handler, - get_rpa_task_handler, get_rpa_tasks_handler, run_rpa_task_handler, stop_rpa_task_handler, - update_rpa_task_handler, + duplicate_rpa_task_handler, export_rpa_task_handler, get_rpa_task_handler, + get_rpa_tasks_handler, update_rpa_task_handler, }; use crate::routes::route::{self, MetaRoute}; -/// 注册 RPA 工作流相关的路由 pub fn register_routes(meta_route: &mut MetaRoute) { let mut rpa_route = route::RouteGroup::new("/rpa"); - // 任务管理 - rpa_route.add_route_item(route::RouteItem::post( - "/tasks", - post(get_rpa_tasks_handler), - )); + rpa_route.add_route_item(route::RouteItem::post("/tasks", post(get_rpa_tasks_handler))); rpa_route.add_route_item(route::RouteItem::post( "/tasks/detail", post(get_rpa_task_handler), @@ -37,16 +31,6 @@ pub fn register_routes(meta_route: &mut MetaRoute) { "/tasks/batch-delete", post(batch_delete_rpa_tasks_handler), )); - - // 任务操作 - rpa_route.add_route_item(route::RouteItem::post( - "/tasks/run", - post(run_rpa_task_handler), - )); - rpa_route.add_route_item(route::RouteItem::post( - "/tasks/stop", - post(stop_rpa_task_handler), - )); rpa_route.add_route_item(route::RouteItem::post( "/tasks/duplicate", post(duplicate_rpa_task_handler), @@ -56,12 +40,5 @@ pub fn register_routes(meta_route: &mut MetaRoute) { post(export_rpa_task_handler), )); - // 执行记录 - rpa_route.add_route_item(route::RouteItem::post("/runs", post(get_rpa_runs_handler))); - rpa_route.add_route_item(route::RouteItem::post( - "/runs/detail", - post(get_rpa_run_handler), - )); - meta_route.add_route_group(rpa_route); } diff --git a/src/services/rpa.rs b/src/services/rpa.rs index 18e7d70..69f988d 100644 --- a/src/services/rpa.rs +++ b/src/services/rpa.rs @@ -1,14 +1,13 @@ -use uuid::Uuid; +use uuid::Uuid; -use crate::dto::{RpaTaskDto, RpaTaskRunDto, RpaTaskStepDto}; -use crate::entitys::{ - CreateRpaTaskRequest, DuplicateRpaTaskRequest, ListRpaRunsRequest, ListRpaTasksRequest, - UpdateRpaTaskRequest, -}; +use crate::dto::{RpaTaskDto, RpaTaskStepDto}; +use crate::entitys::{ + CreateRpaTaskRequest, DuplicateRpaTaskRequest, ListRpaTasksRequest, UpdateRpaTaskRequest, +}; use crate::models; use crate::svc_ctx::SvcCtx; -/// 获取 RPA 任务列表 +/// List RPA tasks. pub async fn get_rpa_tasks_service( svc_ctx: &SvcCtx, user_uuid: Uuid, @@ -40,7 +39,7 @@ pub async fn get_rpa_tasks_service( Ok((tasks, total)) } -/// 获取 RPA 任务详情 +/// Get RPA task detail. pub async fn get_rpa_task_service( svc_ctx: &SvcCtx, task_uuid: Uuid, @@ -48,7 +47,7 @@ pub async fn get_rpa_task_service( let task = models::rpa::fetch_rpa_task_by_uuid(&svc_ctx.db, task_uuid) .await .map_err(|e| e.to_string())? - .ok_or_else(|| "RPA 任务不存在".to_string())?; + .ok_or_else(|| "RPA task not found".to_string())?; let steps = models::rpa::fetch_rpa_task_steps(&svc_ctx.db, task_uuid) .await @@ -64,7 +63,7 @@ pub async fn get_rpa_task_service( Ok((task, steps, environment_uuids)) } -/// 创建 RPA 任务 +/// Create an RPA task. pub async fn create_rpa_task_service( svc_ctx: &SvcCtx, user_uuid: Uuid, @@ -95,7 +94,7 @@ pub async fn create_rpa_task_service( .await .map_err(|e| e.to_string())?; - // 添加步骤 + // Persist ordered steps. if let Some(steps) = &payload.steps { for (i, step) in steps.iter().enumerate() { models::rpa::insert_rpa_task_step( @@ -108,13 +107,15 @@ pub async fn create_rpa_task_service( step.position_x, step.position_y, Some(step.sort_order.unwrap_or(i as i32)), + step.next_step_uuid, + step.branch_config.as_ref(), ) .await .map_err(|e| e.to_string())?; } } - // 添加环境关联 + // Persist environment bindings. if let Some(env_uuids) = &payload.environment_uuids { for (i, env_uuid) in env_uuids.iter().enumerate() { models::rpa::insert_rpa_task_environment( @@ -131,7 +132,7 @@ pub async fn create_rpa_task_service( Ok(task_uuid) } -/// 更新 RPA 任务 +/// Update an RPA task. pub async fn update_rpa_task_service( svc_ctx: &SvcCtx, payload: &UpdateRpaTaskRequest, @@ -159,14 +160,14 @@ pub async fn update_rpa_task_service( .await .map_err(|e| e.to_string())?; - // 更新步骤(如果提供) + // Replace stored steps when the caller sends a full step list. if let Some(steps) = &payload.steps { - // 先删除旧步骤 + // Remove previous step rows first. models::rpa::delete_rpa_task_steps(&svc_ctx.db, payload.uuid) .await .map_err(|e| e.to_string())?; - // 添加新步骤 + // Insert the new step rows. for (i, step) in steps.iter().enumerate() { models::rpa::insert_rpa_task_step( &svc_ctx.db, @@ -178,20 +179,22 @@ pub async fn update_rpa_task_service( step.position_x, step.position_y, Some(step.sort_order.unwrap_or(i as i32)), + step.next_step_uuid, + step.branch_config.as_ref(), ) .await .map_err(|e| e.to_string())?; } } - // 更新环境关联(如果提供) + // Replace environment bindings when provided. if let Some(env_uuids) = &payload.environment_uuids { - // 先删除旧关联 + // Remove previous environment bindings. models::rpa::delete_rpa_task_environments(&svc_ctx.db, payload.uuid) .await .map_err(|e| e.to_string())?; - // 添加新关联 + // Insert the new environment bindings. for (i, env_uuid) in env_uuids.iter().enumerate() { models::rpa::insert_rpa_task_environment( &svc_ctx.db, @@ -207,14 +210,14 @@ pub async fn update_rpa_task_service( Ok(()) } -/// 删除 RPA 任务 +/// Soft-delete an RPA task. pub async fn delete_rpa_task_service(svc_ctx: &SvcCtx, task_uuid: Uuid) -> Result<(), String> { models::rpa::delete_rpa_task(&svc_ctx.db, task_uuid) .await .map_err(|e| e.to_string()) } -/// 批量删除 RPA 任务 +/// Soft-delete RPA tasks in batch. pub async fn batch_delete_rpa_tasks_service( svc_ctx: &SvcCtx, task_uuids: &[Uuid], @@ -224,56 +227,22 @@ pub async fn batch_delete_rpa_tasks_service( .map_err(|e| e.to_string()) } -/// 运行 RPA 任务 -pub async fn run_rpa_task_service(svc_ctx: &SvcCtx, task_uuid: Uuid) -> Result { - // 获取任务信息 - let task = models::rpa::fetch_rpa_task_by_uuid(&svc_ctx.db, task_uuid) - .await - .map_err(|e| e.to_string())? - .ok_or_else(|| "RPA 任务不存在".to_string())?; - - if task.status == "running" { - return Err("任务正在运行中".to_string()); - } - - // 获取步骤数 - let steps = models::rpa::fetch_rpa_task_steps(&svc_ctx.db, task_uuid) - .await - .map_err(|e| e.to_string())?; - - // 更新任务状态为运行中 - models::rpa::update_rpa_task_status(&svc_ctx.db, task_uuid, "running") - .await - .map_err(|e| e.to_string())?; - - // 创建执行记录 - let run_uuid = models::rpa::insert_rpa_task_run(&svc_ctx.db, task_uuid, steps.len() as i32) - .await - .map_err(|e| e.to_string())?; - - Ok(run_uuid) -} - -/// 停止 RPA 任务 -pub async fn stop_rpa_task_service(svc_ctx: &SvcCtx, task_uuid: Uuid) -> Result<(), String> { - models::rpa::update_rpa_task_status(&svc_ctx.db, task_uuid, "idle") - .await - .map_err(|e| e.to_string()) -} - -/// 复制 RPA 任务 +/// Duplicate an RPA task. pub async fn duplicate_rpa_task_service( svc_ctx: &SvcCtx, user_uuid: Uuid, team_uuid: Option, payload: &DuplicateRpaTaskRequest, ) -> Result { - // 获取原任务信息 + // Load source task data. let (task, steps, environment_uuids) = get_rpa_task_service(svc_ctx, payload.uuid).await?; - let new_name = payload.new_name.clone().unwrap_or_else(|| format!("{} (副本)", task.name)); + let new_name = payload + .new_name + .clone() + .unwrap_or_else(|| format!("{} (copy)", task.name)); - // 创建新任务 + // Create duplicated task row. let new_task_uuid = models::rpa::insert_rpa_task( &svc_ctx.db, user_uuid, @@ -296,7 +265,7 @@ pub async fn duplicate_rpa_task_service( .await .map_err(|e| e.to_string())?; - // 复制步骤 + // Copy steps. for step in steps { models::rpa::insert_rpa_task_step( &svc_ctx.db, @@ -308,12 +277,14 @@ pub async fn duplicate_rpa_task_service( step.position_x, step.position_y, step.sort_order, + step.next_step_uuid, + step.branch_config.as_ref(), ) .await .map_err(|e| e.to_string())?; } - // 复制环境关联 + // Copy environment bindings. for (i, env_uuid) in environment_uuids.iter().enumerate() { models::rpa::insert_rpa_task_environment( &svc_ctx.db, @@ -328,7 +299,7 @@ pub async fn duplicate_rpa_task_service( Ok(new_task_uuid) } -/// 导出 RPA 任务 +/// Export an RPA task. pub async fn export_rpa_task_service( svc_ctx: &SvcCtx, task_uuid: Uuid, @@ -358,6 +329,8 @@ pub async fn export_rpa_task_service( "position_x": s.position_x, "position_y": s.position_y, "sort_order": s.sort_order, + "next_step_uuid": s.next_step_uuid, + "branch_config": s.branch_config, })).collect::>(), "environment_uuids": environment_uuids, }); @@ -368,41 +341,5 @@ pub async fn export_rpa_task_service( Ok((content, filename)) } -/// 获取执行记录列表 -pub async fn get_rpa_runs_service( - svc_ctx: &SvcCtx, - payload: &ListRpaRunsRequest, -) -> Result<(Vec, i64), String> { - let offset = (payload.pagination.page - 1) * payload.pagination.page_size; - - let runs = models::rpa::fetch_rpa_task_runs( - &svc_ctx.db, - payload.task_uuid, - payload.status.as_deref(), - offset, - payload.pagination.page_size, - ) - .await - .map_err(|e| e.to_string())?; - - let total = models::rpa::fetch_rpa_task_runs_count( - &svc_ctx.db, - payload.task_uuid, - payload.status.as_deref(), - ) - .await - .map_err(|e| e.to_string())?; - Ok((runs, total)) -} -/// 获取执行记录详情 -pub async fn get_rpa_run_service( - svc_ctx: &SvcCtx, - run_uuid: Uuid, -) -> Result { - models::rpa::fetch_rpa_task_run_by_uuid(&svc_ctx.db, run_uuid) - .await - .map_err(|e| e.to_string())? - .ok_or_else(|| "执行记录不存在".to_string()) -} From ecae2b5e348a85c5ba1cec28e3eb70ab2ec0d3a1 Mon Sep 17 00:00:00 2001 From: lius-new Date: Thu, 12 Mar 2026 22:40:05 +0800 Subject: [PATCH 21/60] feat: support rpa task search and environment counts --- src/dto/rpa.rs | 15 ++--- src/models/rpa.rs | 132 +++++++++++++++++++++++--------------------- src/services/rpa.rs | 45 +++++++++------ 3 files changed, 105 insertions(+), 87 deletions(-) diff --git a/src/dto/rpa.rs b/src/dto/rpa.rs index f65db79..acc72d9 100644 --- a/src/dto/rpa.rs +++ b/src/dto/rpa.rs @@ -5,9 +5,9 @@ use uuid::Uuid; /// RPA 任务 DTO #[derive(Debug, Clone, FromRow, Serialize)] -pub struct RpaTaskDto { - pub id: i32, - pub uuid: Uuid, +pub struct RpaTaskDto { + pub id: i32, + pub uuid: Uuid, pub user_uuid: Uuid, pub team_uuid: Option, pub name: String, @@ -25,10 +25,11 @@ pub struct RpaTaskDto { pub notify_on_complete: Option, pub notify_on_error: Option, pub status: String, - pub run_count: Option, - pub success_count: Option, - pub last_run_at: Option>, - pub next_run_at: Option>, + pub run_count: Option, + pub success_count: Option, + pub environment_count: Option, + pub last_run_at: Option>, + pub next_run_at: Option>, pub created_at: DateTime, pub updated_at: DateTime, pub deleted_at: Option>, diff --git a/src/models/rpa.rs b/src/models/rpa.rs index cd307aa..68b5ad3 100644 --- a/src/models/rpa.rs +++ b/src/models/rpa.rs @@ -57,65 +57,72 @@ pub async fn insert_rpa_task( } /// 查询 RPA 任务列表 -pub async fn fetch_rpa_tasks( - pool: &Pool, - team_uuid: Option, - user_uuid: Uuid, - status: Option<&str>, - trigger_type: Option<&str>, - offset: i64, - limit: i64, -) -> Result, Error> { +pub async fn fetch_rpa_tasks( + pool: &Pool, + team_uuid: Option, + user_uuid: Uuid, + keyword: Option<&str>, + status: Option<&str>, + trigger_type: Option<&str>, + offset: i64, + limit: i64, +) -> Result, Error> { let recs = sqlx::query_as::<_, RpaTaskDto>( r#" - SELECT id, uuid, user_uuid, team_uuid, name, description, tags, trigger_type, - schedule, cron_expression, run_mode, retry_count, retry_interval, timeout, - concurrency, stop_on_error, notify_on_complete, notify_on_error, status, - run_count, success_count, last_run_at, next_run_at, created_at, updated_at, deleted_at - FROM rpa_tasks - WHERE (team_uuid = $1 OR (team_uuid IS NULL AND user_uuid = $2)) - AND ($3::varchar IS NULL OR status = $3) - AND ($4::varchar IS NULL OR trigger_type = $4) - AND deleted_at IS NULL - ORDER BY created_at DESC - LIMIT $5 OFFSET $6 - "#, - ) - .bind(team_uuid) - .bind(user_uuid) - .bind(status) - .bind(trigger_type) - .bind(limit) - .bind(offset) - .fetch_all(pool) - .await?; - - Ok(recs) -} - -/// 查询 RPA 任务总数 -pub async fn fetch_rpa_tasks_count( - pool: &Pool, - team_uuid: Option, - user_uuid: Uuid, - status: Option<&str>, - trigger_type: Option<&str>, -) -> Result { - let count: i64 = sqlx::query_scalar( - r#" - SELECT COUNT(*) FROM rpa_tasks - WHERE (team_uuid = $1 OR (team_uuid IS NULL AND user_uuid = $2)) - AND ($3::varchar IS NULL OR status = $3) - AND ($4::varchar IS NULL OR trigger_type = $4) - AND deleted_at IS NULL - "#, - ) - .bind(team_uuid) - .bind(user_uuid) - .bind(status) - .bind(trigger_type) - .fetch_one(pool) - .await?; + SELECT id, uuid, user_uuid, team_uuid, name, description, tags, trigger_type, + schedule, cron_expression, run_mode, retry_count, retry_interval, timeout, + concurrency, stop_on_error, notify_on_complete, notify_on_error, status, + run_count, success_count, last_run_at, next_run_at, created_at, updated_at, deleted_at + , (SELECT COUNT(*) FROM rpa_task_environments rte WHERE rte.task_uuid = rpa_tasks.uuid) AS environment_count + FROM rpa_tasks + WHERE (team_uuid = $1 OR (team_uuid IS NULL AND user_uuid = $2)) + AND ($3::varchar IS NULL OR name ILIKE $3 OR COALESCE(description, '') ILIKE $3) + AND ($4::varchar IS NULL OR status = $4) + AND ($5::varchar IS NULL OR trigger_type = $5) + AND deleted_at IS NULL + ORDER BY created_at DESC + LIMIT $6 OFFSET $7 + "#, + ) + .bind(team_uuid) + .bind(user_uuid) + .bind(keyword.map(|k| format!("%{}%", k))) + .bind(status) + .bind(trigger_type) + .bind(limit) + .bind(offset) + .fetch_all(pool) + .await?; + + Ok(recs) +} + +/// 查询 RPA 任务总数 +pub async fn fetch_rpa_tasks_count( + pool: &Pool, + team_uuid: Option, + user_uuid: Uuid, + keyword: Option<&str>, + status: Option<&str>, + trigger_type: Option<&str>, +) -> Result { + let count: i64 = sqlx::query_scalar( + r#" + SELECT COUNT(*) FROM rpa_tasks + WHERE (team_uuid = $1 OR (team_uuid IS NULL AND user_uuid = $2)) + AND ($3::varchar IS NULL OR status = $3) + AND ($4::varchar IS NULL OR trigger_type = $4) + AND ($5::varchar IS NULL OR name ILIKE $5 OR COALESCE(description, '') ILIKE $5) + AND deleted_at IS NULL + "#, + ) + .bind(team_uuid) + .bind(user_uuid) + .bind(status) + .bind(trigger_type) + .bind(keyword.map(|k| format!("%{}%", k))) + .fetch_one(pool) + .await?; Ok(count) } @@ -127,11 +134,12 @@ pub async fn fetch_rpa_task_by_uuid( ) -> Result, Error> { let rec = sqlx::query_as::<_, RpaTaskDto>( r#" - SELECT id, uuid, user_uuid, team_uuid, name, description, tags, trigger_type, - schedule, cron_expression, run_mode, retry_count, retry_interval, timeout, - concurrency, stop_on_error, notify_on_complete, notify_on_error, status, - run_count, success_count, last_run_at, next_run_at, created_at, updated_at, deleted_at - FROM rpa_tasks + SELECT id, uuid, user_uuid, team_uuid, name, description, tags, trigger_type, + schedule, cron_expression, run_mode, retry_count, retry_interval, timeout, + concurrency, stop_on_error, notify_on_complete, notify_on_error, status, + run_count, success_count, last_run_at, next_run_at, created_at, updated_at, deleted_at + , (SELECT COUNT(*) FROM rpa_task_environments rte WHERE rte.task_uuid = rpa_tasks.uuid) AS environment_count + FROM rpa_tasks WHERE uuid = $1 AND deleted_at IS NULL "#, ) diff --git a/src/services/rpa.rs b/src/services/rpa.rs index 69f988d..4b58e8e 100644 --- a/src/services/rpa.rs +++ b/src/services/rpa.rs @@ -13,28 +13,37 @@ pub async fn get_rpa_tasks_service( user_uuid: Uuid, team_uuid: Option, payload: &ListRpaTasksRequest, -) -> Result<(Vec, i64), String> { - let offset = (payload.pagination.page - 1) * payload.pagination.page_size; - - let status = payload.filters.as_ref().and_then(|f| f.status.as_deref()); - let trigger_type = payload.filters.as_ref().and_then(|f| f.trigger_type.as_deref()); - - let tasks = models::rpa::fetch_rpa_tasks( - &svc_ctx.db, - team_uuid, - user_uuid, - status, - trigger_type, - offset, - payload.pagination.page_size, +) -> Result<(Vec, i64), String> { + let offset = (payload.pagination.page - 1) * payload.pagination.page_size; + + let keyword = payload.filters.as_ref().and_then(|f| f.keyword.as_deref()); + let status = payload.filters.as_ref().and_then(|f| f.status.as_deref()); + let trigger_type = payload.filters.as_ref().and_then(|f| f.trigger_type.as_deref()); + + let tasks = models::rpa::fetch_rpa_tasks( + &svc_ctx.db, + team_uuid, + user_uuid, + keyword, + status, + trigger_type, + offset, + payload.pagination.page_size, ) .await .map_err(|e| e.to_string())?; - let total = - models::rpa::fetch_rpa_tasks_count(&svc_ctx.db, team_uuid, user_uuid, status, trigger_type) - .await - .map_err(|e| e.to_string())?; + let total = + models::rpa::fetch_rpa_tasks_count( + &svc_ctx.db, + team_uuid, + user_uuid, + keyword, + status, + trigger_type, + ) + .await + .map_err(|e| e.to_string())?; Ok((tasks, total)) } From de3da101b46c2747e6da99030dfd40a408ade08b Mon Sep 17 00:00:00 2001 From: lius-new Date: Fri, 13 Mar 2026 17:41:08 +0800 Subject: [PATCH 22/60] remove legacy server event sync protocol --- src/dto/events.rs | 95 ------- src/dto/mod.rs | 13 +- src/handlers.rs | 1 - src/handlers/accounts.rs | 77 +----- src/handlers/environments.rs | 493 ++++------------------------------- src/handlers/events.rs | 89 ------- src/handlers/proxies.rs | 77 +----- src/main.rs | 3 +- src/routes.rs | 1 - src/routes/events.rs | 18 -- src/services.rs | 1 - src/services/events.rs | 182 ------------- 12 files changed, 84 insertions(+), 966 deletions(-) delete mode 100644 src/dto/events.rs delete mode 100644 src/handlers/events.rs delete mode 100644 src/routes/events.rs delete mode 100644 src/services/events.rs diff --git a/src/dto/events.rs b/src/dto/events.rs deleted file mode 100644 index 5574b07..0000000 --- a/src/dto/events.rs +++ /dev/null @@ -1,95 +0,0 @@ -use serde::{Deserialize, Serialize}; -use uuid::Uuid; - -/// 工作空间事件 -#[derive(Debug, Clone, Serialize, Deserialize)] -pub struct WorkspaceEvent { - /// 事件 ID(Redis Stream 自动生成) - pub event_id: String, - - /// 工作空间 ID - pub workspace_uuid: Uuid, - - /// 事件类型 - pub event_type: EventType, - - /// 实体类型 - pub entity_type: EntityType, - - /// 实体 ID(可选,如批量删除时为空) - pub entity_id: Option, - - /// 受影响的 API 列表 - pub affected_apis: Vec, - - /// 事件负载(可选,用于携带额外信息) - #[serde(skip_serializing_if = "Option::is_none")] - pub payload: Option, - - /// 创建时间戳(毫秒) - pub created_at: i64, -} - -/// 事件类型 -#[derive(Debug, Clone, Serialize, Deserialize)] -#[serde(rename_all = "snake_case")] -pub enum EventType { - /// 创建 - Created, - /// 更新 - Updated, - /// 删除 - Deleted, - /// 批量删除 - BatchDeleted, - /// 移动 - Moved, - /// 分配标签 - TagsAssigned, - /// 移除标签 - TagsRemoved, -} - -/// 实体类型 -#[derive(Debug, Clone, Serialize, Deserialize)] -#[serde(rename_all = "snake_case")] -pub enum EntityType { - /// 环境 - Environment, - /// 分组 - Group, - /// 标签 - Tag, - /// 代理 - Proxy, - /// 账号 - Account, -} - -impl WorkspaceEvent { - /// 创建新事件 - pub fn new( - workspace_uuid: Uuid, - event_type: EventType, - entity_type: EntityType, - entity_id: Option, - affected_apis: Vec, - ) -> Self { - Self { - event_id: String::new(), // Redis Stream 会自动生成 - workspace_uuid, - event_type, - entity_type, - entity_id, - affected_apis, - payload: None, - created_at: chrono::Utc::now().timestamp_millis(), - } - } - - /// 设置负载 - pub fn with_payload(mut self, payload: serde_json::Value) -> Self { - self.payload = Some(payload); - self - } -} diff --git a/src/dto/mod.rs b/src/dto/mod.rs index 176eea7..61cf0f9 100644 --- a/src/dto/mod.rs +++ b/src/dto/mod.rs @@ -24,10 +24,9 @@ pub mod proxy_visible_teams; pub mod referral; pub mod rpa; pub mod system; -pub mod teams; -pub mod workspace_quotas; -pub mod workspaces; -pub mod events; +pub mod teams; +pub mod workspace_quotas; +pub mod workspaces; pub use gray_releases::*; pub use gray_resources::*; @@ -55,6 +54,6 @@ pub use proxy_visible_teams::*; pub use referral::*; pub use rpa::*; pub use system::*; -pub use teams::*; -pub use workspace_quotas::*; -pub use workspaces::*; +pub use teams::*; +pub use workspace_quotas::*; +pub use workspaces::*; diff --git a/src/handlers.rs b/src/handlers.rs index 24fbb1a..f533e8e 100644 --- a/src/handlers.rs +++ b/src/handlers.rs @@ -22,7 +22,6 @@ pub mod teams; pub mod templates; pub mod workspace_quotas; pub mod workspaces; -pub mod events; pub use health::*; pub use secret::*; diff --git a/src/handlers/accounts.rs b/src/handlers/accounts.rs index b24efce..47f1a9e 100644 --- a/src/handlers/accounts.rs +++ b/src/handlers/accounts.rs @@ -1,7 +1,6 @@ use axum::{extract::Extension, extract::State}; -use crate::dto::events::{EntityType, EventType}; -use crate::entitys::{ +use crate::entitys::{ AccountListResponse, BatchImportAccountsRequest, BatchImportResponse, BatchUuidRequest, CreateAccountRequest, CreateResponse, ListAccountsRequest, UpdateAccountRequest, UuidRequest, }; @@ -71,23 +70,9 @@ pub async fn create_account_handler( .await .map_err(|e| Response::fail(Some(&e)))?; - // 发布账号创建事件 - if let Err(e) = services::events::EventService::publish_from_context( - &svc_ctx, - &ctx, - EventType::Created, - EntityType::Account, - Some(account_uuid), - vec!["accounts/list".to_string()], - ) - .await - { - tracing::error!("发布账号创建事件失败: {}", e); - } - - Ok(Response::success( - Some("创建成功"), - Some(CreateResponse { uuid: account_uuid }), + Ok(Response::success( + Some("创建成功"), + Some(CreateResponse { uuid: account_uuid }), )) } @@ -101,22 +86,8 @@ pub async fn update_account_handler( .await .map_err(|e| Response::fail(Some(&e)))?; - // 发布账号更新事件 - if let Err(e) = services::events::EventService::publish_from_context( - &svc_ctx, - &ctx, - EventType::Updated, - EntityType::Account, - Some(payload.uuid), - vec!["accounts/list".to_string()], - ) - .await - { - tracing::error!("发布账号更新事件失败: {}", e); - } - - Ok(Response::success(Some("更新成功"), None)) -} + Ok(Response::success(Some("更新成功"), None)) +} /// 删除账号 pub async fn delete_account_handler( @@ -128,22 +99,8 @@ pub async fn delete_account_handler( .await .map_err(|e| Response::fail(Some(&e)))?; - // 发布账号删除事件 - if let Err(e) = services::events::EventService::publish_from_context( - &svc_ctx, - &ctx, - EventType::Deleted, - EntityType::Account, - Some(payload.uuid), - vec!["accounts/list".to_string()], - ) - .await - { - tracing::error!("发布账号删除事件失败: {}", e); - } - - Ok(Response::success(Some("删除成功"), None)) -} + Ok(Response::success(Some("删除成功"), None)) +} /// 批量删除账号 pub async fn batch_delete_accounts_handler( @@ -155,22 +112,8 @@ pub async fn batch_delete_accounts_handler( .await .map_err(|e| Response::fail(Some(&e)))?; - // 发布批量删除账号事件 - if let Err(e) = services::events::EventService::publish_from_context( - &svc_ctx, - &ctx, - EventType::Deleted, - EntityType::Account, - None, - vec!["accounts/list".to_string()], - ) - .await - { - tracing::error!("发布批量删除账号事件失败: {}", e); - } - - Ok(Response::success(Some("删除成功"), Some(count))) -} + Ok(Response::success(Some("删除成功"), Some(count))) +} /// 批量导入账号 pub async fn batch_import_accounts_handler( diff --git a/src/handlers/environments.rs b/src/handlers/environments.rs index 1c56f4c..20543a6 100644 --- a/src/handlers/environments.rs +++ b/src/handlers/environments.rs @@ -1,8 +1,7 @@ use axum::{extract::Extension, extract::State}; use crate::audit_log; -use crate::dto::events::{EntityType, EventType}; -use crate::entitys::{ +use crate::entitys::{ AddEnvironmentCookieRequest, AddEnvironmentUrlRequest, AssignTagsRequest, BatchAssignTagRequest, BatchCreateEnvironmentRequest, BatchMoveToGroupRequest, BatchRemoveTagsRequest, BatchUuidRequest, ClearEnvironmentCookiesRequest, @@ -127,22 +126,8 @@ pub async fn delete_group_handler( audit_log!(&svc_ctx, &ctx, "delete", "group", payload.uuid, "删除分组").await; - // 发布分组删除事件 - if let Err(e) = services::events::EventService::publish_from_context( - &svc_ctx, - &ctx, - EventType::Deleted, - EntityType::Group, - Some(payload.uuid), - vec!["groups/list".to_string()], - ) - .await - { - tracing::error!("发布分组删除事件失败: {}", e); - } - - Ok(Response::success(Some("删除成功"), None)) -} + Ok(Response::success(Some("删除成功"), None)) +} // ============ Tags ============ @@ -172,23 +157,9 @@ pub async fn create_tag_handler( ) .await; - // 发布标签创建事件 - if let Err(e) = services::events::EventService::publish_from_context( - &svc_ctx, - &ctx, - EventType::Created, - EntityType::Tag, - Some(tag_uuid), - vec!["tags/list".to_string()], - ) - .await - { - tracing::error!("发布标签创建事件失败: {}", e); - } - - Ok(Response::success( - Some("创建成功"), - Some(CreateResponse { uuid: tag_uuid }), + Ok(Response::success( + Some("创建成功"), + Some(CreateResponse { uuid: tag_uuid }), )) } @@ -218,22 +189,8 @@ pub async fn update_tag_handler( .await .map_err(|e| Response::fail(Some(&e)))?; - // 发布标签更新事件 - if let Err(e) = services::events::EventService::publish_from_context( - &svc_ctx, - &ctx, - EventType::Updated, - EntityType::Tag, - Some(payload.uuid), - vec!["tags/list".to_string()], - ) - .await - { - tracing::error!("发布标签更新事件失败: {}", e); - } - - Ok(Response::success(Some("更新成功"), None)) -} + Ok(Response::success(Some("更新成功"), None)) +} /// 删除标签 pub async fn delete_tag_handler( @@ -247,22 +204,8 @@ pub async fn delete_tag_handler( audit_log!(&svc_ctx, &ctx, "delete", "tag", payload.uuid, "删除标签").await; - // 发布标签删除事件 - if let Err(e) = services::events::EventService::publish_from_context( - &svc_ctx, - &ctx, - EventType::Deleted, - EntityType::Tag, - Some(payload.uuid), - vec!["tags/list".to_string()], - ) - .await - { - tracing::error!("发布标签删除事件失败: {}", e); - } - - Ok(Response::success(Some("删除成功"), None)) -} + Ok(Response::success(Some("删除成功"), None)) +} // ============ Environments ============ @@ -490,23 +433,9 @@ pub async fn create_environment_handler( ) .await; - // 发布环境创建事件 - if let Err(e) = services::events::EventService::publish_from_context( - &svc_ctx, - &ctx, - EventType::Created, - EntityType::Environment, - Some(env_uuid), - vec!["environments/list".to_string()], - ) - .await - { - tracing::error!("发布环境创建事件失败: {}", e); - } - - Ok(Response::success( - Some("创建成功"), - Some(CreateResponse { uuid: env_uuid }), + Ok(Response::success( + Some("创建成功"), + Some(CreateResponse { uuid: env_uuid }), )) } @@ -544,22 +473,8 @@ pub async fn batch_create_environments_handler( ) .await; - // 发布批量创建环境事件 - if let Err(e) = services::events::EventService::publish_from_context( - &svc_ctx, - &ctx, - EventType::Created, - EntityType::Environment, - None, - vec!["environments/list".to_string()], - ) - .await - { - tracing::error!("发布批量创建环境事件失败: {}", e); - } - - Ok(Response::success(Some("批量创建成功"), Some(responses))) -} + Ok(Response::success(Some("批量创建成功"), Some(responses))) +} /// 更新环境 pub async fn update_environment_handler( @@ -582,22 +497,8 @@ pub async fn update_environment_handler( .await .map_err(|e| Response::fail(Some(&e)))?; - // 发布环境更新事件 - if let Err(e) = services::events::EventService::publish_from_context( - &svc_ctx, - &ctx, - EventType::Updated, - EntityType::Environment, - Some(payload.uuid), - vec!["environments/list".to_string()], - ) - .await - { - tracing::error!("发布环境更新事件失败: {}", e); - } - - Ok(Response::success(Some("更新成功"), None)) -} + Ok(Response::success(Some("更新成功"), None)) +} /// 删除环境 pub async fn delete_environment_handler( @@ -630,22 +531,8 @@ pub async fn delete_environment_handler( ) .await; - // 发布环境删除事件 - if let Err(e) = services::events::EventService::publish_from_context( - &svc_ctx, - &ctx, - EventType::Deleted, - EntityType::Environment, - Some(payload.uuid), - vec!["environments/list".to_string()], - ) - .await - { - tracing::error!("发布环境删除事件失败: {}", e); - } - - Ok(Response::success(Some("删除成功"), None)) -} + Ok(Response::success(Some("删除成功"), None)) +} /// 批量删除环境 pub async fn batch_delete_environments_handler( @@ -666,22 +553,8 @@ pub async fn batch_delete_environments_handler( ) .await; - // 发布批量删除环境事件 - if let Err(e) = services::events::EventService::publish_from_context( - &svc_ctx, - &ctx, - EventType::Deleted, - EntityType::Environment, - None, - vec!["environments/list".to_string()], - ) - .await - { - tracing::error!("发布批量删除环境事件失败: {}", e); - } - - Ok(Response::success(Some("删除成功"), Some(count))) -} + Ok(Response::success(Some("删除成功"), Some(count))) +} // ============ Recycle Bin ============ @@ -737,22 +610,8 @@ pub async fn restore_environment_handler( ) .await; - // 发布恢复环境事件 - if let Err(e) = services::events::EventService::publish_from_context( - &svc_ctx, - &ctx, - EventType::Updated, - EntityType::Environment, - Some(payload.uuid), - vec!["environments/list".to_string(), "recycle-bin/list".to_string()], - ) - .await - { - tracing::error!("发布恢复环境事件失败: {}", e); - } - - Ok(Response::success(Some("恢复成功"), None)) -} + Ok(Response::success(Some("恢复成功"), None)) +} /// 批量恢复环境 pub async fn batch_restore_environments_handler( @@ -773,22 +632,8 @@ pub async fn batch_restore_environments_handler( ) .await; - // 发布批量恢复环境事件 - if let Err(e) = services::events::EventService::publish_from_context( - &svc_ctx, - &ctx, - EventType::Updated, - EntityType::Environment, - None, - vec!["environments/list".to_string(), "recycle-bin/list".to_string()], - ) - .await - { - tracing::error!("发布批量恢复环境事件失败: {}", e); - } - - Ok(Response::success(Some("恢复成功"), Some(count))) -} + Ok(Response::success(Some("恢复成功"), Some(count))) +} /// 永久删除环境 pub async fn permanent_delete_environment_handler( @@ -814,22 +659,8 @@ pub async fn permanent_delete_environment_handler( ) .await; - // 发布永久删除环境事件 - if let Err(e) = services::events::EventService::publish_from_context( - &svc_ctx, - &ctx, - EventType::Deleted, - EntityType::Environment, - Some(payload.uuid), - vec!["recycle-bin/list".to_string()], - ) - .await - { - tracing::error!("发布永久删除环境事件失败: {}", e); - } - - Ok(Response::success(Some("永久删除成功"), None)) -} + Ok(Response::success(Some("永久删除成功"), None)) +} /// 批量永久删除环境 pub async fn batch_permanent_delete_environments_handler( @@ -858,22 +689,8 @@ pub async fn batch_permanent_delete_environments_handler( ) .await; - // 发布批量永久删除环境事件 - if let Err(e) = services::events::EventService::publish_from_context( - &svc_ctx, - &ctx, - EventType::Deleted, - EntityType::Environment, - None, - vec!["recycle-bin/list".to_string()], - ) - .await - { - tracing::error!("发布批量永久删除环境事件失败: {}", e); - } - - Ok(Response::success(Some("永久删除成功"), Some(count))) -} + Ok(Response::success(Some("永久删除成功"), Some(count))) +} /// 设置环境代理 pub async fn set_proxy_handler( @@ -885,22 +702,8 @@ pub async fn set_proxy_handler( .await .map_err(|e| Response::fail(Some(&e)))?; - // 发布环境更新事件 - if let Err(e) = services::events::EventService::publish_from_context( - &svc_ctx, - &ctx, - EventType::Updated, - EntityType::Environment, - Some(payload.uuid), - vec!["environments/list".to_string()], - ) - .await - { - tracing::error!("发布环境更新事件失败: {}", e); - } - - Ok(Response::success(Some("设置成功"), None)) -} + Ok(Response::success(Some("设置成功"), None)) +} /// 分配标签 pub async fn assign_tags_handler( @@ -912,22 +715,8 @@ pub async fn assign_tags_handler( .await .map_err(|e| Response::fail(Some(&e)))?; - // 发布环境更新事件 - if let Err(e) = services::events::EventService::publish_from_context( - &svc_ctx, - &ctx, - EventType::Updated, - EntityType::Environment, - Some(payload.uuid), - vec!["environments/list".to_string()], - ) - .await - { - tracing::error!("发布环境更新事件失败: {}", e); - } - - Ok(Response::success(Some("分配成功"), None)) -} + Ok(Response::success(Some("分配成功"), None)) +} /// 移除标签 pub async fn remove_tag_handler( @@ -939,22 +728,8 @@ pub async fn remove_tag_handler( .await .map_err(|e| Response::fail(Some(&e)))?; - // 发布环境更新事件 - if let Err(e) = services::events::EventService::publish_from_context( - &svc_ctx, - &ctx, - EventType::Updated, - EntityType::Environment, - Some(payload.uuid), - vec!["environments/list".to_string()], - ) - .await - { - tracing::error!("发布环境更新事件失败: {}", e); - } - - Ok(Response::success(Some("移除成功"), None)) -} + Ok(Response::success(Some("移除成功"), None)) +} /// 移动到分组 pub async fn move_to_group_handler( @@ -966,22 +741,8 @@ pub async fn move_to_group_handler( .await .map_err(|e| Response::fail(Some(&e)))?; - // 发布环境更新事件 - if let Err(e) = services::events::EventService::publish_from_context( - &svc_ctx, - &ctx, - EventType::Updated, - EntityType::Environment, - Some(payload.uuid), - vec!["environments/list".to_string()], - ) - .await - { - tracing::error!("发布环境更新事件失败: {}", e); - } - - Ok(Response::success(Some("移动成功"), None)) -} + Ok(Response::success(Some("移动成功"), None)) +} /// 批量移动到分组 pub async fn batch_move_to_group_handler( @@ -993,22 +754,8 @@ pub async fn batch_move_to_group_handler( .await .map_err(|e| Response::fail(Some(&e)))?; - // 发布批量环境更新事件 - if let Err(e) = services::events::EventService::publish_from_context( - &svc_ctx, - &ctx, - EventType::Updated, - EntityType::Environment, - None, - vec!["environments/list".to_string()], - ) - .await - { - tracing::error!("发布批量环境更新事件失败: {}", e); - } - - Ok(Response::success(Some("移动成功"), None)) -} + Ok(Response::success(Some("移动成功"), None)) +} /// 设置环境账号 pub async fn set_environment_accounts_handler( @@ -1024,22 +771,8 @@ pub async fn set_environment_accounts_handler( .await .map_err(|e| Response::fail(Some(&e)))?; - // 发布环境更新事件 - if let Err(e) = services::events::EventService::publish_from_context( - &svc_ctx, - &ctx, - EventType::Updated, - EntityType::Environment, - Some(payload.uuid), - vec!["environments/list".to_string()], - ) - .await - { - tracing::error!("发布环境更新事件失败: {}", e); - } - - Ok(Response::success(Some("设置成功"), None)) -} + Ok(Response::success(Some("设置成功"), None)) +} /// 批量分配标签 pub async fn batch_assign_tags_handler( @@ -1051,22 +784,8 @@ pub async fn batch_assign_tags_handler( .await .map_err(|e| Response::fail(Some(&e)))?; - // 发布批量环境更新事件 - if let Err(e) = services::events::EventService::publish_from_context( - &svc_ctx, - &ctx, - EventType::Updated, - EntityType::Environment, - None, - vec!["environments/list".to_string()], - ) - .await - { - tracing::error!("发布批量环境更新事件失败: {}", e); - } - - Ok(Response::success(Some("分配成功"), None)) -} + Ok(Response::success(Some("分配成功"), None)) +} /// 批量移除标签 pub async fn batch_remove_tags_handler( @@ -1078,22 +797,8 @@ pub async fn batch_remove_tags_handler( .await .map_err(|e| Response::fail(Some(&e)))?; - // 发布批量环境更新事件 - if let Err(e) = services::events::EventService::publish_from_context( - &svc_ctx, - &ctx, - EventType::Updated, - EntityType::Environment, - None, - vec!["environments/list".to_string()], - ) - .await - { - tracing::error!("发布批量环境更新事件失败: {}", e); - } - - Ok(Response::success(Some("移除成功"), None)) -} + Ok(Response::success(Some("移除成功"), None)) +} // ============ Environment URLs ============ @@ -1107,22 +812,8 @@ pub async fn add_environment_url_handler( .await .map_err(|e| Response::fail(Some(&e)))?; - // 发布环境更新事件 - if let Err(e) = services::events::EventService::publish_from_context( - &svc_ctx, - &ctx, - EventType::Updated, - EntityType::Environment, - Some(payload.environment_uuid), - vec!["environments/list".to_string()], - ) - .await - { - tracing::error!("发布环境更新事件失败: {}", e); - } - - Ok(Response::success(Some("添加成功"), Some(IdResponse { id }))) -} + Ok(Response::success(Some("添加成功"), Some(IdResponse { id }))) +} /// 获取环境 URL 列表 pub async fn get_environment_urls_handler( @@ -1146,22 +837,8 @@ pub async fn delete_environment_url_handler( .await .map_err(|e| Response::fail(Some(&e)))?; - // 发布环境更新事件 - if let Err(e) = services::events::EventService::publish_from_context( - &svc_ctx, - &ctx, - EventType::Updated, - EntityType::Environment, - None, - vec!["environments/list".to_string()], - ) - .await - { - tracing::error!("发布环境更新事件失败: {}", e); - } - - Ok(Response::success(Some("删除成功"), None)) -} + Ok(Response::success(Some("删除成功"), None)) +} /// 清空环境 URL pub async fn clear_environment_urls_handler( @@ -1174,22 +851,8 @@ pub async fn clear_environment_urls_handler( .await .map_err(|e| Response::fail(Some(&e)))?; - // 发布环境更新事件 - if let Err(e) = services::events::EventService::publish_from_context( - &svc_ctx, - &ctx, - EventType::Updated, - EntityType::Environment, - Some(payload.environment_uuid), - vec!["environments/list".to_string()], - ) - .await - { - tracing::error!("发布环境更新事件失败: {}", e); - } - - Ok(Response::success(Some("清空成功"), Some(count))) -} + Ok(Response::success(Some("清空成功"), Some(count))) +} // ============ Environment Cookies ============ @@ -1203,22 +866,8 @@ pub async fn add_environment_cookie_handler( .await .map_err(|e| Response::fail(Some(&e)))?; - // 发布环境更新事件 - if let Err(e) = services::events::EventService::publish_from_context( - &svc_ctx, - &ctx, - EventType::Updated, - EntityType::Environment, - Some(payload.environment_uuid), - vec!["environments/list".to_string()], - ) - .await - { - tracing::error!("发布环境更新事件失败: {}", e); - } - - Ok(Response::success(Some("添加成功"), Some(IdResponse { id }))) -} + Ok(Response::success(Some("添加成功"), Some(IdResponse { id }))) +} /// 获取环境 Cookie 列表 pub async fn get_environment_cookies_handler( @@ -1242,22 +891,8 @@ pub async fn delete_environment_cookie_handler( .await .map_err(|e| Response::fail(Some(&e)))?; - // 发布环境更新事件 - if let Err(e) = services::events::EventService::publish_from_context( - &svc_ctx, - &ctx, - EventType::Updated, - EntityType::Environment, - None, - vec!["environments/list".to_string()], - ) - .await - { - tracing::error!("发布环境更新事件失败: {}", e); - } - - Ok(Response::success(Some("删除成功"), None)) -} + Ok(Response::success(Some("删除成功"), None)) +} /// 清空环境 Cookies pub async fn clear_environment_cookies_handler( @@ -1272,19 +907,5 @@ pub async fn clear_environment_cookies_handler( .await .map_err(|e| Response::fail(Some(&e)))?; - // 发布环境更新事件 - if let Err(e) = services::events::EventService::publish_from_context( - &svc_ctx, - &ctx, - EventType::Updated, - EntityType::Environment, - Some(payload.environment_uuid), - vec!["environments/list".to_string()], - ) - .await - { - tracing::error!("发布环境更新事件失败: {}", e); - } - - Ok(Response::success(Some("清空成功"), Some(count))) -} + Ok(Response::success(Some("清空成功"), Some(count))) +} diff --git a/src/handlers/events.rs b/src/handlers/events.rs deleted file mode 100644 index e2b7952..0000000 --- a/src/handlers/events.rs +++ /dev/null @@ -1,89 +0,0 @@ -use axum::{extract::State, Extension}; -use serde::{Deserialize, Serialize}; - -use crate::dto::events::WorkspaceEvent; -use crate::services::events::EventService; -use crate::state::RequestContext; -use crate::svc_ctx::SvcCtx; -use crate::utils::{Json, Response, Result}; - -/// 同步事件请求 -#[derive(Debug, Serialize, Deserialize)] -pub struct SyncEventsRequest { - /// 上次同步的事件 ID(可选) - pub last_event_id: Option, -} - -/// 同步事件响应 -#[derive(Debug, Serialize)] -pub struct SyncEventsResponse { - /// 事件列表 - pub events: Vec, - - /// 是否需要全量刷新 - pub full_refresh: bool, - - /// 最新事件 ID - pub latest_event_id: Option, -} - -/// 同步事件 -/// -/// POST /api/v1/events/sync -#[axum::debug_handler] -pub async fn sync_events_handler( - State(svc_ctx): State, - Extension(ctx): Extension, - Json(req): Json, -) -> Result { - let workspace_uuid = ctx - .current_workspace_uuid - .ok_or_else(|| Response::fail(Some("未选择工作空间")))?; - - // 获取事件 - let events = EventService::get_events_since(&svc_ctx, workspace_uuid, req.last_event_id.clone()) - .await - .map_err(|e| { - tracing::error!("获取事件失败: {}", e); - Response::fail(Some("获取事件失败")) - })?; - - // 判断是否需要全量刷新 - let full_refresh = if let Some(last_id) = &req.last_event_id { - // 如果提供了 last_event_id 但没有获取到任何事件 - // 可能是因为事件已过期(> 72 小时) - if !last_id.is_empty() && events.is_empty() { - // 检查事件是否真的过期了 - match EventService::event_exists(&svc_ctx, workspace_uuid, last_id).await { - Ok(exists) => !exists, - Err(e) => { - tracing::error!("检查事件是否存在失败: {}", e); - false - } - } - } else { - false - } - } else { - false - }; - - let latest_event_id = events.last().map(|e| e.event_id.clone()); - - tracing::debug!( - "同步事件: workspace={}, last_id={:?}, count={}, full_refresh={}", - workspace_uuid, - req.last_event_id, - events.len(), - full_refresh - ); - - Ok(Response::success( - Some("同步成功"), - Some(SyncEventsResponse { - events, - full_refresh, - latest_event_id, - }), - )) -} diff --git a/src/handlers/proxies.rs b/src/handlers/proxies.rs index bd3e220..c3e8cf0 100644 --- a/src/handlers/proxies.rs +++ b/src/handlers/proxies.rs @@ -1,8 +1,7 @@ use axum::{extract::Extension, extract::State}; use crate::audit_log; -use crate::dto::events::{EntityType, EventType}; -use crate::entitys::{ +use crate::entitys::{ BatchImportProxiesRequest, BatchImportResponse, BatchUuidRequest, CreateProxyRequest, CreateResponse, ListProxiesRequest, ProxyListResponse, UpdateProxyRequest, UuidRequest, }; @@ -72,23 +71,9 @@ pub async fn create_proxy_handler( ) .await; - // 发布代理创建事件 - if let Err(e) = services::events::EventService::publish_from_context( - &svc_ctx, - &ctx, - EventType::Created, - EntityType::Proxy, - Some(proxy_uuid), - vec!["proxies/list".to_string()], - ) - .await - { - tracing::error!("发布代理创建事件失败: {}", e); - } - - Ok(Response::success( - Some("创建成功"), - Some(CreateResponse { uuid: proxy_uuid }), + Ok(Response::success( + Some("创建成功"), + Some(CreateResponse { uuid: proxy_uuid }), )) } @@ -102,22 +87,8 @@ pub async fn update_proxy_handler( .await .map_err(|e| Response::fail(Some(&e)))?; - // 发布代理更新事件 - if let Err(e) = services::events::EventService::publish_from_context( - &svc_ctx, - &ctx, - EventType::Updated, - EntityType::Proxy, - Some(payload.uuid), - vec!["proxies/list".to_string()], - ) - .await - { - tracing::error!("发布代理更新事件失败: {}", e); - } - - Ok(Response::success(Some("更新成功"), None)) -} + Ok(Response::success(Some("更新成功"), None)) +} /// 删除代理 pub async fn delete_proxy_handler( @@ -131,22 +102,8 @@ pub async fn delete_proxy_handler( audit_log!(&svc_ctx, &ctx, "delete", "proxy", payload.uuid, "删除代理").await; - // 发布代理删除事件 - if let Err(e) = services::events::EventService::publish_from_context( - &svc_ctx, - &ctx, - EventType::Deleted, - EntityType::Proxy, - Some(payload.uuid), - vec!["proxies/list".to_string()], - ) - .await - { - tracing::error!("发布代理删除事件失败: {}", e); - } - - Ok(Response::success(Some("删除成功"), None)) -} + Ok(Response::success(Some("删除成功"), None)) +} /// 批量删除代理 pub async fn batch_delete_proxies_handler( @@ -167,22 +124,8 @@ pub async fn batch_delete_proxies_handler( ) .await; - // 发布批量删除代理事件 - if let Err(e) = services::events::EventService::publish_from_context( - &svc_ctx, - &ctx, - EventType::Deleted, - EntityType::Proxy, - None, - vec!["proxies/list".to_string()], - ) - .await - { - tracing::error!("发布批量删除代理事件失败: {}", e); - } - - Ok(Response::success(Some("删除成功"), Some(count))) -} + Ok(Response::success(Some("删除成功"), Some(count))) +} /// 批量导入代理 pub async fn batch_import_proxies_handler( diff --git a/src/main.rs b/src/main.rs index cf97f66..b50b85a 100644 --- a/src/main.rs +++ b/src/main.rs @@ -5,7 +5,7 @@ use simprint_server::{init_encrypt_secret, init_minio}; use simprint_server::{ middlewares, routes::{ - accounts, api, audit, billing, browser_kernel, environments, events, extensions, + accounts, api, audit, billing, browser_kernel, environments, extensions, group_permissions, messages, preferences, proxies, proxy_visibility, referral, rpa, secret, teams, templates, time, users, workspace_quotas, workspaces, @@ -88,7 +88,6 @@ fn register_all_routes(svc_ctx: &SvcCtx) -> Router { api::register_routes(&mut meta_route); preferences::register_routes(&mut meta_route); messages::register_routes(&mut meta_route); - events::register_routes(&mut meta_route); tracing::info!("---------- {:?} ----------", meta_route.count()); meta_route.build() diff --git a/src/routes.rs b/src/routes.rs index 2067c49..7e209bd 100644 --- a/src/routes.rs +++ b/src/routes.rs @@ -22,7 +22,6 @@ pub mod teams; pub mod templates; pub mod workspace_quotas; pub mod workspaces; -pub mod events; // route core pub mod route { diff --git a/src/routes/events.rs b/src/routes/events.rs deleted file mode 100644 index db9873c..0000000 --- a/src/routes/events.rs +++ /dev/null @@ -1,18 +0,0 @@ -use axum::routing::post; - -use crate::handlers::events; -use crate::routes::route::{self, MetaRoute, RequestMethod}; - -/// 注册事件相关的路由 -pub fn register_routes(meta_route: &mut MetaRoute) { - let mut events_route = route::RouteGroup::new("/events"); - - // POST /events/sync - 同步事件 - events_route.add_route_item(route::RouteItem::new( - "/sync", - RequestMethod::POST, - post(events::sync_events_handler), - )); - - meta_route.add_route_group(events_route); -} diff --git a/src/services.rs b/src/services.rs index 76ee30e..cfaa0d4 100644 --- a/src/services.rs +++ b/src/services.rs @@ -36,7 +36,6 @@ pub mod templates; pub mod wallet; pub mod workspace_quotas; pub mod workspaces; -pub mod events; pub use gray_releases::*; pub use gray_resources::*; diff --git a/src/services/events.rs b/src/services/events.rs deleted file mode 100644 index 59b4e61..0000000 --- a/src/services/events.rs +++ /dev/null @@ -1,182 +0,0 @@ -use redis::AsyncCommands; -use uuid::Uuid; - -use crate::dto::events::{EntityType, EventType, WorkspaceEvent}; -use crate::state::RequestContext; -use crate::svc_ctx::SvcCtx; - -/// 事件服务 -pub struct EventService; - -impl EventService { - /// 发布事件到 Redis Stream - /// - /// # Arguments - /// * `svc_ctx` - 服务上下文 - /// * `event` - 工作空间事件 - /// - /// # Returns - /// * `Ok(String)` - 事件 ID - /// * `Err` - 发布失败 - pub async fn publish_event( - svc_ctx: &SvcCtx, - event: WorkspaceEvent, - ) -> anyhow::Result { - let stream_key = format!("events:workspace:{}", event.workspace_uuid); - - // 序列化事件 - let event_json = serde_json::to_string(&event)?; - - // 写入 Redis Stream - let mut conn = svc_ctx.redis.clone(); - let event_id: String = conn - .xadd_maxlen( - &stream_key, - redis::streams::StreamMaxlen::Approx(15000), - "*", // 自动生成 ID - &[("data", event_json)], - ) - .await?; - - // 设置 TTL(72 小时 = 259200 秒) - let _: () = conn.expire(&stream_key, 259200).await?; - - tracing::debug!( - "事件已发布: stream={}, event_id={}, type={:?}, entity={:?}", - stream_key, - event_id, - event.event_type, - event.entity_type - ); - - Ok(event_id) - } - - /// 获取指定时间后的事件 - /// - /// # Arguments - /// * `svc_ctx` - 服务上下文 - /// * `workspace_uuid` - 工作空间 ID - /// * `since_id` - 起始事件 ID(可选,默认从头开始) - /// - /// # Returns - /// * `Ok(Vec)` - 事件列表 - /// * `Err` - 获取失败 - pub async fn get_events_since( - svc_ctx: &SvcCtx, - workspace_uuid: Uuid, - since_id: Option, - ) -> anyhow::Result> { - let stream_key = format!("events:workspace:{}", workspace_uuid); - let start_id = since_id.unwrap_or_else(|| "0".to_string()); - - let mut conn = svc_ctx.redis.clone(); - - // 读取事件 - let results: redis::streams::StreamReadReply = conn - .xread(&[&stream_key], &[&start_id]) - .await?; - - let mut events = Vec::new(); - for stream_key_result in results.keys { - for stream_id in stream_key_result.ids { - if let Some(data) = stream_id.map.get("data") { - // redis 0.31.0 使用 BulkString 而不是 Data - match data { - redis::Value::BulkString(bytes) => { - match serde_json::from_slice::(bytes) { - Ok(mut event) => { - // 设置 Redis Stream 生成的 event_id - event.event_id = stream_id.id.clone(); - events.push(event); - } - Err(e) => { - tracing::error!("反序列化事件失败: {}", e); - } - } - } - _ => { - tracing::warn!("意外的 Redis Value 类型: {:?}", data); - } - } - } - } - } - - tracing::debug!( - "获取事件: workspace={}, since={}, count={}", - workspace_uuid, - start_id, - events.len() - ); - - Ok(events) - } - - /// 检查事件 ID 是否仍然存在于 Stream 中 - /// - /// # Arguments - /// * `svc_ctx` - 服务上下文 - /// * `workspace_uuid` - 工作空间 ID - /// * `event_id` - 事件 ID - /// - /// # Returns - /// * `Ok(bool)` - true 表示存在,false 表示已过期 - pub async fn event_exists( - svc_ctx: &SvcCtx, - workspace_uuid: Uuid, - event_id: &str, - ) -> anyhow::Result { - let stream_key = format!("events:workspace:{}", workspace_uuid); - - let mut conn = svc_ctx.redis.clone(); - - // 尝试读取指定 ID 的事件 - let results: redis::streams::StreamReadReply = conn - .xread_options( - &[&stream_key], - &[event_id], - &redis::streams::StreamReadOptions::default().count(1), - ) - .await?; - - Ok(!results.keys.is_empty()) - } - - /// 便捷方法:从 RequestContext 发布事件 - /// - /// # Arguments - /// * `svc_ctx` - 服务上下文 - /// * `ctx` - 请求上下文(包含 workspace_uuid) - /// * `event_type` - 事件类型 - /// * `entity_type` - 实体类型 - /// * `entity_id` - 实体 ID(可选) - /// * `affected_apis` - 受影响的查询接口列表 - /// - /// # Returns - /// * `Ok(())` - 发布成功 - /// * `Err` - 发布失败 - pub async fn publish_from_context( - svc_ctx: &SvcCtx, - ctx: &RequestContext, - event_type: EventType, - entity_type: EntityType, - entity_id: Option, - affected_apis: Vec, - ) -> anyhow::Result<()> { - let workspace_uuid = ctx.workspace_uuid().ok_or_else(|| { - anyhow::anyhow!("工作空间未设置") - })?; - - let event = WorkspaceEvent::new( - workspace_uuid, - event_type, - entity_type, - entity_id, - affected_apis, - ); - - Self::publish_event(svc_ctx, event).await?; - Ok(()) - } -} From 7e12ef50b5896a34348c6f605291f1cb0d750bb0 Mon Sep 17 00:00:00 2001 From: lius-new Date: Fri, 13 Mar 2026 23:22:56 +0800 Subject: [PATCH 23/60] feat: add server-side filters for admin pages --- src/entitys/extensions.rs | 2 + src/entitys/referral.rs | 2 + src/handlers/proxies.rs | 14 +++- src/models/accounts.rs | 124 +++++++++++++++------------ src/models/audit.rs | 114 +++++++++++++------------ src/models/extensions.rs | 65 ++++++++++----- src/models/proxy_visible_teams.rs | 134 ++++++++++++++++++++++++++++-- src/models/referral.rs | 34 +++++--- src/services/accounts.rs | 70 ++++++++++------ src/services/audit.rs | 63 ++++++++------ src/services/extensions.rs | 28 ++++--- src/services/proxies.rs | 106 ++++++++++++----------- src/services/referral.rs | 4 + 13 files changed, 500 insertions(+), 260 deletions(-) diff --git a/src/entitys/extensions.rs b/src/entitys/extensions.rs index 6029c03..86e6ff1 100644 --- a/src/entitys/extensions.rs +++ b/src/entitys/extensions.rs @@ -72,6 +72,8 @@ pub struct ExtensionFilters { pub keyword: Option, pub category: Option, pub installed_only: Option, + pub sort_by: Option, + pub sort_order: Option, } /// 安装扩展请求 diff --git a/src/entitys/referral.rs b/src/entitys/referral.rs index 07874e6..20d2dba 100644 --- a/src/entitys/referral.rs +++ b/src/entitys/referral.rs @@ -14,6 +14,7 @@ pub struct SwitchReferralLinkRequest { pub struct ListReferralRewardsRequest { #[serde(flatten)] pub pagination: Pagination, + pub keyword: Option, pub reward_type: Option, pub status: Option, } @@ -23,6 +24,7 @@ pub struct ListReferralRewardsRequest { pub struct ListReferredUsersRequest { #[serde(flatten)] pub pagination: Pagination, + pub keyword: Option, pub status: Option, } diff --git a/src/handlers/proxies.rs b/src/handlers/proxies.rs index c3e8cf0..d0ecd7e 100644 --- a/src/handlers/proxies.rs +++ b/src/handlers/proxies.rs @@ -20,10 +20,16 @@ pub async fn get_proxies_handler( .current_workspace_uuid .ok_or_else(|| Response::fail(Some("请先选择工作空间")))?; - let (proxies, total) = - services::proxies::get_proxies_service(&svc_ctx, ctx.user_uuid_unwrap(), workspace_uuid, &payload) - .await - .map_err(|e| Response::fail(Some(&e)))?; + let (proxies, total) = + services::proxies::get_proxies_service( + &svc_ctx, + ctx.user_uuid_unwrap(), + workspace_uuid, + ctx.current_team_uuid, + &payload, + ) + .await + .map_err(|e| Response::fail(Some(&e)))?; Ok(Response::success( Some("获取成功"), diff --git a/src/models/accounts.rs b/src/models/accounts.rs index dbf9e1d..ec72e37 100644 --- a/src/models/accounts.rs +++ b/src/models/accounts.rs @@ -36,36 +36,47 @@ pub async fn insert_platform_account( } /// 查询平台账号列表 -pub async fn fetch_platform_accounts( - pool: &Pool, - team_uuid: Option, - user_uuid: Uuid, - platform_name: Option<&str>, - status: Option<&str>, - offset: i64, - limit: i64, -) -> Result, Error> { - let recs = sqlx::query_as::<_, PlatformAccountDto>( - r#" - SELECT pa.id, pa.uuid, pa.user_uuid, pa.team_uuid, pa.platform_url, pa.platform_name, - pa.account, pa.password_encrypted, pa.status, pa.remark, pa.usage_count, - (SELECT COUNT(*) FROM environment_accounts ea WHERE ea.account_uuid = pa.uuid) AS environments_count, - pa.last_used_at, pa.created_at, pa.updated_at, pa.deleted_at - FROM platform_accounts pa - WHERE (pa.team_uuid = $1 OR (pa.team_uuid IS NULL AND pa.user_uuid = $2)) - AND ($3::varchar IS NULL OR pa.platform_name = $3) - AND ($4::varchar IS NULL OR pa.status = $4) - AND pa.deleted_at IS NULL - ORDER BY pa.created_at DESC - LIMIT $5 OFFSET $6 - "#, - ) - .bind(team_uuid) - .bind(user_uuid) - .bind(platform_name) - .bind(status) - .bind(limit) - .bind(offset) +pub async fn fetch_platform_accounts( + pool: &Pool, + team_uuid: Option, + user_uuid: Uuid, + keyword: Option<&str>, + platform_name: Option<&str>, + status: Option<&str>, + offset: i64, + limit: i64, +) -> Result, Error> { + let keyword = keyword.map(|value| format!("%{}%", value.trim())); + + let recs = sqlx::query_as::<_, PlatformAccountDto>( + r#" + SELECT pa.id, pa.uuid, pa.user_uuid, pa.team_uuid, pa.platform_url, pa.platform_name, + pa.account, pa.password_encrypted, pa.status, pa.remark, pa.usage_count, + (SELECT COUNT(*) FROM environment_accounts ea WHERE ea.account_uuid = pa.uuid) AS environments_count, + pa.last_used_at, pa.created_at, pa.updated_at, pa.deleted_at + FROM platform_accounts pa + WHERE (pa.team_uuid = $1 OR (pa.team_uuid IS NULL AND pa.user_uuid = $2)) + AND ( + $3::text IS NULL + OR pa.platform_url ILIKE $3 + OR COALESCE(pa.platform_name, '') ILIKE $3 + OR pa.account ILIKE $3 + OR COALESCE(pa.remark, '') ILIKE $3 + ) + AND ($4::varchar IS NULL OR pa.platform_name = $4) + AND ($5::varchar IS NULL OR pa.status = $5) + AND pa.deleted_at IS NULL + ORDER BY pa.created_at DESC + LIMIT $6 OFFSET $7 + "#, + ) + .bind(team_uuid) + .bind(user_uuid) + .bind(keyword) + .bind(platform_name) + .bind(status) + .bind(limit) + .bind(offset) .fetch_all(pool) .await?; @@ -73,27 +84,38 @@ pub async fn fetch_platform_accounts( } /// 查询平台账号总数 -pub async fn fetch_platform_accounts_count( - pool: &Pool, - team_uuid: Option, - user_uuid: Uuid, - platform_name: Option<&str>, - status: Option<&str>, -) -> Result { - let count: i64 = sqlx::query_scalar( - r#" - SELECT COUNT(*) FROM platform_accounts - WHERE (team_uuid = $1 OR (team_uuid IS NULL AND user_uuid = $2)) - AND ($3::varchar IS NULL OR platform_name = $3) - AND ($4::varchar IS NULL OR status = $4) - AND deleted_at IS NULL - "#, - ) - .bind(team_uuid) - .bind(user_uuid) - .bind(platform_name) - .bind(status) - .fetch_one(pool) +pub async fn fetch_platform_accounts_count( + pool: &Pool, + team_uuid: Option, + user_uuid: Uuid, + keyword: Option<&str>, + platform_name: Option<&str>, + status: Option<&str>, +) -> Result { + let keyword = keyword.map(|value| format!("%{}%", value.trim())); + + let count: i64 = sqlx::query_scalar( + r#" + SELECT COUNT(*) FROM platform_accounts + WHERE (team_uuid = $1 OR (team_uuid IS NULL AND user_uuid = $2)) + AND ( + $3::text IS NULL + OR platform_url ILIKE $3 + OR COALESCE(platform_name, '') ILIKE $3 + OR account ILIKE $3 + OR COALESCE(remark, '') ILIKE $3 + ) + AND ($4::varchar IS NULL OR platform_name = $4) + AND ($5::varchar IS NULL OR status = $5) + AND deleted_at IS NULL + "#, + ) + .bind(team_uuid) + .bind(user_uuid) + .bind(keyword) + .bind(platform_name) + .bind(status) + .fetch_one(pool) .await?; Ok(count) diff --git a/src/models/audit.rs b/src/models/audit.rs index 0c494e2..471ef8c 100644 --- a/src/models/audit.rs +++ b/src/models/audit.rs @@ -48,16 +48,17 @@ pub async fn insert_audit_log( /// 查询逻辑: /// - 当前团队的所有审计日志 /// - 加上当前用户的个人操作日志(team_uuid 为空的,如登录、注册等) -pub async fn fetch_audit_logs( - pool: &Pool, - current_user_uuid: Uuid, - team_uuid: Option, - user_uuid_filter: Option, - action: Option<&str>, - target_type: Option<&str>, - offset: i64, - limit: i64, -) -> Result, Error> { +pub async fn fetch_audit_logs( + pool: &Pool, + current_user_uuid: Uuid, + team_uuid: Option, + user_uuid_filter: Option, + keyword: Option<&str>, + action: Option<&str>, + target_type: Option<&str>, + offset: i64, + limit: i64, +) -> Result, Error> { let recs = sqlx::query_as::<_, AuditLogDto>( r#" SELECT @@ -66,24 +67,26 @@ pub async fn fetch_audit_logs( ui.nickname AS user_name, ui.email AS user_email FROM audit_logs a LEFT JOIN user_infos ui ON a.user_uuid = ui.user_uuid - WHERE ( - ($2::uuid IS NULL OR a.team_uuid = $2) - OR (a.team_uuid IS NULL AND a.user_uuid = $1) - ) - AND ($3::uuid IS NULL OR a.user_uuid = $3) - AND ($4::varchar IS NULL OR a.action = $4) - AND ($5::varchar IS NULL OR a.target_type = $5) - ORDER BY a.created_at DESC - LIMIT $6 OFFSET $7 - "#, - ) - .bind(current_user_uuid) - .bind(team_uuid) - .bind(user_uuid_filter) - .bind(action) - .bind(target_type) - .bind(limit) - .bind(offset) + WHERE ( + ($2::uuid IS NULL OR a.team_uuid = $2) + OR (a.team_uuid IS NULL AND a.user_uuid = $1) + ) + AND ($3::uuid IS NULL OR a.user_uuid = $3) + AND ($4::text IS NULL OR COALESCE(a.details, '') ILIKE '%' || $4 || '%') + AND ($5::varchar IS NULL OR a.action = $5) + AND ($6::varchar IS NULL OR a.target_type = $6) + ORDER BY a.created_at DESC + LIMIT $7 OFFSET $8 + "#, + ) + .bind(current_user_uuid) + .bind(team_uuid) + .bind(user_uuid_filter) + .bind(keyword) + .bind(action) + .bind(target_type) + .bind(limit) + .bind(offset) .fetch_all(pool) .await?; @@ -91,33 +94,36 @@ pub async fn fetch_audit_logs( } /// 查询审计日志总数 -pub async fn fetch_audit_logs_count( - pool: &Pool, - current_user_uuid: Uuid, - team_uuid: Option, - user_uuid_filter: Option, - action: Option<&str>, - target_type: Option<&str>, -) -> Result { - let count: i64 = sqlx::query_scalar( - r#" +pub async fn fetch_audit_logs_count( + pool: &Pool, + current_user_uuid: Uuid, + team_uuid: Option, + user_uuid_filter: Option, + keyword: Option<&str>, + action: Option<&str>, + target_type: Option<&str>, +) -> Result { + let count: i64 = sqlx::query_scalar( + r#" SELECT COUNT(*) FROM audit_logs - WHERE ( - ($2::uuid IS NULL OR team_uuid = $2) - OR (team_uuid IS NULL AND user_uuid = $1) - ) - AND ($3::uuid IS NULL OR user_uuid = $3) - AND ($4::varchar IS NULL OR action = $4) - AND ($5::varchar IS NULL OR target_type = $5) - "#, - ) - .bind(current_user_uuid) - .bind(team_uuid) - .bind(user_uuid_filter) - .bind(action) - .bind(target_type) - .fetch_one(pool) - .await?; + WHERE ( + ($2::uuid IS NULL OR team_uuid = $2) + OR (team_uuid IS NULL AND user_uuid = $1) + ) + AND ($3::uuid IS NULL OR user_uuid = $3) + AND ($4::text IS NULL OR COALESCE(details, '') ILIKE '%' || $4 || '%') + AND ($5::varchar IS NULL OR action = $5) + AND ($6::varchar IS NULL OR target_type = $6) + "#, + ) + .bind(current_user_uuid) + .bind(team_uuid) + .bind(user_uuid_filter) + .bind(keyword) + .bind(action) + .bind(target_type) + .fetch_one(pool) + .await?; Ok(count) } diff --git a/src/models/extensions.rs b/src/models/extensions.rs index f092be1..92eb702 100644 --- a/src/models/extensions.rs +++ b/src/models/extensions.rs @@ -110,29 +110,48 @@ pub async fn get_extension_by_extension_id( // ============ Extensions Query ============ /// 查询扩展列表 -pub async fn fetch_extensions( - pool: &Pool, - keyword: Option<&str>, - category: Option<&str>, - offset: i64, - limit: i64, -) -> Result, Error> { - let recs = sqlx::query_as::<_, ExtensionDto>( - r#" - SELECT id, uuid, extension_id, name, description, version, category, browser, - developer, homepage, icon_url, download_url, file_size, downloads_count, - rating, permissions, status, changelog, published_at, hash, created_at, updated_at - FROM extensions - WHERE status = 'active' - AND ($1::varchar IS NULL OR name ILIKE '%' || $1 || '%' OR description ILIKE '%' || $1 || '%') - AND ($2::varchar IS NULL OR category = $2) - ORDER BY downloads_count DESC NULLS LAST - LIMIT $3 OFFSET $4 - "#, - ) - .bind(keyword) - .bind(category) - .bind(limit) +pub async fn fetch_extensions( + pool: &Pool, + keyword: Option<&str>, + category: Option<&str>, + sort_by: Option<&str>, + sort_order: Option<&str>, + offset: i64, + limit: i64, +) -> Result, Error> { + let order_clause = match ( + sort_by.unwrap_or("downloads"), + sort_order.unwrap_or("desc").to_ascii_lowercase().as_str(), + ) { + ("rating", "asc") => "rating ASC NULLS LAST, created_at DESC", + ("rating", _) => "rating DESC NULLS LAST, created_at DESC", + ("name", "asc") => "name ASC, created_at DESC", + ("name", _) => "name DESC, created_at DESC", + ("newest", "asc") => "updated_at ASC NULLS LAST, created_at ASC", + ("newest", _) => "updated_at DESC NULLS LAST, created_at DESC", + ("downloads", "asc") => "downloads_count ASC NULLS LAST, created_at DESC", + _ => "downloads_count DESC NULLS LAST, created_at DESC", + }; + + let query = format!( + r#" + SELECT id, uuid, extension_id, name, description, version, category, browser, + developer, homepage, icon_url, download_url, file_size, downloads_count, + rating, permissions, status, changelog, published_at, hash, created_at, updated_at + FROM extensions + WHERE status = 'active' + AND ($1::varchar IS NULL OR name ILIKE '%' || $1 || '%' OR description ILIKE '%' || $1 || '%') + AND ($2::varchar IS NULL OR category = $2) + ORDER BY {} + LIMIT $3 OFFSET $4 + "#, + order_clause + ); + + let recs = sqlx::query_as::<_, ExtensionDto>(&query) + .bind(keyword) + .bind(category) + .bind(limit) .bind(offset) .fetch_all(pool) .await?; diff --git a/src/models/proxy_visible_teams.rs b/src/models/proxy_visible_teams.rs index ffd1879..43560ca 100644 --- a/src/models/proxy_visible_teams.rs +++ b/src/models/proxy_visible_teams.rs @@ -117,11 +117,11 @@ pub async fn check_proxy_visibility( } /// 查询工作空间所有可见的代理(包括工作空间 Owner 和代理所有者的代理) -pub async fn fetch_visible_proxies_for_user( - pool: &Pool, - workspace_uuid: Uuid, - user_uuid: Uuid, - team_uuid: Option, +pub async fn fetch_visible_proxies_for_user( + pool: &Pool, + workspace_uuid: Uuid, + user_uuid: Uuid, + team_uuid: Option, ) -> Result, Error> { // 工作空间 Owner 可以看到所有代理 // 代理所有者可以看到自己的代理 @@ -163,6 +163,124 @@ pub async fn fetch_visible_proxies_for_user( .bind(team_uuid) .fetch_all(pool) .await?; - - Ok(recs) -} + + Ok(recs) +} + +/// 分页查询用户可见的代理列表,并支持名称搜索和筛选 +pub async fn fetch_visible_proxies_for_user_paginated( + pool: &Pool, + workspace_uuid: Uuid, + user_uuid: Uuid, + team_uuid: Option, + name_keyword: Option<&str>, + proxy_type: Option<&str>, + status: Option<&str>, + country: Option<&str>, + offset: i64, + limit: i64, +) -> Result, Error> { + let name_keyword = name_keyword.map(|keyword| format!("%{}%", keyword.trim())); + + let recs = sqlx::query_as::<_, ProxyDto>( + r#" + SELECT DISTINCT p.id, p.uuid, p.workspace_uuid, p.owner_uuid, p.name, p.host, p.port, p.proxy_type, + p.username, p.password_encrypted, p.ssh_key_encrypted, p.ssh_passphrase_encrypted, + p.country, p.city, p.status, p.latency, p.last_check_ip, p.last_checked_at, + (SELECT COUNT(*) FROM environments e WHERE e.proxy_uuid = p.uuid AND e.deleted_at IS NULL) AS environments_count, + p.created_at, p.updated_at, p.deleted_at + FROM proxies p + WHERE p.workspace_uuid = $1 + AND p.deleted_at IS NULL + AND ($4::text IS NULL OR p.name ILIKE $4) + AND ($5::text IS NULL OR p.proxy_type = $5) + AND ($6::text IS NULL OR p.status = $6) + AND ($7::text IS NULL OR p.country = $7) + AND ( + EXISTS ( + SELECT 1 FROM workspaces w + WHERE w.uuid = $1 AND w.owner_uuid = $2 AND w.deleted_at IS NULL + ) + OR p.owner_uuid = $2 + OR ( + $3 IS NOT NULL + AND EXISTS ( + SELECT 1 FROM proxy_visible_teams pvt + WHERE pvt.proxy_uuid = p.uuid + AND pvt.workspace_uuid = $1 + AND pvt.team_uuid = $3 + ) + ) + ) + ORDER BY p.created_at DESC + OFFSET $8 LIMIT $9 + "#, + ) + .bind(workspace_uuid) + .bind(user_uuid) + .bind(team_uuid) + .bind(name_keyword) + .bind(proxy_type) + .bind(status) + .bind(country) + .bind(offset) + .bind(limit) + .fetch_all(pool) + .await?; + + Ok(recs) +} + +/// 查询用户可见代理总数,并支持名称搜索和筛选 +pub async fn fetch_visible_proxies_for_user_count( + pool: &Pool, + workspace_uuid: Uuid, + user_uuid: Uuid, + team_uuid: Option, + name_keyword: Option<&str>, + proxy_type: Option<&str>, + status: Option<&str>, + country: Option<&str>, +) -> Result { + let name_keyword = name_keyword.map(|keyword| format!("%{}%", keyword.trim())); + + let count: i64 = sqlx::query_scalar( + r#" + SELECT COUNT(DISTINCT p.uuid) + FROM proxies p + WHERE p.workspace_uuid = $1 + AND p.deleted_at IS NULL + AND ($4::text IS NULL OR p.name ILIKE $4) + AND ($5::text IS NULL OR p.proxy_type = $5) + AND ($6::text IS NULL OR p.status = $6) + AND ($7::text IS NULL OR p.country = $7) + AND ( + EXISTS ( + SELECT 1 FROM workspaces w + WHERE w.uuid = $1 AND w.owner_uuid = $2 AND w.deleted_at IS NULL + ) + OR p.owner_uuid = $2 + OR ( + $3 IS NOT NULL + AND EXISTS ( + SELECT 1 FROM proxy_visible_teams pvt + WHERE pvt.proxy_uuid = p.uuid + AND pvt.workspace_uuid = $1 + AND pvt.team_uuid = $3 + ) + ) + ) + "#, + ) + .bind(workspace_uuid) + .bind(user_uuid) + .bind(team_uuid) + .bind(name_keyword) + .bind(proxy_type) + .bind(status) + .bind(country) + .fetch_one(pool) + .await?; + + Ok(count) +} diff --git a/src/models/referral.rs b/src/models/referral.rs index 464c97a..9b43bc9 100644 --- a/src/models/referral.rs +++ b/src/models/referral.rs @@ -207,6 +207,7 @@ pub async fn switch_current_referral_link( pub async fn fetch_referred_users( pool: &Pool, inviter_uuid: Uuid, + keyword: Option<&str>, status: Option<&str>, offset: i64, limit: i64, @@ -224,12 +225,14 @@ pub async fn fetch_referred_users( FROM user_referrals ur JOIN user_infos ui ON ui.user_uuid = ur.invitee_uuid WHERE ur.inviter_uuid = $1 - AND ($2::varchar IS NULL OR ur.status = $2) + AND ($2::text IS NULL OR ui.email ILIKE '%' || $2 || '%') + AND ($3::varchar IS NULL OR ur.status = $3) ORDER BY registered_at DESC - LIMIT $3 OFFSET $4 + LIMIT $4 OFFSET $5 "#, ) .bind(inviter_uuid) + .bind(keyword) .bind(status) .bind(limit) .bind(offset) @@ -243,16 +246,21 @@ pub async fn fetch_referred_users( pub async fn fetch_referred_users_count( pool: &Pool, inviter_uuid: Uuid, + keyword: Option<&str>, status: Option<&str>, ) -> Result { let count: i64 = sqlx::query_scalar( r#" - SELECT COUNT(*) FROM user_referrals - WHERE inviter_uuid = $1 - AND ($2::varchar IS NULL OR status = $2) + SELECT COUNT(*) + FROM user_referrals ur + JOIN user_infos ui ON ui.user_uuid = ur.invitee_uuid + WHERE ur.inviter_uuid = $1 + AND ($2::text IS NULL OR ui.email ILIKE '%' || $2 || '%') + AND ($3::varchar IS NULL OR ur.status = $3) "#, ) .bind(inviter_uuid) + .bind(keyword) .bind(status) .fetch_one(pool) .await?; @@ -294,6 +302,7 @@ pub async fn fetch_referral_stats( pub async fn fetch_referral_rewards( pool: &Pool, user_uuid: Uuid, + keyword: Option<&str>, reward_type: Option<&str>, status: Option<&str>, offset: i64, @@ -305,13 +314,15 @@ pub async fn fetch_referral_rewards( referred_user_uuid, link_uuid, status, created_at FROM referral_rewards WHERE user_uuid = $1 - AND ($2::varchar IS NULL OR reward_type = $2) - AND ($3::varchar IS NULL OR status = $3) + AND ($2::text IS NULL OR COALESCE(description, '') ILIKE '%' || $2 || '%') + AND ($3::varchar IS NULL OR reward_type = $3) + AND ($4::varchar IS NULL OR status = $4) ORDER BY created_at DESC - LIMIT $4 OFFSET $5 + LIMIT $5 OFFSET $6 "#, ) .bind(user_uuid) + .bind(keyword) .bind(reward_type) .bind(status) .bind(limit) @@ -326,6 +337,7 @@ pub async fn fetch_referral_rewards( pub async fn fetch_referral_rewards_count( pool: &Pool, user_uuid: Uuid, + keyword: Option<&str>, reward_type: Option<&str>, status: Option<&str>, ) -> Result { @@ -333,11 +345,13 @@ pub async fn fetch_referral_rewards_count( r#" SELECT COUNT(*) FROM referral_rewards WHERE user_uuid = $1 - AND ($2::varchar IS NULL OR reward_type = $2) - AND ($3::varchar IS NULL OR status = $3) + AND ($2::text IS NULL OR COALESCE(description, '') ILIKE '%' || $2 || '%') + AND ($3::varchar IS NULL OR reward_type = $3) + AND ($4::varchar IS NULL OR status = $4) "#, ) .bind(user_uuid) + .bind(keyword) .bind(reward_type) .bind(status) .fetch_one(pool) diff --git a/src/services/accounts.rs b/src/services/accounts.rs index 06215ad..6d6e548 100644 --- a/src/services/accounts.rs +++ b/src/services/accounts.rs @@ -43,31 +43,51 @@ pub async fn get_accounts_service( user_uuid: Uuid, team_uuid: Option, payload: &ListAccountsRequest, -) -> Result<(Vec, i64), String> { - let offset = (payload.pagination.page - 1) * payload.pagination.page_size; - - let platform_name = payload.filters.as_ref().and_then(|f| f.platform_name.as_deref()); - let status = payload.filters.as_ref().and_then(|f| f.status.as_deref()); - - let accounts = models::fetch_platform_accounts( - &svc_ctx.db, - team_uuid, - user_uuid, - platform_name, - status, - offset, - payload.pagination.page_size, - ) - .await - .map_err(|e| e.to_string())?; - - let total = models::fetch_platform_accounts_count( - &svc_ctx.db, - team_uuid, - user_uuid, - platform_name, - status, - ) +) -> Result<(Vec, i64), String> { + let page = payload.pagination.page.max(1); + let page_size = payload.pagination.page_size.max(1); + let offset = (page - 1) * page_size; + + let keyword = payload + .filters + .as_ref() + .and_then(|f| f.keyword.as_deref()) + .map(str::trim) + .filter(|value| !value.is_empty()); + let platform_name = payload + .filters + .as_ref() + .and_then(|f| f.platform_name.as_deref()) + .map(str::trim) + .filter(|value| !value.is_empty()); + let status = payload + .filters + .as_ref() + .and_then(|f| f.status.as_deref()) + .map(str::trim) + .filter(|value| !value.is_empty()); + + let accounts = models::fetch_platform_accounts( + &svc_ctx.db, + team_uuid, + user_uuid, + keyword, + platform_name, + status, + offset, + page_size, + ) + .await + .map_err(|e| e.to_string())?; + + let total = models::fetch_platform_accounts_count( + &svc_ctx.db, + team_uuid, + user_uuid, + keyword, + platform_name, + status, + ) .await .map_err(|e| e.to_string())?; diff --git a/src/services/audit.rs b/src/services/audit.rs index c096f89..4284829 100644 --- a/src/services/audit.rs +++ b/src/services/audit.rs @@ -178,33 +178,36 @@ pub async fn get_audit_logs_service( team_uuid: Option, payload: &ListAuditLogsRequest, ) -> Result<(Vec, i64), String> { - let offset = (payload.pagination.page - 1) * payload.pagination.page_size; - - let user_uuid_filter = payload.filters.as_ref().and_then(|f| f.user_uuid); - let action = payload.filters.as_ref().and_then(|f| f.action.as_deref()); - let target_type = payload.filters.as_ref().and_then(|f| f.target_type.as_deref()); - - let logs = models::fetch_audit_logs( - &svc_ctx.db, - current_user_uuid, - team_uuid, - user_uuid_filter, - action, - target_type, - offset, - payload.pagination.page_size, + let offset = (payload.pagination.page - 1) * payload.pagination.page_size; + + let user_uuid_filter = payload.filters.as_ref().and_then(|f| f.user_uuid); + let keyword = payload.filters.as_ref().and_then(|f| f.keyword.as_deref()); + let action = payload.filters.as_ref().and_then(|f| f.action.as_deref()); + let target_type = payload.filters.as_ref().and_then(|f| f.target_type.as_deref()); + + let logs = models::fetch_audit_logs( + &svc_ctx.db, + current_user_uuid, + team_uuid, + user_uuid_filter, + keyword, + action, + target_type, + offset, + payload.pagination.page_size, ) .await .map_err(|e| e.to_string())?; let total = models::fetch_audit_logs_count( - &svc_ctx.db, - current_user_uuid, - team_uuid, - user_uuid_filter, - action, - target_type, - ) + &svc_ctx.db, + current_user_uuid, + team_uuid, + user_uuid_filter, + keyword, + action, + target_type, + ) .await .map_err(|e| e.to_string())?; @@ -229,10 +232,18 @@ pub async fn get_audit_stats_service( team_uuid: Option, ) -> Result { // 总数 - let total_logs = - models::fetch_audit_logs_count(&svc_ctx.db, current_user_uuid, team_uuid, None, None, None) - .await - .map_err(|e| e.to_string())?; + let total_logs = + models::fetch_audit_logs_count( + &svc_ctx.db, + current_user_uuid, + team_uuid, + None, + None, + None, + None, + ) + .await + .map_err(|e| e.to_string())?; // 今日数量 let today = chrono::Utc::now().date_naive(); diff --git a/src/services/extensions.rs b/src/services/extensions.rs index 414d977..e79bb93 100644 --- a/src/services/extensions.rs +++ b/src/services/extensions.rs @@ -15,18 +15,22 @@ pub async fn get_extensions_service( svc_ctx: &SvcCtx, payload: &ListExtensionsRequest, ) -> Result<(Vec, i64), String> { - let offset = (payload.pagination.page - 1) * payload.pagination.page_size; - - let keyword = payload.filters.as_ref().and_then(|f| f.keyword.as_deref()); - let category = payload.filters.as_ref().and_then(|f| f.category.as_deref()); - - let mut extensions = models::extensions::fetch_extensions( - &svc_ctx.db, - keyword, - category, - offset, - payload.pagination.page_size, - ) + let offset = (payload.pagination.page - 1) * payload.pagination.page_size; + + let keyword = payload.filters.as_ref().and_then(|f| f.keyword.as_deref()); + let category = payload.filters.as_ref().and_then(|f| f.category.as_deref()); + let sort_by = payload.filters.as_ref().and_then(|f| f.sort_by.as_deref()); + let sort_order = payload.filters.as_ref().and_then(|f| f.sort_order.as_deref()); + + let mut extensions = models::extensions::fetch_extensions( + &svc_ctx.db, + keyword, + category, + sort_by, + sort_order, + offset, + payload.pagination.page_size, + ) .await .map_err(|e| e.to_string())?; diff --git a/src/services/proxies.rs b/src/services/proxies.rs index 28c7076..61217b4 100644 --- a/src/services/proxies.rs +++ b/src/services/proxies.rs @@ -57,53 +57,65 @@ pub async fn create_proxy_service( } /// 获取代理列表(根据可见性过滤) -pub async fn get_proxies_service( - svc_ctx: &SvcCtx, - user_uuid: Uuid, - workspace_uuid: Uuid, - payload: &ListProxiesRequest, -) -> Result<(Vec, i64), String> { - // 获取用户当前团队(用于过滤可见的代理) - let current_team_uuid = models::fetch_user_current_team(&svc_ctx.db, user_uuid) - .await - .map_err(|e| e.to_string())?; - - // 使用可见性过滤获取代理列表 - let proxies = models::fetch_visible_proxies_for_user( - &svc_ctx.db, - workspace_uuid, - user_uuid, - current_team_uuid, - ) - .await - .map_err(|e| e.to_string())?; - - // 应用类型和状态过滤 - let proxy_type = payload.filters.as_ref().and_then(|f| f.proxy_type.as_deref()); - let status = payload.filters.as_ref().and_then(|f| f.status.as_deref()); - - let mut filtered_proxies: Vec = proxies - .into_iter() - .filter(|p| { - (proxy_type.is_none() || p.proxy_type == proxy_type.unwrap()) - && (status.is_none() || p.status == status.unwrap()) - }) - .collect(); - - // 应用分页 - let total = filtered_proxies.len() as i64; - let offset = ((payload.pagination.page - 1) * payload.pagination.page_size) as usize; - let limit = payload.pagination.page_size as usize; - let paginated_proxies = if offset < filtered_proxies.len() { - filtered_proxies - .drain(offset..std::cmp::min(offset + limit, filtered_proxies.len())) - .collect() - } else { - vec![] - }; - - Ok((paginated_proxies, total)) -} +pub async fn get_proxies_service( + svc_ctx: &SvcCtx, + user_uuid: Uuid, + workspace_uuid: Uuid, + current_team_uuid: Option, + payload: &ListProxiesRequest, +) -> Result<(Vec, i64), String> { + let filters = payload.filters.as_ref(); + let keyword = filters + .and_then(|f| f.keyword.as_deref()) + .map(str::trim) + .filter(|value| !value.is_empty()); + let proxy_type = filters + .and_then(|f| f.proxy_type.as_deref()) + .map(str::trim) + .filter(|value| !value.is_empty()); + let status = filters + .and_then(|f| f.status.as_deref()) + .map(str::trim) + .filter(|value| !value.is_empty()); + let country = filters + .and_then(|f| f.country.as_deref()) + .map(str::trim) + .filter(|value| !value.is_empty()); + + let page = payload.pagination.page.max(1); + let page_size = payload.pagination.page_size.max(1); + let offset = (page - 1) * page_size; + + let proxies = models::fetch_visible_proxies_for_user_paginated( + &svc_ctx.db, + workspace_uuid, + user_uuid, + current_team_uuid, + keyword, + proxy_type, + status, + country, + offset, + page_size, + ) + .await + .map_err(|e| e.to_string())?; + + let total = models::fetch_visible_proxies_for_user_count( + &svc_ctx.db, + workspace_uuid, + user_uuid, + current_team_uuid, + keyword, + proxy_type, + status, + country, + ) + .await + .map_err(|e| e.to_string())?; + + Ok((proxies, total)) +} /// 获取代理详情 pub async fn get_proxy_service(svc_ctx: &SvcCtx, proxy_uuid: Uuid) -> Result { diff --git a/src/services/referral.rs b/src/services/referral.rs index beaa1ff..9c7c5a0 100644 --- a/src/services/referral.rs +++ b/src/services/referral.rs @@ -238,6 +238,7 @@ pub async fn get_referral_rewards_service( let rewards = models::referral::fetch_referral_rewards( &svc_ctx.db, user_uuid, + payload.keyword.as_deref(), payload.reward_type.as_deref(), payload.status.as_deref(), offset, @@ -249,6 +250,7 @@ pub async fn get_referral_rewards_service( let total = models::referral::fetch_referral_rewards_count( &svc_ctx.db, user_uuid, + payload.keyword.as_deref(), payload.reward_type.as_deref(), payload.status.as_deref(), ) @@ -269,6 +271,7 @@ pub async fn get_referred_users_service( let rows = models::referral::fetch_referred_users( &svc_ctx.db, user_uuid, + payload.keyword.as_deref(), payload.status.as_deref(), offset, payload.pagination.page_size, @@ -295,6 +298,7 @@ pub async fn get_referred_users_service( let total = models::referral::fetch_referred_users_count( &svc_ctx.db, user_uuid, + payload.keyword.as_deref(), payload.status.as_deref(), ) .await From 1d022df3178d6353588d0b6a81eb812dc97927f2 Mon Sep 17 00:00:00 2001 From: lius-new Date: Sat, 14 Mar 2026 00:06:06 +0800 Subject: [PATCH 24/60] feat: add password verification endpoint --- src/entitys/user.rs | 12 ++++++++++++ src/handlers/users.rs | 16 +++++++++++++++- src/routes/users.rs | 6 ++++++ src/services/users.rs | 22 +++++++++++++++++++++- 4 files changed, 54 insertions(+), 2 deletions(-) diff --git a/src/entitys/user.rs b/src/entitys/user.rs index d9874dd..a778684 100644 --- a/src/entitys/user.rs +++ b/src/entitys/user.rs @@ -87,6 +87,12 @@ pub struct UpdatePasswordRequest { pub new_password: String, } +/// 校验当前用户密码请求 +#[derive(Debug, Deserialize, Serialize, Default)] +pub struct VerifyPasswordRequest { + pub password: String, +} + /// 重置密码请求 #[derive(Debug, Deserialize, Serialize, Default)] pub struct ResetPasswordRequest { @@ -138,3 +144,9 @@ pub struct RegisterResponse { pub refresh_token: String, pub user_info: Option, } + +/// 密码校验响应 +#[derive(Debug, Serialize)] +pub struct VerifyPasswordResponse { + pub valid: bool, +} diff --git a/src/handlers/users.rs b/src/handlers/users.rs index 9683b84..59544b1 100644 --- a/src/handlers/users.rs +++ b/src/handlers/users.rs @@ -4,12 +4,13 @@ use crate::audit_log; use crate::entitys::{ LoginRequest, LoginResponse, RegisterRequest, RegisterResponse, ResetPasswordRequest, SendCodeRequest, UpdatePasswordRequest, UpdateUserRequest, UserResponse, + VerifyPasswordRequest, VerifyPasswordResponse, }; use crate::services::audit::log_audit_anonymous; use crate::services::users::{ get_current_user_service, login_service, refresh_token_service, register_service, reset_password_service, send_verification_code_service, update_password_service, - update_user_service, + update_user_service, verify_password_service, }; use crate::state::RequestContext; use crate::svc_ctx::SvcCtx; @@ -112,6 +113,19 @@ pub async fn update_password_handler( Ok(Response::success(Some("修改成功"), None)) } +/// 校验当前用户密码处理 +pub async fn verify_password_handler( + State(svc_ctx): State, + Extension(ctx): Extension, + Json(payload): Json, +) -> Result { + let result = verify_password_service(&svc_ctx, ctx.user_uuid_unwrap(), &payload) + .await + .map_err(|e| Response::fail(Some(&e.to_string())))?; + + Ok(Response::success(Some("校验成功"), Some(result))) +} + /// 重置密码处理 pub async fn reset_password_handler( State(svc_ctx): State, diff --git a/src/routes/users.rs b/src/routes/users.rs index 411f8f6..61a98c6 100644 --- a/src/routes/users.rs +++ b/src/routes/users.rs @@ -3,6 +3,7 @@ use axum::routing::post; use crate::handlers::{ get_current_user_handler, login_handler, refresh_token_handler, register_handler, reset_password_handler, send_code_handler, update_password_handler, update_user_handler, + verify_password_handler, }; use crate::routes::route::{self, MetaRoute}; @@ -31,6 +32,11 @@ pub fn register_routes(meta_route: &mut MetaRoute) -> () { post(update_password_handler), )); + user_route.add_route_item(route::RouteItem::post( + "/verify-password", + post(verify_password_handler), + )); + user_route.add_route_item(route::RouteItem::post( "/reset-password", post(reset_password_handler), diff --git a/src/services/users.rs b/src/services/users.rs index 59d45c5..881e9df 100644 --- a/src/services/users.rs +++ b/src/services/users.rs @@ -6,7 +6,8 @@ use crate::caches::{ }; use crate::entitys::{ CreateMachineUserRequest, LoginRequest, LoginResponse, MachineInfo, RegisterRequest, - RegisterResponse, ResetPasswordRequest, UpdatePasswordRequest, UpdateUserRequest, UserResponse, + RegisterResponse, ResetPasswordRequest, UpdatePasswordRequest, UpdateUserRequest, + UserResponse, VerifyPasswordRequest, VerifyPasswordResponse, }; use crate::models::{ billing, @@ -413,6 +414,25 @@ pub async fn update_password_service( Ok(()) } +/// 校验当前用户密码 +pub async fn verify_password_service( + svc_ctx: &SvcCtx, + user_uuid: Uuid, + payload: &VerifyPasswordRequest, +) -> Result { + let user_info = fetch_user_info_by_uuid(&svc_ctx.db, user_uuid) + .await? + .ok_or_else(|| anyhow::anyhow!("用户不存在"))?; + + if user_info.status != "active" { + return Err(anyhow::anyhow!("用户已被禁用")); + } + + Ok(VerifyPasswordResponse { + valid: verify_password(&payload.password, &user_info.password), + }) +} + /// 重置密码服务 /// /// # Arguments From 1cd9cf2e6a5a75f4b8b8e9d778a49de928f846cc Mon Sep 17 00:00:00 2001 From: lius-new Date: Sat, 14 Mar 2026 12:49:15 +0800 Subject: [PATCH 25/60] refactor: migrate server storage config to R2 --- configs/README.md | 2 +- configs/config.dev.toml | 19 +-- configs/config.prod.toml | 19 +-- configs/config.test.toml | 19 +-- src/dto/extensions.rs | 70 ++++----- src/lib.rs | 33 ++-- src/main.rs | 4 +- src/services/browser_kernel.rs | 10 +- src/services/extensions.rs | 86 +++++------ src/services/users.rs | 6 +- src/services/versions.rs | 25 ++- src/utils.rs | 26 ++-- src/utils/config.rs | 21 ++- src/utils/minios.rs | 111 -------------- src/utils/minios/get_objects.rs | 61 -------- src/utils/storage.rs | 86 +++++++++++ src/utils/storage/get_objects.rs | 84 ++++++++++ src/utils/{minios => storage}/put_objects.rs | 145 ++++++++++-------- .../{minios => storage}/remove_objects.rs | 4 +- 19 files changed, 416 insertions(+), 415 deletions(-) delete mode 100644 src/utils/minios.rs delete mode 100644 src/utils/minios/get_objects.rs create mode 100644 src/utils/storage.rs create mode 100644 src/utils/storage/get_objects.rs rename src/utils/{minios => storage}/put_objects.rs (63%) rename src/utils/{minios => storage}/remove_objects.rs (69%) diff --git a/configs/README.md b/configs/README.md index c675c9c..9d2a619 100644 --- a/configs/README.md +++ b/configs/README.md @@ -14,7 +14,7 @@ cargo run -- -f configs/config.update-gateway.dev.toml | 文件 | 用途 | |------|------| -| `config.dev.toml` / `config.test.toml` / `config.prod.toml` | 主服务(客户端网关),含数据库、Redis、MinIO、SMTP、工作空间配额等 | +| `config.dev.toml` / `config.test.toml` / `config.prod.toml` | 主服务(客户端网关),含数据库、Redis、对象存储、SMTP、工作空间配额等 | | `config.update-gateway.dev.toml` / `.prod` / `.test` | 更新网关,仅版本与维护相关接口,配置项更少 | **说明**:控制台网关(console-gateway)已拆分为独立仓库 `simprint-console-server`,此处不再保留 `config.console-gateway.*` 配置。 diff --git a/configs/config.dev.toml b/configs/config.dev.toml index a0fdba8..db29efc 100644 --- a/configs/config.dev.toml +++ b/configs/config.dev.toml @@ -29,14 +29,15 @@ idle_timeout = 600 [redis] url = "redis://192.168.224.129:6381?protocol=resp3" -[minio] -server_url = "http://111.230.19.75:9000" -resource_url = "http://111.230.19.75:9000" -access_key = "5kSjAHnITsMZQEwzJN2g" -secret_access_key = "eGs3ntV5KvmVzMbLpQwj86w5b5vdSHxUcmqcYhay" -avatar_bucket = "avatars" -extension_bucket = "extensions" -version_resource_bucket = "versions" +[storage] +endpoint = "https://503bc762ec98603e9ff53f374d78df22.r2.cloudflarestorage.com" +public_base_url = "https://pub-39307a5e69c74324855a762027cbf9bf.r2.dev" +access_key = "9295bc8f31a4bc6ce80d0014e29e6896" +secret_access_key = "60a9cd6d3dd6b10ea0f0aaf2411c127bfb01ef8700a62bf8cb3a8bf6c3bb6d73" +bucket = "simprint-client" +avatar_root = "avatars" +extension_root = "extensions" +version_root = "versions" [smtp] smtp_server = "smtp.163.com" @@ -49,4 +50,4 @@ smtp_password = "NSgjTyY6uSQwPGwV" max_environments = 8 max_team_members = 1 max_proxies = 99999 -max_rpa_tasks = 99999 \ No newline at end of file +max_rpa_tasks = 99999 diff --git a/configs/config.prod.toml b/configs/config.prod.toml index e147897..fe2af0d 100644 --- a/configs/config.prod.toml +++ b/configs/config.prod.toml @@ -29,14 +29,15 @@ idle_timeout = 600 [redis] url = "redis://111.230.19.75:6380?protocol=resp3" -[minio] -server_url = "http://111.230.19.75:9000" -resource_url = "http://111.230.19.75:9000" -access_key = "5kSjAHnITsMZQEwzJN2g" -secret_access_key = "eGs3ntV5KvmVzMbLpQwj86w5b5vdSHxUcmqcYhay" -avatar_bucket = "avatars" -extension_bucket = "extensions" -version_resource_bucket = "versions" +[storage] +endpoint = "https://503bc762ec98603e9ff53f374d78df22.r2.cloudflarestorage.com" +public_base_url = "https://pub-39307a5e69c74324855a762027cbf9bf.r2.dev" +access_key = "9295bc8f31a4bc6ce80d0014e29e6896" +secret_access_key = "60a9cd6d3dd6b10ea0f0aaf2411c127bfb01ef8700a62bf8cb3a8bf6c3bb6d73" +bucket = "simprint-client" +avatar_root = "avatars" +extension_root = "extensions" +version_root = "versions" [smtp] smtp_server = "smtp.163.com" @@ -49,4 +50,4 @@ smtp_password = "NSgjTyY6uSQwPGwV" max_environments = 8 max_team_members = 1 max_proxies = 99999 -max_rpa_tasks = 99999 \ No newline at end of file +max_rpa_tasks = 99999 diff --git a/configs/config.test.toml b/configs/config.test.toml index 03635ac..1457ab2 100644 --- a/configs/config.test.toml +++ b/configs/config.test.toml @@ -27,14 +27,15 @@ idle_timeout = 600 [redis] url = "redis://1.12.233.51:6380?protocol=resp3" -[minio] -server_url = "http://14.21.74.66:9000" -resource_url = "https://oss.csgot9.com" -access_key = "5kSjAHnITsMZQEwzJN2g" -secret_access_key = "eGs3ntV5KvmVzMbLpQwj86w5b5vdSHxUcmqcYhay" -avatar_bucket = "avatars" -extension_bucket = "extensions" -version_resource_bucket = "versions" +[storage] +endpoint = "https://503bc762ec98603e9ff53f374d78df22.r2.cloudflarestorage.com" +public_base_url = "https://pub-39307a5e69c74324855a762027cbf9bf.r2.dev" +access_key = "9295bc8f31a4bc6ce80d0014e29e6896" +secret_access_key = "60a9cd6d3dd6b10ea0f0aaf2411c127bfb01ef8700a62bf8cb3a8bf6c3bb6d73" +bucket = "simprint-client" +avatar_root = "avatars" +extension_root = "extensions" +version_root = "versions" [smtp] smtp_server = "smtp.163.com" @@ -47,4 +48,4 @@ smtp_password = "NSgjTyY6uSQwPGwV" max_environments = 8 max_team_members = 1 max_proxies = 99999 -max_rpa_tasks = 99999 \ No newline at end of file +max_rpa_tasks = 99999 diff --git a/src/dto/extensions.rs b/src/dto/extensions.rs index 46eefd9..7cec132 100644 --- a/src/dto/extensions.rs +++ b/src/dto/extensions.rs @@ -4,7 +4,7 @@ use serde::Serialize; use sqlx::FromRow; use uuid::Uuid; -use crate::utils::minios::get_objects; +use crate::utils::storage::get_objects; /// 扩展 DTO #[derive(Debug, Clone, FromRow, Serialize)] @@ -36,45 +36,45 @@ pub struct ExtensionDto { impl ExtensionDto { /// 将 object path 转换为完整 URL /// - /// 数据库中存储的是 MinIO object path(如 `ext_id/version/hash.crx`), - /// 返回给客户端时需要组装为完整 URL。 - /// - /// 如果 URL 已经是完整的 http(s) 地址,则不进行转换。 - pub fn transform_urls(&mut self, resource_url: &str, extension_bucket: &str) { - // 转换图标 URL - if let Some(path) = &self.icon_url { - if !path.is_empty() && !path.starts_with("http") { - self.icon_url = Some(get_objects::get_extension_icon_url( - resource_url, - extension_bucket, - path, - )); - } - } + /// 数据库中存储的是对象存储 object path(如 `ext_id/version/hash.crx`), + /// 返回给客户端时需要组装为完整 URL。 + /// + /// 如果 URL 已经是完整的 http(s) 地址,则不进行转换。 + pub fn transform_urls(&mut self, public_base_url: &str, extension_root: &str) { + // 转换图标 URL + if let Some(path) = &self.icon_url { + if !path.is_empty() && !path.starts_with("http") { + self.icon_url = Some(get_objects::get_extension_icon_url( + public_base_url, + extension_root, + path, + )); + } + } // 转换下载 URL - if let Some(path) = &self.download_url { - if !path.is_empty() && !path.starts_with("http") { - self.download_url = Some(get_objects::get_extension_crx_url( - resource_url, - extension_bucket, - path, - )); - } - } + if let Some(path) = &self.download_url { + if !path.is_empty() && !path.starts_with("http") { + self.download_url = Some(get_objects::get_extension_crx_url( + public_base_url, + extension_root, + path, + )); + } + } } /// 批量转换 ExtensionDto 列表中的 object path 为完整 URL - pub fn transform_urls_batch( - extensions: &mut [ExtensionDto], - resource_url: &str, - extension_bucket: &str, - ) { - for ext in extensions.iter_mut() { - ext.transform_urls(resource_url, extension_bucket); - } - } -} + pub fn transform_urls_batch( + extensions: &mut [ExtensionDto], + public_base_url: &str, + extension_root: &str, + ) { + for ext in extensions.iter_mut() { + ext.transform_urls(public_base_url, extension_root); + } + } +} /// 用户扩展 DTO #[derive(Debug, Clone, FromRow, Serialize)] diff --git a/src/lib.rs b/src/lib.rs index 54120ab..0c3c9fb 100644 --- a/src/lib.rs +++ b/src/lib.rs @@ -19,28 +19,17 @@ pub async fn init_encrypt_secret(config: &IConfig) { utils::init_rsa_secret(key_path).await; } -/// 初始化minio -pub async fn init_minio(config: &IConfig) { - let minio_config = &config.clone().minio; +/// 初始化对象存储 +pub async fn init_storage(config: &IConfig) { + let storage_config = &config.clone().storage; - let client = { - utils::init_minio( - &minio_config.server_url, - &minio_config.access_key, - &minio_config.secret_access_key, - None, - None, - ) + utils::init_storage( + &storage_config.endpoint, + &storage_config.access_key, + &storage_config.secret_access_key, + None, + None, + ) .await - .expect("初始化Minio客户端失败") - }; - - // 初始化存储桶 - client - .init_bucket(vec![ - minio_config.avatar_bucket.clone(), - minio_config.extension_bucket.clone(), - ]) - .await - .expect("初始化Minio桶失败"); + .expect("初始化对象存储客户端失败"); } diff --git a/src/main.rs b/src/main.rs index b50b85a..3309379 100644 --- a/src/main.rs +++ b/src/main.rs @@ -1,7 +1,7 @@ use std::env; use axum::{Router, middleware}; -use simprint_server::{init_encrypt_secret, init_minio}; +use simprint_server::{init_encrypt_secret, init_storage}; use simprint_server::{ middlewares, routes::{ @@ -44,7 +44,7 @@ async fn run_server(config: IConfig) -> Result<(), Box> { // init resources init_encrypt_secret(&config).await; - init_minio(&config).await; + init_storage(&config).await; let port = &config.app.port; let listener = tokio::net::TcpListener::bind(format!("0.0.0.0:{}", port)) diff --git a/src/services/browser_kernel.rs b/src/services/browser_kernel.rs index baee0df..f1be244 100644 --- a/src/services/browser_kernel.rs +++ b/src/services/browser_kernel.rs @@ -24,17 +24,13 @@ pub async fn get_browser_kernel_list_service( let mut map: HashMap> = HashMap::new(); - let minio_config = &svc_ctx.config.minio; - let (bucket_name, resource_url) = ( - &minio_config.version_resource_bucket, - &minio_config.resource_url, - ); + let storage_config = &svc_ctx.config.storage; for (type_code, _resource_name, version) in results { map.entry(type_code).or_insert_with(Vec::new).push(Version { url: Some(get_version_resource_url( - resource_url, - bucket_name, + &storage_config.public_base_url, + &storage_config.version_root, &version.url.unwrap_or_default(), )), ..version diff --git a/src/services/extensions.rs b/src/services/extensions.rs index e79bb93..9742b3a 100644 --- a/src/services/extensions.rs +++ b/src/services/extensions.rs @@ -8,7 +8,7 @@ use crate::entitys::{ use crate::models; use crate::models::environments as env_models; use crate::svc_ctx::SvcCtx; -use crate::utils::minios::get_objects; +use crate::utils::storage::get_objects; /// 获取扩展列表 pub async fn get_extensions_service( @@ -35,9 +35,9 @@ pub async fn get_extensions_service( .map_err(|e| e.to_string())?; // 将 object path 转换为完整 URL - let resource_url = svc_ctx.config.minio.resource_url.as_str(); - let extension_bucket = svc_ctx.config.minio.extension_bucket.as_str(); - ExtensionDto::transform_urls_batch(&mut extensions, resource_url, extension_bucket); + let public_base_url = svc_ctx.config.storage.public_base_url.as_str(); + let extension_root = svc_ctx.config.storage.extension_root.as_str(); + ExtensionDto::transform_urls_batch(&mut extensions, public_base_url, extension_root); let total = models::extensions::fetch_extensions_count(&svc_ctx.db, keyword, category) .await @@ -57,9 +57,9 @@ pub async fn get_extension_service( .ok_or_else(|| "扩展不存在".to_string())?; // 将 object path 转换为完整 URL - let resource_url = svc_ctx.config.minio.resource_url.as_str(); - let extension_bucket = svc_ctx.config.minio.extension_bucket.as_str(); - extension.transform_urls(&resource_url, &extension_bucket); + let public_base_url = svc_ctx.config.storage.public_base_url.as_str(); + let extension_root = svc_ctx.config.storage.extension_root.as_str(); + extension.transform_urls(&public_base_url, &extension_root); Ok(extension) } @@ -77,8 +77,8 @@ pub async fn get_user_installed_extensions_service( user_uuid: Uuid, team_uuid: Option, ) -> Result, String> { - let resource_url = svc_ctx.config.minio.resource_url.as_str(); - let extension_bucket = svc_ctx.config.minio.extension_bucket.as_str(); + let public_base_url = svc_ctx.config.storage.public_base_url.as_str(); + let extension_root = svc_ctx.config.storage.extension_root.as_str(); // 查询用户直接安装的扩展 let user_installed = models::extensions::fetch_user_extensions(&svc_ctx.db, user_uuid) @@ -104,9 +104,9 @@ pub async fn get_user_installed_extensions_service( let mut icon_url = ext.icon_url.clone(); if let Some(path) = &icon_url { if !path.is_empty() && !path.starts_with("http") { - icon_url = Some(get_objects::get_extension_icon_url( - &resource_url, - &extension_bucket, + icon_url = Some(get_objects::get_extension_icon_url( + &public_base_url, + &extension_root, path, )); } @@ -181,9 +181,9 @@ pub async fn get_user_installed_extensions_service( let mut icon_url = ext.icon_url.clone(); if let Some(path) = &icon_url { if !path.is_empty() && !path.starts_with("http") { - icon_url = Some(get_objects::get_extension_icon_url( - &resource_url, - &extension_bucket, + icon_url = Some(get_objects::get_extension_icon_url( + &public_base_url, + &extension_root, path, )); } @@ -261,8 +261,8 @@ pub async fn get_team_installed_extensions_service( team_uuid: Uuid, user_uuid: Uuid, ) -> Result, String> { - let resource_url = svc_ctx.config.minio.resource_url.as_str(); - let extension_bucket = svc_ctx.config.minio.extension_bucket.as_str(); + let public_base_url = svc_ctx.config.storage.public_base_url.as_str(); + let extension_root = svc_ctx.config.storage.extension_root.as_str(); // 查询团队直接安装的扩展 let team_installed = models::extensions::fetch_team_extensions(&svc_ctx.db, team_uuid) @@ -295,9 +295,9 @@ pub async fn get_team_installed_extensions_service( let mut icon_url = ext.icon_url.clone(); if let Some(path) = &icon_url { if !path.is_empty() && !path.starts_with("http") { - icon_url = Some(get_objects::get_extension_icon_url( - &resource_url, - &extension_bucket, + icon_url = Some(get_objects::get_extension_icon_url( + &public_base_url, + &extension_root, path, )); } @@ -380,9 +380,9 @@ pub async fn get_team_installed_extensions_service( let mut icon_url = ext.icon_url.clone(); if let Some(path) = &icon_url { if !path.is_empty() && !path.starts_with("http") { - icon_url = Some(get_objects::get_extension_icon_url( - &resource_url, - &extension_bucket, + icon_url = Some(get_objects::get_extension_icon_url( + &public_base_url, + &extension_root, path, )); } @@ -805,8 +805,8 @@ pub async fn get_environment_extensions_service( ) -> Result, String> { use std::collections::HashMap; - let resource_url = svc_ctx.config.minio.resource_url.as_str(); - let extension_bucket = svc_ctx.config.minio.extension_bucket.as_str(); + let public_base_url = svc_ctx.config.storage.public_base_url.as_str(); + let extension_root = svc_ctx.config.storage.extension_root.as_str(); // 使用 HashMap 去重,key 为 extension_id let mut extension_map: HashMap = HashMap::new(); @@ -835,9 +835,9 @@ pub async fn get_environment_extensions_service( let mut icon_url = ext.icon_url.clone(); if let Some(path) = &icon_url { if !path.is_empty() && !path.starts_with("http") { - icon_url = Some(crate::utils::minios::get_objects::get_extension_icon_url( - resource_url, - extension_bucket, + icon_url = Some(crate::utils::storage::get_objects::get_extension_icon_url( + public_base_url, + extension_root, path, )); } @@ -846,9 +846,9 @@ pub async fn get_environment_extensions_service( let mut download_url = ext.download_url.clone(); if let Some(path) = &download_url { if !path.is_empty() && !path.starts_with("http") { - download_url = Some(crate::utils::minios::get_objects::get_extension_crx_url( - resource_url, - extension_bucket, + download_url = Some(crate::utils::storage::get_objects::get_extension_crx_url( + public_base_url, + extension_root, path, )); } @@ -884,9 +884,9 @@ pub async fn get_environment_extensions_service( let mut icon_url = ext.icon_url.clone(); if let Some(path) = &icon_url { if !path.is_empty() && !path.starts_with("http") { - icon_url = Some(crate::utils::minios::get_objects::get_extension_icon_url( - resource_url, - extension_bucket, + icon_url = Some(crate::utils::storage::get_objects::get_extension_icon_url( + public_base_url, + extension_root, path, )); } @@ -895,9 +895,9 @@ pub async fn get_environment_extensions_service( let mut download_url = ext.download_url.clone(); if let Some(path) = &download_url { if !path.is_empty() && !path.starts_with("http") { - download_url = Some(crate::utils::minios::get_objects::get_extension_crx_url( - resource_url, - extension_bucket, + download_url = Some(crate::utils::storage::get_objects::get_extension_crx_url( + public_base_url, + extension_root, path, )); } @@ -934,9 +934,9 @@ pub async fn get_environment_extensions_service( let mut icon_url = ext.icon_url.clone(); if let Some(path) = &icon_url { if !path.is_empty() && !path.starts_with("http") { - icon_url = Some(crate::utils::minios::get_objects::get_extension_icon_url( - resource_url, - extension_bucket, + icon_url = Some(crate::utils::storage::get_objects::get_extension_icon_url( + public_base_url, + extension_root, path, )); } @@ -945,9 +945,9 @@ pub async fn get_environment_extensions_service( let mut download_url = ext.download_url.clone(); if let Some(path) = &download_url { if !path.is_empty() && !path.starts_with("http") { - download_url = Some(crate::utils::minios::get_objects::get_extension_crx_url( - resource_url, - extension_bucket, + download_url = Some(crate::utils::storage::get_objects::get_extension_crx_url( + public_base_url, + extension_root, path, )); } diff --git a/src/services/users.rs b/src/services/users.rs index 881e9df..3889a08 100644 --- a/src/services/users.rs +++ b/src/services/users.rs @@ -344,9 +344,9 @@ pub async fn get_current_user_service( .avatar_hash .as_ref() .map(|hash| { - crate::utils::minios::get_objects::get_avatar_url( - &svc_ctx.config.minio.resource_url, - &svc_ctx.config.minio.avatar_bucket, + crate::utils::storage::get_objects::get_avatar_url( + &svc_ctx.config.storage.public_base_url, + &svc_ctx.config.storage.avatar_root, hash, ) }); diff --git a/src/services/versions.rs b/src/services/versions.rs index cf2f3b6..8ad02c0 100644 --- a/src/services/versions.rs +++ b/src/services/versions.rs @@ -44,14 +44,13 @@ pub async fn get_version_by_id(svc_ctx: &SvcCtx, id: i32) -> Result> let mut map: HashMap> = HashMap::new(); - let minio_config = &svc_ctx.config.minio; - let (bucket_name, resource_url) = ( - &minio_config.version_resource_bucket, - &minio_config.resource_url, - ); + let storage_config = &svc_ctx.config.storage; for (type_code, _resource_name, version) in results { map.entry(type_code).or_insert_with(Vec::new).push(Version { url: Some(get_version_resource_url( - &bucket_name, - &resource_url, + &storage_config.public_base_url, + &storage_config.version_root, &version.url.unwrap_or_default(), )), ..version diff --git a/src/utils.rs b/src/utils.rs index 1d0f239..f8901cb 100644 --- a/src/utils.rs +++ b/src/utils.rs @@ -1,19 +1,19 @@ mod config; pub mod encryption; -mod extractor; -pub mod gray_checker; -mod jwt; -pub mod minios; -mod password; -mod responses; -mod secret; +mod extractor; +pub mod gray_checker; +mod jwt; +pub mod storage; +mod password; +mod responses; +mod secret; -pub use config::*; -pub use encryption::*; -pub use extractor::*; -pub use jwt::*; -pub use minios::*; -pub use password::*; +pub use config::*; +pub use encryption::*; +pub use extractor::*; +pub use jwt::*; +pub use storage::*; +pub use password::*; pub use responses::*; pub use secret::*; diff --git a/src/utils/config.rs b/src/utils/config.rs index 22dba8f..847e192 100644 --- a/src/utils/config.rs +++ b/src/utils/config.rs @@ -18,20 +18,17 @@ pub struct RedisConfig { pub url: String, } -/// MINIO 配置 +/// 对象存储配置 #[derive(Debug, Clone, Deserialize)] -pub struct MinioConfig { - pub server_url: String, - pub resource_url: String, +pub struct StorageConfig { + pub endpoint: String, + pub public_base_url: String, pub access_key: String, pub secret_access_key: String, - pub avatar_bucket: String, - pub extension_bucket: String, - pub version_resource_bucket: String, -} - -fn default_extension_bucket() -> String { - "extensions".to_string() + pub bucket: String, + pub avatar_root: String, + pub extension_root: String, + pub version_root: String, } /// SMTP 配置 @@ -87,7 +84,7 @@ pub struct IConfig { pub app: AppConfig, pub database: DatabaseConfig, pub redis: RedisConfig, - pub minio: MinioConfig, + pub storage: StorageConfig, pub smtp: Option, #[serde(default = "default_workspace_quota_config")] pub workspace_quota: WorkspaceQuotaConfig, diff --git a/src/utils/minios.rs b/src/utils/minios.rs deleted file mode 100644 index dd83ebb..0000000 --- a/src/utils/minios.rs +++ /dev/null @@ -1,111 +0,0 @@ -use std::{path::Path, sync::Arc}; - -pub mod get_objects; -pub mod put_objects; -pub mod remove_objects; - -use minio::s3::types::S3Api; -use minio::s3::{client::ClientBuilder, creds::StaticProvider, http::BaseUrl}; -use tokio::sync::OnceCell; - -/// Minio client for interacting with MinIO server -pub struct Minio { - pub client: minio::s3::client::Client, -} - -impl Minio { - /// 创建一个新的Minio客户端 - /// - /// # Arguments - /// - `base_url`: MinIO服务器的基础URL。 - /// - `access_key`: MinIO认证的访问密钥。 - /// - `secret_key`: MinIO认证的密钥。 - /// - `session_token`: 可选的临时凭证会话令牌。 - /// - `cert_path`: 可选的SSL证书文件路径。 - pub fn new( - base_url: &str, - access_key: &str, - secret_key: &str, - session_token: Option<&str>, - cert_path: Option<&Path>, - ) -> Result { - let base_url = base_url.parse::()?; - let static_provider = StaticProvider::new(access_key, secret_key, session_token); - - let client = ClientBuilder::new(base_url) - .provider(Some(Box::new(static_provider))) - .ssl_cert_file(cert_path) - .build()?; - - Ok(Self { client }) - } - - /// 初始化bucket - pub async fn init_bucket(&self, buckets: Vec) -> Result<(), anyhow::Error> { - for bucket_name in &buckets { - let resp = self.client.bucket_exists(bucket_name).send().await?; - - if !resp.exists { - self.client.create_bucket(bucket_name).send().await?; - // 自定义桶策略 - // 这里设置桶策略为公开读取 - let config = format!( - r#"{{ - "Version": "2012-10-17", - "Statement": [ - {{ - "Effect": "Allow", - "Principal": {{"AWS": "*"}}, - "Action": "s3:GetObject", - "Resource": "arn:aws:s3:::{}/*" - }} - ] - }}"#, - bucket_name - ); - self.client.put_bucket_policy(bucket_name).config(config).send().await?; - } - } - - Ok(()) - } - - // 获取Minio客户端 - pub fn get_client(&self) -> &minio::s3::client::Client { - &self.client - } -} - -static MINIO: OnceCell> = OnceCell::const_new(); - -/// 初始化Minio客户端 -pub async fn init_minio( - base_url: &str, - access_key: &str, - secret_key: &str, - session_token: Option<&str>, - cert_path: Option<&Path>, -) -> Result<&'static Arc, anyhow::Error> { - Ok(MINIO - .get_or_init(|| async { - let minio = match Minio::new(base_url, access_key, secret_key, session_token, cert_path) - { - Ok(v) => v, - Err(e) => { - tracing::error!("Failed to initialize the MinIO client: {:?}", e); - std::process::exit(-1); - } - }; - - Arc::new(minio) - }) - .await) -} - -/// 获取Minio客户端 -pub fn get_minio_client() -> Result<&'static minio::s3::client::Client, anyhow::Error> { - match MINIO.get() { - Some(minio) => Ok(minio.get_client()), - None => Err(anyhow::anyhow!("MinIO client not initialized")), - } -} diff --git a/src/utils/minios/get_objects.rs b/src/utils/minios/get_objects.rs deleted file mode 100644 index 57a22f7..0000000 --- a/src/utils/minios/get_objects.rs +++ /dev/null @@ -1,61 +0,0 @@ -//! MinIO 对象 URL 获取模块 -//! -//! 提供获取 MinIO 存储对象访问 URL 的功能 - -/// 获取对象的完整 URL -/// -/// # Arguments -/// - `resource_url`: MinIO 资源访问基础 URL(如 `http://localhost:9000`) -/// - `bucket_name`: 存储桶名称 -/// - `object_path`: 对象路径 -/// -/// # Returns -/// 返回完整的访问 URL -pub fn get_object_url(resource_url: &str, bucket_name: &str, object_path: &str) -> String { - format!("{}/{}/{}", resource_url, bucket_name, object_path) -} - -/// 获取扩展 CRX 文件的完整 URL -/// -/// # Arguments -/// - `resource_url`: MinIO 资源访问基础 URL -/// - `bucket_name`: 扩展存储桶名称 -/// - `object_path`: CRX 对象路径(数据库中存储的路径) -pub fn get_extension_crx_url(resource_url: &str, bucket_name: &str, object_path: &str) -> String { - get_object_url(resource_url, bucket_name, object_path) -} - -/// 获取扩展图标的完整 URL -/// -/// # Arguments -/// - `resource_url`: MinIO 资源访问基础 URL -/// - `bucket_name`: 扩展存储桶名称 -/// - `object_path`: 图标对象路径(数据库中存储的路径) -// pub fn get_extension_icon_url(resource_url: &str, bucket_name: &str, object_path: &str) -> String { -pub fn get_extension_icon_url(resource_url: &str, bucket_name: &str, object_path: &str) -> String { - get_object_url(resource_url, bucket_name, object_path) -} - -/// 获取头像的完整 URL -/// -/// # Arguments -/// - `resource_url`: MinIO 资源访问基础 URL -/// - `bucket_name`: 头像存储桶名称 -/// - `resource_hash`: 头像文件哈希 -pub fn get_avatar_url(resource_url: &str, bucket_name: &str, resource_hash: &str) -> String { - get_object_url(resource_url, bucket_name, resource_hash) -} - -/// 获取版本资源的完整 URL -/// -/// # Arguments -/// - `resource_url`: MinIO 资源访问基础 URL -/// - `bucket_name`: 版本资源存储桶名称 -/// - `object_path`: 版本资源对象路径(数据库中存储的路径) -pub fn get_version_resource_url( - resource_url: &str, - bucket_name: &str, - object_path: &str, -) -> String { - get_object_url(resource_url, bucket_name, object_path) -} diff --git a/src/utils/storage.rs b/src/utils/storage.rs new file mode 100644 index 0000000..c4dbc1c --- /dev/null +++ b/src/utils/storage.rs @@ -0,0 +1,86 @@ +use std::{path::Path, sync::Arc}; + +pub mod get_objects; +pub mod put_objects; +pub mod remove_objects; + +use minio::s3::{client::ClientBuilder, creds::StaticProvider, http::BaseUrl}; +use tokio::sync::OnceCell; + +/// S3-compatible object storage client +pub struct ObjectStorage { + pub client: minio::s3::client::Client, +} + +impl ObjectStorage { + /// 创建一个新的对象存储客户端 + /// + /// # Arguments + /// - `base_url`: 对象存储服务的基础URL。 + /// - `access_key`: 对象存储认证的访问密钥。 + /// - `secret_key`: 对象存储认证的密钥。 + /// - `session_token`: 可选的临时凭证会话令牌。 + /// - `cert_path`: 可选的SSL证书文件路径。 + pub fn new( + base_url: &str, + access_key: &str, + secret_key: &str, + session_token: Option<&str>, + cert_path: Option<&Path>, + ) -> Result { + let base_url = base_url.parse::()?; + let static_provider = StaticProvider::new(access_key, secret_key, session_token); + + let client = ClientBuilder::new(base_url) + .provider(Some(Box::new(static_provider))) + .ssl_cert_file(cert_path) + .build()?; + + Ok(Self { client }) + } + + // 获取底层对象存储客户端 + pub fn get_client(&self) -> &minio::s3::client::Client { + &self.client + } +} + +static STORAGE: OnceCell> = OnceCell::const_new(); + +/// 初始化对象存储客户端 +pub async fn init_storage( + base_url: &str, + access_key: &str, + secret_key: &str, + session_token: Option<&str>, + cert_path: Option<&Path>, +) -> Result<&'static Arc, anyhow::Error> { + Ok(STORAGE + .get_or_init(|| async { + let storage = match ObjectStorage::new( + base_url, + access_key, + secret_key, + session_token, + cert_path, + ) + { + Ok(v) => v, + Err(e) => { + tracing::error!("Failed to initialize the storage client: {:?}", e); + std::process::exit(-1); + } + }; + + Arc::new(storage) + }) + .await) +} + +/// 获取对象存储客户端 +pub fn get_storage_client() -> Result<&'static minio::s3::client::Client, anyhow::Error> { + match STORAGE.get() { + Some(storage) => Ok(storage.get_client()), + None => Err(anyhow::anyhow!("storage client not initialized")), + } +} diff --git a/src/utils/storage/get_objects.rs b/src/utils/storage/get_objects.rs new file mode 100644 index 0000000..caaf5b5 --- /dev/null +++ b/src/utils/storage/get_objects.rs @@ -0,0 +1,84 @@ +//! 对象存储 URL 获取模块 +//! +//! 提供获取对象存储资源访问 URL 的功能 + +fn ensure_rooted_path(root: &str, object_path: &str) -> String { + let root = root.trim_matches('/'); + let object_path = object_path.trim_start_matches('/'); + if object_path.is_empty() { + return root.to_string(); + } + if object_path == root || object_path.starts_with(&format!("{}/", root)) { + object_path.to_string() + } else { + format!("{}/{}", root, object_path) + } +} + +/// 获取对象的完整 URL +/// +/// # Arguments +/// - `public_base_url`: 对象存储资源访问基础 URL +/// - `object_path`: 对象路径 +/// +/// # Returns +/// 返回完整的访问 URL +pub fn get_object_url(public_base_url: &str, object_path: &str) -> String { + format!( + "{}/{}", + public_base_url.trim_end_matches('/'), + object_path.trim_start_matches('/') + ) +} + +/// 获取扩展 CRX 文件的完整 URL +/// +/// # Arguments +/// - `public_base_url`: 对象存储资源访问基础 URL +/// - `extension_root`: 扩展对象根路径 +/// - `object_path`: CRX 对象路径(数据库中存储的路径) +pub fn get_extension_crx_url( + public_base_url: &str, + extension_root: &str, + object_path: &str, +) -> String { + get_object_url(public_base_url, &ensure_rooted_path(extension_root, object_path)) +} + +/// 获取扩展图标的完整 URL +/// +/// # Arguments +/// - `public_base_url`: 对象存储资源访问基础 URL +/// - `extension_root`: 扩展对象根路径 +/// - `object_path`: 图标对象路径(数据库中存储的路径) +pub fn get_extension_icon_url( + public_base_url: &str, + extension_root: &str, + object_path: &str, +) -> String { + get_object_url(public_base_url, &ensure_rooted_path(extension_root, object_path)) +} + +/// 获取头像的完整 URL +/// +/// # Arguments +/// - `public_base_url`: 对象存储资源访问基础 URL +/// - `avatar_root`: 头像对象根路径 +/// - `resource_hash`: 头像文件哈希 +pub fn get_avatar_url(public_base_url: &str, avatar_root: &str, resource_hash: &str) -> String { + get_object_url(public_base_url, &ensure_rooted_path(avatar_root, resource_hash)) +} + +/// 获取版本资源的完整 URL +/// +/// # Arguments +/// - `public_base_url`: 对象存储资源访问基础 URL +/// - `version_root`: 版本资源对象根路径 +/// - `object_path`: 版本资源对象路径(数据库中存储的路径) +pub fn get_version_resource_url( + public_base_url: &str, + version_root: &str, + object_path: &str, +) -> String { + get_object_url(public_base_url, &ensure_rooted_path(version_root, object_path)) +} diff --git a/src/utils/minios/put_objects.rs b/src/utils/storage/put_objects.rs similarity index 63% rename from src/utils/minios/put_objects.rs rename to src/utils/storage/put_objects.rs index c05eefb..524b6ee 100644 --- a/src/utils/minios/put_objects.rs +++ b/src/utils/storage/put_objects.rs @@ -1,14 +1,14 @@ -//! MinIO 对象上传模块 -//! -//! 提供各类资源上传到 MinIO 的功能 +//! 对象存储上传模块 +//! +//! 提供各类资源上传到对象存储的功能 use bytes::Bytes; use minio::s3::{segmented_bytes::SegmentedBytes, types::S3Api}; use sha2::{Digest, Sha256}; -use super::get_minio_client; +use super::get_storage_client; -/// 上传通用对象到 MinIO +/// 上传通用对象到对象存储 /// /// # Arguments /// - `bucket_name`: 存储桶名称 @@ -19,39 +19,45 @@ pub async fn put_object( object_path: &str, data: Bytes, ) -> Result<(), anyhow::Error> { - let client = get_minio_client()?; + let client = get_storage_client()?; let data: SegmentedBytes = SegmentedBytes::from(data); client.put_object(bucket_name, object_path, data).send().await?; Ok(()) } -/// 上传扩展 CRX 文件到 MinIO -/// -/// 存储路径格式:`{extension_id_hash}/{version}/{crx_hash}.crx` -/// -/// # Arguments -/// - `bucket_name`: 扩展存储桶名称 -/// - `extension_id`: 扩展 ID(如 Chrome 扩展 ID) -/// - `version`: 扩展版本号 -/// - `crx_hash`: CRX 文件内容的哈希值 -/// - `data`: CRX 文件数据 -/// -/// # Returns -/// 返回存储的对象路径(不包含 bucket 名称) -pub async fn put_extension_crx( - bucket_name: &str, - extension_id: &str, - version: &str, - crx_hash: &str, - data: Bytes, -) -> Result { - let client = get_minio_client()?; +/// 上传扩展 CRX 文件到对象存储 +/// +/// 存储路径格式:`{extension_root}/{extension_id_hash}/{version}/{crx_hash}.crx` +/// +/// # Arguments +/// - `bucket_name`: 存储桶名称 +/// - `extension_root`: 扩展对象根路径 +/// - `extension_id`: 扩展 ID(如 Chrome 扩展 ID) +/// - `version`: 扩展版本号 +/// - `crx_hash`: CRX 文件内容的哈希值 +/// - `data`: CRX 文件数据 +/// +/// # Returns +/// 返回存储的对象路径(不包含 bucket 名称) +pub async fn put_extension_crx( + bucket_name: &str, + extension_root: &str, + extension_id: &str, + version: &str, + crx_hash: &str, + data: Bytes, +) -> Result { + let client = get_storage_client()?; // 计算扩展 ID 的哈希作为目录名 let extension_id_hash = calculate_short_hash(extension_id); - // 构建对象路径:{extension_id_hash}/{version}/{crx_hash}.crx - let object_path = format!("{}/{}/{}.crx", extension_id_hash, version, crx_hash); + // 构建对象路径:{extension_root}/{extension_id_hash}/{version}/{crx_hash}.crx + let object_path = format!( + "{}/{}.crx", + join_root(extension_root, &format!("{}/{}", extension_id_hash, version)), + crx_hash + ); let data: SegmentedBytes = SegmentedBytes::from(data); client.put_object(bucket_name, &object_path, data).send().await?; @@ -65,30 +71,37 @@ pub async fn put_extension_crx( Ok(object_path) } -/// 上传扩展图标到 MinIO -/// -/// 存储路径格式:`{extension_id_hash}/icons/{icon_hash}.{ext}` -/// -/// # Arguments -/// - `bucket_name`: 扩展存储桶名称 -/// - `extension_id`: 扩展 ID -/// - `icon_hash`: 图标文件的哈希值 -/// - `extension`: 文件扩展名(如 `png`、`svg`) -/// - `data`: 图标数据 +/// 上传扩展图标到对象存储 +/// +/// 存储路径格式:`{extension_root}/{extension_id_hash}/icons/{icon_hash}.{ext}` +/// +/// # Arguments +/// - `bucket_name`: 存储桶名称 +/// - `extension_root`: 扩展对象根路径 +/// - `extension_id`: 扩展 ID +/// - `icon_hash`: 图标文件的哈希值 +/// - `extension`: 文件扩展名(如 `png`、`svg`) +/// - `data`: 图标数据 /// /// # Returns /// 返回存储的对象路径 -pub async fn put_extension_icon( - bucket_name: &str, - extension_id: &str, - icon_hash: &str, - extension: &str, - data: Bytes, -) -> Result { - let client = get_minio_client()?; - - let extension_id_hash = calculate_short_hash(extension_id); - let object_path = format!("{}/icons/{}.{}", extension_id_hash, icon_hash, extension); +pub async fn put_extension_icon( + bucket_name: &str, + extension_root: &str, + extension_id: &str, + icon_hash: &str, + extension: &str, + data: Bytes, +) -> Result { + let client = get_storage_client()?; + + let extension_id_hash = calculate_short_hash(extension_id); + let object_path = format!( + "{}/icons/{}.{}", + join_root(extension_root, &extension_id_hash), + icon_hash, + extension + ); let data: SegmentedBytes = SegmentedBytes::from(data); client.put_object(bucket_name, &object_path, data).send().await?; @@ -102,17 +115,27 @@ pub async fn put_extension_icon( Ok(object_path) } -/// 上传头像对象到 MinIO -pub async fn put_avatar_object( - bucket_name: &str, - object: &str, - data: Bytes, -) -> Result<(), anyhow::Error> { - let client = get_minio_client()?; - let data: SegmentedBytes = SegmentedBytes::from(data); - client.put_object(bucket_name, object, data).send().await?; - Ok(()) -} +/// 上传头像对象到对象存储 +pub async fn put_avatar_object( + bucket_name: &str, + avatar_root: &str, + object: &str, + data: Bytes, +) -> Result<(), anyhow::Error> { + let client = get_storage_client()?; + let data: SegmentedBytes = SegmentedBytes::from(data); + let object_path = join_root(avatar_root, object); + client.put_object(bucket_name, &object_path, data).send().await?; + Ok(()) +} + +fn join_root(root: &str, object_path: &str) -> String { + format!( + "{}/{}", + root.trim_matches('/'), + object_path.trim_start_matches('/') + ) +} /// 计算字符串的短哈希值(用于目录命名) /// diff --git a/src/utils/minios/remove_objects.rs b/src/utils/storage/remove_objects.rs similarity index 69% rename from src/utils/minios/remove_objects.rs rename to src/utils/storage/remove_objects.rs index a999ab4..352e6f6 100644 --- a/src/utils/minios/remove_objects.rs +++ b/src/utils/storage/remove_objects.rs @@ -1,11 +1,11 @@ // use minio::s3::types::S3Api; -// /// 从 MinIO 中删除对象 +// /// 从对象存储中删除对象 // pub async fn remove_csgo_mvp_music_object( // bucket_name: &str, // object: &str, // ) -> Result<(), anyhow::Error> { -// let client = crate::minios::get_minio_client()?; +// let client = crate::minios::get_storage_client()?; // client.delete_object(bucket_name, object).send().await?; From 6083e1d39028e3c1f9025f6bd8daeacb26fb7a0a Mon Sep 17 00:00:00 2001 From: lius-new Date: Sat, 14 Mar 2026 15:08:10 +0800 Subject: [PATCH 26/60] chore: align server docker deployment --- Dockerfile | 6 +-- README.md | 72 ++++++++++++++++++++++++++++--- build_docker.py | 78 ++++++++++++++++------------------ configs/config.prod.toml | 4 +- docker-compose.yml | 91 +++++++++++----------------------------- 5 files changed, 134 insertions(+), 117 deletions(-) diff --git a/Dockerfile b/Dockerfile index efe7010..400f52f 100644 --- a/Dockerfile +++ b/Dockerfile @@ -1,5 +1,5 @@ -# 运行时镜像 -FROM debian:bookworm-slim +# 运行时镜像 +FROM debian:bullseye-slim # 临时禁用 APT 的 GPG 验证以解决密钥环问题 # 安装运行时依赖(这些包来自官方 Debian 仓库) @@ -8,7 +8,7 @@ RUN echo 'Acquire::AllowInsecureRepositories "true";' > /etc/apt/apt.conf.d/99al && apt-get update --allow-releaseinfo-change \ && apt-get install -y --no-install-recommends \ ca-certificates \ - libssl3 \ + libssl1.1 \ && rm -f /etc/apt/apt.conf.d/99allow-insecure \ && rm -rf /var/lib/apt/lists/* diff --git a/README.md b/README.md index bc20c26..21ac51a 100644 --- a/README.md +++ b/README.md @@ -6,11 +6,73 @@ Simprint 应用的后端服务系统。 Simprint Server 是 Simprint 桌面应用的后端服务,提供用户认证、数据管理、版本更新等核心功能支持。 -## 使用示例 - -### 启动服务 - -服务需要通过配置文件启动,配置文件路径通过命令行参数指定: +## 使用示例 + +### 通过 `build_docker.py` 构建 Docker 部署包 + +脚本位置: + +```bash +./build_docker.py +``` + +脚本会执行这些步骤: + +- 编译 `simprint-server` +- 编译 `console-gateway` +- 编译 `update-gateway` +- 将三个二进制文件复制到项目根目录 +- 默认生成一个 `tar.gz` Docker 部署包 + +在项目根目录执行: + +```bash +python3 build_docker.py +``` + +如果你本地使用 `uv`,也可以直接执行: + +```bash +uv run python build_docker.py +``` + +常用命令: + +```bash +# 默认 release 构建,并生成 tar.gz 部署包 +python3 build_docker.py +uv run python build_docker.py + +# 构建前清理旧二进制 +python3 build_docker.py --clean +uv run python build_docker.py --clean + +# 只编译并复制二进制,不生成压缩包 +python3 build_docker.py --no-package +uv run python build_docker.py --no-package + +# 生成 zip 格式部署包 +python3 build_docker.py --format zip +uv run python build_docker.py --format zip + +# 使用 dev 模式编译 +python3 build_docker.py --dev --no-package +uv run python build_docker.py --dev --no-package +``` + +构建完成后: + +- 二进制文件会出现在当前项目根目录: + - `./simprint-server` + - `./console-gateway` + - `./update-gateway` +- 如果未使用 `--no-package`,还会在项目根目录生成: + - `simprint-server-docker-*.tar.gz` + - 或 `simprint-server-docker-*.zip` + +### 启动服务 + +服务需要通过配置文件启动,配置文件路径通过命令行参数指定: ```bash ./simprint-server -f=configs/config.dev.toml diff --git a/build_docker.py b/build_docker.py index 4888cd0..42478f0 100644 --- a/build_docker.py +++ b/build_docker.py @@ -21,24 +21,22 @@ from pathlib import Path from datetime import datetime -# 需要编译的服务列表 -SERVICES = [ - "simprint-server", - "console-gateway", - "update-gateway", -] +# 需要编译的服务列表(本仓库仅 simprint-server) +SERVICES = [ + "simprint-server", +] # 项目根目录 PROJECT_ROOT = Path(__file__).parent.resolve() TARGET_DIR = PROJECT_ROOT / "target" RELEASE_DIR = TARGET_DIR / "release" -# 需要打包到压缩包的文件和目录 -PACKAGE_FILES = [ - "Dockerfile", - "docker-compose.yml", - ".dockerignore", - "configs", +# 需要打包到压缩包的文件和目录 +PACKAGE_FILES = [ + "Dockerfile", + "docker-compose.yml", + ".dockerignore", + "configs", "assets", ] @@ -229,47 +227,45 @@ def prepare_package_directory() -> Path: # 创建 README 文件 readme_content = f"""# Simprint Server Docker 部署包 -本压缩包包含运行 Simprint Server 容器所需的所有文件。 +本压缩包包含运行 Simprint Server(客户端网关)容器所需的所有文件。 ## 包含内容 -- 三个服务的二进制文件: {', '.join(SERVICES)} -- Dockerfile: Docker 镜像构建文件 -- docker-compose.yml: Docker Compose 配置文件 -- configs/: 配置文件目录 -- assets/: 资源文件目录 +- 二进制文件: {', '.join(SERVICES)} +- Dockerfile: Docker 镜像构建文件 +- docker-compose.yml: Docker Compose 配置文件 +- configs/: 配置文件目录 +- assets/: 资源文件目录 ## 使用方法 1. 解压压缩包到目标目录 -2. 确保配置文件中的数据库、Redis、MinIO 地址正确 +2. 确保配置文件中的数据库、Redis、对象存储地址正确 3. 构建 Docker 镜像: ```bash docker-compose build ``` -4. 启动所有服务: - ```bash - docker-compose up -d - ``` +4. 启动服务: + ```bash + docker-compose up -d + ``` 5. 查看服务状态: ```bash docker-compose ps ``` -6. 查看日志: - ```bash - docker-compose logs -f - ``` - -## 服务端口 - -- 客户端网关 (client-gateway): 40041 -- 更新网关 (update-gateway): 40042 -- 控制台网关 (console-gateway): 40043 +6. 查看日志: + ```bash + docker-compose logs -f simprint-server + ``` + +## 服务端口 + +- 客户端网关 (simprint-server): 40041 ## 注意事项 @@ -324,7 +320,7 @@ def main(): # 确定编译模式 release_mode = args.release and not args.dev - print_step("Simprint Server Docker 构建脚本") + print_step("Simprint Server Docker 构建脚本") print(f"项目目录: {PROJECT_ROOT}") print(f"编译模式: {'release' if release_mode else 'dev'}") @@ -344,7 +340,7 @@ def main(): build_success = False if not build_success: - print("\n✗ 部分服务编译失败,请检查错误信息") + print("\n✗ 服务编译失败,请检查错误信息") sys.exit(1) # 复制二进制文件 @@ -355,7 +351,7 @@ def main(): copy_success = False if not copy_success: - print("\n✗ 部分文件复制失败") + print("\n✗ 文件复制失败") sys.exit(1) # 验证文件 @@ -391,11 +387,11 @@ def main(): print(" 1. 将压缩包传输到目标服务器") print(" 2. 解压后运行: docker-compose up -d") else: - print_step("构建完成!") - print("\n现在可以使用以下命令构建 Docker 镜像:") - print(" docker-compose build") - print("\n或者启动所有服务:") - print(" docker-compose up -d") + print_step("构建完成!") + print("\n现在可以使用以下命令构建 Docker 镜像:") + print(" docker-compose build") + print("\n或者启动服务:") + print(" docker-compose up -d") if __name__ == "__main__": diff --git a/configs/config.prod.toml b/configs/config.prod.toml index fe2af0d..65cd4d3 100644 --- a/configs/config.prod.toml +++ b/configs/config.prod.toml @@ -19,7 +19,7 @@ referral_link_prefix = "https://www.simprint.cc/download" [database] -url = "postgres://lius:0gMAZGgriRK395Apzcb8@111.230.19.75:5433/simprintdb" +url = "postgres://lius:0gMAZGgriRK395Apzcb8@postgresql-pro:5432/simprintdb" max_connections = 25 min_connections = 5 max_lifetime = 3000 @@ -27,7 +27,7 @@ acquire_timeout = 30 idle_timeout = 600 [redis] -url = "redis://111.230.19.75:6380?protocol=resp3" +url = "redis://redis-server-pro:6379?protocol=resp3" [storage] endpoint = "https://503bc762ec98603e9ff53f374d78df22.r2.cloudflarestorage.com" diff --git a/docker-compose.yml b/docker-compose.yml index 7730f64..8715ddd 100644 --- a/docker-compose.yml +++ b/docker-compose.yml @@ -1,66 +1,25 @@ -version: '3.8' - -services: - # 客户端网关服务 - client-gateway: - build: - context: . - dockerfile: Dockerfile - args: - BINARY_NAME: simprint-server - container_name: simprint-client-gateway - command: ["-f=/app/configs/config.prod.toml"] - ports: - - "40041:40041" - volumes: - - ./configs:/app/configs:ro - - ./assets:/app/assets:ro - environment: - - RUST_LOG=info - restart: unless-stopped - networks: - - simprint-network - - # 控制台网关服务 - console-gateway: - build: - context: . - dockerfile: Dockerfile - args: - BINARY_NAME: console-gateway - container_name: simprint-console-gateway - command: ["-f=/app/configs/config.console-gateway.prod.toml"] - ports: - - "40043:40043" - volumes: - - ./configs:/app/configs:ro - - ./assets:/app/assets:ro - environment: - - RUST_LOG=info - restart: unless-stopped - networks: - - simprint-network - - # 更新网关服务 - update-gateway: - build: - context: . - dockerfile: Dockerfile - args: - BINARY_NAME: update-gateway - container_name: simprint-update-gateway - command: ["-f=/app/configs/config.update-gateway.prod.toml"] - ports: - - "40042:40042" - volumes: - - ./configs:/app/configs:ro - - ./assets:/app/assets:ro - environment: - - RUST_LOG=info - restart: unless-stopped - networks: - - simprint-network - -networks: - simprint-network: - driver: bridge +version: '3.8' + +services: + simprint-server: + build: + context: . + dockerfile: Dockerfile + args: + BINARY_NAME: simprint-server + container_name: simprint-client-gateway + command: ["-f=/app/configs/config.prod.toml"] + ports: + - "40041:40041" + volumes: + - ./configs:/app/configs:ro + - ./assets:/app/assets:ro + environment: + - RUST_LOG=info + restart: unless-stopped + networks: + - simprint-network + +networks: + simprint-network: + external: true From a942ba2d54714a4fe8bd4d226a67f6bf1acd59b0 Mon Sep 17 00:00:00 2001 From: lius-new Date: Sat, 14 Mar 2026 15:35:00 +0800 Subject: [PATCH 27/60] chore: update referral links to simprint.app --- configs/config.dev.toml | 2 +- configs/config.prod.toml | 2 +- configs/config.test.toml | 2 +- 3 files changed, 3 insertions(+), 3 deletions(-) diff --git a/configs/config.dev.toml b/configs/config.dev.toml index db29efc..423a2e8 100644 --- a/configs/config.dev.toml +++ b/configs/config.dev.toml @@ -15,7 +15,7 @@ route_whitelists = [ "GET+/api/v1/time/now", "POST+/api/v1/versions/check" ] -referral_link_prefix = "https://www.simprint.cc/download" +referral_link_prefix = "https://www.simprint.app/download" [database] diff --git a/configs/config.prod.toml b/configs/config.prod.toml index 65cd4d3..4ebb4cc 100644 --- a/configs/config.prod.toml +++ b/configs/config.prod.toml @@ -15,7 +15,7 @@ route_whitelists = [ "GET+/api/v1/time/now", "POST+/api/v1/versions/check" ] -referral_link_prefix = "https://www.simprint.cc/download" +referral_link_prefix = "https://www.simprint.app/download" [database] diff --git a/configs/config.test.toml b/configs/config.test.toml index 1457ab2..2dd368c 100644 --- a/configs/config.test.toml +++ b/configs/config.test.toml @@ -13,7 +13,7 @@ route_whitelists = [ "POST+/api/v2/users/refresh-credentials", "GET+/api/v2/secret/public/key" ] -referral_link_prefix = "https://www.simprint.cc/download" +referral_link_prefix = "https://www.simprint.app/download" [database] From 6b94ee40bc8eff2720467d0dd22ca91c264ef4fa Mon Sep 17 00:00:00 2001 From: lius-new Date: Sat, 14 Mar 2026 23:56:15 +0800 Subject: [PATCH 28/60] feat: tighten default quotas and version constraints --- configs/config.dev.toml | 4 ++-- configs/config.prod.toml | 4 ++-- configs/config.test.toml | 4 ++-- ...0001_alter_versions_require_publish_fields.sql | 15 +++++++++++++++ 4 files changed, 21 insertions(+), 6 deletions(-) create mode 100644 migrations/20260314000001_alter_versions_require_publish_fields.sql diff --git a/configs/config.dev.toml b/configs/config.dev.toml index 423a2e8..b695a8b 100644 --- a/configs/config.dev.toml +++ b/configs/config.dev.toml @@ -47,7 +47,7 @@ smtp_password = "NSgjTyY6uSQwPGwV" [workspace_quota] # 默认配额(新用户注册和手动创建工作空间都使用此配置) [workspace_quota.default] -max_environments = 8 -max_team_members = 1 +max_environments = 99999 +max_team_members = 99999 max_proxies = 99999 max_rpa_tasks = 99999 diff --git a/configs/config.prod.toml b/configs/config.prod.toml index 4ebb4cc..987e6b3 100644 --- a/configs/config.prod.toml +++ b/configs/config.prod.toml @@ -47,7 +47,7 @@ smtp_password = "NSgjTyY6uSQwPGwV" [workspace_quota] # 默认配额(新用户注册和手动创建工作空间都使用此配置) [workspace_quota.default] -max_environments = 8 -max_team_members = 1 +max_environments = 99999 +max_team_members = 99999 max_proxies = 99999 max_rpa_tasks = 99999 diff --git a/configs/config.test.toml b/configs/config.test.toml index 2dd368c..3774292 100644 --- a/configs/config.test.toml +++ b/configs/config.test.toml @@ -45,7 +45,7 @@ smtp_password = "NSgjTyY6uSQwPGwV" [workspace_quota] # 默认配额(新用户注册和手动创建工作空间都使用此配置) [workspace_quota.default] -max_environments = 8 -max_team_members = 1 +max_environments = 99999 +max_team_members = 99999 max_proxies = 99999 max_rpa_tasks = 99999 diff --git a/migrations/20260314000001_alter_versions_require_publish_fields.sql b/migrations/20260314000001_alter_versions_require_publish_fields.sql new file mode 100644 index 0000000..de062ec --- /dev/null +++ b/migrations/20260314000001_alter_versions_require_publish_fields.sql @@ -0,0 +1,15 @@ +-- Enforce required publish metadata for versions exposed to the updater. +-- Existing dirty rows are removed first so the NOT NULL constraints can be applied safely. + +DELETE FROM public.versions +WHERE platform IS NULL + OR url IS NULL + OR hash IS NULL + OR file_size IS NULL + OR pub_date IS NULL; + +ALTER TABLE public.versions +ALTER COLUMN platform SET NOT NULL, +ALTER COLUMN url SET NOT NULL, +ALTER COLUMN hash SET NOT NULL, +ALTER COLUMN file_size SET NOT NULL; \ No newline at end of file From 48b500961371a95956536a2f017a65bd277baff7 Mon Sep 17 00:00:00 2001 From: lius-new Date: Sun, 15 Mar 2026 01:03:51 +0800 Subject: [PATCH 29/60] fix: preserve version object paths --- src/models/versions.rs | 4 ++-- 1 file changed, 2 insertions(+), 2 deletions(-) diff --git a/src/models/versions.rs b/src/models/versions.rs index 0afd466..d10f378 100644 --- a/src/models/versions.rs +++ b/src/models/versions.rs @@ -385,7 +385,7 @@ pub async fn query_all_active_latest_versions( name: r.name.clone(), notes: r.notes, platform: r.platform, - url: r.name, + url: r.url, hash: r.hash, signature: r.signature, install_path: r.install_path, @@ -524,7 +524,7 @@ pub async fn query_browser_kernel_latest_versions( name: r.name.clone(), notes: r.notes, platform: r.platform, - url: r.name, + url: r.url, hash: r.hash, signature: r.signature, install_path: r.install_path, From a425f66709f6c4f2ba8c98f85423cc82bbf99ce8 Mon Sep 17 00:00:00 2001 From: lius-new Date: Mon, 16 Mar 2026 15:10:33 +0800 Subject: [PATCH 30/60] refactor(server): remove legacy api and admin auth --- src/dto/console_admin.rs | 41 --- src/dto/console_apikey.rs | 23 -- src/dto/mod.rs | 24 +- src/dto/system.rs | 30 +-- src/entitys/api_service.rs | 115 --------- src/entitys/console_admin.rs | 33 --- src/entitys/console_apikey.rs | 14 -- src/entitys/mod.rs | 6 - src/handlers.rs | 2 - src/handlers/api.rs | 79 ------ src/main.rs | 3 +- src/middlewares.rs | 32 ++- src/middlewares/admin_permission.rs | 73 ------ src/middlewares/dual_auth.rs | 141 ----------- src/models/admin.rs | 370 ---------------------------- src/models/api.rs | 201 --------------- src/models/apikey.rs | 179 -------------- src/models/mod.rs | 23 +- src/routes.rs | 1 - src/routes/api.rs | 27 -- src/services.rs | 1 - src/services/api.rs | 171 ------------- 22 files changed, 38 insertions(+), 1551 deletions(-) delete mode 100644 src/dto/console_admin.rs delete mode 100644 src/dto/console_apikey.rs delete mode 100644 src/entitys/api_service.rs delete mode 100644 src/entitys/console_admin.rs delete mode 100644 src/entitys/console_apikey.rs delete mode 100644 src/handlers/api.rs delete mode 100644 src/middlewares/admin_permission.rs delete mode 100644 src/middlewares/dual_auth.rs delete mode 100644 src/models/admin.rs delete mode 100644 src/models/api.rs delete mode 100644 src/models/apikey.rs delete mode 100644 src/routes/api.rs delete mode 100644 src/services/api.rs diff --git a/src/dto/console_admin.rs b/src/dto/console_admin.rs deleted file mode 100644 index 299c158..0000000 --- a/src/dto/console_admin.rs +++ /dev/null @@ -1,41 +0,0 @@ -//! Console Gateway 管理员和权限 DTO - -use chrono::{DateTime, Utc}; -use sqlx::FromRow; -use uuid::Uuid; - -/// 管理员 DTO -#[derive(Debug, Clone, FromRow)] -pub struct ConsoleAdminDto { - pub id: i32, - pub uuid: Uuid, - pub user_uuid: Uuid, - pub is_active: bool, - pub created_at: DateTime, - pub updated_at: DateTime, - pub deleted_at: Option>, -} - -/// 权限 DTO -#[derive(Debug, Clone, FromRow)] -pub struct ConsolePermissionDto { - pub id: i32, - pub uuid: Uuid, - pub route_path: String, - pub method: String, - pub description: Option, - pub created_at: DateTime, - pub updated_at: DateTime, - pub deleted_at: Option>, -} - -/// 管理员权限关联 DTO -#[derive(Debug, Clone, FromRow)] -pub struct ConsoleAdminPermissionDto { - pub id: i32, - pub uuid: Uuid, - pub admin_id: i32, - pub permission_id: i32, - pub granted_at: DateTime, - pub granted_by: Option, -} diff --git a/src/dto/console_apikey.rs b/src/dto/console_apikey.rs deleted file mode 100644 index 7522fb4..0000000 --- a/src/dto/console_apikey.rs +++ /dev/null @@ -1,23 +0,0 @@ -//! Console Gateway API 密钥 DTO - -use chrono::{DateTime, Utc}; -use sqlx::FromRow; -use uuid::Uuid; - -/// API 密钥 DTO -#[derive(Debug, Clone, FromRow)] -pub struct ConsoleApiKeyDto { - pub id: i32, - pub uuid: Uuid, - pub key_id: String, - pub key_secret: String, - pub name: String, - pub description: Option, - pub is_active: bool, - pub created_at: DateTime, - pub updated_at: DateTime, - pub expires_at: Option>, - pub last_used_at: Option>, - pub created_by: Option, - pub deleted_at: Option>, -} diff --git a/src/dto/mod.rs b/src/dto/mod.rs index 61cf0f9..3730b38 100644 --- a/src/dto/mod.rs +++ b/src/dto/mod.rs @@ -10,13 +10,11 @@ pub mod version_types; pub mod versions; // 新增模块 -pub mod accounts; -pub mod audit; -pub mod billing; -pub mod console_admin; -pub mod console_apikey; -pub mod environments; -pub mod extensions; +pub mod accounts; +pub mod audit; +pub mod billing; +pub mod environments; +pub mod extensions; pub mod group_member_permissions; pub mod messages; pub mod proxies; @@ -40,13 +38,11 @@ pub use version_types::*; pub use versions::*; // 新增导出 -pub use accounts::*; -pub use audit::*; -pub use billing::*; -pub use console_admin::*; -pub use console_apikey::*; -pub use environments::*; -pub use extensions::*; +pub use accounts::*; +pub use audit::*; +pub use billing::*; +pub use environments::*; +pub use extensions::*; pub use group_member_permissions::*; pub use messages::*; pub use proxies::*; diff --git a/src/dto/system.rs b/src/dto/system.rs index e92cb0e..7a8ea75 100644 --- a/src/dto/system.rs +++ b/src/dto/system.rs @@ -1,29 +1,7 @@ -use chrono::{DateTime, Utc}; -use serde::Serialize; -use sqlx::FromRow; -use uuid::Uuid; - -/// API 密钥 DTO -#[derive(Debug, Clone, FromRow, Serialize)] -pub struct ApiKeyDto { - pub id: i32, - pub uuid: Uuid, - pub user_uuid: Uuid, - pub name: String, - pub key_hash: String, - pub key_prefix: String, - pub permissions: serde_json::Value, - pub rate_limit: Option, - pub daily_limit: Option, - pub ip_whitelist: Option, - pub expires_at: Option>, - pub usage_count: Option, - pub daily_usage: Option, - pub last_used_at: Option>, - pub status: String, - pub created_at: DateTime, - pub updated_at: DateTime, -} +use chrono::{DateTime, Utc}; +use serde::Serialize; +use sqlx::FromRow; +use uuid::Uuid; /// 用户偏好设置 DTO #[derive(Debug, Clone, FromRow, Serialize)] diff --git a/src/entitys/api_service.rs b/src/entitys/api_service.rs deleted file mode 100644 index 73ae5a4..0000000 --- a/src/entitys/api_service.rs +++ /dev/null @@ -1,115 +0,0 @@ -use chrono::{DateTime, Utc}; -use serde::{Deserialize, Serialize}; -use uuid::Uuid; - -/// 创建 API 密钥请求 -#[derive(Debug, Clone, Deserialize, Serialize)] -pub struct CreateApiKeyRequest { - pub name: String, - pub permissions: Vec, - pub rate_limit: Option, - pub daily_limit: Option, - pub ip_whitelist: Option>, - pub expires_at: Option>, -} - -/// 重置 API 密钥请求 -#[derive(Debug, Clone, Deserialize, Serialize)] -pub struct ResetApiKeyRequest { - pub uuid: Uuid, -} - -/// 删除 API 密钥请求 -#[derive(Debug, Clone, Deserialize, Serialize)] -pub struct DeleteApiKeyRequest { - pub uuid: Uuid, -} - -/// API 使用统计请求 -#[derive(Debug, Clone, Deserialize, Serialize)] -pub struct ApiStatsRequest { - pub date_from: Option, - pub date_to: Option, -} - -use super::Pagination; - -/// 查询 API 密钥列表请求 -#[derive(Debug, Clone, Deserialize, Serialize)] -pub struct ListApiKeysRequest { - #[serde(flatten)] - pub pagination: Pagination, - pub status: Option, -} - -/// 更新 API 密钥请求 -#[derive(Debug, Clone, Deserialize, Serialize)] -pub struct UpdateApiKeyRequest { - pub uuid: Uuid, - pub name: Option, - pub permissions: Option>, - pub rate_limit: Option, - pub daily_limit: Option, - pub ip_whitelist: Option>, -} - -/// 撤销 API 密钥请求 -#[derive(Debug, Clone, Deserialize, Serialize)] -pub struct RevokeApiKeyRequest { - pub uuid: Uuid, -} - -// ========== 响应结构体 ========== - -/// API 密钥列表响应 -#[derive(Debug, Clone, Serialize)] -pub struct ApiKeysListResponse { - pub items: Vec, - pub total: i64, - pub page: i64, - pub page_size: i64, -} - -/// API 密钥项(不包含敏感信息) -#[derive(Debug, Clone, Serialize)] -pub struct ApiKeyItem { - pub uuid: Uuid, - pub name: String, - pub key_prefix: String, - pub permissions: serde_json::Value, - pub rate_limit: Option, - pub daily_limit: Option, - pub ip_whitelist: Option, - pub expires_at: Option>, - pub usage_count: Option, - pub last_used_at: Option>, - pub status: String, - pub created_at: DateTime, -} - -/// 创建 API 密钥响应 -#[derive(Debug, Clone, Serialize)] -pub struct CreateApiKeyResponse { - pub uuid: Uuid, - pub api_key: String, - pub key_prefix: String, -} - -impl From for ApiKeyItem { - fn from(dto: crate::dto::ApiKeyDto) -> Self { - Self { - uuid: dto.uuid, - name: dto.name, - key_prefix: dto.key_prefix, - permissions: dto.permissions, - rate_limit: dto.rate_limit, - daily_limit: dto.daily_limit, - ip_whitelist: dto.ip_whitelist, - expires_at: dto.expires_at, - usage_count: dto.usage_count, - last_used_at: dto.last_used_at, - status: dto.status, - created_at: dto.created_at, - } - } -} diff --git a/src/entitys/console_admin.rs b/src/entitys/console_admin.rs deleted file mode 100644 index 25a60e2..0000000 --- a/src/entitys/console_admin.rs +++ /dev/null @@ -1,33 +0,0 @@ -//! Console Gateway 管理员相关请求/响应实体 - -use serde::{Deserialize, Serialize}; -use uuid::Uuid; - -/// 创建管理员请求 -#[derive(Debug, Clone, Deserialize, Serialize)] -pub struct CreateConsoleAdminRequest { - pub user_uuid: Uuid, -} - -/// 更新管理员请求 -#[derive(Debug, Clone, Deserialize, Serialize, Default)] -pub struct UpdateConsoleAdminRequest { - #[serde(skip_serializing_if = "Option::is_none")] - pub is_active: Option, -} - -/// 创建权限请求 -#[derive(Debug, Clone, Deserialize, Serialize)] -pub struct CreateConsolePermissionRequest { - pub route_path: String, - pub method: String, - #[serde(skip_serializing_if = "Option::is_none")] - pub description: Option, -} - -/// 授予权限请求 -#[derive(Debug, Clone, Deserialize, Serialize)] -pub struct GrantConsolePermissionRequest { - pub admin_id: i32, - pub permission_id: i32, -} diff --git a/src/entitys/console_apikey.rs b/src/entitys/console_apikey.rs deleted file mode 100644 index 27159c8..0000000 --- a/src/entitys/console_apikey.rs +++ /dev/null @@ -1,14 +0,0 @@ -//! Console Gateway API 密钥相关请求/响应实体 - -use chrono::{DateTime, Utc}; -use serde::{Deserialize, Serialize}; - -/// 创建 API 密钥请求 -#[derive(Debug, Clone, Deserialize, Serialize)] -pub struct CreateConsoleApiKeyRequest { - pub name: String, - #[serde(skip_serializing_if = "Option::is_none")] - pub description: Option, - #[serde(skip_serializing_if = "Option::is_none")] - pub expires_at: Option>, -} diff --git a/src/entitys/mod.rs b/src/entitys/mod.rs index 45a6b4b..a9bb142 100644 --- a/src/entitys/mod.rs +++ b/src/entitys/mod.rs @@ -11,13 +11,10 @@ pub mod versions; // 新增模块 pub mod accounts; -pub mod api_service; pub mod audit; pub mod billing; pub mod browser_kernel; pub mod common; -pub mod console_admin; -pub mod console_apikey; pub mod environments; pub mod extensions; pub mod group_member_permissions; @@ -47,13 +44,10 @@ pub use versions::*; // 新增导出 pub use accounts::*; -pub use api_service::*; pub use audit::*; pub use billing::*; pub use browser_kernel::*; pub use common::*; -pub use console_admin::*; -pub use console_apikey::*; pub use environments::*; pub use extensions::*; pub use group_member_permissions::*; diff --git a/src/handlers.rs b/src/handlers.rs index f533e8e..7537c77 100644 --- a/src/handlers.rs +++ b/src/handlers.rs @@ -5,7 +5,6 @@ mod users; // 新增模块 pub mod accounts; -pub mod api; pub mod audit; pub mod billing; pub mod browser_kernel; @@ -30,7 +29,6 @@ pub use users::*; // 新增导出 pub use accounts::*; -pub use api::*; pub use audit::*; pub use billing::*; pub use browser_kernel::*; diff --git a/src/handlers/api.rs b/src/handlers/api.rs deleted file mode 100644 index 807a422..0000000 --- a/src/handlers/api.rs +++ /dev/null @@ -1,79 +0,0 @@ -use axum::extract::{Extension, State}; - -use crate::entitys::api_service::{ - ApiKeysListResponse, CreateApiKeyRequest, CreateApiKeyResponse, ListApiKeysRequest, - RevokeApiKeyRequest, UpdateApiKeyRequest, -}; -use crate::services; -use crate::state::RequestContext; -use crate::svc_ctx::SvcCtx; -use crate::utils::{Json, Response, Result}; - -/// 获取 API 密钥列表 -pub async fn get_api_keys_handler( - State(svc_ctx): State, - Extension(ctx): Extension, - Json(payload): Json, -) -> Result { - let (items, total) = - services::api::get_api_keys_service(&svc_ctx, ctx.user_uuid_unwrap(), &payload) - .await - .map_err(|e| Response::fail(Some(&e)))?; - - Ok(Response::success( - Some("获取成功"), - Some(ApiKeysListResponse { - items, - total, - page: payload.pagination.page, - page_size: payload.pagination.page_size, - }), - )) -} - -/// 创建 API 密钥 -pub async fn create_api_key_handler( - State(svc_ctx): State, - Extension(ctx): Extension, - Json(payload): Json, -) -> Result { - let (uuid, api_key, key_prefix) = - services::api::create_api_key_service(&svc_ctx, ctx.user_uuid_unwrap(), &payload) - .await - .map_err(|e| Response::fail(Some(&e)))?; - - Ok(Response::success( - Some("创建成功,请妥善保存密钥"), - Some(CreateApiKeyResponse { - uuid, - api_key, - key_prefix, - }), - )) -} - -/// 更新 API 密钥 -pub async fn update_api_key_handler( - State(svc_ctx): State, - Extension(ctx): Extension, - Json(payload): Json, -) -> Result<()> { - services::api::update_api_key_service(&svc_ctx, ctx.user_uuid_unwrap(), &payload) - .await - .map_err(|e| Response::fail(Some(&e)))?; - - Ok(Response::success(Some("更新成功"), None)) -} - -/// 撤销 API 密钥 -pub async fn revoke_api_key_handler( - State(svc_ctx): State, - Extension(ctx): Extension, - Json(payload): Json, -) -> Result<()> { - services::api::revoke_api_key_service(&svc_ctx, ctx.user_uuid_unwrap(), payload.uuid) - .await - .map_err(|e| Response::fail(Some(&e)))?; - - Ok(Response::success(Some("撤销成功"), None)) -} diff --git a/src/main.rs b/src/main.rs index 3309379..72f86a7 100644 --- a/src/main.rs +++ b/src/main.rs @@ -5,7 +5,7 @@ use simprint_server::{init_encrypt_secret, init_storage}; use simprint_server::{ middlewares, routes::{ - accounts, api, audit, billing, browser_kernel, environments, extensions, + accounts, audit, billing, browser_kernel, environments, extensions, group_permissions, messages, preferences, proxies, proxy_visibility, referral, rpa, secret, teams, templates, time, users, workspace_quotas, workspaces, @@ -85,7 +85,6 @@ fn register_all_routes(svc_ctx: &SvcCtx) -> Router { rpa::register_routes(&mut meta_route); referral::register_routes(&mut meta_route); extensions::register_routes(&mut meta_route); - api::register_routes(&mut meta_route); preferences::register_routes(&mut meta_route); messages::register_routes(&mut meta_route); diff --git a/src/middlewares.rs b/src/middlewares.rs index cbd500d..e970d2d 100644 --- a/src/middlewares.rs +++ b/src/middlewares.rs @@ -1,21 +1,17 @@ -mod admin_permission; -mod auth; -mod cors; -mod decrypt; -mod dual_auth; -mod encrypt; -mod logger; -mod machine_auth; -mod real_ip; - -pub use admin_permission::*; -pub use auth::*; -pub use cors::*; -pub use decrypt::*; -pub use dual_auth::*; -pub use encrypt::*; -pub use logger::*; -pub use machine_auth::*; +mod auth; +mod cors; +mod decrypt; +mod encrypt; +mod logger; +mod machine_auth; +mod real_ip; + +pub use auth::*; +pub use cors::*; +pub use decrypt::*; +pub use encrypt::*; +pub use logger::*; +pub use machine_auth::*; pub use real_ip::*; use axum::extract::{MatchedPath, Request}; diff --git a/src/middlewares/admin_permission.rs b/src/middlewares/admin_permission.rs deleted file mode 100644 index dc1a192..0000000 --- a/src/middlewares/admin_permission.rs +++ /dev/null @@ -1,73 +0,0 @@ -//! 管理员权限检查中间件 -//! -//! 对通过 JWT 认证的用户进行细粒度权限检查 -//! API Key 认证的请求直接通过(不进行权限检查) - -use axum::{ - extract::{Request, State}, - http::StatusCode, - middleware::Next, - response::Response, -}; - -use crate::{ - middlewares::get_request_path_and_method, models::admin, state::CurrentUser, svc_ctx::SvcCtx, -}; - -/// 管理员权限检查中间件 -/// -/// 认证逻辑: -/// 1. 检查是否为 OPTIONS 请求,直接放行 -/// 2. 检查是否有 CurrentUser 扩展(只有 JWT 认证才会有) -/// - 有:检查用户是否为管理员且拥有访问该路由的权限 -/// - 无:说明是 API Key 认证,直接放行 -/// -/// 权限检查: -/// - 通过 user_uuid 查询 console_admins 表确认是否为管理员 -/// - 通过 console_admin_permissions 和 console_permissions 表检查权限 -pub async fn admin_permission_check( - State(state): State, - req: Request, - next: Next, -) -> Result { - let (method, path) = get_request_path_and_method(&req); - - // OPTIONS 请求直接通过 - if method.eq("OPTIONS") { - return Ok(next.run(req).await); - } - - // 检查是否有当前用户信息(只有 JWT 认证的请求才会有) - if let Some(current_user) = req.extensions().get::() { - let user_uuid = current_user.user_uuid; - - // 检查用户是否为管理员以及是否有访问权限 - match admin::check_admin_permission(&state.db, user_uuid, path, method).await { - Ok(has_permission) => { - if !has_permission { - tracing::warn!( - "Admin permission denied for user {} on {} {}", - user_uuid, - method, - path - ); - return Err(StatusCode::FORBIDDEN); - } - } - Err(e) => { - tracing::error!("Failed to check admin permission: {}", e); - return Err(StatusCode::INTERNAL_SERVER_ERROR); - } - } - - tracing::debug!( - "Admin permission granted for user {} on {} {}", - user_uuid, - method, - path - ); - } - // 如果没有 CurrentUser 扩展,说明是 API Key 认证,直接通过 - - Ok(next.run(req).await) -} diff --git a/src/middlewares/dual_auth.rs b/src/middlewares/dual_auth.rs deleted file mode 100644 index 1223c3e..0000000 --- a/src/middlewares/dual_auth.rs +++ /dev/null @@ -1,141 +0,0 @@ -//! 双重认证中间件 -//! -//! 支持 API Key 认证和 JWT Token 认证 -//! - 优先检查 X-API-KEY header -//! - 如果没有 API Key,则使用 JWT 认证 - -use std::str::FromStr; - -use axum::{ - extract::{Request, State}, - http::{StatusCode, header}, - middleware::Next, - response::{IntoResponse, Response}, -}; -use tracing::warn; -use uuid::Uuid; - -use crate::{ - middlewares::get_request_path_and_method, - models::apikey, - services, - state::{CurrentIpAddr, CurrentUser}, - svc_ctx::SvcCtx, -}; - -/// 双重认证中间件:支持 JWT 认证和 API Key 认证 -/// -/// 认证流程: -/// 1. 检查是否为 OPTIONS 请求或白名单路由,直接放行 -/// 2. 检查 X-API-KEY header,验证 API Key -/// 3. 如果没有 API Key,则使用 JWT 认证 -/// -/// API Key 格式:`key_id:key_secret` -pub async fn dual_auth(State(state): State, mut req: Request, next: Next) -> Response { - let (resource_method, resource_path) = get_request_path_and_method(&req); - - // OPTIONS 请求直接通过 - if resource_method.eq("OPTIONS") { - return next.run(req).await; - } - - // 判断请求的是否在白名单,如果是白名单就直接允许访问 - { - let combine = format!("{}+{}", resource_method, resource_path); - let whitelists = &state.config.app.route_whitelists; - - if whitelists.contains(&combine) { - return next.run(req).await; - } - } - - // 首先检查是否有 API Key - if let Some(api_key_header) = req.headers().get("X-API-KEY") { - if let Ok(key_str) = api_key_header.to_str() { - let parts: Vec<&str> = key_str.split(':').collect(); - - if parts.len() == 2 { - let key_id = parts[0]; - let key_secret = parts[1]; - - match apikey::validate_api_key(&state.db, key_id, key_secret).await { - Ok(Some(api_key)) => { - tracing::info!( - "API Key authentication successful: {} ({})", - api_key.name, - key_id - ); - // API Key 验证成功,直接继续(不注入 CurrentUser) - return next.run(req).await; - } - Ok(None) => { - warn!("Invalid or expired API key: {}", key_id); - return (StatusCode::UNAUTHORIZED, "Invalid or expired API key") - .into_response(); - } - Err(e) => { - warn!("Database error during API key validation: {:?}", e); - return ( - StatusCode::INTERNAL_SERVER_ERROR, - "Authentication service error", - ) - .into_response(); - } - } - } else { - warn!("Invalid API key format - should be key_id:key_secret"); - return (StatusCode::UNAUTHORIZED, "Invalid API key format").into_response(); - } - } else { - warn!("Invalid API key header encoding"); - return (StatusCode::UNAUTHORIZED, "Invalid API key format").into_response(); - } - } - - // 如果没有 API Key,则使用 JWT 认证 - let auth_header = - match req.headers().get(header::AUTHORIZATION).and_then(|header| header.to_str().ok()) { - Some(header) => header, - None => { - return (StatusCode::UNAUTHORIZED, "Missing authentication").into_response(); - } - }; - - let token_str = if auth_header.starts_with("Bearer ") { - &auth_header[7..] - } else { - return ( - StatusCode::UNAUTHORIZED, - "Invalid authorization header format", - ) - .into_response(); - }; - - // 验证 JWT Token - let secret = state.config.app.secret.as_bytes(); - let verify_token_response = services::verify_token_service(token_str, secret); - - if let Err(_e) = verify_token_response { - return (StatusCode::UNAUTHORIZED, "Invalid or expired token").into_response(); - } - - let verify_token_response = verify_token_response.unwrap(); - - // 解析用户 UUID - let uuid = match Uuid::from_str(&verify_token_response) { - Ok(uuid) => uuid, - Err(_) => { - return (StatusCode::UNAUTHORIZED, "Invalid user identifier").into_response(); - } - }; - - // 记录认证信息 - if let Some(current_ip) = req.extensions().get::() { - let ip = current_ip.real_ip.as_str(); - tracing::info!("JWT auth user uuid: {}, ip: {}", uuid, ip); - } - - // 注入当前用户信息(只有 JWT 认证才会注入) - req.extensions_mut().insert(CurrentUser { user_uuid: uuid }); - next.run(req).await -} diff --git a/src/models/admin.rs b/src/models/admin.rs deleted file mode 100644 index 58fa547..0000000 --- a/src/models/admin.rs +++ /dev/null @@ -1,370 +0,0 @@ -//! Console Gateway 管理员和权限数据库操作 - -use sqlx::{Error, Pool, Postgres}; -use uuid::Uuid; - -use crate::dto::{ConsoleAdminDto, ConsoleAdminPermissionDto, ConsolePermissionDto}; -use crate::entitys::{ - CreateConsoleAdminRequest, CreateConsolePermissionRequest, GrantConsolePermissionRequest, - UpdateConsoleAdminRequest, -}; - -// ==================== 管理员操作 ==================== - -/// 创建管理员 -pub async fn create_admin( - pool: &Pool, - request: CreateConsoleAdminRequest, -) -> Result { - let admin = sqlx::query_as::<_, ConsoleAdminDto>( - r#" - INSERT INTO console_admins (user_uuid, is_active) - VALUES ($1, TRUE) - RETURNING id, uuid, user_uuid, is_active, created_at, updated_at, deleted_at - "#, - ) - .bind(request.user_uuid) - .fetch_one(pool) - .await?; - - Ok(admin) -} - -/// 根据用户 UUID 获取管理员 -pub async fn get_admin_by_user_uuid( - pool: &Pool, - user_uuid: Uuid, -) -> Result, Error> { - let admin = sqlx::query_as::<_, ConsoleAdminDto>( - r#" - SELECT id, uuid, user_uuid, is_active, created_at, updated_at, deleted_at - FROM console_admins - WHERE user_uuid = $1 AND is_active = TRUE AND deleted_at IS NULL - "#, - ) - .bind(user_uuid) - .fetch_optional(pool) - .await?; - - Ok(admin) -} - -/// 根据 ID 获取管理员 -pub async fn get_admin_by_id( - pool: &Pool, - admin_id: i32, -) -> Result, Error> { - let admin = sqlx::query_as::<_, ConsoleAdminDto>( - r#" - SELECT id, uuid, user_uuid, is_active, created_at, updated_at, deleted_at - FROM console_admins - WHERE id = $1 AND deleted_at IS NULL - "#, - ) - .bind(admin_id) - .fetch_optional(pool) - .await?; - - Ok(admin) -} - -/// 列出所有管理员 -pub async fn list_admins( - pool: &Pool, - limit: Option, - offset: Option, -) -> Result, Error> { - let limit = limit.unwrap_or(50); - let offset = offset.unwrap_or(0); - - let admins = sqlx::query_as::<_, ConsoleAdminDto>( - r#" - SELECT id, uuid, user_uuid, is_active, created_at, updated_at, deleted_at - FROM console_admins - WHERE deleted_at IS NULL - ORDER BY created_at DESC - LIMIT $1 OFFSET $2 - "#, - ) - .bind(limit) - .bind(offset) - .fetch_all(pool) - .await?; - - Ok(admins) -} - -/// 更新管理员 -pub async fn update_admin( - pool: &Pool, - admin_id: i32, - request: UpdateConsoleAdminRequest, -) -> Result { - if let Some(is_active) = request.is_active { - let result = sqlx::query( - r#" - UPDATE console_admins - SET is_active = $1, updated_at = CURRENT_TIMESTAMP - WHERE id = $2 AND deleted_at IS NULL - "#, - ) - .bind(is_active) - .bind(admin_id) - .execute(pool) - .await?; - - Ok(result.rows_affected() > 0) - } else { - Ok(false) - } -} - -/// 删除管理员(软删除) -pub async fn delete_admin(pool: &Pool, admin_id: i32) -> Result { - let result = sqlx::query( - r#" - UPDATE console_admins - SET deleted_at = CURRENT_TIMESTAMP, is_active = FALSE - WHERE id = $1 AND deleted_at IS NULL - "#, - ) - .bind(admin_id) - .execute(pool) - .await?; - - Ok(result.rows_affected() > 0) -} - -// ==================== 权限操作 ==================== - -/// 创建权限 -pub async fn create_permission( - pool: &Pool, - request: CreateConsolePermissionRequest, -) -> Result { - let permission = sqlx::query_as::<_, ConsolePermissionDto>( - r#" - INSERT INTO console_permissions (route_path, method, description) - VALUES ($1, $2, $3) - RETURNING id, uuid, route_path, method, description, created_at, updated_at, deleted_at - "#, - ) - .bind(request.route_path) - .bind(request.method) - .bind(request.description) - .fetch_one(pool) - .await?; - - Ok(permission) -} - -/// 根据路由和方法获取权限 -pub async fn get_permission_by_route( - pool: &Pool, - route_path: &str, - method: &str, -) -> Result, Error> { - let permission = sqlx::query_as::<_, ConsolePermissionDto>( - r#" - SELECT id, uuid, route_path, method, description, created_at, updated_at, deleted_at - FROM console_permissions - WHERE route_path = $1 AND method = $2 AND deleted_at IS NULL - "#, - ) - .bind(route_path) - .bind(method) - .fetch_optional(pool) - .await?; - - Ok(permission) -} - -/// 列出所有权限 -pub async fn list_permissions( - pool: &Pool, - limit: Option, - offset: Option, -) -> Result, Error> { - let limit = limit.unwrap_or(9999); - let offset = offset.unwrap_or(0); - - let permissions = sqlx::query_as::<_, ConsolePermissionDto>( - r#" - SELECT id, uuid, route_path, method, description, created_at, updated_at, deleted_at - FROM console_permissions - WHERE deleted_at IS NULL - ORDER BY route_path, method - LIMIT $1 OFFSET $2 - "#, - ) - .bind(limit) - .bind(offset) - .fetch_all(pool) - .await?; - - Ok(permissions) -} - -/// 授予权限给管理员 -pub async fn grant_permission( - pool: &Pool, - request: GrantConsolePermissionRequest, -) -> Result { - let admin_permission = sqlx::query_as::<_, ConsoleAdminPermissionDto>( - r#" - INSERT INTO console_admin_permissions (admin_id, permission_id) - VALUES ($1, $2) - RETURNING id, uuid, admin_id, permission_id, granted_at, granted_by - "#, - ) - .bind(request.admin_id) - .bind(request.permission_id) - .fetch_one(pool) - .await?; - - Ok(admin_permission) -} - -/// 撤销管理员的权限 -pub async fn revoke_permission( - pool: &Pool, - admin_id: i32, - permission_id: i32, -) -> Result { - let result = sqlx::query( - r#" - DELETE FROM console_admin_permissions - WHERE admin_id = $1 AND permission_id = $2 - "#, - ) - .bind(admin_id) - .bind(permission_id) - .execute(pool) - .await?; - - Ok(result.rows_affected() > 0) -} - -/// 检查管理员是否有某个权限 -pub async fn check_admin_permission( - pool: &Pool, - user_uuid: Uuid, - route_path: &str, - method: &str, -) -> Result { - let result: Option = sqlx::query_scalar( - r#" - SELECT COUNT(*) - FROM console_admins a - JOIN console_admin_permissions ap ON a.id = ap.admin_id - JOIN console_permissions p ON ap.permission_id = p.id - WHERE a.user_uuid = $1 - AND a.is_active = TRUE - AND a.deleted_at IS NULL - AND p.route_path = $2 - AND p.method = $3 - AND p.deleted_at IS NULL - "#, - ) - .bind(user_uuid) - .bind(route_path) - .bind(method) - .fetch_optional(pool) - .await?; - - Ok(result.unwrap_or(0) > 0) -} - -/// 获取管理员的所有权限 -pub async fn get_admin_permissions( - pool: &Pool, - admin_id: i32, -) -> Result, Error> { - let permissions = sqlx::query_as::<_, ConsolePermissionDto>( - r#" - SELECT p.id, p.uuid, p.route_path, p.method, p.description, p.created_at, p.updated_at, p.deleted_at - FROM console_permissions p - JOIN console_admin_permissions ap ON p.id = ap.permission_id - WHERE ap.admin_id = $1 AND p.deleted_at IS NULL - ORDER BY p.route_path, p.method - "#, - ) - .bind(admin_id) - .fetch_all(pool) - .await?; - - Ok(permissions) -} - -/// 从路由同步权限到数据库 -pub async fn sync_permissions_from_routes( - pool: &Pool, - routes: Vec<(String, String, Option)>, // (route_path, method, description) -) -> Result<(), Error> { - let mut tx = pool.begin().await?; - - for (route_path, method, description) in routes { - let existing: Option = sqlx::query_scalar( - r#" - SELECT COUNT(*) FROM console_permissions - WHERE route_path = $1 AND method = $2 AND deleted_at IS NULL - "#, - ) - .bind(&route_path) - .bind(&method) - .fetch_optional(&mut *tx) - .await?; - - if existing.unwrap_or(0) == 0 { - sqlx::query( - r#" - INSERT INTO console_permissions (route_path, method, description) - VALUES ($1, $2, $3) - "#, - ) - .bind(route_path) - .bind(method) - .bind(description) - .execute(&mut *tx) - .await?; - } - } - - tx.commit().await?; - Ok(()) -} - -/// 从 MetaRoute 同步权限到数据库 -pub async fn sync_route_permissions( - pool: &Pool, - meta_route: &crate::routes::route::MetaRoute, -) -> Result<(), Error> { - use crate::routes::route::{RequestMethod, RouteGroup}; - - let mut routes = Vec::new(); - - for route_group in &meta_route.routes { - let RouteGroup { - prefix, - routes: route_items, - } = route_group; - - for route_item in route_items { - let full_path = format!("{}{}{}", meta_route.prefix, prefix, route_item.path); - let method = match route_item.method { - RequestMethod::GET => "GET", - RequestMethod::POST => "POST", - RequestMethod::PUT => "PUT", - RequestMethod::DELETE => "DELETE", - RequestMethod::PATCH => "PATCH", - } - .to_string(); - - let description = Some(format!("{} {}", method, full_path)); - routes.push((full_path, method, description)); - } - } - - sync_permissions_from_routes(pool, routes).await?; - Ok(()) -} diff --git a/src/models/api.rs b/src/models/api.rs deleted file mode 100644 index fa6355d..0000000 --- a/src/models/api.rs +++ /dev/null @@ -1,201 +0,0 @@ -use chrono::{DateTime, Utc}; -use sqlx::{Error, Pool, Postgres}; -use uuid::Uuid; - -use crate::dto::ApiKeyDto; - -/// 创建 API 密钥 -pub async fn insert_api_key( - pool: &Pool, - user_uuid: Uuid, - name: &str, - key_hash: &str, - key_prefix: &str, - permissions: &serde_json::Value, - rate_limit: Option, - daily_limit: Option, - ip_whitelist: Option<&serde_json::Value>, - expires_at: Option>, -) -> Result { - let uuid: Uuid = sqlx::query_scalar( - r#" - INSERT INTO api_keys (user_uuid, name, key_hash, key_prefix, permissions, - rate_limit, daily_limit, ip_whitelist, expires_at) - VALUES ($1, $2, $3, $4, $5, $6, $7, $8, $9) - RETURNING uuid; - "#, - ) - .bind(user_uuid) - .bind(name) - .bind(key_hash) - .bind(key_prefix) - .bind(permissions) - .bind(rate_limit) - .bind(daily_limit) - .bind(ip_whitelist) - .bind(expires_at) - .fetch_one(pool) - .await?; - - Ok(uuid) -} - -/// 查询用户的 API 密钥列表 -pub async fn fetch_api_keys( - pool: &Pool, - user_uuid: Uuid, - status: Option<&str>, - offset: i64, - limit: i64, -) -> Result, Error> { - let recs = sqlx::query_as::<_, ApiKeyDto>( - r#" - SELECT id, uuid, user_uuid, name, key_hash, key_prefix, permissions, - rate_limit, daily_limit, ip_whitelist, expires_at, usage_count, - daily_usage, last_used_at, status, created_at, updated_at - FROM api_keys - WHERE user_uuid = $1 - AND ($2::varchar IS NULL OR status = $2) - ORDER BY created_at DESC - LIMIT $3 OFFSET $4 - "#, - ) - .bind(user_uuid) - .bind(status) - .bind(limit) - .bind(offset) - .fetch_all(pool) - .await?; - - Ok(recs) -} - -/// 查询用户的 API 密钥总数 -pub async fn fetch_api_keys_count( - pool: &Pool, - user_uuid: Uuid, - status: Option<&str>, -) -> Result { - let count: i64 = sqlx::query_scalar( - r#" - SELECT COUNT(*) FROM api_keys - WHERE user_uuid = $1 - AND ($2::varchar IS NULL OR status = $2) - "#, - ) - .bind(user_uuid) - .bind(status) - .fetch_one(pool) - .await?; - - Ok(count) -} - -/// 根据 UUID 查询 API 密钥 -pub async fn fetch_api_key_by_uuid( - pool: &Pool, - key_uuid: Uuid, -) -> Result, Error> { - let rec = sqlx::query_as::<_, ApiKeyDto>( - r#" - SELECT id, uuid, user_uuid, name, key_hash, key_prefix, permissions, - rate_limit, daily_limit, ip_whitelist, expires_at, usage_count, - daily_usage, last_used_at, status, created_at, updated_at - FROM api_keys - WHERE uuid = $1 - "#, - ) - .bind(key_uuid) - .fetch_optional(pool) - .await?; - - Ok(rec) -} - -/// 更新 API 密钥 -pub async fn update_api_key( - pool: &Pool, - key_uuid: Uuid, - name: Option<&str>, - permissions: Option<&serde_json::Value>, - rate_limit: Option, - daily_limit: Option, - ip_whitelist: Option<&serde_json::Value>, -) -> Result<(), Error> { - sqlx::query( - r#" - UPDATE api_keys - SET name = COALESCE($1, name), - permissions = COALESCE($2, permissions), - rate_limit = COALESCE($3, rate_limit), - daily_limit = COALESCE($4, daily_limit), - ip_whitelist = COALESCE($5, ip_whitelist), - updated_at = CURRENT_TIMESTAMP - WHERE uuid = $6 - "#, - ) - .bind(name) - .bind(permissions) - .bind(rate_limit) - .bind(daily_limit) - .bind(ip_whitelist) - .bind(key_uuid) - .execute(pool) - .await?; - - Ok(()) -} - -/// 撤销 API 密钥 -pub async fn revoke_api_key(pool: &Pool, key_uuid: Uuid) -> Result<(), Error> { - sqlx::query( - r#" - UPDATE api_keys SET status = 'revoked', updated_at = CURRENT_TIMESTAMP - WHERE uuid = $1 - "#, - ) - .bind(key_uuid) - .execute(pool) - .await?; - - Ok(()) -} - -/// 验证 API 密钥(根据 key_hash 查询) -pub async fn fetch_api_key_by_hash( - pool: &Pool, - key_hash: &str, -) -> Result, Error> { - let rec = sqlx::query_as::<_, ApiKeyDto>( - r#" - SELECT id, uuid, user_uuid, name, key_hash, key_prefix, permissions, - rate_limit, daily_limit, ip_whitelist, expires_at, usage_count, - daily_usage, last_used_at, status, created_at, updated_at - FROM api_keys - WHERE key_hash = $1 AND status = 'active' - "#, - ) - .bind(key_hash) - .fetch_optional(pool) - .await?; - - Ok(rec) -} - -/// 更新 API 密钥使用统计 -pub async fn update_api_key_usage(pool: &Pool, key_uuid: Uuid) -> Result<(), Error> { - sqlx::query( - r#" - UPDATE api_keys - SET usage_count = COALESCE(usage_count, 0) + 1, - daily_usage = COALESCE(daily_usage, 0) + 1, - last_used_at = CURRENT_TIMESTAMP - WHERE uuid = $1 - "#, - ) - .bind(key_uuid) - .execute(pool) - .await?; - - Ok(()) -} diff --git a/src/models/apikey.rs b/src/models/apikey.rs deleted file mode 100644 index 51bbd32..0000000 --- a/src/models/apikey.rs +++ /dev/null @@ -1,179 +0,0 @@ -//! Console Gateway API 密钥数据库操作 - -use rand::Rng; -use sqlx::{Error, Pool, Postgres}; -use uuid::Uuid; - -use crate::dto::ConsoleApiKeyDto; -use crate::entitys::CreateConsoleApiKeyRequest; - -/// 创建 API 密钥 -pub async fn create_api_key( - pool: &Pool, - request: CreateConsoleApiKeyRequest, -) -> Result { - let key_id = Uuid::new_v4().to_string(); - let key_secret = generate_api_secret(); - - let api_key = sqlx::query_as::<_, ConsoleApiKeyDto>( - r#" - INSERT INTO console_api_keys (key_id, key_secret, name, description, is_active, expires_at) - VALUES ($1, $2, $3, $4, TRUE, $5) - RETURNING id, uuid, key_id, key_secret, name, description, is_active, - created_at, updated_at, expires_at, last_used_at, created_by, deleted_at - "#, - ) - .bind(&key_id) - .bind(&key_secret) - .bind(&request.name) - .bind(&request.description) - .bind(request.expires_at) - .fetch_one(pool) - .await?; - - Ok(api_key) -} - -/// 列出 API 密钥 -pub async fn list_api_keys( - pool: &Pool, - limit: Option, - offset: Option, -) -> Result, Error> { - let limit = limit.unwrap_or(50); - let offset = offset.unwrap_or(0); - - let api_keys = sqlx::query_as::<_, ConsoleApiKeyDto>( - r#" - SELECT id, uuid, key_id, key_secret, name, description, is_active, - created_at, updated_at, expires_at, last_used_at, created_by, deleted_at - FROM console_api_keys - WHERE deleted_at IS NULL - ORDER BY created_at DESC - LIMIT $1 OFFSET $2 - "#, - ) - .bind(limit) - .bind(offset) - .fetch_all(pool) - .await?; - - Ok(api_keys) -} - -/// 停用 API 密钥 -pub async fn deactivate_api_key(pool: &Pool, key_id: &str) -> Result { - let result = sqlx::query( - r#" - UPDATE console_api_keys - SET is_active = FALSE, updated_at = CURRENT_TIMESTAMP - WHERE key_id = $1 AND deleted_at IS NULL - "#, - ) - .bind(key_id) - .execute(pool) - .await?; - - Ok(result.rows_affected() > 0) -} - -/// 验证 API 密钥 -pub async fn validate_api_key( - pool: &Pool, - key_id: &str, - key_secret: &str, -) -> Result, Error> { - let api_key = sqlx::query_as::<_, ConsoleApiKeyDto>( - r#" - SELECT id, uuid, key_id, key_secret, name, description, is_active, - created_at, updated_at, expires_at, last_used_at, created_by, deleted_at - FROM console_api_keys - WHERE key_id = $1 AND key_secret = $2 AND is_active = TRUE AND deleted_at IS NULL - "#, - ) - .bind(key_id) - .bind(key_secret) - .fetch_optional(pool) - .await?; - - if let Some(ref api_key) = api_key { - // 检查是否过期 - if let Some(expires_at) = api_key.expires_at { - if chrono::Utc::now() > expires_at { - return Ok(None); - } - } - - // 更新最后使用时间 - update_last_used(pool, api_key.id).await?; - } - - Ok(api_key) -} - -/// 更新最后使用时间 -async fn update_last_used(pool: &Pool, api_key_id: i32) -> Result<(), Error> { - sqlx::query( - r#" - UPDATE console_api_keys - SET last_used_at = CURRENT_TIMESTAMP - WHERE id = $1 - "#, - ) - .bind(api_key_id) - .execute(pool) - .await?; - - Ok(()) -} - -/// 根据 key_id 获取 API 密钥 -pub async fn get_api_key_by_key_id( - pool: &Pool, - key_id: &str, -) -> Result, Error> { - let api_key = sqlx::query_as::<_, ConsoleApiKeyDto>( - r#" - SELECT id, uuid, key_id, key_secret, name, description, is_active, - created_at, updated_at, expires_at, last_used_at, created_by, deleted_at - FROM console_api_keys - WHERE key_id = $1 AND deleted_at IS NULL - "#, - ) - .bind(key_id) - .fetch_optional(pool) - .await?; - - Ok(api_key) -} - -/// 删除 API 密钥(软删除) -pub async fn delete_api_key(pool: &Pool, key_id: &str) -> Result { - let result = sqlx::query( - r#" - UPDATE console_api_keys - SET deleted_at = CURRENT_TIMESTAMP, is_active = FALSE - WHERE key_id = $1 AND deleted_at IS NULL - "#, - ) - .bind(key_id) - .execute(pool) - .await?; - - Ok(result.rows_affected() > 0) -} - -/// 生成 API 密钥密文 -fn generate_api_secret() -> String { - const CHARSET: &[u8] = b"ABCDEFGHIJKLMNOPQRSTUVWXYZ\ - abcdefghijklmnopqrstuvwxyz\ - 0123456789"; - let mut rng = rand::rng(); - - (0..64) - .map(|_| { - let idx = rng.random_range(0..CHARSET.len()); - CHARSET[idx] as char - }) - .collect() -} diff --git a/src/models/mod.rs b/src/models/mod.rs index b0ba171..a6b6068 100644 --- a/src/models/mod.rs +++ b/src/models/mod.rs @@ -9,12 +9,11 @@ pub mod user; pub mod version_types; pub mod versions; -// 新增模块 -pub mod accounts; -pub mod api; -pub mod audit; -pub mod billing; -pub mod environments; +// 新增模块 +pub mod accounts; +pub mod audit; +pub mod billing; +pub mod environments; pub mod extensions; pub mod group_member_permissions; pub mod messages; @@ -24,14 +23,10 @@ pub mod proxy_visible_teams; pub mod referral; pub mod rpa; pub mod teams; -pub mod workspace_quotas; -pub mod workspaces; - -// Console Gateway 模块 -pub mod admin; -pub mod apikey; - -pub use gray_releases::*; +pub mod workspace_quotas; +pub mod workspaces; + +pub use gray_releases::*; pub use gray_resources::*; pub use machine_gray_allocations::*; pub use machine_tags::*; diff --git a/src/routes.rs b/src/routes.rs index 7e209bd..9afb748 100644 --- a/src/routes.rs +++ b/src/routes.rs @@ -5,7 +5,6 @@ pub mod users; // 新增模块 pub mod accounts; -pub mod api; pub mod audit; pub mod billing; pub mod browser_kernel; diff --git a/src/routes/api.rs b/src/routes/api.rs deleted file mode 100644 index 86a016b..0000000 --- a/src/routes/api.rs +++ /dev/null @@ -1,27 +0,0 @@ -use axum::routing::post; - -use crate::handlers::{ - create_api_key_handler, get_api_keys_handler, revoke_api_key_handler, update_api_key_handler, -}; -use crate::routes::route::{self, MetaRoute}; - -/// 注册 API 服务相关的路由 -pub fn register_routes(meta_route: &mut MetaRoute) { - let mut api_route = route::RouteGroup::new("/api-keys"); - - api_route.add_route_item(route::RouteItem::post("/list", post(get_api_keys_handler))); - api_route.add_route_item(route::RouteItem::post( - "/create", - post(create_api_key_handler), - )); - api_route.add_route_item(route::RouteItem::post( - "/update", - post(update_api_key_handler), - )); - api_route.add_route_item(route::RouteItem::post( - "/revoke", - post(revoke_api_key_handler), - )); - - meta_route.add_route_group(api_route); -} diff --git a/src/services.rs b/src/services.rs index cfaa0d4..0ced820 100644 --- a/src/services.rs +++ b/src/services.rs @@ -12,7 +12,6 @@ mod versions; // 新增模块 pub mod accounts; -pub mod api; pub mod audit; pub mod browser_kernel; pub mod billing; diff --git a/src/services/api.rs b/src/services/api.rs deleted file mode 100644 index fcbc59c..0000000 --- a/src/services/api.rs +++ /dev/null @@ -1,171 +0,0 @@ -use chrono::Utc; -use sha2::{Digest, Sha256}; -use uuid::Uuid; - -use crate::dto::ApiKeyDto; -use crate::entitys::api_service::{ - ApiKeyItem, CreateApiKeyRequest, ListApiKeysRequest, UpdateApiKeyRequest, -}; -use crate::models; -use crate::svc_ctx::SvcCtx; - -/// 生成随机 API 密钥 -fn generate_api_key() -> String { - use rand::Rng; - let mut rng = rand::rng(); - let bytes: Vec = (0..32).map(|_| rng.random()).collect(); - hex::encode(bytes) -} - -/// 计算密钥哈希 -fn hash_api_key(key: &str) -> String { - let mut hasher = Sha256::new(); - hasher.update(key.as_bytes()); - hex::encode(hasher.finalize()) -} - -/// 获取密钥前缀 -fn get_key_prefix(key: &str) -> String { - format!("sp_{}", &key[..8]) -} - -/// 获取 API 密钥列表 -pub async fn get_api_keys_service( - svc_ctx: &SvcCtx, - user_uuid: Uuid, - payload: &ListApiKeysRequest, -) -> Result<(Vec, i64), String> { - let offset = (payload.pagination.page - 1) * payload.pagination.page_size; - - let keys = models::api::fetch_api_keys( - &svc_ctx.db, - user_uuid, - payload.status.as_deref(), - offset, - payload.pagination.page_size, - ) - .await - .map_err(|e| e.to_string())?; - - let total = - models::api::fetch_api_keys_count(&svc_ctx.db, user_uuid, payload.status.as_deref()) - .await - .map_err(|e| e.to_string())?; - - let items = keys.into_iter().map(ApiKeyItem::from).collect(); - - Ok((items, total)) -} - -/// 创建 API 密钥 -pub async fn create_api_key_service( - svc_ctx: &SvcCtx, - user_uuid: Uuid, - payload: &CreateApiKeyRequest, -) -> Result<(Uuid, String, String), String> { - // 生成密钥 - let api_key = generate_api_key(); - let key_hash = hash_api_key(&api_key); - let key_prefix = get_key_prefix(&api_key); - - // 处理权限 - let permissions = serde_json::json!(payload.permissions); - - // 处理 IP 白名单 - let ip_whitelist = payload.ip_whitelist.as_ref().map(|ips| serde_json::json!(ips)); - - let uuid = models::api::insert_api_key( - &svc_ctx.db, - user_uuid, - &payload.name, - &key_hash, - &key_prefix, - &permissions, - payload.rate_limit, - payload.daily_limit, - ip_whitelist.as_ref(), - payload.expires_at, - ) - .await - .map_err(|e| e.to_string())?; - - Ok((uuid, api_key, key_prefix)) -} - -/// 更新 API 密钥 -pub async fn update_api_key_service( - svc_ctx: &SvcCtx, - user_uuid: Uuid, - payload: &UpdateApiKeyRequest, -) -> Result<(), String> { - // 验证密钥所有权 - let key = models::api::fetch_api_key_by_uuid(&svc_ctx.db, payload.uuid) - .await - .map_err(|e| e.to_string())? - .ok_or_else(|| "API 密钥不存在".to_string())?; - - if key.user_uuid != user_uuid { - return Err("无权操作此密钥".to_string()); - } - - let permissions = payload.permissions.as_ref().map(|p| serde_json::json!(p)); - let ip_whitelist = payload.ip_whitelist.as_ref().map(|ips| serde_json::json!(ips)); - - models::api::update_api_key( - &svc_ctx.db, - payload.uuid, - payload.name.as_deref(), - permissions.as_ref(), - payload.rate_limit, - payload.daily_limit, - ip_whitelist.as_ref(), - ) - .await - .map_err(|e| e.to_string()) -} - -/// 撤销 API 密钥 -pub async fn revoke_api_key_service( - svc_ctx: &SvcCtx, - user_uuid: Uuid, - key_uuid: Uuid, -) -> Result<(), String> { - // 验证密钥所有权 - let key = models::api::fetch_api_key_by_uuid(&svc_ctx.db, key_uuid) - .await - .map_err(|e| e.to_string())? - .ok_or_else(|| "API 密钥不存在".to_string())?; - - if key.user_uuid != user_uuid { - return Err("无权操作此密钥".to_string()); - } - - models::api::revoke_api_key(&svc_ctx.db, key_uuid) - .await - .map_err(|e| e.to_string()) -} - -/// 验证 API 密钥 -pub async fn validate_api_key_service( - svc_ctx: &SvcCtx, - api_key: &str, -) -> Result { - let key_hash = hash_api_key(api_key); - - let key = models::api::fetch_api_key_by_hash(&svc_ctx.db, &key_hash) - .await - .map_err(|e| e.to_string())? - .ok_or_else(|| "无效的 API 密钥".to_string())?; - - // 检查是否过期 - if let Some(expires_at) = key.expires_at { - if expires_at < Utc::now() { - return Err("API 密钥已过期".to_string()); - } - } - - // 更新使用统计 - let _ = models::api::update_api_key_usage(&svc_ctx.db, key.uuid).await; - - Ok(key) -} From 2885ece7699c4ba1f8648b565bb05f11b5b71117 Mon Sep 17 00:00:00 2001 From: lius-new Date: Mon, 16 Mar 2026 18:05:03 +0800 Subject: [PATCH 31/60] feat(server): add local api management --- ...000001_create_local_api_service_tables.sql | 109 ++++++ ...2_initialize_local_api_default_configs.sql | 13 + ...reate_local_api_permission_definitions.sql | 25 ++ ...alize_local_api_permission_definitions.sql | 55 +++ ...000005_replace_masked_key_with_api_key.sql | 9 + src/dto/local_api.rs | 97 +++++ src/dto/mod.rs | 2 + src/entitys/local_api.rs | 23 ++ src/entitys/mod.rs | 2 + src/handlers.rs | 2 + src/handlers/local_api.rs | 62 +++ src/main.rs | 3 +- src/models/local_api.rs | 341 +++++++++++++++++ src/models/mod.rs | 6 +- src/models/user.rs | 92 ++++- src/routes.rs | 1 + src/routes/local_api.rs | 27 ++ src/services.rs | 1 + src/services/local_api.rs | 352 ++++++++++++++++++ 19 files changed, 1209 insertions(+), 13 deletions(-) create mode 100644 migrations/20260316000001_create_local_api_service_tables.sql create mode 100644 migrations/20260316000002_initialize_local_api_default_configs.sql create mode 100644 migrations/20260316000003_create_local_api_permission_definitions.sql create mode 100644 migrations/20260316000004_initialize_local_api_permission_definitions.sql create mode 100644 migrations/20260316000005_replace_masked_key_with_api_key.sql create mode 100644 src/dto/local_api.rs create mode 100644 src/entitys/local_api.rs create mode 100644 src/handlers/local_api.rs create mode 100644 src/models/local_api.rs create mode 100644 src/routes/local_api.rs create mode 100644 src/services/local_api.rs diff --git a/migrations/20260316000001_create_local_api_service_tables.sql b/migrations/20260316000001_create_local_api_service_tables.sql new file mode 100644 index 0000000..71b4fd9 --- /dev/null +++ b/migrations/20260316000001_create_local_api_service_tables.sql @@ -0,0 +1,109 @@ +-- 创建本地 API 服务相关表 + +CREATE TABLE IF NOT EXISTS user_local_api_settings ( + id SERIAL PRIMARY KEY, + uuid UUID NOT NULL DEFAULT gen_random_uuid() UNIQUE, + user_uuid UUID NOT NULL UNIQUE, + enabled BOOLEAN NOT NULL DEFAULT FALSE, + port INTEGER NOT NULL DEFAULT 8080, + remote_access BOOLEAN NOT NULL DEFAULT FALSE, + cors_origins JSONB NOT NULL DEFAULT '[]'::jsonb, + created_at TIMESTAMP WITH TIME ZONE NOT NULL DEFAULT CURRENT_TIMESTAMP, + updated_at TIMESTAMP WITH TIME ZONE NOT NULL DEFAULT CURRENT_TIMESTAMP, + deleted_at TIMESTAMP WITH TIME ZONE, + CONSTRAINT fk_user_local_api_settings_user FOREIGN KEY (user_uuid) REFERENCES users(uuid) ON DELETE CASCADE, + CONSTRAINT chk_user_local_api_settings_port CHECK (port BETWEEN 1 AND 65535), + CONSTRAINT chk_user_local_api_settings_cors_origins CHECK (jsonb_typeof(cors_origins) = 'array') +); + +CREATE INDEX idx_user_local_api_settings_user_uuid + ON user_local_api_settings(user_uuid); + +CREATE TRIGGER update_user_local_api_settings_updated_at + BEFORE UPDATE ON user_local_api_settings + FOR EACH ROW EXECUTE FUNCTION update_updated_at_column(); + +CREATE TABLE IF NOT EXISTS user_local_api_keys ( + id SERIAL PRIMARY KEY, + uuid UUID NOT NULL DEFAULT gen_random_uuid() UNIQUE, + user_uuid UUID NOT NULL, + key_prefix VARCHAR(32) NOT NULL, + key_hash VARCHAR(128) NOT NULL, + masked_key VARCHAR(64) NOT NULL, + is_active BOOLEAN NOT NULL DEFAULT TRUE, + requests_today INTEGER NOT NULL DEFAULT 0, + daily_limit INTEGER NOT NULL DEFAULT 1000, + last_reset_date DATE NOT NULL DEFAULT CURRENT_DATE, + last_used_at TIMESTAMP WITH TIME ZONE, + expires_at TIMESTAMP WITH TIME ZONE, + created_at TIMESTAMP WITH TIME ZONE NOT NULL DEFAULT CURRENT_TIMESTAMP, + updated_at TIMESTAMP WITH TIME ZONE NOT NULL DEFAULT CURRENT_TIMESTAMP, + deleted_at TIMESTAMP WITH TIME ZONE, + CONSTRAINT fk_user_local_api_keys_user FOREIGN KEY (user_uuid) REFERENCES users(uuid) ON DELETE CASCADE, + CONSTRAINT chk_user_local_api_keys_requests_today CHECK (requests_today >= 0), + CONSTRAINT chk_user_local_api_keys_daily_limit CHECK (daily_limit >= 0) +); + +CREATE INDEX idx_user_local_api_keys_user_uuid + ON user_local_api_keys(user_uuid); + +CREATE INDEX idx_user_local_api_keys_key_prefix + ON user_local_api_keys(key_prefix); + +CREATE INDEX idx_user_local_api_keys_key_hash + ON user_local_api_keys(key_hash); + +CREATE UNIQUE INDEX idx_user_local_api_keys_active_user + ON user_local_api_keys(user_uuid) + WHERE is_active = TRUE AND deleted_at IS NULL; + +CREATE TRIGGER update_user_local_api_keys_updated_at + BEFORE UPDATE ON user_local_api_keys + FOR EACH ROW EXECUTE FUNCTION update_updated_at_column(); + +CREATE TABLE IF NOT EXISTS user_local_api_key_permissions ( + id SERIAL PRIMARY KEY, + uuid UUID NOT NULL DEFAULT gen_random_uuid() UNIQUE, + api_key_id INTEGER NOT NULL, + permission_code VARCHAR(128) NOT NULL, + is_enabled BOOLEAN NOT NULL DEFAULT TRUE, + rate_limit_per_minute INTEGER NOT NULL DEFAULT 60, + rate_limit_per_hour INTEGER NOT NULL DEFAULT 1000, + created_at TIMESTAMP WITH TIME ZONE NOT NULL DEFAULT CURRENT_TIMESTAMP, + updated_at TIMESTAMP WITH TIME ZONE NOT NULL DEFAULT CURRENT_TIMESTAMP, + deleted_at TIMESTAMP WITH TIME ZONE, + CONSTRAINT fk_user_local_api_key_permissions_key FOREIGN KEY (api_key_id) REFERENCES user_local_api_keys(id) ON DELETE CASCADE, + CONSTRAINT uq_user_local_api_key_permissions UNIQUE (api_key_id, permission_code), + CONSTRAINT chk_user_local_api_key_permissions_minute CHECK (rate_limit_per_minute >= 0), + CONSTRAINT chk_user_local_api_key_permissions_hour CHECK (rate_limit_per_hour >= 0) +); + +CREATE INDEX idx_user_local_api_key_permissions_key + ON user_local_api_key_permissions(api_key_id); + +CREATE INDEX idx_user_local_api_key_permissions_code + ON user_local_api_key_permissions(permission_code); + +CREATE TRIGGER update_user_local_api_key_permissions_updated_at + BEFORE UPDATE ON user_local_api_key_permissions + FOR EACH ROW EXECUTE FUNCTION update_updated_at_column(); + +CREATE TABLE IF NOT EXISTS user_local_api_request_counters ( + id SERIAL PRIMARY KEY, + api_key_id INTEGER NOT NULL, + permission_code VARCHAR(128) NOT NULL, + window_type VARCHAR(16) NOT NULL, + window_start TIMESTAMP WITH TIME ZONE NOT NULL, + request_count INTEGER NOT NULL DEFAULT 0, + updated_at TIMESTAMP WITH TIME ZONE NOT NULL DEFAULT CURRENT_TIMESTAMP, + CONSTRAINT fk_user_local_api_request_counters_key FOREIGN KEY (api_key_id) REFERENCES user_local_api_keys(id) ON DELETE CASCADE, + CONSTRAINT uq_user_local_api_request_counters UNIQUE (api_key_id, permission_code, window_type, window_start), + CONSTRAINT chk_user_local_api_request_counters_window_type CHECK (window_type IN ('minute', 'hour', 'day')), + CONSTRAINT chk_user_local_api_request_counters_request_count CHECK (request_count >= 0) +); + +CREATE INDEX idx_user_local_api_request_counters_key + ON user_local_api_request_counters(api_key_id); + +CREATE INDEX idx_user_local_api_request_counters_lookup + ON user_local_api_request_counters(api_key_id, permission_code, window_type, window_start); diff --git a/migrations/20260316000002_initialize_local_api_default_configs.sql b/migrations/20260316000002_initialize_local_api_default_configs.sql new file mode 100644 index 0000000..c97f8ee --- /dev/null +++ b/migrations/20260316000002_initialize_local_api_default_configs.sql @@ -0,0 +1,13 @@ +-- 初始化本地 API 功能默认配置 +-- 这些配置作为系统级默认值存在,具体用户数据仍在首次使用时按需创建。 + +INSERT INTO system_configs (config_key, config_value, description) +VALUES + ('local_api_default_enabled', 'false', '本地 API 默认是否启用'), + ('local_api_default_port', '8080', '本地 API 默认监听端口'), + ('local_api_default_remote_access', 'false', '本地 API 默认是否允许局域网访问'), + ('local_api_default_cors_origins', '[]', '本地 API 默认允许的 CORS 来源'), + ('local_api_default_daily_limit', '1000', '本地 API 默认每日总调用上限'), + ('local_api_default_rate_limit_per_minute', '60', '本地 API 默认每分钟限流'), + ('local_api_default_rate_limit_per_hour', '1000', '本地 API 默认每小时限流') +ON CONFLICT (config_key) DO NOTHING; diff --git a/migrations/20260316000003_create_local_api_permission_definitions.sql b/migrations/20260316000003_create_local_api_permission_definitions.sql new file mode 100644 index 0000000..2c9172a --- /dev/null +++ b/migrations/20260316000003_create_local_api_permission_definitions.sql @@ -0,0 +1,25 @@ +-- 创建本地 API 权限定义表 + +CREATE TABLE IF NOT EXISTS local_api_permission_definitions ( + id SERIAL PRIMARY KEY, + uuid UUID NOT NULL DEFAULT gen_random_uuid() UNIQUE, + permission_code VARCHAR(128) NOT NULL UNIQUE, + name VARCHAR(128) NOT NULL, + description TEXT, + default_enabled BOOLEAN NOT NULL DEFAULT TRUE, + default_rate_limit_per_minute INTEGER NOT NULL DEFAULT 60, + default_rate_limit_per_hour INTEGER NOT NULL DEFAULT 1000, + sort_order INTEGER NOT NULL DEFAULT 0, + created_at TIMESTAMP WITH TIME ZONE NOT NULL DEFAULT CURRENT_TIMESTAMP, + updated_at TIMESTAMP WITH TIME ZONE NOT NULL DEFAULT CURRENT_TIMESTAMP, + deleted_at TIMESTAMP WITH TIME ZONE, + CONSTRAINT chk_local_api_permission_definitions_minute CHECK (default_rate_limit_per_minute >= 0), + CONSTRAINT chk_local_api_permission_definitions_hour CHECK (default_rate_limit_per_hour >= 0) +); + +CREATE INDEX idx_local_api_permission_definitions_sort_order + ON local_api_permission_definitions(sort_order); + +CREATE TRIGGER update_local_api_permission_definitions_updated_at + BEFORE UPDATE ON local_api_permission_definitions + FOR EACH ROW EXECUTE FUNCTION update_updated_at_column(); diff --git a/migrations/20260316000004_initialize_local_api_permission_definitions.sql b/migrations/20260316000004_initialize_local_api_permission_definitions.sql new file mode 100644 index 0000000..0694f03 --- /dev/null +++ b/migrations/20260316000004_initialize_local_api_permission_definitions.sql @@ -0,0 +1,55 @@ +-- 初始化本地 API 权限定义 + +INSERT INTO local_api_permission_definitions ( + permission_code, name, description, default_enabled, default_rate_limit_per_minute, default_rate_limit_per_hour, sort_order +) +VALUES + ('workspaces.list', '工作空间列表', '读取工作空间列表', TRUE, 120, 3000, 10), + ('workspaces.get', '工作空间详情', '读取工作空间详情', TRUE, 120, 3000, 20), + ('workspaces.switch', '切换工作空间', '切换当前工作空间', TRUE, 60, 1000, 30), + ('browser-kernels.list', '浏览器内核列表', '读取浏览器内核列表', TRUE, 120, 3000, 40), + ('groups.list', '分组列表', '读取分组列表', TRUE, 120, 3000, 50), + ('groups.create', '创建分组', '创建新的分组', TRUE, 30, 500, 60), + ('groups.update', '更新分组', '更新分组信息', TRUE, 30, 500, 70), + ('groups.delete', '删除分组', '删除分组', TRUE, 20, 300, 80), + ('tags.list', '标签列表', '读取标签列表', TRUE, 120, 3000, 90), + ('tags.create', '创建标签', '创建新的标签', TRUE, 30, 500, 100), + ('tags.update', '更新标签', '更新标签信息', TRUE, 30, 500, 110), + ('tags.delete', '删除标签', '删除标签', TRUE, 20, 300, 120), + ('environments.list', '环境列表', '读取环境列表', TRUE, 120, 3000, 130), + ('environments.detail', '环境详情', '读取环境详情', TRUE, 120, 3000, 140), + ('environments.batch-detail', '批量环境详情', '批量读取环境详情', TRUE, 60, 1200, 150), + ('environments.create', '创建环境', '创建新的环境', TRUE, 20, 300, 160), + ('environments.batch-create', '批量创建环境', '批量创建环境', TRUE, 10, 120, 170), + ('environments.update', '更新环境', '更新环境配置', TRUE, 30, 500, 180), + ('environments.delete', '删除环境', '删除环境', TRUE, 20, 300, 190), + ('environments.batch-delete', '批量删除环境', '批量删除环境', TRUE, 10, 120, 200), + ('environments.set-proxy', '设置环境代理', '为环境设置代理', TRUE, 30, 500, 210), + ('environments.assign-tags', '分配标签', '为环境分配标签', TRUE, 30, 500, 220), + ('environments.remove-tag', '移除标签', '从环境移除标签', TRUE, 30, 500, 230), + ('environments.move-to-group', '移动到分组', '将环境移动到指定分组', TRUE, 30, 500, 240), + ('environments.batch-move-to-group', '批量移动到分组', '批量移动环境到指定分组', TRUE, 15, 180, 250), + ('environments.set-accounts', '设置环境账号', '为环境关联账号', TRUE, 20, 300, 260), + ('environments.batch-assign-tags', '批量分配标签', '批量为环境分配标签', TRUE, 15, 180, 270), + ('environments.batch-remove-tags', '批量移除标签', '批量从环境移除标签', TRUE, 15, 180, 280), + ('environments.urls.list', '环境 URL 列表', '读取环境 URL 列表', TRUE, 120, 3000, 290), + ('environments.urls.add', '添加环境 URL', '向环境添加 URL', TRUE, 30, 500, 300), + ('environments.urls.delete', '删除环境 URL', '删除环境 URL', TRUE, 30, 500, 310), + ('environments.urls.clear', '清空环境 URL', '清空环境 URL', TRUE, 20, 300, 320), + ('environments.cookies.list', '环境 Cookie 列表', '读取环境 Cookie 列表', TRUE, 120, 3000, 330), + ('environments.cookies.add', '添加环境 Cookie', '向环境添加 Cookie', TRUE, 30, 500, 340), + ('environments.cookies.delete', '删除环境 Cookie', '删除环境 Cookie', TRUE, 30, 500, 350), + ('environments.cookies.clear', '清空环境 Cookie', '清空环境 Cookie', TRUE, 20, 300, 360), + ('environments.recycle-bin.list', '回收站环境列表', '读取回收站中的环境', TRUE, 120, 3000, 370), + ('environments.recycle-bin.restore', '恢复环境', '从回收站恢复环境', TRUE, 20, 300, 380), + ('environments.recycle-bin.batch-restore', '批量恢复环境', '批量从回收站恢复环境', TRUE, 10, 120, 390), + ('environments.recycle-bin.permanent-delete', '永久删除环境', '永久删除环境', TRUE, 10, 120, 400), + ('environments.recycle-bin.batch-permanent-delete', '批量永久删除环境', '批量永久删除环境', TRUE, 5, 60, 410), + ('proxies.list', '代理列表', '读取代理列表', TRUE, 120, 3000, 420), + ('proxies.detail', '代理详情', '读取代理详情', TRUE, 120, 3000, 430), + ('proxies.create', '创建代理', '创建新的代理', TRUE, 30, 500, 440), + ('proxies.update', '更新代理', '更新代理信息', TRUE, 30, 500, 450), + ('proxies.delete', '删除代理', '删除代理', TRUE, 20, 300, 460), + ('proxies.batch-delete', '批量删除代理', '批量删除代理', TRUE, 10, 120, 470), + ('proxies.batch-import', '批量导入代理', '批量导入代理', TRUE, 10, 120, 480) +ON CONFLICT (permission_code) DO NOTHING; diff --git a/migrations/20260316000005_replace_masked_key_with_api_key.sql b/migrations/20260316000005_replace_masked_key_with_api_key.sql new file mode 100644 index 0000000..8bf5b65 --- /dev/null +++ b/migrations/20260316000005_replace_masked_key_with_api_key.sql @@ -0,0 +1,9 @@ +-- 使用完整 api_key 替代 masked_key +-- 旧数据无法从 masked_key 还原明文,因此 api_key 先允许为空; +-- 服务端在发现历史记录缺少 api_key 时,会自动轮换生成新 key。 + +ALTER TABLE user_local_api_keys + ADD COLUMN IF NOT EXISTS api_key TEXT; + +ALTER TABLE user_local_api_keys + DROP COLUMN IF EXISTS masked_key; diff --git a/src/dto/local_api.rs b/src/dto/local_api.rs new file mode 100644 index 0000000..47017a1 --- /dev/null +++ b/src/dto/local_api.rs @@ -0,0 +1,97 @@ +use chrono::{DateTime, NaiveDate, Utc}; +use serde::Serialize; +use serde_json::Value; +use sqlx::FromRow; +use uuid::Uuid; + +#[derive(Debug, Clone, FromRow)] +pub struct LocalApiSettingsDto { + pub id: i32, + pub uuid: Uuid, + pub user_uuid: Uuid, + pub enabled: bool, + pub port: i32, + pub remote_access: bool, + pub cors_origins: Value, + pub created_at: DateTime, + pub updated_at: DateTime, + pub deleted_at: Option>, +} + +#[derive(Debug, Clone, FromRow)] +pub struct LocalApiKeyDto { + pub id: i32, + pub uuid: Uuid, + pub user_uuid: Uuid, + pub key_prefix: String, + pub key_hash: String, + pub api_key: Option, + pub is_active: bool, + pub requests_today: i32, + pub daily_limit: i32, + pub last_reset_date: NaiveDate, + pub last_used_at: Option>, + pub expires_at: Option>, + pub created_at: DateTime, + pub updated_at: DateTime, + pub deleted_at: Option>, +} + +#[derive(Debug, Clone, FromRow)] +pub struct LocalApiKeyPermissionDto { + pub id: i32, + pub uuid: Uuid, + pub api_key_id: i32, + pub permission_code: String, + pub is_enabled: bool, + pub rate_limit_per_minute: i32, + pub rate_limit_per_hour: i32, + pub created_at: DateTime, + pub updated_at: DateTime, + pub deleted_at: Option>, +} + +#[derive(Debug, Clone, FromRow)] +pub struct LocalApiPermissionDefinitionDto { + pub id: i32, + pub uuid: Uuid, + pub permission_code: String, + pub name: String, + pub description: Option, + pub default_enabled: bool, + pub default_rate_limit_per_minute: i32, + pub default_rate_limit_per_hour: i32, + pub sort_order: i32, + pub created_at: DateTime, + pub updated_at: DateTime, + pub deleted_at: Option>, +} + +#[derive(Debug, Clone, Serialize)] +#[serde(rename_all = "camelCase")] +pub struct LocalApiConfigDto { + pub enabled: bool, + pub api_key: String, + pub port: i32, + pub remote_access: bool, + pub cors_origins: Vec, + pub requests_today: i32, + pub daily_limit: i32, +} + +#[derive(Debug, Clone, Serialize)] +#[serde(rename_all = "camelCase")] +pub struct ResetLocalApiKeyDto { + pub api_key: String, +} + +#[derive(Debug, Clone, Serialize)] +#[serde(rename_all = "camelCase")] +pub struct ValidateLocalApiKeyDto { + pub valid: bool, + pub permission_code: String, + pub requests_today: i32, + pub daily_limit: i32, + pub rate_limit_per_minute: i32, + pub rate_limit_per_hour: i32, +} diff --git a/src/dto/mod.rs b/src/dto/mod.rs index 3730b38..8d24463 100644 --- a/src/dto/mod.rs +++ b/src/dto/mod.rs @@ -11,6 +11,7 @@ pub mod versions; // 新增模块 pub mod accounts; +pub mod local_api; pub mod audit; pub mod billing; pub mod environments; @@ -39,6 +40,7 @@ pub use versions::*; // 新增导出 pub use accounts::*; +pub use local_api::*; pub use audit::*; pub use billing::*; pub use environments::*; diff --git a/src/entitys/local_api.rs b/src/entitys/local_api.rs new file mode 100644 index 0000000..a190a53 --- /dev/null +++ b/src/entitys/local_api.rs @@ -0,0 +1,23 @@ +use serde::{Deserialize, Serialize}; + +#[derive(Debug, Clone, Default, Deserialize, Serialize)] +pub struct GetLocalApiConfigRequest {} + +#[derive(Debug, Clone, Default, Deserialize, Serialize)] +pub struct ResetLocalApiKeyRequest {} + +#[derive(Debug, Clone, Deserialize, Serialize)] +#[serde(rename_all = "camelCase")] +pub struct UpdateLocalApiConfigRequest { + pub enabled: Option, + pub port: Option, + pub remote_access: Option, + pub cors_origins: Option>, +} + +#[derive(Debug, Clone, Deserialize, Serialize)] +#[serde(rename_all = "camelCase")] +pub struct ValidateLocalApiKeyRequest { + pub api_key: String, + pub permission_code: String, +} diff --git a/src/entitys/mod.rs b/src/entitys/mod.rs index a9bb142..bca1a73 100644 --- a/src/entitys/mod.rs +++ b/src/entitys/mod.rs @@ -11,6 +11,7 @@ pub mod versions; // 新增模块 pub mod accounts; +pub mod local_api; pub mod audit; pub mod billing; pub mod browser_kernel; @@ -44,6 +45,7 @@ pub use versions::*; // 新增导出 pub use accounts::*; +pub use local_api::*; pub use audit::*; pub use billing::*; pub use browser_kernel::*; diff --git a/src/handlers.rs b/src/handlers.rs index 7537c77..b341009 100644 --- a/src/handlers.rs +++ b/src/handlers.rs @@ -5,6 +5,7 @@ mod users; // 新增模块 pub mod accounts; +pub mod local_api; pub mod audit; pub mod billing; pub mod browser_kernel; @@ -29,6 +30,7 @@ pub use users::*; // 新增导出 pub use accounts::*; +pub use local_api::*; pub use audit::*; pub use billing::*; pub use browser_kernel::*; diff --git a/src/handlers/local_api.rs b/src/handlers/local_api.rs new file mode 100644 index 0000000..4578060 --- /dev/null +++ b/src/handlers/local_api.rs @@ -0,0 +1,62 @@ +use axum::extract::{Extension, State}; + +use crate::entitys::{ + GetLocalApiConfigRequest, ResetLocalApiKeyRequest, UpdateLocalApiConfigRequest, + ValidateLocalApiKeyRequest, +}; +use crate::services; +use crate::state::RequestContext; +use crate::svc_ctx::SvcCtx; +use crate::utils::{Json, Response, Result}; + +pub async fn get_local_api_config_handler( + State(svc_ctx): State, + Extension(ctx): Extension, + Json(_payload): Json, +) -> Result { + let config = + services::local_api::get_local_api_config_service(&svc_ctx, ctx.user_uuid_unwrap()) + .await + .map_err(|e| Response::fail(Some(&e)))?; + + Ok(Response::success(None, Some(config))) +} + +pub async fn update_local_api_config_handler( + State(svc_ctx): State, + Extension(ctx): Extension, + Json(payload): Json, +) -> Result { + let config = services::local_api::update_local_api_config_service( + &svc_ctx, + ctx.user_uuid_unwrap(), + &payload, + ) + .await + .map_err(|e| Response::fail(Some(&e)))?; + + Ok(Response::success(Some("更新成功"), Some(config))) +} + +pub async fn reset_local_api_key_handler( + State(svc_ctx): State, + Extension(ctx): Extension, + Json(_payload): Json, +) -> Result { + let result = services::local_api::reset_local_api_key_service(&svc_ctx, ctx.user_uuid_unwrap()) + .await + .map_err(|e| Response::fail(Some(&e)))?; + + Ok(Response::success(Some("重置成功"), Some(result))) +} + +pub async fn validate_local_api_key_handler( + State(svc_ctx): State, + Json(payload): Json, +) -> Result { + let result = services::local_api::validate_local_api_key_service(&svc_ctx, &payload) + .await + .map_err(|e| Response::fail(Some(&e)))?; + + Ok(Response::success(None, Some(result))) +} diff --git a/src/main.rs b/src/main.rs index 72f86a7..3aa7308 100644 --- a/src/main.rs +++ b/src/main.rs @@ -5,7 +5,7 @@ use simprint_server::{init_encrypt_secret, init_storage}; use simprint_server::{ middlewares, routes::{ - accounts, audit, billing, browser_kernel, environments, extensions, + accounts, audit, billing, browser_kernel, environments, extensions, local_api, group_permissions, messages, preferences, proxies, proxy_visibility, referral, rpa, secret, teams, templates, time, users, workspace_quotas, workspaces, @@ -68,6 +68,7 @@ fn register_all_routes(svc_ctx: &SvcCtx) -> Router { secret::register_routes(&mut meta_route); time::register_routes(&mut meta_route); users::register_routes(&mut meta_route); + local_api::register_routes(&mut meta_route); // 新增路由 workspaces::register_routes(&mut meta_route); diff --git a/src/models/local_api.rs b/src/models/local_api.rs new file mode 100644 index 0000000..8914289 --- /dev/null +++ b/src/models/local_api.rs @@ -0,0 +1,341 @@ +use chrono::{DateTime, Utc}; +use serde_json::{Value, json}; +use sha2::{Digest, Sha256}; +use sqlx::{Error, Pool, Postgres}; +use uuid::Uuid; + +use crate::dto::{ + LocalApiKeyDto, LocalApiKeyPermissionDto, LocalApiPermissionDefinitionDto, LocalApiSettingsDto, +}; + +pub async fn fetch_local_api_settings( + pool: &Pool, + user_uuid: Uuid, +) -> Result, Error> { + sqlx::query_as::<_, LocalApiSettingsDto>( + r#" + SELECT id, uuid, user_uuid, enabled, port, remote_access, cors_origins, created_at, updated_at, deleted_at + FROM user_local_api_settings + WHERE user_uuid = $1 AND deleted_at IS NULL + "#, + ) + .bind(user_uuid) + .fetch_optional(pool) + .await +} + +pub async fn upsert_local_api_settings( + pool: &Pool, + user_uuid: Uuid, + enabled: Option, + port: Option, + remote_access: Option, + cors_origins: Option<&Value>, +) -> Result<(), Error> { + sqlx::query( + r#" + INSERT INTO user_local_api_settings (user_uuid, enabled, port, remote_access, cors_origins) + VALUES ($1, COALESCE($2, FALSE), COALESCE($3, 8080), COALESCE($4, FALSE), COALESCE($5, '[]'::jsonb)) + ON CONFLICT (user_uuid) DO UPDATE SET + enabled = COALESCE($2, user_local_api_settings.enabled), + port = COALESCE($3, user_local_api_settings.port), + remote_access = COALESCE($4, user_local_api_settings.remote_access), + cors_origins = COALESCE($5, user_local_api_settings.cors_origins), + updated_at = CURRENT_TIMESTAMP + "#, + ) + .bind(user_uuid) + .bind(enabled) + .bind(port) + .bind(remote_access) + .bind(cors_origins) + .execute(pool) + .await?; + + Ok(()) +} + +pub async fn fetch_active_api_key( + pool: &Pool, + user_uuid: Uuid, +) -> Result, Error> { + sqlx::query_as::<_, LocalApiKeyDto>( + r#" + SELECT + id, uuid, user_uuid, key_prefix, key_hash, api_key, is_active, requests_today, + daily_limit, last_reset_date, last_used_at, expires_at, created_at, updated_at, deleted_at + FROM user_local_api_keys + WHERE user_uuid = $1 AND is_active = TRUE AND deleted_at IS NULL + ORDER BY id DESC + LIMIT 1 + "#, + ) + .bind(user_uuid) + .fetch_optional(pool) + .await +} + +pub async fn fetch_api_key_by_hash( + pool: &Pool, + key_hash: &str, +) -> Result, Error> { + sqlx::query_as::<_, LocalApiKeyDto>( + r#" + SELECT + id, uuid, user_uuid, key_prefix, key_hash, api_key, is_active, requests_today, + daily_limit, last_reset_date, last_used_at, expires_at, created_at, updated_at, deleted_at + FROM user_local_api_keys + WHERE key_hash = $1 AND is_active = TRUE AND deleted_at IS NULL + LIMIT 1 + "#, + ) + .bind(key_hash) + .fetch_optional(pool) + .await +} + +pub async fn deactivate_api_keys_for_user(pool: &Pool, user_uuid: Uuid) -> Result<(), Error> { + sqlx::query( + r#" + UPDATE user_local_api_keys + SET is_active = FALSE, updated_at = CURRENT_TIMESTAMP + WHERE user_uuid = $1 AND is_active = TRUE AND deleted_at IS NULL + "#, + ) + .bind(user_uuid) + .execute(pool) + .await?; + + Ok(()) +} + +pub async fn insert_api_key( + pool: &Pool, + user_uuid: Uuid, + key_prefix: &str, + key_hash: &str, + api_key: &str, + daily_limit: i32, +) -> Result { + sqlx::query_as::<_, LocalApiKeyDto>( + r#" + INSERT INTO user_local_api_keys (user_uuid, key_prefix, key_hash, api_key, daily_limit) + VALUES ($1, $2, $3, $4, $5) + RETURNING + id, uuid, user_uuid, key_prefix, key_hash, api_key, is_active, requests_today, + daily_limit, last_reset_date, last_used_at, expires_at, created_at, updated_at, deleted_at + "#, + ) + .bind(user_uuid) + .bind(key_prefix) + .bind(key_hash) + .bind(api_key) + .bind(daily_limit) + .fetch_one(pool) + .await +} + +pub async fn fetch_api_key_permission( + pool: &Pool, + api_key_id: i32, + permission_code: &str, +) -> Result, Error> { + sqlx::query_as::<_, LocalApiKeyPermissionDto>( + r#" + SELECT + id, uuid, api_key_id, permission_code, is_enabled, rate_limit_per_minute, + rate_limit_per_hour, created_at, updated_at, deleted_at + FROM user_local_api_key_permissions + WHERE api_key_id = $1 + AND permission_code = $2 + AND deleted_at IS NULL + LIMIT 1 + "#, + ) + .bind(api_key_id) + .bind(permission_code) + .fetch_optional(pool) + .await +} + +pub async fn insert_api_key_permission( + pool: &Pool, + api_key_id: i32, + permission_code: &str, + rate_limit_per_minute: i32, + rate_limit_per_hour: i32, +) -> Result<(), Error> { + sqlx::query( + r#" + INSERT INTO user_local_api_key_permissions ( + api_key_id, permission_code, is_enabled, rate_limit_per_minute, rate_limit_per_hour + ) + VALUES ($1, $2, TRUE, $3, $4) + ON CONFLICT (api_key_id, permission_code) DO NOTHING + "#, + ) + .bind(api_key_id) + .bind(permission_code) + .bind(rate_limit_per_minute) + .bind(rate_limit_per_hour) + .execute(pool) + .await?; + + Ok(()) +} + +pub async fn fetch_permission_definition( + pool: &Pool, + permission_code: &str, +) -> Result, Error> { + sqlx::query_as::<_, LocalApiPermissionDefinitionDto>( + r#" + SELECT + id, uuid, permission_code, name, description, default_enabled, + default_rate_limit_per_minute, default_rate_limit_per_hour, sort_order, + created_at, updated_at, deleted_at + FROM local_api_permission_definitions + WHERE permission_code = $1 AND deleted_at IS NULL + LIMIT 1 + "#, + ) + .bind(permission_code) + .fetch_optional(pool) + .await +} + +pub async fn fetch_permission_definitions( + pool: &Pool, +) -> Result, Error> { + sqlx::query_as::<_, LocalApiPermissionDefinitionDto>( + r#" + SELECT + id, uuid, permission_code, name, description, default_enabled, + default_rate_limit_per_minute, default_rate_limit_per_hour, sort_order, + created_at, updated_at, deleted_at + FROM local_api_permission_definitions + WHERE deleted_at IS NULL + ORDER BY sort_order ASC, id ASC + "#, + ) + .fetch_all(pool) + .await +} + +pub async fn reset_api_key_daily_usage( + pool: &Pool, + api_key_id: i32, + today: chrono::NaiveDate, +) -> Result<(), Error> { + sqlx::query( + r#" + UPDATE user_local_api_keys + SET requests_today = 0, last_reset_date = $2, updated_at = CURRENT_TIMESTAMP + WHERE id = $1 + "#, + ) + .bind(api_key_id) + .bind(today) + .execute(pool) + .await?; + + Ok(()) +} + +pub async fn increment_api_key_usage( + pool: &Pool, + api_key_id: i32, + used_at: DateTime, +) -> Result<(), Error> { + sqlx::query( + r#" + UPDATE user_local_api_keys + SET requests_today = requests_today + 1, last_used_at = $2, updated_at = CURRENT_TIMESTAMP + WHERE id = $1 + "#, + ) + .bind(api_key_id) + .bind(used_at) + .execute(pool) + .await?; + + Ok(()) +} + +pub async fn fetch_request_count( + pool: &Pool, + api_key_id: i32, + permission_code: &str, + window_type: &str, + window_start: DateTime, +) -> Result { + let count = sqlx::query_scalar::<_, i32>( + r#" + SELECT request_count + FROM user_local_api_request_counters + WHERE api_key_id = $1 + AND permission_code = $2 + AND window_type = $3 + AND window_start = $4 + LIMIT 1 + "#, + ) + .bind(api_key_id) + .bind(permission_code) + .bind(window_type) + .bind(window_start) + .fetch_optional(pool) + .await?; + + Ok(count.unwrap_or(0)) +} + +pub async fn increment_request_counter( + pool: &Pool, + api_key_id: i32, + permission_code: &str, + window_type: &str, + window_start: DateTime, +) -> Result<(), Error> { + sqlx::query( + r#" + INSERT INTO user_local_api_request_counters ( + api_key_id, permission_code, window_type, window_start, request_count, updated_at + ) + VALUES ($1, $2, $3, $4, 1, CURRENT_TIMESTAMP) + ON CONFLICT (api_key_id, permission_code, window_type, window_start) DO UPDATE SET + request_count = user_local_api_request_counters.request_count + 1, + updated_at = CURRENT_TIMESTAMP + "#, + ) + .bind(api_key_id) + .bind(permission_code) + .bind(window_type) + .bind(window_start) + .execute(pool) + .await?; + + Ok(()) +} + +pub fn build_cors_origins_value(origins: &[String]) -> Value { + json!(origins) +} + +pub fn parse_cors_origins(value: &Value) -> Vec { + value + .as_array() + .map(|items| { + items + .iter() + .filter_map(|item| item.as_str().map(ToOwned::to_owned)) + .collect() + }) + .unwrap_or_default() +} + +pub fn hash_api_key(api_key: &str) -> String { + let mut hasher = Sha256::new(); + hasher.update(api_key.as_bytes()); + hex::encode(hasher.finalize()) +} diff --git a/src/models/mod.rs b/src/models/mod.rs index a6b6068..88c114b 100644 --- a/src/models/mod.rs +++ b/src/models/mod.rs @@ -11,6 +11,7 @@ pub mod versions; // 新增模块 pub mod accounts; +pub mod local_api; pub mod audit; pub mod billing; pub mod environments; @@ -38,8 +39,9 @@ pub use version_types::*; pub use versions::*; // 新增导出 -pub use accounts::*; -pub use audit::*; +pub use accounts::*; +pub use local_api::*; +pub use audit::*; pub use billing::*; pub use environments::*; pub use group_member_permissions::*; diff --git a/src/models/user.rs b/src/models/user.rs index e60b65f..f7d75ae 100644 --- a/src/models/user.rs +++ b/src/models/user.rs @@ -2,7 +2,7 @@ use rust_decimal::Decimal; use sqlx::{Error, Pool, Postgres}; use uuid::Uuid; -use crate::dto::{UserDto, UserInfoDto}; +use crate::dto::{LocalApiPermissionDefinitionDto, UserDto, UserInfoDto}; use crate::entitys::{RegisterRequest, UpdateUserRequest}; /// 插入用户基础信息 @@ -58,6 +58,8 @@ pub async fn create_user_with_info( // 为用户初始化推广链接时,需要读取所有推广层级配置。 // 这里复用 referral 模块中已经定义好的 DTO / 查询函数,避免在 models 中新增本地 struct 类型。 let tiers = crate::models::referral::fetch_referral_tiers(pool).await?; + let local_api_permission_definitions = + crate::models::local_api::fetch_permission_definitions(pool).await?; let mut tx = pool.begin().await?; @@ -105,7 +107,44 @@ pub async fn create_user_with_info( .execute(&mut *tx) .await?; - // 6. 初始化推广积分 + // 6. 初始化本地 API 配置 + sqlx::query( + r#" + INSERT INTO user_local_api_settings (user_uuid, enabled, port, remote_access, cors_origins) + VALUES ($1, FALSE, 8080, FALSE, '[]'::jsonb); + "#, + ) + .bind(user_uuid) + .execute(&mut *tx) + .await?; + + // 7. 初始化本地 API 密钥 + let local_api_key = generate_local_api_key(); + let local_api_key_hash = crate::models::local_api::hash_api_key(&local_api_key); + let local_api_key_prefix = local_api_key.chars().take(16).collect::(); + let local_api_key_id: i32 = sqlx::query_scalar( + r#" + INSERT INTO user_local_api_keys (user_uuid, key_prefix, key_hash, api_key, daily_limit) + VALUES ($1, $2, $3, $4, 1000) + RETURNING id; + "#, + ) + .bind(user_uuid) + .bind(&local_api_key_prefix) + .bind(&local_api_key_hash) + .bind(&local_api_key) + .fetch_one(&mut *tx) + .await?; + + // 8. 初始化本地 API 权限记录 + insert_local_api_permissions( + &mut tx, + local_api_key_id, + &local_api_permission_definitions, + ) + .await?; + + // 9. 初始化推广积分 sqlx::query( r#" INSERT INTO user_referral_points (user_uuid, total_points, available_points, used_points, pending_points) @@ -116,7 +155,7 @@ pub async fn create_user_with_info( .execute(&mut *tx) .await?; - // 7. 创建推广链接(根据层级配置创建最多 4 个链接) + // 10. 创建推广链接(根据层级配置创建最多 4 个链接) // // 规则: // - 始终为用户预创建所有层级对应的推广链接(默认最多 4 个) @@ -169,7 +208,7 @@ pub async fn create_user_with_info( .await?; } - // 8. 处理推荐人关系(如果有邀请码) + // 11. 处理推荐人关系(如果有邀请码) if let Some(ref referrer_code) = payload.referral_code { // 查找推荐人 let referrer: Option<(Uuid, Uuid)> = sqlx::query_as( @@ -207,7 +246,7 @@ pub async fn create_user_with_info( } } - // 9. 创建个人团队(每个用户都应该有一个团队) + // 12. 创建个人团队(每个用户都应该有一个团队) // 团队名称使用用户昵称,如果没有昵称则使用邮箱前缀 let team_name = payload.nickname.as_ref().map(|n| format!("{} 的团队", n)).unwrap_or_else(|| { @@ -217,7 +256,7 @@ pub async fn create_user_with_info( ) }); - // 9. 创建个人工作空间 + // 13. 创建个人工作空间 let workspace_name = format!( "{} 的工作空间", payload.nickname.as_deref().unwrap_or("用户") @@ -234,7 +273,7 @@ pub async fn create_user_with_info( .fetch_one(&mut *tx) .await?; - // 10. 创建工作空间配额(使用传入的配额配置) + // 14. 创建工作空间配额(使用传入的配额配置) sqlx::query( r#" INSERT INTO workspace_quotas (workspace_uuid, max_environments, max_team_members, max_proxies, max_rpa_tasks) @@ -249,7 +288,7 @@ pub async fn create_user_with_info( .execute(&mut *tx) .await?; - // 11. 创建个人团队(每个用户自动创建一个团队) + // 15. 创建个人团队(每个用户自动创建一个团队) let team_uuid: Uuid = sqlx::query_scalar( r#" INSERT INTO teams (workspace_uuid, name, description, owner_uuid) @@ -264,7 +303,7 @@ pub async fn create_user_with_info( .fetch_one(&mut *tx) .await?; - // 12. 添加用户为团队成员(owner 角色) + // 16. 添加用户为团队成员(owner 角色) sqlx::query( r#" INSERT INTO team_members (team_uuid, workspace_uuid, user_uuid, role, status) @@ -277,7 +316,7 @@ pub async fn create_user_with_info( .execute(&mut *tx) .await?; - // 13. 设置为用户当前团队和工作空间 + // 17. 设置为用户当前团队和工作空间 sqlx::query( r#" UPDATE user_infos SET current_team_uuid = $1, current_workspace_uuid = $2 WHERE user_uuid = $3; @@ -294,6 +333,39 @@ pub async fn create_user_with_info( Ok(user_uuid) } +async fn insert_local_api_permissions( + tx: &mut sqlx::Transaction<'_, Postgres>, + api_key_id: i32, + definitions: &[LocalApiPermissionDefinitionDto], +) -> Result<(), Error> { + for definition in definitions { + sqlx::query( + r#" + INSERT INTO user_local_api_key_permissions ( + api_key_id, permission_code, is_enabled, rate_limit_per_minute, rate_limit_per_hour + ) + VALUES ($1, $2, $3, $4, $5) + ON CONFLICT (api_key_id, permission_code) DO NOTHING; + "#, + ) + .bind(api_key_id) + .bind(definition.permission_code.as_str()) + .bind(definition.default_enabled) + .bind(definition.default_rate_limit_per_minute) + .bind(definition.default_rate_limit_per_hour) + .execute(&mut **tx) + .await?; + } + + Ok(()) +} + +fn generate_local_api_key() -> String { + let raw = Uuid::new_v4().simple().to_string(); + format!("sk_local_{}", raw) +} + + /// 根据 UUID 查询用户 pub async fn fetch_user_by_uuid( pool: &Pool, diff --git a/src/routes.rs b/src/routes.rs index 9afb748..a18c4dc 100644 --- a/src/routes.rs +++ b/src/routes.rs @@ -5,6 +5,7 @@ pub mod users; // 新增模块 pub mod accounts; +pub mod local_api; pub mod audit; pub mod billing; pub mod browser_kernel; diff --git a/src/routes/local_api.rs b/src/routes/local_api.rs new file mode 100644 index 0000000..ffef4e8 --- /dev/null +++ b/src/routes/local_api.rs @@ -0,0 +1,27 @@ +use axum::routing::post; + +use crate::handlers::local_api; +use crate::routes::route::{self, MetaRoute}; + +pub fn register_routes(meta_route: &mut MetaRoute) { + let mut local_api_route = route::RouteGroup::new("/local-api"); + + local_api_route.add_route_item(route::RouteItem::post( + "/get", + post(local_api::get_local_api_config_handler), + )); + local_api_route.add_route_item(route::RouteItem::post( + "/update", + post(local_api::update_local_api_config_handler), + )); + local_api_route.add_route_item(route::RouteItem::post( + "/reset-api-key", + post(local_api::reset_local_api_key_handler), + )); + local_api_route.add_route_item(route::RouteItem::post( + "/validate", + post(local_api::validate_local_api_key_handler), + )); + + meta_route.add_route_group(local_api_route); +} diff --git a/src/services.rs b/src/services.rs index 0ced820..e8c92bd 100644 --- a/src/services.rs +++ b/src/services.rs @@ -12,6 +12,7 @@ mod versions; // 新增模块 pub mod accounts; +pub mod local_api; pub mod audit; pub mod browser_kernel; pub mod billing; diff --git a/src/services/local_api.rs b/src/services/local_api.rs new file mode 100644 index 0000000..3577df2 --- /dev/null +++ b/src/services/local_api.rs @@ -0,0 +1,352 @@ +use chrono::{Timelike, Utc}; +use uuid::Uuid; + +use crate::dto::{LocalApiConfigDto, ResetLocalApiKeyDto, ValidateLocalApiKeyDto}; +use crate::entitys::{UpdateLocalApiConfigRequest, ValidateLocalApiKeyRequest}; +use crate::models; +use crate::svc_ctx::SvcCtx; + +const DEFAULT_DAILY_LIMIT: i32 = 1000; + +pub async fn get_local_api_config_service( + svc_ctx: &SvcCtx, + user_uuid: Uuid, +) -> Result { + init_local_api_for_user_service(svc_ctx, user_uuid).await?; + + let settings = models::local_api::fetch_local_api_settings(&svc_ctx.db, user_uuid) + .await + .map_err(|e| e.to_string())? + .ok_or_else(|| "API 服务配置不存在".to_string())?; + + let mut api_key = models::local_api::fetch_active_api_key(&svc_ctx.db, user_uuid) + .await + .map_err(|e| e.to_string())? + .ok_or_else(|| "API 密钥不存在".to_string())?; + + if api_key.api_key.is_none() { + rotate_local_api_key_for_user(svc_ctx, user_uuid).await?; + api_key = models::local_api::fetch_active_api_key(&svc_ctx.db, user_uuid) + .await + .map_err(|e| e.to_string())? + .ok_or_else(|| "API 密钥不存在".to_string())?; + } + + Ok(LocalApiConfigDto { + enabled: settings.enabled, + api_key: api_key + .api_key + .clone() + .ok_or_else(|| "API 密钥不存在".to_string())?, + port: settings.port, + remote_access: settings.remote_access, + cors_origins: models::local_api::parse_cors_origins(&settings.cors_origins), + requests_today: api_key.requests_today, + daily_limit: api_key.daily_limit, + }) +} + +pub async fn update_local_api_config_service( + svc_ctx: &SvcCtx, + user_uuid: Uuid, + payload: &UpdateLocalApiConfigRequest, +) -> Result { + if let Some(port) = payload.port { + if !(1..=65535).contains(&port) { + return Err("端口范围无效".to_string()); + } + } + + init_local_api_for_user_service(svc_ctx, user_uuid).await?; + + let cors_origins = payload + .cors_origins + .as_ref() + .map(|origins| models::local_api::build_cors_origins_value(origins)); + + models::local_api::upsert_local_api_settings( + &svc_ctx.db, + user_uuid, + payload.enabled, + payload.port, + payload.remote_access, + cors_origins.as_ref(), + ) + .await + .map_err(|e| e.to_string())?; + + get_local_api_config_service(svc_ctx, user_uuid).await +} + +pub async fn reset_local_api_key_service( + svc_ctx: &SvcCtx, + user_uuid: Uuid, +) -> Result { + init_local_api_for_user_service(svc_ctx, user_uuid).await?; + + models::local_api::deactivate_api_keys_for_user(&svc_ctx.db, user_uuid) + .await + .map_err(|e| e.to_string())?; + + let api_key = generate_api_key(); + let key_hash = models::local_api::hash_api_key(&api_key); + let key_prefix = api_key.chars().take(16).collect::(); + let created_key = models::local_api::insert_api_key( + &svc_ctx.db, + user_uuid, + &key_prefix, + &key_hash, + &api_key, + DEFAULT_DAILY_LIMIT, + ) + .await + .map_err(|e| e.to_string())?; + + ensure_default_permissions(svc_ctx, created_key.id).await?; + + Ok(ResetLocalApiKeyDto { + api_key, + }) +} + +pub async fn validate_local_api_key_service( + svc_ctx: &SvcCtx, + payload: &ValidateLocalApiKeyRequest, +) -> Result { + let key_hash = models::local_api::hash_api_key(payload.api_key.as_str()); + let api_key = models::local_api::fetch_api_key_by_hash(&svc_ctx.db, &key_hash) + .await + .map_err(|e| e.to_string())? + .ok_or_else(|| "API 密钥无效".to_string())?; + + if !api_key.is_active { + return Err("API 密钥已停用".to_string()); + } + + if let Some(expires_at) = api_key.expires_at { + if expires_at < Utc::now() { + return Err("API 密钥已过期".to_string()); + } + } + + let today = Utc::now().date_naive(); + if api_key.last_reset_date != today { + models::local_api::reset_api_key_daily_usage(&svc_ctx.db, api_key.id, today) + .await + .map_err(|e| e.to_string())?; + } + + let refreshed_key = models::local_api::fetch_api_key_by_hash(&svc_ctx.db, &key_hash) + .await + .map_err(|e| e.to_string())? + .ok_or_else(|| "API 密钥无效".to_string())?; + + if refreshed_key.requests_today >= refreshed_key.daily_limit { + return Err("API 密钥已达到今日调用上限".to_string()); + } + + let definition = models::local_api::fetch_permission_definition( + &svc_ctx.db, + &payload.permission_code, + ) + .await + .map_err(|e| e.to_string())? + .ok_or_else(|| "未知的 permissionCode".to_string())?; + + let permission = fetch_effective_permission(svc_ctx, refreshed_key.id, &payload.permission_code) + .await? + .ok_or_else(|| "当前密钥无权访问该接口".to_string())?; + + if !permission.is_enabled { + return Err("当前密钥已被禁止访问该接口".to_string()); + } + + let now = Utc::now(); + let minute_start = now + .with_second(0) + .and_then(|dt| dt.with_nanosecond(0)) + .ok_or_else(|| "时间窗口计算失败".to_string())?; + let hour_start = minute_start + .with_minute(0) + .ok_or_else(|| "时间窗口计算失败".to_string())?; + let day_start = hour_start + .date_naive() + .and_hms_opt(0, 0, 0) + .ok_or_else(|| "时间窗口计算失败".to_string())? + .and_utc(); + + let minute_count = models::local_api::fetch_request_count( + &svc_ctx.db, + refreshed_key.id, + payload.permission_code.as_str(), + "minute", + minute_start, + ) + .await + .map_err(|e| e.to_string())?; + if minute_count >= permission.rate_limit_per_minute { + return Err("接口每分钟调用次数已达上限".to_string()); + } + + let hour_count = models::local_api::fetch_request_count( + &svc_ctx.db, + refreshed_key.id, + payload.permission_code.as_str(), + "hour", + hour_start, + ) + .await + .map_err(|e| e.to_string())?; + if hour_count >= permission.rate_limit_per_hour { + return Err("接口每小时调用次数已达上限".to_string()); + } + + models::local_api::increment_request_counter( + &svc_ctx.db, + refreshed_key.id, + payload.permission_code.as_str(), + "minute", + minute_start, + ) + .await + .map_err(|e| e.to_string())?; + models::local_api::increment_request_counter( + &svc_ctx.db, + refreshed_key.id, + payload.permission_code.as_str(), + "hour", + hour_start, + ) + .await + .map_err(|e| e.to_string())?; + models::local_api::increment_request_counter( + &svc_ctx.db, + refreshed_key.id, + payload.permission_code.as_str(), + "day", + day_start, + ) + .await + .map_err(|e| e.to_string())?; + models::local_api::increment_api_key_usage(&svc_ctx.db, refreshed_key.id, now) + .await + .map_err(|e| e.to_string())?; + + Ok(ValidateLocalApiKeyDto { + valid: true, + permission_code: definition.permission_code, + requests_today: refreshed_key.requests_today + 1, + daily_limit: refreshed_key.daily_limit, + rate_limit_per_minute: permission.rate_limit_per_minute, + rate_limit_per_hour: permission.rate_limit_per_hour, + }) +} + +pub async fn init_local_api_for_user_service( + svc_ctx: &SvcCtx, + user_uuid: Uuid, +) -> Result<(), String> { + models::local_api::upsert_local_api_settings( + &svc_ctx.db, + user_uuid, + None, + None, + None, + None, + ) + .await + .map_err(|e| e.to_string())?; + + let current_key = models::local_api::fetch_active_api_key(&svc_ctx.db, user_uuid) + .await + .map_err(|e| e.to_string())?; + + if let Some(api_key) = current_key { + if api_key.api_key.is_none() { + rotate_local_api_key_for_user(svc_ctx, user_uuid).await?; + return Ok(()); + } + ensure_default_permissions(svc_ctx, api_key.id).await?; + return Ok(()); + } + + let api_key = generate_api_key(); + let key_hash = models::local_api::hash_api_key(&api_key); + let key_prefix = api_key.chars().take(16).collect::(); + + let created_key = models::local_api::insert_api_key( + &svc_ctx.db, + user_uuid, + &key_prefix, + &key_hash, + &api_key, + DEFAULT_DAILY_LIMIT, + ) + .await + .map_err(|e| e.to_string())?; + + ensure_default_permissions(svc_ctx, created_key.id).await +} + +async fn ensure_default_permissions(svc_ctx: &SvcCtx, api_key_id: i32) -> Result<(), String> { + let definitions = models::local_api::fetch_permission_definitions(&svc_ctx.db) + .await + .map_err(|e| e.to_string())?; + + for definition in definitions { + models::local_api::insert_api_key_permission( + &svc_ctx.db, + api_key_id, + definition.permission_code.as_str(), + definition.default_rate_limit_per_minute, + definition.default_rate_limit_per_hour, + ) + .await + .map_err(|e| e.to_string())?; + } + + Ok(()) +} + +async fn fetch_effective_permission( + svc_ctx: &SvcCtx, + api_key_id: i32, + permission_code: &str, +) -> Result, String> { + models::local_api::fetch_api_key_permission(&svc_ctx.db, api_key_id, permission_code) + .await + .map_err(|e| e.to_string()) +} + +fn generate_api_key() -> String { + let raw = Uuid::new_v4().simple().to_string(); + format!("sk_local_{}", raw) +} + +async fn rotate_local_api_key_for_user( + svc_ctx: &SvcCtx, + user_uuid: Uuid, +) -> Result { + models::local_api::deactivate_api_keys_for_user(&svc_ctx.db, user_uuid) + .await + .map_err(|e| e.to_string())?; + + let api_key = generate_api_key(); + let key_hash = models::local_api::hash_api_key(&api_key); + let key_prefix = api_key.chars().take(16).collect::(); + + let created_key = models::local_api::insert_api_key( + &svc_ctx.db, + user_uuid, + &key_prefix, + &key_hash, + &api_key, + DEFAULT_DAILY_LIMIT, + ) + .await + .map_err(|e| e.to_string())?; + + ensure_default_permissions(svc_ctx, created_key.id).await?; + + Ok(ResetLocalApiKeyDto { api_key }) +} From 6e129fa28e86934b0d61a5f2347ce420972a4646 Mon Sep 17 00:00:00 2001 From: lius-new Date: Mon, 16 Mar 2026 23:52:18 +0800 Subject: [PATCH 32/60] feat(server): add local api auth middleware --- src/caches/local_api.rs | 230 +++++++++++++++++++++++++++ src/caches/mod.rs | 8 +- src/dto/local_api.rs | 4 +- src/main.rs | 4 + src/middlewares.rs | 4 +- src/middlewares/auth.rs | 140 ++++++++++------- src/middlewares/local_api_auth.rs | 219 ++++++++++++++++++++++++++ src/services/local_api.rs | 253 +++++++++++++++++++++--------- 8 files changed, 726 insertions(+), 136 deletions(-) create mode 100644 src/caches/local_api.rs create mode 100644 src/middlewares/local_api_auth.rs diff --git a/src/caches/local_api.rs b/src/caches/local_api.rs new file mode 100644 index 0000000..334e1b7 --- /dev/null +++ b/src/caches/local_api.rs @@ -0,0 +1,230 @@ +use chrono::Utc; +use redis::{AsyncCommands, cmd}; +use serde::{Deserialize, Serialize}; +use uuid::Uuid; + +use crate::dto::LocalApiPermissionDefinitionDto; +use crate::svc_ctx::SvcCtx; + +const LOCAL_API_KEY_CACHE_PREFIX: &str = "local_api:key"; +const LOCAL_API_PERMISSION_CACHE_PREFIX: &str = "local_api:permission"; +const LOCAL_API_PERMISSION_DEFINITION_CACHE_PREFIX: &str = "local_api:permission_definition"; +const LOCAL_API_RATE_CACHE_PREFIX: &str = "local_api:rate"; +const LOCAL_API_KEY_CACHE_TTL: u64 = 60 * 10; +const LOCAL_API_PERMISSION_CACHE_TTL: u64 = 60 * 10; +const MINUTE_WINDOW_SECONDS: i64 = 60; +const HOUR_WINDOW_SECONDS: i64 = 60 * 60; +const DAY_WINDOW_SECONDS: i64 = 60 * 60 * 24; + +#[derive(Debug, Clone, Serialize, Deserialize)] +pub struct LocalApiKeyCache { + pub id: i32, + pub user_uuid: Uuid, + pub is_active: bool, + pub expires_at: Option>, + pub daily_limit: i32, +} + +#[derive(Debug, Clone, Serialize, Deserialize)] +pub struct LocalApiPermissionCache { + pub is_enabled: bool, + pub rate_limit_per_minute: i32, + pub rate_limit_per_hour: i32, +} + +fn local_api_key_cache_key(key_hash: &str) -> String { + format!("{}:{}", LOCAL_API_KEY_CACHE_PREFIX, key_hash) +} + +fn local_api_permission_cache_key(api_key_id: i32, permission_code: &str) -> String { + format!( + "{}:{}:{}", + LOCAL_API_PERMISSION_CACHE_PREFIX, api_key_id, permission_code + ) +} + +fn local_api_permission_definition_cache_key(permission_code: &str) -> String { + format!( + "{}:{}", + LOCAL_API_PERMISSION_DEFINITION_CACHE_PREFIX, permission_code + ) +} + +fn local_api_rate_cache_key( + api_key_id: i32, + permission_code: Option<&str>, + window_type: &str, + window_key: &str, +) -> String { + if let Some(permission_code) = permission_code { + format!( + "{}:{}:{}:{}:{}", + LOCAL_API_RATE_CACHE_PREFIX, api_key_id, permission_code, window_type, window_key + ) + } else { + format!( + "{}:{}:{}:{}", + LOCAL_API_RATE_CACHE_PREFIX, api_key_id, window_type, window_key + ) + } +} + +pub async fn get_local_api_key_cache( + svc_ctx: &SvcCtx, + key_hash: &str, +) -> Result, anyhow::Error> { + let key = local_api_key_cache_key(key_hash); + let value: Option = svc_ctx.redis.clone().get(key).await?; + let value = value + .map(|payload| serde_json::from_str::(&payload)) + .transpose()?; + Ok(value) +} + +pub async fn set_local_api_key_cache( + svc_ctx: &SvcCtx, + key_hash: &str, + cache: &LocalApiKeyCache, +) -> Result<(), anyhow::Error> { + let key = local_api_key_cache_key(key_hash); + let value = serde_json::to_string(cache)?; + let _: () = svc_ctx + .redis + .clone() + .set_ex(key, value, LOCAL_API_KEY_CACHE_TTL) + .await?; + Ok(()) +} + +pub async fn delete_local_api_key_cache( + svc_ctx: &SvcCtx, + key_hash: &str, +) -> Result<(), anyhow::Error> { + let key = local_api_key_cache_key(key_hash); + let _: usize = svc_ctx.redis.clone().del(key).await?; + Ok(()) +} + +pub async fn get_local_api_permission_cache( + svc_ctx: &SvcCtx, + api_key_id: i32, + permission_code: &str, +) -> Result, anyhow::Error> { + let key = local_api_permission_cache_key(api_key_id, permission_code); + let value: Option = svc_ctx.redis.clone().get(key).await?; + let value = value + .map(|payload| serde_json::from_str::(&payload)) + .transpose()?; + Ok(value) +} + +pub async fn set_local_api_permission_cache( + svc_ctx: &SvcCtx, + api_key_id: i32, + permission_code: &str, + cache: &LocalApiPermissionCache, +) -> Result<(), anyhow::Error> { + let key = local_api_permission_cache_key(api_key_id, permission_code); + let value = serde_json::to_string(cache)?; + let _: () = svc_ctx + .redis + .clone() + .set_ex(key, value, LOCAL_API_PERMISSION_CACHE_TTL) + .await?; + Ok(()) +} + +pub async fn delete_local_api_permission_cache( + svc_ctx: &SvcCtx, + api_key_id: i32, + permission_code: &str, +) -> Result<(), anyhow::Error> { + let key = local_api_permission_cache_key(api_key_id, permission_code); + let _: usize = svc_ctx.redis.clone().del(key).await?; + Ok(()) +} + +pub async fn delete_local_api_permission_caches_for_key( + svc_ctx: &SvcCtx, + api_key_id: i32, +) -> Result<(), anyhow::Error> { + let pattern = format!("{}:{}:*", LOCAL_API_PERMISSION_CACHE_PREFIX, api_key_id); + let keys: Vec = cmd("KEYS") + .arg(&pattern) + .query_async(&mut svc_ctx.redis.clone()) + .await?; + if !keys.is_empty() { + let _: usize = svc_ctx.redis.clone().del(keys).await?; + } + Ok(()) +} + +pub async fn get_local_api_permission_definition_cache( + svc_ctx: &SvcCtx, + permission_code: &str, +) -> Result, anyhow::Error> { + let key = local_api_permission_definition_cache_key(permission_code); + let value: Option = svc_ctx.redis.clone().get(key).await?; + let value = value + .map(|payload| serde_json::from_str::(&payload)) + .transpose()?; + Ok(value) +} + +pub async fn set_local_api_permission_definition_cache( + svc_ctx: &SvcCtx, + permission_code: &str, + cache: &LocalApiPermissionDefinitionDto, +) -> Result<(), anyhow::Error> { + let key = local_api_permission_definition_cache_key(permission_code); + let value = serde_json::to_string(cache)?; + let _: () = svc_ctx + .redis + .clone() + .set_ex(key, value, LOCAL_API_PERMISSION_CACHE_TTL) + .await?; + Ok(()) +} + +pub async fn delete_local_api_permission_definition_cache( + svc_ctx: &SvcCtx, + permission_code: &str, +) -> Result<(), anyhow::Error> { + let key = local_api_permission_definition_cache_key(permission_code); + let _: usize = svc_ctx.redis.clone().del(key).await?; + Ok(()) +} + +pub async fn get_local_api_rate_count( + svc_ctx: &SvcCtx, + api_key_id: i32, + permission_code: Option<&str>, + window_type: &str, + window_key: &str, +) -> Result { + let key = local_api_rate_cache_key(api_key_id, permission_code, window_type, window_key); + let value: Option = svc_ctx.redis.clone().get(key).await?; + Ok(value.unwrap_or(0)) +} + +pub async fn increment_local_api_rate_count( + svc_ctx: &SvcCtx, + api_key_id: i32, + permission_code: Option<&str>, + window_type: &str, + window_key: &str, +) -> Result { + let key = local_api_rate_cache_key(api_key_id, permission_code, window_type, window_key); + let mut redis = svc_ctx.redis.clone(); + let count: i64 = redis.incr(&key, 1).await?; + if count == 1 { + let ttl = match window_type { + "minute" => MINUTE_WINDOW_SECONDS, + "hour" => HOUR_WINDOW_SECONDS, + "day" => DAY_WINDOW_SECONDS, + _ => DAY_WINDOW_SECONDS, + }; + let _: bool = redis.expire(&key, ttl).await?; + } + Ok(count) +} diff --git a/src/caches/mod.rs b/src/caches/mod.rs index d095889..c9df650 100644 --- a/src/caches/mod.rs +++ b/src/caches/mod.rs @@ -1,3 +1,5 @@ -pub mod user; - -pub use user::*; +pub mod local_api; +pub mod user; + +pub use local_api::*; +pub use user::*; diff --git a/src/dto/local_api.rs b/src/dto/local_api.rs index 47017a1..256c28b 100644 --- a/src/dto/local_api.rs +++ b/src/dto/local_api.rs @@ -1,5 +1,5 @@ use chrono::{DateTime, NaiveDate, Utc}; -use serde::Serialize; +use serde::{Deserialize, Serialize}; use serde_json::Value; use sqlx::FromRow; use uuid::Uuid; @@ -51,7 +51,7 @@ pub struct LocalApiKeyPermissionDto { pub deleted_at: Option>, } -#[derive(Debug, Clone, FromRow)] +#[derive(Debug, Clone, FromRow, Serialize, Deserialize)] pub struct LocalApiPermissionDefinitionDto { pub id: i32, pub uuid: Uuid, diff --git a/src/main.rs b/src/main.rs index 3aa7308..f20ee6c 100644 --- a/src/main.rs +++ b/src/main.rs @@ -104,6 +104,10 @@ fn register_middlewares(svc_ctx: &SvcCtx, app: Router) -> Router svc_ctx.clone(), middlewares::auth, )) + .route_layer(middleware::from_fn_with_state( + svc_ctx.clone(), + middlewares::local_api_auth, + )) .route_layer(middleware::from_fn_with_state( svc_ctx.clone(), middlewares::machine_auth, diff --git a/src/middlewares.rs b/src/middlewares.rs index e970d2d..50d8773 100644 --- a/src/middlewares.rs +++ b/src/middlewares.rs @@ -3,6 +3,7 @@ mod cors; mod decrypt; mod encrypt; mod logger; +mod local_api_auth; mod machine_auth; mod real_ip; @@ -11,8 +12,9 @@ pub use cors::*; pub use decrypt::*; pub use encrypt::*; pub use logger::*; +pub use local_api_auth::*; pub use machine_auth::*; -pub use real_ip::*; +pub use real_ip::*; use axum::extract::{MatchedPath, Request}; diff --git a/src/middlewares/auth.rs b/src/middlewares/auth.rs index 15320ff..6d40a9c 100644 --- a/src/middlewares/auth.rs +++ b/src/middlewares/auth.rs @@ -15,23 +15,38 @@ use crate::{ svc_ctx::SvcCtx, }; -pub async fn auth( - State(state): State, - mut req: Request, - next: Next, -) -> Result { +pub async fn auth( + State(state): State, + mut req: Request, + next: Next, +) -> Result { let (resource_method, resource_path) = get_request_path_and_method(&req); // 转换为拥有所有权的 String,避免借用冲突 let resource_method = resource_method.to_string(); let resource_path = resource_path.to_string(); - if resource_method.eq("OPTIONS") { - return Ok(next.run(req).await); - } - - // 判断请求的是否在白名单,如果是白名单就直接允许访问。 - { + if resource_method.eq("OPTIONS") { + return Ok(next.run(req).await); + } + + if req + .extensions() + .get::() + .and_then(|ctx| ctx.current_user.as_ref()) + .is_some() + { + return Ok(next.run(req).await); + } + + if crate::middlewares::has_local_api_auth_headers(&req) + && !extract_resource_path(&resource_path).starts_with("/local-api") + { + return Ok(next.run(req).await); + } + + // 判断请求的是否在白名单,如果是白名单就直接允许访问。 + { let combine = format!("{}+{}", resource_method, resource_path); let whitelists = &state.config.app.route_whitelists; @@ -64,49 +79,60 @@ pub async fn auth( // TODO: 根据获取到的uuid可以获取到用户并调用检查权限的方法。 // 2025-08-27 10:05:00: 权限检查可能移除, 该网关服务只面向用户 - let uuid = if let Ok(uuid) = Uuid::from_str(&verify_token_response) { - uuid - } else { - return Err(StatusCode::UNAUTHORIZED); - }; - - // 更新 RequestContext 中的 current_user、current_workspace 和 current_team - if let Some(ctx) = req.extensions_mut().get_mut::() { - let ip = ctx.ip_or_unknown(); - tracing::info!("auth user uuid: {}, ip: {}", uuid, ip); - - ctx.current_user = Some(CurrentUser { user_uuid: uuid }); - - // 设置资源标识符(method+业务路径) - let business_path = extract_resource_path(&resource_path); - ctx.resource_identifier = Some(format!("{}+{}", resource_method, business_path)); - - // 获取用户信息(包含 current_workspace_uuid 和 current_team_uuid) - let user_info = crate::models::user::fetch_user_info_by_uuid(&state.db, uuid) - .await - .ok() - .flatten(); - - // 初始化 current_team_uuid 和 current_workspace_uuid - if let Some(user_info) = user_info { - ctx.current_team_uuid = user_info.current_team_uuid; - - // 优先使用 current_workspace_uuid - // 如果不存在,尝试从当前团队获取工作空间 - ctx.current_workspace_uuid = if let Some(ws_uuid) = user_info.current_workspace_uuid { - Some(ws_uuid) - } else if let Some(team_uuid) = user_info.current_team_uuid { - // 从当前团队获取工作空间 - crate::models::teams::fetch_team_by_uuid(&state.db, team_uuid) - .await - .ok() - .flatten() - .map(|team| team.workspace_uuid) - } else { - None - }; - } - } - - Ok(next.run(req).await) -} + let uuid = if let Ok(uuid) = Uuid::from_str(&verify_token_response) { + uuid + } else { + return Err(StatusCode::UNAUTHORIZED); + }; + + fill_authenticated_request_context(&state, &mut req, uuid, &resource_method, &resource_path) + .await; + + Ok(next.run(req).await) +} + +pub async fn fill_authenticated_request_context( + state: &SvcCtx, + req: &mut Request, + user_uuid: Uuid, + resource_method: &str, + resource_path: &str, +) { + if req.extensions().get::().is_none() { + req.extensions_mut().insert(RequestContext::default()); + } + + if let Some(ctx) = req.extensions_mut().get_mut::() { + let ip = ctx.ip_or_unknown().to_string(); + tracing::info!("auth user uuid: {}, ip: {}", user_uuid, ip); + + ctx.current_user = Some(CurrentUser { user_uuid }); + ctx.current_team_uuid = None; + ctx.current_workspace_uuid = None; + + let business_path = extract_resource_path(resource_path); + ctx.resource_identifier = Some(format!("{}+{}", resource_method, business_path)); + } + + let user_info = crate::models::user::fetch_user_info_by_uuid(&state.db, user_uuid) + .await + .ok() + .flatten(); + + if let Some(ctx) = req.extensions_mut().get_mut::() { + if let Some(user_info) = user_info { + ctx.current_team_uuid = user_info.current_team_uuid; + ctx.current_workspace_uuid = if let Some(ws_uuid) = user_info.current_workspace_uuid { + Some(ws_uuid) + } else if let Some(team_uuid) = user_info.current_team_uuid { + crate::models::teams::fetch_team_by_uuid(&state.db, team_uuid) + .await + .ok() + .flatten() + .map(|team| team.workspace_uuid) + } else { + None + }; + } + } +} diff --git a/src/middlewares/local_api_auth.rs b/src/middlewares/local_api_auth.rs new file mode 100644 index 0000000..53dcbaa --- /dev/null +++ b/src/middlewares/local_api_auth.rs @@ -0,0 +1,219 @@ +use axum::{ + extract::{Request, State}, + http::StatusCode, + middleware::Next, + response::Response, +}; +use chrono::Utc; + +use crate::{ + caches::{ + LocalApiKeyCache, LocalApiPermissionCache, get_local_api_key_cache, + get_local_api_permission_cache, get_local_api_permission_definition_cache, + get_local_api_rate_count, increment_local_api_rate_count, set_local_api_key_cache, + set_local_api_permission_cache, set_local_api_permission_definition_cache, + }, + middlewares::{fill_authenticated_request_context, get_request_path_and_method}, + models, + svc_ctx::SvcCtx, +}; + +const LOCAL_API_AUTH_HEADER: &str = "x-local-api-auth"; +const LOCAL_API_KEY_HEADER: &str = "x-local-api-key"; +const LOCAL_API_PERMISSION_HEADER: &str = "x-local-api-permission"; + +pub fn has_local_api_auth_headers(req: &Request) -> bool { + req.headers().contains_key(LOCAL_API_AUTH_HEADER) + || (req.headers().contains_key(LOCAL_API_KEY_HEADER) + && req.headers().contains_key(LOCAL_API_PERMISSION_HEADER)) +} + +fn is_local_api_management_path(path: &str) -> bool { + let business_path = crate::middlewares::extract_resource_path(path); + business_path.starts_with("/local-api") +} + +fn parse_local_api_auth(req: &Request) -> Option<(String, String)> { + if let Some(combined) = req + .headers() + .get(LOCAL_API_AUTH_HEADER) + .and_then(|value| value.to_str().ok()) + { + let (api_key, permission_code) = combined.split_once(':')?; + let api_key = api_key.trim(); + let permission_code = permission_code.trim(); + + if !api_key.is_empty() && !permission_code.is_empty() { + return Some((api_key.to_string(), permission_code.to_string())); + } + } + + let api_key = req + .headers() + .get(LOCAL_API_KEY_HEADER) + .and_then(|value| value.to_str().ok()) + .map(str::trim) + .filter(|value| !value.is_empty())?; + let permission_code = req + .headers() + .get(LOCAL_API_PERMISSION_HEADER) + .and_then(|value| value.to_str().ok()) + .map(str::trim) + .filter(|value| !value.is_empty())?; + + Some((api_key.to_string(), permission_code.to_string())) +} + +pub async fn local_api_auth( + State(state): State, + mut req: Request, + next: Next, +) -> Result { + let (resource_method, resource_path) = get_request_path_and_method(&req); + let resource_method = resource_method.to_string(); + let resource_path = resource_path.to_string(); + + if resource_method == "OPTIONS" || is_local_api_management_path(&resource_path) { + return Ok(next.run(req).await); + } + + let Some((api_key, permission_code)) = parse_local_api_auth(&req) else { + return Ok(next.run(req).await); + }; + + let user_uuid = authorize_local_api_request(&state, api_key.as_str(), permission_code.as_str()) + .await?; + + fill_authenticated_request_context( + &state, + &mut req, + user_uuid, + &resource_method, + &resource_path, + ) + .await; + + Ok(next.run(req).await) +} + +async fn authorize_local_api_request( + state: &SvcCtx, + api_key: &str, + permission_code: &str, +) -> Result { + let key_hash = models::local_api::hash_api_key(api_key); + let api_key = if let Some(cache) = get_local_api_key_cache(state, &key_hash) + .await + .map_err(|_| StatusCode::INTERNAL_SERVER_ERROR)? + { + cache + } else { + let db_api_key = models::local_api::fetch_api_key_by_hash(&state.db, &key_hash) + .await + .map_err(|_| StatusCode::INTERNAL_SERVER_ERROR)? + .ok_or(StatusCode::UNAUTHORIZED)?; + + let cache = LocalApiKeyCache { + id: db_api_key.id, + user_uuid: db_api_key.user_uuid, + is_active: db_api_key.is_active, + expires_at: db_api_key.expires_at, + daily_limit: db_api_key.daily_limit, + }; + set_local_api_key_cache(state, &key_hash, &cache) + .await + .map_err(|_| StatusCode::INTERNAL_SERVER_ERROR)?; + cache + }; + + if !api_key.is_active { + return Err(StatusCode::UNAUTHORIZED); + } + + if let Some(expires_at) = api_key.expires_at { + if expires_at < Utc::now() { + return Err(StatusCode::UNAUTHORIZED); + } + } + + if get_local_api_permission_definition_cache(state, permission_code) + .await + .map_err(|_| StatusCode::INTERNAL_SERVER_ERROR)? + .is_none() + { + let definition = models::local_api::fetch_permission_definition(&state.db, permission_code) + .await + .map_err(|_| StatusCode::INTERNAL_SERVER_ERROR)? + .ok_or(StatusCode::FORBIDDEN)?; + set_local_api_permission_definition_cache(state, permission_code, &definition) + .await + .map_err(|_| StatusCode::INTERNAL_SERVER_ERROR)?; + } + + let permission = if let Some(cache) = + get_local_api_permission_cache(state, api_key.id, permission_code) + .await + .map_err(|_| StatusCode::INTERNAL_SERVER_ERROR)? + { + cache + } else { + let db_permission = + models::local_api::fetch_api_key_permission(&state.db, api_key.id, permission_code) + .await + .map_err(|_| StatusCode::INTERNAL_SERVER_ERROR)? + .ok_or(StatusCode::FORBIDDEN)?; + let cache = LocalApiPermissionCache { + is_enabled: db_permission.is_enabled, + rate_limit_per_minute: db_permission.rate_limit_per_minute, + rate_limit_per_hour: db_permission.rate_limit_per_hour, + }; + set_local_api_permission_cache(state, api_key.id, permission_code, &cache) + .await + .map_err(|_| StatusCode::INTERNAL_SERVER_ERROR)?; + cache + }; + + if !permission.is_enabled { + return Err(StatusCode::FORBIDDEN); + } + + let now = Utc::now(); + let day_key = now.format("%Y%m%d").to_string(); + let hour_key = now.format("%Y%m%d%H").to_string(); + let minute_key = now.format("%Y%m%d%H%M").to_string(); + + let day_count = get_local_api_rate_count(state, api_key.id, None, "day", &day_key) + .await + .map_err(|_| StatusCode::INTERNAL_SERVER_ERROR)?; + if day_count >= i64::from(api_key.daily_limit) { + return Err(StatusCode::TOO_MANY_REQUESTS); + } + + let minute_count = + get_local_api_rate_count(state, api_key.id, Some(permission_code), "minute", &minute_key) + .await + .map_err(|_| StatusCode::INTERNAL_SERVER_ERROR)?; + if minute_count >= i64::from(permission.rate_limit_per_minute) { + return Err(StatusCode::TOO_MANY_REQUESTS); + } + + let hour_count = + get_local_api_rate_count(state, api_key.id, Some(permission_code), "hour", &hour_key) + .await + .map_err(|_| StatusCode::INTERNAL_SERVER_ERROR)?; + if hour_count >= i64::from(permission.rate_limit_per_hour) { + return Err(StatusCode::TOO_MANY_REQUESTS); + } + + increment_local_api_rate_count(state, api_key.id, Some(permission_code), "minute", &minute_key) + .await + .map_err(|_| StatusCode::INTERNAL_SERVER_ERROR)?; + increment_local_api_rate_count(state, api_key.id, Some(permission_code), "hour", &hour_key) + .await + .map_err(|_| StatusCode::INTERNAL_SERVER_ERROR)?; + increment_local_api_rate_count(state, api_key.id, None, "day", &day_key) + .await + .map_err(|_| StatusCode::INTERNAL_SERVER_ERROR)?; + + Ok(api_key.user_uuid) +} diff --git a/src/services/local_api.rs b/src/services/local_api.rs index 3577df2..3f66fa1 100644 --- a/src/services/local_api.rs +++ b/src/services/local_api.rs @@ -1,6 +1,13 @@ -use chrono::{Timelike, Utc}; +use chrono::Utc; use uuid::Uuid; +use crate::caches::{ + LocalApiKeyCache, LocalApiPermissionCache, delete_local_api_key_cache, + delete_local_api_permission_caches_for_key, + get_local_api_key_cache, get_local_api_permission_cache, get_local_api_permission_definition_cache, + get_local_api_rate_count, increment_local_api_rate_count, set_local_api_key_cache, + set_local_api_permission_cache, set_local_api_permission_definition_cache, +}; use crate::dto::{LocalApiConfigDto, ResetLocalApiKeyDto, ValidateLocalApiKeyDto}; use crate::entitys::{UpdateLocalApiConfigRequest, ValidateLocalApiKeyRequest}; use crate::models; @@ -41,7 +48,17 @@ pub async fn get_local_api_config_service( port: settings.port, remote_access: settings.remote_access, cors_origins: models::local_api::parse_cors_origins(&settings.cors_origins), - requests_today: api_key.requests_today, + requests_today: get_local_api_rate_count( + svc_ctx, + api_key.id, + None, + "day", + &Utc::now().format("%Y%m%d").to_string(), + ) + .await + .ok() + .map(|count| count as i32) + .unwrap_or(api_key.requests_today), daily_limit: api_key.daily_limit, }) } @@ -84,6 +101,18 @@ pub async fn reset_local_api_key_service( ) -> Result { init_local_api_for_user_service(svc_ctx, user_uuid).await?; + if let Some(current_key) = models::local_api::fetch_active_api_key(&svc_ctx.db, user_uuid) + .await + .map_err(|e| e.to_string())? + { + delete_local_api_key_cache(svc_ctx, ¤t_key.key_hash) + .await + .map_err(|e| e.to_string())?; + delete_local_api_permission_caches_for_key(svc_ctx, current_key.id) + .await + .map_err(|e| e.to_string())?; + } + models::local_api::deactivate_api_keys_for_user(&svc_ctx.db, user_uuid) .await .map_err(|e| e.to_string())?; @@ -101,6 +130,19 @@ pub async fn reset_local_api_key_service( ) .await .map_err(|e| e.to_string())?; + set_local_api_key_cache( + svc_ctx, + &key_hash, + &LocalApiKeyCache { + id: created_key.id, + user_uuid: created_key.user_uuid, + is_active: created_key.is_active, + expires_at: created_key.expires_at, + daily_limit: created_key.daily_limit, + }, + ) + .await + .map_err(|e| e.to_string())?; ensure_default_permissions(svc_ctx, created_key.id).await?; @@ -114,10 +156,28 @@ pub async fn validate_local_api_key_service( payload: &ValidateLocalApiKeyRequest, ) -> Result { let key_hash = models::local_api::hash_api_key(payload.api_key.as_str()); - let api_key = models::local_api::fetch_api_key_by_hash(&svc_ctx.db, &key_hash) + let api_key = if let Some(cache) = get_local_api_key_cache(svc_ctx, &key_hash) .await .map_err(|e| e.to_string())? - .ok_or_else(|| "API 密钥无效".to_string())?; + { + cache + } else { + let db_api_key = models::local_api::fetch_api_key_by_hash(&svc_ctx.db, &key_hash) + .await + .map_err(|e| e.to_string())? + .ok_or_else(|| "API 密钥无效".to_string())?; + let cache = LocalApiKeyCache { + id: db_api_key.id, + user_uuid: db_api_key.user_uuid, + is_active: db_api_key.is_active, + expires_at: db_api_key.expires_at, + daily_limit: db_api_key.daily_limit, + }; + set_local_api_key_cache(svc_ctx, &key_hash, &cache) + .await + .map_err(|e| e.to_string())?; + cache + }; if !api_key.is_active { return Err("API 密钥已停用".to_string()); @@ -129,114 +189,116 @@ pub async fn validate_local_api_key_service( } } - let today = Utc::now().date_naive(); - if api_key.last_reset_date != today { - models::local_api::reset_api_key_daily_usage(&svc_ctx.db, api_key.id, today) - .await - .map_err(|e| e.to_string())?; - } - - let refreshed_key = models::local_api::fetch_api_key_by_hash(&svc_ctx.db, &key_hash) + let day_key = Utc::now().format("%Y%m%d").to_string(); + let day_count = get_local_api_rate_count(svc_ctx, api_key.id, None, "day", &day_key) .await - .map_err(|e| e.to_string())? - .ok_or_else(|| "API 密钥无效".to_string())?; - - if refreshed_key.requests_today >= refreshed_key.daily_limit { + .map_err(|e| e.to_string())?; + if day_count >= i64::from(api_key.daily_limit) { return Err("API 密钥已达到今日调用上限".to_string()); } - let definition = models::local_api::fetch_permission_definition( - &svc_ctx.db, - &payload.permission_code, - ) - .await - .map_err(|e| e.to_string())? - .ok_or_else(|| "未知的 permissionCode".to_string())?; + let definition = if let Some(cache) = + get_local_api_permission_definition_cache(svc_ctx, &payload.permission_code) + .await + .map_err(|e| e.to_string())? + { + cache + } else { + let definition = models::local_api::fetch_permission_definition( + &svc_ctx.db, + &payload.permission_code, + ) + .await + .map_err(|e| e.to_string())? + .ok_or_else(|| "未知的 permissionCode".to_string())?; + set_local_api_permission_definition_cache(svc_ctx, &payload.permission_code, &definition) + .await + .map_err(|e| e.to_string())?; + definition + }; - let permission = fetch_effective_permission(svc_ctx, refreshed_key.id, &payload.permission_code) - .await? - .ok_or_else(|| "当前密钥无权访问该接口".to_string())?; + let permission = if let Some(cache) = + get_local_api_permission_cache(svc_ctx, api_key.id, &payload.permission_code) + .await + .map_err(|e| e.to_string())? + { + cache + } else { + let db_permission = fetch_effective_permission(svc_ctx, api_key.id, &payload.permission_code) + .await? + .ok_or_else(|| "当前密钥无权访问该接口".to_string())?; + let cache = LocalApiPermissionCache { + is_enabled: db_permission.is_enabled, + rate_limit_per_minute: db_permission.rate_limit_per_minute, + rate_limit_per_hour: db_permission.rate_limit_per_hour, + }; + set_local_api_permission_cache(svc_ctx, api_key.id, &payload.permission_code, &cache) + .await + .map_err(|e| e.to_string())?; + cache + }; if !permission.is_enabled { return Err("当前密钥已被禁止访问该接口".to_string()); } let now = Utc::now(); - let minute_start = now - .with_second(0) - .and_then(|dt| dt.with_nanosecond(0)) - .ok_or_else(|| "时间窗口计算失败".to_string())?; - let hour_start = minute_start - .with_minute(0) - .ok_or_else(|| "时间窗口计算失败".to_string())?; - let day_start = hour_start - .date_naive() - .and_hms_opt(0, 0, 0) - .ok_or_else(|| "时间窗口计算失败".to_string())? - .and_utc(); - - let minute_count = models::local_api::fetch_request_count( - &svc_ctx.db, - refreshed_key.id, - payload.permission_code.as_str(), + let minute_key = now.format("%Y%m%d%H%M").to_string(); + let hour_key = now.format("%Y%m%d%H").to_string(); + + let minute_count = get_local_api_rate_count( + svc_ctx, + api_key.id, + Some(payload.permission_code.as_str()), "minute", - minute_start, + &minute_key, ) .await .map_err(|e| e.to_string())?; - if minute_count >= permission.rate_limit_per_minute { + if minute_count >= i64::from(permission.rate_limit_per_minute) { return Err("接口每分钟调用次数已达上限".to_string()); } - let hour_count = models::local_api::fetch_request_count( - &svc_ctx.db, - refreshed_key.id, - payload.permission_code.as_str(), + let hour_count = get_local_api_rate_count( + svc_ctx, + api_key.id, + Some(payload.permission_code.as_str()), "hour", - hour_start, + &hour_key, ) .await .map_err(|e| e.to_string())?; - if hour_count >= permission.rate_limit_per_hour { + if hour_count >= i64::from(permission.rate_limit_per_hour) { return Err("接口每小时调用次数已达上限".to_string()); } - models::local_api::increment_request_counter( - &svc_ctx.db, - refreshed_key.id, - payload.permission_code.as_str(), + increment_local_api_rate_count( + svc_ctx, + api_key.id, + Some(payload.permission_code.as_str()), "minute", - minute_start, + &minute_key, ) .await .map_err(|e| e.to_string())?; - models::local_api::increment_request_counter( - &svc_ctx.db, - refreshed_key.id, - payload.permission_code.as_str(), + increment_local_api_rate_count( + svc_ctx, + api_key.id, + Some(payload.permission_code.as_str()), "hour", - hour_start, - ) - .await - .map_err(|e| e.to_string())?; - models::local_api::increment_request_counter( - &svc_ctx.db, - refreshed_key.id, - payload.permission_code.as_str(), - "day", - day_start, + &hour_key, ) .await .map_err(|e| e.to_string())?; - models::local_api::increment_api_key_usage(&svc_ctx.db, refreshed_key.id, now) + increment_local_api_rate_count(svc_ctx, api_key.id, None, "day", &day_key) .await .map_err(|e| e.to_string())?; Ok(ValidateLocalApiKeyDto { valid: true, permission_code: definition.permission_code, - requests_today: refreshed_key.requests_today + 1, - daily_limit: refreshed_key.daily_limit, + requests_today: (day_count + 1) as i32, + daily_limit: api_key.daily_limit, rate_limit_per_minute: permission.rate_limit_per_minute, rate_limit_per_hour: permission.rate_limit_per_hour, }) @@ -284,6 +346,19 @@ pub async fn init_local_api_for_user_service( ) .await .map_err(|e| e.to_string())?; + set_local_api_key_cache( + svc_ctx, + &key_hash, + &LocalApiKeyCache { + id: created_key.id, + user_uuid: created_key.user_uuid, + is_active: created_key.is_active, + expires_at: created_key.expires_at, + daily_limit: created_key.daily_limit, + }, + ) + .await + .map_err(|e| e.to_string())?; ensure_default_permissions(svc_ctx, created_key.id).await } @@ -303,6 +378,25 @@ async fn ensure_default_permissions(svc_ctx: &SvcCtx, api_key_id: i32) -> Result ) .await .map_err(|e| e.to_string())?; + set_local_api_permission_definition_cache( + svc_ctx, + definition.permission_code.as_str(), + &definition, + ) + .await + .map_err(|e| e.to_string())?; + set_local_api_permission_cache( + svc_ctx, + api_key_id, + definition.permission_code.as_str(), + &LocalApiPermissionCache { + is_enabled: true, + rate_limit_per_minute: definition.default_rate_limit_per_minute, + rate_limit_per_hour: definition.default_rate_limit_per_hour, + }, + ) + .await + .map_err(|e| e.to_string())?; } Ok(()) @@ -345,6 +439,19 @@ async fn rotate_local_api_key_for_user( ) .await .map_err(|e| e.to_string())?; + set_local_api_key_cache( + svc_ctx, + &key_hash, + &LocalApiKeyCache { + id: created_key.id, + user_uuid: created_key.user_uuid, + is_active: created_key.is_active, + expires_at: created_key.expires_at, + daily_limit: created_key.daily_limit, + }, + ) + .await + .map_err(|e| e.to_string())?; ensure_default_permissions(svc_ctx, created_key.id).await?; From 71a69aeb08251195f147e3caf309d1212733f84f Mon Sep 17 00:00:00 2001 From: lius-new Date: Wed, 6 May 2026 12:14:53 +0800 Subject: [PATCH 33/60] refactor: remove server password encryption flow --- ...001_replace_encrypted_password_columns.sql | 11 + src/dto/accounts.rs | 70 +- src/dto/environments.rs | 598 +-- src/dto/proxies.rs | 86 +- src/models/accounts.rs | 498 +-- src/models/environments.rs | 3221 +++++++++-------- src/models/proxies.rs | 604 ++-- src/models/proxy_visible_teams.rs | 322 +- src/services/accounts.rs | 363 +- src/services/environments.rs | 2535 ++++++------- src/services/proxies.rs | 383 +- src/utils.rs | 84 +- src/utils/encryption.rs | 109 - 13 files changed, 4368 insertions(+), 4516 deletions(-) create mode 100644 migrations/20260506000001_replace_encrypted_password_columns.sql delete mode 100644 src/utils/encryption.rs diff --git a/migrations/20260506000001_replace_encrypted_password_columns.sql b/migrations/20260506000001_replace_encrypted_password_columns.sql new file mode 100644 index 0000000..62dea71 --- /dev/null +++ b/migrations/20260506000001_replace_encrypted_password_columns.sql @@ -0,0 +1,11 @@ +ALTER TABLE public.proxies + DROP COLUMN IF EXISTS password_encrypted, + ADD COLUMN password TEXT; + +COMMENT ON COLUMN public.proxies.password IS '代理密码(明文存储)'; + +ALTER TABLE public.platform_accounts + DROP COLUMN IF EXISTS password_encrypted, + ADD COLUMN password TEXT; + +COMMENT ON COLUMN public.platform_accounts.password IS '平台账号密码(明文存储)'; diff --git a/src/dto/accounts.rs b/src/dto/accounts.rs index 86219df..4b34f9e 100644 --- a/src/dto/accounts.rs +++ b/src/dto/accounts.rs @@ -1,35 +1,35 @@ -use chrono::{DateTime, Utc}; -use serde::{Deserialize, Serialize}; -use sqlx::FromRow; -use uuid::Uuid; - -/// 平台账号 DTO -#[derive(Debug, Clone, FromRow, Serialize, Deserialize)] -pub struct PlatformAccountDto { - pub id: i32, - pub uuid: Uuid, - pub user_uuid: Uuid, - pub team_uuid: Option, - pub platform_url: String, - pub platform_name: Option, - pub account: String, - pub password_encrypted: Option, - pub status: String, - pub remark: Option, - pub usage_count: Option, - pub environments_count: Option, - pub last_used_at: Option>, - pub created_at: DateTime, - pub updated_at: DateTime, - pub deleted_at: Option>, -} - -/// 环境账号关联 DTO -#[derive(Debug, Clone, FromRow, Serialize)] -pub struct EnvironmentAccountDto { - pub id: i32, - pub environment_uuid: Uuid, - pub account_uuid: Uuid, - pub sort_order: Option, - pub created_at: DateTime, -} +use chrono::{DateTime, Utc}; +use serde::{Deserialize, Serialize}; +use sqlx::FromRow; +use uuid::Uuid; + +/// 平台账号 DTO +#[derive(Debug, Clone, FromRow, Serialize, Deserialize)] +pub struct PlatformAccountDto { + pub id: i32, + pub uuid: Uuid, + pub user_uuid: Uuid, + pub team_uuid: Option, + pub platform_url: String, + pub platform_name: Option, + pub account: String, + pub password: Option, + pub status: String, + pub remark: Option, + pub usage_count: Option, + pub environments_count: Option, + pub last_used_at: Option>, + pub created_at: DateTime, + pub updated_at: DateTime, + pub deleted_at: Option>, +} + +/// 环境账号关联 DTO +#[derive(Debug, Clone, FromRow, Serialize)] +pub struct EnvironmentAccountDto { + pub id: i32, + pub environment_uuid: Uuid, + pub account_uuid: Uuid, + pub sort_order: Option, + pub created_at: DateTime, +} diff --git a/src/dto/environments.rs b/src/dto/environments.rs index f08cfc0..f8b2bfb 100644 --- a/src/dto/environments.rs +++ b/src/dto/environments.rs @@ -1,299 +1,299 @@ -use chrono::{DateTime, Utc}; -use serde::{Deserialize, Serialize}; -use sqlx::FromRow; -use uuid::Uuid; - -/// 分组 DTO -#[derive(Debug, Clone, FromRow, Serialize)] -pub struct GroupDto { - pub id: i32, - pub uuid: Uuid, - pub workspace_uuid: Uuid, - pub team_uuid: Uuid, - pub team_name: Option, - pub name: String, - pub description: Option, - pub sort_order: Option, - pub created_by: Option, - pub created_by_name: Option, - pub environments_count: Option, - pub created_at: DateTime, - pub updated_at: DateTime, - pub deleted_at: Option>, -} - -/// 标签 DTO -#[derive(Debug, Clone, FromRow, Serialize, Deserialize)] -pub struct TagDto { - pub id: i32, - pub uuid: Uuid, - pub user_uuid: Uuid, - pub team_uuid: Option, - pub name: String, - pub color: Option, - pub sort_order: Option, - pub environments_count: Option, - pub created_at: DateTime, - pub updated_at: DateTime, - pub deleted_at: Option>, -} - -/// 环境 DTO -#[derive(Debug, Clone, FromRow, Serialize, Deserialize)] -pub struct EnvironmentDto { - pub id: i32, - pub uuid: Uuid, - pub workspace_uuid: Uuid, - pub user_uuid: Uuid, - pub team_uuid: Uuid, - pub name: String, - pub description: Option, - pub status: String, - pub group_uuid: Option, - pub proxy_uuid: Option, - pub system_info: Option, - pub kernel_info: Option, - pub fingerprint_summary: Option, - pub last_opened_at: Option>, - pub created_at: DateTime, - pub updated_at: DateTime, - pub deleted_at: Option>, -} - -/// 环境列表行(基础查询结果) -#[derive(Debug, Clone, sqlx::FromRow)] -pub struct EnvironmentRowDto { - pub id: i32, - pub uuid: Uuid, - pub workspace_uuid: Uuid, - pub user_uuid: Uuid, - pub team_uuid: Uuid, - pub name: String, - pub description: Option, - pub status: String, - pub system_info: Option, - pub kernel_info: Option, - pub fingerprint_summary: Option, - pub group_uuid: Option, - pub proxy_uuid: Option, - pub last_opened_at: Option>, - pub created_at: DateTime, - pub updated_at: DateTime, -} - -/// 环境标签关联行(用于批量查询) -#[derive(Debug, Clone, sqlx::FromRow)] -pub struct EnvironmentTagRowDto { - pub environment_uuid: Uuid, - pub tag_id: i32, - pub tag_uuid: Uuid, - pub tag_name: String, - pub tag_color: Option, - pub tag_sort_order: Option, - pub tag_user_uuid: Uuid, - pub tag_team_uuid: Option, - pub tag_environments_count: Option, - pub tag_created_at: chrono::DateTime, - pub tag_updated_at: chrono::DateTime, - pub tag_deleted_at: Option>, -} - -/// 环境账号关联行(用于批量查询) -#[derive(Debug, Clone, sqlx::FromRow)] -pub struct EnvironmentAccountRowDto { - pub environment_uuid: Uuid, - pub account_id: i32, - pub account_uuid: Uuid, - pub platform_url: String, - pub platform_name: Option, - pub account: String, - pub account_status: String, - pub remark: Option, -} - -/// 分组查询行(用于批量查询) -#[derive(Debug, Clone, sqlx::FromRow)] -pub struct GroupRowDto { - pub id: i32, - pub uuid: Uuid, - pub name: String, - pub description: Option, - pub sort_order: Option, -} - -/// 代理查询行(用于批量查询,排除敏感数据) -#[derive(Debug, Clone, sqlx::FromRow)] -pub struct ProxyRowDto { - pub id: i32, - pub uuid: Uuid, - pub name: String, - pub host: String, - pub port: i32, - pub proxy_type: String, - pub username: Option, - pub password_encrypted: Option, - pub country: Option, - pub city: Option, - pub status: String, - pub latency: Option, - pub last_check_ip: Option, -} - -/// 代理摘要 DTO(用于环境列表) -#[derive(Debug, Clone, Serialize, Deserialize)] -pub struct ProxySummaryDto { - pub id: i32, - pub uuid: Uuid, - pub name: String, - pub host: String, - pub port: i32, - pub proxy_type: String, - pub username: Option, - pub password_encrypted: Option, - pub country: Option, - pub city: Option, - pub status: String, - pub latency: Option, - pub last_check_ip: Option, -} - -/// 分组摘要 DTO(用于环境列表) -#[derive(Debug, Clone, Serialize, Deserialize)] -pub struct GroupSummaryDto { - pub id: i32, - pub uuid: Uuid, - pub name: String, - pub description: Option, - pub sort_order: Option, -} - -/// 标签摘要 DTO(用于环境列表) -#[derive(Debug, Clone, Serialize)] -pub struct TagSummaryDto { - pub id: i32, - pub uuid: Uuid, - pub name: String, - pub color: Option, - pub sort_order: Option, -} - -/// 账号摘要 DTO(用于环境列表,排除敏感数据) -#[derive(Debug, Clone, Serialize)] -pub struct AccountSummaryDto { - pub id: i32, - pub uuid: Uuid, - pub platform_url: String, - pub platform_name: Option, - pub account: String, - pub status: String, - pub remark: Option, -} - -/// 环境列表项 DTO(包含完整关联数据) -#[derive(Debug, Clone, Serialize)] -pub struct EnvironmentListItemDto { - // 基础信息 - pub id: i32, - pub uuid: Uuid, - pub name: String, - pub description: Option, - pub status: String, - pub system_info: Option, - pub kernel_info: Option, - pub fingerprint_summary: Option, - pub last_opened_at: Option>, - pub created_at: DateTime, - pub updated_at: DateTime, - // 分组详情(完整对象) - pub group: Option, - // 代理详情(完整对象) - pub proxy: Option, - // 标签列表(完整对象列表,与环境详情接口保持一致) - pub tags: Vec, - // 账号列表(完整对象列表) - pub accounts: Vec, - // 扩展列表(插件列表) - pub extensions: Vec, -} - -/// 扩展摘要 DTO(用于环境列表) -#[derive(Debug, Clone, Serialize, Deserialize)] -pub struct ExtensionSummaryDto { - pub extension_id: String, - pub name: String, - pub version: String, - pub icon_url: Option, - pub download_url: Option, - pub hash: Option, - pub scope: String, // user, team, group-personal, group-team -} - -/// 环境配置 DTO -#[derive(Debug, Clone, FromRow, Serialize, Deserialize)] -pub struct EnvironmentConfigDto { - pub id: i32, - pub environment_uuid: Uuid, - pub window_info: serde_json::Value, - pub basic_settings: serde_json::Value, - pub fingerprint_settings: serde_json::Value, - pub device_settings: serde_json::Value, - pub preference_settings: serde_json::Value, - pub project_metadata: serde_json::Value, - pub created_at: DateTime, - pub updated_at: DateTime, -} - -/// 环境标签关联 DTO -#[derive(Debug, Clone, FromRow)] -pub struct EnvironmentTagDto { - pub id: i32, - pub environment_uuid: Uuid, - pub tag_uuid: Uuid, - pub created_at: DateTime, -} - -/// 环境 URL DTO -#[derive(Debug, Clone, FromRow, Serialize)] -pub struct EnvironmentUrlDto { - pub id: i32, - pub environment_uuid: Uuid, - pub url: String, - pub title: Option, - pub sort_order: Option, - pub created_at: DateTime, -} - -/// 环境 Cookie DTO -#[derive(Debug, Clone, FromRow, Serialize)] -pub struct EnvironmentCookieDto { - pub id: i32, - pub environment_uuid: Uuid, - pub domain: String, - pub name: String, - pub value: String, - pub path: Option, - pub expires_at: Option>, - pub http_only: Option, - pub secure: Option, - pub same_site: Option, - pub created_at: DateTime, -} - -/// 模板 DTO -#[derive(Debug, Clone, FromRow, Serialize)] -pub struct TemplateDto { - pub id: i32, - pub uuid: Uuid, - pub user_uuid: Uuid, - pub team_uuid: Option, - pub name: String, - pub description: Option, - pub is_public: Option, - pub system_info: Option, - pub kernel_info: Option, - pub config_json: serde_json::Value, - pub usage_count: Option, - pub created_at: DateTime, - pub updated_at: DateTime, - pub deleted_at: Option>, -} +use chrono::{DateTime, Utc}; +use serde::{Deserialize, Serialize}; +use sqlx::FromRow; +use uuid::Uuid; + +/// 分组 DTO +#[derive(Debug, Clone, FromRow, Serialize)] +pub struct GroupDto { + pub id: i32, + pub uuid: Uuid, + pub workspace_uuid: Uuid, + pub team_uuid: Uuid, + pub team_name: Option, + pub name: String, + pub description: Option, + pub sort_order: Option, + pub created_by: Option, + pub created_by_name: Option, + pub environments_count: Option, + pub created_at: DateTime, + pub updated_at: DateTime, + pub deleted_at: Option>, +} + +/// 标签 DTO +#[derive(Debug, Clone, FromRow, Serialize, Deserialize)] +pub struct TagDto { + pub id: i32, + pub uuid: Uuid, + pub user_uuid: Uuid, + pub team_uuid: Option, + pub name: String, + pub color: Option, + pub sort_order: Option, + pub environments_count: Option, + pub created_at: DateTime, + pub updated_at: DateTime, + pub deleted_at: Option>, +} + +/// 环境 DTO +#[derive(Debug, Clone, FromRow, Serialize, Deserialize)] +pub struct EnvironmentDto { + pub id: i32, + pub uuid: Uuid, + pub workspace_uuid: Uuid, + pub user_uuid: Uuid, + pub team_uuid: Uuid, + pub name: String, + pub description: Option, + pub status: String, + pub group_uuid: Option, + pub proxy_uuid: Option, + pub system_info: Option, + pub kernel_info: Option, + pub fingerprint_summary: Option, + pub last_opened_at: Option>, + pub created_at: DateTime, + pub updated_at: DateTime, + pub deleted_at: Option>, +} + +/// 环境列表行(基础查询结果) +#[derive(Debug, Clone, sqlx::FromRow)] +pub struct EnvironmentRowDto { + pub id: i32, + pub uuid: Uuid, + pub workspace_uuid: Uuid, + pub user_uuid: Uuid, + pub team_uuid: Uuid, + pub name: String, + pub description: Option, + pub status: String, + pub system_info: Option, + pub kernel_info: Option, + pub fingerprint_summary: Option, + pub group_uuid: Option, + pub proxy_uuid: Option, + pub last_opened_at: Option>, + pub created_at: DateTime, + pub updated_at: DateTime, +} + +/// 环境标签关联行(用于批量查询) +#[derive(Debug, Clone, sqlx::FromRow)] +pub struct EnvironmentTagRowDto { + pub environment_uuid: Uuid, + pub tag_id: i32, + pub tag_uuid: Uuid, + pub tag_name: String, + pub tag_color: Option, + pub tag_sort_order: Option, + pub tag_user_uuid: Uuid, + pub tag_team_uuid: Option, + pub tag_environments_count: Option, + pub tag_created_at: chrono::DateTime, + pub tag_updated_at: chrono::DateTime, + pub tag_deleted_at: Option>, +} + +/// 环境账号关联行(用于批量查询) +#[derive(Debug, Clone, sqlx::FromRow)] +pub struct EnvironmentAccountRowDto { + pub environment_uuid: Uuid, + pub account_id: i32, + pub account_uuid: Uuid, + pub platform_url: String, + pub platform_name: Option, + pub account: String, + pub account_status: String, + pub remark: Option, +} + +/// 分组查询行(用于批量查询) +#[derive(Debug, Clone, sqlx::FromRow)] +pub struct GroupRowDto { + pub id: i32, + pub uuid: Uuid, + pub name: String, + pub description: Option, + pub sort_order: Option, +} + +/// 代理查询行(用于批量查询,排除敏感数据) +#[derive(Debug, Clone, sqlx::FromRow)] +pub struct ProxyRowDto { + pub id: i32, + pub uuid: Uuid, + pub name: String, + pub host: String, + pub port: i32, + pub proxy_type: String, + pub username: Option, + pub password: Option, + pub country: Option, + pub city: Option, + pub status: String, + pub latency: Option, + pub last_check_ip: Option, +} + +/// 代理摘要 DTO(用于环境列表) +#[derive(Debug, Clone, Serialize, Deserialize)] +pub struct ProxySummaryDto { + pub id: i32, + pub uuid: Uuid, + pub name: String, + pub host: String, + pub port: i32, + pub proxy_type: String, + pub username: Option, + pub password: Option, + pub country: Option, + pub city: Option, + pub status: String, + pub latency: Option, + pub last_check_ip: Option, +} + +/// 分组摘要 DTO(用于环境列表) +#[derive(Debug, Clone, Serialize, Deserialize)] +pub struct GroupSummaryDto { + pub id: i32, + pub uuid: Uuid, + pub name: String, + pub description: Option, + pub sort_order: Option, +} + +/// 标签摘要 DTO(用于环境列表) +#[derive(Debug, Clone, Serialize)] +pub struct TagSummaryDto { + pub id: i32, + pub uuid: Uuid, + pub name: String, + pub color: Option, + pub sort_order: Option, +} + +/// 账号摘要 DTO(用于环境列表,排除敏感数据) +#[derive(Debug, Clone, Serialize)] +pub struct AccountSummaryDto { + pub id: i32, + pub uuid: Uuid, + pub platform_url: String, + pub platform_name: Option, + pub account: String, + pub status: String, + pub remark: Option, +} + +/// 环境列表项 DTO(包含完整关联数据) +#[derive(Debug, Clone, Serialize)] +pub struct EnvironmentListItemDto { + // 基础信息 + pub id: i32, + pub uuid: Uuid, + pub name: String, + pub description: Option, + pub status: String, + pub system_info: Option, + pub kernel_info: Option, + pub fingerprint_summary: Option, + pub last_opened_at: Option>, + pub created_at: DateTime, + pub updated_at: DateTime, + // 分组详情(完整对象) + pub group: Option, + // 代理详情(完整对象) + pub proxy: Option, + // 标签列表(完整对象列表,与环境详情接口保持一致) + pub tags: Vec, + // 账号列表(完整对象列表) + pub accounts: Vec, + // 扩展列表(插件列表) + pub extensions: Vec, +} + +/// 扩展摘要 DTO(用于环境列表) +#[derive(Debug, Clone, Serialize, Deserialize)] +pub struct ExtensionSummaryDto { + pub extension_id: String, + pub name: String, + pub version: String, + pub icon_url: Option, + pub download_url: Option, + pub hash: Option, + pub scope: String, // user, team, group-personal, group-team +} + +/// 环境配置 DTO +#[derive(Debug, Clone, FromRow, Serialize, Deserialize)] +pub struct EnvironmentConfigDto { + pub id: i32, + pub environment_uuid: Uuid, + pub window_info: serde_json::Value, + pub basic_settings: serde_json::Value, + pub fingerprint_settings: serde_json::Value, + pub device_settings: serde_json::Value, + pub preference_settings: serde_json::Value, + pub project_metadata: serde_json::Value, + pub created_at: DateTime, + pub updated_at: DateTime, +} + +/// 环境标签关联 DTO +#[derive(Debug, Clone, FromRow)] +pub struct EnvironmentTagDto { + pub id: i32, + pub environment_uuid: Uuid, + pub tag_uuid: Uuid, + pub created_at: DateTime, +} + +/// 环境 URL DTO +#[derive(Debug, Clone, FromRow, Serialize)] +pub struct EnvironmentUrlDto { + pub id: i32, + pub environment_uuid: Uuid, + pub url: String, + pub title: Option, + pub sort_order: Option, + pub created_at: DateTime, +} + +/// 环境 Cookie DTO +#[derive(Debug, Clone, FromRow, Serialize)] +pub struct EnvironmentCookieDto { + pub id: i32, + pub environment_uuid: Uuid, + pub domain: String, + pub name: String, + pub value: String, + pub path: Option, + pub expires_at: Option>, + pub http_only: Option, + pub secure: Option, + pub same_site: Option, + pub created_at: DateTime, +} + +/// 模板 DTO +#[derive(Debug, Clone, FromRow, Serialize)] +pub struct TemplateDto { + pub id: i32, + pub uuid: Uuid, + pub user_uuid: Uuid, + pub team_uuid: Option, + pub name: String, + pub description: Option, + pub is_public: Option, + pub system_info: Option, + pub kernel_info: Option, + pub config_json: serde_json::Value, + pub usage_count: Option, + pub created_at: DateTime, + pub updated_at: DateTime, + pub deleted_at: Option>, +} diff --git a/src/dto/proxies.rs b/src/dto/proxies.rs index 3ea2544..6a9da79 100644 --- a/src/dto/proxies.rs +++ b/src/dto/proxies.rs @@ -1,43 +1,43 @@ -use chrono::{DateTime, Utc}; -use serde::Serialize; -use sqlx::FromRow; -use uuid::Uuid; - -/// 代理 DTO -#[derive(Debug, Clone, FromRow, Serialize)] -pub struct ProxyDto { - pub id: i32, - pub uuid: Uuid, - pub workspace_uuid: Uuid, - pub owner_uuid: Uuid, - pub name: String, - pub host: String, - pub port: i32, - pub proxy_type: String, - pub username: Option, - pub password_encrypted: Option, - pub ssh_key_encrypted: Option, - pub ssh_passphrase_encrypted: Option, - pub country: Option, - pub city: Option, - pub status: String, - pub latency: Option, - pub last_check_ip: Option, - pub last_checked_at: Option>, - pub environments_count: Option, - pub created_at: DateTime, - pub updated_at: DateTime, - pub deleted_at: Option>, -} - -/// 代理健康检查 DTO -#[derive(Debug, Clone, FromRow)] -pub struct ProxyHealthCheckDto { - pub id: i64, - pub proxy_uuid: Uuid, - pub status: String, - pub latency: Option, - pub ip_address: Option, - pub error_message: Option, - pub checked_at: DateTime, -} +use chrono::{DateTime, Utc}; +use serde::Serialize; +use sqlx::FromRow; +use uuid::Uuid; + +/// 代理 DTO +#[derive(Debug, Clone, FromRow, Serialize)] +pub struct ProxyDto { + pub id: i32, + pub uuid: Uuid, + pub workspace_uuid: Uuid, + pub owner_uuid: Uuid, + pub name: String, + pub host: String, + pub port: i32, + pub proxy_type: String, + pub username: Option, + pub password: Option, + pub ssh_key_encrypted: Option, + pub ssh_passphrase_encrypted: Option, + pub country: Option, + pub city: Option, + pub status: String, + pub latency: Option, + pub last_check_ip: Option, + pub last_checked_at: Option>, + pub environments_count: Option, + pub created_at: DateTime, + pub updated_at: DateTime, + pub deleted_at: Option>, +} + +/// 代理健康检查 DTO +#[derive(Debug, Clone, FromRow)] +pub struct ProxyHealthCheckDto { + pub id: i64, + pub proxy_uuid: Uuid, + pub status: String, + pub latency: Option, + pub ip_address: Option, + pub error_message: Option, + pub checked_at: DateTime, +} diff --git a/src/models/accounts.rs b/src/models/accounts.rs index ec72e37..a7ca843 100644 --- a/src/models/accounts.rs +++ b/src/models/accounts.rs @@ -1,41 +1,41 @@ -use sqlx::{Error, Pool, Postgres}; -use uuid::Uuid; - -use crate::dto::PlatformAccountDto; - -/// 创建平台账号 -pub async fn insert_platform_account( - pool: &Pool, - user_uuid: Uuid, - team_uuid: Option, - platform_url: &str, - platform_name: Option<&str>, - account: &str, - password_encrypted: Option<&str>, - remark: Option<&str>, -) -> Result { - let uuid: Uuid = sqlx::query_scalar( - r#" - INSERT INTO platform_accounts (user_uuid, team_uuid, platform_url, platform_name, - account, password_encrypted, remark) - VALUES ($1, $2, $3, $4, $5, $6, $7) - RETURNING uuid; - "#, - ) - .bind(user_uuid) - .bind(team_uuid) - .bind(platform_url) - .bind(platform_name) - .bind(account) - .bind(password_encrypted) - .bind(remark) - .fetch_one(pool) - .await?; - - Ok(uuid) -} - -/// 查询平台账号列表 +use sqlx::{Error, Pool, Postgres}; +use uuid::Uuid; + +use crate::dto::PlatformAccountDto; + +/// 创建平台账号 +pub async fn insert_platform_account( + pool: &Pool, + user_uuid: Uuid, + team_uuid: Option, + platform_url: &str, + platform_name: Option<&str>, + account: &str, + password: Option<&str>, + remark: Option<&str>, +) -> Result { + let uuid: Uuid = sqlx::query_scalar( + r#" + INSERT INTO platform_accounts (user_uuid, team_uuid, platform_url, platform_name, + account, password, remark) + VALUES ($1, $2, $3, $4, $5, $6, $7) + RETURNING uuid; + "#, + ) + .bind(user_uuid) + .bind(team_uuid) + .bind(platform_url) + .bind(platform_name) + .bind(account) + .bind(password) + .bind(remark) + .fetch_one(pool) + .await?; + + Ok(uuid) +} + +/// 查询平台账号列表 pub async fn fetch_platform_accounts( pool: &Pool, team_uuid: Option, @@ -51,7 +51,7 @@ pub async fn fetch_platform_accounts( let recs = sqlx::query_as::<_, PlatformAccountDto>( r#" SELECT pa.id, pa.uuid, pa.user_uuid, pa.team_uuid, pa.platform_url, pa.platform_name, - pa.account, pa.password_encrypted, pa.status, pa.remark, pa.usage_count, + pa.account, pa.password, pa.status, pa.remark, pa.usage_count, (SELECT COUNT(*) FROM environment_accounts ea WHERE ea.account_uuid = pa.uuid) AS environments_count, pa.last_used_at, pa.created_at, pa.updated_at, pa.deleted_at FROM platform_accounts pa @@ -77,13 +77,13 @@ pub async fn fetch_platform_accounts( .bind(status) .bind(limit) .bind(offset) - .fetch_all(pool) - .await?; - - Ok(recs) -} - -/// 查询平台账号总数 + .fetch_all(pool) + .await?; + + Ok(recs) +} + +/// 查询平台账号总数 pub async fn fetch_platform_accounts_count( pool: &Pool, team_uuid: Option, @@ -116,206 +116,206 @@ pub async fn fetch_platform_accounts_count( .bind(platform_name) .bind(status) .fetch_one(pool) - .await?; - - Ok(count) -} - -/// 根据 UUID 查询平台账号 -pub async fn fetch_platform_account_by_uuid( - pool: &Pool, - account_uuid: Uuid, -) -> Result, Error> { - let rec = sqlx::query_as::<_, PlatformAccountDto>( - r#" - SELECT pa.id, pa.uuid, pa.user_uuid, pa.team_uuid, pa.platform_url, pa.platform_name, - pa.account, pa.password_encrypted, pa.status, pa.remark, pa.usage_count, - (SELECT COUNT(*) FROM environment_accounts ea WHERE ea.account_uuid = pa.uuid) AS environments_count, - pa.last_used_at, pa.created_at, pa.updated_at, pa.deleted_at - FROM platform_accounts pa - WHERE pa.uuid = $1 AND pa.deleted_at IS NULL - "#, - ) - .bind(account_uuid) - .fetch_optional(pool) - .await?; - - Ok(rec) -} - -/// 更新平台账号 -pub async fn update_platform_account( - pool: &Pool, - account_uuid: Uuid, - platform_url: Option<&str>, - platform_name: Option<&str>, - account: Option<&str>, - password_encrypted: Option<&str>, - remark: Option<&str>, - status: Option<&str>, -) -> Result<(), Error> { - sqlx::query( - r#" - UPDATE platform_accounts - SET platform_url = COALESCE($1, platform_url), - platform_name = COALESCE($2, platform_name), - account = COALESCE($3, account), - password_encrypted = COALESCE($4, password_encrypted), - remark = COALESCE($5, remark), - status = COALESCE($6, status) - WHERE uuid = $7 AND deleted_at IS NULL - "#, - ) - .bind(platform_url) - .bind(platform_name) - .bind(account) - .bind(password_encrypted) - .bind(remark) - .bind(status) - .bind(account_uuid) - .execute(pool) - .await?; - - Ok(()) -} - -/// 增加账号使用次数 -pub async fn increment_account_usage( - pool: &Pool, - account_uuid: Uuid, -) -> Result<(), Error> { - sqlx::query( - r#" - UPDATE platform_accounts - SET usage_count = usage_count + 1, last_used_at = CURRENT_TIMESTAMP - WHERE uuid = $1 AND deleted_at IS NULL - "#, - ) - .bind(account_uuid) - .execute(pool) - .await?; - - Ok(()) -} - -/// 软删除平台账号 -pub async fn delete_platform_account( - pool: &Pool, - account_uuid: Uuid, -) -> Result<(), Error> { - sqlx::query( - r#" - UPDATE platform_accounts SET deleted_at = CURRENT_TIMESTAMP - WHERE uuid = $1 AND deleted_at IS NULL - "#, - ) - .bind(account_uuid) - .execute(pool) - .await?; - - Ok(()) -} - -/// 批量软删除平台账号 -pub async fn batch_delete_platform_accounts( - pool: &Pool, - account_uuids: &[Uuid], -) -> Result { - let result = sqlx::query( - r#" - UPDATE platform_accounts SET deleted_at = CURRENT_TIMESTAMP - WHERE uuid = ANY($1) AND deleted_at IS NULL - "#, - ) - .bind(account_uuids) - .execute(pool) - .await?; - - Ok(result.rows_affected()) -} - -// ============ Environment Accounts ============ - -/// 关联环境和账号 -pub async fn insert_environment_account( - pool: &Pool, - env_uuid: Uuid, - account_uuid: Uuid, - sort_order: i32, -) -> Result<(), Error> { - sqlx::query( - r#" - INSERT INTO environment_accounts (environment_uuid, account_uuid, sort_order) - VALUES ($1, $2, $3) - ON CONFLICT (environment_uuid, account_uuid) DO UPDATE SET sort_order = $3; - "#, - ) - .bind(env_uuid) - .bind(account_uuid) - .bind(sort_order) - .execute(pool) - .await?; - - Ok(()) -} - -/// 移除环境和账号的关联 -pub async fn remove_environment_account( - pool: &Pool, - env_uuid: Uuid, - account_uuid: Uuid, -) -> Result<(), Error> { - sqlx::query( - r#" - DELETE FROM environment_accounts - WHERE environment_uuid = $1 AND account_uuid = $2; - "#, - ) - .bind(env_uuid) - .bind(account_uuid) - .execute(pool) - .await?; - - Ok(()) -} - -/// 清空环境的所有账号关联 -pub async fn clear_environment_accounts( - pool: &Pool, - env_uuid: Uuid, -) -> Result<(), Error> { - sqlx::query( - r#" - DELETE FROM environment_accounts WHERE environment_uuid = $1; - "#, - ) - .bind(env_uuid) - .execute(pool) - .await?; - - Ok(()) -} - -/// 查询环境关联的所有账号 -pub async fn fetch_environment_accounts( - pool: &Pool, - env_uuid: Uuid, -) -> Result, Error> { - let recs = sqlx::query_as::<_, PlatformAccountDto>( - r#" - SELECT pa.id, pa.uuid, pa.user_uuid, pa.team_uuid, pa.platform_url, pa.platform_name, - pa.account, pa.password_encrypted, pa.status, pa.remark, pa.usage_count, - (SELECT COUNT(*) FROM environment_accounts ea2 WHERE ea2.account_uuid = pa.uuid) AS environments_count, - pa.last_used_at, pa.created_at, pa.updated_at, pa.deleted_at - FROM platform_accounts pa - INNER JOIN environment_accounts ea ON pa.uuid = ea.account_uuid - WHERE ea.environment_uuid = $1 AND pa.deleted_at IS NULL - ORDER BY ea.sort_order - "#, - ) - .bind(env_uuid) - .fetch_all(pool) - .await?; - - Ok(recs) -} + .await?; + + Ok(count) +} + +/// 根据 UUID 查询平台账号 +pub async fn fetch_platform_account_by_uuid( + pool: &Pool, + account_uuid: Uuid, +) -> Result, Error> { + let rec = sqlx::query_as::<_, PlatformAccountDto>( + r#" + SELECT pa.id, pa.uuid, pa.user_uuid, pa.team_uuid, pa.platform_url, pa.platform_name, + pa.account, pa.password, pa.status, pa.remark, pa.usage_count, + (SELECT COUNT(*) FROM environment_accounts ea WHERE ea.account_uuid = pa.uuid) AS environments_count, + pa.last_used_at, pa.created_at, pa.updated_at, pa.deleted_at + FROM platform_accounts pa + WHERE pa.uuid = $1 AND pa.deleted_at IS NULL + "#, + ) + .bind(account_uuid) + .fetch_optional(pool) + .await?; + + Ok(rec) +} + +/// 更新平台账号 +pub async fn update_platform_account( + pool: &Pool, + account_uuid: Uuid, + platform_url: Option<&str>, + platform_name: Option<&str>, + account: Option<&str>, + password: Option<&str>, + remark: Option<&str>, + status: Option<&str>, +) -> Result<(), Error> { + sqlx::query( + r#" + UPDATE platform_accounts + SET platform_url = COALESCE($1, platform_url), + platform_name = COALESCE($2, platform_name), + account = COALESCE($3, account), + password = COALESCE($4, password), + remark = COALESCE($5, remark), + status = COALESCE($6, status) + WHERE uuid = $7 AND deleted_at IS NULL + "#, + ) + .bind(platform_url) + .bind(platform_name) + .bind(account) + .bind(password) + .bind(remark) + .bind(status) + .bind(account_uuid) + .execute(pool) + .await?; + + Ok(()) +} + +/// 增加账号使用次数 +pub async fn increment_account_usage( + pool: &Pool, + account_uuid: Uuid, +) -> Result<(), Error> { + sqlx::query( + r#" + UPDATE platform_accounts + SET usage_count = usage_count + 1, last_used_at = CURRENT_TIMESTAMP + WHERE uuid = $1 AND deleted_at IS NULL + "#, + ) + .bind(account_uuid) + .execute(pool) + .await?; + + Ok(()) +} + +/// 软删除平台账号 +pub async fn delete_platform_account( + pool: &Pool, + account_uuid: Uuid, +) -> Result<(), Error> { + sqlx::query( + r#" + UPDATE platform_accounts SET deleted_at = CURRENT_TIMESTAMP + WHERE uuid = $1 AND deleted_at IS NULL + "#, + ) + .bind(account_uuid) + .execute(pool) + .await?; + + Ok(()) +} + +/// 批量软删除平台账号 +pub async fn batch_delete_platform_accounts( + pool: &Pool, + account_uuids: &[Uuid], +) -> Result { + let result = sqlx::query( + r#" + UPDATE platform_accounts SET deleted_at = CURRENT_TIMESTAMP + WHERE uuid = ANY($1) AND deleted_at IS NULL + "#, + ) + .bind(account_uuids) + .execute(pool) + .await?; + + Ok(result.rows_affected()) +} + +// ============ Environment Accounts ============ + +/// 关联环境和账号 +pub async fn insert_environment_account( + pool: &Pool, + env_uuid: Uuid, + account_uuid: Uuid, + sort_order: i32, +) -> Result<(), Error> { + sqlx::query( + r#" + INSERT INTO environment_accounts (environment_uuid, account_uuid, sort_order) + VALUES ($1, $2, $3) + ON CONFLICT (environment_uuid, account_uuid) DO UPDATE SET sort_order = $3; + "#, + ) + .bind(env_uuid) + .bind(account_uuid) + .bind(sort_order) + .execute(pool) + .await?; + + Ok(()) +} + +/// 移除环境和账号的关联 +pub async fn remove_environment_account( + pool: &Pool, + env_uuid: Uuid, + account_uuid: Uuid, +) -> Result<(), Error> { + sqlx::query( + r#" + DELETE FROM environment_accounts + WHERE environment_uuid = $1 AND account_uuid = $2; + "#, + ) + .bind(env_uuid) + .bind(account_uuid) + .execute(pool) + .await?; + + Ok(()) +} + +/// 清空环境的所有账号关联 +pub async fn clear_environment_accounts( + pool: &Pool, + env_uuid: Uuid, +) -> Result<(), Error> { + sqlx::query( + r#" + DELETE FROM environment_accounts WHERE environment_uuid = $1; + "#, + ) + .bind(env_uuid) + .execute(pool) + .await?; + + Ok(()) +} + +/// 查询环境关联的所有账号 +pub async fn fetch_environment_accounts( + pool: &Pool, + env_uuid: Uuid, +) -> Result, Error> { + let recs = sqlx::query_as::<_, PlatformAccountDto>( + r#" + SELECT pa.id, pa.uuid, pa.user_uuid, pa.team_uuid, pa.platform_url, pa.platform_name, + pa.account, pa.password, pa.status, pa.remark, pa.usage_count, + (SELECT COUNT(*) FROM environment_accounts ea2 WHERE ea2.account_uuid = pa.uuid) AS environments_count, + pa.last_used_at, pa.created_at, pa.updated_at, pa.deleted_at + FROM platform_accounts pa + INNER JOIN environment_accounts ea ON pa.uuid = ea.account_uuid + WHERE ea.environment_uuid = $1 AND pa.deleted_at IS NULL + ORDER BY ea.sort_order + "#, + ) + .bind(env_uuid) + .fetch_all(pool) + .await?; + + Ok(recs) +} diff --git a/src/models/environments.rs b/src/models/environments.rs index 2721070..4273098 100644 --- a/src/models/environments.rs +++ b/src/models/environments.rs @@ -1,1608 +1,1613 @@ -use sqlx::{Error, Pool, Postgres}; -use uuid::Uuid; - -use crate::dto::{ - EnvironmentAccountRowDto, EnvironmentConfigDto, EnvironmentCookieDto, EnvironmentDto, - EnvironmentRowDto, EnvironmentTagRowDto, EnvironmentUrlDto, GroupDto, GroupRowDto, ProxyRowDto, - TagDto, TemplateDto, -}; -use crate::entitys::CookieInput; - -// ============ Groups ============ - -/// 创建分组 -pub async fn insert_group( - pool: &Pool, - workspace_uuid: Uuid, - team_uuid: Uuid, - name: &str, - description: Option<&str>, - created_by: Uuid, -) -> Result { - let uuid: Uuid = sqlx::query_scalar( - r#" - INSERT INTO groups (workspace_uuid, team_uuid, name, description, created_by) - VALUES ($1, $2, $3, $4, $5) - RETURNING uuid; - "#, - ) - .bind(workspace_uuid) - .bind(team_uuid) - .bind(name) - .bind(description) - .bind(created_by) - .fetch_one(pool) - .await?; - - Ok(uuid) -} - -/// 查询分组列表(工作空间级别) -pub async fn fetch_groups( - pool: &Pool, - workspace_uuid: Uuid, - team_uuid: Uuid, - offset: i64, - limit: i64, -) -> Result, Error> { - let recs = sqlx::query_as::<_, GroupDto>( - r#" - SELECT g.id, g.uuid, g.workspace_uuid, g.team_uuid, t.name AS team_name, - g.name, g.description, g.sort_order, - g.created_by, ui.nickname AS created_by_name, - (SELECT COUNT(*) FROM environments e WHERE e.group_uuid = g.uuid AND e.deleted_at IS NULL) AS environments_count, - g.created_at, g.updated_at, g.deleted_at - FROM groups g - LEFT JOIN teams t ON g.team_uuid = t.uuid - LEFT JOIN user_infos ui ON g.created_by = ui.user_uuid - WHERE g.workspace_uuid = $1 AND g.team_uuid = $2 AND g.deleted_at IS NULL - ORDER BY g.sort_order, g.name - LIMIT $3 OFFSET $4 - "#, - ) - .bind(workspace_uuid) - .bind(team_uuid) - .bind(limit) - .bind(offset) - .fetch_all(pool) - .await?; - - Ok(recs) -} - -/// 根据 UUID 查询分组 -pub async fn fetch_group_by_uuid( - pool: &Pool, - group_uuid: Uuid, -) -> Result, Error> { - let rec = sqlx::query_as::<_, GroupDto>( - r#" - SELECT g.id, g.uuid, g.workspace_uuid, g.team_uuid, t.name AS team_name, - g.name, g.description, g.sort_order, - g.created_by, ui.nickname AS created_by_name, - (SELECT COUNT(*) FROM environments e WHERE e.group_uuid = g.uuid AND e.deleted_at IS NULL) AS environments_count, - g.created_at, g.updated_at, g.deleted_at - FROM groups g - LEFT JOIN teams t ON g.team_uuid = t.uuid - LEFT JOIN user_infos ui ON g.created_by = ui.user_uuid - WHERE g.uuid = $1 AND g.deleted_at IS NULL - "#, - ) - .bind(group_uuid) - .fetch_optional(pool) - .await?; - - Ok(rec) -} - -/// 更新分组 -pub async fn update_group( - pool: &Pool, - group_uuid: Uuid, - name: Option<&str>, - description: Option<&str>, - sort_order: Option, -) -> Result<(), Error> { - sqlx::query( - r#" - UPDATE groups - SET name = COALESCE($1, name), - description = COALESCE($2, description), - sort_order = COALESCE($3, sort_order) - WHERE uuid = $4 AND deleted_at IS NULL - "#, - ) - .bind(name) - .bind(description) - .bind(sort_order) - .bind(group_uuid) - .execute(pool) - .await?; - - Ok(()) -} - -/// 软删除分组 -pub async fn delete_group(pool: &Pool, group_uuid: Uuid) -> Result<(), Error> { - sqlx::query( - r#" - UPDATE groups SET deleted_at = CURRENT_TIMESTAMP - WHERE uuid = $1 AND deleted_at IS NULL - "#, - ) - .bind(group_uuid) - .execute(pool) - .await?; - - Ok(()) -} - -// ============ Tags ============ - -/// 创建标签 -pub async fn insert_tag( - pool: &Pool, - user_uuid: Uuid, - team_uuid: Option, - name: &str, - color: Option<&str>, -) -> Result { - let uuid: Uuid = sqlx::query_scalar( - r#" - INSERT INTO tags (user_uuid, team_uuid, name, color) - VALUES ($1, $2, $3, $4) - RETURNING uuid; - "#, - ) - .bind(user_uuid) - .bind(team_uuid) - .bind(name) - .bind(color.unwrap_or("gray")) - .fetch_one(pool) - .await?; - - Ok(uuid) -} - -/// 查询标签列表 -pub async fn fetch_tags( - pool: &Pool, - team_uuid: Option, - user_uuid: Uuid, -) -> Result, Error> { - let recs = sqlx::query_as::<_, TagDto>( - r#" - SELECT id, uuid, user_uuid, team_uuid, name, color, sort_order, - environments_count, created_at, updated_at, deleted_at - FROM tags - WHERE (team_uuid = $1 OR (team_uuid IS NULL AND user_uuid = $2)) - AND deleted_at IS NULL - ORDER BY sort_order, name - "#, - ) - .bind(team_uuid) - .bind(user_uuid) - .fetch_all(pool) - .await?; - - Ok(recs) -} - -/// 根据 UUID 查询标签 -pub async fn fetch_tag_by_uuid( - pool: &Pool, - tag_uuid: Uuid, -) -> Result, Error> { - let rec = sqlx::query_as::<_, TagDto>( - r#" - SELECT id, uuid, user_uuid, team_uuid, name, color, sort_order, - environments_count, created_at, updated_at, deleted_at - FROM tags - WHERE uuid = $1 AND deleted_at IS NULL - "#, - ) - .bind(tag_uuid) - .fetch_optional(pool) - .await?; - - Ok(rec) -} - -/// 更新标签 -pub async fn update_tag( - pool: &Pool, - tag_uuid: Uuid, - name: Option<&str>, - color: Option<&str>, - sort_order: Option, -) -> Result<(), Error> { - sqlx::query( - r#" - UPDATE tags - SET name = COALESCE($1, name), - color = COALESCE($2, color), - sort_order = COALESCE($3, sort_order) - WHERE uuid = $4 AND deleted_at IS NULL - "#, - ) - .bind(name) - .bind(color) - .bind(sort_order) - .bind(tag_uuid) - .execute(pool) - .await?; - - Ok(()) -} - -/// 软删除标签 -pub async fn delete_tag(pool: &Pool, tag_uuid: Uuid) -> Result<(), Error> { - sqlx::query( - r#" - UPDATE tags SET deleted_at = CURRENT_TIMESTAMP - WHERE uuid = $1 AND deleted_at IS NULL - "#, - ) - .bind(tag_uuid) - .execute(pool) - .await?; - - Ok(()) -} - -// ============ Environments ============ - -/// 创建环境 -pub async fn insert_environment( - pool: &Pool, - workspace_uuid: Uuid, - user_uuid: Uuid, - team_uuid: Uuid, - name: &str, - description: Option<&str>, - group_uuid: Option, - proxy_uuid: Option, - system_info: Option<&str>, - kernel_info: Option<&str>, -) -> Result { - let uuid: Uuid = sqlx::query_scalar( - r#" - INSERT INTO environments (workspace_uuid, user_uuid, team_uuid, name, description, - group_uuid, proxy_uuid, system_info, kernel_info) - VALUES ($1, $2, $3, $4, $5, $6, $7, $8, $9) - RETURNING uuid; - "#, - ) - .bind(workspace_uuid) - .bind(user_uuid) - .bind(team_uuid) - .bind(name) - .bind(description) - .bind(group_uuid) - .bind(proxy_uuid) - .bind(system_info) - .bind(kernel_info) - .fetch_one(pool) - .await?; - - Ok(uuid) -} - -/// 查询环境列表(基础信息) -pub async fn fetch_environments_base( - pool: &Pool, - workspace_uuid: Uuid, - team_uuid: Uuid, - group_uuid: Option, - status: Option<&str>, - keyword: Option<&str>, - tag_uuids: Option<&[Uuid]>, - offset: i64, - limit: i64, -) -> Result, Error> { - let mut query = String::from( - r#" - SELECT DISTINCT - e.id, e.uuid, e.workspace_uuid, e.user_uuid, e.team_uuid, e.name, e.description, e.status, - e.system_info, e.kernel_info, e.fingerprint_summary, - e.group_uuid, e.proxy_uuid, - e.last_opened_at, e.created_at, e.updated_at - FROM environments e - "#, - ); - - // 如果有标签过滤,需要 JOIN environment_tags 表 - if tag_uuids.is_some() { - query.push_str(" LEFT JOIN environment_tags et ON e.uuid = et.environment_uuid "); - } - - query.push_str( - r#" - WHERE e.workspace_uuid = $1 - AND e.team_uuid = $2 - AND e.deleted_at IS NULL - "#, - ); - - let mut param_index = 3; - let mut conditions = Vec::new(); - - // 分组过滤 - if group_uuid.is_some() { - conditions.push(format!("e.group_uuid = ${}", param_index)); - param_index += 1; - } - - // 状态过滤 - if status.is_some() { - conditions.push(format!("e.status = ${}", param_index)); - param_index += 1; - } - - // 关键词搜索 - if keyword.is_some() { - conditions.push(format!( - "(e.name ILIKE ${} OR e.uuid::text ILIKE ${})", - param_index, param_index - )); - param_index += 1; - } - - // 标签过滤 - if let Some(tags) = tag_uuids { - if !tags.is_empty() { - conditions.push(format!("et.tag_uuid = ANY(${})", param_index)); - param_index += 1; - } - } - - // 添加所有条件 - for condition in conditions { - query.push_str(&format!(" AND {}", condition)); - } - - query.push_str(&format!( - " ORDER BY e.created_at DESC LIMIT ${} OFFSET ${}", - param_index, - param_index + 1 - )); - - // 构建查询 - let mut sql_query = sqlx::query_as::<_, EnvironmentRowDto>(&query) - .bind(workspace_uuid) - .bind(team_uuid); - - // 绑定参数 - if let Some(g) = group_uuid { - sql_query = sql_query.bind(g); - } - if let Some(s) = status { - sql_query = sql_query.bind(s); - } - if let Some(k) = keyword { - let search_pattern = format!("%{}%", k); - sql_query = sql_query.bind(search_pattern); - } - if let Some(tags) = tag_uuids { - if !tags.is_empty() { - sql_query = sql_query.bind(tags); - } - } - - sql_query = sql_query.bind(limit).bind(offset); - - let recs = sql_query.fetch_all(pool).await?; - - Ok(recs) -} - -/// 查询环境列表(基础) -pub async fn fetch_environments( - pool: &Pool, - workspace_uuid: Uuid, - team_uuid: Uuid, - group_uuid: Option, - status: Option<&str>, - offset: i64, - limit: i64, -) -> Result, Error> { - let recs = sqlx::query_as::<_, EnvironmentDto>( - r#" - SELECT id, uuid, workspace_uuid, user_uuid, team_uuid, name, description, - status, group_uuid, proxy_uuid, system_info, kernel_info, fingerprint_summary, - last_opened_at, created_at, updated_at, deleted_at - FROM environments - WHERE workspace_uuid = $1 AND team_uuid = $2 - AND ($3::uuid IS NULL OR group_uuid = $3) - AND ($4::varchar IS NULL OR status = $4) - AND deleted_at IS NULL - ORDER BY created_at DESC - LIMIT $5 OFFSET $6 - "#, - ) - .bind(workspace_uuid) - .bind(team_uuid) - .bind(group_uuid) - .bind(status) - .bind(limit) - .bind(offset) - .fetch_all(pool) - .await?; - - Ok(recs) -} - -/// 查询环境总数 -pub async fn fetch_environments_count( - pool: &Pool, - workspace_uuid: Uuid, - team_uuid: Uuid, - group_uuid: Option, - status: Option<&str>, - keyword: Option<&str>, - tag_uuids: Option<&[Uuid]>, -) -> Result { - let mut query = String::from( - r#" - SELECT COUNT(DISTINCT e.id) FROM environments e - "#, - ); - - // 如果有标签过滤,需要 JOIN environment_tags 表 - if tag_uuids.is_some() { - query.push_str(" LEFT JOIN environment_tags et ON e.uuid = et.environment_uuid "); - } - - query.push_str( - r#" - WHERE e.workspace_uuid = $1 - AND e.team_uuid = $2 - AND e.deleted_at IS NULL - "#, - ); - - let mut param_index = 3; - let mut conditions = Vec::new(); - - // 分组过滤 - if group_uuid.is_some() { - conditions.push(format!("e.group_uuid = ${}", param_index)); - param_index += 1; - } - - // 状态过滤 - if status.is_some() { - conditions.push(format!("e.status = ${}", param_index)); - param_index += 1; - } - - // 关键词搜索 - if keyword.is_some() { - conditions.push(format!( - "(e.name ILIKE ${} OR e.uuid::text ILIKE ${})", - param_index, param_index - )); - param_index += 1; - } - - // 标签过滤 - if let Some(tags) = tag_uuids { - if !tags.is_empty() { - conditions.push(format!("et.tag_uuid = ANY(${})", param_index)); - } - } - - // 添加所有条件 - for condition in conditions { - query.push_str(&format!(" AND {}", condition)); - } - - // 构建查询 - let mut sql_query = sqlx::query_scalar::<_, i64>(&query) - .bind(workspace_uuid) - .bind(team_uuid); - - // 绑定参数 - if let Some(g) = group_uuid { - sql_query = sql_query.bind(g); - } - if let Some(s) = status { - sql_query = sql_query.bind(s); - } - if let Some(k) = keyword { - let search_pattern = format!("%{}%", k); - sql_query = sql_query.bind(search_pattern); - } - if let Some(tags) = tag_uuids { - if !tags.is_empty() { - sql_query = sql_query.bind(tags); - } - } - - let count = sql_query.fetch_one(pool).await?; - - Ok(count) -} - -/// 根据 UUID 查询环境(不带工作空间过滤,用于内部查询) -pub async fn fetch_environment_by_uuid_unfiltered( - pool: &Pool, - env_uuid: Uuid, -) -> Result, Error> { - let rec = sqlx::query_as::<_, EnvironmentDto>( - r#" - SELECT id, uuid, workspace_uuid, user_uuid, team_uuid, name, description, - status, group_uuid, proxy_uuid, system_info, kernel_info, fingerprint_summary, - last_opened_at, created_at, updated_at, deleted_at - FROM environments - WHERE uuid = $1 AND deleted_at IS NULL - "#, - ) - .bind(env_uuid) - .fetch_optional(pool) - .await?; - - Ok(rec) -} - -/// 根据 UUID 查询环境(带工作空间过滤) -pub async fn fetch_environment_by_uuid( - pool: &Pool, - workspace_uuid: Uuid, - env_uuid: Uuid, -) -> Result, Error> { - let rec = sqlx::query_as::<_, EnvironmentDto>( - r#" - SELECT id, uuid, workspace_uuid, user_uuid, team_uuid, name, description, - status, group_uuid, proxy_uuid, system_info, kernel_info, fingerprint_summary, - last_opened_at, created_at, updated_at, deleted_at - FROM environments - WHERE uuid = $1 AND workspace_uuid = $2 AND deleted_at IS NULL - "#, - ) - .bind(env_uuid) - .bind(workspace_uuid) - .fetch_optional(pool) - .await?; - - Ok(rec) -} - -/// 更新环境基础信息 -pub async fn update_environment( - pool: &Pool, - env_uuid: Uuid, - name: Option<&str>, - description: Option<&str>, - group_uuid: Option, -) -> Result<(), Error> { - sqlx::query( - r#" - UPDATE environments - SET name = COALESCE($1, name), - description = COALESCE($2, description), - group_uuid = $3 - WHERE uuid = $4 AND deleted_at IS NULL - "#, - ) - .bind(name) - .bind(description) - .bind(group_uuid) - .bind(env_uuid) - .execute(pool) - .await?; - - Ok(()) -} - -/// 更新环境状态 -pub async fn update_environment_status( - pool: &Pool, - env_uuid: Uuid, - status: &str, -) -> Result<(), Error> { - sqlx::query( - r#" - UPDATE environments SET status = $1 - WHERE uuid = $2 AND deleted_at IS NULL - "#, - ) - .bind(status) - .bind(env_uuid) - .execute(pool) - .await?; - - Ok(()) -} - -/// 更新环境代理 -pub async fn update_environment_proxy( - pool: &Pool, - env_uuid: Uuid, - proxy_uuid: Option, -) -> Result<(), Error> { - sqlx::query( - r#" - UPDATE environments SET proxy_uuid = $1 - WHERE uuid = $2 AND deleted_at IS NULL - "#, - ) - .bind(proxy_uuid) - .bind(env_uuid) - .execute(pool) - .await?; - - Ok(()) -} - -/// 更新环境最后打开时间 -pub async fn update_environment_last_opened( - pool: &Pool, - env_uuid: Uuid, -) -> Result<(), Error> { - sqlx::query( - r#" - UPDATE environments SET last_opened_at = CURRENT_TIMESTAMP - WHERE uuid = $1 AND deleted_at IS NULL - "#, - ) - .bind(env_uuid) - .execute(pool) - .await?; - - Ok(()) -} - -/// 软删除环境 -pub async fn delete_environment(pool: &Pool, env_uuid: Uuid) -> Result<(), Error> { - sqlx::query( - r#" - UPDATE environments SET deleted_at = CURRENT_TIMESTAMP - WHERE uuid = $1 AND deleted_at IS NULL - "#, - ) - .bind(env_uuid) - .execute(pool) - .await?; - - Ok(()) -} - -/// 批量软删除环境 -pub async fn batch_delete_environments( - pool: &Pool, - env_uuids: &[Uuid], -) -> Result { - let result = sqlx::query( - r#" - UPDATE environments SET deleted_at = CURRENT_TIMESTAMP - WHERE uuid = ANY($1) AND deleted_at IS NULL - "#, - ) - .bind(env_uuids) - .execute(pool) - .await?; - - Ok(result.rows_affected()) -} - -// ============ Recycle Bin ============ - -/// 查询回收站环境列表(已删除但未永久删除) -pub async fn fetch_deleted_environments_base( - pool: &Pool, - workspace_uuid: Uuid, - team_uuid: Uuid, - group_uuid: Option, - keyword: Option<&str>, - offset: i64, - limit: i64, -) -> Result, Error> { - let mut query = String::from( - r#" - SELECT DISTINCT - e.id, e.uuid, e.workspace_uuid, e.user_uuid, e.team_uuid, e.name, e.description, e.status, - e.system_info, e.kernel_info, e.fingerprint_summary, - e.group_uuid, e.proxy_uuid, - e.last_opened_at, e.created_at, e.updated_at, e.deleted_at - FROM environments e - WHERE e.workspace_uuid = $1 - AND e.team_uuid = $2 - AND e.deleted_at IS NOT NULL - "#, - ); - - let mut param_index = 3; - let mut conditions = Vec::new(); - - // 分组过滤 - if group_uuid.is_some() { - conditions.push(format!("e.group_uuid = ${}", param_index)); - param_index += 1; - } - - // 关键词搜索 - if keyword.is_some() { - conditions.push(format!("(e.name ILIKE ${} OR e.uuid::text ILIKE ${})", param_index, param_index)); - param_index += 1; - } - - if !conditions.is_empty() { - query.push_str(" AND "); - query.push_str(&conditions.join(" AND ")); - } - - query.push_str(" ORDER BY e.deleted_at DESC LIMIT $"); - query.push_str(¶m_index.to_string()); - param_index += 1; - query.push_str(" OFFSET $"); - query.push_str(¶m_index.to_string()); - - let mut q = sqlx::query_as::<_, EnvironmentRowDto>(&query) - .bind(workspace_uuid) - .bind(team_uuid); - - if let Some(gid) = group_uuid { - q = q.bind(gid); - } - - if let Some(kw) = keyword { - let pattern = format!("%{}%", kw); - q = q.bind(pattern); - } - - q = q.bind(limit).bind(offset); - - let recs = q.fetch_all(pool).await?; - Ok(recs) -} - -/// 统计回收站环境总数 -pub async fn fetch_deleted_environments_count( - pool: &Pool, - workspace_uuid: Uuid, - team_uuid: Uuid, - group_uuid: Option, - keyword: Option<&str>, -) -> Result { - let mut query = String::from( - r#" - SELECT COUNT(DISTINCT e.id) - FROM environments e - WHERE e.workspace_uuid = $1 - AND e.team_uuid = $2 - AND e.deleted_at IS NOT NULL - "#, - ); - - let mut param_index = 3; - let mut conditions = Vec::new(); - - if group_uuid.is_some() { - conditions.push(format!("e.group_uuid = ${}", param_index)); - param_index += 1; - } - - if keyword.is_some() { - conditions.push(format!("(e.name ILIKE ${} OR e.uuid::text ILIKE ${})", param_index, param_index)); - param_index += 1; - } - - if !conditions.is_empty() { - query.push_str(" AND "); - query.push_str(&conditions.join(" AND ")); - } - - let mut q = sqlx::query_scalar::<_, i64>(&query) - .bind(workspace_uuid) - .bind(team_uuid); - - if let Some(gid) = group_uuid { - q = q.bind(gid); - } - - if let Some(kw) = keyword { - let pattern = format!("%{}%", kw); - q = q.bind(pattern); - } - - let count = q.fetch_one(pool).await?; - Ok(count) -} - -/// 恢复环境(将 deleted_at 设为 NULL) -pub async fn restore_environment(pool: &Pool, env_uuid: Uuid) -> Result<(), Error> { - sqlx::query( - r#" - UPDATE environments SET deleted_at = NULL - WHERE uuid = $1 AND deleted_at IS NOT NULL - "#, - ) - .bind(env_uuid) - .execute(pool) - .await?; - - Ok(()) -} - -/// 批量恢复环境 -pub async fn batch_restore_environments( - pool: &Pool, - env_uuids: &[Uuid], -) -> Result { - let result = sqlx::query( - r#" - UPDATE environments SET deleted_at = NULL - WHERE uuid = ANY($1) AND deleted_at IS NOT NULL - "#, - ) - .bind(env_uuids) - .execute(pool) - .await?; - - Ok(result.rows_affected()) -} - -/// 永久删除环境(真正的 DELETE) -pub async fn permanent_delete_environment(pool: &Pool, env_uuid: Uuid) -> Result<(), Error> { - // 先删除关联数据 - sqlx::query("DELETE FROM environment_tags WHERE environment_uuid = $1") - .bind(env_uuid) - .execute(pool) - .await?; - - sqlx::query("DELETE FROM environment_urls WHERE environment_uuid = $1") - .bind(env_uuid) - .execute(pool) - .await?; - - sqlx::query("DELETE FROM environment_cookies WHERE environment_uuid = $1") - .bind(env_uuid) - .execute(pool) - .await?; - - sqlx::query("DELETE FROM environment_configs WHERE environment_uuid = $1") - .bind(env_uuid) - .execute(pool) - .await?; - - sqlx::query("DELETE FROM environment_accounts WHERE environment_uuid = $1") - .bind(env_uuid) - .execute(pool) - .await?; - - sqlx::query("DELETE FROM environment_extensions WHERE environment_uuid = $1") - .bind(env_uuid) - .execute(pool) - .await?; - - // 最后删除环境本身 - sqlx::query("DELETE FROM environments WHERE uuid = $1") - .bind(env_uuid) - .execute(pool) - .await?; - - Ok(()) -} - -/// 批量永久删除环境 -pub async fn batch_permanent_delete_environments( - pool: &Pool, - env_uuids: &[Uuid], -) -> Result { - // 先删除关联数据 - sqlx::query("DELETE FROM environment_tags WHERE environment_uuid = ANY($1)") - .bind(env_uuids) - .execute(pool) - .await?; - - sqlx::query("DELETE FROM environment_urls WHERE environment_uuid = ANY($1)") - .bind(env_uuids) - .execute(pool) - .await?; - - sqlx::query("DELETE FROM environment_cookies WHERE environment_uuid = ANY($1)") - .bind(env_uuids) - .execute(pool) - .await?; - - sqlx::query("DELETE FROM environment_configs WHERE environment_uuid = ANY($1)") - .bind(env_uuids) - .execute(pool) - .await?; - - sqlx::query("DELETE FROM environment_accounts WHERE environment_uuid = ANY($1)") - .bind(env_uuids) - .execute(pool) - .await?; - - sqlx::query("DELETE FROM environment_extensions WHERE environment_uuid = ANY($1)") - .bind(env_uuids) - .execute(pool) - .await?; - - // 最后删除环境本身 - let result = sqlx::query("DELETE FROM environments WHERE uuid = ANY($1)") - .bind(env_uuids) - .execute(pool) - .await?; - - Ok(result.rows_affected()) -} - -// ============ Environment Configs ============ - -/// 创建或更新环境配置 -pub async fn upsert_environment_config( - pool: &Pool, - env_uuid: Uuid, - window_info: &serde_json::Value, - basic_settings: &serde_json::Value, - fingerprint_settings: &serde_json::Value, - device_settings: &serde_json::Value, - preference_settings: &serde_json::Value, - project_metadata: &serde_json::Value, -) -> Result { - let id: i32 = sqlx::query_scalar( - r#" - INSERT INTO environment_configs (environment_uuid, window_info, basic_settings, - fingerprint_settings, device_settings, preference_settings, - project_metadata) - VALUES ($1, $2, $3, $4, $5, $6, $7) - ON CONFLICT (environment_uuid) DO UPDATE SET - window_info = $2, - basic_settings = $3, - fingerprint_settings = $4, - device_settings = $5, - preference_settings = $6, - project_metadata = $7 - RETURNING id; - "#, - ) - .bind(env_uuid) - .bind(window_info) - .bind(basic_settings) - .bind(fingerprint_settings) - .bind(device_settings) - .bind(preference_settings) - .bind(project_metadata) - .fetch_one(pool) - .await?; - - Ok(id) -} - -/// 查询环境配置 -pub async fn fetch_environment_config( - pool: &Pool, - env_uuid: Uuid, -) -> Result, Error> { - let rec = sqlx::query_as::<_, EnvironmentConfigDto>( - r#" - SELECT id, environment_uuid, window_info, basic_settings, fingerprint_settings, - device_settings, preference_settings, project_metadata, created_at, updated_at - FROM environment_configs - WHERE environment_uuid = $1 - "#, - ) - .bind(env_uuid) - .fetch_optional(pool) - .await?; - - Ok(rec) -} - -/// 批量查询环境配置 -pub async fn fetch_environment_configs_by_uuids( - pool: &Pool, - env_uuids: &[Uuid], -) -> Result, Error> { - if env_uuids.is_empty() { - return Ok(vec![]); - } - - let recs = sqlx::query_as::<_, EnvironmentConfigDto>( - r#" - SELECT id, environment_uuid, window_info, basic_settings, fingerprint_settings, - device_settings, preference_settings, project_metadata, created_at, updated_at - FROM environment_configs - WHERE environment_uuid = ANY($1) - "#, - ) - .bind(env_uuids) - .fetch_all(pool) - .await?; - - Ok(recs) -} - -// ============ Environment Tags ============ - -/// 为环境添加标签 -pub async fn insert_environment_tag( - pool: &Pool, - env_uuid: Uuid, - tag_uuid: Uuid, -) -> Result<(), Error> { - sqlx::query( - r#" - INSERT INTO environment_tags (environment_uuid, tag_uuid) - VALUES ($1, $2) - ON CONFLICT DO NOTHING; - "#, - ) - .bind(env_uuid) - .bind(tag_uuid) - .execute(pool) - .await?; - - Ok(()) -} - -/// 移除环境的标签 -pub async fn remove_environment_tag( - pool: &Pool, - env_uuid: Uuid, - tag_uuid: Uuid, -) -> Result<(), Error> { - sqlx::query( - r#" - DELETE FROM environment_tags - WHERE environment_uuid = $1 AND tag_uuid = $2; - "#, - ) - .bind(env_uuid) - .bind(tag_uuid) - .execute(pool) - .await?; - - Ok(()) -} - -/// 清除环境的所有标签 -pub async fn clear_environment_tags(pool: &Pool, env_uuid: Uuid) -> Result<(), Error> { - sqlx::query( - r#" - DELETE FROM environment_tags - WHERE environment_uuid = $1; - "#, - ) - .bind(env_uuid) - .execute(pool) - .await?; - - Ok(()) -} - -/// 查询环境的所有标签 -pub async fn fetch_environment_tags( - pool: &Pool, - env_uuid: Uuid, -) -> Result, Error> { - let recs = sqlx::query_as::<_, TagDto>( - r#" - SELECT t.id, t.uuid, t.user_uuid, t.team_uuid, t.name, t.color, t.sort_order, - t.environments_count, t.created_at, t.updated_at, t.deleted_at - FROM tags t - INNER JOIN environment_tags et ON t.uuid = et.tag_uuid - WHERE et.environment_uuid = $1 AND t.deleted_at IS NULL - ORDER BY t.sort_order, t.name - "#, - ) - .bind(env_uuid) - .fetch_all(pool) - .await?; - - Ok(recs) -} - -/// 批量查询环境标签(完整标签信息) -pub async fn fetch_tags_for_environments( - pool: &Pool, - env_uuids: &[Uuid], -) -> Result, Error> { - if env_uuids.is_empty() { - return Ok(vec![]); - } - - let recs = sqlx::query_as::<_, EnvironmentTagRowDto>( - r#" - SELECT - et.environment_uuid, - t.id as tag_id, - t.uuid as tag_uuid, - t.name as tag_name, - t.color as tag_color, - t.sort_order as tag_sort_order, - t.user_uuid as tag_user_uuid, - t.team_uuid as tag_team_uuid, - t.environments_count as tag_environments_count, - t.created_at as tag_created_at, - t.updated_at as tag_updated_at, - t.deleted_at as tag_deleted_at - FROM environment_tags et - INNER JOIN tags t ON et.tag_uuid = t.uuid - WHERE et.environment_uuid = ANY($1) AND t.deleted_at IS NULL - ORDER BY t.sort_order, t.name - "#, - ) - .bind(env_uuids) - .fetch_all(pool) - .await?; - - Ok(recs) -} - -/// 批量查询环境账号(完整账号信息,排除敏感数据) -pub async fn fetch_accounts_for_environments( - pool: &Pool, - env_uuids: &[Uuid], -) -> Result, Error> { - if env_uuids.is_empty() { - return Ok(vec![]); - } - - let recs = sqlx::query_as::<_, EnvironmentAccountRowDto>( - r#" - SELECT - ea.environment_uuid, - pa.id as account_id, - pa.uuid as account_uuid, - pa.platform_url, - pa.platform_name, - pa.account, - pa.status as account_status, - pa.remark - FROM environment_accounts ea - INNER JOIN platform_accounts pa ON ea.account_uuid = pa.uuid - WHERE ea.environment_uuid = ANY($1) AND pa.deleted_at IS NULL - ORDER BY ea.sort_order - "#, - ) - .bind(env_uuids) - .fetch_all(pool) - .await?; - - Ok(recs) -} - -/// 批量查询分组 -pub async fn fetch_groups_by_uuids( - pool: &Pool, - group_uuids: &[Uuid], -) -> Result, Error> { - if group_uuids.is_empty() { - return Ok(vec![]); - } - - let recs = sqlx::query_as::<_, GroupRowDto>( - r#" - SELECT id, uuid, name, description, sort_order - FROM groups - WHERE uuid = ANY($1) AND deleted_at IS NULL - "#, - ) - .bind(group_uuids) - .fetch_all(pool) - .await?; - - Ok(recs) -} - -/// 批量查询代理 -pub async fn fetch_proxies_by_uuids( - pool: &Pool, - proxy_uuids: &[Uuid], -) -> Result, Error> { - if proxy_uuids.is_empty() { - return Ok(vec![]); - } - - let recs = sqlx::query_as::<_, ProxyRowDto>( - r#" - SELECT id, uuid, name, host, port, proxy_type, - username, password_encrypted, - country, city, status, latency, last_check_ip - FROM proxies - WHERE uuid = ANY($1) AND deleted_at IS NULL - "#, - ) - .bind(proxy_uuids) - .fetch_all(pool) - .await?; - - Ok(recs) -} - -// ============ Templates ============ - -/// 创建模板 -pub async fn insert_template( - pool: &Pool, - user_uuid: Uuid, - team_uuid: Option, - name: &str, - description: Option<&str>, - is_public: bool, - system_info: Option<&str>, - kernel_info: Option<&str>, - config_json: &serde_json::Value, -) -> Result { - let uuid: Uuid = sqlx::query_scalar( - r#" - INSERT INTO templates (user_uuid, team_uuid, name, description, is_public, - system_info, kernel_info, config_json) - VALUES ($1, $2, $3, $4, $5, $6, $7, $8) - RETURNING uuid; - "#, - ) - .bind(user_uuid) - .bind(team_uuid) - .bind(name) - .bind(description) - .bind(is_public) - .bind(system_info) - .bind(kernel_info) - .bind(config_json) - .fetch_one(pool) - .await?; - - Ok(uuid) -} - -/// 查询模板列表 -pub async fn fetch_templates( - pool: &Pool, - team_uuid: Option, - user_uuid: Uuid, - is_public: Option, - offset: i64, - limit: i64, -) -> Result, Error> { - let recs = sqlx::query_as::<_, TemplateDto>( - r#" - SELECT id, uuid, user_uuid, team_uuid, name, description, is_public, - system_info, kernel_info, config_json, usage_count, created_at, updated_at, deleted_at - FROM templates - WHERE (team_uuid = $1 OR (team_uuid IS NULL AND user_uuid = $2) OR is_public = TRUE) - AND ($3::boolean IS NULL OR is_public = $3) - AND deleted_at IS NULL - ORDER BY usage_count DESC, created_at DESC - LIMIT $4 OFFSET $5 - "#, - ) - .bind(team_uuid) - .bind(user_uuid) - .bind(is_public) - .bind(limit) - .bind(offset) - .fetch_all(pool) - .await?; - - Ok(recs) -} - -/// 查询模板总数 -pub async fn fetch_templates_count( - pool: &Pool, - team_uuid: Option, - user_uuid: Uuid, - is_public: Option, -) -> Result { - let count: i64 = sqlx::query_scalar( - r#" - SELECT COUNT(*) - FROM templates - WHERE (team_uuid = $1 OR (team_uuid IS NULL AND user_uuid = $2) OR is_public = TRUE) - AND ($3::boolean IS NULL OR is_public = $3) - AND deleted_at IS NULL - "#, - ) - .bind(team_uuid) - .bind(user_uuid) - .bind(is_public) - .fetch_one(pool) - .await?; - - Ok(count) -} - -/// 根据 UUID 查询模板 -pub async fn fetch_template_by_uuid( - pool: &Pool, - template_uuid: Uuid, -) -> Result, Error> { - let rec = sqlx::query_as::<_, TemplateDto>( - r#" - SELECT id, uuid, user_uuid, team_uuid, name, description, is_public, - system_info, kernel_info, config_json, usage_count, created_at, updated_at, deleted_at - FROM templates - WHERE uuid = $1 AND deleted_at IS NULL - "#, - ) - .bind(template_uuid) - .fetch_optional(pool) - .await?; - - Ok(rec) -} - -/// 更新模板 -pub async fn update_template( - pool: &Pool, - template_uuid: Uuid, - name: Option<&str>, - description: Option<&str>, - is_public: Option, - config_json: Option<&serde_json::Value>, -) -> Result<(), Error> { - sqlx::query( - r#" - UPDATE templates - SET name = COALESCE($1, name), - description = COALESCE($2, description), - is_public = COALESCE($3, is_public), - config_json = COALESCE($4, config_json) - WHERE uuid = $5 AND deleted_at IS NULL - "#, - ) - .bind(name) - .bind(description) - .bind(is_public) - .bind(config_json) - .bind(template_uuid) - .execute(pool) - .await?; - - Ok(()) -} - -/// 增加模板使用次数 -pub async fn increment_template_usage( - pool: &Pool, - template_uuid: Uuid, -) -> Result<(), Error> { - sqlx::query( - r#" - UPDATE templates SET usage_count = usage_count + 1 - WHERE uuid = $1 AND deleted_at IS NULL - "#, - ) - .bind(template_uuid) - .execute(pool) - .await?; - - Ok(()) -} - -/// 软删除模板 -pub async fn delete_template(pool: &Pool, template_uuid: Uuid) -> Result<(), Error> { - sqlx::query( - r#" - UPDATE templates SET deleted_at = CURRENT_TIMESTAMP - WHERE uuid = $1 AND deleted_at IS NULL - "#, - ) - .bind(template_uuid) - .execute(pool) - .await?; - - Ok(()) -} - -// ============ Environment URLs ============ - -/// 添加环境 URL -pub async fn insert_environment_url( - pool: &Pool, - env_uuid: Uuid, - url: &str, - title: Option<&str>, - sort_order: Option, -) -> Result { - let id: i32 = sqlx::query_scalar( - r#" - INSERT INTO environment_urls (environment_uuid, url, title, sort_order) - VALUES ($1, $2, $3, $4) - RETURNING id; - "#, - ) - .bind(env_uuid) - .bind(url) - .bind(title) - .bind(sort_order.unwrap_or(0)) - .fetch_one(pool) - .await?; - - Ok(id) -} - -/// 批量添加环境 URL -pub async fn batch_insert_environment_urls( - pool: &Pool, - env_uuid: Uuid, - urls: &[(String, Option)], -) -> Result { - let mut count = 0; - for (idx, (url, title)) in urls.iter().enumerate() { - sqlx::query( - r#" - INSERT INTO environment_urls (environment_uuid, url, title, sort_order) - VALUES ($1, $2, $3, $4) - ON CONFLICT DO NOTHING; - "#, - ) - .bind(env_uuid) - .bind(url) - .bind(title.as_deref()) - .bind(idx as i32) - .execute(pool) - .await?; - count += 1; - } - Ok(count) -} - -/// 查询环境的所有 URL -pub async fn fetch_environment_urls( - pool: &Pool, - env_uuid: Uuid, -) -> Result, Error> { - let recs = sqlx::query_as::<_, EnvironmentUrlDto>( - r#" - SELECT id, environment_uuid, url, title, sort_order, created_at - FROM environment_urls - WHERE environment_uuid = $1 - ORDER BY sort_order, id - "#, - ) - .bind(env_uuid) - .fetch_all(pool) - .await?; - - Ok(recs) -} - -/// 删除环境的 URL -pub async fn delete_environment_url(pool: &Pool, url_id: i32) -> Result<(), Error> { - sqlx::query( - r#" - DELETE FROM environment_urls WHERE id = $1 - "#, - ) - .bind(url_id) - .execute(pool) - .await?; - - Ok(()) -} - -/// 清空环境的所有 URL -pub async fn clear_environment_urls(pool: &Pool, env_uuid: Uuid) -> Result { - let result = sqlx::query( - r#" - DELETE FROM environment_urls WHERE environment_uuid = $1 - "#, - ) - .bind(env_uuid) - .execute(pool) - .await?; - - Ok(result.rows_affected()) -} - -// ============ Environment Cookies ============ - -/// 添加环境 Cookie -pub async fn insert_environment_cookie( - pool: &Pool, - env_uuid: Uuid, - domain: &str, - name: &str, - value: &str, - path: Option<&str>, - http_only: Option, - secure: Option, - same_site: Option<&str>, -) -> Result { - let id: i32 = sqlx::query_scalar( - r#" - INSERT INTO environment_cookies (environment_uuid, domain, name, value, path, http_only, secure, same_site) - VALUES ($1, $2, $3, $4, $5, $6, $7, $8) - RETURNING id; - "#, - ) - .bind(env_uuid) - .bind(domain) - .bind(name) - .bind(value) - .bind(path.unwrap_or("/")) - .bind(http_only.unwrap_or(false)) - .bind(secure.unwrap_or(false)) - .bind(same_site.unwrap_or("Lax")) - .fetch_one(pool) - .await?; - - Ok(id) -} - -/// 批量添加环境 Cookies -pub async fn batch_insert_environment_cookies( - pool: &Pool, - env_uuid: Uuid, - cookies: &[CookieInput], -) -> Result { - let mut count = 0; - for cookie in cookies { - sqlx::query( - r#" - INSERT INTO environment_cookies (environment_uuid, domain, name, value, path, http_only, secure, same_site) - VALUES ($1, $2, $3, $4, $5, $6, $7, $8) - ON CONFLICT DO NOTHING; - "#, - ) - .bind(env_uuid) - .bind(&cookie.domain) - .bind(&cookie.name) - .bind(&cookie.value) - .bind(cookie.path.as_deref().unwrap_or("/")) - .bind(cookie.http_only.unwrap_or(false)) - .bind(cookie.secure.unwrap_or(false)) - .bind(cookie.same_site.as_deref().unwrap_or("Lax")) - .execute(pool) - .await?; - count += 1; - } - Ok(count) -} - -/// 查询环境的所有 Cookies -pub async fn fetch_environment_cookies( - pool: &Pool, - env_uuid: Uuid, -) -> Result, Error> { - let recs = sqlx::query_as::<_, EnvironmentCookieDto>( - r#" - SELECT id, environment_uuid, domain, name, value, path, expires_at, http_only, secure, same_site, created_at - FROM environment_cookies - WHERE environment_uuid = $1 - ORDER BY domain, name - "#, - ) - .bind(env_uuid) - .fetch_all(pool) - .await?; - - Ok(recs) -} - -/// 删除环境的 Cookie -pub async fn delete_environment_cookie(pool: &Pool, cookie_id: i32) -> Result<(), Error> { - sqlx::query( - r#" - DELETE FROM environment_cookies WHERE id = $1 - "#, - ) - .bind(cookie_id) - .execute(pool) - .await?; - - Ok(()) -} - -/// 清空环境的所有 Cookies -pub async fn clear_environment_cookies( - pool: &Pool, - env_uuid: Uuid, -) -> Result { - let result = sqlx::query( - r#" - DELETE FROM environment_cookies WHERE environment_uuid = $1 - "#, - ) - .bind(env_uuid) - .execute(pool) - .await?; - - Ok(result.rows_affected()) -} +use sqlx::{Error, Pool, Postgres}; +use uuid::Uuid; + +use crate::dto::{ + EnvironmentAccountRowDto, EnvironmentConfigDto, EnvironmentCookieDto, EnvironmentDto, + EnvironmentRowDto, EnvironmentTagRowDto, EnvironmentUrlDto, GroupDto, GroupRowDto, ProxyRowDto, + TagDto, TemplateDto, +}; +use crate::entitys::CookieInput; + +// ============ Groups ============ + +/// 创建分组 +pub async fn insert_group( + pool: &Pool, + workspace_uuid: Uuid, + team_uuid: Uuid, + name: &str, + description: Option<&str>, + created_by: Uuid, +) -> Result { + let uuid: Uuid = sqlx::query_scalar( + r#" + INSERT INTO groups (workspace_uuid, team_uuid, name, description, created_by) + VALUES ($1, $2, $3, $4, $5) + RETURNING uuid; + "#, + ) + .bind(workspace_uuid) + .bind(team_uuid) + .bind(name) + .bind(description) + .bind(created_by) + .fetch_one(pool) + .await?; + + Ok(uuid) +} + +/// 查询分组列表(工作空间级别) +pub async fn fetch_groups( + pool: &Pool, + workspace_uuid: Uuid, + team_uuid: Uuid, + offset: i64, + limit: i64, +) -> Result, Error> { + let recs = sqlx::query_as::<_, GroupDto>( + r#" + SELECT g.id, g.uuid, g.workspace_uuid, g.team_uuid, t.name AS team_name, + g.name, g.description, g.sort_order, + g.created_by, ui.nickname AS created_by_name, + (SELECT COUNT(*) FROM environments e WHERE e.group_uuid = g.uuid AND e.deleted_at IS NULL) AS environments_count, + g.created_at, g.updated_at, g.deleted_at + FROM groups g + LEFT JOIN teams t ON g.team_uuid = t.uuid + LEFT JOIN user_infos ui ON g.created_by = ui.user_uuid + WHERE g.workspace_uuid = $1 AND g.team_uuid = $2 AND g.deleted_at IS NULL + ORDER BY g.sort_order, g.name + LIMIT $3 OFFSET $4 + "#, + ) + .bind(workspace_uuid) + .bind(team_uuid) + .bind(limit) + .bind(offset) + .fetch_all(pool) + .await?; + + Ok(recs) +} + +/// 根据 UUID 查询分组 +pub async fn fetch_group_by_uuid( + pool: &Pool, + group_uuid: Uuid, +) -> Result, Error> { + let rec = sqlx::query_as::<_, GroupDto>( + r#" + SELECT g.id, g.uuid, g.workspace_uuid, g.team_uuid, t.name AS team_name, + g.name, g.description, g.sort_order, + g.created_by, ui.nickname AS created_by_name, + (SELECT COUNT(*) FROM environments e WHERE e.group_uuid = g.uuid AND e.deleted_at IS NULL) AS environments_count, + g.created_at, g.updated_at, g.deleted_at + FROM groups g + LEFT JOIN teams t ON g.team_uuid = t.uuid + LEFT JOIN user_infos ui ON g.created_by = ui.user_uuid + WHERE g.uuid = $1 AND g.deleted_at IS NULL + "#, + ) + .bind(group_uuid) + .fetch_optional(pool) + .await?; + + Ok(rec) +} + +/// 更新分组 +pub async fn update_group( + pool: &Pool, + group_uuid: Uuid, + name: Option<&str>, + description: Option<&str>, + sort_order: Option, +) -> Result<(), Error> { + sqlx::query( + r#" + UPDATE groups + SET name = COALESCE($1, name), + description = COALESCE($2, description), + sort_order = COALESCE($3, sort_order) + WHERE uuid = $4 AND deleted_at IS NULL + "#, + ) + .bind(name) + .bind(description) + .bind(sort_order) + .bind(group_uuid) + .execute(pool) + .await?; + + Ok(()) +} + +/// 软删除分组 +pub async fn delete_group(pool: &Pool, group_uuid: Uuid) -> Result<(), Error> { + sqlx::query( + r#" + UPDATE groups SET deleted_at = CURRENT_TIMESTAMP + WHERE uuid = $1 AND deleted_at IS NULL + "#, + ) + .bind(group_uuid) + .execute(pool) + .await?; + + Ok(()) +} + +// ============ Tags ============ + +/// 创建标签 +pub async fn insert_tag( + pool: &Pool, + user_uuid: Uuid, + team_uuid: Option, + name: &str, + color: Option<&str>, +) -> Result { + let uuid: Uuid = sqlx::query_scalar( + r#" + INSERT INTO tags (user_uuid, team_uuid, name, color) + VALUES ($1, $2, $3, $4) + RETURNING uuid; + "#, + ) + .bind(user_uuid) + .bind(team_uuid) + .bind(name) + .bind(color.unwrap_or("gray")) + .fetch_one(pool) + .await?; + + Ok(uuid) +} + +/// 查询标签列表 +pub async fn fetch_tags( + pool: &Pool, + team_uuid: Option, + user_uuid: Uuid, +) -> Result, Error> { + let recs = sqlx::query_as::<_, TagDto>( + r#" + SELECT id, uuid, user_uuid, team_uuid, name, color, sort_order, + environments_count, created_at, updated_at, deleted_at + FROM tags + WHERE (team_uuid = $1 OR (team_uuid IS NULL AND user_uuid = $2)) + AND deleted_at IS NULL + ORDER BY sort_order, name + "#, + ) + .bind(team_uuid) + .bind(user_uuid) + .fetch_all(pool) + .await?; + + Ok(recs) +} + +/// 根据 UUID 查询标签 +pub async fn fetch_tag_by_uuid( + pool: &Pool, + tag_uuid: Uuid, +) -> Result, Error> { + let rec = sqlx::query_as::<_, TagDto>( + r#" + SELECT id, uuid, user_uuid, team_uuid, name, color, sort_order, + environments_count, created_at, updated_at, deleted_at + FROM tags + WHERE uuid = $1 AND deleted_at IS NULL + "#, + ) + .bind(tag_uuid) + .fetch_optional(pool) + .await?; + + Ok(rec) +} + +/// 更新标签 +pub async fn update_tag( + pool: &Pool, + tag_uuid: Uuid, + name: Option<&str>, + color: Option<&str>, + sort_order: Option, +) -> Result<(), Error> { + sqlx::query( + r#" + UPDATE tags + SET name = COALESCE($1, name), + color = COALESCE($2, color), + sort_order = COALESCE($3, sort_order) + WHERE uuid = $4 AND deleted_at IS NULL + "#, + ) + .bind(name) + .bind(color) + .bind(sort_order) + .bind(tag_uuid) + .execute(pool) + .await?; + + Ok(()) +} + +/// 软删除标签 +pub async fn delete_tag(pool: &Pool, tag_uuid: Uuid) -> Result<(), Error> { + sqlx::query( + r#" + UPDATE tags SET deleted_at = CURRENT_TIMESTAMP + WHERE uuid = $1 AND deleted_at IS NULL + "#, + ) + .bind(tag_uuid) + .execute(pool) + .await?; + + Ok(()) +} + +// ============ Environments ============ + +/// 创建环境 +pub async fn insert_environment( + pool: &Pool, + workspace_uuid: Uuid, + user_uuid: Uuid, + team_uuid: Uuid, + name: &str, + description: Option<&str>, + group_uuid: Option, + proxy_uuid: Option, + system_info: Option<&str>, + kernel_info: Option<&str>, +) -> Result { + let uuid: Uuid = sqlx::query_scalar( + r#" + INSERT INTO environments (workspace_uuid, user_uuid, team_uuid, name, description, + group_uuid, proxy_uuid, system_info, kernel_info) + VALUES ($1, $2, $3, $4, $5, $6, $7, $8, $9) + RETURNING uuid; + "#, + ) + .bind(workspace_uuid) + .bind(user_uuid) + .bind(team_uuid) + .bind(name) + .bind(description) + .bind(group_uuid) + .bind(proxy_uuid) + .bind(system_info) + .bind(kernel_info) + .fetch_one(pool) + .await?; + + Ok(uuid) +} + +/// 查询环境列表(基础信息) +pub async fn fetch_environments_base( + pool: &Pool, + workspace_uuid: Uuid, + team_uuid: Uuid, + group_uuid: Option, + status: Option<&str>, + keyword: Option<&str>, + tag_uuids: Option<&[Uuid]>, + offset: i64, + limit: i64, +) -> Result, Error> { + let mut query = String::from( + r#" + SELECT DISTINCT + e.id, e.uuid, e.workspace_uuid, e.user_uuid, e.team_uuid, e.name, e.description, e.status, + e.system_info, e.kernel_info, e.fingerprint_summary, + e.group_uuid, e.proxy_uuid, + e.last_opened_at, e.created_at, e.updated_at + FROM environments e + "#, + ); + + // 如果有标签过滤,需要 JOIN environment_tags 表 + if tag_uuids.is_some() { + query.push_str(" LEFT JOIN environment_tags et ON e.uuid = et.environment_uuid "); + } + + query.push_str( + r#" + WHERE e.workspace_uuid = $1 + AND e.team_uuid = $2 + AND e.deleted_at IS NULL + "#, + ); + + let mut param_index = 3; + let mut conditions = Vec::new(); + + // 分组过滤 + if group_uuid.is_some() { + conditions.push(format!("e.group_uuid = ${}", param_index)); + param_index += 1; + } + + // 状态过滤 + if status.is_some() { + conditions.push(format!("e.status = ${}", param_index)); + param_index += 1; + } + + // 关键词搜索 + if keyword.is_some() { + conditions.push(format!( + "(e.name ILIKE ${} OR e.uuid::text ILIKE ${})", + param_index, param_index + )); + param_index += 1; + } + + // 标签过滤 + if let Some(tags) = tag_uuids { + if !tags.is_empty() { + conditions.push(format!("et.tag_uuid = ANY(${})", param_index)); + param_index += 1; + } + } + + // 添加所有条件 + for condition in conditions { + query.push_str(&format!(" AND {}", condition)); + } + + query.push_str(&format!( + " ORDER BY e.created_at DESC LIMIT ${} OFFSET ${}", + param_index, + param_index + 1 + )); + + // 构建查询 + let mut sql_query = sqlx::query_as::<_, EnvironmentRowDto>(&query) + .bind(workspace_uuid) + .bind(team_uuid); + + // 绑定参数 + if let Some(g) = group_uuid { + sql_query = sql_query.bind(g); + } + if let Some(s) = status { + sql_query = sql_query.bind(s); + } + if let Some(k) = keyword { + let search_pattern = format!("%{}%", k); + sql_query = sql_query.bind(search_pattern); + } + if let Some(tags) = tag_uuids { + if !tags.is_empty() { + sql_query = sql_query.bind(tags); + } + } + + sql_query = sql_query.bind(limit).bind(offset); + + let recs = sql_query.fetch_all(pool).await?; + + Ok(recs) +} + +/// 查询环境列表(基础) +pub async fn fetch_environments( + pool: &Pool, + workspace_uuid: Uuid, + team_uuid: Uuid, + group_uuid: Option, + status: Option<&str>, + offset: i64, + limit: i64, +) -> Result, Error> { + let recs = sqlx::query_as::<_, EnvironmentDto>( + r#" + SELECT id, uuid, workspace_uuid, user_uuid, team_uuid, name, description, + status, group_uuid, proxy_uuid, system_info, kernel_info, fingerprint_summary, + last_opened_at, created_at, updated_at, deleted_at + FROM environments + WHERE workspace_uuid = $1 AND team_uuid = $2 + AND ($3::uuid IS NULL OR group_uuid = $3) + AND ($4::varchar IS NULL OR status = $4) + AND deleted_at IS NULL + ORDER BY created_at DESC + LIMIT $5 OFFSET $6 + "#, + ) + .bind(workspace_uuid) + .bind(team_uuid) + .bind(group_uuid) + .bind(status) + .bind(limit) + .bind(offset) + .fetch_all(pool) + .await?; + + Ok(recs) +} + +/// 查询环境总数 +pub async fn fetch_environments_count( + pool: &Pool, + workspace_uuid: Uuid, + team_uuid: Uuid, + group_uuid: Option, + status: Option<&str>, + keyword: Option<&str>, + tag_uuids: Option<&[Uuid]>, +) -> Result { + let mut query = String::from( + r#" + SELECT COUNT(DISTINCT e.id) FROM environments e + "#, + ); + + // 如果有标签过滤,需要 JOIN environment_tags 表 + if tag_uuids.is_some() { + query.push_str(" LEFT JOIN environment_tags et ON e.uuid = et.environment_uuid "); + } + + query.push_str( + r#" + WHERE e.workspace_uuid = $1 + AND e.team_uuid = $2 + AND e.deleted_at IS NULL + "#, + ); + + let mut param_index = 3; + let mut conditions = Vec::new(); + + // 分组过滤 + if group_uuid.is_some() { + conditions.push(format!("e.group_uuid = ${}", param_index)); + param_index += 1; + } + + // 状态过滤 + if status.is_some() { + conditions.push(format!("e.status = ${}", param_index)); + param_index += 1; + } + + // 关键词搜索 + if keyword.is_some() { + conditions.push(format!( + "(e.name ILIKE ${} OR e.uuid::text ILIKE ${})", + param_index, param_index + )); + param_index += 1; + } + + // 标签过滤 + if let Some(tags) = tag_uuids { + if !tags.is_empty() { + conditions.push(format!("et.tag_uuid = ANY(${})", param_index)); + } + } + + // 添加所有条件 + for condition in conditions { + query.push_str(&format!(" AND {}", condition)); + } + + // 构建查询 + let mut sql_query = sqlx::query_scalar::<_, i64>(&query).bind(workspace_uuid).bind(team_uuid); + + // 绑定参数 + if let Some(g) = group_uuid { + sql_query = sql_query.bind(g); + } + if let Some(s) = status { + sql_query = sql_query.bind(s); + } + if let Some(k) = keyword { + let search_pattern = format!("%{}%", k); + sql_query = sql_query.bind(search_pattern); + } + if let Some(tags) = tag_uuids { + if !tags.is_empty() { + sql_query = sql_query.bind(tags); + } + } + + let count = sql_query.fetch_one(pool).await?; + + Ok(count) +} + +/// 根据 UUID 查询环境(不带工作空间过滤,用于内部查询) +pub async fn fetch_environment_by_uuid_unfiltered( + pool: &Pool, + env_uuid: Uuid, +) -> Result, Error> { + let rec = sqlx::query_as::<_, EnvironmentDto>( + r#" + SELECT id, uuid, workspace_uuid, user_uuid, team_uuid, name, description, + status, group_uuid, proxy_uuid, system_info, kernel_info, fingerprint_summary, + last_opened_at, created_at, updated_at, deleted_at + FROM environments + WHERE uuid = $1 AND deleted_at IS NULL + "#, + ) + .bind(env_uuid) + .fetch_optional(pool) + .await?; + + Ok(rec) +} + +/// 根据 UUID 查询环境(带工作空间过滤) +pub async fn fetch_environment_by_uuid( + pool: &Pool, + workspace_uuid: Uuid, + env_uuid: Uuid, +) -> Result, Error> { + let rec = sqlx::query_as::<_, EnvironmentDto>( + r#" + SELECT id, uuid, workspace_uuid, user_uuid, team_uuid, name, description, + status, group_uuid, proxy_uuid, system_info, kernel_info, fingerprint_summary, + last_opened_at, created_at, updated_at, deleted_at + FROM environments + WHERE uuid = $1 AND workspace_uuid = $2 AND deleted_at IS NULL + "#, + ) + .bind(env_uuid) + .bind(workspace_uuid) + .fetch_optional(pool) + .await?; + + Ok(rec) +} + +/// 更新环境基础信息 +pub async fn update_environment( + pool: &Pool, + env_uuid: Uuid, + name: Option<&str>, + description: Option<&str>, + group_uuid: Option, +) -> Result<(), Error> { + sqlx::query( + r#" + UPDATE environments + SET name = COALESCE($1, name), + description = COALESCE($2, description), + group_uuid = $3 + WHERE uuid = $4 AND deleted_at IS NULL + "#, + ) + .bind(name) + .bind(description) + .bind(group_uuid) + .bind(env_uuid) + .execute(pool) + .await?; + + Ok(()) +} + +/// 更新环境状态 +pub async fn update_environment_status( + pool: &Pool, + env_uuid: Uuid, + status: &str, +) -> Result<(), Error> { + sqlx::query( + r#" + UPDATE environments SET status = $1 + WHERE uuid = $2 AND deleted_at IS NULL + "#, + ) + .bind(status) + .bind(env_uuid) + .execute(pool) + .await?; + + Ok(()) +} + +/// 更新环境代理 +pub async fn update_environment_proxy( + pool: &Pool, + env_uuid: Uuid, + proxy_uuid: Option, +) -> Result<(), Error> { + sqlx::query( + r#" + UPDATE environments SET proxy_uuid = $1 + WHERE uuid = $2 AND deleted_at IS NULL + "#, + ) + .bind(proxy_uuid) + .bind(env_uuid) + .execute(pool) + .await?; + + Ok(()) +} + +/// 更新环境最后打开时间 +pub async fn update_environment_last_opened( + pool: &Pool, + env_uuid: Uuid, +) -> Result<(), Error> { + sqlx::query( + r#" + UPDATE environments SET last_opened_at = CURRENT_TIMESTAMP + WHERE uuid = $1 AND deleted_at IS NULL + "#, + ) + .bind(env_uuid) + .execute(pool) + .await?; + + Ok(()) +} + +/// 软删除环境 +pub async fn delete_environment(pool: &Pool, env_uuid: Uuid) -> Result<(), Error> { + sqlx::query( + r#" + UPDATE environments SET deleted_at = CURRENT_TIMESTAMP + WHERE uuid = $1 AND deleted_at IS NULL + "#, + ) + .bind(env_uuid) + .execute(pool) + .await?; + + Ok(()) +} + +/// 批量软删除环境 +pub async fn batch_delete_environments( + pool: &Pool, + env_uuids: &[Uuid], +) -> Result { + let result = sqlx::query( + r#" + UPDATE environments SET deleted_at = CURRENT_TIMESTAMP + WHERE uuid = ANY($1) AND deleted_at IS NULL + "#, + ) + .bind(env_uuids) + .execute(pool) + .await?; + + Ok(result.rows_affected()) +} + +// ============ Recycle Bin ============ + +/// 查询回收站环境列表(已删除但未永久删除) +pub async fn fetch_deleted_environments_base( + pool: &Pool, + workspace_uuid: Uuid, + team_uuid: Uuid, + group_uuid: Option, + keyword: Option<&str>, + offset: i64, + limit: i64, +) -> Result, Error> { + let mut query = String::from( + r#" + SELECT DISTINCT + e.id, e.uuid, e.workspace_uuid, e.user_uuid, e.team_uuid, e.name, e.description, e.status, + e.system_info, e.kernel_info, e.fingerprint_summary, + e.group_uuid, e.proxy_uuid, + e.last_opened_at, e.created_at, e.updated_at, e.deleted_at + FROM environments e + WHERE e.workspace_uuid = $1 + AND e.team_uuid = $2 + AND e.deleted_at IS NOT NULL + "#, + ); + + let mut param_index = 3; + let mut conditions = Vec::new(); + + // 分组过滤 + if group_uuid.is_some() { + conditions.push(format!("e.group_uuid = ${}", param_index)); + param_index += 1; + } + + // 关键词搜索 + if keyword.is_some() { + conditions.push(format!( + "(e.name ILIKE ${} OR e.uuid::text ILIKE ${})", + param_index, param_index + )); + param_index += 1; + } + + if !conditions.is_empty() { + query.push_str(" AND "); + query.push_str(&conditions.join(" AND ")); + } + + query.push_str(" ORDER BY e.deleted_at DESC LIMIT $"); + query.push_str(¶m_index.to_string()); + param_index += 1; + query.push_str(" OFFSET $"); + query.push_str(¶m_index.to_string()); + + let mut q = sqlx::query_as::<_, EnvironmentRowDto>(&query) + .bind(workspace_uuid) + .bind(team_uuid); + + if let Some(gid) = group_uuid { + q = q.bind(gid); + } + + if let Some(kw) = keyword { + let pattern = format!("%{}%", kw); + q = q.bind(pattern); + } + + q = q.bind(limit).bind(offset); + + let recs = q.fetch_all(pool).await?; + Ok(recs) +} + +/// 统计回收站环境总数 +pub async fn fetch_deleted_environments_count( + pool: &Pool, + workspace_uuid: Uuid, + team_uuid: Uuid, + group_uuid: Option, + keyword: Option<&str>, +) -> Result { + let mut query = String::from( + r#" + SELECT COUNT(DISTINCT e.id) + FROM environments e + WHERE e.workspace_uuid = $1 + AND e.team_uuid = $2 + AND e.deleted_at IS NOT NULL + "#, + ); + + let mut param_index = 3; + let mut conditions = Vec::new(); + + if group_uuid.is_some() { + conditions.push(format!("e.group_uuid = ${}", param_index)); + param_index += 1; + } + + if keyword.is_some() { + conditions.push(format!( + "(e.name ILIKE ${} OR e.uuid::text ILIKE ${})", + param_index, param_index + )); + param_index += 1; + } + + if !conditions.is_empty() { + query.push_str(" AND "); + query.push_str(&conditions.join(" AND ")); + } + + let mut q = sqlx::query_scalar::<_, i64>(&query).bind(workspace_uuid).bind(team_uuid); + + if let Some(gid) = group_uuid { + q = q.bind(gid); + } + + if let Some(kw) = keyword { + let pattern = format!("%{}%", kw); + q = q.bind(pattern); + } + + let count = q.fetch_one(pool).await?; + Ok(count) +} + +/// 恢复环境(将 deleted_at 设为 NULL) +pub async fn restore_environment(pool: &Pool, env_uuid: Uuid) -> Result<(), Error> { + sqlx::query( + r#" + UPDATE environments SET deleted_at = NULL + WHERE uuid = $1 AND deleted_at IS NOT NULL + "#, + ) + .bind(env_uuid) + .execute(pool) + .await?; + + Ok(()) +} + +/// 批量恢复环境 +pub async fn batch_restore_environments( + pool: &Pool, + env_uuids: &[Uuid], +) -> Result { + let result = sqlx::query( + r#" + UPDATE environments SET deleted_at = NULL + WHERE uuid = ANY($1) AND deleted_at IS NOT NULL + "#, + ) + .bind(env_uuids) + .execute(pool) + .await?; + + Ok(result.rows_affected()) +} + +/// 永久删除环境(真正的 DELETE) +pub async fn permanent_delete_environment( + pool: &Pool, + env_uuid: Uuid, +) -> Result<(), Error> { + // 先删除关联数据 + sqlx::query("DELETE FROM environment_tags WHERE environment_uuid = $1") + .bind(env_uuid) + .execute(pool) + .await?; + + sqlx::query("DELETE FROM environment_urls WHERE environment_uuid = $1") + .bind(env_uuid) + .execute(pool) + .await?; + + sqlx::query("DELETE FROM environment_cookies WHERE environment_uuid = $1") + .bind(env_uuid) + .execute(pool) + .await?; + + sqlx::query("DELETE FROM environment_configs WHERE environment_uuid = $1") + .bind(env_uuid) + .execute(pool) + .await?; + + sqlx::query("DELETE FROM environment_accounts WHERE environment_uuid = $1") + .bind(env_uuid) + .execute(pool) + .await?; + + sqlx::query("DELETE FROM environment_extensions WHERE environment_uuid = $1") + .bind(env_uuid) + .execute(pool) + .await?; + + // 最后删除环境本身 + sqlx::query("DELETE FROM environments WHERE uuid = $1") + .bind(env_uuid) + .execute(pool) + .await?; + + Ok(()) +} + +/// 批量永久删除环境 +pub async fn batch_permanent_delete_environments( + pool: &Pool, + env_uuids: &[Uuid], +) -> Result { + // 先删除关联数据 + sqlx::query("DELETE FROM environment_tags WHERE environment_uuid = ANY($1)") + .bind(env_uuids) + .execute(pool) + .await?; + + sqlx::query("DELETE FROM environment_urls WHERE environment_uuid = ANY($1)") + .bind(env_uuids) + .execute(pool) + .await?; + + sqlx::query("DELETE FROM environment_cookies WHERE environment_uuid = ANY($1)") + .bind(env_uuids) + .execute(pool) + .await?; + + sqlx::query("DELETE FROM environment_configs WHERE environment_uuid = ANY($1)") + .bind(env_uuids) + .execute(pool) + .await?; + + sqlx::query("DELETE FROM environment_accounts WHERE environment_uuid = ANY($1)") + .bind(env_uuids) + .execute(pool) + .await?; + + sqlx::query("DELETE FROM environment_extensions WHERE environment_uuid = ANY($1)") + .bind(env_uuids) + .execute(pool) + .await?; + + // 最后删除环境本身 + let result = sqlx::query("DELETE FROM environments WHERE uuid = ANY($1)") + .bind(env_uuids) + .execute(pool) + .await?; + + Ok(result.rows_affected()) +} + +// ============ Environment Configs ============ + +/// 创建或更新环境配置 +pub async fn upsert_environment_config( + pool: &Pool, + env_uuid: Uuid, + window_info: &serde_json::Value, + basic_settings: &serde_json::Value, + fingerprint_settings: &serde_json::Value, + device_settings: &serde_json::Value, + preference_settings: &serde_json::Value, + project_metadata: &serde_json::Value, +) -> Result { + let id: i32 = sqlx::query_scalar( + r#" + INSERT INTO environment_configs (environment_uuid, window_info, basic_settings, + fingerprint_settings, device_settings, preference_settings, + project_metadata) + VALUES ($1, $2, $3, $4, $5, $6, $7) + ON CONFLICT (environment_uuid) DO UPDATE SET + window_info = $2, + basic_settings = $3, + fingerprint_settings = $4, + device_settings = $5, + preference_settings = $6, + project_metadata = $7 + RETURNING id; + "#, + ) + .bind(env_uuid) + .bind(window_info) + .bind(basic_settings) + .bind(fingerprint_settings) + .bind(device_settings) + .bind(preference_settings) + .bind(project_metadata) + .fetch_one(pool) + .await?; + + Ok(id) +} + +/// 查询环境配置 +pub async fn fetch_environment_config( + pool: &Pool, + env_uuid: Uuid, +) -> Result, Error> { + let rec = sqlx::query_as::<_, EnvironmentConfigDto>( + r#" + SELECT id, environment_uuid, window_info, basic_settings, fingerprint_settings, + device_settings, preference_settings, project_metadata, created_at, updated_at + FROM environment_configs + WHERE environment_uuid = $1 + "#, + ) + .bind(env_uuid) + .fetch_optional(pool) + .await?; + + Ok(rec) +} + +/// 批量查询环境配置 +pub async fn fetch_environment_configs_by_uuids( + pool: &Pool, + env_uuids: &[Uuid], +) -> Result, Error> { + if env_uuids.is_empty() { + return Ok(vec![]); + } + + let recs = sqlx::query_as::<_, EnvironmentConfigDto>( + r#" + SELECT id, environment_uuid, window_info, basic_settings, fingerprint_settings, + device_settings, preference_settings, project_metadata, created_at, updated_at + FROM environment_configs + WHERE environment_uuid = ANY($1) + "#, + ) + .bind(env_uuids) + .fetch_all(pool) + .await?; + + Ok(recs) +} + +// ============ Environment Tags ============ + +/// 为环境添加标签 +pub async fn insert_environment_tag( + pool: &Pool, + env_uuid: Uuid, + tag_uuid: Uuid, +) -> Result<(), Error> { + sqlx::query( + r#" + INSERT INTO environment_tags (environment_uuid, tag_uuid) + VALUES ($1, $2) + ON CONFLICT DO NOTHING; + "#, + ) + .bind(env_uuid) + .bind(tag_uuid) + .execute(pool) + .await?; + + Ok(()) +} + +/// 移除环境的标签 +pub async fn remove_environment_tag( + pool: &Pool, + env_uuid: Uuid, + tag_uuid: Uuid, +) -> Result<(), Error> { + sqlx::query( + r#" + DELETE FROM environment_tags + WHERE environment_uuid = $1 AND tag_uuid = $2; + "#, + ) + .bind(env_uuid) + .bind(tag_uuid) + .execute(pool) + .await?; + + Ok(()) +} + +/// 清除环境的所有标签 +pub async fn clear_environment_tags(pool: &Pool, env_uuid: Uuid) -> Result<(), Error> { + sqlx::query( + r#" + DELETE FROM environment_tags + WHERE environment_uuid = $1; + "#, + ) + .bind(env_uuid) + .execute(pool) + .await?; + + Ok(()) +} + +/// 查询环境的所有标签 +pub async fn fetch_environment_tags( + pool: &Pool, + env_uuid: Uuid, +) -> Result, Error> { + let recs = sqlx::query_as::<_, TagDto>( + r#" + SELECT t.id, t.uuid, t.user_uuid, t.team_uuid, t.name, t.color, t.sort_order, + t.environments_count, t.created_at, t.updated_at, t.deleted_at + FROM tags t + INNER JOIN environment_tags et ON t.uuid = et.tag_uuid + WHERE et.environment_uuid = $1 AND t.deleted_at IS NULL + ORDER BY t.sort_order, t.name + "#, + ) + .bind(env_uuid) + .fetch_all(pool) + .await?; + + Ok(recs) +} + +/// 批量查询环境标签(完整标签信息) +pub async fn fetch_tags_for_environments( + pool: &Pool, + env_uuids: &[Uuid], +) -> Result, Error> { + if env_uuids.is_empty() { + return Ok(vec![]); + } + + let recs = sqlx::query_as::<_, EnvironmentTagRowDto>( + r#" + SELECT + et.environment_uuid, + t.id as tag_id, + t.uuid as tag_uuid, + t.name as tag_name, + t.color as tag_color, + t.sort_order as tag_sort_order, + t.user_uuid as tag_user_uuid, + t.team_uuid as tag_team_uuid, + t.environments_count as tag_environments_count, + t.created_at as tag_created_at, + t.updated_at as tag_updated_at, + t.deleted_at as tag_deleted_at + FROM environment_tags et + INNER JOIN tags t ON et.tag_uuid = t.uuid + WHERE et.environment_uuid = ANY($1) AND t.deleted_at IS NULL + ORDER BY t.sort_order, t.name + "#, + ) + .bind(env_uuids) + .fetch_all(pool) + .await?; + + Ok(recs) +} + +/// 批量查询环境账号(完整账号信息,排除敏感数据) +pub async fn fetch_accounts_for_environments( + pool: &Pool, + env_uuids: &[Uuid], +) -> Result, Error> { + if env_uuids.is_empty() { + return Ok(vec![]); + } + + let recs = sqlx::query_as::<_, EnvironmentAccountRowDto>( + r#" + SELECT + ea.environment_uuid, + pa.id as account_id, + pa.uuid as account_uuid, + pa.platform_url, + pa.platform_name, + pa.account, + pa.status as account_status, + pa.remark + FROM environment_accounts ea + INNER JOIN platform_accounts pa ON ea.account_uuid = pa.uuid + WHERE ea.environment_uuid = ANY($1) AND pa.deleted_at IS NULL + ORDER BY ea.sort_order + "#, + ) + .bind(env_uuids) + .fetch_all(pool) + .await?; + + Ok(recs) +} + +/// 批量查询分组 +pub async fn fetch_groups_by_uuids( + pool: &Pool, + group_uuids: &[Uuid], +) -> Result, Error> { + if group_uuids.is_empty() { + return Ok(vec![]); + } + + let recs = sqlx::query_as::<_, GroupRowDto>( + r#" + SELECT id, uuid, name, description, sort_order + FROM groups + WHERE uuid = ANY($1) AND deleted_at IS NULL + "#, + ) + .bind(group_uuids) + .fetch_all(pool) + .await?; + + Ok(recs) +} + +/// 批量查询代理 +pub async fn fetch_proxies_by_uuids( + pool: &Pool, + proxy_uuids: &[Uuid], +) -> Result, Error> { + if proxy_uuids.is_empty() { + return Ok(vec![]); + } + + let recs = sqlx::query_as::<_, ProxyRowDto>( + r#" + SELECT id, uuid, name, host, port, proxy_type, + username, password, + country, city, status, latency, last_check_ip + FROM proxies + WHERE uuid = ANY($1) AND deleted_at IS NULL + "#, + ) + .bind(proxy_uuids) + .fetch_all(pool) + .await?; + + Ok(recs) +} + +// ============ Templates ============ + +/// 创建模板 +pub async fn insert_template( + pool: &Pool, + user_uuid: Uuid, + team_uuid: Option, + name: &str, + description: Option<&str>, + is_public: bool, + system_info: Option<&str>, + kernel_info: Option<&str>, + config_json: &serde_json::Value, +) -> Result { + let uuid: Uuid = sqlx::query_scalar( + r#" + INSERT INTO templates (user_uuid, team_uuid, name, description, is_public, + system_info, kernel_info, config_json) + VALUES ($1, $2, $3, $4, $5, $6, $7, $8) + RETURNING uuid; + "#, + ) + .bind(user_uuid) + .bind(team_uuid) + .bind(name) + .bind(description) + .bind(is_public) + .bind(system_info) + .bind(kernel_info) + .bind(config_json) + .fetch_one(pool) + .await?; + + Ok(uuid) +} + +/// 查询模板列表 +pub async fn fetch_templates( + pool: &Pool, + team_uuid: Option, + user_uuid: Uuid, + is_public: Option, + offset: i64, + limit: i64, +) -> Result, Error> { + let recs = sqlx::query_as::<_, TemplateDto>( + r#" + SELECT id, uuid, user_uuid, team_uuid, name, description, is_public, + system_info, kernel_info, config_json, usage_count, created_at, updated_at, deleted_at + FROM templates + WHERE (team_uuid = $1 OR (team_uuid IS NULL AND user_uuid = $2) OR is_public = TRUE) + AND ($3::boolean IS NULL OR is_public = $3) + AND deleted_at IS NULL + ORDER BY usage_count DESC, created_at DESC + LIMIT $4 OFFSET $5 + "#, + ) + .bind(team_uuid) + .bind(user_uuid) + .bind(is_public) + .bind(limit) + .bind(offset) + .fetch_all(pool) + .await?; + + Ok(recs) +} + +/// 查询模板总数 +pub async fn fetch_templates_count( + pool: &Pool, + team_uuid: Option, + user_uuid: Uuid, + is_public: Option, +) -> Result { + let count: i64 = sqlx::query_scalar( + r#" + SELECT COUNT(*) + FROM templates + WHERE (team_uuid = $1 OR (team_uuid IS NULL AND user_uuid = $2) OR is_public = TRUE) + AND ($3::boolean IS NULL OR is_public = $3) + AND deleted_at IS NULL + "#, + ) + .bind(team_uuid) + .bind(user_uuid) + .bind(is_public) + .fetch_one(pool) + .await?; + + Ok(count) +} + +/// 根据 UUID 查询模板 +pub async fn fetch_template_by_uuid( + pool: &Pool, + template_uuid: Uuid, +) -> Result, Error> { + let rec = sqlx::query_as::<_, TemplateDto>( + r#" + SELECT id, uuid, user_uuid, team_uuid, name, description, is_public, + system_info, kernel_info, config_json, usage_count, created_at, updated_at, deleted_at + FROM templates + WHERE uuid = $1 AND deleted_at IS NULL + "#, + ) + .bind(template_uuid) + .fetch_optional(pool) + .await?; + + Ok(rec) +} + +/// 更新模板 +pub async fn update_template( + pool: &Pool, + template_uuid: Uuid, + name: Option<&str>, + description: Option<&str>, + is_public: Option, + config_json: Option<&serde_json::Value>, +) -> Result<(), Error> { + sqlx::query( + r#" + UPDATE templates + SET name = COALESCE($1, name), + description = COALESCE($2, description), + is_public = COALESCE($3, is_public), + config_json = COALESCE($4, config_json) + WHERE uuid = $5 AND deleted_at IS NULL + "#, + ) + .bind(name) + .bind(description) + .bind(is_public) + .bind(config_json) + .bind(template_uuid) + .execute(pool) + .await?; + + Ok(()) +} + +/// 增加模板使用次数 +pub async fn increment_template_usage( + pool: &Pool, + template_uuid: Uuid, +) -> Result<(), Error> { + sqlx::query( + r#" + UPDATE templates SET usage_count = usage_count + 1 + WHERE uuid = $1 AND deleted_at IS NULL + "#, + ) + .bind(template_uuid) + .execute(pool) + .await?; + + Ok(()) +} + +/// 软删除模板 +pub async fn delete_template(pool: &Pool, template_uuid: Uuid) -> Result<(), Error> { + sqlx::query( + r#" + UPDATE templates SET deleted_at = CURRENT_TIMESTAMP + WHERE uuid = $1 AND deleted_at IS NULL + "#, + ) + .bind(template_uuid) + .execute(pool) + .await?; + + Ok(()) +} + +// ============ Environment URLs ============ + +/// 添加环境 URL +pub async fn insert_environment_url( + pool: &Pool, + env_uuid: Uuid, + url: &str, + title: Option<&str>, + sort_order: Option, +) -> Result { + let id: i32 = sqlx::query_scalar( + r#" + INSERT INTO environment_urls (environment_uuid, url, title, sort_order) + VALUES ($1, $2, $3, $4) + RETURNING id; + "#, + ) + .bind(env_uuid) + .bind(url) + .bind(title) + .bind(sort_order.unwrap_or(0)) + .fetch_one(pool) + .await?; + + Ok(id) +} + +/// 批量添加环境 URL +pub async fn batch_insert_environment_urls( + pool: &Pool, + env_uuid: Uuid, + urls: &[(String, Option)], +) -> Result { + let mut count = 0; + for (idx, (url, title)) in urls.iter().enumerate() { + sqlx::query( + r#" + INSERT INTO environment_urls (environment_uuid, url, title, sort_order) + VALUES ($1, $2, $3, $4) + ON CONFLICT DO NOTHING; + "#, + ) + .bind(env_uuid) + .bind(url) + .bind(title.as_deref()) + .bind(idx as i32) + .execute(pool) + .await?; + count += 1; + } + Ok(count) +} + +/// 查询环境的所有 URL +pub async fn fetch_environment_urls( + pool: &Pool, + env_uuid: Uuid, +) -> Result, Error> { + let recs = sqlx::query_as::<_, EnvironmentUrlDto>( + r#" + SELECT id, environment_uuid, url, title, sort_order, created_at + FROM environment_urls + WHERE environment_uuid = $1 + ORDER BY sort_order, id + "#, + ) + .bind(env_uuid) + .fetch_all(pool) + .await?; + + Ok(recs) +} + +/// 删除环境的 URL +pub async fn delete_environment_url(pool: &Pool, url_id: i32) -> Result<(), Error> { + sqlx::query( + r#" + DELETE FROM environment_urls WHERE id = $1 + "#, + ) + .bind(url_id) + .execute(pool) + .await?; + + Ok(()) +} + +/// 清空环境的所有 URL +pub async fn clear_environment_urls(pool: &Pool, env_uuid: Uuid) -> Result { + let result = sqlx::query( + r#" + DELETE FROM environment_urls WHERE environment_uuid = $1 + "#, + ) + .bind(env_uuid) + .execute(pool) + .await?; + + Ok(result.rows_affected()) +} + +// ============ Environment Cookies ============ + +/// 添加环境 Cookie +pub async fn insert_environment_cookie( + pool: &Pool, + env_uuid: Uuid, + domain: &str, + name: &str, + value: &str, + path: Option<&str>, + http_only: Option, + secure: Option, + same_site: Option<&str>, +) -> Result { + let id: i32 = sqlx::query_scalar( + r#" + INSERT INTO environment_cookies (environment_uuid, domain, name, value, path, http_only, secure, same_site) + VALUES ($1, $2, $3, $4, $5, $6, $7, $8) + RETURNING id; + "#, + ) + .bind(env_uuid) + .bind(domain) + .bind(name) + .bind(value) + .bind(path.unwrap_or("/")) + .bind(http_only.unwrap_or(false)) + .bind(secure.unwrap_or(false)) + .bind(same_site.unwrap_or("Lax")) + .fetch_one(pool) + .await?; + + Ok(id) +} + +/// 批量添加环境 Cookies +pub async fn batch_insert_environment_cookies( + pool: &Pool, + env_uuid: Uuid, + cookies: &[CookieInput], +) -> Result { + let mut count = 0; + for cookie in cookies { + sqlx::query( + r#" + INSERT INTO environment_cookies (environment_uuid, domain, name, value, path, http_only, secure, same_site) + VALUES ($1, $2, $3, $4, $5, $6, $7, $8) + ON CONFLICT DO NOTHING; + "#, + ) + .bind(env_uuid) + .bind(&cookie.domain) + .bind(&cookie.name) + .bind(&cookie.value) + .bind(cookie.path.as_deref().unwrap_or("/")) + .bind(cookie.http_only.unwrap_or(false)) + .bind(cookie.secure.unwrap_or(false)) + .bind(cookie.same_site.as_deref().unwrap_or("Lax")) + .execute(pool) + .await?; + count += 1; + } + Ok(count) +} + +/// 查询环境的所有 Cookies +pub async fn fetch_environment_cookies( + pool: &Pool, + env_uuid: Uuid, +) -> Result, Error> { + let recs = sqlx::query_as::<_, EnvironmentCookieDto>( + r#" + SELECT id, environment_uuid, domain, name, value, path, expires_at, http_only, secure, same_site, created_at + FROM environment_cookies + WHERE environment_uuid = $1 + ORDER BY domain, name + "#, + ) + .bind(env_uuid) + .fetch_all(pool) + .await?; + + Ok(recs) +} + +/// 删除环境的 Cookie +pub async fn delete_environment_cookie(pool: &Pool, cookie_id: i32) -> Result<(), Error> { + sqlx::query( + r#" + DELETE FROM environment_cookies WHERE id = $1 + "#, + ) + .bind(cookie_id) + .execute(pool) + .await?; + + Ok(()) +} + +/// 清空环境的所有 Cookies +pub async fn clear_environment_cookies( + pool: &Pool, + env_uuid: Uuid, +) -> Result { + let result = sqlx::query( + r#" + DELETE FROM environment_cookies WHERE environment_uuid = $1 + "#, + ) + .bind(env_uuid) + .execute(pool) + .await?; + + Ok(result.rows_affected()) +} diff --git a/src/models/proxies.rs b/src/models/proxies.rs index fa6cbad..dfb4223 100644 --- a/src/models/proxies.rs +++ b/src/models/proxies.rs @@ -1,302 +1,302 @@ -use sqlx::{Error, Pool, Postgres}; -use uuid::Uuid; - -use crate::dto::{ProxyDto, ProxyHealthCheckDto}; - -/// 创建代理 -pub async fn insert_proxy( - pool: &Pool, - workspace_uuid: Uuid, - owner_uuid: Uuid, - name: &str, - host: &str, - port: i32, - proxy_type: &str, - username: Option<&str>, - password_encrypted: Option<&str>, - country: Option<&str>, - city: Option<&str>, -) -> Result { - let uuid: Uuid = sqlx::query_scalar( - r#" - INSERT INTO proxies (workspace_uuid, owner_uuid, name, host, port, proxy_type, - username, password_encrypted, country, city) - VALUES ($1, $2, $3, $4, $5, $6, $7, $8, $9, $10) - RETURNING uuid; - "#, - ) - .bind(workspace_uuid) - .bind(owner_uuid) - .bind(name) - .bind(host) - .bind(port) - .bind(proxy_type) - .bind(username) - .bind(password_encrypted) - .bind(country) - .bind(city) - .fetch_one(pool) - .await?; - - Ok(uuid) -} - -/// 查询代理列表 -pub async fn fetch_proxies( - pool: &Pool, - workspace_uuid: Uuid, - proxy_type: Option<&str>, - status: Option<&str>, - offset: i64, - limit: i64, -) -> Result, Error> { - let recs = sqlx::query_as::<_, ProxyDto>( - r#" - SELECT p.id, p.uuid, p.workspace_uuid, p.owner_uuid, p.name, p.host, p.port, p.proxy_type, - p.username, p.password_encrypted, p.ssh_key_encrypted, p.ssh_passphrase_encrypted, - p.country, p.city, p.status, p.latency, p.last_check_ip, p.last_checked_at, - (SELECT COUNT(*) FROM environments e WHERE e.proxy_uuid = p.uuid AND e.deleted_at IS NULL) AS environments_count, - p.created_at, p.updated_at, p.deleted_at - FROM proxies p - WHERE p.workspace_uuid = $1 - AND ($2::varchar IS NULL OR p.proxy_type = $2) - AND ($3::varchar IS NULL OR p.status = $3) - AND p.deleted_at IS NULL - ORDER BY p.created_at DESC - LIMIT $5 OFFSET $6 - "#, - ) - .bind(workspace_uuid) - .bind(proxy_type) - .bind(status) - .bind(limit) - .bind(offset) - .fetch_all(pool) - .await?; - - Ok(recs) -} - -/// 查询代理总数 -pub async fn fetch_proxies_count( - pool: &Pool, - workspace_uuid: Uuid, - proxy_type: Option<&str>, - status: Option<&str>, -) -> Result { - let count: i64 = sqlx::query_scalar( - r#" - SELECT COUNT(*) FROM proxies - WHERE workspace_uuid = $1 - AND ($2::varchar IS NULL OR proxy_type = $2) - AND ($3::varchar IS NULL OR status = $3) - AND deleted_at IS NULL - "#, - ) - .bind(workspace_uuid) - .bind(proxy_type) - .bind(status) - .fetch_one(pool) - .await?; - - Ok(count) -} - -/// 根据 UUID 查询代理 -pub async fn fetch_proxy_by_uuid( - pool: &Pool, - proxy_uuid: Uuid, -) -> Result, Error> { - let rec = sqlx::query_as::<_, ProxyDto>( - r#" - SELECT p.id, p.uuid, p.workspace_uuid, p.owner_uuid, p.name, p.host, p.port, p.proxy_type, - p.username, p.password_encrypted, p.ssh_key_encrypted, p.ssh_passphrase_encrypted, - p.country, p.city, p.status, p.latency, p.last_check_ip, p.last_checked_at, - (SELECT COUNT(*) FROM environments e WHERE e.proxy_uuid = p.uuid AND e.deleted_at IS NULL) AS environments_count, - p.created_at, p.updated_at, p.deleted_at - FROM proxies p - WHERE p.uuid = $1 AND p.deleted_at IS NULL - "#, - ) - .bind(proxy_uuid) - .fetch_optional(pool) - .await?; - - Ok(rec) -} - -/// 更新代理 -pub async fn update_proxy( - pool: &Pool, - proxy_uuid: Uuid, - name: Option<&str>, - host: Option<&str>, - port: Option, - proxy_type: Option<&str>, - username: Option<&str>, - password_encrypted: Option<&str>, - country: Option<&str>, - city: Option<&str>, -) -> Result<(), Error> { - sqlx::query( - r#" - UPDATE proxies - SET name = COALESCE($1, name), - host = COALESCE($2, host), - port = COALESCE($3, port), - proxy_type = COALESCE($4, proxy_type), - username = COALESCE($5, username), - password_encrypted = COALESCE($6, password_encrypted), - country = COALESCE($7, country), - city = COALESCE($8, city) - WHERE uuid = $9 AND deleted_at IS NULL - "#, - ) - .bind(name) - .bind(host) - .bind(port) - .bind(proxy_type) - .bind(username) - .bind(password_encrypted) - .bind(country) - .bind(city) - .bind(proxy_uuid) - .execute(pool) - .await?; - - Ok(()) -} - -/// 更新代理检测结果 -pub async fn update_proxy_check_result( - pool: &Pool, - proxy_uuid: Uuid, - status: &str, - latency: Option, - ip_address: Option<&str>, - country: Option<&str>, - city: Option<&str>, -) -> Result<(), Error> { - sqlx::query( - r#" - UPDATE proxies - SET status = $1, - latency = $2, - last_check_ip = $3, - country = $4, - city = $5, - last_checked_at = CURRENT_TIMESTAMP - WHERE uuid = $6 AND deleted_at IS NULL - "#, - ) - .bind(status) - .bind(latency) - .bind(ip_address) - .bind(country) - .bind(city) - .bind(proxy_uuid) - .execute(pool) - .await?; - - Ok(()) -} - -/// 增加代理使用次数 -pub async fn increment_proxy_usage(pool: &Pool, proxy_uuid: Uuid) -> Result<(), Error> { - sqlx::query( - r#" - UPDATE proxies SET usage_count = usage_count + 1 - WHERE uuid = $1 AND deleted_at IS NULL - "#, - ) - .bind(proxy_uuid) - .execute(pool) - .await?; - - Ok(()) -} - -/// 软删除代理 -pub async fn delete_proxy(pool: &Pool, proxy_uuid: Uuid) -> Result<(), Error> { - sqlx::query( - r#" - UPDATE proxies SET deleted_at = CURRENT_TIMESTAMP - WHERE uuid = $1 AND deleted_at IS NULL - "#, - ) - .bind(proxy_uuid) - .execute(pool) - .await?; - - Ok(()) -} - -/// 批量软删除代理 -pub async fn batch_delete_proxies( - pool: &Pool, - proxy_uuids: &[Uuid], -) -> Result { - let result = sqlx::query( - r#" - UPDATE proxies SET deleted_at = CURRENT_TIMESTAMP - WHERE uuid = ANY($1) AND deleted_at IS NULL - "#, - ) - .bind(proxy_uuids) - .execute(pool) - .await?; - - Ok(result.rows_affected()) -} - -// ============ Proxy Health Checks ============ - -/// 记录代理健康检查 -pub async fn insert_proxy_health_check( - pool: &Pool, - proxy_uuid: Uuid, - status: &str, - latency: Option, - ip_address: Option<&str>, - error_message: Option<&str>, -) -> Result { - let id: i64 = sqlx::query_scalar( - r#" - INSERT INTO proxy_health_checks (proxy_uuid, status, latency, ip_address, error_message) - VALUES ($1, $2, $3, $4, $5) - RETURNING id; - "#, - ) - .bind(proxy_uuid) - .bind(status) - .bind(latency) - .bind(ip_address) - .bind(error_message) - .fetch_one(pool) - .await?; - - Ok(id) -} - -/// 查询代理健康检查历史 -pub async fn fetch_proxy_health_checks( - pool: &Pool, - proxy_uuid: Uuid, - limit: i64, -) -> Result, Error> { - let recs = sqlx::query_as::<_, ProxyHealthCheckDto>( - r#" - SELECT id, proxy_uuid, status, latency, ip_address, error_message, checked_at - FROM proxy_health_checks - WHERE proxy_uuid = $1 - ORDER BY checked_at DESC - LIMIT $2 - "#, - ) - .bind(proxy_uuid) - .bind(limit) - .fetch_all(pool) - .await?; - - Ok(recs) -} +use sqlx::{Error, Pool, Postgres}; +use uuid::Uuid; + +use crate::dto::{ProxyDto, ProxyHealthCheckDto}; + +/// 创建代理 +pub async fn insert_proxy( + pool: &Pool, + workspace_uuid: Uuid, + owner_uuid: Uuid, + name: &str, + host: &str, + port: i32, + proxy_type: &str, + username: Option<&str>, + password: Option<&str>, + country: Option<&str>, + city: Option<&str>, +) -> Result { + let uuid: Uuid = sqlx::query_scalar( + r#" + INSERT INTO proxies (workspace_uuid, owner_uuid, name, host, port, proxy_type, + username, password, country, city) + VALUES ($1, $2, $3, $4, $5, $6, $7, $8, $9, $10) + RETURNING uuid; + "#, + ) + .bind(workspace_uuid) + .bind(owner_uuid) + .bind(name) + .bind(host) + .bind(port) + .bind(proxy_type) + .bind(username) + .bind(password) + .bind(country) + .bind(city) + .fetch_one(pool) + .await?; + + Ok(uuid) +} + +/// 查询代理列表 +pub async fn fetch_proxies( + pool: &Pool, + workspace_uuid: Uuid, + proxy_type: Option<&str>, + status: Option<&str>, + offset: i64, + limit: i64, +) -> Result, Error> { + let recs = sqlx::query_as::<_, ProxyDto>( + r#" + SELECT p.id, p.uuid, p.workspace_uuid, p.owner_uuid, p.name, p.host, p.port, p.proxy_type, + p.username, p.password, p.ssh_key_encrypted, p.ssh_passphrase_encrypted, + p.country, p.city, p.status, p.latency, p.last_check_ip, p.last_checked_at, + (SELECT COUNT(*) FROM environments e WHERE e.proxy_uuid = p.uuid AND e.deleted_at IS NULL) AS environments_count, + p.created_at, p.updated_at, p.deleted_at + FROM proxies p + WHERE p.workspace_uuid = $1 + AND ($2::varchar IS NULL OR p.proxy_type = $2) + AND ($3::varchar IS NULL OR p.status = $3) + AND p.deleted_at IS NULL + ORDER BY p.created_at DESC + LIMIT $5 OFFSET $6 + "#, + ) + .bind(workspace_uuid) + .bind(proxy_type) + .bind(status) + .bind(limit) + .bind(offset) + .fetch_all(pool) + .await?; + + Ok(recs) +} + +/// 查询代理总数 +pub async fn fetch_proxies_count( + pool: &Pool, + workspace_uuid: Uuid, + proxy_type: Option<&str>, + status: Option<&str>, +) -> Result { + let count: i64 = sqlx::query_scalar( + r#" + SELECT COUNT(*) FROM proxies + WHERE workspace_uuid = $1 + AND ($2::varchar IS NULL OR proxy_type = $2) + AND ($3::varchar IS NULL OR status = $3) + AND deleted_at IS NULL + "#, + ) + .bind(workspace_uuid) + .bind(proxy_type) + .bind(status) + .fetch_one(pool) + .await?; + + Ok(count) +} + +/// 根据 UUID 查询代理 +pub async fn fetch_proxy_by_uuid( + pool: &Pool, + proxy_uuid: Uuid, +) -> Result, Error> { + let rec = sqlx::query_as::<_, ProxyDto>( + r#" + SELECT p.id, p.uuid, p.workspace_uuid, p.owner_uuid, p.name, p.host, p.port, p.proxy_type, + p.username, p.password, p.ssh_key_encrypted, p.ssh_passphrase_encrypted, + p.country, p.city, p.status, p.latency, p.last_check_ip, p.last_checked_at, + (SELECT COUNT(*) FROM environments e WHERE e.proxy_uuid = p.uuid AND e.deleted_at IS NULL) AS environments_count, + p.created_at, p.updated_at, p.deleted_at + FROM proxies p + WHERE p.uuid = $1 AND p.deleted_at IS NULL + "#, + ) + .bind(proxy_uuid) + .fetch_optional(pool) + .await?; + + Ok(rec) +} + +/// 更新代理 +pub async fn update_proxy( + pool: &Pool, + proxy_uuid: Uuid, + name: Option<&str>, + host: Option<&str>, + port: Option, + proxy_type: Option<&str>, + username: Option<&str>, + password: Option<&str>, + country: Option<&str>, + city: Option<&str>, +) -> Result<(), Error> { + sqlx::query( + r#" + UPDATE proxies + SET name = COALESCE($1, name), + host = COALESCE($2, host), + port = COALESCE($3, port), + proxy_type = COALESCE($4, proxy_type), + username = COALESCE($5, username), + password = COALESCE($6, password), + country = COALESCE($7, country), + city = COALESCE($8, city) + WHERE uuid = $9 AND deleted_at IS NULL + "#, + ) + .bind(name) + .bind(host) + .bind(port) + .bind(proxy_type) + .bind(username) + .bind(password) + .bind(country) + .bind(city) + .bind(proxy_uuid) + .execute(pool) + .await?; + + Ok(()) +} + +/// 更新代理检测结果 +pub async fn update_proxy_check_result( + pool: &Pool, + proxy_uuid: Uuid, + status: &str, + latency: Option, + ip_address: Option<&str>, + country: Option<&str>, + city: Option<&str>, +) -> Result<(), Error> { + sqlx::query( + r#" + UPDATE proxies + SET status = $1, + latency = $2, + last_check_ip = $3, + country = $4, + city = $5, + last_checked_at = CURRENT_TIMESTAMP + WHERE uuid = $6 AND deleted_at IS NULL + "#, + ) + .bind(status) + .bind(latency) + .bind(ip_address) + .bind(country) + .bind(city) + .bind(proxy_uuid) + .execute(pool) + .await?; + + Ok(()) +} + +/// 增加代理使用次数 +pub async fn increment_proxy_usage(pool: &Pool, proxy_uuid: Uuid) -> Result<(), Error> { + sqlx::query( + r#" + UPDATE proxies SET usage_count = usage_count + 1 + WHERE uuid = $1 AND deleted_at IS NULL + "#, + ) + .bind(proxy_uuid) + .execute(pool) + .await?; + + Ok(()) +} + +/// 软删除代理 +pub async fn delete_proxy(pool: &Pool, proxy_uuid: Uuid) -> Result<(), Error> { + sqlx::query( + r#" + UPDATE proxies SET deleted_at = CURRENT_TIMESTAMP + WHERE uuid = $1 AND deleted_at IS NULL + "#, + ) + .bind(proxy_uuid) + .execute(pool) + .await?; + + Ok(()) +} + +/// 批量软删除代理 +pub async fn batch_delete_proxies( + pool: &Pool, + proxy_uuids: &[Uuid], +) -> Result { + let result = sqlx::query( + r#" + UPDATE proxies SET deleted_at = CURRENT_TIMESTAMP + WHERE uuid = ANY($1) AND deleted_at IS NULL + "#, + ) + .bind(proxy_uuids) + .execute(pool) + .await?; + + Ok(result.rows_affected()) +} + +// ============ Proxy Health Checks ============ + +/// 记录代理健康检查 +pub async fn insert_proxy_health_check( + pool: &Pool, + proxy_uuid: Uuid, + status: &str, + latency: Option, + ip_address: Option<&str>, + error_message: Option<&str>, +) -> Result { + let id: i64 = sqlx::query_scalar( + r#" + INSERT INTO proxy_health_checks (proxy_uuid, status, latency, ip_address, error_message) + VALUES ($1, $2, $3, $4, $5) + RETURNING id; + "#, + ) + .bind(proxy_uuid) + .bind(status) + .bind(latency) + .bind(ip_address) + .bind(error_message) + .fetch_one(pool) + .await?; + + Ok(id) +} + +/// 查询代理健康检查历史 +pub async fn fetch_proxy_health_checks( + pool: &Pool, + proxy_uuid: Uuid, + limit: i64, +) -> Result, Error> { + let recs = sqlx::query_as::<_, ProxyHealthCheckDto>( + r#" + SELECT id, proxy_uuid, status, latency, ip_address, error_message, checked_at + FROM proxy_health_checks + WHERE proxy_uuid = $1 + ORDER BY checked_at DESC + LIMIT $2 + "#, + ) + .bind(proxy_uuid) + .bind(limit) + .fetch_all(pool) + .await?; + + Ok(recs) +} diff --git a/src/models/proxy_visible_teams.rs b/src/models/proxy_visible_teams.rs index 43560ca..401c19f 100644 --- a/src/models/proxy_visible_teams.rs +++ b/src/models/proxy_visible_teams.rs @@ -1,168 +1,168 @@ -use sqlx::{Error, Pool, Postgres}; -use uuid::Uuid; - -use crate::dto::{ProxyDto, ProxyVisibleTeamDto}; - -/// 添加代理可见团队 -pub async fn insert_proxy_visible_team( - pool: &Pool, - proxy_uuid: Uuid, - workspace_uuid: Uuid, - team_uuid: Uuid, -) -> Result<(), Error> { - sqlx::query( - r#" - INSERT INTO proxy_visible_teams (proxy_uuid, workspace_uuid, team_uuid) - VALUES ($1, $2, $3) - ON CONFLICT (proxy_uuid, team_uuid) DO NOTHING - "#, - ) - .bind(proxy_uuid) - .bind(workspace_uuid) - .bind(team_uuid) - .execute(pool) - .await?; - - Ok(()) -} - -/// 移除代理可见团队 -pub async fn remove_proxy_visible_team( - pool: &Pool, - proxy_uuid: Uuid, - team_uuid: Uuid, -) -> Result<(), Error> { - sqlx::query( - r#" - DELETE FROM proxy_visible_teams - WHERE proxy_uuid = $1 AND team_uuid = $2 - "#, - ) - .bind(proxy_uuid) - .bind(team_uuid) - .execute(pool) - .await?; - - Ok(()) -} - -/// 查询代理的可见团队列表 -pub async fn fetch_visible_teams_by_proxy( - pool: &Pool, - proxy_uuid: Uuid, -) -> Result, Error> { - let recs = sqlx::query_as::<_, ProxyVisibleTeamDto>( - r#" - SELECT proxy_uuid, workspace_uuid, team_uuid, created_at - FROM proxy_visible_teams - WHERE proxy_uuid = $1 - ORDER BY created_at - "#, - ) - .bind(proxy_uuid) - .fetch_all(pool) - .await?; - - Ok(recs) -} - -/// 查询团队可见的代理列表 -pub async fn fetch_visible_proxies_by_team( - pool: &Pool, - workspace_uuid: Uuid, - team_uuid: Uuid, -) -> Result, Error> { - let recs = sqlx::query_as::<_, ProxyDto>( - r#" - SELECT p.id, p.uuid, p.workspace_uuid, p.owner_uuid, p.name, p.host, p.port, p.proxy_type, - p.username, p.password_encrypted, p.ssh_key_encrypted, p.ssh_passphrase_encrypted, - p.country, p.city, p.status, p.latency, p.last_check_ip, p.last_checked_at, - (SELECT COUNT(*) FROM environments e WHERE e.proxy_uuid = p.uuid AND e.deleted_at IS NULL) AS environments_count, - p.created_at, p.updated_at, p.deleted_at - FROM proxies p - INNER JOIN proxy_visible_teams pvt ON p.uuid = pvt.proxy_uuid - WHERE pvt.workspace_uuid = $1 AND pvt.team_uuid = $2 - AND p.deleted_at IS NULL - ORDER BY p.created_at DESC - "#, - ) - .bind(workspace_uuid) - .bind(team_uuid) - .fetch_all(pool) - .await?; - - Ok(recs) -} - -/// 检查代理对团队是否可见 -pub async fn check_proxy_visibility( - pool: &Pool, - proxy_uuid: Uuid, - workspace_uuid: Uuid, - team_uuid: Uuid, -) -> Result { - let count: i64 = sqlx::query_scalar( - r#" - SELECT COUNT(*) FROM proxy_visible_teams - WHERE proxy_uuid = $1 AND workspace_uuid = $2 AND team_uuid = $3 - "#, - ) - .bind(proxy_uuid) - .bind(workspace_uuid) - .bind(team_uuid) - .fetch_one(pool) - .await?; - - Ok(count > 0) -} - -/// 查询工作空间所有可见的代理(包括工作空间 Owner 和代理所有者的代理) +use sqlx::{Error, Pool, Postgres}; +use uuid::Uuid; + +use crate::dto::{ProxyDto, ProxyVisibleTeamDto}; + +/// 添加代理可见团队 +pub async fn insert_proxy_visible_team( + pool: &Pool, + proxy_uuid: Uuid, + workspace_uuid: Uuid, + team_uuid: Uuid, +) -> Result<(), Error> { + sqlx::query( + r#" + INSERT INTO proxy_visible_teams (proxy_uuid, workspace_uuid, team_uuid) + VALUES ($1, $2, $3) + ON CONFLICT (proxy_uuid, team_uuid) DO NOTHING + "#, + ) + .bind(proxy_uuid) + .bind(workspace_uuid) + .bind(team_uuid) + .execute(pool) + .await?; + + Ok(()) +} + +/// 移除代理可见团队 +pub async fn remove_proxy_visible_team( + pool: &Pool, + proxy_uuid: Uuid, + team_uuid: Uuid, +) -> Result<(), Error> { + sqlx::query( + r#" + DELETE FROM proxy_visible_teams + WHERE proxy_uuid = $1 AND team_uuid = $2 + "#, + ) + .bind(proxy_uuid) + .bind(team_uuid) + .execute(pool) + .await?; + + Ok(()) +} + +/// 查询代理的可见团队列表 +pub async fn fetch_visible_teams_by_proxy( + pool: &Pool, + proxy_uuid: Uuid, +) -> Result, Error> { + let recs = sqlx::query_as::<_, ProxyVisibleTeamDto>( + r#" + SELECT proxy_uuid, workspace_uuid, team_uuid, created_at + FROM proxy_visible_teams + WHERE proxy_uuid = $1 + ORDER BY created_at + "#, + ) + .bind(proxy_uuid) + .fetch_all(pool) + .await?; + + Ok(recs) +} + +/// 查询团队可见的代理列表 +pub async fn fetch_visible_proxies_by_team( + pool: &Pool, + workspace_uuid: Uuid, + team_uuid: Uuid, +) -> Result, Error> { + let recs = sqlx::query_as::<_, ProxyDto>( + r#" + SELECT p.id, p.uuid, p.workspace_uuid, p.owner_uuid, p.name, p.host, p.port, p.proxy_type, + p.username, p.password, p.ssh_key_encrypted, p.ssh_passphrase_encrypted, + p.country, p.city, p.status, p.latency, p.last_check_ip, p.last_checked_at, + (SELECT COUNT(*) FROM environments e WHERE e.proxy_uuid = p.uuid AND e.deleted_at IS NULL) AS environments_count, + p.created_at, p.updated_at, p.deleted_at + FROM proxies p + INNER JOIN proxy_visible_teams pvt ON p.uuid = pvt.proxy_uuid + WHERE pvt.workspace_uuid = $1 AND pvt.team_uuid = $2 + AND p.deleted_at IS NULL + ORDER BY p.created_at DESC + "#, + ) + .bind(workspace_uuid) + .bind(team_uuid) + .fetch_all(pool) + .await?; + + Ok(recs) +} + +/// 检查代理对团队是否可见 +pub async fn check_proxy_visibility( + pool: &Pool, + proxy_uuid: Uuid, + workspace_uuid: Uuid, + team_uuid: Uuid, +) -> Result { + let count: i64 = sqlx::query_scalar( + r#" + SELECT COUNT(*) FROM proxy_visible_teams + WHERE proxy_uuid = $1 AND workspace_uuid = $2 AND team_uuid = $3 + "#, + ) + .bind(proxy_uuid) + .bind(workspace_uuid) + .bind(team_uuid) + .fetch_one(pool) + .await?; + + Ok(count > 0) +} + +/// 查询工作空间所有可见的代理(包括工作空间 Owner 和代理所有者的代理) pub async fn fetch_visible_proxies_for_user( pool: &Pool, workspace_uuid: Uuid, user_uuid: Uuid, team_uuid: Option, -) -> Result, Error> { - // 工作空间 Owner 可以看到所有代理 - // 代理所有者可以看到自己的代理 - // 团队成员可以看到 proxy_visible_teams 中包含其团队的代理 - let recs = sqlx::query_as::<_, ProxyDto>( - r#" - SELECT DISTINCT p.id, p.uuid, p.workspace_uuid, p.owner_uuid, p.name, p.host, p.port, p.proxy_type, - p.username, p.password_encrypted, p.ssh_key_encrypted, p.ssh_passphrase_encrypted, - p.country, p.city, p.status, p.latency, p.last_check_ip, p.last_checked_at, - (SELECT COUNT(*) FROM environments e WHERE e.proxy_uuid = p.uuid AND e.deleted_at IS NULL) AS environments_count, - p.created_at, p.updated_at, p.deleted_at - FROM proxies p - WHERE p.workspace_uuid = $1 - AND p.deleted_at IS NULL - AND ( - -- 工作空间 Owner - EXISTS ( - SELECT 1 FROM workspaces w - WHERE w.uuid = $1 AND w.owner_uuid = $2 AND w.deleted_at IS NULL - ) - -- 代理所有者 - OR p.owner_uuid = $2 - -- 团队成员可见的代理 - OR ( - $3 IS NOT NULL - AND EXISTS ( - SELECT 1 FROM proxy_visible_teams pvt - WHERE pvt.proxy_uuid = p.uuid - AND pvt.workspace_uuid = $1 - AND pvt.team_uuid = $3 - ) - ) - ) - ORDER BY p.created_at DESC - "#, - ) - .bind(workspace_uuid) - .bind(user_uuid) - .bind(team_uuid) - .fetch_all(pool) - .await?; +) -> Result, Error> { + // 工作空间 Owner 可以看到所有代理 + // 代理所有者可以看到自己的代理 + // 团队成员可以看到 proxy_visible_teams 中包含其团队的代理 + let recs = sqlx::query_as::<_, ProxyDto>( + r#" + SELECT DISTINCT p.id, p.uuid, p.workspace_uuid, p.owner_uuid, p.name, p.host, p.port, p.proxy_type, + p.username, p.password, p.ssh_key_encrypted, p.ssh_passphrase_encrypted, + p.country, p.city, p.status, p.latency, p.last_check_ip, p.last_checked_at, + (SELECT COUNT(*) FROM environments e WHERE e.proxy_uuid = p.uuid AND e.deleted_at IS NULL) AS environments_count, + p.created_at, p.updated_at, p.deleted_at + FROM proxies p + WHERE p.workspace_uuid = $1 + AND p.deleted_at IS NULL + AND ( + -- 工作空间 Owner + EXISTS ( + SELECT 1 FROM workspaces w + WHERE w.uuid = $1 AND w.owner_uuid = $2 AND w.deleted_at IS NULL + ) + -- 代理所有者 + OR p.owner_uuid = $2 + -- 团队成员可见的代理 + OR ( + $3 IS NOT NULL + AND EXISTS ( + SELECT 1 FROM proxy_visible_teams pvt + WHERE pvt.proxy_uuid = p.uuid + AND pvt.workspace_uuid = $1 + AND pvt.team_uuid = $3 + ) + ) + ) + ORDER BY p.created_at DESC + "#, + ) + .bind(workspace_uuid) + .bind(user_uuid) + .bind(team_uuid) + .fetch_all(pool) + .await?; Ok(recs) } @@ -185,7 +185,7 @@ pub async fn fetch_visible_proxies_for_user_paginated( let recs = sqlx::query_as::<_, ProxyDto>( r#" SELECT DISTINCT p.id, p.uuid, p.workspace_uuid, p.owner_uuid, p.name, p.host, p.port, p.proxy_type, - p.username, p.password_encrypted, p.ssh_key_encrypted, p.ssh_passphrase_encrypted, + p.username, p.password, p.ssh_key_encrypted, p.ssh_passphrase_encrypted, p.country, p.city, p.status, p.latency, p.last_check_ip, p.last_checked_at, (SELECT COUNT(*) FROM environments e WHERE e.proxy_uuid = p.uuid AND e.deleted_at IS NULL) AS environments_count, p.created_at, p.updated_at, p.deleted_at diff --git a/src/services/accounts.rs b/src/services/accounts.rs index 6d6e548..a55df8f 100644 --- a/src/services/accounts.rs +++ b/src/services/accounts.rs @@ -1,48 +1,39 @@ -use uuid::Uuid; - -use crate::dto::PlatformAccountDto; -use crate::entitys::{ - BatchImportAccountsRequest, CreateAccountRequest, ListAccountsRequest, UpdateAccountRequest, -}; -use crate::models; -use crate::svc_ctx::SvcCtx; -use crate::utils::encryption::{encrypt_password, get_encryption_key}; - -/// 创建账号 -pub async fn create_account_service( - svc_ctx: &SvcCtx, - user_uuid: Uuid, - team_uuid: Option, - payload: &CreateAccountRequest, -) -> Result { - // 加密密码 - let password_encrypted = if let Some(password) = &payload.password { - let key = get_encryption_key(); - Some(encrypt_password(password, &key)?) - } else { - None - }; - - models::insert_platform_account( - &svc_ctx.db, - user_uuid, - team_uuid, - &payload.platform_url, - payload.platform_name.as_deref(), - &payload.account, - password_encrypted.as_deref(), - payload.remark.as_deref(), - ) - .await - .map_err(|e| e.to_string()) -} - -/// 获取账号列表 -pub async fn get_accounts_service( - svc_ctx: &SvcCtx, - user_uuid: Uuid, - team_uuid: Option, - payload: &ListAccountsRequest, +use uuid::Uuid; + +use crate::dto::PlatformAccountDto; +use crate::entitys::{ + BatchImportAccountsRequest, CreateAccountRequest, ListAccountsRequest, UpdateAccountRequest, +}; +use crate::models; +use crate::svc_ctx::SvcCtx; + +/// 创建账号 +pub async fn create_account_service( + svc_ctx: &SvcCtx, + user_uuid: Uuid, + team_uuid: Option, + payload: &CreateAccountRequest, +) -> Result { + models::insert_platform_account( + &svc_ctx.db, + user_uuid, + team_uuid, + &payload.platform_url, + payload.platform_name.as_deref(), + &payload.account, + payload.password.as_deref(), + payload.remark.as_deref(), + ) + .await + .map_err(|e| e.to_string()) +} + +/// 获取账号列表 +pub async fn get_accounts_service( + svc_ctx: &SvcCtx, + user_uuid: Uuid, + team_uuid: Option, + payload: &ListAccountsRequest, ) -> Result<(Vec, i64), String> { let page = payload.pagination.page.max(1); let page_size = payload.pagination.page_size.max(1); @@ -88,156 +79,132 @@ pub async fn get_accounts_service( platform_name, status, ) - .await - .map_err(|e| e.to_string())?; - - Ok((accounts, total)) -} - -/// 获取账号详情 -pub async fn get_account_service( - svc_ctx: &SvcCtx, - account_uuid: Uuid, -) -> Result { - models::fetch_platform_account_by_uuid(&svc_ctx.db, account_uuid) - .await - .map_err(|e| e.to_string())? - .ok_or_else(|| "账号不存在".to_string()) -} - -/// 更新账号 -pub async fn update_account_service( - svc_ctx: &SvcCtx, - payload: &UpdateAccountRequest, -) -> Result<(), String> { - // 加密密码 - let password_encrypted = if let Some(password) = &payload.password { - let key = get_encryption_key(); - Some(encrypt_password(password, &key)?) - } else { - None - }; - - models::update_platform_account( - &svc_ctx.db, - payload.uuid, - payload.platform_url.as_deref(), - payload.platform_name.as_deref(), - payload.account.as_deref(), - password_encrypted.as_deref(), - payload.remark.as_deref(), - payload.status.as_deref(), - ) - .await - .map_err(|e| e.to_string()) -} - -/// 删除账号 -pub async fn delete_account_service(svc_ctx: &SvcCtx, account_uuid: Uuid) -> Result<(), String> { - models::delete_platform_account(&svc_ctx.db, account_uuid) - .await - .map_err(|e| e.to_string()) -} - -/// 批量删除账号 -pub async fn batch_delete_accounts_service( - svc_ctx: &SvcCtx, - account_uuids: &[Uuid], -) -> Result { - models::batch_delete_platform_accounts(&svc_ctx.db, account_uuids) - .await - .map_err(|e| e.to_string()) -} - -/// 获取环境关联的账号 -pub async fn get_environment_accounts_service( - svc_ctx: &SvcCtx, - env_uuid: Uuid, -) -> Result, String> { - models::fetch_environment_accounts(&svc_ctx.db, env_uuid) - .await - .map_err(|e| e.to_string()) -} - -/// 设置环境关联的账号 -pub async fn set_environment_accounts_service( - svc_ctx: &SvcCtx, - env_uuid: Uuid, - account_uuids: &[Uuid], -) -> Result<(), String> { - // 清空现有关联 - models::accounts::clear_environment_accounts(&svc_ctx.db, env_uuid) - .await - .map_err(|e| e.to_string())?; - - // 添加新关联 - for (idx, account_uuid) in account_uuids.iter().enumerate() { - models::accounts::insert_environment_account( - &svc_ctx.db, - env_uuid, - *account_uuid, - idx as i32, - ) - .await - .map_err(|e| e.to_string())?; - } - - Ok(()) -} - -/// 批量导入账号 -/// -/// 接收客户端已解析好的账号列表,直接保存到数据库 -pub async fn batch_import_accounts_service( - svc_ctx: &SvcCtx, - user_uuid: Uuid, - team_uuid: Option, - payload: &BatchImportAccountsRequest, -) -> Result { - let mut success_count = 0; - let mut failed_count = 0; - let mut errors: Vec = vec![]; - let key = get_encryption_key(); - - for (index, account) in payload.accounts.iter().enumerate() { - // 加密密码 - let password_encrypted = if let Some(pwd) = &account.password { - match encrypt_password(pwd, &key) { - Ok(encrypted) => Some(encrypted), - Err(e) => { - failed_count += 1; - errors.push(format!("第 {} 项密码加密失败: {}", index + 1, e)); - continue; - } - } - } else { - None - }; - - let result = models::insert_platform_account( - &svc_ctx.db, - user_uuid, - team_uuid, - &account.platform_url, - account.platform_name.as_deref(), - &account.account, - password_encrypted.as_deref(), - account.remark.as_deref(), - ) - .await; - - match result { - Ok(_) => success_count += 1, - Err(e) => { - failed_count += 1; - errors.push(format!("第 {} 项: {}", index + 1, e)); - } - } - } - - Ok(crate::entitys::BatchImportResponse { - success_count, - failed_count, - errors, - }) -} + .await + .map_err(|e| e.to_string())?; + + Ok((accounts, total)) +} + +/// 获取账号详情 +pub async fn get_account_service( + svc_ctx: &SvcCtx, + account_uuid: Uuid, +) -> Result { + models::fetch_platform_account_by_uuid(&svc_ctx.db, account_uuid) + .await + .map_err(|e| e.to_string())? + .ok_or_else(|| "账号不存在".to_string()) +} + +/// 更新账号 +pub async fn update_account_service( + svc_ctx: &SvcCtx, + payload: &UpdateAccountRequest, +) -> Result<(), String> { + models::update_platform_account( + &svc_ctx.db, + payload.uuid, + payload.platform_url.as_deref(), + payload.platform_name.as_deref(), + payload.account.as_deref(), + payload.password.as_deref(), + payload.remark.as_deref(), + payload.status.as_deref(), + ) + .await + .map_err(|e| e.to_string()) +} + +/// 删除账号 +pub async fn delete_account_service(svc_ctx: &SvcCtx, account_uuid: Uuid) -> Result<(), String> { + models::delete_platform_account(&svc_ctx.db, account_uuid) + .await + .map_err(|e| e.to_string()) +} + +/// 批量删除账号 +pub async fn batch_delete_accounts_service( + svc_ctx: &SvcCtx, + account_uuids: &[Uuid], +) -> Result { + models::batch_delete_platform_accounts(&svc_ctx.db, account_uuids) + .await + .map_err(|e| e.to_string()) +} + +/// 获取环境关联的账号 +pub async fn get_environment_accounts_service( + svc_ctx: &SvcCtx, + env_uuid: Uuid, +) -> Result, String> { + models::fetch_environment_accounts(&svc_ctx.db, env_uuid) + .await + .map_err(|e| e.to_string()) +} + +/// 设置环境关联的账号 +pub async fn set_environment_accounts_service( + svc_ctx: &SvcCtx, + env_uuid: Uuid, + account_uuids: &[Uuid], +) -> Result<(), String> { + // 清空现有关联 + models::accounts::clear_environment_accounts(&svc_ctx.db, env_uuid) + .await + .map_err(|e| e.to_string())?; + + // 添加新关联 + for (idx, account_uuid) in account_uuids.iter().enumerate() { + models::accounts::insert_environment_account( + &svc_ctx.db, + env_uuid, + *account_uuid, + idx as i32, + ) + .await + .map_err(|e| e.to_string())?; + } + + Ok(()) +} + +/// 批量导入账号 +/// +/// 接收客户端已解析好的账号列表,直接保存到数据库 +pub async fn batch_import_accounts_service( + svc_ctx: &SvcCtx, + user_uuid: Uuid, + team_uuid: Option, + payload: &BatchImportAccountsRequest, +) -> Result { + let mut success_count = 0; + let mut failed_count = 0; + let mut errors: Vec = vec![]; + for (index, account) in payload.accounts.iter().enumerate() { + let result = models::insert_platform_account( + &svc_ctx.db, + user_uuid, + team_uuid, + &account.platform_url, + account.platform_name.as_deref(), + &account.account, + account.password.as_deref(), + account.remark.as_deref(), + ) + .await; + + match result { + Ok(_) => success_count += 1, + Err(e) => { + failed_count += 1; + errors.push(format!("第 {} 项: {}", index + 1, e)); + } + } + } + + Ok(crate::entitys::BatchImportResponse { + success_count, + failed_count, + errors, + }) +} diff --git a/src/services/environments.rs b/src/services/environments.rs index 14d3056..24b90b2 100644 --- a/src/services/environments.rs +++ b/src/services/environments.rs @@ -1,1261 +1,1274 @@ -use std::collections::HashMap; -use uuid::Uuid; - -use crate::dto::{ - EnvironmentConfigDto, EnvironmentCookieDto, EnvironmentDto, EnvironmentUrlDto, GroupSummaryDto, - PlatformAccountDto, ProxySummaryDto, TagDto, -}; -use crate::entitys::{ - AddEnvironmentCookieRequest, AddEnvironmentUrlRequest, AssignTagsRequest, - BatchAssignTagRequest, BatchCreateEnvironmentRequest, BatchMoveToGroupRequest, - BatchRemoveTagsRequest, CookieInput, CreateEnvironmentRequest, ListEnvironmentsRequest, - MoveToGroupRequest, SetEnvironmentProxyRequest, UpdateEnvironmentRequest, -}; -use crate::models; -use crate::services::accounts; -use crate::svc_ctx::SvcCtx; - -// ============ Environments ============ - -/// 创建环境 -pub async fn create_environment_service( - svc_ctx: &SvcCtx, - user_uuid: Uuid, - workspace_uuid: Uuid, - team_uuid: Uuid, - payload: &CreateEnvironmentRequest, -) -> Result { - // 1. 检查用户是否在当前工作空间的团队中(工作空间级别) - let team_member = models::teams::fetch_team_member(&svc_ctx.db, workspace_uuid, team_uuid, user_uuid) - .await - .map_err(|e| e.to_string())? - .ok_or_else(|| "您不是该团队的成员".to_string())?; - - // 2. 权限检查 - if let Some(group_uuid) = payload.group_uuid { - // 如果指定了分组,检查用户是否有目标分组的 write 或 manage 权限 - let has_write = models::check_group_permission( - &svc_ctx.db, - workspace_uuid, - group_uuid, - user_uuid, - "write", - ) - .await - .map_err(|e| e.to_string())?; - - let has_manage = models::check_group_permission( - &svc_ctx.db, - workspace_uuid, - group_uuid, - user_uuid, - "manage", - ) - .await - .map_err(|e| e.to_string())?; - - if !has_write && !has_manage { - return Err("您没有在该分组中创建环境的权限".to_string()); - } - } else { - // 如果未指定分组,检查用户是否有团队级别的环境创建权限(Editor/Admin/Owner) - let can_create = matches!(team_member.role.as_str(), "owner" | "admin" | "editor"); - if !can_create { - return Err("您没有创建环境的权限,需要 Editor 及以上角色".to_string()); - } - } - - // 3. 检查工作空间配额是否充足 - let quota_available = models::check_quota(&svc_ctx.db, workspace_uuid, "environments") - .await - .map_err(|e| e.to_string())?; - if !quota_available { - return Err("工作空间环境配额不足,无法创建新环境".to_string()); - } - - // 提取系统信息 - let system_info = payload - .config - .window_info - .get("system") - .and_then(|v| v.as_str()) - .map(|s| s.to_string()); - let kernel_info = payload - .config - .window_info - .get("kernel") - .and_then(|v| v.as_str()) - .map(|s| s.to_string()); - - // 创建环境 - let env_uuid = models::insert_environment( - &svc_ctx.db, - workspace_uuid, - user_uuid, - team_uuid, - &payload.name, - payload.description.as_deref(), - payload.group_uuid, - payload.proxy_uuid, - system_info.as_deref(), - kernel_info.as_deref(), - ) - .await - .map_err(|e| e.to_string())?; - - // 创建环境配置 - models::upsert_environment_config( - &svc_ctx.db, - env_uuid, - &payload.config.window_info, - &payload.config.basic_settings, - &payload.config.fingerprint_settings, - &payload.config.device_settings, - &payload.config.preference_settings, - &payload.config.project_metadata, - ) - .await - .map_err(|e| e.to_string())?; - - // 分配标签 - if let Some(tag_uuids) = &payload.tag_uuids { - for tag_uuid in tag_uuids { - let _ = models::insert_environment_tag(&svc_ctx.db, env_uuid, *tag_uuid).await; - } - } - - // 关联账号 - if let Some(account_uuids) = &payload.account_uuids { - for (idx, account_uuid) in account_uuids.iter().enumerate() { - let _ = models::accounts::insert_environment_account( - &svc_ctx.db, - env_uuid, - *account_uuid, - idx as i32, - ) - .await; - } - } - - // 添加 Cookies - if let Some(cookie_strings) = &payload.cookies { - let parsed_cookies = parse_cookie_strings(cookie_strings)?; - if !parsed_cookies.is_empty() { - let _ = - models::batch_insert_environment_cookies(&svc_ctx.db, env_uuid, &parsed_cookies) - .await - .map_err(|e| format!("添加 Cookies 失败: {}", e))?; - } - } - - // 4. 更新工作空间配额(创建后增加使用数) - models::increment_used_environments(&svc_ctx.db, workspace_uuid, 1) - .await - .map_err(|e| format!("更新配额失败: {}", e))?; - - Ok(env_uuid) -} - -/// 解析 Cookie 字符串数组为 CookieInput 数组 -/// Cookie 字符串格式支持: -/// - 简单格式: "name=value" -/// - 完整格式: "name=value; domain=example.com; path=/; secure; httpOnly" -fn parse_cookie_strings(cookie_strings: &[String]) -> Result, String> { - let mut cookies = Vec::new(); - - for cookie_str in cookie_strings { - let cookie_str = cookie_str.trim(); - if cookie_str.is_empty() { - continue; - } - - // 解析 cookie 字符串 - let parts: Vec<&str> = cookie_str.split(';').map(|s| s.trim()).collect(); - if parts.is_empty() { - continue; - } - - // 解析 name=value - let name_value = parts[0]; - let (name, value) = if let Some(eq_pos) = name_value.find('=') { - (name_value[..eq_pos].trim(), name_value[eq_pos + 1..].trim()) - } else { - return Err(format!("无效的 Cookie 格式: {}", cookie_str)); - }; - - if name.is_empty() { - return Err(format!("Cookie 名称不能为空: {}", cookie_str)); - } - - // 解析其他属性 - let mut domain = None; - let mut path = Some("/".to_string()); - let mut http_only = Some(false); - let mut secure = Some(false); - let mut same_site = Some("Lax".to_string()); - - for part in parts.iter().skip(1) { - let part_lower = part.to_lowercase(); - if part_lower.starts_with("domain=") { - domain = Some(part[7..].trim().to_string()); - } else if part_lower.starts_with("path=") { - path = Some(part[5..].trim().to_string()); - } else if part_lower == "secure" { - secure = Some(true); - } else if part_lower == "httponly" || part_lower == "http-only" { - http_only = Some(true); - } else if part_lower.starts_with("samesite=") { - same_site = Some(part[9..].trim().to_string()); - } - } - - // 如果没有指定 domain,尝试从 cookie 值中推断(如果可能) - // 这里简化处理,如果没有 domain 则使用空字符串或默认值 - let domain = domain.unwrap_or_else(|| "".to_string()); - - cookies.push(CookieInput { - domain, - name: name.to_string(), - value: value.to_string(), - path, - http_only, - secure, - same_site, - }); - } - - Ok(cookies) -} - -/// 获取环境列表(包含完整关联数据) -pub async fn get_environments_service( - svc_ctx: &SvcCtx, - user_uuid: Uuid, - workspace_uuid: Uuid, - team_uuid: Uuid, - payload: &ListEnvironmentsRequest, -) -> Result<(Vec, i64), String> { - // 1. 检查用户是否在当前工作空间的团队中 - let team_member = models::teams::fetch_team_member(&svc_ctx.db, workspace_uuid, team_uuid, user_uuid) - .await - .map_err(|e| e.to_string())? - .ok_or_else(|| "您不是该团队的成员".to_string())?; - - let offset = (payload.pagination.page - 1) * payload.pagination.page_size; - - let group_uuid = payload.filters.as_ref().and_then(|f| f.group_uuid); - let status = payload.filters.as_ref().and_then(|f| f.status.as_deref()); - let keyword = payload.filters.as_ref().and_then(|f| f.keyword.as_deref()); - let tag_uuids = payload.filters.as_ref().and_then(|f| f.tag_uuids.as_deref()); - - // 2. 查询环境总数(用于分页) - let total_count = models::fetch_environments_count( - &svc_ctx.db, - workspace_uuid, - team_uuid, - group_uuid, - status, - keyword, - tag_uuids, - ) - .await - .map_err(|e| e.to_string())?; - - // 3. 查询环境基础列表 - let env_rows = models::fetch_environments_base( - &svc_ctx.db, - workspace_uuid, - team_uuid, - group_uuid, - status, - keyword, - tag_uuids, - offset, - payload.pagination.page_size, - ) - .await - .map_err(|e| e.to_string())?; - - // 2. 权限过滤:根据分组权限过滤环境 - let is_owner_or_admin = matches!(team_member.role.as_str(), "owner" | "admin"); - - // 收集所有需要检查权限的分组 UUID(去重) - let unique_group_uuids: Vec = env_rows - .iter() - .filter_map(|row| row.group_uuid) - .collect::>() - .into_iter() - .collect(); - - // 批量检查分组权限(如果不是 Owner/Admin) - let mut group_permissions_cache: std::collections::HashMap = - std::collections::HashMap::new(); - if !is_owner_or_admin && !unique_group_uuids.is_empty() { - for group_uuid in unique_group_uuids { - let has_permission = models::check_group_permission( - &svc_ctx.db, - workspace_uuid, - group_uuid, - user_uuid, - "read", - ) - .await - .map_err(|e| e.to_string())?; - group_permissions_cache.insert(group_uuid, has_permission); - } - } - - // 8. 过滤无权限的环境 - let filtered_env_rows: Vec<_> = env_rows - .into_iter() - .filter(|row| { - if let Some(group_uuid) = row.group_uuid { - // 如果环境有分组,检查用户是否有分组的 read 权限 - if is_owner_or_admin { - true // Owner/Admin 自动拥有所有分组权限 - } else { - *group_permissions_cache.get(&group_uuid).unwrap_or(&false) - } - } else { - // 如果环境无分组,所有团队成员都可以查看(已在团队成员检查中验证) - true - } - }) - .collect(); - - // 9. 重新收集关联 UUID(基于过滤后的环境) - let env_uuids: Vec = filtered_env_rows.iter().map(|e| e.uuid).collect(); - let group_uuids: Vec = filtered_env_rows.iter().filter_map(|e| e.group_uuid).collect(); - let proxy_uuids: Vec = filtered_env_rows.iter().filter_map(|e| e.proxy_uuid).collect(); - - // 10. 重新查询关联数据(基于过滤后的环境) - // 批量查询环境配置 - let mut configs_map: HashMap = HashMap::new(); - if !env_uuids.is_empty() { - let config_rows = models::fetch_environment_configs_by_uuids(&svc_ctx.db, &env_uuids) - .await - .map_err(|e| e.to_string())?; - for config in config_rows { - configs_map.insert(config.environment_uuid, config); - } - } - - // 批量查询分组 - let group_rows = if !group_uuids.is_empty() { - models::fetch_groups_by_uuids(&svc_ctx.db, &group_uuids) - .await - .map_err(|e| e.to_string())? - } else { - vec![] - }; - let groups_map: HashMap = group_rows.into_iter().map(|g| (g.uuid, g)).collect(); - - // 批量查询代理 - let proxy_rows = if !proxy_uuids.is_empty() { - models::fetch_proxies_by_uuids(&svc_ctx.db, &proxy_uuids) - .await - .map_err(|e| e.to_string())? - } else { - vec![] - }; - let proxies_map: HashMap = proxy_rows.into_iter().map(|p| (p.uuid, p)).collect(); - - // 批量查询标签 - let tag_rows = if !env_uuids.is_empty() { - models::fetch_tags_for_environments(&svc_ctx.db, &env_uuids) - .await - .map_err(|e| e.to_string())? - } else { - vec![] - }; - - // 按环境分组标签 - let mut tags_map: HashMap> = HashMap::new(); - for tag_row in tag_rows { - tags_map.entry(tag_row.environment_uuid).or_default().push(TagDto { - id: tag_row.tag_id, - uuid: tag_row.tag_uuid, - user_uuid: tag_row.tag_user_uuid, - team_uuid: tag_row.tag_team_uuid, - name: tag_row.tag_name, - color: tag_row.tag_color, - sort_order: tag_row.tag_sort_order, - environments_count: tag_row.tag_environments_count, - created_at: tag_row.tag_created_at, - updated_at: tag_row.tag_updated_at, - deleted_at: tag_row.tag_deleted_at, - }); - } - - // 批量查询账号 - let mut accounts_map: HashMap> = HashMap::new(); - for env_uuid in &env_uuids { - let accounts = accounts::get_environment_accounts_service(&svc_ctx, *env_uuid) - .await - .unwrap_or_default(); - accounts_map.insert(*env_uuid, accounts); - } - - // 批量查询扩展(为每个环境动态合并插件) - let mut extensions_map: HashMap> = HashMap::new(); - for row in &filtered_env_rows { - let extensions = crate::services::extensions::get_environment_extensions_service( - &svc_ctx, - user_uuid, - team_uuid, - row.group_uuid, - ) - .await - .unwrap_or_default(); - extensions_map.insert(row.uuid, extensions); - } - - // 11. 组装完整数据(使用与环境详情一致的数据结构) - let environments: Vec = filtered_env_rows - .into_iter() - .map(|row| { - // 构建 EnvironmentDto - let environment = EnvironmentDto { - id: row.id, - uuid: row.uuid, - workspace_uuid: row.workspace_uuid, - user_uuid: row.user_uuid, - team_uuid: row.team_uuid, - name: row.name, - description: row.description, - status: row.status, - group_uuid: row.group_uuid, - proxy_uuid: row.proxy_uuid, - system_info: row.system_info, - kernel_info: row.kernel_info, - fingerprint_summary: row.fingerprint_summary, - last_opened_at: row.last_opened_at, - created_at: row.created_at, - updated_at: row.updated_at, - deleted_at: None, - }; - - // 分组详情 - let group = row.group_uuid.and_then(|uuid| { - groups_map.get(&uuid).map(|g| GroupSummaryDto { - id: g.id, - uuid: g.uuid, - name: g.name.clone(), - description: g.description.clone(), - sort_order: g.sort_order, - }) - }); - - // 代理详情 - let proxy = row.proxy_uuid.and_then(|uuid| { - proxies_map.get(&uuid).map(|p| ProxySummaryDto { - id: p.id, - uuid: p.uuid, - name: p.name.clone(), - host: p.host.clone(), - port: p.port, - proxy_type: p.proxy_type.clone(), - username: p.username.clone(), - password_encrypted: p.password_encrypted.clone(), - country: p.country.clone(), - city: p.city.clone(), - status: p.status.clone(), - latency: p.latency, - last_check_ip: p.last_check_ip.clone(), - }) - }); - - crate::entitys::EnvironmentDetailResponse { - environment, - config: configs_map.remove(&row.uuid), // 返回配置信息,用于传递给指纹浏览器内核 - tags: tags_map.remove(&row.uuid).unwrap_or_default(), - accounts: accounts_map.remove(&row.uuid).unwrap_or_default(), - group, - proxy, - extensions: extensions_map.remove(&row.uuid).unwrap_or_default(), - } - }) - .collect(); - - // 返回过滤后的环境列表和数据库总数(用于分页显示) - // 注意:total 是数据库中符合条件的总数,不考虑权限过滤 - // 权限过滤只影响当前页返回的数据,不影响总数统计 - Ok((environments, total_count)) -} - -/// 获取环境详情 -pub async fn get_environment_service( - svc_ctx: &SvcCtx, - workspace_uuid: Uuid, - team_uuid: Uuid, - user_uuid: Uuid, - env_uuid: Uuid, -) -> Result { - // 1. 检查用户是否在当前工作空间的团队中 - let _team_member = models::teams::fetch_team_member(&svc_ctx.db, workspace_uuid, team_uuid, user_uuid) - .await - .map_err(|e| e.to_string())? - .ok_or_else(|| "您不是该团队的成员".to_string())?; - - // 2. 查询环境(带工作空间过滤) - let environment = models::fetch_environment_by_uuid(&svc_ctx.db, workspace_uuid, env_uuid) - .await - .map_err(|e| e.to_string())? - .ok_or_else(|| "环境不存在或不属于当前工作空间".to_string())?; - - // 3. 验证环境属于指定团队 - if environment.team_uuid != team_uuid { - return Err("环境不属于指定团队".to_string()); - } - - // 4. 权限检查 - if let Some(group_uuid) = environment.group_uuid { - // 如果环境有分组,检查用户是否有分组的 read/write/manage 权限 - // Owner/Admin 自动拥有所有分组权限(已在 check_group_permission 中处理) - let has_permission = models::check_group_permission( - &svc_ctx.db, - workspace_uuid, - group_uuid, - user_uuid, - "read", - ) - .await - .map_err(|e| e.to_string())?; - - if !has_permission { - return Err("您没有查看该环境的权限".to_string()); - } - } - // 如果环境无分组,所有团队成员都可以查看(已在团队成员检查中验证) - - Ok(environment) -} - -/// 获取环境配置 -pub async fn get_environment_config_service( - svc_ctx: &SvcCtx, - env_uuid: Uuid, -) -> Result { - models::fetch_environment_config(&svc_ctx.db, env_uuid) - .await - .map_err(|e| e.to_string())? - .ok_or_else(|| "环境配置不存在".to_string()) -} - -/// 获取环境的标签 -pub async fn get_environment_tags_service( - svc_ctx: &SvcCtx, - env_uuid: Uuid, -) -> Result, String> { - models::fetch_environment_tags(&svc_ctx.db, env_uuid) - .await - .map_err(|e| e.to_string()) -} - -/// 获取环境详情(包含完整关联数据) -pub async fn get_environment_detail_service( - svc_ctx: &SvcCtx, - workspace_uuid: Uuid, - team_uuid: Uuid, - user_uuid: Uuid, - env_uuid: Uuid, -) -> Result { - let environment = - get_environment_service(svc_ctx, workspace_uuid, team_uuid, user_uuid, env_uuid).await?; - - let config = get_environment_config_service(svc_ctx, env_uuid).await.ok(); - - let tags = get_environment_tags_service(svc_ctx, env_uuid).await?; - - let accounts = accounts::get_environment_accounts_service(svc_ctx, env_uuid) - .await - .unwrap_or_default(); - - // 获取分组信息 - let group = if let Some(group_uuid) = environment.group_uuid { - get_group_summary_service(svc_ctx, group_uuid).await.ok() - } else { - None - }; - - // 获取代理信息 - let proxy = if let Some(proxy_uuid) = environment.proxy_uuid { - get_proxy_summary_service(svc_ctx, proxy_uuid).await.ok() - } else { - None - }; - - // 获取扩展列表 - let extensions = crate::services::extensions::get_environment_extensions_service( - svc_ctx, - user_uuid, - team_uuid, - environment.group_uuid, - ) - .await - .unwrap_or_default(); - - Ok(crate::entitys::EnvironmentDetailResponse { - environment, - config, - tags, - accounts, - group, - proxy, - extensions, - }) -} - -/// 获取分组摘要信息 -pub async fn get_group_summary_service( - svc_ctx: &SvcCtx, - group_uuid: Uuid, -) -> Result { - let group = models::fetch_group_by_uuid(&svc_ctx.db, group_uuid) - .await - .map_err(|e| e.to_string())? - .ok_or_else(|| "分组不存在".to_string())?; - - Ok(crate::dto::GroupSummaryDto { - id: group.id, - uuid: group.uuid, - name: group.name, - description: group.description, - sort_order: group.sort_order, - }) -} - -/// 获取代理摘要信息 -pub async fn get_proxy_summary_service( - svc_ctx: &SvcCtx, - proxy_uuid: Uuid, -) -> Result { - let proxy = crate::models::proxies::fetch_proxy_by_uuid(&svc_ctx.db, proxy_uuid) - .await - .map_err(|e| e.to_string())? - .ok_or_else(|| "代理不存在".to_string())?; - - Ok(crate::dto::ProxySummaryDto { - id: proxy.id, - uuid: proxy.uuid, - name: proxy.name, - host: proxy.host, - port: proxy.port, - proxy_type: proxy.proxy_type, - username: proxy.username, - password_encrypted: proxy.password_encrypted, - country: proxy.country, - city: proxy.city, - status: proxy.status, - latency: proxy.latency, - last_check_ip: proxy.last_check_ip, - }) -} - -/// 更新环境 -pub async fn update_environment_service( - svc_ctx: &SvcCtx, - workspace_uuid: Uuid, - team_uuid: Uuid, - user_uuid: Uuid, - payload: &UpdateEnvironmentRequest, -) -> Result<(), String> { - // 1. 检查用户是否在当前工作空间的团队中 - let team_member = models::teams::fetch_team_member(&svc_ctx.db, workspace_uuid, team_uuid, user_uuid) - .await - .map_err(|e| e.to_string())? - .ok_or_else(|| "您不是该团队的成员".to_string())?; - - // 2. 查询环境(带工作空间过滤) - let environment = models::fetch_environment_by_uuid(&svc_ctx.db, workspace_uuid, payload.uuid) - .await - .map_err(|e| e.to_string())? - .ok_or_else(|| "环境不存在或不属于当前工作空间".to_string())?; - - // 3. 验证环境属于指定团队 - if environment.team_uuid != team_uuid { - return Err("环境不属于指定团队".to_string()); - } - - // 4. 权限检查 - if let Some(group_uuid) = environment.group_uuid { - // 如果环境有分组,检查用户是否有分组的 write 或 manage 权限 - let has_write = models::check_group_permission( - &svc_ctx.db, - workspace_uuid, - group_uuid, - user_uuid, - "write", - ) - .await - .map_err(|e| e.to_string())?; - - let has_manage = models::check_group_permission( - &svc_ctx.db, - workspace_uuid, - group_uuid, - user_uuid, - "manage", - ) - .await - .map_err(|e| e.to_string())?; - - if !has_write && !has_manage { - return Err("您没有编辑该环境的权限".to_string()); - } - } else { - // 如果环境无分组,检查用户是否有团队级别的编辑权限(Editor/Admin/Owner) - let can_edit = matches!(team_member.role.as_str(), "owner" | "admin" | "editor"); - if !can_edit { - return Err("您没有编辑环境的权限,需要 Editor 及以上角色".to_string()); - } - } - - models::update_environment( - &svc_ctx.db, - payload.uuid, - payload.name.as_deref(), - payload.description.as_deref(), - payload.group_uuid, - ) - .await - .map_err(|e| e.to_string())?; - - // 更新配置 - if let Some(config) = &payload.config { - models::upsert_environment_config( - &svc_ctx.db, - payload.uuid, - &config.window_info, - &config.basic_settings, - &config.fingerprint_settings, - &config.device_settings, - &config.preference_settings, - &config.project_metadata, - ) - .await - .map_err(|e| e.to_string())?; - } - - Ok(()) -} - -/// 设置环境代理 -pub async fn set_environment_proxy_service( - svc_ctx: &SvcCtx, - payload: &SetEnvironmentProxyRequest, -) -> Result<(), String> { - models::update_environment_proxy(&svc_ctx.db, payload.uuid, payload.proxy_uuid) - .await - .map_err(|e| e.to_string()) -} - -/// 分配标签 -pub async fn assign_tags_service( - svc_ctx: &SvcCtx, - payload: &AssignTagsRequest, -) -> Result<(), String> { - for tag_uuid in &payload.tag_uuids { - models::insert_environment_tag(&svc_ctx.db, payload.uuid, *tag_uuid) - .await - .map_err(|e| e.to_string())?; - } - Ok(()) -} - -/// 移除标签 -pub async fn remove_tag_service( - svc_ctx: &SvcCtx, - env_uuid: Uuid, - tag_uuid: Uuid, -) -> Result<(), String> { - models::remove_environment_tag(&svc_ctx.db, env_uuid, tag_uuid) - .await - .map_err(|e| e.to_string()) -} - -/// 移动到分组 -pub async fn move_to_group_service( - svc_ctx: &SvcCtx, - payload: &MoveToGroupRequest, -) -> Result<(), String> { - models::update_environment(&svc_ctx.db, payload.uuid, None, None, payload.group_uuid) - .await - .map_err(|e| e.to_string()) -} - -/// 批量移动到分组 -pub async fn batch_move_to_group_service( - svc_ctx: &SvcCtx, - payload: &BatchMoveToGroupRequest, -) -> Result<(), String> { - for env_uuid in &payload.env_uuids { - models::update_environment(&svc_ctx.db, *env_uuid, None, None, Some(payload.group_uuid)) - .await - .map_err(|e| e.to_string())?; - } - Ok(()) -} - -/// 批量分配标签 -pub async fn batch_assign_tags_service( - svc_ctx: &SvcCtx, - payload: &BatchAssignTagRequest, -) -> Result<(), String> { - for env_uuid in &payload.env_uuids { - models::insert_environment_tag(&svc_ctx.db, *env_uuid, payload.tag_uuid) - .await - .map_err(|e| e.to_string())?; - } - Ok(()) -} - -/// 批量移除标签 -pub async fn batch_remove_tags_service( - svc_ctx: &SvcCtx, - payload: &BatchRemoveTagsRequest, -) -> Result<(), String> { - if let Some(tag_uuid) = payload.tag_uuid { - // 移除指定的标签 - for env_uuid in &payload.env_uuids { - models::remove_environment_tag(&svc_ctx.db, *env_uuid, tag_uuid) - .await - .map_err(|e| e.to_string())?; - } - } else { - // 移除所有标签 - for env_uuid in &payload.env_uuids { - models::clear_environment_tags(&svc_ctx.db, *env_uuid) - .await - .map_err(|e| e.to_string())?; - } - } - Ok(()) -} - -/// 删除环境 -pub async fn delete_environment_service( - svc_ctx: &SvcCtx, - workspace_uuid: Uuid, - team_uuid: Uuid, - user_uuid: Uuid, - env_uuid: Uuid, -) -> Result<(), String> { - // 1. 检查用户是否在当前工作空间的团队中 - let team_member = models::teams::fetch_team_member(&svc_ctx.db, workspace_uuid, team_uuid, user_uuid) - .await - .map_err(|e| e.to_string())? - .ok_or_else(|| "您不是该团队的成员".to_string())?; - - // 2. 查询环境(带工作空间过滤) - let environment = models::fetch_environment_by_uuid(&svc_ctx.db, workspace_uuid, env_uuid) - .await - .map_err(|e| e.to_string())? - .ok_or_else(|| "环境不存在或不属于当前工作空间".to_string())?; - - // 3. 验证环境属于指定团队 - if environment.team_uuid != team_uuid { - return Err("环境不属于指定团队".to_string()); - } - - // 4. 权限检查 - if let Some(group_uuid) = environment.group_uuid { - // 如果环境有分组,检查用户是否有分组的 manage 权限 - let has_manage = models::check_group_permission( - &svc_ctx.db, - workspace_uuid, - group_uuid, - user_uuid, - "manage", - ) - .await - .map_err(|e| e.to_string())?; - - if !has_manage { - return Err("您没有删除该环境的权限".to_string()); - } - } else { - // 如果环境无分组,检查用户是否有团队级别的删除权限(Owner/Admin) - let can_delete = matches!(team_member.role.as_str(), "owner" | "admin"); - if !can_delete { - return Err("您没有删除环境的权限,需要 Owner 或 Admin 角色".to_string()); - } - } - - // 5. 删除环境 - models::delete_environment(&svc_ctx.db, env_uuid) - .await - .map_err(|e| e.to_string())?; - - // 6. 更新工作空间配额(删除后减少使用数) - models::decrement_used_environments(&svc_ctx.db, workspace_uuid, 1) - .await - .map_err(|e| format!("更新配额失败: {}", e))?; - - Ok(()) -} - -/// 批量删除环境 -pub async fn batch_delete_environments_service( - svc_ctx: &SvcCtx, - env_uuids: &[Uuid], -) -> Result { - models::batch_delete_environments(&svc_ctx.db, env_uuids) - .await - .map_err(|e| e.to_string()) -} - -// ============ Recycle Bin ============ - -/// 查询回收站环境列表 -pub async fn get_recycle_bin_environments_service( - svc_ctx: &SvcCtx, - workspace_uuid: Uuid, - team_uuid: Uuid, - user_uuid: Uuid, - payload: &ListEnvironmentsRequest, -) -> Result<(Vec, i64), String> { - // 1. 提取过滤参数 - let group_uuid = payload.filters.as_ref().and_then(|f| f.group_uuid); - let keyword = payload.filters.as_ref().and_then(|f| f.keyword.as_deref()); - - // 2. 计算分页参数 - let page = payload.pagination.page; - let page_size = payload.pagination.page_size; - let offset = (page - 1) * page_size; - let limit = page_size; - - // 3. 查询回收站环境总数 - let total_count = models::fetch_deleted_environments_count( - &svc_ctx.db, - workspace_uuid, - team_uuid, - group_uuid, - keyword, - ) - .await - .map_err(|e| e.to_string())?; - - // 4. 查询回收站环境基础列表 - let env_rows = models::fetch_deleted_environments_base( - &svc_ctx.db, - workspace_uuid, - team_uuid, - group_uuid, - keyword, - offset, - limit, - ) - .await - .map_err(|e| e.to_string())?; - - if env_rows.is_empty() { - return Ok((vec![], total_count)); - } - - // 5. 获取所有环境的 UUID - let env_uuids: Vec = env_rows.iter().map(|e| e.uuid).collect(); - let group_uuids: Vec = env_rows.iter().filter_map(|e| e.group_uuid).collect(); - let proxy_uuids: Vec = env_rows.iter().filter_map(|e| e.proxy_uuid).collect(); - - // 6. 批量查询分组信息 - let group_rows = if !group_uuids.is_empty() { - models::fetch_groups_by_uuids(&svc_ctx.db, &group_uuids) - .await - .map_err(|e| e.to_string())? - } else { - vec![] - }; - let group_map: HashMap = group_rows - .into_iter() - .map(|g| (g.uuid, GroupSummaryDto { - id: g.id, - uuid: g.uuid, - name: g.name, - description: g.description, - sort_order: g.sort_order, - })) - .collect(); - - // 7. 批量查询代理信息 - let proxy_rows = if !proxy_uuids.is_empty() { - models::fetch_proxies_by_uuids(&svc_ctx.db, &proxy_uuids) - .await - .map_err(|e| e.to_string())? - } else { - vec![] - }; - let proxy_map: HashMap = proxy_rows - .into_iter() - .map(|p| (p.uuid, ProxySummaryDto { - id: p.id, - uuid: p.uuid, - name: p.name, - host: p.host, - port: p.port, - proxy_type: p.proxy_type, - username: p.username, - password_encrypted: p.password_encrypted, - country: p.country, - city: p.city, - status: p.status, - latency: p.latency, - last_check_ip: p.last_check_ip, - })) - .collect(); - - // 8. 批量查询标签信息 - let tag_rows = if !env_uuids.is_empty() { - models::fetch_tags_for_environments(&svc_ctx.db, &env_uuids) - .await - .map_err(|e| e.to_string())? - } else { - vec![] - }; - let mut env_tags_map: HashMap> = HashMap::new(); - for tag_row in tag_rows { - env_tags_map.entry(tag_row.environment_uuid).or_default().push(TagDto { - id: tag_row.tag_id, - uuid: tag_row.tag_uuid, - user_uuid: tag_row.tag_user_uuid, - team_uuid: tag_row.tag_team_uuid, - name: tag_row.tag_name, - color: tag_row.tag_color, - sort_order: tag_row.tag_sort_order, - environments_count: tag_row.tag_environments_count, - created_at: tag_row.tag_created_at, - updated_at: tag_row.tag_updated_at, - deleted_at: tag_row.tag_deleted_at, - }); - } - - // 9. 批量查询账号信息 - let mut env_accounts_map: HashMap> = HashMap::new(); - for env_uuid in &env_uuids { - let accounts = accounts::get_environment_accounts_service(svc_ctx, *env_uuid) - .await - .unwrap_or_default(); - if !accounts.is_empty() { - env_accounts_map.insert(*env_uuid, accounts); - } - } - - // 10. 组装完整的环境信息 - let environments: Vec = env_rows - .into_iter() - .map(|row| { - let group = row.group_uuid.and_then(|gid| group_map.get(&gid).cloned()); - let proxy = row.proxy_uuid.and_then(|pid| proxy_map.get(&pid).cloned()); - let tags = env_tags_map.get(&row.uuid).cloned().unwrap_or_default(); - let accounts = env_accounts_map.get(&row.uuid).cloned().unwrap_or_default(); - - crate::entitys::EnvironmentDetailResponse { - environment: EnvironmentDto { - id: row.id, - uuid: row.uuid, - workspace_uuid: row.workspace_uuid, - user_uuid: row.user_uuid, - team_uuid: row.team_uuid, - name: row.name, - description: row.description, - status: row.status, - group_uuid: row.group_uuid, - proxy_uuid: row.proxy_uuid, - system_info: row.system_info, - kernel_info: row.kernel_info, - fingerprint_summary: row.fingerprint_summary, - last_opened_at: row.last_opened_at, - created_at: row.created_at, - updated_at: row.updated_at, - deleted_at: None, - }, - config: None, - tags, - accounts, - group, - proxy, - extensions: vec![], // 回收站不需要返回扩展数据 - } - }) - .collect(); - - Ok((environments, total_count)) -} - -/// 恢复环境 -pub async fn restore_environment_service( - svc_ctx: &SvcCtx, - env_uuid: Uuid, -) -> Result<(), String> { - models::restore_environment(&svc_ctx.db, env_uuid) - .await - .map_err(|e| e.to_string()) -} - -/// 批量恢复环境 -pub async fn batch_restore_environments_service( - svc_ctx: &SvcCtx, - env_uuids: &[Uuid], -) -> Result { - models::batch_restore_environments(&svc_ctx.db, env_uuids) - .await - .map_err(|e| e.to_string()) -} - -/// 永久删除环境 -pub async fn permanent_delete_environment_service( - svc_ctx: &SvcCtx, - env_uuid: Uuid, - workspace_uuid: Uuid, -) -> Result<(), String> { - // 永久删除环境 - models::permanent_delete_environment(&svc_ctx.db, env_uuid) - .await - .map_err(|e| e.to_string())?; - - // 更新工作空间配额(永久删除后减少使用数) - models::decrement_used_environments(&svc_ctx.db, workspace_uuid, 1) - .await - .map_err(|e| format!("更新配额失败: {}", e))?; - - Ok(()) -} - -/// 批量永久删除环境 -pub async fn batch_permanent_delete_environments_service( - svc_ctx: &SvcCtx, - env_uuids: &[Uuid], - workspace_uuid: Uuid, -) -> Result { - let count = models::batch_permanent_delete_environments(&svc_ctx.db, env_uuids) - .await - .map_err(|e| e.to_string())?; - - // 更新工作空间配额 - if count > 0 { - models::decrement_used_environments(&svc_ctx.db, workspace_uuid, count as i32) - .await - .map_err(|e| format!("更新配额失败: {}", e))?; - } - - Ok(count) -} - -// ============ Environment URLs ============ - -/// 添加环境 URL -pub async fn add_environment_url_service( - svc_ctx: &SvcCtx, - payload: &AddEnvironmentUrlRequest, -) -> Result { - models::insert_environment_url( - &svc_ctx.db, - payload.environment_uuid, - &payload.url, - payload.title.as_deref(), - payload.sort_order, - ) - .await - .map_err(|e| e.to_string()) -} - -/// 获取环境的所有 URL -pub async fn get_environment_urls_service( - svc_ctx: &SvcCtx, - env_uuid: Uuid, -) -> Result, String> { - models::fetch_environment_urls(&svc_ctx.db, env_uuid) - .await - .map_err(|e| e.to_string()) -} - -/// 删除环境 URL -pub async fn delete_environment_url_service(svc_ctx: &SvcCtx, url_id: i32) -> Result<(), String> { - models::delete_environment_url(&svc_ctx.db, url_id) - .await - .map_err(|e| e.to_string()) -} - -/// 清空环境的所有 URL -pub async fn clear_environment_urls_service( - svc_ctx: &SvcCtx, - env_uuid: Uuid, -) -> Result { - models::clear_environment_urls(&svc_ctx.db, env_uuid) - .await - .map_err(|e| e.to_string()) -} - -// ============ Environment Cookies ============ - -/// 添加环境 Cookie -pub async fn add_environment_cookie_service( - svc_ctx: &SvcCtx, - payload: &AddEnvironmentCookieRequest, -) -> Result { - models::insert_environment_cookie( - &svc_ctx.db, - payload.environment_uuid, - &payload.domain, - &payload.name, - &payload.value, - payload.path.as_deref(), - payload.http_only, - payload.secure, - payload.same_site.as_deref(), - ) - .await - .map_err(|e| e.to_string()) -} - -/// 获取环境的所有 Cookies -pub async fn get_environment_cookies_service( - svc_ctx: &SvcCtx, - env_uuid: Uuid, -) -> Result, String> { - models::fetch_environment_cookies(&svc_ctx.db, env_uuid) - .await - .map_err(|e| e.to_string()) -} - -/// 删除环境 Cookie -pub async fn delete_environment_cookie_service( - svc_ctx: &SvcCtx, - cookie_id: i32, -) -> Result<(), String> { - models::delete_environment_cookie(&svc_ctx.db, cookie_id) - .await - .map_err(|e| e.to_string()) -} - -/// 清空环境的所有 Cookies -pub async fn clear_environment_cookies_service( - svc_ctx: &SvcCtx, - env_uuid: Uuid, -) -> Result { - models::clear_environment_cookies(&svc_ctx.db, env_uuid) - .await - .map_err(|e| e.to_string()) -} - -/// 批量创建环境 -pub async fn batch_create_environments_service( - svc_ctx: &SvcCtx, - user_uuid: Uuid, - workspace_uuid: Uuid, - team_uuid: Uuid, - payload: &BatchCreateEnvironmentRequest, -) -> Result, String> { - let mut created_uuids = Vec::new(); - - for env_request in &payload.environments { - let env_uuid = - create_environment_service(svc_ctx, user_uuid, workspace_uuid, team_uuid, env_request) - .await - .map_err(|e| format!("创建环境 '{}' 失败: {}", env_request.name, e))?; - - created_uuids.push(env_uuid); - } - - Ok(created_uuids) -} +use std::collections::HashMap; +use uuid::Uuid; + +use crate::dto::{ + EnvironmentConfigDto, EnvironmentCookieDto, EnvironmentDto, EnvironmentUrlDto, GroupSummaryDto, + PlatformAccountDto, ProxySummaryDto, TagDto, +}; +use crate::entitys::{ + AddEnvironmentCookieRequest, AddEnvironmentUrlRequest, AssignTagsRequest, + BatchAssignTagRequest, BatchCreateEnvironmentRequest, BatchMoveToGroupRequest, + BatchRemoveTagsRequest, CookieInput, CreateEnvironmentRequest, ListEnvironmentsRequest, + MoveToGroupRequest, SetEnvironmentProxyRequest, UpdateEnvironmentRequest, +}; +use crate::models; +use crate::services::accounts; +use crate::svc_ctx::SvcCtx; + +// ============ Environments ============ + +/// 创建环境 +pub async fn create_environment_service( + svc_ctx: &SvcCtx, + user_uuid: Uuid, + workspace_uuid: Uuid, + team_uuid: Uuid, + payload: &CreateEnvironmentRequest, +) -> Result { + // 1. 检查用户是否在当前工作空间的团队中(工作空间级别) + let team_member = + models::teams::fetch_team_member(&svc_ctx.db, workspace_uuid, team_uuid, user_uuid) + .await + .map_err(|e| e.to_string())? + .ok_or_else(|| "您不是该团队的成员".to_string())?; + + // 2. 权限检查 + if let Some(group_uuid) = payload.group_uuid { + // 如果指定了分组,检查用户是否有目标分组的 write 或 manage 权限 + let has_write = models::check_group_permission( + &svc_ctx.db, + workspace_uuid, + group_uuid, + user_uuid, + "write", + ) + .await + .map_err(|e| e.to_string())?; + + let has_manage = models::check_group_permission( + &svc_ctx.db, + workspace_uuid, + group_uuid, + user_uuid, + "manage", + ) + .await + .map_err(|e| e.to_string())?; + + if !has_write && !has_manage { + return Err("您没有在该分组中创建环境的权限".to_string()); + } + } else { + // 如果未指定分组,检查用户是否有团队级别的环境创建权限(Editor/Admin/Owner) + let can_create = matches!(team_member.role.as_str(), "owner" | "admin" | "editor"); + if !can_create { + return Err("您没有创建环境的权限,需要 Editor 及以上角色".to_string()); + } + } + + // 3. 检查工作空间配额是否充足 + let quota_available = models::check_quota(&svc_ctx.db, workspace_uuid, "environments") + .await + .map_err(|e| e.to_string())?; + if !quota_available { + return Err("工作空间环境配额不足,无法创建新环境".to_string()); + } + + // 提取系统信息 + let system_info = payload + .config + .window_info + .get("system") + .and_then(|v| v.as_str()) + .map(|s| s.to_string()); + let kernel_info = payload + .config + .window_info + .get("kernel") + .and_then(|v| v.as_str()) + .map(|s| s.to_string()); + + // 创建环境 + let env_uuid = models::insert_environment( + &svc_ctx.db, + workspace_uuid, + user_uuid, + team_uuid, + &payload.name, + payload.description.as_deref(), + payload.group_uuid, + payload.proxy_uuid, + system_info.as_deref(), + kernel_info.as_deref(), + ) + .await + .map_err(|e| e.to_string())?; + + // 创建环境配置 + models::upsert_environment_config( + &svc_ctx.db, + env_uuid, + &payload.config.window_info, + &payload.config.basic_settings, + &payload.config.fingerprint_settings, + &payload.config.device_settings, + &payload.config.preference_settings, + &payload.config.project_metadata, + ) + .await + .map_err(|e| e.to_string())?; + + // 分配标签 + if let Some(tag_uuids) = &payload.tag_uuids { + for tag_uuid in tag_uuids { + let _ = models::insert_environment_tag(&svc_ctx.db, env_uuid, *tag_uuid).await; + } + } + + // 关联账号 + if let Some(account_uuids) = &payload.account_uuids { + for (idx, account_uuid) in account_uuids.iter().enumerate() { + let _ = models::accounts::insert_environment_account( + &svc_ctx.db, + env_uuid, + *account_uuid, + idx as i32, + ) + .await; + } + } + + // 添加 Cookies + if let Some(cookie_strings) = &payload.cookies { + let parsed_cookies = parse_cookie_strings(cookie_strings)?; + if !parsed_cookies.is_empty() { + let _ = + models::batch_insert_environment_cookies(&svc_ctx.db, env_uuid, &parsed_cookies) + .await + .map_err(|e| format!("添加 Cookies 失败: {}", e))?; + } + } + + // 4. 更新工作空间配额(创建后增加使用数) + models::increment_used_environments(&svc_ctx.db, workspace_uuid, 1) + .await + .map_err(|e| format!("更新配额失败: {}", e))?; + + Ok(env_uuid) +} + +/// 解析 Cookie 字符串数组为 CookieInput 数组 +/// Cookie 字符串格式支持: +/// - 简单格式: "name=value" +/// - 完整格式: "name=value; domain=example.com; path=/; secure; httpOnly" +fn parse_cookie_strings(cookie_strings: &[String]) -> Result, String> { + let mut cookies = Vec::new(); + + for cookie_str in cookie_strings { + let cookie_str = cookie_str.trim(); + if cookie_str.is_empty() { + continue; + } + + // 解析 cookie 字符串 + let parts: Vec<&str> = cookie_str.split(';').map(|s| s.trim()).collect(); + if parts.is_empty() { + continue; + } + + // 解析 name=value + let name_value = parts[0]; + let (name, value) = if let Some(eq_pos) = name_value.find('=') { + (name_value[..eq_pos].trim(), name_value[eq_pos + 1..].trim()) + } else { + return Err(format!("无效的 Cookie 格式: {}", cookie_str)); + }; + + if name.is_empty() { + return Err(format!("Cookie 名称不能为空: {}", cookie_str)); + } + + // 解析其他属性 + let mut domain = None; + let mut path = Some("/".to_string()); + let mut http_only = Some(false); + let mut secure = Some(false); + let mut same_site = Some("Lax".to_string()); + + for part in parts.iter().skip(1) { + let part_lower = part.to_lowercase(); + if part_lower.starts_with("domain=") { + domain = Some(part[7..].trim().to_string()); + } else if part_lower.starts_with("path=") { + path = Some(part[5..].trim().to_string()); + } else if part_lower == "secure" { + secure = Some(true); + } else if part_lower == "httponly" || part_lower == "http-only" { + http_only = Some(true); + } else if part_lower.starts_with("samesite=") { + same_site = Some(part[9..].trim().to_string()); + } + } + + // 如果没有指定 domain,尝试从 cookie 值中推断(如果可能) + // 这里简化处理,如果没有 domain 则使用空字符串或默认值 + let domain = domain.unwrap_or_else(|| "".to_string()); + + cookies.push(CookieInput { + domain, + name: name.to_string(), + value: value.to_string(), + path, + http_only, + secure, + same_site, + }); + } + + Ok(cookies) +} + +/// 获取环境列表(包含完整关联数据) +pub async fn get_environments_service( + svc_ctx: &SvcCtx, + user_uuid: Uuid, + workspace_uuid: Uuid, + team_uuid: Uuid, + payload: &ListEnvironmentsRequest, +) -> Result<(Vec, i64), String> { + // 1. 检查用户是否在当前工作空间的团队中 + let team_member = + models::teams::fetch_team_member(&svc_ctx.db, workspace_uuid, team_uuid, user_uuid) + .await + .map_err(|e| e.to_string())? + .ok_or_else(|| "您不是该团队的成员".to_string())?; + + let offset = (payload.pagination.page - 1) * payload.pagination.page_size; + + let group_uuid = payload.filters.as_ref().and_then(|f| f.group_uuid); + let status = payload.filters.as_ref().and_then(|f| f.status.as_deref()); + let keyword = payload.filters.as_ref().and_then(|f| f.keyword.as_deref()); + let tag_uuids = payload.filters.as_ref().and_then(|f| f.tag_uuids.as_deref()); + + // 2. 查询环境总数(用于分页) + let total_count = models::fetch_environments_count( + &svc_ctx.db, + workspace_uuid, + team_uuid, + group_uuid, + status, + keyword, + tag_uuids, + ) + .await + .map_err(|e| e.to_string())?; + + // 3. 查询环境基础列表 + let env_rows = models::fetch_environments_base( + &svc_ctx.db, + workspace_uuid, + team_uuid, + group_uuid, + status, + keyword, + tag_uuids, + offset, + payload.pagination.page_size, + ) + .await + .map_err(|e| e.to_string())?; + + // 2. 权限过滤:根据分组权限过滤环境 + let is_owner_or_admin = matches!(team_member.role.as_str(), "owner" | "admin"); + + // 收集所有需要检查权限的分组 UUID(去重) + let unique_group_uuids: Vec = env_rows + .iter() + .filter_map(|row| row.group_uuid) + .collect::>() + .into_iter() + .collect(); + + // 批量检查分组权限(如果不是 Owner/Admin) + let mut group_permissions_cache: std::collections::HashMap = + std::collections::HashMap::new(); + if !is_owner_or_admin && !unique_group_uuids.is_empty() { + for group_uuid in unique_group_uuids { + let has_permission = models::check_group_permission( + &svc_ctx.db, + workspace_uuid, + group_uuid, + user_uuid, + "read", + ) + .await + .map_err(|e| e.to_string())?; + group_permissions_cache.insert(group_uuid, has_permission); + } + } + + // 8. 过滤无权限的环境 + let filtered_env_rows: Vec<_> = env_rows + .into_iter() + .filter(|row| { + if let Some(group_uuid) = row.group_uuid { + // 如果环境有分组,检查用户是否有分组的 read 权限 + if is_owner_or_admin { + true // Owner/Admin 自动拥有所有分组权限 + } else { + *group_permissions_cache.get(&group_uuid).unwrap_or(&false) + } + } else { + // 如果环境无分组,所有团队成员都可以查看(已在团队成员检查中验证) + true + } + }) + .collect(); + + // 9. 重新收集关联 UUID(基于过滤后的环境) + let env_uuids: Vec = filtered_env_rows.iter().map(|e| e.uuid).collect(); + let group_uuids: Vec = filtered_env_rows.iter().filter_map(|e| e.group_uuid).collect(); + let proxy_uuids: Vec = filtered_env_rows.iter().filter_map(|e| e.proxy_uuid).collect(); + + // 10. 重新查询关联数据(基于过滤后的环境) + // 批量查询环境配置 + let mut configs_map: HashMap = HashMap::new(); + if !env_uuids.is_empty() { + let config_rows = models::fetch_environment_configs_by_uuids(&svc_ctx.db, &env_uuids) + .await + .map_err(|e| e.to_string())?; + for config in config_rows { + configs_map.insert(config.environment_uuid, config); + } + } + + // 批量查询分组 + let group_rows = if !group_uuids.is_empty() { + models::fetch_groups_by_uuids(&svc_ctx.db, &group_uuids) + .await + .map_err(|e| e.to_string())? + } else { + vec![] + }; + let groups_map: HashMap = group_rows.into_iter().map(|g| (g.uuid, g)).collect(); + + // 批量查询代理 + let proxy_rows = if !proxy_uuids.is_empty() { + models::fetch_proxies_by_uuids(&svc_ctx.db, &proxy_uuids) + .await + .map_err(|e| e.to_string())? + } else { + vec![] + }; + let proxies_map: HashMap = proxy_rows.into_iter().map(|p| (p.uuid, p)).collect(); + + // 批量查询标签 + let tag_rows = if !env_uuids.is_empty() { + models::fetch_tags_for_environments(&svc_ctx.db, &env_uuids) + .await + .map_err(|e| e.to_string())? + } else { + vec![] + }; + + // 按环境分组标签 + let mut tags_map: HashMap> = HashMap::new(); + for tag_row in tag_rows { + tags_map.entry(tag_row.environment_uuid).or_default().push(TagDto { + id: tag_row.tag_id, + uuid: tag_row.tag_uuid, + user_uuid: tag_row.tag_user_uuid, + team_uuid: tag_row.tag_team_uuid, + name: tag_row.tag_name, + color: tag_row.tag_color, + sort_order: tag_row.tag_sort_order, + environments_count: tag_row.tag_environments_count, + created_at: tag_row.tag_created_at, + updated_at: tag_row.tag_updated_at, + deleted_at: tag_row.tag_deleted_at, + }); + } + + // 批量查询账号 + let mut accounts_map: HashMap> = HashMap::new(); + for env_uuid in &env_uuids { + let accounts = accounts::get_environment_accounts_service(&svc_ctx, *env_uuid) + .await + .unwrap_or_default(); + accounts_map.insert(*env_uuid, accounts); + } + + // 批量查询扩展(为每个环境动态合并插件) + let mut extensions_map: HashMap> = + HashMap::new(); + for row in &filtered_env_rows { + let extensions = crate::services::extensions::get_environment_extensions_service( + &svc_ctx, + user_uuid, + team_uuid, + row.group_uuid, + ) + .await + .unwrap_or_default(); + extensions_map.insert(row.uuid, extensions); + } + + // 11. 组装完整数据(使用与环境详情一致的数据结构) + let environments: Vec = filtered_env_rows + .into_iter() + .map(|row| { + // 构建 EnvironmentDto + let environment = EnvironmentDto { + id: row.id, + uuid: row.uuid, + workspace_uuid: row.workspace_uuid, + user_uuid: row.user_uuid, + team_uuid: row.team_uuid, + name: row.name, + description: row.description, + status: row.status, + group_uuid: row.group_uuid, + proxy_uuid: row.proxy_uuid, + system_info: row.system_info, + kernel_info: row.kernel_info, + fingerprint_summary: row.fingerprint_summary, + last_opened_at: row.last_opened_at, + created_at: row.created_at, + updated_at: row.updated_at, + deleted_at: None, + }; + + // 分组详情 + let group = row.group_uuid.and_then(|uuid| { + groups_map.get(&uuid).map(|g| GroupSummaryDto { + id: g.id, + uuid: g.uuid, + name: g.name.clone(), + description: g.description.clone(), + sort_order: g.sort_order, + }) + }); + + // 代理详情 + let proxy = row.proxy_uuid.and_then(|uuid| { + proxies_map.get(&uuid).map(|p| ProxySummaryDto { + id: p.id, + uuid: p.uuid, + name: p.name.clone(), + host: p.host.clone(), + port: p.port, + proxy_type: p.proxy_type.clone(), + username: p.username.clone(), + password: p.password.clone(), + country: p.country.clone(), + city: p.city.clone(), + status: p.status.clone(), + latency: p.latency, + last_check_ip: p.last_check_ip.clone(), + }) + }); + + crate::entitys::EnvironmentDetailResponse { + environment, + config: configs_map.remove(&row.uuid), // 返回配置信息,用于传递给指纹浏览器内核 + tags: tags_map.remove(&row.uuid).unwrap_or_default(), + accounts: accounts_map.remove(&row.uuid).unwrap_or_default(), + group, + proxy, + extensions: extensions_map.remove(&row.uuid).unwrap_or_default(), + } + }) + .collect(); + + // 返回过滤后的环境列表和数据库总数(用于分页显示) + // 注意:total 是数据库中符合条件的总数,不考虑权限过滤 + // 权限过滤只影响当前页返回的数据,不影响总数统计 + Ok((environments, total_count)) +} + +/// 获取环境详情 +pub async fn get_environment_service( + svc_ctx: &SvcCtx, + workspace_uuid: Uuid, + team_uuid: Uuid, + user_uuid: Uuid, + env_uuid: Uuid, +) -> Result { + // 1. 检查用户是否在当前工作空间的团队中 + let _team_member = + models::teams::fetch_team_member(&svc_ctx.db, workspace_uuid, team_uuid, user_uuid) + .await + .map_err(|e| e.to_string())? + .ok_or_else(|| "您不是该团队的成员".to_string())?; + + // 2. 查询环境(带工作空间过滤) + let environment = models::fetch_environment_by_uuid(&svc_ctx.db, workspace_uuid, env_uuid) + .await + .map_err(|e| e.to_string())? + .ok_or_else(|| "环境不存在或不属于当前工作空间".to_string())?; + + // 3. 验证环境属于指定团队 + if environment.team_uuid != team_uuid { + return Err("环境不属于指定团队".to_string()); + } + + // 4. 权限检查 + if let Some(group_uuid) = environment.group_uuid { + // 如果环境有分组,检查用户是否有分组的 read/write/manage 权限 + // Owner/Admin 自动拥有所有分组权限(已在 check_group_permission 中处理) + let has_permission = models::check_group_permission( + &svc_ctx.db, + workspace_uuid, + group_uuid, + user_uuid, + "read", + ) + .await + .map_err(|e| e.to_string())?; + + if !has_permission { + return Err("您没有查看该环境的权限".to_string()); + } + } + // 如果环境无分组,所有团队成员都可以查看(已在团队成员检查中验证) + + Ok(environment) +} + +/// 获取环境配置 +pub async fn get_environment_config_service( + svc_ctx: &SvcCtx, + env_uuid: Uuid, +) -> Result { + models::fetch_environment_config(&svc_ctx.db, env_uuid) + .await + .map_err(|e| e.to_string())? + .ok_or_else(|| "环境配置不存在".to_string()) +} + +/// 获取环境的标签 +pub async fn get_environment_tags_service( + svc_ctx: &SvcCtx, + env_uuid: Uuid, +) -> Result, String> { + models::fetch_environment_tags(&svc_ctx.db, env_uuid) + .await + .map_err(|e| e.to_string()) +} + +/// 获取环境详情(包含完整关联数据) +pub async fn get_environment_detail_service( + svc_ctx: &SvcCtx, + workspace_uuid: Uuid, + team_uuid: Uuid, + user_uuid: Uuid, + env_uuid: Uuid, +) -> Result { + let environment = + get_environment_service(svc_ctx, workspace_uuid, team_uuid, user_uuid, env_uuid).await?; + + let config = get_environment_config_service(svc_ctx, env_uuid).await.ok(); + + let tags = get_environment_tags_service(svc_ctx, env_uuid).await?; + + let accounts = accounts::get_environment_accounts_service(svc_ctx, env_uuid) + .await + .unwrap_or_default(); + + // 获取分组信息 + let group = if let Some(group_uuid) = environment.group_uuid { + get_group_summary_service(svc_ctx, group_uuid).await.ok() + } else { + None + }; + + // 获取代理信息 + let proxy = if let Some(proxy_uuid) = environment.proxy_uuid { + get_proxy_summary_service(svc_ctx, proxy_uuid).await.ok() + } else { + None + }; + + // 获取扩展列表 + let extensions = crate::services::extensions::get_environment_extensions_service( + svc_ctx, + user_uuid, + team_uuid, + environment.group_uuid, + ) + .await + .unwrap_or_default(); + + Ok(crate::entitys::EnvironmentDetailResponse { + environment, + config, + tags, + accounts, + group, + proxy, + extensions, + }) +} + +/// 获取分组摘要信息 +pub async fn get_group_summary_service( + svc_ctx: &SvcCtx, + group_uuid: Uuid, +) -> Result { + let group = models::fetch_group_by_uuid(&svc_ctx.db, group_uuid) + .await + .map_err(|e| e.to_string())? + .ok_or_else(|| "分组不存在".to_string())?; + + Ok(crate::dto::GroupSummaryDto { + id: group.id, + uuid: group.uuid, + name: group.name, + description: group.description, + sort_order: group.sort_order, + }) +} + +/// 获取代理摘要信息 +pub async fn get_proxy_summary_service( + svc_ctx: &SvcCtx, + proxy_uuid: Uuid, +) -> Result { + let proxy = crate::models::proxies::fetch_proxy_by_uuid(&svc_ctx.db, proxy_uuid) + .await + .map_err(|e| e.to_string())? + .ok_or_else(|| "代理不存在".to_string())?; + + Ok(crate::dto::ProxySummaryDto { + id: proxy.id, + uuid: proxy.uuid, + name: proxy.name, + host: proxy.host, + port: proxy.port, + proxy_type: proxy.proxy_type, + username: proxy.username, + password: proxy.password, + country: proxy.country, + city: proxy.city, + status: proxy.status, + latency: proxy.latency, + last_check_ip: proxy.last_check_ip, + }) +} + +/// 更新环境 +pub async fn update_environment_service( + svc_ctx: &SvcCtx, + workspace_uuid: Uuid, + team_uuid: Uuid, + user_uuid: Uuid, + payload: &UpdateEnvironmentRequest, +) -> Result<(), String> { + // 1. 检查用户是否在当前工作空间的团队中 + let team_member = + models::teams::fetch_team_member(&svc_ctx.db, workspace_uuid, team_uuid, user_uuid) + .await + .map_err(|e| e.to_string())? + .ok_or_else(|| "您不是该团队的成员".to_string())?; + + // 2. 查询环境(带工作空间过滤) + let environment = models::fetch_environment_by_uuid(&svc_ctx.db, workspace_uuid, payload.uuid) + .await + .map_err(|e| e.to_string())? + .ok_or_else(|| "环境不存在或不属于当前工作空间".to_string())?; + + // 3. 验证环境属于指定团队 + if environment.team_uuid != team_uuid { + return Err("环境不属于指定团队".to_string()); + } + + // 4. 权限检查 + if let Some(group_uuid) = environment.group_uuid { + // 如果环境有分组,检查用户是否有分组的 write 或 manage 权限 + let has_write = models::check_group_permission( + &svc_ctx.db, + workspace_uuid, + group_uuid, + user_uuid, + "write", + ) + .await + .map_err(|e| e.to_string())?; + + let has_manage = models::check_group_permission( + &svc_ctx.db, + workspace_uuid, + group_uuid, + user_uuid, + "manage", + ) + .await + .map_err(|e| e.to_string())?; + + if !has_write && !has_manage { + return Err("您没有编辑该环境的权限".to_string()); + } + } else { + // 如果环境无分组,检查用户是否有团队级别的编辑权限(Editor/Admin/Owner) + let can_edit = matches!(team_member.role.as_str(), "owner" | "admin" | "editor"); + if !can_edit { + return Err("您没有编辑环境的权限,需要 Editor 及以上角色".to_string()); + } + } + + models::update_environment( + &svc_ctx.db, + payload.uuid, + payload.name.as_deref(), + payload.description.as_deref(), + payload.group_uuid, + ) + .await + .map_err(|e| e.to_string())?; + + // 更新配置 + if let Some(config) = &payload.config { + models::upsert_environment_config( + &svc_ctx.db, + payload.uuid, + &config.window_info, + &config.basic_settings, + &config.fingerprint_settings, + &config.device_settings, + &config.preference_settings, + &config.project_metadata, + ) + .await + .map_err(|e| e.to_string())?; + } + + Ok(()) +} + +/// 设置环境代理 +pub async fn set_environment_proxy_service( + svc_ctx: &SvcCtx, + payload: &SetEnvironmentProxyRequest, +) -> Result<(), String> { + models::update_environment_proxy(&svc_ctx.db, payload.uuid, payload.proxy_uuid) + .await + .map_err(|e| e.to_string()) +} + +/// 分配标签 +pub async fn assign_tags_service( + svc_ctx: &SvcCtx, + payload: &AssignTagsRequest, +) -> Result<(), String> { + for tag_uuid in &payload.tag_uuids { + models::insert_environment_tag(&svc_ctx.db, payload.uuid, *tag_uuid) + .await + .map_err(|e| e.to_string())?; + } + Ok(()) +} + +/// 移除标签 +pub async fn remove_tag_service( + svc_ctx: &SvcCtx, + env_uuid: Uuid, + tag_uuid: Uuid, +) -> Result<(), String> { + models::remove_environment_tag(&svc_ctx.db, env_uuid, tag_uuid) + .await + .map_err(|e| e.to_string()) +} + +/// 移动到分组 +pub async fn move_to_group_service( + svc_ctx: &SvcCtx, + payload: &MoveToGroupRequest, +) -> Result<(), String> { + models::update_environment(&svc_ctx.db, payload.uuid, None, None, payload.group_uuid) + .await + .map_err(|e| e.to_string()) +} + +/// 批量移动到分组 +pub async fn batch_move_to_group_service( + svc_ctx: &SvcCtx, + payload: &BatchMoveToGroupRequest, +) -> Result<(), String> { + for env_uuid in &payload.env_uuids { + models::update_environment(&svc_ctx.db, *env_uuid, None, None, Some(payload.group_uuid)) + .await + .map_err(|e| e.to_string())?; + } + Ok(()) +} + +/// 批量分配标签 +pub async fn batch_assign_tags_service( + svc_ctx: &SvcCtx, + payload: &BatchAssignTagRequest, +) -> Result<(), String> { + for env_uuid in &payload.env_uuids { + models::insert_environment_tag(&svc_ctx.db, *env_uuid, payload.tag_uuid) + .await + .map_err(|e| e.to_string())?; + } + Ok(()) +} + +/// 批量移除标签 +pub async fn batch_remove_tags_service( + svc_ctx: &SvcCtx, + payload: &BatchRemoveTagsRequest, +) -> Result<(), String> { + if let Some(tag_uuid) = payload.tag_uuid { + // 移除指定的标签 + for env_uuid in &payload.env_uuids { + models::remove_environment_tag(&svc_ctx.db, *env_uuid, tag_uuid) + .await + .map_err(|e| e.to_string())?; + } + } else { + // 移除所有标签 + for env_uuid in &payload.env_uuids { + models::clear_environment_tags(&svc_ctx.db, *env_uuid) + .await + .map_err(|e| e.to_string())?; + } + } + Ok(()) +} + +/// 删除环境 +pub async fn delete_environment_service( + svc_ctx: &SvcCtx, + workspace_uuid: Uuid, + team_uuid: Uuid, + user_uuid: Uuid, + env_uuid: Uuid, +) -> Result<(), String> { + // 1. 检查用户是否在当前工作空间的团队中 + let team_member = + models::teams::fetch_team_member(&svc_ctx.db, workspace_uuid, team_uuid, user_uuid) + .await + .map_err(|e| e.to_string())? + .ok_or_else(|| "您不是该团队的成员".to_string())?; + + // 2. 查询环境(带工作空间过滤) + let environment = models::fetch_environment_by_uuid(&svc_ctx.db, workspace_uuid, env_uuid) + .await + .map_err(|e| e.to_string())? + .ok_or_else(|| "环境不存在或不属于当前工作空间".to_string())?; + + // 3. 验证环境属于指定团队 + if environment.team_uuid != team_uuid { + return Err("环境不属于指定团队".to_string()); + } + + // 4. 权限检查 + if let Some(group_uuid) = environment.group_uuid { + // 如果环境有分组,检查用户是否有分组的 manage 权限 + let has_manage = models::check_group_permission( + &svc_ctx.db, + workspace_uuid, + group_uuid, + user_uuid, + "manage", + ) + .await + .map_err(|e| e.to_string())?; + + if !has_manage { + return Err("您没有删除该环境的权限".to_string()); + } + } else { + // 如果环境无分组,检查用户是否有团队级别的删除权限(Owner/Admin) + let can_delete = matches!(team_member.role.as_str(), "owner" | "admin"); + if !can_delete { + return Err("您没有删除环境的权限,需要 Owner 或 Admin 角色".to_string()); + } + } + + // 5. 删除环境 + models::delete_environment(&svc_ctx.db, env_uuid) + .await + .map_err(|e| e.to_string())?; + + // 6. 更新工作空间配额(删除后减少使用数) + models::decrement_used_environments(&svc_ctx.db, workspace_uuid, 1) + .await + .map_err(|e| format!("更新配额失败: {}", e))?; + + Ok(()) +} + +/// 批量删除环境 +pub async fn batch_delete_environments_service( + svc_ctx: &SvcCtx, + env_uuids: &[Uuid], +) -> Result { + models::batch_delete_environments(&svc_ctx.db, env_uuids) + .await + .map_err(|e| e.to_string()) +} + +// ============ Recycle Bin ============ + +/// 查询回收站环境列表 +pub async fn get_recycle_bin_environments_service( + svc_ctx: &SvcCtx, + workspace_uuid: Uuid, + team_uuid: Uuid, + user_uuid: Uuid, + payload: &ListEnvironmentsRequest, +) -> Result<(Vec, i64), String> { + // 1. 提取过滤参数 + let group_uuid = payload.filters.as_ref().and_then(|f| f.group_uuid); + let keyword = payload.filters.as_ref().and_then(|f| f.keyword.as_deref()); + + // 2. 计算分页参数 + let page = payload.pagination.page; + let page_size = payload.pagination.page_size; + let offset = (page - 1) * page_size; + let limit = page_size; + + // 3. 查询回收站环境总数 + let total_count = models::fetch_deleted_environments_count( + &svc_ctx.db, + workspace_uuid, + team_uuid, + group_uuid, + keyword, + ) + .await + .map_err(|e| e.to_string())?; + + // 4. 查询回收站环境基础列表 + let env_rows = models::fetch_deleted_environments_base( + &svc_ctx.db, + workspace_uuid, + team_uuid, + group_uuid, + keyword, + offset, + limit, + ) + .await + .map_err(|e| e.to_string())?; + + if env_rows.is_empty() { + return Ok((vec![], total_count)); + } + + // 5. 获取所有环境的 UUID + let env_uuids: Vec = env_rows.iter().map(|e| e.uuid).collect(); + let group_uuids: Vec = env_rows.iter().filter_map(|e| e.group_uuid).collect(); + let proxy_uuids: Vec = env_rows.iter().filter_map(|e| e.proxy_uuid).collect(); + + // 6. 批量查询分组信息 + let group_rows = if !group_uuids.is_empty() { + models::fetch_groups_by_uuids(&svc_ctx.db, &group_uuids) + .await + .map_err(|e| e.to_string())? + } else { + vec![] + }; + let group_map: HashMap = group_rows + .into_iter() + .map(|g| { + ( + g.uuid, + GroupSummaryDto { + id: g.id, + uuid: g.uuid, + name: g.name, + description: g.description, + sort_order: g.sort_order, + }, + ) + }) + .collect(); + + // 7. 批量查询代理信息 + let proxy_rows = if !proxy_uuids.is_empty() { + models::fetch_proxies_by_uuids(&svc_ctx.db, &proxy_uuids) + .await + .map_err(|e| e.to_string())? + } else { + vec![] + }; + let proxy_map: HashMap = proxy_rows + .into_iter() + .map(|p| { + ( + p.uuid, + ProxySummaryDto { + id: p.id, + uuid: p.uuid, + name: p.name, + host: p.host, + port: p.port, + proxy_type: p.proxy_type, + username: p.username, + password: p.password, + country: p.country, + city: p.city, + status: p.status, + latency: p.latency, + last_check_ip: p.last_check_ip, + }, + ) + }) + .collect(); + + // 8. 批量查询标签信息 + let tag_rows = if !env_uuids.is_empty() { + models::fetch_tags_for_environments(&svc_ctx.db, &env_uuids) + .await + .map_err(|e| e.to_string())? + } else { + vec![] + }; + let mut env_tags_map: HashMap> = HashMap::new(); + for tag_row in tag_rows { + env_tags_map.entry(tag_row.environment_uuid).or_default().push(TagDto { + id: tag_row.tag_id, + uuid: tag_row.tag_uuid, + user_uuid: tag_row.tag_user_uuid, + team_uuid: tag_row.tag_team_uuid, + name: tag_row.tag_name, + color: tag_row.tag_color, + sort_order: tag_row.tag_sort_order, + environments_count: tag_row.tag_environments_count, + created_at: tag_row.tag_created_at, + updated_at: tag_row.tag_updated_at, + deleted_at: tag_row.tag_deleted_at, + }); + } + + // 9. 批量查询账号信息 + let mut env_accounts_map: HashMap> = HashMap::new(); + for env_uuid in &env_uuids { + let accounts = accounts::get_environment_accounts_service(svc_ctx, *env_uuid) + .await + .unwrap_or_default(); + if !accounts.is_empty() { + env_accounts_map.insert(*env_uuid, accounts); + } + } + + // 10. 组装完整的环境信息 + let environments: Vec = env_rows + .into_iter() + .map(|row| { + let group = row.group_uuid.and_then(|gid| group_map.get(&gid).cloned()); + let proxy = row.proxy_uuid.and_then(|pid| proxy_map.get(&pid).cloned()); + let tags = env_tags_map.get(&row.uuid).cloned().unwrap_or_default(); + let accounts = env_accounts_map.get(&row.uuid).cloned().unwrap_or_default(); + + crate::entitys::EnvironmentDetailResponse { + environment: EnvironmentDto { + id: row.id, + uuid: row.uuid, + workspace_uuid: row.workspace_uuid, + user_uuid: row.user_uuid, + team_uuid: row.team_uuid, + name: row.name, + description: row.description, + status: row.status, + group_uuid: row.group_uuid, + proxy_uuid: row.proxy_uuid, + system_info: row.system_info, + kernel_info: row.kernel_info, + fingerprint_summary: row.fingerprint_summary, + last_opened_at: row.last_opened_at, + created_at: row.created_at, + updated_at: row.updated_at, + deleted_at: None, + }, + config: None, + tags, + accounts, + group, + proxy, + extensions: vec![], // 回收站不需要返回扩展数据 + } + }) + .collect(); + + Ok((environments, total_count)) +} + +/// 恢复环境 +pub async fn restore_environment_service(svc_ctx: &SvcCtx, env_uuid: Uuid) -> Result<(), String> { + models::restore_environment(&svc_ctx.db, env_uuid) + .await + .map_err(|e| e.to_string()) +} + +/// 批量恢复环境 +pub async fn batch_restore_environments_service( + svc_ctx: &SvcCtx, + env_uuids: &[Uuid], +) -> Result { + models::batch_restore_environments(&svc_ctx.db, env_uuids) + .await + .map_err(|e| e.to_string()) +} + +/// 永久删除环境 +pub async fn permanent_delete_environment_service( + svc_ctx: &SvcCtx, + env_uuid: Uuid, + workspace_uuid: Uuid, +) -> Result<(), String> { + // 永久删除环境 + models::permanent_delete_environment(&svc_ctx.db, env_uuid) + .await + .map_err(|e| e.to_string())?; + + // 更新工作空间配额(永久删除后减少使用数) + models::decrement_used_environments(&svc_ctx.db, workspace_uuid, 1) + .await + .map_err(|e| format!("更新配额失败: {}", e))?; + + Ok(()) +} + +/// 批量永久删除环境 +pub async fn batch_permanent_delete_environments_service( + svc_ctx: &SvcCtx, + env_uuids: &[Uuid], + workspace_uuid: Uuid, +) -> Result { + let count = models::batch_permanent_delete_environments(&svc_ctx.db, env_uuids) + .await + .map_err(|e| e.to_string())?; + + // 更新工作空间配额 + if count > 0 { + models::decrement_used_environments(&svc_ctx.db, workspace_uuid, count as i32) + .await + .map_err(|e| format!("更新配额失败: {}", e))?; + } + + Ok(count) +} + +// ============ Environment URLs ============ + +/// 添加环境 URL +pub async fn add_environment_url_service( + svc_ctx: &SvcCtx, + payload: &AddEnvironmentUrlRequest, +) -> Result { + models::insert_environment_url( + &svc_ctx.db, + payload.environment_uuid, + &payload.url, + payload.title.as_deref(), + payload.sort_order, + ) + .await + .map_err(|e| e.to_string()) +} + +/// 获取环境的所有 URL +pub async fn get_environment_urls_service( + svc_ctx: &SvcCtx, + env_uuid: Uuid, +) -> Result, String> { + models::fetch_environment_urls(&svc_ctx.db, env_uuid) + .await + .map_err(|e| e.to_string()) +} + +/// 删除环境 URL +pub async fn delete_environment_url_service(svc_ctx: &SvcCtx, url_id: i32) -> Result<(), String> { + models::delete_environment_url(&svc_ctx.db, url_id) + .await + .map_err(|e| e.to_string()) +} + +/// 清空环境的所有 URL +pub async fn clear_environment_urls_service( + svc_ctx: &SvcCtx, + env_uuid: Uuid, +) -> Result { + models::clear_environment_urls(&svc_ctx.db, env_uuid) + .await + .map_err(|e| e.to_string()) +} + +// ============ Environment Cookies ============ + +/// 添加环境 Cookie +pub async fn add_environment_cookie_service( + svc_ctx: &SvcCtx, + payload: &AddEnvironmentCookieRequest, +) -> Result { + models::insert_environment_cookie( + &svc_ctx.db, + payload.environment_uuid, + &payload.domain, + &payload.name, + &payload.value, + payload.path.as_deref(), + payload.http_only, + payload.secure, + payload.same_site.as_deref(), + ) + .await + .map_err(|e| e.to_string()) +} + +/// 获取环境的所有 Cookies +pub async fn get_environment_cookies_service( + svc_ctx: &SvcCtx, + env_uuid: Uuid, +) -> Result, String> { + models::fetch_environment_cookies(&svc_ctx.db, env_uuid) + .await + .map_err(|e| e.to_string()) +} + +/// 删除环境 Cookie +pub async fn delete_environment_cookie_service( + svc_ctx: &SvcCtx, + cookie_id: i32, +) -> Result<(), String> { + models::delete_environment_cookie(&svc_ctx.db, cookie_id) + .await + .map_err(|e| e.to_string()) +} + +/// 清空环境的所有 Cookies +pub async fn clear_environment_cookies_service( + svc_ctx: &SvcCtx, + env_uuid: Uuid, +) -> Result { + models::clear_environment_cookies(&svc_ctx.db, env_uuid) + .await + .map_err(|e| e.to_string()) +} + +/// 批量创建环境 +pub async fn batch_create_environments_service( + svc_ctx: &SvcCtx, + user_uuid: Uuid, + workspace_uuid: Uuid, + team_uuid: Uuid, + payload: &BatchCreateEnvironmentRequest, +) -> Result, String> { + let mut created_uuids = Vec::new(); + + for env_request in &payload.environments { + let env_uuid = + create_environment_service(svc_ctx, user_uuid, workspace_uuid, team_uuid, env_request) + .await + .map_err(|e| format!("创建环境 '{}' 失败: {}", env_request.name, e))?; + + created_uuids.push(env_uuid); + } + + Ok(created_uuids) +} diff --git a/src/services/proxies.rs b/src/services/proxies.rs index 61217b4..1e4e254 100644 --- a/src/services/proxies.rs +++ b/src/services/proxies.rs @@ -1,62 +1,53 @@ -use uuid::Uuid; - -use crate::dto::ProxyDto; -use crate::entitys::{ - BatchImportProxiesRequest, CreateProxyRequest, ListProxiesRequest, UpdateProxyRequest, -}; -use crate::models; -use crate::svc_ctx::SvcCtx; -use crate::utils::encryption::{encrypt_password, get_encryption_key}; - -/// 创建代理 -pub async fn create_proxy_service( - svc_ctx: &SvcCtx, - owner_uuid: Uuid, - workspace_uuid: Uuid, - payload: &CreateProxyRequest, -) -> Result { - // 1. 检查工作空间代理配额是否充足 - let quota_available = models::check_quota(&svc_ctx.db, workspace_uuid, "proxies") - .await - .map_err(|e| e.to_string())?; - if !quota_available { - return Err("工作空间代理配额不足,无法创建新代理".to_string()); - } - - // 2. 加密密码 - let password_encrypted = if let Some(password) = &payload.password { - let key = get_encryption_key(); - Some(encrypt_password(password, &key)?) - } else { - None - }; - - // 3. 创建代理 - let proxy_uuid = models::insert_proxy( - &svc_ctx.db, - workspace_uuid, - owner_uuid, - &payload.name, - &payload.host, - payload.port, - &payload.proxy_type, - payload.username.as_deref(), - password_encrypted.as_deref(), - payload.country.as_deref(), - payload.city.as_deref(), - ) - .await - .map_err(|e| e.to_string())?; - - // 4. 更新工作空间配额(创建后增加使用数) - models::increment_used_proxies(&svc_ctx.db, workspace_uuid, 1) - .await - .map_err(|e| format!("更新配额失败: {}", e))?; - - Ok(proxy_uuid) -} - -/// 获取代理列表(根据可见性过滤) +use uuid::Uuid; + +use crate::dto::ProxyDto; +use crate::entitys::{ + BatchImportProxiesRequest, CreateProxyRequest, ListProxiesRequest, UpdateProxyRequest, +}; +use crate::models; +use crate::svc_ctx::SvcCtx; + +/// 创建代理 +pub async fn create_proxy_service( + svc_ctx: &SvcCtx, + owner_uuid: Uuid, + workspace_uuid: Uuid, + payload: &CreateProxyRequest, +) -> Result { + // 1. 检查工作空间代理配额是否充足 + let quota_available = models::check_quota(&svc_ctx.db, workspace_uuid, "proxies") + .await + .map_err(|e| e.to_string())?; + if !quota_available { + return Err("工作空间代理配额不足,无法创建新代理".to_string()); + } + + // 2. 创建代理 + let proxy_uuid = models::insert_proxy( + &svc_ctx.db, + workspace_uuid, + owner_uuid, + &payload.name, + &payload.host, + payload.port, + &payload.proxy_type, + payload.username.as_deref(), + payload.password.as_deref(), + payload.country.as_deref(), + payload.city.as_deref(), + ) + .await + .map_err(|e| e.to_string())?; + + // 3. 更新工作空间配额(创建后增加使用数) + models::increment_used_proxies(&svc_ctx.db, workspace_uuid, 1) + .await + .map_err(|e| format!("更新配额失败: {}", e))?; + + Ok(proxy_uuid) +} + +/// 获取代理列表(根据可见性过滤) pub async fn get_proxies_service( svc_ctx: &SvcCtx, user_uuid: Uuid, @@ -116,152 +107,128 @@ pub async fn get_proxies_service( Ok((proxies, total)) } - -/// 获取代理详情 -pub async fn get_proxy_service(svc_ctx: &SvcCtx, proxy_uuid: Uuid) -> Result { - models::fetch_proxy_by_uuid(&svc_ctx.db, proxy_uuid) - .await - .map_err(|e| e.to_string())? - .ok_or_else(|| "代理不存在".to_string()) -} - -/// 更新代理 -pub async fn update_proxy_service( - svc_ctx: &SvcCtx, - payload: &UpdateProxyRequest, -) -> Result<(), String> { - // 加密密码 - let password_encrypted = if let Some(password) = &payload.password { - let key = get_encryption_key(); - Some(encrypt_password(password, &key)?) - } else { - None - }; - - models::update_proxy( - &svc_ctx.db, - payload.uuid, - payload.name.as_deref(), - payload.host.as_deref(), - payload.port, - payload.proxy_type.as_deref(), - payload.username.as_deref(), - password_encrypted.as_deref(), - payload.country.as_deref(), - payload.city.as_deref(), - ) - .await - .map_err(|e| e.to_string()) -} - -/// 删除代理 -pub async fn delete_proxy_service(svc_ctx: &SvcCtx, proxy_uuid: Uuid) -> Result<(), String> { - // 1. 获取代理信息(用于获取 workspace_uuid) - let proxy = models::fetch_proxy_by_uuid(&svc_ctx.db, proxy_uuid) - .await - .map_err(|e| e.to_string())? - .ok_or_else(|| "代理不存在".to_string())?; - - let workspace_uuid = proxy.workspace_uuid; - - // 2. 删除代理 - models::delete_proxy(&svc_ctx.db, proxy_uuid).await.map_err(|e| e.to_string())?; - - // 3. 更新工作空间配额(删除后减少使用数) - models::decrement_used_proxies(&svc_ctx.db, workspace_uuid, 1) - .await - .map_err(|e| format!("更新配额失败: {}", e))?; - - Ok(()) -} - -/// 批量删除代理 -pub async fn batch_delete_proxies_service( - svc_ctx: &SvcCtx, - proxy_uuids: &[Uuid], -) -> Result { - models::batch_delete_proxies(&svc_ctx.db, proxy_uuids) - .await - .map_err(|e| e.to_string()) -} - -/// 批量导入代理 -/// -/// 接收客户端已解析好的代理列表,直接保存到数据库 -pub async fn batch_import_proxies_service( - svc_ctx: &SvcCtx, - owner_uuid: Uuid, - workspace_uuid: Uuid, - payload: &BatchImportProxiesRequest, -) -> Result { - // 1. 检查工作空间代理配额是否充足(检查是否有足够配额导入所有代理) - let import_count = payload.proxies.len() as i32; - let quota = models::fetch_workspace_quota(&svc_ctx.db, workspace_uuid) - .await - .map_err(|e| e.to_string())? - .ok_or_else(|| "工作空间配额不存在".to_string())?; - - if quota.used_proxies + import_count > quota.max_proxies { - return Err(format!( - "工作空间代理配额不足,当前已使用 {}/{},无法导入 {} 个代理", - quota.used_proxies, quota.max_proxies, import_count - )); - } - - let mut success_count = 0; - let mut failed_count = 0; - let mut errors: Vec = vec![]; - let key = get_encryption_key(); - - for (index, proxy) in payload.proxies.iter().enumerate() { - // 加密密码 - let password_encrypted = if let Some(pwd) = &proxy.password { - match encrypt_password(pwd, &key) { - Ok(encrypted) => Some(encrypted), - Err(e) => { - failed_count += 1; - errors.push(format!("第 {} 项密码加密失败: {}", index + 1, e)); - continue; - } - } - } else { - None - }; - - let result = models::insert_proxy( - &svc_ctx.db, - workspace_uuid, - owner_uuid, - &proxy.name, - &proxy.host, - proxy.port, - &proxy.proxy_type, - proxy.username.as_deref(), - password_encrypted.as_deref(), - proxy.country.as_deref(), - proxy.city.as_deref(), - ) - .await; - - match result { - Ok(_) => { - success_count += 1; - // 更新配额(每成功导入一个代理就增加配额使用数) - if let Err(e) = models::increment_used_proxies(&svc_ctx.db, workspace_uuid, 1).await - { - errors.push(format!("第 {} 项导入成功但更新配额失败: {}", index + 1, e)); - } - } - Err(e) => { - failed_count += 1; - errors.push(format!("第 {} 项: {}", index + 1, e)); - } - } - } - - Ok(crate::entitys::BatchImportResponse { - success_count, - failed_count, - errors, - }) -} + +/// 获取代理详情 +pub async fn get_proxy_service(svc_ctx: &SvcCtx, proxy_uuid: Uuid) -> Result { + models::fetch_proxy_by_uuid(&svc_ctx.db, proxy_uuid) + .await + .map_err(|e| e.to_string())? + .ok_or_else(|| "代理不存在".to_string()) +} + +/// 更新代理 +pub async fn update_proxy_service( + svc_ctx: &SvcCtx, + payload: &UpdateProxyRequest, +) -> Result<(), String> { + models::update_proxy( + &svc_ctx.db, + payload.uuid, + payload.name.as_deref(), + payload.host.as_deref(), + payload.port, + payload.proxy_type.as_deref(), + payload.username.as_deref(), + payload.password.as_deref(), + payload.country.as_deref(), + payload.city.as_deref(), + ) + .await + .map_err(|e| e.to_string()) +} + +/// 删除代理 +pub async fn delete_proxy_service(svc_ctx: &SvcCtx, proxy_uuid: Uuid) -> Result<(), String> { + // 1. 获取代理信息(用于获取 workspace_uuid) + let proxy = models::fetch_proxy_by_uuid(&svc_ctx.db, proxy_uuid) + .await + .map_err(|e| e.to_string())? + .ok_or_else(|| "代理不存在".to_string())?; + + let workspace_uuid = proxy.workspace_uuid; + + // 2. 删除代理 + models::delete_proxy(&svc_ctx.db, proxy_uuid).await.map_err(|e| e.to_string())?; + + // 3. 更新工作空间配额(删除后减少使用数) + models::decrement_used_proxies(&svc_ctx.db, workspace_uuid, 1) + .await + .map_err(|e| format!("更新配额失败: {}", e))?; + + Ok(()) +} + +/// 批量删除代理 +pub async fn batch_delete_proxies_service( + svc_ctx: &SvcCtx, + proxy_uuids: &[Uuid], +) -> Result { + models::batch_delete_proxies(&svc_ctx.db, proxy_uuids) + .await + .map_err(|e| e.to_string()) +} + +/// 批量导入代理 +/// +/// 接收客户端已解析好的代理列表,直接保存到数据库 +pub async fn batch_import_proxies_service( + svc_ctx: &SvcCtx, + owner_uuid: Uuid, + workspace_uuid: Uuid, + payload: &BatchImportProxiesRequest, +) -> Result { + // 1. 检查工作空间代理配额是否充足(检查是否有足够配额导入所有代理) + let import_count = payload.proxies.len() as i32; + let quota = models::fetch_workspace_quota(&svc_ctx.db, workspace_uuid) + .await + .map_err(|e| e.to_string())? + .ok_or_else(|| "工作空间配额不存在".to_string())?; + + if quota.used_proxies + import_count > quota.max_proxies { + return Err(format!( + "工作空间代理配额不足,当前已使用 {}/{},无法导入 {} 个代理", + quota.used_proxies, quota.max_proxies, import_count + )); + } + + let mut success_count = 0; + let mut failed_count = 0; + let mut errors: Vec = vec![]; + for (index, proxy) in payload.proxies.iter().enumerate() { + let result = models::insert_proxy( + &svc_ctx.db, + workspace_uuid, + owner_uuid, + &proxy.name, + &proxy.host, + proxy.port, + &proxy.proxy_type, + proxy.username.as_deref(), + proxy.password.as_deref(), + proxy.country.as_deref(), + proxy.city.as_deref(), + ) + .await; + + match result { + Ok(_) => { + success_count += 1; + // 更新配额(每成功导入一个代理就增加配额使用数) + if let Err(e) = models::increment_used_proxies(&svc_ctx.db, workspace_uuid, 1).await + { + errors.push(format!("第 {} 项导入成功但更新配额失败: {}", index + 1, e)); + } + } + Err(e) => { + failed_count += 1; + errors.push(format!("第 {} 项: {}", index + 1, e)); + } + } + } + + Ok(crate::entitys::BatchImportResponse { + success_count, + failed_count, + errors, + }) +} diff --git a/src/utils.rs b/src/utils.rs index f8901cb..f3031a8 100644 --- a/src/utils.rs +++ b/src/utils.rs @@ -1,53 +1,51 @@ -mod config; -pub mod encryption; +mod config; mod extractor; pub mod gray_checker; mod jwt; -pub mod storage; mod password; mod responses; mod secret; - +pub mod storage; + pub use config::*; -pub use encryption::*; pub use extractor::*; pub use jwt::*; -pub use storage::*; pub use password::*; -pub use responses::*; -pub use secret::*; - -/// 向指定邮箱发送验证码 -pub fn send_email( - smtp_username: &str, - smtp_password: &str, - smtp_server: &str, - to: &str, - title: &str, - body: &str, -) -> std::result::Result { - use lettre::message::header::ContentType; - use lettre::transport::smtp::authentication::Credentials; - use lettre::{Message, SmtpTransport, Transport}; - - let email = Message::builder() - .from(smtp_username.parse().map_err(|_| anyhow::anyhow!("from email is failed."))?) - .to(to.parse().map_err(|_| anyhow::anyhow!("receive email is failed."))?) - .subject(title) - .header(ContentType::TEXT_HTML) - .body(String::from(body))?; - let creds = Credentials::new(smtp_username.to_string(), smtp_password.to_string()); - let mailer = SmtpTransport::relay(smtp_server).unwrap().credentials(creds).build(); - - match mailer.send(&email) { - Ok(_) => Ok(true), - Err(_e) => Err(anyhow::anyhow!("email send failed.")), - } -} - -/// 随机生成6位的数字编码 -pub fn random_six_number_code() -> String { - let mut rng = rand::rng(); - let random_number: i32 = rand::Rng::random_range(&mut rng, 100000..1000000); - format!("{}", random_number) -} +pub use responses::*; +pub use secret::*; +pub use storage::*; + +/// 向指定邮箱发送验证码 +pub fn send_email( + smtp_username: &str, + smtp_password: &str, + smtp_server: &str, + to: &str, + title: &str, + body: &str, +) -> std::result::Result { + use lettre::message::header::ContentType; + use lettre::transport::smtp::authentication::Credentials; + use lettre::{Message, SmtpTransport, Transport}; + + let email = Message::builder() + .from(smtp_username.parse().map_err(|_| anyhow::anyhow!("from email is failed."))?) + .to(to.parse().map_err(|_| anyhow::anyhow!("receive email is failed."))?) + .subject(title) + .header(ContentType::TEXT_HTML) + .body(String::from(body))?; + let creds = Credentials::new(smtp_username.to_string(), smtp_password.to_string()); + let mailer = SmtpTransport::relay(smtp_server).unwrap().credentials(creds).build(); + + match mailer.send(&email) { + Ok(_) => Ok(true), + Err(_e) => Err(anyhow::anyhow!("email send failed.")), + } +} + +/// 随机生成6位的数字编码 +pub fn random_six_number_code() -> String { + let mut rng = rand::rng(); + let random_number: i32 = rand::Rng::random_range(&mut rng, 100000..1000000); + format!("{}", random_number) +} diff --git a/src/utils/encryption.rs b/src/utils/encryption.rs deleted file mode 100644 index 25cfd64..0000000 --- a/src/utils/encryption.rs +++ /dev/null @@ -1,109 +0,0 @@ -use aes_gcm::{ - Aes256Gcm, Nonce, - aead::{Aead, KeyInit}, -}; -use base64::{Engine, engine::general_purpose::STANDARD as BASE64}; -use rand::RngCore; -use sha2::{Digest, Sha256}; - -const NONCE_SIZE: usize = 12; - -/// 应用程序固定标识符,用于派生加密密钥 -/// 注意:此值一旦确定后不可修改,否则已加密的数据将无法解密 -const APP_SECRET_SALT: &str = "simprint-fingerprint-browser-v1-secure-salt-2024"; - -/// 加密密码 -/// -/// 使用 AES-256-GCM 加密,返回 base64 编码的密文 -pub fn encrypt_password(password: &str, key: &[u8; 32]) -> Result { - let cipher = Aes256Gcm::new_from_slice(key).map_err(|e| e.to_string())?; - - // 生成随机 nonce - let mut nonce_bytes = [0u8; NONCE_SIZE]; - rand::rng().fill_bytes(&mut nonce_bytes); - let nonce = Nonce::from_slice(&nonce_bytes); - - // 加密 - let ciphertext = cipher.encrypt(nonce, password.as_bytes()).map_err(|e| e.to_string())?; - - // 组合 nonce + ciphertext - let mut result = nonce_bytes.to_vec(); - result.extend(ciphertext); - - Ok(BASE64.encode(result)) -} - -/// 解密密码 -/// -/// 接收 base64 编码的密文,返回原始密码 -pub fn decrypt_password(encrypted: &str, key: &[u8; 32]) -> Result { - let cipher = Aes256Gcm::new_from_slice(key).map_err(|e| e.to_string())?; - - // Base64 解码 - let data = BASE64.decode(encrypted).map_err(|e| e.to_string())?; - - if data.len() < NONCE_SIZE { - return Err("Invalid encrypted data".to_string()); - } - - // 分离 nonce 和 ciphertext - let (nonce_bytes, ciphertext) = data.split_at(NONCE_SIZE); - let nonce = Nonce::from_slice(nonce_bytes); - - // 解密 - let plaintext = - cipher.decrypt(nonce, ciphertext).map_err(|_| "Decryption failed".to_string())?; - - String::from_utf8(plaintext).map_err(|e| e.to_string()) -} - -/// 获取加密密钥 -/// -/// 使用确定性算法从固定盐值派生 32 字节 AES-256 密钥 -/// 每次调用都会返回相同的密钥,无需外部配置 -pub fn get_encryption_key() -> [u8; 32] { - // 使用 SHA-256 从固定盐值派生密钥 - let mut hasher = Sha256::new(); - hasher.update(APP_SECRET_SALT.as_bytes()); - let result = hasher.finalize(); - - let mut key = [0u8; 32]; - key.copy_from_slice(&result); - key -} - -#[cfg(test)] -mod tests { - use super::*; - - #[test] - fn test_encrypt_decrypt() { - let key = get_encryption_key(); - let password = "my_secret_password"; - - let encrypted = encrypt_password(password, &key).unwrap(); - let decrypted = decrypt_password(&encrypted, &key).unwrap(); - - assert_eq!(password, decrypted); - } - - #[test] - fn test_key_deterministic() { - // 确保多次调用返回相同的密钥 - let key1 = get_encryption_key(); - let key2 = get_encryption_key(); - - assert_eq!(key1, key2); - } - - #[test] - fn test_encrypt_decrypt_special_chars() { - let key = get_encryption_key(); - let password = "P@ssw0rd!#$%^&*()_+-=[]{}|;':\",./<>?中文密码"; - - let encrypted = encrypt_password(password, &key).unwrap(); - let decrypted = decrypt_password(&encrypted, &key).unwrap(); - - assert_eq!(password, decrypted); - } -} From 0b50a3b6fb78ed022e0f2fe43bd6dd24257bbdd4 Mon Sep 17 00:00:00 2001 From: lius-new Date: Wed, 6 May 2026 13:05:36 +0800 Subject: [PATCH 34/60] refactor: remove machine code and gray release flow --- ...506000002_drop_machine_and_gray_tables.sql | 6 + src/dto/gray_releases.rs | 22 - src/dto/gray_resources.rs | 11 - src/dto/machine_gray_allocations.rs | 16 - src/dto/machine_tags.rs | 38 -- src/dto/machine_users.rs | 21 - src/dto/mod.rs | 72 ++- src/entitys/gray_releases.rs | 72 --- src/entitys/gray_resources.rs | 35 -- src/entitys/machine_gray_allocations.rs | 56 -- src/entitys/machine_tags.rs | 37 -- src/entitys/machine_users.rs | 107 ---- src/entitys/mod.rs | 14 +- src/entitys/user.rs | 20 - src/main.rs | 13 +- src/middlewares.rs | 60 ++- src/middlewares/machine_auth.rs | 71 --- src/models/gray_releases.rs | 292 ----------- src/models/gray_resources.rs | 127 ----- src/models/machine_gray_allocations.rs | 187 ------- src/models/machine_tags.rs | 230 --------- src/models/machine_users.rs | 481 ------------------ src/models/mod.rs | 76 ++- src/services.rs | 14 +- src/services/gray_releases.rs | 254 --------- src/services/gray_resources.rs | 145 ------ src/services/machine_gray_allocations.rs | 207 -------- src/services/machine_tags.rs | 55 -- src/services/machine_users.rs | 265 ---------- src/services/users.rs | 75 +-- src/state.rs | 175 +++---- src/utils.rs | 1 - src/utils/gray_checker.rs | 88 ---- 33 files changed, 207 insertions(+), 3136 deletions(-) create mode 100644 migrations/20260506000002_drop_machine_and_gray_tables.sql delete mode 100644 src/dto/gray_releases.rs delete mode 100644 src/dto/gray_resources.rs delete mode 100644 src/dto/machine_gray_allocations.rs delete mode 100644 src/dto/machine_tags.rs delete mode 100644 src/dto/machine_users.rs delete mode 100644 src/entitys/gray_releases.rs delete mode 100644 src/entitys/gray_resources.rs delete mode 100644 src/entitys/machine_gray_allocations.rs delete mode 100644 src/entitys/machine_tags.rs delete mode 100644 src/entitys/machine_users.rs delete mode 100644 src/middlewares/machine_auth.rs delete mode 100644 src/models/gray_releases.rs delete mode 100644 src/models/gray_resources.rs delete mode 100644 src/models/machine_gray_allocations.rs delete mode 100644 src/models/machine_tags.rs delete mode 100644 src/models/machine_users.rs delete mode 100644 src/services/gray_releases.rs delete mode 100644 src/services/gray_resources.rs delete mode 100644 src/services/machine_gray_allocations.rs delete mode 100644 src/services/machine_tags.rs delete mode 100644 src/services/machine_users.rs delete mode 100644 src/utils/gray_checker.rs diff --git a/migrations/20260506000002_drop_machine_and_gray_tables.sql b/migrations/20260506000002_drop_machine_and_gray_tables.sql new file mode 100644 index 0000000..9bdf508 --- /dev/null +++ b/migrations/20260506000002_drop_machine_and_gray_tables.sql @@ -0,0 +1,6 @@ +DROP TABLE IF EXISTS public.machine_gray_allocations; +DROP TABLE IF EXISTS public.gray_resources; +DROP TABLE IF EXISTS public.gray_releases; +DROP TABLE IF EXISTS public.machine_user_tags; +DROP TABLE IF EXISTS public.machine_tags; +DROP TABLE IF EXISTS public.machine_users; diff --git a/src/dto/gray_releases.rs b/src/dto/gray_releases.rs deleted file mode 100644 index 1f02424..0000000 --- a/src/dto/gray_releases.rs +++ /dev/null @@ -1,22 +0,0 @@ -use chrono::DateTime; -use serde::{Deserialize, Serialize}; -use sqlx::FromRow; - -/// 灰度发布 -#[derive(Debug, Deserialize, Serialize, FromRow, Clone, Default)] -pub struct GrayRelease { - pub id: i32, - pub name: String, - pub description: Option, - pub platform: String, - pub status: String, - pub start_time: DateTime, - pub end_time: Option>, - pub max_machines: Option, - pub allocated_count: i32, - pub priority: i32, - pub strategy_type: Option, - pub strategy_config: Option, - pub created_at: DateTime, - pub updated_at: Option>, -} diff --git a/src/dto/gray_resources.rs b/src/dto/gray_resources.rs deleted file mode 100644 index 1b56409..0000000 --- a/src/dto/gray_resources.rs +++ /dev/null @@ -1,11 +0,0 @@ -use serde::{Deserialize, Serialize}; -use sqlx::FromRow; - -/// 灰度资源关联 -#[derive(Debug, Deserialize, Serialize, FromRow, Clone, Default)] -pub struct GrayResource { - pub id: i32, - pub gray_release_id: i32, - pub version_id: i32, - pub sort_order: i32, -} diff --git a/src/dto/machine_gray_allocations.rs b/src/dto/machine_gray_allocations.rs deleted file mode 100644 index 8522bed..0000000 --- a/src/dto/machine_gray_allocations.rs +++ /dev/null @@ -1,16 +0,0 @@ -use chrono::DateTime; -use serde::{Deserialize, Serialize}; -use sqlx::FromRow; - -/// 机器灰度分配 -#[derive(Debug, Deserialize, Serialize, FromRow, Clone, Default)] -pub struct MachineGrayAllocation { - pub id: i32, - pub machine_code: String, - pub gray_release_id: i32, - pub allocated_at: DateTime, - pub effective_time: Option>, - pub status: String, - pub notes: Option, - pub created_at: DateTime, -} diff --git a/src/dto/machine_tags.rs b/src/dto/machine_tags.rs deleted file mode 100644 index fe68b5b..0000000 --- a/src/dto/machine_tags.rs +++ /dev/null @@ -1,38 +0,0 @@ -use chrono::DateTime; -use serde::{Deserialize, Serialize}; -use sqlx::FromRow; - -/// 机器标签 -#[derive(Debug, Deserialize, Serialize, FromRow, Clone)] -pub struct MachineTag { - pub id: i32, - pub tag_name: String, - pub description: Option, - pub category: Option, - pub is_active: bool, - pub created_at: DateTime, -} - -/// 机器用户标签关联 -#[derive(Debug, Deserialize, Serialize, FromRow, Clone)] -pub struct MachineUserTag { - pub id: i32, - pub machine_user_id: i32, - pub tag_id: i32, - pub created_at: DateTime, -} - -/// 带标签信息的机器用户(关联查询结果) -#[derive(Debug, Deserialize, Serialize, Clone)] -pub struct MachineUserWithTags { - pub id: i32, - pub machine_code: String, - pub user_uuid: Option, - pub platform: Option, - pub bind_time: DateTime, - pub status: String, - pub version_info: Option, - pub created_at: DateTime, - pub updated_at: Option>, - pub tags: Vec, -} diff --git a/src/dto/machine_users.rs b/src/dto/machine_users.rs deleted file mode 100644 index 52741b2..0000000 --- a/src/dto/machine_users.rs +++ /dev/null @@ -1,21 +0,0 @@ -use chrono::DateTime; -use serde::{Deserialize, Serialize}; -use sqlx::FromRow; -use uuid::Uuid; - -/// 机器用户绑定 -#[derive(Debug, Deserialize, Serialize, FromRow, Clone, Default)] -pub struct MachineUser { - pub id: i32, - pub machine_code: String, - pub user_uuid: Option, - pub platform: Option, - pub bind_time: DateTime, - pub status: String, - pub version_info: Option, // 版本信息(JSON格式) - pub hardware_hash: Option, // 硬件哈希 - pub hardware_raw: Option, // 硬件原始信息 - pub allow: bool, // 是否允许使用 - pub created_at: DateTime, - pub updated_at: Option>, -} diff --git a/src/dto/mod.rs b/src/dto/mod.rs index 8d24463..c3e5514 100644 --- a/src/dto/mod.rs +++ b/src/dto/mod.rs @@ -1,57 +1,47 @@ -pub mod gray_releases; -pub mod gray_resources; -pub mod machine_gray_allocations; -pub mod machine_tags; -pub mod machine_users; -pub mod maintenance; -pub mod strategy_types; -pub mod user; -pub mod version_types; -pub mod versions; - -// 新增模块 +pub mod maintenance; +pub mod strategy_types; +pub mod user; +pub mod version_types; +pub mod versions; + +// 新增模块 pub mod accounts; -pub mod local_api; pub mod audit; pub mod billing; pub mod environments; pub mod extensions; -pub mod group_member_permissions; -pub mod messages; -pub mod proxies; -pub mod proxy_visible_teams; -pub mod referral; -pub mod rpa; -pub mod system; +pub mod group_member_permissions; +pub mod local_api; +pub mod messages; +pub mod proxies; +pub mod proxy_visible_teams; +pub mod referral; +pub mod rpa; +pub mod system; pub mod teams; pub mod workspace_quotas; pub mod workspaces; - -pub use gray_releases::*; -pub use gray_resources::*; -pub use machine_gray_allocations::*; -pub use machine_tags::*; -pub use machine_users::*; -pub use maintenance::*; -pub use strategy_types::*; -pub use user::*; -pub use version_types::*; -pub use versions::*; - -// 新增导出 + +pub use maintenance::*; +pub use strategy_types::*; +pub use user::*; +pub use version_types::*; +pub use versions::*; + +// 新增导出 pub use accounts::*; -pub use local_api::*; pub use audit::*; pub use billing::*; pub use environments::*; pub use extensions::*; -pub use group_member_permissions::*; -pub use messages::*; -pub use proxies::*; -pub use proxy_visible_teams::*; -pub use referral::*; -pub use rpa::*; -pub use system::*; +pub use group_member_permissions::*; +pub use local_api::*; +pub use messages::*; +pub use proxies::*; +pub use proxy_visible_teams::*; +pub use referral::*; +pub use rpa::*; +pub use system::*; pub use teams::*; pub use workspace_quotas::*; pub use workspaces::*; diff --git a/src/entitys/gray_releases.rs b/src/entitys/gray_releases.rs deleted file mode 100644 index 8a224cd..0000000 --- a/src/entitys/gray_releases.rs +++ /dev/null @@ -1,72 +0,0 @@ -use chrono::DateTime; -use serde::{Deserialize, Serialize}; - -/// 创建灰度发布请求 -#[derive(Debug, Deserialize, Serialize)] -pub struct CreateGrayReleaseRequest { - pub name: String, - pub description: Option, - pub platform: String, - pub start_time: DateTime, - pub end_time: Option>, - pub max_machines: Option, - pub priority: Option, - pub strategy_type: Option, - pub strategy_config: Option, - pub version_ids: Vec, - pub auto_start: bool, -} - -/// 启动灰度发布请求 -#[derive(Debug, Deserialize, Serialize)] -pub struct StartGrayReleaseRequest { - pub gray_release_id: i32, -} - -/// 停止灰度发布请求 -#[derive(Debug, Deserialize, Serialize)] -pub struct StopGrayReleaseRequest { - pub gray_release_id: i32, -} - -/// 获取灰度发布详情请求 -#[derive(Debug, Deserialize, Serialize)] -pub struct GetGrayReleaseRequest { - pub gray_release_id: i32, -} - -/// 查询灰度发布列表请求 -#[derive(Debug, Deserialize, Serialize)] -pub struct ListGrayReleasesRequest { - pub platform: Option, - pub status: Option, - pub page: Option, - pub page_size: Option, -} - -/// 灰度发布列表响应 -#[derive(Debug, Deserialize, Serialize)] -pub struct GrayReleaseListResponse { - pub total: i64, - pub list: Vec, -} - -/// 更新灰度发布请求 -#[derive(Debug, Deserialize, Serialize)] -pub struct UpdateGrayReleaseRequest { - pub gray_release_id: i32, - pub name: Option, - pub description: Option, - pub status: Option, - pub end_time: Option>, - pub max_machines: Option, - pub priority: Option, - pub strategy_type: Option, - pub strategy_config: Option, -} - -/// 删除灰度发布请求 -#[derive(Debug, Deserialize, Serialize)] -pub struct DeleteGrayReleaseRequest { - pub gray_release_id: i32, -} diff --git a/src/entitys/gray_resources.rs b/src/entitys/gray_resources.rs deleted file mode 100644 index 941abac..0000000 --- a/src/entitys/gray_resources.rs +++ /dev/null @@ -1,35 +0,0 @@ -use serde::{Deserialize, Serialize}; - -/// 创建灰度资源请求 -#[derive(Debug, Deserialize, Serialize)] -pub struct CreateGrayResourceRequest { - pub gray_release_id: i32, - pub version_id: i32, - pub sort_order: Option, -} - -/// 批量创建灰度资源请求 -#[derive(Debug, Deserialize, Serialize)] -pub struct BatchCreateGrayResourceRequest { - pub gray_release_id: i32, - pub version_ids: Vec, -} - -/// 移除灰度资源请求 -#[derive(Debug, Deserialize, Serialize)] -pub struct RemoveGrayResourceRequest { - pub id: i32, -} - -/// 更新资源排序请求 -#[derive(Debug, Deserialize, Serialize)] -pub struct UpdateResourceSortOrderRequest { - pub id: i32, - pub sort_order: i32, -} - -/// 查询灰度资源列表请求 -#[derive(Debug, Deserialize, Serialize)] -pub struct ListGrayResourcesRequest { - pub gray_release_id: i32, -} diff --git a/src/entitys/machine_gray_allocations.rs b/src/entitys/machine_gray_allocations.rs deleted file mode 100644 index fcd50ab..0000000 --- a/src/entitys/machine_gray_allocations.rs +++ /dev/null @@ -1,56 +0,0 @@ -use chrono::DateTime; -use serde::{Deserialize, Serialize}; - -/// 分配机器到灰度请求 -#[derive(Debug, Deserialize, Serialize)] -pub struct AllocateMachineRequest { - pub machine_code: String, - pub gray_release_id: i32, - pub effective_time: Option>, - pub notes: Option, -} - -/// 批量分配机器请求 -#[derive(Debug, Deserialize, Serialize)] -pub struct BatchAllocateMachineRequest { - pub machine_codes: Vec, - pub gray_release_id: i32, - pub effective_time: Option>, - pub notes: Option, -} - -/// 分配列表响应 -#[derive(Debug, Deserialize, Serialize)] -pub struct AllocationListResponse { - pub total: i64, - pub list: Vec, -} - -/// 取消机器分配请求 -#[derive(Debug, Deserialize, Serialize)] -pub struct DeallocateMachineRequest { - pub machine_code: String, - pub gray_release_id: i32, -} - -/// 查询灰度分配列表请求 -#[derive(Debug, Deserialize, Serialize)] -pub struct ListAllocationsByReleaseRequest { - pub gray_release_id: i32, - pub page_num: Option, - pub page_size: Option, -} - -/// 检查机器是否在灰度中请求 -#[derive(Debug, Deserialize, Serialize)] -pub struct CheckMachineInGrayRequest { - pub machine_code: String, -} - -/// 检查机器是否在灰度中响应 -#[derive(Debug, Deserialize, Serialize)] -pub struct CheckMachineInGrayResponse { - pub in_gray: bool, - pub gray_release_id: Option, - pub gray_release: Option, -} diff --git a/src/entitys/machine_tags.rs b/src/entitys/machine_tags.rs deleted file mode 100644 index 1a5344b..0000000 --- a/src/entitys/machine_tags.rs +++ /dev/null @@ -1,37 +0,0 @@ -use serde::{Deserialize, Serialize}; - -/// 创建标签请求 -#[derive(Debug, Deserialize, Serialize)] -pub struct CreateTagRequest { - pub tag_name: String, - pub description: Option, - pub category: Option, -} - -/// 标签查询参数 -#[derive(Debug, Deserialize)] -pub struct QueryTagsParams { - pub category: Option, - pub is_active: Option, -} - -/// 标签列表响应 -#[derive(Debug, Deserialize, Serialize)] -pub struct TagListResponse { - pub total: i64, - pub list: Vec, -} - -/// 绑定标签到机器用户的请求 -#[derive(Debug, Deserialize, Serialize)] -pub struct BindTagsToMachineUserRequest { - pub machine_user_id: i32, - pub tag_names: Vec, -} - -/// 解绑标签的请求 -#[derive(Debug, Deserialize, Serialize)] -pub struct UnbindTagsFromMachineUserRequest { - pub machine_user_id: i32, - pub tag_ids: Vec, -} diff --git a/src/entitys/machine_users.rs b/src/entitys/machine_users.rs deleted file mode 100644 index b1036cd..0000000 --- a/src/entitys/machine_users.rs +++ /dev/null @@ -1,107 +0,0 @@ -use serde::{Deserialize, Serialize}; - -use uuid::Uuid; - -/// 创建/绑定机器用户请求 -#[derive(Debug, Deserialize, Serialize)] -pub struct CreateMachineUserRequest { - pub machine_code: String, - #[serde(skip_serializing_if = "Option::is_none")] - pub user_uuid: Option, - pub platform: Option, - /// 标签列表(可选,注册成功后会绑定到机器) - #[serde(skip_serializing_if = "Option::is_none")] - pub tags: Option>, - pub hardware_hash: Option, // 硬件哈希 - pub hardware_raw: Option, // 硬件原始信息 - pub version_info: Option, // JSON格式的版本信息 -} - -/// 更新机器用户请求 -#[derive(Debug, Deserialize, Serialize)] -pub struct UpdateMachineUserRequest { - #[serde(skip_serializing_if = "Option::is_none")] - pub user_uuid: Option, - pub platform: Option, - pub status: Option, - #[serde(skip_serializing_if = "Option::is_none")] - pub hardware_hash: Option, // 硬件哈希 - #[serde(skip_serializing_if = "Option::is_none")] - pub hardware_raw: Option, // 硬件原始信息 -} - -/// 机器用户列表响应 -#[derive(Debug, Deserialize, Serialize)] -pub struct MachineUserListResponse { - pub total: i64, - pub list: Vec, -} - -/// 查询机器用户参数 -#[derive(Debug, Deserialize, Serialize)] -pub struct QueryMachineUserParams { - pub user_uuid: Option, // UUID字符串格式 - pub platform: Option, - pub status: Option, -} - -/// 获取机器信息请求(id 或 machine_code 二选一) -#[derive(Debug, Deserialize, Serialize)] -pub struct GetMachineRequest { - #[serde(skip_serializing_if = "Option::is_none")] - pub id: Option, - #[serde(skip_serializing_if = "Option::is_none")] - pub machine_code: Option, -} - -/// 查询机器列表请求 -#[derive(Debug, Deserialize, Serialize)] -pub struct QueryMachinesRequest { - pub params: QueryMachineUserParams, - #[serde(skip_serializing_if = "Option::is_none")] - pub page_num: Option, - #[serde(skip_serializing_if = "Option::is_none")] - pub page_size: Option, -} - -/// 更新机器信息请求 -#[derive(Debug, Deserialize, Serialize)] -pub struct UpdateMachineRequest { - pub id: i32, - pub request: UpdateMachineUserRequest, -} - -/// 绑定用户到机器请求 -#[derive(Debug, Deserialize, Serialize)] -pub struct BindUserRequest { - pub machine_code: String, - pub user_uuid: Uuid, -} - -/// 解绑用户请求 -#[derive(Debug, Deserialize, Serialize)] -pub struct UnbindUserRequest { - pub machine_code: String, - pub user_uuid: Uuid, -} - -/// 获取用户机器列表请求 -#[derive(Debug, Deserialize, Serialize)] -pub struct GetUserMachinesRequest { - pub user_uuid: Uuid, -} - -/// 更新机器版本信息请求 -#[derive(Debug, Deserialize, Serialize)] -pub struct UpdateMachineVersionRequest { - pub machine_code: String, - pub user_uuid: Uuid, - pub version_info: serde_json::Value, -} - -/// 拉黑/恢复机器请求 -#[derive(Debug, Deserialize, Serialize)] -pub struct AllowOrBlacklistMachineRequest { - pub machine_code: String, - pub allow: bool, -} diff --git a/src/entitys/mod.rs b/src/entitys/mod.rs index bca1a73..9d71108 100644 --- a/src/entitys/mod.rs +++ b/src/entitys/mod.rs @@ -1,8 +1,3 @@ -pub mod gray_releases; -pub mod gray_resources; -pub mod machine_gray_allocations; -pub mod machine_tags; -pub mod machine_users; pub mod maintenance; pub mod strategy_types; pub mod user; @@ -11,7 +6,6 @@ pub mod versions; // 新增模块 pub mod accounts; -pub mod local_api; pub mod audit; pub mod billing; pub mod browser_kernel; @@ -20,6 +14,7 @@ pub mod environments; pub mod extensions; pub mod group_member_permissions; pub mod groups; +pub mod local_api; pub mod messages; pub mod proxies; pub mod proxy_visible_teams; @@ -32,11 +27,6 @@ pub mod templates; pub mod workspace_quotas; pub mod workspaces; -pub use gray_releases::*; -pub use gray_resources::*; -pub use machine_gray_allocations::*; -pub use machine_tags::*; -pub use machine_users::*; pub use maintenance::*; pub use strategy_types::*; pub use user::*; @@ -45,7 +35,6 @@ pub use versions::*; // 新增导出 pub use accounts::*; -pub use local_api::*; pub use audit::*; pub use billing::*; pub use browser_kernel::*; @@ -54,6 +43,7 @@ pub use environments::*; pub use extensions::*; pub use group_member_permissions::*; pub use groups::*; +pub use local_api::*; pub use messages::*; pub use proxies::*; pub use proxy_visible_teams::*; diff --git a/src/entitys/user.rs b/src/entitys/user.rs index a778684..8df1108 100644 --- a/src/entitys/user.rs +++ b/src/entitys/user.rs @@ -2,20 +2,6 @@ use chrono::{DateTime, Utc}; use serde::{Deserialize, Serialize}; use uuid::Uuid; -/// 机器信息(用于注册和登录) -#[derive(Debug, Deserialize, Serialize, Default, Clone)] -pub struct MachineInfo { - pub machine_code: String, - #[serde(skip_serializing_if = "Option::is_none")] - pub platform: Option, - #[serde(skip_serializing_if = "Option::is_none")] - pub hardware_hash: Option, // 硬件哈希 - #[serde(skip_serializing_if = "Option::is_none")] - pub hardware_raw: Option, // 硬件原始信息 - #[serde(skip_serializing_if = "Option::is_none")] - pub version_info: Option, // JSON格式的版本信息 -} - /// 注册请求 #[derive(Debug, Deserialize, Serialize, Default)] pub struct RegisterRequest { @@ -27,8 +13,6 @@ pub struct RegisterRequest { #[serde(skip_serializing_if = "Option::is_none")] pub public_secret_key: Option, // 用户公钥(可选) #[serde(skip_serializing_if = "Option::is_none")] - pub machine_info: Option, // 机器信息(可选) - #[serde(skip_serializing_if = "Option::is_none")] pub referral_code: Option, // 推荐码(可选) } @@ -38,8 +22,6 @@ pub struct BasicLoginData { pub email: String, pub password: String, #[serde(skip_serializing_if = "Option::is_none")] - pub machine_info: Option, - #[serde(skip_serializing_if = "Option::is_none")] pub public_secret_key: Option, } @@ -49,8 +31,6 @@ pub struct RememberLoginData { pub email: String, pub refresh_token: String, #[serde(skip_serializing_if = "Option::is_none")] - pub machine_info: Option, - #[serde(skip_serializing_if = "Option::is_none")] pub public_secret_key: Option, } diff --git a/src/main.rs b/src/main.rs index f20ee6c..b84af01 100644 --- a/src/main.rs +++ b/src/main.rs @@ -4,13 +4,12 @@ use axum::{Router, middleware}; use simprint_server::{init_encrypt_secret, init_storage}; use simprint_server::{ middlewares, + routes::route::MetaRoute, routes::{ - accounts, audit, billing, browser_kernel, environments, extensions, local_api, - group_permissions, messages, - preferences, proxies, proxy_visibility, referral, rpa, secret, teams, templates, time, - users, workspace_quotas, workspaces, + accounts, audit, billing, browser_kernel, environments, extensions, group_permissions, + local_api, messages, preferences, proxies, proxy_visibility, referral, rpa, secret, teams, + templates, time, users, workspace_quotas, workspaces, }, - routes::route::MetaRoute, svc_ctx::SvcCtx, utils::IConfig, }; @@ -108,10 +107,6 @@ fn register_middlewares(svc_ctx: &SvcCtx, app: Router) -> Router svc_ctx.clone(), middlewares::local_api_auth, )) - .route_layer(middleware::from_fn_with_state( - svc_ctx.clone(), - middlewares::machine_auth, - )) .route_layer(middleware::from_fn_with_state( svc_ctx.clone(), middlewares::decrypt, diff --git a/src/middlewares.rs b/src/middlewares.rs index 50d8773..0530061 100644 --- a/src/middlewares.rs +++ b/src/middlewares.rs @@ -2,43 +2,41 @@ mod auth; mod cors; mod decrypt; mod encrypt; -mod logger; mod local_api_auth; -mod machine_auth; +mod logger; mod real_ip; pub use auth::*; pub use cors::*; pub use decrypt::*; pub use encrypt::*; -pub use logger::*; pub use local_api_auth::*; -pub use machine_auth::*; +pub use logger::*; pub use real_ip::*; - -use axum::extract::{MatchedPath, Request}; - -/// 获取请求路径和请求方法 -pub fn get_request_path_and_method(req: &Request) -> (&str, &str) { - let path = req - .extensions() - .get::() - .map_or_else(|| req.uri().path(), |path| path.as_str()); - - let method = req.method().as_str(); - - (method, path) -} - -/// 从完整路径中提取业务路径(去除 /api/v*/ 前缀,保留前导斜杠) -/// 例如:/api/v1/environments -> /environments -pub fn extract_resource_path(path: &str) -> &str { - // 匹配 /api/v{数字}/ 格式的前缀 - if let Some(stripped) = path.strip_prefix("/api/v") { - // 找到第一个 / 后的内容(包含斜杠) - if let Some(pos) = stripped.find('/') { - return &stripped[pos..]; - } - } - path -} + +use axum::extract::{MatchedPath, Request}; + +/// 获取请求路径和请求方法 +pub fn get_request_path_and_method(req: &Request) -> (&str, &str) { + let path = req + .extensions() + .get::() + .map_or_else(|| req.uri().path(), |path| path.as_str()); + + let method = req.method().as_str(); + + (method, path) +} + +/// 从完整路径中提取业务路径(去除 /api/v*/ 前缀,保留前导斜杠) +/// 例如:/api/v1/environments -> /environments +pub fn extract_resource_path(path: &str) -> &str { + // 匹配 /api/v{数字}/ 格式的前缀 + if let Some(stripped) = path.strip_prefix("/api/v") { + // 找到第一个 / 后的内容(包含斜杠) + if let Some(pos) = stripped.find('/') { + return &stripped[pos..]; + } + } + path +} diff --git a/src/middlewares/machine_auth.rs b/src/middlewares/machine_auth.rs deleted file mode 100644 index 6115973..0000000 --- a/src/middlewares/machine_auth.rs +++ /dev/null @@ -1,71 +0,0 @@ -use crate::services::{get_machine_by_code, machine_not_allow}; -// use crate::services::{get_machine_by_code_service, machine_not_allow_service}; -use crate::state::{CurrentMachineUser, RequestContext}; -use crate::svc_ctx::SvcCtx; -use axum::{extract::Request, extract::State, middleware::Next, response::Response}; - -/// 机器码认证中间件 -/// 从请求头 x-machine-code 中提取机器码,并验证机器是否允许使用 -pub async fn machine_auth( - State(svc_ctx): State, - mut request: Request, - next: Next, -) -> Response { - // 从请求头获取机器码 - let machine_code = request - .headers() - .get("x-machine-code") - .and_then(|v| v.to_str().ok()) - .map(|s| s.to_string()); - - if let Some(code) = machine_code { - // 检查机器是否被拉黑 - let current_machine = match machine_not_allow(&svc_ctx, code.clone()).await { - Ok(true) => { - // 机器被拉黑,但继续处理(业务层决定如何处理) - CurrentMachineUser { - machine_code: code, - platform: None, - user_uuid: None, - is_allowed: false, - } - } - Ok(false) => { - // 机器未被拉黑,尝试获取机器信息 - match get_machine_by_code(&svc_ctx, code.clone()).await { - Ok(machine) => CurrentMachineUser { - machine_code: code, - platform: machine.platform, - user_uuid: machine.user_uuid, - is_allowed: true, - }, - Err(_) => { - // 机器不存在,创建默认的机器用户信息 - CurrentMachineUser { - machine_code: code, - platform: None, - user_uuid: None, - is_allowed: true, - } - } - } - } - Err(_) => { - // 查询出错,创建默认的机器用户信息 - CurrentMachineUser { - machine_code: code, - platform: None, - user_uuid: None, - is_allowed: true, - } - } - }; - - // 更新 RequestContext 中的 current_machine_user - if let Some(ctx) = request.extensions_mut().get_mut::() { - ctx.current_machine_user = Some(current_machine); - } - } - - next.run(request).await -} diff --git a/src/models/gray_releases.rs b/src/models/gray_releases.rs deleted file mode 100644 index cf665d6..0000000 --- a/src/models/gray_releases.rs +++ /dev/null @@ -1,292 +0,0 @@ -use crate::dto::gray_releases::GrayRelease; -use crate::entitys::gray_releases::{CreateGrayReleaseRequest, UpdateGrayReleaseRequest}; -use sqlx::{Error, Pool, Postgres}; - -/// 创建灰度发布 -pub async fn create_gray_release( - pool: &Pool, - request: &CreateGrayReleaseRequest, -) -> Result { - let sql = " - INSERT INTO gray_releases ( - name, description, platform, status, start_time, end_time, - max_machines, priority, strategy_type, strategy_config, created_at - ) VALUES ( - $1, $2, $3, 'pending', $4, $5, $6, $7, $8, $9, NOW() - ) RETURNING id - "; - - let result: (i32,) = sqlx::query_as(sql) - .bind(&request.name) - .bind(&request.description) - .bind(&request.platform) - .bind(request.start_time) - .bind(request.end_time) - .bind(request.max_machines) - .bind(request.priority.unwrap_or(0)) - .bind(&request.strategy_type) - .bind(&request.strategy_config) - .fetch_one(pool) - .await?; - - Ok(result.0) -} - -/// 查询灰度发布 -pub async fn query_gray_release_by_id( - pool: &Pool, - id: i32, -) -> Result { - let release: GrayRelease = sqlx::query_as("SELECT * FROM gray_releases WHERE id = $1") - .bind(id) - .fetch_one(pool) - .await?; - - Ok(release) -} - -/// 查询灰度发布列表 -pub async fn query_gray_releases( - pool: &Pool, - platform: Option<&str>, - status: Option<&str>, - page_num: i32, - page_size: i32, -) -> Result<(i64, Vec), Error> { - let mut where_conditions = vec![]; - let mut param_index = 1; - - if let Some(_plat) = platform { - where_conditions.push(format!("platform = ${}", param_index)); - param_index += 1; - } - if let Some(_st) = status { - where_conditions.push(format!("status = ${}", param_index)); - param_index += 1; - } - - let where_clause = if where_conditions.is_empty() { - "".to_string() - } else { - format!("WHERE {}", where_conditions.join(" AND ")) - }; - - // 获取总数 - let count_sql = format!("SELECT COUNT(*) FROM gray_releases {}", where_clause); - let count_query = match (platform, status) { - (Some(plat), Some(st)) => sqlx::query_as(&count_sql).bind(plat).bind(st), - (Some(plat), None) => sqlx::query_as(&count_sql).bind(plat), - (None, Some(st)) => sqlx::query_as(&count_sql).bind(st), - (None, None) => sqlx::query_as(&count_sql), - }; - let total: (i64,) = count_query.fetch_one(pool).await?; - - // 获取分页列表 - let list_sql = format!( - "SELECT * FROM gray_releases {} ORDER BY created_at DESC LIMIT ${} OFFSET ${}", - where_clause, - param_index, - param_index + 1 - ); - let list_query = match (platform, status) { - (Some(plat), Some(st)) => sqlx::query_as(&list_sql) - .bind(plat) - .bind(st) - .bind(page_size) - .bind((page_num - 1) * page_size), - (Some(plat), None) => sqlx::query_as(&list_sql) - .bind(plat) - .bind(page_size) - .bind((page_num - 1) * page_size), - (None, Some(st)) => sqlx::query_as(&list_sql) - .bind(st) - .bind(page_size) - .bind((page_num - 1) * page_size), - (None, None) => sqlx::query_as(&list_sql) - .bind(page_size) - .bind((page_num - 1) * page_size), - }; - let releases: Vec = list_query.fetch_all(pool).await?; - - Ok((total.0, releases)) -} - -/// 更新灰度发布 -pub async fn update_gray_release( - pool: &Pool, - id: i32, - request: &UpdateGrayReleaseRequest, -) -> Result { - let sql = " - UPDATE gray_releases SET - name = COALESCE($1, name), - description = COALESCE($2, description), - status = COALESCE($3, status), - end_time = COALESCE($4, end_time), - max_machines = COALESCE($5, max_machines), - priority = COALESCE($6, priority), - strategy_type = COALESCE($7, strategy_type), - strategy_config = COALESCE($8, strategy_config), - updated_at = NOW() - WHERE id = $9 - "; - - let row = sqlx::query(sql) - .bind(&request.name) - .bind(&request.description) - .bind(&request.status) - .bind(request.end_time) - .bind(request.max_machines) - .bind(request.priority) - .bind(&request.strategy_type) - .bind(&request.strategy_config) - .bind(id) - .execute(pool) - .await?; - - Ok(row.rows_affected() == 1) -} - -/// 更新配额计数 -pub async fn update_allocated_count( - pool: &Pool, - id: i32, - increment: i32, -) -> Result { - let sql = " - UPDATE gray_releases - SET allocated_count = allocated_count + $1, updated_at = NOW() - WHERE id = $2 - "; - - let row = sqlx::query(sql) - .bind(increment) - .bind(id) - .execute(pool) - .await?; - - Ok(row.rows_affected() == 1) -} - -/// 增加分配计数 -pub async fn increment_allocated_count(pool: &Pool, id: i32) -> Result { - update_allocated_count(pool, id, 1).await -} - -/// 减少分配计数 -pub async fn decrement_allocated_count(pool: &Pool, id: i32) -> Result { - update_allocated_count(pool, id, -1).await -} - -/// 查询活跃的灰度发布 -pub async fn query_active_gray_releases( - pool: &Pool, - machine_code: &str, -) -> Result, Error> { - let releases: Vec = sqlx::query_as( - r#" - SELECT gr.* FROM gray_releases gr - INNER JOIN machine_gray_allocations mga ON gr.id = mga.gray_release_id - WHERE mga.machine_code = $1 - AND mga.status = 'active' - AND gr.status = 'active' - AND NOW() >= gr.start_time - AND (gr.end_time IS NULL OR NOW() <= gr.end_time) - ORDER BY gr.priority DESC, gr.created_at DESC - "#, - ) - .bind(machine_code) - .fetch_all(pool) - .await?; - - Ok(releases) -} - -/// 删除灰度发布(物理删除) -pub async fn delete_gray_release(pool: &Pool, id: i32) -> Result { - let sql = "DELETE FROM gray_releases WHERE id = $1"; - let row = sqlx::query(sql).bind(id).execute(pool).await?; - Ok(row.rows_affected() == 1) -} - -/// 检查白名单策略(直接 JOIN 查询,单次查询 O(1)) -pub async fn check_whitelist_strategy( - pool: &Pool, - machine_code: &str, -) -> Result, Error> { - let release = sqlx::query_as::<_, GrayRelease>( - r#" - SELECT gr.* FROM gray_releases gr - INNER JOIN machine_gray_allocations mga ON gr.id = mga.gray_release_id - WHERE mga.machine_code = $1 - AND mga.status = 'active' - AND gr.status = 'active' - AND gr.start_time <= NOW() - AND (gr.end_time IS NULL OR gr.end_time >= NOW()) - ORDER BY gr.priority DESC, gr.created_at DESC - LIMIT 1 - "#, - ) - .bind(machine_code) - .fetch_optional(pool) - .await?; - - Ok(release) -} - -/// 检查标签策略(数据库层面过滤,单次查询) -pub async fn check_tags_strategy( - pool: &Pool, - machine_code: &str, -) -> Result, Error> { - let release = sqlx::query_as::<_, GrayRelease>( - r#" - SELECT DISTINCT gr.* FROM gray_releases gr - INNER JOIN machine_users mu ON mu.machine_code = $1 - INNER JOIN machine_user_tags mut ON mut.machine_user_id = mu.id - INNER JOIN machine_tags mt ON mt.id = mut.tag_id - WHERE gr.status = 'active' - AND gr.strategy_type = 'filter_user_tag' - AND gr.start_time <= NOW() - AND (gr.end_time IS NULL OR gr.end_time >= NOW()) - AND mt.tag_name = ANY( - SELECT jsonb_array_elements_text(gr.strategy_config->'tags') - ) - GROUP BY gr.id - HAVING CASE - WHEN COALESCE((gr.strategy_config->>'match_all')::boolean, false) = true - THEN COUNT(DISTINCT mt.tag_name) >= ( - SELECT jsonb_array_length(gr.strategy_config->'tags') - ) - ELSE COUNT(DISTINCT mt.tag_name) >= 1 - END - ORDER BY gr.priority DESC, gr.created_at DESC - LIMIT 1 - "#, - ) - .bind(machine_code) - .fetch_optional(pool) - .await?; - - Ok(release) -} - -/// 查询需要动态计算的灰度发布(百分比、随机策略) -pub async fn query_dynamic_strategy_releases( - pool: &Pool, -) -> Result, Error> { - let releases = sqlx::query_as::<_, GrayRelease>( - r#" - SELECT * FROM gray_releases - WHERE status = 'active' - AND strategy_type IN ('filter_percentage', 'filter_random') - AND start_time <= NOW() - AND (end_time IS NULL OR end_time >= NOW()) - ORDER BY priority DESC, created_at DESC - "#, - ) - .fetch_all(pool) - .await?; - - Ok(releases) -} diff --git a/src/models/gray_resources.rs b/src/models/gray_resources.rs deleted file mode 100644 index f4d4389..0000000 --- a/src/models/gray_resources.rs +++ /dev/null @@ -1,127 +0,0 @@ -use crate::dto::gray_resources::GrayResource; -use crate::entitys::gray_resources::{BatchCreateGrayResourceRequest, CreateGrayResourceRequest}; -use sqlx::{Error, Pool, Postgres}; - -/// 创建灰度资源关联 -pub async fn create_gray_resource( - pool: &Pool, - request: &CreateGrayResourceRequest, -) -> Result { - let sql = " - INSERT INTO gray_resources (gray_release_id, version_id, sort_order) - VALUES ($1, $2, $3) RETURNING id - "; - - let result: (i32,) = sqlx::query_as(sql) - .bind(request.gray_release_id) - .bind(request.version_id) - .bind(request.sort_order.unwrap_or(0)) - .fetch_one(pool) - .await?; - - Ok(result.0) -} - -/// 批量创建灰度资源 -pub async fn batch_create_gray_resources( - pool: &Pool, - request: &BatchCreateGrayResourceRequest, -) -> Result { - let mut tx = pool.begin().await?; - - let mut count = 0; - for (index, version_id) in request.version_ids.iter().enumerate() { - let sql = " - INSERT INTO gray_resources (gray_release_id, version_id, sort_order) - VALUES ($1, $2, $3) - "; - - sqlx::query(sql) - .bind(request.gray_release_id) - .bind(version_id) - .bind(index as i32) - .execute(&mut *tx) - .await?; - - count += 1; - } - - tx.commit().await?; - Ok(count) -} - -/// 查询灰度发布的所有资源 -pub async fn query_gray_resources( - pool: &Pool, - gray_release_id: i32, -) -> Result, Error> { - let resources: Vec = sqlx::query_as( - "SELECT * FROM gray_resources WHERE gray_release_id = $1 ORDER BY sort_order", - ) - .bind(gray_release_id) - .fetch_all(pool) - .await?; - - Ok(resources) -} - -/// 根据ID查询灰度资源 -pub async fn query_gray_resource_by_id( - pool: &Pool, - id: i32, -) -> Result { - let resource: GrayResource = sqlx::query_as("SELECT * FROM gray_resources WHERE id = $1") - .bind(id) - .fetch_one(pool) - .await?; - - Ok(resource) -} - -/// 查询最大排序值 -pub async fn query_max_sort_order( - pool: &Pool, - gray_release_id: i32, -) -> Result { - let result: Option<(i32,)> = - sqlx::query_as("SELECT MAX(sort_order) FROM gray_resources WHERE gray_release_id = $1") - .bind(gray_release_id) - .fetch_optional(pool) - .await?; - - Ok(result.map(|r| r.0).unwrap_or(0)) -} - -/// 更新排序值 -pub async fn update_sort_order( - pool: &Pool, - id: i32, - sort_order: i32, -) -> Result { - let sql = "UPDATE gray_resources SET sort_order = $1 WHERE id = $2"; - let row = sqlx::query(sql) - .bind(sort_order) - .bind(id) - .execute(pool) - .await?; - Ok(row.rows_affected() == 1) -} - -/// 按灰度发布 ID 删除该灰度下所有资源关联(用于删除灰度前清理外键) -pub async fn delete_resources_by_gray_release_id( - pool: &Pool, - gray_release_id: i32, -) -> Result { - let row = sqlx::query("DELETE FROM gray_resources WHERE gray_release_id = $1") - .bind(gray_release_id) - .execute(pool) - .await?; - Ok(row.rows_affected()) -} - -/// 删除灰度资源 -pub async fn delete_gray_resource(pool: &Pool, id: i32) -> Result { - let sql = "DELETE FROM gray_resources WHERE id = $1"; - let row = sqlx::query(sql).bind(id).execute(pool).await?; - Ok(row.rows_affected() == 1) -} diff --git a/src/models/machine_gray_allocations.rs b/src/models/machine_gray_allocations.rs deleted file mode 100644 index 6278c63..0000000 --- a/src/models/machine_gray_allocations.rs +++ /dev/null @@ -1,187 +0,0 @@ -use crate::dto::machine_gray_allocations::MachineGrayAllocation; -use crate::entitys::machine_gray_allocations::{ - AllocateMachineRequest, BatchAllocateMachineRequest, -}; -use sqlx::{Error, Pool, Postgres}; - -/// 分配机器到灰度 -pub async fn allocate_machine( - pool: &Pool, - request: &AllocateMachineRequest, -) -> Result { - let sql = " - INSERT INTO machine_gray_allocations ( - machine_code, gray_release_id, allocated_at, - effective_time, status, notes, created_at - ) VALUES ( - $1, $2, NOW(), $3, 'active', $4, NOW() - ) - ON CONFLICT (machine_code, gray_release_id) DO UPDATE SET - status = 'active', - effective_time = COALESCE(EXCLUDED.effective_time, machine_gray_allocations.effective_time), - notes = COALESCE(EXCLUDED.notes, machine_gray_allocations.notes) - "; - - let row = sqlx::query(sql) - .bind(&request.machine_code) - .bind(request.gray_release_id) - .bind(request.effective_time) - .bind(&request.notes) - .execute(pool) - .await?; - - Ok(row.rows_affected() > 0) -} - -/// 批量分配机器 -pub async fn batch_allocate_machines( - pool: &Pool, - request: &BatchAllocateMachineRequest, -) -> Result { - let mut tx = pool.begin().await?; - - let mut count = 0; - for machine_code in &request.machine_codes { - let sql = " - INSERT INTO machine_gray_allocations ( - machine_code, gray_release_id, allocated_at, - effective_time, status, notes, created_at - ) VALUES ( - $1, $2, NOW(), $3, 'active', $4, NOW() - ) - ON CONFLICT (machine_code, gray_release_id) DO NOTHING - "; - - let row = sqlx::query(sql) - .bind(machine_code) - .bind(request.gray_release_id) - .bind(request.effective_time) - .bind(&request.notes) - .execute(&mut *tx) - .await?; - - if row.rows_affected() > 0 { - count += 1; - } - } - - tx.commit().await?; - Ok(count) -} - -/// 按灰度发布 ID 删除该灰度下所有机器分配(用于删除灰度前清理外键) -pub async fn delete_allocations_by_gray_release_id( - pool: &Pool, - gray_release_id: i32, -) -> Result { - let row = sqlx::query("DELETE FROM machine_gray_allocations WHERE gray_release_id = $1") - .bind(gray_release_id) - .execute(pool) - .await?; - Ok(row.rows_affected()) -} - -/// 移除机器分配 -pub async fn remove_allocation( - pool: &Pool, - machine_code: &str, - gray_release_id: i32, -) -> Result { - let sql = " - UPDATE machine_gray_allocations - SET status = 'released' - WHERE machine_code = $1 AND gray_release_id = $2 - "; - - let row = sqlx::query(sql) - .bind(machine_code) - .bind(gray_release_id) - .execute(pool) - .await?; - - Ok(row.rows_affected() == 1) -} - -/// 查询机器分配 -pub async fn query_allocation( - pool: &Pool, - machine_code: &str, - gray_release_id: i32, -) -> Result, Error> { - let allocation: Option = sqlx::query_as( - "SELECT * FROM machine_gray_allocations - WHERE machine_code = $1 AND gray_release_id = $2", - ) - .bind(machine_code) - .bind(gray_release_id) - .fetch_optional(pool) - .await?; - - Ok(allocation) -} - -/// 查询机器的所有活跃分配 -pub async fn query_machine_allocations( - pool: &Pool, - machine_code: &str, -) -> Result, Error> { - let allocations: Vec = sqlx::query_as( - "SELECT * FROM machine_gray_allocations - WHERE machine_code = $1 AND status = 'active' - ORDER BY allocated_at DESC", - ) - .bind(machine_code) - .fetch_all(pool) - .await?; - - Ok(allocations) -} - -/// 查询灰度发布的所有分配 -pub async fn query_release_allocations( - pool: &Pool, - gray_release_id: i32, - page_num: i32, - page_size: i32, -) -> Result<(i64, Vec), Error> { - // 获取总数 - let total: (i64,) = - sqlx::query_as("SELECT COUNT(*) FROM machine_gray_allocations WHERE gray_release_id = $1") - .bind(gray_release_id) - .fetch_one(pool) - .await?; - - // 获取分页列表 - let allocations: Vec = sqlx::query_as( - "SELECT * FROM machine_gray_allocations - WHERE gray_release_id = $1 - ORDER BY allocated_at DESC - LIMIT $2 OFFSET $3", - ) - .bind(gray_release_id) - .bind(page_size) - .bind((page_num - 1) * page_size) - .fetch_all(pool) - .await?; - - Ok((total.0, allocations)) -} - -/// 检查机器是否在灰度中 -pub async fn is_machine_in_gray( - pool: &Pool, - machine_code: &str, - gray_release_id: i32, -) -> Result { - let result: Option<(i32,)> = sqlx::query_as( - "SELECT 1 FROM machine_gray_allocations - WHERE machine_code = $1 AND gray_release_id = $2 AND status = 'active' - LIMIT 1", - ) - .bind(machine_code) - .bind(gray_release_id) - .fetch_optional(pool) - .await?; - - Ok(result.is_some()) -} diff --git a/src/models/machine_tags.rs b/src/models/machine_tags.rs deleted file mode 100644 index 27cec82..0000000 --- a/src/models/machine_tags.rs +++ /dev/null @@ -1,230 +0,0 @@ -use crate::dto::machine_tags::MachineTag; -use crate::entitys::machine_tags::{CreateTagRequest, QueryTagsParams}; -use sqlx::{Error, Pool, Postgres}; - -/// 插入或获取标签(按名称去重) -pub async fn insert_or_get_tag( - pool: &Pool, - request: &CreateTagRequest, -) -> Result { - // 先尝试查询是否存在 - let existing_tag = - sqlx::query_as::<_, (i32,)>("SELECT id FROM machine_tags WHERE tag_name = $1") - .bind(&request.tag_name) - .fetch_optional(pool) - .await?; - - if let Some((id,)) = existing_tag { - return Ok(id); - } - - // 不存在则插入 - let result: (i32,) = sqlx::query_as( - " - INSERT INTO machine_tags (tag_name, description, category, is_active, created_at) - VALUES ($1, $2, $3, true, NOW()) - RETURNING id - ", - ) - .bind(&request.tag_name) - .bind(&request.description) - .bind(&request.category) - .fetch_one(pool) - .await?; - - Ok(result.0) -} - -/// 批量插入或获取标签(按名称列表批量去重) -pub async fn batch_insert_or_get_tags( - pool: &Pool, - tag_names: &[String], -) -> Result, Error> { - if tag_names.is_empty() { - return Ok(vec![]); - } - - let mut tag_ids = Vec::new(); - - // 查询已存在的标签 - let existing_tags = sqlx::query_as::<_, (String, i32)>( - " - SELECT tag_name, id FROM machine_tags - WHERE tag_name = ANY($1) - ", - ) - .bind(tag_names) - .fetch_all(pool) - .await?; - - // 建立已存在标签的映射 - let mut existing_map = std::collections::HashMap::new(); - for (name, id) in existing_tags { - existing_map.insert(name, id); - tag_ids.push(id); - } - - // 找出需要插入的标签 - let tags_to_insert: Vec = tag_names - .iter() - .filter(|name| !existing_map.contains_key(*name)) - .cloned() - .collect(); - - // 批量插入新标签 - if !tags_to_insert.is_empty() { - for tag_name in tags_to_insert { - let new_id = sqlx::query_as::<_, (i32,)>( - " - INSERT INTO machine_tags (tag_name, is_active, created_at) - VALUES ($1, true, NOW()) - RETURNING id - ", - ) - .bind(&tag_name) - .fetch_one(pool) - .await?; - - tag_ids.push(new_id.0); - } - } - - Ok(tag_ids) -} - -/// 查询所有去重标签 -pub async fn query_all_distinct_tags( - pool: &Pool, - params: &QueryTagsParams, -) -> Result, Error> { - let mut query = " - SELECT * FROM machine_tags - WHERE 1=1 - " - .to_string(); - - let _ = params; // 暂时忽略参数,直接查询所有激活标签 - - if let Some(_category) = ¶ms.category { - query.push_str(" AND category = $1"); - } - - if let Some(_is_active) = params.is_active { - query.push_str(" AND is_active = $2"); - } - - query.push_str(" ORDER BY created_at DESC"); - - let result = if params.category.is_some() || params.is_active.is_some() { - sqlx::query_as::<_, MachineTag>(&query) - .bind(¶ms.category) - .bind(¶ms.is_active) - .fetch_all(pool) - .await? - } else { - sqlx::query_as::<_, MachineTag>(&query) - .fetch_all(pool) - .await? - }; - - Ok(result) -} - -/// 根据机器用户ID查询标签 -pub async fn query_tags_by_machine_user_id( - pool: &Pool, - machine_user_id: i32, -) -> Result, Error> { - let tags = sqlx::query_as::<_, MachineTag>( - " - SELECT mt.* FROM machine_tags mt - INNER JOIN machine_user_tags mut ON mt.id = mut.tag_id - WHERE mut.machine_user_id = $1 - ORDER BY mt.tag_name - ", - ) - .bind(machine_user_id) - .fetch_all(pool) - .await?; - - Ok(tags) -} - -/// 绑定标签到机器用户 -pub async fn bind_tags_to_machine_user( - pool: &Pool, - machine_user_id: i32, - tag_ids: &[i32], -) -> Result { - if tag_ids.is_empty() { - return Ok(0); - } - - let mut count = 0; - - for &tag_id in tag_ids { - // 使用 ON CONFLICT DO NOTHING 避免重复插入 - let result = sqlx::query( - " - INSERT INTO machine_user_tags (machine_user_id, tag_id, created_at) - VALUES ($1, $2, NOW()) - ON CONFLICT (machine_user_id, tag_id) DO NOTHING - ", - ) - .bind(machine_user_id) - .bind(tag_id) - .execute(pool) - .await?; - - count += result.rows_affected() as usize; - } - - Ok(count) -} - -/// 解绑标签 -pub async fn unbind_tags_from_machine_user( - pool: &Pool, - machine_user_id: i32, - tag_ids: &[i32], -) -> Result { - if tag_ids.is_empty() { - return Ok(0); - } - - let result = sqlx::query( - " - DELETE FROM machine_user_tags - WHERE machine_user_id = $1 AND tag_id = ANY($2) - ", - ) - .bind(machine_user_id) - .bind(tag_ids) - .execute(pool) - .await?; - - Ok(result.rows_affected() as usize) -} - -/// 根据标签查询机器用户ID -pub async fn query_machine_user_ids_by_tag_names( - pool: &Pool, - tag_names: &[String], -) -> Result, Error> { - if tag_names.is_empty() { - return Ok(vec![]); - } - - let machine_user_ids = sqlx::query_as::<_, (i32,)>( - " - SELECT DISTINCT mut.machine_user_id FROM machine_user_tags mut - INNER JOIN machine_tags mt ON mt.id = mut.tag_id - WHERE mt.tag_name = ANY($1) - ", - ) - .bind(tag_names) - .fetch_all(pool) - .await?; - - Ok(machine_user_ids.into_iter().map(|(id,)| id).collect()) -} diff --git a/src/models/machine_users.rs b/src/models/machine_users.rs deleted file mode 100644 index 2dcb142..0000000 --- a/src/models/machine_users.rs +++ /dev/null @@ -1,481 +0,0 @@ -use crate::dto::machine_users::MachineUser; -use crate::entitys::machine_users::{CreateMachineUserRequest, UpdateMachineUserRequest}; -use sqlx::{Error, Pool, Postgres}; -use uuid::Uuid; - -/// 插入新机器用户(支持 version_info 一次性插入) -pub async fn insert_machine_user( - pool: &Pool, - request: &CreateMachineUserRequest, -) -> Result { - let sql = " - INSERT INTO machine_users ( - machine_code, user_uuid, platform, status, version_info, hardware_hash, hardware_raw, bind_time, created_at - ) VALUES ( - $1, $2, $3, 'active', $4, $5, $6, NOW(), NOW() - ) RETURNING id - "; - - // 解析 version_info JSON 字符串 - let version_info_value = request - .version_info - .as_ref() - .and_then(|v| serde_json::from_str::(v).ok()); - - let result: (i32,) = sqlx::query_as(sql) - .bind(&request.machine_code) - .bind(&request.user_uuid) - .bind(&request.platform) - .bind(&version_info_value) - .bind(&request.hardware_hash) - .bind(&request.hardware_raw) - .fetch_one(pool) - .await?; - - Ok(result.0) -} - -/// 根据ID查询机器用户 -pub async fn query_machine_user_by_id( - pool: &Pool, - id: i32, -) -> Result { - let machine_user: MachineUser = sqlx::query_as("SELECT * FROM machine_users WHERE id = $1") - .bind(id) - .fetch_one(pool) - .await?; - - Ok(machine_user) -} - -/// 根据机器码查询机器用户(返回多个记录中的第一个) -pub async fn query_machine_user_by_code( - pool: &Pool, - machine_code: &str, -) -> Result { - let machine_user: MachineUser = sqlx::query_as( - "SELECT * FROM machine_users WHERE machine_code = $1 ORDER BY bind_time DESC LIMIT 1", - ) - .bind(machine_code) - .fetch_one(pool) - .await?; - - Ok(machine_user) -} - -/// 根据机器码查询所有机器用户记录 -pub async fn query_machines_by_code( - pool: &Pool, - machine_code: &str, -) -> Result, Error> { - let machines: Vec = sqlx::query_as( - "SELECT * FROM machine_users WHERE machine_code = $1 ORDER BY bind_time DESC", - ) - .bind(machine_code) - .fetch_all(pool) - .await?; - - Ok(machines) -} - -/// 根据机器码和用户UUID查询机器用户 -pub async fn query_machine_user_by_code_and_user( - pool: &Pool, - machine_code: &str, - user_uuid: &Uuid, -) -> Result, Error> { - let machine_user: Option = - sqlx::query_as("SELECT * FROM machine_users WHERE machine_code = $1 AND user_uuid = $2") - .bind(machine_code) - .bind(user_uuid) - .fetch_optional(pool) - .await?; - - Ok(machine_user) -} - -/// 判断机器是否允许 -pub async fn machine_not_allow(pool: &Pool, machine_code: &str) -> Result { - let allow: bool = sqlx::query_scalar( - "SELECT EXISTS(SELECT 1 FROM machine_users WHERE machine_code = $1 AND allow = false) ", - ) - .bind(machine_code) - .fetch_one(pool) - .await?; - - Ok(allow) -} - -/// 拉黑或取消拉黑机器 -pub async fn allow_or_blacklist_machine(pool: &Pool, machine_code: &str, allow: bool) -> Result { - let row = sqlx::query("UPDATE machine_users SET allow = $1 WHERE machine_code = $2") - .bind(allow) - .bind(machine_code) - .execute(pool) - .await?; - - Ok(row.rows_affected() > 1) -} - -/// 根据用户UUID查询机器列表 -pub async fn query_machines_by_user_uuid( - pool: &Pool, - user_uuid: &Uuid, -) -> Result, Error> { - let machines: Vec = - sqlx::query_as("SELECT * FROM machine_users WHERE user_uuid = $1 ORDER BY bind_time DESC") - .bind(user_uuid) - .fetch_all(pool) - .await?; - - Ok(machines) -} - -/// 根据多个用户 UUID 批量查询机器列表(返回所有匹配的 machine_users,含 user_uuid 便于按用户分组) -pub async fn query_machines_by_user_uuids( - pool: &Pool, - user_uuids: &[Uuid], -) -> Result, Error> { - if user_uuids.is_empty() { - return Ok(vec![]); - } - let machines: Vec = sqlx::query_as( - "SELECT * FROM machine_users WHERE user_uuid = ANY($1) ORDER BY user_uuid, bind_time DESC", - ) - .bind(user_uuids) - .fetch_all(pool) - .await?; - - Ok(machines) -} - -/// 查询机器用户列表(分页) -pub async fn query_machine_users( - pool: &Pool, - user_uuid: Option<&Uuid>, - platform: Option<&str>, - status: Option<&str>, - page_num: i32, - page_size: i32, -) -> Result<(i64, Vec), Error> { - // 构建查询条件 - let mut where_clauses = vec!["1=1".to_string()]; - let mut param_index = 1; - - if let Some(_uuid) = user_uuid { - where_clauses.push(format!("user_uuid = ${}", param_index)); - param_index += 1; - } - if let Some(_plat) = platform { - where_clauses.push(format!("platform = ${}", param_index)); - param_index += 1; - } - if let Some(_st) = status { - where_clauses.push(format!("status = ${}", param_index)); - param_index += 1; - } - - let where_sql = where_clauses.join(" AND "); - - // 获取总数 - let count_sql = format!("SELECT COUNT(*) FROM machine_users WHERE {}", where_sql); - let count_result: (i64,) = match (user_uuid, platform, status) { - (Some(u), Some(p), Some(s)) => { - sqlx::query_as(&count_sql) - .bind(u) - .bind(p) - .bind(s) - .fetch_one(pool) - .await? - } - (Some(u), Some(p), None) => { - sqlx::query_as(&count_sql) - .bind(u) - .bind(p) - .fetch_one(pool) - .await? - } - (Some(u), None, Some(s)) => { - sqlx::query_as(&count_sql) - .bind(u) - .bind(s) - .fetch_one(pool) - .await? - } - (Some(u), None, None) => sqlx::query_as(&count_sql).bind(u).fetch_one(pool).await?, - (None, Some(p), Some(s)) => { - sqlx::query_as(&count_sql) - .bind(p) - .bind(s) - .fetch_one(pool) - .await? - } - (None, Some(p), None) => sqlx::query_as(&count_sql).bind(p).fetch_one(pool).await?, - (None, None, Some(s)) => sqlx::query_as(&count_sql).bind(s).fetch_one(pool).await?, - (None, None, None) => sqlx::query_as(&count_sql).fetch_one(pool).await?, - }; - - // 获取分页列表 - let list_sql = format!( - "SELECT * FROM machine_users WHERE {} ORDER BY created_at DESC LIMIT ${} OFFSET ${}", - where_sql, - param_index, - param_index + 1 - ); - let machines: Vec = match (user_uuid, platform, status) { - (Some(u), Some(p), Some(s)) => { - sqlx::query_as(&list_sql) - .bind(u) - .bind(p) - .bind(s) - .bind(page_size) - .bind((page_num - 1) * page_size) - .fetch_all(pool) - .await? - } - (Some(u), Some(p), None) => { - sqlx::query_as(&list_sql) - .bind(u) - .bind(p) - .bind(page_size) - .bind((page_num - 1) * page_size) - .fetch_all(pool) - .await? - } - (Some(u), None, Some(s)) => { - sqlx::query_as(&list_sql) - .bind(u) - .bind(s) - .bind(page_size) - .bind((page_num - 1) * page_size) - .fetch_all(pool) - .await? - } - (Some(u), None, None) => { - sqlx::query_as(&list_sql) - .bind(u) - .bind(page_size) - .bind((page_num - 1) * page_size) - .fetch_all(pool) - .await? - } - (None, Some(p), Some(s)) => { - sqlx::query_as(&list_sql) - .bind(p) - .bind(s) - .bind(page_size) - .bind((page_num - 1) * page_size) - .fetch_all(pool) - .await? - } - (None, Some(p), None) => { - sqlx::query_as(&list_sql) - .bind(p) - .bind(page_size) - .bind((page_num - 1) * page_size) - .fetch_all(pool) - .await? - } - (None, None, Some(s)) => { - sqlx::query_as(&list_sql) - .bind(s) - .bind(page_size) - .bind((page_num - 1) * page_size) - .fetch_all(pool) - .await? - } - (None, None, None) => { - sqlx::query_as(&list_sql) - .bind(page_size) - .bind((page_num - 1) * page_size) - .fetch_all(pool) - .await? - } - }; - - Ok((count_result.0, machines)) -} - -/// 更新机器用户 -pub async fn update_machine_user( - pool: &Pool, - id: i32, - request: &UpdateMachineUserRequest, -) -> Result { - let sql = " - UPDATE machine_users SET - user_uuid = COALESCE($1, user_uuid), - platform = COALESCE($2, platform), - status = COALESCE($3, status), - hardware_hash = COALESCE($4, hardware_hash), - hardware_raw = COALESCE($5, hardware_raw), - updated_at = NOW() - WHERE id = $6 - "; - - let row = sqlx::query(sql) - .bind(&request.user_uuid) - .bind(&request.platform) - .bind(&request.status) - .bind(&request.hardware_hash) - .bind(&request.hardware_raw) - .bind(id) - .execute(pool) - .await?; - - Ok(row.rows_affected() == 1) -} - -/// 更新机器版本信息(整机更新,不更新 updated_at) -pub async fn update_machine_version_info( - pool: &Pool, - machine_code: &str, - version_info: &serde_json::Value, -) -> Result { - let sql = " - UPDATE machine_users SET - version_info = $1 - WHERE machine_code = $2 - "; - - let row = sqlx::query(sql) - .bind(version_info) - .bind(machine_code) - .execute(pool) - .await?; - - Ok(row.rows_affected() > 0) -} - -/// 一次性更新机器绑定时间和版本信息(用于已存在记录的更新场景) -pub async fn update_machine_bind_and_version( - pool: &Pool, - machine_code: &str, - user_uuid: &Uuid, - version_info: Option<&serde_json::Value>, -) -> Result { - let sql = " - UPDATE machine_users SET - bind_time = NOW(), - updated_at = NOW(), - version_info = COALESCE($3, version_info) - WHERE machine_code = $1 AND user_uuid = $2 - "; - - let row = sqlx::query(sql) - .bind(machine_code) - .bind(user_uuid) - .bind(version_info) - .execute(pool) - .await?; - - Ok(row.rows_affected() > 0) -} - -/// 更新用户的 updated_at(仅更新指定用户的绑定时间) -pub async fn update_user_bind_time( - pool: &Pool, - machine_code: &str, - user_uuid: &Uuid, -) -> Result { - let sql = " - UPDATE machine_users SET - updated_at = NOW() - WHERE machine_code = $1 AND user_uuid = $2 - "; - - let row = sqlx::query(sql) - .bind(machine_code) - .bind(user_uuid) - .execute(pool) - .await?; - - Ok(row.rows_affected() > 0) -} - -/// 绑定用户到机器(如果已存在则更新,否则创建新记录) -pub async fn bind_user_to_machine( - pool: &Pool, - machine_code: &str, - user_uuid: &Uuid, -) -> Result { - // 先检查是否已有绑定记录 - let existing = query_machine_user_by_code_and_user(pool, machine_code, user_uuid).await?; - - if existing.is_some() { - // 已存在,只更新绑定时间 - let sql = " - UPDATE machine_users SET - bind_time = NOW(), - updated_at = NOW() - WHERE machine_code = $1 AND user_uuid = $2 - "; - - let row = sqlx::query(sql) - .bind(machine_code) - .bind(user_uuid) - .execute(pool) - .await?; - - Ok(row.rows_affected() > 0) - } else { - // 不存在,创建新记录 - let sql = " - INSERT INTO machine_users ( - machine_code, user_uuid, status, bind_time, created_at - ) VALUES ( - $1, $2, 'active', NOW(), NOW() - ) - "; - - let row = sqlx::query(sql) - .bind(machine_code) - .bind(user_uuid) - .execute(pool) - .await?; - - Ok(row.rows_affected() > 0) - } -} - -/// 解绑用户(删除绑定记录) -pub async fn unbind_user_from_machine( - pool: &Pool, - machine_code: &str, - user_uuid: &Uuid, -) -> Result { - let sql = " - DELETE FROM machine_users - WHERE machine_code = $1 AND user_uuid = $2 - "; - - let row = sqlx::query(sql) - .bind(machine_code) - .bind(user_uuid) - .execute(pool) - .await?; - - Ok(row.rows_affected() > 0) -} - -/// 更新机器绑定时间(当用户重新登录已存在的机器时) -pub async fn update_machine_bind_time( - pool: &Pool, - machine_code: &str, - user_uuid: &Uuid, -) -> Result { - let sql = " - UPDATE machine_users SET - updated_at = NOW(), - bind_time = NOW() - WHERE machine_code = $1 AND user_uuid = $2 - "; - - let row = sqlx::query(sql) - .bind(machine_code) - .bind(user_uuid) - .execute(pool) - .await?; - - Ok(row.rows_affected() > 0) -} diff --git a/src/models/mod.rs b/src/models/mod.rs index 88c114b..117ddd4 100644 --- a/src/models/mod.rs +++ b/src/models/mod.rs @@ -1,53 +1,43 @@ -pub mod gray_releases; -pub mod gray_resources; -pub mod machine_gray_allocations; -pub mod machine_tags; -pub mod machine_users; -pub mod maintenance; -pub mod strategy_types; -pub mod user; -pub mod version_types; -pub mod versions; - +pub mod maintenance; +pub mod strategy_types; +pub mod user; +pub mod version_types; +pub mod versions; + // 新增模块 pub mod accounts; -pub mod local_api; pub mod audit; pub mod billing; pub mod environments; -pub mod extensions; -pub mod group_member_permissions; -pub mod messages; -pub mod preferences; -pub mod proxies; -pub mod proxy_visible_teams; -pub mod referral; -pub mod rpa; -pub mod teams; +pub mod extensions; +pub mod group_member_permissions; +pub mod local_api; +pub mod messages; +pub mod preferences; +pub mod proxies; +pub mod proxy_visible_teams; +pub mod referral; +pub mod rpa; +pub mod teams; pub mod workspace_quotas; pub mod workspaces; -pub use gray_releases::*; -pub use gray_resources::*; -pub use machine_gray_allocations::*; -pub use machine_tags::*; -pub use machine_users::*; -pub use maintenance::*; -pub use strategy_types::*; -pub use user::*; -pub use version_types::*; -pub use versions::*; - -// 新增导出 +pub use maintenance::*; +pub use strategy_types::*; +pub use user::*; +pub use version_types::*; +pub use versions::*; + +// 新增导出 pub use accounts::*; -pub use local_api::*; pub use audit::*; -pub use billing::*; -pub use environments::*; -pub use group_member_permissions::*; -pub use messages::*; -pub use proxies::*; -pub use proxy_visible_teams::*; -pub use teams::*; -pub use workspace_quotas::*; -pub use workspaces::*; +pub use billing::*; +pub use environments::*; +pub use group_member_permissions::*; +pub use local_api::*; +pub use messages::*; +pub use proxies::*; +pub use proxy_visible_teams::*; +pub use teams::*; +pub use workspace_quotas::*; +pub use workspaces::*; diff --git a/src/services.rs b/src/services.rs index e8c92bd..acdff50 100644 --- a/src/services.rs +++ b/src/services.rs @@ -1,8 +1,3 @@ -mod gray_releases; -mod gray_resources; -mod machine_gray_allocations; -mod machine_tags; -mod machine_users; mod maintenance; mod strategy_types; mod time; @@ -12,15 +7,15 @@ mod versions; // 新增模块 pub mod accounts; -pub mod local_api; pub mod audit; -pub mod browser_kernel; pub mod billing; +pub mod browser_kernel; pub mod coupons; pub mod environments; pub mod extensions; pub mod group_permissions; pub mod groups; +pub mod local_api; pub mod messages; pub mod orders; pub mod plans; @@ -37,11 +32,6 @@ pub mod wallet; pub mod workspace_quotas; pub mod workspaces; -pub use gray_releases::*; -pub use gray_resources::*; -pub use machine_gray_allocations::*; -pub use machine_tags::*; -pub use machine_users::*; pub use maintenance::*; pub use strategy_types::*; pub use time::*; diff --git a/src/services/gray_releases.rs b/src/services/gray_releases.rs deleted file mode 100644 index aa6593e..0000000 --- a/src/services/gray_releases.rs +++ /dev/null @@ -1,254 +0,0 @@ -use crate::{ - dto::gray_releases::GrayRelease, entitys::gray_releases::*, errors::SimprintError, - svc_ctx::SvcCtx, -}; - -/// 创建灰度发布 -pub async fn create_gray_release( - svc_ctx: &SvcCtx, - req: CreateGrayReleaseRequest, -) -> Result { - let id = crate::models::gray_releases::create_gray_release(&svc_ctx.db, &req).await?; - - Ok(id) -} - -/// 查询灰度发布详情 -pub async fn get_gray_release_by_id( - svc_ctx: &SvcCtx, - id: i32, -) -> Result { - let release = crate::models::gray_releases::query_gray_release_by_id(&svc_ctx.db, id) - .await - .map_err(|_| SimprintError::GrayReleaseNotFound)?; - - Ok(release) -} - -/// 查询灰度发布列表 -pub async fn query_gray_releases_service( - svc_ctx: &SvcCtx, - request: ListGrayReleasesRequest, - // params: QueryGrayReleaseParams, - // page_num: i32, - // page_size: i32, -) -> Result { - let (total, list) = crate::models::gray_releases::query_gray_releases( - &svc_ctx.db, - request.platform.as_deref(), - request.status.as_deref(), - request.page.unwrap_or(1), - request.page_size.unwrap_or(20), - ) - .await?; - - Ok(GrayReleaseListResponse { total, list }) -} - -/// 更新灰度发布 -pub async fn update_gray_release_service( - svc_ctx: &SvcCtx, - request: UpdateGrayReleaseRequest, -) -> Result { - let id = request.gray_release_id; - - // 检查灰度发布是否存在 - crate::models::gray_releases::query_gray_release_by_id(&svc_ctx.db, id) - .await - .map_err(|_| SimprintError::GrayReleaseNotFound)?; - - // 更新灰度发布 - let success = - crate::models::gray_releases::update_gray_release(&svc_ctx.db, id, &request).await?; - - if !success { - return Err(SimprintError::GrayReleaseNotFound); - } - - Ok(true) -} - -/// 启动灰度发布 -pub async fn start_gray_release_service(svc_ctx: &SvcCtx, id: i32) -> Result { - let release = crate::models::gray_releases::query_gray_release_by_id(&svc_ctx.db, id) - .await - .map_err(|_| SimprintError::GrayReleaseNotFound)?; - - // 检查状态 - if release.status == "active" { - return Ok(true); // 已经是活跃状态 - } - - if release.status != "pending" { - return Err(SimprintError::InvalidRequest(format!( - "灰度发布状态为 {},无法启动", - release.status - ))); - } - - // 更新状态为 active - let request = UpdateGrayReleaseRequest { - gray_release_id: id, - name: None, - description: None, - status: Some("active".to_string()), - end_time: None, - max_machines: None, - priority: None, - strategy_type: None, - strategy_config: None, - }; - - let success = - crate::models::gray_releases::update_gray_release(&svc_ctx.db, id, &request).await?; - - if !success { - return Err(SimprintError::GrayReleaseNotFound); - } - - Ok(true) -} - -/// 暂停灰度发布 -pub async fn pause_gray_release_service(svc_ctx: &SvcCtx, id: i32) -> Result { - let release = crate::models::gray_releases::query_gray_release_by_id(&svc_ctx.db, id) - .await - .map_err(|_| SimprintError::GrayReleaseNotFound)?; - - // 检查状态 - if release.status != "active" { - return Err(SimprintError::InvalidRequest(format!( - "灰度发布状态为 {},无法暂停", - release.status - ))); - } - - // 更新状态为 paused - let request = UpdateGrayReleaseRequest { - gray_release_id: id, - name: None, - description: None, - status: Some("paused".to_string()), - end_time: None, - max_machines: None, - priority: None, - strategy_type: None, - strategy_config: None, - }; - - let success = - crate::models::gray_releases::update_gray_release(&svc_ctx.db, id, &request).await?; - - if !success { - return Err(SimprintError::GrayReleaseNotFound); - } - - Ok(true) -} - -/// 结束灰度发布 -pub async fn finish_gray_release_service(svc_ctx: &SvcCtx, id: i32) -> Result { - let release = crate::models::gray_releases::query_gray_release_by_id(&svc_ctx.db, id) - .await - .map_err(|_| SimprintError::GrayReleaseNotFound)?; - - // 检查状态 - if release.status == "finished" { - return Ok(true); // 已经结束 - } - - if !["active", "paused"].contains(&release.status.as_str()) { - return Err(SimprintError::InvalidRequest(format!( - "灰度发布状态为 {},无法结束", - release.status - ))); - } - - // 更新状态为 finished,并设置结束时间 - let request = UpdateGrayReleaseRequest { - gray_release_id: id, - name: None, - description: None, - status: Some("finished".to_string()), - end_time: Some(chrono::Utc::now()), - max_machines: None, - priority: None, - strategy_type: None, - strategy_config: None, - }; - - let success = - crate::models::gray_releases::update_gray_release(&svc_ctx.db, id, &request).await?; - - if !success { - return Err(SimprintError::GrayReleaseNotFound); - } - - Ok(true) -} - -/// 取消灰度发布 -pub async fn cancel_gray_release_service(svc_ctx: &SvcCtx, id: i32) -> Result { - let release = crate::models::gray_releases::query_gray_release_by_id(&svc_ctx.db, id) - .await - .map_err(|_| SimprintError::GrayReleaseNotFound)?; - - // 检查状态 - if release.status == "cancelled" { - return Ok(true); // 已经取消 - } - - if !["pending", "active", "paused"].contains(&release.status.as_str()) { - return Err(SimprintError::InvalidRequest(format!( - "灰度发布状态为 {},无法取消", - release.status - ))); - } - - // 更新状态为 cancelled - let request = UpdateGrayReleaseRequest { - gray_release_id: id, - name: None, - description: None, - status: Some("cancelled".to_string()), - end_time: Some(chrono::Utc::now()), - max_machines: None, - priority: None, - strategy_type: None, - strategy_config: None, - }; - - let success = - crate::models::gray_releases::update_gray_release(&svc_ctx.db, id, &request).await?; - - if !success { - return Err(SimprintError::GrayReleaseNotFound); - } - - Ok(true) -} - -/// 删除灰度发布(物理删除) -pub async fn delete_gray_release_service(svc_ctx: &SvcCtx, id: i32) -> Result { - // 先检查是否存在 - let _release = crate::models::gray_releases::query_gray_release_by_id(&svc_ctx.db, id) - .await - .map_err(|_| SimprintError::GrayReleaseNotFound)?; - - // 先删除关联表,避免外键约束 - let _ = crate::models::machine_gray_allocations::delete_allocations_by_gray_release_id( - &svc_ctx.db, - id, - ) - .await?; - let _ = crate::models::gray_resources::delete_resources_by_gray_release_id(&svc_ctx.db, id) - .await?; - - let success = crate::models::gray_releases::delete_gray_release(&svc_ctx.db, id).await?; - - if !success { - return Err(SimprintError::GrayReleaseNotFound); - } - - Ok(true) -} diff --git a/src/services/gray_resources.rs b/src/services/gray_resources.rs deleted file mode 100644 index e7496fe..0000000 --- a/src/services/gray_resources.rs +++ /dev/null @@ -1,145 +0,0 @@ -use crate::{ - dto::gray_resources::GrayResource, entitys::gray_resources::*, errors::SimprintError, - svc_ctx::SvcCtx, -}; - -/// 添加灰度资源 -pub async fn add_gray_resource( - svc_ctx: &SvcCtx, - request: CreateGrayResourceRequest, -) -> Result { - // 验证灰度发布是否存在 - crate::models::gray_releases::query_gray_release_by_id(&svc_ctx.db, request.gray_release_id) - .await - .map_err(|_| SimprintError::GrayReleaseNotFound)?; - - // 验证版本是否存在 - crate::models::versions::query_version_by_id(&svc_ctx.db, request.version_id) - .await - .map_err(|_| SimprintError::VersionNotFound)?; - - // 检查是否已存在该资源 - let existing_resources = - crate::models::gray_resources::query_gray_resources(&svc_ctx.db, request.gray_release_id) - .await?; - - for resource in existing_resources { - if resource.version_id == request.version_id { - return Err(SimprintError::InvalidRequest( - "该版本已存在于灰度资源中".to_string(), - )); - } - } - - // 如果没有指定排序,使用最大排序值+1 - let sort_order = if request.sort_order.is_some() { - request.sort_order.unwrap() - } else { - let max_sort = crate::models::gray_resources::query_max_sort_order( - &svc_ctx.db, - request.gray_release_id, - ) - .await - .unwrap_or(0); - max_sort + 1 - }; - - let id = crate::models::gray_resources::create_gray_resource( - &svc_ctx.db, - &CreateGrayResourceRequest { - gray_release_id: request.gray_release_id, - version_id: request.version_id, - sort_order: Some(sort_order), - }, - ) - .await?; - - Ok(id) -} - -/// 批量添加灰度资源 -pub async fn batch_add_gray_resources( - svc_ctx: &SvcCtx, - request: BatchCreateGrayResourceRequest, -) -> Result { - // 验证灰度发布是否存在 - crate::models::gray_releases::query_gray_release_by_id(&svc_ctx.db, request.gray_release_id) - .await - .map_err(|_| SimprintError::GrayReleaseNotFound)?; - - // 验证所有版本是否存在 - for version_id in &request.version_ids { - crate::models::versions::query_version_by_id(&svc_ctx.db, *version_id) - .await - .map_err(|_| SimprintError::VersionNotFound)?; - } - - // 检查是否有重复的版本ID - let mut seen = std::collections::HashSet::new(); - for version_id in &request.version_ids { - if !seen.insert(version_id) { - return Err(SimprintError::InvalidRequest(format!( - "版本ID {} 重复", - version_id - ))); - } - } - - let count = - crate::models::gray_resources::batch_create_gray_resources(&svc_ctx.db, &request).await?; - - Ok(count) -} - -/// 移除灰度资源 -pub async fn remove_gray_resource(svc_ctx: &SvcCtx, id: i32) -> Result { - // 检查资源是否存在 - let _resource = crate::models::gray_resources::query_gray_resource_by_id(&svc_ctx.db, id) - .await - .map_err(|_| SimprintError::InvalidRequest("灰度资源不存在".to_string()))?; - - let success = crate::models::gray_resources::delete_gray_resource(&svc_ctx.db, id).await?; - - if !success { - return Err(SimprintError::InvalidRequest("删除失败".to_string())); - } - - Ok(true) -} - -/// 调整资源顺序 -pub async fn update_resource_sort_order( - svc_ctx: &SvcCtx, - id: i32, - sort_order: i32, -) -> Result { - // 检查资源是否存在 - let _resource = crate::models::gray_resources::query_gray_resource_by_id(&svc_ctx.db, id) - .await - .map_err(|_| SimprintError::InvalidRequest("灰度资源不存在".to_string()))?; - - let success = - crate::models::gray_resources::update_sort_order(&svc_ctx.db, id, sort_order).await?; - - if !success { - return Err(SimprintError::InvalidRequest("更新失败".to_string())); - } - - Ok(true) -} - -/// 查询灰度资源列表 -pub async fn query_gray_resources( - svc_ctx: &SvcCtx, - gray_release_id: i32, -) -> Result, SimprintError> { - // 验证灰度发布是否存在 - crate::models::gray_releases::query_gray_release_by_id(&svc_ctx.db, gray_release_id) - .await - .map_err(|_| SimprintError::GrayReleaseNotFound)?; - - let resources = - crate::models::gray_resources::query_gray_resources(&svc_ctx.db, gray_release_id).await?; - - Ok(resources) -} diff --git a/src/services/machine_gray_allocations.rs b/src/services/machine_gray_allocations.rs deleted file mode 100644 index d93eca9..0000000 --- a/src/services/machine_gray_allocations.rs +++ /dev/null @@ -1,207 +0,0 @@ -use crate::{ - entitys::machine_gray_allocations::*, errors::SimprintError, svc_ctx::SvcCtx, - utils::gray_checker::check_machine_by_strategy, -}; - -/// 分配机器到灰度 -pub async fn allocate_machine_service( - svc_ctx: &SvcCtx, - request: AllocateMachineRequest, -) -> Result { - // 验证机器码 - if request.machine_code.is_empty() { - return Err(SimprintError::InvalidRequest("机器码不能为空".to_string())); - } - - // 检查灰度发布是否存在 - crate::models::gray_releases::query_gray_release_by_id(&svc_ctx.db, request.gray_release_id) - .await - .map_err(|_| SimprintError::GrayReleaseNotFound)?; - - // 检查是否已达到最大配额 - let release = crate::models::gray_releases::query_gray_release_by_id( - &svc_ctx.db, - request.gray_release_id, - ) - .await?; - - if let Some(max_machines) = release.max_machines { - if release.allocated_count >= max_machines { - return Err(SimprintError::InvalidRequest( - "灰度发布已达到最大配额".to_string(), - )); - } - } - - // 分配机器 - let success = - crate::models::machine_gray_allocations::allocate_machine(&svc_ctx.db, &request).await?; - - if success { - // 更新分配计数 - let _ = crate::models::gray_releases::increment_allocated_count( - &svc_ctx.db, - request.gray_release_id, - ) - .await?; - } - - Ok(success) -} - -/// 批量分配机器 -pub async fn batch_allocate_machines_service( - svc_ctx: &SvcCtx, - request: BatchAllocateMachineRequest, -) -> Result { - if request.machine_codes.is_empty() { - return Err(SimprintError::InvalidRequest( - "机器码列表不能为空".to_string(), - )); - } - - // 检查灰度发布是否存在 - crate::models::gray_releases::query_gray_release_by_id(&svc_ctx.db, request.gray_release_id) - .await - .map_err(|_| SimprintError::GrayReleaseNotFound)?; - - // 分配机器 - let count = - crate::models::machine_gray_allocations::batch_allocate_machines(&svc_ctx.db, &request) - .await?; - - // 更新分配计数 - for _ in 0..count { - let _ = crate::models::gray_releases::increment_allocated_count( - &svc_ctx.db, - request.gray_release_id, - ) - .await?; - } - - Ok(count) -} - -/// 取消机器分配 -pub async fn remove_allocation_service( - svc_ctx: &SvcCtx, - machine_code: String, - gray_release_id: i32, -) -> Result { - // 检查分配是否存在 - let allocation = crate::models::machine_gray_allocations::query_allocation( - &svc_ctx.db, - &machine_code, - gray_release_id, - ) - .await?; - - if allocation.is_none() { - return Err(SimprintError::Other("分配不存在".to_string())); - } - - // 移除分配 - let success = crate::models::machine_gray_allocations::remove_allocation( - &svc_ctx.db, - &machine_code, - gray_release_id, - ) - .await?; - - if success { - // 减少分配计数 - let _ = - crate::models::gray_releases::decrement_allocated_count(&svc_ctx.db, gray_release_id) - .await?; - } - - Ok(success) -} - -/// 查询机器是否在灰度中(旧方法,仅用于白名单) -pub async fn is_machine_in_gray_service( - svc_ctx: &SvcCtx, - machine_code: String, - gray_release_id: i32, -) -> Result { - let is_in_gray = crate::models::machine_gray_allocations::is_machine_in_gray( - &svc_ctx.db, - &machine_code, - gray_release_id, - ) - .await?; - - Ok(is_in_gray) -} - -/// 检查机器是否在任意活跃灰度中(优化版:分策略类型查询) -pub async fn check_machine_in_any_gray_service( - svc_ctx: &SvcCtx, - machine_code: String, -) -> Result< - ( - bool, - Option, - Option, - ), - SimprintError, -> { - // 1. 优先检查白名单(最快,单次 JOIN 查询) - if let Some(release) = - crate::models::gray_releases::check_whitelist_strategy(&svc_ctx.db, &machine_code).await? - { - return Ok((true, Some(release.id), Some(release))); - } - - // 2. 检查标签策略(较快,单次数据库过滤) - if let Some(release) = - crate::models::gray_releases::check_tags_strategy(&svc_ctx.db, &machine_code).await? - { - return Ok((true, Some(release.id), Some(release))); - } - - // 3. 检查动态策略(百分比/随机,需要计算但数据量小) - let dynamic_releases = - crate::models::gray_releases::query_dynamic_strategy_releases(&svc_ctx.db).await?; - - for release in dynamic_releases { - if check_machine_by_strategy(&machine_code, None, &release)? { - return Ok((true, Some(release.id), Some(release))); - } - } - - // 4. 未命中任何灰度 - Ok((false, None, None)) -} - -/// 查询机器的所有活跃分配 -pub async fn get_machine_allocations_service( - svc_ctx: &SvcCtx, - machine_code: String, -) -> Result, SimprintError> { - let allocations = crate::models::machine_gray_allocations::query_machine_allocations( - &svc_ctx.db, - &machine_code, - ) - .await?; - - Ok(allocations) -} - -/// 查询灰度发布的所有分配 -pub async fn get_release_allocations_service( - svc_ctx: &SvcCtx, - gray_release_id: i32, - page_num: i32, - page_size: i32, -) -> Result { - let (total, list) = crate::models::machine_gray_allocations::query_release_allocations( - &svc_ctx.db, - gray_release_id, - page_num, - page_size, - ) - .await?; - - Ok(AllocationListResponse { total, list }) -} diff --git a/src/services/machine_tags.rs b/src/services/machine_tags.rs deleted file mode 100644 index c75ac58..0000000 --- a/src/services/machine_tags.rs +++ /dev/null @@ -1,55 +0,0 @@ -use crate::{ - dto::machine_tags::MachineTag, entitys::machine_tags::QueryTagsParams, - errors::SimprintError, svc_ctx::SvcCtx, -}; - -/// 查询所有标签(去重) -pub async fn query_all_tags_service( - svc_ctx: &SvcCtx, -) -> Result, SimprintError> { - let params = QueryTagsParams { - category: None, - is_active: Some(true), - }; - - let tags = crate::models::machine_tags::query_all_distinct_tags(&svc_ctx.db, ¶ms).await?; - - Ok(tags) -} - -/// 根据机器用户ID查询标签 -pub async fn query_tags_by_machine_user_id_service( - svc_ctx: &SvcCtx, - machine_user_id: i32, -) -> Result, SimprintError> { - let tags = - crate::models::machine_tags::query_tags_by_machine_user_id(&svc_ctx.db, machine_user_id) - .await?; - - Ok(tags) -} - -/// 绑定标签到机器用户(内部使用,处理标签字符串) -pub async fn bind_tags_to_machine_user_service( - svc_ctx: &SvcCtx, - machine_user_id: i32, - tag_names: &[String], -) -> Result { - if tag_names.is_empty() { - return Ok(0); - } - - // 批量插入或获取标签 - let tag_ids = - crate::models::machine_tags::batch_insert_or_get_tags(&svc_ctx.db, tag_names).await?; - - // 绑定标签到机器用户 - let count = crate::models::machine_tags::bind_tags_to_machine_user( - &svc_ctx.db, - machine_user_id, - &tag_ids, - ) - .await?; - - Ok(count) -} diff --git a/src/services/machine_users.rs b/src/services/machine_users.rs deleted file mode 100644 index ba850b3..0000000 --- a/src/services/machine_users.rs +++ /dev/null @@ -1,265 +0,0 @@ -use crate::{ - dto::machine_users::MachineUser, entitys::machine_users::*, errors::SimprintError, - svc_ctx::SvcCtx, -}; -use serde_json; -use uuid::Uuid; - -/// 注册机器 -pub async fn register_machine( - svc_ctx: &SvcCtx, - request: CreateMachineUserRequest, -) -> Result { - if let Err(e) = match (request.machine_code.is_empty(), request.user_uuid) { - (true, _) => Err("机器码不能为空".to_string()), - (_, None) => Err("用户UUID不能为空".to_string()), - _ => Ok(""), - } { - return Err(SimprintError::InvalidRequest(e)); - } - let user_uuid = request.user_uuid.unwrap_or_default(); - - // 如果提供了用户UUID,检查是否已存在绑定 - let existing = crate::models::machine_users::query_machine_user_by_code_and_user( - &svc_ctx.db, - &request.machine_code, - &user_uuid, - ) - .await?; - - if let Some(machine) = existing { - // 已存在,一次性更新绑定时间和版本信息 - let version_info = request - .version_info - .as_ref() - .and_then(|v| serde_json::from_str::(v).ok()); - - crate::models::machine_users::update_machine_bind_and_version( - &svc_ctx.db, - &request.machine_code, - &user_uuid, - version_info.as_ref(), - ) - .await?; - - return Ok(machine.id); - } - - // 不存在,一次性插入所有信息(包括 version_info) - let id = crate::models::machine_users::insert_machine_user(&svc_ctx.db, &request).await?; - Ok(id) -} - -/// 根据机器码查询机器信息 -pub async fn get_machine_by_code( - svc_ctx: &SvcCtx, - machine_code: String, -) -> Result { - let machine = - crate::models::machine_users::query_machine_user_by_code(&svc_ctx.db, &machine_code) - .await - .map_err(|_| SimprintError::MachineNotFound)?; - - Ok(machine) -} - -/// 根据ID查询机器信息 -pub async fn get_machine_by_id( - svc_ctx: &SvcCtx, - id: i32, -) -> Result { - let machine = crate::models::machine_users::query_machine_user_by_id(&svc_ctx.db, id) - .await - .map_err(|_| SimprintError::MachineNotFound)?; - - Ok(machine) -} - -/// 查询用户的机器列表 -pub async fn get_machines_by_user_uuid( - svc_ctx: &SvcCtx, - user_uuid: Uuid, -) -> Result, SimprintError> { - let machines = - crate::models::machine_users::query_machines_by_user_uuid(&svc_ctx.db, &user_uuid).await?; - - Ok(machines) -} - -/// 查询机器列表 -pub async fn query_machines_service( - svc_ctx: &SvcCtx, - params: QueryMachineUserParams, - page_num: i32, - page_size: i32, -) -> Result { - let user_uuid = params - .user_uuid - .as_ref() - .map(|s| Uuid::parse_str(s)) - .transpose() - .map_err(|_| SimprintError::InvalidRequest("无效的用户UUID格式".to_string()))?; - - let (total, list) = crate::models::machine_users::query_machine_users( - &svc_ctx.db, - user_uuid.as_ref(), - params.platform.as_deref(), - params.status.as_deref(), - page_num, - page_size, - ) - .await?; - - Ok(MachineUserListResponse { total, list }) -} - -/// 更新机器信息 -pub async fn update_machine_info_service( - svc_ctx: &SvcCtx, - id: i32, - request: UpdateMachineUserRequest, -) -> Result { - // 检查机器是否存在 - crate::models::machine_users::query_machine_user_by_id(&svc_ctx.db, id) - .await - .map_err(|_| SimprintError::MachineNotFound)?; - - // 更新机器信息 - let success = - crate::models::machine_users::update_machine_user(&svc_ctx.db, id, &request).await?; - - if !success { - return Err(SimprintError::MachineNotFound); - } - - Ok(true) -} - -/// 绑定用户到机器 -pub async fn bind_user_service( - svc_ctx: &SvcCtx, - machine_code: String, - user_uuid: Uuid, -) -> Result { - // 检查机器是否存在 - crate::models::machine_users::query_machine_user_by_code(&svc_ctx.db, &machine_code) - .await - .map_err(|_| SimprintError::MachineNotFound)?; - - // 绑定用户 - let success = - crate::models::machine_users::bind_user_to_machine(&svc_ctx.db, &machine_code, &user_uuid) - .await?; - - if !success { - return Err(SimprintError::MachineNotFound); - } - - Ok(true) -} - -/// 解绑用户 -pub async fn unbind_user_service( - svc_ctx: &SvcCtx, - machine_code: String, - user_uuid: Uuid, -) -> Result { - // 检查绑定是否存在 - let binding = crate::models::machine_users::query_machine_user_by_code_and_user( - &svc_ctx.db, - &machine_code, - &user_uuid, - ) - .await?; - - if binding.is_none() { - return Err(SimprintError::MachineNotBound); - } - - // 解绑用户 - let success = crate::models::machine_users::unbind_user_from_machine( - &svc_ctx.db, - &machine_code, - &user_uuid, - ) - .await?; - - if !success { - return Err(SimprintError::MachineNotFound); - } - - Ok(true) -} - -/// 获取机器版本信息 -pub async fn get_machine_version_info( - svc_ctx: &SvcCtx, - machine_code: String, - user_uuid: Uuid, -) -> Result { - // 查询特定用户的机器记录 - let machine = crate::models::machine_users::query_machine_user_by_code_and_user( - &svc_ctx.db, - &machine_code, - &user_uuid, - ) - .await? - .ok_or(SimprintError::MachineNotBound)?; - - Ok(machine.version_info.unwrap_or(serde_json::json!({}))) -} - -/// 拉黑机器 -pub async fn allow_or_blacklist_machine( - svc_ctx: &SvcCtx, - machine_code: String, - allow: bool, -) -> Result { - let success = - crate::models::machine_users::allow_or_blacklist_machine(&svc_ctx.db, &machine_code, allow) - .await?; - - Ok(success) -} - -/// 判断机器是否允许 -pub async fn machine_not_allow( - svc_ctx: &SvcCtx, - machine_code: String, -) -> Result { - let allow = crate::models::machine_users::machine_not_allow(&svc_ctx.db, &machine_code).await?; - - Ok(allow) -} - -/// 更新机器版本信息 -pub async fn update_machine_version_info_service( - svc_ctx: &SvcCtx, - machine_code: String, - user_uuid: Uuid, - version_info: serde_json::Value, -) -> Result { - // 检查机器是否存在 - crate::models::machine_users::query_machine_user_by_code(&svc_ctx.db, &machine_code) - .await - .map_err(|_| SimprintError::MachineNotFound)?; - - // 1. 更新整机的 version_info(所有用户记录) - let success = crate::models::machine_users::update_machine_version_info( - &svc_ctx.db, - &machine_code, - &version_info, - ) - .await?; - - if !success { - return Err(SimprintError::MachineNotFound); - } - - // 2. 只更新当前用户的 updated_at - let _ = - crate::models::machine_users::update_user_bind_time(&svc_ctx.db, &machine_code, &user_uuid) - .await?; - - Ok(true) -} diff --git a/src/services/users.rs b/src/services/users.rs index 3889a08..2c9bede 100644 --- a/src/services/users.rs +++ b/src/services/users.rs @@ -5,9 +5,9 @@ use crate::caches::{ set_reset_password_code, set_user_public_key, }; use crate::entitys::{ - CreateMachineUserRequest, LoginRequest, LoginResponse, MachineInfo, RegisterRequest, - RegisterResponse, ResetPasswordRequest, UpdatePasswordRequest, UpdateUserRequest, - UserResponse, VerifyPasswordRequest, VerifyPasswordResponse, + LoginRequest, LoginResponse, RegisterRequest, RegisterResponse, ResetPasswordRequest, + UpdatePasswordRequest, UpdateUserRequest, UserResponse, VerifyPasswordRequest, + VerifyPasswordResponse, }; use crate::models::{ billing, @@ -27,13 +27,11 @@ use crate::utils::{ /// 处理登录、注册、记住密码登录的共同逻辑: /// - 生成 Token /// - 保存公钥到缓存 -/// - 注册/绑定机器用户 /// - 获取用户信息 async fn common_login_logic( svc_ctx: &SvcCtx, user_uuid: Uuid, public_key: Option<&String>, - machine_info: Option<&MachineInfo>, ) -> Result<(String, String, Option), anyhow::Error> { let secret = svc_ctx.config.app.secret.as_bytes(); @@ -49,27 +47,7 @@ async fn common_login_logic( } } - // 3. 如果提供了机器信息,注册/绑定机器用户 - if let Some(ref machine_info) = machine_info { - if !machine_info.machine_code.is_empty() { - let _ = crate::services::machine_users::register_machine( - svc_ctx, - CreateMachineUserRequest { - machine_code: machine_info.machine_code.clone(), - user_uuid: Some(user_uuid), - platform: machine_info.platform.clone(), - hardware_hash: machine_info.hardware_hash.clone(), - hardware_raw: machine_info.hardware_raw.clone(), - version_info: machine_info.version_info.clone(), - tags: None, - }, - ) - .await; - // 注册失败不影响登录流程,只记录错误 - } - } - - // 4. 获取用户信息 + // 3. 获取用户信息 let user_info = get_current_user_service(svc_ctx, user_uuid).await.ok(); Ok((access_token, refresh_token, user_info)) @@ -114,13 +92,8 @@ pub async fn register_service( let _ = issue_welcome_coupons(svc_ctx, user_uuid).await; // 6. 执行通用登录逻辑 - let (access_token, refresh_token, user_info) = common_login_logic( - svc_ctx, - user_uuid, - payload.public_secret_key.as_ref(), - payload.machine_info.as_ref(), - ) - .await?; + let (access_token, refresh_token, user_info) = + common_login_logic(svc_ctx, user_uuid, payload.public_secret_key.as_ref()).await?; // 7. 发送欢迎系统通知 let _ = messages::create_message_service( @@ -186,7 +159,7 @@ pub async fn login_service( payload: LoginRequest, ) -> Result { let pool = &svc_ctx.db; - let (user_uuid, machine_info, public_secret_key) = match payload { + let (user_uuid, public_secret_key) = match payload { LoginRequest::Basic(data) => { // 基本登录:验证邮箱和密码 // 1. 查询用户 @@ -204,11 +177,7 @@ pub async fn login_service( return Err(anyhow::anyhow!("用户已被禁用")); } - ( - user_info.user_uuid, - data.machine_info, - data.public_secret_key, - ) + (user_info.user_uuid, data.public_secret_key) } LoginRequest::Remember(data) => { // 记住密码登录:验证 refresh_token @@ -232,18 +201,13 @@ pub async fn login_service( return Err(anyhow::anyhow!("用户已被禁用")); } - (user_uuid, data.machine_info, data.public_secret_key) + (user_uuid, data.public_secret_key) } }; // 执行通用登录逻辑 - let (access_token, refresh_token, user_response) = common_login_logic( - svc_ctx, - user_uuid, - public_secret_key.as_ref(), - machine_info.as_ref(), - ) - .await?; + let (access_token, refresh_token, user_response) = + common_login_logic(svc_ctx, user_uuid, public_secret_key.as_ref()).await?; Ok(LoginResponse { access_token, @@ -340,16 +304,13 @@ pub async fn get_current_user_service( None }; - let avatar_url = user_info - .avatar_hash - .as_ref() - .map(|hash| { - crate::utils::storage::get_objects::get_avatar_url( - &svc_ctx.config.storage.public_base_url, - &svc_ctx.config.storage.avatar_root, - hash, - ) - }); + let avatar_url = user_info.avatar_hash.as_ref().map(|hash| { + crate::utils::storage::get_objects::get_avatar_url( + &svc_ctx.config.storage.public_base_url, + &svc_ctx.config.storage.avatar_root, + hash, + ) + }); Ok(UserResponse { uuid: user.uuid, diff --git a/src/state.rs b/src/state.rs index 3e7db65..eccd6ff 100644 --- a/src/state.rs +++ b/src/state.rs @@ -1,93 +1,82 @@ -use uuid::Uuid; - -/// 当前用户 -#[derive(Debug, Clone)] -pub struct CurrentUser { - pub user_uuid: Uuid, -} - -/// 当前工作空间 -#[derive(Debug, Clone)] -pub struct CurrentWorkspace { - pub workspace_uuid: Uuid, -} - -/// 当前 IP 地址 -#[derive(Debug, Clone)] -pub struct CurrentIpAddr { - pub real_ip: String, -} - -/// 当前机器用户 -#[derive(Debug, Clone)] -pub struct CurrentMachineUser { - pub machine_code: String, - pub platform: Option, - pub user_uuid: Option, - pub is_allowed: bool, // 是否允许使用 -} - -/// 请求上下文 - 包含所有请求相关的上下文信息 -/// -/// 在中间件中逐步填充,handler 中通过 Extension 获取 -#[derive(Debug, Clone, Default)] -pub struct RequestContext { - /// 当前用户信息 - pub current_user: Option, - /// 当前 IP 地址 - pub current_ip_addr: Option, - /// 当前机器用户 - pub current_machine_user: Option, - /// 当前团队 UUID - pub current_team_uuid: Option, - /// 当前工作空间 UUID - pub current_workspace_uuid: Option, - /// 资源标识符:method+path(去除 /api/v*/ 前缀,保留前导斜杠) - /// 例如:POST+/environments, GET+/proxies - pub resource_identifier: Option, -} - -impl RequestContext { - /// 获取用户 UUID(如果已认证) - pub fn user_uuid(&self) -> Option { - self.current_user.as_ref().map(|u| u.user_uuid) - } - - /// 获取用户 UUID,如果未认证则 panic - pub fn user_uuid_unwrap(&self) -> Uuid { - self.current_user.as_ref().expect("用户未认证").user_uuid - } - - /// 获取 IP 地址 - pub fn ip(&self) -> Option<&str> { - self.current_ip_addr.as_ref().map(|i| i.real_ip.as_str()) - } - - /// 获取 IP 地址,如果不存在则返回 "unknown" - pub fn ip_or_unknown(&self) -> &str { - self.ip().unwrap_or("unknown") - } - - /// 获取工作空间 UUID - pub fn workspace_uuid(&self) -> Option { - self.current_workspace_uuid - } - - /// 获取工作空间 UUID,如果不存在则 panic - pub fn workspace_uuid_unwrap(&self) -> Uuid { - self.current_workspace_uuid.expect("工作空间未设置") - } - - /// 获取资源标识符 - pub fn resource_identifier(&self) -> Option<&str> { - self.resource_identifier.as_deref() - } - - /// 获取资源路径(从资源标识符中提取路径部分) - /// 例如:POST+/environments -> /environments - pub fn resource_path(&self) -> Option<&str> { - self.resource_identifier.as_ref().and_then(|id| { - id.split_once('+').map(|(_, path)| path) - }) - } -} +use uuid::Uuid; + +/// 当前用户 +#[derive(Debug, Clone)] +pub struct CurrentUser { + pub user_uuid: Uuid, +} + +/// 当前工作空间 +#[derive(Debug, Clone)] +pub struct CurrentWorkspace { + pub workspace_uuid: Uuid, +} + +/// 当前 IP 地址 +#[derive(Debug, Clone)] +pub struct CurrentIpAddr { + pub real_ip: String, +} + +/// 请求上下文 - 包含所有请求相关的上下文信息 +/// +/// 在中间件中逐步填充,handler 中通过 Extension 获取 +#[derive(Debug, Clone, Default)] +pub struct RequestContext { + /// 当前用户信息 + pub current_user: Option, + /// 当前 IP 地址 + pub current_ip_addr: Option, + /// 当前团队 UUID + pub current_team_uuid: Option, + /// 当前工作空间 UUID + pub current_workspace_uuid: Option, + /// 资源标识符:method+path(去除 /api/v*/ 前缀,保留前导斜杠) + /// 例如:POST+/environments, GET+/proxies + pub resource_identifier: Option, +} + +impl RequestContext { + /// 获取用户 UUID(如果已认证) + pub fn user_uuid(&self) -> Option { + self.current_user.as_ref().map(|u| u.user_uuid) + } + + /// 获取用户 UUID,如果未认证则 panic + pub fn user_uuid_unwrap(&self) -> Uuid { + self.current_user.as_ref().expect("用户未认证").user_uuid + } + + /// 获取 IP 地址 + pub fn ip(&self) -> Option<&str> { + self.current_ip_addr.as_ref().map(|i| i.real_ip.as_str()) + } + + /// 获取 IP 地址,如果不存在则返回 "unknown" + pub fn ip_or_unknown(&self) -> &str { + self.ip().unwrap_or("unknown") + } + + /// 获取工作空间 UUID + pub fn workspace_uuid(&self) -> Option { + self.current_workspace_uuid + } + + /// 获取工作空间 UUID,如果不存在则 panic + pub fn workspace_uuid_unwrap(&self) -> Uuid { + self.current_workspace_uuid.expect("工作空间未设置") + } + + /// 获取资源标识符 + pub fn resource_identifier(&self) -> Option<&str> { + self.resource_identifier.as_deref() + } + + /// 获取资源路径(从资源标识符中提取路径部分) + /// 例如:POST+/environments -> /environments + pub fn resource_path(&self) -> Option<&str> { + self.resource_identifier + .as_ref() + .and_then(|id| id.split_once('+').map(|(_, path)| path)) + } +} diff --git a/src/utils.rs b/src/utils.rs index f3031a8..1cd8899 100644 --- a/src/utils.rs +++ b/src/utils.rs @@ -1,6 +1,5 @@ mod config; mod extractor; -pub mod gray_checker; mod jwt; mod password; mod responses; diff --git a/src/utils/gray_checker.rs b/src/utils/gray_checker.rs deleted file mode 100644 index eab52b8..0000000 --- a/src/utils/gray_checker.rs +++ /dev/null @@ -1,88 +0,0 @@ -use crate::dto::gray_releases::GrayRelease; -use crate::errors::SimprintError; - -/// 根据策略类型检查机器是否匹配 -/// 注意:filter_whitelist 策略已在数据库层实现,此函数仅处理 filter_percentage 和 filter_random -pub fn check_machine_by_strategy( - machine_code: &str, - _user_uuid: Option<&uuid::Uuid>, - release: &GrayRelease, -) -> Result { - // 如果没有配置策略,默认返回 false - let strategy_type = match &release.strategy_type { - Some(st) => st, - None => return Ok(false), - }; - - // 如果没有配置参数,返回 false - let config = match &release.strategy_config { - Some(cfg) => cfg, - None => return Ok(false), - }; - - match strategy_type.as_str() { - "filter_percentage" => { - let percent: f64 = config - .get("percent") - .and_then(|v| v.as_f64()) - .ok_or_else(|| SimprintError::InvalidStrategyConfig)?; - is_in_percentage_range(machine_code, release.id, percent) - } - "filter_random" => { - let seed = config.get("seed").and_then(|v| v.as_u64()).unwrap_or(12345); - is_in_random_selection(machine_code, release.id, seed) - } - "filter_user_tag" | "filter_whitelist" => { - // 这些策略已在数据库查询阶段处理,不应该到达这里 - Ok(false) - } - _ => Err(SimprintError::InvalidStrategyConfig), - } -} - -/// 检查是否在百分比范围内 -fn is_in_percentage_range( - machine_code: &str, - release_id: i32, - percent: f64, -) -> Result { - if percent < 0.0 || percent > 100.0 { - return Err(SimprintError::InvalidStrategyConfig); - } - - // 使用机器码和灰度发布ID的哈希值计算 - let combined = format!("{}:{}", machine_code, release_id); - let hash = calculate_hash(&combined); - - // 将哈希值映射到 0-100 范围 - let value = (hash % 10000) as f64 / 100.0; - - Ok(value < percent) -} - -/// 检查是否在随机选择中 -fn is_in_random_selection( - machine_code: &str, - release_id: i32, - seed: u64, -) -> Result { - // 使用机器码、灰度发布ID和种子值的哈希值计算 - let combined = format!("{}:{}:{}", machine_code, release_id, seed); - let hash = calculate_hash(&combined); - - // 将哈希值映射到 0-1 范围 - let value = (hash % 10000) as f64 / 10000.0; - - // 默认概率为 0.5(50%) - Ok(value < 0.5) -} - -/// 计算字符串的哈希值 -fn calculate_hash(s: &str) -> u64 { - use std::collections::hash_map::DefaultHasher; - use std::hash::{Hash, Hasher}; - - let mut hasher = DefaultHasher::new(); - s.hash(&mut hasher); - hasher.finish() -} From 099528e5c601b0ecf91f9c19ffd79969bf0cd0a9 Mon Sep 17 00:00:00 2001 From: lius-new Date: Fri, 8 May 2026 17:40:46 +0800 Subject: [PATCH 35/60] Prepare self-hosted server release flow --- Dockerfile | 13 ++-- README.md | 35 ++++++++- build_docker.py | 74 ++++++++++++------- configs/config.example.toml | 51 +++++++++++++ docker-compose.yml | 49 +++++++++++- ..._insert_default_browser_kernel_version.sql | 67 +++++++++++++++++ src/cli.rs | 33 +++++++++ src/lib.rs | 5 +- src/main.rs | 26 ++++--- 9 files changed, 300 insertions(+), 53 deletions(-) create mode 100644 configs/config.example.toml create mode 100644 migrations/20260508000001_insert_default_browser_kernel_version.sql create mode 100644 src/cli.rs diff --git a/Dockerfile b/Dockerfile index 400f52f..7786303 100644 --- a/Dockerfile +++ b/Dockerfile @@ -24,12 +24,13 @@ ARG BINARY_NAME # 从本地根目录复制已编译的二进制文件到固定路径 COPY --chown=appuser:appuser ${BINARY_NAME} /app/app -# 复制配置文件目录和资源文件 -COPY --chown=appuser:appuser configs ./configs -COPY --chown=appuser:appuser assets ./assets - -# 设置文件权限 -RUN chmod +x /app/app +# 复制配置文件目录 +COPY --chown=appuser:appuser configs ./configs + +# 设置文件权限 +RUN mkdir -p /app/assets/secret \ + && chown -R appuser:appuser /app/assets \ + && chmod +x /app/app # 切换到非 root 用户 USER appuser diff --git a/README.md b/README.md index 21ac51a..efd7722 100644 --- a/README.md +++ b/README.md @@ -73,10 +73,37 @@ uv run python build_docker.py --dev --no-package ### 启动服务 服务需要通过配置文件启动,配置文件路径通过命令行参数指定: - -```bash -./simprint-server -f=configs/config.dev.toml -``` + +```bash +./simprint-server -f=configs/config.dev.toml +./simprint-server -f=configs/config.dev.toml serve +``` + +### Docker 部署配置模板 + +对外发布的 Docker 部署包仅包含 `configs/config.toml` 配置模板。 +部署前请先按实际环境修改这个文件。 +其中数据库连接串默认指向 Compose 内的 `postgres` 服务, +如果你修改了 `docker-compose.yml` 里的 `POSTGRES_USER`、`POSTGRES_PASSWORD` 或数据库名, +需要同步修改 `configs/config.toml` 中的 `[database].url`。 +RSA 密钥不会随部署包下发,服务首次启动时会在容器卷中自动生成并持久化保存。 + +然后再使用 Docker Compose 启动,Compose 默认读取: + +```bash +/app/configs/config.toml +``` + +### 初始化数据库 + +数据库迁移已编译进 `simprint-server` 二进制,并会在服务启动时自动执行。 +因此部署时无需再单独执行迁移命令。 + +```bash +docker compose up -d +``` + +该流程无需额外公开 `migrations/` 目录。 ### 服务入口 diff --git a/build_docker.py b/build_docker.py index 42478f0..11c24f1 100644 --- a/build_docker.py +++ b/build_docker.py @@ -36,9 +36,11 @@ "Dockerfile", "docker-compose.yml", ".dockerignore", - "configs", - "assets", -] +] + +PACKAGE_CONFIG_FILES = [ + ("configs/config.example.toml", "configs/config.toml"), +] # 需要打包的二进制文件(在构建后复制) PACKAGE_BINARIES = SERVICES @@ -178,7 +180,7 @@ def create_package(package_dir: Path, output_format: str = "zip") -> Path: return archive_path -def prepare_package_directory() -> Path: +def prepare_package_directory() -> Path: """准备打包目录,复制所有必需的文件""" print_step("准备打包目录") @@ -193,7 +195,7 @@ def prepare_package_directory() -> Path: # 复制必需的文件和目录 copied_count = 0 - for item in PACKAGE_FILES: + for item in PACKAGE_FILES: source = PROJECT_ROOT / item dest = package_dir / item @@ -209,8 +211,26 @@ def prepare_package_directory() -> Path: shutil.copy2(source, dest) print(f" ✓ 复制文件: {item}") copied_count += 1 - except Exception as e: - print(f" ✗ 复制失败 {item}: {e}") + except Exception as e: + print(f" ✗ 复制失败 {item}: {e}") + + # 仅复制对外发布需要的示例配置文件 + print(f"\n 复制配置模板:") + for source_item, dest_item in PACKAGE_CONFIG_FILES: + source = PROJECT_ROOT / source_item + dest = package_dir / dest_item + + if not source.exists(): + print(f" ⚠ 警告: {source_item} 不存在,跳过") + continue + + try: + dest.parent.mkdir(parents=True, exist_ok=True) + shutil.copy2(source, dest) + print(f" ✓ 复制文件: {source_item} -> {dest_item}") + copied_count += 1 + except Exception as e: + print(f" ✗ 复制失败 {source_item}: {e}") # 复制二进制文件 print(f"\n 复制二进制文件:") @@ -234,29 +254,31 @@ def prepare_package_directory() -> Path: - 二进制文件: {', '.join(SERVICES)} - Dockerfile: Docker 镜像构建文件 - docker-compose.yml: Docker Compose 配置文件 -- configs/: 配置文件目录 -- assets/: 资源文件目录 - -## 使用方法 - -1. 解压压缩包到目标目录 - -2. 确保配置文件中的数据库、Redis、对象存储地址正确 - -3. 构建 Docker 镜像: - ```bash - docker-compose build - ``` - +- configs/config.toml: 配置模板 +- 自动生成的密钥会保存在 Docker 卷中,不随部署包分发 + +## 使用方法 + +1. 解压压缩包到目标目录 + +2. 确保 `configs/config.toml` 中的数据库、Redis、对象存储地址正确 + +3. 构建 Docker 镜像: + ```bash + docker-compose build + ``` + 4. 启动服务: ```bash docker-compose up -d ``` - -5. 查看服务状态: - ```bash - docker-compose ps - ``` + +5. 查看服务状态: + ```bash + docker-compose ps + ``` + +服务启动时会自动执行数据库迁移。 6. 查看日志: ```bash diff --git a/configs/config.example.toml b/configs/config.example.toml new file mode 100644 index 0000000..c30453e --- /dev/null +++ b/configs/config.example.toml @@ -0,0 +1,51 @@ +[app] +name = "simprint-server" +port = 40041 +secret = "change-this-to-a-long-random-secret" +prefix = "/api/v1" +encrypt_secret_location = "./assets/secret" +route_whitelists = [ + "POST+/api/v1/users/login", + "POST+/api/v1/users/register", + "POST+/api/v1/users/register-send-code", + "POST+/api/v1/users/reset-password", + "POST+/api/v1/users/reset-password-send-code", + "POST+/api/v1/users/refresh-credentials", + "GET+/api/v1/secret/public/key", + "GET+/api/v1/time/now", + "POST+/api/v1/versions/check" +] +referral_link_prefix = "https://your-domain.example/download" + +[database] +url = "postgres://simprint:change-me@postgres:5432/simprintdb" +max_connections = 25 +min_connections = 5 +max_lifetime = 3000 +acquire_timeout = 30 +idle_timeout = 600 + +[redis] +url = "redis://redis:6379?protocol=resp3" + +[storage] +endpoint = "https://your-object-storage-endpoint.example" +public_base_url = "https://your-object-storage-public.example" +access_key = "change-me" +secret_access_key = "change-me" +bucket = "simprint-client" +avatar_root = "avatars" +extension_root = "extensions" +version_root = "versions" + +[smtp] +smtp_server = "smtp.example.com" +smtp_username = "noreply@example.com" +smtp_password = "change-me" + +[workspace_quota] +[workspace_quota.default] +max_environments = 99999 +max_team_members = 99999 +max_proxies = 99999 +max_rpa_tasks = 99999 diff --git a/docker-compose.yml b/docker-compose.yml index 8715ddd..08b5d82 100644 --- a/docker-compose.yml +++ b/docker-compose.yml @@ -1,6 +1,39 @@ version: '3.8' services: + postgres: + image: postgres:16-alpine + container_name: simprint-postgres + environment: + POSTGRES_DB: simprintdb + POSTGRES_USER: simprint + POSTGRES_PASSWORD: change-me + volumes: + - simprint-postgres-data:/var/lib/postgresql/data + healthcheck: + test: ["CMD-SHELL", "pg_isready -U simprint -d simprintdb"] + interval: 10s + timeout: 5s + retries: 5 + restart: unless-stopped + networks: + - simprint-network + + redis: + image: redis:7-alpine + container_name: simprint-redis + command: ["redis-server", "--appendonly", "yes"] + volumes: + - simprint-redis-data:/data + healthcheck: + test: ["CMD", "redis-cli", "ping"] + interval: 10s + timeout: 5s + retries: 5 + restart: unless-stopped + networks: + - simprint-network + simprint-server: build: context: . @@ -8,12 +41,17 @@ services: args: BINARY_NAME: simprint-server container_name: simprint-client-gateway - command: ["-f=/app/configs/config.prod.toml"] + command: ["-f=/app/configs/config.toml"] + depends_on: + postgres: + condition: service_healthy + redis: + condition: service_healthy ports: - "40041:40041" volumes: - ./configs:/app/configs:ro - - ./assets:/app/assets:ro + - simprint-secret-data:/app/assets/secret environment: - RUST_LOG=info restart: unless-stopped @@ -22,4 +60,9 @@ services: networks: simprint-network: - external: true + name: simprint-network + +volumes: + simprint-postgres-data: + simprint-redis-data: + simprint-secret-data: diff --git a/migrations/20260508000001_insert_default_browser_kernel_version.sql b/migrations/20260508000001_insert_default_browser_kernel_version.sql new file mode 100644 index 0000000..7c6125e --- /dev/null +++ b/migrations/20260508000001_insert_default_browser_kernel_version.sql @@ -0,0 +1,67 @@ +-- 初始化默认浏览器内核版本数据 +-- 使用 version_types.type_code 定位类型,避免写死 type_id。 +-- 使用 NOT EXISTS 保证迁移可重复执行。 + +WITH kernel_type AS ( + SELECT id + FROM public.version_types + WHERE type_code = 'SIMPRINT_KERNEL_CHROMIUM' +) +INSERT INTO public.versions ( + type_id, + resource_name, + version, + name, + notes, + platform, + url, + hash, + signature, + install_path, + file_size, + is_latest, + status, + pub_date, + created_at, + updated_at, + deleted_at, + arch, + package_format, + requires_extract, + entrypoint_template, + extract_root +) +SELECT + kernel_type.id, + 'Chrome 144', + '144.0.7559.118.1', + 'simprint-browser-144.0.7559.118.zip', + '上传浏览器内核版本。', + 'windows', + 'versions/144.0.7559.118.1/simprint-browser-144.0.7559.118.zip', + 'c74ce58537c93e99e4099c94667a21a8e150ac4052c383bc2a095ffdb3b0e075', + 'a864f950c2e77d18ef932f2ad3dbde63039c3b467994daab83081ce6b28c4f82', + NULL, + 184526645, + false, + 'active', + '2026-05-05 17:01:26.746804+08'::timestamptz, + '2026-05-05 17:01:26.746804+08'::timestamptz, + NULL, + NULL, + 'x86_64', + 'zip', + true, + NULL, + NULL +FROM kernel_type +WHERE NOT EXISTS ( + SELECT 1 + FROM public.versions v + WHERE v.type_id = kernel_type.id + AND v.resource_name = 'Chrome 144' + AND v.version = '144.0.7559.118.1' + AND v.platform = 'windows' + AND v.arch = 'x86_64' + AND v.deleted_at IS NULL +); diff --git a/src/cli.rs b/src/cli.rs new file mode 100644 index 0000000..3984f6c --- /dev/null +++ b/src/cli.rs @@ -0,0 +1,33 @@ +use clap::{Parser, Subcommand}; + +/// Simprint Server CLI +#[derive(Debug, Parser)] +#[command(name = "simprint-server")] +#[command(author = "Simprint Team")] +#[command(version)] +#[command(about = "Simprint Server - 客户端网关服务")] +pub struct Cli { + /// 配置文件路径 + #[arg(short = 'f', long = "config", help = "配置文件路径 (TOML 格式)")] + pub config: String, + + /// 子命令 + #[command(subcommand)] + pub command: Option, +} + +/// 可用的子命令 +#[derive(Debug, Subcommand)] +pub enum Commands { + /// 启动 HTTP 服务 + Serve, +} + +impl Cli { + /// 获取命令,如果没有指定则默认为 Serve + pub fn command_or_default(self) -> (String, Commands) { + let config = self.config; + let command = self.command.unwrap_or(Commands::Serve); + (config, command) + } +} diff --git a/src/lib.rs b/src/lib.rs index 0c3c9fb..dd7aa74 100644 --- a/src/lib.rs +++ b/src/lib.rs @@ -1,6 +1,7 @@ use crate::utils::IConfig; pub mod caches; +pub mod cli; pub mod dto; pub mod entitys; pub mod errors; @@ -30,6 +31,6 @@ pub async fn init_storage(config: &IConfig) { None, None, ) - .await - .expect("初始化对象存储客户端失败"); + .await + .expect("初始化对象存储客户端失败"); } diff --git a/src/main.rs b/src/main.rs index b84af01..a0bf301 100644 --- a/src/main.rs +++ b/src/main.rs @@ -1,8 +1,7 @@ -use std::env; - use axum::{Router, middleware}; -use simprint_server::{init_encrypt_secret, init_storage}; +use clap::Parser; use simprint_server::{ + cli::{Cli, Commands}, middlewares, routes::route::MetaRoute, routes::{ @@ -13,19 +12,18 @@ use simprint_server::{ svc_ctx::SvcCtx, utils::IConfig, }; +use simprint_server::{init_encrypt_secret, init_storage}; + +static MIGRATOR: sqlx::migrate::Migrator = sqlx::migrate!(); #[tokio::main] async fn main() -> Result<(), Box> { - // 支持接收-f参数,指定配置文件路径 - let config_path = env::args() - .find(|arg| arg.starts_with("-f")) - .map(|arg| arg.split("=").nth(1).unwrap().to_string()) - .expect("config path is required"); - + let cli = Cli::parse(); + let (config_path, command) = cli.command_or_default(); let config = IConfig::build_by_filepath(&config_path).expect("failed to build config"); - if let Err(e) = run_server(config).await { - return Err(e.into()); + match command { + Commands::Serve => run_server(config).await?, } Ok(()) @@ -39,6 +37,11 @@ async fn run_server(config: IConfig) -> Result<(), Box> { .with_thread_ids(true) .init(); + let db = SvcCtx::create_db(&config.database).await?; + tracing::info!("Running embedded database migrations"); + MIGRATOR.run(&db).await?; + tracing::info!("Embedded database migrations completed"); + let svc_ctx = SvcCtx::new(&config).await?; // init resources @@ -59,7 +62,6 @@ async fn run_server(config: IConfig) -> Result<(), Box> { axum::serve(listener, app).await?; Ok(()) } - /// 注册所有路由 fn register_all_routes(svc_ctx: &SvcCtx) -> Router { let mut meta_route = MetaRoute::new(svc_ctx.config.app.prefix.clone()); From 0c2e6b4ee5b693430f440dac8aeee149153987ca Mon Sep 17 00:00:00 2001 From: lius-new Date: Fri, 8 May 2026 19:40:22 +0800 Subject: [PATCH 36/60] Improve self-hosted docs and mail error handling --- README.md | 222 ++++++++++++++++++++++++------------------ src/services/users.rs | 14 ++- 2 files changed, 141 insertions(+), 95 deletions(-) diff --git a/README.md b/README.md index efd7722..502be73 100644 --- a/README.md +++ b/README.md @@ -1,11 +1,11 @@ -# Simprint Server - -Simprint 应用的后端服务系统。 - -## 项目简介 - -Simprint Server 是 Simprint 桌面应用的后端服务,提供用户认证、数据管理、版本更新等核心功能支持。 - +# Simprint Server + +Simprint 应用的后端服务系统。 + +## 项目简介 + +Simprint Server 是 Simprint 桌面应用的后端服务,提供用户认证、数据管理、版本更新等核心功能支持。 + ## 使用示例 ### 通过 `build_docker.py` 构建 Docker 部署包 @@ -94,6 +94,40 @@ RSA 密钥不会随部署包下发,服务首次启动时会在容器卷中自 /app/configs/config.toml ``` +### 发布 `simprint-server` 镜像 + +如果你要手动发布 `simprint-server` 到 GHCR,可以按下面的流程执行: + +```bash +# 1. 构建发布包 +python3 build_docker.py + +# 2. 解压部署包 +tar -xzf simprint-server-docker-*.tar.gz +cd simprint-server-docker-* + +# 3. 本地构建镜像 +docker build --build-arg BINARY_NAME=simprint-server -t simprint-server:local . + +# 4. 可选:本地验证 +docker tag simprint-server:local publish-simprint-server:latest +docker images | grep simprint-server + +# 5. 登录 GHCR +echo "$GITHUB_TOKEN" | docker login ghcr.io -u --password-stdin + +# 6. 打 tag 并推送 +docker tag simprint-server:local ghcr.io/simprint/simprint-server:latest +docker push ghcr.io/simprint/simprint-server:latest +``` + +如果你要发布特定版本,可以额外打一个版本标签: + +```bash +docker tag simprint-server:local ghcr.io/simprint/simprint-server:v0.1.0 +docker push ghcr.io/simprint/simprint-server:v0.1.0 +``` + ### 初始化数据库 数据库迁移已编译进 `simprint-server` 二进制,并会在服务启动时自动执行。 @@ -104,89 +138,89 @@ docker compose up -d ``` 该流程无需额外公开 `migrations/` 目录。 - -### 服务入口 - -Simprint Server 提供三种服务入口: - -- **客户端网关**:为桌面应用提供基础服务接口 -- **控制台网关**:为管理后台提供管理功能接口 -- **更新网关**:为应用更新提供版本检查接口 - -> **注意**:详细的使用示例和 API 文档将在后续版本中添加。 - -## 贡献指南 - -我们欢迎所有形式的贡献!如果您想为 Simprint Server 项目做出贡献,请遵循以下步骤: - -1. **Fork 项目**:在 GitHub 上 Fork 本项目 -2. **创建分支**:从 `master` 分支创建新的功能分支 -3. **提交更改**:进行代码修改并提交,遵循项目的代码规范 -4. **推送分支**:将更改推送到您的 Fork -5. **创建 Pull Request**:向主项目提交 Pull Request - -### 代码规范 - -- 遵循 Rust 代码风格和格式化规范 -- 提交前运行 `cargo fmt` 和 `cargo clippy` -- 确保所有测试通过 -- 数据库迁移文件命名遵循规范 -- 添加适当的文档注释 - -更多详细信息请参考项目目录下的 `AGENTS.md` 文件。 - -## 许可证信息 - -本项目采用私有许可证,未经授权不得使用、复制、修改或分发。 - -## 故障排除 - -### 常见问题 - -**Q: 服务无法启动?** - -A: 请检查以下事项: - -- 确保配置文件路径正确 -- 检查配置文件格式是否正确 -- 确保数据库连接配置正确 -- 检查端口是否被占用 -- 查看服务日志获取详细错误信息 - -**Q: 数据库连接失败?** - -A: 请检查: - -- 数据库服务是否正在运行 -- 数据库连接字符串是否正确 -- 数据库用户权限是否足够 -- 网络连接是否正常 - -**Q: Redis 连接失败?** - -A: 请检查: - -- Redis 服务是否正在运行 -- Redis 连接 URL 是否正确 -- Redis 认证信息是否正确 - -**Q: 对象存储服务异常?** - -A: 可能的原因: - -- 对象存储服务未启动 -- 访问密钥配置错误 -- 网络连接问题 -- 存储桶不存在或权限不足 - -**Q: 如何查看服务日志?** - -A: 服务日志会输出到控制台,生产环境建议配置日志文件输出。 - -### 获取帮助 - -如果以上方法无法解决您的问题,请: - -- 查看项目的 Issue 列表,看是否有类似问题 -- 创建新的 Issue 描述您的问题 -- 联系项目维护者 + +### 服务入口 + +Simprint Server 提供三种服务入口: + +- **客户端网关**:为桌面应用提供基础服务接口 +- **控制台网关**:为管理后台提供管理功能接口 +- **更新网关**:为应用更新提供版本检查接口 + +> **注意**:详细的使用示例和 API 文档将在后续版本中添加。 + +## 贡献指南 + +我们欢迎所有形式的贡献!如果您想为 Simprint Server 项目做出贡献,请遵循以下步骤: + +1. **Fork 项目**:在 GitHub 上 Fork 本项目 +2. **创建分支**:从 `master` 分支创建新的功能分支 +3. **提交更改**:进行代码修改并提交,遵循项目的代码规范 +4. **推送分支**:将更改推送到您的 Fork +5. **创建 Pull Request**:向主项目提交 Pull Request + +### 代码规范 + +- 遵循 Rust 代码风格和格式化规范 +- 提交前运行 `cargo fmt` 和 `cargo clippy` +- 确保所有测试通过 +- 数据库迁移文件命名遵循规范 +- 添加适当的文档注释 + +更多详细信息请参考项目目录下的 `AGENTS.md` 文件。 + +## 许可证信息 + +本项目采用私有许可证,未经授权不得使用、复制、修改或分发。 + +## 故障排除 + +### 常见问题 + +**Q: 服务无法启动?** + +A: 请检查以下事项: + +- 确保配置文件路径正确 +- 检查配置文件格式是否正确 +- 确保数据库连接配置正确 +- 检查端口是否被占用 +- 查看服务日志获取详细错误信息 + +**Q: 数据库连接失败?** + +A: 请检查: + +- 数据库服务是否正在运行 +- 数据库连接字符串是否正确 +- 数据库用户权限是否足够 +- 网络连接是否正常 + +**Q: Redis 连接失败?** + +A: 请检查: + +- Redis 服务是否正在运行 +- Redis 连接 URL 是否正确 +- Redis 认证信息是否正确 + +**Q: 对象存储服务异常?** + +A: 可能的原因: + +- 对象存储服务未启动 +- 访问密钥配置错误 +- 网络连接问题 +- 存储桶不存在或权限不足 + +**Q: 如何查看服务日志?** + +A: 服务日志会输出到控制台,生产环境建议配置日志文件输出。 + +### 获取帮助 + +如果以上方法无法解决您的问题,请: + +- 查看项目的 Issue 列表,看是否有类似问题 +- 创建新的 Issue 描述您的问题 +- 联系项目维护者 diff --git a/src/services/users.rs b/src/services/users.rs index 2c9bede..fd33f90 100644 --- a/src/services/users.rs +++ b/src/services/users.rs @@ -436,6 +436,19 @@ pub async fn send_verification_code_service( email: &str, code_type: &str, ) -> Result<(), anyhow::Error> { + if code_type == "reset_password" { + let user_exists = fetch_user_info_by_email(&svc_ctx.db, email).await?.is_some(); + if !user_exists { + return Err(anyhow::anyhow!("用户不存在")); + } + } + + let smtp_config = svc_ctx + .config + .clone() + .smtp + .ok_or_else(|| anyhow::anyhow!("邮箱配置不存在"))?; + // 1. 生成验证码 let code = random_six_number_code(); @@ -447,7 +460,6 @@ pub async fn send_verification_code_service( } // 3. 发送邮件 - let smtp_config = svc_ctx.config.clone().smtp.expect("邮箱配置不存在"); let title = match code_type { "register" => "注册验证码", "reset_password" => "重置密码验证码", From f64a6a25351d41072e0094ee9754f9e6c6a151cf Mon Sep 17 00:00:00 2001 From: lius-new Date: Mon, 11 May 2026 16:01:41 +0800 Subject: [PATCH 37/60] refactor(environment): move urls to top-level environment detail and persist via environment_urls --- src/dto/environments.rs | 2 +- src/entitys/environments.rs | 60 +++++++------ src/handlers/environments.rs | 170 ++++++++--------------------------- src/models/environments.rs | 24 +++++ src/services/environments.rs | 66 +++++++++++++- src/services/templates.rs | 50 ++++++++--- 6 files changed, 195 insertions(+), 177 deletions(-) diff --git a/src/dto/environments.rs b/src/dto/environments.rs index f8b2bfb..b37c862 100644 --- a/src/dto/environments.rs +++ b/src/dto/environments.rs @@ -253,7 +253,7 @@ pub struct EnvironmentTagDto { } /// 环境 URL DTO -#[derive(Debug, Clone, FromRow, Serialize)] +#[derive(Debug, Clone, FromRow, Serialize, Deserialize)] pub struct EnvironmentUrlDto { pub id: i32, pub environment_uuid: Uuid, diff --git a/src/entitys/environments.rs b/src/entitys/environments.rs index f7e8cbc..92c0faa 100644 --- a/src/entitys/environments.rs +++ b/src/entitys/environments.rs @@ -24,16 +24,17 @@ pub struct EnvironmentFilters { /// 创建环境请求 #[derive(Debug, Clone, Deserialize, Serialize)] -pub struct CreateEnvironmentRequest { - pub name: String, - pub description: Option, - pub group_uuid: Option, - pub tag_uuids: Option>, - pub account_uuids: Option>, - pub proxy_uuid: Option, // 代理 UUID(单个,可选) - pub cookies: Option>, // Cookie 字符串数组(如 "name=value" 或 "name=value; domain=example.com") - pub config: EnvironmentConfigRequest, -} +pub struct CreateEnvironmentRequest { + pub name: String, + pub description: Option, + pub group_uuid: Option, + pub tag_uuids: Option>, + pub account_uuids: Option>, + pub proxy_uuid: Option, // 代理 UUID(单个,可选) + pub cookies: Option>, // Cookie 字符串数组(如 "name=value" 或 "name=value; domain=example.com") + pub urls: Option>, + pub config: EnvironmentConfigRequest, +} /// 环境配置请求(对应 WindowConfig) #[derive(Debug, Clone, Deserialize, Serialize)] @@ -55,13 +56,14 @@ pub struct BatchCreateEnvironmentRequest { /// 更新环境请求 #[derive(Debug, Clone, Deserialize, Serialize)] -pub struct UpdateEnvironmentRequest { - pub uuid: Uuid, - pub name: Option, - pub description: Option, - pub group_uuid: Option, - pub config: Option, -} +pub struct UpdateEnvironmentRequest { + pub uuid: Uuid, + pub name: Option, + pub description: Option, + pub group_uuid: Option, + pub urls: Option>, + pub config: Option, +} /// 设置环境代理请求 #[derive(Debug, Clone, Deserialize, Serialize)] @@ -139,10 +141,11 @@ pub struct BatchAddEnvironmentUrlsRequest { /// URL 输入 #[derive(Debug, Clone, Deserialize, Serialize)] -pub struct UrlInput { - pub url: String, - pub title: Option, -} +pub struct UrlInput { + pub url: String, + pub title: Option, + pub sort_order: Option, +} /// 删除环境 URL 请求 #[derive(Debug, Clone, Deserialize, Serialize)] @@ -227,12 +230,13 @@ pub struct EnvironmentListResponse { /// 环境详情响应(包含完整配置) #[derive(Debug, Clone, Serialize, Deserialize)] -pub struct EnvironmentDetailResponse { - pub environment: crate::dto::EnvironmentDto, - pub config: Option, - pub tags: Vec, - pub accounts: Vec, - pub group: Option, // 分组完整信息 - pub proxy: Option, // 代理完整信息 +pub struct EnvironmentDetailResponse { + pub environment: crate::dto::EnvironmentDto, + pub config: Option, + pub urls: Vec, + pub tags: Vec, + pub accounts: Vec, + pub group: Option, // 分组完整信息 + pub proxy: Option, // 代理完整信息 pub extensions: Vec, // 扩展列表 } diff --git a/src/handlers/environments.rs b/src/handlers/environments.rs index 20543a6..a20b038 100644 --- a/src/handlers/environments.rs +++ b/src/handlers/environments.rs @@ -246,75 +246,27 @@ pub async fn get_environment_handler( State(svc_ctx): State, Extension(ctx): Extension, Json(payload): Json, -) -> Result { - let workspace_uuid = ctx - .current_workspace_uuid - .ok_or_else(|| Response::fail(Some("请先选择工作空间")))?; - let team_uuid = ctx.current_team_uuid.ok_or_else(|| Response::fail(Some("请先选择团队")))?; - - let environment = services::environments::get_environment_service( - &svc_ctx, - workspace_uuid, - team_uuid, - ctx.user_uuid_unwrap(), - payload.uuid, - ) - .await - .map_err(|e| Response::fail(Some(&e)))?; - - let config = services::environments::get_environment_config_service(&svc_ctx, payload.uuid) - .await - .ok(); - - let tags = services::environments::get_environment_tags_service(&svc_ctx, payload.uuid) - .await - .map_err(|e| Response::fail(Some(&e)))?; - - let accounts = services::accounts::get_environment_accounts_service(&svc_ctx, payload.uuid) - .await - .unwrap_or_default(); - - // 获取分组信息 - let group = if let Some(group_uuid) = environment.group_uuid { - services::environments::get_group_summary_service(&svc_ctx, group_uuid) - .await - .ok() - } else { - None - }; - - // 获取代理信息 - let proxy = if let Some(proxy_uuid) = environment.proxy_uuid { - services::environments::get_proxy_summary_service(&svc_ctx, proxy_uuid) - .await - .ok() - } else { - None - }; - - // 获取扩展列表 - let extensions = crate::services::extensions::get_environment_extensions_service( - &svc_ctx, - ctx.user_uuid_unwrap(), - team_uuid, - environment.group_uuid, - ) - .await - .unwrap_or_default(); - - Ok(Response::success( - Some("获取成功"), - Some(EnvironmentDetailResponse { - environment, - config, - tags, - accounts, - group, - proxy, - extensions, - }), - )) -} +) -> Result { + let workspace_uuid = ctx + .current_workspace_uuid + .ok_or_else(|| Response::fail(Some("请先选择工作空间")))?; + let team_uuid = ctx.current_team_uuid.ok_or_else(|| Response::fail(Some("请先选择团队")))?; + + let detail = services::environments::get_environment_detail_service( + &svc_ctx, + workspace_uuid, + team_uuid, + ctx.user_uuid_unwrap(), + payload.uuid, + ) + .await + .map_err(|e| Response::fail(Some(&e)))?; + + Ok(Response::success( + Some("获取成功"), + Some(detail), + )) +} /// 批量获取环境详情 pub async fn batch_get_environments_handler( @@ -327,70 +279,22 @@ pub async fn batch_get_environments_handler( .ok_or_else(|| Response::fail(Some("请先选择工作空间")))?; let team_uuid = ctx.current_team_uuid.ok_or_else(|| Response::fail(Some("请先选择团队")))?; - let mut results = std::collections::HashMap::new(); - - for uuid in payload.uuids { - // 对每个 UUID 尝试获取环境详情,失败则跳过 - let detail = async { - let environment = services::environments::get_environment_service( - &svc_ctx, - workspace_uuid, - team_uuid, - ctx.user_uuid_unwrap(), - uuid, - ) - .await - .ok()?; - - let config = services::environments::get_environment_config_service(&svc_ctx, uuid) - .await - .ok(); - - let tags = services::environments::get_environment_tags_service(&svc_ctx, uuid) - .await - .ok()?; - - let accounts = services::accounts::get_environment_accounts_service(&svc_ctx, uuid) - .await - .unwrap_or_default(); - - let group = if let Some(group_uuid) = environment.group_uuid { - services::environments::get_group_summary_service(&svc_ctx, group_uuid) - .await - .ok() - } else { - None - }; - - let proxy = if let Some(proxy_uuid) = environment.proxy_uuid { - services::environments::get_proxy_summary_service(&svc_ctx, proxy_uuid) - .await - .ok() - } else { - None - }; - - // 获取扩展列表 - let extensions = crate::services::extensions::get_environment_extensions_service( - &svc_ctx, - ctx.user_uuid_unwrap(), - team_uuid, - environment.group_uuid, - ) - .await - .unwrap_or_default(); - - Some(EnvironmentDetailResponse { - environment, - config, - tags, - accounts, - group, - proxy, - extensions, - }) - } - .await; + let mut results = std::collections::HashMap::new(); + + for uuid in payload.uuids { + // 对每个 UUID 尝试获取环境详情,失败则跳过 + let detail = async { + services::environments::get_environment_detail_service( + &svc_ctx, + workspace_uuid, + team_uuid, + ctx.user_uuid_unwrap(), + uuid, + ) + .await + .ok() + } + .await; // 只有成功获取的环境才加入结果 if let Some(detail) = detail { diff --git a/src/models/environments.rs b/src/models/environments.rs index 4273098..8f7995b 100644 --- a/src/models/environments.rs +++ b/src/models/environments.rs @@ -1468,6 +1468,30 @@ pub async fn fetch_environment_urls( Ok(recs) } +/// 批量查询环境的所有 URL +pub async fn fetch_environment_urls_by_uuids( + pool: &Pool, + env_uuids: &[Uuid], +) -> Result, Error> { + if env_uuids.is_empty() { + return Ok(vec![]); + } + + let recs = sqlx::query_as::<_, EnvironmentUrlDto>( + r#" + SELECT id, environment_uuid, url, title, sort_order, created_at + FROM environment_urls + WHERE environment_uuid = ANY($1) + ORDER BY environment_uuid, sort_order, id + "#, + ) + .bind(env_uuids) + .fetch_all(pool) + .await?; + + Ok(recs) +} + /// 删除环境的 URL pub async fn delete_environment_url(pool: &Pool, url_id: i32) -> Result<(), Error> { sqlx::query( diff --git a/src/services/environments.rs b/src/services/environments.rs index 24b90b2..c2a1f75 100644 --- a/src/services/environments.rs +++ b/src/services/environments.rs @@ -9,7 +9,7 @@ use crate::entitys::{ AddEnvironmentCookieRequest, AddEnvironmentUrlRequest, AssignTagsRequest, BatchAssignTagRequest, BatchCreateEnvironmentRequest, BatchMoveToGroupRequest, BatchRemoveTagsRequest, CookieInput, CreateEnvironmentRequest, ListEnvironmentsRequest, - MoveToGroupRequest, SetEnvironmentProxyRequest, UpdateEnvironmentRequest, + MoveToGroupRequest, SetEnvironmentProxyRequest, UpdateEnvironmentRequest, UrlInput, }; use crate::models; use crate::services::accounts; @@ -118,6 +118,8 @@ pub async fn create_environment_service( .await .map_err(|e| e.to_string())?; + replace_environment_urls(svc_ctx, env_uuid, payload.urls.as_deref()).await?; + // 分配标签 if let Some(tag_uuids) = &payload.tag_uuids { for tag_uuid in tag_uuids { @@ -228,6 +230,39 @@ fn parse_cookie_strings(cookie_strings: &[String]) -> Result, S Ok(cookies) } +async fn replace_environment_urls( + svc_ctx: &SvcCtx, + env_uuid: Uuid, + urls: Option<&[UrlInput]>, +) -> Result<(), String> { + let Some(urls) = urls else { + return Ok(()); + }; + + models::clear_environment_urls(&svc_ctx.db, env_uuid) + .await + .map_err(|e| format!("清空 URLs 失败: {}", e))?; + + for (idx, item) in urls.iter().enumerate() { + let url = item.url.trim(); + if url.is_empty() { + continue; + } + + models::insert_environment_url( + &svc_ctx.db, + env_uuid, + url, + item.title.as_deref(), + item.sort_order.or(Some(idx as i32)), + ) + .await + .map_err(|e| format!("保存 URL 失败: {}", e))?; + } + + Ok(()) +} + /// 获取环境列表(包含完整关联数据) pub async fn get_environments_service( svc_ctx: &SvcCtx, @@ -389,6 +424,16 @@ pub async fn get_environments_service( }); } + let mut urls_map: HashMap> = HashMap::new(); + if !env_uuids.is_empty() { + let url_rows = models::fetch_environment_urls_by_uuids(&svc_ctx.db, &env_uuids) + .await + .map_err(|e| e.to_string())?; + for url in url_rows { + urls_map.entry(url.environment_uuid).or_default().push(url); + } + } + // 批量查询账号 let mut accounts_map: HashMap> = HashMap::new(); for env_uuid in &env_uuids { @@ -471,6 +516,7 @@ pub async fn get_environments_service( crate::entitys::EnvironmentDetailResponse { environment, config: configs_map.remove(&row.uuid), // 返回配置信息,用于传递给指纹浏览器内核 + urls: urls_map.remove(&row.uuid).unwrap_or_default(), tags: tags_map.remove(&row.uuid).unwrap_or_default(), accounts: accounts_map.remove(&row.uuid).unwrap_or_default(), group, @@ -568,6 +614,9 @@ pub async fn get_environment_detail_service( get_environment_service(svc_ctx, workspace_uuid, team_uuid, user_uuid, env_uuid).await?; let config = get_environment_config_service(svc_ctx, env_uuid).await.ok(); + let urls = get_environment_urls_service(svc_ctx, env_uuid) + .await + .unwrap_or_default(); let tags = get_environment_tags_service(svc_ctx, env_uuid).await?; @@ -602,6 +651,7 @@ pub async fn get_environment_detail_service( Ok(crate::entitys::EnvironmentDetailResponse { environment, config, + urls, tags, accounts, group, @@ -742,6 +792,8 @@ pub async fn update_environment_service( .map_err(|e| e.to_string())?; } + replace_environment_urls(svc_ctx, payload.uuid, payload.urls.as_deref()).await?; + Ok(()) } @@ -1045,6 +1097,17 @@ pub async fn get_recycle_bin_environments_service( }); } + let mut env_urls_map: HashMap> = HashMap::new(); + let url_rows = models::fetch_environment_urls_by_uuids(&svc_ctx.db, &env_uuids) + .await + .map_err(|e| e.to_string())?; + for url in url_rows { + env_urls_map + .entry(url.environment_uuid) + .or_default() + .push(url); + } + // 9. 批量查询账号信息 let mut env_accounts_map: HashMap> = HashMap::new(); for env_uuid in &env_uuids { @@ -1086,6 +1149,7 @@ pub async fn get_recycle_bin_environments_service( deleted_at: None, }, config: None, + urls: env_urls_map.remove(&row.uuid).unwrap_or_default(), tags, accounts, group, diff --git a/src/services/templates.rs b/src/services/templates.rs index bd03e7b..6a424ed 100644 --- a/src/services/templates.rs +++ b/src/services/templates.rs @@ -229,14 +229,25 @@ pub async fn apply_template_service( .map_err(|e| format!("解析模板数据失败: {}", e))?; // 更新环境配置 - let update_req = crate::entitys::UpdateEnvironmentRequest { - uuid: payload.environment_uuid, - name: None, - description: None, - group_uuid: None, - config: env_detail.config.map(|config| crate::entitys::EnvironmentConfigRequest { - window_info: config.window_info, - basic_settings: config.basic_settings, + let update_req = crate::entitys::UpdateEnvironmentRequest { + uuid: payload.environment_uuid, + name: None, + description: None, + group_uuid: None, + urls: Some( + env_detail + .urls + .iter() + .map(|item| crate::entitys::UrlInput { + url: item.url.clone(), + title: item.title.clone(), + sort_order: item.sort_order, + }) + .collect(), + ), + config: env_detail.config.map(|config| crate::entitys::EnvironmentConfigRequest { + window_info: config.window_info, + basic_settings: config.basic_settings, fingerprint_settings: config.fingerprint_settings, device_settings: config.device_settings, preference_settings: config.preference_settings, @@ -309,12 +320,23 @@ pub async fn create_from_template_service( None } else { Some(account_uuids) - }, - proxy_uuid, - cookies: None, // 模板中不包含 cookies - config: crate::entitys::EnvironmentConfigRequest { - window_info: config.window_info, - basic_settings: config.basic_settings, + }, + proxy_uuid, + cookies: None, // 模板中不包含 cookies + urls: Some( + env_detail + .urls + .iter() + .map(|item| crate::entitys::UrlInput { + url: item.url.clone(), + title: item.title.clone(), + sort_order: item.sort_order, + }) + .collect(), + ), + config: crate::entitys::EnvironmentConfigRequest { + window_info: config.window_info, + basic_settings: config.basic_settings, fingerprint_settings: config.fingerprint_settings, device_settings: config.device_settings, preference_settings: config.preference_settings, From abf385b049b8abca441fc826b3ff20da89126eae Mon Sep 17 00:00:00 2001 From: lius-new Date: Mon, 11 May 2026 17:28:32 +0800 Subject: [PATCH 38/60] feat: store grouped environment cookies --- ..._add_site_input_to_environment_cookies.sql | 5 + src/dto/environments.rs | 10 +- src/entitys/environments.rs | 75 ++- src/handlers/environments.rs | 14 +- src/models/environments.rs | 39 +- src/services/environments.rs | 455 ++++++++++++++---- src/services/templates.rs | 37 +- 7 files changed, 485 insertions(+), 150 deletions(-) create mode 100644 migrations/20260511000015_add_site_input_to_environment_cookies.sql diff --git a/migrations/20260511000015_add_site_input_to_environment_cookies.sql b/migrations/20260511000015_add_site_input_to_environment_cookies.sql new file mode 100644 index 0000000..7a6c538 --- /dev/null +++ b/migrations/20260511000015_add_site_input_to_environment_cookies.sql @@ -0,0 +1,5 @@ +ALTER TABLE environment_cookies +ADD COLUMN IF NOT EXISTS site_input TEXT NOT NULL DEFAULT ''; + +CREATE INDEX IF NOT EXISTS idx_env_cookies_env_uuid_site_input +ON environment_cookies(environment_uuid, site_input); diff --git a/src/dto/environments.rs b/src/dto/environments.rs index b37c862..75529a0 100644 --- a/src/dto/environments.rs +++ b/src/dto/environments.rs @@ -264,10 +264,11 @@ pub struct EnvironmentUrlDto { } /// 环境 Cookie DTO -#[derive(Debug, Clone, FromRow, Serialize)] +#[derive(Debug, Clone, FromRow, Serialize, Deserialize)] pub struct EnvironmentCookieDto { pub id: i32, pub environment_uuid: Uuid, + pub site_input: String, pub domain: String, pub name: String, pub value: String, @@ -279,6 +280,13 @@ pub struct EnvironmentCookieDto { pub created_at: DateTime, } +/// 环境 Cookie 分组 DTO +#[derive(Debug, Clone, Serialize, Deserialize)] +pub struct EnvironmentCookieGroupDto { + pub site: String, + pub cookie_text: String, +} + /// 模板 DTO #[derive(Debug, Clone, FromRow, Serialize)] pub struct TemplateDto { diff --git a/src/entitys/environments.rs b/src/entitys/environments.rs index 92c0faa..7e2bb52 100644 --- a/src/entitys/environments.rs +++ b/src/entitys/environments.rs @@ -23,7 +23,7 @@ pub struct EnvironmentFilters { } /// 创建环境请求 -#[derive(Debug, Clone, Deserialize, Serialize)] +#[derive(Debug, Clone, Deserialize, Serialize)] pub struct CreateEnvironmentRequest { pub name: String, pub description: Option, @@ -31,7 +31,7 @@ pub struct CreateEnvironmentRequest { pub tag_uuids: Option>, pub account_uuids: Option>, pub proxy_uuid: Option, // 代理 UUID(单个,可选) - pub cookies: Option>, // Cookie 字符串数组(如 "name=value" 或 "name=value; domain=example.com") + pub cookies: Option>, pub urls: Option>, pub config: EnvironmentConfigRequest, } @@ -61,6 +61,7 @@ pub struct UpdateEnvironmentRequest { pub name: Option, pub description: Option, pub group_uuid: Option, + pub cookies: Option>, pub urls: Option>, pub config: Option, } @@ -161,37 +162,27 @@ pub struct ClearEnvironmentUrlsRequest { // ============ Environment Cookies ============ -/// 添加环境 Cookie 请求 -#[derive(Debug, Clone, Deserialize, Serialize)] -pub struct AddEnvironmentCookieRequest { - pub environment_uuid: Uuid, - pub domain: String, - pub name: String, - pub value: String, - pub path: Option, - pub http_only: Option, - pub secure: Option, - pub same_site: Option, -} - -/// 批量添加环境 Cookie 请求 -#[derive(Debug, Clone, Deserialize, Serialize)] -pub struct BatchAddEnvironmentCookiesRequest { - pub environment_uuid: Uuid, - pub cookies: Vec, -} - -/// Cookie 输入 -#[derive(Debug, Clone, Deserialize, Serialize)] -pub struct CookieInputRequest { - pub domain: String, - pub name: String, - pub value: String, - pub path: Option, - pub http_only: Option, - pub secure: Option, - pub same_site: Option, -} +/// 添加环境 Cookie 请求 +#[derive(Debug, Clone, Deserialize, Serialize)] +pub struct AddEnvironmentCookieRequest { + pub environment_uuid: Uuid, + pub site: String, + pub cookie_text: String, +} + +/// 批量添加环境 Cookie 请求 +#[derive(Debug, Clone, Deserialize, Serialize)] +pub struct BatchAddEnvironmentCookiesRequest { + pub environment_uuid: Uuid, + pub cookies: Vec, +} + +/// Cookie 分组输入 +#[derive(Debug, Clone, Deserialize, Serialize)] +pub struct CookieGroupInput { + pub site: String, + pub cookie_text: String, +} /// 删除环境 Cookie 请求 #[derive(Debug, Clone, Deserialize, Serialize)] @@ -205,14 +196,15 @@ pub struct ClearEnvironmentCookiesRequest { pub environment_uuid: Uuid, } -/// Cookie 输入结构(用于批量添加) -#[derive(Debug, Clone, Deserialize, Serialize)] -pub struct CookieInput { - pub domain: String, - pub name: String, - pub value: String, - pub path: Option, - pub http_only: Option, +/// Cookie 输入结构(用于批量添加) +#[derive(Debug, Clone, Deserialize, Serialize)] +pub struct CookieInput { + pub site_input: String, + pub domain: String, + pub name: String, + pub value: String, + pub path: Option, + pub http_only: Option, pub secure: Option, pub same_site: Option, } @@ -233,6 +225,7 @@ pub struct EnvironmentListResponse { pub struct EnvironmentDetailResponse { pub environment: crate::dto::EnvironmentDto, pub config: Option, + pub cookies: Vec, pub urls: Vec, pub tags: Vec, pub accounts: Vec, diff --git a/src/handlers/environments.rs b/src/handlers/environments.rs index a20b038..270ed50 100644 --- a/src/handlers/environments.rs +++ b/src/handlers/environments.rs @@ -774,13 +774,13 @@ pub async fn add_environment_cookie_handler( } /// 获取环境 Cookie 列表 -pub async fn get_environment_cookies_handler( - State(svc_ctx): State, - Json(payload): Json, -) -> Result> { - let cookies = services::environments::get_environment_cookies_service(&svc_ctx, payload.uuid) - .await - .map_err(|e| Response::fail(Some(&e)))?; +pub async fn get_environment_cookies_handler( + State(svc_ctx): State, + Json(payload): Json, +) -> Result> { + let cookies = services::environments::get_environment_cookies_service(&svc_ctx, payload.uuid) + .await + .map_err(|e| Response::fail(Some(&e)))?; Ok(Response::success(Some("获取成功"), Some(cookies))) } diff --git a/src/models/environments.rs b/src/models/environments.rs index 8f7995b..dea4e9f 100644 --- a/src/models/environments.rs +++ b/src/models/environments.rs @@ -1526,6 +1526,7 @@ pub async fn clear_environment_urls(pool: &Pool, env_uuid: Uuid) -> Re pub async fn insert_environment_cookie( pool: &Pool, env_uuid: Uuid, + site_input: &str, domain: &str, name: &str, value: &str, @@ -1536,12 +1537,13 @@ pub async fn insert_environment_cookie( ) -> Result { let id: i32 = sqlx::query_scalar( r#" - INSERT INTO environment_cookies (environment_uuid, domain, name, value, path, http_only, secure, same_site) - VALUES ($1, $2, $3, $4, $5, $6, $7, $8) + INSERT INTO environment_cookies (environment_uuid, site_input, domain, name, value, path, http_only, secure, same_site) + VALUES ($1, $2, $3, $4, $5, $6, $7, $8, $9) RETURNING id; "#, ) .bind(env_uuid) + .bind(site_input) .bind(domain) .bind(name) .bind(value) @@ -1565,12 +1567,13 @@ pub async fn batch_insert_environment_cookies( for cookie in cookies { sqlx::query( r#" - INSERT INTO environment_cookies (environment_uuid, domain, name, value, path, http_only, secure, same_site) - VALUES ($1, $2, $3, $4, $5, $6, $7, $8) + INSERT INTO environment_cookies (environment_uuid, site_input, domain, name, value, path, http_only, secure, same_site) + VALUES ($1, $2, $3, $4, $5, $6, $7, $8, $9) ON CONFLICT DO NOTHING; "#, ) .bind(env_uuid) + .bind(&cookie.site_input) .bind(&cookie.domain) .bind(&cookie.name) .bind(&cookie.value) @@ -1592,10 +1595,10 @@ pub async fn fetch_environment_cookies( ) -> Result, Error> { let recs = sqlx::query_as::<_, EnvironmentCookieDto>( r#" - SELECT id, environment_uuid, domain, name, value, path, expires_at, http_only, secure, same_site, created_at + SELECT id, environment_uuid, site_input, domain, name, value, path, expires_at, http_only, secure, same_site, created_at FROM environment_cookies WHERE environment_uuid = $1 - ORDER BY domain, name + ORDER BY site_input, domain, name "#, ) .bind(env_uuid) @@ -1605,6 +1608,30 @@ pub async fn fetch_environment_cookies( Ok(recs) } +/// 批量查询环境的所有 Cookies +pub async fn fetch_environment_cookies_by_uuids( + pool: &Pool, + env_uuids: &[Uuid], +) -> Result, Error> { + if env_uuids.is_empty() { + return Ok(vec![]); + } + + let recs = sqlx::query_as::<_, EnvironmentCookieDto>( + r#" + SELECT id, environment_uuid, site_input, domain, name, value, path, expires_at, http_only, secure, same_site, created_at + FROM environment_cookies + WHERE environment_uuid = ANY($1) + ORDER BY environment_uuid, site_input, domain, name + "#, + ) + .bind(env_uuids) + .fetch_all(pool) + .await?; + + Ok(recs) +} + /// 删除环境的 Cookie pub async fn delete_environment_cookie(pool: &Pool, cookie_id: i32) -> Result<(), Error> { sqlx::query( diff --git a/src/services/environments.rs b/src/services/environments.rs index c2a1f75..cc95b0f 100644 --- a/src/services/environments.rs +++ b/src/services/environments.rs @@ -1,15 +1,17 @@ use std::collections::HashMap; +use url::Url; use uuid::Uuid; use crate::dto::{ - EnvironmentConfigDto, EnvironmentCookieDto, EnvironmentDto, EnvironmentUrlDto, GroupSummaryDto, - PlatformAccountDto, ProxySummaryDto, TagDto, + EnvironmentConfigDto, EnvironmentCookieDto, EnvironmentCookieGroupDto, EnvironmentDto, + EnvironmentUrlDto, GroupSummaryDto, PlatformAccountDto, ProxySummaryDto, TagDto, }; use crate::entitys::{ AddEnvironmentCookieRequest, AddEnvironmentUrlRequest, AssignTagsRequest, BatchAssignTagRequest, BatchCreateEnvironmentRequest, BatchMoveToGroupRequest, - BatchRemoveTagsRequest, CookieInput, CreateEnvironmentRequest, ListEnvironmentsRequest, - MoveToGroupRequest, SetEnvironmentProxyRequest, UpdateEnvironmentRequest, UrlInput, + BatchRemoveTagsRequest, CookieGroupInput, CookieInput, CreateEnvironmentRequest, + ListEnvironmentsRequest, MoveToGroupRequest, SetEnvironmentProxyRequest, + UpdateEnvironmentRequest, UrlInput, }; use crate::models; use crate::services::accounts; @@ -119,6 +121,7 @@ pub async fn create_environment_service( .map_err(|e| e.to_string())?; replace_environment_urls(svc_ctx, env_uuid, payload.urls.as_deref()).await?; + replace_environment_cookies(svc_ctx, env_uuid, payload.cookies.as_deref()).await?; // 分配标签 if let Some(tag_uuids) = &payload.tag_uuids { @@ -140,17 +143,6 @@ pub async fn create_environment_service( } } - // 添加 Cookies - if let Some(cookie_strings) = &payload.cookies { - let parsed_cookies = parse_cookie_strings(cookie_strings)?; - if !parsed_cookies.is_empty() { - let _ = - models::batch_insert_environment_cookies(&svc_ctx.db, env_uuid, &parsed_cookies) - .await - .map_err(|e| format!("添加 Cookies 失败: {}", e))?; - } - } - // 4. 更新工作空间配额(创建后增加使用数) models::increment_used_environments(&svc_ctx.db, workspace_uuid, 1) .await @@ -159,77 +151,284 @@ pub async fn create_environment_service( Ok(env_uuid) } -/// 解析 Cookie 字符串数组为 CookieInput 数组 -/// Cookie 字符串格式支持: -/// - 简单格式: "name=value" -/// - 完整格式: "name=value; domain=example.com; path=/; secure; httpOnly" -fn parse_cookie_strings(cookie_strings: &[String]) -> Result, String> { - let mut cookies = Vec::new(); +#[derive(Debug, Clone)] +struct CookieSiteTarget { + site_input: String, + domain: String, + path: String, + secure: bool, +} - for cookie_str in cookie_strings { - let cookie_str = cookie_str.trim(); - if cookie_str.is_empty() { - continue; +fn normalize_cookie_path(path: &str) -> String { + if path.trim().is_empty() || path == "/" { + "/".to_string() + } else if path.starts_with('/') { + path.to_string() + } else { + format!("/{}", path) + } +} + +fn parse_cookie_site(site: &str) -> Result { + let site = site.trim(); + if site.is_empty() { + return Err("Cookie 目标网页/域名不能为空".to_string()); + } + + if let Ok(parsed) = Url::parse(site) { + let host = parsed + .host_str() + .ok_or_else(|| format!("无效的 Cookie 目标网页: {}", site))?; + + return Ok(CookieSiteTarget { + site_input: site.to_string(), + domain: host.to_string(), + path: normalize_cookie_path(parsed.path()), + secure: parsed.scheme().eq_ignore_ascii_case("https"), + }); + } + + if !site.contains("://") { + if site.starts_with('.') && !site.contains('/') && !site.chars().any(|ch| ch.is_whitespace()) { + return Ok(CookieSiteTarget { + site_input: site.to_string(), + domain: site.to_string(), + path: "/".to_string(), + secure: false, + }); + } + + let candidate = format!("https://{}", site); + if let Ok(parsed) = Url::parse(&candidate) { + if let Some(host) = parsed.host_str() { + return Ok(CookieSiteTarget { + site_input: site.to_string(), + domain: host.to_string(), + path: normalize_cookie_path(parsed.path()), + secure: false, + }); + } + } + } + + Err(format!("无效的 Cookie 目标网页/域名: {}", site)) +} + +fn is_cookie_attribute(part: &str) -> bool { + let lower = part.trim().to_lowercase(); + matches!( + lower.as_str(), + "secure" | "httponly" | "http-only" | "partitioned" + ) || lower.starts_with("domain=") + || lower.starts_with("path=") + || lower.starts_with("expires=") + || lower.starts_with("max-age=") + || lower.starts_with("samesite=") +} + +fn parse_cookie_name_value(part: &str) -> Result<(String, String), String> { + let Some(eq_pos) = part.find('=') else { + return Err(format!("无效的 Cookie 格式: {}", part)); + }; + + let name = part[..eq_pos].trim(); + let value = part[eq_pos + 1..].trim(); + if name.is_empty() { + return Err(format!("Cookie 名称不能为空: {}", part)); + } + + Ok((name.to_string(), value.to_string())) +} + +fn parse_cookie_line_with_attrs( + line: &str, + site_target: &CookieSiteTarget, +) -> Result { + let parts: Vec<&str> = line.split(';').map(|part| part.trim()).filter(|part| !part.is_empty()).collect(); + let (name, value) = parse_cookie_name_value( + parts + .first() + .copied() + .ok_or_else(|| "Cookie 内容不能为空".to_string())?, + )?; + + let mut domain = site_target.domain.clone(); + let mut path = site_target.path.clone(); + let mut http_only = false; + let mut secure = site_target.secure; + let mut same_site = Some("Lax".to_string()); + + for part in parts.iter().skip(1) { + let lower = part.to_lowercase(); + if lower.starts_with("domain=") { + domain = part[7..].trim().to_string(); + } else if lower.starts_with("path=") { + path = normalize_cookie_path(part[5..].trim()); + } else if lower == "secure" { + secure = true; + } else if lower == "httponly" || lower == "http-only" { + http_only = true; + } else if lower.starts_with("samesite=") { + same_site = Some(part[9..].trim().to_string()); } + } + + Ok(CookieInput { + site_input: site_target.site_input.clone(), + domain, + name, + value, + path: Some(path), + http_only: Some(http_only), + secure: Some(secure), + same_site, + }) +} + +fn parse_cookie_group(group: &CookieGroupInput) -> Result, String> { + let site_target = parse_cookie_site(&group.site)?; + let cookie_text = group.cookie_text.trim(); + if cookie_text.is_empty() { + return Err("Cookie 内容不能为空".to_string()); + } + + let mut cookies = Vec::new(); + + for line in cookie_text + .lines() + .map(|line| line.trim()) + .filter(|line| !line.is_empty()) + { + let parts: Vec<&str> = line + .split(';') + .map(|part| part.trim()) + .filter(|part| !part.is_empty()) + .collect(); - // 解析 cookie 字符串 - let parts: Vec<&str> = cookie_str.split(';').map(|s| s.trim()).collect(); if parts.is_empty() { continue; } - // 解析 name=value - let name_value = parts[0]; - let (name, value) = if let Some(eq_pos) = name_value.find('=') { - (name_value[..eq_pos].trim(), name_value[eq_pos + 1..].trim()) - } else { - return Err(format!("无效的 Cookie 格式: {}", cookie_str)); - }; - - if name.is_empty() { - return Err(format!("Cookie 名称不能为空: {}", cookie_str)); + let has_attr_style = parts.len() > 1 && parts.iter().skip(1).all(|part| is_cookie_attribute(part)); + if has_attr_style { + cookies.push(parse_cookie_line_with_attrs(line, &site_target)?); + continue; } - // 解析其他属性 - let mut domain = None; - let mut path = Some("/".to_string()); - let mut http_only = Some(false); - let mut secure = Some(false); - let mut same_site = Some("Lax".to_string()); - - for part in parts.iter().skip(1) { - let part_lower = part.to_lowercase(); - if part_lower.starts_with("domain=") { - domain = Some(part[7..].trim().to_string()); - } else if part_lower.starts_with("path=") { - path = Some(part[5..].trim().to_string()); - } else if part_lower == "secure" { - secure = Some(true); - } else if part_lower == "httponly" || part_lower == "http-only" { - http_only = Some(true); - } else if part_lower.starts_with("samesite=") { - same_site = Some(part[9..].trim().to_string()); - } + for part in parts { + let (name, value) = parse_cookie_name_value(part)?; + cookies.push(CookieInput { + site_input: site_target.site_input.clone(), + domain: site_target.domain.clone(), + name, + value, + path: Some(site_target.path.clone()), + http_only: Some(false), + secure: Some(site_target.secure), + same_site: Some("Lax".to_string()), + }); } + } - // 如果没有指定 domain,尝试从 cookie 值中推断(如果可能) - // 这里简化处理,如果没有 domain 则使用空字符串或默认值 - let domain = domain.unwrap_or_else(|| "".to_string()); - - cookies.push(CookieInput { - domain, - name: name.to_string(), - value: value.to_string(), - path, - http_only, - secure, - same_site, - }); + if cookies.is_empty() { + return Err("Cookie 内容不能为空".to_string()); } Ok(cookies) } +fn format_cookie_row(cookie: &EnvironmentCookieDto, site_target: Option<&CookieSiteTarget>) -> String { + let mut parts = vec![format!("{}={}", cookie.name, cookie.value)]; + + let default_domain = site_target.map(|target| target.domain.as_str()).unwrap_or(""); + let default_path = site_target.map(|target| target.path.as_str()).unwrap_or("/"); + let default_secure = site_target.map(|target| target.secure).unwrap_or(false); + + if !cookie.domain.trim().is_empty() && cookie.domain != default_domain { + parts.push(format!("domain={}", cookie.domain)); + } + + let cookie_path = cookie.path.as_deref().unwrap_or("/"); + if cookie_path != default_path { + parts.push(format!("path={}", cookie_path)); + } + + if cookie.secure.unwrap_or(false) != default_secure && cookie.secure.unwrap_or(false) { + parts.push("secure".to_string()); + } + + if cookie.http_only.unwrap_or(false) { + parts.push("httpOnly".to_string()); + } + + if let Some(same_site) = cookie + .same_site + .as_deref() + .filter(|same_site| !same_site.trim().is_empty() && *same_site != "Lax") + { + parts.push(format!("sameSite={}", same_site)); + } + + parts.join("; ") +} + +fn group_cookie_rows(cookie_rows: Vec) -> Vec { + let mut grouped: HashMap> = HashMap::new(); + + for cookie in cookie_rows { + grouped + .entry(cookie.site_input.clone()) + .or_default() + .push(cookie); + } + + let mut items: Vec = grouped + .into_iter() + .map(|(site, cookies)| { + let site_target = parse_cookie_site(&site).ok(); + let simple_only = cookies.iter().all(|cookie| { + let default_domain = site_target + .as_ref() + .map(|target| target.domain.as_str()) + .unwrap_or(""); + let default_path = site_target + .as_ref() + .map(|target| target.path.as_str()) + .unwrap_or("/"); + let default_secure = site_target.as_ref().map(|target| target.secure).unwrap_or(false); + + cookie.domain == default_domain + && cookie.path.as_deref().unwrap_or("/") == default_path + && cookie.secure.unwrap_or(false) == default_secure + && !cookie.http_only.unwrap_or(false) + && cookie + .same_site + .as_deref() + .map(|same_site| same_site.eq_ignore_ascii_case("lax")) + .unwrap_or(true) + && cookie.expires_at.is_none() + }); + + let parts: Vec = cookies + .iter() + .map(|cookie| format_cookie_row(cookie, site_target.as_ref())) + .collect(); + + EnvironmentCookieGroupDto { + site, + cookie_text: if simple_only { + parts.join("; ") + } else { + parts.join("\n") + }, + } + }) + .collect(); + + items.sort_by(|left, right| left.site.cmp(&right.site)); + items +} + async fn replace_environment_urls( svc_ctx: &SvcCtx, env_uuid: Uuid, @@ -263,6 +462,36 @@ async fn replace_environment_urls( Ok(()) } +async fn replace_environment_cookies( + svc_ctx: &SvcCtx, + env_uuid: Uuid, + cookie_groups: Option<&[CookieGroupInput]>, +) -> Result<(), String> { + let Some(cookie_groups) = cookie_groups else { + return Ok(()); + }; + + models::clear_environment_cookies(&svc_ctx.db, env_uuid) + .await + .map_err(|e| format!("清空 Cookies 失败: {}", e))?; + + let mut parsed_cookies = Vec::new(); + for group in cookie_groups { + let mut items = parse_cookie_group(group)?; + parsed_cookies.append(&mut items); + } + + if parsed_cookies.is_empty() { + return Ok(()); + } + + models::batch_insert_environment_cookies(&svc_ctx.db, env_uuid, &parsed_cookies) + .await + .map_err(|e| format!("保存 Cookies 失败: {}", e))?; + + Ok(()) +} + /// 获取环境列表(包含完整关联数据) pub async fn get_environments_service( svc_ctx: &SvcCtx, @@ -434,6 +663,24 @@ pub async fn get_environments_service( } } + let mut cookies_map: HashMap> = HashMap::new(); + if !env_uuids.is_empty() { + let cookie_rows = models::fetch_environment_cookies_by_uuids(&svc_ctx.db, &env_uuids) + .await + .map_err(|e| e.to_string())?; + let mut grouped_rows: HashMap> = HashMap::new(); + for cookie in cookie_rows { + grouped_rows + .entry(cookie.environment_uuid) + .or_default() + .push(cookie); + } + for (environment_uuid, rows) in grouped_rows { + cookies_map + .insert(environment_uuid, group_cookie_rows(rows)); + } + } + // 批量查询账号 let mut accounts_map: HashMap> = HashMap::new(); for env_uuid in &env_uuids { @@ -516,6 +763,7 @@ pub async fn get_environments_service( crate::entitys::EnvironmentDetailResponse { environment, config: configs_map.remove(&row.uuid), // 返回配置信息,用于传递给指纹浏览器内核 + cookies: cookies_map.remove(&row.uuid).unwrap_or_default(), urls: urls_map.remove(&row.uuid).unwrap_or_default(), tags: tags_map.remove(&row.uuid).unwrap_or_default(), accounts: accounts_map.remove(&row.uuid).unwrap_or_default(), @@ -614,6 +862,9 @@ pub async fn get_environment_detail_service( get_environment_service(svc_ctx, workspace_uuid, team_uuid, user_uuid, env_uuid).await?; let config = get_environment_config_service(svc_ctx, env_uuid).await.ok(); + let cookies = get_environment_cookies_service(svc_ctx, env_uuid) + .await + .unwrap_or_default(); let urls = get_environment_urls_service(svc_ctx, env_uuid) .await .unwrap_or_default(); @@ -651,6 +902,7 @@ pub async fn get_environment_detail_service( Ok(crate::entitys::EnvironmentDetailResponse { environment, config, + cookies, urls, tags, accounts, @@ -793,6 +1045,7 @@ pub async fn update_environment_service( } replace_environment_urls(svc_ctx, payload.uuid, payload.urls.as_deref()).await?; + replace_environment_cookies(svc_ctx, payload.uuid, payload.cookies.as_deref()).await?; Ok(()) } @@ -1108,6 +1361,22 @@ pub async fn get_recycle_bin_environments_service( .push(url); } + let mut env_cookies_map: HashMap> = HashMap::new(); + let cookie_rows = models::fetch_environment_cookies_by_uuids(&svc_ctx.db, &env_uuids) + .await + .map_err(|e| e.to_string())?; + let mut grouped_cookie_rows: HashMap> = HashMap::new(); + for cookie in cookie_rows { + grouped_cookie_rows + .entry(cookie.environment_uuid) + .or_default() + .push(cookie); + } + for (environment_uuid, rows) in grouped_cookie_rows { + env_cookies_map + .insert(environment_uuid, group_cookie_rows(rows)); + } + // 9. 批量查询账号信息 let mut env_accounts_map: HashMap> = HashMap::new(); for env_uuid in &env_uuids { @@ -1149,6 +1418,7 @@ pub async fn get_recycle_bin_environments_service( deleted_at: None, }, config: None, + cookies: env_cookies_map.remove(&row.uuid).unwrap_or_default(), urls: env_urls_map.remove(&row.uuid).unwrap_or_default(), tags, accounts, @@ -1270,29 +1540,42 @@ pub async fn add_environment_cookie_service( svc_ctx: &SvcCtx, payload: &AddEnvironmentCookieRequest, ) -> Result { - models::insert_environment_cookie( - &svc_ctx.db, - payload.environment_uuid, - &payload.domain, - &payload.name, - &payload.value, - payload.path.as_deref(), - payload.http_only, - payload.secure, - payload.same_site.as_deref(), - ) - .await - .map_err(|e| e.to_string()) + let cookies = parse_cookie_group(&CookieGroupInput { + site: payload.site.clone(), + cookie_text: payload.cookie_text.clone(), + })?; + + let mut last_id = 0; + for cookie in cookies { + last_id = models::insert_environment_cookie( + &svc_ctx.db, + payload.environment_uuid, + &cookie.site_input, + &cookie.domain, + &cookie.name, + &cookie.value, + cookie.path.as_deref(), + cookie.http_only, + cookie.secure, + cookie.same_site.as_deref(), + ) + .await + .map_err(|e| e.to_string())?; + } + + Ok(last_id) } /// 获取环境的所有 Cookies pub async fn get_environment_cookies_service( svc_ctx: &SvcCtx, env_uuid: Uuid, -) -> Result, String> { - models::fetch_environment_cookies(&svc_ctx.db, env_uuid) +) -> Result, String> { + let rows = models::fetch_environment_cookies(&svc_ctx.db, env_uuid) .await - .map_err(|e| e.to_string()) + .map_err(|e| e.to_string())?; + + Ok(group_cookie_rows(rows)) } /// 删除环境 Cookie diff --git a/src/services/templates.rs b/src/services/templates.rs index 6a424ed..27f9d12 100644 --- a/src/services/templates.rs +++ b/src/services/templates.rs @@ -5,12 +5,12 @@ use crate::entitys::{ ApplyTemplateRequest, AssociationsStatus, CreateFromTemplateRequest, CreateTemplateRequest, TemplateDetailResponse, UpdateTemplateRequest, }; -use crate::models; -use crate::services; -use crate::svc_ctx::SvcCtx; - -/// 创建模板 -pub async fn create_template_service( +use crate::models; +use crate::services; +use crate::svc_ctx::SvcCtx; + +/// 创建模板 +pub async fn create_template_service( svc_ctx: &SvcCtx, user_uuid: Uuid, team_uuid: Option, @@ -234,6 +234,16 @@ pub async fn apply_template_service( name: None, description: None, group_uuid: None, + cookies: Some( + env_detail + .cookies + .iter() + .map(|item| crate::entitys::CookieGroupInput { + site: item.site.clone(), + cookie_text: item.cookie_text.clone(), + }) + .collect(), + ), urls: Some( env_detail .urls @@ -318,11 +328,20 @@ pub async fn create_from_template_service( }, account_uuids: if account_uuids.is_empty() { None - } else { - Some(account_uuids) + } else { + Some(account_uuids) }, proxy_uuid, - cookies: None, // 模板中不包含 cookies + cookies: Some( + env_detail + .cookies + .iter() + .map(|item| crate::entitys::CookieGroupInput { + site: item.site.clone(), + cookie_text: item.cookie_text.clone(), + }) + .collect(), + ), urls: Some( env_detail .urls From 4b0ac906d81b7b8119a887371ea277d82afbe752 Mon Sep 17 00:00:00 2001 From: lius-new Date: Thu, 14 May 2026 10:24:38 +0800 Subject: [PATCH 39/60] chore: prepare simprint-server for open source release --- .gitignore | 8 +- LICENSE | 662 +++++++++++++++++++++++++++++++++++++++ README.md | 259 +++++---------- README.zh-CN.md | 125 ++++++++ configs/README.md | 20 -- configs/config.dev.toml | 53 ---- configs/config.prod.toml | 53 ---- configs/config.test.toml | 51 --- 8 files changed, 872 insertions(+), 359 deletions(-) create mode 100644 LICENSE create mode 100644 README.zh-CN.md delete mode 100644 configs/README.md delete mode 100644 configs/config.dev.toml delete mode 100644 configs/config.prod.toml delete mode 100644 configs/config.test.toml diff --git a/.gitignore b/.gitignore index cbd0941..2fef621 100644 --- a/.gitignore +++ b/.gitignore @@ -30,6 +30,10 @@ console-gateway simprint-server update-gateway -# Docker 镜像文件 -simprint-server-docker-*.tar.gz +# Docker 镜像文件 +simprint-server-docker-*.tar.gz + +# 配置文件:仅保留示例配置可提交 +configs/* +!configs/config.example.toml diff --git a/LICENSE b/LICENSE new file mode 100644 index 0000000..a666a4d --- /dev/null +++ b/LICENSE @@ -0,0 +1,662 @@ + GNU AFFERO GENERAL PUBLIC LICENSE + Version 3, 19 November 2007 + + Copyright (C) 2007 Free Software Foundation, Inc. + Everyone is permitted to copy and distribute verbatim copies + of this license document, but changing it is not allowed. + + Preamble + + The GNU Affero General Public License is a free, copyleft license for +software and other kinds of works, specifically designed to ensure +cooperation with the community in the case of network server software. + + The licenses for most software and other practical works are designed +to take away your freedom to share and change the works. By contrast, +our General Public Licenses are intended to guarantee your freedom to +share and change all versions of a program--to make sure it remains free +software for all its users. + + When we speak of free software, we are referring to freedom, not +price. Our General Public Licenses are designed to make sure that you +have the freedom to distribute copies of free software (and charge for +them if you wish), that you receive source code or can get it if you +want it, that you can change the software or use pieces of it in new +free programs, and that you know you can do these things. + + Developers that use our General Public Licenses protect your rights +with two steps: (1) assert copyright on the software, and (2) offer +you this License which gives you legal permission to copy, distribute +and/or modify the software. + + A secondary benefit of defending all users' freedom is that +improvements made in alternate versions of the program, if they +receive widespread use, become available for other developers to +incorporate. Many developers of free software are heartened and +encouraged by the resulting cooperation. However, in the case of +software used on network servers, this result may fail to come about. +The GNU General Public License permits making a modified version and +letting the public access it on a server without ever releasing its +source code to the public. + + The GNU Affero General Public License is designed specifically to +ensure that, in such cases, the modified source code becomes available +to the community. It requires the operator of a network server to +provide the source code of the modified version running there to the +users of that server. Therefore, public use of a modified version, on +a publicly accessible server, gives the public access to the source +code of the modified version. + + An older license, called the Affero General Public License and +published by Affero, was designed to accomplish similar goals. This is +a different license, not a version of the Affero GPL, but Affero has +released a new version of the Affero GPL which permits relicensing under +this license. + + The precise terms and conditions for copying, distribution and +modification follow. + + TERMS AND CONDITIONS + + 0. Definitions. + + "This License" refers to version 3 of the GNU Affero General Public License. + + "Copyright" also means copyright-like laws that apply to other kinds of +works, such as semiconductor masks. + + "The Program" refers to any copyrightable work licensed under this +License. Each licensee is addressed as "you". "Licensees" and +"recipients" may be individuals or organizations. + + To "modify" a work means to copy from or adapt all or part of the work +in a fashion requiring copyright permission, other than the making of an +exact copy. The resulting work is called a "modified version" of the +earlier work or a work "based on" the earlier work. + + A "covered work" means either the unmodified Program or a work based +on the Program. + + To "propagate" a work means to do anything with it that, without +permission, would make you directly or secondarily liable for +infringement under applicable copyright law, except executing it on a +computer or modifying a private copy. Propagation includes copying, +distribution (with or without modification), making available to the +public, and in some countries other activities as well. + + To "convey" a work means any kind of propagation that enables other +parties to make or receive copies. Mere interaction with a user through +a computer network, with no transfer of a copy, is not conveying. + + An interactive user interface displays "Appropriate Legal Notices" +to the extent that it includes a convenient and prominently visible +feature that (1) displays an appropriate copyright notice, and (2) +tells the user that there is no warranty for the work (except to the +extent that warranties are provided), that licensees may convey the +work under this License, and how to view a copy of this License. If +the interface presents a list of user commands or options, such as a +menu, a prominent item in the list meets this criterion. + + 1. Source Code. + + The "source code" for a work means the preferred form of the work +for making modifications to it. "Object code" means any non-source +form of a work. + + A "Standard Interface" means an interface that either is an official +standard defined by a recognized standards body, or, in the case of +interfaces specified for a particular programming language, one that +is widely used among developers working in that language. + + The "System Libraries" of an executable work include anything, other +than the work as a whole, that (a) is included in the normal form of +packaging a Major Component, but which is not part of that Major +Component, and (b) serves only to enable use of the work with that +Major Component, or to implement a Standard Interface for which an +implementation is available to the public in source code form. A +"Major Component", in this context, means a major essential component +(kernel, window system, and so on) of the specific operating system +(if any) on which the executable work runs, or a compiler used to +produce the work, or an object code interpreter used to run it. + + The "Corresponding Source" for a work in object code form means all +the source code needed to generate, install, and (for an executable +work) run the object code and to modify the work, including scripts to +control those activities. However, it does not include the work's +System Libraries, or general-purpose tools or generally available free +programs which are used unmodified in performing those activities but +which are not part of the work. For example, Corresponding Source +includes interface definition files associated with source files for +the work, and the source code for shared libraries and dynamically +linked subprograms that the work is specifically designed to require, +such as by intimate data communication or control flow between those +subprograms and other parts of the work. + + The Corresponding Source need not include anything that users +can regenerate automatically from other parts of the Corresponding +Source. + + The Corresponding Source for a work in source code form is that +same work. + + 2. Basic Permissions. + + All rights granted under this License are granted for the term of +copyright on the Program, and are irrevocable provided the stated +conditions are met. This License explicitly affirms your unlimited +permission to run the unmodified Program. The output from running a +covered work is covered by this License only if the output, given its +content, constitutes a covered work. This License acknowledges your +rights of fair use or other equivalent, as provided by copyright law. + + You may make, run and propagate covered works that you do not +convey, without conditions so long as your license otherwise remains +in force. You may convey covered works to others for the sole purpose +of having them make modifications exclusively for you, or provide you +with facilities for running those works, provided that you comply with +the terms of this License in conveying all material for which you do +not control copyright. Those thus making or running the covered works +for you must do so exclusively on your behalf, under your direction +and control, on terms that prohibit them from making any copies of +your copyrighted material outside their relationship with you. + + Conveying under any other circumstances is permitted solely under +the conditions stated below. Sublicensing is not allowed; section 10 +makes it unnecessary. + + 3. Protecting Users' Legal Rights From Anti-Circumvention Law. + + No covered work shall be deemed part of an effective technological +measure under any applicable law fulfilling obligations under article +11 of the WIPO copyright treaty adopted on 20 December 1996, or +similar laws prohibiting or restricting circumvention of such +measures. + + When you convey a covered work, you waive any legal power to forbid +circumvention of technological measures to the extent such circumvention +is effected by exercising rights under this License with respect to +the covered work, and you disclaim any intention to limit operation or +modification of the work as a means of enforcing, against the work's +users, your or third parties' legal rights to forbid circumvention of +technological measures. + + 4. Conveying Verbatim Copies. + + You may convey verbatim copies of the Program's source code as you +receive it, in any medium, provided that you conspicuously and +appropriately publish on each copy an appropriate copyright notice; +keep intact all notices stating that this License and any +non-permissive terms added in accord with section 7 apply to the code; +keep intact all notices of the absence of any warranty; and give all +recipients a copy of this License along with the Program. + + You may charge any price or no price for each copy that you convey, +and you may offer support or warranty protection for a fee. + + 5. Conveying Modified Source Versions. + + You may convey a work based on the Program, or the modifications to +produce it from the Program, in the form of source code under the +terms of section 4, provided that you also meet all of these conditions: + + a) The work must carry prominent notices stating that you modified + it, and giving a relevant date. + + b) The work must carry prominent notices stating that it is + released under this License and any conditions added under section + 7. This requirement modifies the requirement in section 4 to + "keep intact all notices". + + c) You must license the entire work, as a whole, under this + License to anyone who comes into possession of a copy. This + License will therefore apply, along with any applicable section 7 + additional terms, to the whole of the work, and all its parts, + regardless of how they are packaged. This License gives no + permission to license the work in any other way, but it does not + invalidate such permission if you have separately received it. + + d) If the work has interactive user interfaces, each must display + Appropriate Legal Notices; however, if the Program has interactive + interfaces that do not display Appropriate Legal Notices, your + work need not make them do so. + + A compilation of a covered work with other separate and independent +works, which are not by their nature extensions of the covered work, +and which are not combined with it such as to form a larger program, +in or on a volume of a storage or distribution medium, is called an +"aggregate" if the compilation and its resulting copyright are not +used to limit the access or legal rights of the compilation's users +beyond what the individual works permit. Inclusion of a covered work +in an aggregate does not cause this License to apply to the other +parts of the aggregate. + + 6. Conveying Non-Source Forms. + + You may convey a covered work in object code form under the terms +of sections 4 and 5, provided that you also convey the +machine-readable Corresponding Source under the terms of this License, +in one of these ways: + + a) Convey the object code in, or embodied in, a physical product + (including a physical distribution medium), accompanied by the + Corresponding Source fixed on a durable physical medium + customarily used for software interchange. + + b) Convey the object code in, or embodied in, a physical product + (including a physical distribution medium), accompanied by a + written offer, valid for at least three years and valid for as + long as you offer spare parts or customer support for that product + model, to give anyone who possesses the object code either (1) a + copy of the Corresponding Source for all the software in the + product that is covered by this License, on a durable physical + medium customarily used for software interchange, for a price no + more than your reasonable cost of physically performing this + conveying of source, or (2) access to copy the + Corresponding Source from a network server at no charge. + + c) Convey individual copies of the object code with a copy of the + written offer to provide the Corresponding Source. This + alternative is allowed only occasionally and noncommercially, and + only if you received the object code with such an offer, in accord + with subsection 6b. + + d) Convey the object code by offering access from a designated + place (gratis or for a charge), and offer equivalent access to the + Corresponding Source in the same way through the same place at no + further charge. You need not require recipients to copy the + Corresponding Source along with the object code. If the place to + copy the object code is a network server, the Corresponding Source + may be on a different server (operated by you or a third party) + that supports equivalent copying facilities, provided you maintain + clear directions next to the object code saying where to find the + Corresponding Source. Regardless of what server hosts the + Corresponding Source, you remain obligated to ensure that it is + available for as long as needed to satisfy these requirements. + + e) Convey the object code using peer-to-peer transmission, provided + you inform other peers where the object code and Corresponding + Source of the work are being offered to the general public at no + charge under subsection 6d. + + A separable portion of the object code, whose source code is excluded +from the Corresponding Source as a System Library, need not be +included in conveying the object code work. + + A "User Product" is either (1) a "consumer product", which means any +tangible personal property which is normally used for personal, family, +or household purposes, or (2) anything designed or sold for incorporation +into a dwelling. In determining whether a product is a consumer product, +doubtful cases shall be resolved in favor of coverage. For a particular +product received by a particular user, "normally used" refers to a +typical or common use of that class of product, regardless of the status +of the particular user or of the way in which the particular user +actually uses, or expects or is expected to use, the product. A product +is a consumer product regardless of whether the product has substantial +commercial, industrial or non-consumer uses, unless such uses represent +the only significant mode of use of the product. + + "Installation Information" for a User Product means any methods, +procedures, authorization keys, or other information required to install +and execute modified versions of a covered work in that User Product from +a modified version of its Corresponding Source. The information must +suffice to ensure that the continued functioning of the modified object +code is in no case prevented or interfered with solely because +modification has been made. + + If you convey an object code work under this section in, or with, or +specifically for use in, a User Product, and the conveying occurs as +part of a transaction in which the right of possession and use of the +User Product is transferred to the recipient in perpetuity or for a +fixed term (regardless of how the transaction is characterized), the +Corresponding Source conveyed under this section must be accompanied +by the Installation Information. But this requirement does not apply +if neither you nor any third party retains the ability to install +modified object code on the User Product (for example, the work has +been installed in ROM). + + The requirement to provide Installation Information does not include a +requirement to continue to provide support service, warranty, or updates +for a work that has been modified or installed by the recipient, or for +the User Product in which it has been modified or installed. Access to a +network may be denied when the modification itself materially and +adversely affects the operation of the network or violates the rules and +protocols for communication across the network. + + Corresponding Source conveyed, and Installation Information provided, +in accord with this section must be in a format that is publicly +documented (and with an implementation available to the public in +source code form), and must require no special password or key for +unpacking, reading or copying. + + 7. Additional Terms. + + "Additional permissions" are terms that supplement the terms of this +License by making exceptions from one or more of its conditions. +Additional permissions that are applicable to the entire Program shall +be treated as though they were included in this License, to the extent +that they are valid under applicable law. If additional permissions +apply only to part of the Program, that part may be used separately +under those permissions, but the entire Program remains governed by +this License without regard to the additional permissions. + + When you convey a copy of a covered work, you may at your option +remove any additional permissions from that copy, or from any part of +it. (Additional permissions may be written to require their own +removal in certain cases when you modify the work.) You may place +additional permissions on material, added by you to a covered work, +for which you have or can give appropriate copyright permission. + + Notwithstanding any other provision of this License, for material you +add to a covered work, you may (if authorized by the copyright holders of +that material) supplement the terms of this License with terms: + + a) Disclaiming warranty or limiting liability differently from the + terms of sections 15 and 16 of this License; or + + b) Requiring preservation of specified reasonable legal notices or + author attributions in that material or in the Appropriate Legal + Notices displayed by works containing it; or + + c) Prohibiting misrepresentation of the origin of that material, or + requiring that modified versions of such material be marked in + reasonable ways as different from the original version; or + + d) Limiting the use for publicity purposes of names of licensors or + authors of the material; or + + e) Declining to grant rights under trademark law for use of some + trade names, trademarks, or service marks; or + + f) Requiring indemnification of licensors and authors of that + material by anyone who conveys the material (or modified versions of + it) with contractual assumptions of liability to the recipient, for + any liability that these contractual assumptions directly impose on + those licensors and authors. + + All other non-permissive additional terms are considered "further +restrictions" within the meaning of section 10. If the Program as you +received it, or any part of it, contains a notice stating that it is +governed by this License along with a term that is a further +restriction, you may remove that term. If a license document contains +a further restriction but permits relicensing or conveying under this +License, you may add to a covered work material governed by the terms +of that license document, provided that the further restriction does +not survive such relicensing or conveying. + + If you add terms to a covered work in accord with this section, you +must place, in the relevant source files, a statement of the +additional terms that apply to those files, or a notice indicating +where to find the applicable terms. + + Additional terms, permissive or non-permissive, may be stated in the +form of a separately written license, or stated as exceptions; +the above requirements apply either way. + + 8. Termination. + + You may not propagate or modify a covered work except as expressly +provided under this License. Any attempt otherwise to propagate or +modify it is void, and will automatically terminate your rights under +this License (including any patent licenses granted under the third +paragraph of section 11). + + However, if you cease all violation of this License, then your +license from a particular copyright holder is reinstated (a) +provisionally, unless and until the copyright holder explicitly and +finally terminates your license, and (b) permanently, if the copyright +holder fails to notify you of the violation by some reasonable means +prior to 60 days after the cessation. + + Moreover, your license from a particular copyright holder is +reinstated permanently if the copyright holder notifies you of the +violation by some reasonable means, this is the first time you have +received notice of violation of this License (for any work) from that +copyright holder, and you cure the violation prior to 30 days after +your receipt of the notice. + + Termination of your rights under this section does not terminate the +licenses of parties who have received copies or rights from you under +this License. If your rights have been terminated and not permanently +reinstated, you do not qualify to receive new licenses for the same +material under section 10. + + 9. Acceptance Not Required for Having Copies. + + You are not required to accept this License in order to receive or +run a copy of the Program. Ancillary propagation of a covered work +occurring solely as a consequence of using peer-to-peer transmission +to receive a copy likewise does not require acceptance. However, +nothing other than this License grants you permission to propagate or +modify any covered work. These actions infringe copyright if you do +not accept this License. Therefore, by modifying or propagating a +covered work, you indicate your acceptance of this License to do so. + + 10. Automatic Licensing of Downstream Recipients. + + Each time you convey a covered work, the recipient automatically +receives a license from the original licensors, to run, modify and +propagate that work, subject to this License. You are not responsible +for enforcing compliance by third parties with this License. + + An "entity transaction" is a transaction transferring control of an +organization, or substantially all assets of one, or subdividing an +organization, or merging organizations. If propagation of a covered +work results from an entity transaction, each party to that +transaction who receives a copy of the work also receives whatever +licenses to the work the party's predecessor in interest had or could +give under the previous paragraph, plus a right to possession of the +Corresponding Source of the work from the predecessor in interest, if +the predecessor has it or can get it with reasonable efforts. + + You may not impose any further restrictions on the exercise of the +rights granted or affirmed under this License. For example, you may +not impose a license fee, royalty, or other charge for exercise of +rights granted under this License, and you may not initiate litigation +(including a cross-claim or counterclaim in a lawsuit) alleging that +any patent claim is infringed by making, using, selling, offering for +sale, or importing the Program or any portion of it. + + 11. Patents. + + A "contributor" is a copyright holder who authorizes use under this +License of the Program or a work on which the Program is based. The +work thus licensed is called the contributor's "contributor version". + + A contributor's "essential patent claims" are all patent claims +owned or controlled by the contributor, whether already acquired or +hereafter acquired, that would be infringed by some manner, permitted +by this License, of making, using, or selling its contributor version, +but do not include claims that would be infringed only as a +consequence of further modification of the contributor version. For +purposes of this definition, "control" includes the right to grant +patent sublicenses in a manner consistent with the requirements of +this License. + + Each contributor grants you a non-exclusive, worldwide, royalty-free +patent license under the contributor's essential patent claims, to +make, use, sell, offer for sale, import and otherwise run, modify and +propagate the contents of its contributor version. + + In the following three paragraphs, a "patent license" is any express +agreement or commitment, however denominated, not to enforce a patent +(such as an express permission to practice a patent or covenant not to +sue for patent infringement). To "grant" such a patent license to a +party means to make such an agreement or commitment not to enforce a +patent against the party. + + If you convey a covered work, knowingly relying on a patent license, +and the Corresponding Source of the work is not available for anyone +to copy, free of charge and under the terms of this License, through a +publicly available network server or other readily accessible means, +then you must either (1) cause the Corresponding Source to be so +available, or (2) arrange to deprive yourself of the benefit of the +patent license for this particular work, or (3) arrange, in a manner +consistent with the requirements of this License, to extend the patent +license to downstream recipients. "Knowingly relying" means you have +actual knowledge that, but for the patent license, your conveying the +covered work in a country, or your recipient's use of the covered work +in a country, would infringe one or more identifiable patents in that +country that you have reason to believe are valid. + + If, pursuant to or in connection with a single transaction or +arrangement, you convey, or propagate by procuring conveyance of, a +covered work, and grant a patent license to some of the parties +receiving the covered work authorizing them to use, propagate, modify +or convey a specific copy of the covered work, then the patent license +you grant is automatically extended to all recipients of the covered +work and works based on it. + + A patent license is "discriminatory" if it does not include within +the scope of its coverage, prohibits the exercise of, or is +conditioned on the non-exercise of one or more of the rights that are +specifically granted under this License. You may not convey a covered +work if you are a party to an arrangement with a third party that is +in the business of distributing software, under which you make payment +to the third party based on the extent of your activity of conveying +the work, and under which the third party grants, to any of the +parties who would receive the covered work from you, a discriminatory +patent license (a) in connection with copies of the covered work +conveyed by you (or copies made from those copies), or (b) primarily +for and in connection with specific products or compilations that +contain the covered work, unless you entered into that arrangement, +or that patent license was granted, prior to 28 March 2007. + + Nothing in this License shall be construed as excluding or limiting +any implied license or other defenses to infringement that may +otherwise be available to you under applicable patent law. + + 12. No Surrender of Others' Freedom. + + If conditions are imposed on you (whether by court order, agreement or +otherwise) that contradict the conditions of this License, they do not +excuse you from the conditions of this License. If you cannot convey a +covered work so as to satisfy simultaneously your obligations under this +License and any other pertinent obligations, then as a consequence you may +not convey it at all. For example, if you agree to terms that obligate you +to collect a royalty for further conveying from those to whom you convey +the Program, the only way you could satisfy both those terms and this +License would be to refrain entirely from conveying the Program. + + 13. Remote Network Interaction; Use with the GNU General Public License. + + Notwithstanding any other provision of this License, if you modify the +Program, your modified version must prominently offer all users +interacting with it remotely through a computer network (if your version +supports such interaction) an opportunity to receive the Corresponding +Source of your version by providing access to the Corresponding Source +from a network server at no charge, through some standard or customary +means of facilitating copying of software. This Corresponding Source +shall include the Corresponding Source for any work covered by version 3 +of the GNU General Public License that is incorporated pursuant to the +following paragraph. + + Notwithstanding any other provision of this License, you have +permission to link or combine any covered work with a work licensed +under version 3 of the GNU General Public License into a single +combined work, and to convey the resulting work. The terms of this +License will continue to apply to the part which is the covered work, +but the special requirements of the GNU General Public License, section +13, concerning interaction through a network will apply to the +combination as such. + + 14. Revised Versions of this License. + + The Free Software Foundation may publish revised and/or new versions of +the GNU Affero General Public License from time to time. Such new versions +will be similar in spirit to the present version, but may differ in detail to +address new problems or concerns. + + Each version is given a distinguishing version number. If the +Program specifies that a certain numbered version of the GNU Affero General +Public License "or any later version" applies to it, you have the +option of following the terms and conditions either of that numbered +version or of any later version published by the Free Software +Foundation. If the Program does not specify a version number of the +GNU Affero General Public License, you may choose any version ever published +by the Free Software Foundation. + + If the Program specifies that a proxy can decide which future +versions of the GNU Affero General Public License can be used, that proxy's +public statement of acceptance of a version permanently authorizes you +to choose that version for the Program. + + Later license versions may give you additional or different +permissions. However, no additional obligations are imposed on any +author or copyright holder as a result of your choosing to follow a +later version. + + 15. Disclaimer of Warranty. + + THERE IS NO WARRANTY FOR THE PROGRAM, TO THE EXTENT PERMITTED BY +APPLICABLE LAW. EXCEPT WHEN OTHERWISE STATED IN WRITING THE COPYRIGHT +HOLDERS AND/OR OTHER PARTIES PROVIDE THE PROGRAM "AS IS" WITHOUT WARRANTY +OF ANY KIND, EITHER EXPRESSED OR IMPLIED, INCLUDING, BUT NOT LIMITED TO, +THE IMPLIED WARRANTIES OF MERCHANTABILITY AND FITNESS FOR A PARTICULAR +PURPOSE. THE ENTIRE RISK AS TO THE QUALITY AND PERFORMANCE OF THE PROGRAM +IS WITH YOU. SHOULD THE PROGRAM PROVE DEFECTIVE, YOU ASSUME THE COST OF +ALL NECESSARY SERVICING, REPAIR OR CORRECTION. + + 16. Limitation of Liability. + + IN NO EVENT UNLESS REQUIRED BY APPLICABLE LAW OR AGREED TO IN WRITING +WILL ANY COPYRIGHT HOLDER, OR ANY OTHER PARTY WHO MODIFIES AND/OR CONVEYS +THE PROGRAM AS PERMITTED ABOVE, BE LIABLE TO YOU FOR DAMAGES, INCLUDING ANY +GENERAL, SPECIAL, INCIDENTAL OR CONSEQUENTIAL DAMAGES ARISING OUT OF THE +USE OR INABILITY TO USE THE PROGRAM (INCLUDING BUT NOT LIMITED TO LOSS OF +DATA OR DATA BEING RENDERED INACCURATE OR LOSSES SUSTAINED BY YOU OR THIRD +PARTIES OR A FAILURE OF THE PROGRAM TO OPERATE WITH ANY OTHER PROGRAMS), +EVEN IF SUCH HOLDER OR OTHER PARTY HAS BEEN ADVISED OF THE POSSIBILITY OF +SUCH DAMAGES. + + 17. Interpretation of Sections 15 and 16. + + If the disclaimer of warranty and limitation of liability provided +above cannot be given local legal effect according to their terms, +reviewing courts shall apply local law that most closely approximates +an absolute waiver of all civil liability in connection with the +Program, unless a warranty or assumption of liability accompanies a +copy of the Program in return for a fee. + + END OF TERMS AND CONDITIONS + + How to Apply These Terms to Your New Programs + + If you develop a new program, and you want it to be of the greatest +possible use to the public, the best way to achieve this is to make it +free software which everyone can redistribute and change under these terms. + + To do so, attach the following notices to the program. It is safest +to attach them to the start of each source file to most effectively +state the exclusion of warranty; and each file should have at least +the "copyright" line and a pointer to where the full notice is found. + + + Copyright (C) + + This program is free software: you can redistribute it and/or modify + it under the terms of the GNU Affero General Public License as + published by the Free Software Foundation, either version 3 of the + License, or (at your option) any later version. + + This program is distributed in the hope that it will be useful, + but WITHOUT ANY WARRANTY; without even the implied warranty of + MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the + GNU Affero General Public License for more details. + + You should have received a copy of the GNU Affero General Public License + along with this program. If not, see . + + Also add information on how to contact you by electronic and paper mail. + + If your software can interact with users remotely through a computer +network, you should also make sure that it provides a way for users to +get its source. For example, if your program is a web application, its +interface could display a "Source" link that leads users to an archive +of the code. There are many ways you could offer source, and different +solutions will be better for different programs; see section 13 for the +specific requirements. + + You should also get your employer (if you work as a programmer) or school, +if any, to sign a "copyright disclaimer" for the program, if necessary. +For more information on this, and how to apply and follow the GNU AGPL, see +. diff --git a/README.md b/README.md index 502be73..24cb5a0 100644 --- a/README.md +++ b/README.md @@ -1,226 +1,125 @@ -# Simprint Server +
+

Simprint Server

+

Self-hosted backend service for Simprint workspaces, accounts, environments, proxies, and related runtime APIs.

+

+ Language Rust 2024 + Framework Axum 0.8 + Database PostgreSQL + Cache Redis +

+

+ English | 简体中文 +

+
-Simprint 应用的后端服务系统。 +--- -## 项目简介 +## Introduction -Simprint Server 是 Simprint 桌面应用的后端服务,提供用户认证、数据管理、版本更新等核心功能支持。 +Simprint Server is the backend service used by Simprint clients and self-hosted deployments. It exposes the application API, manages authentication and persistent data, initializes encryption and storage resources, and runs embedded database migrations during startup. -## 使用示例 +It is intended for operators who want to run Simprint inside their own infrastructure instead of depending on a shared hosted backend. The service is configured through a local TOML file and is designed to work with PostgreSQL, Redis, and S3-compatible object storage. -### 通过 `build_docker.py` 构建 Docker 部署包 +## Why Simprint Server? -脚本位置: +Running Simprint in a self-hosted setup usually requires more than just an HTTP server: -```bash -./build_docker.py -``` +- You need control over API availability, credentials, and storage infrastructure. +- You need deployment artifacts that are safe to publish without leaking real environment configuration. +- You need database schema upgrades to happen predictably during release and restart. +- You need one backend entry point that can serve workspace, environment, proxy, and account-related APIs together. -脚本会执行这些步骤: +Simprint Server is built around those constraints: a single Rust service, a config-first deployment model, embedded migrations, and packaging that only ships a publish-safe example configuration. -- 编译 `simprint-server` -- 编译 `console-gateway` -- 编译 `update-gateway` -- 将三个二进制文件复制到项目根目录 -- 默认生成一个 `tar.gz` Docker 部署包 +## Features -在项目根目录执行: +- **Core application API**: Serves account, workspace, team, environment, proxy, template, preference, message, extension, and local runtime endpoints from one process. +- **Authentication and secret initialization**: Supports login-related flows, token refresh, route whitelists, and RSA secret bootstrap on first startup. +- **Embedded database migrations**: Executes `sqlx` migrations automatically before the HTTP server starts accepting traffic. +- **S3-compatible storage integration**: Configures external object storage for avatars, extension assets, and version-related files. +- **Redis-backed runtime coordination**: Uses Redis for runtime coordination and cache-oriented service flows. +- **Docker-oriented release packaging**: Generates a deployment archive with `Dockerfile`, `docker-compose.yml`, and `configs/config.toml` copied from `configs/config.example.toml`. +- **Config-first execution**: Runs locally and in containers with the same `-f ` startup model. -```bash -python3 build_docker.py -``` +## Quick Start -如果你本地使用 `uv`,也可以直接执行: +### Prerequisites -```bash -uv run python build_docker.py -``` +- Rust toolchain +- PostgreSQL 16+ or a compatible PostgreSQL instance +- Redis 7+ +- S3-compatible object storage +- Optional SMTP server for email-related flows -常用命令: +### One-line self-hosted server install -```bash -# 默认 release 构建,并生成 tar.gz 部署包 -python3 build_docker.py -uv run python build_docker.py - -# 构建前清理旧二进制 -python3 build_docker.py --clean -uv run python build_docker.py --clean - -# 只编译并复制二进制,不生成压缩包 -python3 build_docker.py --no-package -uv run python build_docker.py --no-package - -# 生成 zip 格式部署包 -python3 build_docker.py --format zip -uv run python build_docker.py --format zip - -# 使用 dev 模式编译 -python3 build_docker.py --dev --no-package -uv run python build_docker.py --dev --no-package -``` - -构建完成后: - -- 二进制文件会出现在当前项目根目录: - - `./simprint-server` - - `./console-gateway` - - `./update-gateway` -- 如果未使用 `--no-package`,还会在项目根目录生成: - - `simprint-server-docker-*.tar.gz` - - 或 `simprint-server-docker-*.zip` - -### 启动服务 - -服务需要通过配置文件启动,配置文件路径通过命令行参数指定: +Linux servers can bootstrap the self-hosted backend with: ```bash -./simprint-server -f=configs/config.dev.toml -./simprint-server -f=configs/config.dev.toml serve +curl -fsSL https://raw.githubusercontent.com/Simprint/simprint/main/deploy/install-server.sh | bash # Update the client config afterwards, for example: base_url = http://127.0.0.1:40041/api/ ``` -### Docker 部署配置模板 - -对外发布的 Docker 部署包仅包含 `configs/config.toml` 配置模板。 -部署前请先按实际环境修改这个文件。 -其中数据库连接串默认指向 Compose 内的 `postgres` 服务, -如果你修改了 `docker-compose.yml` 里的 `POSTGRES_USER`、`POSTGRES_PASSWORD` 或数据库名, -需要同步修改 `configs/config.toml` 中的 `[database].url`。 -RSA 密钥不会随部署包下发,服务首次启动时会在容器卷中自动生成并持久化保存。 - -然后再使用 Docker Compose 启动,Compose 默认读取: +### Run locally ```bash -/app/configs/config.toml +cp configs/config.example.toml configs/config.local.toml +# edit configs/config.local.toml +cargo run -- -f configs/config.local.toml ``` -### 发布 `simprint-server` 镜像 - -如果你要手动发布 `simprint-server` 到 GHCR,可以按下面的流程执行: - -```bash -# 1. 构建发布包 -python3 build_docker.py - -# 2. 解压部署包 -tar -xzf simprint-server-docker-*.tar.gz -cd simprint-server-docker-* - -# 3. 本地构建镜像 -docker build --build-arg BINARY_NAME=simprint-server -t simprint-server:local . +The example configuration listens on port `40041` and uses the `/api/v1` prefix by default. -# 4. 可选:本地验证 -docker tag simprint-server:local publish-simprint-server:latest -docker images | grep simprint-server +### Build a Docker release package -# 5. 登录 GHCR -echo "$GITHUB_TOKEN" | docker login ghcr.io -u --password-stdin - -# 6. 打 tag 并推送 -docker tag simprint-server:local ghcr.io/simprint/simprint-server:latest -docker push ghcr.io/simprint/simprint-server:latest -``` - -如果你要发布特定版本,可以额外打一个版本标签: +Use: ```bash -docker tag simprint-server:local ghcr.io/simprint/simprint-server:v0.1.0 -docker push ghcr.io/simprint/simprint-server:v0.1.0 +uv run python build_docker.py ``` -### 初始化数据库 +The default build produces: -数据库迁移已编译进 `simprint-server` 二进制,并会在服务启动时自动执行。 -因此部署时无需再单独执行迁移命令。 +- `./simprint-server` +- `./simprint-server-docker-*.tar.gz` + +You can also use options such as: ```bash -docker compose up -d +uv run python build_docker.py --clean +uv run python build_docker.py --no-package +uv run python build_docker.py --format zip +uv run python build_docker.py --dev --no-package ``` -该流程无需额外公开 `migrations/` 目录。 - -### 服务入口 - -Simprint Server 提供三种服务入口: - -- **客户端网关**:为桌面应用提供基础服务接口 -- **控制台网关**:为管理后台提供管理功能接口 -- **更新网关**:为应用更新提供版本检查接口 - -> **注意**:详细的使用示例和 API 文档将在后续版本中添加。 - -## 贡献指南 - -我们欢迎所有形式的贡献!如果您想为 Simprint Server 项目做出贡献,请遵循以下步骤: - -1. **Fork 项目**:在 GitHub 上 Fork 本项目 -2. **创建分支**:从 `master` 分支创建新的功能分支 -3. **提交更改**:进行代码修改并提交,遵循项目的代码规范 -4. **推送分支**:将更改推送到您的 Fork -5. **创建 Pull Request**:向主项目提交 Pull Request - -### 代码规范 - -- 遵循 Rust 代码风格和格式化规范 -- 提交前运行 `cargo fmt` 和 `cargo clippy` -- 确保所有测试通过 -- 数据库迁移文件命名遵循规范 -- 添加适当的文档注释 - -更多详细信息请参考项目目录下的 `AGENTS.md` 文件。 - -## 许可证信息 - -本项目采用私有许可证,未经授权不得使用、复制、修改或分发。 - -## 故障排除 - -### 常见问题 - -**Q: 服务无法启动?** - -A: 请检查以下事项: - -- 确保配置文件路径正确 -- 检查配置文件格式是否正确 -- 确保数据库连接配置正确 -- 检查端口是否被占用 -- 查看服务日志获取详细错误信息 - -**Q: 数据库连接失败?** - -A: 请检查: +The packaged `configs/config.toml` is generated from `configs/config.example.toml`, and real environment-specific config files are intentionally not included in the release archive. -- 数据库服务是否正在运行 -- 数据库连接字符串是否正确 -- 数据库用户权限是否足够 -- 网络连接是否正常 +## Status -**Q: Redis 连接失败?** +Simprint Server was originally developed as part of a private commercial backend stack. This repository is now being prepared for a public open-source release, and the documentation is being rewritten to make standalone self-hosted deployment easier to understand. -A: 请检查: +Some modules and naming still reflect earlier internal deployment assumptions. The current direction is to keep the client-facing gateway service deployable as an independent repository with a cleaner public-facing setup. -- Redis 服务是否正在运行 -- Redis 连接 URL 是否正确 -- Redis 认证信息是否正确 +## Contributing -**Q: 对象存储服务异常?** +This repository is still in an open-source refactoring phase, but issues and pull requests are welcome. -A: 可能的原因: +High-value contribution areas include: -- 对象存储服务未启动 -- 访问密钥配置错误 -- 网络连接问题 -- 存储桶不存在或权限不足 +- Self-hosted deployment docs and onboarding improvements +- Test coverage and regression verification +- API documentation and route-level usage examples +- Packaging, release, and CI improvements -**Q: 如何查看服务日志?** +Useful entry points when exploring the codebase: -A: 服务日志会输出到控制台,生产环境建议配置日志文件输出。 +- `src/main.rs` +- `src/cli.rs` +- `configs/config.example.toml` +- `build_docker.py` +- `docs/` -### 获取帮助 +## License -如果以上方法无法解决您的问题,请: +This project is licensed under the GNU Affero General Public License v3.0 (AGPLv3). -- 查看项目的 Issue 列表,看是否有类似问题 -- 创建新的 Issue 描述您的问题 -- 联系项目维护者 +If you want to use Simprint Server in a way that does not comply with the AGPLv3 obligations, including distributing modified versions or providing modified versions as a closed-source service, please contact us for a commercial license. diff --git a/README.zh-CN.md b/README.zh-CN.md new file mode 100644 index 0000000..34a1431 --- /dev/null +++ b/README.zh-CN.md @@ -0,0 +1,125 @@ +
+

Simprint Server

+

面向 Simprint 工作区、账号体系、环境管理、代理资源和运行时接口的自托管后端服务。

+

+ Language Rust 2024 + Framework Axum 0.8 + Database PostgreSQL + Cache Redis +

+

+ English | 简体中文 +

+
+ +--- + +## Introduction + +Simprint Server 是 Simprint 客户端和私有化部署场景使用的后端服务。它负责暴露应用 API、管理认证与持久化数据、初始化加密和存储相关资源,并在启动时自动执行内嵌的数据库迁移。 + +它面向希望把 Simprint 部署在自有基础设施中的使用者,而不是依赖共享托管后端。服务通过本地 TOML 配置文件驱动,默认围绕 PostgreSQL、Redis 和兼容 S3 的对象存储来组织运行环境。 + +## Why Simprint Server? + +想把 Simprint 作为自托管服务落地,通常不只是“起一个 HTTP 服务”这么简单: + +- 你需要控制 API 可用性、认证凭据和对象存储基础设施。 +- 你需要一套可以公开发布、但不会泄露真实环境配置的部署产物。 +- 你需要在发布和重启过程中稳定地完成数据库结构升级。 +- 你需要一个统一的后端入口来承载工作区、环境、代理和账号相关接口。 + +Simprint Server 的设计就是围绕这些约束展开的:单个 Rust 服务、配置优先的部署模型、内嵌数据库迁移,以及仅打包可公开示例配置的发布流程。 + +## Features + +- **核心应用 API**:在一个进程中承载账号、工作区、团队、环境、代理、模板、偏好、消息、扩展和本地运行时等接口。 +- **认证与密钥初始化**:支持登录相关流程、令牌刷新、白名单路由以及首次启动时的 RSA 密钥初始化。 +- **内嵌数据库迁移**:在 HTTP 服务开始接收流量前自动执行 `sqlx` migrations。 +- **兼容 S3 的对象存储集成**:为头像、扩展资源和版本相关文件接入外部对象存储。 +- **基于 Redis 的运行时协同**:使用 Redis 承担运行时协同和缓存类服务能力。 +- **面向 Docker 的发布打包**:生成包含 `Dockerfile`、`docker-compose.yml` 以及由 `configs/config.example.toml` 复制出的 `configs/config.toml` 的部署包。 +- **配置优先的运行方式**:本地运行和容器运行都使用同一套 `-f ` 启动模型。 + +## Quick Start + +### Prerequisites + +- Rust toolchain +- PostgreSQL 16+ 或兼容的 PostgreSQL 实例 +- Redis 7+ +- 兼容 S3 的对象存储 +- 可选的 SMTP 服务,用于邮件相关流程 + +### 一键安装自托管服务端 + +Linux 服务器可直接执行: + +```bash +curl -fsSL https://raw.githubusercontent.com/Simprint/simprint/main/deploy/install-server.sh | bash # 请修改客户端的配置, 如: base_url = http://127.0.0.1:40041/api/ +``` + +### 本地运行 + +```bash +cp configs/config.example.toml configs/config.local.toml +# 修改 configs/config.local.toml +cargo run -- -f configs/config.local.toml +``` + +示例配置默认监听 `40041` 端口,并使用 `/api/v1` 作为接口前缀。 + +### 构建 Docker 发布包 + +使用: + +```bash +uv run python build_docker.py +``` + +默认构建产物包括: + +- `./simprint-server` +- `./simprint-server-docker-*.tar.gz` + +也可以使用这些常见参数: + +```bash +uv run python build_docker.py --clean +uv run python build_docker.py --no-package +uv run python build_docker.py --format zip +uv run python build_docker.py --dev --no-package +``` + +打包后的 `configs/config.toml` 来自仓库中的 `configs/config.example.toml`,真实环境配置文件不会被包含在对外发布的部署包中。 + +## Status + +Simprint Server 最初是作为私有商业后端体系的一部分开发的。当前这个仓库正在为公开开源发布做整理,文档也在同步重写,以便外部使用者更容易理解和部署独立的自托管版本。 + +仓库中的部分模块划分和命名,仍然会带有早期内部部署模型的痕迹。当前方向是把面向客户端的网关服务整理成一个可以独立部署、便于公开协作的仓库。 + +## Contributing + +这个仓库目前仍处于开源重构阶段,但已经欢迎通过 Issue 和 Pull Request 参与改进。 + +当前更有价值的贡献方向包括: + +- 自托管部署文档和上手流程优化 +- 测试覆盖和回归验证补充 +- API 文档和路由级使用示例完善 +- 打包、发布和 CI 流程改进 + +如果你准备快速建立上下文,建议先看这些入口: + +- `src/main.rs` +- `src/cli.rs` +- `configs/config.example.toml` +- `build_docker.py` +- `docs/` + +## License + +本项目采用 GNU Affero General Public License v3.0 (AGPLv3) 进行许可。 + +如果你希望在不履行 AGPLv3 义务的前提下使用 Simprint Server,包括分发修改版本或以闭源服务形式提供修改版本,请联系获取商业许可。 diff --git a/configs/README.md b/configs/README.md deleted file mode 100644 index 9d2a619..0000000 --- a/configs/README.md +++ /dev/null @@ -1,20 +0,0 @@ -# 配置文件说明 - -本服务通过 `-f <配置文件路径>` 指定使用的配置,例如: - -```bash -# 主服务(客户端网关):用户、工作空间、计费、环境、代理等完整 API -cargo run -- -f configs/config.dev.toml - -# 更新网关:仅版本检查、维护状态、健康检查等 -cargo run -- -f configs/config.update-gateway.dev.toml -``` - -## 文件说明 - -| 文件 | 用途 | -|------|------| -| `config.dev.toml` / `config.test.toml` / `config.prod.toml` | 主服务(客户端网关),含数据库、Redis、对象存储、SMTP、工作空间配额等 | -| `config.update-gateway.dev.toml` / `.prod` / `.test` | 更新网关,仅版本与维护相关接口,配置项更少 | - -**说明**:控制台网关(console-gateway)已拆分为独立仓库 `simprint-console-server`,此处不再保留 `config.console-gateway.*` 配置。 diff --git a/configs/config.dev.toml b/configs/config.dev.toml deleted file mode 100644 index b695a8b..0000000 --- a/configs/config.dev.toml +++ /dev/null @@ -1,53 +0,0 @@ -[app] -name = "simprint-server" -port = 40041 -secret = "p4y8uX1Jc22K7bAN52arpvJpVbduUvGNNNfo" -prefix = "/api/v1" -encrypt_secret_location = "./assets/secret" -route_whitelists = [ - "POST+/api/v1/users/login", - "POST+/api/v1/users/register", - "POST+/api/v1/users/register-send-code", - "POST+/api/v1/users/reset-password", - "POST+/api/v1/users/reset-password-send-code", - "POST+/api/v1/users/refresh-credentials", - "GET+/api/v1/secret/public/key", - "GET+/api/v1/time/now", - "POST+/api/v1/versions/check" -] -referral_link_prefix = "https://www.simprint.app/download" - - -[database] -url = "postgres://lius:URxywfBRZZ1xpWcyPRbG@192.168.224.129:5432/simprintdb" -max_connections = 25 -min_connections = 5 -max_lifetime = 3000 -acquire_timeout = 30 -idle_timeout = 600 - -[redis] -url = "redis://192.168.224.129:6381?protocol=resp3" - -[storage] -endpoint = "https://503bc762ec98603e9ff53f374d78df22.r2.cloudflarestorage.com" -public_base_url = "https://pub-39307a5e69c74324855a762027cbf9bf.r2.dev" -access_key = "9295bc8f31a4bc6ce80d0014e29e6896" -secret_access_key = "60a9cd6d3dd6b10ea0f0aaf2411c127bfb01ef8700a62bf8cb3a8bf6c3bb6d73" -bucket = "simprint-client" -avatar_root = "avatars" -extension_root = "extensions" -version_root = "versions" - -[smtp] -smtp_server = "smtp.163.com" -smtp_username = "liushuinew@163.com" -smtp_password = "NSgjTyY6uSQwPGwV" - -[workspace_quota] -# 默认配额(新用户注册和手动创建工作空间都使用此配置) -[workspace_quota.default] -max_environments = 99999 -max_team_members = 99999 -max_proxies = 99999 -max_rpa_tasks = 99999 diff --git a/configs/config.prod.toml b/configs/config.prod.toml deleted file mode 100644 index 987e6b3..0000000 --- a/configs/config.prod.toml +++ /dev/null @@ -1,53 +0,0 @@ -[app] -name = "simprint-server" -port = 40041 -secret = "p4y8uX1Jc22K7bAN52arpvJpVbduUvGNNNfo" -prefix = "/api/v1" -encrypt_secret_location = "./assets/secret" -route_whitelists = [ - "POST+/api/v1/users/login", - "POST+/api/v1/users/register", - "POST+/api/v1/users/register-send-code", - "POST+/api/v1/users/reset-password", - "POST+/api/v1/users/reset-password-send-code", - "POST+/api/v1/users/refresh-credentials", - "GET+/api/v1/secret/public/key", - "GET+/api/v1/time/now", - "POST+/api/v1/versions/check" -] -referral_link_prefix = "https://www.simprint.app/download" - - -[database] -url = "postgres://lius:0gMAZGgriRK395Apzcb8@postgresql-pro:5432/simprintdb" -max_connections = 25 -min_connections = 5 -max_lifetime = 3000 -acquire_timeout = 30 -idle_timeout = 600 - -[redis] -url = "redis://redis-server-pro:6379?protocol=resp3" - -[storage] -endpoint = "https://503bc762ec98603e9ff53f374d78df22.r2.cloudflarestorage.com" -public_base_url = "https://pub-39307a5e69c74324855a762027cbf9bf.r2.dev" -access_key = "9295bc8f31a4bc6ce80d0014e29e6896" -secret_access_key = "60a9cd6d3dd6b10ea0f0aaf2411c127bfb01ef8700a62bf8cb3a8bf6c3bb6d73" -bucket = "simprint-client" -avatar_root = "avatars" -extension_root = "extensions" -version_root = "versions" - -[smtp] -smtp_server = "smtp.163.com" -smtp_username = "liushuinew@163.com" -smtp_password = "NSgjTyY6uSQwPGwV" - -[workspace_quota] -# 默认配额(新用户注册和手动创建工作空间都使用此配置) -[workspace_quota.default] -max_environments = 99999 -max_team_members = 99999 -max_proxies = 99999 -max_rpa_tasks = 99999 diff --git a/configs/config.test.toml b/configs/config.test.toml deleted file mode 100644 index 3774292..0000000 --- a/configs/config.test.toml +++ /dev/null @@ -1,51 +0,0 @@ -[app] -name = "simprint-server" -port = 40041 -secret = "p4y8uX1Jc22K7bAN52arpvJpVbduUvGNNNfo" -prefix = "/api/v1" -encrypt_secret_location = "./assets/secret" -route_whitelists = [ - "POST+/api/v2/users/login", - "POST+/api/v2/users/register", - "POST+/api/v2/users/register-send-code", - "POST+/api/v2/users/reset-password", - "POST+/api/v2/users/reset-password-send-code", - "POST+/api/v2/users/refresh-credentials", - "GET+/api/v2/secret/public/key" -] -referral_link_prefix = "https://www.simprint.app/download" - - -[database] -url = "postgres://admin-lius:ZZutdqfPAE4RFo5PYjgA@1.12.233.51:5433/l_g_p_db" -max_connections = 25 -min_connections = 5 -max_lifetime = 3000 -acquire_timeout = 30 -idle_timeout = 600 - -[redis] -url = "redis://1.12.233.51:6380?protocol=resp3" - -[storage] -endpoint = "https://503bc762ec98603e9ff53f374d78df22.r2.cloudflarestorage.com" -public_base_url = "https://pub-39307a5e69c74324855a762027cbf9bf.r2.dev" -access_key = "9295bc8f31a4bc6ce80d0014e29e6896" -secret_access_key = "60a9cd6d3dd6b10ea0f0aaf2411c127bfb01ef8700a62bf8cb3a8bf6c3bb6d73" -bucket = "simprint-client" -avatar_root = "avatars" -extension_root = "extensions" -version_root = "versions" - -[smtp] -smtp_server = "smtp.163.com" -smtp_username = "liushuinew@163.com" -smtp_password = "NSgjTyY6uSQwPGwV" - -[workspace_quota] -# 默认配额(新用户注册和手动创建工作空间都使用此配置) -[workspace_quota.default] -max_environments = 99999 -max_team_members = 99999 -max_proxies = 99999 -max_rpa_tasks = 99999 From 8f749aff0a9305bc4b0a66f8d0486c46925c807d Mon Sep 17 00:00:00 2001 From: lius-new Date: Thu, 14 May 2026 11:30:33 +0800 Subject: [PATCH 40/60] ci: publish ghcr image on version tags --- .github/workflows/publish-ghcr-image.yml | 76 ++++++++++++++++++++++++ Dockerfile | 11 ++-- 2 files changed, 80 insertions(+), 7 deletions(-) create mode 100644 .github/workflows/publish-ghcr-image.yml diff --git a/.github/workflows/publish-ghcr-image.yml b/.github/workflows/publish-ghcr-image.yml new file mode 100644 index 0000000..f2160b4 --- /dev/null +++ b/.github/workflows/publish-ghcr-image.yml @@ -0,0 +1,76 @@ +name: Publish GHCR Image + +on: + push: + tags: + - "v*" + +permissions: + contents: read + packages: write + +jobs: + publish: + name: Build and Publish Image + runs-on: ubuntu-latest + + steps: + - name: Checkout repository + uses: actions/checkout@v4 + + - name: Set image name + run: echo "IMAGE_NAME=ghcr.io/${GITHUB_REPOSITORY_OWNER,,}/simprint-server" >> "$GITHUB_ENV" + + - name: Log in to GitHub Container Registry + uses: docker/login-action@v3 + with: + registry: ghcr.io + username: ${{ github.actor }} + password: ${{ secrets.GITHUB_TOKEN }} + + - name: Extract Docker metadata + id: meta + uses: docker/metadata-action@v5 + with: + images: ${{ env.IMAGE_NAME }} + tags: | + type=ref,event=tag + type=match,pattern=v(.*),group=1 + type=raw,value=latest + type=sha,prefix=sha- + + - name: Build release binary in Debian Bullseye + run: | + docker run --rm \ + -v "$PWD:/workspace" \ + -w /workspace \ + rust:1.85-bullseye \ + bash -lc ' + apt-get update && + apt-get install -y --no-install-recommends pkg-config libssl-dev && + cargo build --release --bin simprint-server + ' + + - name: Prepare Docker build context + run: | + rm -rf .dist/docker-context + mkdir -p .dist/docker-context + cp Dockerfile .dist/docker-context/ + cp docker-compose.yml .dist/docker-context/ + cp .dockerignore .dist/docker-context/ + cp target/release/simprint-server .dist/docker-context/simprint-server + + - name: Set up Docker Buildx + uses: docker/setup-buildx-action@v3 + + - name: Build and push image + uses: docker/build-push-action@v6 + with: + context: .dist/docker-context + file: .dist/docker-context/Dockerfile + push: true + platforms: linux/amd64 + build-args: | + BINARY_NAME=simprint-server + tags: ${{ steps.meta.outputs.tags }} + labels: ${{ steps.meta.outputs.labels }} diff --git a/Dockerfile b/Dockerfile index 7786303..f7dd63b 100644 --- a/Dockerfile +++ b/Dockerfile @@ -21,15 +21,12 @@ WORKDIR /app # 构建参数:指定要复制的二进制文件名 ARG BINARY_NAME -# 从本地根目录复制已编译的二进制文件到固定路径 -COPY --chown=appuser:appuser ${BINARY_NAME} /app/app - -# 复制配置文件目录 -COPY --chown=appuser:appuser configs ./configs +# 从本地根目录复制已编译的二进制文件到固定路径 +COPY --chown=appuser:appuser ${BINARY_NAME} /app/app # 设置文件权限 -RUN mkdir -p /app/assets/secret \ - && chown -R appuser:appuser /app/assets \ +RUN mkdir -p /app/assets/secret /app/configs \ + && chown -R appuser:appuser /app/assets /app/configs \ && chmod +x /app/app # 切换到非 root 用户 From 5b7faae4d76f979e25100b372b1cb9c13e4fe990 Mon Sep 17 00:00:00 2001 From: lius-new Date: Thu, 14 May 2026 11:41:38 +0800 Subject: [PATCH 41/60] ci: fix ghcr image build environment --- .github/workflows/publish-ghcr-image.yml | 4 +++- 1 file changed, 3 insertions(+), 1 deletion(-) diff --git a/.github/workflows/publish-ghcr-image.yml b/.github/workflows/publish-ghcr-image.yml index f2160b4..565ed3f 100644 --- a/.github/workflows/publish-ghcr-image.yml +++ b/.github/workflows/publish-ghcr-image.yml @@ -45,9 +45,11 @@ jobs: -v "$PWD:/workspace" \ -w /workspace \ rust:1.85-bullseye \ - bash -lc ' + bash -c ' + export PATH="/usr/local/cargo/bin:/usr/local/rustup/bin:$PATH" && apt-get update && apt-get install -y --no-install-recommends pkg-config libssl-dev && + cargo --version && cargo build --release --bin simprint-server ' From 55588af3dc9c1b3b56515d50b41a02df96a4fe6f Mon Sep 17 00:00:00 2001 From: lius-new Date: Thu, 14 May 2026 12:17:36 +0800 Subject: [PATCH 42/60] ci: upgrade rust toolchain for ghcr build --- .github/workflows/publish-ghcr-image.yml | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/.github/workflows/publish-ghcr-image.yml b/.github/workflows/publish-ghcr-image.yml index 565ed3f..41de940 100644 --- a/.github/workflows/publish-ghcr-image.yml +++ b/.github/workflows/publish-ghcr-image.yml @@ -44,7 +44,7 @@ jobs: docker run --rm \ -v "$PWD:/workspace" \ -w /workspace \ - rust:1.85-bullseye \ + rust:1.88-bullseye \ bash -c ' export PATH="/usr/local/cargo/bin:/usr/local/rustup/bin:$PATH" && apt-get update && From 54c9b064e928438274f6b61cfe0b32c82a01bb4e Mon Sep 17 00:00:00 2001 From: lius-new Date: Fri, 15 May 2026 15:58:04 +0800 Subject: [PATCH 43/60] fix: remove stale environment extension cleanup --- src/models/environments.rs | 12 ++++-------- 1 file changed, 4 insertions(+), 8 deletions(-) diff --git a/src/models/environments.rs b/src/models/environments.rs index dea4e9f..fb934b3 100644 --- a/src/models/environments.rs +++ b/src/models/environments.rs @@ -876,10 +876,8 @@ pub async fn permanent_delete_environment( .execute(pool) .await?; - sqlx::query("DELETE FROM environment_extensions WHERE environment_uuid = $1") - .bind(env_uuid) - .execute(pool) - .await?; + // `environment_extensions` 已在扩展系统重构后移除。 + // 环境的扩展现在通过 user/team/group 绑定动态合并,不再需要清理环境级关联。 // 最后删除环境本身 sqlx::query("DELETE FROM environments WHERE uuid = $1") @@ -921,10 +919,8 @@ pub async fn batch_permanent_delete_environments( .execute(pool) .await?; - sqlx::query("DELETE FROM environment_extensions WHERE environment_uuid = ANY($1)") - .bind(env_uuids) - .execute(pool) - .await?; + // `environment_extensions` 已在扩展系统重构后移除。 + // 环境的扩展现在通过 user/team/group 绑定动态合并,不再需要清理环境级关联。 // 最后删除环境本身 let result = sqlx::query("DELETE FROM environments WHERE uuid = ANY($1)") From a62acd4f6c8963855988c584c33d302fbca9c5d9 Mon Sep 17 00:00:00 2001 From: lius-new Date: Fri, 15 May 2026 21:22:10 +0800 Subject: [PATCH 44/60] fix: keep workspace and team context in sync --- src/handlers/workspaces.rs | 5 ++--- src/models/user.rs | 22 ++++++++++++++++++++++ src/services/workspaces.rs | 37 ++++++++++++++++++++++++++++++++++--- 3 files changed, 58 insertions(+), 6 deletions(-) diff --git a/src/handlers/workspaces.rs b/src/handlers/workspaces.rs index f14f14b..2d22ce5 100644 --- a/src/handlers/workspaces.rs +++ b/src/handlers/workspaces.rs @@ -171,10 +171,9 @@ pub async fn switch_workspace_handler( return Err(Response::fail(Some("您不是该工作空间的所有者"))); } - // 更新用户当前工作空间 - crate::models::user::set_user_current_workspace(&svc_ctx.db, user_uuid, payload.workspace_uuid) + services::workspaces::switch_workspace_service(&svc_ctx, payload.workspace_uuid, user_uuid) .await - .map_err(|e| Response::fail(Some(&e.to_string())))?; + .map_err(|e| Response::fail(Some(&e)))?; Ok(Response::success(Some("切换成功"), None)) } diff --git a/src/models/user.rs b/src/models/user.rs index f7d75ae..28d5e60 100644 --- a/src/models/user.rs +++ b/src/models/user.rs @@ -507,3 +507,25 @@ pub async fn set_user_current_workspace( Ok(()) } + +pub async fn set_user_current_workspace_and_team( + pool: &Pool, + user_uuid: Uuid, + workspace_uuid: Uuid, + team_uuid: Uuid, +) -> Result<(), Error> { + sqlx::query( + r#" + UPDATE user_infos + SET current_workspace_uuid = $1, current_team_uuid = $2 + WHERE user_uuid = $3 + "#, + ) + .bind(workspace_uuid) + .bind(team_uuid) + .bind(user_uuid) + .execute(pool) + .await?; + + Ok(()) +} diff --git a/src/services/workspaces.rs b/src/services/workspaces.rs index bb0fac5..1abc013 100644 --- a/src/services/workspaces.rs +++ b/src/services/workspaces.rs @@ -57,12 +57,12 @@ pub async fn create_workspace_service( name: team_name, description: Some("默认团队".to_string()), }; - models::insert_team(&svc_ctx.db, user_uuid, &team_request) + let team_uuid = models::insert_team(&svc_ctx.db, user_uuid, &team_request) .await .map_err(|e| e.to_string())?; - // 设置用户当前工作空间(与注册时的逻辑保持一致) - models::user::set_user_current_workspace(&svc_ctx.db, user_uuid, workspace_uuid) + // 设置用户当前工作空间和团队,确保上下文始终一致。 + models::user::set_user_current_workspace_and_team(&svc_ctx.db, user_uuid, workspace_uuid, team_uuid) .await .map_err(|e| e.to_string())?; @@ -127,6 +127,14 @@ pub async fn delete_workspace_service( return Err("只有工作空间所有者可以删除".to_string()); } + let current_workspace_uuid = models::user::fetch_user_current_workspace(&svc_ctx.db, user_uuid) + .await + .map_err(|e| e.to_string())?; + + if current_workspace_uuid == Some(workspace_uuid) { + return Err("不能删除当前正在使用的工作空间,请先切换到其他工作空间".to_string()); + } + // 检查用户是否只有一个工作空间,如果是则不允许删除 let user_workspaces = models::fetch_user_workspaces(&svc_ctx.db, user_uuid) .await @@ -151,3 +159,26 @@ pub async fn check_workspace_owner_service( .await .map_err(|e| e.to_string()) } + +pub async fn switch_workspace_service( + svc_ctx: &SvcCtx, + workspace_uuid: Uuid, + user_uuid: Uuid, +) -> Result<(), String> { + let teams = models::fetch_user_teams(&svc_ctx.db, workspace_uuid, user_uuid) + .await + .map_err(|e| e.to_string())?; + + let current_team_uuid = models::fetch_user_current_team(&svc_ctx.db, user_uuid) + .await + .map_err(|e| e.to_string())?; + + let team_uuid = current_team_uuid + .filter(|current| teams.iter().any(|team| team.uuid == *current)) + .or_else(|| teams.first().map(|team| team.uuid)) + .ok_or_else(|| "该工作空间下没有可用团队".to_string())?; + + models::user::set_user_current_workspace_and_team(&svc_ctx.db, user_uuid, workspace_uuid, team_uuid) + .await + .map_err(|e| e.to_string()) +} From 4901014e811c65d194d75737a69163024898d3e5 Mon Sep 17 00:00:00 2001 From: lius-new Date: Wed, 12 Aug 2026 10:38:49 +0800 Subject: [PATCH 45/60] refactor(server): expose reusable service runner Move route registration and service startup into the library so the desktop application can embed the server on a loopback address with graceful shutdown. Fix secret tests to resolve fixtures from the crate root. --- server/src/app.rs | 116 +++++++++++++++++++++++++++++++++ server/src/lib.rs | 3 + server/src/main.rs | 107 ++---------------------------- server/src/utils/secret/aes.rs | 5 +- server/src/utils/secret/rsa.rs | 3 +- 5 files changed, 131 insertions(+), 103 deletions(-) create mode 100644 server/src/app.rs diff --git a/server/src/app.rs b/server/src/app.rs new file mode 100644 index 0000000..1da9de3 --- /dev/null +++ b/server/src/app.rs @@ -0,0 +1,116 @@ +use std::{future::Future, net::SocketAddr}; + +use axum::{Router, middleware}; + +use crate::{ + init_encrypt_secret, init_storage, middlewares, + routes::route::MetaRoute, + routes::{ + accounts, audit, billing, browser_kernel, environments, extensions, group_permissions, + local_api, messages, preferences, proxies, proxy_visibility, referral, rpa, secret, teams, + templates, time, users, workspace_quotas, workspaces, + }, + svc_ctx::SvcCtx, + utils::IConfig, +}; + +static MIGRATOR: sqlx::migrate::Migrator = sqlx::migrate!(); + +/// Start the service using its configured port on every network interface. +/// +/// This preserves the original standalone server behavior. Embedded callers +/// should prefer [`serve_on`] or [`serve_on_with_shutdown`] and bind to a +/// loopback address explicitly. +pub async fn serve(config: IConfig) -> anyhow::Result<()> { + let address = SocketAddr::from(([0, 0, 0, 0], config.app.port)); + serve_on(config, address).await +} + +/// Start the service on an explicit address. +pub async fn serve_on(config: IConfig, address: SocketAddr) -> anyhow::Result<()> { + serve_on_with_shutdown(config, address, std::future::pending()).await +} + +/// Start the service on an explicit address and stop it gracefully when the +/// supplied shutdown future completes. +pub async fn serve_on_with_shutdown( + config: IConfig, + address: SocketAddr, + shutdown: F, +) -> anyhow::Result<()> +where + F: Future + Send + 'static, +{ + let db = SvcCtx::create_db(&config.database).await?; + tracing::info!("Running embedded database migrations"); + MIGRATOR.run(&db).await?; + tracing::info!("Embedded database migrations completed"); + + let svc_ctx = SvcCtx::new(&config).await?; + + init_encrypt_secret(&config).await; + init_storage(&config).await; + + let listener = tokio::net::TcpListener::bind(address).await?; + let bound_address = listener.local_addr()?; + + let app = register_all_routes(&svc_ctx); + let app = register_middlewares(&svc_ctx, app); + let app = app.with_state(svc_ctx); + + tracing::info!("Starting server on {}", bound_address); + + axum::serve(listener, app).with_graceful_shutdown(shutdown).await?; + Ok(()) +} + +fn register_all_routes(svc_ctx: &SvcCtx) -> Router { + let mut meta_route = MetaRoute::new(svc_ctx.config.app.prefix.clone()); + + secret::register_routes(&mut meta_route); + time::register_routes(&mut meta_route); + users::register_routes(&mut meta_route); + local_api::register_routes(&mut meta_route); + workspaces::register_routes(&mut meta_route); + workspace_quotas::register_routes(&mut meta_route); + teams::register_routes(&mut meta_route); + browser_kernel::register_routes(&mut meta_route); + environments::register_routes(&mut meta_route); + proxies::register_routes(&mut meta_route); + proxy_visibility::register_routes(&mut meta_route); + group_permissions::register_routes(&mut meta_route); + accounts::register_routes(&mut meta_route); + templates::register_routes(&mut meta_route); + billing::register_routes(&mut meta_route); + audit::register_routes(&mut meta_route); + rpa::register_routes(&mut meta_route); + referral::register_routes(&mut meta_route); + extensions::register_routes(&mut meta_route); + preferences::register_routes(&mut meta_route); + messages::register_routes(&mut meta_route); + + tracing::info!("---------- {:?} ----------", meta_route.count()); + meta_route.build() +} + +fn register_middlewares(svc_ctx: &SvcCtx, app: Router) -> Router { + app.route_layer(middleware::from_fn_with_state( + svc_ctx.clone(), + middlewares::encrypt, + )) + .route_layer(middleware::from_fn_with_state( + svc_ctx.clone(), + middlewares::auth, + )) + .route_layer(middleware::from_fn_with_state( + svc_ctx.clone(), + middlewares::local_api_auth, + )) + .route_layer(middleware::from_fn_with_state( + svc_ctx.clone(), + middlewares::decrypt, + )) + .route_layer(middleware::from_fn(middlewares::real_ip)) + .route_layer(middleware::from_fn(middlewares::logger)) + .layer(middlewares::cors()) +} diff --git a/server/src/lib.rs b/server/src/lib.rs index dd7aa74..c29db91 100644 --- a/server/src/lib.rs +++ b/server/src/lib.rs @@ -1,5 +1,6 @@ use crate::utils::IConfig; +pub mod app; pub mod caches; pub mod cli; pub mod dto; @@ -14,6 +15,8 @@ pub mod state; pub mod svc_ctx; pub mod utils; +pub use app::{serve, serve_on, serve_on_with_shutdown}; + /// 初始化加密密钥 pub async fn init_encrypt_secret(config: &IConfig) { let key_path = &config.app.encrypt_secret_location; diff --git a/server/src/main.rs b/server/src/main.rs index a0bf301..12d75c1 100644 --- a/server/src/main.rs +++ b/server/src/main.rs @@ -1,119 +1,26 @@ -use axum::{Router, middleware}; use clap::Parser; use simprint_server::{ cli::{Cli, Commands}, - middlewares, - routes::route::MetaRoute, - routes::{ - accounts, audit, billing, browser_kernel, environments, extensions, group_permissions, - local_api, messages, preferences, proxies, proxy_visibility, referral, rpa, secret, teams, - templates, time, users, workspace_quotas, workspaces, - }, - svc_ctx::SvcCtx, + serve, utils::IConfig, }; -use simprint_server::{init_encrypt_secret, init_storage}; - -static MIGRATOR: sqlx::migrate::Migrator = sqlx::migrate!(); #[tokio::main] -async fn main() -> Result<(), Box> { +async fn main() -> anyhow::Result<()> { let cli = Cli::parse(); let (config_path, command) = cli.command_or_default(); let config = IConfig::build_by_filepath(&config_path).expect("failed to build config"); - match command { - Commands::Serve => run_server(config).await?, - } - - Ok(()) -} - -async fn run_server(config: IConfig) -> Result<(), Box> { - // 启用更详细的日志以调试中间件问题 tracing_subscriber::fmt() .with_max_level(tracing::Level::INFO) .with_target(true) .with_thread_ids(true) - .init(); - - let db = SvcCtx::create_db(&config.database).await?; - tracing::info!("Running embedded database migrations"); - MIGRATOR.run(&db).await?; - tracing::info!("Embedded database migrations completed"); - - let svc_ctx = SvcCtx::new(&config).await?; - - // init resources - init_encrypt_secret(&config).await; - init_storage(&config).await; + .try_init() + .map_err(|error| anyhow::anyhow!(error.to_string()))?; - let port = &config.app.port; - let listener = tokio::net::TcpListener::bind(format!("0.0.0.0:{}", port)) - .await - .expect("Failed to create listener"); - - let app = register_all_routes(&svc_ctx); - let app = register_middlewares(&svc_ctx, app); - let app = app.with_state(svc_ctx); - - tracing::info!("Starting server on port {}", port); + match command { + Commands::Serve => serve(config).await?, + } - axum::serve(listener, app).await?; Ok(()) } -/// 注册所有路由 -fn register_all_routes(svc_ctx: &SvcCtx) -> Router { - let mut meta_route = MetaRoute::new(svc_ctx.config.app.prefix.clone()); - - secret::register_routes(&mut meta_route); - time::register_routes(&mut meta_route); - users::register_routes(&mut meta_route); - local_api::register_routes(&mut meta_route); - - // 新增路由 - workspaces::register_routes(&mut meta_route); - workspace_quotas::register_routes(&mut meta_route); - teams::register_routes(&mut meta_route); - browser_kernel::register_routes(&mut meta_route); - environments::register_routes(&mut meta_route); - proxies::register_routes(&mut meta_route); - proxy_visibility::register_routes(&mut meta_route); - group_permissions::register_routes(&mut meta_route); - accounts::register_routes(&mut meta_route); - templates::register_routes(&mut meta_route); - billing::register_routes(&mut meta_route); - audit::register_routes(&mut meta_route); - rpa::register_routes(&mut meta_route); - referral::register_routes(&mut meta_route); - extensions::register_routes(&mut meta_route); - preferences::register_routes(&mut meta_route); - messages::register_routes(&mut meta_route); - - tracing::info!("---------- {:?} ----------", meta_route.count()); - meta_route.build() -} - -/// 注册中间件 -/// 中间件顺序保持与原 routes.rs 一致 -fn register_middlewares(svc_ctx: &SvcCtx, app: Router) -> Router { - app.route_layer(middleware::from_fn_with_state( - svc_ctx.clone(), - middlewares::encrypt, - )) - .route_layer(middleware::from_fn_with_state( - svc_ctx.clone(), - middlewares::auth, - )) - .route_layer(middleware::from_fn_with_state( - svc_ctx.clone(), - middlewares::local_api_auth, - )) - .route_layer(middleware::from_fn_with_state( - svc_ctx.clone(), - middlewares::decrypt, - )) - .route_layer(middleware::from_fn(middlewares::real_ip)) - .route_layer(middleware::from_fn(middlewares::logger)) - .layer(middlewares::cors()) -} diff --git a/server/src/utils/secret/aes.rs b/server/src/utils/secret/aes.rs index 6191103..23174a9 100644 --- a/server/src/utils/secret/aes.rs +++ b/server/src/utils/secret/aes.rs @@ -183,8 +183,9 @@ mod tests { let encrypted = secret.encrypt(&json_bytes).expect("Encryption failed"); // 下面连续的代码对应: crate::secret::rsa::get_rsa_secret_instance() - let public_key_str = - std::fs::read("../../assets/secret/public_key.pem").expect("读取公钥失败"); + let public_key_path = + std::path::Path::new(env!("CARGO_MANIFEST_DIR")).join("assets/secret/public_key.pem"); + let public_key_str = std::fs::read(public_key_path).expect("读取公钥失败"); let public_key = String::from_utf8(public_key_str).expect("转换公钥失败"); let public_key = rsa::RsaPublicKey::from_pkcs1_pem(&public_key) .map_err(|e| { diff --git a/server/src/utils/secret/rsa.rs b/server/src/utils/secret/rsa.rs index 907236f..5c82c43 100644 --- a/server/src/utils/secret/rsa.rs +++ b/server/src/utils/secret/rsa.rs @@ -151,7 +151,8 @@ mod tests { age: u32, } - init_rsa_secret("../../assets/secret").await; + let secret_path = std::path::Path::new(env!("CARGO_MANIFEST_DIR")).join("assets/secret"); + init_rsa_secret(secret_path.to_str().expect("invalid secret path")).await; let secret_instance = get_rsa_secret_instance(); From c6142eaaf0e425231634a3f03fe46bd03681d065 Mon Sep 17 00:00:00 2001 From: lius-new Date: Wed, 12 Aug 2026 10:42:43 +0800 Subject: [PATCH 46/60] fix(server): avoid recursive response formatting Format response metadata directly and add a regression test so Display can no longer recurse until stack overflow. --- server/src/utils/responses.rs | 24 +++++++++++++++++++++++- 1 file changed, 23 insertions(+), 1 deletion(-) diff --git a/server/src/utils/responses.rs b/server/src/utils/responses.rs index 24c2239..065b83d 100644 --- a/server/src/utils/responses.rs +++ b/server/src/utils/responses.rs @@ -63,7 +63,14 @@ impl Response { // 允许直接打印和to_string impl Display for Response { fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result { - write!(f, "{}", self) + write!( + f, + "Response {{ status_code: {}, code: {}, message: {:?}, has_data: {} }}", + self.status_code, + self.code, + self.message, + self.data.is_some() + ) } } @@ -102,3 +109,18 @@ impl From for Response<()> { ) } } + +#[cfg(test)] +mod tests { + use super::Response; + + #[test] + fn display_response_without_recursion() { + let response = Response::success(Some("ok"), Some(42)); + + assert_eq!( + response.to_string(), + "Response { status_code: 200, code: 1, message: Some(\"ok\"), has_data: true }" + ); + } +} From 967e5f610e38f3cf4a0e0a71223a4f3b751ef3fd Mon Sep 17 00:00:00 2001 From: lius-new Date: Wed, 12 Aug 2026 11:00:57 +0800 Subject: [PATCH 47/60] refactor(server): support local cache and storage Use an in-process TTL cache when Redis is omitted, add a local filesystem object storage backend with traversal protection, and expose local assets without API authentication. Preserve Redis and S3 compatibility. --- server/.gitignore | 2 +- server/configs/config.local.example.toml | 45 ++++ server/src/app.rs | 8 +- server/src/caches/local_api.rs | 106 ++++----- server/src/caches/mod.rs | 2 + server/src/caches/store.rs | 272 +++++++++++++++++++++++ server/src/caches/user.rs | 29 +-- server/src/lib.rs | 14 +- server/src/svc_ctx.rs | 38 ++-- server/src/utils/config.rs | 45 +++- server/src/utils/storage.rs | 201 +++++++++++------ server/src/utils/storage/put_objects.rs | 23 +- 12 files changed, 582 insertions(+), 203 deletions(-) create mode 100644 server/configs/config.local.example.toml create mode 100644 server/src/caches/store.rs diff --git a/server/.gitignore b/server/.gitignore index 2fef621..53141d7 100644 --- a/server/.gitignore +++ b/server/.gitignore @@ -35,5 +35,5 @@ simprint-server-docker-*.tar.gz # 配置文件:仅保留示例配置可提交 configs/* -!configs/config.example.toml +!configs/*.example.toml diff --git a/server/configs/config.local.example.toml b/server/configs/config.local.example.toml new file mode 100644 index 0000000..704577b --- /dev/null +++ b/server/configs/config.local.example.toml @@ -0,0 +1,45 @@ +[app] +name = "simprint-server-local" +port = 40041 +secret = "change-this-to-a-long-random-secret" +prefix = "/api/v1" +encrypt_secret_location = "./assets/secret" +route_whitelists = [ + "POST+/api/v1/users/login", + "POST+/api/v1/users/register", + "POST+/api/v1/users/register-send-code", + "POST+/api/v1/users/reset-password", + "POST+/api/v1/users/reset-password-send-code", + "POST+/api/v1/users/refresh-credentials", + "GET+/api/v1/secret/public/key", + "GET+/api/v1/time/now", + "POST+/api/v1/versions/check", +] +referral_link_prefix = "http://127.0.0.1:40041/download" + +# PostgreSQL remains the only external dependency during this migration stage. +[database] +url = "postgres://simprint:change-me@127.0.0.1:5432/simprintdb" +max_connections = 10 +min_connections = 1 +max_lifetime = 3000 +acquire_timeout = 30 +idle_timeout = 600 + +# Omitting [redis] selects the in-process TTL cache. + +[storage] +backend = "local" +local_path = "./data/storage" +public_base_url = "http://127.0.0.1:40041/assets/simprint-client" +bucket = "simprint-client" +avatar_root = "avatars" +extension_root = "extensions" +version_root = "versions" + +[workspace_quota] +[workspace_quota.default] +max_environments = 99999 +max_team_members = 99999 +max_proxies = 99999 +max_rpa_tasks = 99999 diff --git a/server/src/app.rs b/server/src/app.rs index 1da9de3..a8ea9ac 100644 --- a/server/src/app.rs +++ b/server/src/app.rs @@ -1,6 +1,7 @@ use std::{future::Future, net::SocketAddr}; use axum::{Router, middleware}; +use tower_http::services::ServeDir; use crate::{ init_encrypt_secret, init_storage, middlewares, @@ -11,7 +12,7 @@ use crate::{ templates, time, users, workspace_quotas, workspaces, }, svc_ctx::SvcCtx, - utils::IConfig, + utils::{IConfig, StorageBackend}, }; static MIGRATOR: sqlx::migrate::Migrator = sqlx::migrate!(); @@ -57,6 +58,11 @@ where let app = register_all_routes(&svc_ctx); let app = register_middlewares(&svc_ctx, app); let app = app.with_state(svc_ctx); + let app = if config.storage.backend == StorageBackend::Local { + app.nest_service("/assets", ServeDir::new(config.storage.local_root())) + } else { + app + }; tracing::info!("Starting server on {}", bound_address); diff --git a/server/src/caches/local_api.rs b/server/src/caches/local_api.rs index 334e1b7..1f71b35 100644 --- a/server/src/caches/local_api.rs +++ b/server/src/caches/local_api.rs @@ -1,6 +1,6 @@ use chrono::Utc; -use redis::{AsyncCommands, cmd}; use serde::{Deserialize, Serialize}; +use std::time::Duration; use uuid::Uuid; use crate::dto::LocalApiPermissionDefinitionDto; @@ -74,11 +74,7 @@ pub async fn get_local_api_key_cache( key_hash: &str, ) -> Result, anyhow::Error> { let key = local_api_key_cache_key(key_hash); - let value: Option = svc_ctx.redis.clone().get(key).await?; - let value = value - .map(|payload| serde_json::from_str::(&payload)) - .transpose()?; - Ok(value) + svc_ctx.cache.get_json(&key).await } pub async fn set_local_api_key_cache( @@ -87,13 +83,10 @@ pub async fn set_local_api_key_cache( cache: &LocalApiKeyCache, ) -> Result<(), anyhow::Error> { let key = local_api_key_cache_key(key_hash); - let value = serde_json::to_string(cache)?; - let _: () = svc_ctx - .redis - .clone() - .set_ex(key, value, LOCAL_API_KEY_CACHE_TTL) - .await?; - Ok(()) + svc_ctx + .cache + .set_json(key, cache, Duration::from_secs(LOCAL_API_KEY_CACHE_TTL)) + .await } pub async fn delete_local_api_key_cache( @@ -101,8 +94,7 @@ pub async fn delete_local_api_key_cache( key_hash: &str, ) -> Result<(), anyhow::Error> { let key = local_api_key_cache_key(key_hash); - let _: usize = svc_ctx.redis.clone().del(key).await?; - Ok(()) + svc_ctx.cache.delete(&key).await } pub async fn get_local_api_permission_cache( @@ -111,11 +103,7 @@ pub async fn get_local_api_permission_cache( permission_code: &str, ) -> Result, anyhow::Error> { let key = local_api_permission_cache_key(api_key_id, permission_code); - let value: Option = svc_ctx.redis.clone().get(key).await?; - let value = value - .map(|payload| serde_json::from_str::(&payload)) - .transpose()?; - Ok(value) + svc_ctx.cache.get_json(&key).await } pub async fn set_local_api_permission_cache( @@ -125,13 +113,14 @@ pub async fn set_local_api_permission_cache( cache: &LocalApiPermissionCache, ) -> Result<(), anyhow::Error> { let key = local_api_permission_cache_key(api_key_id, permission_code); - let value = serde_json::to_string(cache)?; - let _: () = svc_ctx - .redis - .clone() - .set_ex(key, value, LOCAL_API_PERMISSION_CACHE_TTL) - .await?; - Ok(()) + svc_ctx + .cache + .set_json( + key, + cache, + Duration::from_secs(LOCAL_API_PERMISSION_CACHE_TTL), + ) + .await } pub async fn delete_local_api_permission_cache( @@ -140,23 +129,15 @@ pub async fn delete_local_api_permission_cache( permission_code: &str, ) -> Result<(), anyhow::Error> { let key = local_api_permission_cache_key(api_key_id, permission_code); - let _: usize = svc_ctx.redis.clone().del(key).await?; - Ok(()) + svc_ctx.cache.delete(&key).await } pub async fn delete_local_api_permission_caches_for_key( svc_ctx: &SvcCtx, api_key_id: i32, ) -> Result<(), anyhow::Error> { - let pattern = format!("{}:{}:*", LOCAL_API_PERMISSION_CACHE_PREFIX, api_key_id); - let keys: Vec = cmd("KEYS") - .arg(&pattern) - .query_async(&mut svc_ctx.redis.clone()) - .await?; - if !keys.is_empty() { - let _: usize = svc_ctx.redis.clone().del(keys).await?; - } - Ok(()) + let prefix = format!("{}:{}:", LOCAL_API_PERMISSION_CACHE_PREFIX, api_key_id); + svc_ctx.cache.delete_prefix(&prefix).await } pub async fn get_local_api_permission_definition_cache( @@ -164,11 +145,7 @@ pub async fn get_local_api_permission_definition_cache( permission_code: &str, ) -> Result, anyhow::Error> { let key = local_api_permission_definition_cache_key(permission_code); - let value: Option = svc_ctx.redis.clone().get(key).await?; - let value = value - .map(|payload| serde_json::from_str::(&payload)) - .transpose()?; - Ok(value) + svc_ctx.cache.get_json(&key).await } pub async fn set_local_api_permission_definition_cache( @@ -177,13 +154,14 @@ pub async fn set_local_api_permission_definition_cache( cache: &LocalApiPermissionDefinitionDto, ) -> Result<(), anyhow::Error> { let key = local_api_permission_definition_cache_key(permission_code); - let value = serde_json::to_string(cache)?; - let _: () = svc_ctx - .redis - .clone() - .set_ex(key, value, LOCAL_API_PERMISSION_CACHE_TTL) - .await?; - Ok(()) + svc_ctx + .cache + .set_json( + key, + cache, + Duration::from_secs(LOCAL_API_PERMISSION_CACHE_TTL), + ) + .await } pub async fn delete_local_api_permission_definition_cache( @@ -191,8 +169,7 @@ pub async fn delete_local_api_permission_definition_cache( permission_code: &str, ) -> Result<(), anyhow::Error> { let key = local_api_permission_definition_cache_key(permission_code); - let _: usize = svc_ctx.redis.clone().del(key).await?; - Ok(()) + svc_ctx.cache.delete(&key).await } pub async fn get_local_api_rate_count( @@ -203,8 +180,7 @@ pub async fn get_local_api_rate_count( window_key: &str, ) -> Result { let key = local_api_rate_cache_key(api_key_id, permission_code, window_type, window_key); - let value: Option = svc_ctx.redis.clone().get(key).await?; - Ok(value.unwrap_or(0)) + Ok(svc_ctx.cache.get_i64(&key).await?.unwrap_or(0)) } pub async fn increment_local_api_rate_count( @@ -215,16 +191,14 @@ pub async fn increment_local_api_rate_count( window_key: &str, ) -> Result { let key = local_api_rate_cache_key(api_key_id, permission_code, window_type, window_key); - let mut redis = svc_ctx.redis.clone(); - let count: i64 = redis.incr(&key, 1).await?; - if count == 1 { - let ttl = match window_type { - "minute" => MINUTE_WINDOW_SECONDS, - "hour" => HOUR_WINDOW_SECONDS, - "day" => DAY_WINDOW_SECONDS, - _ => DAY_WINDOW_SECONDS, - }; - let _: bool = redis.expire(&key, ttl).await?; - } - Ok(count) + let ttl = match window_type { + "minute" => MINUTE_WINDOW_SECONDS, + "hour" => HOUR_WINDOW_SECONDS, + "day" => DAY_WINDOW_SECONDS, + _ => DAY_WINDOW_SECONDS, + }; + svc_ctx + .cache + .increment(&key, Duration::from_secs(ttl as u64)) + .await } diff --git a/server/src/caches/mod.rs b/server/src/caches/mod.rs index c9df650..f0165b0 100644 --- a/server/src/caches/mod.rs +++ b/server/src/caches/mod.rs @@ -1,5 +1,7 @@ pub mod local_api; +pub mod store; pub mod user; pub use local_api::*; +pub use store::*; pub use user::*; diff --git a/server/src/caches/store.rs b/server/src/caches/store.rs new file mode 100644 index 0000000..6ec13b1 --- /dev/null +++ b/server/src/caches/store.rs @@ -0,0 +1,272 @@ +use std::{ + sync::{ + Arc, + atomic::{AtomicUsize, Ordering}, + }, + time::{Duration, Instant}, +}; + +use dashmap::{DashMap, mapref::entry::Entry}; +use redis::{AsyncCommands, aio::ConnectionManager, cmd}; +use serde::{Serialize, de::DeserializeOwned}; + +#[derive(Clone)] +pub enum CacheStore { + Memory(Arc), + Redis(ConnectionManager), +} + +#[derive(Default)] +pub struct MemoryCache { + entries: DashMap, + operations: AtomicUsize, +} + +struct CacheEntry { + value: String, + expires_at: Option, +} + +impl CacheEntry { + fn is_expired(&self, now: Instant) -> bool { + self.expires_at.is_some_and(|expires_at| expires_at <= now) + } +} + +impl CacheStore { + pub fn memory() -> Self { + Self::Memory(Arc::new(MemoryCache::default())) + } + + pub async fn redis(url: &str) -> Result { + let client = redis::Client::open(url)?; + let connection = ConnectionManager::new(client).await?; + Ok(Self::Redis(connection)) + } + + pub async fn get_string(&self, key: &str) -> Result, anyhow::Error> { + match self { + Self::Memory(cache) => Ok(cache.get(key)), + Self::Redis(connection) => { + let mut connection = connection.clone(); + Ok(connection.get(key).await?) + } + } + } + + pub async fn set_string( + &self, + key: impl Into, + value: impl Into, + ttl: Duration, + ) -> Result<(), anyhow::Error> { + let key = key.into(); + let value = value.into(); + match self { + Self::Memory(cache) => cache.set(key, value, ttl), + Self::Redis(connection) => { + let mut connection = connection.clone(); + let _: () = connection.set_ex(key, value, ttl.as_secs()).await?; + } + } + Ok(()) + } + + pub async fn get_json(&self, key: &str) -> Result, anyhow::Error> + where + T: DeserializeOwned, + { + self.get_string(key) + .await? + .map(|value| serde_json::from_str(&value)) + .transpose() + .map_err(Into::into) + } + + pub async fn set_json( + &self, + key: impl Into, + value: &T, + ttl: Duration, + ) -> Result<(), anyhow::Error> + where + T: Serialize, + { + self.set_string(key, serde_json::to_string(value)?, ttl).await + } + + pub async fn delete(&self, key: &str) -> Result<(), anyhow::Error> { + match self { + Self::Memory(cache) => { + cache.entries.remove(key); + } + Self::Redis(connection) => { + let mut connection = connection.clone(); + let _: usize = connection.del(key).await?; + } + } + Ok(()) + } + + pub async fn delete_prefix(&self, prefix: &str) -> Result<(), anyhow::Error> { + match self { + Self::Memory(cache) => cache.delete_prefix(prefix), + Self::Redis(connection) => { + let mut connection = connection.clone(); + let pattern = format!("{prefix}*"); + let keys: Vec = + cmd("KEYS").arg(pattern).query_async(&mut connection).await?; + if !keys.is_empty() { + let _: usize = connection.del(keys).await?; + } + } + } + Ok(()) + } + + pub async fn get_i64(&self, key: &str) -> Result, anyhow::Error> { + self.get_string(key) + .await? + .map(|value| value.parse::()) + .transpose() + .map_err(Into::into) + } + + pub async fn increment(&self, key: &str, ttl: Duration) -> Result { + match self { + Self::Memory(cache) => Ok(cache.increment(key, ttl)?), + Self::Redis(connection) => { + let mut connection = connection.clone(); + let count: i64 = connection.incr(key, 1).await?; + if count == 1 { + let _: bool = connection.expire(key, ttl.as_secs() as i64).await?; + } + Ok(count) + } + } + } +} + +impl MemoryCache { + fn get(&self, key: &str) -> Option { + self.maybe_prune(); + let now = Instant::now(); + let value = self.entries.get(key).and_then(|entry| { + if entry.is_expired(now) { + None + } else { + Some(entry.value.clone()) + } + }); + + if value.is_none() { + self.entries.remove_if(key, |_, entry| entry.is_expired(now)); + } + value + } + + fn set(&self, key: String, value: String, ttl: Duration) { + self.maybe_prune(); + self.entries.insert( + key, + CacheEntry { + value, + expires_at: Some(Instant::now() + ttl), + }, + ); + } + + fn delete_prefix(&self, prefix: &str) { + let keys = self + .entries + .iter() + .filter(|entry| entry.key().starts_with(prefix)) + .map(|entry| entry.key().clone()) + .collect::>(); + for key in keys { + self.entries.remove(&key); + } + } + + fn increment(&self, key: &str, ttl: Duration) -> Result { + self.maybe_prune(); + let now = Instant::now(); + match self.entries.entry(key.to_string()) { + Entry::Occupied(mut entry) if !entry.get().is_expired(now) => { + let count = entry.get().value.parse::()? + 1; + entry.get_mut().value = count.to_string(); + Ok(count) + } + Entry::Occupied(mut entry) => { + entry.insert(CacheEntry { + value: "1".to_string(), + expires_at: Some(now + ttl), + }); + Ok(1) + } + Entry::Vacant(entry) => { + entry.insert(CacheEntry { + value: "1".to_string(), + expires_at: Some(now + ttl), + }); + Ok(1) + } + } + } + + fn maybe_prune(&self) { + const PRUNE_INTERVAL: usize = 256; + if self.operations.fetch_add(1, Ordering::Relaxed) % PRUNE_INTERVAL != 0 { + return; + } + + let now = Instant::now(); + self.entries.retain(|_, entry| !entry.is_expired(now)); + } +} + +#[cfg(test)] +mod tests { + use super::CacheStore; + use serde::{Deserialize, Serialize}; + use std::time::Duration; + + #[derive(Debug, PartialEq, Serialize, Deserialize)] + struct Value { + name: String, + } + + #[tokio::test] + async fn memory_cache_supports_values_prefixes_and_counters() { + let cache = CacheStore::memory(); + cache + .set_json( + "group:one", + &Value { name: "one".into() }, + Duration::from_secs(30), + ) + .await + .unwrap(); + cache.set_string("group:two", "two", Duration::from_secs(30)).await.unwrap(); + + assert_eq!( + cache.get_json::("group:one").await.unwrap(), + Some(Value { name: "one".into() }) + ); + assert_eq!( + cache.increment("counter", Duration::from_secs(30)).await.unwrap(), + 1 + ); + assert_eq!( + cache.increment("counter", Duration::from_secs(30)).await.unwrap(), + 2 + ); + + cache.delete_prefix("group:").await.unwrap(); + assert_eq!(cache.get_string("group:one").await.unwrap(), None); + assert_eq!(cache.get_string("group:two").await.unwrap(), None); + + cache.set_string("expired", "value", Duration::ZERO).await.unwrap(); + assert_eq!(cache.get_string("expired").await.unwrap(), None); + } +} diff --git a/server/src/caches/user.rs b/server/src/caches/user.rs index d56a70f..81ac708 100644 --- a/server/src/caches/user.rs +++ b/server/src/caches/user.rs @@ -1,5 +1,5 @@ use crate::svc_ctx::SvcCtx; -use redis::AsyncCommands; +use std::time::Duration; use uuid::Uuid; pub(crate) const REGISTER_CODE_CACHE_KEY: &str = "verification:code:register:send"; @@ -15,8 +15,10 @@ pub async fn set_register_code( code: &str, ) -> Result<(), anyhow::Error> { let key = format!("{}:{}", REGISTER_CODE_CACHE_KEY, email); - let _: () = svc_ctx.redis.clone().set_ex(key, code, CODE_EXPIRATION).await?; - Ok(()) + svc_ctx + .cache + .set_string(key, code, Duration::from_secs(CODE_EXPIRATION)) + .await } /// 获取用户注册的验证码 @@ -25,8 +27,7 @@ pub async fn get_register_code( email: &str, ) -> Result, anyhow::Error> { let key = format!("{}:{}", REGISTER_CODE_CACHE_KEY, email); - let result: Option = svc_ctx.redis.clone().get(key).await?; - Ok(result) + svc_ctx.cache.get_string(&key).await } /// 设置重置密码的验证码 @@ -36,8 +37,10 @@ pub async fn set_reset_password_code( code: &str, ) -> Result<(), anyhow::Error> { let key = format!("{}:{}", RESET_PASSWORD_CODE_CACHE_KEY, email); - let _: () = svc_ctx.redis.clone().set_ex(key, code, CODE_EXPIRATION).await?; - Ok(()) + svc_ctx + .cache + .set_string(key, code, Duration::from_secs(CODE_EXPIRATION)) + .await } /// 获取重置密码的验证码 @@ -46,8 +49,7 @@ pub async fn get_reset_password_code( email: &str, ) -> Result, anyhow::Error> { let key = format!("{}:{}", RESET_PASSWORD_CODE_CACHE_KEY, email); - let result: Option = svc_ctx.redis.clone().get(key).await?; - Ok(result) + svc_ctx.cache.get_string(&key).await } /// 设置用户公钥 @@ -57,8 +59,10 @@ pub async fn set_user_public_key( public_key: &str, ) -> Result<(), anyhow::Error> { let key = format!("{}:{}", USER_PUBLIC_KEY_CACHE_KEY, user_uuid); - let _: () = svc_ctx.redis.clone().set_ex(key, public_key, PUBLIC_KEY_EXPIRATION).await?; - Ok(()) + svc_ctx + .cache + .set_string(key, public_key, Duration::from_secs(PUBLIC_KEY_EXPIRATION)) + .await } /// 获取用户公钥 @@ -67,6 +71,5 @@ pub async fn get_user_public_key( user_uuid: &Uuid, ) -> Result, anyhow::Error> { let key = format!("{}:{}", USER_PUBLIC_KEY_CACHE_KEY, user_uuid); - let result: Option = svc_ctx.redis.clone().get(key).await?; - Ok(result) + svc_ctx.cache.get_string(&key).await } diff --git a/server/src/lib.rs b/server/src/lib.rs index c29db91..bc6bc73 100644 --- a/server/src/lib.rs +++ b/server/src/lib.rs @@ -25,15 +25,7 @@ pub async fn init_encrypt_secret(config: &IConfig) { /// 初始化对象存储 pub async fn init_storage(config: &IConfig) { - let storage_config = &config.clone().storage; - - utils::init_storage( - &storage_config.endpoint, - &storage_config.access_key, - &storage_config.secret_access_key, - None, - None, - ) - .await - .expect("初始化对象存储客户端失败"); + utils::init_storage(&config.storage) + .await + .expect("初始化对象存储客户端失败"); } diff --git a/server/src/svc_ctx.rs b/server/src/svc_ctx.rs index 159634c..c127838 100644 --- a/server/src/svc_ctx.rs +++ b/server/src/svc_ctx.rs @@ -1,38 +1,39 @@ -use redis::aio::ConnectionManager; use sqlx::{Pool, Postgres, postgres::PgPoolOptions}; -use crate::utils::{DatabaseConfig, IConfig, RedisConfig}; +use crate::{ + caches::CacheStore, + utils::{DatabaseConfig, IConfig}, +}; -/// 服务上下文 - 管理共享资源 +/// Shared resources used by handlers and services. #[derive(Clone)] pub struct SvcCtx { pub config: IConfig, pub db: Pool, - pub redis: ConnectionManager, + pub cache: CacheStore, } impl SvcCtx { - /// 创建服务上下文 pub async fn new(config: &IConfig) -> Result { - // 创建数据库连接池 let db = Self::create_db(&config.database).await?; - let redis = Self::create_redis(&config.redis).await?; + let cache = if let Some(redis) = &config.redis { + CacheStore::redis(&redis.url).await? + } else { + CacheStore::memory() + }; Ok(Self { config: config.clone(), db, - redis, + cache, }) } - /// 创建数据库连接 pub async fn create_db(config: &DatabaseConfig) -> Result, anyhow::Error> { let pool = PgPoolOptions::new() - .max_lifetime(std::time::Duration::from_secs(config.max_lifetime as u64)) // 连接最大生存时间, 5 分钟 - .idle_timeout(std::time::Duration::from_secs(config.idle_timeout as u64)) // 空闲超时时间, 30秒 - .acquire_timeout(std::time::Duration::from_secs( - config.acquire_timeout as u64, - )) // 添加获取连接的超时时间 + .max_lifetime(std::time::Duration::from_secs(config.max_lifetime)) + .idle_timeout(std::time::Duration::from_secs(config.idle_timeout)) + .acquire_timeout(std::time::Duration::from_secs(config.acquire_timeout)) .max_connections(config.max_connections) .min_connections(config.min_connections) .connect(&config.url) @@ -40,13 +41,4 @@ impl SvcCtx { Ok(pool) } - - /// 创建redis连接 - pub async fn create_redis(config: &RedisConfig) -> Result { - let client = redis::Client::open(config.url.as_str())?; - - let connection = ConnectionManager::new(client).await?; - - Ok(connection) - } } diff --git a/server/src/utils/config.rs b/server/src/utils/config.rs index 847e192..afddd9b 100644 --- a/server/src/utils/config.rs +++ b/server/src/utils/config.rs @@ -1,5 +1,6 @@ use config::{Config, ConfigError}; use serde::Deserialize; +use std::path::{Path, PathBuf}; /// 数据库配置 #[derive(Debug, Clone, Deserialize)] @@ -19,16 +20,58 @@ pub struct RedisConfig { } /// 对象存储配置 +#[derive(Debug, Clone, Copy, Default, Deserialize, PartialEq, Eq)] +#[serde(rename_all = "snake_case")] +pub enum StorageBackend { + Local, + #[default] + S3, +} + #[derive(Debug, Clone, Deserialize)] pub struct StorageConfig { + #[serde(default)] + pub backend: StorageBackend, + #[serde(default)] pub endpoint: String, + #[serde(default)] pub public_base_url: String, + #[serde(default)] pub access_key: String, + #[serde(default)] pub secret_access_key: String, + #[serde(default = "default_storage_bucket")] pub bucket: String, + #[serde(default = "default_avatar_root")] pub avatar_root: String, + #[serde(default = "default_extension_root")] pub extension_root: String, + #[serde(default = "default_version_root")] pub version_root: String, + #[serde(default)] + pub local_path: Option, +} + +impl StorageConfig { + pub fn local_root(&self) -> &Path { + self.local_path.as_deref().unwrap_or_else(|| Path::new("./data/storage")) + } +} + +fn default_storage_bucket() -> String { + "simprint-client".to_string() +} + +fn default_avatar_root() -> String { + "avatars".to_string() +} + +fn default_extension_root() -> String { + "extensions".to_string() +} + +fn default_version_root() -> String { + "versions".to_string() } /// SMTP 配置 @@ -83,7 +126,7 @@ pub struct AppConfig { pub struct IConfig { pub app: AppConfig, pub database: DatabaseConfig, - pub redis: RedisConfig, + pub redis: Option, pub storage: StorageConfig, pub smtp: Option, #[serde(default = "default_workspace_quota_config")] diff --git a/server/src/utils/storage.rs b/server/src/utils/storage.rs index c4dbc1c..88e50b2 100644 --- a/server/src/utils/storage.rs +++ b/server/src/utils/storage.rs @@ -1,86 +1,149 @@ -use std::{path::Path, sync::Arc}; - -pub mod get_objects; -pub mod put_objects; -pub mod remove_objects; - -use minio::s3::{client::ClientBuilder, creds::StaticProvider, http::BaseUrl}; +use std::{ + path::{Component, Path, PathBuf}, + sync::Arc, +}; + +use bytes::Bytes; +use minio::s3::{ + client::ClientBuilder, creds::StaticProvider, http::BaseUrl, segmented_bytes::SegmentedBytes, + types::S3Api, +}; use tokio::sync::OnceCell; - -/// S3-compatible object storage client + +use crate::utils::{StorageBackend, StorageConfig}; + +pub mod get_objects; +pub mod put_objects; +pub mod remove_objects; + pub struct ObjectStorage { - pub client: minio::s3::client::Client, + backend: ObjectStorageBackend, +} + +enum ObjectStorageBackend { + Local { root: PathBuf }, + S3 { client: minio::s3::client::Client }, } impl ObjectStorage { - /// 创建一个新的对象存储客户端 - /// - /// # Arguments - /// - `base_url`: 对象存储服务的基础URL。 - /// - `access_key`: 对象存储认证的访问密钥。 - /// - `secret_key`: 对象存储认证的密钥。 - /// - `session_token`: 可选的临时凭证会话令牌。 - /// - `cert_path`: 可选的SSL证书文件路径。 - pub fn new( - base_url: &str, - access_key: &str, - secret_key: &str, - session_token: Option<&str>, - cert_path: Option<&Path>, - ) -> Result { - let base_url = base_url.parse::()?; - let static_provider = StaticProvider::new(access_key, secret_key, session_token); - - let client = ClientBuilder::new(base_url) - .provider(Some(Box::new(static_provider))) - .ssl_cert_file(cert_path) - .build()?; - - Ok(Self { client }) - } - - // 获取底层对象存储客户端 - pub fn get_client(&self) -> &minio::s3::client::Client { - &self.client + async fn new(config: &StorageConfig) -> Result { + let backend = match config.backend { + StorageBackend::Local => { + let root = config.local_root().to_path_buf(); + tokio::fs::create_dir_all(&root).await?; + ObjectStorageBackend::Local { root } + } + StorageBackend::S3 => { + let base_url = config.endpoint.parse::()?; + let provider = + StaticProvider::new(&config.access_key, &config.secret_access_key, None); + let client = + ClientBuilder::new(base_url).provider(Some(Box::new(provider))).build()?; + ObjectStorageBackend::S3 { client } + } + }; + + Ok(Self { backend }) + } + + pub async fn put_object( + &self, + bucket: &str, + object_path: &str, + data: Bytes, + ) -> Result<(), anyhow::Error> { + match &self.backend { + ObjectStorageBackend::Local { root } => { + let bucket_root = safe_join(root, bucket)?; + let file_path = safe_join(&bucket_root, object_path)?; + if let Some(parent) = file_path.parent() { + tokio::fs::create_dir_all(parent).await?; + } + tokio::fs::write(file_path, data).await?; + } + ObjectStorageBackend::S3 { client } => { + let data = SegmentedBytes::from(data); + client.put_object(bucket, object_path, data).send().await?; + } + } + Ok(()) } } static STORAGE: OnceCell> = OnceCell::const_new(); -/// 初始化对象存储客户端 pub async fn init_storage( - base_url: &str, - access_key: &str, - secret_key: &str, - session_token: Option<&str>, - cert_path: Option<&Path>, + config: &StorageConfig, ) -> Result<&'static Arc, anyhow::Error> { - Ok(STORAGE - .get_or_init(|| async { - let storage = match ObjectStorage::new( - base_url, - access_key, - secret_key, - session_token, - cert_path, - ) - { - Ok(v) => v, - Err(e) => { - tracing::error!("Failed to initialize the storage client: {:?}", e); - std::process::exit(-1); - } - }; + STORAGE + .get_or_try_init(|| async { ObjectStorage::new(config).await.map(Arc::new) }) + .await +} + +pub fn get_storage() -> Result<&'static Arc, anyhow::Error> { + STORAGE.get().ok_or_else(|| anyhow::anyhow!("storage not initialized")) +} - Arc::new(storage) - }) - .await) +fn safe_join(root: &Path, relative_path: &str) -> Result { + let mut output = root.to_path_buf(); + for component in Path::new(relative_path).components() { + match component { + Component::Normal(segment) => output.push(segment), + Component::CurDir => {} + _ => return Err(anyhow::anyhow!("invalid storage path: {relative_path}")), + } + } + Ok(output) } -/// 获取对象存储客户端 -pub fn get_storage_client() -> Result<&'static minio::s3::client::Client, anyhow::Error> { - match STORAGE.get() { - Some(storage) => Ok(storage.get_client()), - None => Err(anyhow::anyhow!("storage client not initialized")), +#[cfg(test)] +mod tests { + use super::{ObjectStorage, safe_join}; + use crate::utils::{StorageBackend, StorageConfig}; + use bytes::Bytes; + use std::path::Path; + + #[test] + fn local_storage_paths_stay_inside_the_root() { + let root = Path::new("storage-root"); + assert_eq!( + safe_join(root, "bucket/extensions/file.crx").unwrap(), + root.join("bucket/extensions/file.crx") + ); + assert!(safe_join(root, "../outside").is_err()); + assert!(safe_join(root, "/absolute").is_err()); + } + + #[tokio::test] + async fn local_storage_writes_objects_below_its_root() { + let root = std::env::temp_dir().join(format!("simprint-storage-{}", uuid::Uuid::new_v4())); + let config = StorageConfig { + backend: StorageBackend::Local, + endpoint: String::new(), + public_base_url: String::new(), + access_key: String::new(), + secret_access_key: String::new(), + bucket: "bucket".to_string(), + avatar_root: "avatars".to_string(), + extension_root: "extensions".to_string(), + version_root: "versions".to_string(), + local_path: Some(root.clone()), + }; + let storage = ObjectStorage::new(&config).await.unwrap(); + + storage + .put_object( + "bucket", + "extensions/file.crx", + Bytes::from_static(b"content"), + ) + .await + .unwrap(); + + assert_eq!( + tokio::fs::read(root.join("bucket/extensions/file.crx")).await.unwrap(), + b"content" + ); + tokio::fs::remove_dir_all(root).await.unwrap(); } } diff --git a/server/src/utils/storage/put_objects.rs b/server/src/utils/storage/put_objects.rs index 524b6ee..56ed9cd 100644 --- a/server/src/utils/storage/put_objects.rs +++ b/server/src/utils/storage/put_objects.rs @@ -3,10 +3,9 @@ //! 提供各类资源上传到对象存储的功能 use bytes::Bytes; -use minio::s3::{segmented_bytes::SegmentedBytes, types::S3Api}; use sha2::{Digest, Sha256}; -use super::get_storage_client; +use super::get_storage; /// 上传通用对象到对象存储 /// @@ -19,10 +18,7 @@ pub async fn put_object( object_path: &str, data: Bytes, ) -> Result<(), anyhow::Error> { - let client = get_storage_client()?; - let data: SegmentedBytes = SegmentedBytes::from(data); - client.put_object(bucket_name, object_path, data).send().await?; - Ok(()) + get_storage()?.put_object(bucket_name, object_path, data).await } /// 上传扩展 CRX 文件到对象存储 @@ -47,8 +43,6 @@ pub async fn put_extension_crx( crx_hash: &str, data: Bytes, ) -> Result { - let client = get_storage_client()?; - // 计算扩展 ID 的哈希作为目录名 let extension_id_hash = calculate_short_hash(extension_id); @@ -59,8 +53,7 @@ pub async fn put_extension_crx( crx_hash ); - let data: SegmentedBytes = SegmentedBytes::from(data); - client.put_object(bucket_name, &object_path, data).send().await?; + get_storage()?.put_object(bucket_name, &object_path, data).await?; tracing::info!( "Extension CRX uploaded: bucket={}, path={}", @@ -93,8 +86,6 @@ pub async fn put_extension_icon( extension: &str, data: Bytes, ) -> Result { - let client = get_storage_client()?; - let extension_id_hash = calculate_short_hash(extension_id); let object_path = format!( "{}/icons/{}.{}", @@ -103,8 +94,7 @@ pub async fn put_extension_icon( extension ); - let data: SegmentedBytes = SegmentedBytes::from(data); - client.put_object(bucket_name, &object_path, data).send().await?; + get_storage()?.put_object(bucket_name, &object_path, data).await?; tracing::info!( "Extension icon uploaded: bucket={}, path={}", @@ -122,11 +112,8 @@ pub async fn put_avatar_object( object: &str, data: Bytes, ) -> Result<(), anyhow::Error> { - let client = get_storage_client()?; - let data: SegmentedBytes = SegmentedBytes::from(data); let object_path = join_root(avatar_root, object); - client.put_object(bucket_name, &object_path, data).send().await?; - Ok(()) + get_storage()?.put_object(bucket_name, &object_path, data).await } fn join_root(root: &str, object_path: &str) -> String { From 87acb69b653697ed8fe13c5f204c2455763c80c7 Mon Sep 17 00:00:00 2001 From: lius-new Date: Wed, 12 Aug 2026 11:07:21 +0800 Subject: [PATCH 48/60] fix(server): reuse startup database pool --- server/src/app.rs | 6 ++---- 1 file changed, 2 insertions(+), 4 deletions(-) diff --git a/server/src/app.rs b/server/src/app.rs index a8ea9ac..396719c 100644 --- a/server/src/app.rs +++ b/server/src/app.rs @@ -42,13 +42,11 @@ pub async fn serve_on_with_shutdown( where F: Future + Send + 'static, { - let db = SvcCtx::create_db(&config.database).await?; + let svc_ctx = SvcCtx::new(&config).await?; tracing::info!("Running embedded database migrations"); - MIGRATOR.run(&db).await?; + MIGRATOR.run(&svc_ctx.db).await?; tracing::info!("Embedded database migrations completed"); - let svc_ctx = SvcCtx::new(&config).await?; - init_encrypt_secret(&config).await; init_storage(&config).await; From cfc2e255abbb10db4b37c786bf1e16bb4de505c5 Mon Sep 17 00:00:00 2001 From: lius-new Date: Wed, 12 Aug 2026 11:08:51 +0800 Subject: [PATCH 49/60] refactor(server): isolate database connection boundary --- server/src/database.rs | 24 ++++++++++++++++++++++++ server/src/lib.rs | 1 + server/src/svc_ctx.rs | 30 ++++++++++-------------------- 3 files changed, 35 insertions(+), 20 deletions(-) create mode 100644 server/src/database.rs diff --git a/server/src/database.rs b/server/src/database.rs new file mode 100644 index 0000000..2d59d6c --- /dev/null +++ b/server/src/database.rs @@ -0,0 +1,24 @@ +use sqlx::{Pool, Postgres, postgres::PgPoolOptions}; + +use crate::utils::DatabaseConfig; + +/// Database engine currently used by the imported service models. +/// +/// Keeping this alias in one module gives the SQLite migration a single +/// boundary while the PostgreSQL-specific queries are converted domain by +/// domain. +pub type Db = Postgres; +pub type DbPool = Pool; + +pub async fn connect(config: &DatabaseConfig) -> anyhow::Result { + let pool = PgPoolOptions::new() + .max_lifetime(std::time::Duration::from_secs(config.max_lifetime)) + .idle_timeout(std::time::Duration::from_secs(config.idle_timeout)) + .acquire_timeout(std::time::Duration::from_secs(config.acquire_timeout)) + .max_connections(config.max_connections) + .min_connections(config.min_connections) + .connect(&config.url) + .await?; + + Ok(pool) +} diff --git a/server/src/lib.rs b/server/src/lib.rs index bc6bc73..7d0e829 100644 --- a/server/src/lib.rs +++ b/server/src/lib.rs @@ -3,6 +3,7 @@ use crate::utils::IConfig; pub mod app; pub mod caches; pub mod cli; +pub mod database; pub mod dto; pub mod entitys; pub mod errors; diff --git a/server/src/svc_ctx.rs b/server/src/svc_ctx.rs index c127838..e45f392 100644 --- a/server/src/svc_ctx.rs +++ b/server/src/svc_ctx.rs @@ -1,15 +1,14 @@ -use sqlx::{Pool, Postgres, postgres::PgPoolOptions}; - -use crate::{ - caches::CacheStore, - utils::{DatabaseConfig, IConfig}, -}; +use crate::{ + caches::CacheStore, + database::{self, DbPool}, + utils::{DatabaseConfig, IConfig}, +}; /// Shared resources used by handlers and services. #[derive(Clone)] pub struct SvcCtx { pub config: IConfig, - pub db: Pool, + pub db: DbPool, pub cache: CacheStore, } @@ -29,16 +28,7 @@ impl SvcCtx { }) } - pub async fn create_db(config: &DatabaseConfig) -> Result, anyhow::Error> { - let pool = PgPoolOptions::new() - .max_lifetime(std::time::Duration::from_secs(config.max_lifetime)) - .idle_timeout(std::time::Duration::from_secs(config.idle_timeout)) - .acquire_timeout(std::time::Duration::from_secs(config.acquire_timeout)) - .max_connections(config.max_connections) - .min_connections(config.min_connections) - .connect(&config.url) - .await?; - - Ok(pool) - } -} + pub async fn create_db(config: &DatabaseConfig) -> Result { + database::connect(config).await + } +} From cd9f632bc3986b4f1a58b34e56a28bd66a788ce0 Mon Sep 17 00:00:00 2001 From: lius-new Date: Wed, 12 Aug 2026 11:11:38 +0800 Subject: [PATCH 50/60] refactor(server): route models through database boundary --- server/src/database.rs | 3 ++- server/src/models/accounts.rs | 4 +++- server/src/models/audit.rs | 4 +++- server/src/models/billing.rs | 4 +++- server/src/models/environments.rs | 4 +++- server/src/models/extensions.rs | 4 +++- server/src/models/group_member_permissions.rs | 4 +++- server/src/models/local_api.rs | 4 +++- server/src/models/maintenance.rs | 2 +- server/src/models/messages.rs | 4 +++- server/src/models/preferences.rs | 4 +++- server/src/models/proxies.rs | 4 +++- server/src/models/proxy_visible_teams.rs | 4 +++- server/src/models/referral.rs | 4 +++- server/src/models/rpa.rs | 4 +++- server/src/models/strategy_types.rs | 4 +++- server/src/models/teams.rs | 6 ++++-- server/src/models/user.rs | 4 +++- server/src/models/version_types.rs | 4 +++- server/src/models/versions.rs | 4 +++- server/src/models/workspace_quotas.rs | 4 +++- server/src/models/workspaces.rs | 4 +++- 22 files changed, 64 insertions(+), 23 deletions(-) diff --git a/server/src/database.rs b/server/src/database.rs index 2d59d6c..b075596 100644 --- a/server/src/database.rs +++ b/server/src/database.rs @@ -1,4 +1,4 @@ -use sqlx::{Pool, Postgres, postgres::PgPoolOptions}; +use sqlx::{Postgres, postgres::PgPoolOptions}; use crate::utils::DatabaseConfig; @@ -8,6 +8,7 @@ use crate::utils::DatabaseConfig; /// boundary while the PostgreSQL-specific queries are converted domain by /// domain. pub type Db = Postgres; +pub type Pool = sqlx::Pool; pub type DbPool = Pool; pub async fn connect(config: &DatabaseConfig) -> anyhow::Result { diff --git a/server/src/models/accounts.rs b/server/src/models/accounts.rs index a7ca843..f75a8cd 100644 --- a/server/src/models/accounts.rs +++ b/server/src/models/accounts.rs @@ -1,6 +1,8 @@ -use sqlx::{Error, Pool, Postgres}; +use sqlx::Error; use uuid::Uuid; +use crate::database::{Db as Postgres, Pool}; + use crate::dto::PlatformAccountDto; /// 创建平台账号 diff --git a/server/src/models/audit.rs b/server/src/models/audit.rs index 471ef8c..e88280e 100644 --- a/server/src/models/audit.rs +++ b/server/src/models/audit.rs @@ -1,6 +1,8 @@ -use sqlx::{Error, Pool, Postgres}; +use sqlx::Error; use uuid::Uuid; +use crate::database::{Db as Postgres, Pool}; + use crate::dto::AuditLogDto; /// 记录审计日志 diff --git a/server/src/models/billing.rs b/server/src/models/billing.rs index d5813c2..3b271a7 100644 --- a/server/src/models/billing.rs +++ b/server/src/models/billing.rs @@ -1,8 +1,10 @@ use chrono::NaiveDate; use rust_decimal::Decimal; -use sqlx::{Error, Pool, Postgres, Row}; +use sqlx::{Error, Row}; use uuid::Uuid; +use crate::database::{Db as Postgres, Pool}; + use crate::dto::{ AutoRenewalServiceDto, CouponDto, InvoiceDto, PaymentOrderDto, PlanDto, PlanFeatureDto, SubscriptionDto, UserQuotaDto, UserWalletDto, WalletTransactionDto, diff --git a/server/src/models/environments.rs b/server/src/models/environments.rs index fb934b3..92a45d7 100644 --- a/server/src/models/environments.rs +++ b/server/src/models/environments.rs @@ -1,6 +1,8 @@ -use sqlx::{Error, Pool, Postgres}; +use sqlx::Error; use uuid::Uuid; +use crate::database::{Db as Postgres, Pool}; + use crate::dto::{ EnvironmentAccountRowDto, EnvironmentConfigDto, EnvironmentCookieDto, EnvironmentDto, EnvironmentRowDto, EnvironmentTagRowDto, EnvironmentUrlDto, GroupDto, GroupRowDto, ProxyRowDto, diff --git a/server/src/models/extensions.rs b/server/src/models/extensions.rs index 92eb702..b189eb4 100644 --- a/server/src/models/extensions.rs +++ b/server/src/models/extensions.rs @@ -1,6 +1,8 @@ -use sqlx::{Error, Pool, Postgres}; +use sqlx::Error; use uuid::Uuid; +use crate::database::{Db as Postgres, Pool}; + use crate::entitys::extensions::{CreateExtensionParams, UpdateExtensionParams}; use crate::dto::{ ExtensionDto, GroupExtensionDto, TeamExtensionDto, UserExtensionDto, diff --git a/server/src/models/group_member_permissions.rs b/server/src/models/group_member_permissions.rs index 8a33b4b..2922ea4 100644 --- a/server/src/models/group_member_permissions.rs +++ b/server/src/models/group_member_permissions.rs @@ -1,6 +1,8 @@ -use sqlx::{Error, Pool, Postgres}; +use sqlx::Error; use uuid::Uuid; +use crate::database::{Db as Postgres, Pool}; + use crate::dto::GroupMemberPermissionDto; /// 授予分组权限 diff --git a/server/src/models/local_api.rs b/server/src/models/local_api.rs index 8914289..16601e3 100644 --- a/server/src/models/local_api.rs +++ b/server/src/models/local_api.rs @@ -1,9 +1,11 @@ use chrono::{DateTime, Utc}; use serde_json::{Value, json}; use sha2::{Digest, Sha256}; -use sqlx::{Error, Pool, Postgres}; +use sqlx::Error; use uuid::Uuid; +use crate::database::{Db as Postgres, Pool}; + use crate::dto::{ LocalApiKeyDto, LocalApiKeyPermissionDto, LocalApiPermissionDefinitionDto, LocalApiSettingsDto, }; diff --git a/server/src/models/maintenance.rs b/server/src/models/maintenance.rs index 35280fb..e4ed930 100644 --- a/server/src/models/maintenance.rs +++ b/server/src/models/maintenance.rs @@ -1,5 +1,5 @@ use chrono::Utc; -use sqlx::{Pool, Postgres}; +use crate::database::{Db as Postgres, Pool}; use crate::{dto::maintenance::Maintenance, entitys::maintenance::CreateMaintenanceRequest}; diff --git a/server/src/models/messages.rs b/server/src/models/messages.rs index db1a92f..747a519 100644 --- a/server/src/models/messages.rs +++ b/server/src/models/messages.rs @@ -1,6 +1,8 @@ -use sqlx::{Error, Pool, Postgres}; +use sqlx::Error; use uuid::Uuid; +use crate::database::{Db as Postgres, Pool}; + use crate::dto::{MessageDto, UserMessageDto}; // ============ Messages ============ diff --git a/server/src/models/preferences.rs b/server/src/models/preferences.rs index ed4d3c0..e1e9541 100644 --- a/server/src/models/preferences.rs +++ b/server/src/models/preferences.rs @@ -1,6 +1,8 @@ -use sqlx::{Error, Pool, Postgres}; +use sqlx::Error; use uuid::Uuid; +use crate::database::{Db as Postgres, Pool}; + use crate::dto::UserPreferenceDto; /// 获取用户偏好设置 diff --git a/server/src/models/proxies.rs b/server/src/models/proxies.rs index dfb4223..c6561aa 100644 --- a/server/src/models/proxies.rs +++ b/server/src/models/proxies.rs @@ -1,6 +1,8 @@ -use sqlx::{Error, Pool, Postgres}; +use sqlx::Error; use uuid::Uuid; +use crate::database::{Db as Postgres, Pool}; + use crate::dto::{ProxyDto, ProxyHealthCheckDto}; /// 创建代理 diff --git a/server/src/models/proxy_visible_teams.rs b/server/src/models/proxy_visible_teams.rs index 401c19f..0696684 100644 --- a/server/src/models/proxy_visible_teams.rs +++ b/server/src/models/proxy_visible_teams.rs @@ -1,6 +1,8 @@ -use sqlx::{Error, Pool, Postgres}; +use sqlx::Error; use uuid::Uuid; +use crate::database::{Db as Postgres, Pool}; + use crate::dto::{ProxyDto, ProxyVisibleTeamDto}; /// 添加代理可见团队 diff --git a/server/src/models/referral.rs b/server/src/models/referral.rs index 9b43bc9..f6827de 100644 --- a/server/src/models/referral.rs +++ b/server/src/models/referral.rs @@ -1,8 +1,10 @@ use chrono::{DateTime, Utc}; use rust_decimal::Decimal; -use sqlx::{Error, Pool, Postgres}; +use sqlx::Error; use uuid::Uuid; +use crate::database::{Db as Postgres, Pool}; + use crate::dto::{ RedeemOptionDto, RedeemRecordDto, ReferralLinkDto, ReferralLinkTierDto, ReferralRewardDto, UserReferralPointsDto, diff --git a/server/src/models/rpa.rs b/server/src/models/rpa.rs index 68b5ad3..e751d1d 100644 --- a/server/src/models/rpa.rs +++ b/server/src/models/rpa.rs @@ -1,6 +1,8 @@ -use sqlx::{Error, Pool, Postgres}; +use sqlx::Error; use uuid::Uuid; +use crate::database::{Db as Postgres, Pool}; + use crate::dto::{RpaTaskDto, RpaTaskEnvironmentDto, RpaTaskRunDto, RpaTaskStepDto}; // ============ RPA Tasks ============ diff --git a/server/src/models/strategy_types.rs b/server/src/models/strategy_types.rs index 323306c..c9c4cbc 100644 --- a/server/src/models/strategy_types.rs +++ b/server/src/models/strategy_types.rs @@ -1,5 +1,7 @@ use crate::dto::strategy_types::StrategyType; -use sqlx::{Error, Pool, Postgres}; +use sqlx::Error; + +use crate::database::{Db as Postgres, Pool}; /// 根据ID查询策略类型 pub async fn query_strategy_type_by_id( diff --git a/server/src/models/teams.rs b/server/src/models/teams.rs index 9b9ce29..fedf7a0 100644 --- a/server/src/models/teams.rs +++ b/server/src/models/teams.rs @@ -1,6 +1,8 @@ -use sqlx::{Error, Pool, Postgres}; +use sqlx::Error; use uuid::Uuid; +use crate::database::{Db as Postgres, Pool}; + use crate::dto::{LoginHistoryDto, TeamDto, TeamInvitationDto, TeamMemberDto}; use crate::entitys::CreateTeamRequest; @@ -162,7 +164,7 @@ pub async fn update_team( avatar_hash: Option<&str>, ) -> Result<(), Error> { let mut query = String::from("UPDATE teams SET updated_at = NOW()"); - let mut params: Vec + Send + Sync>> = vec![]; + let mut params: Vec + Send + Sync>> = vec![]; if let Some(n) = name { query.push_str(", name = $"); diff --git a/server/src/models/user.rs b/server/src/models/user.rs index 28d5e60..02a9ad5 100644 --- a/server/src/models/user.rs +++ b/server/src/models/user.rs @@ -1,7 +1,9 @@ use rust_decimal::Decimal; -use sqlx::{Error, Pool, Postgres}; +use sqlx::Error; use uuid::Uuid; +use crate::database::{Db as Postgres, Pool}; + use crate::dto::{LocalApiPermissionDefinitionDto, UserDto, UserInfoDto}; use crate::entitys::{RegisterRequest, UpdateUserRequest}; diff --git a/server/src/models/version_types.rs b/server/src/models/version_types.rs index d029e80..418819b 100644 --- a/server/src/models/version_types.rs +++ b/server/src/models/version_types.rs @@ -1,6 +1,8 @@ use crate::dto::version_types::VersionType; use crate::entitys::version_types::{CreateVersionTypeRequest, UpdateVersionTypeRequest}; -use sqlx::{Error, Pool, Postgres}; +use sqlx::Error; + +use crate::database::{Db as Postgres, Pool}; /// 插入新版本类型 pub async fn insert_version_type( diff --git a/server/src/models/versions.rs b/server/src/models/versions.rs index d10f378..093e61e 100644 --- a/server/src/models/versions.rs +++ b/server/src/models/versions.rs @@ -1,7 +1,9 @@ use crate::dto::versions::Version; use crate::entitys::versions::{CreateVersionRequest, UpdateVersionRequest}; use chrono::{DateTime, Utc}; -use sqlx::{Error, Pool, Postgres}; +use sqlx::Error; + +use crate::database::{Db as Postgres, Pool}; /// 插入新版本 pub async fn insert_version( diff --git a/server/src/models/workspace_quotas.rs b/server/src/models/workspace_quotas.rs index 6104f1a..863f705 100644 --- a/server/src/models/workspace_quotas.rs +++ b/server/src/models/workspace_quotas.rs @@ -1,6 +1,8 @@ -use sqlx::{Error, Pool, Postgres}; +use sqlx::Error; use uuid::Uuid; +use crate::database::{Db as Postgres, Pool}; + use crate::dto::WorkspaceQuotaDto; /// 创建或更新工作空间配额 diff --git a/server/src/models/workspaces.rs b/server/src/models/workspaces.rs index 169a268..3f07957 100644 --- a/server/src/models/workspaces.rs +++ b/server/src/models/workspaces.rs @@ -1,6 +1,8 @@ -use sqlx::{Error, Pool, Postgres}; +use sqlx::Error; use uuid::Uuid; +use crate::database::{Db as Postgres, Pool}; + use crate::dto::WorkspaceDto; use crate::entitys::CreateWorkspaceRequest; From 3eed068f4c51e6ca796b0d4dde8447d0d9736e2d Mon Sep 17 00:00:00 2001 From: lius-new Date: Wed, 12 Aug 2026 11:22:08 +0800 Subject: [PATCH 51/60] refactor(server): remove unused remote infrastructure Drop the unused MinIO upload subsystem and Redis backend. Keep only URL formatting for externally hosted download resources and transient in-process state required by the temporary HTTP compatibility layer. --- server/Cargo.toml | 9 +- server/configs/config.example.toml | 9 +- server/configs/config.local.example.toml | 7 +- server/src/app.rs | 11 +- server/src/caches/store.rs | 65 +-------- server/src/lib.rs | 9 +- server/src/svc_ctx.rs | 6 +- server/src/utils/config.rs | 40 +----- server/src/utils/storage.rs | 151 +-------------------- server/src/utils/storage/put_objects.rs | 143 ------------------- server/src/utils/storage/remove_objects.rs | 13 -- 11 files changed, 19 insertions(+), 444 deletions(-) delete mode 100644 server/src/utils/storage/put_objects.rs delete mode 100644 server/src/utils/storage/remove_objects.rs diff --git a/server/Cargo.toml b/server/Cargo.toml index 5f035d2..416b5ed 100644 --- a/server/Cargo.toml +++ b/server/Cargo.toml @@ -10,7 +10,7 @@ path = "src/lib.rs" [dependencies] axum = { version = "0.8", features = ["macros", "multipart", "ws"] } tower = "0.5.2" -tower-http = { version = "0.6", features = ["cors", "default", "fs"] } +tower-http = { version = "0.6", features = ["cors", "default"] } tokio = { version = "1", features = [ "macros", "rt-multi-thread", @@ -58,12 +58,6 @@ sqlx = { version = "0.8", features = [ "rust_decimal", "bigdecimal", ] } -redis = { version = "0.31.0", features = [ - "tokio-comp", - "json", - "tokio-native-tls-comp", - "connection-manager", -] } uuid = { version = "1.11.0", features = [ "v4", "fast-rng", @@ -76,7 +70,6 @@ bytes = "1.10.0" dashmap = "6.1.0" rcon = { version = "0.6.0", features = ["rt-tokio", "rt-async-std"] } tungstenite = "0.26.2" -minio = "0.3.0" textnonce = "1.0.0" url = "2.5.4" urlencoding = "2.1.3" diff --git a/server/configs/config.example.toml b/server/configs/config.example.toml index c30453e..42a2128 100644 --- a/server/configs/config.example.toml +++ b/server/configs/config.example.toml @@ -25,15 +25,8 @@ max_lifetime = 3000 acquire_timeout = 30 idle_timeout = 600 -[redis] -url = "redis://redis:6379?protocol=resp3" - [storage] -endpoint = "https://your-object-storage-endpoint.example" -public_base_url = "https://your-object-storage-public.example" -access_key = "change-me" -secret_access_key = "change-me" -bucket = "simprint-client" +public_base_url = "https://your-resource-download-host.example" avatar_root = "avatars" extension_root = "extensions" version_root = "versions" diff --git a/server/configs/config.local.example.toml b/server/configs/config.local.example.toml index 704577b..1c7b789 100644 --- a/server/configs/config.local.example.toml +++ b/server/configs/config.local.example.toml @@ -26,13 +26,8 @@ max_lifetime = 3000 acquire_timeout = 30 idle_timeout = 600 -# Omitting [redis] selects the in-process TTL cache. - [storage] -backend = "local" -local_path = "./data/storage" -public_base_url = "http://127.0.0.1:40041/assets/simprint-client" -bucket = "simprint-client" +public_base_url = "https://your-resource-download-host.example" avatar_root = "avatars" extension_root = "extensions" version_root = "versions" diff --git a/server/src/app.rs b/server/src/app.rs index 396719c..e784aa5 100644 --- a/server/src/app.rs +++ b/server/src/app.rs @@ -1,10 +1,9 @@ use std::{future::Future, net::SocketAddr}; use axum::{Router, middleware}; -use tower_http::services::ServeDir; use crate::{ - init_encrypt_secret, init_storage, middlewares, + init_encrypt_secret, middlewares, routes::route::MetaRoute, routes::{ accounts, audit, billing, browser_kernel, environments, extensions, group_permissions, @@ -12,7 +11,7 @@ use crate::{ templates, time, users, workspace_quotas, workspaces, }, svc_ctx::SvcCtx, - utils::{IConfig, StorageBackend}, + utils::IConfig, }; static MIGRATOR: sqlx::migrate::Migrator = sqlx::migrate!(); @@ -48,7 +47,6 @@ where tracing::info!("Embedded database migrations completed"); init_encrypt_secret(&config).await; - init_storage(&config).await; let listener = tokio::net::TcpListener::bind(address).await?; let bound_address = listener.local_addr()?; @@ -56,11 +54,6 @@ where let app = register_all_routes(&svc_ctx); let app = register_middlewares(&svc_ctx, app); let app = app.with_state(svc_ctx); - let app = if config.storage.backend == StorageBackend::Local { - app.nest_service("/assets", ServeDir::new(config.storage.local_root())) - } else { - app - }; tracing::info!("Starting server on {}", bound_address); diff --git a/server/src/caches/store.rs b/server/src/caches/store.rs index 6ec13b1..8bd4b14 100644 --- a/server/src/caches/store.rs +++ b/server/src/caches/store.rs @@ -7,14 +7,10 @@ use std::{ }; use dashmap::{DashMap, mapref::entry::Entry}; -use redis::{AsyncCommands, aio::ConnectionManager, cmd}; use serde::{Serialize, de::DeserializeOwned}; #[derive(Clone)] -pub enum CacheStore { - Memory(Arc), - Redis(ConnectionManager), -} +pub struct CacheStore(Arc); #[derive(Default)] pub struct MemoryCache { @@ -35,23 +31,11 @@ impl CacheEntry { impl CacheStore { pub fn memory() -> Self { - Self::Memory(Arc::new(MemoryCache::default())) - } - - pub async fn redis(url: &str) -> Result { - let client = redis::Client::open(url)?; - let connection = ConnectionManager::new(client).await?; - Ok(Self::Redis(connection)) + Self(Arc::new(MemoryCache::default())) } pub async fn get_string(&self, key: &str) -> Result, anyhow::Error> { - match self { - Self::Memory(cache) => Ok(cache.get(key)), - Self::Redis(connection) => { - let mut connection = connection.clone(); - Ok(connection.get(key).await?) - } - } + Ok(self.0.get(key)) } pub async fn set_string( @@ -62,13 +46,7 @@ impl CacheStore { ) -> Result<(), anyhow::Error> { let key = key.into(); let value = value.into(); - match self { - Self::Memory(cache) => cache.set(key, value, ttl), - Self::Redis(connection) => { - let mut connection = connection.clone(); - let _: () = connection.set_ex(key, value, ttl.as_secs()).await?; - } - } + self.0.set(key, value, ttl); Ok(()) } @@ -96,31 +74,12 @@ impl CacheStore { } pub async fn delete(&self, key: &str) -> Result<(), anyhow::Error> { - match self { - Self::Memory(cache) => { - cache.entries.remove(key); - } - Self::Redis(connection) => { - let mut connection = connection.clone(); - let _: usize = connection.del(key).await?; - } - } + self.0.entries.remove(key); Ok(()) } pub async fn delete_prefix(&self, prefix: &str) -> Result<(), anyhow::Error> { - match self { - Self::Memory(cache) => cache.delete_prefix(prefix), - Self::Redis(connection) => { - let mut connection = connection.clone(); - let pattern = format!("{prefix}*"); - let keys: Vec = - cmd("KEYS").arg(pattern).query_async(&mut connection).await?; - if !keys.is_empty() { - let _: usize = connection.del(keys).await?; - } - } - } + self.0.delete_prefix(prefix); Ok(()) } @@ -133,17 +92,7 @@ impl CacheStore { } pub async fn increment(&self, key: &str, ttl: Duration) -> Result { - match self { - Self::Memory(cache) => Ok(cache.increment(key, ttl)?), - Self::Redis(connection) => { - let mut connection = connection.clone(); - let count: i64 = connection.incr(key, 1).await?; - if count == 1 { - let _: bool = connection.expire(key, ttl.as_secs() as i64).await?; - } - Ok(count) - } - } + Ok(self.0.increment(key, ttl)?) } } diff --git a/server/src/lib.rs b/server/src/lib.rs index 7d0e829..a525243 100644 --- a/server/src/lib.rs +++ b/server/src/lib.rs @@ -18,15 +18,8 @@ pub mod utils; pub use app::{serve, serve_on, serve_on_with_shutdown}; -/// 初始化加密密钥 +/// Initialize the response-encryption key used by the transitional HTTP API. pub async fn init_encrypt_secret(config: &IConfig) { let key_path = &config.app.encrypt_secret_location; utils::init_rsa_secret(key_path).await; } - -/// 初始化对象存储 -pub async fn init_storage(config: &IConfig) { - utils::init_storage(&config.storage) - .await - .expect("初始化对象存储客户端失败"); -} diff --git a/server/src/svc_ctx.rs b/server/src/svc_ctx.rs index e45f392..bd84a75 100644 --- a/server/src/svc_ctx.rs +++ b/server/src/svc_ctx.rs @@ -15,11 +15,7 @@ pub struct SvcCtx { impl SvcCtx { pub async fn new(config: &IConfig) -> Result { let db = Self::create_db(&config.database).await?; - let cache = if let Some(redis) = &config.redis { - CacheStore::redis(&redis.url).await? - } else { - CacheStore::memory() - }; + let cache = CacheStore::memory(); Ok(Self { config: config.clone(), diff --git a/server/src/utils/config.rs b/server/src/utils/config.rs index afddd9b..a964ca4 100644 --- a/server/src/utils/config.rs +++ b/server/src/utils/config.rs @@ -1,6 +1,5 @@ use config::{Config, ConfigError}; use serde::Deserialize; -use std::path::{Path, PathBuf}; /// 数据库配置 #[derive(Debug, Clone, Deserialize)] @@ -13,53 +12,17 @@ pub struct DatabaseConfig { pub idle_timeout: u64, } -/// Redis配置 -#[derive(Debug, Clone, Deserialize)] -pub struct RedisConfig { - pub url: String, -} - -/// 对象存储配置 -#[derive(Debug, Clone, Copy, Default, Deserialize, PartialEq, Eq)] -#[serde(rename_all = "snake_case")] -pub enum StorageBackend { - Local, - #[default] - S3, -} - +/// Download-resource URL configuration retained by the transitional HTTP API. #[derive(Debug, Clone, Deserialize)] pub struct StorageConfig { - #[serde(default)] - pub backend: StorageBackend, - #[serde(default)] - pub endpoint: String, #[serde(default)] pub public_base_url: String, - #[serde(default)] - pub access_key: String, - #[serde(default)] - pub secret_access_key: String, - #[serde(default = "default_storage_bucket")] - pub bucket: String, #[serde(default = "default_avatar_root")] pub avatar_root: String, #[serde(default = "default_extension_root")] pub extension_root: String, #[serde(default = "default_version_root")] pub version_root: String, - #[serde(default)] - pub local_path: Option, -} - -impl StorageConfig { - pub fn local_root(&self) -> &Path { - self.local_path.as_deref().unwrap_or_else(|| Path::new("./data/storage")) - } -} - -fn default_storage_bucket() -> String { - "simprint-client".to_string() } fn default_avatar_root() -> String { @@ -126,7 +89,6 @@ pub struct AppConfig { pub struct IConfig { pub app: AppConfig, pub database: DatabaseConfig, - pub redis: Option, pub storage: StorageConfig, pub smtp: Option, #[serde(default = "default_workspace_quota_config")] diff --git a/server/src/utils/storage.rs b/server/src/utils/storage.rs index 88e50b2..1f67535 100644 --- a/server/src/utils/storage.rs +++ b/server/src/utils/storage.rs @@ -1,149 +1,6 @@ -use std::{ - path::{Component, Path, PathBuf}, - sync::Arc, -}; - -use bytes::Bytes; -use minio::s3::{ - client::ClientBuilder, creds::StaticProvider, http::BaseUrl, segmented_bytes::SegmentedBytes, - types::S3Api, -}; -use tokio::sync::OnceCell; - -use crate::utils::{StorageBackend, StorageConfig}; +//! Helpers for turning stored resource paths into download URLs. +//! +//! The imported service never exposed an upload API, so object-storage +//! clients do not belong in the local-first application. pub mod get_objects; -pub mod put_objects; -pub mod remove_objects; - -pub struct ObjectStorage { - backend: ObjectStorageBackend, -} - -enum ObjectStorageBackend { - Local { root: PathBuf }, - S3 { client: minio::s3::client::Client }, -} - -impl ObjectStorage { - async fn new(config: &StorageConfig) -> Result { - let backend = match config.backend { - StorageBackend::Local => { - let root = config.local_root().to_path_buf(); - tokio::fs::create_dir_all(&root).await?; - ObjectStorageBackend::Local { root } - } - StorageBackend::S3 => { - let base_url = config.endpoint.parse::()?; - let provider = - StaticProvider::new(&config.access_key, &config.secret_access_key, None); - let client = - ClientBuilder::new(base_url).provider(Some(Box::new(provider))).build()?; - ObjectStorageBackend::S3 { client } - } - }; - - Ok(Self { backend }) - } - - pub async fn put_object( - &self, - bucket: &str, - object_path: &str, - data: Bytes, - ) -> Result<(), anyhow::Error> { - match &self.backend { - ObjectStorageBackend::Local { root } => { - let bucket_root = safe_join(root, bucket)?; - let file_path = safe_join(&bucket_root, object_path)?; - if let Some(parent) = file_path.parent() { - tokio::fs::create_dir_all(parent).await?; - } - tokio::fs::write(file_path, data).await?; - } - ObjectStorageBackend::S3 { client } => { - let data = SegmentedBytes::from(data); - client.put_object(bucket, object_path, data).send().await?; - } - } - Ok(()) - } -} - -static STORAGE: OnceCell> = OnceCell::const_new(); - -pub async fn init_storage( - config: &StorageConfig, -) -> Result<&'static Arc, anyhow::Error> { - STORAGE - .get_or_try_init(|| async { ObjectStorage::new(config).await.map(Arc::new) }) - .await -} - -pub fn get_storage() -> Result<&'static Arc, anyhow::Error> { - STORAGE.get().ok_or_else(|| anyhow::anyhow!("storage not initialized")) -} - -fn safe_join(root: &Path, relative_path: &str) -> Result { - let mut output = root.to_path_buf(); - for component in Path::new(relative_path).components() { - match component { - Component::Normal(segment) => output.push(segment), - Component::CurDir => {} - _ => return Err(anyhow::anyhow!("invalid storage path: {relative_path}")), - } - } - Ok(output) -} - -#[cfg(test)] -mod tests { - use super::{ObjectStorage, safe_join}; - use crate::utils::{StorageBackend, StorageConfig}; - use bytes::Bytes; - use std::path::Path; - - #[test] - fn local_storage_paths_stay_inside_the_root() { - let root = Path::new("storage-root"); - assert_eq!( - safe_join(root, "bucket/extensions/file.crx").unwrap(), - root.join("bucket/extensions/file.crx") - ); - assert!(safe_join(root, "../outside").is_err()); - assert!(safe_join(root, "/absolute").is_err()); - } - - #[tokio::test] - async fn local_storage_writes_objects_below_its_root() { - let root = std::env::temp_dir().join(format!("simprint-storage-{}", uuid::Uuid::new_v4())); - let config = StorageConfig { - backend: StorageBackend::Local, - endpoint: String::new(), - public_base_url: String::new(), - access_key: String::new(), - secret_access_key: String::new(), - bucket: "bucket".to_string(), - avatar_root: "avatars".to_string(), - extension_root: "extensions".to_string(), - version_root: "versions".to_string(), - local_path: Some(root.clone()), - }; - let storage = ObjectStorage::new(&config).await.unwrap(); - - storage - .put_object( - "bucket", - "extensions/file.crx", - Bytes::from_static(b"content"), - ) - .await - .unwrap(); - - assert_eq!( - tokio::fs::read(root.join("bucket/extensions/file.crx")).await.unwrap(), - b"content" - ); - tokio::fs::remove_dir_all(root).await.unwrap(); - } -} diff --git a/server/src/utils/storage/put_objects.rs b/server/src/utils/storage/put_objects.rs deleted file mode 100644 index 56ed9cd..0000000 --- a/server/src/utils/storage/put_objects.rs +++ /dev/null @@ -1,143 +0,0 @@ -//! 对象存储上传模块 -//! -//! 提供各类资源上传到对象存储的功能 - -use bytes::Bytes; -use sha2::{Digest, Sha256}; - -use super::get_storage; - -/// 上传通用对象到对象存储 -/// -/// # Arguments -/// - `bucket_name`: 存储桶名称 -/// - `object_path`: 对象路径(如 `hash/version/filename`) -/// - `data`: 对象数据 -pub async fn put_object( - bucket_name: &str, - object_path: &str, - data: Bytes, -) -> Result<(), anyhow::Error> { - get_storage()?.put_object(bucket_name, object_path, data).await -} - -/// 上传扩展 CRX 文件到对象存储 -/// -/// 存储路径格式:`{extension_root}/{extension_id_hash}/{version}/{crx_hash}.crx` -/// -/// # Arguments -/// - `bucket_name`: 存储桶名称 -/// - `extension_root`: 扩展对象根路径 -/// - `extension_id`: 扩展 ID(如 Chrome 扩展 ID) -/// - `version`: 扩展版本号 -/// - `crx_hash`: CRX 文件内容的哈希值 -/// - `data`: CRX 文件数据 -/// -/// # Returns -/// 返回存储的对象路径(不包含 bucket 名称) -pub async fn put_extension_crx( - bucket_name: &str, - extension_root: &str, - extension_id: &str, - version: &str, - crx_hash: &str, - data: Bytes, -) -> Result { - // 计算扩展 ID 的哈希作为目录名 - let extension_id_hash = calculate_short_hash(extension_id); - - // 构建对象路径:{extension_root}/{extension_id_hash}/{version}/{crx_hash}.crx - let object_path = format!( - "{}/{}.crx", - join_root(extension_root, &format!("{}/{}", extension_id_hash, version)), - crx_hash - ); - - get_storage()?.put_object(bucket_name, &object_path, data).await?; - - tracing::info!( - "Extension CRX uploaded: bucket={}, path={}", - bucket_name, - object_path - ); - - Ok(object_path) -} - -/// 上传扩展图标到对象存储 -/// -/// 存储路径格式:`{extension_root}/{extension_id_hash}/icons/{icon_hash}.{ext}` -/// -/// # Arguments -/// - `bucket_name`: 存储桶名称 -/// - `extension_root`: 扩展对象根路径 -/// - `extension_id`: 扩展 ID -/// - `icon_hash`: 图标文件的哈希值 -/// - `extension`: 文件扩展名(如 `png`、`svg`) -/// - `data`: 图标数据 -/// -/// # Returns -/// 返回存储的对象路径 -pub async fn put_extension_icon( - bucket_name: &str, - extension_root: &str, - extension_id: &str, - icon_hash: &str, - extension: &str, - data: Bytes, -) -> Result { - let extension_id_hash = calculate_short_hash(extension_id); - let object_path = format!( - "{}/icons/{}.{}", - join_root(extension_root, &extension_id_hash), - icon_hash, - extension - ); - - get_storage()?.put_object(bucket_name, &object_path, data).await?; - - tracing::info!( - "Extension icon uploaded: bucket={}, path={}", - bucket_name, - object_path - ); - - Ok(object_path) -} - -/// 上传头像对象到对象存储 -pub async fn put_avatar_object( - bucket_name: &str, - avatar_root: &str, - object: &str, - data: Bytes, -) -> Result<(), anyhow::Error> { - let object_path = join_root(avatar_root, object); - get_storage()?.put_object(bucket_name, &object_path, data).await -} - -fn join_root(root: &str, object_path: &str) -> String { - format!( - "{}/{}", - root.trim_matches('/'), - object_path.trim_start_matches('/') - ) -} - -/// 计算字符串的短哈希值(用于目录命名) -/// -/// 使用 SHA256 取前 16 个字符 -fn calculate_short_hash(input: &str) -> String { - let mut hasher = Sha256::new(); - hasher.update(input.as_bytes()); - let result = hasher.finalize(); - hex::encode(&result[..8]) // 取前 8 字节 = 16 个十六进制字符 -} - -/// 计算文件内容的哈希值(SHA256) -pub fn calculate_file_hash(data: &[u8]) -> String { - let mut hasher = Sha256::new(); - hasher.update(data); - let result = hasher.finalize(); - hex::encode(result) -} diff --git a/server/src/utils/storage/remove_objects.rs b/server/src/utils/storage/remove_objects.rs deleted file mode 100644 index 352e6f6..0000000 --- a/server/src/utils/storage/remove_objects.rs +++ /dev/null @@ -1,13 +0,0 @@ -// use minio::s3::types::S3Api; - -// /// 从对象存储中删除对象 -// pub async fn remove_csgo_mvp_music_object( -// bucket_name: &str, -// object: &str, -// ) -> Result<(), anyhow::Error> { -// let client = crate::minios::get_storage_client()?; - -// client.delete_object(bucket_name, object).send().await?; - -// Ok(()) -// } From de1ddafdc9067562f4c7f98576cc5504b33d6f33 Mon Sep 17 00:00:00 2001 From: lius-new Date: Wed, 12 Aug 2026 11:33:23 +0800 Subject: [PATCH 52/60] refactor: copy server business into Tauri core Preserve the existing implementation, SQL models, migrations, and service code byte-for-byte under src-tauri/crates/business before adapting or removing the standalone server shell. --- src-tauri/crates/business/.cargo/config.toml | 2 + src-tauri/crates/business/.dockerignore | 51 + src-tauri/crates/business/.editorconfig | 22 + .../.github/workflows/publish-ghcr-image.yml | 78 + src-tauri/crates/business/.gitignore | 39 + src-tauri/crates/business/Cargo.toml | 86 + src-tauri/crates/business/Dockerfile | 36 + src-tauri/crates/business/LICENSE | 662 +++++++ src-tauri/crates/business/README.md | 125 ++ src-tauri/crates/business/README.zh-CN.md | 125 ++ .../business/assets/secret/private_key.pem | 27 + .../business/assets/secret/public_key.pem | 8 + src-tauri/crates/business/build_docker.py | 421 +++++ src-tauri/crates/business/clippy.toml | 7 + .../business/configs/config.example.toml | 44 + .../configs/config.local.example.toml | 40 + src-tauri/crates/business/docker-compose.yml | 68 + .../crates/business/docs/database-design.md | 109 ++ .../docs/extension-system-refactor.md | 256 +++ .../business/docs/public-key-endpoint-plan.md | 279 +++ ...rkspace-architecture-refactoring-report.md | 686 +++++++ .../business/docs/workspace-architecture.md | 884 +++++++++ .../20250101000001_create_users.sql | 29 + .../20250101000002_create_user_infos.sql | 31 + ...50101000003_create_machine_users_table.sql | 41 + ...50101000004_create_version_types_table.sql | 26 + .../20250101000005_create_versions_table.sql | 53 + ...50101000006_create_gray_releases_table.sql | 44 + ...0101000007_create_gray_resources_table.sql | 24 + ..._create_machine_gray_allocations_table.sql | 33 + ...250101000009_create_maintenances_table.sql | 33 + ...0101000010_create_strategy_types_table.sql | 31 + ...50101000011_insert_strategy_types_data.sql | 63 + .../20250120000001_create_teams.sql | 36 + .../20250120000002_create_team_members.sql | 39 + ...20250120000003_create_team_invitations.sql | 37 + .../20250120000004_create_login_history.sql | 29 + ...0005_alter_user_infos_add_current_team.sql | 22 + .../20250120000006_create_groups.sql | 39 + .../migrations/20250120000007_create_tags.sql | 31 + .../20250120000008_create_proxies.sql | 49 + ...50120000009_create_proxy_health_checks.sql | 23 + .../20250120000010_create_environments.sql | 48 + ...50120000011_create_environment_configs.sql | 33 + ...20250120000012_create_environment_tags.sql | 21 + ...20250120000013_create_environment_urls.sql | 18 + ...50120000014_create_environment_cookies.sql | 24 + .../20250120000015_create_templates.sql | 38 + ...0250120000016_create_platform_accounts.sql | 40 + ...0120000017_create_environment_accounts.sql | 22 + .../20250120000018_create_extensions.sql | 48 + .../20250120000019_create_user_extensions.sql | 28 + .../20250120000020_create_team_extensions.sql | 31 + ...20250120000021_create_group_extensions.sql | 31 + ...20000022_create_environment_extensions.sql | 28 + .../20250120000023_create_plans.sql | 40 + .../20250120000024_create_plan_features.sql | 25 + .../20250120000025_create_subscriptions.sql | 40 + .../20250120000026_create_user_wallets.sql | 27 + ...50120000027_create_wallet_transactions.sql | 33 + .../20250120000028_create_invoices.sql | 44 + .../20250120000029_create_user_quotas.sql | 31 + .../20250120000030_create_coupons.sql | 42 + .../20250120000031_create_coupon_usages.sql | 21 + .../20250120000032_create_payment_orders.sql | 50 + ...120000033_create_auto_renewal_services.sql | 36 + .../20250120000034_create_rpa_tasks.sql | 59 + .../20250120000035_create_rpa_task_steps.sql | 39 + ...120000036_create_rpa_task_environments.sql | 20 + .../20250120000037_create_rpa_task_runs.sql | 32 + ...50120000038_create_referral_link_tiers.sql | 31 + .../20250120000039_create_referral_links.sql | 42 + .../20250120000040_create_user_referrals.sql | 32 + ...20250120000041_create_referral_rewards.sql | 33 + ...0120000042_create_user_referral_points.sql | 28 + .../20250120000043_create_redeem_options.sql | 36 + .../20250120000044_create_redeem_records.sql | 29 + .../20250120000045_create_audit_logs.sql | 37 + .../20250120000046_create_api_keys.sql | 43 + ...20250120000047_create_user_preferences.sql | 26 + .../20250120000048_create_system_configs.sql | 23 + .../20250120000049_add_foreign_keys.sql | 32 + .../20250120000050_insert_default_data.sql | 37 + ...120000051_remove_icon_and_color_fields.sql | 10 + .../20250123000001_create_console_admins.sql | 28 + ...50123000002_create_console_permissions.sql | 51 + ...20250123000003_create_console_api_keys.sql | 41 + .../20250124000001_create_messages.sql | 148 ++ ...002_add_deleted_at_to_team_invitations.sql | 9 + .../20250125000001_create_workspaces.sql | 34 + ...20250125000002_create_workspace_quotas.sql | 43 + ...50125000003_create_proxy_visible_teams.sql | 28 + ...000004_create_group_member_permissions.sql | 45 + ...250125000005_alter_teams_add_workspace.sql | 19 + ...00006_alter_team_members_add_workspace.sql | 21 + ...50125000007_alter_groups_add_workspace.sql | 25 + ...00008_alter_environments_add_workspace.sql | 13 + ...0125000009_alter_proxies_add_workspace.sql | 29 + ...0010_alter_subscriptions_add_workspace.sql | 13 + .../20250125000011_deprecate_user_quotas.sql | 8 + .../20250125000012_migrate_to_workspaces.sql | 416 +++++ ...alter_user_infos_add_current_workspace.sql | 44 + ...001_insert_default_plans_with_features.sql | 432 +++++ .../20250126000002_create_user_coupons.sql | 27 + .../20250126000003_insert_default_coupons.sql | 37 + ...0127000001_add_coupon_name_description.sql | 13 + ...127000002_reset_referral_default_tiers.sql | 25 + ...50127000003_create_machine_tags_tables.sql | 53 + ...ter_version_types_add_is_auto_download.sql | 7 + ...00002_alter_versions_add_kernel_fields.sql | 16 + ...204000003_insert_default_version_types.sql | 8 + ...3000001_add_updated_at_to_user_coupons.sql | 9 + ...6000001_fix_extensions_status_comments.sql | 18 + ...0306000002_drop_environment_extensions.sql | 12 + ...add_is_team_shared_to_group_extensions.sql | 11 + ...date_extension_status_support_disabled.sql | 7 + ...create_user_team_extension_preferences.sql | 23 + .../20260308000001_add_hash_to_extensions.sql | 8 + ..._alter_versions_require_publish_fields.sql | 15 + ...000001_create_local_api_service_tables.sql | 109 ++ ...2_initialize_local_api_default_configs.sql | 13 + ...reate_local_api_permission_definitions.sql | 25 + ...alize_local_api_permission_definitions.sql | 55 + ...000005_replace_masked_key_with_api_key.sql | 9 + ...001_replace_encrypted_password_columns.sql | 11 + ...506000002_drop_machine_and_gray_tables.sql | 6 + ..._insert_default_browser_kernel_version.sql | 67 + ..._add_site_input_to_environment_cookies.sql | 5 + src-tauri/crates/business/rustfmt.toml | 17 + src-tauri/crates/business/src/app.rs | 113 ++ .../crates/business/src/caches/local_api.rs | 204 ++ src-tauri/crates/business/src/caches/mod.rs | 7 + src-tauri/crates/business/src/caches/store.rs | 221 +++ src-tauri/crates/business/src/caches/user.rs | 75 + src-tauri/crates/business/src/cli.rs | 33 + src-tauri/crates/business/src/database.rs | 25 + src-tauri/crates/business/src/dto/accounts.rs | 35 + src-tauri/crates/business/src/dto/audit.rs | 26 + src-tauri/crates/business/src/dto/billing.rs | 236 +++ .../crates/business/src/dto/environments.rs | 307 +++ .../crates/business/src/dto/extensions.rs | 128 ++ .../src/dto/group_member_permissions.rs | 32 + .../crates/business/src/dto/local_api.rs | 97 + .../crates/business/src/dto/maintenance.rs | 49 + src-tauri/crates/business/src/dto/messages.rs | 60 + src-tauri/crates/business/src/dto/mod.rs | 47 + src-tauri/crates/business/src/dto/proxies.rs | 43 + .../business/src/dto/proxy_visible_teams.rs | 23 + src-tauri/crates/business/src/dto/referral.rs | 153 ++ src-tauri/crates/business/src/dto/rpa.rs | 83 + .../crates/business/src/dto/strategy_types.rs | 92 + src-tauri/crates/business/src/dto/system.rs | 27 + src-tauri/crates/business/src/dto/teams.rs | 84 + src-tauri/crates/business/src/dto/user.rs | 31 + .../crates/business/src/dto/version_types.rs | 14 + src-tauri/crates/business/src/dto/versions.rs | 31 + .../business/src/dto/workspace_quotas.rs | 20 + .../crates/business/src/dto/workspaces.rs | 25 + .../crates/business/src/entitys/accounts.rs | 77 + .../crates/business/src/entitys/audit.rs | 83 + .../crates/business/src/entitys/billing.rs | 247 +++ .../business/src/entitys/browser_kernel.rs | 10 + .../crates/business/src/entitys/common.rs | 65 + .../business/src/entitys/environments.rs | 235 +++ .../crates/business/src/entitys/extensions.rs | 228 +++ .../src/entitys/group_member_permissions.rs | 54 + .../crates/business/src/entitys/groups.rs | 36 + .../crates/business/src/entitys/local_api.rs | 23 + .../business/src/entitys/maintenance.rs | 42 + .../crates/business/src/entitys/messages.rs | 71 + src-tauri/crates/business/src/entitys/mod.rs | 57 + .../crates/business/src/entitys/proxies.rs | 80 + .../src/entitys/proxy_visible_teams.rs | 50 + .../crates/business/src/entitys/referral.rs | 141 ++ src-tauri/crates/business/src/entitys/rpa.rs | 135 ++ .../crates/business/src/entitys/settings.rs | 30 + .../business/src/entitys/strategy_types.rs | 32 + src-tauri/crates/business/src/entitys/tags.rs | 28 + .../crates/business/src/entitys/teams.rs | 130 ++ .../crates/business/src/entitys/templates.rs | 94 + src-tauri/crates/business/src/entitys/user.rs | 132 ++ .../business/src/entitys/version_types.rs | 69 + .../crates/business/src/entitys/versions.rs | 132 ++ .../business/src/entitys/workspace_quotas.rs | 34 + .../crates/business/src/entitys/workspaces.rs | 57 + src-tauri/crates/business/src/errors.rs | 105 ++ src-tauri/crates/business/src/handlers.rs | 49 + .../crates/business/src/handlers/accounts.rs | 145 ++ .../crates/business/src/handlers/audit.rs | 98 + .../crates/business/src/handlers/billing.rs | 409 ++++ .../business/src/handlers/browser_kernel.rs | 23 + .../business/src/handlers/environments.rs | 815 ++++++++ .../business/src/handlers/extensions.rs | 242 +++ .../src/handlers/group_permissions.rs | 138 ++ .../crates/business/src/handlers/health.rs | 15 + .../crates/business/src/handlers/local_api.rs | 62 + .../crates/business/src/handlers/messages.rs | 133 ++ .../business/src/handlers/preferences.rs | 38 + .../crates/business/src/handlers/proxies.rs | 174 ++ .../business/src/handlers/proxy_visibility.rs | 150 ++ .../crates/business/src/handlers/referral.rs | 210 +++ src-tauri/crates/business/src/handlers/rpa.rs | 144 ++ .../crates/business/src/handlers/secret.rs | 15 + .../crates/business/src/handlers/teams.rs | 364 ++++ .../crates/business/src/handlers/templates.rs | 157 ++ .../crates/business/src/handlers/time.rs | 10 + .../crates/business/src/handlers/users.rs | 151 ++ .../business/src/handlers/workspace_quotas.rs | 43 + .../business/src/handlers/workspaces.rs | 179 ++ src-tauri/crates/business/src/lib.rs | 25 + src-tauri/crates/business/src/main.rs | 26 + src-tauri/crates/business/src/middlewares.rs | 42 + .../crates/business/src/middlewares/auth.rs | 138 ++ .../crates/business/src/middlewares/cors.rs | 28 + .../business/src/middlewares/decrypt.rs | 152 ++ .../business/src/middlewares/encrypt.rs | 126 ++ .../src/middlewares/local_api_auth.rs | 219 +++ .../crates/business/src/middlewares/logger.rs | 67 + .../business/src/middlewares/real_ip.rs | 47 + .../crates/business/src/models/accounts.rs | 323 ++++ src-tauri/crates/business/src/models/audit.rs | 242 +++ .../crates/business/src/models/billing.rs | 1073 +++++++++++ .../business/src/models/environments.rs | 1662 +++++++++++++++++ .../crates/business/src/models/extensions.rs | 614 ++++++ .../src/models/group_member_permissions.rs | 199 ++ .../crates/business/src/models/local_api.rs | 343 ++++ .../crates/business/src/models/maintenance.rs | 180 ++ .../crates/business/src/models/messages.rs | 413 ++++ src-tauri/crates/business/src/models/mod.rs | 43 + .../crates/business/src/models/preferences.rs | 54 + .../crates/business/src/models/proxies.rs | 304 +++ .../src/models/proxy_visible_teams.rs | 288 +++ .../crates/business/src/models/referral.rs | 569 ++++++ src-tauri/crates/business/src/models/rpa.rs | 543 ++++++ .../business/src/models/strategy_types.rs | 58 + src-tauri/crates/business/src/models/teams.rs | 839 +++++++++ src-tauri/crates/business/src/models/user.rs | 533 ++++++ .../business/src/models/version_types.rs | 126 ++ .../crates/business/src/models/versions.rs | 551 ++++++ .../business/src/models/workspace_quotas.rs | 235 +++ .../crates/business/src/models/workspaces.rs | 123 ++ src-tauri/crates/business/src/routes.rs | 169 ++ .../crates/business/src/routes/accounts.rs | 37 + src-tauri/crates/business/src/routes/audit.rs | 31 + .../crates/business/src/routes/billing.rs | 108 ++ .../business/src/routes/browser_kernel.rs | 16 + .../business/src/routes/environments.rs | 172 ++ .../crates/business/src/routes/extensions.rs | 64 + .../business/src/routes/group_permissions.rs | 28 + .../crates/business/src/routes/health.rs | 13 + .../crates/business/src/routes/local_api.rs | 27 + .../crates/business/src/routes/messages.rs | 45 + .../crates/business/src/routes/preferences.rs | 20 + .../crates/business/src/routes/proxies.rs | 37 + .../business/src/routes/proxy_visibility.rs | 32 + .../crates/business/src/routes/referral.rs | 82 + src-tauri/crates/business/src/routes/rpa.rs | 44 + .../crates/business/src/routes/secret.rs | 16 + src-tauri/crates/business/src/routes/teams.rs | 61 + .../crates/business/src/routes/templates.rs | 40 + src-tauri/crates/business/src/routes/time.rs | 12 + src-tauri/crates/business/src/routes/users.rs | 56 + .../business/src/routes/workspace_quotas.rs | 20 + .../crates/business/src/routes/workspaces.rs | 36 + src-tauri/crates/business/src/services.rs | 62 + .../crates/business/src/services/accounts.rs | 210 +++ .../crates/business/src/services/audit.rs | 352 ++++ .../crates/business/src/services/billing.rs | 122 ++ .../business/src/services/browser_kernel.rs | 41 + .../crates/business/src/services/coupons.rs | 171 ++ .../business/src/services/environments.rs | 1621 ++++++++++++++++ .../business/src/services/extensions.rs | 1030 ++++++++++ .../src/services/group_permissions.rs | 139 ++ .../crates/business/src/services/groups.rs | 158 ++ .../crates/business/src/services/local_api.rs | 459 +++++ .../business/src/services/maintenance.rs | 78 + .../crates/business/src/services/messages.rs | 187 ++ .../crates/business/src/services/orders.rs | 83 + .../crates/business/src/services/plans.rs | 152 ++ .../business/src/services/preferences.rs | 49 + .../crates/business/src/services/proxies.rs | 234 +++ .../business/src/services/proxy_visibility.rs | 143 ++ .../crates/business/src/services/referral.rs | 420 +++++ src-tauri/crates/business/src/services/rpa.rs | 354 ++++ .../business/src/services/strategy_types.rs | 50 + .../business/src/services/subscriptions.rs | 275 +++ .../crates/business/src/services/tags.rs | 64 + .../crates/business/src/services/teams.rs | 495 +++++ .../crates/business/src/services/templates.rs | 381 ++++ .../crates/business/src/services/time.rs | 4 + .../crates/business/src/services/users.rs | 498 +++++ .../business/src/services/version_types.rs | 123 ++ .../crates/business/src/services/versions.rs | 240 +++ .../crates/business/src/services/wallet.rs | 103 + .../business/src/services/workspace_quotas.rs | 72 + .../business/src/services/workspaces.rs | 184 ++ src-tauri/crates/business/src/state.rs | 82 + src-tauri/crates/business/src/svc_ctx.rs | 30 + src-tauri/crates/business/src/utils.rs | 50 + src-tauri/crates/business/src/utils/config.rs | 114 ++ .../crates/business/src/utils/extractor.rs | 43 + src-tauri/crates/business/src/utils/jwt.rs | 59 + .../crates/business/src/utils/password.rs | 26 + .../crates/business/src/utils/responses.rs | 126 ++ .../crates/business/src/utils/secret/aes.rs | 216 +++ .../crates/business/src/utils/secret/mod.rs | 5 + .../crates/business/src/utils/secret/rsa.rs | 173 ++ .../crates/business/src/utils/storage.rs | 6 + .../business/src/utils/storage/get_objects.rs | 84 + 309 files changed, 37690 insertions(+) create mode 100644 src-tauri/crates/business/.cargo/config.toml create mode 100644 src-tauri/crates/business/.dockerignore create mode 100644 src-tauri/crates/business/.editorconfig create mode 100644 src-tauri/crates/business/.github/workflows/publish-ghcr-image.yml create mode 100644 src-tauri/crates/business/.gitignore create mode 100644 src-tauri/crates/business/Cargo.toml create mode 100644 src-tauri/crates/business/Dockerfile create mode 100644 src-tauri/crates/business/LICENSE create mode 100644 src-tauri/crates/business/README.md create mode 100644 src-tauri/crates/business/README.zh-CN.md create mode 100644 src-tauri/crates/business/assets/secret/private_key.pem create mode 100644 src-tauri/crates/business/assets/secret/public_key.pem create mode 100644 src-tauri/crates/business/build_docker.py create mode 100644 src-tauri/crates/business/clippy.toml create mode 100644 src-tauri/crates/business/configs/config.example.toml create mode 100644 src-tauri/crates/business/configs/config.local.example.toml create mode 100644 src-tauri/crates/business/docker-compose.yml create mode 100644 src-tauri/crates/business/docs/database-design.md create mode 100644 src-tauri/crates/business/docs/extension-system-refactor.md create mode 100644 src-tauri/crates/business/docs/public-key-endpoint-plan.md create mode 100644 src-tauri/crates/business/docs/workspace-architecture-refactoring-report.md create mode 100644 src-tauri/crates/business/docs/workspace-architecture.md create mode 100644 src-tauri/crates/business/migrations/20250101000001_create_users.sql create mode 100644 src-tauri/crates/business/migrations/20250101000002_create_user_infos.sql create mode 100644 src-tauri/crates/business/migrations/20250101000003_create_machine_users_table.sql create mode 100644 src-tauri/crates/business/migrations/20250101000004_create_version_types_table.sql create mode 100644 src-tauri/crates/business/migrations/20250101000005_create_versions_table.sql create mode 100644 src-tauri/crates/business/migrations/20250101000006_create_gray_releases_table.sql create mode 100644 src-tauri/crates/business/migrations/20250101000007_create_gray_resources_table.sql create mode 100644 src-tauri/crates/business/migrations/20250101000008_create_machine_gray_allocations_table.sql create mode 100644 src-tauri/crates/business/migrations/20250101000009_create_maintenances_table.sql create mode 100644 src-tauri/crates/business/migrations/20250101000010_create_strategy_types_table.sql create mode 100644 src-tauri/crates/business/migrations/20250101000011_insert_strategy_types_data.sql create mode 100644 src-tauri/crates/business/migrations/20250120000001_create_teams.sql create mode 100644 src-tauri/crates/business/migrations/20250120000002_create_team_members.sql create mode 100644 src-tauri/crates/business/migrations/20250120000003_create_team_invitations.sql create mode 100644 src-tauri/crates/business/migrations/20250120000004_create_login_history.sql create mode 100644 src-tauri/crates/business/migrations/20250120000005_alter_user_infos_add_current_team.sql create mode 100644 src-tauri/crates/business/migrations/20250120000006_create_groups.sql create mode 100644 src-tauri/crates/business/migrations/20250120000007_create_tags.sql create mode 100644 src-tauri/crates/business/migrations/20250120000008_create_proxies.sql create mode 100644 src-tauri/crates/business/migrations/20250120000009_create_proxy_health_checks.sql create mode 100644 src-tauri/crates/business/migrations/20250120000010_create_environments.sql create mode 100644 src-tauri/crates/business/migrations/20250120000011_create_environment_configs.sql create mode 100644 src-tauri/crates/business/migrations/20250120000012_create_environment_tags.sql create mode 100644 src-tauri/crates/business/migrations/20250120000013_create_environment_urls.sql create mode 100644 src-tauri/crates/business/migrations/20250120000014_create_environment_cookies.sql create mode 100644 src-tauri/crates/business/migrations/20250120000015_create_templates.sql create mode 100644 src-tauri/crates/business/migrations/20250120000016_create_platform_accounts.sql create mode 100644 src-tauri/crates/business/migrations/20250120000017_create_environment_accounts.sql create mode 100644 src-tauri/crates/business/migrations/20250120000018_create_extensions.sql create mode 100644 src-tauri/crates/business/migrations/20250120000019_create_user_extensions.sql create mode 100644 src-tauri/crates/business/migrations/20250120000020_create_team_extensions.sql create mode 100644 src-tauri/crates/business/migrations/20250120000021_create_group_extensions.sql create mode 100644 src-tauri/crates/business/migrations/20250120000022_create_environment_extensions.sql create mode 100644 src-tauri/crates/business/migrations/20250120000023_create_plans.sql create mode 100644 src-tauri/crates/business/migrations/20250120000024_create_plan_features.sql create mode 100644 src-tauri/crates/business/migrations/20250120000025_create_subscriptions.sql create mode 100644 src-tauri/crates/business/migrations/20250120000026_create_user_wallets.sql create mode 100644 src-tauri/crates/business/migrations/20250120000027_create_wallet_transactions.sql create mode 100644 src-tauri/crates/business/migrations/20250120000028_create_invoices.sql create mode 100644 src-tauri/crates/business/migrations/20250120000029_create_user_quotas.sql create mode 100644 src-tauri/crates/business/migrations/20250120000030_create_coupons.sql create mode 100644 src-tauri/crates/business/migrations/20250120000031_create_coupon_usages.sql create mode 100644 src-tauri/crates/business/migrations/20250120000032_create_payment_orders.sql create mode 100644 src-tauri/crates/business/migrations/20250120000033_create_auto_renewal_services.sql create mode 100644 src-tauri/crates/business/migrations/20250120000034_create_rpa_tasks.sql create mode 100644 src-tauri/crates/business/migrations/20250120000035_create_rpa_task_steps.sql create mode 100644 src-tauri/crates/business/migrations/20250120000036_create_rpa_task_environments.sql create mode 100644 src-tauri/crates/business/migrations/20250120000037_create_rpa_task_runs.sql create mode 100644 src-tauri/crates/business/migrations/20250120000038_create_referral_link_tiers.sql create mode 100644 src-tauri/crates/business/migrations/20250120000039_create_referral_links.sql create mode 100644 src-tauri/crates/business/migrations/20250120000040_create_user_referrals.sql create mode 100644 src-tauri/crates/business/migrations/20250120000041_create_referral_rewards.sql create mode 100644 src-tauri/crates/business/migrations/20250120000042_create_user_referral_points.sql create mode 100644 src-tauri/crates/business/migrations/20250120000043_create_redeem_options.sql create mode 100644 src-tauri/crates/business/migrations/20250120000044_create_redeem_records.sql create mode 100644 src-tauri/crates/business/migrations/20250120000045_create_audit_logs.sql create mode 100644 src-tauri/crates/business/migrations/20250120000046_create_api_keys.sql create mode 100644 src-tauri/crates/business/migrations/20250120000047_create_user_preferences.sql create mode 100644 src-tauri/crates/business/migrations/20250120000048_create_system_configs.sql create mode 100644 src-tauri/crates/business/migrations/20250120000049_add_foreign_keys.sql create mode 100644 src-tauri/crates/business/migrations/20250120000050_insert_default_data.sql create mode 100644 src-tauri/crates/business/migrations/20250120000051_remove_icon_and_color_fields.sql create mode 100644 src-tauri/crates/business/migrations/20250123000001_create_console_admins.sql create mode 100644 src-tauri/crates/business/migrations/20250123000002_create_console_permissions.sql create mode 100644 src-tauri/crates/business/migrations/20250123000003_create_console_api_keys.sql create mode 100644 src-tauri/crates/business/migrations/20250124000001_create_messages.sql create mode 100644 src-tauri/crates/business/migrations/20250124000002_add_deleted_at_to_team_invitations.sql create mode 100644 src-tauri/crates/business/migrations/20250125000001_create_workspaces.sql create mode 100644 src-tauri/crates/business/migrations/20250125000002_create_workspace_quotas.sql create mode 100644 src-tauri/crates/business/migrations/20250125000003_create_proxy_visible_teams.sql create mode 100644 src-tauri/crates/business/migrations/20250125000004_create_group_member_permissions.sql create mode 100644 src-tauri/crates/business/migrations/20250125000005_alter_teams_add_workspace.sql create mode 100644 src-tauri/crates/business/migrations/20250125000006_alter_team_members_add_workspace.sql create mode 100644 src-tauri/crates/business/migrations/20250125000007_alter_groups_add_workspace.sql create mode 100644 src-tauri/crates/business/migrations/20250125000008_alter_environments_add_workspace.sql create mode 100644 src-tauri/crates/business/migrations/20250125000009_alter_proxies_add_workspace.sql create mode 100644 src-tauri/crates/business/migrations/20250125000010_alter_subscriptions_add_workspace.sql create mode 100644 src-tauri/crates/business/migrations/20250125000011_deprecate_user_quotas.sql create mode 100644 src-tauri/crates/business/migrations/20250125000012_migrate_to_workspaces.sql create mode 100644 src-tauri/crates/business/migrations/20250125000013_alter_user_infos_add_current_workspace.sql create mode 100644 src-tauri/crates/business/migrations/20250126000001_insert_default_plans_with_features.sql create mode 100644 src-tauri/crates/business/migrations/20250126000002_create_user_coupons.sql create mode 100644 src-tauri/crates/business/migrations/20250126000003_insert_default_coupons.sql create mode 100644 src-tauri/crates/business/migrations/20250127000001_add_coupon_name_description.sql create mode 100644 src-tauri/crates/business/migrations/20250127000002_reset_referral_default_tiers.sql create mode 100644 src-tauri/crates/business/migrations/20250127000003_create_machine_tags_tables.sql create mode 100644 src-tauri/crates/business/migrations/20250204000001_alter_version_types_add_is_auto_download.sql create mode 100644 src-tauri/crates/business/migrations/20250204000002_alter_versions_add_kernel_fields.sql create mode 100644 src-tauri/crates/business/migrations/20250204000003_insert_default_version_types.sql create mode 100644 src-tauri/crates/business/migrations/20260213000001_add_updated_at_to_user_coupons.sql create mode 100644 src-tauri/crates/business/migrations/20260306000001_fix_extensions_status_comments.sql create mode 100644 src-tauri/crates/business/migrations/20260306000002_drop_environment_extensions.sql create mode 100644 src-tauri/crates/business/migrations/20260306000003_add_is_team_shared_to_group_extensions.sql create mode 100644 src-tauri/crates/business/migrations/20260306000004_update_extension_status_support_disabled.sql create mode 100644 src-tauri/crates/business/migrations/20260306000005_create_user_team_extension_preferences.sql create mode 100644 src-tauri/crates/business/migrations/20260308000001_add_hash_to_extensions.sql create mode 100644 src-tauri/crates/business/migrations/20260314000001_alter_versions_require_publish_fields.sql create mode 100644 src-tauri/crates/business/migrations/20260316000001_create_local_api_service_tables.sql create mode 100644 src-tauri/crates/business/migrations/20260316000002_initialize_local_api_default_configs.sql create mode 100644 src-tauri/crates/business/migrations/20260316000003_create_local_api_permission_definitions.sql create mode 100644 src-tauri/crates/business/migrations/20260316000004_initialize_local_api_permission_definitions.sql create mode 100644 src-tauri/crates/business/migrations/20260316000005_replace_masked_key_with_api_key.sql create mode 100644 src-tauri/crates/business/migrations/20260506000001_replace_encrypted_password_columns.sql create mode 100644 src-tauri/crates/business/migrations/20260506000002_drop_machine_and_gray_tables.sql create mode 100644 src-tauri/crates/business/migrations/20260508000001_insert_default_browser_kernel_version.sql create mode 100644 src-tauri/crates/business/migrations/20260511000015_add_site_input_to_environment_cookies.sql create mode 100644 src-tauri/crates/business/rustfmt.toml create mode 100644 src-tauri/crates/business/src/app.rs create mode 100644 src-tauri/crates/business/src/caches/local_api.rs create mode 100644 src-tauri/crates/business/src/caches/mod.rs create mode 100644 src-tauri/crates/business/src/caches/store.rs create mode 100644 src-tauri/crates/business/src/caches/user.rs create mode 100644 src-tauri/crates/business/src/cli.rs create mode 100644 src-tauri/crates/business/src/database.rs create mode 100644 src-tauri/crates/business/src/dto/accounts.rs create mode 100644 src-tauri/crates/business/src/dto/audit.rs create mode 100644 src-tauri/crates/business/src/dto/billing.rs create mode 100644 src-tauri/crates/business/src/dto/environments.rs create mode 100644 src-tauri/crates/business/src/dto/extensions.rs create mode 100644 src-tauri/crates/business/src/dto/group_member_permissions.rs create mode 100644 src-tauri/crates/business/src/dto/local_api.rs create mode 100644 src-tauri/crates/business/src/dto/maintenance.rs create mode 100644 src-tauri/crates/business/src/dto/messages.rs create mode 100644 src-tauri/crates/business/src/dto/mod.rs create mode 100644 src-tauri/crates/business/src/dto/proxies.rs create mode 100644 src-tauri/crates/business/src/dto/proxy_visible_teams.rs create mode 100644 src-tauri/crates/business/src/dto/referral.rs create mode 100644 src-tauri/crates/business/src/dto/rpa.rs create mode 100644 src-tauri/crates/business/src/dto/strategy_types.rs create mode 100644 src-tauri/crates/business/src/dto/system.rs create mode 100644 src-tauri/crates/business/src/dto/teams.rs create mode 100644 src-tauri/crates/business/src/dto/user.rs create mode 100644 src-tauri/crates/business/src/dto/version_types.rs create mode 100644 src-tauri/crates/business/src/dto/versions.rs create mode 100644 src-tauri/crates/business/src/dto/workspace_quotas.rs create mode 100644 src-tauri/crates/business/src/dto/workspaces.rs create mode 100644 src-tauri/crates/business/src/entitys/accounts.rs create mode 100644 src-tauri/crates/business/src/entitys/audit.rs create mode 100644 src-tauri/crates/business/src/entitys/billing.rs create mode 100644 src-tauri/crates/business/src/entitys/browser_kernel.rs create mode 100644 src-tauri/crates/business/src/entitys/common.rs create mode 100644 src-tauri/crates/business/src/entitys/environments.rs create mode 100644 src-tauri/crates/business/src/entitys/extensions.rs create mode 100644 src-tauri/crates/business/src/entitys/group_member_permissions.rs create mode 100644 src-tauri/crates/business/src/entitys/groups.rs create mode 100644 src-tauri/crates/business/src/entitys/local_api.rs create mode 100644 src-tauri/crates/business/src/entitys/maintenance.rs create mode 100644 src-tauri/crates/business/src/entitys/messages.rs create mode 100644 src-tauri/crates/business/src/entitys/mod.rs create mode 100644 src-tauri/crates/business/src/entitys/proxies.rs create mode 100644 src-tauri/crates/business/src/entitys/proxy_visible_teams.rs create mode 100644 src-tauri/crates/business/src/entitys/referral.rs create mode 100644 src-tauri/crates/business/src/entitys/rpa.rs create mode 100644 src-tauri/crates/business/src/entitys/settings.rs create mode 100644 src-tauri/crates/business/src/entitys/strategy_types.rs create mode 100644 src-tauri/crates/business/src/entitys/tags.rs create mode 100644 src-tauri/crates/business/src/entitys/teams.rs create mode 100644 src-tauri/crates/business/src/entitys/templates.rs create mode 100644 src-tauri/crates/business/src/entitys/user.rs create mode 100644 src-tauri/crates/business/src/entitys/version_types.rs create mode 100644 src-tauri/crates/business/src/entitys/versions.rs create mode 100644 src-tauri/crates/business/src/entitys/workspace_quotas.rs create mode 100644 src-tauri/crates/business/src/entitys/workspaces.rs create mode 100644 src-tauri/crates/business/src/errors.rs create mode 100644 src-tauri/crates/business/src/handlers.rs create mode 100644 src-tauri/crates/business/src/handlers/accounts.rs create mode 100644 src-tauri/crates/business/src/handlers/audit.rs create mode 100644 src-tauri/crates/business/src/handlers/billing.rs create mode 100644 src-tauri/crates/business/src/handlers/browser_kernel.rs create mode 100644 src-tauri/crates/business/src/handlers/environments.rs create mode 100644 src-tauri/crates/business/src/handlers/extensions.rs create mode 100644 src-tauri/crates/business/src/handlers/group_permissions.rs create mode 100644 src-tauri/crates/business/src/handlers/health.rs create mode 100644 src-tauri/crates/business/src/handlers/local_api.rs create mode 100644 src-tauri/crates/business/src/handlers/messages.rs create mode 100644 src-tauri/crates/business/src/handlers/preferences.rs create mode 100644 src-tauri/crates/business/src/handlers/proxies.rs create mode 100644 src-tauri/crates/business/src/handlers/proxy_visibility.rs create mode 100644 src-tauri/crates/business/src/handlers/referral.rs create mode 100644 src-tauri/crates/business/src/handlers/rpa.rs create mode 100644 src-tauri/crates/business/src/handlers/secret.rs create mode 100644 src-tauri/crates/business/src/handlers/teams.rs create mode 100644 src-tauri/crates/business/src/handlers/templates.rs create mode 100644 src-tauri/crates/business/src/handlers/time.rs create mode 100644 src-tauri/crates/business/src/handlers/users.rs create mode 100644 src-tauri/crates/business/src/handlers/workspace_quotas.rs create mode 100644 src-tauri/crates/business/src/handlers/workspaces.rs create mode 100644 src-tauri/crates/business/src/lib.rs create mode 100644 src-tauri/crates/business/src/main.rs create mode 100644 src-tauri/crates/business/src/middlewares.rs create mode 100644 src-tauri/crates/business/src/middlewares/auth.rs create mode 100644 src-tauri/crates/business/src/middlewares/cors.rs create mode 100644 src-tauri/crates/business/src/middlewares/decrypt.rs create mode 100644 src-tauri/crates/business/src/middlewares/encrypt.rs create mode 100644 src-tauri/crates/business/src/middlewares/local_api_auth.rs create mode 100644 src-tauri/crates/business/src/middlewares/logger.rs create mode 100644 src-tauri/crates/business/src/middlewares/real_ip.rs create mode 100644 src-tauri/crates/business/src/models/accounts.rs create mode 100644 src-tauri/crates/business/src/models/audit.rs create mode 100644 src-tauri/crates/business/src/models/billing.rs create mode 100644 src-tauri/crates/business/src/models/environments.rs create mode 100644 src-tauri/crates/business/src/models/extensions.rs create mode 100644 src-tauri/crates/business/src/models/group_member_permissions.rs create mode 100644 src-tauri/crates/business/src/models/local_api.rs create mode 100644 src-tauri/crates/business/src/models/maintenance.rs create mode 100644 src-tauri/crates/business/src/models/messages.rs create mode 100644 src-tauri/crates/business/src/models/mod.rs create mode 100644 src-tauri/crates/business/src/models/preferences.rs create mode 100644 src-tauri/crates/business/src/models/proxies.rs create mode 100644 src-tauri/crates/business/src/models/proxy_visible_teams.rs create mode 100644 src-tauri/crates/business/src/models/referral.rs create mode 100644 src-tauri/crates/business/src/models/rpa.rs create mode 100644 src-tauri/crates/business/src/models/strategy_types.rs create mode 100644 src-tauri/crates/business/src/models/teams.rs create mode 100644 src-tauri/crates/business/src/models/user.rs create mode 100644 src-tauri/crates/business/src/models/version_types.rs create mode 100644 src-tauri/crates/business/src/models/versions.rs create mode 100644 src-tauri/crates/business/src/models/workspace_quotas.rs create mode 100644 src-tauri/crates/business/src/models/workspaces.rs create mode 100644 src-tauri/crates/business/src/routes.rs create mode 100644 src-tauri/crates/business/src/routes/accounts.rs create mode 100644 src-tauri/crates/business/src/routes/audit.rs create mode 100644 src-tauri/crates/business/src/routes/billing.rs create mode 100644 src-tauri/crates/business/src/routes/browser_kernel.rs create mode 100644 src-tauri/crates/business/src/routes/environments.rs create mode 100644 src-tauri/crates/business/src/routes/extensions.rs create mode 100644 src-tauri/crates/business/src/routes/group_permissions.rs create mode 100644 src-tauri/crates/business/src/routes/health.rs create mode 100644 src-tauri/crates/business/src/routes/local_api.rs create mode 100644 src-tauri/crates/business/src/routes/messages.rs create mode 100644 src-tauri/crates/business/src/routes/preferences.rs create mode 100644 src-tauri/crates/business/src/routes/proxies.rs create mode 100644 src-tauri/crates/business/src/routes/proxy_visibility.rs create mode 100644 src-tauri/crates/business/src/routes/referral.rs create mode 100644 src-tauri/crates/business/src/routes/rpa.rs create mode 100644 src-tauri/crates/business/src/routes/secret.rs create mode 100644 src-tauri/crates/business/src/routes/teams.rs create mode 100644 src-tauri/crates/business/src/routes/templates.rs create mode 100644 src-tauri/crates/business/src/routes/time.rs create mode 100644 src-tauri/crates/business/src/routes/users.rs create mode 100644 src-tauri/crates/business/src/routes/workspace_quotas.rs create mode 100644 src-tauri/crates/business/src/routes/workspaces.rs create mode 100644 src-tauri/crates/business/src/services.rs create mode 100644 src-tauri/crates/business/src/services/accounts.rs create mode 100644 src-tauri/crates/business/src/services/audit.rs create mode 100644 src-tauri/crates/business/src/services/billing.rs create mode 100644 src-tauri/crates/business/src/services/browser_kernel.rs create mode 100644 src-tauri/crates/business/src/services/coupons.rs create mode 100644 src-tauri/crates/business/src/services/environments.rs create mode 100644 src-tauri/crates/business/src/services/extensions.rs create mode 100644 src-tauri/crates/business/src/services/group_permissions.rs create mode 100644 src-tauri/crates/business/src/services/groups.rs create mode 100644 src-tauri/crates/business/src/services/local_api.rs create mode 100644 src-tauri/crates/business/src/services/maintenance.rs create mode 100644 src-tauri/crates/business/src/services/messages.rs create mode 100644 src-tauri/crates/business/src/services/orders.rs create mode 100644 src-tauri/crates/business/src/services/plans.rs create mode 100644 src-tauri/crates/business/src/services/preferences.rs create mode 100644 src-tauri/crates/business/src/services/proxies.rs create mode 100644 src-tauri/crates/business/src/services/proxy_visibility.rs create mode 100644 src-tauri/crates/business/src/services/referral.rs create mode 100644 src-tauri/crates/business/src/services/rpa.rs create mode 100644 src-tauri/crates/business/src/services/strategy_types.rs create mode 100644 src-tauri/crates/business/src/services/subscriptions.rs create mode 100644 src-tauri/crates/business/src/services/tags.rs create mode 100644 src-tauri/crates/business/src/services/teams.rs create mode 100644 src-tauri/crates/business/src/services/templates.rs create mode 100644 src-tauri/crates/business/src/services/time.rs create mode 100644 src-tauri/crates/business/src/services/users.rs create mode 100644 src-tauri/crates/business/src/services/version_types.rs create mode 100644 src-tauri/crates/business/src/services/versions.rs create mode 100644 src-tauri/crates/business/src/services/wallet.rs create mode 100644 src-tauri/crates/business/src/services/workspace_quotas.rs create mode 100644 src-tauri/crates/business/src/services/workspaces.rs create mode 100644 src-tauri/crates/business/src/state.rs create mode 100644 src-tauri/crates/business/src/svc_ctx.rs create mode 100644 src-tauri/crates/business/src/utils.rs create mode 100644 src-tauri/crates/business/src/utils/config.rs create mode 100644 src-tauri/crates/business/src/utils/extractor.rs create mode 100644 src-tauri/crates/business/src/utils/jwt.rs create mode 100644 src-tauri/crates/business/src/utils/password.rs create mode 100644 src-tauri/crates/business/src/utils/responses.rs create mode 100644 src-tauri/crates/business/src/utils/secret/aes.rs create mode 100644 src-tauri/crates/business/src/utils/secret/mod.rs create mode 100644 src-tauri/crates/business/src/utils/secret/rsa.rs create mode 100644 src-tauri/crates/business/src/utils/storage.rs create mode 100644 src-tauri/crates/business/src/utils/storage/get_objects.rs diff --git a/src-tauri/crates/business/.cargo/config.toml b/src-tauri/crates/business/.cargo/config.toml new file mode 100644 index 0000000..ac5c657 --- /dev/null +++ b/src-tauri/crates/business/.cargo/config.toml @@ -0,0 +1,2 @@ +[target.x86_64-pc-windows-msvc] +linker = "rust-lld" \ No newline at end of file diff --git a/src-tauri/crates/business/.dockerignore b/src-tauri/crates/business/.dockerignore new file mode 100644 index 0000000..a9a5c4e --- /dev/null +++ b/src-tauri/crates/business/.dockerignore @@ -0,0 +1,51 @@ +# Git +.git +.gitignore + +# Rust +target/ +**/*.rs.bk +*.pdb +Cargo.lock + +# IDE +.vscode/ +.idea/ +*.swp +*.swo +*~ + +# OS +.DS_Store +Thumbs.db + +# 日志文件 +*.log + +# 数据库文件 +*.db +*.sqlite +*.sqlite3 + +# Docker +Dockerfile +docker-compose.yml +.dockerignore + +# 文档 +README.md +AGENTS.md +docs/ + +# 测试和开发文件 +*.test.toml +*.dev.toml + + + + + + + + + diff --git a/src-tauri/crates/business/.editorconfig b/src-tauri/crates/business/.editorconfig new file mode 100644 index 0000000..c52266d --- /dev/null +++ b/src-tauri/crates/business/.editorconfig @@ -0,0 +1,22 @@ +root = true + +[*] +charset = utf-8 +end_of_line = lf +insert_final_newline = true +trim_trailing_whitespace = true +indent_style = space +indent_size = 4 + +[*.{json,yml,yaml,toml}] +indent_size = 2 + +[*.md] +trim_trailing_whitespace = false + +[*.rs] +indent_size = 4 + + + + diff --git a/src-tauri/crates/business/.github/workflows/publish-ghcr-image.yml b/src-tauri/crates/business/.github/workflows/publish-ghcr-image.yml new file mode 100644 index 0000000..cddf1d3 --- /dev/null +++ b/src-tauri/crates/business/.github/workflows/publish-ghcr-image.yml @@ -0,0 +1,78 @@ +name: Publish GHCR Image + +on: + push: + tags: + - "v*" + +permissions: + contents: read + packages: write + +jobs: + publish: + name: Build and Publish Image + runs-on: ubuntu-latest + + steps: + - name: Checkout repository + uses: actions/checkout@v4 + + - name: Set image name + run: echo "IMAGE_NAME=ghcr.io/${GITHUB_REPOSITORY_OWNER,,}/simprint-server" >> "$GITHUB_ENV" + + - name: Log in to GitHub Container Registry + uses: docker/login-action@v3 + with: + registry: ghcr.io + username: ${{ github.actor }} + password: ${{ secrets.GITHUB_TOKEN }} + + - name: Extract Docker metadata + id: meta + uses: docker/metadata-action@v5 + with: + images: ${{ env.IMAGE_NAME }} + tags: | + type=ref,event=tag + type=match,pattern=v(.*),group=1 + type=raw,value=latest + type=sha,prefix=sha- + + - name: Build release binary in Debian Bullseye + run: | + docker run --rm \ + -v "$PWD:/workspace" \ + -w /workspace \ + rust:1.88-bullseye \ + bash -c ' + export PATH="/usr/local/cargo/bin:/usr/local/rustup/bin:$PATH" && + apt-get update && + apt-get install -y --no-install-recommends pkg-config libssl-dev && + cargo --version && + cargo build --release --bin simprint-server + ' + + - name: Prepare Docker build context + run: | + rm -rf .dist/docker-context + mkdir -p .dist/docker-context + cp Dockerfile .dist/docker-context/ + cp docker-compose.yml .dist/docker-context/ + cp .dockerignore .dist/docker-context/ + cp target/release/simprint-server .dist/docker-context/simprint-server + + - name: Set up Docker Buildx + uses: docker/setup-buildx-action@v3 + + - name: Build and push image + uses: docker/build-push-action@v6 + with: + context: .dist/docker-context + file: .dist/docker-context/Dockerfile + push: true + platforms: linux/amd64 + build-args: | + BINARY_NAME=simprint-server + tags: ${{ steps.meta.outputs.tags }} + labels: ${{ steps.meta.outputs.labels }} diff --git a/src-tauri/crates/business/.gitignore b/src-tauri/crates/business/.gitignore new file mode 100644 index 0000000..53141d7 --- /dev/null +++ b/src-tauri/crates/business/.gitignore @@ -0,0 +1,39 @@ +# Rust 编译输出 +/target/ +**/*.rs.bk +*.pdb + +# Cargo +Cargo.lock + +# IDE +.vscode/ +.idea/ +*.swp +*.swo +*~ + +# OS +.DS_Store +Thumbs.db + +# 日志文件 +*.log + +# 数据库文件(如果使用本地数据库) +*.db +*.sqlite +*.sqlite3 + +# 编译后的二进制文件 +console-gateway +simprint-server +update-gateway + +# Docker 镜像文件 +simprint-server-docker-*.tar.gz + +# 配置文件:仅保留示例配置可提交 +configs/* +!configs/*.example.toml + diff --git a/src-tauri/crates/business/Cargo.toml b/src-tauri/crates/business/Cargo.toml new file mode 100644 index 0000000..416b5ed --- /dev/null +++ b/src-tauri/crates/business/Cargo.toml @@ -0,0 +1,86 @@ +[package] +name = "simprint-server" +version = "0.1.0" +edition = "2024" + +[lib] +name = "simprint_server" +path = "src/lib.rs" + +[dependencies] +axum = { version = "0.8", features = ["macros", "multipart", "ws"] } +tower = "0.5.2" +tower-http = { version = "0.6", features = ["cors", "default"] } +tokio = { version = "1", features = [ + "macros", + "rt-multi-thread", + "sync", + "io-util", +] } +tokio-stream = "0.1" +tokio-tungstenite = "0.26.2" +tokio-util = { version = "0.7" } +serde = { version = "^1.0.217", features = ["derive"] } +serde_json = "^1" +base64 = "0.22.1" +mio = "1.0.1" +thiserror = "2" +anyhow = "1.0.93" +chrono = { version = "0.4", features = ["serde"] } +rand = "0.9" +rand_chacha = "0.9" +md5 = "0.8" +jwt = "0.16.0" +argon2 = "0.5.3" +aes-gcm = "0.10.3" +rsa = { version = "0.9.8", features = ["sha2"] } +sha2 = "0.10.8" +sha1 = "0.10.6" +hmac = "0.12.1" +hex = "0.4" +pkcs1 = "0.7" +pkcs8 = "0.10" +pem = "3.0" +config = "0.15" +tracing = "0.1" +tracing-subscriber = { version = "0.3", features = ["env-filter"] } +tracing-appender = "=0.2" +once_cell = "1.20.2" +sqlx = { version = "0.8", features = [ + "sqlite", + "mysql", + "postgres", + "runtime-tokio", + "chrono", + "json", + "uuid", + "ipnetwork", + "rust_decimal", + "bigdecimal", +] } +uuid = { version = "1.11.0", features = [ + "v4", + "fast-rng", + "macro-diagnostics", + "serde", +] } +regex = "1.5.4" +lettre = "0.11" +bytes = "1.10.0" +dashmap = "6.1.0" +rcon = { version = "0.6.0", features = ["rt-tokio", "rt-async-std"] } +tungstenite = "0.26.2" +textnonce = "1.0.0" +url = "2.5.4" +urlencoding = "2.1.3" +reqwest = { version = "0.12", features = ["json"] } +lazy_static = "1.5" +rust_decimal = { version = "1.39.0", features = [ + "serde-with-str", + "db-postgres", +] } +clap = { version = "4.0", features = ["derive"] } + +[profile.dev] +incremental = false +debug = 1 diff --git a/src-tauri/crates/business/Dockerfile b/src-tauri/crates/business/Dockerfile new file mode 100644 index 0000000..f7dd63b --- /dev/null +++ b/src-tauri/crates/business/Dockerfile @@ -0,0 +1,36 @@ +# 运行时镜像 +FROM debian:bullseye-slim + +# 临时禁用 APT 的 GPG 验证以解决密钥环问题 +# 安装运行时依赖(这些包来自官方 Debian 仓库) +RUN echo 'Acquire::AllowInsecureRepositories "true";' > /etc/apt/apt.conf.d/99allow-insecure \ + && echo 'APT::Get::AllowUnauthenticated "true";' >> /etc/apt/apt.conf.d/99allow-insecure \ + && apt-get update --allow-releaseinfo-change \ + && apt-get install -y --no-install-recommends \ + ca-certificates \ + libssl1.1 \ + && rm -f /etc/apt/apt.conf.d/99allow-insecure \ + && rm -rf /var/lib/apt/lists/* + +# 创建应用用户 +RUN useradd -m -u 1000 appuser + +# 设置工作目录 +WORKDIR /app + +# 构建参数:指定要复制的二进制文件名 +ARG BINARY_NAME + +# 从本地根目录复制已编译的二进制文件到固定路径 +COPY --chown=appuser:appuser ${BINARY_NAME} /app/app + +# 设置文件权限 +RUN mkdir -p /app/assets/secret /app/configs \ + && chown -R appuser:appuser /app/assets /app/configs \ + && chmod +x /app/app + +# 切换到非 root 用户 +USER appuser + +# 统一入口,配置文件路径通过 docker-compose 的 command 参数传入 +ENTRYPOINT ["/app/app"] diff --git a/src-tauri/crates/business/LICENSE b/src-tauri/crates/business/LICENSE new file mode 100644 index 0000000..e139fc3 --- /dev/null +++ b/src-tauri/crates/business/LICENSE @@ -0,0 +1,662 @@ + GNU AFFERO GENERAL PUBLIC LICENSE + Version 3, 19 November 2007 + + Copyright (C) 2007 Free Software Foundation, Inc. + Everyone is permitted to copy and distribute verbatim copies + of this license document, but changing it is not allowed. + + Preamble + + The GNU Affero General Public License is a free, copyleft license for +software and other kinds of works, specifically designed to ensure +cooperation with the community in the case of network server software. + + The licenses for most software and other practical works are designed +to take away your freedom to share and change the works. By contrast, +our General Public Licenses are intended to guarantee your freedom to +share and change all versions of a program--to make sure it remains free +software for all its users. + + When we speak of free software, we are referring to freedom, not +price. Our General Public Licenses are designed to make sure that you +have the freedom to distribute copies of free software (and charge for +them if you wish), that you receive source code or can get it if you +want it, that you can change the software or use pieces of it in new +free programs, and that you know you can do these things. + + Developers that use our General Public Licenses protect your rights +with two steps: (1) assert copyright on the software, and (2) offer +you this License which gives you legal permission to copy, distribute +and/or modify the software. + + A secondary benefit of defending all users' freedom is that +improvements made in alternate versions of the program, if they +receive widespread use, become available for other developers to +incorporate. Many developers of free software are heartened and +encouraged by the resulting cooperation. However, in the case of +software used on network servers, this result may fail to come about. +The GNU General Public License permits making a modified version and +letting the public access it on a server without ever releasing its +source code to the public. + + The GNU Affero General Public License is designed specifically to +ensure that, in such cases, the modified source code becomes available +to the community. It requires the operator of a network server to +provide the source code of the modified version running there to the +users of that server. Therefore, public use of a modified version, on +a publicly accessible server, gives the public access to the source +code of the modified version. + + An older license, called the Affero General Public License and +published by Affero, was designed to accomplish similar goals. This is +a different license, not a version of the Affero GPL, but Affero has +released a new version of the Affero GPL which permits relicensing under +this license. + + The precise terms and conditions for copying, distribution and +modification follow. + + TERMS AND CONDITIONS + + 0. Definitions. + + "This License" refers to version 3 of the GNU Affero General Public License. + + "Copyright" also means copyright-like laws that apply to other kinds of +works, such as semiconductor masks. + + "The Program" refers to any copyrightable work licensed under this +License. Each licensee is addressed as "you". "Licensees" and +"recipients" may be individuals or organizations. + + To "modify" a work means to copy from or adapt all or part of the work +in a fashion requiring copyright permission, other than the making of an +exact copy. The resulting work is called a "modified version" of the +earlier work or a work "based on" the earlier work. + + A "covered work" means either the unmodified Program or a work based +on the Program. + + To "propagate" a work means to do anything with it that, without +permission, would make you directly or secondarily liable for +infringement under applicable copyright law, except executing it on a +computer or modifying a private copy. Propagation includes copying, +distribution (with or without modification), making available to the +public, and in some countries other activities as well. + + To "convey" a work means any kind of propagation that enables other +parties to make or receive copies. Mere interaction with a user through +a computer network, with no transfer of a copy, is not conveying. + + An interactive user interface displays "Appropriate Legal Notices" +to the extent that it includes a convenient and prominently visible +feature that (1) displays an appropriate copyright notice, and (2) +tells the user that there is no warranty for the work (except to the +extent that warranties are provided), that licensees may convey the +work under this License, and how to view a copy of this License. If +the interface presents a list of user commands or options, such as a +menu, a prominent item in the list meets this criterion. + + 1. Source Code. + + The "source code" for a work means the preferred form of the work +for making modifications to it. "Object code" means any non-source +form of a work. + + A "Standard Interface" means an interface that either is an official +standard defined by a recognized standards body, or, in the case of +interfaces specified for a particular programming language, one that +is widely used among developers working in that language. + + The "System Libraries" of an executable work include anything, other +than the work as a whole, that (a) is included in the normal form of +packaging a Major Component, but which is not part of that Major +Component, and (b) serves only to enable use of the work with that +Major Component, or to implement a Standard Interface for which an +implementation is available to the public in source code form. A +"Major Component", in this context, means a major essential component +(kernel, window system, and so on) of the specific operating system +(if any) on which the executable work runs, or a compiler used to +produce the work, or an object code interpreter used to run it. + + The "Corresponding Source" for a work in object code form means all +the source code needed to generate, install, and (for an executable +work) run the object code and to modify the work, including scripts to +control those activities. However, it does not include the work's +System Libraries, or general-purpose tools or generally available free +programs which are used unmodified in performing those activities but +which are not part of the work. For example, Corresponding Source +includes interface definition files associated with source files for +the work, and the source code for shared libraries and dynamically +linked subprograms that the work is specifically designed to require, +such as by intimate data communication or control flow between those +subprograms and other parts of the work. + + The Corresponding Source need not include anything that users +can regenerate automatically from other parts of the Corresponding +Source. + + The Corresponding Source for a work in source code form is that +same work. + + 2. Basic Permissions. + + All rights granted under this License are granted for the term of +copyright on the Program, and are irrevocable provided the stated +conditions are met. This License explicitly affirms your unlimited +permission to run the unmodified Program. The output from running a +covered work is covered by this License only if the output, given its +content, constitutes a covered work. This License acknowledges your +rights of fair use or other equivalent, as provided by copyright law. + + You may make, run and propagate covered works that you do not +convey, without conditions so long as your license otherwise remains +in force. You may convey covered works to others for the sole purpose +of having them make modifications exclusively for you, or provide you +with facilities for running those works, provided that you comply with +the terms of this License in conveying all material for which you do +not control copyright. Those thus making or running the covered works +for you must do so exclusively on your behalf, under your direction +and control, on terms that prohibit them from making any copies of +your copyrighted material outside their relationship with you. + + Conveying under any other circumstances is permitted solely under +the conditions stated below. Sublicensing is not allowed; section 10 +makes it unnecessary. + + 3. Protecting Users' Legal Rights From Anti-Circumvention Law. + + No covered work shall be deemed part of an effective technological +measure under any applicable law fulfilling obligations under article +11 of the WIPO copyright treaty adopted on 20 December 1996, or +similar laws prohibiting or restricting circumvention of such +measures. + + When you convey a covered work, you waive any legal power to forbid +circumvention of technological measures to the extent such circumvention +is effected by exercising rights under this License with respect to +the covered work, and you disclaim any intention to limit operation or +modification of the work as a means of enforcing, against the work's +users, your or third parties' legal rights to forbid circumvention of +technological measures. + + 4. Conveying Verbatim Copies. + + You may convey verbatim copies of the Program's source code as you +receive it, in any medium, provided that you conspicuously and +appropriately publish on each copy an appropriate copyright notice; +keep intact all notices stating that this License and any +non-permissive terms added in accord with section 7 apply to the code; +keep intact all notices of the absence of any warranty; and give all +recipients a copy of this License along with the Program. + + You may charge any price or no price for each copy that you convey, +and you may offer support or warranty protection for a fee. + + 5. Conveying Modified Source Versions. + + You may convey a work based on the Program, or the modifications to +produce it from the Program, in the form of source code under the +terms of section 4, provided that you also meet all of these conditions: + + a) The work must carry prominent notices stating that you modified + it, and giving a relevant date. + + b) The work must carry prominent notices stating that it is + released under this License and any conditions added under section + 7. This requirement modifies the requirement in section 4 to + "keep intact all notices". + + c) You must license the entire work, as a whole, under this + License to anyone who comes into possession of a copy. This + License will therefore apply, along with any applicable section 7 + additional terms, to the whole of the work, and all its parts, + regardless of how they are packaged. This License gives no + permission to license the work in any other way, but it does not + invalidate such permission if you have separately received it. + + d) If the work has interactive user interfaces, each must display + Appropriate Legal Notices; however, if the Program has interactive + interfaces that do not display Appropriate Legal Notices, your + work need not make them do so. + + A compilation of a covered work with other separate and independent +works, which are not by their nature extensions of the covered work, +and which are not combined with it such as to form a larger program, +in or on a volume of a storage or distribution medium, is called an +"aggregate" if the compilation and its resulting copyright are not +used to limit the access or legal rights of the compilation's users +beyond what the individual works permit. Inclusion of a covered work +in an aggregate does not cause this License to apply to the other +parts of the aggregate. + + 6. Conveying Non-Source Forms. + + You may convey a covered work in object code form under the terms +of sections 4 and 5, provided that you also convey the +machine-readable Corresponding Source under the terms of this License, +in one of these ways: + + a) Convey the object code in, or embodied in, a physical product + (including a physical distribution medium), accompanied by the + Corresponding Source fixed on a durable physical medium + customarily used for software interchange. + + b) Convey the object code in, or embodied in, a physical product + (including a physical distribution medium), accompanied by a + written offer, valid for at least three years and valid for as + long as you offer spare parts or customer support for that product + model, to give anyone who possesses the object code either (1) a + copy of the Corresponding Source for all the software in the + product that is covered by this License, on a durable physical + medium customarily used for software interchange, for a price no + more than your reasonable cost of physically performing this + conveying of source, or (2) access to copy the + Corresponding Source from a network server at no charge. + + c) Convey individual copies of the object code with a copy of the + written offer to provide the Corresponding Source. This + alternative is allowed only occasionally and noncommercially, and + only if you received the object code with such an offer, in accord + with subsection 6b. + + d) Convey the object code by offering access from a designated + place (gratis or for a charge), and offer equivalent access to the + Corresponding Source in the same way through the same place at no + further charge. You need not require recipients to copy the + Corresponding Source along with the object code. If the place to + copy the object code is a network server, the Corresponding Source + may be on a different server (operated by you or a third party) + that supports equivalent copying facilities, provided you maintain + clear directions next to the object code saying where to find the + Corresponding Source. Regardless of what server hosts the + Corresponding Source, you remain obligated to ensure that it is + available for as long as needed to satisfy these requirements. + + e) Convey the object code using peer-to-peer transmission, provided + you inform other peers where the object code and Corresponding + Source of the work are being offered to the general public at no + charge under subsection 6d. + + A separable portion of the object code, whose source code is excluded +from the Corresponding Source as a System Library, need not be +included in conveying the object code work. + + A "User Product" is either (1) a "consumer product", which means any +tangible personal property which is normally used for personal, family, +or household purposes, or (2) anything designed or sold for incorporation +into a dwelling. In determining whether a product is a consumer product, +doubtful cases shall be resolved in favor of coverage. For a particular +product received by a particular user, "normally used" refers to a +typical or common use of that class of product, regardless of the status +of the particular user or of the way in which the particular user +actually uses, or expects or is expected to use, the product. A product +is a consumer product regardless of whether the product has substantial +commercial, industrial or non-consumer uses, unless such uses represent +the only significant mode of use of the product. + + "Installation Information" for a User Product means any methods, +procedures, authorization keys, or other information required to install +and execute modified versions of a covered work in that User Product from +a modified version of its Corresponding Source. The information must +suffice to ensure that the continued functioning of the modified object +code is in no case prevented or interfered with solely because +modification has been made. + + If you convey an object code work under this section in, or with, or +specifically for use in, a User Product, and the conveying occurs as +part of a transaction in which the right of possession and use of the +User Product is transferred to the recipient in perpetuity or for a +fixed term (regardless of how the transaction is characterized), the +Corresponding Source conveyed under this section must be accompanied +by the Installation Information. But this requirement does not apply +if neither you nor any third party retains the ability to install +modified object code on the User Product (for example, the work has +been installed in ROM). + + The requirement to provide Installation Information does not include a +requirement to continue to provide support service, warranty, or updates +for a work that has been modified or installed by the recipient, or for +the User Product in which it has been modified or installed. Access to a +network may be denied when the modification itself materially and +adversely affects the operation of the network or violates the rules and +protocols for communication across the network. + + Corresponding Source conveyed, and Installation Information provided, +in accord with this section must be in a format that is publicly +documented (and with an implementation available to the public in +source code form), and must require no special password or key for +unpacking, reading or copying. + + 7. Additional Terms. + + "Additional permissions" are terms that supplement the terms of this +License by making exceptions from one or more of its conditions. +Additional permissions that are applicable to the entire Program shall +be treated as though they were included in this License, to the extent +that they are valid under applicable law. If additional permissions +apply only to part of the Program, that part may be used separately +under those permissions, but the entire Program remains governed by +this License without regard to the additional permissions. + + When you convey a copy of a covered work, you may at your option +remove any additional permissions from that copy, or from any part of +it. (Additional permissions may be written to require their own +removal in certain cases when you modify the work.) You may place +additional permissions on material, added by you to a covered work, +for which you have or can give appropriate copyright permission. + + Notwithstanding any other provision of this License, for material you +add to a covered work, you may (if authorized by the copyright holders of +that material) supplement the terms of this License with terms: + + a) Disclaiming warranty or limiting liability differently from the + terms of sections 15 and 16 of this License; or + + b) Requiring preservation of specified reasonable legal notices or + author attributions in that material or in the Appropriate Legal + Notices displayed by works containing it; or + + c) Prohibiting misrepresentation of the origin of that material, or + requiring that modified versions of such material be marked in + reasonable ways as different from the original version; or + + d) Limiting the use for publicity purposes of names of licensors or + authors of the material; or + + e) Declining to grant rights under trademark law for use of some + trade names, trademarks, or service marks; or + + f) Requiring indemnification of licensors and authors of that + material by anyone who conveys the material (or modified versions of + it) with contractual assumptions of liability to the recipient, for + any liability that these contractual assumptions directly impose on + those licensors and authors. + + All other non-permissive additional terms are considered "further +restrictions" within the meaning of section 10. If the Program as you +received it, or any part of it, contains a notice stating that it is +governed by this License along with a term that is a further +restriction, you may remove that term. If a license document contains +a further restriction but permits relicensing or conveying under this +License, you may add to a covered work material governed by the terms +of that license document, provided that the further restriction does +not survive such relicensing or conveying. + + If you add terms to a covered work in accord with this section, you +must place, in the relevant source files, a statement of the +additional terms that apply to those files, or a notice indicating +where to find the applicable terms. + + Additional terms, permissive or non-permissive, may be stated in the +form of a separately written license, or stated as exceptions; +the above requirements apply either way. + + 8. Termination. + + You may not propagate or modify a covered work except as expressly +provided under this License. Any attempt otherwise to propagate or +modify it is void, and will automatically terminate your rights under +this License (including any patent licenses granted under the third +paragraph of section 11). + + However, if you cease all violation of this License, then your +license from a particular copyright holder is reinstated (a) +provisionally, unless and until the copyright holder explicitly and +finally terminates your license, and (b) permanently, if the copyright +holder fails to notify you of the violation by some reasonable means +prior to 60 days after the cessation. + + Moreover, your license from a particular copyright holder is +reinstated permanently if the copyright holder notifies you of the +violation by some reasonable means, this is the first time you have +received notice of violation of this License (for any work) from that +copyright holder, and you cure the violation prior to 30 days after +your receipt of the notice. + + Termination of your rights under this section does not terminate the +licenses of parties who have received copies or rights from you under +this License. If your rights have been terminated and not permanently +reinstated, you do not qualify to receive new licenses for the same +material under section 10. + + 9. Acceptance Not Required for Having Copies. + + You are not required to accept this License in order to receive or +run a copy of the Program. Ancillary propagation of a covered work +occurring solely as a consequence of using peer-to-peer transmission +to receive a copy likewise does not require acceptance. However, +nothing other than this License grants you permission to propagate or +modify any covered work. These actions infringe copyright if you do +not accept this License. Therefore, by modifying or propagating a +covered work, you indicate your acceptance of this License to do so. + + 10. Automatic Licensing of Downstream Recipients. + + Each time you convey a covered work, the recipient automatically +receives a license from the original licensors, to run, modify and +propagate that work, subject to this License. You are not responsible +for enforcing compliance by third parties with this License. + + An "entity transaction" is a transaction transferring control of an +organization, or substantially all assets of one, or subdividing an +organization, or merging organizations. If propagation of a covered +work results from an entity transaction, each party to that +transaction who receives a copy of the work also receives whatever +licenses to the work the party's predecessor in interest had or could +give under the previous paragraph, plus a right to possession of the +Corresponding Source of the work from the predecessor in interest, if +the predecessor has it or can get it with reasonable efforts. + + You may not impose any further restrictions on the exercise of the +rights granted or affirmed under this License. For example, you may +not impose a license fee, royalty, or other charge for exercise of +rights granted under this License, and you may not initiate litigation +(including a cross-claim or counterclaim in a lawsuit) alleging that +any patent claim is infringed by making, using, selling, offering for +sale, or importing the Program or any portion of it. + + 11. Patents. + + A "contributor" is a copyright holder who authorizes use under this +License of the Program or a work on which the Program is based. The +work thus licensed is called the contributor's "contributor version". + + A contributor's "essential patent claims" are all patent claims +owned or controlled by the contributor, whether already acquired or +hereafter acquired, that would be infringed by some manner, permitted +by this License, of making, using, or selling its contributor version, +but do not include claims that would be infringed only as a +consequence of further modification of the contributor version. For +purposes of this definition, "control" includes the right to grant +patent sublicenses in a manner consistent with the requirements of +this License. + + Each contributor grants you a non-exclusive, worldwide, royalty-free +patent license under the contributor's essential patent claims, to +make, use, sell, offer for sale, import and otherwise run, modify and +propagate the contents of its contributor version. + + In the following three paragraphs, a "patent license" is any express +agreement or commitment, however denominated, not to enforce a patent +(such as an express permission to practice a patent or covenant not to +sue for patent infringement). To "grant" such a patent license to a +party means to make such an agreement or commitment not to enforce a +patent against the party. + + If you convey a covered work, knowingly relying on a patent license, +and the Corresponding Source of the work is not available for anyone +to copy, free of charge and under the terms of this License, through a +publicly available network server or other readily accessible means, +then you must either (1) cause the Corresponding Source to be so +available, or (2) arrange to deprive yourself of the benefit of the +patent license for this particular work, or (3) arrange, in a manner +consistent with the requirements of this License, to extend the patent +license to downstream recipients. "Knowingly relying" means you have +actual knowledge that, but for the patent license, your conveying the +covered work in a country, or your recipient's use of the covered work +in a country, would infringe one or more identifiable patents in that +country that you have reason to believe are valid. + + If, pursuant to or in connection with a single transaction or +arrangement, you convey, or propagate by procuring conveyance of, a +covered work, and grant a patent license to some of the parties +receiving the covered work authorizing them to use, propagate, modify +or convey a specific copy of the covered work, then the patent license +you grant is automatically extended to all recipients of the covered +work and works based on it. + + A patent license is "discriminatory" if it does not include within +the scope of its coverage, prohibits the exercise of, or is +conditioned on the non-exercise of one or more of the rights that are +specifically granted under this License. You may not convey a covered +work if you are a party to an arrangement with a third party that is +in the business of distributing software, under which you make payment +to the third party based on the extent of your activity of conveying +the work, and under which the third party grants, to any of the +parties who would receive the covered work from you, a discriminatory +patent license (a) in connection with copies of the covered work +conveyed by you (or copies made from those copies), or (b) primarily +for and in connection with specific products or compilations that +contain the covered work, unless you entered into that arrangement, +or that patent license was granted, prior to 28 March 2007. + + Nothing in this License shall be construed as excluding or limiting +any implied license or other defenses to infringement that may +otherwise be available to you under applicable patent law. + + 12. No Surrender of Others' Freedom. + + If conditions are imposed on you (whether by court order, agreement or +otherwise) that contradict the conditions of this License, they do not +excuse you from the conditions of this License. If you cannot convey a +covered work so as to satisfy simultaneously your obligations under this +License and any other pertinent obligations, then as a consequence you may +not convey it at all. For example, if you agree to terms that obligate you +to collect a royalty for further conveying from those to whom you convey +the Program, the only way you could satisfy both those terms and this +License would be to refrain entirely from conveying the Program. + + 13. Remote Network Interaction; Use with the GNU General Public License. + + Notwithstanding any other provision of this License, if you modify the +Program, your modified version must prominently offer all users +interacting with it remotely through a computer network (if your version +supports such interaction) an opportunity to receive the Corresponding +Source of your version by providing access to the Corresponding Source +from a network server at no charge, through some standard or customary +means of facilitating copying of software. This Corresponding Source +shall include the Corresponding Source for any work covered by version 3 +of the GNU General Public License that is incorporated pursuant to the +following paragraph. + + Notwithstanding any other provision of this License, you have +permission to link or combine any covered work with a work licensed +under version 3 of the GNU General Public License into a single +combined work, and to convey the resulting work. The terms of this +License will continue to apply to the part which is the covered work, +but the special requirements of the GNU General Public License, section +13, concerning interaction through a network will apply to the +combination as such. + + 14. Revised Versions of this License. + + The Free Software Foundation may publish revised and/or new versions of +the GNU Affero General Public License from time to time. Such new versions +will be similar in spirit to the present version, but may differ in detail to +address new problems or concerns. + + Each version is given a distinguishing version number. If the +Program specifies that a certain numbered version of the GNU Affero General +Public License "or any later version" applies to it, you have the +option of following the terms and conditions either of that numbered +version or of any later version published by the Free Software +Foundation. If the Program does not specify a version number of the +GNU Affero General Public License, you may choose any version ever published +by the Free Software Foundation. + + If the Program specifies that a proxy can decide which future +versions of the GNU Affero General Public License can be used, that proxy's +public statement of acceptance of a version permanently authorizes you +to choose that version for the Program. + + Later license versions may give you additional or different +permissions. However, no additional obligations are imposed on any +author or copyright holder as a result of your choosing to follow a +later version. + + 15. Disclaimer of Warranty. + + THERE IS NO WARRANTY FOR THE PROGRAM, TO THE EXTENT PERMITTED BY +APPLICABLE LAW. EXCEPT WHEN OTHERWISE STATED IN WRITING THE COPYRIGHT +HOLDERS AND/OR OTHER PARTIES PROVIDE THE PROGRAM "AS IS" WITHOUT WARRANTY +OF ANY KIND, EITHER EXPRESSED OR IMPLIED, INCLUDING, BUT NOT LIMITED TO, +THE IMPLIED WARRANTIES OF MERCHANTABILITY AND FITNESS FOR A PARTICULAR +PURPOSE. THE ENTIRE RISK AS TO THE QUALITY AND PERFORMANCE OF THE PROGRAM +IS WITH YOU. SHOULD THE PROGRAM PROVE DEFECTIVE, YOU ASSUME THE COST OF +ALL NECESSARY SERVICING, REPAIR OR CORRECTION. + + 16. Limitation of Liability. + + IN NO EVENT UNLESS REQUIRED BY APPLICABLE LAW OR AGREED TO IN WRITING +WILL ANY COPYRIGHT HOLDER, OR ANY OTHER PARTY WHO MODIFIES AND/OR CONVEYS +THE PROGRAM AS PERMITTED ABOVE, BE LIABLE TO YOU FOR DAMAGES, INCLUDING ANY +GENERAL, SPECIAL, INCIDENTAL OR CONSEQUENTIAL DAMAGES ARISING OUT OF THE +USE OR INABILITY TO USE THE PROGRAM (INCLUDING BUT NOT LIMITED TO LOSS OF +DATA OR DATA BEING RENDERED INACCURATE OR LOSSES SUSTAINED BY YOU OR THIRD +PARTIES OR A FAILURE OF THE PROGRAM TO OPERATE WITH ANY OTHER PROGRAMS), +EVEN IF SUCH HOLDER OR OTHER PARTY HAS BEEN ADVISED OF THE POSSIBILITY OF +SUCH DAMAGES. + + 17. Interpretation of Sections 15 and 16. + + If the disclaimer of warranty and limitation of liability provided +above cannot be given local legal effect according to their terms, +reviewing courts shall apply local law that most closely approximates +an absolute waiver of all civil liability in connection with the +Program, unless a warranty or assumption of liability accompanies a +copy of the Program in return for a fee. + + END OF TERMS AND CONDITIONS + + How to Apply These Terms to Your New Programs + + If you develop a new program, and you want it to be of the greatest +possible use to the public, the best way to achieve this is to make it +free software which everyone can redistribute and change under these terms. + + To do so, attach the following notices to the program. It is safest +to attach them to the start of each source file to most effectively +state the exclusion of warranty; and each file should have at least +the "copyright" line and a pointer to where the full notice is found. + + + Copyright (C) + + This program is free software: you can redistribute it and/or modify + it under the terms of the GNU Affero General Public License as + published by the Free Software Foundation, either version 3 of the + License, or (at your option) any later version. + + This program is distributed in the hope that it will be useful, + but WITHOUT ANY WARRANTY; without even the implied warranty of + MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the + GNU Affero General Public License for more details. + + You should have received a copy of the GNU Affero General Public License + along with this program. If not, see . + + Also add information on how to contact you by electronic and paper mail. + + If your software can interact with users remotely through a computer +network, you should also make sure that it provides a way for users to +get its source. For example, if your program is a web application, its +interface could display a "Source" link that leads users to an archive +of the code. There are many ways you could offer source, and different +solutions will be better for different programs; see section 13 for the +specific requirements. + + You should also get your employer (if you work as a programmer) or school, +if any, to sign a "copyright disclaimer" for the program, if necessary. +For more information on this, and how to apply and follow the GNU AGPL, see +. diff --git a/src-tauri/crates/business/README.md b/src-tauri/crates/business/README.md new file mode 100644 index 0000000..0a02b3b --- /dev/null +++ b/src-tauri/crates/business/README.md @@ -0,0 +1,125 @@ +
+

Simprint Server

+

Self-hosted backend service for Simprint workspaces, accounts, environments, proxies, and related runtime APIs.

+

+ Language Rust 2024 + Framework Axum 0.8 + Database PostgreSQL + Cache Redis +

+

+ English | 简体中文 +

+
+ +--- + +## Introduction + +Simprint Server is the backend service used by Simprint clients and self-hosted deployments. It exposes the application API, manages authentication and persistent data, initializes encryption and storage resources, and runs embedded database migrations during startup. + +It is intended for operators who want to run Simprint inside their own infrastructure instead of depending on a shared hosted backend. The service is configured through a local TOML file and is designed to work with PostgreSQL, Redis, and S3-compatible object storage. + +## Why Simprint Server? + +Running Simprint in a self-hosted setup usually requires more than just an HTTP server: + +- You need control over API availability, credentials, and storage infrastructure. +- You need deployment artifacts that are safe to publish without leaking real environment configuration. +- You need database schema upgrades to happen predictably during release and restart. +- You need one backend entry point that can serve workspace, environment, proxy, and account-related APIs together. + +Simprint Server is built around those constraints: a single Rust service, a config-first deployment model, embedded migrations, and packaging that only ships a publish-safe example configuration. + +## Features + +- **Core application API**: Serves account, workspace, team, environment, proxy, template, preference, message, extension, and local runtime endpoints from one process. +- **Authentication and secret initialization**: Supports login-related flows, token refresh, route whitelists, and RSA secret bootstrap on first startup. +- **Embedded database migrations**: Executes `sqlx` migrations automatically before the HTTP server starts accepting traffic. +- **S3-compatible storage integration**: Configures external object storage for avatars, extension assets, and version-related files. +- **Redis-backed runtime coordination**: Uses Redis for runtime coordination and cache-oriented service flows. +- **Docker-oriented release packaging**: Generates a deployment archive with `Dockerfile`, `docker-compose.yml`, and `configs/config.toml` copied from `configs/config.example.toml`. +- **Config-first execution**: Runs locally and in containers with the same `-f ` startup model. + +## Quick Start + +### Prerequisites + +- Rust toolchain +- PostgreSQL 16+ or a compatible PostgreSQL instance +- Redis 7+ +- S3-compatible object storage +- Optional SMTP server for email-related flows + +### One-line self-hosted server install + +Linux servers can bootstrap the self-hosted backend with: + +```bash +curl -fsSL https://raw.githubusercontent.com/Simprint/simprint/main/deploy/install-server.sh | bash # Update the client config afterwards, for example: base_url = http://127.0.0.1:40041/api/ +``` + +### Run locally + +```bash +cp configs/config.example.toml configs/config.local.toml +# edit configs/config.local.toml +cargo run -- -f configs/config.local.toml +``` + +The example configuration listens on port `40041` and uses the `/api/v1` prefix by default. + +### Build a Docker release package + +Use: + +```bash +uv run python build_docker.py +``` + +The default build produces: + +- `./simprint-server` +- `./simprint-server-docker-*.tar.gz` + +You can also use options such as: + +```bash +uv run python build_docker.py --clean +uv run python build_docker.py --no-package +uv run python build_docker.py --format zip +uv run python build_docker.py --dev --no-package +``` + +The packaged `configs/config.toml` is generated from `configs/config.example.toml`, and real environment-specific config files are intentionally not included in the release archive. + +## Status + +Simprint Server was originally developed as part of a private commercial backend stack. This repository is now being prepared for a public open-source release, and the documentation is being rewritten to make standalone self-hosted deployment easier to understand. + +Some modules and naming still reflect earlier internal deployment assumptions. The current direction is to keep the client-facing gateway service deployable as an independent repository with a cleaner public-facing setup. + +## Contributing + +This repository is still in an open-source refactoring phase, but issues and pull requests are welcome. + +High-value contribution areas include: + +- Self-hosted deployment docs and onboarding improvements +- Test coverage and regression verification +- API documentation and route-level usage examples +- Packaging, release, and CI improvements + +Useful entry points when exploring the codebase: + +- `src/main.rs` +- `src/cli.rs` +- `configs/config.example.toml` +- `build_docker.py` +- `docs/` + +## License + +This project is licensed under the GNU Affero General Public License v3.0 (AGPLv3). + +If you want to use Simprint Server in a way that does not comply with the AGPLv3 obligations, including distributing modified versions or providing modified versions as a closed-source service, please contact us for a commercial license. diff --git a/src-tauri/crates/business/README.zh-CN.md b/src-tauri/crates/business/README.zh-CN.md new file mode 100644 index 0000000..f52f655 --- /dev/null +++ b/src-tauri/crates/business/README.zh-CN.md @@ -0,0 +1,125 @@ +
+

Simprint Server

+

面向 Simprint 工作区、账号体系、环境管理、代理资源和运行时接口的自托管后端服务。

+

+ Language Rust 2024 + Framework Axum 0.8 + Database PostgreSQL + Cache Redis +

+

+ English | 简体中文 +

+
+ +--- + +## Introduction + +Simprint Server 是 Simprint 客户端和私有化部署场景使用的后端服务。它负责暴露应用 API、管理认证与持久化数据、初始化加密和存储相关资源,并在启动时自动执行内嵌的数据库迁移。 + +它面向希望把 Simprint 部署在自有基础设施中的使用者,而不是依赖共享托管后端。服务通过本地 TOML 配置文件驱动,默认围绕 PostgreSQL、Redis 和兼容 S3 的对象存储来组织运行环境。 + +## Why Simprint Server? + +想把 Simprint 作为自托管服务落地,通常不只是“起一个 HTTP 服务”这么简单: + +- 你需要控制 API 可用性、认证凭据和对象存储基础设施。 +- 你需要一套可以公开发布、但不会泄露真实环境配置的部署产物。 +- 你需要在发布和重启过程中稳定地完成数据库结构升级。 +- 你需要一个统一的后端入口来承载工作区、环境、代理和账号相关接口。 + +Simprint Server 的设计就是围绕这些约束展开的:单个 Rust 服务、配置优先的部署模型、内嵌数据库迁移,以及仅打包可公开示例配置的发布流程。 + +## Features + +- **核心应用 API**:在一个进程中承载账号、工作区、团队、环境、代理、模板、偏好、消息、扩展和本地运行时等接口。 +- **认证与密钥初始化**:支持登录相关流程、令牌刷新、白名单路由以及首次启动时的 RSA 密钥初始化。 +- **内嵌数据库迁移**:在 HTTP 服务开始接收流量前自动执行 `sqlx` migrations。 +- **兼容 S3 的对象存储集成**:为头像、扩展资源和版本相关文件接入外部对象存储。 +- **基于 Redis 的运行时协同**:使用 Redis 承担运行时协同和缓存类服务能力。 +- **面向 Docker 的发布打包**:生成包含 `Dockerfile`、`docker-compose.yml` 以及由 `configs/config.example.toml` 复制出的 `configs/config.toml` 的部署包。 +- **配置优先的运行方式**:本地运行和容器运行都使用同一套 `-f ` 启动模型。 + +## Quick Start + +### Prerequisites + +- Rust toolchain +- PostgreSQL 16+ 或兼容的 PostgreSQL 实例 +- Redis 7+ +- 兼容 S3 的对象存储 +- 可选的 SMTP 服务,用于邮件相关流程 + +### 一键安装自托管服务端 + +Linux 服务器可直接执行: + +```bash +curl -fsSL https://raw.githubusercontent.com/Simprint/simprint/main/deploy/install-server.sh | bash # 请修改客户端的配置, 如: base_url = http://127.0.0.1:40041/api/ +``` + +### 本地运行 + +```bash +cp configs/config.example.toml configs/config.local.toml +# 修改 configs/config.local.toml +cargo run -- -f configs/config.local.toml +``` + +示例配置默认监听 `40041` 端口,并使用 `/api/v1` 作为接口前缀。 + +### 构建 Docker 发布包 + +使用: + +```bash +uv run python build_docker.py +``` + +默认构建产物包括: + +- `./simprint-server` +- `./simprint-server-docker-*.tar.gz` + +也可以使用这些常见参数: + +```bash +uv run python build_docker.py --clean +uv run python build_docker.py --no-package +uv run python build_docker.py --format zip +uv run python build_docker.py --dev --no-package +``` + +打包后的 `configs/config.toml` 来自仓库中的 `configs/config.example.toml`,真实环境配置文件不会被包含在对外发布的部署包中。 + +## Status + +Simprint Server 最初是作为私有商业后端体系的一部分开发的。当前这个仓库正在为公开开源发布做整理,文档也在同步重写,以便外部使用者更容易理解和部署独立的自托管版本。 + +仓库中的部分模块划分和命名,仍然会带有早期内部部署模型的痕迹。当前方向是把面向客户端的网关服务整理成一个可以独立部署、便于公开协作的仓库。 + +## Contributing + +这个仓库目前仍处于开源重构阶段,但已经欢迎通过 Issue 和 Pull Request 参与改进。 + +当前更有价值的贡献方向包括: + +- 自托管部署文档和上手流程优化 +- 测试覆盖和回归验证补充 +- API 文档和路由级使用示例完善 +- 打包、发布和 CI 流程改进 + +如果你准备快速建立上下文,建议先看这些入口: + +- `src/main.rs` +- `src/cli.rs` +- `configs/config.example.toml` +- `build_docker.py` +- `docs/` + +## License + +本项目采用 GNU Affero General Public License v3.0 (AGPLv3) 进行许可。 + +如果你希望在不履行 AGPLv3 义务的前提下使用 Simprint Server,包括分发修改版本或以闭源服务形式提供修改版本,请联系获取商业许可。 diff --git a/src-tauri/crates/business/assets/secret/private_key.pem b/src-tauri/crates/business/assets/secret/private_key.pem new file mode 100644 index 0000000..f5c4974 --- /dev/null +++ b/src-tauri/crates/business/assets/secret/private_key.pem @@ -0,0 +1,27 @@ +-----BEGIN RSA PRIVATE KEY----- +MIIEpQIBAAKCAQEArd/zJ0+jxE+q7YV8nNIjfMBtu7wSYbIjUa2FnuxwbxVuDLZP +qe8zMAojrimNGkdswVdcgQl/0E5c561hgTU54a7b2PWhobiM4YBY/jUcy+ufCv4m +qp71kSCsCazAdhKP65xvuu9hkIcJyHEIDASGAYLHKjjj1zbHmZhL5uKOWLnwLbNc +0ujTPlS5McxZeF65B9IWzJa5RmRMNlYgAKYsPlIwPpy2uglXWGdX3LejMpv5qXpC +QUKjrDi1AVOD+R6HIo6J4YMXEpOKbJpiamOpWn4oZLtGTTxAqA1qO43sNpJFKMC5 +4QjO7DMlfnbs3f5oYVjBExVQuqQAsgPMHLA8zQIDAQABAoIBAAd4l/oYcfD9e1q5 +YaFbZir2GcD3U9Le1KWtzxAFGM+tHA1vx/yFSq3FwcK6BlYau5GTL2ZcAiHxPAy4 +Zngm0VXhLZEk/Mz2IRePbEOABLy+YxcV1JOAQQz7WmkQAzUPlx+ImURvBMIxCzqR +oMbjat6lv+kQiLm2oNz4rko9ceBeCsAuuEREbmKMMubFeVgDvmtHOmbZlqsfHktd +BbB2zcvj5B7PpGth0jf9/3CoM0UYoT0hXtZ9s9y0HUyO2SDuRpUsWcIjzuZLVuiV +349z6/dNlVsXRIDLku1OPuyDTbfmFyfY5jCrU7idCet9n+BmyEEVuKJ12ZBwL2PF +KyhstAECgYEA5oOKMGu5G9cx2CdN113pyJW5syM5JLwwyPIXw5Xfv9IeEBYY1di4 +5BCZvsy4ez+CoFpCgFS+aOXo7vtAG5H4F9vOLgk9wBcijACChMwGqffWM5vB/ouA +EtymQe76YA7uBw048aoVJVsOp3dGRvix68BFs5gjMFvOjQD9J3gTnP8CgYEAwRlM +xNvZqDkVSvPqIlTnQ3GU9r7SK2JnBTkIcna68f71lEa38sl5ZUwlpkaTKaZhgA77 +CHtgYRnzOlOEWGxLkd82RinyVhBUKD9LfSJpDOkPiFwwlwYXehQGC69Ib4AFyHoS +JG+KiTVv5freKPv3VhiH/LyJwrU4YGw/d8VaCjMCgYEAlvdlBGs4cyRPb5nmH/tQ +hd6RHOIfpZBefuwWZjB8tlr891oRb9Qc2riIiG35EDa67RvP284kWfzgvcrs5GGH +0tBQytOgjnJYXMpksGYSozQ+I9SJi5R/D1tUw2+oqEp+1z1woszaRnnJMiIqc4ai +t5xXydQEj8JAlxYjtbqtVa8CgYEAtRPVktGb1Y3aMtRy3kkCKZPMnmqpSffYJeSq +0DQY8TAm+Sor+6gFiAGVWMzb2fXlfqINtJGF+ujL1wlUlVrQrvVDvx4824oqcSeR +0cHAA1RWtYfGJQmYYGmAldqEsdK7GZmng7V5k1uiGGddh89ozLrqYw4mnYk1We6I +wfc2jVMCgYEAr0So34S3siiljBx9afNXnEl4DOmTWDCOMlnz7Ru+egQ02M20DHEg +iRwHqBVzTiqncVpJPXIgcitNq7DRfbQCnkLuBfd30ygATk0wYkxyQp/mb7G9Fm3x +BOdFPOMTt5yQhO7nXJH0vt2sO5cUqKy1s6pBCGU1VIxFBuFWe4TGDKs= +-----END RSA PRIVATE KEY----- diff --git a/src-tauri/crates/business/assets/secret/public_key.pem b/src-tauri/crates/business/assets/secret/public_key.pem new file mode 100644 index 0000000..560721f --- /dev/null +++ b/src-tauri/crates/business/assets/secret/public_key.pem @@ -0,0 +1,8 @@ +-----BEGIN RSA PUBLIC KEY----- +MIIBCgKCAQEArd/zJ0+jxE+q7YV8nNIjfMBtu7wSYbIjUa2FnuxwbxVuDLZPqe8z +MAojrimNGkdswVdcgQl/0E5c561hgTU54a7b2PWhobiM4YBY/jUcy+ufCv4mqp71 +kSCsCazAdhKP65xvuu9hkIcJyHEIDASGAYLHKjjj1zbHmZhL5uKOWLnwLbNc0ujT +PlS5McxZeF65B9IWzJa5RmRMNlYgAKYsPlIwPpy2uglXWGdX3LejMpv5qXpCQUKj +rDi1AVOD+R6HIo6J4YMXEpOKbJpiamOpWn4oZLtGTTxAqA1qO43sNpJFKMC54QjO +7DMlfnbs3f5oYVjBExVQuqQAsgPMHLA8zQIDAQAB +-----END RSA PUBLIC KEY----- diff --git a/src-tauri/crates/business/build_docker.py b/src-tauri/crates/business/build_docker.py new file mode 100644 index 0000000..11c24f1 --- /dev/null +++ b/src-tauri/crates/business/build_docker.py @@ -0,0 +1,421 @@ +#!/usr/bin/env python3 +""" +构建脚本:编译 Rust 服务并生成包含运行容器所需文件的压缩包。 + +使用方法: + python build_docker.py [--release] [--clean] [--no-package] + +参数: + --release: 使用 release 模式编译(默认) + --clean: 构建前清理旧的二进制文件 + --no-package: 不生成压缩包,只编译和复制文件 +""" + +import os +import sys +import shutil +import subprocess +import argparse +import zipfile +import tarfile +from pathlib import Path +from datetime import datetime + +# 需要编译的服务列表(本仓库仅 simprint-server) +SERVICES = [ + "simprint-server", +] + +# 项目根目录 +PROJECT_ROOT = Path(__file__).parent.resolve() +TARGET_DIR = PROJECT_ROOT / "target" +RELEASE_DIR = TARGET_DIR / "release" + +# 需要打包到压缩包的文件和目录 +PACKAGE_FILES = [ + "Dockerfile", + "docker-compose.yml", + ".dockerignore", +] + +PACKAGE_CONFIG_FILES = [ + ("configs/config.example.toml", "configs/config.toml"), +] + +# 需要打包的二进制文件(在构建后复制) +PACKAGE_BINARIES = SERVICES + + +def print_step(message: str): + """打印步骤信息""" + print(f"\n{'='*60}") + print(f" {message}") + print(f"{'='*60}\n") + + +def check_cargo(): + """检查 cargo 是否可用""" + try: + result = subprocess.run( + ["cargo", "--version"], + capture_output=True, + text=True, + check=True, + ) + print(f"✓ 找到 Cargo: {result.stdout.strip()}") + return True + except (subprocess.CalledProcessError, FileNotFoundError): + print("✗ 错误: 未找到 Cargo,请确保已安装 Rust 工具链") + return False + + +def clean_binaries(): + """清理项目根目录中的旧二进制文件""" + print_step("清理旧的二进制文件") + removed = [] + for service in SERVICES: + binary_path = PROJECT_ROOT / service + if binary_path.exists(): + binary_path.unlink() + removed.append(service) + print(f" 删除: {binary_path}") + + if not removed: + print(" 没有需要清理的文件") + else: + print(f" 已清理 {len(removed)} 个文件") + + +def build_service(service: str, release: bool = True) -> bool: + """编译单个服务""" + build_mode = "release" if release else "dev" + print(f" 编译 {service} ({build_mode} 模式)...") + + cmd = ["cargo", "build"] + if release: + cmd.append("--release") + cmd.extend(["--bin", service]) + + try: + result = subprocess.run( + cmd, + cwd=PROJECT_ROOT, + check=True, + capture_output=True, + text=True, + ) + print(f" ✓ {service} 编译成功") + return True + except subprocess.CalledProcessError as e: + print(f" ✗ {service} 编译失败") + if e.stderr: + print(f" 错误信息: {e.stderr}") + return False + + +def copy_binary(service: str, release: bool = True, dest_dir: Path = None) -> bool: + """将编译好的二进制文件复制到指定目录""" + if dest_dir is None: + dest_dir = PROJECT_ROOT + + source_dir = RELEASE_DIR if release else TARGET_DIR / "debug" + source_path = source_dir / service + dest_path = dest_dir / service + + if not source_path.exists(): + print(f" ✗ 错误: 源文件不存在: {source_path}") + return False + + try: + shutil.copy2(source_path, dest_path) + # 设置可执行权限 + os.chmod(dest_path, 0o755) + print(f" ✓ 已复制: {service} -> {dest_path}") + return True + except Exception as e: + print(f" ✗ 复制失败: {e}") + return False + + +def create_package(package_dir: Path, output_format: str = "zip") -> Path: + """创建压缩包""" + timestamp = datetime.now().strftime("%Y%m%d_%H%M%S") + package_name = f"simprint-server-docker-{timestamp}" + + if output_format == "zip": + archive_path = PROJECT_ROOT / f"{package_name}.zip" + print_step(f"创建 ZIP 压缩包: {archive_path.name}") + + with zipfile.ZipFile(archive_path, "w", zipfile.ZIP_DEFLATED) as zipf: + for root, dirs, files in os.walk(package_dir): + # 跳过隐藏目录 + dirs[:] = [d for d in dirs if not d.startswith(".")] + + for file in files: + file_path = Path(root) / file + arcname = file_path.relative_to(package_dir) + zipf.write(file_path, arcname) + print(f" 添加: {arcname}") + + elif output_format == "tar.gz": + archive_path = PROJECT_ROOT / f"{package_name}.tar.gz" + print_step(f"创建 TAR.GZ 压缩包: {archive_path.name}") + + with tarfile.open(archive_path, "w:gz") as tar: + for root, dirs, files in os.walk(package_dir): + # 跳过隐藏目录 + dirs[:] = [d for d in dirs if not d.startswith(".")] + + for file in files: + file_path = Path(root) / file + arcname = file_path.relative_to(package_dir) + tar.add(file_path, arcname=arcname, recursive=False) + print(f" 添加: {arcname}") + + else: + raise ValueError(f"不支持的压缩格式: {output_format}") + + size_mb = archive_path.stat().st_size / (1024 * 1024) + print(f"\n✓ 压缩包创建成功: {archive_path.name} ({size_mb:.2f} MB)") + return archive_path + + +def prepare_package_directory() -> Path: + """准备打包目录,复制所有必需的文件""" + print_step("准备打包目录") + + package_dir = PROJECT_ROOT / "docker-package" + + # 清理旧的打包目录 + if package_dir.exists(): + shutil.rmtree(package_dir) + + package_dir.mkdir(exist_ok=True) + print(f" 创建打包目录: {package_dir}") + + # 复制必需的文件和目录 + copied_count = 0 + for item in PACKAGE_FILES: + source = PROJECT_ROOT / item + dest = package_dir / item + + if not source.exists(): + print(f" ⚠ 警告: {item} 不存在,跳过") + continue + + try: + if source.is_dir(): + shutil.copytree(source, dest, dirs_exist_ok=True) + print(f" ✓ 复制目录: {item}") + else: + shutil.copy2(source, dest) + print(f" ✓ 复制文件: {item}") + copied_count += 1 + except Exception as e: + print(f" ✗ 复制失败 {item}: {e}") + + # 仅复制对外发布需要的示例配置文件 + print(f"\n 复制配置模板:") + for source_item, dest_item in PACKAGE_CONFIG_FILES: + source = PROJECT_ROOT / source_item + dest = package_dir / dest_item + + if not source.exists(): + print(f" ⚠ 警告: {source_item} 不存在,跳过") + continue + + try: + dest.parent.mkdir(parents=True, exist_ok=True) + shutil.copy2(source, dest) + print(f" ✓ 复制文件: {source_item} -> {dest_item}") + copied_count += 1 + except Exception as e: + print(f" ✗ 复制失败 {source_item}: {e}") + + # 复制二进制文件 + print(f"\n 复制二进制文件:") + for binary in PACKAGE_BINARIES: + source = PROJECT_ROOT / binary + if source.exists(): + dest = package_dir / binary + shutil.copy2(source, dest) + os.chmod(dest, 0o755) + print(f" ✓ 复制: {binary}") + else: + print(f" ✗ 错误: {binary} 不存在") + + # 创建 README 文件 + readme_content = f"""# Simprint Server Docker 部署包 + +本压缩包包含运行 Simprint Server(客户端网关)容器所需的所有文件。 + +## 包含内容 + +- 二进制文件: {', '.join(SERVICES)} +- Dockerfile: Docker 镜像构建文件 +- docker-compose.yml: Docker Compose 配置文件 +- configs/config.toml: 配置模板 +- 自动生成的密钥会保存在 Docker 卷中,不随部署包分发 + +## 使用方法 + +1. 解压压缩包到目标目录 + +2. 确保 `configs/config.toml` 中的数据库、Redis、对象存储地址正确 + +3. 构建 Docker 镜像: + ```bash + docker-compose build + ``` + +4. 启动服务: + ```bash + docker-compose up -d + ``` + +5. 查看服务状态: + ```bash + docker-compose ps + ``` + +服务启动时会自动执行数据库迁移。 + +6. 查看日志: + ```bash + docker-compose logs -f simprint-server + ``` + +## 服务端口 + +- 客户端网关 (simprint-server): 40041 + +## 注意事项 + +- 请确保已安装 Docker 和 Docker Compose +- 配置文件中的外部服务地址需要根据实际环境调整 +- 首次运行前请检查 configs/ 目录下的配置文件 + +生成时间: {datetime.now().strftime("%Y-%m-%d %H:%M:%S")} +""" + + readme_path = package_dir / "README.md" + readme_path.write_text(readme_content, encoding="utf-8") + print(f" ✓ 创建: README.md") + + print(f"\n✓ 打包目录准备完成,共 {copied_count + len(PACKAGE_BINARIES)} 个项目") + return package_dir + + +def main(): + parser = argparse.ArgumentParser( + description="构建 Rust 服务二进制文件供 Docker 使用" + ) + parser.add_argument( + "--release", + action="store_true", + default=True, + help="使用 release 模式编译(默认)", + ) + parser.add_argument( + "--dev", + action="store_true", + help="使用 dev 模式编译", + ) + parser.add_argument( + "--clean", + action="store_true", + help="构建前清理旧的二进制文件", + ) + parser.add_argument( + "--no-package", + action="store_true", + help="不生成压缩包,只编译和复制文件到项目根目录", + ) + parser.add_argument( + "--format", + choices=["zip", "tar.gz"], + default="tar.gz", + help="压缩包格式 (默认: tar.gz)", + ) + args = parser.parse_args() + + # 确定编译模式 + release_mode = args.release and not args.dev + + print_step("Simprint Server Docker 构建脚本") + print(f"项目目录: {PROJECT_ROOT}") + print(f"编译模式: {'release' if release_mode else 'dev'}") + + # 检查 cargo + if not check_cargo(): + sys.exit(1) + + # 清理旧文件 + if args.clean: + clean_binaries() + + # 编译所有服务 + print_step("编译服务") + build_success = True + for service in SERVICES: + if not build_service(service, release_mode): + build_success = False + + if not build_success: + print("\n✗ 服务编译失败,请检查错误信息") + sys.exit(1) + + # 复制二进制文件 + print_step("复制二进制文件到项目根目录") + copy_success = True + for service in SERVICES: + if not copy_binary(service, release_mode): + copy_success = False + + if not copy_success: + print("\n✗ 文件复制失败") + sys.exit(1) + + # 验证文件 + print_step("验证构建结果") + all_exist = True + for service in SERVICES: + binary_path = PROJECT_ROOT / service + if binary_path.exists(): + size = binary_path.stat().st_size / (1024 * 1024) # MB + print(f" ✓ {service}: {size:.2f} MB") + else: + print(f" ✗ {service}: 文件不存在") + all_exist = False + + if not all_exist: + print("\n✗ 构建验证失败") + sys.exit(1) + + # 生成压缩包 + if not args.no_package: + package_dir = prepare_package_directory() + archive_path = create_package(package_dir, args.format) + + # 清理打包目录 + print_step("清理临时文件") + shutil.rmtree(package_dir) + print(" ✓ 已清理临时打包目录") + + print_step("构建完成!") + print(f"\n✓ 压缩包已生成: {archive_path.name}") + print(f" 位置: {archive_path}") + print("\n现在可以:") + print(" 1. 将压缩包传输到目标服务器") + print(" 2. 解压后运行: docker-compose up -d") + else: + print_step("构建完成!") + print("\n现在可以使用以下命令构建 Docker 镜像:") + print(" docker-compose build") + print("\n或者启动服务:") + print(" docker-compose up -d") + + +if __name__ == "__main__": + main() + diff --git a/src-tauri/crates/business/clippy.toml b/src-tauri/crates/business/clippy.toml new file mode 100644 index 0000000..5efb527 --- /dev/null +++ b/src-tauri/crates/business/clippy.toml @@ -0,0 +1,7 @@ +# Clippy 配置 +avoid-breaking-exported-api = false +msrv = "1.70.0" + + + + diff --git a/src-tauri/crates/business/configs/config.example.toml b/src-tauri/crates/business/configs/config.example.toml new file mode 100644 index 0000000..42a2128 --- /dev/null +++ b/src-tauri/crates/business/configs/config.example.toml @@ -0,0 +1,44 @@ +[app] +name = "simprint-server" +port = 40041 +secret = "change-this-to-a-long-random-secret" +prefix = "/api/v1" +encrypt_secret_location = "./assets/secret" +route_whitelists = [ + "POST+/api/v1/users/login", + "POST+/api/v1/users/register", + "POST+/api/v1/users/register-send-code", + "POST+/api/v1/users/reset-password", + "POST+/api/v1/users/reset-password-send-code", + "POST+/api/v1/users/refresh-credentials", + "GET+/api/v1/secret/public/key", + "GET+/api/v1/time/now", + "POST+/api/v1/versions/check" +] +referral_link_prefix = "https://your-domain.example/download" + +[database] +url = "postgres://simprint:change-me@postgres:5432/simprintdb" +max_connections = 25 +min_connections = 5 +max_lifetime = 3000 +acquire_timeout = 30 +idle_timeout = 600 + +[storage] +public_base_url = "https://your-resource-download-host.example" +avatar_root = "avatars" +extension_root = "extensions" +version_root = "versions" + +[smtp] +smtp_server = "smtp.example.com" +smtp_username = "noreply@example.com" +smtp_password = "change-me" + +[workspace_quota] +[workspace_quota.default] +max_environments = 99999 +max_team_members = 99999 +max_proxies = 99999 +max_rpa_tasks = 99999 diff --git a/src-tauri/crates/business/configs/config.local.example.toml b/src-tauri/crates/business/configs/config.local.example.toml new file mode 100644 index 0000000..1c7b789 --- /dev/null +++ b/src-tauri/crates/business/configs/config.local.example.toml @@ -0,0 +1,40 @@ +[app] +name = "simprint-server-local" +port = 40041 +secret = "change-this-to-a-long-random-secret" +prefix = "/api/v1" +encrypt_secret_location = "./assets/secret" +route_whitelists = [ + "POST+/api/v1/users/login", + "POST+/api/v1/users/register", + "POST+/api/v1/users/register-send-code", + "POST+/api/v1/users/reset-password", + "POST+/api/v1/users/reset-password-send-code", + "POST+/api/v1/users/refresh-credentials", + "GET+/api/v1/secret/public/key", + "GET+/api/v1/time/now", + "POST+/api/v1/versions/check", +] +referral_link_prefix = "http://127.0.0.1:40041/download" + +# PostgreSQL remains the only external dependency during this migration stage. +[database] +url = "postgres://simprint:change-me@127.0.0.1:5432/simprintdb" +max_connections = 10 +min_connections = 1 +max_lifetime = 3000 +acquire_timeout = 30 +idle_timeout = 600 + +[storage] +public_base_url = "https://your-resource-download-host.example" +avatar_root = "avatars" +extension_root = "extensions" +version_root = "versions" + +[workspace_quota] +[workspace_quota.default] +max_environments = 99999 +max_team_members = 99999 +max_proxies = 99999 +max_rpa_tasks = 99999 diff --git a/src-tauri/crates/business/docker-compose.yml b/src-tauri/crates/business/docker-compose.yml new file mode 100644 index 0000000..db174ca --- /dev/null +++ b/src-tauri/crates/business/docker-compose.yml @@ -0,0 +1,68 @@ +version: '3.8' + +services: + postgres: + image: postgres:16-alpine + container_name: simprint-postgres + environment: + POSTGRES_DB: simprintdb + POSTGRES_USER: simprint + POSTGRES_PASSWORD: change-me + volumes: + - simprint-postgres-data:/var/lib/postgresql/data + healthcheck: + test: ["CMD-SHELL", "pg_isready -U simprint -d simprintdb"] + interval: 10s + timeout: 5s + retries: 5 + restart: unless-stopped + networks: + - simprint-network + + redis: + image: redis:7-alpine + container_name: simprint-redis + command: ["redis-server", "--appendonly", "yes"] + volumes: + - simprint-redis-data:/data + healthcheck: + test: ["CMD", "redis-cli", "ping"] + interval: 10s + timeout: 5s + retries: 5 + restart: unless-stopped + networks: + - simprint-network + + simprint-server: + build: + context: . + dockerfile: Dockerfile + args: + BINARY_NAME: simprint-server + container_name: simprint-client-gateway + command: ["-f=/app/configs/config.toml"] + depends_on: + postgres: + condition: service_healthy + redis: + condition: service_healthy + ports: + - "40041:40041" + volumes: + - ./configs:/app/configs:ro + - simprint-secret-data:/app/assets/secret + environment: + - RUST_LOG=info + restart: unless-stopped + networks: + - simprint-network + +networks: + simprint-network: + name: simprint-network + +volumes: + simprint-postgres-data: + simprint-redis-data: + simprint-secret-data: diff --git a/src-tauri/crates/business/docs/database-design.md b/src-tauri/crates/business/docs/database-design.md new file mode 100644 index 0000000..b9c349e --- /dev/null +++ b/src-tauri/crates/business/docs/database-design.md @@ -0,0 +1,109 @@ +# 数据库设计文档 + +本文档描述了 Simprint Server 项目的数据库表结构设计。 + +## 表结构定义规范 + +### users + +用户基础信息表 + +存储用户的基础标识信息,采用 UUID 作为主键,支持全局唯一标识。 + +**字段说明**: + +- `uuid` - UUID 类型,主键,全局唯一标识 +- `id` - 字符串类型,用户 ID,唯一标识,可用于业务查询(如:USER001) +- `created_at` - 时间戳类型,创建时间,NOT NULL +- `updated_at` - 时间戳类型,更新时间,NOT NULL +- `deleted_at` - 时间戳类型,删除时间(软删除),可为 NULL + +**索引**: + +- 主键索引:`uuid` +- 唯一索引:`id` +- 索引:`deleted_at`(用于软删除查询优化) + +**约束**: + +- `id` 必须唯一且不为空 +- `created_at` 和 `updated_at` 由数据库自动管理 + +### user_infos + +用户详细信息表 + +存储用户的详细业务信息,包括登录凭证、联系方式、个人信息等。通过 `user_uuid` 与 `users` 表关联。 + +**字段说明**: + +- `id` - 自增整数类型,主键 +- `user_uuid` - UUID 类型,外键,关联 `users.uuid`,NOT NULL +- `nickname` - 字符串类型,昵称,可为 NULL +- `email` - 字符串类型,邮箱地址,NOT NULL,唯一索引,用于登录 +- `phone` - 字符串类型,手机号,可为 NULL(可选字段) +- `password` - 字符串类型,密码(Argon2 加密存储),NOT NULL +- `avatar_hash` - 字符串类型,头像文件 hash(MinIO 中存储的文件名,无后缀),可为 NULL +- `status` - 字符串类型,用户状态(active/inactive/banned),默认 'active',NOT NULL +- `created_at` - 时间戳类型,创建时间,NOT NULL +- `updated_at` - 时间戳类型,更新时间,NOT NULL +- `deleted_at` - 时间戳类型,删除时间(软删除),可为 NULL + +**索引**: + +- 主键索引:`id` +- 唯一索引:`user_uuid`(一对一关系) +- 唯一索引:`email`(邮箱唯一,NOT NULL,用于登录) +- 索引:`deleted_at`(用于软删除查询优化) +- 索引:`status`(用于状态查询) + +**约束**: + +- `user_uuid` 必须唯一,确保与 `users` 表一对一关系 +- `email` 必须唯一且不为空(用于登录) +- `password` 必须不为空 +- `status` 默认值为 'active' + +**关于头像字段的说明**: + +- `avatar_hash` 字段用于存储 MinIO 中头像文件的 hash 值(文件名,无后缀) +- 目前阶段不实现头像功能,该字段可为 NULL +- 未来实现头像功能时,头像文件将存储在 MinIO 的 `avatar_bucket` 中,文件名为 hash 值 +- 获取头像 URL 时,通过配置的 `minio.resource_url` 和 `avatar_bucket` 拼接完整 URL + +**表关系**: + +- `user_infos.user_uuid` → `users.uuid`(一对一关系,级联删除) + +## 设计说明 + +### 软删除策略 + +- 所有表都支持软删除,通过 `deleted_at` 字段标记 +- 查询时默认过滤 `deleted_at IS NULL` 的记录 +- 物理删除仅在必要时进行(如数据清理) + +### 用户标识 + +- `users.uuid`:系统内部使用的全局唯一标识(UUID) +- `users.id`:业务层使用的用户 ID(字符串,如 USER001) +- `user_infos.phone`:用户登录使用的手机号 + +### 会话管理 + +- Refresh Token 存储在 Redis 中,不在数据库表中 +- 会话相关信息(设备信息、登录时间等)可在 Redis 中管理 +- 如需持久化会话记录,可考虑添加 `user_sessions` 表 + +### 验证码管理 + +- 验证码存储在 Redis 中,设置过期时间(如 5 分钟) +- 格式:`verification_code:{phone}:{type}`(type: register/reset_password) +- 不在数据库表中存储验证码信息 + +### 扩展性考虑 + +- 用户权限:如需权限系统,可添加 `user_permissions` 表 +- 用户角色:如需角色系统,可添加 `user_roles` 表和关联表 +- 登录历史:如需记录登录历史,可添加 `user_login_history` 表 +- 设备管理:如需管理用户设备,可添加 `user_devices` 表 diff --git a/src-tauri/crates/business/docs/extension-system-refactor.md b/src-tauri/crates/business/docs/extension-system-refactor.md new file mode 100644 index 0000000..d13656b --- /dev/null +++ b/src-tauri/crates/business/docs/extension-system-refactor.md @@ -0,0 +1,256 @@ +# 插件系统重构方案 + +## 插件绑定的核心思路 + +### 两个关键维度 + +1. **安装范围**:决定插件作用于哪些环境 + - 全局:所有环境 + - 指定分组:特定分组的环境 + +2. **可见性**:决定插件是个人使用还是团队共享 + - 为团队安装(勾选):团队所有成员可用 + - 不勾选:仅当前用户可用 + +### 四种绑定关系 + +| 安装范围 | 为团队安装 | 绑定关系 | 效果 | +|---------|-----------|---------|------| +| 全局 | ✅ | 插件 ↔ 团队 | 团队所有成员的所有环境都有此插件 | +| 全局 | ❌ | 插件 ↔ 用户 | 仅当前用户的所有环境有此插件 | +| 指定分组 | ✅ | 插件 ↔ 分组(团队共享) | 团队所有成员在该分组下的环境都有此插件 | +| 指定分组 | ❌ | 插件 ↔ 分组(个人私有) | 仅当前用户在该分组下的环境有此插件 | + +### 关键设计原则 + +1. **环境不直接绑定插件** - 环境通过用户/团队/分组间接获得插件 +2. **动态合并** - 环境列表查询时动态合并多个层级的插件 +3. **优先级规则**(从高到低): + - 分组团队插件 + - 分组个人插件 + - 团队全局插件 + - 用户全局插件 + +### 环境获取插件的逻辑 + +当查询某个环境的插件列表时,需要合并: +1. 用户个人全局插件 +2. 团队全局插件 +3. 该环境所属分组的个人插件(如果有) +4. 该环境所属分组的团队插件(如果有) + +### 实际场景举例 + +**场景1:公司统一插件** +- 全局 + 为团队安装 ✅ +- 所有人所有环境都有 + +**场景2:个人常用插件** +- 全局 + 为团队安装 ❌ +- 只有自己所有环境有 + +**场景3:测试分组的团队插件** +- 指定分组(测试分组)+ 为团队安装 ✅ +- 所有人在测试分组的环境都有 + +**场景4:个人在某分组的特殊插件** +- 指定分组(工作分组)+ 为团队安装 ❌ +- 只有自己在工作分组的环境有 + +## 需要补充考虑的场景 + +### 1. 插件卸载场景 + +**问题:** +- 如果用户卸载了一个"团队全局插件",是只对自己生效还是影响整个团队? +- 如果用户卸载了"分组团队插件",其他团队成员还能用吗? + +**建议方案:** +- 团队插件只有所有者/管理员能卸载,卸载后影响所有人 +- 普通用户不能禁用团队插件 +- 个人插件可以直接卸载 + +### 2. 插件更新场景 + +**问题:** +- 团队插件更新到新版本,是否自动更新所有成员? +- 个人插件和团队插件版本冲突怎么办? + +**建议方案:** +- 团队插件更新后,所有成员自动使用新版本 +- 如果个人安装了同一插件的不同版本,个人版本优先 + +### 3. 权限管理场景 + +**问题:** +- 谁可以为团队安装插件? +- 谁可以为分组安装团队共享的插件? + +**建议方案:** +- 团队全局插件:只有所有者/管理员可以安装/卸载 +- 分组团队插件:拥有该分组 manage 权限的用户可以安装/卸载 +- 个人插件:所有用户都可以安装/卸载 + +### 4. 分组变更场景 + +**问题:** +- 环境从分组A移动到分组B,插件如何变化? +- 环境从有分组变为无分组,插件如何变化? + +**建议方案:** +- 环境移动分组后,插件列表自动更新(因为是动态查询的) +- 分组A的插件失效,分组B的插件生效 +- 全局插件始终生效 + +### 5. 用户离开团队场景 + +**问题:** +- 用户离开团队后,之前安装的"分组个人插件"怎么办? +- 用户的环境还能访问吗? + +**建议方案:** +- 用户离开团队,其环境也应该被删除或转移 +- 分组个人插件随环境一起处理 + +### 6. 插件冲突场景 + +**问题:** +- 同一个插件,团队安装了v1.0,个人安装了v2.0,用哪个? +- 分组团队安装了v1.0,分组个人安装了v2.0,用哪个? + +**建议方案:** +- 按优先级规则:分组团队 > 分组个人 > 团队全局 > 用户全局 +- 同一层级只能有一个版本 + +### 7. 插件商店的"已安装"标识 + +**问题:** +- 插件商店显示某个插件"已安装",但实际上可能是: + - 用户个人全局安装 + - 团队全局安装 + - 某个分组安装 + - 多个层级都安装了 + +**建议方案:** +- 显示详细的安装信息: + - "已安装(全局-团队)" + - "已安装(工作分组-个人)" + - "已安装(多处)" - 点击查看详情 + +### 8. 批量操作场景 + +**问题:** +- 能否批量为多个分组安装同一个插件? +- 能否批量卸载某个插件的所有安装? + +**建议方案:** +- 支持多选分组批量安装 +- 已安装列表支持批量卸载(但要区分权限) + +### 9. 插件依赖场景 + +**问题:** +- 插件A依赖插件B,如何处理? +- 卸载插件B时,插件A怎么办? + +**建议方案:** +- 安装时自动检查并提示安装依赖 +- 卸载时检查是否有其他插件依赖,给出警告 + +### 10. 环境启动时的插件加载 + +**问题:** +- 环境启动时,如何知道要加载哪些插件? +- 插件数据是实时查询还是缓存? + +**建议方案:** +- 环境列表接口返回每个环境的插件列表 +- 浏览器内核启动时根据这个列表加载插件 +- 插件变更后,需要重启环境才能生效 + +### 11. 插件状态管理 + +**问题:** +- 插件可能有:已安装、已启用/禁用、待更新等状态 +- 团队插件被禁用,是全局禁用还是个人禁用? + +**建议方案:** +- 团队插件的状态是全局的(所有者/管理员控制) +- 普通用户不能禁用团队插件 +- 个人插件可以自由启用/禁用 + +### 12. 审计和追溯场景 + +**问题:** +- 谁在什么时候为团队安装了什么插件? +- 插件被卸载后,如何追溯? + +**建议方案:** +- 所有插件操作记录到审计日志 +- 包括:安装者、安装时间、安装范围、是否为团队安装 + +--- + +## 实现状态 + +### 已实现功能 + +#### 1. 数据库表结构 +- ✅ `user_extensions` - 用户全局插件 +- ✅ `team_extensions` - 团队全局插件 +- ✅ `group_extensions` - 分组插件(支持 `is_team_shared` 字段区分团队/个人) +- ❌ ~~`environment_extensions`~~ - 已删除(环境不直接绑定插件) + +#### 2. 核心功能 +- ✅ 安装扩展到 user/team/group +- ✅ 卸载扩展(按目标类型) +- ✅ 查询已安装扩展(用户维度、团队维度) +- ✅ 支持批量安装到多个分组 +- ✅ `is_team_shared` 字段区分分组团队插件和分组个人插件 + +#### 3. Scope 字段语义 +返回给前端的 `scope` 字段值: +- `user` - 用户全局插件 +- `team` - 团队全局插件 +- `group-personal` - 分组个人插件(`is_team_shared=false`) +- `group-team` - 分组团队插件(`is_team_shared=true`) + +#### 4. 权限检查 +- ✅ 团队全局插件:检查用户是否为 owner/admin +- ✅ 分组团队共享插件:检查用户是否有分组的"管理"权限 +- ✅ 分组个人插件:检查用户是否有分组的"编辑"权限 + +#### 5. 审计日志 +- ✅ 安装扩展时记录审计日志 +- ✅ 卸载扩展时记录审计日志 +- ✅ 使用现有的 `audit_logs` 表和 `insert_audit_log` 函数 + +### 未实现功能 + +#### 1. 环境获取插件的动态合并逻辑 +- ❌ 环境列表接口未返回每个环境的插件列表 +- ❌ 缺少"获取某个环境的插件列表"接口 +- 需要实现:查询环境时动态合并 4 个层级的插件 + - 用户个人全局插件 + - 团队全局插件 + - 该环境所属分组的个人插件 + - 该环境所属分组的团队插件 + +#### 2. 优先级规则和版本冲突处理 +- ❌ 未实现优先级覆盖逻辑 +- 当前阶段不考虑版本问题 + +#### 3. 插件更新机制 +- ❌ 只能更新用户扩展 +- ❌ 没有团队扩展更新逻辑 +- 当前阶段不考虑更新逻辑 + +#### 4. 插件状态管理 +- ⚠️ 只有 `active/inactive` 状态 +- ❌ 没有区分"团队插件禁用"和"个人禁用" + +### 与原方案的差异 + +1. **审计日志**:使用现有的 `audit_logs` 表,而不是创建新的 `extension_audit_logs` 表 +2. **权限字段**:使用现有的"管理、编辑、查看"权限,而不是新增 `manage` 权限 +3. **环境插件加载**:暂未实现环境启动时的插件动态合并逻辑 diff --git a/src-tauri/crates/business/docs/public-key-endpoint-plan.md b/src-tauri/crates/business/docs/public-key-endpoint-plan.md new file mode 100644 index 0000000..9d5d39f --- /dev/null +++ b/src-tauri/crates/business/docs/public-key-endpoint-plan.md @@ -0,0 +1,279 @@ +# 获取公钥 API 端点实现计划(已更新) + +## 概述 + +本计划用于实现 `GET /api/v1/secret/public/key` API 端点,该端点用于获取服务器的 RSA 公钥。客户端可以使用此公钥加密敏感数据,然后发送到服务器。 + +## 当前状态分析 + +### 已有功能 + +1. ✅ **路由配置**:路由已在配置文件中加入白名单(`configs/config.dev.toml` 和 `configs/config.prod.toml`) +2. ✅ **RSA 密钥工具**:`src/utils/secret/rsa.rs` 中已有 `RsaSecret` 结构和 `get_public_key()` 方法 +3. ✅ **全局 RSA 实例**:`get_rsa_secret_instance()` 函数可以获取全局 RSA 密钥实例 +4. ✅ **客户端实现参考**:`src-tauri` 中已有客户端获取公钥的实现,可作为参考 + +### 缺失功能 + +1. ❌ **Handler 函数**:缺少处理获取公钥请求的 handler 函数 +2. ❌ **路由注册**:缺少注册 `/secret/public/key` 路由的代码 +3. ❌ **Service 函数**:可选,可以直接在 handler 中调用工具函数 + +## 重要发现:客户端期望的响应格式 + +通过分析 `src-tauri/src/infrastructure/storage/credential.rs` 中的客户端实现,发现了关键信息: + +### 客户端代码分析 + +```rust +// 客户端发送 GET 请求 +let response = client.get(url).send().await?; + +// 客户端期望纯文本响应(不是 JSON) +let public_key = response.text().await?; + +// 客户端验证响应格式 +if !public_key.contains("-----BEGIN RSA PUBLIC KEY-----") { + return Err("服务器响应错误".to_string()); +} +``` + +### 关键结论 + +1. **响应格式**:客户端期望**纯文本响应**(`text/plain`),而不是 JSON 格式 +2. **响应内容**:直接返回 PEM 格式的公钥字符串,例如: + ``` + -----BEGIN RSA PUBLIC KEY----- + MIIBCgKCAQEA... + -----END RSA PUBLIC KEY----- + ``` +3. **路由路径**:`secret/public/key`(相对路径,完整路径为 `/api/v1/secret/public/key`) +4. **请求拦截器**:客户端的请求拦截器会跳过 `/secret/public/key` 路径的加密处理(见 `src-tauri/src/infrastructure/network/interceptors/request.rs`) + +## 实现方案 + +### 方案选择 + +由于获取服务器公钥是一个简单的操作,且客户端期望纯文本响应,建议: + +- **直接在 handler 中调用工具函数**,无需创建额外的 service 层 +- **返回纯文本响应**,使用 Axum 的 `PlainText` 或直接使用 `String` 实现 `IntoResponse` + +### 目录结构 + +参考现有的 `time` 模块结构: + +``` +src/ +├── handlers/ +│ ├── time.rs (已有) +│ └── secret.rs (新建) +├── routes/ +│ ├── time.rs (已有) +│ └── secret.rs (新建) +├── handlers.rs +└── routes.rs +``` + +## 实现步骤 + +### 步骤 1:创建 Handler 函数 + +**文件**:`src/handlers/secret.rs` + +**功能**: + +- 从全局 RSA 实例获取公钥 +- 返回纯文本响应(PEM 格式的公钥字符串) + +**代码结构**: + +```rust +use axum::response::{IntoResponse, Response}; +use axum::http::StatusCode; +use crate::utils::get_rsa_secret_instance; + +pub async fn get_public_key_handler() -> impl IntoResponse { + let public_key = get_rsa_secret_instance().get_public_key(); + (StatusCode::OK, public_key).into_response() +} +``` + +**说明**: + +- 使用 `impl IntoResponse` 作为返回类型,可以返回纯文本 +- `(StatusCode::OK, public_key)` 会创建一个状态码为 200 的纯文本响应 +- Axum 会自动设置 `Content-Type: text/plain; charset=utf-8` + +### 步骤 2:创建路由注册模块 + +**文件**:`src/routes/secret.rs` + +**功能**: + +- 注册 `/public/key` 路由 +- 使用 GET 方法 +- 绑定到 `get_public_key_handler` + +**代码结构**: + +```rust +use axum::routing::get; +use crate::handlers::get_public_key_handler; +use crate::routes::route::{self, MetaRoute}; + +pub fn register_routes(meta_route: &mut MetaRoute) -> () { + let mut secret_route = route::RouteGroup::new("/secret"); + + secret_route.add_route_item(route::RouteItem::get("/public/key", get(get_public_key_handler))); + + meta_route.add_route_group(secret_route); +} +``` + +### 步骤 3:注册模块 + +**文件**:`src/handlers.rs` + +```rust +mod time; +mod secret; // 新增 + +pub use time::*; +pub use secret::*; // 新增 +``` + +**文件**:`src/routes.rs` + +```rust +mod time; +mod secret; // 新增 + +pub fn all_routes(svc_ctx: &SvcCtx) -> Router { + // ... + time::register_routes(&mut meta_route); + secret::register_routes(&mut meta_route); // 新增 + // ... +} +``` + +### 步骤 4:验证和测试 + +1. **编译检查**:`cargo check` +2. **代码格式化**:`cargo fmt` +3. **Lint 检查**:`cargo clippy` +4. **功能测试**: + - 启动服务器 + - 使用 curl 或 Postman 测试端点: + ```bash + curl http://localhost:40041/api/v1/secret/public/key + ``` + - 验证返回的是 PEM 格式的公钥字符串 + - 验证响应头 `Content-Type` 为 `text/plain` + +## API 响应格式 + +### 成功响应(HTTP 200) + +**响应头**: + +``` +Content-Type: text/plain; charset=utf-8 +``` + +**响应体**(纯文本): + +``` +-----BEGIN RSA PUBLIC KEY----- +MIIBCgKCAQEAseI9vA7iTxOMb5Y2xCL7BOGr1by9qEH4EfP9Bj90gxDmY8yRsVK/ +o2g+i95oQxzdvdvpPAocKlQv2FEbZaqFr2Q4vy1cLrM0B1NTZl1/hGcmPSofLT9g +mnjzP60ikY40Dxq+YXAxXZ4s2M+9thNnFr4OydacHEPEkTklcBQBglopSXc1yqHU +ARyCQ3/VxQrfh215vIPgMg2f6PH741zXFaIJjucXR8wJVySo7aZhlBTOVz5GzV0b +aWh31zA47ivXh84OXIEI+CKDUSnvsa8SCRMRs8LgaO1Xktv4yCfHHpo8Zoy+KdW0 +LJxP11G+3f9RsYRpjdmsleyHuYYS07suqwIDAQAB +-----END RSA PUBLIC KEY----- +``` + +### 错误处理 + +如果 RSA 实例未初始化,`get_rsa_secret_instance()` 会直接退出程序(在启动时已初始化,正常情况下不会发生)。 + +## 注意事项 + +1. **响应格式的重要性**: + - ⚠️ **必须返回纯文本**,不能使用 `Response` 类型(它会转换为 JSON) + - 客户端使用 `response.text().await` 期望纯文本响应 + - 客户端会验证响应包含 `-----BEGIN RSA PUBLIC KEY-----` + +2. **安全性**: + - 该端点返回的是服务器的**公钥**,是公开信息,可以安全地暴露 + - 路由已在白名单中,无需认证即可访问 + - 客户端的请求拦截器会跳过此路径的加密处理 + +3. **性能**: + - 公钥是静态数据,从内存中读取,性能开销很小 + - 无需数据库查询 + +4. **代码风格**: + - 遵循项目现有的代码风格(参考 `time.rs` 的实现) + - 但注意响应格式不同(纯文本 vs JSON) + - 添加适当的文档注释 + +5. **测试环境差异**: + - `config.test.toml` 中使用的是 `/api/v2/secret/public/key` + - 如果需要支持 v2 版本,可以在同一 handler 中注册多个路由,或创建单独的 handler + +## 客户端兼容性 + +### 客户端实现位置 + +- **文件**:`src-tauri/src/infrastructure/storage/credential.rs` +- **函数**:`fetch_server_public_key()` +- **调用时机**:应用启动时(`init_server_public_key()`) + +### 客户端验证逻辑 + +```rust +// 1. 发送 GET 请求 +let response = client.get(url).send().await?; + +// 2. 获取纯文本响应 +let public_key = response.text().await?; + +// 3. 验证公钥格式 +if !public_key.contains("-----BEGIN RSA PUBLIC KEY-----") { + return Err("服务器响应错误".to_string()); +} + +// 4. 存储公钥 +*SERVER_PUBLIC_KEY.write().unwrap() = Some(public_key.clone()); +``` + +## 后续优化建议(可选) + +1. **HTTP 缓存**:公钥是静态的,可以考虑添加 HTTP 缓存头(如 `Cache-Control: public, max-age=3600`) +2. **版本控制**:如果需要支持 v2 API,可以创建 `routes/secret_v2.rs` +3. **文档**:添加 OpenAPI/Swagger 文档(如果项目使用) + +## 相关文件清单 + +### 需要创建的文件 + +- `src/handlers/secret.rs` +- `src/routes/secret.rs` + +### 需要修改的文件 + +- `src/handlers.rs` - 添加 `mod secret;` 和 `pub use secret::*;` +- `src/routes.rs` - 添加 `mod secret;` 和调用 `secret::register_routes()` + +### 相关配置文件(无需修改,已配置) + +- `configs/config.dev.toml` - 已添加路由白名单 +- `configs/config.prod.toml` - 已添加路由白名单 +- `configs/config.test.toml` - 已添加路由白名单(v2 版本) + +### 客户端参考代码 + +- `src-tauri/src/infrastructure/storage/credential.rs` - 客户端获取公钥的实现 +- `src-tauri/src/infrastructure/network/interceptors/request.rs` - 请求拦截器(跳过公钥端点加密) diff --git a/src-tauri/crates/business/docs/workspace-architecture-refactoring-report.md b/src-tauri/crates/business/docs/workspace-architecture-refactoring-report.md new file mode 100644 index 0000000..d1c1cd2 --- /dev/null +++ b/src-tauri/crates/business/docs/workspace-architecture-refactoring-report.md @@ -0,0 +1,686 @@ +# 工作空间架构重构变更报告 + +**重构日期**:2025-01-25 +**重构范围**:工作空间架构全面重构 +**文档版本**:v1.0 + +--- + +## 概述 + +本次重构将系统架构从"用户-团队-环境"模式升级为"用户-工作空间-团队-分组-环境"的多层级结构,实现了更灵活的资源管理、权限控制和计费体系。 + +--- + +## 1. 架构设计变更 + +### 1.1 新增架构文档 + +- **文件**:`simprint-server/docs/workspace-architecture.md` +- **内容**:完整的工作空间架构设计文档,包括: + - 核心概念和层级结构 + - 实体关系图(ASCII 格式) + - 实体详细设计 + - 权限控制体系 + - 配额管理 + - 代理可见性管理 + - 数据模型设计要点 + +### 1.2 核心设计原则 + +1. **工作空间是资源隔离边界**:所有资源(环境、代理、配额)都归属于工作空间 +2. **团队是协作组织单元**:团队成员共享团队资源,通过分组进行细粒度管理 +3. **分组是环境分类容器**:分组用于对团队内的环境进行分类和权限控制(可选) +4. **环境是核心操作单元**:所有业务操作围绕环境展开,环境必须归属团队 + +--- + +## 2. 数据库变更 + +### 2.1 新增表 + +#### 2.1.1 工作空间表(workspaces) + +- **迁移文件**:`20250125000001_create_workspaces.sql` +- **字段**: + - `uuid`:工作空间唯一标识 + - `name`:工作空间名称 + - `owner_uuid`:所有者用户 UUID + - `workspace_type`:工作空间类型(personal/team/enterprise) + - `created_at`、`updated_at`、`deleted_at`:时间戳 + +#### 2.1.2 工作空间配额表(workspace_quotas) + +- **迁移文件**:`20250125000002_create_workspace_quotas.sql` +- **字段**: + - `workspace_uuid`:工作空间 UUID(主键) + - `max_environments`、`used_environments`:环境配额 + - `max_team_members`、`used_team_members`:成员配额 + - `max_proxies`、`used_proxies`:代理配额 + - `max_rpa_tasks`、`used_rpa_tasks`:RPA 任务配额 + +#### 2.1.3 代理可见团队关联表(proxy_visible_teams) + +- **迁移文件**:`20250125000003_create_proxy_visible_teams.sql` +- **字段**: + - `proxy_uuid`:代理 UUID + - `workspace_uuid`:工作空间 UUID(冗余,便于查询) + - `team_uuid`:团队 UUID + - `created_at`:创建时间 +- **唯一约束**:`UNIQUE (proxy_uuid, team_uuid)` + +#### 2.1.4 分组权限表(group_member_permissions) + +- **迁移文件**:`20250125000004_create_group_member_permissions.sql` +- **字段**: + - `group_uuid`:分组 UUID + - `workspace_uuid`:工作空间 UUID(冗余,便于查询) + - `team_uuid`:团队 UUID(冗余,便于查询) + - `user_uuid`:用户 UUID + - `permission_type`:权限类型(read/write/manage) + - `granted_by`:授权者 UUID + - `created_at`、`updated_at`:时间戳 +- **唯一约束**:`UNIQUE (group_uuid, user_uuid)` + +### 2.2 修改现有表 + +#### 2.2.1 团队表(teams) + +- **迁移文件**:`20250125000005_alter_teams_add_workspace.sql` +- **新增字段**: + - `workspace_uuid`:所属工作空间 UUID +- **移除字段**: + - `max_members`、`max_environments`、`max_proxies`:配额移至 `workspace_quotas` + - `default_proxy_uuid`:不再需要默认代理 + +#### 2.2.2 团队成员表(team_members) + +- **迁移文件**:`20250125000006_alter_team_members_add_workspace.sql` +- **新增字段**: + - `workspace_uuid`:工作空间 UUID(冗余,便于查询) +- **移除字段**: + - `environment_count`、`group_count`:统计字段,可通过查询计算 +- **更新唯一约束**:`UNIQUE (team_uuid, user_uuid, workspace_uuid)` + +#### 2.2.3 分组表(groups) + +- **迁移文件**:`20250125000007_alter_groups_add_workspace.sql` +- **新增字段**: + - `workspace_uuid`:所属工作空间 UUID(冗余,便于查询) +- **移除字段**: + - `user_uuid`:分组属于团队,不属于用户 + - `default_proxy_uuid`:不再需要默认代理 + - `color`:前端可自行管理 +- **约束**:确保 `team_uuid` 为 NOT NULL + +#### 2.2.4 环境表(environments) + +- **迁移文件**:`20250125000008_alter_environments_add_workspace.sql` +- **新增字段**: + - `workspace_uuid`:所属工作空间 UUID(冗余,便于查询) +- **约束**:确保 `team_uuid` 为 NOT NULL + +#### 2.2.5 代理表(proxies) + +- **迁移文件**:`20250125000009_alter_proxies_add_workspace.sql` +- **新增字段**: + - `workspace_uuid`:所属工作空间 UUID(必须) + - `owner_uuid`:代理所有者 UUID(重命名自 `user_uuid`) +- **移除字段**: + - `team_uuid`:代理属于工作空间,不属于团队 + - `usage_count`:可通过查询计算 + +#### 2.2.6 订阅表(subscriptions) + +- **迁移文件**:`20250125000010_alter_subscriptions_add_workspace.sql` +- **新增字段**: + - `workspace_uuid`:所属工作空间 UUID + +#### 2.2.7 用户信息表(user_infos) + +- **迁移文件**:`20250125000013_alter_user_infos_add_current_workspace.sql` +- **新增字段**: + - `current_workspace_uuid`:用户当前工作空间 UUID + +### 2.3 数据迁移 + +#### 2.3.1 废弃用户配额表 + +- **迁移文件**:`20250125000011_deprecate_user_quotas.sql` +- **操作**:将 `user_quotas` 表重命名为 `deprecated_user_quotas` + +#### 2.3.2 数据迁移脚本 + +- **迁移文件**:`20250125000012_migrate_to_workspaces.sql` +- **操作内容**: + 1. 为每个现有用户创建默认个人工作空间 + 2. 为每个工作空间创建默认配额 + 3. 将现有 `user_quotas` 数据迁移到 `workspace_quotas` + 4. 更新所有相关表的 `workspace_uuid` 字段 + 5. 添加外键约束 + 6. 设置 `workspace_uuid` 为 NOT NULL + +--- + +## 3. 代码层变更 + +### 3.1 DTO 层(Data Transfer Object) + +#### 3.1.1 新增 DTO + +- `simprint-server/src/dto/workspaces.rs`:工作空间 DTO +- `simprint-server/src/dto/workspace_quotas.rs`:工作空间配额 DTO +- `simprint-server/src/dto/proxy_visible_teams.rs`:代理可见团队 DTO +- `simprint-server/src/dto/group_member_permissions.rs`:分组权限 DTO + +#### 3.1.2 修改现有 DTO + +- `simprint-server/src/dto/teams.rs`:添加 `workspace_uuid`,移除配额相关字段 +- `simprint-server/src/dto/groups.rs`:添加 `workspace_uuid`,移除 `user_uuid`、`default_proxy_uuid`、`color` +- `simprint-server/src/dto/environments.rs`:添加 `workspace_uuid` +- `simprint-server/src/dto/proxies.rs`:添加 `workspace_uuid`、`owner_uuid`,移除 `team_uuid` +- `simprint-server/src/dto/subscriptions.rs`:添加 `workspace_uuid` +- `simprint-server/src/dto/user.rs`:添加 `current_workspace_uuid` + +### 3.2 Entity 层(请求/响应实体) + +#### 3.2.1 新增 Entity + +- `simprint-server/src/entitys/workspaces.rs`:工作空间相关请求/响应 +- `simprint-server/src/entitys/workspace_quotas.rs`:工作空间配额响应 +- `simprint-server/src/entitys/proxy_visible_teams.rs`:代理可见性请求/响应 +- `simprint-server/src/entitys/group_member_permissions.rs`:分组权限请求/响应 + +#### 3.2.2 修改现有 Entity + +- `simprint-server/src/entitys/teams.rs`:添加 `workspace_uuid`,移除 `default_proxy_uuid` +- `simprint-server/src/entitys/groups.rs`:添加 `workspace_uuid`、`team_uuid`,移除 `default_proxy_uuid` +- `simprint-server/src/entitys/environments.rs`:添加 `workspace_uuid`、`team_uuid` +- `simprint-server/src/entitys/proxies.rs`:添加 `workspace_uuid` +- `simprint-server/src/entitys/subscriptions.rs`:添加 `workspace_uuid` + +### 3.3 Model 层(数据库操作) + +#### 3.3.1 新增 Model + +- `simprint-server/src/models/workspaces.rs`:工作空间数据库操作 +- `simprint-server/src/models/workspace_quotas.rs`:工作空间配额数据库操作 +- `simprint-server/src/models/proxy_visible_teams.rs`:代理可见性数据库操作 +- `simprint-server/src/models/group_member_permissions.rs`:分组权限数据库操作 + +#### 3.3.2 修改现有 Model + +- `simprint-server/src/models/teams.rs`: + - `fetch_team_member`:添加 `workspace_uuid` 参数(工作空间级别隔离) + - `insert_team`:添加 `workspace_uuid` 参数 + - `fetch_team_by_uuid`:添加 `workspace_uuid` 到 SELECT + - `fetch_user_teams`:添加 `workspace_uuid` 过滤 + - 移除配额相关逻辑 + +- `simprint-server/src/models/team_members.rs`: + - 所有函数添加 `workspace_uuid` 参数 + - SQL 查询包含 `workspace_uuid` 过滤 + +- `simprint-server/src/models/groups.rs`: + - `insert_group`:添加 `workspace_uuid`、`team_uuid` 参数,移除 `user_uuid` + - `fetch_groups`:使用 `workspace_uuid` 和 `team_uuid` 过滤 + - 移除 `default_proxy_uuid` 相关逻辑 + +- `simprint-server/src/models/environments.rs`: + - `insert_environment`:添加 `workspace_uuid` 参数 + - `fetch_environment_by_uuid`:添加 `workspace_uuid` 参数进行过滤 + - 新增 `fetch_environment_by_uuid_unfiltered`:用于内部查询 + - 所有查询函数添加 `workspace_uuid` 过滤 + +- `simprint-server/src/models/proxies.rs`: + - `insert_proxy`:添加 `workspace_uuid` 参数,`user_uuid` 改为 `owner_uuid`,移除 `team_uuid` + - `fetch_proxies`:使用 `workspace_uuid` 和 `owner_uuid` 过滤 + - 移除 `usage_count` 相关逻辑 + +- `simprint-server/src/models/subscriptions.rs`: + - 所有函数添加 `workspace_uuid` 参数 + +- `simprint-server/src/models/user.rs`: + - `create_user_with_info`:创建默认工作空间和配额 + - `fetch_user_info_by_uuid`、`fetch_user_info_by_email`:添加 `current_workspace_uuid` 到 SELECT + +### 3.4 Service 层(业务逻辑) + +#### 3.4.1 新增 Service + +- `simprint-server/src/services/workspaces.rs`:工作空间业务逻辑 +- `simprint-server/src/services/workspace_quotas.rs`:工作空间配额业务逻辑 +- `simprint-server/src/services/proxy_visibility.rs`:代理可见性业务逻辑 +- `simprint-server/src/services/group_permissions.rs`:分组权限业务逻辑 + +#### 3.4.2 修改现有 Service + +**环境服务(environments.rs)**: + +- `create_environment_service`: + - 添加工作空间级别团队成员检查 + - 添加分组权限检查(write/manage) + - 添加团队角色权限检查(Editor/Admin/Owner) + - 添加配额检查和更新 +- `get_environment_service`: + - 添加工作空间过滤 + - 添加分组 read 权限检查 +- `get_environments_service`: + - 添加批量权限过滤(根据分组权限) +- `update_environment_service`: + - 添加编辑权限检查(分组 write/manage 或团队角色) +- `delete_environment_service`: + - 添加删除权限检查(分组 manage 或团队 Owner/Admin) + - 添加配额更新 + +**分组服务(groups.rs)**: + +- `create_group_service`:添加 Owner/Admin 权限检查 +- `update_group_service`:添加 Owner/Admin 或 manage 权限检查 +- `delete_group_service`:添加 Owner/Admin 或 manage 权限检查 + +**团队服务(teams.rs)**: + +- 所有函数添加 `workspace_uuid` 参数 +- `switch_team_service`:更新 `current_workspace_uuid` + +**代理服务(proxies.rs)**: + +- `create_proxy_service`:添加配额检查和更新 +- `delete_proxy_service`:添加配额更新 +- `batch_import_proxies_service`:添加配额检查和更新 + +**模板服务(templates.rs)**: + +- `create_template_service`:从源环境获取 `workspace_uuid` 和 `team_uuid` 进行权限检查 +- `apply_template_service`:添加权限检查参数 + +### 3.5 Handler 层(API 端点) + +#### 3.5.1 新增 Handler + +- `simprint-server/src/handlers/client/workspaces.rs`:工作空间 API 端点 +- `simprint-server/src/handlers/client/workspace_quotas.rs`:工作空间配额 API 端点 +- `simprint-server/src/handlers/client/proxy_visibility.rs`:代理可见性 API 端点 +- `simprint-server/src/handlers/client/group_permissions.rs`:分组权限 API 端点 + +#### 3.5.2 修改现有 Handler + +- `simprint-server/src/handlers/client/teams.rs`:使用 `workspace_uuid` 从 `RequestContext` 获取 +- `simprint-server/src/handlers/client/environments.rs`:使用 `workspace_uuid` 和 `team_uuid` 从 `RequestContext` 获取 +- `simprint-server/src/handlers/client/proxies.rs`:使用 `workspace_uuid` 和 `owner_uuid` 从 `RequestContext` 获取 +- `simprint-server/src/handlers/client/templates.rs`:添加权限检查参数 + +### 3.6 Route 层(路由注册) + +#### 3.6.1 新增 Route + +- `simprint-server/src/routes/client/workspaces.rs`:工作空间路由 +- `simprint-server/src/routes/client/workspace_quotas.rs`:工作空间配额路由 +- `simprint-server/src/routes/client/proxy_visibility.rs`:代理可见性路由 +- `simprint-server/src/routes/client/group_permissions.rs`:分组权限路由 + +#### 3.6.2 修改现有 Route + +- `simprint-server/src/routes/client/mod.rs`:注册新路由模块 +- `simprint-server/src/main.rs`:注册所有新路由 + +### 3.7 中间件和状态管理 + +#### 3.7.1 状态管理(state.rs) + +- 添加 `CurrentWorkspace` 结构体 +- `RequestContext` 添加 `current_workspace_uuid` 字段 + +#### 3.7.2 认证中间件(middlewares/auth.rs) + +- 从 `user_infos` 获取 `current_workspace_uuid` 并设置到 `RequestContext` + +--- + +## 4. 核心功能实现 + +### 4.1 权限检查体系 + +#### 4.1.1 工作空间级别隔离 + +- 所有团队成员关系查询都包含 `workspace_uuid` 过滤 +- `fetch_team_member` 函数签名包含 `workspace_uuid` 参数 +- 确保用户在不同工作空间中有不同的团队成员身份 + +#### 4.1.2 环境权限检查 + +- **创建环境**: + - 检查用户是否在当前工作空间的团队中 + - 如果指定分组,检查分组 write/manage 权限 + - 如果未指定分组,检查团队角色权限(Editor/Admin/Owner) + - 检查工作空间配额 +- **查看环境**: + - 检查用户是否在当前工作空间的团队中 + - 如果环境有分组,检查分组 read 权限 + - Owner/Admin 自动拥有所有分组权限 + - 无分组环境,所有团队成员都可以查看 +- **更新环境**: + - 检查分组 write/manage 权限或团队角色权限(Editor/Admin/Owner) +- **删除环境**: + - 检查分组 manage 权限或团队 Owner/Admin 角色 + +#### 4.1.3 分组权限检查 + +- **创建分组**:只有 Owner/Admin 可以创建 +- **更新分组**:Owner/Admin 或拥有 manage 权限 +- **删除分组**:Owner/Admin 或拥有 manage 权限 +- **分组权限函数**:`check_group_permission` 自动处理 Owner/Admin 权限 + +### 4.2 配额管理体系 + +#### 4.2.1 环境配额 + +- **检查**:`check_quota(workspace_uuid, "environments")` +- **更新**: + - 创建环境:`increment_used_environments(workspace_uuid, 1)` + - 删除环境:`decrement_used_environments(workspace_uuid, 1)` + +#### 4.2.2 代理配额 + +- **检查**:`check_quota(workspace_uuid, "proxies")` +- **更新**: + - 创建代理:`increment_used_proxies(workspace_uuid, 1)` + - 删除代理:`decrement_used_proxies(workspace_uuid, 1)` + - 批量导入:每成功导入一个代理就更新一次 + +#### 4.2.3 成员配额 + +- **检查**:`check_quota(workspace_uuid, "team_members")` +- **更新**: + - 接受邀请:`update_used_team_members(workspace_uuid)`(仅当用户之前不是该工作空间的成员时) + - 移除成员:`update_used_team_members(workspace_uuid)` + - 退出团队:`update_used_team_members(workspace_uuid)` +- **注意**:成员配额是统计所有团队的活跃成员总数,需要重新计算 + +### 4.3 代理可见性管理 + +#### 4.3.1 可见性规则 + +1. **工作空间 Owner**:可以看到所有代理 +2. **代理所有者**:可以看到自己的代理(无论是否在可见列表中) +3. **团队成员**:只能看到 `proxy_visible_teams` 中包含其团队的代理 +4. **否则**:不可见 + +#### 4.3.2 可见性设置 + +- 只有代理所有者或工作空间所有者可以设置可见性 +- 通过 `proxy_visible_teams` 关联表控制可见性 +- 支持批量设置可见性 + +### 4.4 工作空间管理 + +#### 4.4.1 工作空间创建 + +- 用户注册时自动创建个人工作空间 +- 支持创建不同类型的工作空间(personal/team/enterprise) +- 自动创建默认配额 + +#### 4.4.2 工作空间切换 + +- 用户可以在多个工作空间之间切换 +- `user_infos.current_workspace_uuid` 记录当前工作空间 +- 认证中间件自动设置当前工作空间 + +--- + +## 5. API 端点变更 + +### 5.1 新增 API 端点 + +#### 5.1.1 工作空间相关 + +- `POST /workspaces/create`:创建工作空间 +- `POST /workspaces/list`:获取用户的工作空间列表 +- `POST /workspaces/get`:获取工作空间详情 +- `POST /workspaces/update`:更新工作空间 +- `POST /workspaces/delete`:删除工作空间 +- `POST /workspaces/switch`:切换工作空间 + +#### 5.1.2 工作空间配额相关 + +- `POST /workspace-quotas/get`:获取工作空间配额 +- `POST /workspace-quotas/update`:更新配额使用情况 + +#### 5.1.3 代理可见性相关 + +- `POST /proxy-visibility/set`:设置代理对团队可见 +- `POST /proxy-visibility/remove`:移除代理对团队的可见性 +- `POST /proxy-visibility/batch-set`:批量设置代理可见性 +- `POST /proxy-visibility/list-visible`:获取可见的代理列表 +- `POST /proxy-visibility/list-teams`:获取代理的可见团队列表 + +#### 5.1.4 分组权限相关 + +- `POST /group-permissions/grant`:授予分组权限 +- `POST /group-permissions/revoke`:撤销分组权限 +- `POST /group-permissions/check`:检查分组权限 +- `POST /group-permissions/list`:列出用户的分组权限 + +### 5.2 修改现有 API 端点 + +所有现有 API 端点现在都需要: + +- 从 `RequestContext` 获取 `current_workspace_uuid` +- 从 `RequestContext` 获取 `current_team_uuid`(如适用) +- 传递 `workspace_uuid` 和 `team_uuid` 到服务层 + +--- + +## 6. 数据模型设计要点 + +### 6.1 冗余字段设计 + +为了优化查询性能,以下表包含冗余字段: + +- `groups.workspace_uuid`:避免 JOIN 查询 +- `environments.workspace_uuid`:便于直接查询工作空间的环境 +- `team_members.workspace_uuid`:便于直接查询工作空间内的团队成员关系(工作空间级别的隔离) +- `group_member_permissions.workspace_uuid`、`team_uuid`:便于权限查询 +- `proxy_visible_teams.workspace_uuid`:便于直接查询工作空间内的代理可见性 + +### 6.2 软删除策略 + +所有表都支持软删除: + +- 使用 `deleted_at` 字段标记删除 +- 查询时默认过滤已删除记录 +- 支持数据恢复 + +### 6.3 时间戳管理 + +所有表都包含时间戳字段: + +- `created_at`:创建时间 +- `updated_at`:更新时间(自动更新) +- `deleted_at`:软删除时间(如适用) + +### 6.4 UUID 主键 + +所有表使用 UUID 作为主键: + +- 全局唯一标识 +- 避免 ID 冲突 +- 支持分布式系统 + +--- + +## 7. 关键设计决策 + +### 7.1 工作空间级别隔离 + +**决策**:团队成员关系是工作空间级别的,不是全局的。 + +**原因**: + +- 用户在不同工作空间中可能有不同的团队成员身份和角色 +- 提供更好的资源隔离和权限控制 +- 支持多工作空间场景 + +**实现**: + +- `team_members` 表包含 `workspace_uuid` 字段(冗余) +- 所有团队成员查询都包含 `workspace_uuid` 过滤 +- 唯一约束包含 `workspace_uuid`:`UNIQUE (team_uuid, user_uuid, workspace_uuid)` + +### 7.2 环境直接归属团队 + +**决策**:环境必须直接归属团队,分组是可选的分类容器。 + +**原因**: + +- 简化数据模型 +- 提供更清晰的权限控制 +- 支持无分组场景 + +**实现**: + +- `environments.team_uuid` 为 NOT NULL +- `environments.group_uuid` 为可选(NULL 表示直接归属团队) +- 权限控制:有分组用分组权限,无分组用团队权限 + +### 7.3 代理属于工作空间 + +**决策**:代理属于工作空间,不属于团队,通过可见性控制访问。 + +**原因**: + +- 代理是工作空间级别的资源 +- 支持跨团队共享代理 +- 提供更灵活的代理管理 + +**实现**: + +- `proxies.workspace_uuid` 为必须 +- `proxies.owner_uuid` 记录代理所有者 +- `proxy_visible_teams` 关联表控制可见性 + +### 7.4 配额基于工作空间 + +**决策**:配额是工作空间级别的,所有团队共享。 + +**原因**: + +- 简化配额管理 +- 支持统一计费 +- 便于资源分配 + +**实现**: + +- `workspace_quotas` 表存储工作空间配额 +- 所有配额检查和更新都基于 `workspace_uuid` +- 成员配额统计所有团队的活跃成员总数 + +--- + +## 8. 迁移和兼容性 + +### 8.1 数据迁移 + +- 为每个现有用户创建默认个人工作空间 +- 将现有 `user_quotas` 数据迁移到 `workspace_quotas` +- 更新所有相关表的 `workspace_uuid` 字段 +- 确保数据完整性 + +### 8.2 向后兼容 + +- 保留 `deprecated_user_quotas` 表用于历史数据 +- 迁移脚本确保所有现有数据都有对应的 `workspace_uuid` +- 用户注册时自动创建个人工作空间 + +--- + +## 9. 测试建议 + +### 9.1 单元测试 + +- 工作空间级别隔离测试 +- 权限检查函数测试 +- 配额管理函数测试 + +### 9.2 集成测试 + +- 代理可见性逻辑测试 +- 分组权限控制测试 +- 工作空间切换测试 + +### 9.3 安全测试 + +- 跨工作空间访问测试 +- 权限绕过测试 +- 配额绕过测试 + +--- + +## 10. 已知问题和限制 + +### 10.1 数据库约束 + +- 迁移脚本中注释掉了 `team_uuid` 的 NOT NULL 约束 +- **建议**:在数据迁移完成后,确保所有环境都有 `team_uuid`,然后添加 NOT NULL 约束 + +### 10.2 性能优化 + +- 冗余字段设计已优化查询性能 +- 建议添加适当的索引以进一步提升性能 + +--- + +## 11. 后续工作 + +### 11.1 待完成 + +- [ ] 在数据迁移完成后,添加 `team_uuid` 的 NOT NULL 约束 +- [ ] 添加单元测试和集成测试 +- [ ] 进行安全审查 + +### 11.2 优化建议 + +- [ ] 添加缓存层以提升性能 +- [ ] 优化配额统计查询 +- [ ] 添加配额使用情况监控 + +--- + +## 12. 总结 + +本次重构实现了完整的工作空间架构,包括: + +✅ **核心功能**: + +- 工作空间管理 +- 工作空间级别隔离 +- 权限检查体系 +- 配额管理体系 +- 代理可见性管理 + +✅ **数据模型**: + +- 4 个新表 +- 7 个表的结构修改 +- 完整的数据迁移脚本 + +✅ **代码实现**: + +- 4 个新的 Service 模块 +- 4 个新的 Handler 模块 +- 4 个新的 Route 模块 +- 所有现有模块的更新 + +✅ **API 端点**: + +- 15+ 个新的 API 端点 +- 所有现有端点的更新 + +所有实现都符合 `workspace-architecture.md` 文档的设计要求,确保了系统的可扩展性、安全性和可维护性。 + +--- + +**报告生成时间**:2025-01-25 +**报告版本**:v1.0 +**文档维护者**:开发团队 diff --git a/src-tauri/crates/business/docs/workspace-architecture.md b/src-tauri/crates/business/docs/workspace-architecture.md new file mode 100644 index 0000000..3403ff9 --- /dev/null +++ b/src-tauri/crates/business/docs/workspace-architecture.md @@ -0,0 +1,884 @@ +# 工作空间架构设计 + +## 概述 + +本文档描述了 Simprint 系统的核心架构设计,采用"用户-工作空间-团队-分组-环境"的多层级结构,实现灵活的资源管理、权限控制和计费体系。 + +## 核心概念 + +### 层级结构 + +``` +用户(User) + └─ 工作空间(Workspace) + ├─ 团队(Team) + │ ├─ 成员(Team Members) + │ └─ 分组(Group) + │ └─ 环境(Environment) + ├─ 代理(Proxy) + ├─ 配额(Quota) + └─ 订阅(Subscription) +``` + +### 关键设计原则 + +1. **工作空间是资源隔离边界**:所有资源(环境、代理、配额)都归属于工作空间 +2. **团队是协作组织单元**:团队成员共享团队资源,通过分组进行细粒度管理 +3. **分组是环境分类容器**:分组用于对团队内的环境进行分类和权限控制 +4. **环境是核心操作单元**:所有业务操作围绕环境展开 + +## 实体关系图 + +### 核心实体关系 + +``` + ┌───────────────────────┐ + │ users │ + │ (用户基础信息) │ + └───────────┬───────────┘ + │ + │ 1:N + │ + ┌───────────────────────────┼───────────────────────────┐ + │ │ │ + │ │ │ + ▼ ▼ ▼ + ┌───────────────┐ ┌───────────────┐ ┌───────────────┐ + │ workspaces │ │ user_infos │ │ user_wallets │ + │ (工作空间) │ │ (用户详情) │ │ (用户钱包) │ + └───────┬───────┘ └───────────────┘ └───────────────┘ + │ + │ 1:N (owner) + │ + ▼ + ┌───────────────┐ + │ teams │◄──────────┐ + │ (团队) │ │ + └───────┬───────┘ │ + │ │ N:M + │ │ + ┌───────────┼───────────┐ │ + │ │ │ │ + │ 1:N │ 1:N │ │ + │ (可选) │ (必须) │ │ + │ │ │ │ + ▼ ▼ │ │ +┌───────────────┐ ┌───────────────┐ │ +│ groups │ │ environments │ │ +│ (分组) │ │ (环境) │ │ +│ (可选分类) │ │ (直接归属团队) │ │ +└───────┬───────┘ └───────┬───────┘ │ + │ │ │ + │ N:1 (可选) │ N:1 (必须)│ + │ (环境可选归属分组)│ (环境必须归属团队)│ + │ │ │ + └─────────┬───────┘ │ + │ │ + │ │ + │ + │ + ┌───────────────┴───────────────┐ + │ │ + ▼ ▼ + ┌───────────────┐ ┌───────────────┐ + │team_members │──────────────►│ users │ + │(团队成员关系) │ │ (用户) │ + │(工作空间级别) │ │ │ + └───────┬───────┘ └───────────────┘ + │ + │ N:1 (冗余) + │ (通过 team.workspace_uuid) + │ + ▼ + ┌───────────────┐ + │ workspaces │ + │ (工作空间) │ + └───────────────┘ +``` + +### 工作空间资源关系 + +``` + ┌───────────────┐ + │ workspaces │ + │ (工作空间) │ + └───────┬───────┘ + │ + ┌───────────────────┼───────────────────┐ + │ │ │ + │ 1:N │ 1:1 │ 1:N + │ │ │ + ▼ ▼ ▼ +┌───────────────┐ ┌───────────────┐ ┌───────────────┐ +│ teams │ │workspace_quotas│ │ subscriptions │ +│ (团队) │ │ (工作空间配额) │ │ (订阅) │ +└───────┬───────┘ └───────────────┘ └───────────────┘ + │ + │ 1:N + │ + ▼ +┌───────────────┐ +│ groups │ +│ (分组) │ +└───────┬───────┘ + │ + │ 1:N (可选) + │ (分组可以没有环境) + │ + ▼ +┌───────────────┐ +│ environments │ +│ (环境) │ +└───────┬───────┘ + │ + │ N:1 (必须) + │ (环境必须属于团队) + │ + ▼ +┌───────────────┐ +│ teams │ +│ (团队) │ +└───────────────┘ +``` + +### 代理资源关系 + +``` + ┌───────────────┐ + │ workspaces │ + │ (工作空间) │ + └───────┬───────┘ + │ + │ 1:N + │ + ▼ + ┌───────────────┐ + │ proxies │ + │ (代理) │ + │ (工作空间级别) │ + └───────┬───────┘ + │ + ┌───────────────────┼───────────────────┐ + │ │ │ + │ N:1 (可选) │ N:M │ N:1 + │ (环境选择代理) │ (可见性控制) │ (所有者) + │ │ │ + ▼ ▼ ▼ +┌───────────────┐ ┌───────────────┐ ┌───────────────┐ +│ environments │ │proxy_visible_ │ │ users │ +│ (环境) │ │teams │ │ (用户) │ +│ │ │(代理可见团队) │ │ (所有者) │ +└───────────────┘ └───────┬───────┘ └───────────────┘ + │ + │ N:1 + │ + ▼ + ┌───────────────┐ + │ teams │ + │ (团队) │ + └───────────────┘ +``` + +### 权限控制关系 + +``` + ┌───────────────┐ + │ workspaces │ + │ (工作空间) │ + └───────┬───────┘ + │ + │ 1:N + │ + ▼ + ┌───────────────┐ + │ teams │ + │ (团队) │ + └───────┬───────┘ + │ + ┌───────────────────┼───────────────────┐ + │ │ │ + │ 1:N │ N:M │ 1:N + │ │ (工作空间级别) │ + │ │ │ + ▼ ▼ ▼ +┌───────────────┐ ┌───────────────┐ ┌───────────────┐ +│ groups │ │team_members │ │group_member_ │ +│ (分组) │ │(团队成员关系) │ │permissions │ +│ │ │(工作空间级别) │ │(分组权限) │ +└───────┬───────┘ └───────┬───────┘ └───────┬───────┘ + │ │ │ + │ N:1 │ N:1 │ N:1 + │ │ │ + ▼ ▼ ▼ +┌───────────────┐ ┌───────────────┐ ┌───────────────┐ +│ environments │ │ users │ │ users │ +│ (环境) │ │ (用户) │ │ (用户) │ +└───────────────┘ └───────────────┘ └───────────────┘ +``` + +## 实体详细设计 + +### 1. 用户(Users) + +**职责**:系统的基础实体,代表使用系统的个人。 + +**关键属性**: + +- `uuid`:用户唯一标识 +- `id`:业务层用户ID +- 与工作空间的关系:一个用户可以有多个工作空间(作为 Owner) + +**关系**: + +- 1:N → 工作空间(作为 Owner) +- N:M → 团队(通过 team_members,工作空间级别) +- N:M → 分组权限(通过 group_member_permissions,工作空间级别) + +**设计要点**: + +- 用户在不同工作空间中,可能有不同的团队成员身份 +- 团队成员关系是工作空间级别的,不是全局的 + +### 2. 工作空间(Workspaces) + +**职责**:资源隔离的顶层容器,所有资源都归属于工作空间。 + +**关键属性**: + +- `uuid`:工作空间唯一标识 +- `name`:工作空间名称 +- `owner_uuid`:所有者用户 UUID +- `workspace_type`:工作空间类型(personal/team/enterprise) + +**关系**: + +- N:1 → 用户(Owner) +- 1:N → 团队 +- 1:1 → 工作空间配额 +- 1:N → 订阅 +- 1:N → 代理(工作空间级别) + +**设计要点**: + +- 工作空间是计费和配额的基础单元 +- 工作空间内的所有资源共享配额 +- 工作空间 Owner 拥有最高权限 + +### 3. 团队(Teams) + +**职责**:工作空间内的协作组织单元,管理团队成员和团队资源。 + +**关键属性**: + +- `uuid`:团队唯一标识 +- `workspace_uuid`:所属工作空间 UUID +- `name`:团队名称 +- `owner_uuid`:团队所有者 UUID +- `description`:团队描述 + +**关系**: + +- N:1 → 工作空间 +- N:1 → 用户(Owner) +- N:M → 用户(通过 team_members) +- 1:N → 分组(可选,团队可以没有分组) +- 1:N → 环境(直接关联,环境必须属于团队) + +**设计要点**: + +- 团队是权限管理的基础单元 +- 团队成员通过 team_members 表关联 +- 团队 Owner/Admin 可以管理团队内的分组和权限 + +### 4. 分组(Groups) + +**职责**:团队内环境的分类容器,用于环境组织和权限控制(可选功能)。 + +**关键属性**: + +- `uuid`:分组唯一标识 +- `workspace_uuid`:所属工作空间 UUID(冗余,便于查询) +- `team_uuid`:所属团队 UUID +- `name`:分组名称 +- `description`:分组描述 + +**关系**: + +- N:1 → 工作空间 +- N:1 → 团队 +- 1:N → 环境(可选,分组可以没有环境) +- N:M → 用户(通过 group_member_permissions) + +**设计要点**: + +- 分组属于团队,用于对团队内的环境进行分类 +- **分组可以独立存在,不一定要有环境**(分组可以预先创建,等待环境加入) +- 分组权限控制团队成员对分组内环境的访问 +- 分组是可选功能,环境可以不使用分组直接归属团队 + +### 5. 环境(Environments) + +**职责**:系统的核心操作单元,所有业务操作围绕环境展开。 + +**关键属性**: + +- `uuid`:环境唯一标识 +- `workspace_uuid`:所属工作空间 UUID(冗余,便于查询) +- `team_uuid`:所属团队 UUID(必须) +- `group_uuid`:所属分组 UUID(可选,NULL 表示直接归属团队) +- `proxy_uuid`:环境使用的代理 UUID(可选,用户明确选择) +- `name`:环境名称 +- `status`:环境状态 + +**关系**: + +- N:1 → 工作空间 +- N:1 → 团队(必须) +- N:1 → 分组(可选) +- N:1 → 代理(可选) + +**设计要点**: + +- **环境必须直接归属团队**(`team_uuid` NOT NULL) +- **环境可以属于某个分组**(`group_uuid` 可选,NULL 表示未分组) +- **环境直接归属团队时,使用团队级别的权限控制** +- **环境可以设置代理**(`proxy_uuid` 可选),用户从可见的代理列表中选择 +- **没有代理继承概念**,用户必须明确选择代理 + +### 6. 代理(Proxies) + +**职责**:网络代理资源,为环境提供网络访问能力。 + +**关键属性**: + +- `uuid`:代理唯一标识 +- `workspace_uuid`:所属工作空间 UUID(必须) +- `owner_uuid`:代理所有者 UUID(创建者) +- `name`:代理名称 +- `proxy_type`:代理类型 +- `host`、`port`:代理地址 +- `status`:代理状态(active/inactive/testing/error) + +**关系**: + +- N:1 → 工作空间(必须) +- N:1 → 用户(Owner,创建者) +- 1:N → 环境(通过环境选择,可选) +- N:M → 团队(通过 proxy_visible_teams,控制可见性) + +**设计要点**: + +- **代理属于工作空间**,不属于团队(`team_uuid` 不存在) +- **代理默认不可见**,只有所有者可以看到 +- **代理可见性通过关联表控制**:`proxy_visible_teams` 表存储代理对哪些团队可见 +- **代理所有者可以设置可见性**:可以设置代理对特定团队可见 +- **工作空间 Owner 可以看到所有代理**,并可以管理所有代理 +- **创建环境时,代理是可选的**,用户从可见的代理列表中选择 +- **没有继承概念**,用户必须明确选择代理 + +### 7. 团队成员(Team Members) + +**职责**:用户与团队的关联关系,定义用户在团队中的角色(工作空间级别)。 + +**关键属性**: + +- `team_uuid`:团队 UUID +- `workspace_uuid`:工作空间 UUID(冗余,便于查询) +- `user_uuid`:用户 UUID +- `role`:角色(owner/admin/editor/viewer) +- `status`:状态(active/pending/inactive) + +**关系**: + +- N:1 → 团队 +- N:1 → 用户 +- N:1 → 工作空间(冗余,通过 team.workspace_uuid 关联) + +**设计要点**: + +- **团队成员关系是工作空间级别的**:用户在某个工作空间加入团队,只在该工作空间内属于该团队 +- 同一用户在不同工作空间中,可能有不同的团队成员身份和角色 +- 定义用户在团队中的角色和权限 +- Owner/Admin 拥有团队管理权限 +- Editor 可以创建和编辑环境 +- Viewer 只能查看环境 +- `workspace_uuid` 作为冗余字段,便于直接查询工作空间内的团队成员关系 + +### 8. 分组权限(Group Member Permissions) + +**职责**:控制团队成员对分组的访问权限,实现细粒度权限控制。 + +**关键属性**: + +- `group_uuid`:分组 UUID +- `workspace_uuid`:工作空间 UUID(冗余,便于查询) +- `team_uuid`:团队 UUID(冗余,便于查询) +- `user_uuid`:用户 UUID +- `permission_type`:权限类型(read/write/manage) +- `granted_by`:授权者 UUID + +**关系**: + +- N:1 → 分组 +- N:1 → 用户 +- N:1 → 工作空间(冗余) +- N:1 → 团队(冗余) + +**设计要点**: + +- 只有团队成员才能被授予分组权限 +- Owner/Admin 自动拥有所有分组权限(无需显式授权) +- Editor/Viewer 需要显式授权才能访问分组 +- 权限类型:read(查看)、write(创建/编辑)、manage(管理分组) + +### 9. 工作空间配额(Workspace Quotas) + +**职责**:定义工作空间的资源配额限制。 + +**关键属性**: + +- `workspace_uuid`:工作空间 UUID +- `max_environments`:最大环境数 +- `used_environments`:已使用环境数 +- `max_team_members`:最大成员数(所有团队总和) +- `max_proxies`:最大代理数 +- `used_proxies`:已使用代理数 +- `max_rpa_tasks`:最大 RPA 任务数 +- `used_rpa_tasks`:已使用 RPA 任务数 + +**关系**: + +- 1:1 → 工作空间 + +**设计要点**: + +- 配额是工作空间级别的,所有团队共享 +- 配额由订阅套餐决定 +- 配额使用情况实时统计 + +### 10. 订阅(Subscriptions) + +**职责**:工作空间的订阅信息,关联套餐和计费。 + +**关键属性**: + +- `uuid`:订阅唯一标识 +- `workspace_uuid`:工作空间 UUID +- `user_uuid`:订阅者用户 UUID(用于记录) +- `plan_uuid`:套餐 UUID +- `billing_period`:计费周期 +- `status`:订阅状态 + +**关系**: + +- N:1 → 工作空间 +- N:1 → 用户(订阅者) +- N:1 → 套餐 + +**设计要点**: + +- 订阅关联到工作空间,而非用户 +- 工作空间 Owner 负责订阅和计费 +- 订阅决定工作空间的配额 + +## 权限控制体系 + +### 权限层级 + +``` +工作空间级别 + └─ Owner:拥有工作空间的所有权限 + ├─ 管理团队 + ├─ 管理订阅和配额 + └─ 管理工作空间级别的代理 + +团队级别 + └─ Owner/Admin:拥有团队的所有权限 + ├─ 管理团队成员 + ├─ 管理分组 + └─ 管理分组权限 + └─ Editor:可以创建和编辑环境 + └─ Viewer:只能查看环境 + +分组级别 + └─ Manage:可以管理分组(编辑分组、授权用户) + └─ Write:可以创建和编辑分组内的环境 + └─ Read:可以查看分组内的环境 +``` + +### 权限检查流程 + +**重要前提**:所有权限检查都在当前工作空间上下文中进行。 + +**创建环境**: + +1. 检查用户是否在当前工作空间的团队中(`team_members.workspace_uuid = 当前工作空间`) +2. 如果指定了分组(`group_uuid` 不为 NULL): + - 检查用户是否有目标分组的 write 或 manage 权限(在当前工作空间上下文中) +3. 如果未指定分组(`group_uuid` 为 NULL): + - 检查用户是否有团队级别的环境创建权限(Editor/Admin/Owner) +4. 检查工作空间配额是否充足 + +**查看环境**: + +1. 检查用户是否在当前工作空间的团队中 +2. 如果环境有分组(`group_uuid` 不为 NULL): + - 检查用户是否有分组的 read/write/manage 权限(在当前工作空间上下文中) + - Owner/Admin 自动拥有所有分组权限 +3. 如果环境无分组(`group_uuid` 为 NULL): + - 检查用户是否有团队级别的查看权限(所有团队成员都可以查看) + +**管理分组**: + +1. 检查用户是否在当前工作空间的团队中 +2. 检查用户是否是团队 Owner/Admin 或拥有分组的 manage 权限(在当前工作空间上下文中) + +**未分组环境的权限规则**: + +- Owner/Admin:拥有所有权限(创建、编辑、删除) +- Editor:可以创建和编辑环境 +- Viewer:只能查看环境 +- 所有团队成员默认可以查看未分组环境 + +### 工作空间级别的团队成员关系 + +**核心设计**: + +- 用户在某个工作空间加入团队,只在该工作空间内属于该团队 +- 同一用户在不同工作空间中,可能有不同的团队成员身份和角色 +- 团队成员关系通过 `team_members` 表关联,包含 `workspace_uuid` 字段(冗余,便于查询) + +**示例场景**: + +``` +用户A: + - 在工作空间1:团队X的 Owner + - 在工作空间2:团队Y的 Editor + - 在工作空间3:团队Z的 Viewer +``` + +**查询逻辑**: + +- 查询用户在某工作空间的团队:`WHERE user_uuid = $1 AND workspace_uuid = $2` +- 查询团队在某工作空间的成员:`WHERE team_uuid = $1 AND workspace_uuid = $2` +- 通过 `team.workspace_uuid` 可以间接关联,但冗余 `workspace_uuid` 字段可以提高查询性能 + +## 配额管理 + +### 配额层级 + +``` +工作空间配额(workspace_quotas) + └─ 所有团队共享 + └─ 由订阅套餐决定 + └─ 实时统计使用情况 +``` + +### 配额检查 + +**环境配额**: + +- 统计工作空间内所有团队的环境总数 +- 检查 `used_environments < max_environments` + +**代理配额**: + +- 统计工作空间内的代理总数 +- 检查 `used_proxies < max_proxies` + +**成员配额**: + +- 统计工作空间内所有团队的活跃成员总数 +- 检查 `used_team_members < max_team_members` + +## 代理管理 + +### 代理可见性设计 + +**核心设计**: + +- 代理属于工作空间,不属于团队 +- 代理默认不可见,只有所有者可以看到 +- 代理所有者可以设置代理对特定团队可见 +- 通过 `proxy_visible_teams` 关联表控制可见性 + +**可见性规则**: + +``` +代理可见性检查(优先级从高到低): +1. 工作空间 Owner:可以看到所有代理 +2. 代理所有者:可以看到自己的代理(无论是否在可见列表中) +3. 团队成员:只能看到 proxy_visible_teams 中包含其团队的代理 +4. 否则:不可见 +``` + +**数据模型**: + +```sql +-- 代理表 +proxies: + - workspace_uuid(必须) + - owner_uuid(必须,创建者) + - name, proxy_type, host, port 等 + +-- 代理可见团队关联表 +proxy_visible_teams: + - proxy_uuid + - workspace_uuid(冗余,便于查询) + - team_uuid + - UNIQUE (proxy_uuid, team_uuid) +``` + +### 代理使用流程 + +**创建环境时的代理选择**: + +1. 用户选择当前工作空间 +2. 用户选择当前团队 +3. 系统查询用户可见的代理: + - 工作空间 Owner:所有代理 + - 代理所有者:自己的代理 + - 团队成员:`proxy_visible_teams` 中包含其团队的代理 +4. 如果没有任何可见的代理: + - 显示"没有可用的代理" + - 提供"上传新代理"选项 +5. 用户从可见代理列表中选择(可选,代理不是必须的) +6. 环境创建时,`proxy_uuid` 设置为用户选择的代理(如果选择了) + +**代理选择逻辑**: + +- 代理是可选的,创建环境时可以不选择代理 +- 用户从可见的代理列表中选择 +- 没有继承概念,用户必须明确选择 +- 如果代理不可见,用户无法选择 + +## 数据模型设计要点 + +### 1. 冗余字段设计 + +为了优化查询性能,部分表包含冗余字段: + +- `groups.workspace_uuid`:冗余字段,避免 JOIN 查询 +- `environments.workspace_uuid`:冗余字段,便于直接查询工作空间的环境 +- `team_members.workspace_uuid`:冗余字段,便于直接查询工作空间内的团队成员关系(工作空间级别的隔离) +- `group_member_permissions.workspace_uuid`、`team_uuid`:冗余字段,便于权限查询 +- `proxy_visible_teams.workspace_uuid`:冗余字段,便于直接查询工作空间内的代理可见性 + +**工作空间级别的隔离设计**: + +- `team_members` 表包含 `workspace_uuid` 字段(冗余),确保团队成员关系是工作空间级别的 +- 查询时始终需要指定 `workspace_uuid`,确保数据隔离 +- 通过 `team.workspace_uuid` 可以间接关联,但冗余字段提高查询性能 + +### 2. 软删除策略 + +所有表都支持软删除: + +- 使用 `deleted_at` 字段标记删除 +- 查询时默认过滤已删除记录 +- 支持数据恢复 + +### 3. 时间戳管理 + +所有表都包含时间戳字段: + +- `created_at`:创建时间 +- `updated_at`:更新时间(自动更新) +- 部分表包含 `deleted_at`(软删除时间) + +### 4. UUID 主键 + +所有表使用 UUID 作为主键: + +- 全局唯一标识 +- 避免 ID 冲突 +- 支持分布式系统 + +## 设计优势 + +### 1. 清晰的层级结构 + +- 用户 → 工作空间 → 团队 → 分组 → 环境 +- 每一层都有明确的职责和边界 +- 易于理解和维护 + +### 2. 灵活的权限控制 + +- 工作空间级别:Owner 管理 +- 团队级别:角色权限 +- 分组级别:细粒度权限 +- 支持复杂的权限场景 + +### 3. 统一的资源管理 + +- 所有资源归属于工作空间 +- 配额和计费基于工作空间 +- 资源隔离清晰 + +### 4. 良好的扩展性 + +- 支持多工作空间 +- 支持多团队协作 +- 支持细粒度权限控制 +- 易于扩展新功能 + +### 5. 符合业务逻辑 + +- 工作空间 = 企业账户/项目空间 +- 团队 = 部门/协作组 +- 分组 = 环境分类 +- 环境 = 核心操作单元 + +## 分组与环境的关系设计 + +### 设计原则 + +1. **环境直接归属团队**:环境必须属于某个团队(`team_uuid` NOT NULL),这是环境的基础归属关系。 + +2. **分组是可选分类容器**:环境可以可选归属分组(`group_uuid` 可选),分组用于环境分类和权限控制,但不是必需的。 + +3. **分组可以独立存在**:分组可以预先创建,不一定要有环境。团队管理者可以创建分组并设置权限,等待环境加入。 + +4. **灵活的权限控制**: + - 有分组的环境:使用分组权限控制 + - 无分组的环境:使用团队级别权限控制 + +### 使用场景 + +**场景1:环境直接归属团队(无分组)** + +``` +团队 + └─ 环境1(group_uuid = NULL) + └─ 环境2(group_uuid = NULL) + └─ 环境3(group_uuid = NULL) +``` + +- 适用于简单场景,不需要复杂分类 +- 权限控制基于团队角色(Owner/Admin/Editor/Viewer) +- 所有团队成员可以查看,Editor 及以上可以创建/编辑 + +**场景2:环境归属分组** + +``` +团队 + ├─ 分组A + │ └─ 环境1(group_uuid = 分组A) + │ └─ 环境2(group_uuid = 分组A) + └─ 分组B + └─ 环境3(group_uuid = 分组B) +``` + +- 适用于需要环境分类的场景 +- 权限控制基于分组权限(read/write/manage) +- 可以实现细粒度的权限控制 + +**场景3:混合模式** + +``` +团队 + ├─ 环境1(group_uuid = NULL,直接归属团队) + ├─ 分组A + │ └─ 环境2(group_uuid = 分组A) + └─ 分组B(空分组,等待环境加入) +``` + +- 部分环境使用分组,部分环境直接归属团队 +- 提供最大的灵活性 + +### 降级方案 + +如果某些场景需要强制使用分组,可以采用以下降级方案: + +1. **默认分组**:团队创建时自动创建一个"默认分组" +2. **强制分组**:创建环境时,如果未指定分组,自动归属默认分组 +3. **透明处理**:前端可以隐藏默认分组,让用户感觉环境直接归属团队 + +但更推荐的设计是:**环境可以直接归属团队,分组是可选的分类功能**。 + +## 代理可见性管理 + +### 核心设计 + +**代理归属**: + +- 代理属于工作空间,不属于团队(`team_uuid` 不存在) +- 代理有所有者(`owner_uuid`),创建者即为所有者 +- 代理默认不可见,只有所有者可以看到 + +**可见性控制**: + +- 代理所有者可以设置代理对特定团队可见(通过 `proxy_visible_teams` 关联表) +- 工作空间 Owner 可以看到所有代理,并可以管理所有代理 +- 团队成员只能看到 `proxy_visible_teams` 中包含其团队的代理 + +**数据模型**: + +```sql +-- 代理表 +proxies: + - uuid + - workspace_uuid(必须) + - owner_uuid(必须,创建者) + - name, proxy_type, host, port, status 等 + +-- 代理可见团队关联表 +proxy_visible_teams: + - proxy_uuid + - workspace_uuid(冗余,便于查询) + - team_uuid + - UNIQUE (proxy_uuid, team_uuid) + - FOREIGN KEY (proxy_uuid) REFERENCES proxies(uuid) ON DELETE CASCADE + - FOREIGN KEY (team_uuid) REFERENCES teams(uuid) ON DELETE CASCADE +``` + +**可见性检查逻辑**(优先级从高到低): + +1. **工作空间 Owner**:可以看到所有代理 +2. **代理所有者**:可以看到自己的代理(无论是否在可见列表中) +3. **团队成员**:只能看到 `proxy_visible_teams` 中包含其团队的代理 +4. **否则**:不可见 + +### 使用流程 + +**创建环境时的代理选择**: + +1. 用户选择当前工作空间 +2. 用户选择当前团队 +3. 系统查询用户可见的代理: + - 工作空间 Owner:所有代理 + - 代理所有者:自己的代理 + - 团队成员:`proxy_visible_teams` 中包含其团队的代理 +4. 如果没有任何可见的代理: + - 显示"没有可用的代理" + - 提供"上传新代理"选项 +5. 用户从可见代理列表中选择(可选,代理不是必须的) +6. 环境创建时,`proxy_uuid` 设置为用户选择的代理(如果选择了) + +**代理管理**: + +- 代理所有者可以添加/移除团队的可见性 +- 通过 `proxy_visible_teams` 表管理可见性 +- 团队删除时,关联的可见性记录自动删除(ON DELETE CASCADE) +- 代理删除时,关联的可见性记录自动删除(ON DELETE CASCADE) + +**设计要点**: + +- **代理是可选的**,创建环境时可以不选择代理 +- **没有继承概念**,用户必须明确选择代理 +- **代理默认不可见**,需要主动设置为可见 +- **管理员创建代理后,需要设置为可见,团队成员才能看到** +- **团队成员创建环境时,只看到可见的代理列表** + +## 总结 + +本架构设计通过"用户-工作空间-团队-分组-环境"的多层级结构,实现了灵活的资源管理、权限控制和计费体系。工作空间作为资源隔离的顶层容器,团队作为协作组织单元,分组作为可选的环境分类容器,环境作为核心操作单元直接归属团队,形成了一个清晰、可扩展的系统架构。 + +**核心设计特点**: + +- 环境必须直接归属团队,分组是可选的分类功能 +- 分组可以独立存在,不一定要有环境 +- 灵活的权限控制:有分组用分组权限,无分组用团队权限 +- 支持混合使用:部分环境使用分组,部分环境直接归属团队 + +该架构既支持个人用户的使用场景,也支持企业级的多团队协作场景,同时通过细粒度的权限控制,确保了资源的安全性和可控性。 diff --git a/src-tauri/crates/business/migrations/20250101000001_create_users.sql b/src-tauri/crates/business/migrations/20250101000001_create_users.sql new file mode 100644 index 0000000..9e019ec --- /dev/null +++ b/src-tauri/crates/business/migrations/20250101000001_create_users.sql @@ -0,0 +1,29 @@ +-- 创建 users 表 +-- 用户基础信息表,存储用户的基础标识信息 + +CREATE TABLE IF NOT EXISTS users ( + uuid UUID PRIMARY KEY DEFAULT gen_random_uuid(), + id VARCHAR(255) NOT NULL UNIQUE, + created_at TIMESTAMP WITH TIME ZONE NOT NULL DEFAULT CURRENT_TIMESTAMP, + updated_at TIMESTAMP WITH TIME ZONE NOT NULL DEFAULT CURRENT_TIMESTAMP, + deleted_at TIMESTAMP WITH TIME ZONE +); + +-- 创建索引 +CREATE INDEX IF NOT EXISTS idx_users_deleted_at ON users(deleted_at); + +-- 创建更新时间触发器 +CREATE OR REPLACE FUNCTION update_updated_at_column() +RETURNS TRIGGER AS $$ +BEGIN + NEW.updated_at = CURRENT_TIMESTAMP; + RETURN NEW; +END; +$$ language 'plpgsql'; + +CREATE TRIGGER update_users_updated_at BEFORE UPDATE ON users + FOR EACH ROW EXECUTE FUNCTION update_updated_at_column(); + + + + diff --git a/src-tauri/crates/business/migrations/20250101000002_create_user_infos.sql b/src-tauri/crates/business/migrations/20250101000002_create_user_infos.sql new file mode 100644 index 0000000..774c504 --- /dev/null +++ b/src-tauri/crates/business/migrations/20250101000002_create_user_infos.sql @@ -0,0 +1,31 @@ +-- 创建 user_infos 表 +-- 用户详细信息表,存储用户的详细业务信息 + +CREATE TABLE IF NOT EXISTS user_infos ( + id SERIAL PRIMARY KEY, + user_uuid UUID NOT NULL UNIQUE, + nickname VARCHAR(255), + email VARCHAR(255) NOT NULL UNIQUE, + phone VARCHAR(50), + password VARCHAR(255) NOT NULL, + avatar_hash VARCHAR(255), + status VARCHAR(50) NOT NULL DEFAULT 'active', + created_at TIMESTAMP WITH TIME ZONE NOT NULL DEFAULT CURRENT_TIMESTAMP, + updated_at TIMESTAMP WITH TIME ZONE NOT NULL DEFAULT CURRENT_TIMESTAMP, + deleted_at TIMESTAMP WITH TIME ZONE, + CONSTRAINT fk_user_infos_user_uuid FOREIGN KEY (user_uuid) REFERENCES users(uuid) ON DELETE CASCADE +); + +-- 创建索引 +CREATE UNIQUE INDEX IF NOT EXISTS idx_user_infos_user_uuid ON user_infos(user_uuid); +CREATE UNIQUE INDEX IF NOT EXISTS idx_user_infos_email ON user_infos(email); +CREATE INDEX IF NOT EXISTS idx_user_infos_deleted_at ON user_infos(deleted_at); +CREATE INDEX IF NOT EXISTS idx_user_infos_status ON user_infos(status); + +-- 创建更新时间触发器 +CREATE TRIGGER update_user_infos_updated_at BEFORE UPDATE ON user_infos + FOR EACH ROW EXECUTE FUNCTION update_updated_at_column(); + + + + diff --git a/src-tauri/crates/business/migrations/20250101000003_create_machine_users_table.sql b/src-tauri/crates/business/migrations/20250101000003_create_machine_users_table.sql new file mode 100644 index 0000000..a5ebc5c --- /dev/null +++ b/src-tauri/crates/business/migrations/20250101000003_create_machine_users_table.sql @@ -0,0 +1,41 @@ +-- Add migration script here +-- public.machine_users definition + +-- Drop table +-- DROP TABLE public.machine_users; + +CREATE TABLE public.machine_users ( + id int4 GENERATED BY DEFAULT AS IDENTITY( INCREMENT BY 1 MINVALUE 1 MAXVALUE 2147483647 START 1 CACHE 1 NO CYCLE) NOT NULL, + machine_code varchar(255) NOT NULL, -- 机器码 + user_uuid uuid NULL, -- 用户UUID + platform varchar(50) NULL, -- 平台 + bind_time timestamptz DEFAULT CURRENT_TIMESTAMP NOT NULL, -- 绑定时间 + status varchar(20) DEFAULT 'active'::character varying NOT NULL, -- 状态 + version_info jsonb NULL, -- 版本信息(JSON格式) + hardware_hash varchar(255) NULL, -- 硬件哈希 + hardware_raw text NULL, -- 硬件原始信息 + allow boolean DEFAULT true NOT NULL, -- 是否允许使用 + created_at timestamptz DEFAULT CURRENT_TIMESTAMP NOT NULL, -- 创建时间 + updated_at timestamptz NULL, -- 更新时间 + CONSTRAINT machine_users_pk PRIMARY KEY (id) +); + +CREATE INDEX machine_users_machine_code_idx ON public.machine_users USING btree (machine_code); +CREATE INDEX machine_users_user_uuid_idx ON public.machine_users USING btree (user_uuid); +CREATE INDEX machine_users_status_idx ON public.machine_users USING btree (status); +CREATE INDEX machine_users_platform_idx ON public.machine_users USING btree (platform); + +-- Column comments +COMMENT ON COLUMN public.machine_users.id IS '主键ID'; +COMMENT ON COLUMN public.machine_users.machine_code IS '机器码'; +COMMENT ON COLUMN public.machine_users.user_uuid IS '用户UUID'; +COMMENT ON COLUMN public.machine_users.platform IS '平台'; +COMMENT ON COLUMN public.machine_users.bind_time IS '绑定时间'; +COMMENT ON COLUMN public.machine_users.status IS '状态'; +COMMENT ON COLUMN public.machine_users.version_info IS '版本信息(JSON格式,存储versions表的信息)'; +COMMENT ON COLUMN public.machine_users.hardware_hash IS '硬件哈希'; +COMMENT ON COLUMN public.machine_users.hardware_raw IS '硬件原始信息'; +COMMENT ON COLUMN public.machine_users.allow IS '是否允许使用'; +COMMENT ON COLUMN public.machine_users.created_at IS '创建时间'; +COMMENT ON COLUMN public.machine_users.updated_at IS '更新时间'; + diff --git a/src-tauri/crates/business/migrations/20250101000004_create_version_types_table.sql b/src-tauri/crates/business/migrations/20250101000004_create_version_types_table.sql new file mode 100644 index 0000000..3ebc8d9 --- /dev/null +++ b/src-tauri/crates/business/migrations/20250101000004_create_version_types_table.sql @@ -0,0 +1,26 @@ +-- Add migration script here +-- public.version_types definition + +-- Drop table +-- DROP TABLE public.version_types; + +CREATE TABLE public.version_types ( + id int4 GENERATED BY DEFAULT AS IDENTITY( INCREMENT BY 1 MINVALUE 1 MAXVALUE 2147483647 START 1 CACHE 1 NO CYCLE) NOT NULL, + type_code varchar(50) NOT NULL, -- 类型代码 + type_name varchar(100) NOT NULL, -- 类型名称 + description text NULL, -- 描述 + sort_order int4 DEFAULT 0 NULL, -- 排序顺序 + is_active bool DEFAULT true NOT NULL, -- 是否激活 + CONSTRAINT version_types_pk PRIMARY KEY (id), + CONSTRAINT version_types_type_code_unique UNIQUE (type_code) +); +CREATE UNIQUE INDEX version_types_type_code_idx ON public.version_types USING btree (type_code); + +-- Column comments +COMMENT ON COLUMN public.version_types.id IS '主键ID'; +COMMENT ON COLUMN public.version_types.type_code IS '类型代码'; +COMMENT ON COLUMN public.version_types.type_name IS '类型名称'; +COMMENT ON COLUMN public.version_types.description IS '描述'; +COMMENT ON COLUMN public.version_types.sort_order IS '排序顺序'; +COMMENT ON COLUMN public.version_types.is_active IS '是否激活'; + diff --git a/src-tauri/crates/business/migrations/20250101000005_create_versions_table.sql b/src-tauri/crates/business/migrations/20250101000005_create_versions_table.sql new file mode 100644 index 0000000..1033107 --- /dev/null +++ b/src-tauri/crates/business/migrations/20250101000005_create_versions_table.sql @@ -0,0 +1,53 @@ +-- Add migration script here +-- public.versions definition + +-- Drop table +-- DROP TABLE public.versions; + +CREATE TABLE public.versions ( + id int4 GENERATED BY DEFAULT AS IDENTITY( INCREMENT BY 1 MINVALUE 1 MAXVALUE 2147483647 START 1 CACHE 1 NO CYCLE) NOT NULL, + type_id int4 NOT NULL, -- 版本类型ID + resource_name varchar(100) NOT NULL, -- 资源名称 + version varchar(50) NOT NULL, -- 版本号 + name varchar(200) NULL, -- 版本名称 + notes text NULL, -- 更新说明 + platform varchar(50) NULL, -- 平台 + url text NULL, -- 下载链接 + hash varchar(255) NULL, -- 文件哈希 + signature varchar(255) NULL, -- 签名 + install_path text NULL, -- 安装路径 + file_size int4 NULL, -- 文件大小 + is_latest bool DEFAULT false NOT NULL, -- 是否最新版本 + status varchar(20) DEFAULT 'active'::character varying NOT NULL, -- 状态 + pub_date timestamptz NULL, -- 发布时间 + created_at timestamptz DEFAULT CURRENT_TIMESTAMP NOT NULL, -- 创建时间 + updated_at timestamptz NULL, -- 更新时间 + deleted_at timestamptz NULL, -- 删除时间 + CONSTRAINT versions_pk PRIMARY KEY (id), + CONSTRAINT versions_type_id_fk FOREIGN KEY (type_id) REFERENCES public.version_types(id) +); +CREATE INDEX versions_type_id_idx ON public.versions USING btree (type_id); +CREATE INDEX versions_resource_name_idx ON public.versions USING btree (resource_name); +CREATE INDEX versions_status_idx ON public.versions USING btree (status); +CREATE INDEX versions_is_latest_idx ON public.versions USING btree (is_latest); + +-- Column comments +COMMENT ON COLUMN public.versions.id IS '主键ID'; +COMMENT ON COLUMN public.versions.type_id IS '版本类型ID'; +COMMENT ON COLUMN public.versions.resource_name IS '资源名称'; +COMMENT ON COLUMN public.versions.version IS '版本号'; +COMMENT ON COLUMN public.versions.name IS '版本名称'; +COMMENT ON COLUMN public.versions.notes IS '更新说明'; +COMMENT ON COLUMN public.versions.platform IS '平台'; +COMMENT ON COLUMN public.versions.url IS '下载链接'; +COMMENT ON COLUMN public.versions.hash IS '文件哈希'; +COMMENT ON COLUMN public.versions.signature IS '签名'; +COMMENT ON COLUMN public.versions.install_path IS '安装路径'; +COMMENT ON COLUMN public.versions.file_size IS '文件大小'; +COMMENT ON COLUMN public.versions.is_latest IS '是否最新版本'; +COMMENT ON COLUMN public.versions.status IS '状态'; +COMMENT ON COLUMN public.versions.pub_date IS '发布时间'; +COMMENT ON COLUMN public.versions.created_at IS '创建时间'; +COMMENT ON COLUMN public.versions.updated_at IS '更新时间'; +COMMENT ON COLUMN public.versions.deleted_at IS '删除时间'; + diff --git a/src-tauri/crates/business/migrations/20250101000006_create_gray_releases_table.sql b/src-tauri/crates/business/migrations/20250101000006_create_gray_releases_table.sql new file mode 100644 index 0000000..7b73ed2 --- /dev/null +++ b/src-tauri/crates/business/migrations/20250101000006_create_gray_releases_table.sql @@ -0,0 +1,44 @@ +-- Add migration script here +-- public.gray_releases definition + +-- Drop table +-- DROP TABLE public.gray_releases; + +CREATE TABLE public.gray_releases ( + id int4 GENERATED BY DEFAULT AS IDENTITY( INCREMENT BY 1 MINVALUE 1 MAXVALUE 2147483647 START 1 CACHE 1 NO CYCLE) NOT NULL, + name varchar(100) NOT NULL, -- 灰度名称 + description text NULL, -- 描述 + platform varchar(50) NOT NULL, -- 平台 + status varchar(20) DEFAULT 'pending'::character varying NOT NULL, -- 状态 + start_time timestamptz NOT NULL, -- 开始时间 + end_time timestamptz NULL, -- 结束时间 + max_machines int4 NULL, -- 最大配额(NULL=不限制) + allocated_count int4 DEFAULT 0 NOT NULL, -- 已分配数量 + priority int4 DEFAULT 0 NOT NULL, -- 优先级 + strategy_type varchar(50) NULL, -- 策略类型 + strategy_config jsonb NULL, -- 策略配置(JSON格式) + created_at timestamptz DEFAULT CURRENT_TIMESTAMP NOT NULL, -- 创建时间 + updated_at timestamptz NULL, -- 更新时间 + CONSTRAINT gray_releases_pk PRIMARY KEY (id) +); +CREATE INDEX gray_releases_status_idx ON public.gray_releases USING btree (status); +CREATE INDEX gray_releases_platform_idx ON public.gray_releases USING btree (platform); +CREATE INDEX gray_releases_time_idx ON public.gray_releases USING btree (start_time, end_time); +CREATE INDEX gray_releases_priority_idx ON public.gray_releases USING btree (priority); + +-- Column comments +COMMENT ON COLUMN public.gray_releases.id IS '主键ID'; +COMMENT ON COLUMN public.gray_releases.name IS '灰度名称'; +COMMENT ON COLUMN public.gray_releases.description IS '描述'; +COMMENT ON COLUMN public.gray_releases.platform IS '平台'; +COMMENT ON COLUMN public.gray_releases.status IS '状态'; +COMMENT ON COLUMN public.gray_releases.start_time IS '开始时间'; +COMMENT ON COLUMN public.gray_releases.end_time IS '结束时间'; +COMMENT ON COLUMN public.gray_releases.max_machines IS '最大配额(NULL=不限制)'; +COMMENT ON COLUMN public.gray_releases.allocated_count IS '已分配数量'; +COMMENT ON COLUMN public.gray_releases.priority IS '优先级'; +COMMENT ON COLUMN public.gray_releases.strategy_type IS '策略类型'; +COMMENT ON COLUMN public.gray_releases.strategy_config IS '策略配置(JSON格式)'; +COMMENT ON COLUMN public.gray_releases.created_at IS '创建时间'; +COMMENT ON COLUMN public.gray_releases.updated_at IS '更新时间'; + diff --git a/src-tauri/crates/business/migrations/20250101000007_create_gray_resources_table.sql b/src-tauri/crates/business/migrations/20250101000007_create_gray_resources_table.sql new file mode 100644 index 0000000..22e9d1c --- /dev/null +++ b/src-tauri/crates/business/migrations/20250101000007_create_gray_resources_table.sql @@ -0,0 +1,24 @@ +-- Add migration script here +-- public.gray_resources definition + +-- Drop table +-- DROP TABLE public.gray_resources; + +CREATE TABLE public.gray_resources ( + id int4 GENERATED BY DEFAULT AS IDENTITY( INCREMENT BY 1 MINVALUE 1 MAXVALUE 2147483647 START 1 CACHE 1 NO CYCLE) NOT NULL, + gray_release_id int4 NOT NULL, -- 灰度发布ID + version_id int4 NOT NULL, -- 版本ID + sort_order int4 DEFAULT 0 NOT NULL, -- 排序顺序 + CONSTRAINT gray_resources_pk PRIMARY KEY (id), + CONSTRAINT gray_resources_gray_release_id_fk FOREIGN KEY (gray_release_id) REFERENCES public.gray_releases(id) ON DELETE CASCADE, + CONSTRAINT gray_resources_version_id_fk FOREIGN KEY (version_id) REFERENCES public.versions(id) +); +CREATE INDEX gray_resources_gray_release_id_idx ON public.gray_resources USING btree (gray_release_id); +CREATE INDEX gray_resources_version_id_idx ON public.gray_resources USING btree (version_id); + +-- Column comments +COMMENT ON COLUMN public.gray_resources.id IS '主键ID'; +COMMENT ON COLUMN public.gray_resources.gray_release_id IS '灰度发布ID'; +COMMENT ON COLUMN public.gray_resources.version_id IS '版本ID'; +COMMENT ON COLUMN public.gray_resources.sort_order IS '排序顺序'; + diff --git a/src-tauri/crates/business/migrations/20250101000008_create_machine_gray_allocations_table.sql b/src-tauri/crates/business/migrations/20250101000008_create_machine_gray_allocations_table.sql new file mode 100644 index 0000000..feac000 --- /dev/null +++ b/src-tauri/crates/business/migrations/20250101000008_create_machine_gray_allocations_table.sql @@ -0,0 +1,33 @@ +-- Add migration script here +-- public.machine_gray_allocations definition + +-- Drop table +-- DROP TABLE public.machine_gray_allocations; + +CREATE TABLE public.machine_gray_allocations ( + id int4 GENERATED BY DEFAULT AS IDENTITY( INCREMENT BY 1 MINVALUE 1 MAXVALUE 2147483647 START 1 CACHE 1 NO CYCLE) NOT NULL, + machine_code varchar(255) NOT NULL, -- 机器码 + gray_release_id int4 NOT NULL, -- 灰度发布ID + allocated_at timestamptz DEFAULT CURRENT_TIMESTAMP NOT NULL, -- 分配时间 + effective_time timestamptz NULL, -- 生效时间 + status varchar(20) DEFAULT 'active'::character varying NOT NULL, -- 状态 + notes text NULL, -- 备注 + created_at timestamptz DEFAULT CURRENT_TIMESTAMP NOT NULL, -- 创建时间 + CONSTRAINT machine_gray_allocations_pk PRIMARY KEY (id), + CONSTRAINT machine_gray_allocations_machine_gray_unique UNIQUE (machine_code, gray_release_id), + CONSTRAINT machine_gray_allocations_gray_release_id_fk FOREIGN KEY (gray_release_id) REFERENCES public.gray_releases(id) +); +CREATE INDEX machine_gray_allocations_machine_code_idx ON public.machine_gray_allocations USING btree (machine_code); +CREATE INDEX machine_gray_allocations_gray_release_id_idx ON public.machine_gray_allocations USING btree (gray_release_id); +CREATE INDEX machine_gray_allocations_status_idx ON public.machine_gray_allocations USING btree (status); + +-- Column comments +COMMENT ON COLUMN public.machine_gray_allocations.id IS '主键ID'; +COMMENT ON COLUMN public.machine_gray_allocations.machine_code IS '机器码'; +COMMENT ON COLUMN public.machine_gray_allocations.gray_release_id IS '灰度发布ID'; +COMMENT ON COLUMN public.machine_gray_allocations.allocated_at IS '分配时间'; +COMMENT ON COLUMN public.machine_gray_allocations.effective_time IS '生效时间'; +COMMENT ON COLUMN public.machine_gray_allocations.status IS '状态'; +COMMENT ON COLUMN public.machine_gray_allocations.notes IS '备注'; +COMMENT ON COLUMN public.machine_gray_allocations.created_at IS '创建时间'; + diff --git a/src-tauri/crates/business/migrations/20250101000009_create_maintenances_table.sql b/src-tauri/crates/business/migrations/20250101000009_create_maintenances_table.sql new file mode 100644 index 0000000..b7038ba --- /dev/null +++ b/src-tauri/crates/business/migrations/20250101000009_create_maintenances_table.sql @@ -0,0 +1,33 @@ +-- Add migration script here +-- public.maintenances definition + +-- Drop table +-- DROP TABLE public.maintenances; + +CREATE TABLE public.maintenances ( + id int8 GENERATED BY DEFAULT AS IDENTITY( INCREMENT BY 1 MINVALUE 1 MAXVALUE 9223372036854775807 START 1 CACHE 1 NO CYCLE) NOT NULL, + name varchar(100) NOT NULL, -- 维护名称 + description text NULL, -- 维护描述 + status varchar(20) NOT NULL, -- 状态 + start_time timestamptz NOT NULL, -- 开始时间 + end_time timestamptz NOT NULL, -- 结束时间 + maintenance_type varchar(20) NOT NULL, -- 维护类型 + created_at timestamptz DEFAULT CURRENT_TIMESTAMP NOT NULL, -- 创建时间 + updated_at timestamptz DEFAULT CURRENT_TIMESTAMP NOT NULL, -- 更新时间 + CONSTRAINT maintenances_pk PRIMARY KEY (id) +); +CREATE INDEX maintenances_status_idx ON public.maintenances USING btree (status); +CREATE INDEX maintenances_time_idx ON public.maintenances USING btree (start_time, end_time); +CREATE INDEX maintenances_type_idx ON public.maintenances USING btree (maintenance_type); + +-- Column comments +COMMENT ON COLUMN public.maintenances.id IS '主键ID'; +COMMENT ON COLUMN public.maintenances.name IS '维护名称'; +COMMENT ON COLUMN public.maintenances.description IS '维护描述'; +COMMENT ON COLUMN public.maintenances.status IS '状态'; +COMMENT ON COLUMN public.maintenances.start_time IS '开始时间'; +COMMENT ON COLUMN public.maintenances.end_time IS '结束时间'; +COMMENT ON COLUMN public.maintenances.maintenance_type IS '维护类型'; +COMMENT ON COLUMN public.maintenances.created_at IS '创建时间'; +COMMENT ON COLUMN public.maintenances.updated_at IS '更新时间'; + diff --git a/src-tauri/crates/business/migrations/20250101000010_create_strategy_types_table.sql b/src-tauri/crates/business/migrations/20250101000010_create_strategy_types_table.sql new file mode 100644 index 0000000..4ff64f5 --- /dev/null +++ b/src-tauri/crates/business/migrations/20250101000010_create_strategy_types_table.sql @@ -0,0 +1,31 @@ +-- Add migration script here +-- public.strategy_types definition + +-- Drop table +-- DROP TABLE public.strategy_types; + +CREATE TABLE public.strategy_types ( + id int4 GENERATED BY DEFAULT AS IDENTITY( INCREMENT BY 1 MINVALUE 1 MAXVALUE 2147483647 START 1 CACHE 1 NO CYCLE) NOT NULL, + code varchar(50) NOT NULL, -- 策略代码(对应 Rust 枚举值) + name varchar(100) NOT NULL, -- 策略名称 + category varchar(50) NULL, -- 分类 + description text NULL, -- 描述 + processor_type varchar(50) NOT NULL, -- 处理器类型(对应 ProcessorType 枚举) + config_schema text NULL, -- 配置JSON Schema + is_active bool DEFAULT true NOT NULL, -- 是否激活 + CONSTRAINT strategy_types_pk PRIMARY KEY (id), + CONSTRAINT strategy_types_code_unique UNIQUE (code) +); +CREATE UNIQUE INDEX strategy_types_code_idx ON public.strategy_types USING btree (code); +CREATE INDEX strategy_types_category_idx ON public.strategy_types USING btree (category); + +-- Column comments +COMMENT ON COLUMN public.strategy_types.id IS '主键ID'; +COMMENT ON COLUMN public.strategy_types.code IS '策略代码(对应 Rust 枚举值)'; +COMMENT ON COLUMN public.strategy_types.name IS '策略名称'; +COMMENT ON COLUMN public.strategy_types.category IS '分类'; +COMMENT ON COLUMN public.strategy_types.description IS '描述'; +COMMENT ON COLUMN public.strategy_types.processor_type IS '处理器类型(对应 ProcessorType 枚举)'; +COMMENT ON COLUMN public.strategy_types.config_schema IS '配置JSON Schema'; +COMMENT ON COLUMN public.strategy_types.is_active IS '是否激活'; + diff --git a/src-tauri/crates/business/migrations/20250101000011_insert_strategy_types_data.sql b/src-tauri/crates/business/migrations/20250101000011_insert_strategy_types_data.sql new file mode 100644 index 0000000..da96553 --- /dev/null +++ b/src-tauri/crates/business/migrations/20250101000011_insert_strategy_types_data.sql @@ -0,0 +1,63 @@ +-- Add migration script here +-- 初始化灰度策略类型数据(仅过滤类策略,不包含标签相关策略) + +-- 插入过滤类策略 +INSERT INTO public.strategy_types (code, name, category, description, processor_type, config_schema, is_active) VALUES + ( + 'filter_whitelist', + '白名单过滤', + 'filter', + '基于机器码白名单的灰度策略,只对白名单中的机器生效', + 'filter_whitelist', + '{ + "type": "object", + "properties": { + "machines": { + "type": "array", + "items": {"type": "string"}, + "description": "机器码列表" + } + }, + "required": ["machines"] + }', + true + ), + ( + 'filter_percentage', + '百分比过滤', + 'filter', + '基于哈希百分比的一致性分配,按百分比随机分配机器到灰度', + 'filter_percentage', + '{ + "type": "object", + "properties": { + "percent": { + "type": "number", + "minimum": 0, + "maximum": 100, + "description": "百分比 (0-100)" + } + }, + "required": ["percent"] + }', + true + ), + ( + 'filter_random', + '随机过滤', + 'filter', + '基于随机种子的随机分配策略', + 'filter_random', + '{ + "type": "object", + "properties": { + "seed": { + "type": "integer", + "description": "随机种子" + } + }, + "required": ["seed"] + }', + true + ); + diff --git a/src-tauri/crates/business/migrations/20250120000001_create_teams.sql b/src-tauri/crates/business/migrations/20250120000001_create_teams.sql new file mode 100644 index 0000000..187d277 --- /dev/null +++ b/src-tauri/crates/business/migrations/20250120000001_create_teams.sql @@ -0,0 +1,36 @@ +-- 创建 teams 表 +-- 团队/工作空间表 + +CREATE TABLE IF NOT EXISTS teams ( + id SERIAL PRIMARY KEY, + uuid UUID NOT NULL DEFAULT gen_random_uuid() UNIQUE, + name VARCHAR(255) NOT NULL, + description TEXT, + -- 所有者 + owner_uuid UUID NOT NULL, + avatar_hash VARCHAR(255), + -- 配额限制 + max_members INT NOT NULL DEFAULT 10, + max_environments INT NOT NULL DEFAULT 100, + max_proxies INT NOT NULL DEFAULT 100, + -- 【关联】团队默认代理(外键在 proxies 表创建后添加) + default_proxy_uuid UUID, + -- 状态 + status VARCHAR(50) NOT NULL DEFAULT 'active', + -- 时间 + created_at TIMESTAMP WITH TIME ZONE NOT NULL DEFAULT CURRENT_TIMESTAMP, + updated_at TIMESTAMP WITH TIME ZONE NOT NULL DEFAULT CURRENT_TIMESTAMP, + deleted_at TIMESTAMP WITH TIME ZONE, + -- 约束 + CONSTRAINT fk_teams_owner FOREIGN KEY (owner_uuid) REFERENCES users(uuid) +); + +-- 创建索引 +CREATE INDEX idx_teams_owner_uuid ON teams(owner_uuid); +CREATE INDEX idx_teams_status ON teams(status); +CREATE INDEX idx_teams_deleted_at ON teams(deleted_at); + +-- 创建更新时间触发器 +CREATE TRIGGER update_teams_updated_at BEFORE UPDATE ON teams + FOR EACH ROW EXECUTE FUNCTION update_updated_at_column(); + diff --git a/src-tauri/crates/business/migrations/20250120000002_create_team_members.sql b/src-tauri/crates/business/migrations/20250120000002_create_team_members.sql new file mode 100644 index 0000000..55732ef --- /dev/null +++ b/src-tauri/crates/business/migrations/20250120000002_create_team_members.sql @@ -0,0 +1,39 @@ +-- 创建 team_members 表 +-- 团队成员关联表(用户 ↔ 团队,多对多) + +CREATE TABLE IF NOT EXISTS team_members ( + id SERIAL PRIMARY KEY, + team_uuid UUID NOT NULL, + user_uuid UUID NOT NULL, + -- 角色: owner, admin, editor, viewer + role VARCHAR(50) NOT NULL DEFAULT 'viewer', + -- 加入信息 + joined_at TIMESTAMP WITH TIME ZONE NOT NULL DEFAULT CURRENT_TIMESTAMP, + invited_by UUID, + -- 统计字段(冗余,提高查询性能) + environment_count INT NOT NULL DEFAULT 0, + group_count INT NOT NULL DEFAULT 0, + -- 状态 + status VARCHAR(50) NOT NULL DEFAULT 'active', + -- 时间 + created_at TIMESTAMP WITH TIME ZONE NOT NULL DEFAULT CURRENT_TIMESTAMP, + updated_at TIMESTAMP WITH TIME ZONE NOT NULL DEFAULT CURRENT_TIMESTAMP, + deleted_at TIMESTAMP WITH TIME ZONE, + -- 约束 + CONSTRAINT fk_team_members_team FOREIGN KEY (team_uuid) REFERENCES teams(uuid) ON DELETE CASCADE, + CONSTRAINT fk_team_members_user FOREIGN KEY (user_uuid) REFERENCES users(uuid) ON DELETE CASCADE, + CONSTRAINT fk_team_members_invited_by FOREIGN KEY (invited_by) REFERENCES users(uuid), + -- 唯一约束:用户在同一团队只能有一条记录 + CONSTRAINT uk_team_members UNIQUE (team_uuid, user_uuid) +); + +-- 创建索引 +CREATE INDEX idx_team_members_team_uuid ON team_members(team_uuid); +CREATE INDEX idx_team_members_user_uuid ON team_members(user_uuid); +CREATE INDEX idx_team_members_role ON team_members(role); +CREATE INDEX idx_team_members_status ON team_members(status); + +-- 创建更新时间触发器 +CREATE TRIGGER update_team_members_updated_at BEFORE UPDATE ON team_members + FOR EACH ROW EXECUTE FUNCTION update_updated_at_column(); + diff --git a/src-tauri/crates/business/migrations/20250120000003_create_team_invitations.sql b/src-tauri/crates/business/migrations/20250120000003_create_team_invitations.sql new file mode 100644 index 0000000..66fd558 --- /dev/null +++ b/src-tauri/crates/business/migrations/20250120000003_create_team_invitations.sql @@ -0,0 +1,37 @@ +-- 创建 team_invitations 表 +-- 团队邀请表 + +CREATE TABLE IF NOT EXISTS team_invitations ( + id SERIAL PRIMARY KEY, + uuid UUID NOT NULL DEFAULT gen_random_uuid() UNIQUE, + team_uuid UUID NOT NULL, + email VARCHAR(255) NOT NULL, + -- 角色 + role VARCHAR(50) NOT NULL DEFAULT 'viewer', + -- 邀请者 + invited_by UUID NOT NULL, + -- 邀请链接 + token VARCHAR(255) NOT NULL UNIQUE, + expires_at TIMESTAMP WITH TIME ZONE NOT NULL, + -- 状态: pending, accepted, rejected, expired, cancelled + status VARCHAR(50) NOT NULL DEFAULT 'pending', + accepted_at TIMESTAMP WITH TIME ZONE, + -- 时间 + created_at TIMESTAMP WITH TIME ZONE NOT NULL DEFAULT CURRENT_TIMESTAMP, + updated_at TIMESTAMP WITH TIME ZONE NOT NULL DEFAULT CURRENT_TIMESTAMP, + -- 约束 + CONSTRAINT fk_invitations_team FOREIGN KEY (team_uuid) REFERENCES teams(uuid) ON DELETE CASCADE, + CONSTRAINT fk_invitations_invited_by FOREIGN KEY (invited_by) REFERENCES users(uuid) +); + +-- 创建索引 +CREATE INDEX idx_invitations_team_uuid ON team_invitations(team_uuid); +CREATE INDEX idx_invitations_email ON team_invitations(email); +CREATE INDEX idx_invitations_token ON team_invitations(token); +CREATE INDEX idx_invitations_status ON team_invitations(status); +CREATE INDEX idx_invitations_expires_at ON team_invitations(expires_at); + +-- 创建更新时间触发器 +CREATE TRIGGER update_team_invitations_updated_at BEFORE UPDATE ON team_invitations + FOR EACH ROW EXECUTE FUNCTION update_updated_at_column(); + diff --git a/src-tauri/crates/business/migrations/20250120000004_create_login_history.sql b/src-tauri/crates/business/migrations/20250120000004_create_login_history.sql new file mode 100644 index 0000000..c3f17f5 --- /dev/null +++ b/src-tauri/crates/business/migrations/20250120000004_create_login_history.sql @@ -0,0 +1,29 @@ +-- 创建 login_history 表 +-- 登录历史表(安全审计) + +CREATE TABLE IF NOT EXISTS login_history ( + id BIGSERIAL PRIMARY KEY, + user_uuid UUID NOT NULL, + -- 登录信息 + ip_address VARCHAR(45) NOT NULL, + device_info VARCHAR(255), + user_agent TEXT, + -- 位置信息(通过 IP 解析) + location VARCHAR(255), + country VARCHAR(100), + city VARCHAR(100), + -- 结果 + success BOOLEAN NOT NULL DEFAULT TRUE, + failure_reason VARCHAR(255), + -- 时间 + created_at TIMESTAMP WITH TIME ZONE NOT NULL DEFAULT CURRENT_TIMESTAMP, + -- 约束 + CONSTRAINT fk_login_history_user FOREIGN KEY (user_uuid) REFERENCES users(uuid) ON DELETE CASCADE +); + +-- 创建索引 +CREATE INDEX idx_login_history_user_uuid ON login_history(user_uuid); +CREATE INDEX idx_login_history_created_at ON login_history(created_at); +CREATE INDEX idx_login_history_ip ON login_history(ip_address); +CREATE INDEX idx_login_history_success ON login_history(success); + diff --git a/src-tauri/crates/business/migrations/20250120000005_alter_user_infos_add_current_team.sql b/src-tauri/crates/business/migrations/20250120000005_alter_user_infos_add_current_team.sql new file mode 100644 index 0000000..d29b077 --- /dev/null +++ b/src-tauri/crates/business/migrations/20250120000005_alter_user_infos_add_current_team.sql @@ -0,0 +1,22 @@ +-- 修改 user_infos 表,添加当前团队字段 +-- 用于团队切换功能 + +ALTER TABLE user_infos +ADD COLUMN IF NOT EXISTS current_team_uuid UUID; + +-- 添加外键约束(如果不存在) +DO $$ +BEGIN + IF NOT EXISTS ( + SELECT 1 FROM information_schema.table_constraints + WHERE constraint_name = 'fk_user_infos_current_team' + ) THEN + ALTER TABLE user_infos + ADD CONSTRAINT fk_user_infos_current_team + FOREIGN KEY (current_team_uuid) REFERENCES teams(uuid) ON DELETE SET NULL; + END IF; +END $$; + +-- 创建索引 +CREATE INDEX IF NOT EXISTS idx_user_infos_current_team ON user_infos(current_team_uuid); + diff --git a/src-tauri/crates/business/migrations/20250120000006_create_groups.sql b/src-tauri/crates/business/migrations/20250120000006_create_groups.sql new file mode 100644 index 0000000..fc868b2 --- /dev/null +++ b/src-tauri/crates/business/migrations/20250120000006_create_groups.sql @@ -0,0 +1,39 @@ +-- 创建 groups 表 +-- 环境分组表 + +CREATE TABLE IF NOT EXISTS groups ( + id SERIAL PRIMARY KEY, + uuid UUID NOT NULL DEFAULT gen_random_uuid() UNIQUE, + user_uuid UUID NOT NULL, + team_uuid UUID, + -- 基础信息 + name VARCHAR(255) NOT NULL, + description TEXT, + color VARCHAR(50) DEFAULT 'gray', + sort_order INT DEFAULT 0, + -- 【关联】分组默认代理(外键在 proxies 表创建后添加) + default_proxy_uuid UUID, + -- 创建者 + created_by UUID, + -- 统计字段(计算字段) + environments_count INT DEFAULT 0, + -- 时间 + created_at TIMESTAMP WITH TIME ZONE NOT NULL DEFAULT CURRENT_TIMESTAMP, + updated_at TIMESTAMP WITH TIME ZONE NOT NULL DEFAULT CURRENT_TIMESTAMP, + deleted_at TIMESTAMP WITH TIME ZONE, + -- 约束 + CONSTRAINT fk_groups_user FOREIGN KEY (user_uuid) REFERENCES users(uuid), + CONSTRAINT fk_groups_team FOREIGN KEY (team_uuid) REFERENCES teams(uuid), + CONSTRAINT fk_groups_created_by FOREIGN KEY (created_by) REFERENCES users(uuid) + -- 注意: default_proxy_uuid 的外键需要在 proxies 表创建后添加 +); + +-- 创建索引 +CREATE INDEX idx_groups_user_uuid ON groups(user_uuid); +CREATE INDEX idx_groups_team_uuid ON groups(team_uuid); +CREATE INDEX idx_groups_deleted_at ON groups(deleted_at); + +-- 创建更新时间触发器 +CREATE TRIGGER update_groups_updated_at BEFORE UPDATE ON groups + FOR EACH ROW EXECUTE FUNCTION update_updated_at_column(); + diff --git a/src-tauri/crates/business/migrations/20250120000007_create_tags.sql b/src-tauri/crates/business/migrations/20250120000007_create_tags.sql new file mode 100644 index 0000000..c7d9c63 --- /dev/null +++ b/src-tauri/crates/business/migrations/20250120000007_create_tags.sql @@ -0,0 +1,31 @@ +-- 创建 tags 表 +-- 标签表 + +CREATE TABLE IF NOT EXISTS tags ( + id SERIAL PRIMARY KEY, + uuid UUID NOT NULL DEFAULT gen_random_uuid() UNIQUE, + user_uuid UUID NOT NULL, + team_uuid UUID, + name VARCHAR(100) NOT NULL, + color VARCHAR(50) DEFAULT 'gray', + sort_order INT DEFAULT 0, + -- 统计字段(计算字段,定期更新或触发器维护) + environments_count INT DEFAULT 0, + -- 时间 + created_at TIMESTAMP WITH TIME ZONE NOT NULL DEFAULT CURRENT_TIMESTAMP, + updated_at TIMESTAMP WITH TIME ZONE NOT NULL DEFAULT CURRENT_TIMESTAMP, + deleted_at TIMESTAMP WITH TIME ZONE, + -- 约束 + CONSTRAINT fk_tags_user FOREIGN KEY (user_uuid) REFERENCES users(uuid), + CONSTRAINT fk_tags_team FOREIGN KEY (team_uuid) REFERENCES teams(uuid) +); + +-- 创建索引 +CREATE INDEX idx_tags_user_uuid ON tags(user_uuid); +CREATE INDEX idx_tags_team_uuid ON tags(team_uuid); +CREATE INDEX idx_tags_deleted_at ON tags(deleted_at); + +-- 创建更新时间触发器 +CREATE TRIGGER update_tags_updated_at BEFORE UPDATE ON tags + FOR EACH ROW EXECUTE FUNCTION update_updated_at_column(); + diff --git a/src-tauri/crates/business/migrations/20250120000008_create_proxies.sql b/src-tauri/crates/business/migrations/20250120000008_create_proxies.sql new file mode 100644 index 0000000..eac8e32 --- /dev/null +++ b/src-tauri/crates/business/migrations/20250120000008_create_proxies.sql @@ -0,0 +1,49 @@ +-- 创建 proxies 表 +-- 代理服务器表 + +CREATE TABLE IF NOT EXISTS proxies ( + id SERIAL PRIMARY KEY, + uuid UUID NOT NULL DEFAULT gen_random_uuid() UNIQUE, + user_uuid UUID NOT NULL, + team_uuid UUID, + -- 基础信息 + name VARCHAR(255) NOT NULL, + host VARCHAR(255) NOT NULL, + port INT NOT NULL, + proxy_type VARCHAR(50) NOT NULL DEFAULT 'http', + -- 认证信息 + username VARCHAR(255), + password_encrypted TEXT, + -- SSH 类型额外字段 + ssh_key_encrypted TEXT, + ssh_passphrase_encrypted TEXT, + -- 地理位置信息 + country VARCHAR(100), + city VARCHAR(100), + -- 状态 + status VARCHAR(50) NOT NULL DEFAULT 'unknown', + latency INT, + last_check_ip VARCHAR(45), + last_checked_at TIMESTAMP WITH TIME ZONE, + -- 统计 + usage_count INT DEFAULT 0, + -- 时间 + created_at TIMESTAMP WITH TIME ZONE NOT NULL DEFAULT CURRENT_TIMESTAMP, + updated_at TIMESTAMP WITH TIME ZONE NOT NULL DEFAULT CURRENT_TIMESTAMP, + deleted_at TIMESTAMP WITH TIME ZONE, + -- 约束 + CONSTRAINT fk_proxies_user FOREIGN KEY (user_uuid) REFERENCES users(uuid), + CONSTRAINT fk_proxies_team FOREIGN KEY (team_uuid) REFERENCES teams(uuid) +); + +-- 创建索引 +CREATE INDEX idx_proxies_user_uuid ON proxies(user_uuid); +CREATE INDEX idx_proxies_team_uuid ON proxies(team_uuid); +CREATE INDEX idx_proxies_proxy_type ON proxies(proxy_type); +CREATE INDEX idx_proxies_status ON proxies(status); +CREATE INDEX idx_proxies_deleted_at ON proxies(deleted_at); + +-- 创建更新时间触发器 +CREATE TRIGGER update_proxies_updated_at BEFORE UPDATE ON proxies + FOR EACH ROW EXECUTE FUNCTION update_updated_at_column(); + diff --git a/src-tauri/crates/business/migrations/20250120000009_create_proxy_health_checks.sql b/src-tauri/crates/business/migrations/20250120000009_create_proxy_health_checks.sql new file mode 100644 index 0000000..8484ec2 --- /dev/null +++ b/src-tauri/crates/business/migrations/20250120000009_create_proxy_health_checks.sql @@ -0,0 +1,23 @@ +-- 创建 proxy_health_checks 表 +-- 代理健康检查记录表 + +CREATE TABLE IF NOT EXISTS proxy_health_checks ( + id BIGSERIAL PRIMARY KEY, + proxy_uuid UUID NOT NULL, + -- 检查结果 + status VARCHAR(50) NOT NULL, + latency INT, + ip_address VARCHAR(45), + error_message TEXT, + -- 时间 + checked_at TIMESTAMP WITH TIME ZONE NOT NULL DEFAULT CURRENT_TIMESTAMP, + -- 约束 + CONSTRAINT fk_proxy_health_checks_proxy FOREIGN KEY (proxy_uuid) + REFERENCES proxies(uuid) ON DELETE CASCADE +); + +-- 创建索引 +CREATE INDEX idx_proxy_health_checks_proxy_uuid ON proxy_health_checks(proxy_uuid); +CREATE INDEX idx_proxy_health_checks_checked_at ON proxy_health_checks(checked_at); +CREATE INDEX idx_proxy_health_checks_status ON proxy_health_checks(status); + diff --git a/src-tauri/crates/business/migrations/20250120000010_create_environments.sql b/src-tauri/crates/business/migrations/20250120000010_create_environments.sql new file mode 100644 index 0000000..c8e45eb --- /dev/null +++ b/src-tauri/crates/business/migrations/20250120000010_create_environments.sql @@ -0,0 +1,48 @@ +-- 创建 environments 表 +-- 环境基础信息表 + +CREATE TABLE IF NOT EXISTS environments ( + id SERIAL PRIMARY KEY, + uuid UUID NOT NULL DEFAULT gen_random_uuid() UNIQUE, + user_uuid UUID NOT NULL, + team_uuid UUID, + -- 基础信息 + name VARCHAR(255) NOT NULL, + description TEXT, + icon VARCHAR(50) DEFAULT 'chrome', + icon_color VARCHAR(50) DEFAULT 'text-gray-500', + -- 状态: ready, running, error + status VARCHAR(50) NOT NULL DEFAULT 'ready', + -- 【关联】分组 + group_uuid UUID, + -- 【关联】代理(环境直接使用的代理,优先级高于分组默认代理) + proxy_uuid UUID, + -- 摘要信息(用于列表显示) + system_info VARCHAR(100), + kernel_info VARCHAR(100), + fingerprint_summary VARCHAR(255), + -- 时间 + last_opened_at TIMESTAMP WITH TIME ZONE, + created_at TIMESTAMP WITH TIME ZONE NOT NULL DEFAULT CURRENT_TIMESTAMP, + updated_at TIMESTAMP WITH TIME ZONE NOT NULL DEFAULT CURRENT_TIMESTAMP, + deleted_at TIMESTAMP WITH TIME ZONE, + -- 约束 + CONSTRAINT fk_environments_user FOREIGN KEY (user_uuid) REFERENCES users(uuid), + CONSTRAINT fk_environments_team FOREIGN KEY (team_uuid) REFERENCES teams(uuid), + CONSTRAINT fk_environments_group FOREIGN KEY (group_uuid) REFERENCES groups(uuid) ON DELETE SET NULL, + CONSTRAINT fk_environments_proxy FOREIGN KEY (proxy_uuid) REFERENCES proxies(uuid) ON DELETE SET NULL +); + +-- 创建索引 +CREATE INDEX idx_environments_user_uuid ON environments(user_uuid); +CREATE INDEX idx_environments_team_uuid ON environments(team_uuid); +CREATE INDEX idx_environments_group_uuid ON environments(group_uuid); +CREATE INDEX idx_environments_proxy_uuid ON environments(proxy_uuid); +CREATE INDEX idx_environments_status ON environments(status); +CREATE INDEX idx_environments_deleted_at ON environments(deleted_at); +CREATE INDEX idx_environments_name ON environments(name); + +-- 创建更新时间触发器 +CREATE TRIGGER update_environments_updated_at BEFORE UPDATE ON environments + FOR EACH ROW EXECUTE FUNCTION update_updated_at_column(); + diff --git a/src-tauri/crates/business/migrations/20250120000011_create_environment_configs.sql b/src-tauri/crates/business/migrations/20250120000011_create_environment_configs.sql new file mode 100644 index 0000000..a2aca75 --- /dev/null +++ b/src-tauri/crates/business/migrations/20250120000011_create_environment_configs.sql @@ -0,0 +1,33 @@ +-- 创建 environment_configs 表 +-- 环境完整配置表(存储 WindowConfig,与 environments 1:1) + +CREATE TABLE IF NOT EXISTS environment_configs ( + id SERIAL PRIMARY KEY, + environment_uuid UUID NOT NULL UNIQUE, + -- WindowInfo + window_info JSONB NOT NULL DEFAULT '{}', + -- BasicSettings + basic_settings JSONB NOT NULL DEFAULT '{}', + -- AdvancedFingerprintSettings + fingerprint_settings JSONB NOT NULL DEFAULT '{}', + -- DeviceSettings + device_settings JSONB NOT NULL DEFAULT '{}', + -- PreferenceSettings + preference_settings JSONB NOT NULL DEFAULT '{}', + -- ProjectMetadata + project_metadata JSONB NOT NULL DEFAULT '{}', + -- 时间 + created_at TIMESTAMP WITH TIME ZONE NOT NULL DEFAULT CURRENT_TIMESTAMP, + updated_at TIMESTAMP WITH TIME ZONE NOT NULL DEFAULT CURRENT_TIMESTAMP, + -- 约束 + CONSTRAINT fk_env_configs_env FOREIGN KEY (environment_uuid) + REFERENCES environments(uuid) ON DELETE CASCADE +); + +-- 创建索引 +CREATE INDEX idx_env_configs_env_uuid ON environment_configs(environment_uuid); + +-- 创建更新时间触发器 +CREATE TRIGGER update_environment_configs_updated_at BEFORE UPDATE ON environment_configs + FOR EACH ROW EXECUTE FUNCTION update_updated_at_column(); + diff --git a/src-tauri/crates/business/migrations/20250120000012_create_environment_tags.sql b/src-tauri/crates/business/migrations/20250120000012_create_environment_tags.sql new file mode 100644 index 0000000..e92b671 --- /dev/null +++ b/src-tauri/crates/business/migrations/20250120000012_create_environment_tags.sql @@ -0,0 +1,21 @@ +-- 创建 environment_tags 表 +-- 环境-标签关联表(多对多) + +CREATE TABLE IF NOT EXISTS environment_tags ( + id SERIAL PRIMARY KEY, + environment_uuid UUID NOT NULL, + tag_uuid UUID NOT NULL, + created_at TIMESTAMP WITH TIME ZONE NOT NULL DEFAULT CURRENT_TIMESTAMP, + -- 约束 + CONSTRAINT fk_env_tags_env FOREIGN KEY (environment_uuid) + REFERENCES environments(uuid) ON DELETE CASCADE, + CONSTRAINT fk_env_tags_tag FOREIGN KEY (tag_uuid) + REFERENCES tags(uuid) ON DELETE CASCADE, + -- 唯一约束:同一环境不能重复添加同一标签 + CONSTRAINT uk_env_tags UNIQUE (environment_uuid, tag_uuid) +); + +-- 创建索引 +CREATE INDEX idx_env_tags_env_uuid ON environment_tags(environment_uuid); +CREATE INDEX idx_env_tags_tag_uuid ON environment_tags(tag_uuid); + diff --git a/src-tauri/crates/business/migrations/20250120000013_create_environment_urls.sql b/src-tauri/crates/business/migrations/20250120000013_create_environment_urls.sql new file mode 100644 index 0000000..0000fd6 --- /dev/null +++ b/src-tauri/crates/business/migrations/20250120000013_create_environment_urls.sql @@ -0,0 +1,18 @@ +-- 创建 environment_urls 表 +-- 环境预设 URL 表(环境可有多个预设 URL) + +CREATE TABLE IF NOT EXISTS environment_urls ( + id SERIAL PRIMARY KEY, + environment_uuid UUID NOT NULL, + url VARCHAR(2048) NOT NULL, + title VARCHAR(255), + sort_order INT DEFAULT 0, + created_at TIMESTAMP WITH TIME ZONE NOT NULL DEFAULT CURRENT_TIMESTAMP, + -- 约束 + CONSTRAINT fk_env_urls_env FOREIGN KEY (environment_uuid) + REFERENCES environments(uuid) ON DELETE CASCADE +); + +-- 创建索引 +CREATE INDEX idx_env_urls_env_uuid ON environment_urls(environment_uuid); + diff --git a/src-tauri/crates/business/migrations/20250120000014_create_environment_cookies.sql b/src-tauri/crates/business/migrations/20250120000014_create_environment_cookies.sql new file mode 100644 index 0000000..54754ac --- /dev/null +++ b/src-tauri/crates/business/migrations/20250120000014_create_environment_cookies.sql @@ -0,0 +1,24 @@ +-- 创建 environment_cookies 表 +-- 环境 Cookie 表(环境可导入多个 Cookie) + +CREATE TABLE IF NOT EXISTS environment_cookies ( + id SERIAL PRIMARY KEY, + environment_uuid UUID NOT NULL, + domain VARCHAR(255) NOT NULL, + name VARCHAR(255) NOT NULL, + value TEXT NOT NULL, + path VARCHAR(255) DEFAULT '/', + expires_at TIMESTAMP WITH TIME ZONE, + http_only BOOLEAN DEFAULT FALSE, + secure BOOLEAN DEFAULT FALSE, + same_site VARCHAR(20) DEFAULT 'Lax', + created_at TIMESTAMP WITH TIME ZONE NOT NULL DEFAULT CURRENT_TIMESTAMP, + -- 约束 + CONSTRAINT fk_env_cookies_env FOREIGN KEY (environment_uuid) + REFERENCES environments(uuid) ON DELETE CASCADE +); + +-- 创建索引 +CREATE INDEX idx_env_cookies_env_uuid ON environment_cookies(environment_uuid); +CREATE INDEX idx_env_cookies_domain ON environment_cookies(domain); + diff --git a/src-tauri/crates/business/migrations/20250120000015_create_templates.sql b/src-tauri/crates/business/migrations/20250120000015_create_templates.sql new file mode 100644 index 0000000..b052a46 --- /dev/null +++ b/src-tauri/crates/business/migrations/20250120000015_create_templates.sql @@ -0,0 +1,38 @@ +-- 创建 templates 表 +-- 环境配置模板表 + +CREATE TABLE IF NOT EXISTS templates ( + id SERIAL PRIMARY KEY, + uuid UUID NOT NULL DEFAULT gen_random_uuid() UNIQUE, + user_uuid UUID NOT NULL, + team_uuid UUID, + name VARCHAR(255) NOT NULL, + description TEXT, + -- 是否公开(团队内所有人可用) + is_public BOOLEAN DEFAULT FALSE, + -- 摘要 + system_info VARCHAR(100), + kernel_info VARCHAR(100), + -- 完整配置(JSON 存储 WindowConfig 结构) + config_json JSONB NOT NULL DEFAULT '{}', + -- 使用统计 + usage_count INT DEFAULT 0, + -- 时间 + created_at TIMESTAMP WITH TIME ZONE NOT NULL DEFAULT CURRENT_TIMESTAMP, + updated_at TIMESTAMP WITH TIME ZONE NOT NULL DEFAULT CURRENT_TIMESTAMP, + deleted_at TIMESTAMP WITH TIME ZONE, + -- 约束 + CONSTRAINT fk_templates_user FOREIGN KEY (user_uuid) REFERENCES users(uuid), + CONSTRAINT fk_templates_team FOREIGN KEY (team_uuid) REFERENCES teams(uuid) +); + +-- 创建索引 +CREATE INDEX idx_templates_user_uuid ON templates(user_uuid); +CREATE INDEX idx_templates_team_uuid ON templates(team_uuid); +CREATE INDEX idx_templates_is_public ON templates(is_public); +CREATE INDEX idx_templates_deleted_at ON templates(deleted_at); + +-- 创建更新时间触发器 +CREATE TRIGGER update_templates_updated_at BEFORE UPDATE ON templates + FOR EACH ROW EXECUTE FUNCTION update_updated_at_column(); + diff --git a/src-tauri/crates/business/migrations/20250120000016_create_platform_accounts.sql b/src-tauri/crates/business/migrations/20250120000016_create_platform_accounts.sql new file mode 100644 index 0000000..f98ea80 --- /dev/null +++ b/src-tauri/crates/business/migrations/20250120000016_create_platform_accounts.sql @@ -0,0 +1,40 @@ +-- 创建 platform_accounts 表 +-- 平台账号表 + +CREATE TABLE IF NOT EXISTS platform_accounts ( + id SERIAL PRIMARY KEY, + uuid UUID NOT NULL DEFAULT gen_random_uuid() UNIQUE, + user_uuid UUID NOT NULL, + team_uuid UUID, + -- 平台信息 + platform_url VARCHAR(512) NOT NULL, + platform_name VARCHAR(100), + -- 账号信息 + account VARCHAR(255) NOT NULL, + password_encrypted TEXT, + -- 状态: active, inactive, expired + status VARCHAR(50) NOT NULL DEFAULT 'active', + remark TEXT, + -- 统计 + usage_count INT DEFAULT 0, + last_used_at TIMESTAMP WITH TIME ZONE, + -- 时间 + created_at TIMESTAMP WITH TIME ZONE NOT NULL DEFAULT CURRENT_TIMESTAMP, + updated_at TIMESTAMP WITH TIME ZONE NOT NULL DEFAULT CURRENT_TIMESTAMP, + deleted_at TIMESTAMP WITH TIME ZONE, + -- 约束 + CONSTRAINT fk_platform_accounts_user FOREIGN KEY (user_uuid) REFERENCES users(uuid), + CONSTRAINT fk_platform_accounts_team FOREIGN KEY (team_uuid) REFERENCES teams(uuid) +); + +-- 创建索引 +CREATE INDEX idx_platform_accounts_user_uuid ON platform_accounts(user_uuid); +CREATE INDEX idx_platform_accounts_team_uuid ON platform_accounts(team_uuid); +CREATE INDEX idx_platform_accounts_platform_name ON platform_accounts(platform_name); +CREATE INDEX idx_platform_accounts_status ON platform_accounts(status); +CREATE INDEX idx_platform_accounts_deleted_at ON platform_accounts(deleted_at); + +-- 创建更新时间触发器 +CREATE TRIGGER update_platform_accounts_updated_at BEFORE UPDATE ON platform_accounts + FOR EACH ROW EXECUTE FUNCTION update_updated_at_column(); + diff --git a/src-tauri/crates/business/migrations/20250120000017_create_environment_accounts.sql b/src-tauri/crates/business/migrations/20250120000017_create_environment_accounts.sql new file mode 100644 index 0000000..d22007f --- /dev/null +++ b/src-tauri/crates/business/migrations/20250120000017_create_environment_accounts.sql @@ -0,0 +1,22 @@ +-- 创建 environment_accounts 表 +-- 环境-账号关联表(多对多) + +CREATE TABLE IF NOT EXISTS environment_accounts ( + id SERIAL PRIMARY KEY, + environment_uuid UUID NOT NULL, + account_uuid UUID NOT NULL, + sort_order INT DEFAULT 0, + created_at TIMESTAMP WITH TIME ZONE NOT NULL DEFAULT CURRENT_TIMESTAMP, + -- 约束 + CONSTRAINT fk_env_accounts_env FOREIGN KEY (environment_uuid) + REFERENCES environments(uuid) ON DELETE CASCADE, + CONSTRAINT fk_env_accounts_account FOREIGN KEY (account_uuid) + REFERENCES platform_accounts(uuid) ON DELETE CASCADE, + -- 唯一约束:同一环境不能重复关联同一账号 + CONSTRAINT uk_env_accounts UNIQUE (environment_uuid, account_uuid) +); + +-- 创建索引 +CREATE INDEX idx_env_accounts_env_uuid ON environment_accounts(environment_uuid); +CREATE INDEX idx_env_accounts_account_uuid ON environment_accounts(account_uuid); + diff --git a/src-tauri/crates/business/migrations/20250120000018_create_extensions.sql b/src-tauri/crates/business/migrations/20250120000018_create_extensions.sql new file mode 100644 index 0000000..b2d47f9 --- /dev/null +++ b/src-tauri/crates/business/migrations/20250120000018_create_extensions.sql @@ -0,0 +1,48 @@ +-- 创建 extensions 表 +-- 浏览器扩展表 + +CREATE TABLE IF NOT EXISTS extensions ( + id SERIAL PRIMARY KEY, + uuid UUID NOT NULL DEFAULT gen_random_uuid() UNIQUE, + -- 扩展 ID(Chrome/Firefox 商店 ID) + extension_id VARCHAR(255) NOT NULL UNIQUE, + name VARCHAR(255) NOT NULL, + description TEXT, + -- 版本信息 + version VARCHAR(50) NOT NULL, + -- 分类: productivity, privacy, automation, social, development, other + category VARCHAR(50) NOT NULL DEFAULT 'other', + -- 浏览器: chrome, firefox, edge, all + browser VARCHAR(50) NOT NULL DEFAULT 'all', + -- 开发者信息 + developer VARCHAR(255), + homepage VARCHAR(512), + icon_url VARCHAR(512), + -- 下载信息 + download_url VARCHAR(512), + file_size BIGINT, + -- 统计 + downloads_count BIGINT DEFAULT 0, + rating DECIMAL(3, 2), + -- 权限 + permissions JSONB DEFAULT '[]', + -- 状态: active, deprecated, removed + status VARCHAR(50) NOT NULL DEFAULT 'active', + -- 更新日志 + changelog JSONB DEFAULT '[]', + -- 时间 + published_at TIMESTAMP WITH TIME ZONE, + created_at TIMESTAMP WITH TIME ZONE NOT NULL DEFAULT CURRENT_TIMESTAMP, + updated_at TIMESTAMP WITH TIME ZONE NOT NULL DEFAULT CURRENT_TIMESTAMP +); + +-- 创建索引 +CREATE INDEX idx_extensions_extension_id ON extensions(extension_id); +CREATE INDEX idx_extensions_category ON extensions(category); +CREATE INDEX idx_extensions_browser ON extensions(browser); +CREATE INDEX idx_extensions_status ON extensions(status); + +-- 创建更新时间触发器 +CREATE TRIGGER update_extensions_updated_at BEFORE UPDATE ON extensions + FOR EACH ROW EXECUTE FUNCTION update_updated_at_column(); + diff --git a/src-tauri/crates/business/migrations/20250120000019_create_user_extensions.sql b/src-tauri/crates/business/migrations/20250120000019_create_user_extensions.sql new file mode 100644 index 0000000..d351519 --- /dev/null +++ b/src-tauri/crates/business/migrations/20250120000019_create_user_extensions.sql @@ -0,0 +1,28 @@ +-- 创建 user_extensions 表 +-- 用户安装的扩展(用户级别) + +CREATE TABLE IF NOT EXISTS user_extensions ( + id SERIAL PRIMARY KEY, + user_uuid UUID NOT NULL, + extension_id VARCHAR(255) NOT NULL, + installed_version VARCHAR(50) NOT NULL, + -- 状态: installed, disabled + status VARCHAR(50) NOT NULL DEFAULT 'installed', + -- 时间 + installed_at TIMESTAMP WITH TIME ZONE NOT NULL DEFAULT CURRENT_TIMESTAMP, + updated_at TIMESTAMP WITH TIME ZONE NOT NULL DEFAULT CURRENT_TIMESTAMP, + -- 约束 + CONSTRAINT fk_user_extensions_user FOREIGN KEY (user_uuid) REFERENCES users(uuid) ON DELETE CASCADE, + CONSTRAINT fk_user_extensions_ext FOREIGN KEY (extension_id) REFERENCES extensions(extension_id) ON DELETE CASCADE, + -- 唯一约束 + CONSTRAINT uk_user_extensions UNIQUE (user_uuid, extension_id) +); + +-- 创建索引 +CREATE INDEX idx_user_extensions_user_uuid ON user_extensions(user_uuid); +CREATE INDEX idx_user_extensions_extension_id ON user_extensions(extension_id); + +-- 创建更新时间触发器 +CREATE TRIGGER update_user_extensions_updated_at BEFORE UPDATE ON user_extensions + FOR EACH ROW EXECUTE FUNCTION update_updated_at_column(); + diff --git a/src-tauri/crates/business/migrations/20250120000020_create_team_extensions.sql b/src-tauri/crates/business/migrations/20250120000020_create_team_extensions.sql new file mode 100644 index 0000000..80ff16f --- /dev/null +++ b/src-tauri/crates/business/migrations/20250120000020_create_team_extensions.sql @@ -0,0 +1,31 @@ +-- 创建 team_extensions 表 +-- 团队安装的扩展(团队级别,所有团队成员可用) + +CREATE TABLE IF NOT EXISTS team_extensions ( + id SERIAL PRIMARY KEY, + team_uuid UUID NOT NULL, + extension_id VARCHAR(255) NOT NULL, + installed_version VARCHAR(50) NOT NULL, + -- 安装者 + installed_by UUID NOT NULL, + -- 状态: installed, disabled + status VARCHAR(50) NOT NULL DEFAULT 'installed', + -- 时间 + installed_at TIMESTAMP WITH TIME ZONE NOT NULL DEFAULT CURRENT_TIMESTAMP, + updated_at TIMESTAMP WITH TIME ZONE NOT NULL DEFAULT CURRENT_TIMESTAMP, + -- 约束 + CONSTRAINT fk_team_extensions_team FOREIGN KEY (team_uuid) REFERENCES teams(uuid) ON DELETE CASCADE, + CONSTRAINT fk_team_extensions_ext FOREIGN KEY (extension_id) REFERENCES extensions(extension_id) ON DELETE CASCADE, + CONSTRAINT fk_team_extensions_user FOREIGN KEY (installed_by) REFERENCES users(uuid), + -- 唯一约束 + CONSTRAINT uk_team_extensions UNIQUE (team_uuid, extension_id) +); + +-- 创建索引 +CREATE INDEX idx_team_extensions_team_uuid ON team_extensions(team_uuid); +CREATE INDEX idx_team_extensions_extension_id ON team_extensions(extension_id); + +-- 创建更新时间触发器 +CREATE TRIGGER update_team_extensions_updated_at BEFORE UPDATE ON team_extensions + FOR EACH ROW EXECUTE FUNCTION update_updated_at_column(); + diff --git a/src-tauri/crates/business/migrations/20250120000021_create_group_extensions.sql b/src-tauri/crates/business/migrations/20250120000021_create_group_extensions.sql new file mode 100644 index 0000000..73f9a7c --- /dev/null +++ b/src-tauri/crates/business/migrations/20250120000021_create_group_extensions.sql @@ -0,0 +1,31 @@ +-- 创建 group_extensions 表 +-- 分组安装的扩展(分组级别,该分组下所有环境可用) + +CREATE TABLE IF NOT EXISTS group_extensions ( + id SERIAL PRIMARY KEY, + group_uuid UUID NOT NULL, + extension_id VARCHAR(255) NOT NULL, + installed_version VARCHAR(50) NOT NULL, + -- 安装者 + installed_by UUID NOT NULL, + -- 状态: installed, disabled + status VARCHAR(50) NOT NULL DEFAULT 'installed', + -- 时间 + installed_at TIMESTAMP WITH TIME ZONE NOT NULL DEFAULT CURRENT_TIMESTAMP, + updated_at TIMESTAMP WITH TIME ZONE NOT NULL DEFAULT CURRENT_TIMESTAMP, + -- 约束 + CONSTRAINT fk_group_extensions_group FOREIGN KEY (group_uuid) REFERENCES groups(uuid) ON DELETE CASCADE, + CONSTRAINT fk_group_extensions_ext FOREIGN KEY (extension_id) REFERENCES extensions(extension_id) ON DELETE CASCADE, + CONSTRAINT fk_group_extensions_user FOREIGN KEY (installed_by) REFERENCES users(uuid), + -- 唯一约束 + CONSTRAINT uk_group_extensions UNIQUE (group_uuid, extension_id) +); + +-- 创建索引 +CREATE INDEX idx_group_extensions_group_uuid ON group_extensions(group_uuid); +CREATE INDEX idx_group_extensions_extension_id ON group_extensions(extension_id); + +-- 创建更新时间触发器 +CREATE TRIGGER update_group_extensions_updated_at BEFORE UPDATE ON group_extensions + FOR EACH ROW EXECUTE FUNCTION update_updated_at_column(); + diff --git a/src-tauri/crates/business/migrations/20250120000022_create_environment_extensions.sql b/src-tauri/crates/business/migrations/20250120000022_create_environment_extensions.sql new file mode 100644 index 0000000..d1bcdc0 --- /dev/null +++ b/src-tauri/crates/business/migrations/20250120000022_create_environment_extensions.sql @@ -0,0 +1,28 @@ +-- 创建 environment_extensions 表 +-- 环境安装的扩展(环境级别) + +CREATE TABLE IF NOT EXISTS environment_extensions ( + id SERIAL PRIMARY KEY, + environment_uuid UUID NOT NULL, + extension_id VARCHAR(255) NOT NULL, + installed_version VARCHAR(50) NOT NULL, + -- 状态: installed, disabled + status VARCHAR(50) NOT NULL DEFAULT 'installed', + -- 时间 + installed_at TIMESTAMP WITH TIME ZONE NOT NULL DEFAULT CURRENT_TIMESTAMP, + updated_at TIMESTAMP WITH TIME ZONE NOT NULL DEFAULT CURRENT_TIMESTAMP, + -- 约束 + CONSTRAINT fk_env_extensions_env FOREIGN KEY (environment_uuid) REFERENCES environments(uuid) ON DELETE CASCADE, + CONSTRAINT fk_env_extensions_ext FOREIGN KEY (extension_id) REFERENCES extensions(extension_id) ON DELETE CASCADE, + -- 唯一约束 + CONSTRAINT uk_env_extensions UNIQUE (environment_uuid, extension_id) +); + +-- 创建索引 +CREATE INDEX idx_env_extensions_env_uuid ON environment_extensions(environment_uuid); +CREATE INDEX idx_env_extensions_extension_id ON environment_extensions(extension_id); + +-- 创建更新时间触发器 +CREATE TRIGGER update_environment_extensions_updated_at BEFORE UPDATE ON environment_extensions + FOR EACH ROW EXECUTE FUNCTION update_updated_at_column(); + diff --git a/src-tauri/crates/business/migrations/20250120000023_create_plans.sql b/src-tauri/crates/business/migrations/20250120000023_create_plans.sql new file mode 100644 index 0000000..1848d36 --- /dev/null +++ b/src-tauri/crates/business/migrations/20250120000023_create_plans.sql @@ -0,0 +1,40 @@ +-- 创建 plans 表 +-- 订阅套餐表 + +CREATE TABLE IF NOT EXISTS plans ( + id SERIAL PRIMARY KEY, + uuid UUID NOT NULL DEFAULT gen_random_uuid() UNIQUE, + -- 基础信息 + name VARCHAR(100) NOT NULL, + description TEXT, + -- 价格 + price_per_month DECIMAL(12, 2) NOT NULL DEFAULT 0, + price_per_year DECIMAL(12, 2) NOT NULL DEFAULT 0, + currency VARCHAR(10) NOT NULL DEFAULT 'USD', + -- 折扣 + discount_monthly DECIMAL(5, 2) DEFAULT 0, + discount_yearly DECIMAL(5, 2) DEFAULT 0, + -- 配额限制 + max_environments INT NOT NULL DEFAULT 10, + max_team_members INT NOT NULL DEFAULT 5, + max_proxies INT NOT NULL DEFAULT 10, + max_rpa_tasks INT NOT NULL DEFAULT 5, + -- 是否推荐 + is_recommended BOOLEAN DEFAULT FALSE, + -- 排序 + sort_order INT DEFAULT 0, + -- 状态: active, deprecated + status VARCHAR(50) NOT NULL DEFAULT 'active', + -- 时间 + created_at TIMESTAMP WITH TIME ZONE NOT NULL DEFAULT CURRENT_TIMESTAMP, + updated_at TIMESTAMP WITH TIME ZONE NOT NULL DEFAULT CURRENT_TIMESTAMP +); + +-- 创建索引 +CREATE INDEX idx_plans_status ON plans(status); +CREATE INDEX idx_plans_sort_order ON plans(sort_order); + +-- 创建更新时间触发器 +CREATE TRIGGER update_plans_updated_at BEFORE UPDATE ON plans + FOR EACH ROW EXECUTE FUNCTION update_updated_at_column(); + diff --git a/src-tauri/crates/business/migrations/20250120000024_create_plan_features.sql b/src-tauri/crates/business/migrations/20250120000024_create_plan_features.sql new file mode 100644 index 0000000..531cdd8 --- /dev/null +++ b/src-tauri/crates/business/migrations/20250120000024_create_plan_features.sql @@ -0,0 +1,25 @@ +-- 创建 plan_features 表 +-- 套餐功能特性表 + +CREATE TABLE IF NOT EXISTS plan_features ( + id SERIAL PRIMARY KEY, + plan_uuid UUID NOT NULL, + -- 特性 + feature_key VARCHAR(100) NOT NULL, + feature_name VARCHAR(255) NOT NULL, + feature_value VARCHAR(255), + -- 是否包含 + is_included BOOLEAN DEFAULT TRUE, + -- 排序 + sort_order INT DEFAULT 0, + -- 时间 + created_at TIMESTAMP WITH TIME ZONE NOT NULL DEFAULT CURRENT_TIMESTAMP, + -- 约束 + CONSTRAINT fk_plan_features_plan FOREIGN KEY (plan_uuid) REFERENCES plans(uuid) ON DELETE CASCADE, + -- 唯一约束 + CONSTRAINT uk_plan_features UNIQUE (plan_uuid, feature_key) +); + +-- 创建索引 +CREATE INDEX idx_plan_features_plan_uuid ON plan_features(plan_uuid); + diff --git a/src-tauri/crates/business/migrations/20250120000025_create_subscriptions.sql b/src-tauri/crates/business/migrations/20250120000025_create_subscriptions.sql new file mode 100644 index 0000000..b8a7c8c --- /dev/null +++ b/src-tauri/crates/business/migrations/20250120000025_create_subscriptions.sql @@ -0,0 +1,40 @@ +-- 创建 subscriptions 表 +-- 用户订阅表 + +CREATE TABLE IF NOT EXISTS subscriptions ( + id SERIAL PRIMARY KEY, + uuid UUID NOT NULL DEFAULT gen_random_uuid() UNIQUE, + user_uuid UUID NOT NULL, + plan_uuid UUID NOT NULL, + -- 订阅周期: monthly, yearly + billing_period VARCHAR(20) NOT NULL DEFAULT 'monthly', + -- 价格快照 + price DECIMAL(12, 2) NOT NULL, + currency VARCHAR(10) NOT NULL DEFAULT 'USD', + -- 时间 + started_at TIMESTAMP WITH TIME ZONE NOT NULL DEFAULT CURRENT_TIMESTAMP, + expires_at TIMESTAMP WITH TIME ZONE NOT NULL, + next_billing_date DATE, + -- 自动续费 + auto_renew BOOLEAN DEFAULT TRUE, + -- 状态: active, cancelled, expired, suspended + status VARCHAR(50) NOT NULL DEFAULT 'active', + cancelled_at TIMESTAMP WITH TIME ZONE, + -- 时间 + created_at TIMESTAMP WITH TIME ZONE NOT NULL DEFAULT CURRENT_TIMESTAMP, + updated_at TIMESTAMP WITH TIME ZONE NOT NULL DEFAULT CURRENT_TIMESTAMP, + -- 约束 + CONSTRAINT fk_subscriptions_user FOREIGN KEY (user_uuid) REFERENCES users(uuid), + CONSTRAINT fk_subscriptions_plan FOREIGN KEY (plan_uuid) REFERENCES plans(uuid) +); + +-- 创建索引 +CREATE INDEX idx_subscriptions_user_uuid ON subscriptions(user_uuid); +CREATE INDEX idx_subscriptions_plan_uuid ON subscriptions(plan_uuid); +CREATE INDEX idx_subscriptions_status ON subscriptions(status); +CREATE INDEX idx_subscriptions_expires_at ON subscriptions(expires_at); + +-- 创建更新时间触发器 +CREATE TRIGGER update_subscriptions_updated_at BEFORE UPDATE ON subscriptions + FOR EACH ROW EXECUTE FUNCTION update_updated_at_column(); + diff --git a/src-tauri/crates/business/migrations/20250120000026_create_user_wallets.sql b/src-tauri/crates/business/migrations/20250120000026_create_user_wallets.sql new file mode 100644 index 0000000..36c4f86 --- /dev/null +++ b/src-tauri/crates/business/migrations/20250120000026_create_user_wallets.sql @@ -0,0 +1,27 @@ +-- 创建 user_wallets 表 +-- 用户钱包表 + +CREATE TABLE IF NOT EXISTS user_wallets ( + id SERIAL PRIMARY KEY, + user_uuid UUID NOT NULL UNIQUE, + -- 余额 + balance DECIMAL(12, 2) NOT NULL DEFAULT 0, + currency VARCHAR(10) NOT NULL DEFAULT 'USD', + -- 冻结金额 + frozen_amount DECIMAL(12, 2) NOT NULL DEFAULT 0, + -- 自动续费组合金额 + auto_renewal_combined DECIMAL(12, 2) NOT NULL DEFAULT 0, + -- 时间 + created_at TIMESTAMP WITH TIME ZONE NOT NULL DEFAULT CURRENT_TIMESTAMP, + updated_at TIMESTAMP WITH TIME ZONE NOT NULL DEFAULT CURRENT_TIMESTAMP, + -- 约束 + CONSTRAINT fk_user_wallets_user FOREIGN KEY (user_uuid) REFERENCES users(uuid) ON DELETE CASCADE +); + +-- 创建索引 +CREATE INDEX idx_user_wallets_user_uuid ON user_wallets(user_uuid); + +-- 创建更新时间触发器 +CREATE TRIGGER update_user_wallets_updated_at BEFORE UPDATE ON user_wallets + FOR EACH ROW EXECUTE FUNCTION update_updated_at_column(); + diff --git a/src-tauri/crates/business/migrations/20250120000027_create_wallet_transactions.sql b/src-tauri/crates/business/migrations/20250120000027_create_wallet_transactions.sql new file mode 100644 index 0000000..b556ffc --- /dev/null +++ b/src-tauri/crates/business/migrations/20250120000027_create_wallet_transactions.sql @@ -0,0 +1,33 @@ +-- 创建 wallet_transactions 表 +-- 钱包交易记录表 + +CREATE TABLE IF NOT EXISTS wallet_transactions ( + id BIGSERIAL PRIMARY KEY, + uuid UUID NOT NULL DEFAULT gen_random_uuid() UNIQUE, + user_uuid UUID NOT NULL, + -- 交易类型: recharge, consume, refund, reward + transaction_type VARCHAR(50) NOT NULL, + -- 金额(正数为收入,负数为支出) + amount DECIMAL(12, 2) NOT NULL, + currency VARCHAR(10) NOT NULL DEFAULT 'USD', + -- 余额快照 + balance_before DECIMAL(12, 2) NOT NULL, + balance_after DECIMAL(12, 2) NOT NULL, + -- 描述 + description TEXT, + -- 关联订单 + order_uuid UUID, + -- 状态: pending, completed, failed + status VARCHAR(50) NOT NULL DEFAULT 'completed', + -- 时间 + created_at TIMESTAMP WITH TIME ZONE NOT NULL DEFAULT CURRENT_TIMESTAMP, + -- 约束 + CONSTRAINT fk_wallet_transactions_user FOREIGN KEY (user_uuid) REFERENCES users(uuid) +); + +-- 创建索引 +CREATE INDEX idx_wallet_transactions_user_uuid ON wallet_transactions(user_uuid); +CREATE INDEX idx_wallet_transactions_type ON wallet_transactions(transaction_type); +CREATE INDEX idx_wallet_transactions_created_at ON wallet_transactions(created_at); +CREATE INDEX idx_wallet_transactions_order_uuid ON wallet_transactions(order_uuid); + diff --git a/src-tauri/crates/business/migrations/20250120000028_create_invoices.sql b/src-tauri/crates/business/migrations/20250120000028_create_invoices.sql new file mode 100644 index 0000000..84e7c70 --- /dev/null +++ b/src-tauri/crates/business/migrations/20250120000028_create_invoices.sql @@ -0,0 +1,44 @@ +-- 创建 invoices 表 +-- 发票表 + +CREATE TABLE IF NOT EXISTS invoices ( + id SERIAL PRIMARY KEY, + uuid UUID NOT NULL DEFAULT gen_random_uuid() UNIQUE, + user_uuid UUID NOT NULL, + -- 发票号 + invoice_number VARCHAR(100) NOT NULL UNIQUE, + -- 金额 + amount DECIMAL(12, 2) NOT NULL, + currency VARCHAR(10) NOT NULL DEFAULT 'USD', + -- 关联订阅/订单 + subscription_uuid UUID, + order_uuid UUID, + -- 发票类型: subscription, addon, recharge + invoice_type VARCHAR(50) NOT NULL, + -- 状态: draft, issued, paid, cancelled + status VARCHAR(50) NOT NULL DEFAULT 'draft', + -- 时间 + issued_at TIMESTAMP WITH TIME ZONE, + due_at TIMESTAMP WITH TIME ZONE, + paid_at TIMESTAMP WITH TIME ZONE, + -- 发票信息(PDF 链接等) + invoice_url VARCHAR(512), + -- 时间 + created_at TIMESTAMP WITH TIME ZONE NOT NULL DEFAULT CURRENT_TIMESTAMP, + updated_at TIMESTAMP WITH TIME ZONE NOT NULL DEFAULT CURRENT_TIMESTAMP, + -- 约束 + CONSTRAINT fk_invoices_user FOREIGN KEY (user_uuid) REFERENCES users(uuid), + CONSTRAINT fk_invoices_subscription FOREIGN KEY (subscription_uuid) REFERENCES subscriptions(uuid) +); + +-- 创建索引 +CREATE INDEX idx_invoices_user_uuid ON invoices(user_uuid); +CREATE INDEX idx_invoices_invoice_number ON invoices(invoice_number); +CREATE INDEX idx_invoices_status ON invoices(status); +CREATE INDEX idx_invoices_subscription_uuid ON invoices(subscription_uuid); +CREATE INDEX idx_invoices_created_at ON invoices(created_at); + +-- 创建更新时间触发器 +CREATE TRIGGER update_invoices_updated_at BEFORE UPDATE ON invoices + FOR EACH ROW EXECUTE FUNCTION update_updated_at_column(); + diff --git a/src-tauri/crates/business/migrations/20250120000029_create_user_quotas.sql b/src-tauri/crates/business/migrations/20250120000029_create_user_quotas.sql new file mode 100644 index 0000000..283f6ea --- /dev/null +++ b/src-tauri/crates/business/migrations/20250120000029_create_user_quotas.sql @@ -0,0 +1,31 @@ +-- 创建 user_quotas 表 +-- 用户配额表 + +CREATE TABLE IF NOT EXISTS user_quotas ( + id SERIAL PRIMARY KEY, + user_uuid UUID NOT NULL UNIQUE, + -- 环境配额 + max_environments INT NOT NULL DEFAULT 10, + used_environments INT NOT NULL DEFAULT 0, + -- 团队成员配额 + max_team_members INT NOT NULL DEFAULT 5, + -- 代理配额 + max_proxies INT NOT NULL DEFAULT 10, + used_proxies INT NOT NULL DEFAULT 0, + -- RPA 任务配额 + max_rpa_tasks INT NOT NULL DEFAULT 5, + used_rpa_tasks INT NOT NULL DEFAULT 0, + -- 时间 + created_at TIMESTAMP WITH TIME ZONE NOT NULL DEFAULT CURRENT_TIMESTAMP, + updated_at TIMESTAMP WITH TIME ZONE NOT NULL DEFAULT CURRENT_TIMESTAMP, + -- 约束 + CONSTRAINT fk_user_quotas_user FOREIGN KEY (user_uuid) REFERENCES users(uuid) ON DELETE CASCADE +); + +-- 创建索引 +CREATE INDEX idx_user_quotas_user_uuid ON user_quotas(user_uuid); + +-- 创建更新时间触发器 +CREATE TRIGGER update_user_quotas_updated_at BEFORE UPDATE ON user_quotas + FOR EACH ROW EXECUTE FUNCTION update_updated_at_column(); + diff --git a/src-tauri/crates/business/migrations/20250120000030_create_coupons.sql b/src-tauri/crates/business/migrations/20250120000030_create_coupons.sql new file mode 100644 index 0000000..6713f09 --- /dev/null +++ b/src-tauri/crates/business/migrations/20250120000030_create_coupons.sql @@ -0,0 +1,42 @@ +-- 创建 coupons 表 +-- 优惠券表 + +CREATE TABLE IF NOT EXISTS coupons ( + id SERIAL PRIMARY KEY, + uuid UUID NOT NULL DEFAULT gen_random_uuid() UNIQUE, + -- 优惠券码 + code VARCHAR(50) NOT NULL UNIQUE, + -- 优惠类型: percent, fixed + discount_type VARCHAR(20) NOT NULL, + -- 优惠值(百分比或固定金额) + discount_value DECIMAL(12, 2) NOT NULL, + -- 最低消费 + min_amount DECIMAL(12, 2) DEFAULT 0, + -- 最大折扣(百分比类型时有效) + max_discount DECIMAL(12, 2), + -- 使用限制 + max_uses INT, + used_count INT NOT NULL DEFAULT 0, + -- 每用户限制 + max_uses_per_user INT DEFAULT 1, + -- 有效期 + valid_from TIMESTAMP WITH TIME ZONE NOT NULL DEFAULT CURRENT_TIMESTAMP, + valid_until TIMESTAMP WITH TIME ZONE, + -- 适用范围: all, subscription, addon + applicable_to VARCHAR(50) NOT NULL DEFAULT 'all', + -- 状态: active, inactive, expired + status VARCHAR(50) NOT NULL DEFAULT 'active', + -- 时间 + created_at TIMESTAMP WITH TIME ZONE NOT NULL DEFAULT CURRENT_TIMESTAMP, + updated_at TIMESTAMP WITH TIME ZONE NOT NULL DEFAULT CURRENT_TIMESTAMP +); + +-- 创建索引 +CREATE INDEX idx_coupons_code ON coupons(code); +CREATE INDEX idx_coupons_status ON coupons(status); +CREATE INDEX idx_coupons_valid_until ON coupons(valid_until); + +-- 创建更新时间触发器 +CREATE TRIGGER update_coupons_updated_at BEFORE UPDATE ON coupons + FOR EACH ROW EXECUTE FUNCTION update_updated_at_column(); + diff --git a/src-tauri/crates/business/migrations/20250120000031_create_coupon_usages.sql b/src-tauri/crates/business/migrations/20250120000031_create_coupon_usages.sql new file mode 100644 index 0000000..1242fab --- /dev/null +++ b/src-tauri/crates/business/migrations/20250120000031_create_coupon_usages.sql @@ -0,0 +1,21 @@ +-- 创建 coupon_usages 表 +-- 优惠券使用记录表 + +CREATE TABLE IF NOT EXISTS coupon_usages ( + id SERIAL PRIMARY KEY, + coupon_uuid UUID NOT NULL, + user_uuid UUID NOT NULL, + order_uuid UUID, + -- 折扣金额 + discount_amount DECIMAL(12, 2) NOT NULL, + -- 使用时间 + used_at TIMESTAMP WITH TIME ZONE NOT NULL DEFAULT CURRENT_TIMESTAMP, + -- 约束 + CONSTRAINT fk_coupon_usages_coupon FOREIGN KEY (coupon_uuid) REFERENCES coupons(uuid), + CONSTRAINT fk_coupon_usages_user FOREIGN KEY (user_uuid) REFERENCES users(uuid) +); + +-- 创建索引 +CREATE INDEX idx_coupon_usages_coupon_uuid ON coupon_usages(coupon_uuid); +CREATE INDEX idx_coupon_usages_user_uuid ON coupon_usages(user_uuid); + diff --git a/src-tauri/crates/business/migrations/20250120000032_create_payment_orders.sql b/src-tauri/crates/business/migrations/20250120000032_create_payment_orders.sql new file mode 100644 index 0000000..70f6635 --- /dev/null +++ b/src-tauri/crates/business/migrations/20250120000032_create_payment_orders.sql @@ -0,0 +1,50 @@ +-- 创建 payment_orders 表 +-- 支付订单表 + +CREATE TABLE IF NOT EXISTS payment_orders ( + id BIGSERIAL PRIMARY KEY, + uuid UUID NOT NULL DEFAULT gen_random_uuid() UNIQUE, + -- 订单号 + order_no VARCHAR(100) NOT NULL UNIQUE, + user_uuid UUID NOT NULL, + -- 订单类型: recharge, subscription, addon, refund + order_type VARCHAR(50) NOT NULL, + -- 金额 + amount DECIMAL(12, 2) NOT NULL, + currency VARCHAR(10) NOT NULL DEFAULT 'USD', + -- 状态: pending, paid, failed, refunded, cancelled + status VARCHAR(50) NOT NULL DEFAULT 'pending', + -- 支付渠道: alipay, wechatpay, stripe, paypal 等 + payment_channel VARCHAR(50), + -- 第三方订单号 + external_order_id VARCHAR(255), + -- 描述 + description TEXT, + -- 关联 + subscription_uuid UUID, + coupon_uuid UUID, + -- 折扣信息 + original_amount DECIMAL(12, 2), + discount_amount DECIMAL(12, 2) DEFAULT 0, + -- 时间 + paid_at TIMESTAMP WITH TIME ZONE, + refunded_at TIMESTAMP WITH TIME ZONE, + created_at TIMESTAMP WITH TIME ZONE NOT NULL DEFAULT CURRENT_TIMESTAMP, + updated_at TIMESTAMP WITH TIME ZONE NOT NULL DEFAULT CURRENT_TIMESTAMP, + -- 约束 + CONSTRAINT fk_payment_orders_user FOREIGN KEY (user_uuid) REFERENCES users(uuid), + CONSTRAINT fk_payment_orders_subscription FOREIGN KEY (subscription_uuid) REFERENCES subscriptions(uuid), + CONSTRAINT fk_payment_orders_coupon FOREIGN KEY (coupon_uuid) REFERENCES coupons(uuid) +); + +-- 创建索引 +CREATE INDEX idx_payment_orders_user_uuid ON payment_orders(user_uuid); +CREATE INDEX idx_payment_orders_order_no ON payment_orders(order_no); +CREATE INDEX idx_payment_orders_status ON payment_orders(status); +CREATE INDEX idx_payment_orders_order_type ON payment_orders(order_type); +CREATE INDEX idx_payment_orders_created_at ON payment_orders(created_at); + +-- 创建更新时间触发器 +CREATE TRIGGER update_payment_orders_updated_at BEFORE UPDATE ON payment_orders + FOR EACH ROW EXECUTE FUNCTION update_updated_at_column(); + diff --git a/src-tauri/crates/business/migrations/20250120000033_create_auto_renewal_services.sql b/src-tauri/crates/business/migrations/20250120000033_create_auto_renewal_services.sql new file mode 100644 index 0000000..1f045ac --- /dev/null +++ b/src-tauri/crates/business/migrations/20250120000033_create_auto_renewal_services.sql @@ -0,0 +1,36 @@ +-- 创建 auto_renewal_services 表 +-- 自动续费服务表 + +CREATE TABLE IF NOT EXISTS auto_renewal_services ( + id SERIAL PRIMARY KEY, + uuid UUID NOT NULL DEFAULT gen_random_uuid() UNIQUE, + user_uuid UUID NOT NULL, + -- 服务类型: subscription, addon + service_type VARCHAR(50) NOT NULL, + -- 关联服务 ID + service_uuid UUID, + -- 服务名称 + service_name VARCHAR(255) NOT NULL, + -- 续费价格 + renewal_price DECIMAL(12, 2) NOT NULL, + currency VARCHAR(10) NOT NULL DEFAULT 'USD', + -- 下次扣费日期 + next_bill_date DATE NOT NULL, + -- 状态: active, paused, cancelled + status VARCHAR(50) NOT NULL DEFAULT 'active', + -- 时间 + created_at TIMESTAMP WITH TIME ZONE NOT NULL DEFAULT CURRENT_TIMESTAMP, + updated_at TIMESTAMP WITH TIME ZONE NOT NULL DEFAULT CURRENT_TIMESTAMP, + -- 约束 + CONSTRAINT fk_auto_renewal_user FOREIGN KEY (user_uuid) REFERENCES users(uuid) ON DELETE CASCADE +); + +-- 创建索引 +CREATE INDEX idx_auto_renewal_services_user_uuid ON auto_renewal_services(user_uuid); +CREATE INDEX idx_auto_renewal_services_status ON auto_renewal_services(status); +CREATE INDEX idx_auto_renewal_services_next_bill_date ON auto_renewal_services(next_bill_date); + +-- 创建更新时间触发器 +CREATE TRIGGER update_auto_renewal_services_updated_at BEFORE UPDATE ON auto_renewal_services + FOR EACH ROW EXECUTE FUNCTION update_updated_at_column(); + diff --git a/src-tauri/crates/business/migrations/20250120000034_create_rpa_tasks.sql b/src-tauri/crates/business/migrations/20250120000034_create_rpa_tasks.sql new file mode 100644 index 0000000..bb37bc2 --- /dev/null +++ b/src-tauri/crates/business/migrations/20250120000034_create_rpa_tasks.sql @@ -0,0 +1,59 @@ +-- 创建 rpa_tasks 表 +-- RPA 任务表 + +CREATE TABLE IF NOT EXISTS rpa_tasks ( + id SERIAL PRIMARY KEY, + uuid UUID NOT NULL DEFAULT gen_random_uuid() UNIQUE, + user_uuid UUID NOT NULL, + team_uuid UUID, + -- 基础信息 + name VARCHAR(255) NOT NULL, + description TEXT, + tags JSONB DEFAULT '[]', + -- 触发器: manual, scheduled, event + trigger_type VARCHAR(50) NOT NULL DEFAULT 'manual', + -- 调度: hourly, daily, weekly, custom + schedule VARCHAR(50), + cron_expression VARCHAR(100), + -- 运行模式: sequential, parallel + run_mode VARCHAR(50) NOT NULL DEFAULT 'sequential', + -- 重试设置 + retry_count INT DEFAULT 0, + retry_interval INT DEFAULT 5, + -- 超时(秒) + timeout INT DEFAULT 300, + -- 并发数 + concurrency INT DEFAULT 1, + -- 错误时停止 + stop_on_error BOOLEAN DEFAULT TRUE, + -- 通知设置 + notify_on_complete BOOLEAN DEFAULT FALSE, + notify_on_error BOOLEAN DEFAULT TRUE, + -- 状态: idle, running, completed, failed, cancelled + status VARCHAR(50) NOT NULL DEFAULT 'idle', + -- 统计 + run_count INT DEFAULT 0, + success_count INT DEFAULT 0, + last_run_at TIMESTAMP WITH TIME ZONE, + next_run_at TIMESTAMP WITH TIME ZONE, + -- 时间 + created_at TIMESTAMP WITH TIME ZONE NOT NULL DEFAULT CURRENT_TIMESTAMP, + updated_at TIMESTAMP WITH TIME ZONE NOT NULL DEFAULT CURRENT_TIMESTAMP, + deleted_at TIMESTAMP WITH TIME ZONE, + -- 约束 + CONSTRAINT fk_rpa_tasks_user FOREIGN KEY (user_uuid) REFERENCES users(uuid), + CONSTRAINT fk_rpa_tasks_team FOREIGN KEY (team_uuid) REFERENCES teams(uuid) +); + +-- 创建索引 +CREATE INDEX idx_rpa_tasks_user_uuid ON rpa_tasks(user_uuid); +CREATE INDEX idx_rpa_tasks_team_uuid ON rpa_tasks(team_uuid); +CREATE INDEX idx_rpa_tasks_trigger_type ON rpa_tasks(trigger_type); +CREATE INDEX idx_rpa_tasks_status ON rpa_tasks(status); +CREATE INDEX idx_rpa_tasks_next_run_at ON rpa_tasks(next_run_at); +CREATE INDEX idx_rpa_tasks_deleted_at ON rpa_tasks(deleted_at); + +-- 创建更新时间触发器 +CREATE TRIGGER update_rpa_tasks_updated_at BEFORE UPDATE ON rpa_tasks + FOR EACH ROW EXECUTE FUNCTION update_updated_at_column(); + diff --git a/src-tauri/crates/business/migrations/20250120000035_create_rpa_task_steps.sql b/src-tauri/crates/business/migrations/20250120000035_create_rpa_task_steps.sql new file mode 100644 index 0000000..c0deaaf --- /dev/null +++ b/src-tauri/crates/business/migrations/20250120000035_create_rpa_task_steps.sql @@ -0,0 +1,39 @@ +-- 创建 rpa_task_steps 表 +-- RPA 任务步骤表 + +CREATE TABLE IF NOT EXISTS rpa_task_steps ( + id SERIAL PRIMARY KEY, + uuid UUID NOT NULL DEFAULT gen_random_uuid() UNIQUE, + task_uuid UUID NOT NULL, + -- 步骤类型: navigate, click, input, wait, screenshot, script, condition, loop, scroll, keyboard, download, upload + step_type VARCHAR(50) NOT NULL, + -- 步骤名称 + name VARCHAR(255) NOT NULL, + -- 步骤配置(JSON) + config JSONB NOT NULL DEFAULT '{}', + -- 是否启用 + enabled BOOLEAN DEFAULT TRUE, + -- 画布位置 + position_x INT DEFAULT 0, + position_y INT DEFAULT 0, + -- 排序 + sort_order INT DEFAULT 0, + -- 连接到的下一个步骤 + next_step_uuid UUID, + -- 条件分支(条件类型步骤) + branch_config JSONB, + -- 时间 + created_at TIMESTAMP WITH TIME ZONE NOT NULL DEFAULT CURRENT_TIMESTAMP, + updated_at TIMESTAMP WITH TIME ZONE NOT NULL DEFAULT CURRENT_TIMESTAMP, + -- 约束 + CONSTRAINT fk_rpa_task_steps_task FOREIGN KEY (task_uuid) REFERENCES rpa_tasks(uuid) ON DELETE CASCADE +); + +-- 创建索引 +CREATE INDEX idx_rpa_task_steps_task_uuid ON rpa_task_steps(task_uuid); +CREATE INDEX idx_rpa_task_steps_sort_order ON rpa_task_steps(sort_order); + +-- 创建更新时间触发器 +CREATE TRIGGER update_rpa_task_steps_updated_at BEFORE UPDATE ON rpa_task_steps + FOR EACH ROW EXECUTE FUNCTION update_updated_at_column(); + diff --git a/src-tauri/crates/business/migrations/20250120000036_create_rpa_task_environments.sql b/src-tauri/crates/business/migrations/20250120000036_create_rpa_task_environments.sql new file mode 100644 index 0000000..e68d5a6 --- /dev/null +++ b/src-tauri/crates/business/migrations/20250120000036_create_rpa_task_environments.sql @@ -0,0 +1,20 @@ +-- 创建 rpa_task_environments 表 +-- RPA 任务-环境关联表(多对多) + +CREATE TABLE IF NOT EXISTS rpa_task_environments ( + id SERIAL PRIMARY KEY, + task_uuid UUID NOT NULL, + environment_uuid UUID NOT NULL, + sort_order INT DEFAULT 0, + created_at TIMESTAMP WITH TIME ZONE NOT NULL DEFAULT CURRENT_TIMESTAMP, + -- 约束 + CONSTRAINT fk_rpa_task_env_task FOREIGN KEY (task_uuid) REFERENCES rpa_tasks(uuid) ON DELETE CASCADE, + CONSTRAINT fk_rpa_task_env_env FOREIGN KEY (environment_uuid) REFERENCES environments(uuid) ON DELETE CASCADE, + -- 唯一约束 + CONSTRAINT uk_rpa_task_environments UNIQUE (task_uuid, environment_uuid) +); + +-- 创建索引 +CREATE INDEX idx_rpa_task_env_task_uuid ON rpa_task_environments(task_uuid); +CREATE INDEX idx_rpa_task_env_env_uuid ON rpa_task_environments(environment_uuid); + diff --git a/src-tauri/crates/business/migrations/20250120000037_create_rpa_task_runs.sql b/src-tauri/crates/business/migrations/20250120000037_create_rpa_task_runs.sql new file mode 100644 index 0000000..664da2e --- /dev/null +++ b/src-tauri/crates/business/migrations/20250120000037_create_rpa_task_runs.sql @@ -0,0 +1,32 @@ +-- 创建 rpa_task_runs 表 +-- RPA 任务执行记录表 + +CREATE TABLE IF NOT EXISTS rpa_task_runs ( + id BIGSERIAL PRIMARY KEY, + uuid UUID NOT NULL DEFAULT gen_random_uuid() UNIQUE, + task_uuid UUID NOT NULL, + -- 状态: running, completed, failed, cancelled + status VARCHAR(50) NOT NULL DEFAULT 'running', + -- 步骤统计 + total_steps INT NOT NULL DEFAULT 0, + completed_steps INT NOT NULL DEFAULT 0, + failed_steps INT NOT NULL DEFAULT 0, + -- 执行时间 + started_at TIMESTAMP WITH TIME ZONE NOT NULL DEFAULT CURRENT_TIMESTAMP, + finished_at TIMESTAMP WITH TIME ZONE, + duration_ms BIGINT, + -- 结果摘要 + result_summary TEXT, + -- 错误信息 + error_message TEXT, + -- 执行日志(JSON 数组) + logs JSONB DEFAULT '[]', + -- 约束 + CONSTRAINT fk_rpa_task_runs_task FOREIGN KEY (task_uuid) REFERENCES rpa_tasks(uuid) ON DELETE CASCADE +); + +-- 创建索引 +CREATE INDEX idx_rpa_task_runs_task_uuid ON rpa_task_runs(task_uuid); +CREATE INDEX idx_rpa_task_runs_status ON rpa_task_runs(status); +CREATE INDEX idx_rpa_task_runs_started_at ON rpa_task_runs(started_at); + diff --git a/src-tauri/crates/business/migrations/20250120000038_create_referral_link_tiers.sql b/src-tauri/crates/business/migrations/20250120000038_create_referral_link_tiers.sql new file mode 100644 index 0000000..7da2d83 --- /dev/null +++ b/src-tauri/crates/business/migrations/20250120000038_create_referral_link_tiers.sql @@ -0,0 +1,31 @@ +-- 创建 referral_link_tiers 表 +-- 推广链接层级配置表 + +CREATE TABLE IF NOT EXISTS referral_link_tiers ( + id SERIAL PRIMARY KEY, + uuid UUID NOT NULL DEFAULT gen_random_uuid() UNIQUE, + -- 层级名称 + name VARCHAR(100) NOT NULL, + -- 解锁条件(邀请人数) + unlock_threshold INT NOT NULL DEFAULT 0, + -- 奖励比例(百分比) + reward_rate DECIMAL(5, 2) NOT NULL DEFAULT 0, + -- 被邀请人折扣比例(百分比) + discount_rate DECIMAL(5, 2) NOT NULL DEFAULT 0, + -- 描述 + description TEXT, + -- 排序 + sort_order INT DEFAULT 0, + -- 时间 + created_at TIMESTAMP WITH TIME ZONE NOT NULL DEFAULT CURRENT_TIMESTAMP, + updated_at TIMESTAMP WITH TIME ZONE NOT NULL DEFAULT CURRENT_TIMESTAMP +); + +-- 创建索引 +CREATE INDEX idx_referral_link_tiers_unlock ON referral_link_tiers(unlock_threshold); +CREATE INDEX idx_referral_link_tiers_sort ON referral_link_tiers(sort_order); + +-- 创建更新时间触发器 +CREATE TRIGGER update_referral_link_tiers_updated_at BEFORE UPDATE ON referral_link_tiers + FOR EACH ROW EXECUTE FUNCTION update_updated_at_column(); + diff --git a/src-tauri/crates/business/migrations/20250120000039_create_referral_links.sql b/src-tauri/crates/business/migrations/20250120000039_create_referral_links.sql new file mode 100644 index 0000000..0366672 --- /dev/null +++ b/src-tauri/crates/business/migrations/20250120000039_create_referral_links.sql @@ -0,0 +1,42 @@ +-- 创建 referral_links 表 +-- 用户推广链接表 + +CREATE TABLE IF NOT EXISTS referral_links ( + id SERIAL PRIMARY KEY, + uuid UUID NOT NULL DEFAULT gen_random_uuid() UNIQUE, + user_uuid UUID NOT NULL, + -- 链接码 + code VARCHAR(50) NOT NULL UNIQUE, + -- 完整 URL + url VARCHAR(512), + -- 当前层级 + tier_uuid UUID, + -- 是否解锁 + unlocked BOOLEAN DEFAULT TRUE, + -- 是否当前使用 + is_current BOOLEAN DEFAULT FALSE, + -- 奖励比例(冗余,便于查询) + reward_rate DECIMAL(5, 2) NOT NULL DEFAULT 0, + discount_rate DECIMAL(5, 2) NOT NULL DEFAULT 0, + -- 统计 + registered_users INT DEFAULT 0, + paid_users INT DEFAULT 0, + total_consumption DECIMAL(12, 2) DEFAULT 0, + last_30_days_consumption DECIMAL(12, 2) DEFAULT 0, + -- 时间 + created_at TIMESTAMP WITH TIME ZONE NOT NULL DEFAULT CURRENT_TIMESTAMP, + updated_at TIMESTAMP WITH TIME ZONE NOT NULL DEFAULT CURRENT_TIMESTAMP, + -- 约束 + CONSTRAINT fk_referral_links_user FOREIGN KEY (user_uuid) REFERENCES users(uuid) ON DELETE CASCADE, + CONSTRAINT fk_referral_links_tier FOREIGN KEY (tier_uuid) REFERENCES referral_link_tiers(uuid) +); + +-- 创建索引 +CREATE INDEX idx_referral_links_user_uuid ON referral_links(user_uuid); +CREATE INDEX idx_referral_links_code ON referral_links(code); +CREATE INDEX idx_referral_links_is_current ON referral_links(is_current); + +-- 创建更新时间触发器 +CREATE TRIGGER update_referral_links_updated_at BEFORE UPDATE ON referral_links + FOR EACH ROW EXECUTE FUNCTION update_updated_at_column(); + diff --git a/src-tauri/crates/business/migrations/20250120000040_create_user_referrals.sql b/src-tauri/crates/business/migrations/20250120000040_create_user_referrals.sql new file mode 100644 index 0000000..a52cdfb --- /dev/null +++ b/src-tauri/crates/business/migrations/20250120000040_create_user_referrals.sql @@ -0,0 +1,32 @@ +-- 创建 user_referrals 表 +-- 用户邀请关系表 + +CREATE TABLE IF NOT EXISTS user_referrals ( + id SERIAL PRIMARY KEY, + -- 邀请者 + inviter_uuid UUID NOT NULL, + -- 被邀请者 + invitee_uuid UUID NOT NULL UNIQUE, + -- 通过哪个推广链接 + link_uuid UUID, + -- 状态: registered, activated, paid + status VARCHAR(50) NOT NULL DEFAULT 'registered', + -- 消费统计 + total_consumption DECIMAL(12, 2) DEFAULT 0, + last_30_days_consumption DECIMAL(12, 2) DEFAULT 0, + -- 时间 + registered_at TIMESTAMP WITH TIME ZONE NOT NULL DEFAULT CURRENT_TIMESTAMP, + activated_at TIMESTAMP WITH TIME ZONE, + first_paid_at TIMESTAMP WITH TIME ZONE, + -- 约束 + CONSTRAINT fk_user_referrals_inviter FOREIGN KEY (inviter_uuid) REFERENCES users(uuid), + CONSTRAINT fk_user_referrals_invitee FOREIGN KEY (invitee_uuid) REFERENCES users(uuid), + CONSTRAINT fk_user_referrals_link FOREIGN KEY (link_uuid) REFERENCES referral_links(uuid) +); + +-- 创建索引 +CREATE INDEX idx_user_referrals_inviter_uuid ON user_referrals(inviter_uuid); +CREATE INDEX idx_user_referrals_invitee_uuid ON user_referrals(invitee_uuid); +CREATE INDEX idx_user_referrals_status ON user_referrals(status); +CREATE INDEX idx_user_referrals_link_uuid ON user_referrals(link_uuid); + diff --git a/src-tauri/crates/business/migrations/20250120000041_create_referral_rewards.sql b/src-tauri/crates/business/migrations/20250120000041_create_referral_rewards.sql new file mode 100644 index 0000000..6d7f05d --- /dev/null +++ b/src-tauri/crates/business/migrations/20250120000041_create_referral_rewards.sql @@ -0,0 +1,33 @@ +-- 创建 referral_rewards 表 +-- 推荐奖励记录表 + +CREATE TABLE IF NOT EXISTS referral_rewards ( + id BIGSERIAL PRIMARY KEY, + uuid UUID NOT NULL DEFAULT gen_random_uuid() UNIQUE, + user_uuid UUID NOT NULL, + -- 奖励类型: registration, first_pay, consumption + reward_type VARCHAR(50) NOT NULL, + -- 积分 + points INT NOT NULL, + -- 描述 + description TEXT, + -- 关联被邀请用户 + referred_user_uuid UUID, + -- 关联推广链接 + link_uuid UUID, + -- 状态: pending, approved, rejected + status VARCHAR(50) NOT NULL DEFAULT 'pending', + -- 时间 + created_at TIMESTAMP WITH TIME ZONE NOT NULL DEFAULT CURRENT_TIMESTAMP, + -- 约束 + CONSTRAINT fk_referral_rewards_user FOREIGN KEY (user_uuid) REFERENCES users(uuid), + CONSTRAINT fk_referral_rewards_referred FOREIGN KEY (referred_user_uuid) REFERENCES users(uuid), + CONSTRAINT fk_referral_rewards_link FOREIGN KEY (link_uuid) REFERENCES referral_links(uuid) +); + +-- 创建索引 +CREATE INDEX idx_referral_rewards_user_uuid ON referral_rewards(user_uuid); +CREATE INDEX idx_referral_rewards_reward_type ON referral_rewards(reward_type); +CREATE INDEX idx_referral_rewards_status ON referral_rewards(status); +CREATE INDEX idx_referral_rewards_created_at ON referral_rewards(created_at); + diff --git a/src-tauri/crates/business/migrations/20250120000042_create_user_referral_points.sql b/src-tauri/crates/business/migrations/20250120000042_create_user_referral_points.sql new file mode 100644 index 0000000..7147616 --- /dev/null +++ b/src-tauri/crates/business/migrations/20250120000042_create_user_referral_points.sql @@ -0,0 +1,28 @@ +-- 创建 user_referral_points 表 +-- 用户推荐积分表 + +CREATE TABLE IF NOT EXISTS user_referral_points ( + id SERIAL PRIMARY KEY, + user_uuid UUID NOT NULL UNIQUE, + -- 总积分 + total_points INT NOT NULL DEFAULT 0, + -- 可用积分 + available_points INT NOT NULL DEFAULT 0, + -- 已使用积分 + used_points INT NOT NULL DEFAULT 0, + -- 待审核积分 + pending_points INT NOT NULL DEFAULT 0, + -- 时间 + created_at TIMESTAMP WITH TIME ZONE NOT NULL DEFAULT CURRENT_TIMESTAMP, + updated_at TIMESTAMP WITH TIME ZONE NOT NULL DEFAULT CURRENT_TIMESTAMP, + -- 约束 + CONSTRAINT fk_user_referral_points_user FOREIGN KEY (user_uuid) REFERENCES users(uuid) ON DELETE CASCADE +); + +-- 创建索引 +CREATE INDEX idx_user_referral_points_user_uuid ON user_referral_points(user_uuid); + +-- 创建更新时间触发器 +CREATE TRIGGER update_user_referral_points_updated_at BEFORE UPDATE ON user_referral_points + FOR EACH ROW EXECUTE FUNCTION update_updated_at_column(); + diff --git a/src-tauri/crates/business/migrations/20250120000043_create_redeem_options.sql b/src-tauri/crates/business/migrations/20250120000043_create_redeem_options.sql new file mode 100644 index 0000000..e1293e4 --- /dev/null +++ b/src-tauri/crates/business/migrations/20250120000043_create_redeem_options.sql @@ -0,0 +1,36 @@ +-- 创建 redeem_options 表 +-- 积分兑换选项表 + +CREATE TABLE IF NOT EXISTS redeem_options ( + id SERIAL PRIMARY KEY, + uuid UUID NOT NULL DEFAULT gen_random_uuid() UNIQUE, + -- 兑换类型: wallet, coupon, gift + redeem_type VARCHAR(50) NOT NULL, + -- 名称 + name VARCHAR(255) NOT NULL, + -- 描述 + description TEXT, + -- 所需积分 + points_required INT NOT NULL, + -- 兑换价值 + value DECIMAL(12, 2) NOT NULL, + currency VARCHAR(10) DEFAULT 'USD', + -- 兑换比例(每 N 积分 = 1 单位价值) + exchange_rate INT NOT NULL DEFAULT 100, + -- 状态: active, inactive + status VARCHAR(50) NOT NULL DEFAULT 'active', + -- 排序 + sort_order INT DEFAULT 0, + -- 时间 + created_at TIMESTAMP WITH TIME ZONE NOT NULL DEFAULT CURRENT_TIMESTAMP, + updated_at TIMESTAMP WITH TIME ZONE NOT NULL DEFAULT CURRENT_TIMESTAMP +); + +-- 创建索引 +CREATE INDEX idx_redeem_options_redeem_type ON redeem_options(redeem_type); +CREATE INDEX idx_redeem_options_status ON redeem_options(status); + +-- 创建更新时间触发器 +CREATE TRIGGER update_redeem_options_updated_at BEFORE UPDATE ON redeem_options + FOR EACH ROW EXECUTE FUNCTION update_updated_at_column(); + diff --git a/src-tauri/crates/business/migrations/20250120000044_create_redeem_records.sql b/src-tauri/crates/business/migrations/20250120000044_create_redeem_records.sql new file mode 100644 index 0000000..72eb103 --- /dev/null +++ b/src-tauri/crates/business/migrations/20250120000044_create_redeem_records.sql @@ -0,0 +1,29 @@ +-- 创建 redeem_records 表 +-- 积分兑换记录表 + +CREATE TABLE IF NOT EXISTS redeem_records ( + id BIGSERIAL PRIMARY KEY, + uuid UUID NOT NULL DEFAULT gen_random_uuid() UNIQUE, + user_uuid UUID NOT NULL, + option_uuid UUID NOT NULL, + -- 兑换积分 + points_used INT NOT NULL, + -- 兑换价值 + value DECIMAL(12, 2) NOT NULL, + currency VARCHAR(10) DEFAULT 'USD', + -- 状态: pending, completed, failed + status VARCHAR(50) NOT NULL DEFAULT 'pending', + -- 时间 + created_at TIMESTAMP WITH TIME ZONE NOT NULL DEFAULT CURRENT_TIMESTAMP, + completed_at TIMESTAMP WITH TIME ZONE, + -- 约束 + CONSTRAINT fk_redeem_records_user FOREIGN KEY (user_uuid) REFERENCES users(uuid), + CONSTRAINT fk_redeem_records_option FOREIGN KEY (option_uuid) REFERENCES redeem_options(uuid) +); + +-- 创建索引 +CREATE INDEX idx_redeem_records_user_uuid ON redeem_records(user_uuid); +CREATE INDEX idx_redeem_records_option_uuid ON redeem_records(option_uuid); +CREATE INDEX idx_redeem_records_status ON redeem_records(status); +CREATE INDEX idx_redeem_records_created_at ON redeem_records(created_at); + diff --git a/src-tauri/crates/business/migrations/20250120000045_create_audit_logs.sql b/src-tauri/crates/business/migrations/20250120000045_create_audit_logs.sql new file mode 100644 index 0000000..ecb8a4d --- /dev/null +++ b/src-tauri/crates/business/migrations/20250120000045_create_audit_logs.sql @@ -0,0 +1,37 @@ +-- 创建 audit_logs 表 +-- 审计日志表 + +CREATE TABLE IF NOT EXISTS audit_logs ( + id BIGSERIAL PRIMARY KEY, + uuid UUID NOT NULL DEFAULT gen_random_uuid() UNIQUE, + user_uuid UUID NOT NULL, + team_uuid UUID, + -- 操作类型: login, logout, password_change, create, update, delete, batch_delete, start, stop, import, export, invite, role_change, member_remove, settings_update + action VARCHAR(50) NOT NULL, + -- 目标类型: environment, group, tag, proxy, account, team, settings, system + target_type VARCHAR(50) NOT NULL, + -- 目标 ID + target_uuid UUID, + target_name VARCHAR(255), + -- 详情 + details TEXT, + -- 变更内容(JSON) + changes JSONB, + -- 请求信息 + ip_address VARCHAR(45), + user_agent TEXT, + request_id VARCHAR(100), + -- 时间 + created_at TIMESTAMP WITH TIME ZONE NOT NULL DEFAULT CURRENT_TIMESTAMP, + -- 约束 + CONSTRAINT fk_audit_logs_user FOREIGN KEY (user_uuid) REFERENCES users(uuid) +); + +-- 创建索引 +CREATE INDEX idx_audit_logs_user_uuid ON audit_logs(user_uuid); +CREATE INDEX idx_audit_logs_team_uuid ON audit_logs(team_uuid); +CREATE INDEX idx_audit_logs_action ON audit_logs(action); +CREATE INDEX idx_audit_logs_target_type ON audit_logs(target_type); +CREATE INDEX idx_audit_logs_target_uuid ON audit_logs(target_uuid); +CREATE INDEX idx_audit_logs_created_at ON audit_logs(created_at); + diff --git a/src-tauri/crates/business/migrations/20250120000046_create_api_keys.sql b/src-tauri/crates/business/migrations/20250120000046_create_api_keys.sql new file mode 100644 index 0000000..7e7c840 --- /dev/null +++ b/src-tauri/crates/business/migrations/20250120000046_create_api_keys.sql @@ -0,0 +1,43 @@ +-- 创建 api_keys 表 +-- API 密钥表 + +CREATE TABLE IF NOT EXISTS api_keys ( + id SERIAL PRIMARY KEY, + uuid UUID NOT NULL DEFAULT gen_random_uuid() UNIQUE, + user_uuid UUID NOT NULL, + -- 密钥信息 + name VARCHAR(255) NOT NULL, + key_hash VARCHAR(255) NOT NULL, + key_prefix VARCHAR(20) NOT NULL, + -- 权限: read, write, delete, admin + permissions JSONB NOT NULL DEFAULT '["read"]', + -- 限流 + rate_limit INT DEFAULT 1000, + daily_limit INT DEFAULT 10000, + -- IP 白名单(JSON 数组) + ip_whitelist JSONB DEFAULT '[]', + -- 过期时间 + expires_at TIMESTAMP WITH TIME ZONE, + -- 使用统计 + usage_count BIGINT DEFAULT 0, + daily_usage INT DEFAULT 0, + last_used_at TIMESTAMP WITH TIME ZONE, + -- 状态: active, revoked + status VARCHAR(50) NOT NULL DEFAULT 'active', + -- 时间 + created_at TIMESTAMP WITH TIME ZONE NOT NULL DEFAULT CURRENT_TIMESTAMP, + updated_at TIMESTAMP WITH TIME ZONE NOT NULL DEFAULT CURRENT_TIMESTAMP, + -- 约束 + CONSTRAINT fk_api_keys_user FOREIGN KEY (user_uuid) REFERENCES users(uuid) ON DELETE CASCADE +); + +-- 创建索引 +CREATE INDEX idx_api_keys_user_uuid ON api_keys(user_uuid); +CREATE INDEX idx_api_keys_key_hash ON api_keys(key_hash); +CREATE INDEX idx_api_keys_key_prefix ON api_keys(key_prefix); +CREATE INDEX idx_api_keys_status ON api_keys(status); + +-- 创建更新时间触发器 +CREATE TRIGGER update_api_keys_updated_at BEFORE UPDATE ON api_keys + FOR EACH ROW EXECUTE FUNCTION update_updated_at_column(); + diff --git a/src-tauri/crates/business/migrations/20250120000047_create_user_preferences.sql b/src-tauri/crates/business/migrations/20250120000047_create_user_preferences.sql new file mode 100644 index 0000000..97294b7 --- /dev/null +++ b/src-tauri/crates/business/migrations/20250120000047_create_user_preferences.sql @@ -0,0 +1,26 @@ +-- 创建 user_preferences 表 +-- 用户偏好设置表(仅云同步设置) + +CREATE TABLE IF NOT EXISTS user_preferences ( + id SERIAL PRIMARY KEY, + user_uuid UUID NOT NULL UNIQUE, + -- 主题: light, dark, system + theme VARCHAR(50) NOT NULL DEFAULT 'system', + -- 语言 + language VARCHAR(20) NOT NULL DEFAULT 'zh-CN', + -- 通知开关 + notifications_enabled BOOLEAN NOT NULL DEFAULT TRUE, + -- 时间 + created_at TIMESTAMP WITH TIME ZONE NOT NULL DEFAULT CURRENT_TIMESTAMP, + updated_at TIMESTAMP WITH TIME ZONE NOT NULL DEFAULT CURRENT_TIMESTAMP, + -- 约束 + CONSTRAINT fk_user_preferences_user FOREIGN KEY (user_uuid) REFERENCES users(uuid) ON DELETE CASCADE +); + +-- 创建索引 +CREATE INDEX idx_user_preferences_user_uuid ON user_preferences(user_uuid); + +-- 创建更新时间触发器 +CREATE TRIGGER update_user_preferences_updated_at BEFORE UPDATE ON user_preferences + FOR EACH ROW EXECUTE FUNCTION update_updated_at_column(); + diff --git a/src-tauri/crates/business/migrations/20250120000048_create_system_configs.sql b/src-tauri/crates/business/migrations/20250120000048_create_system_configs.sql new file mode 100644 index 0000000..0e5078a --- /dev/null +++ b/src-tauri/crates/business/migrations/20250120000048_create_system_configs.sql @@ -0,0 +1,23 @@ +-- 创建 system_configs 表 +-- 系统配置表(全局配置) + +CREATE TABLE IF NOT EXISTS system_configs ( + id SERIAL PRIMARY KEY, + -- 配置键 + config_key VARCHAR(255) NOT NULL UNIQUE, + -- 配置值(JSON) + config_value JSONB NOT NULL DEFAULT '{}', + -- 描述 + description TEXT, + -- 时间 + created_at TIMESTAMP WITH TIME ZONE NOT NULL DEFAULT CURRENT_TIMESTAMP, + updated_at TIMESTAMP WITH TIME ZONE NOT NULL DEFAULT CURRENT_TIMESTAMP +); + +-- 创建索引 +CREATE INDEX idx_system_configs_key ON system_configs(config_key); + +-- 创建更新时间触发器 +CREATE TRIGGER update_system_configs_updated_at BEFORE UPDATE ON system_configs + FOR EACH ROW EXECUTE FUNCTION update_updated_at_column(); + diff --git a/src-tauri/crates/business/migrations/20250120000049_add_foreign_keys.sql b/src-tauri/crates/business/migrations/20250120000049_add_foreign_keys.sql new file mode 100644 index 0000000..828dcb8 --- /dev/null +++ b/src-tauri/crates/business/migrations/20250120000049_add_foreign_keys.sql @@ -0,0 +1,32 @@ +-- 添加延迟创建的外键约束 +-- 某些外键需要在相关表都创建后才能添加 + +-- teams.default_proxy_uuid -> proxies.uuid +DO $$ +BEGIN + IF NOT EXISTS ( + SELECT 1 FROM information_schema.table_constraints + WHERE constraint_name = 'fk_teams_default_proxy' + ) THEN + ALTER TABLE teams + ADD CONSTRAINT fk_teams_default_proxy + FOREIGN KEY (default_proxy_uuid) REFERENCES proxies(uuid) ON DELETE SET NULL; + END IF; +END $$; + +-- groups.default_proxy_uuid -> proxies.uuid +DO $$ +BEGIN + IF NOT EXISTS ( + SELECT 1 FROM information_schema.table_constraints + WHERE constraint_name = 'fk_groups_default_proxy' + ) THEN + ALTER TABLE groups + ADD CONSTRAINT fk_groups_default_proxy + FOREIGN KEY (default_proxy_uuid) REFERENCES proxies(uuid) ON DELETE SET NULL; + END IF; +END $$; + +-- 创建 groups.default_proxy_uuid 索引 +CREATE INDEX IF NOT EXISTS idx_groups_default_proxy ON groups(default_proxy_uuid); + diff --git a/src-tauri/crates/business/migrations/20250120000050_insert_default_data.sql b/src-tauri/crates/business/migrations/20250120000050_insert_default_data.sql new file mode 100644 index 0000000..c058ba4 --- /dev/null +++ b/src-tauri/crates/business/migrations/20250120000050_insert_default_data.sql @@ -0,0 +1,37 @@ +-- 插入默认数据 + +-- 1. 默认套餐 +INSERT INTO plans (uuid, name, description, price_per_month, price_per_year, max_environments, max_team_members, max_proxies, max_rpa_tasks, is_recommended, sort_order, status) +VALUES + (gen_random_uuid(), 'Free', '免费套餐,适合个人试用', 0, 0, 5, 1, 5, 2, FALSE, 1, 'active'), + (gen_random_uuid(), 'Basic', '基础套餐,适合个人用户', 9.99, 99.99, 20, 3, 20, 5, FALSE, 2, 'active'), + (gen_random_uuid(), 'Pro', '专业套餐,适合小型团队', 29.99, 299.99, 100, 10, 100, 20, TRUE, 3, 'active'), + (gen_random_uuid(), 'Enterprise', '企业套餐,无限制', 99.99, 999.99, 1000, 100, 1000, 100, FALSE, 4, 'active') +ON CONFLICT DO NOTHING; + +-- 2. 默认推广链接层级 +INSERT INTO referral_link_tiers (uuid, name, unlock_threshold, reward_rate, discount_rate, description, sort_order) +VALUES + (gen_random_uuid(), '青铜', 0, 10.00, 5.00, '默认层级,邀请即享', 1), + (gen_random_uuid(), '白银', 10, 15.00, 8.00, '邀请满10人解锁', 2), + (gen_random_uuid(), '黄金', 50, 20.00, 10.00, '邀请满50人解锁', 3), + (gen_random_uuid(), '钻石', 100, 25.00, 15.00, '邀请满100人解锁', 4) +ON CONFLICT DO NOTHING; + +-- 3. 默认兑换选项 +INSERT INTO redeem_options (uuid, redeem_type, name, description, points_required, value, exchange_rate, status, sort_order) +VALUES + (gen_random_uuid(), 'wallet', '兑换余额', '100 积分 = $1', 100, 1.00, 100, 'active', 1), + (gen_random_uuid(), 'coupon', '兑换优惠券', '500 积分 = $10 优惠券', 500, 10.00, 50, 'active', 2), + (gen_random_uuid(), 'coupon', '兑换优惠券', '1000 积分 = $25 优惠券', 1000, 25.00, 40, 'active', 3) +ON CONFLICT DO NOTHING; + +-- 4. 默认系统配置 +INSERT INTO system_configs (config_key, config_value, description) +VALUES + ('app_version', '"1.0.0"', '应用版本'), + ('maintenance_mode', 'false', '维护模式'), + ('registration_enabled', 'true', '是否允许注册'), + ('referral_enabled', 'true', '是否启用推广计划') +ON CONFLICT (config_key) DO NOTHING; + diff --git a/src-tauri/crates/business/migrations/20250120000051_remove_icon_and_color_fields.sql b/src-tauri/crates/business/migrations/20250120000051_remove_icon_and_color_fields.sql new file mode 100644 index 0000000..8b1a178 --- /dev/null +++ b/src-tauri/crates/business/migrations/20250120000051_remove_icon_and_color_fields.sql @@ -0,0 +1,10 @@ +-- 移除环境的 icon 和 icon_color 字段 +-- 移除分组的 color 字段 + +-- 移除 environments 表的 icon 和 icon_color 字段 +ALTER TABLE environments DROP COLUMN IF EXISTS icon; +ALTER TABLE environments DROP COLUMN IF EXISTS icon_color; + +-- 移除 groups 表的 color 字段 +ALTER TABLE groups DROP COLUMN IF EXISTS color; + diff --git a/src-tauri/crates/business/migrations/20250123000001_create_console_admins.sql b/src-tauri/crates/business/migrations/20250123000001_create_console_admins.sql new file mode 100644 index 0000000..bc530e8 --- /dev/null +++ b/src-tauri/crates/business/migrations/20250123000001_create_console_admins.sql @@ -0,0 +1,28 @@ +-- Console Gateway 管理员表 +-- 用于存储 console-gateway 的管理员信息 + +CREATE TABLE IF NOT EXISTS console_admins ( + id SERIAL PRIMARY KEY, + uuid UUID NOT NULL DEFAULT gen_random_uuid() UNIQUE, + user_uuid UUID NOT NULL UNIQUE, + is_active BOOLEAN NOT NULL DEFAULT TRUE, + created_at TIMESTAMP WITH TIME ZONE NOT NULL DEFAULT CURRENT_TIMESTAMP, + updated_at TIMESTAMP WITH TIME ZONE NOT NULL DEFAULT CURRENT_TIMESTAMP, + deleted_at TIMESTAMP WITH TIME ZONE +); + +-- 索引 +CREATE INDEX IF NOT EXISTS idx_console_admins_user_uuid ON console_admins(user_uuid); +CREATE INDEX IF NOT EXISTS idx_console_admins_is_active ON console_admins(is_active); +CREATE INDEX IF NOT EXISTS idx_console_admins_deleted_at ON console_admins(deleted_at); + +-- 注释 +COMMENT ON TABLE console_admins IS 'Console Gateway 管理员表'; +COMMENT ON COLUMN console_admins.id IS '主键 ID'; +COMMENT ON COLUMN console_admins.uuid IS '唯一标识'; +COMMENT ON COLUMN console_admins.user_uuid IS '关联的用户 UUID'; +COMMENT ON COLUMN console_admins.is_active IS '是否激活'; +COMMENT ON COLUMN console_admins.created_at IS '创建时间'; +COMMENT ON COLUMN console_admins.updated_at IS '更新时间'; +COMMENT ON COLUMN console_admins.deleted_at IS '删除时间 (软删除)'; + diff --git a/src-tauri/crates/business/migrations/20250123000002_create_console_permissions.sql b/src-tauri/crates/business/migrations/20250123000002_create_console_permissions.sql new file mode 100644 index 0000000..4434cea --- /dev/null +++ b/src-tauri/crates/business/migrations/20250123000002_create_console_permissions.sql @@ -0,0 +1,51 @@ +-- Console Gateway 权限表 +-- 用于存储 console-gateway 的路由权限 + +CREATE TABLE IF NOT EXISTS console_permissions ( + id SERIAL PRIMARY KEY, + uuid UUID NOT NULL DEFAULT gen_random_uuid() UNIQUE, + route_path VARCHAR(512) NOT NULL, + method VARCHAR(10) NOT NULL, + description TEXT, + created_at TIMESTAMP WITH TIME ZONE NOT NULL DEFAULT CURRENT_TIMESTAMP, + updated_at TIMESTAMP WITH TIME ZONE NOT NULL DEFAULT CURRENT_TIMESTAMP, + deleted_at TIMESTAMP WITH TIME ZONE, + UNIQUE(route_path, method) +); + +-- Console Gateway 管理员权限关联表 +CREATE TABLE IF NOT EXISTS console_admin_permissions ( + id SERIAL PRIMARY KEY, + uuid UUID NOT NULL DEFAULT gen_random_uuid() UNIQUE, + admin_id INTEGER NOT NULL REFERENCES console_admins(id) ON DELETE CASCADE, + permission_id INTEGER NOT NULL REFERENCES console_permissions(id) ON DELETE CASCADE, + granted_at TIMESTAMP WITH TIME ZONE NOT NULL DEFAULT CURRENT_TIMESTAMP, + granted_by UUID, + UNIQUE(admin_id, permission_id) +); + +-- 索引 +CREATE INDEX IF NOT EXISTS idx_console_permissions_route ON console_permissions(route_path, method); +CREATE INDEX IF NOT EXISTS idx_console_permissions_deleted_at ON console_permissions(deleted_at); +CREATE INDEX IF NOT EXISTS idx_console_admin_permissions_admin ON console_admin_permissions(admin_id); +CREATE INDEX IF NOT EXISTS idx_console_admin_permissions_permission ON console_admin_permissions(permission_id); + +-- 注释 +COMMENT ON TABLE console_permissions IS 'Console Gateway 路由权限表'; +COMMENT ON COLUMN console_permissions.id IS '主键 ID'; +COMMENT ON COLUMN console_permissions.uuid IS '唯一标识'; +COMMENT ON COLUMN console_permissions.route_path IS '路由路径'; +COMMENT ON COLUMN console_permissions.method IS 'HTTP 方法 (GET, POST, PUT, DELETE, PATCH)'; +COMMENT ON COLUMN console_permissions.description IS '权限描述'; +COMMENT ON COLUMN console_permissions.created_at IS '创建时间'; +COMMENT ON COLUMN console_permissions.updated_at IS '更新时间'; +COMMENT ON COLUMN console_permissions.deleted_at IS '删除时间 (软删除)'; + +COMMENT ON TABLE console_admin_permissions IS 'Console Gateway 管理员权限关联表'; +COMMENT ON COLUMN console_admin_permissions.id IS '主键 ID'; +COMMENT ON COLUMN console_admin_permissions.uuid IS '唯一标识'; +COMMENT ON COLUMN console_admin_permissions.admin_id IS '管理员 ID'; +COMMENT ON COLUMN console_admin_permissions.permission_id IS '权限 ID'; +COMMENT ON COLUMN console_admin_permissions.granted_at IS '授权时间'; +COMMENT ON COLUMN console_admin_permissions.granted_by IS '授权人 UUID'; + diff --git a/src-tauri/crates/business/migrations/20250123000003_create_console_api_keys.sql b/src-tauri/crates/business/migrations/20250123000003_create_console_api_keys.sql new file mode 100644 index 0000000..a923c1c --- /dev/null +++ b/src-tauri/crates/business/migrations/20250123000003_create_console_api_keys.sql @@ -0,0 +1,41 @@ +-- Console Gateway API 密钥表 +-- 用于存储 console-gateway 的 API 密钥 + +CREATE TABLE IF NOT EXISTS console_api_keys ( + id SERIAL PRIMARY KEY, + uuid UUID NOT NULL DEFAULT gen_random_uuid() UNIQUE, + key_id VARCHAR(64) NOT NULL UNIQUE, + key_secret VARCHAR(128) NOT NULL, + name VARCHAR(255) NOT NULL, + description TEXT, + is_active BOOLEAN NOT NULL DEFAULT TRUE, + created_at TIMESTAMP WITH TIME ZONE NOT NULL DEFAULT CURRENT_TIMESTAMP, + updated_at TIMESTAMP WITH TIME ZONE NOT NULL DEFAULT CURRENT_TIMESTAMP, + expires_at TIMESTAMP WITH TIME ZONE, + last_used_at TIMESTAMP WITH TIME ZONE, + created_by UUID, + deleted_at TIMESTAMP WITH TIME ZONE +); + +-- 索引 +CREATE INDEX IF NOT EXISTS idx_console_api_keys_key_id ON console_api_keys(key_id); +CREATE INDEX IF NOT EXISTS idx_console_api_keys_is_active ON console_api_keys(is_active); +CREATE INDEX IF NOT EXISTS idx_console_api_keys_expires_at ON console_api_keys(expires_at); +CREATE INDEX IF NOT EXISTS idx_console_api_keys_deleted_at ON console_api_keys(deleted_at); + +-- 注释 +COMMENT ON TABLE console_api_keys IS 'Console Gateway API 密钥表'; +COMMENT ON COLUMN console_api_keys.id IS '主键 ID'; +COMMENT ON COLUMN console_api_keys.uuid IS '唯一标识'; +COMMENT ON COLUMN console_api_keys.key_id IS 'API 密钥 ID (公开部分)'; +COMMENT ON COLUMN console_api_keys.key_secret IS 'API 密钥密文 (私密部分)'; +COMMENT ON COLUMN console_api_keys.name IS '密钥名称'; +COMMENT ON COLUMN console_api_keys.description IS '密钥描述'; +COMMENT ON COLUMN console_api_keys.is_active IS '是否激活'; +COMMENT ON COLUMN console_api_keys.created_at IS '创建时间'; +COMMENT ON COLUMN console_api_keys.updated_at IS '更新时间'; +COMMENT ON COLUMN console_api_keys.expires_at IS '过期时间'; +COMMENT ON COLUMN console_api_keys.last_used_at IS '最后使用时间'; +COMMENT ON COLUMN console_api_keys.created_by IS '创建人 UUID'; +COMMENT ON COLUMN console_api_keys.deleted_at IS '删除时间 (软删除)'; + diff --git a/src-tauri/crates/business/migrations/20250124000001_create_messages.sql b/src-tauri/crates/business/migrations/20250124000001_create_messages.sql new file mode 100644 index 0000000..e904ff0 --- /dev/null +++ b/src-tauri/crates/business/migrations/20250124000001_create_messages.sql @@ -0,0 +1,148 @@ +-- 创建 messages 表 +-- 消息表,用于存储系统消息、团队邀请通知等 + +CREATE TABLE IF NOT EXISTS messages ( + id SERIAL PRIMARY KEY, + uuid UUID NOT NULL DEFAULT gen_random_uuid() UNIQUE, + + -- 消息类型 + message_type VARCHAR(50) NOT NULL, + -- 可选值: + -- 'private_chat' - 用户私信 + -- 'team_announcement' - 团队公告 + -- 'team_invitation' - 团队邀请 + -- 'team_removal' - 团队移除成员 + -- 'system_notification' - 系统通知 + + -- 消息内容 + title VARCHAR(255) NOT NULL, -- 消息标题 + content TEXT, -- 消息内容(支持 JSON 格式存储扩展数据) + + -- 发送者 + sender_uuid UUID, -- 发送者 UUID(系统消息可为 NULL) + + -- 接收者模式 + recipient_type VARCHAR(20) NOT NULL DEFAULT 'single', + -- 'single' - 单个用户 + -- 'multiple' - 多个指定用户 + -- 'team' - 团队内所有成员 + -- 'all' - 所有用户(系统广播) + + -- 关联资源(根据消息类型关联不同的资源) + related_type VARCHAR(50), -- 关联类型:team, invitation, etc. + related_uuid UUID, -- 关联资源的 UUID(如 team_uuid, invitation_uuid) + + -- 消息元数据(JSON 格式,存储扩展信息) + metadata JSONB, + -- 例如:{ + -- "team_name": "研发团队", + -- "inviter_name": "张三", + -- "role": "editor" + -- } + + -- 消息状态 + status VARCHAR(20) NOT NULL DEFAULT 'active', -- active, deleted + priority VARCHAR(20) NOT NULL DEFAULT 'normal', -- low, normal, high, urgent + + -- 时间戳 + created_at TIMESTAMP WITH TIME ZONE NOT NULL DEFAULT CURRENT_TIMESTAMP, + updated_at TIMESTAMP WITH TIME ZONE NOT NULL DEFAULT CURRENT_TIMESTAMP, + deleted_at TIMESTAMP WITH TIME ZONE, + + -- 外键约束 + CONSTRAINT fk_messages_sender FOREIGN KEY (sender_uuid) + REFERENCES users(uuid) ON DELETE SET NULL +); + +-- 创建 user_messages 表(用户消息关联表,支持多接收者) +CREATE TABLE IF NOT EXISTS user_messages ( + id SERIAL PRIMARY KEY, + message_uuid UUID NOT NULL, + user_uuid UUID NOT NULL, + + -- 阅读状态 + is_read BOOLEAN NOT NULL DEFAULT FALSE, + read_at TIMESTAMP WITH TIME ZONE, + + -- 操作状态(用于邀请、移除等需要操作的消息) + action_status VARCHAR(20), + -- 'pending' - 待处理(邀请类消息) + -- 'accepted' - 已接受 + -- 'rejected' - 已拒绝 + -- 'expired' - 已过期 + -- NULL - 无需操作的消息 + + action_at TIMESTAMP WITH TIME ZONE, + + -- 时间戳 + created_at TIMESTAMP WITH TIME ZONE NOT NULL DEFAULT CURRENT_TIMESTAMP, + updated_at TIMESTAMP WITH TIME ZONE NOT NULL DEFAULT CURRENT_TIMESTAMP, + + -- 外键约束 + CONSTRAINT fk_user_messages_message FOREIGN KEY (message_uuid) + REFERENCES messages(uuid) ON DELETE CASCADE, + CONSTRAINT fk_user_messages_user FOREIGN KEY (user_uuid) + REFERENCES users(uuid) ON DELETE CASCADE, + + -- 唯一约束:一个用户对一条消息只能有一条记录 + CONSTRAINT uk_user_messages_message_user UNIQUE (message_uuid, user_uuid) +); + +-- 创建索引 +CREATE INDEX IF NOT EXISTS idx_messages_type ON messages(message_type); +CREATE INDEX IF NOT EXISTS idx_messages_sender ON messages(sender_uuid); +CREATE INDEX IF NOT EXISTS idx_messages_related ON messages(related_type, related_uuid); +CREATE INDEX IF NOT EXISTS idx_messages_recipient_type ON messages(recipient_type); +CREATE INDEX IF NOT EXISTS idx_messages_status ON messages(status); +CREATE INDEX IF NOT EXISTS idx_messages_priority ON messages(priority); +CREATE INDEX IF NOT EXISTS idx_messages_created_at ON messages(created_at DESC); +CREATE INDEX IF NOT EXISTS idx_messages_deleted_at ON messages(deleted_at); + +CREATE INDEX IF NOT EXISTS idx_user_messages_user ON user_messages(user_uuid); +CREATE INDEX IF NOT EXISTS idx_user_messages_message ON user_messages(message_uuid); +CREATE INDEX IF NOT EXISTS idx_user_messages_is_read ON user_messages(user_uuid, is_read); +CREATE INDEX IF NOT EXISTS idx_user_messages_action_status ON user_messages(user_uuid, action_status); +CREATE INDEX IF NOT EXISTS idx_user_messages_user_unread ON user_messages(user_uuid, is_read) + WHERE is_read = FALSE; + +-- 复合索引:用于查询用户未读消息 +CREATE INDEX IF NOT EXISTS idx_user_messages_user_type_unread + ON user_messages(user_uuid, is_read, created_at DESC) + WHERE is_read = FALSE; + +-- 创建更新时间触发器 +CREATE TRIGGER update_messages_updated_at BEFORE UPDATE ON messages + FOR EACH ROW EXECUTE FUNCTION update_updated_at_column(); + +CREATE TRIGGER update_user_messages_updated_at BEFORE UPDATE ON user_messages + FOR EACH ROW EXECUTE FUNCTION update_updated_at_column(); + +-- 团队消息自动分发触发器 +-- 当创建 recipient_type='team' 的消息时,自动为团队成员创建 user_messages 记录 +CREATE OR REPLACE FUNCTION auto_create_team_message_recipients() +RETURNS TRIGGER AS $$ +BEGIN + -- 如果接收者类型是 team,且有关联的团队 UUID + IF NEW.recipient_type = 'team' AND NEW.related_type = 'team' AND NEW.related_uuid IS NOT NULL THEN + -- 为团队所有活跃成员创建消息关联记录 + INSERT INTO user_messages (message_uuid, user_uuid, is_read, action_status) + SELECT NEW.uuid, tm.user_uuid, FALSE, + CASE + WHEN NEW.message_type = 'team_invitation' THEN 'pending' + ELSE NULL + END + FROM team_members tm + WHERE tm.team_uuid = NEW.related_uuid + AND tm.status = 'active' + AND tm.deleted_at IS NULL + ON CONFLICT (message_uuid, user_uuid) DO NOTHING; + END IF; + RETURN NEW; +END; +$$ LANGUAGE plpgsql; + +CREATE TRIGGER trigger_auto_create_team_message_recipients + AFTER INSERT ON messages + FOR EACH ROW + EXECUTE FUNCTION auto_create_team_message_recipients(); + diff --git a/src-tauri/crates/business/migrations/20250124000002_add_deleted_at_to_team_invitations.sql b/src-tauri/crates/business/migrations/20250124000002_add_deleted_at_to_team_invitations.sql new file mode 100644 index 0000000..f96eab0 --- /dev/null +++ b/src-tauri/crates/business/migrations/20250124000002_add_deleted_at_to_team_invitations.sql @@ -0,0 +1,9 @@ +-- 为 team_invitations 表添加 deleted_at 字段 +-- 用于软删除功能 + +ALTER TABLE team_invitations +ADD COLUMN IF NOT EXISTS deleted_at TIMESTAMP WITH TIME ZONE; + +-- 创建索引以优化查询性能 +CREATE INDEX IF NOT EXISTS idx_invitations_deleted_at ON team_invitations(deleted_at) WHERE deleted_at IS NULL; + diff --git a/src-tauri/crates/business/migrations/20250125000001_create_workspaces.sql b/src-tauri/crates/business/migrations/20250125000001_create_workspaces.sql new file mode 100644 index 0000000..1bf948e --- /dev/null +++ b/src-tauri/crates/business/migrations/20250125000001_create_workspaces.sql @@ -0,0 +1,34 @@ +-- 创建 workspaces 表 +-- 工作空间表,资源隔离的顶层容器 + +CREATE TABLE IF NOT EXISTS workspaces ( + uuid UUID PRIMARY KEY DEFAULT gen_random_uuid(), + name VARCHAR(255) NOT NULL, + owner_uuid UUID NOT NULL, + workspace_type VARCHAR(50) NOT NULL DEFAULT 'personal', + created_at TIMESTAMP WITH TIME ZONE NOT NULL DEFAULT CURRENT_TIMESTAMP, + updated_at TIMESTAMP WITH TIME ZONE NOT NULL DEFAULT CURRENT_TIMESTAMP, + deleted_at TIMESTAMP WITH TIME ZONE, + -- 约束 + CONSTRAINT fk_workspaces_owner FOREIGN KEY (owner_uuid) REFERENCES users(uuid) +); + +-- 创建索引 +CREATE INDEX idx_workspaces_owner_uuid ON workspaces(owner_uuid); +CREATE INDEX idx_workspaces_deleted_at ON workspaces(deleted_at); +CREATE INDEX idx_workspaces_workspace_type ON workspaces(workspace_type); + +-- 创建更新时间触发器 +CREATE TRIGGER update_workspaces_updated_at BEFORE UPDATE ON workspaces + FOR EACH ROW EXECUTE FUNCTION update_updated_at_column(); + +-- 列注释 +COMMENT ON TABLE workspaces IS '工作空间表,资源隔离的顶层容器'; +COMMENT ON COLUMN workspaces.uuid IS '工作空间唯一标识'; +COMMENT ON COLUMN workspaces.name IS '工作空间名称'; +COMMENT ON COLUMN workspaces.owner_uuid IS '所有者用户 UUID'; +COMMENT ON COLUMN workspaces.workspace_type IS '工作空间类型:personal/team/enterprise'; +COMMENT ON COLUMN workspaces.created_at IS '创建时间'; +COMMENT ON COLUMN workspaces.updated_at IS '更新时间'; +COMMENT ON COLUMN workspaces.deleted_at IS '删除时间(软删除)'; + diff --git a/src-tauri/crates/business/migrations/20250125000002_create_workspace_quotas.sql b/src-tauri/crates/business/migrations/20250125000002_create_workspace_quotas.sql new file mode 100644 index 0000000..e195227 --- /dev/null +++ b/src-tauri/crates/business/migrations/20250125000002_create_workspace_quotas.sql @@ -0,0 +1,43 @@ +-- 创建 workspace_quotas 表 +-- 工作空间配额表,定义工作空间的资源配额限制 + +CREATE TABLE IF NOT EXISTS workspace_quotas ( + workspace_uuid UUID PRIMARY KEY, + -- 环境配额 + max_environments INT NOT NULL DEFAULT 10, + used_environments INT NOT NULL DEFAULT 0, + -- 团队成员配额(所有团队总和) + max_team_members INT NOT NULL DEFAULT 5, + used_team_members INT NOT NULL DEFAULT 0, + -- 代理配额 + max_proxies INT NOT NULL DEFAULT 10, + used_proxies INT NOT NULL DEFAULT 0, + -- RPA 任务配额 + max_rpa_tasks INT NOT NULL DEFAULT 5, + used_rpa_tasks INT NOT NULL DEFAULT 0, + -- 时间 + created_at TIMESTAMP WITH TIME ZONE NOT NULL DEFAULT CURRENT_TIMESTAMP, + updated_at TIMESTAMP WITH TIME ZONE NOT NULL DEFAULT CURRENT_TIMESTAMP, + -- 约束 + CONSTRAINT fk_workspace_quotas_workspace FOREIGN KEY (workspace_uuid) REFERENCES workspaces(uuid) ON DELETE CASCADE +); + +-- 创建索引 +CREATE INDEX idx_workspace_quotas_workspace_uuid ON workspace_quotas(workspace_uuid); + +-- 创建更新时间触发器 +CREATE TRIGGER update_workspace_quotas_updated_at BEFORE UPDATE ON workspace_quotas + FOR EACH ROW EXECUTE FUNCTION update_updated_at_column(); + +-- 列注释 +COMMENT ON TABLE workspace_quotas IS '工作空间配额表,定义工作空间的资源配额限制'; +COMMENT ON COLUMN workspace_quotas.workspace_uuid IS '工作空间 UUID'; +COMMENT ON COLUMN workspace_quotas.max_environments IS '最大环境数'; +COMMENT ON COLUMN workspace_quotas.used_environments IS '已使用环境数'; +COMMENT ON COLUMN workspace_quotas.max_team_members IS '最大成员数(所有团队总和)'; +COMMENT ON COLUMN workspace_quotas.used_team_members IS '已使用成员数'; +COMMENT ON COLUMN workspace_quotas.max_proxies IS '最大代理数'; +COMMENT ON COLUMN workspace_quotas.used_proxies IS '已使用代理数'; +COMMENT ON COLUMN workspace_quotas.max_rpa_tasks IS '最大 RPA 任务数'; +COMMENT ON COLUMN workspace_quotas.used_rpa_tasks IS '已使用 RPA 任务数'; + diff --git a/src-tauri/crates/business/migrations/20250125000003_create_proxy_visible_teams.sql b/src-tauri/crates/business/migrations/20250125000003_create_proxy_visible_teams.sql new file mode 100644 index 0000000..353955d --- /dev/null +++ b/src-tauri/crates/business/migrations/20250125000003_create_proxy_visible_teams.sql @@ -0,0 +1,28 @@ +-- 创建 proxy_visible_teams 表 +-- 代理可见团队关联表,控制代理对哪些团队可见 + +CREATE TABLE IF NOT EXISTS proxy_visible_teams ( + proxy_uuid UUID NOT NULL, + workspace_uuid UUID NOT NULL, + team_uuid UUID NOT NULL, + created_at TIMESTAMP WITH TIME ZONE NOT NULL DEFAULT CURRENT_TIMESTAMP, + -- 约束 + CONSTRAINT fk_proxy_visible_teams_proxy FOREIGN KEY (proxy_uuid) REFERENCES proxies(uuid) ON DELETE CASCADE, + CONSTRAINT fk_proxy_visible_teams_workspace FOREIGN KEY (workspace_uuid) REFERENCES workspaces(uuid) ON DELETE CASCADE, + CONSTRAINT fk_proxy_visible_teams_team FOREIGN KEY (team_uuid) REFERENCES teams(uuid) ON DELETE CASCADE, + -- 唯一约束:一个代理对一个团队只能有一条可见性记录 + CONSTRAINT uk_proxy_visible_teams UNIQUE (proxy_uuid, team_uuid) +); + +-- 创建索引 +CREATE INDEX idx_proxy_visible_teams_proxy_uuid ON proxy_visible_teams(proxy_uuid); +CREATE INDEX idx_proxy_visible_teams_team_uuid ON proxy_visible_teams(team_uuid); +CREATE INDEX idx_proxy_visible_teams_workspace_uuid ON proxy_visible_teams(workspace_uuid); + +-- 列注释 +COMMENT ON TABLE proxy_visible_teams IS '代理可见团队关联表,控制代理对哪些团队可见'; +COMMENT ON COLUMN proxy_visible_teams.proxy_uuid IS '代理 UUID'; +COMMENT ON COLUMN proxy_visible_teams.workspace_uuid IS '工作空间 UUID(冗余,便于查询)'; +COMMENT ON COLUMN proxy_visible_teams.team_uuid IS '团队 UUID'; +COMMENT ON COLUMN proxy_visible_teams.created_at IS '创建时间'; + diff --git a/src-tauri/crates/business/migrations/20250125000004_create_group_member_permissions.sql b/src-tauri/crates/business/migrations/20250125000004_create_group_member_permissions.sql new file mode 100644 index 0000000..7d65419 --- /dev/null +++ b/src-tauri/crates/business/migrations/20250125000004_create_group_member_permissions.sql @@ -0,0 +1,45 @@ +-- 创建 group_member_permissions 表 +-- 分组权限表,控制团队成员对分组的访问权限 + +CREATE TABLE IF NOT EXISTS group_member_permissions ( + group_uuid UUID NOT NULL, + workspace_uuid UUID NOT NULL, + team_uuid UUID NOT NULL, + user_uuid UUID NOT NULL, + permission_type VARCHAR(50) NOT NULL DEFAULT 'read', + granted_by UUID NOT NULL, + created_at TIMESTAMP WITH TIME ZONE NOT NULL DEFAULT CURRENT_TIMESTAMP, + updated_at TIMESTAMP WITH TIME ZONE NOT NULL DEFAULT CURRENT_TIMESTAMP, + -- 约束 + CONSTRAINT fk_group_member_permissions_group FOREIGN KEY (group_uuid) REFERENCES groups(uuid) ON DELETE CASCADE, + CONSTRAINT fk_group_member_permissions_workspace FOREIGN KEY (workspace_uuid) REFERENCES workspaces(uuid) ON DELETE CASCADE, + CONSTRAINT fk_group_member_permissions_team FOREIGN KEY (team_uuid) REFERENCES teams(uuid) ON DELETE CASCADE, + CONSTRAINT fk_group_member_permissions_user FOREIGN KEY (user_uuid) REFERENCES users(uuid) ON DELETE CASCADE, + CONSTRAINT fk_group_member_permissions_granted_by FOREIGN KEY (granted_by) REFERENCES users(uuid), + -- 唯一约束:一个用户对一个分组只能有一条权限记录 + CONSTRAINT uk_group_member_permissions UNIQUE (group_uuid, user_uuid), + -- 检查约束:权限类型必须是 read/write/manage 之一 + CONSTRAINT ck_group_member_permissions_type CHECK (permission_type IN ('read', 'write', 'manage')) +); + +-- 创建索引 +CREATE INDEX idx_group_member_permissions_group_uuid ON group_member_permissions(group_uuid); +CREATE INDEX idx_group_member_permissions_user_uuid ON group_member_permissions(user_uuid); +CREATE INDEX idx_group_member_permissions_workspace_uuid ON group_member_permissions(workspace_uuid); +CREATE INDEX idx_group_member_permissions_team_uuid ON group_member_permissions(team_uuid); + +-- 创建更新时间触发器 +CREATE TRIGGER update_group_member_permissions_updated_at BEFORE UPDATE ON group_member_permissions + FOR EACH ROW EXECUTE FUNCTION update_updated_at_column(); + +-- 列注释 +COMMENT ON TABLE group_member_permissions IS '分组权限表,控制团队成员对分组的访问权限'; +COMMENT ON COLUMN group_member_permissions.group_uuid IS '分组 UUID'; +COMMENT ON COLUMN group_member_permissions.workspace_uuid IS '工作空间 UUID(冗余,便于查询)'; +COMMENT ON COLUMN group_member_permissions.team_uuid IS '团队 UUID(冗余,便于查询)'; +COMMENT ON COLUMN group_member_permissions.user_uuid IS '用户 UUID'; +COMMENT ON COLUMN group_member_permissions.permission_type IS '权限类型:read/write/manage'; +COMMENT ON COLUMN group_member_permissions.granted_by IS '授权者 UUID'; +COMMENT ON COLUMN group_member_permissions.created_at IS '创建时间'; +COMMENT ON COLUMN group_member_permissions.updated_at IS '更新时间'; + diff --git a/src-tauri/crates/business/migrations/20250125000005_alter_teams_add_workspace.sql b/src-tauri/crates/business/migrations/20250125000005_alter_teams_add_workspace.sql new file mode 100644 index 0000000..7c06755 --- /dev/null +++ b/src-tauri/crates/business/migrations/20250125000005_alter_teams_add_workspace.sql @@ -0,0 +1,19 @@ +-- 修改 teams 表,添加工作空间支持 +-- 添加 workspace_uuid,移除配额相关字段和默认代理 + +-- 添加 workspace_uuid 列(先允许 NULL,数据迁移后再设置为 NOT NULL) +ALTER TABLE teams ADD COLUMN IF NOT EXISTS workspace_uuid UUID; + +-- 创建索引 +CREATE INDEX IF NOT EXISTS idx_teams_workspace_uuid ON teams(workspace_uuid); + +-- 移除配额相关字段(配额移至 workspace_quotas) +ALTER TABLE teams DROP COLUMN IF EXISTS max_members; +ALTER TABLE teams DROP COLUMN IF EXISTS max_environments; +ALTER TABLE teams DROP COLUMN IF EXISTS max_proxies; + +-- 移除默认代理字段(不再需要默认代理) +ALTER TABLE teams DROP COLUMN IF EXISTS default_proxy_uuid; + +-- 注意:workspace_uuid 的外键约束和数据填充将在数据迁移脚本中完成 + diff --git a/src-tauri/crates/business/migrations/20250125000006_alter_team_members_add_workspace.sql b/src-tauri/crates/business/migrations/20250125000006_alter_team_members_add_workspace.sql new file mode 100644 index 0000000..ea5c086 --- /dev/null +++ b/src-tauri/crates/business/migrations/20250125000006_alter_team_members_add_workspace.sql @@ -0,0 +1,21 @@ +-- 修改 team_members 表,添加工作空间支持 +-- 添加 workspace_uuid(冗余字段),移除统计字段 + +-- 添加 workspace_uuid 列(先允许 NULL,数据迁移后再设置为 NOT NULL) +ALTER TABLE team_members ADD COLUMN IF NOT EXISTS workspace_uuid UUID; + +-- 创建索引 +CREATE INDEX IF NOT EXISTS idx_team_members_workspace_uuid ON team_members(workspace_uuid); + +-- 移除统计字段(可通过查询计算) +ALTER TABLE team_members DROP COLUMN IF EXISTS environment_count; +ALTER TABLE team_members DROP COLUMN IF EXISTS group_count; + +-- 删除旧的唯一约束 +ALTER TABLE team_members DROP CONSTRAINT IF EXISTS uk_team_members; + +-- 添加新的唯一约束(包含 workspace_uuid) +ALTER TABLE team_members ADD CONSTRAINT uk_team_members UNIQUE (team_uuid, user_uuid, workspace_uuid); + +-- 注意:workspace_uuid 的外键约束和数据填充将在数据迁移脚本中完成 + diff --git a/src-tauri/crates/business/migrations/20250125000007_alter_groups_add_workspace.sql b/src-tauri/crates/business/migrations/20250125000007_alter_groups_add_workspace.sql new file mode 100644 index 0000000..96e83bd --- /dev/null +++ b/src-tauri/crates/business/migrations/20250125000007_alter_groups_add_workspace.sql @@ -0,0 +1,25 @@ +-- 修改 groups 表,添加工作空间支持 +-- 添加 workspace_uuid,移除 user_uuid, default_proxy_uuid, color,确保 team_uuid NOT NULL + +-- 添加 workspace_uuid 列(先允许 NULL,数据迁移后再设置为 NOT NULL) +ALTER TABLE groups ADD COLUMN IF NOT EXISTS workspace_uuid UUID; + +-- 创建索引 +CREATE INDEX IF NOT EXISTS idx_groups_workspace_uuid ON groups(workspace_uuid); + +-- 移除 user_uuid(分组属于团队,不再直接属于用户) +ALTER TABLE groups DROP COLUMN IF EXISTS user_uuid; + +-- 移除默认代理字段(不再需要默认代理) +ALTER TABLE groups DROP COLUMN IF EXISTS default_proxy_uuid; + +-- 移除 color 字段(简化设计) +ALTER TABLE groups DROP COLUMN IF EXISTS color; + +-- 删除旧的索引 +DROP INDEX IF EXISTS idx_groups_user_uuid; + +-- 注意: +-- 1. workspace_uuid 的外键约束和数据填充将在数据迁移脚本中完成 +-- 2. team_uuid 的 NOT NULL 约束将在数据迁移后添加(确保所有数据都有 team_uuid) + diff --git a/src-tauri/crates/business/migrations/20250125000008_alter_environments_add_workspace.sql b/src-tauri/crates/business/migrations/20250125000008_alter_environments_add_workspace.sql new file mode 100644 index 0000000..92819d7 --- /dev/null +++ b/src-tauri/crates/business/migrations/20250125000008_alter_environments_add_workspace.sql @@ -0,0 +1,13 @@ +-- 修改 environments 表,添加工作空间支持 +-- 添加 workspace_uuid,确保 team_uuid NOT NULL + +-- 添加 workspace_uuid 列(先允许 NULL,数据迁移后再设置为 NOT NULL) +ALTER TABLE environments ADD COLUMN IF NOT EXISTS workspace_uuid UUID; + +-- 创建索引 +CREATE INDEX IF NOT EXISTS idx_environments_workspace_uuid ON environments(workspace_uuid); + +-- 注意: +-- 1. workspace_uuid 的外键约束和数据填充将在数据迁移脚本中完成 +-- 2. team_uuid 的 NOT NULL 约束将在数据迁移后添加(确保所有数据都有 team_uuid) + diff --git a/src-tauri/crates/business/migrations/20250125000009_alter_proxies_add_workspace.sql b/src-tauri/crates/business/migrations/20250125000009_alter_proxies_add_workspace.sql new file mode 100644 index 0000000..4fcedf0 --- /dev/null +++ b/src-tauri/crates/business/migrations/20250125000009_alter_proxies_add_workspace.sql @@ -0,0 +1,29 @@ +-- 修改 proxies 表,添加工作空间支持 +-- 添加 workspace_uuid 和 owner_uuid(重命名自 user_uuid),移除 team_uuid, usage_count + +-- 添加 workspace_uuid 列(先允许 NULL,数据迁移后再设置为 NOT NULL) +ALTER TABLE proxies ADD COLUMN IF NOT EXISTS workspace_uuid UUID; + +-- 添加 owner_uuid 列(先允许 NULL,数据迁移后再设置为 NOT NULL) +ALTER TABLE proxies ADD COLUMN IF NOT EXISTS owner_uuid UUID; + +-- 创建索引 +CREATE INDEX IF NOT EXISTS idx_proxies_workspace_uuid ON proxies(workspace_uuid); +CREATE INDEX IF NOT EXISTS idx_proxies_owner_uuid ON proxies(owner_uuid); + +-- 将 user_uuid 的数据复制到 owner_uuid(如果 owner_uuid 为空) +UPDATE proxies SET owner_uuid = user_uuid WHERE owner_uuid IS NULL; + +-- 移除 team_uuid(代理属于工作空间,不属于团队) +ALTER TABLE proxies DROP COLUMN IF EXISTS team_uuid; + +-- 移除 usage_count(可通过查询计算) +ALTER TABLE proxies DROP COLUMN IF EXISTS usage_count; + +-- 删除旧的索引 +DROP INDEX IF EXISTS idx_proxies_team_uuid; + +-- 注意: +-- 1. workspace_uuid 和 owner_uuid 的外键约束将在数据迁移脚本中完成 +-- 2. user_uuid 列将在数据迁移后删除(迁移到 owner_uuid) + diff --git a/src-tauri/crates/business/migrations/20250125000010_alter_subscriptions_add_workspace.sql b/src-tauri/crates/business/migrations/20250125000010_alter_subscriptions_add_workspace.sql new file mode 100644 index 0000000..eb6a47d --- /dev/null +++ b/src-tauri/crates/business/migrations/20250125000010_alter_subscriptions_add_workspace.sql @@ -0,0 +1,13 @@ +-- 修改 subscriptions 表,添加工作空间支持 +-- 添加 workspace_uuid,保留 user_uuid(用于记录订阅者) + +-- 添加 workspace_uuid 列(先允许 NULL,数据迁移后再设置为 NOT NULL) +ALTER TABLE subscriptions ADD COLUMN IF NOT EXISTS workspace_uuid UUID; + +-- 创建索引 +CREATE INDEX IF NOT EXISTS idx_subscriptions_workspace_uuid ON subscriptions(workspace_uuid); + +-- 注意: +-- 1. workspace_uuid 的外键约束和数据填充将在数据迁移脚本中完成 +-- 2. 唯一约束(确保一个工作空间只有一个活跃订阅)将在数据迁移后添加 + diff --git a/src-tauri/crates/business/migrations/20250125000011_deprecate_user_quotas.sql b/src-tauri/crates/business/migrations/20250125000011_deprecate_user_quotas.sql new file mode 100644 index 0000000..294fc7a --- /dev/null +++ b/src-tauri/crates/business/migrations/20250125000011_deprecate_user_quotas.sql @@ -0,0 +1,8 @@ +-- 废弃 user_quotas 表 +-- 保留表结构,但标记为废弃,数据将迁移到 workspace_quotas + +-- 添加注释标记为废弃 +COMMENT ON TABLE user_quotas IS '已废弃:配额已迁移到 workspace_quotas 表,此表保留仅用于历史数据查询'; + +-- 注意:表结构保持不变,数据迁移将在数据迁移脚本中完成 + diff --git a/src-tauri/crates/business/migrations/20250125000012_migrate_to_workspaces.sql b/src-tauri/crates/business/migrations/20250125000012_migrate_to_workspaces.sql new file mode 100644 index 0000000..3140a0c --- /dev/null +++ b/src-tauri/crates/business/migrations/20250125000012_migrate_to_workspaces.sql @@ -0,0 +1,416 @@ +-- 数据迁移脚本:将现有数据迁移到工作空间架构 +-- 此脚本将: +-- 1. 为每个用户创建默认工作空间(personal 类型) +-- 2. 为每个现有团队创建对应的工作空间(team 类型) +-- 3. 迁移配额数据从 user_quotas 到 workspace_quotas +-- 4. 更新所有相关表的外键关系 + +BEGIN; + +-- ============================================ +-- 步骤 1: 为每个用户创建默认工作空间 +-- ============================================ +INSERT INTO workspaces (uuid, name, owner_uuid, workspace_type, created_at, updated_at) +SELECT + gen_random_uuid(), + COALESCE(ui.nickname, ui.email, '我的工作空间') || ' 的工作空间', + u.uuid, + 'personal', + u.created_at, + u.updated_at +FROM users u +LEFT JOIN user_infos ui ON u.uuid = ui.user_uuid +WHERE u.deleted_at IS NULL + AND NOT EXISTS ( + SELECT 1 FROM workspaces w + WHERE w.owner_uuid = u.uuid + AND w.workspace_type = 'personal' + AND w.deleted_at IS NULL + ); + +-- ============================================ +-- 步骤 2: 为每个现有团队创建对应的工作空间 +-- ============================================ +INSERT INTO workspaces (uuid, name, owner_uuid, workspace_type, created_at, updated_at) +SELECT + gen_random_uuid(), + t.name || ' 的工作空间', + t.owner_uuid, + 'team', + t.created_at, + t.updated_at +FROM teams t +WHERE t.deleted_at IS NULL + AND NOT EXISTS ( + SELECT 1 FROM workspaces w + WHERE w.owner_uuid = t.owner_uuid + AND w.name = t.name || ' 的工作空间' + AND w.deleted_at IS NULL + ); + +-- ============================================ +-- 步骤 3: 更新 teams 表的 workspace_uuid +-- ============================================ +-- 为每个团队分配工作空间(优先使用团队对应的工作空间,如果没有则使用团队所有者的个人工作空间) +UPDATE teams t +SET workspace_uuid = COALESCE( + (SELECT w.uuid FROM workspaces w + WHERE w.owner_uuid = t.owner_uuid + AND w.workspace_type = 'team' + AND w.name = t.name || ' 的工作空间' + AND w.deleted_at IS NULL + LIMIT 1), + (SELECT w.uuid FROM workspaces w + WHERE w.owner_uuid = t.owner_uuid + AND w.workspace_type = 'personal' + AND w.deleted_at IS NULL + LIMIT 1) +) +WHERE t.workspace_uuid IS NULL + AND t.deleted_at IS NULL; + +-- 设置 workspace_uuid 为 NOT NULL +ALTER TABLE teams ALTER COLUMN workspace_uuid SET NOT NULL; + +-- 添加外键约束 +ALTER TABLE teams +ADD CONSTRAINT fk_teams_workspace +FOREIGN KEY (workspace_uuid) REFERENCES workspaces(uuid) ON DELETE CASCADE; + +-- ============================================ +-- 步骤 4: 更新 team_members 表的 workspace_uuid +-- ============================================ +UPDATE team_members tm +SET workspace_uuid = t.workspace_uuid +FROM teams t +WHERE tm.team_uuid = t.uuid + AND tm.workspace_uuid IS NULL + AND tm.deleted_at IS NULL; + +-- 设置 workspace_uuid 为 NOT NULL +ALTER TABLE team_members ALTER COLUMN workspace_uuid SET NOT NULL; + +-- 添加外键约束 +ALTER TABLE team_members +ADD CONSTRAINT fk_team_members_workspace +FOREIGN KEY (workspace_uuid) REFERENCES workspaces(uuid) ON DELETE CASCADE; + +-- ============================================ +-- 步骤 5: 更新 groups 表的 workspace_uuid +-- ============================================ +-- 首先更新有 team_uuid 的分组 +UPDATE groups g +SET workspace_uuid = t.workspace_uuid +FROM teams t +WHERE g.team_uuid = t.uuid + AND g.workspace_uuid IS NULL + AND g.deleted_at IS NULL; + +-- 对于没有 team_uuid 的分组,先分配一个默认团队 +UPDATE groups g +SET team_uuid = ( + SELECT t.uuid FROM teams t + INNER JOIN workspaces w ON t.workspace_uuid = w.uuid + WHERE w.owner_uuid = ( + -- 尝试从 environments 表找到关联的用户 + SELECT e.user_uuid FROM environments e + WHERE e.group_uuid = g.uuid + AND e.deleted_at IS NULL + LIMIT 1 + ) + AND t.deleted_at IS NULL + LIMIT 1 +) +WHERE g.team_uuid IS NULL + AND g.deleted_at IS NULL; + +-- 再次更新 workspace_uuid(包括刚刚分配了 team_uuid 的分组) +UPDATE groups g +SET workspace_uuid = t.workspace_uuid +FROM teams t +WHERE g.team_uuid = t.uuid + AND g.workspace_uuid IS NULL + AND g.deleted_at IS NULL; + +-- 对于仍然没有 workspace_uuid 的分组,使用用户的个人工作空间 +UPDATE groups g +SET workspace_uuid = ( + SELECT w.uuid FROM workspaces w + WHERE w.owner_uuid = ( + SELECT e.user_uuid FROM environments e + WHERE e.group_uuid = g.uuid + AND e.deleted_at IS NULL + LIMIT 1 + ) + AND w.workspace_type = 'personal' + AND w.deleted_at IS NULL + LIMIT 1 +) +WHERE g.workspace_uuid IS NULL + AND g.deleted_at IS NULL; + +-- 如果还有 NULL 值,使用第一个可用的工作空间(兜底方案) +UPDATE groups g +SET workspace_uuid = ( + SELECT w.uuid FROM workspaces w + WHERE w.deleted_at IS NULL + ORDER BY w.created_at + LIMIT 1 +) +WHERE g.workspace_uuid IS NULL + AND g.deleted_at IS NULL; + +-- 验证:确保所有分组都有 workspace_uuid +DO $$ +DECLARE + null_count INTEGER; +BEGIN + SELECT COUNT(*) INTO null_count + FROM groups + WHERE workspace_uuid IS NULL + AND deleted_at IS NULL; + + IF null_count > 0 THEN + RAISE EXCEPTION '仍有 % 个分组的 workspace_uuid 为 NULL,请手动处理', null_count; + END IF; +END $$; + +-- 设置 workspace_uuid 为 NOT NULL +ALTER TABLE groups ALTER COLUMN workspace_uuid SET NOT NULL; + +-- 确保 team_uuid 为 NOT NULL(删除没有 team_uuid 的分组或分配默认团队) +-- 注意:这里假设所有分组都应该有 team_uuid,如果没有则可能需要手动处理 +UPDATE groups g +SET team_uuid = ( + SELECT t.uuid FROM teams t + INNER JOIN workspaces w ON t.workspace_uuid = w.uuid + WHERE w.owner_uuid = ( + SELECT owner_uuid FROM workspaces WHERE uuid = g.workspace_uuid + ) + AND t.deleted_at IS NULL + LIMIT 1 +) +WHERE g.team_uuid IS NULL +AND g.deleted_at IS NULL; + +-- 设置 team_uuid 为 NOT NULL(如果还有 NULL 值,可能需要手动处理) +-- ALTER TABLE groups ALTER COLUMN team_uuid SET NOT NULL; + +-- 添加外键约束 +ALTER TABLE groups +ADD CONSTRAINT fk_groups_workspace +FOREIGN KEY (workspace_uuid) REFERENCES workspaces(uuid) ON DELETE CASCADE; + +-- ============================================ +-- 步骤 6: 更新 environments 表的 workspace_uuid +-- ============================================ +UPDATE environments e +SET workspace_uuid = t.workspace_uuid +FROM teams t +WHERE e.team_uuid = t.uuid + AND e.workspace_uuid IS NULL + AND e.deleted_at IS NULL; + +-- 对于没有 team_uuid 的环境,使用用户的个人工作空间 +UPDATE environments e +SET workspace_uuid = ( + SELECT w.uuid FROM workspaces w + WHERE w.owner_uuid = e.user_uuid + AND w.workspace_type = 'personal' + AND w.deleted_at IS NULL + LIMIT 1 +) +WHERE e.workspace_uuid IS NULL + AND e.deleted_at IS NULL; + +-- 设置 workspace_uuid 为 NOT NULL +ALTER TABLE environments ALTER COLUMN workspace_uuid SET NOT NULL; + +-- 确保 team_uuid 为 NOT NULL(为没有 team_uuid 的环境分配默认团队) +UPDATE environments e +SET team_uuid = ( + SELECT t.uuid FROM teams t + INNER JOIN workspaces w ON t.workspace_uuid = w.uuid + WHERE w.uuid = e.workspace_uuid + AND t.deleted_at IS NULL + LIMIT 1 +) +WHERE e.team_uuid IS NULL + AND e.deleted_at IS NULL; + +-- 设置 team_uuid 为 NOT NULL(如果还有 NULL 值,可能需要手动处理) +-- ALTER TABLE environments ALTER COLUMN team_uuid SET NOT NULL; + +-- 添加外键约束 +ALTER TABLE environments +ADD CONSTRAINT fk_environments_workspace +FOREIGN KEY (workspace_uuid) REFERENCES workspaces(uuid) ON DELETE CASCADE; + +-- ============================================ +-- 步骤 7: 更新 proxies 表的 workspace_uuid 和 owner_uuid +-- ============================================ +-- 确保 owner_uuid 已填充(从 user_uuid 复制) +UPDATE proxies p +SET owner_uuid = p.user_uuid +WHERE p.owner_uuid IS NULL + AND p.deleted_at IS NULL; + +-- 为代理分配工作空间(优先使用团队的工作空间,如果没有则使用用户的个人工作空间) +UPDATE proxies p +SET workspace_uuid = COALESCE( + (SELECT t.workspace_uuid FROM teams t + WHERE t.uuid = ( + SELECT e.team_uuid FROM environments e + WHERE e.proxy_uuid = p.uuid + AND e.deleted_at IS NULL + LIMIT 1 + ) + AND t.deleted_at IS NULL + LIMIT 1), + (SELECT w.uuid FROM workspaces w + WHERE w.owner_uuid = p.owner_uuid + AND w.workspace_type = 'personal' + AND w.deleted_at IS NULL + LIMIT 1) +) +WHERE p.workspace_uuid IS NULL + AND p.deleted_at IS NULL; + +-- 设置 workspace_uuid 和 owner_uuid 为 NOT NULL +ALTER TABLE proxies ALTER COLUMN workspace_uuid SET NOT NULL; +ALTER TABLE proxies ALTER COLUMN owner_uuid SET NOT NULL; + +-- 添加外键约束 +ALTER TABLE proxies +ADD CONSTRAINT fk_proxies_workspace +FOREIGN KEY (workspace_uuid) REFERENCES workspaces(uuid) ON DELETE CASCADE; + +ALTER TABLE proxies +ADD CONSTRAINT fk_proxies_owner +FOREIGN KEY (owner_uuid) REFERENCES users(uuid) ON DELETE CASCADE; + +-- 删除旧的 user_uuid 列(如果存在) +ALTER TABLE proxies DROP COLUMN IF EXISTS user_uuid; + +-- ============================================ +-- 步骤 8: 更新 subscriptions 表的 workspace_uuid +-- ============================================ +-- 为订阅分配工作空间(使用订阅者的个人工作空间) +UPDATE subscriptions s +SET workspace_uuid = ( + SELECT w.uuid FROM workspaces w + WHERE w.owner_uuid = s.user_uuid + AND w.workspace_type = 'personal' + AND w.deleted_at IS NULL + LIMIT 1 +) +WHERE s.workspace_uuid IS NULL; + +-- 设置 workspace_uuid 为 NOT NULL +ALTER TABLE subscriptions ALTER COLUMN workspace_uuid SET NOT NULL; + +-- 添加外键约束 +ALTER TABLE subscriptions +ADD CONSTRAINT fk_subscriptions_workspace +FOREIGN KEY (workspace_uuid) REFERENCES workspaces(uuid) ON DELETE CASCADE; + +-- ============================================ +-- 步骤 9: 迁移配额数据从 user_quotas 到 workspace_quotas +-- ============================================ +INSERT INTO workspace_quotas ( + workspace_uuid, + max_environments, + used_environments, + max_team_members, + used_team_members, + max_proxies, + used_proxies, + max_rpa_tasks, + used_rpa_tasks, + created_at, + updated_at +) +SELECT + w.uuid, + COALESCE(uq.max_environments, 10), + COALESCE(uq.used_environments, 0), + COALESCE(uq.max_team_members, 5), + 0, -- used_team_members: user_quotas 表中没有此字段,使用默认值 0 + COALESCE(uq.max_proxies, 10), + COALESCE(uq.used_proxies, 0), + COALESCE(uq.max_rpa_tasks, 5), + COALESCE(uq.used_rpa_tasks, 0), + COALESCE(uq.created_at, CURRENT_TIMESTAMP), + COALESCE(uq.updated_at, CURRENT_TIMESTAMP) +FROM workspaces w +LEFT JOIN user_quotas uq ON w.owner_uuid = uq.user_uuid +WHERE w.deleted_at IS NULL + AND NOT EXISTS ( + SELECT 1 FROM workspace_quotas wq + WHERE wq.workspace_uuid = w.uuid + ); + +-- ============================================ +-- 步骤 10: 更新配额使用情况统计 +-- ============================================ +-- 更新环境使用数 +UPDATE workspace_quotas wq +SET used_environments = ( + SELECT COUNT(*) FROM environments e + WHERE e.workspace_uuid = wq.workspace_uuid + AND e.deleted_at IS NULL +); + +-- 更新代理使用数 +UPDATE workspace_quotas wq +SET used_proxies = ( + SELECT COUNT(*) FROM proxies p + WHERE p.workspace_uuid = wq.workspace_uuid + AND p.deleted_at IS NULL +); + +-- 更新团队成员使用数(所有团队总和) +UPDATE workspace_quotas wq +SET used_team_members = ( + SELECT COUNT(DISTINCT tm.user_uuid) FROM team_members tm + INNER JOIN teams t ON tm.team_uuid = t.uuid + WHERE t.workspace_uuid = wq.workspace_uuid + AND tm.deleted_at IS NULL + AND t.deleted_at IS NULL +); + +-- 更新 RPA 任务使用数(通过 team_uuid 关联到工作空间) +UPDATE workspace_quotas wq +SET used_rpa_tasks = ( + SELECT COUNT(*) FROM rpa_tasks rt + INNER JOIN teams t ON rt.team_uuid = t.uuid + WHERE t.workspace_uuid = wq.workspace_uuid + AND rt.deleted_at IS NULL + AND t.deleted_at IS NULL +) +WHERE EXISTS ( + SELECT 1 FROM information_schema.tables + WHERE table_name = 'rpa_tasks' +); + +COMMIT; + +-- 迁移完成后的验证查询(可选,用于检查迁移结果) +-- SELECT +-- 'workspaces' as table_name, +-- COUNT(*) as total_count, +-- COUNT(*) FILTER (WHERE deleted_at IS NULL) as active_count +-- FROM workspaces +-- UNION ALL +-- SELECT +-- 'teams', +-- COUNT(*), +-- COUNT(*) FILTER (WHERE deleted_at IS NULL) +-- FROM teams +-- UNION ALL +-- SELECT +-- 'workspace_quotas', +-- COUNT(*), +-- COUNT(*) +-- FROM workspace_quotas; + diff --git a/src-tauri/crates/business/migrations/20250125000013_alter_user_infos_add_current_workspace.sql b/src-tauri/crates/business/migrations/20250125000013_alter_user_infos_add_current_workspace.sql new file mode 100644 index 0000000..9ccc763 --- /dev/null +++ b/src-tauri/crates/business/migrations/20250125000013_alter_user_infos_add_current_workspace.sql @@ -0,0 +1,44 @@ +-- 修改 user_infos 表,添加当前工作空间字段 +-- 用于工作空间切换功能 + +ALTER TABLE user_infos +ADD COLUMN IF NOT EXISTS current_workspace_uuid UUID; + +-- 添加外键约束(如果不存在) +DO $$ +BEGIN + IF NOT EXISTS ( + SELECT 1 FROM information_schema.table_constraints + WHERE constraint_name = 'fk_user_infos_current_workspace' + ) THEN + ALTER TABLE user_infos + ADD CONSTRAINT fk_user_infos_current_workspace + FOREIGN KEY (current_workspace_uuid) REFERENCES workspaces(uuid) ON DELETE SET NULL; + END IF; +END $$; + +-- 创建索引 +CREATE INDEX IF NOT EXISTS idx_user_infos_current_workspace ON user_infos(current_workspace_uuid); + +-- 从当前团队的工作空间初始化 current_workspace_uuid +UPDATE user_infos ui +SET current_workspace_uuid = ( + SELECT t.workspace_uuid FROM teams t + WHERE t.uuid = ui.current_team_uuid + AND t.deleted_at IS NULL + LIMIT 1 +) +WHERE ui.current_team_uuid IS NOT NULL + AND ui.current_workspace_uuid IS NULL; + +-- 对于没有当前团队的用户,使用其个人工作空间 +UPDATE user_infos ui +SET current_workspace_uuid = ( + SELECT w.uuid FROM workspaces w + WHERE w.owner_uuid = ui.user_uuid + AND w.workspace_type = 'personal' + AND w.deleted_at IS NULL + LIMIT 1 +) +WHERE ui.current_workspace_uuid IS NULL; + diff --git a/src-tauri/crates/business/migrations/20250126000001_insert_default_plans_with_features.sql b/src-tauri/crates/business/migrations/20250126000001_insert_default_plans_with_features.sql new file mode 100644 index 0000000..e2366bb --- /dev/null +++ b/src-tauri/crates/business/migrations/20250126000001_insert_default_plans_with_features.sql @@ -0,0 +1,432 @@ +-- 插入默认套餐数据和特性 +-- 删除现有套餐数据(包括关联的特性数据,因为有外键约束会自动删除) + +-- 1. 先删除 invoices 表中引用 subscriptions 的数据 +-- 由于 invoices 表有外键约束引用 subscriptions,需要先删除发票数据 +-- 注意:这会删除所有现有发票记录,请确保这是预期的行为 +DELETE FROM invoices; + +-- 2. 删除 subscriptions 表中引用 plans 的数据 +-- 由于 subscriptions 表有外键约束(没有 ON DELETE CASCADE),需要先删除订阅数据 +-- 注意:这会删除所有现有订阅记录,请确保这是预期的行为 +DELETE FROM subscriptions; + +-- 3. 删除现有套餐数据(会级联删除 plan_features 数据) +DELETE FROM plans; + +-- 4. 插入新的套餐数据 +-- 套餐:免费套餐、基础套餐、专业套餐、商业套餐、企业套餐 +-- 月付价格:0, 21.4, 11.4, 758.4, 3558.4 +-- 年付价格:按月付*10计算(相当于2个月免费) +-- 最大环境:8, 100, 1000, 10000, 100000 +-- 最大团队:1, 5, 10, 18, 30 +-- 代理数量:所有都是99999 +-- max_rpa_tasks:所有都是99999 + +INSERT INTO plans (uuid, name, description, price_per_month, price_per_year, currency, max_environments, max_team_members, max_proxies, max_rpa_tasks, is_recommended, sort_order, status) +VALUES + -- 免费套餐:月付 0,年付 0,环境 8,团队 1 + (gen_random_uuid(), '免费套餐', '适合个人试用,体验基础功能', 0.00, 0.00, 'USD', 8, 1, 99999, 99999, FALSE, 1, 'active'), + -- 基础套餐:月付 21.4,年付 214.00,环境 100,团队 5 + (gen_random_uuid(), '基础套餐', '适合个人用户和小型项目', 21.40, 214.00, 'USD', 100, 5, 99999, 99999, FALSE, 2, 'active'), + -- 专业套餐:月付 11.4,年付 114.00,环境 1000,团队 10 + (gen_random_uuid(), '专业套餐', '适合小型团队和成长型企业', 108.40, 1084.00, 'USD', 1000, 10, 99999, 99999, TRUE, 3, 'active'), + -- 商业套餐:月付 758.4,年付 7584.00,环境 10000,团队 18 + (gen_random_uuid(), '商业套餐', '适合中大型企业和专业团队', 758.40, 7584.00, 'USD', 10000, 18, 99999, 99999, FALSE, 4, 'active'), + -- 企业套餐:月付 3558.4,年付 35584.00,环境 100000,团队 30 + (gen_random_uuid(), '企业套餐', '适合大型企业,提供最高级别的服务和支持', 3558.40, 35584.00, 'USD', 100000, 30, 99999, 99999, FALSE, 5, 'active') +ON CONFLICT DO NOTHING; + +-- 5. 为每个套餐插入特性数据 +-- 使用 CTE 来获取套餐 UUID,然后插入特性 + +-- 免费套餐特性 +INSERT INTO plan_features (plan_uuid, feature_key, feature_name, feature_value, is_included, sort_order) +SELECT + p.uuid, + 'env_limit', + '环境数量', + '8 个环境', + TRUE, + 1 +FROM plans p WHERE p.name = '免费套餐' +ON CONFLICT (plan_uuid, feature_key) DO NOTHING; + +INSERT INTO plan_features (plan_uuid, feature_key, feature_name, feature_value, is_included, sort_order) +SELECT + p.uuid, + 'team_members', + '团队成员', + '1 人', + TRUE, + 2 +FROM plans p WHERE p.name = '免费套餐' +ON CONFLICT (plan_uuid, feature_key) DO NOTHING; + +INSERT INTO plan_features (plan_uuid, feature_key, feature_name, feature_value, is_included, sort_order) +SELECT + p.uuid, + 'basic_support', + '基础支持', + '社区支持', + TRUE, + 3 +FROM plans p WHERE p.name = '免费套餐' +ON CONFLICT (plan_uuid, feature_key) DO NOTHING; + +-- 基础套餐特性 +INSERT INTO plan_features (plan_uuid, feature_key, feature_name, feature_value, is_included, sort_order) +SELECT + p.uuid, + 'env_limit', + '环境数量', + '100 个环境', + TRUE, + 1 +FROM plans p WHERE p.name = '基础套餐' +ON CONFLICT (plan_uuid, feature_key) DO NOTHING; + +INSERT INTO plan_features (plan_uuid, feature_key, feature_name, feature_value, is_included, sort_order) +SELECT + p.uuid, + 'team_members', + '团队成员', + '5 人', + TRUE, + 2 +FROM plans p WHERE p.name = '基础套餐' +ON CONFLICT (plan_uuid, feature_key) DO NOTHING; + +INSERT INTO plan_features (plan_uuid, feature_key, feature_name, feature_value, is_included, sort_order) +SELECT + p.uuid, + 'proxy_access', + '代理访问', + '99999 个代理', + TRUE, + 3 +FROM plans p WHERE p.name = '基础套餐' +ON CONFLICT (plan_uuid, feature_key) DO NOTHING; + +INSERT INTO plan_features (plan_uuid, feature_key, feature_name, feature_value, is_included, sort_order) +SELECT + p.uuid, + 'rpa_tasks', + 'RPA 任务', + '99999 个任务', + TRUE, + 4 +FROM plans p WHERE p.name = '基础套餐' +ON CONFLICT (plan_uuid, feature_key) DO NOTHING; + +INSERT INTO plan_features (plan_uuid, feature_key, feature_name, feature_value, is_included, sort_order) +SELECT + p.uuid, + 'email_support', + '邮件支持', + '工作日支持', + TRUE, + 5 +FROM plans p WHERE p.name = '基础套餐' +ON CONFLICT (plan_uuid, feature_key) DO NOTHING; + +-- 专业套餐特性(包含基础套餐的所有特性,并增加更多) +INSERT INTO plan_features (plan_uuid, feature_key, feature_name, feature_value, is_included, sort_order) +SELECT + p.uuid, + 'env_limit', + '环境数量', + '1000 个环境', + TRUE, + 1 +FROM plans p WHERE p.name = '专业套餐' +ON CONFLICT (plan_uuid, feature_key) DO NOTHING; + +INSERT INTO plan_features (plan_uuid, feature_key, feature_name, feature_value, is_included, sort_order) +SELECT + p.uuid, + 'team_members', + '团队成员', + '10 人', + TRUE, + 2 +FROM plans p WHERE p.name = '专业套餐' +ON CONFLICT (plan_uuid, feature_key) DO NOTHING; + +INSERT INTO plan_features (plan_uuid, feature_key, feature_name, feature_value, is_included, sort_order) +SELECT + p.uuid, + 'proxy_access', + '代理访问', + '99999 个代理', + TRUE, + 3 +FROM plans p WHERE p.name = '专业套餐' +ON CONFLICT (plan_uuid, feature_key) DO NOTHING; + +INSERT INTO plan_features (plan_uuid, feature_key, feature_name, feature_value, is_included, sort_order) +SELECT + p.uuid, + 'rpa_tasks', + 'RPA 任务', + '99999 个任务', + TRUE, + 4 +FROM plans p WHERE p.name = '专业套餐' +ON CONFLICT (plan_uuid, feature_key) DO NOTHING; + +INSERT INTO plan_features (plan_uuid, feature_key, feature_name, feature_value, is_included, sort_order) +SELECT + p.uuid, + 'priority_support', + '优先支持', + '工作日优先响应', + TRUE, + 5 +FROM plans p WHERE p.name = '专业套餐' +ON CONFLICT (plan_uuid, feature_key) DO NOTHING; + +INSERT INTO plan_features (plan_uuid, feature_key, feature_name, feature_value, is_included, sort_order) +SELECT + p.uuid, + 'api_access', + 'API 访问', + '完整 API 权限', + TRUE, + 6 +FROM plans p WHERE p.name = '专业套餐' +ON CONFLICT (plan_uuid, feature_key) DO NOTHING; + +INSERT INTO plan_features (plan_uuid, feature_key, feature_name, feature_value, is_included, sort_order) +SELECT + p.uuid, + 'advanced_features', + '高级功能', + '指纹管理、Cookie 同步等', + TRUE, + 7 +FROM plans p WHERE p.name = '专业套餐' +ON CONFLICT (plan_uuid, feature_key) DO NOTHING; + +-- 商业套餐特性(包含专业套餐的所有特性,并增加更多) +INSERT INTO plan_features (plan_uuid, feature_key, feature_name, feature_value, is_included, sort_order) +SELECT + p.uuid, + 'env_limit', + '环境数量', + '10000 个环境', + TRUE, + 1 +FROM plans p WHERE p.name = '商业套餐' +ON CONFLICT (plan_uuid, feature_key) DO NOTHING; + +INSERT INTO plan_features (plan_uuid, feature_key, feature_name, feature_value, is_included, sort_order) +SELECT + p.uuid, + 'team_members', + '团队成员', + '18 人', + TRUE, + 2 +FROM plans p WHERE p.name = '商业套餐' +ON CONFLICT (plan_uuid, feature_key) DO NOTHING; + +INSERT INTO plan_features (plan_uuid, feature_key, feature_name, feature_value, is_included, sort_order) +SELECT + p.uuid, + 'proxy_access', + '代理访问', + '99999 个代理', + TRUE, + 3 +FROM plans p WHERE p.name = '商业套餐' +ON CONFLICT (plan_uuid, feature_key) DO NOTHING; + +INSERT INTO plan_features (plan_uuid, feature_key, feature_name, feature_value, is_included, sort_order) +SELECT + p.uuid, + 'rpa_tasks', + 'RPA 任务', + '99999 个任务', + TRUE, + 4 +FROM plans p WHERE p.name = '商业套餐' +ON CONFLICT (plan_uuid, feature_key) DO NOTHING; + +INSERT INTO plan_features (plan_uuid, feature_key, feature_name, feature_value, is_included, sort_order) +SELECT + p.uuid, + 'dedicated_support', + '专属支持', + '7x24 小时专属支持', + TRUE, + 5 +FROM plans p WHERE p.name = '商业套餐' +ON CONFLICT (plan_uuid, feature_key) DO NOTHING; + +INSERT INTO plan_features (plan_uuid, feature_key, feature_name, feature_value, is_included, sort_order) +SELECT + p.uuid, + 'api_access', + 'API 访问', + '完整 API 权限 + 高级 API', + TRUE, + 6 +FROM plans p WHERE p.name = '商业套餐' +ON CONFLICT (plan_uuid, feature_key) DO NOTHING; + +INSERT INTO plan_features (plan_uuid, feature_key, feature_name, feature_value, is_included, sort_order) +SELECT + p.uuid, + 'advanced_features', + '高级功能', + '所有高级功能', + TRUE, + 7 +FROM plans p WHERE p.name = '商业套餐' +ON CONFLICT (plan_uuid, feature_key) DO NOTHING; + +INSERT INTO plan_features (plan_uuid, feature_key, feature_name, feature_value, is_included, sort_order) +SELECT + p.uuid, + 'custom_integration', + '定制集成', + '支持定制化集成', + TRUE, + 8 +FROM plans p WHERE p.name = '商业套餐' +ON CONFLICT (plan_uuid, feature_key) DO NOTHING; + +INSERT INTO plan_features (plan_uuid, feature_key, feature_name, feature_value, is_included, sort_order) +SELECT + p.uuid, + 'sla_guarantee', + 'SLA 保障', + '99.9% 可用性保障', + TRUE, + 9 +FROM plans p WHERE p.name = '商业套餐' +ON CONFLICT (plan_uuid, feature_key) DO NOTHING; + +-- 企业套餐特性(包含商业套餐的所有特性,并增加更多) +INSERT INTO plan_features (plan_uuid, feature_key, feature_name, feature_value, is_included, sort_order) +SELECT + p.uuid, + 'env_limit', + '环境数量', + '100000 个环境', + TRUE, + 1 +FROM plans p WHERE p.name = '企业套餐' +ON CONFLICT (plan_uuid, feature_key) DO NOTHING; + +INSERT INTO plan_features (plan_uuid, feature_key, feature_name, feature_value, is_included, sort_order) +SELECT + p.uuid, + 'team_members', + '团队成员', + '30 人', + TRUE, + 2 +FROM plans p WHERE p.name = '企业套餐' +ON CONFLICT (plan_uuid, feature_key) DO NOTHING; + +INSERT INTO plan_features (plan_uuid, feature_key, feature_name, feature_value, is_included, sort_order) +SELECT + p.uuid, + 'proxy_access', + '代理访问', + '99999 个代理', + TRUE, + 3 +FROM plans p WHERE p.name = '企业套餐' +ON CONFLICT (plan_uuid, feature_key) DO NOTHING; + +INSERT INTO plan_features (plan_uuid, feature_key, feature_name, feature_value, is_included, sort_order) +SELECT + p.uuid, + 'rpa_tasks', + 'RPA 任务', + '99999 个任务', + TRUE, + 4 +FROM plans p WHERE p.name = '企业套餐' +ON CONFLICT (plan_uuid, feature_key) DO NOTHING; + +INSERT INTO plan_features (plan_uuid, feature_key, feature_name, feature_value, is_included, sort_order) +SELECT + p.uuid, + 'premium_support', + '高级支持', + '7x24 小时专属支持 + 专属客户经理', + TRUE, + 5 +FROM plans p WHERE p.name = '企业套餐' +ON CONFLICT (plan_uuid, feature_key) DO NOTHING; + +INSERT INTO plan_features (plan_uuid, feature_key, feature_name, feature_value, is_included, sort_order) +SELECT + p.uuid, + 'api_access', + 'API 访问', + '完整 API 权限 + 高级 API + 定制 API', + TRUE, + 6 +FROM plans p WHERE p.name = '企业套餐' +ON CONFLICT (plan_uuid, feature_key) DO NOTHING; + +INSERT INTO plan_features (plan_uuid, feature_key, feature_name, feature_value, is_included, sort_order) +SELECT + p.uuid, + 'advanced_features', + '高级功能', + '所有高级功能 + 企业级功能', + TRUE, + 7 +FROM plans p WHERE p.name = '企业套餐' +ON CONFLICT (plan_uuid, feature_key) DO NOTHING; + +INSERT INTO plan_features (plan_uuid, feature_key, feature_name, feature_value, is_included, sort_order) +SELECT + p.uuid, + 'custom_integration', + '定制集成', + '完全定制化集成 + 专属技术支持', + TRUE, + 8 +FROM plans p WHERE p.name = '企业套餐' +ON CONFLICT (plan_uuid, feature_key) DO NOTHING; + +INSERT INTO plan_features (plan_uuid, feature_key, feature_name, feature_value, is_included, sort_order) +SELECT + p.uuid, + 'sla_guarantee', + 'SLA 保障', + '99.99% 可用性保障', + TRUE, + 9 +FROM plans p WHERE p.name = '企业套餐' +ON CONFLICT (plan_uuid, feature_key) DO NOTHING; + +INSERT INTO plan_features (plan_uuid, feature_key, feature_name, feature_value, is_included, sort_order) +SELECT + p.uuid, + 'dedicated_infrastructure', + '专属基础设施', + '专属服务器和资源', + TRUE, + 10 +FROM plans p WHERE p.name = '企业套餐' +ON CONFLICT (plan_uuid, feature_key) DO NOTHING; + +INSERT INTO plan_features (plan_uuid, feature_key, feature_name, feature_value, is_included, sort_order) +SELECT + p.uuid, + 'training_sessions', + '培训服务', + '定期培训和最佳实践指导', + TRUE, + 11 +FROM plans p WHERE p.name = '企业套餐' +ON CONFLICT (plan_uuid, feature_key) DO NOTHING; + diff --git a/src-tauri/crates/business/migrations/20250126000002_create_user_coupons.sql b/src-tauri/crates/business/migrations/20250126000002_create_user_coupons.sql new file mode 100644 index 0000000..24d65e5 --- /dev/null +++ b/src-tauri/crates/business/migrations/20250126000002_create_user_coupons.sql @@ -0,0 +1,27 @@ +-- 创建 user_coupons 表 +-- 用户优惠券表 + +CREATE TABLE IF NOT EXISTS user_coupons ( + id SERIAL PRIMARY KEY, + user_uuid UUID NOT NULL, + coupon_uuid UUID NOT NULL, + -- 状态: unused, used, expired + status VARCHAR(50) NOT NULL DEFAULT 'unused', + -- 发放时间 + issued_at TIMESTAMP WITH TIME ZONE NOT NULL DEFAULT CURRENT_TIMESTAMP, + -- 使用时间 + used_at TIMESTAMP WITH TIME ZONE, + -- 过期时间(可选,继承自优惠券或自定义) + expires_at TIMESTAMP WITH TIME ZONE, + -- 约束 + CONSTRAINT fk_user_coupons_user FOREIGN KEY (user_uuid) REFERENCES users(uuid), + CONSTRAINT fk_user_coupons_coupon FOREIGN KEY (coupon_uuid) REFERENCES coupons(uuid), + -- 唯一约束:同一用户不能重复拥有同一优惠券 + UNIQUE(user_uuid, coupon_uuid) +); + +-- 创建索引 +CREATE INDEX idx_user_coupons_user_uuid ON user_coupons(user_uuid); +CREATE INDEX idx_user_coupons_status ON user_coupons(status); +CREATE INDEX idx_user_coupons_coupon_uuid ON user_coupons(coupon_uuid); +CREATE INDEX idx_user_coupons_expires_at ON user_coupons(expires_at); diff --git a/src-tauri/crates/business/migrations/20250126000003_insert_default_coupons.sql b/src-tauri/crates/business/migrations/20250126000003_insert_default_coupons.sql new file mode 100644 index 0000000..1b35f65 --- /dev/null +++ b/src-tauri/crates/business/migrations/20250126000003_insert_default_coupons.sql @@ -0,0 +1,37 @@ +-- 插入默认优惠券数据 + +-- 1. 新用户专享优惠券(10%折扣,无使用次数限制,适用于所有订单) +INSERT INTO coupons (uuid, code, discount_type, discount_value, min_amount, max_discount, max_uses, max_uses_per_user, valid_from, valid_until, applicable_to, status) +VALUES + (gen_random_uuid(), 'WELCOME10', 'percentage', 10.00, 0.00, NULL, NULL, 1, CURRENT_TIMESTAMP, NULL, 'all', 'active') +ON CONFLICT (code) DO NOTHING; + +-- 2. 年付优惠券(固定金额 $20,最低消费 $100,适用于订阅) +INSERT INTO coupons (uuid, code, discount_type, discount_value, min_amount, max_discount, max_uses, max_uses_per_user, valid_from, valid_until, applicable_to, status) +VALUES + (gen_random_uuid(), 'YEARLY20', 'fixed', 20.00, 100.00, NULL, NULL, 1, CURRENT_TIMESTAMP, NULL, 'subscription', 'active') +ON CONFLICT (code) DO NOTHING; + +-- 3. 限时促销优惠券(15%折扣,最大折扣 $50,使用次数限制 100 次) +INSERT INTO coupons (uuid, code, discount_type, discount_value, min_amount, max_discount, max_uses, max_uses_per_user, valid_from, valid_until, applicable_to, status) +VALUES + (gen_random_uuid(), 'PROMO15', 'percentage', 15.00, 50.00, 50.00, 100, 1, CURRENT_TIMESTAMP, (CURRENT_TIMESTAMP + INTERVAL '30 days'), 'all', 'active') +ON CONFLICT (code) DO NOTHING; + +-- 4. 大额订单优惠券(固定金额 $50,最低消费 $200) +INSERT INTO coupons (uuid, code, discount_type, discount_value, min_amount, max_discount, max_uses, max_uses_per_user, valid_from, valid_until, applicable_to, status) +VALUES + (gen_random_uuid(), 'BIG50', 'fixed', 50.00, 200.00, NULL, NULL, 1, CURRENT_TIMESTAMP, NULL, 'all', 'active') +ON CONFLICT (code) DO NOTHING; + +-- 5. 年付专属优惠券(20%折扣,仅适用于年付订阅,最大折扣 $200) +INSERT INTO coupons (uuid, code, discount_type, discount_value, min_amount, max_discount, max_uses, max_uses_per_user, valid_from, valid_until, applicable_to, status) +VALUES + (gen_random_uuid(), 'YEARLY20PCT', 'percentage', 20.00, 100.00, 200.00, NULL, 1, CURRENT_TIMESTAMP, NULL, 'subscription', 'active') +ON CONFLICT (code) DO NOTHING; + +-- 6. 首次购买优惠券(固定金额 $10,无最低消费,每人限用1次) +INSERT INTO coupons (uuid, code, discount_type, discount_value, min_amount, max_discount, max_uses, max_uses_per_user, valid_from, valid_until, applicable_to, status) +VALUES + (gen_random_uuid(), 'FIRST10', 'fixed', 10.00, 0.00, NULL, NULL, 1, CURRENT_TIMESTAMP, NULL, 'all', 'active') +ON CONFLICT (code) DO NOTHING; diff --git a/src-tauri/crates/business/migrations/20250127000001_add_coupon_name_description.sql b/src-tauri/crates/business/migrations/20250127000001_add_coupon_name_description.sql new file mode 100644 index 0000000..f8f7b98 --- /dev/null +++ b/src-tauri/crates/business/migrations/20250127000001_add_coupon_name_description.sql @@ -0,0 +1,13 @@ +-- 为优惠券表添加名称和描述字段 + +ALTER TABLE coupons +ADD COLUMN IF NOT EXISTS name VARCHAR(100), +ADD COLUMN IF NOT EXISTS description TEXT; + +-- 为现有优惠券更新名称和描述 +UPDATE coupons SET name = '新用户专享', description = '新用户注册专享优惠,享受10%折扣' WHERE code = 'WELCOME10'; +UPDATE coupons SET name = '年付优惠', description = '年付订阅专享,立减$20,最低消费$100' WHERE code = 'YEARLY20'; +UPDATE coupons SET name = '限时促销', description = '限时促销活动,享受15%折扣,最大折扣$50' WHERE code = 'PROMO15'; +UPDATE coupons SET name = '大额订单优惠', description = '大额订单专享,立减$50,最低消费$200' WHERE code = 'BIG50'; +UPDATE coupons SET name = '年付专属', description = '年付订阅专属优惠,享受20%折扣,最大折扣$200' WHERE code = 'YEARLY20PCT'; +UPDATE coupons SET name = '首次购买', description = '首次购买专享,立减$10,无最低消费限制' WHERE code = 'FIRST10'; diff --git a/src-tauri/crates/business/migrations/20250127000002_reset_referral_default_tiers.sql b/src-tauri/crates/business/migrations/20250127000002_reset_referral_default_tiers.sql new file mode 100644 index 0000000..eeb7fcc --- /dev/null +++ b/src-tauri/crates/business/migrations/20250127000002_reset_referral_default_tiers.sql @@ -0,0 +1,25 @@ +-- 重置推广链接层级默认数据 +-- 说明: +-- - 该迁移用于在开发 / 测试环境中统一推广层级配置 +-- - 按产品设计保留 4 个层级,按照邀请人数逐级解锁 +-- - 为避免脏数据干扰,这里会清空依赖 referral_link_tiers 的相关表 + +-- 1. 清空依赖表(注意:这会删除所有历史推广数据) +TRUNCATE TABLE redeem_records RESTART IDENTITY CASCADE; +TRUNCATE TABLE referral_rewards RESTART IDENTITY CASCADE; +TRUNCATE TABLE user_referrals RESTART IDENTITY CASCADE; +TRUNCATE TABLE referral_links RESTART IDENTITY CASCADE; +TRUNCATE TABLE referral_link_tiers RESTART IDENTITY CASCADE; + +-- 2. 重新插入 4 个默认推广层级 +INSERT INTO referral_link_tiers (uuid, name, unlock_threshold, reward_rate, discount_rate, description, sort_order) +VALUES + -- 第一档:默认开放 + (gen_random_uuid(), '青铜', 0, 10.00, 5.00, '默认层级,绑定第一个推广链接', 1), + -- 第二档:达到一定推广人数后解锁 + (gen_random_uuid(), '白银', 10, 15.00, 8.00, '推广满 10 人后解锁第二个推广链接', 2), + -- 第三档 + (gen_random_uuid(), '黄金', 50, 20.00, 10.00, '推广满 50 人后解锁第三个推广链接', 3), + -- 第四档 + (gen_random_uuid(), '钻石', 100, 25.00, 15.00, '推广满 100 人后解锁第四个推广链接', 4); + diff --git a/src-tauri/crates/business/migrations/20250127000003_create_machine_tags_tables.sql b/src-tauri/crates/business/migrations/20250127000003_create_machine_tags_tables.sql new file mode 100644 index 0000000..4aa6378 --- /dev/null +++ b/src-tauri/crates/business/migrations/20250127000003_create_machine_tags_tables.sql @@ -0,0 +1,53 @@ +-- Add migration script here +-- Create machine_tags and machine_user_tags tables for tag management + +-- Drop tables (for development) +-- DROP TABLE public.machine_user_tags CASCADE; +-- DROP TABLE public.machine_tags CASCADE; + +-- Create machine_tags table +CREATE TABLE public.machine_tags ( + id int4 GENERATED BY DEFAULT AS IDENTITY( INCREMENT BY 1 MINVALUE 1 MAXVALUE 2147483647 START 1 CACHE 1 NO CYCLE) NOT NULL, + tag_name varchar(100) NOT NULL, -- 标签名称 + description text NULL, -- 标签描述 + category varchar(50) NULL, -- 标签分类 + is_active boolean DEFAULT true NOT NULL, -- 是否激活 + created_at timestamptz DEFAULT CURRENT_TIMESTAMP NOT NULL, -- 创建时间 + CONSTRAINT machine_tags_pk PRIMARY KEY (id), + CONSTRAINT machine_tags_tag_name_unique UNIQUE (tag_name) +); + +-- Create machine_user_tags table (association table) +CREATE TABLE public.machine_user_tags ( + id int4 GENERATED BY DEFAULT AS IDENTITY( INCREMENT BY 1 MINVALUE 1 MAXVALUE 2147483647 START 1 CACHE 1 NO CYCLE) NOT NULL, + machine_user_id int4 NOT NULL, -- 机器用户ID + tag_id int4 NOT NULL, -- 标签ID + created_at timestamptz DEFAULT CURRENT_TIMESTAMP NOT NULL, -- 创建时间 + CONSTRAINT machine_user_tags_pk PRIMARY KEY (id), + CONSTRAINT machine_user_tags_machine_user_id_fk FOREIGN KEY (machine_user_id) REFERENCES public.machine_users(id) ON DELETE CASCADE, + CONSTRAINT machine_user_tags_tag_id_fk FOREIGN KEY (tag_id) REFERENCES public.machine_tags(id) ON DELETE CASCADE, + CONSTRAINT machine_user_tags_unique UNIQUE (machine_user_id, tag_id) +); + +-- Create indexes for performance +CREATE INDEX machine_tags_tag_name_idx ON public.machine_tags USING btree (tag_name); +CREATE INDEX machine_tags_is_active_idx ON public.machine_tags USING btree (is_active); +CREATE INDEX machine_user_tags_machine_user_id_idx ON public.machine_user_tags USING btree (machine_user_id); +CREATE INDEX machine_user_tags_tag_id_idx ON public.machine_user_tags USING btree (tag_id); + +-- Column comments +COMMENT ON TABLE public.machine_tags IS '机器标签主表'; +COMMENT ON TABLE public.machine_user_tags IS '机器用户标签关联表'; + +COMMENT ON COLUMN public.machine_tags.id IS '主键ID'; +COMMENT ON COLUMN public.machine_tags.tag_name IS '标签名称(唯一)'; +COMMENT ON COLUMN public.machine_tags.description IS '标签描述'; +COMMENT ON COLUMN public.machine_tags.category IS '标签分类'; +COMMENT ON COLUMN public.machine_tags.is_active IS '是否激活'; +COMMENT ON COLUMN public.machine_tags.created_at IS '创建时间'; + +COMMENT ON COLUMN public.machine_user_tags.id IS '主键ID'; +COMMENT ON COLUMN public.machine_user_tags.machine_user_id IS '机器用户ID'; +COMMENT ON COLUMN public.machine_user_tags.tag_id IS '标签ID'; +COMMENT ON COLUMN public.machine_user_tags.created_at IS '创建时间'; + diff --git a/src-tauri/crates/business/migrations/20250204000001_alter_version_types_add_is_auto_download.sql b/src-tauri/crates/business/migrations/20250204000001_alter_version_types_add_is_auto_download.sql new file mode 100644 index 0000000..e91548f --- /dev/null +++ b/src-tauri/crates/business/migrations/20250204000001_alter_version_types_add_is_auto_download.sql @@ -0,0 +1,7 @@ +-- Add is_auto_download flag to version_types to control participation in auto-update flows + +ALTER TABLE public.version_types +ADD COLUMN IF NOT EXISTS is_auto_download bool DEFAULT true NOT NULL; + +COMMENT ON COLUMN public.version_types.is_auto_download IS '是否参与自动检查更新与自动下载'; + diff --git a/src-tauri/crates/business/migrations/20250204000002_alter_versions_add_kernel_fields.sql b/src-tauri/crates/business/migrations/20250204000002_alter_versions_add_kernel_fields.sql new file mode 100644 index 0000000..a1059ef --- /dev/null +++ b/src-tauri/crates/business/migrations/20250204000002_alter_versions_add_kernel_fields.sql @@ -0,0 +1,16 @@ +-- Extend versions table with kernel-related metadata fields. +-- These fields are optional for普通资源,但对浏览器内核版本会被要求填充。 + +ALTER TABLE public.versions +ADD COLUMN IF NOT EXISTS arch text NULL, +ADD COLUMN IF NOT EXISTS package_format text NULL, +ADD COLUMN IF NOT EXISTS requires_extract bool DEFAULT false NOT NULL, +ADD COLUMN IF NOT EXISTS entrypoint_template text NULL, +ADD COLUMN IF NOT EXISTS extract_root text NULL; + +COMMENT ON COLUMN public.versions.arch IS '架构(如 x86_64、arm64)'; +COMMENT ON COLUMN public.versions.package_format IS '包格式(如 zip、exe)'; +COMMENT ON COLUMN public.versions.requires_extract IS '是否需要解压(zip 等资源为 true)'; +COMMENT ON COLUMN public.versions.entrypoint_template IS '入口相对路径模板(如 Simprint-Browser/chrome-{version}/chrome.exe)'; +COMMENT ON COLUMN public.versions.extract_root IS '可选的解压根目录描述'; + diff --git a/src-tauri/crates/business/migrations/20250204000003_insert_default_version_types.sql b/src-tauri/crates/business/migrations/20250204000003_insert_default_version_types.sql new file mode 100644 index 0000000..d999c95 --- /dev/null +++ b/src-tauri/crates/business/migrations/20250204000003_insert_default_version_types.sql @@ -0,0 +1,8 @@ +-- 初始化版本类型默认数据 +-- 注意:使用 ON CONFLICT 以保证迁移可重复执行 + +INSERT INTO public.version_types (type_code, type_name, description, sort_order, is_active, is_auto_download) +VALUES + ('SIMPRINT_CLIENT_INSTALLER', 'Simprint 客户端安装包', 'Simprint 浏览器主程序安装包', 10, true, true), + ('SIMPRINT_KERNEL_CHROMIUM', 'Chromium 内核', 'Chromium 浏览器内核 zip 包', 30, true, false) +ON CONFLICT (type_code) DO NOTHING; \ No newline at end of file diff --git a/src-tauri/crates/business/migrations/20260213000001_add_updated_at_to_user_coupons.sql b/src-tauri/crates/business/migrations/20260213000001_add_updated_at_to_user_coupons.sql new file mode 100644 index 0000000..a97141c --- /dev/null +++ b/src-tauri/crates/business/migrations/20260213000001_add_updated_at_to_user_coupons.sql @@ -0,0 +1,9 @@ +-- 为 user_coupons 表添加 updated_at 字段 +-- 修复订阅功能中缺少 updated_at 字段的问题 + +ALTER TABLE user_coupons +ADD COLUMN updated_at TIMESTAMP WITH TIME ZONE NOT NULL DEFAULT CURRENT_TIMESTAMP; + +-- 创建更新时间触发器 +CREATE TRIGGER update_user_coupons_updated_at BEFORE UPDATE ON user_coupons + FOR EACH ROW EXECUTE FUNCTION update_updated_at_column(); diff --git a/src-tauri/crates/business/migrations/20260306000001_fix_extensions_status_comments.sql b/src-tauri/crates/business/migrations/20260306000001_fix_extensions_status_comments.sql new file mode 100644 index 0000000..c46f477 --- /dev/null +++ b/src-tauri/crates/business/migrations/20260306000001_fix_extensions_status_comments.sql @@ -0,0 +1,18 @@ +-- 修正扩展表的状态默认值和注释 +-- 将状态从 "installed, disabled" 改为 "active, inactive" 以对齐代码实现 + +-- user_extensions 表 +ALTER TABLE user_extensions ALTER COLUMN status SET DEFAULT 'active'; +COMMENT ON COLUMN user_extensions.status IS '状态: active, inactive'; + +-- team_extensions 表 +ALTER TABLE team_extensions ALTER COLUMN status SET DEFAULT 'active'; +COMMENT ON COLUMN team_extensions.status IS '状态: active, inactive'; + +-- group_extensions 表 +ALTER TABLE group_extensions ALTER COLUMN status SET DEFAULT 'active'; +COMMENT ON COLUMN group_extensions.status IS '状态: active, inactive'; + +-- environment_extensions 表 +ALTER TABLE environment_extensions ALTER COLUMN status SET DEFAULT 'active'; +COMMENT ON COLUMN environment_extensions.status IS '状态: active, inactive'; diff --git a/src-tauri/crates/business/migrations/20260306000002_drop_environment_extensions.sql b/src-tauri/crates/business/migrations/20260306000002_drop_environment_extensions.sql new file mode 100644 index 0000000..af9dfb1 --- /dev/null +++ b/src-tauri/crates/business/migrations/20260306000002_drop_environment_extensions.sql @@ -0,0 +1,12 @@ +-- 删除 environment_extensions 表 +-- 环境不直接绑定插件,插件通过用户/团队/分组间接作用于环境 + +-- 删除触发器 +DROP TRIGGER IF EXISTS update_environment_extensions_updated_at ON environment_extensions; + +-- 删除索引 +DROP INDEX IF EXISTS idx_env_extensions_extension_id; +DROP INDEX IF EXISTS idx_env_extensions_env_uuid; + +-- 删除表 +DROP TABLE IF EXISTS environment_extensions; diff --git a/src-tauri/crates/business/migrations/20260306000003_add_is_team_shared_to_group_extensions.sql b/src-tauri/crates/business/migrations/20260306000003_add_is_team_shared_to_group_extensions.sql new file mode 100644 index 0000000..286a7b5 --- /dev/null +++ b/src-tauri/crates/business/migrations/20260306000003_add_is_team_shared_to_group_extensions.sql @@ -0,0 +1,11 @@ +-- 为 group_extensions 表添加 is_team_shared 字段 +-- 用于区分"分组团队插件"和"分组个人插件" + +-- 添加 is_team_shared 字段 +ALTER TABLE group_extensions ADD COLUMN is_team_shared BOOLEAN NOT NULL DEFAULT false; + +-- 添加索引 +CREATE INDEX idx_group_extensions_is_team_shared ON group_extensions(is_team_shared); + +-- 更新注释 +COMMENT ON COLUMN group_extensions.is_team_shared IS '是否为团队共享: true=团队所有成员可用, false=仅创建者可用'; diff --git a/src-tauri/crates/business/migrations/20260306000004_update_extension_status_support_disabled.sql b/src-tauri/crates/business/migrations/20260306000004_update_extension_status_support_disabled.sql new file mode 100644 index 0000000..299781e --- /dev/null +++ b/src-tauri/crates/business/migrations/20260306000004_update_extension_status_support_disabled.sql @@ -0,0 +1,7 @@ +-- 更新扩展状态支持 disabled +-- 允许用户禁用团队/分组插件 + +-- 更新状态注释 +COMMENT ON COLUMN user_extensions.status IS '状态: active=已启用, disabled=已禁用, inactive=已卸载'; +COMMENT ON COLUMN team_extensions.status IS '状态: active=已安装, inactive=已卸载'; +COMMENT ON COLUMN group_extensions.status IS '状态: active=已安装, inactive=已卸载'; diff --git a/src-tauri/crates/business/migrations/20260306000005_create_user_team_extension_preferences.sql b/src-tauri/crates/business/migrations/20260306000005_create_user_team_extension_preferences.sql new file mode 100644 index 0000000..badb26c --- /dev/null +++ b/src-tauri/crates/business/migrations/20260306000005_create_user_team_extension_preferences.sql @@ -0,0 +1,23 @@ +-- 创建用户团队插件偏好设置表 +CREATE TABLE IF NOT EXISTS user_team_extension_preferences ( + id SERIAL PRIMARY KEY, + user_uuid UUID NOT NULL, + team_uuid UUID NOT NULL, + extension_id VARCHAR(255) NOT NULL, + is_disabled BOOLEAN DEFAULT false, + created_at TIMESTAMP DEFAULT CURRENT_TIMESTAMP, + updated_at TIMESTAMP DEFAULT CURRENT_TIMESTAMP, + UNIQUE(user_uuid, team_uuid, extension_id) +); + +-- 添加索引 +CREATE INDEX idx_user_team_extension_preferences_user_uuid ON user_team_extension_preferences(user_uuid); +CREATE INDEX idx_user_team_extension_preferences_team_uuid ON user_team_extension_preferences(team_uuid); +CREATE INDEX idx_user_team_extension_preferences_extension_id ON user_team_extension_preferences(extension_id); + +-- 添加注释 +COMMENT ON TABLE user_team_extension_preferences IS '用户对团队插件的偏好设置(如禁用)'; +COMMENT ON COLUMN user_team_extension_preferences.user_uuid IS '用户 UUID'; +COMMENT ON COLUMN user_team_extension_preferences.team_uuid IS '团队 UUID'; +COMMENT ON COLUMN user_team_extension_preferences.extension_id IS '插件 ID'; +COMMENT ON COLUMN user_team_extension_preferences.is_disabled IS '是否禁用(true 表示用户禁用了该团队插件)'; diff --git a/src-tauri/crates/business/migrations/20260308000001_add_hash_to_extensions.sql b/src-tauri/crates/business/migrations/20260308000001_add_hash_to_extensions.sql new file mode 100644 index 0000000..bd2db84 --- /dev/null +++ b/src-tauri/crates/business/migrations/20260308000001_add_hash_to_extensions.sql @@ -0,0 +1,8 @@ +-- 为 extensions 表添加 hash 字段 +-- 用于存储扩展文件的哈希值 + +ALTER TABLE extensions +ADD COLUMN hash VARCHAR(255) DEFAULT NULL; + +-- 为 hash 字段创建索引(可选,如果需要通过 hash 查询) +CREATE INDEX idx_extensions_hash ON extensions(hash); diff --git a/src-tauri/crates/business/migrations/20260314000001_alter_versions_require_publish_fields.sql b/src-tauri/crates/business/migrations/20260314000001_alter_versions_require_publish_fields.sql new file mode 100644 index 0000000..07b4a60 --- /dev/null +++ b/src-tauri/crates/business/migrations/20260314000001_alter_versions_require_publish_fields.sql @@ -0,0 +1,15 @@ +-- Enforce required publish metadata for versions exposed to the updater. +-- Existing dirty rows are removed first so the NOT NULL constraints can be applied safely. + +DELETE FROM public.versions +WHERE platform IS NULL + OR url IS NULL + OR hash IS NULL + OR file_size IS NULL + OR pub_date IS NULL; + +ALTER TABLE public.versions +ALTER COLUMN platform SET NOT NULL, +ALTER COLUMN url SET NOT NULL, +ALTER COLUMN hash SET NOT NULL, +ALTER COLUMN file_size SET NOT NULL; \ No newline at end of file diff --git a/src-tauri/crates/business/migrations/20260316000001_create_local_api_service_tables.sql b/src-tauri/crates/business/migrations/20260316000001_create_local_api_service_tables.sql new file mode 100644 index 0000000..8461bcf --- /dev/null +++ b/src-tauri/crates/business/migrations/20260316000001_create_local_api_service_tables.sql @@ -0,0 +1,109 @@ +-- 创建本地 API 服务相关表 + +CREATE TABLE IF NOT EXISTS user_local_api_settings ( + id SERIAL PRIMARY KEY, + uuid UUID NOT NULL DEFAULT gen_random_uuid() UNIQUE, + user_uuid UUID NOT NULL UNIQUE, + enabled BOOLEAN NOT NULL DEFAULT FALSE, + port INTEGER NOT NULL DEFAULT 8080, + remote_access BOOLEAN NOT NULL DEFAULT FALSE, + cors_origins JSONB NOT NULL DEFAULT '[]'::jsonb, + created_at TIMESTAMP WITH TIME ZONE NOT NULL DEFAULT CURRENT_TIMESTAMP, + updated_at TIMESTAMP WITH TIME ZONE NOT NULL DEFAULT CURRENT_TIMESTAMP, + deleted_at TIMESTAMP WITH TIME ZONE, + CONSTRAINT fk_user_local_api_settings_user FOREIGN KEY (user_uuid) REFERENCES users(uuid) ON DELETE CASCADE, + CONSTRAINT chk_user_local_api_settings_port CHECK (port BETWEEN 1 AND 65535), + CONSTRAINT chk_user_local_api_settings_cors_origins CHECK (jsonb_typeof(cors_origins) = 'array') +); + +CREATE INDEX idx_user_local_api_settings_user_uuid + ON user_local_api_settings(user_uuid); + +CREATE TRIGGER update_user_local_api_settings_updated_at + BEFORE UPDATE ON user_local_api_settings + FOR EACH ROW EXECUTE FUNCTION update_updated_at_column(); + +CREATE TABLE IF NOT EXISTS user_local_api_keys ( + id SERIAL PRIMARY KEY, + uuid UUID NOT NULL DEFAULT gen_random_uuid() UNIQUE, + user_uuid UUID NOT NULL, + key_prefix VARCHAR(32) NOT NULL, + key_hash VARCHAR(128) NOT NULL, + masked_key VARCHAR(64) NOT NULL, + is_active BOOLEAN NOT NULL DEFAULT TRUE, + requests_today INTEGER NOT NULL DEFAULT 0, + daily_limit INTEGER NOT NULL DEFAULT 1000, + last_reset_date DATE NOT NULL DEFAULT CURRENT_DATE, + last_used_at TIMESTAMP WITH TIME ZONE, + expires_at TIMESTAMP WITH TIME ZONE, + created_at TIMESTAMP WITH TIME ZONE NOT NULL DEFAULT CURRENT_TIMESTAMP, + updated_at TIMESTAMP WITH TIME ZONE NOT NULL DEFAULT CURRENT_TIMESTAMP, + deleted_at TIMESTAMP WITH TIME ZONE, + CONSTRAINT fk_user_local_api_keys_user FOREIGN KEY (user_uuid) REFERENCES users(uuid) ON DELETE CASCADE, + CONSTRAINT chk_user_local_api_keys_requests_today CHECK (requests_today >= 0), + CONSTRAINT chk_user_local_api_keys_daily_limit CHECK (daily_limit >= 0) +); + +CREATE INDEX idx_user_local_api_keys_user_uuid + ON user_local_api_keys(user_uuid); + +CREATE INDEX idx_user_local_api_keys_key_prefix + ON user_local_api_keys(key_prefix); + +CREATE INDEX idx_user_local_api_keys_key_hash + ON user_local_api_keys(key_hash); + +CREATE UNIQUE INDEX idx_user_local_api_keys_active_user + ON user_local_api_keys(user_uuid) + WHERE is_active = TRUE AND deleted_at IS NULL; + +CREATE TRIGGER update_user_local_api_keys_updated_at + BEFORE UPDATE ON user_local_api_keys + FOR EACH ROW EXECUTE FUNCTION update_updated_at_column(); + +CREATE TABLE IF NOT EXISTS user_local_api_key_permissions ( + id SERIAL PRIMARY KEY, + uuid UUID NOT NULL DEFAULT gen_random_uuid() UNIQUE, + api_key_id INTEGER NOT NULL, + permission_code VARCHAR(128) NOT NULL, + is_enabled BOOLEAN NOT NULL DEFAULT TRUE, + rate_limit_per_minute INTEGER NOT NULL DEFAULT 60, + rate_limit_per_hour INTEGER NOT NULL DEFAULT 1000, + created_at TIMESTAMP WITH TIME ZONE NOT NULL DEFAULT CURRENT_TIMESTAMP, + updated_at TIMESTAMP WITH TIME ZONE NOT NULL DEFAULT CURRENT_TIMESTAMP, + deleted_at TIMESTAMP WITH TIME ZONE, + CONSTRAINT fk_user_local_api_key_permissions_key FOREIGN KEY (api_key_id) REFERENCES user_local_api_keys(id) ON DELETE CASCADE, + CONSTRAINT uq_user_local_api_key_permissions UNIQUE (api_key_id, permission_code), + CONSTRAINT chk_user_local_api_key_permissions_minute CHECK (rate_limit_per_minute >= 0), + CONSTRAINT chk_user_local_api_key_permissions_hour CHECK (rate_limit_per_hour >= 0) +); + +CREATE INDEX idx_user_local_api_key_permissions_key + ON user_local_api_key_permissions(api_key_id); + +CREATE INDEX idx_user_local_api_key_permissions_code + ON user_local_api_key_permissions(permission_code); + +CREATE TRIGGER update_user_local_api_key_permissions_updated_at + BEFORE UPDATE ON user_local_api_key_permissions + FOR EACH ROW EXECUTE FUNCTION update_updated_at_column(); + +CREATE TABLE IF NOT EXISTS user_local_api_request_counters ( + id SERIAL PRIMARY KEY, + api_key_id INTEGER NOT NULL, + permission_code VARCHAR(128) NOT NULL, + window_type VARCHAR(16) NOT NULL, + window_start TIMESTAMP WITH TIME ZONE NOT NULL, + request_count INTEGER NOT NULL DEFAULT 0, + updated_at TIMESTAMP WITH TIME ZONE NOT NULL DEFAULT CURRENT_TIMESTAMP, + CONSTRAINT fk_user_local_api_request_counters_key FOREIGN KEY (api_key_id) REFERENCES user_local_api_keys(id) ON DELETE CASCADE, + CONSTRAINT uq_user_local_api_request_counters UNIQUE (api_key_id, permission_code, window_type, window_start), + CONSTRAINT chk_user_local_api_request_counters_window_type CHECK (window_type IN ('minute', 'hour', 'day')), + CONSTRAINT chk_user_local_api_request_counters_request_count CHECK (request_count >= 0) +); + +CREATE INDEX idx_user_local_api_request_counters_key + ON user_local_api_request_counters(api_key_id); + +CREATE INDEX idx_user_local_api_request_counters_lookup + ON user_local_api_request_counters(api_key_id, permission_code, window_type, window_start); diff --git a/src-tauri/crates/business/migrations/20260316000002_initialize_local_api_default_configs.sql b/src-tauri/crates/business/migrations/20260316000002_initialize_local_api_default_configs.sql new file mode 100644 index 0000000..ebfb8a4 --- /dev/null +++ b/src-tauri/crates/business/migrations/20260316000002_initialize_local_api_default_configs.sql @@ -0,0 +1,13 @@ +-- 初始化本地 API 功能默认配置 +-- 这些配置作为系统级默认值存在,具体用户数据仍在首次使用时按需创建。 + +INSERT INTO system_configs (config_key, config_value, description) +VALUES + ('local_api_default_enabled', 'false', '本地 API 默认是否启用'), + ('local_api_default_port', '8080', '本地 API 默认监听端口'), + ('local_api_default_remote_access', 'false', '本地 API 默认是否允许局域网访问'), + ('local_api_default_cors_origins', '[]', '本地 API 默认允许的 CORS 来源'), + ('local_api_default_daily_limit', '1000', '本地 API 默认每日总调用上限'), + ('local_api_default_rate_limit_per_minute', '60', '本地 API 默认每分钟限流'), + ('local_api_default_rate_limit_per_hour', '1000', '本地 API 默认每小时限流') +ON CONFLICT (config_key) DO NOTHING; diff --git a/src-tauri/crates/business/migrations/20260316000003_create_local_api_permission_definitions.sql b/src-tauri/crates/business/migrations/20260316000003_create_local_api_permission_definitions.sql new file mode 100644 index 0000000..44da062 --- /dev/null +++ b/src-tauri/crates/business/migrations/20260316000003_create_local_api_permission_definitions.sql @@ -0,0 +1,25 @@ +-- 创建本地 API 权限定义表 + +CREATE TABLE IF NOT EXISTS local_api_permission_definitions ( + id SERIAL PRIMARY KEY, + uuid UUID NOT NULL DEFAULT gen_random_uuid() UNIQUE, + permission_code VARCHAR(128) NOT NULL UNIQUE, + name VARCHAR(128) NOT NULL, + description TEXT, + default_enabled BOOLEAN NOT NULL DEFAULT TRUE, + default_rate_limit_per_minute INTEGER NOT NULL DEFAULT 60, + default_rate_limit_per_hour INTEGER NOT NULL DEFAULT 1000, + sort_order INTEGER NOT NULL DEFAULT 0, + created_at TIMESTAMP WITH TIME ZONE NOT NULL DEFAULT CURRENT_TIMESTAMP, + updated_at TIMESTAMP WITH TIME ZONE NOT NULL DEFAULT CURRENT_TIMESTAMP, + deleted_at TIMESTAMP WITH TIME ZONE, + CONSTRAINT chk_local_api_permission_definitions_minute CHECK (default_rate_limit_per_minute >= 0), + CONSTRAINT chk_local_api_permission_definitions_hour CHECK (default_rate_limit_per_hour >= 0) +); + +CREATE INDEX idx_local_api_permission_definitions_sort_order + ON local_api_permission_definitions(sort_order); + +CREATE TRIGGER update_local_api_permission_definitions_updated_at + BEFORE UPDATE ON local_api_permission_definitions + FOR EACH ROW EXECUTE FUNCTION update_updated_at_column(); diff --git a/src-tauri/crates/business/migrations/20260316000004_initialize_local_api_permission_definitions.sql b/src-tauri/crates/business/migrations/20260316000004_initialize_local_api_permission_definitions.sql new file mode 100644 index 0000000..0bfaf1f --- /dev/null +++ b/src-tauri/crates/business/migrations/20260316000004_initialize_local_api_permission_definitions.sql @@ -0,0 +1,55 @@ +-- 初始化本地 API 权限定义 + +INSERT INTO local_api_permission_definitions ( + permission_code, name, description, default_enabled, default_rate_limit_per_minute, default_rate_limit_per_hour, sort_order +) +VALUES + ('workspaces.list', '工作空间列表', '读取工作空间列表', TRUE, 120, 3000, 10), + ('workspaces.get', '工作空间详情', '读取工作空间详情', TRUE, 120, 3000, 20), + ('workspaces.switch', '切换工作空间', '切换当前工作空间', TRUE, 60, 1000, 30), + ('browser-kernels.list', '浏览器内核列表', '读取浏览器内核列表', TRUE, 120, 3000, 40), + ('groups.list', '分组列表', '读取分组列表', TRUE, 120, 3000, 50), + ('groups.create', '创建分组', '创建新的分组', TRUE, 30, 500, 60), + ('groups.update', '更新分组', '更新分组信息', TRUE, 30, 500, 70), + ('groups.delete', '删除分组', '删除分组', TRUE, 20, 300, 80), + ('tags.list', '标签列表', '读取标签列表', TRUE, 120, 3000, 90), + ('tags.create', '创建标签', '创建新的标签', TRUE, 30, 500, 100), + ('tags.update', '更新标签', '更新标签信息', TRUE, 30, 500, 110), + ('tags.delete', '删除标签', '删除标签', TRUE, 20, 300, 120), + ('environments.list', '环境列表', '读取环境列表', TRUE, 120, 3000, 130), + ('environments.detail', '环境详情', '读取环境详情', TRUE, 120, 3000, 140), + ('environments.batch-detail', '批量环境详情', '批量读取环境详情', TRUE, 60, 1200, 150), + ('environments.create', '创建环境', '创建新的环境', TRUE, 20, 300, 160), + ('environments.batch-create', '批量创建环境', '批量创建环境', TRUE, 10, 120, 170), + ('environments.update', '更新环境', '更新环境配置', TRUE, 30, 500, 180), + ('environments.delete', '删除环境', '删除环境', TRUE, 20, 300, 190), + ('environments.batch-delete', '批量删除环境', '批量删除环境', TRUE, 10, 120, 200), + ('environments.set-proxy', '设置环境代理', '为环境设置代理', TRUE, 30, 500, 210), + ('environments.assign-tags', '分配标签', '为环境分配标签', TRUE, 30, 500, 220), + ('environments.remove-tag', '移除标签', '从环境移除标签', TRUE, 30, 500, 230), + ('environments.move-to-group', '移动到分组', '将环境移动到指定分组', TRUE, 30, 500, 240), + ('environments.batch-move-to-group', '批量移动到分组', '批量移动环境到指定分组', TRUE, 15, 180, 250), + ('environments.set-accounts', '设置环境账号', '为环境关联账号', TRUE, 20, 300, 260), + ('environments.batch-assign-tags', '批量分配标签', '批量为环境分配标签', TRUE, 15, 180, 270), + ('environments.batch-remove-tags', '批量移除标签', '批量从环境移除标签', TRUE, 15, 180, 280), + ('environments.urls.list', '环境 URL 列表', '读取环境 URL 列表', TRUE, 120, 3000, 290), + ('environments.urls.add', '添加环境 URL', '向环境添加 URL', TRUE, 30, 500, 300), + ('environments.urls.delete', '删除环境 URL', '删除环境 URL', TRUE, 30, 500, 310), + ('environments.urls.clear', '清空环境 URL', '清空环境 URL', TRUE, 20, 300, 320), + ('environments.cookies.list', '环境 Cookie 列表', '读取环境 Cookie 列表', TRUE, 120, 3000, 330), + ('environments.cookies.add', '添加环境 Cookie', '向环境添加 Cookie', TRUE, 30, 500, 340), + ('environments.cookies.delete', '删除环境 Cookie', '删除环境 Cookie', TRUE, 30, 500, 350), + ('environments.cookies.clear', '清空环境 Cookie', '清空环境 Cookie', TRUE, 20, 300, 360), + ('environments.recycle-bin.list', '回收站环境列表', '读取回收站中的环境', TRUE, 120, 3000, 370), + ('environments.recycle-bin.restore', '恢复环境', '从回收站恢复环境', TRUE, 20, 300, 380), + ('environments.recycle-bin.batch-restore', '批量恢复环境', '批量从回收站恢复环境', TRUE, 10, 120, 390), + ('environments.recycle-bin.permanent-delete', '永久删除环境', '永久删除环境', TRUE, 10, 120, 400), + ('environments.recycle-bin.batch-permanent-delete', '批量永久删除环境', '批量永久删除环境', TRUE, 5, 60, 410), + ('proxies.list', '代理列表', '读取代理列表', TRUE, 120, 3000, 420), + ('proxies.detail', '代理详情', '读取代理详情', TRUE, 120, 3000, 430), + ('proxies.create', '创建代理', '创建新的代理', TRUE, 30, 500, 440), + ('proxies.update', '更新代理', '更新代理信息', TRUE, 30, 500, 450), + ('proxies.delete', '删除代理', '删除代理', TRUE, 20, 300, 460), + ('proxies.batch-delete', '批量删除代理', '批量删除代理', TRUE, 10, 120, 470), + ('proxies.batch-import', '批量导入代理', '批量导入代理', TRUE, 10, 120, 480) +ON CONFLICT (permission_code) DO NOTHING; diff --git a/src-tauri/crates/business/migrations/20260316000005_replace_masked_key_with_api_key.sql b/src-tauri/crates/business/migrations/20260316000005_replace_masked_key_with_api_key.sql new file mode 100644 index 0000000..ff513ad --- /dev/null +++ b/src-tauri/crates/business/migrations/20260316000005_replace_masked_key_with_api_key.sql @@ -0,0 +1,9 @@ +-- 使用完整 api_key 替代 masked_key +-- 旧数据无法从 masked_key 还原明文,因此 api_key 先允许为空; +-- 服务端在发现历史记录缺少 api_key 时,会自动轮换生成新 key。 + +ALTER TABLE user_local_api_keys + ADD COLUMN IF NOT EXISTS api_key TEXT; + +ALTER TABLE user_local_api_keys + DROP COLUMN IF EXISTS masked_key; diff --git a/src-tauri/crates/business/migrations/20260506000001_replace_encrypted_password_columns.sql b/src-tauri/crates/business/migrations/20260506000001_replace_encrypted_password_columns.sql new file mode 100644 index 0000000..dc0029b --- /dev/null +++ b/src-tauri/crates/business/migrations/20260506000001_replace_encrypted_password_columns.sql @@ -0,0 +1,11 @@ +ALTER TABLE public.proxies + DROP COLUMN IF EXISTS password_encrypted, + ADD COLUMN password TEXT; + +COMMENT ON COLUMN public.proxies.password IS '代理密码(明文存储)'; + +ALTER TABLE public.platform_accounts + DROP COLUMN IF EXISTS password_encrypted, + ADD COLUMN password TEXT; + +COMMENT ON COLUMN public.platform_accounts.password IS '平台账号密码(明文存储)'; diff --git a/src-tauri/crates/business/migrations/20260506000002_drop_machine_and_gray_tables.sql b/src-tauri/crates/business/migrations/20260506000002_drop_machine_and_gray_tables.sql new file mode 100644 index 0000000..eea4800 --- /dev/null +++ b/src-tauri/crates/business/migrations/20260506000002_drop_machine_and_gray_tables.sql @@ -0,0 +1,6 @@ +DROP TABLE IF EXISTS public.machine_gray_allocations; +DROP TABLE IF EXISTS public.gray_resources; +DROP TABLE IF EXISTS public.gray_releases; +DROP TABLE IF EXISTS public.machine_user_tags; +DROP TABLE IF EXISTS public.machine_tags; +DROP TABLE IF EXISTS public.machine_users; diff --git a/src-tauri/crates/business/migrations/20260508000001_insert_default_browser_kernel_version.sql b/src-tauri/crates/business/migrations/20260508000001_insert_default_browser_kernel_version.sql new file mode 100644 index 0000000..92bf5a5 --- /dev/null +++ b/src-tauri/crates/business/migrations/20260508000001_insert_default_browser_kernel_version.sql @@ -0,0 +1,67 @@ +-- 初始化默认浏览器内核版本数据 +-- 使用 version_types.type_code 定位类型,避免写死 type_id。 +-- 使用 NOT EXISTS 保证迁移可重复执行。 + +WITH kernel_type AS ( + SELECT id + FROM public.version_types + WHERE type_code = 'SIMPRINT_KERNEL_CHROMIUM' +) +INSERT INTO public.versions ( + type_id, + resource_name, + version, + name, + notes, + platform, + url, + hash, + signature, + install_path, + file_size, + is_latest, + status, + pub_date, + created_at, + updated_at, + deleted_at, + arch, + package_format, + requires_extract, + entrypoint_template, + extract_root +) +SELECT + kernel_type.id, + 'Chrome 144', + '144.0.7559.118.1', + 'simprint-browser-144.0.7559.118.zip', + '上传浏览器内核版本。', + 'windows', + 'versions/144.0.7559.118.1/simprint-browser-144.0.7559.118.zip', + 'c74ce58537c93e99e4099c94667a21a8e150ac4052c383bc2a095ffdb3b0e075', + 'a864f950c2e77d18ef932f2ad3dbde63039c3b467994daab83081ce6b28c4f82', + NULL, + 184526645, + false, + 'active', + '2026-05-05 17:01:26.746804+08'::timestamptz, + '2026-05-05 17:01:26.746804+08'::timestamptz, + NULL, + NULL, + 'x86_64', + 'zip', + true, + NULL, + NULL +FROM kernel_type +WHERE NOT EXISTS ( + SELECT 1 + FROM public.versions v + WHERE v.type_id = kernel_type.id + AND v.resource_name = 'Chrome 144' + AND v.version = '144.0.7559.118.1' + AND v.platform = 'windows' + AND v.arch = 'x86_64' + AND v.deleted_at IS NULL +); diff --git a/src-tauri/crates/business/migrations/20260511000015_add_site_input_to_environment_cookies.sql b/src-tauri/crates/business/migrations/20260511000015_add_site_input_to_environment_cookies.sql new file mode 100644 index 0000000..a91132e --- /dev/null +++ b/src-tauri/crates/business/migrations/20260511000015_add_site_input_to_environment_cookies.sql @@ -0,0 +1,5 @@ +ALTER TABLE environment_cookies +ADD COLUMN IF NOT EXISTS site_input TEXT NOT NULL DEFAULT ''; + +CREATE INDEX IF NOT EXISTS idx_env_cookies_env_uuid_site_input +ON environment_cookies(environment_uuid, site_input); diff --git a/src-tauri/crates/business/rustfmt.toml b/src-tauri/crates/business/rustfmt.toml new file mode 100644 index 0000000..e79c5a6 --- /dev/null +++ b/src-tauri/crates/business/rustfmt.toml @@ -0,0 +1,17 @@ +# rustfmt 配置文件 +# 注意:某些配置选项需要 nightly 版本,这里只使用稳定版本支持的选项 +edition = "2021" +max_width = 100 +tab_spaces = 4 +newline_style = "Unix" +use_small_heuristics = "Default" +hard_tabs = false +chain_width = 80 +# 以下选项需要 nightly 版本,已注释 +# wrap_comments = true +# format_code_in_doc_comments = true +# format_strings = true +# format_macro_matchers = true +# format_macro_bodies = true +# format_macro_definitions = false + diff --git a/src-tauri/crates/business/src/app.rs b/src-tauri/crates/business/src/app.rs new file mode 100644 index 0000000..e784aa5 --- /dev/null +++ b/src-tauri/crates/business/src/app.rs @@ -0,0 +1,113 @@ +use std::{future::Future, net::SocketAddr}; + +use axum::{Router, middleware}; + +use crate::{ + init_encrypt_secret, middlewares, + routes::route::MetaRoute, + routes::{ + accounts, audit, billing, browser_kernel, environments, extensions, group_permissions, + local_api, messages, preferences, proxies, proxy_visibility, referral, rpa, secret, teams, + templates, time, users, workspace_quotas, workspaces, + }, + svc_ctx::SvcCtx, + utils::IConfig, +}; + +static MIGRATOR: sqlx::migrate::Migrator = sqlx::migrate!(); + +/// Start the service using its configured port on every network interface. +/// +/// This preserves the original standalone server behavior. Embedded callers +/// should prefer [`serve_on`] or [`serve_on_with_shutdown`] and bind to a +/// loopback address explicitly. +pub async fn serve(config: IConfig) -> anyhow::Result<()> { + let address = SocketAddr::from(([0, 0, 0, 0], config.app.port)); + serve_on(config, address).await +} + +/// Start the service on an explicit address. +pub async fn serve_on(config: IConfig, address: SocketAddr) -> anyhow::Result<()> { + serve_on_with_shutdown(config, address, std::future::pending()).await +} + +/// Start the service on an explicit address and stop it gracefully when the +/// supplied shutdown future completes. +pub async fn serve_on_with_shutdown( + config: IConfig, + address: SocketAddr, + shutdown: F, +) -> anyhow::Result<()> +where + F: Future + Send + 'static, +{ + let svc_ctx = SvcCtx::new(&config).await?; + tracing::info!("Running embedded database migrations"); + MIGRATOR.run(&svc_ctx.db).await?; + tracing::info!("Embedded database migrations completed"); + + init_encrypt_secret(&config).await; + + let listener = tokio::net::TcpListener::bind(address).await?; + let bound_address = listener.local_addr()?; + + let app = register_all_routes(&svc_ctx); + let app = register_middlewares(&svc_ctx, app); + let app = app.with_state(svc_ctx); + + tracing::info!("Starting server on {}", bound_address); + + axum::serve(listener, app).with_graceful_shutdown(shutdown).await?; + Ok(()) +} + +fn register_all_routes(svc_ctx: &SvcCtx) -> Router { + let mut meta_route = MetaRoute::new(svc_ctx.config.app.prefix.clone()); + + secret::register_routes(&mut meta_route); + time::register_routes(&mut meta_route); + users::register_routes(&mut meta_route); + local_api::register_routes(&mut meta_route); + workspaces::register_routes(&mut meta_route); + workspace_quotas::register_routes(&mut meta_route); + teams::register_routes(&mut meta_route); + browser_kernel::register_routes(&mut meta_route); + environments::register_routes(&mut meta_route); + proxies::register_routes(&mut meta_route); + proxy_visibility::register_routes(&mut meta_route); + group_permissions::register_routes(&mut meta_route); + accounts::register_routes(&mut meta_route); + templates::register_routes(&mut meta_route); + billing::register_routes(&mut meta_route); + audit::register_routes(&mut meta_route); + rpa::register_routes(&mut meta_route); + referral::register_routes(&mut meta_route); + extensions::register_routes(&mut meta_route); + preferences::register_routes(&mut meta_route); + messages::register_routes(&mut meta_route); + + tracing::info!("---------- {:?} ----------", meta_route.count()); + meta_route.build() +} + +fn register_middlewares(svc_ctx: &SvcCtx, app: Router) -> Router { + app.route_layer(middleware::from_fn_with_state( + svc_ctx.clone(), + middlewares::encrypt, + )) + .route_layer(middleware::from_fn_with_state( + svc_ctx.clone(), + middlewares::auth, + )) + .route_layer(middleware::from_fn_with_state( + svc_ctx.clone(), + middlewares::local_api_auth, + )) + .route_layer(middleware::from_fn_with_state( + svc_ctx.clone(), + middlewares::decrypt, + )) + .route_layer(middleware::from_fn(middlewares::real_ip)) + .route_layer(middleware::from_fn(middlewares::logger)) + .layer(middlewares::cors()) +} diff --git a/src-tauri/crates/business/src/caches/local_api.rs b/src-tauri/crates/business/src/caches/local_api.rs new file mode 100644 index 0000000..1f71b35 --- /dev/null +++ b/src-tauri/crates/business/src/caches/local_api.rs @@ -0,0 +1,204 @@ +use chrono::Utc; +use serde::{Deserialize, Serialize}; +use std::time::Duration; +use uuid::Uuid; + +use crate::dto::LocalApiPermissionDefinitionDto; +use crate::svc_ctx::SvcCtx; + +const LOCAL_API_KEY_CACHE_PREFIX: &str = "local_api:key"; +const LOCAL_API_PERMISSION_CACHE_PREFIX: &str = "local_api:permission"; +const LOCAL_API_PERMISSION_DEFINITION_CACHE_PREFIX: &str = "local_api:permission_definition"; +const LOCAL_API_RATE_CACHE_PREFIX: &str = "local_api:rate"; +const LOCAL_API_KEY_CACHE_TTL: u64 = 60 * 10; +const LOCAL_API_PERMISSION_CACHE_TTL: u64 = 60 * 10; +const MINUTE_WINDOW_SECONDS: i64 = 60; +const HOUR_WINDOW_SECONDS: i64 = 60 * 60; +const DAY_WINDOW_SECONDS: i64 = 60 * 60 * 24; + +#[derive(Debug, Clone, Serialize, Deserialize)] +pub struct LocalApiKeyCache { + pub id: i32, + pub user_uuid: Uuid, + pub is_active: bool, + pub expires_at: Option>, + pub daily_limit: i32, +} + +#[derive(Debug, Clone, Serialize, Deserialize)] +pub struct LocalApiPermissionCache { + pub is_enabled: bool, + pub rate_limit_per_minute: i32, + pub rate_limit_per_hour: i32, +} + +fn local_api_key_cache_key(key_hash: &str) -> String { + format!("{}:{}", LOCAL_API_KEY_CACHE_PREFIX, key_hash) +} + +fn local_api_permission_cache_key(api_key_id: i32, permission_code: &str) -> String { + format!( + "{}:{}:{}", + LOCAL_API_PERMISSION_CACHE_PREFIX, api_key_id, permission_code + ) +} + +fn local_api_permission_definition_cache_key(permission_code: &str) -> String { + format!( + "{}:{}", + LOCAL_API_PERMISSION_DEFINITION_CACHE_PREFIX, permission_code + ) +} + +fn local_api_rate_cache_key( + api_key_id: i32, + permission_code: Option<&str>, + window_type: &str, + window_key: &str, +) -> String { + if let Some(permission_code) = permission_code { + format!( + "{}:{}:{}:{}:{}", + LOCAL_API_RATE_CACHE_PREFIX, api_key_id, permission_code, window_type, window_key + ) + } else { + format!( + "{}:{}:{}:{}", + LOCAL_API_RATE_CACHE_PREFIX, api_key_id, window_type, window_key + ) + } +} + +pub async fn get_local_api_key_cache( + svc_ctx: &SvcCtx, + key_hash: &str, +) -> Result, anyhow::Error> { + let key = local_api_key_cache_key(key_hash); + svc_ctx.cache.get_json(&key).await +} + +pub async fn set_local_api_key_cache( + svc_ctx: &SvcCtx, + key_hash: &str, + cache: &LocalApiKeyCache, +) -> Result<(), anyhow::Error> { + let key = local_api_key_cache_key(key_hash); + svc_ctx + .cache + .set_json(key, cache, Duration::from_secs(LOCAL_API_KEY_CACHE_TTL)) + .await +} + +pub async fn delete_local_api_key_cache( + svc_ctx: &SvcCtx, + key_hash: &str, +) -> Result<(), anyhow::Error> { + let key = local_api_key_cache_key(key_hash); + svc_ctx.cache.delete(&key).await +} + +pub async fn get_local_api_permission_cache( + svc_ctx: &SvcCtx, + api_key_id: i32, + permission_code: &str, +) -> Result, anyhow::Error> { + let key = local_api_permission_cache_key(api_key_id, permission_code); + svc_ctx.cache.get_json(&key).await +} + +pub async fn set_local_api_permission_cache( + svc_ctx: &SvcCtx, + api_key_id: i32, + permission_code: &str, + cache: &LocalApiPermissionCache, +) -> Result<(), anyhow::Error> { + let key = local_api_permission_cache_key(api_key_id, permission_code); + svc_ctx + .cache + .set_json( + key, + cache, + Duration::from_secs(LOCAL_API_PERMISSION_CACHE_TTL), + ) + .await +} + +pub async fn delete_local_api_permission_cache( + svc_ctx: &SvcCtx, + api_key_id: i32, + permission_code: &str, +) -> Result<(), anyhow::Error> { + let key = local_api_permission_cache_key(api_key_id, permission_code); + svc_ctx.cache.delete(&key).await +} + +pub async fn delete_local_api_permission_caches_for_key( + svc_ctx: &SvcCtx, + api_key_id: i32, +) -> Result<(), anyhow::Error> { + let prefix = format!("{}:{}:", LOCAL_API_PERMISSION_CACHE_PREFIX, api_key_id); + svc_ctx.cache.delete_prefix(&prefix).await +} + +pub async fn get_local_api_permission_definition_cache( + svc_ctx: &SvcCtx, + permission_code: &str, +) -> Result, anyhow::Error> { + let key = local_api_permission_definition_cache_key(permission_code); + svc_ctx.cache.get_json(&key).await +} + +pub async fn set_local_api_permission_definition_cache( + svc_ctx: &SvcCtx, + permission_code: &str, + cache: &LocalApiPermissionDefinitionDto, +) -> Result<(), anyhow::Error> { + let key = local_api_permission_definition_cache_key(permission_code); + svc_ctx + .cache + .set_json( + key, + cache, + Duration::from_secs(LOCAL_API_PERMISSION_CACHE_TTL), + ) + .await +} + +pub async fn delete_local_api_permission_definition_cache( + svc_ctx: &SvcCtx, + permission_code: &str, +) -> Result<(), anyhow::Error> { + let key = local_api_permission_definition_cache_key(permission_code); + svc_ctx.cache.delete(&key).await +} + +pub async fn get_local_api_rate_count( + svc_ctx: &SvcCtx, + api_key_id: i32, + permission_code: Option<&str>, + window_type: &str, + window_key: &str, +) -> Result { + let key = local_api_rate_cache_key(api_key_id, permission_code, window_type, window_key); + Ok(svc_ctx.cache.get_i64(&key).await?.unwrap_or(0)) +} + +pub async fn increment_local_api_rate_count( + svc_ctx: &SvcCtx, + api_key_id: i32, + permission_code: Option<&str>, + window_type: &str, + window_key: &str, +) -> Result { + let key = local_api_rate_cache_key(api_key_id, permission_code, window_type, window_key); + let ttl = match window_type { + "minute" => MINUTE_WINDOW_SECONDS, + "hour" => HOUR_WINDOW_SECONDS, + "day" => DAY_WINDOW_SECONDS, + _ => DAY_WINDOW_SECONDS, + }; + svc_ctx + .cache + .increment(&key, Duration::from_secs(ttl as u64)) + .await +} diff --git a/src-tauri/crates/business/src/caches/mod.rs b/src-tauri/crates/business/src/caches/mod.rs new file mode 100644 index 0000000..f0165b0 --- /dev/null +++ b/src-tauri/crates/business/src/caches/mod.rs @@ -0,0 +1,7 @@ +pub mod local_api; +pub mod store; +pub mod user; + +pub use local_api::*; +pub use store::*; +pub use user::*; diff --git a/src-tauri/crates/business/src/caches/store.rs b/src-tauri/crates/business/src/caches/store.rs new file mode 100644 index 0000000..8bd4b14 --- /dev/null +++ b/src-tauri/crates/business/src/caches/store.rs @@ -0,0 +1,221 @@ +use std::{ + sync::{ + Arc, + atomic::{AtomicUsize, Ordering}, + }, + time::{Duration, Instant}, +}; + +use dashmap::{DashMap, mapref::entry::Entry}; +use serde::{Serialize, de::DeserializeOwned}; + +#[derive(Clone)] +pub struct CacheStore(Arc); + +#[derive(Default)] +pub struct MemoryCache { + entries: DashMap, + operations: AtomicUsize, +} + +struct CacheEntry { + value: String, + expires_at: Option, +} + +impl CacheEntry { + fn is_expired(&self, now: Instant) -> bool { + self.expires_at.is_some_and(|expires_at| expires_at <= now) + } +} + +impl CacheStore { + pub fn memory() -> Self { + Self(Arc::new(MemoryCache::default())) + } + + pub async fn get_string(&self, key: &str) -> Result, anyhow::Error> { + Ok(self.0.get(key)) + } + + pub async fn set_string( + &self, + key: impl Into, + value: impl Into, + ttl: Duration, + ) -> Result<(), anyhow::Error> { + let key = key.into(); + let value = value.into(); + self.0.set(key, value, ttl); + Ok(()) + } + + pub async fn get_json(&self, key: &str) -> Result, anyhow::Error> + where + T: DeserializeOwned, + { + self.get_string(key) + .await? + .map(|value| serde_json::from_str(&value)) + .transpose() + .map_err(Into::into) + } + + pub async fn set_json( + &self, + key: impl Into, + value: &T, + ttl: Duration, + ) -> Result<(), anyhow::Error> + where + T: Serialize, + { + self.set_string(key, serde_json::to_string(value)?, ttl).await + } + + pub async fn delete(&self, key: &str) -> Result<(), anyhow::Error> { + self.0.entries.remove(key); + Ok(()) + } + + pub async fn delete_prefix(&self, prefix: &str) -> Result<(), anyhow::Error> { + self.0.delete_prefix(prefix); + Ok(()) + } + + pub async fn get_i64(&self, key: &str) -> Result, anyhow::Error> { + self.get_string(key) + .await? + .map(|value| value.parse::()) + .transpose() + .map_err(Into::into) + } + + pub async fn increment(&self, key: &str, ttl: Duration) -> Result { + Ok(self.0.increment(key, ttl)?) + } +} + +impl MemoryCache { + fn get(&self, key: &str) -> Option { + self.maybe_prune(); + let now = Instant::now(); + let value = self.entries.get(key).and_then(|entry| { + if entry.is_expired(now) { + None + } else { + Some(entry.value.clone()) + } + }); + + if value.is_none() { + self.entries.remove_if(key, |_, entry| entry.is_expired(now)); + } + value + } + + fn set(&self, key: String, value: String, ttl: Duration) { + self.maybe_prune(); + self.entries.insert( + key, + CacheEntry { + value, + expires_at: Some(Instant::now() + ttl), + }, + ); + } + + fn delete_prefix(&self, prefix: &str) { + let keys = self + .entries + .iter() + .filter(|entry| entry.key().starts_with(prefix)) + .map(|entry| entry.key().clone()) + .collect::>(); + for key in keys { + self.entries.remove(&key); + } + } + + fn increment(&self, key: &str, ttl: Duration) -> Result { + self.maybe_prune(); + let now = Instant::now(); + match self.entries.entry(key.to_string()) { + Entry::Occupied(mut entry) if !entry.get().is_expired(now) => { + let count = entry.get().value.parse::()? + 1; + entry.get_mut().value = count.to_string(); + Ok(count) + } + Entry::Occupied(mut entry) => { + entry.insert(CacheEntry { + value: "1".to_string(), + expires_at: Some(now + ttl), + }); + Ok(1) + } + Entry::Vacant(entry) => { + entry.insert(CacheEntry { + value: "1".to_string(), + expires_at: Some(now + ttl), + }); + Ok(1) + } + } + } + + fn maybe_prune(&self) { + const PRUNE_INTERVAL: usize = 256; + if self.operations.fetch_add(1, Ordering::Relaxed) % PRUNE_INTERVAL != 0 { + return; + } + + let now = Instant::now(); + self.entries.retain(|_, entry| !entry.is_expired(now)); + } +} + +#[cfg(test)] +mod tests { + use super::CacheStore; + use serde::{Deserialize, Serialize}; + use std::time::Duration; + + #[derive(Debug, PartialEq, Serialize, Deserialize)] + struct Value { + name: String, + } + + #[tokio::test] + async fn memory_cache_supports_values_prefixes_and_counters() { + let cache = CacheStore::memory(); + cache + .set_json( + "group:one", + &Value { name: "one".into() }, + Duration::from_secs(30), + ) + .await + .unwrap(); + cache.set_string("group:two", "two", Duration::from_secs(30)).await.unwrap(); + + assert_eq!( + cache.get_json::("group:one").await.unwrap(), + Some(Value { name: "one".into() }) + ); + assert_eq!( + cache.increment("counter", Duration::from_secs(30)).await.unwrap(), + 1 + ); + assert_eq!( + cache.increment("counter", Duration::from_secs(30)).await.unwrap(), + 2 + ); + + cache.delete_prefix("group:").await.unwrap(); + assert_eq!(cache.get_string("group:one").await.unwrap(), None); + assert_eq!(cache.get_string("group:two").await.unwrap(), None); + + cache.set_string("expired", "value", Duration::ZERO).await.unwrap(); + assert_eq!(cache.get_string("expired").await.unwrap(), None); + } +} diff --git a/src-tauri/crates/business/src/caches/user.rs b/src-tauri/crates/business/src/caches/user.rs new file mode 100644 index 0000000..81ac708 --- /dev/null +++ b/src-tauri/crates/business/src/caches/user.rs @@ -0,0 +1,75 @@ +use crate::svc_ctx::SvcCtx; +use std::time::Duration; +use uuid::Uuid; + +pub(crate) const REGISTER_CODE_CACHE_KEY: &str = "verification:code:register:send"; +pub(crate) const RESET_PASSWORD_CODE_CACHE_KEY: &str = "verification:code:reset_password:send"; +pub(crate) const CODE_EXPIRATION: u64 = 60 * 5; // 5 分钟过期 +pub(crate) const USER_PUBLIC_KEY_CACHE_KEY: &str = "user:public_key"; +pub(crate) const PUBLIC_KEY_EXPIRATION: u64 = 60 * 60 * 24 * 7; // 7 天过期(与 refresh token 一致) + +/// 设置用户注册的验证码 +pub async fn set_register_code( + svc_ctx: &SvcCtx, + email: &str, + code: &str, +) -> Result<(), anyhow::Error> { + let key = format!("{}:{}", REGISTER_CODE_CACHE_KEY, email); + svc_ctx + .cache + .set_string(key, code, Duration::from_secs(CODE_EXPIRATION)) + .await +} + +/// 获取用户注册的验证码 +pub async fn get_register_code( + svc_ctx: &SvcCtx, + email: &str, +) -> Result, anyhow::Error> { + let key = format!("{}:{}", REGISTER_CODE_CACHE_KEY, email); + svc_ctx.cache.get_string(&key).await +} + +/// 设置重置密码的验证码 +pub async fn set_reset_password_code( + svc_ctx: &SvcCtx, + email: &str, + code: &str, +) -> Result<(), anyhow::Error> { + let key = format!("{}:{}", RESET_PASSWORD_CODE_CACHE_KEY, email); + svc_ctx + .cache + .set_string(key, code, Duration::from_secs(CODE_EXPIRATION)) + .await +} + +/// 获取重置密码的验证码 +pub async fn get_reset_password_code( + svc_ctx: &SvcCtx, + email: &str, +) -> Result, anyhow::Error> { + let key = format!("{}:{}", RESET_PASSWORD_CODE_CACHE_KEY, email); + svc_ctx.cache.get_string(&key).await +} + +/// 设置用户公钥 +pub async fn set_user_public_key( + svc_ctx: &SvcCtx, + user_uuid: &Uuid, + public_key: &str, +) -> Result<(), anyhow::Error> { + let key = format!("{}:{}", USER_PUBLIC_KEY_CACHE_KEY, user_uuid); + svc_ctx + .cache + .set_string(key, public_key, Duration::from_secs(PUBLIC_KEY_EXPIRATION)) + .await +} + +/// 获取用户公钥 +pub async fn get_user_public_key( + svc_ctx: &SvcCtx, + user_uuid: &Uuid, +) -> Result, anyhow::Error> { + let key = format!("{}:{}", USER_PUBLIC_KEY_CACHE_KEY, user_uuid); + svc_ctx.cache.get_string(&key).await +} diff --git a/src-tauri/crates/business/src/cli.rs b/src-tauri/crates/business/src/cli.rs new file mode 100644 index 0000000..6df61d2 --- /dev/null +++ b/src-tauri/crates/business/src/cli.rs @@ -0,0 +1,33 @@ +use clap::{Parser, Subcommand}; + +/// Simprint Server CLI +#[derive(Debug, Parser)] +#[command(name = "simprint-server")] +#[command(author = "Simprint Team")] +#[command(version)] +#[command(about = "Simprint Server - 客户端网关服务")] +pub struct Cli { + /// 配置文件路径 + #[arg(short = 'f', long = "config", help = "配置文件路径 (TOML 格式)")] + pub config: String, + + /// 子命令 + #[command(subcommand)] + pub command: Option, +} + +/// 可用的子命令 +#[derive(Debug, Subcommand)] +pub enum Commands { + /// 启动 HTTP 服务 + Serve, +} + +impl Cli { + /// 获取命令,如果没有指定则默认为 Serve + pub fn command_or_default(self) -> (String, Commands) { + let config = self.config; + let command = self.command.unwrap_or(Commands::Serve); + (config, command) + } +} diff --git a/src-tauri/crates/business/src/database.rs b/src-tauri/crates/business/src/database.rs new file mode 100644 index 0000000..b075596 --- /dev/null +++ b/src-tauri/crates/business/src/database.rs @@ -0,0 +1,25 @@ +use sqlx::{Postgres, postgres::PgPoolOptions}; + +use crate::utils::DatabaseConfig; + +/// Database engine currently used by the imported service models. +/// +/// Keeping this alias in one module gives the SQLite migration a single +/// boundary while the PostgreSQL-specific queries are converted domain by +/// domain. +pub type Db = Postgres; +pub type Pool = sqlx::Pool; +pub type DbPool = Pool; + +pub async fn connect(config: &DatabaseConfig) -> anyhow::Result { + let pool = PgPoolOptions::new() + .max_lifetime(std::time::Duration::from_secs(config.max_lifetime)) + .idle_timeout(std::time::Duration::from_secs(config.idle_timeout)) + .acquire_timeout(std::time::Duration::from_secs(config.acquire_timeout)) + .max_connections(config.max_connections) + .min_connections(config.min_connections) + .connect(&config.url) + .await?; + + Ok(pool) +} diff --git a/src-tauri/crates/business/src/dto/accounts.rs b/src-tauri/crates/business/src/dto/accounts.rs new file mode 100644 index 0000000..918d883 --- /dev/null +++ b/src-tauri/crates/business/src/dto/accounts.rs @@ -0,0 +1,35 @@ +use chrono::{DateTime, Utc}; +use serde::{Deserialize, Serialize}; +use sqlx::FromRow; +use uuid::Uuid; + +/// 平台账号 DTO +#[derive(Debug, Clone, FromRow, Serialize, Deserialize)] +pub struct PlatformAccountDto { + pub id: i32, + pub uuid: Uuid, + pub user_uuid: Uuid, + pub team_uuid: Option, + pub platform_url: String, + pub platform_name: Option, + pub account: String, + pub password: Option, + pub status: String, + pub remark: Option, + pub usage_count: Option, + pub environments_count: Option, + pub last_used_at: Option>, + pub created_at: DateTime, + pub updated_at: DateTime, + pub deleted_at: Option>, +} + +/// 环境账号关联 DTO +#[derive(Debug, Clone, FromRow, Serialize)] +pub struct EnvironmentAccountDto { + pub id: i32, + pub environment_uuid: Uuid, + pub account_uuid: Uuid, + pub sort_order: Option, + pub created_at: DateTime, +} diff --git a/src-tauri/crates/business/src/dto/audit.rs b/src-tauri/crates/business/src/dto/audit.rs new file mode 100644 index 0000000..3ec1b06 --- /dev/null +++ b/src-tauri/crates/business/src/dto/audit.rs @@ -0,0 +1,26 @@ +use chrono::{DateTime, Utc}; +use serde::Serialize; +use sqlx::FromRow; +use uuid::Uuid; + +/// 审计日志 DTO +#[derive(Debug, Clone, FromRow, Serialize)] +pub struct AuditLogDto { + pub id: i64, + pub uuid: Uuid, + pub user_uuid: Uuid, + pub team_uuid: Option, + pub action: String, + pub target_type: String, + pub target_uuid: Option, + pub target_name: Option, + pub details: Option, + pub changes: Option, + pub ip_address: Option, + pub user_agent: Option, + pub request_id: Option, + pub created_at: DateTime, + // 用户信息(联表查询) + pub user_name: Option, + pub user_email: Option, +} diff --git a/src-tauri/crates/business/src/dto/billing.rs b/src-tauri/crates/business/src/dto/billing.rs new file mode 100644 index 0000000..38189a5 --- /dev/null +++ b/src-tauri/crates/business/src/dto/billing.rs @@ -0,0 +1,236 @@ +use chrono::{DateTime, NaiveDate, Utc}; +use rust_decimal::Decimal; +use serde::Serialize; +use sqlx::FromRow; +use uuid::Uuid; + +/// 套餐 DTO +#[derive(Debug, Clone, FromRow, Serialize)] +pub struct PlanDto { + pub id: i32, + pub uuid: Uuid, + pub name: String, + pub description: Option, + pub price_per_month: Decimal, + pub price_per_year: Decimal, + pub currency: String, + pub discount_monthly: Option, + pub discount_yearly: Option, + pub max_environments: i32, + pub max_team_members: i32, + pub max_proxies: i32, + pub max_rpa_tasks: i32, + pub is_recommended: Option, + pub sort_order: Option, + pub status: String, + pub created_at: DateTime, + pub updated_at: DateTime, +} + +/// 套餐特性 DTO +#[derive(Debug, Clone, FromRow, Serialize)] +pub struct PlanFeatureDto { + pub id: i32, + pub plan_uuid: Uuid, + pub feature_key: String, + pub feature_name: String, + pub feature_value: Option, + pub is_included: Option, + pub sort_order: Option, + pub created_at: DateTime, +} + +/// 订阅 DTO +#[derive(Debug, Clone, FromRow, Serialize)] +pub struct SubscriptionDto { + pub id: i32, + pub uuid: Uuid, + pub workspace_uuid: Uuid, + pub user_uuid: Uuid, + pub plan_uuid: Uuid, + pub billing_period: String, + pub price: Decimal, + pub currency: String, + pub started_at: DateTime, + pub expires_at: DateTime, + pub next_billing_date: Option, + pub auto_renew: Option, + pub status: String, + pub cancelled_at: Option>, + pub created_at: DateTime, + pub updated_at: DateTime, +} + +/// 用户钱包 DTO +#[derive(Debug, Clone, FromRow, Serialize)] +pub struct UserWalletDto { + pub id: i32, + pub user_uuid: Uuid, + pub balance: Decimal, + pub currency: String, + pub frozen_amount: Decimal, + pub auto_renewal_combined: Decimal, + pub created_at: DateTime, + pub updated_at: DateTime, +} + +/// 钱包交易 DTO +#[derive(Debug, Clone, FromRow, Serialize)] +pub struct WalletTransactionDto { + pub id: i64, + pub uuid: Uuid, + pub user_uuid: Uuid, + pub transaction_type: String, + pub amount: Decimal, + pub currency: String, + pub balance_before: Decimal, + pub balance_after: Decimal, + pub description: Option, + pub order_uuid: Option, + pub status: String, + pub created_at: DateTime, +} + +/// 发票 DTO +#[derive(Debug, Clone, FromRow, Serialize)] +pub struct InvoiceDto { + pub id: i32, + pub uuid: Uuid, + pub user_uuid: Uuid, + pub invoice_number: String, + pub amount: Decimal, + pub currency: String, + pub subscription_uuid: Option, + pub order_uuid: Option, + pub invoice_type: String, + pub status: String, + pub issued_at: Option>, + pub due_at: Option>, + pub paid_at: Option>, + pub invoice_url: Option, + pub created_at: DateTime, + pub updated_at: DateTime, +} + +/// 用户配额 DTO +#[derive(Debug, Clone, FromRow, Serialize)] +pub struct UserQuotaDto { + pub id: i32, + pub user_uuid: Uuid, + pub max_environments: i32, + pub used_environments: i32, + pub max_team_members: i32, + pub max_proxies: i32, + pub used_proxies: i32, + pub max_rpa_tasks: i32, + pub used_rpa_tasks: i32, + pub created_at: DateTime, + pub updated_at: DateTime, +} + +/// 优惠券 DTO +#[derive(Debug, Clone, FromRow, Serialize)] +pub struct CouponDto { + pub id: i32, + pub uuid: Uuid, + pub code: String, + pub name: Option, + pub description: Option, + pub discount_type: String, + pub discount_value: Decimal, + pub min_amount: Option, + pub max_discount: Option, + pub max_uses: Option, + pub used_count: i32, + pub max_uses_per_user: Option, + pub valid_from: DateTime, + pub valid_until: Option>, + pub applicable_to: String, + pub status: String, + pub created_at: DateTime, + pub updated_at: DateTime, +} + +/// 优惠券使用记录 DTO +#[derive(Debug, Clone, FromRow, Serialize)] +pub struct CouponUsageDto { + pub id: i32, + pub coupon_uuid: Uuid, + pub user_uuid: Uuid, + pub order_uuid: Option, + pub discount_amount: Decimal, + pub used_at: DateTime, +} + +/// 用户优惠券 DTO +#[derive(Debug, Clone, FromRow, Serialize)] +pub struct UserCouponDto { + pub id: i32, + pub user_uuid: Uuid, + pub coupon_uuid: Uuid, + pub status: String, + pub issued_at: DateTime, + pub used_at: Option>, + pub expires_at: Option>, +} + +/// 用户优惠券详细信息 DTO(包含优惠券详情) +#[derive(Debug, Clone, Serialize)] +pub struct UserCouponWithDetailsDto { + pub id: i32, + pub user_uuid: Uuid, + pub coupon_uuid: Uuid, + pub status: String, + pub issued_at: DateTime, + pub used_at: Option>, + pub expires_at: Option>, + // 优惠券详细信息 + pub code: String, + pub name: Option, + pub description: Option, + pub discount_type: String, + pub discount_value: Decimal, + pub min_amount: Option, + pub max_discount: Option, +} + +/// 支付订单 DTO +#[derive(Debug, Clone, FromRow, Serialize)] +pub struct PaymentOrderDto { + pub id: i64, + pub uuid: Uuid, + pub order_no: String, + pub user_uuid: Uuid, + pub order_type: String, + pub amount: Decimal, + pub currency: String, + pub status: String, + pub payment_channel: Option, + pub external_order_id: Option, + pub description: Option, + pub subscription_uuid: Option, + pub coupon_uuid: Option, + pub original_amount: Option, + pub discount_amount: Option, + pub paid_at: Option>, + pub refunded_at: Option>, + pub created_at: DateTime, + pub updated_at: DateTime, +} + +/// 自动续费服务 DTO +#[derive(Debug, Clone, FromRow, Serialize)] +pub struct AutoRenewalServiceDto { + pub id: i32, + pub uuid: Uuid, + pub user_uuid: Uuid, + pub service_type: String, + pub service_uuid: Option, + pub service_name: String, + pub renewal_price: Decimal, + pub currency: String, + pub next_bill_date: NaiveDate, + pub status: String, + pub created_at: DateTime, + pub updated_at: DateTime, +} diff --git a/src-tauri/crates/business/src/dto/environments.rs b/src-tauri/crates/business/src/dto/environments.rs new file mode 100644 index 0000000..5775493 --- /dev/null +++ b/src-tauri/crates/business/src/dto/environments.rs @@ -0,0 +1,307 @@ +use chrono::{DateTime, Utc}; +use serde::{Deserialize, Serialize}; +use sqlx::FromRow; +use uuid::Uuid; + +/// 分组 DTO +#[derive(Debug, Clone, FromRow, Serialize)] +pub struct GroupDto { + pub id: i32, + pub uuid: Uuid, + pub workspace_uuid: Uuid, + pub team_uuid: Uuid, + pub team_name: Option, + pub name: String, + pub description: Option, + pub sort_order: Option, + pub created_by: Option, + pub created_by_name: Option, + pub environments_count: Option, + pub created_at: DateTime, + pub updated_at: DateTime, + pub deleted_at: Option>, +} + +/// 标签 DTO +#[derive(Debug, Clone, FromRow, Serialize, Deserialize)] +pub struct TagDto { + pub id: i32, + pub uuid: Uuid, + pub user_uuid: Uuid, + pub team_uuid: Option, + pub name: String, + pub color: Option, + pub sort_order: Option, + pub environments_count: Option, + pub created_at: DateTime, + pub updated_at: DateTime, + pub deleted_at: Option>, +} + +/// 环境 DTO +#[derive(Debug, Clone, FromRow, Serialize, Deserialize)] +pub struct EnvironmentDto { + pub id: i32, + pub uuid: Uuid, + pub workspace_uuid: Uuid, + pub user_uuid: Uuid, + pub team_uuid: Uuid, + pub name: String, + pub description: Option, + pub status: String, + pub group_uuid: Option, + pub proxy_uuid: Option, + pub system_info: Option, + pub kernel_info: Option, + pub fingerprint_summary: Option, + pub last_opened_at: Option>, + pub created_at: DateTime, + pub updated_at: DateTime, + pub deleted_at: Option>, +} + +/// 环境列表行(基础查询结果) +#[derive(Debug, Clone, sqlx::FromRow)] +pub struct EnvironmentRowDto { + pub id: i32, + pub uuid: Uuid, + pub workspace_uuid: Uuid, + pub user_uuid: Uuid, + pub team_uuid: Uuid, + pub name: String, + pub description: Option, + pub status: String, + pub system_info: Option, + pub kernel_info: Option, + pub fingerprint_summary: Option, + pub group_uuid: Option, + pub proxy_uuid: Option, + pub last_opened_at: Option>, + pub created_at: DateTime, + pub updated_at: DateTime, +} + +/// 环境标签关联行(用于批量查询) +#[derive(Debug, Clone, sqlx::FromRow)] +pub struct EnvironmentTagRowDto { + pub environment_uuid: Uuid, + pub tag_id: i32, + pub tag_uuid: Uuid, + pub tag_name: String, + pub tag_color: Option, + pub tag_sort_order: Option, + pub tag_user_uuid: Uuid, + pub tag_team_uuid: Option, + pub tag_environments_count: Option, + pub tag_created_at: chrono::DateTime, + pub tag_updated_at: chrono::DateTime, + pub tag_deleted_at: Option>, +} + +/// 环境账号关联行(用于批量查询) +#[derive(Debug, Clone, sqlx::FromRow)] +pub struct EnvironmentAccountRowDto { + pub environment_uuid: Uuid, + pub account_id: i32, + pub account_uuid: Uuid, + pub platform_url: String, + pub platform_name: Option, + pub account: String, + pub account_status: String, + pub remark: Option, +} + +/// 分组查询行(用于批量查询) +#[derive(Debug, Clone, sqlx::FromRow)] +pub struct GroupRowDto { + pub id: i32, + pub uuid: Uuid, + pub name: String, + pub description: Option, + pub sort_order: Option, +} + +/// 代理查询行(用于批量查询,排除敏感数据) +#[derive(Debug, Clone, sqlx::FromRow)] +pub struct ProxyRowDto { + pub id: i32, + pub uuid: Uuid, + pub name: String, + pub host: String, + pub port: i32, + pub proxy_type: String, + pub username: Option, + pub password: Option, + pub country: Option, + pub city: Option, + pub status: String, + pub latency: Option, + pub last_check_ip: Option, +} + +/// 代理摘要 DTO(用于环境列表) +#[derive(Debug, Clone, Serialize, Deserialize)] +pub struct ProxySummaryDto { + pub id: i32, + pub uuid: Uuid, + pub name: String, + pub host: String, + pub port: i32, + pub proxy_type: String, + pub username: Option, + pub password: Option, + pub country: Option, + pub city: Option, + pub status: String, + pub latency: Option, + pub last_check_ip: Option, +} + +/// 分组摘要 DTO(用于环境列表) +#[derive(Debug, Clone, Serialize, Deserialize)] +pub struct GroupSummaryDto { + pub id: i32, + pub uuid: Uuid, + pub name: String, + pub description: Option, + pub sort_order: Option, +} + +/// 标签摘要 DTO(用于环境列表) +#[derive(Debug, Clone, Serialize)] +pub struct TagSummaryDto { + pub id: i32, + pub uuid: Uuid, + pub name: String, + pub color: Option, + pub sort_order: Option, +} + +/// 账号摘要 DTO(用于环境列表,排除敏感数据) +#[derive(Debug, Clone, Serialize)] +pub struct AccountSummaryDto { + pub id: i32, + pub uuid: Uuid, + pub platform_url: String, + pub platform_name: Option, + pub account: String, + pub status: String, + pub remark: Option, +} + +/// 环境列表项 DTO(包含完整关联数据) +#[derive(Debug, Clone, Serialize)] +pub struct EnvironmentListItemDto { + // 基础信息 + pub id: i32, + pub uuid: Uuid, + pub name: String, + pub description: Option, + pub status: String, + pub system_info: Option, + pub kernel_info: Option, + pub fingerprint_summary: Option, + pub last_opened_at: Option>, + pub created_at: DateTime, + pub updated_at: DateTime, + // 分组详情(完整对象) + pub group: Option, + // 代理详情(完整对象) + pub proxy: Option, + // 标签列表(完整对象列表,与环境详情接口保持一致) + pub tags: Vec, + // 账号列表(完整对象列表) + pub accounts: Vec, + // 扩展列表(插件列表) + pub extensions: Vec, +} + +/// 扩展摘要 DTO(用于环境列表) +#[derive(Debug, Clone, Serialize, Deserialize)] +pub struct ExtensionSummaryDto { + pub extension_id: String, + pub name: String, + pub version: String, + pub icon_url: Option, + pub download_url: Option, + pub hash: Option, + pub scope: String, // user, team, group-personal, group-team +} + +/// 环境配置 DTO +#[derive(Debug, Clone, FromRow, Serialize, Deserialize)] +pub struct EnvironmentConfigDto { + pub id: i32, + pub environment_uuid: Uuid, + pub window_info: serde_json::Value, + pub basic_settings: serde_json::Value, + pub fingerprint_settings: serde_json::Value, + pub device_settings: serde_json::Value, + pub preference_settings: serde_json::Value, + pub project_metadata: serde_json::Value, + pub created_at: DateTime, + pub updated_at: DateTime, +} + +/// 环境标签关联 DTO +#[derive(Debug, Clone, FromRow)] +pub struct EnvironmentTagDto { + pub id: i32, + pub environment_uuid: Uuid, + pub tag_uuid: Uuid, + pub created_at: DateTime, +} + +/// 环境 URL DTO +#[derive(Debug, Clone, FromRow, Serialize, Deserialize)] +pub struct EnvironmentUrlDto { + pub id: i32, + pub environment_uuid: Uuid, + pub url: String, + pub title: Option, + pub sort_order: Option, + pub created_at: DateTime, +} + +/// 环境 Cookie DTO +#[derive(Debug, Clone, FromRow, Serialize, Deserialize)] +pub struct EnvironmentCookieDto { + pub id: i32, + pub environment_uuid: Uuid, + pub site_input: String, + pub domain: String, + pub name: String, + pub value: String, + pub path: Option, + pub expires_at: Option>, + pub http_only: Option, + pub secure: Option, + pub same_site: Option, + pub created_at: DateTime, +} + +/// 环境 Cookie 分组 DTO +#[derive(Debug, Clone, Serialize, Deserialize)] +pub struct EnvironmentCookieGroupDto { + pub site: String, + pub cookie_text: String, +} + +/// 模板 DTO +#[derive(Debug, Clone, FromRow, Serialize)] +pub struct TemplateDto { + pub id: i32, + pub uuid: Uuid, + pub user_uuid: Uuid, + pub team_uuid: Option, + pub name: String, + pub description: Option, + pub is_public: Option, + pub system_info: Option, + pub kernel_info: Option, + pub config_json: serde_json::Value, + pub usage_count: Option, + pub created_at: DateTime, + pub updated_at: DateTime, + pub deleted_at: Option>, +} diff --git a/src-tauri/crates/business/src/dto/extensions.rs b/src-tauri/crates/business/src/dto/extensions.rs new file mode 100644 index 0000000..7cec132 --- /dev/null +++ b/src-tauri/crates/business/src/dto/extensions.rs @@ -0,0 +1,128 @@ +use chrono::{DateTime, Utc}; +use rust_decimal::Decimal; +use serde::Serialize; +use sqlx::FromRow; +use uuid::Uuid; + +use crate::utils::storage::get_objects; + +/// 扩展 DTO +#[derive(Debug, Clone, FromRow, Serialize)] +pub struct ExtensionDto { + pub id: i32, + pub uuid: Uuid, + pub extension_id: String, + pub name: String, + pub description: Option, + pub version: String, + pub category: String, + pub browser: String, + pub developer: Option, + pub homepage: Option, + pub icon_url: Option, + pub download_url: Option, + pub file_size: Option, + pub downloads_count: Option, + pub rating: Option, + pub permissions: Option, + pub status: String, + pub changelog: Option, + pub published_at: Option>, + pub hash: Option, + pub created_at: DateTime, + pub updated_at: DateTime, +} + +impl ExtensionDto { + /// 将 object path 转换为完整 URL + /// + /// 数据库中存储的是对象存储 object path(如 `ext_id/version/hash.crx`), + /// 返回给客户端时需要组装为完整 URL。 + /// + /// 如果 URL 已经是完整的 http(s) 地址,则不进行转换。 + pub fn transform_urls(&mut self, public_base_url: &str, extension_root: &str) { + // 转换图标 URL + if let Some(path) = &self.icon_url { + if !path.is_empty() && !path.starts_with("http") { + self.icon_url = Some(get_objects::get_extension_icon_url( + public_base_url, + extension_root, + path, + )); + } + } + + // 转换下载 URL + if let Some(path) = &self.download_url { + if !path.is_empty() && !path.starts_with("http") { + self.download_url = Some(get_objects::get_extension_crx_url( + public_base_url, + extension_root, + path, + )); + } + } + } + + /// 批量转换 ExtensionDto 列表中的 object path 为完整 URL + pub fn transform_urls_batch( + extensions: &mut [ExtensionDto], + public_base_url: &str, + extension_root: &str, + ) { + for ext in extensions.iter_mut() { + ext.transform_urls(public_base_url, extension_root); + } + } +} + +/// 用户扩展 DTO +#[derive(Debug, Clone, FromRow, Serialize)] +pub struct UserExtensionDto { + pub id: i32, + pub user_uuid: Uuid, + pub extension_id: String, + pub installed_version: String, + pub status: String, + pub installed_at: DateTime, + pub updated_at: DateTime, +} + +/// 团队扩展 DTO +#[derive(Debug, Clone, FromRow, Serialize)] +pub struct TeamExtensionDto { + pub id: i32, + pub team_uuid: Uuid, + pub extension_id: String, + pub installed_version: String, + pub installed_by: Uuid, + pub status: String, + pub installed_at: DateTime, + pub updated_at: DateTime, +} + +/// 分组扩展 DTO +#[derive(Debug, Clone, FromRow, Serialize)] +pub struct GroupExtensionDto { + pub id: i32, + pub group_uuid: Uuid, + pub extension_id: String, + pub installed_version: String, + pub installed_by: Uuid, + pub status: String, + pub is_team_shared: bool, + pub installed_at: DateTime, + pub updated_at: DateTime, +} + +/// 用户团队插件偏好设置 DTO +#[derive(Debug, Clone, FromRow, Serialize)] +pub struct UserTeamExtensionPreferenceDto { + pub id: i32, + pub user_uuid: Uuid, + pub team_uuid: Uuid, + pub extension_id: String, + pub is_disabled: bool, + pub created_at: DateTime, + pub updated_at: DateTime, +} diff --git a/src-tauri/crates/business/src/dto/group_member_permissions.rs b/src-tauri/crates/business/src/dto/group_member_permissions.rs new file mode 100644 index 0000000..356b82d --- /dev/null +++ b/src-tauri/crates/business/src/dto/group_member_permissions.rs @@ -0,0 +1,32 @@ +use chrono::{DateTime, Utc}; +use serde::Serialize; +use sqlx::FromRow; +use uuid::Uuid; + +/// 分组权限 DTO +#[derive(Debug, Clone, FromRow, Serialize)] +pub struct GroupMemberPermissionDto { + pub group_uuid: Uuid, + pub workspace_uuid: Uuid, + pub team_uuid: Uuid, + pub user_uuid: Uuid, + pub permission_type: String, + pub granted_by: Uuid, + pub created_at: DateTime, + pub updated_at: DateTime, +} + +/// 分组权限详情 DTO(包含用户信息) +#[derive(Debug, Clone, Serialize)] +pub struct GroupMemberPermissionDetailDto { + pub group_uuid: Uuid, + pub workspace_uuid: Uuid, + pub team_uuid: Uuid, + pub user_uuid: Uuid, + pub permission_type: String, + pub granted_by: Uuid, + pub user_name: Option, + pub user_email: Option, + pub created_at: DateTime, + pub updated_at: DateTime, +} diff --git a/src-tauri/crates/business/src/dto/local_api.rs b/src-tauri/crates/business/src/dto/local_api.rs new file mode 100644 index 0000000..ed32129 --- /dev/null +++ b/src-tauri/crates/business/src/dto/local_api.rs @@ -0,0 +1,97 @@ +use chrono::{DateTime, NaiveDate, Utc}; +use serde::{Deserialize, Serialize}; +use serde_json::Value; +use sqlx::FromRow; +use uuid::Uuid; + +#[derive(Debug, Clone, FromRow)] +pub struct LocalApiSettingsDto { + pub id: i32, + pub uuid: Uuid, + pub user_uuid: Uuid, + pub enabled: bool, + pub port: i32, + pub remote_access: bool, + pub cors_origins: Value, + pub created_at: DateTime, + pub updated_at: DateTime, + pub deleted_at: Option>, +} + +#[derive(Debug, Clone, FromRow)] +pub struct LocalApiKeyDto { + pub id: i32, + pub uuid: Uuid, + pub user_uuid: Uuid, + pub key_prefix: String, + pub key_hash: String, + pub api_key: Option, + pub is_active: bool, + pub requests_today: i32, + pub daily_limit: i32, + pub last_reset_date: NaiveDate, + pub last_used_at: Option>, + pub expires_at: Option>, + pub created_at: DateTime, + pub updated_at: DateTime, + pub deleted_at: Option>, +} + +#[derive(Debug, Clone, FromRow)] +pub struct LocalApiKeyPermissionDto { + pub id: i32, + pub uuid: Uuid, + pub api_key_id: i32, + pub permission_code: String, + pub is_enabled: bool, + pub rate_limit_per_minute: i32, + pub rate_limit_per_hour: i32, + pub created_at: DateTime, + pub updated_at: DateTime, + pub deleted_at: Option>, +} + +#[derive(Debug, Clone, FromRow, Serialize, Deserialize)] +pub struct LocalApiPermissionDefinitionDto { + pub id: i32, + pub uuid: Uuid, + pub permission_code: String, + pub name: String, + pub description: Option, + pub default_enabled: bool, + pub default_rate_limit_per_minute: i32, + pub default_rate_limit_per_hour: i32, + pub sort_order: i32, + pub created_at: DateTime, + pub updated_at: DateTime, + pub deleted_at: Option>, +} + +#[derive(Debug, Clone, Serialize)] +#[serde(rename_all = "camelCase")] +pub struct LocalApiConfigDto { + pub enabled: bool, + pub api_key: String, + pub port: i32, + pub remote_access: bool, + pub cors_origins: Vec, + pub requests_today: i32, + pub daily_limit: i32, +} + +#[derive(Debug, Clone, Serialize)] +#[serde(rename_all = "camelCase")] +pub struct ResetLocalApiKeyDto { + pub api_key: String, +} + +#[derive(Debug, Clone, Serialize)] +#[serde(rename_all = "camelCase")] +pub struct ValidateLocalApiKeyDto { + pub valid: bool, + pub permission_code: String, + pub requests_today: i32, + pub daily_limit: i32, + pub rate_limit_per_minute: i32, + pub rate_limit_per_hour: i32, +} diff --git a/src-tauri/crates/business/src/dto/maintenance.rs b/src-tauri/crates/business/src/dto/maintenance.rs new file mode 100644 index 0000000..735555a --- /dev/null +++ b/src-tauri/crates/business/src/dto/maintenance.rs @@ -0,0 +1,49 @@ +use chrono::{DateTime, Utc}; +use serde::{Deserialize, Serialize}; +use std::str::FromStr; + +#[derive(Debug, Clone, Serialize, Deserialize, PartialEq)] +pub enum MaintenanceType { + #[serde(rename = "scheduled")] + Scheduled, + #[serde(rename = "emergency")] + Emergency, + #[serde(rename = "upgrade")] + Upgrade, +} + +impl FromStr for MaintenanceType { + type Err = String; + + fn from_str(s: &str) -> Result { + match s { + "scheduled" => Ok(MaintenanceType::Scheduled), + "emergency" => Ok(MaintenanceType::Emergency), + "upgrade" => Ok(MaintenanceType::Upgrade), + _ => Err(format!("无效的维护类型: {}", s)), + } + } +} + +impl From for String { + fn from(maintenance_type: MaintenanceType) -> Self { + match maintenance_type { + MaintenanceType::Scheduled => "scheduled".to_string(), + MaintenanceType::Emergency => "emergency".to_string(), + MaintenanceType::Upgrade => "upgrade".to_string(), + } + } +} + +#[derive(Debug, Clone, Serialize, Deserialize, sqlx::FromRow)] +pub struct Maintenance { + pub id: i64, + pub name: String, + pub description: Option, + pub status: String, + pub start_time: DateTime, + pub end_time: DateTime, + pub maintenance_type: String, + pub created_at: DateTime, + pub updated_at: DateTime, +} diff --git a/src-tauri/crates/business/src/dto/messages.rs b/src-tauri/crates/business/src/dto/messages.rs new file mode 100644 index 0000000..1227f26 --- /dev/null +++ b/src-tauri/crates/business/src/dto/messages.rs @@ -0,0 +1,60 @@ +use chrono::{DateTime, Utc}; +use serde::Serialize; +use sqlx::FromRow; +use uuid::Uuid; + +/// 消息 DTO +#[derive(Debug, Clone, FromRow, Serialize)] +pub struct MessageDto { + pub id: i32, + pub uuid: Uuid, + pub message_type: String, + pub title: String, + pub content: Option, + pub sender_uuid: Option, + pub recipient_type: String, + pub related_type: Option, + pub related_uuid: Option, + pub metadata: Option, + pub status: String, + pub priority: String, + pub created_at: DateTime, + pub updated_at: DateTime, + pub deleted_at: Option>, +} + +/// 用户消息关联 DTO(包含消息详情和用户状态) +#[derive(Debug, Clone, FromRow, Serialize)] +pub struct UserMessageDto { + // 消息基本信息 + pub message_uuid: Uuid, + pub message_type: String, + pub title: String, + pub content: Option, + pub sender_uuid: Option, + pub related_type: Option, + pub related_uuid: Option, + pub metadata: Option, + pub priority: String, + pub message_created_at: DateTime, + + // 用户消息状态 + pub is_read: bool, + pub read_at: Option>, + pub action_status: Option, + pub action_at: Option>, + + // 发送者信息(可选,通过 JOIN 获取) + #[sqlx(default)] + pub sender_name: Option, + #[sqlx(default)] + pub sender_email: Option, +} + +/// 消息统计 DTO +#[derive(Debug, Clone, Serialize)] +pub struct MessageStatsDto { + pub total: i64, + pub unread: i64, + pub by_type: std::collections::HashMap, +} diff --git a/src-tauri/crates/business/src/dto/mod.rs b/src-tauri/crates/business/src/dto/mod.rs new file mode 100644 index 0000000..4d6b924 --- /dev/null +++ b/src-tauri/crates/business/src/dto/mod.rs @@ -0,0 +1,47 @@ +pub mod maintenance; +pub mod strategy_types; +pub mod user; +pub mod version_types; +pub mod versions; + +// 新增模块 +pub mod accounts; +pub mod audit; +pub mod billing; +pub mod environments; +pub mod extensions; +pub mod group_member_permissions; +pub mod local_api; +pub mod messages; +pub mod proxies; +pub mod proxy_visible_teams; +pub mod referral; +pub mod rpa; +pub mod system; +pub mod teams; +pub mod workspace_quotas; +pub mod workspaces; + +pub use maintenance::*; +pub use strategy_types::*; +pub use user::*; +pub use version_types::*; +pub use versions::*; + +// 新增导出 +pub use accounts::*; +pub use audit::*; +pub use billing::*; +pub use environments::*; +pub use extensions::*; +pub use group_member_permissions::*; +pub use local_api::*; +pub use messages::*; +pub use proxies::*; +pub use proxy_visible_teams::*; +pub use referral::*; +pub use rpa::*; +pub use system::*; +pub use teams::*; +pub use workspace_quotas::*; +pub use workspaces::*; diff --git a/src-tauri/crates/business/src/dto/proxies.rs b/src-tauri/crates/business/src/dto/proxies.rs new file mode 100644 index 0000000..411f19b --- /dev/null +++ b/src-tauri/crates/business/src/dto/proxies.rs @@ -0,0 +1,43 @@ +use chrono::{DateTime, Utc}; +use serde::Serialize; +use sqlx::FromRow; +use uuid::Uuid; + +/// 代理 DTO +#[derive(Debug, Clone, FromRow, Serialize)] +pub struct ProxyDto { + pub id: i32, + pub uuid: Uuid, + pub workspace_uuid: Uuid, + pub owner_uuid: Uuid, + pub name: String, + pub host: String, + pub port: i32, + pub proxy_type: String, + pub username: Option, + pub password: Option, + pub ssh_key_encrypted: Option, + pub ssh_passphrase_encrypted: Option, + pub country: Option, + pub city: Option, + pub status: String, + pub latency: Option, + pub last_check_ip: Option, + pub last_checked_at: Option>, + pub environments_count: Option, + pub created_at: DateTime, + pub updated_at: DateTime, + pub deleted_at: Option>, +} + +/// 代理健康检查 DTO +#[derive(Debug, Clone, FromRow)] +pub struct ProxyHealthCheckDto { + pub id: i64, + pub proxy_uuid: Uuid, + pub status: String, + pub latency: Option, + pub ip_address: Option, + pub error_message: Option, + pub checked_at: DateTime, +} diff --git a/src-tauri/crates/business/src/dto/proxy_visible_teams.rs b/src-tauri/crates/business/src/dto/proxy_visible_teams.rs new file mode 100644 index 0000000..92c6730 --- /dev/null +++ b/src-tauri/crates/business/src/dto/proxy_visible_teams.rs @@ -0,0 +1,23 @@ +use chrono::{DateTime, Utc}; +use serde::Serialize; +use sqlx::FromRow; +use uuid::Uuid; + +/// 代理可见团队 DTO +#[derive(Debug, Clone, FromRow, Serialize)] +pub struct ProxyVisibleTeamDto { + pub proxy_uuid: Uuid, + pub workspace_uuid: Uuid, + pub team_uuid: Uuid, + pub created_at: DateTime, +} + +/// 代理可见团队详情 DTO(包含团队信息) +#[derive(Debug, Clone, Serialize)] +pub struct ProxyVisibleTeamDetailDto { + pub proxy_uuid: Uuid, + pub workspace_uuid: Uuid, + pub team_uuid: Uuid, + pub team_name: Option, + pub created_at: DateTime, +} diff --git a/src-tauri/crates/business/src/dto/referral.rs b/src-tauri/crates/business/src/dto/referral.rs new file mode 100644 index 0000000..4e66695 --- /dev/null +++ b/src-tauri/crates/business/src/dto/referral.rs @@ -0,0 +1,153 @@ +use chrono::{DateTime, Utc}; +use rust_decimal::Decimal; +use serde::Serialize; +use sqlx::FromRow; +use uuid::Uuid; + +/// 推广链接层级 DTO +#[derive(Debug, Clone, FromRow, Serialize)] +pub struct ReferralLinkTierDto { + pub id: i32, + pub uuid: Uuid, + pub name: String, + pub unlock_threshold: i32, + pub reward_rate: Decimal, + pub discount_rate: Decimal, + pub description: Option, + pub sort_order: Option, + pub created_at: DateTime, + pub updated_at: DateTime, +} + +/// 推广链接 DTO +#[derive(Debug, Clone, FromRow, Serialize)] +pub struct ReferralLinkDto { + pub id: i32, + pub uuid: Uuid, + pub user_uuid: Uuid, + pub code: String, + pub url: Option, + pub tier_uuid: Option, + pub unlocked: Option, + pub is_current: Option, + pub reward_rate: Decimal, + pub discount_rate: Decimal, + pub registered_users: Option, + pub paid_users: Option, + pub total_consumption: Option, + pub last_30_days_consumption: Option, + pub created_at: DateTime, + pub updated_at: DateTime, +} + +/// 用户邀请关系 DTO +#[derive(Debug, Clone, FromRow, Serialize)] +pub struct UserReferralDto { + pub id: i32, + pub inviter_uuid: Uuid, + pub invitee_uuid: Uuid, + pub link_uuid: Option, + pub status: String, + pub total_consumption: Option, + pub last_30_days_consumption: Option, + pub registered_at: DateTime, + pub activated_at: Option>, + pub first_paid_at: Option>, +} + +/// 被邀请用户列表查询行(用于 `referral/users` 的 JOIN 查询) +/// +/// 注意:这是 SQL 行映射结构,属于 DTO/数据结构层(而不是 models 业务逻辑层)。 +#[derive(Debug, Clone, FromRow)] +pub struct ReferredUserRow { + pub id: i32, + pub email: String, + pub status: String, + pub link_uuid: Option, + pub total_consumption: Option, + pub last_30_days_consumption: Option, + pub registered_at: DateTime, +} + +/// 被邀请用户列表项(对齐前端 `ReferredUser`:camelCase + number) +/// +/// 前端表格字段: +/// - id +/// - email +/// - registeredAt +/// - status +/// - totalConsumption +/// - last30DaysConsumption +/// - linkId +#[derive(Debug, Clone, Serialize)] +#[serde(rename_all = "camelCase")] +pub struct ReferredUserItemDto { + pub id: String, + pub email: String, + pub registered_at: DateTime, + pub status: String, + pub total_consumption: f64, + pub last_30_days_consumption: f64, + pub link_id: String, +} + +/// 推荐奖励 DTO +#[derive(Debug, Clone, FromRow, Serialize)] +pub struct ReferralRewardDto { + pub id: i64, + pub uuid: Uuid, + pub user_uuid: Uuid, + pub reward_type: String, + pub points: i32, + pub description: Option, + pub referred_user_uuid: Option, + pub link_uuid: Option, + pub status: String, + pub created_at: DateTime, +} + +/// 用户推荐积分 DTO +#[derive(Debug, Clone, FromRow, Serialize)] +pub struct UserReferralPointsDto { + pub id: i32, + pub user_uuid: Uuid, + pub total_points: i32, + pub available_points: i32, + pub used_points: i32, + pub pending_points: i32, + pub created_at: DateTime, + pub updated_at: DateTime, +} + +/// 兑换选项 DTO +#[derive(Debug, Clone, FromRow, Serialize)] +pub struct RedeemOptionDto { + pub id: i32, + pub uuid: Uuid, + pub redeem_type: String, + pub name: String, + pub description: Option, + pub points_required: i32, + pub value: Decimal, + pub currency: Option, + pub exchange_rate: i32, + pub status: String, + pub sort_order: Option, + pub created_at: DateTime, + pub updated_at: DateTime, +} + +/// 兑换记录 DTO +#[derive(Debug, Clone, FromRow, Serialize)] +pub struct RedeemRecordDto { + pub id: i64, + pub uuid: Uuid, + pub user_uuid: Uuid, + pub option_uuid: Uuid, + pub points_used: i32, + pub value: Decimal, + pub currency: Option, + pub status: String, + pub created_at: DateTime, + pub completed_at: Option>, +} diff --git a/src-tauri/crates/business/src/dto/rpa.rs b/src-tauri/crates/business/src/dto/rpa.rs new file mode 100644 index 0000000..acc72d9 --- /dev/null +++ b/src-tauri/crates/business/src/dto/rpa.rs @@ -0,0 +1,83 @@ +use chrono::{DateTime, Utc}; +use serde::Serialize; +use sqlx::FromRow; +use uuid::Uuid; + +/// RPA 任务 DTO +#[derive(Debug, Clone, FromRow, Serialize)] +pub struct RpaTaskDto { + pub id: i32, + pub uuid: Uuid, + pub user_uuid: Uuid, + pub team_uuid: Option, + pub name: String, + pub description: Option, + pub tags: Option, + pub trigger_type: String, + pub schedule: Option, + pub cron_expression: Option, + pub run_mode: String, + pub retry_count: Option, + pub retry_interval: Option, + pub timeout: Option, + pub concurrency: Option, + pub stop_on_error: Option, + pub notify_on_complete: Option, + pub notify_on_error: Option, + pub status: String, + pub run_count: Option, + pub success_count: Option, + pub environment_count: Option, + pub last_run_at: Option>, + pub next_run_at: Option>, + pub created_at: DateTime, + pub updated_at: DateTime, + pub deleted_at: Option>, +} + +/// RPA 任务步骤 DTO +#[derive(Debug, Clone, FromRow, Serialize)] +pub struct RpaTaskStepDto { + pub id: i32, + pub uuid: Uuid, + pub task_uuid: Uuid, + pub step_type: String, + pub name: String, + pub config: serde_json::Value, + pub enabled: Option, + pub position_x: Option, + pub position_y: Option, + pub sort_order: Option, + pub next_step_uuid: Option, + pub branch_config: Option, + pub created_at: DateTime, + pub updated_at: DateTime, +} + +/// RPA 任务环境关联 DTO +#[derive(Debug, Clone, FromRow, Serialize)] +pub struct RpaTaskEnvironmentDto { + pub id: i32, + pub task_uuid: Uuid, + pub environment_uuid: Uuid, + pub sort_order: Option, + pub created_at: DateTime, +} + +/// RPA 任务执行记录 DTO +#[derive(Debug, Clone, FromRow, Serialize)] +pub struct RpaTaskRunDto { + pub id: i64, + pub uuid: Uuid, + pub task_uuid: Uuid, + pub status: String, + pub total_steps: i32, + pub completed_steps: i32, + pub failed_steps: i32, + pub started_at: DateTime, + pub finished_at: Option>, + pub duration_ms: Option, + pub result_summary: Option, + pub error_message: Option, + pub logs: Option, +} diff --git a/src-tauri/crates/business/src/dto/strategy_types.rs b/src-tauri/crates/business/src/dto/strategy_types.rs new file mode 100644 index 0000000..0618594 --- /dev/null +++ b/src-tauri/crates/business/src/dto/strategy_types.rs @@ -0,0 +1,92 @@ +use serde::{Deserialize, Serialize}; +use sqlx::FromRow; + +pub mod processor_config { + use super::*; + + /// 白名单过滤配置 + #[derive(Debug, Deserialize, Serialize, Clone, PartialEq)] + pub struct FilterWhitelistConfig { + pub machines: Vec, // 机器码列表 + } + + /// 百分比过滤配置 + #[derive(Debug, Deserialize, Serialize, Clone, PartialEq)] + pub struct FilterPercentageConfig { + pub percent: f64, // 百分比 (0-100) + } + + /// 随机过滤配置 + #[derive(Debug, Deserialize, Serialize, Clone, PartialEq)] + pub struct FilterRandomConfig { + pub count: usize, // 随机选择的数量 + } + + /// 用户标签过滤配置 + #[derive(Debug, Deserialize, Serialize, Clone, PartialEq)] + pub struct FilterUserTagConfig { + pub tags: Vec, // 标签列表 + pub match_all: bool, // true: 必须包含所有标签, false: 包含任意一个标签 + } + + /// 时间段转换配置 + #[derive(Debug, Deserialize, Serialize, Clone, PartialEq)] + pub struct TransformTimeRangeConfig { + pub start_hour: u8, // 开始小时 (0-23) + pub end_hour: u8, // 结束小时 (0-23) + pub days: Vec, // 生效星期 (0-6, 0=周日) + } + + /// 延迟分配配置 + #[derive(Debug, Deserialize, Serialize, Clone, PartialEq)] + pub struct TransformDelayConfig { + pub delay_seconds: u64, // 延迟秒数 + } + + /// 分步分配配置 + #[derive(Debug, Deserialize, Serialize, Clone, PartialEq)] + pub struct TransformStepConfig { + pub step_percentage: f64, // 每一步的百分比 + pub step_interval_hours: u32, // 每步间隔(小时) + } +} + +/// 处理器类型枚举 +#[derive(Debug, Clone, PartialEq)] +pub enum ProcessorType { + FilterWhitelist(processor_config::FilterWhitelistConfig), + FilterPercentage(processor_config::FilterPercentageConfig), + FilterRandom(processor_config::FilterRandomConfig), + FilterUserTag(processor_config::FilterUserTagConfig), + TransformTimeRange(processor_config::TransformTimeRangeConfig), + TransformDelay(processor_config::TransformDelayConfig), + TransformStep(processor_config::TransformStepConfig), +} + +impl ProcessorType { + /// 获取处理器类型名称 + pub fn type_name(&self) -> &'static str { + match self { + ProcessorType::FilterWhitelist(_) => "filter_whitelist", + ProcessorType::FilterPercentage(_) => "filter_percentage", + ProcessorType::FilterRandom(_) => "filter_random", + ProcessorType::FilterUserTag(_) => "filter_user_tag", + ProcessorType::TransformTimeRange(_) => "transform_time_range", + ProcessorType::TransformDelay(_) => "transform_delay", + ProcessorType::TransformStep(_) => "transform_step", + } + } +} + +/// 策略类型 +#[derive(Debug, Deserialize, Serialize, FromRow, Clone)] +pub struct StrategyType { + pub id: i32, + pub code: String, + pub name: String, + pub category: Option, + pub description: Option, + pub processor_type: String, + pub config_schema: Option, + pub is_active: bool, +} diff --git a/src-tauri/crates/business/src/dto/system.rs b/src-tauri/crates/business/src/dto/system.rs new file mode 100644 index 0000000..7a8ea75 --- /dev/null +++ b/src-tauri/crates/business/src/dto/system.rs @@ -0,0 +1,27 @@ +use chrono::{DateTime, Utc}; +use serde::Serialize; +use sqlx::FromRow; +use uuid::Uuid; + +/// 用户偏好设置 DTO +#[derive(Debug, Clone, FromRow, Serialize)] +pub struct UserPreferenceDto { + pub id: i32, + pub user_uuid: Uuid, + pub theme: String, + pub language: String, + pub notifications_enabled: bool, + pub created_at: DateTime, + pub updated_at: DateTime, +} + +/// 系统配置 DTO +#[derive(Debug, Clone, FromRow, Serialize)] +pub struct SystemConfigDto { + pub id: i32, + pub config_key: String, + pub config_value: serde_json::Value, + pub description: Option, + pub created_at: DateTime, + pub updated_at: DateTime, +} diff --git a/src-tauri/crates/business/src/dto/teams.rs b/src-tauri/crates/business/src/dto/teams.rs new file mode 100644 index 0000000..7e0f27d --- /dev/null +++ b/src-tauri/crates/business/src/dto/teams.rs @@ -0,0 +1,84 @@ +use chrono::{DateTime, Utc}; +use serde::Serialize; +use sqlx::FromRow; +use uuid::Uuid; + +/// 团队 DTO +#[derive(Debug, Clone, FromRow, Serialize)] +pub struct TeamDto { + pub id: i32, + pub uuid: Uuid, + pub workspace_uuid: Uuid, + pub name: String, + pub description: Option, + pub owner_uuid: Uuid, + pub avatar_hash: Option, + pub status: String, + pub created_at: DateTime, + pub updated_at: DateTime, + pub deleted_at: Option>, +} + +/// 团队摘要 DTO(用于登录响应等场景,只包含基本信息) +#[derive(Debug, Clone, Serialize)] +pub struct TeamSummaryDto { + pub uuid: Uuid, + pub name: String, + pub description: Option, +} + +/// 团队成员 DTO(包含用户信息) +#[derive(Debug, Clone, FromRow, Serialize)] +pub struct TeamMemberDto { + pub id: i32, + pub team_uuid: Uuid, + pub workspace_uuid: Uuid, + pub user_uuid: Uuid, + pub role: String, + pub joined_at: DateTime, + pub invited_by: Option, + pub status: String, + pub created_at: DateTime, + pub updated_at: DateTime, + pub deleted_at: Option>, + // 从 user_infos 表关联的用户信息 + #[serde(skip_serializing_if = "Option::is_none")] + pub name: Option, + #[serde(skip_serializing_if = "Option::is_none")] + pub email: Option, + #[serde(skip_serializing_if = "Option::is_none")] + pub avatar: Option, +} + +/// 团队邀请 DTO +#[derive(Debug, Clone, FromRow, Serialize)] +pub struct TeamInvitationDto { + pub id: i32, + pub uuid: Uuid, + pub team_uuid: Uuid, + pub email: String, + pub role: String, + pub invited_by: Uuid, + pub token: String, + pub expires_at: DateTime, + pub status: String, + pub accepted_at: Option>, + pub created_at: DateTime, + pub updated_at: DateTime, +} + +/// 登录历史 DTO +#[derive(Debug, Clone, FromRow)] +pub struct LoginHistoryDto { + pub id: i64, + pub user_uuid: Uuid, + pub ip_address: String, + pub device_info: Option, + pub user_agent: Option, + pub location: Option, + pub country: Option, + pub city: Option, + pub success: bool, + pub failure_reason: Option, + pub created_at: DateTime, +} diff --git a/src-tauri/crates/business/src/dto/user.rs b/src-tauri/crates/business/src/dto/user.rs new file mode 100644 index 0000000..e49baa0 --- /dev/null +++ b/src-tauri/crates/business/src/dto/user.rs @@ -0,0 +1,31 @@ +use chrono::{DateTime, Utc}; +use sqlx::FromRow; +use uuid::Uuid; + +/// 用户基础信息 DTO +#[derive(Debug, Clone, FromRow)] +pub struct UserDto { + pub uuid: Uuid, + pub id: String, + pub created_at: DateTime, + pub updated_at: DateTime, + pub deleted_at: Option>, +} + +/// 用户详细信息 DTO +#[derive(Debug, Clone, FromRow)] +pub struct UserInfoDto { + pub id: i32, + pub user_uuid: Uuid, + pub nickname: Option, + pub email: String, + pub phone: Option, + pub password: String, + pub avatar_hash: Option, + pub status: String, + pub current_team_uuid: Option, + pub current_workspace_uuid: Option, + pub created_at: DateTime, + pub updated_at: DateTime, + pub deleted_at: Option>, +} diff --git a/src-tauri/crates/business/src/dto/version_types.rs b/src-tauri/crates/business/src/dto/version_types.rs new file mode 100644 index 0000000..048cd3f --- /dev/null +++ b/src-tauri/crates/business/src/dto/version_types.rs @@ -0,0 +1,14 @@ +use serde::{Deserialize, Serialize}; +use sqlx::FromRow; + +/// 版本类型 +#[derive(Debug, Deserialize, Serialize, FromRow, Clone, Default)] +pub struct VersionType { + pub id: i32, + pub type_code: String, + pub type_name: String, + pub description: Option, + pub sort_order: i32, + pub is_active: bool, + pub is_auto_download: bool, +} diff --git a/src-tauri/crates/business/src/dto/versions.rs b/src-tauri/crates/business/src/dto/versions.rs new file mode 100644 index 0000000..50ad520 --- /dev/null +++ b/src-tauri/crates/business/src/dto/versions.rs @@ -0,0 +1,31 @@ +use chrono::DateTime; +use serde::{Deserialize, Serialize}; +use sqlx::FromRow; + +/// 统一版本表 +#[derive(Debug, Deserialize, Serialize, FromRow, Clone, Default)] +pub struct Version { + pub id: i32, + pub type_id: i32, + pub resource_name: String, + pub version: String, + pub name: Option, + pub notes: Option, + pub platform: Option, + pub url: Option, + pub hash: Option, + pub signature: Option, + pub install_path: Option, + pub file_size: Option, + pub is_latest: bool, + pub status: String, + pub pub_date: Option>, + pub created_at: DateTime, + pub updated_at: Option>, + pub deleted_at: Option>, + pub arch: Option, + pub package_format: Option, + pub requires_extract: bool, + pub entrypoint_template: Option, + pub extract_root: Option, +} diff --git a/src-tauri/crates/business/src/dto/workspace_quotas.rs b/src-tauri/crates/business/src/dto/workspace_quotas.rs new file mode 100644 index 0000000..9aaf60e --- /dev/null +++ b/src-tauri/crates/business/src/dto/workspace_quotas.rs @@ -0,0 +1,20 @@ +use chrono::{DateTime, Utc}; +use serde::Serialize; +use sqlx::FromRow; +use uuid::Uuid; + +/// 工作空间配额 DTO +#[derive(Debug, Clone, FromRow, Serialize)] +pub struct WorkspaceQuotaDto { + pub workspace_uuid: Uuid, + pub max_environments: i32, + pub used_environments: i32, + pub max_team_members: i32, + pub used_team_members: i32, + pub max_proxies: i32, + pub used_proxies: i32, + pub max_rpa_tasks: i32, + pub used_rpa_tasks: i32, + pub created_at: DateTime, + pub updated_at: DateTime, +} diff --git a/src-tauri/crates/business/src/dto/workspaces.rs b/src-tauri/crates/business/src/dto/workspaces.rs new file mode 100644 index 0000000..4058def --- /dev/null +++ b/src-tauri/crates/business/src/dto/workspaces.rs @@ -0,0 +1,25 @@ +use chrono::{DateTime, Utc}; +use serde::Serialize; +use sqlx::FromRow; +use uuid::Uuid; + +/// 工作空间 DTO +#[derive(Debug, Clone, FromRow, Serialize)] +pub struct WorkspaceDto { + pub uuid: Uuid, + pub name: String, + pub owner_uuid: Uuid, + pub workspace_type: String, + pub created_at: DateTime, + pub updated_at: DateTime, + pub deleted_at: Option>, +} + +/// 工作空间摘要 DTO(用于列表显示等场景) +#[derive(Debug, Clone, Serialize)] +pub struct WorkspaceSummaryDto { + pub uuid: Uuid, + pub name: String, + pub workspace_type: String, + pub owner_uuid: Uuid, +} diff --git a/src-tauri/crates/business/src/entitys/accounts.rs b/src-tauri/crates/business/src/entitys/accounts.rs new file mode 100644 index 0000000..700855d --- /dev/null +++ b/src-tauri/crates/business/src/entitys/accounts.rs @@ -0,0 +1,77 @@ +use serde::{Deserialize, Serialize}; +use uuid::Uuid; + +use super::Pagination; + +/// 查询账号列表请求 +#[derive(Debug, Clone, Deserialize, Serialize)] +pub struct ListAccountsRequest { + #[serde(flatten)] + pub pagination: Pagination, + pub filters: Option, +} + +/// 账号筛选条件 +#[derive(Debug, Clone, Deserialize, Serialize)] +pub struct AccountFilters { + pub keyword: Option, + pub platform_name: Option, + pub status: Option, +} + +/// 创建账号请求 +#[derive(Debug, Clone, Deserialize, Serialize)] +pub struct CreateAccountRequest { + pub platform_url: String, + pub platform_name: Option, + pub account: String, + pub password: Option, + pub remark: Option, +} + +/// 更新账号请求 +#[derive(Debug, Clone, Deserialize, Serialize)] +pub struct UpdateAccountRequest { + pub uuid: Uuid, + pub platform_url: Option, + pub platform_name: Option, + pub account: Option, + pub password: Option, + pub remark: Option, + pub status: Option, +} + +/// 批量导入账号项(客户端已解析好的结构化数据) +#[derive(Debug, Clone, Deserialize, Serialize)] +pub struct BatchImportAccountItem { + pub platform_url: String, + pub platform_name: Option, + pub account: String, + pub password: Option, + pub remark: Option, +} + +/// 批量导入账号请求 +#[derive(Debug, Clone, Deserialize, Serialize)] +pub struct BatchImportAccountsRequest { + pub accounts: Vec, +} + +/// 导出账号请求 +#[derive(Debug, Clone, Deserialize, Serialize)] +pub struct ExportAccountsRequest { + pub uuids: Option>, + pub format: String, + pub include_password: bool, +} + +// ========== 响应结构体 ========== + +/// 账号列表响应 +#[derive(Debug, Clone, Serialize)] +pub struct AccountListResponse { + pub items: Vec, + pub total: i64, + pub page: i64, + pub page_size: i64, +} diff --git a/src-tauri/crates/business/src/entitys/audit.rs b/src-tauri/crates/business/src/entitys/audit.rs new file mode 100644 index 0000000..403c5f2 --- /dev/null +++ b/src-tauri/crates/business/src/entitys/audit.rs @@ -0,0 +1,83 @@ +use serde::{Deserialize, Serialize}; +use uuid::Uuid; + +use super::Pagination; +use crate::dto::AuditLogDto; + +/// 查询审计日志请求 +#[derive(Debug, Clone, Deserialize, Serialize)] +pub struct ListAuditLogsRequest { + #[serde(flatten)] + pub pagination: Pagination, + pub filters: Option, +} + +// ========== 响应结构体 ========== + +/// 审计日志列表响应 +#[derive(Debug, Clone, Serialize)] +pub struct AuditLogsListResponse { + pub items: Vec, + pub total: i64, + pub page: i64, + pub page_size: i64, +} + +/// 导出响应 +#[derive(Debug, Clone, Serialize)] +pub struct ExportResponse { + pub content: String, + pub filename: String, + pub mime_type: String, +} + +/// 审计统计响应 +#[derive(Debug, Clone, Serialize)] +pub struct AuditStatsResponse { + pub total_logs: i64, + pub logs_today: i64, + pub logs_this_week: i64, + pub logs_this_month: i64, + pub top_actions: Vec, + pub top_target_types: Vec, +} + +/// 操作计数 +#[derive(Debug, Clone, Serialize)] +pub struct ActionCount { + pub action: String, + pub count: i64, +} + +/// 目标类型计数 +#[derive(Debug, Clone, Serialize)] +pub struct TargetTypeCount { + pub target_type: String, + pub count: i64, +} + +/// 审计日志筛选条件 +#[derive(Debug, Clone, Deserialize, Serialize)] +pub struct AuditLogFilters { + pub keyword: Option, + pub action: Option, + pub target_type: Option, + pub user_uuid: Option, + pub date_from: Option, + pub date_to: Option, +} + +/// 导出审计日志请求 +#[derive(Debug, Clone, Deserialize, Serialize)] +pub struct ExportAuditLogsRequest { + pub format: String, + pub filters: Option, + pub max_records: Option, +} + +/// 审计统计请求 +#[derive(Debug, Clone, Deserialize, Serialize)] +pub struct AuditStatsRequest { + pub date_from: Option, + pub date_to: Option, +} diff --git a/src-tauri/crates/business/src/entitys/billing.rs b/src-tauri/crates/business/src/entitys/billing.rs new file mode 100644 index 0000000..c9f98b1 --- /dev/null +++ b/src-tauri/crates/business/src/entitys/billing.rs @@ -0,0 +1,247 @@ +use rust_decimal::Decimal; +use serde::{Deserialize, Serialize}; +use uuid::Uuid; + +use super::Pagination; +use crate::dto::{ + CouponDto, InvoiceDto, PaymentOrderDto, PlanDto, PlanFeatureDto, + UserCouponWithDetailsDto, WalletTransactionDto, +}; + +/// 订阅套餐请求 +#[derive(Debug, Clone, Deserialize, Serialize)] +pub struct SubscribePlanRequest { + pub plan_uuid: Uuid, + pub billing_period: String, + pub coupon_code: Option, + /// 支付方式:wallet(钱包)、alipay(支付宝)、wechat(微信) + pub payment_method: Option, +} + +/// 取消订阅请求 +#[derive(Debug, Clone, Deserialize, Serialize)] +pub struct CancelSubscriptionRequest { + pub subscription_uuid: Uuid, +} + +/// 恢复订阅请求 +#[derive(Debug, Clone, Deserialize, Serialize)] +pub struct ResumeSubscriptionRequest { + pub subscription_uuid: Uuid, +} + +/// 切换自动续费请求 +#[derive(Debug, Clone, Deserialize, Serialize)] +pub struct ToggleAutoRenewRequest { + pub subscription_uuid: Uuid, + pub auto_renew: bool, +} + +/// 查询交易记录请求 +#[derive(Debug, Clone, Deserialize, Serialize)] +pub struct ListTransactionsRequest { + #[serde(flatten)] + pub pagination: Pagination, + pub transaction_type: Option, +} + +/// 查询发票列表请求 +#[derive(Debug, Clone, Deserialize, Serialize)] +pub struct ListInvoicesRequest { + #[serde(flatten)] + pub pagination: Pagination, + pub status: Option, +} + +/// 验证优惠券请求 +#[derive(Debug, Clone, Deserialize, Serialize)] +pub struct VerifyCouponRequest { + pub code: String, + pub amount: Decimal, +} + +/// 创建充值订单请求 +#[derive(Debug, Clone, Deserialize, Serialize)] +pub struct CreateRechargeOrderRequest { + pub amount: Decimal, + pub payment_channel: String, +} + +/// 查询支付订单请求 +#[derive(Debug, Clone, Deserialize, Serialize)] +pub struct ListPaymentOrdersRequest { + #[serde(flatten)] + pub pagination: Pagination, + pub order_type: Option, + pub status: Option, +} + +/// 获取用户优惠券列表请求 +#[derive(Debug, Clone, Deserialize, Serialize)] +pub struct GetUserCouponsRequest { + #[serde(flatten)] + pub pagination: Pagination, + pub status: Option, // unused, used, expired +} + +/// 发放优惠券请求(管理员功能) +#[derive(Debug, Clone, Deserialize, Serialize)] +pub struct IssueCouponRequest { + pub coupon_uuid: Uuid, + pub user_uuid: Uuid, + pub expires_at: Option>, // 可选的自定义过期时间 +} + +/// 批量发放优惠券请求(管理员功能) +#[derive(Debug, Clone, Deserialize, Serialize)] +pub struct BatchIssueCouponRequest { + pub coupon_uuid: Uuid, + pub user_uuids: Vec, + pub expires_at: Option>, // 可选的自定义过期时间 +} + +/// 获取套餐价格请求 +#[derive(Debug, Clone, Deserialize, Serialize)] +pub struct GetPlanPriceRequest { + pub plan_uuid: Uuid, + pub billing_period: String, // monthly, yearly + pub coupon_code: Option, +} + +/// 获取套餐列表请求 +#[derive(Debug, Clone, Deserialize, Serialize)] +pub struct GetPlansRequest { + /// 优惠券代码(可选) + pub coupon_code: Option, + /// 计费周期(用于计算价格,默认为 monthly) + #[serde(default = "default_billing_period")] + pub billing_period: String, +} + +fn default_billing_period() -> String { + "monthly".to_string() +} + +// ========== 响应结构体 ========== + +/// 带特性的套餐结构 +#[derive(Debug, Clone, Serialize)] +pub struct PlanWithFeatures { + pub plan: PlanDto, + pub features: Vec, + /// 计算后的价格信息(如果提供了优惠券代码) + #[serde(skip_serializing_if = "Option::is_none")] + pub calculated_price: Option, +} + +/// 套餐价格信息(用于套餐列表响应) +#[derive(Debug, Clone, Serialize)] +pub struct PlanPriceInfo { + pub original_price: Decimal, + pub plan_discount: Decimal, + pub coupon_discount: Decimal, + pub final_price: Decimal, + pub total_saved: Decimal, + pub billing_period: String, // monthly, yearly +} + +/// 套餐列表响应 +#[derive(Debug, Clone, Serialize)] +pub struct PlansResponse { + pub plans: Vec, +} + +/// 套餐详情响应 +#[derive(Debug, Clone, Serialize)] +pub struct PlanDetailResponse { + pub plan: PlanDto, + pub features: Vec, +} + +/// 订阅响应 +#[derive(Debug, Clone, Serialize)] +pub struct SubscribeResponse { + pub subscription_uuid: Uuid, +} + +/// 交易记录列表响应 +#[derive(Debug, Clone, Serialize)] +pub struct TransactionsListResponse { + pub items: Vec, + pub total: i64, + pub page: i64, + pub page_size: i64, +} + +/// 发票列表响应 +#[derive(Debug, Clone, Serialize)] +pub struct InvoicesListResponse { + pub items: Vec, + pub total: i64, + pub page: i64, + pub page_size: i64, +} + +/// 优惠券验证响应 +#[derive(Debug, Clone, Serialize)] +pub struct VerifyCouponResponse { + pub coupon_uuid: Uuid, + pub discount_type: String, + pub discount_value: rust_decimal::Decimal, + pub discount_amount: rust_decimal::Decimal, +} + +/// 创建订单响应 +#[derive(Debug, Clone, Serialize)] +pub struct CreateOrderResponse { + pub order_uuid: Uuid, + pub order_no: String, +} + +/// 支付订单列表响应 +#[derive(Debug, Clone, Serialize)] +pub struct PaymentOrdersListResponse { + pub items: Vec, + pub total: i64, + pub page: i64, + pub page_size: i64, +} + +/// 优惠券验证结果 +#[derive(Debug, Clone, Serialize)] +pub struct CouponValidationResult { + pub coupon: CouponDto, + pub discount_amount: rust_decimal::Decimal, +} + +/// 账户信息响应 +#[derive(Debug, Clone, Serialize)] +pub struct AccountInfoResponse { + pub email: String, + pub wallet_balance: rust_decimal::Decimal, + pub gift_balance: rust_decimal::Decimal, + pub currency: String, + pub subscription: Option, + pub quota: crate::dto::WorkspaceQuotaDto, + pub monthly_billing: rust_decimal::Decimal, +} + +/// 用户优惠券列表响应 +#[derive(Debug, Clone, Serialize)] +pub struct UserCouponsListResponse { + pub items: Vec, + pub total: i64, + pub page: i64, + pub page_size: i64, +} + +/// 套餐价格响应 +#[derive(Debug, Clone, Serialize)] +pub struct PlanPriceResponse { + pub original_price: Decimal, + pub plan_discount: Decimal, // 套餐级折扣金额 + pub coupon_discount: Decimal, // 优惠券折扣金额 + pub final_price: Decimal, // 最终价格 + pub total_saved: Decimal, // 总节省金额 + pub coupon_info: Option, // 优惠券信息(如果使用了优惠券) +} diff --git a/src-tauri/crates/business/src/entitys/browser_kernel.rs b/src-tauri/crates/business/src/entitys/browser_kernel.rs new file mode 100644 index 0000000..edc1495 --- /dev/null +++ b/src-tauri/crates/business/src/entitys/browser_kernel.rs @@ -0,0 +1,10 @@ +use serde::{Deserialize, Serialize}; + +/// 查询浏览器内核列表请求 +#[derive(Debug, Default, Deserialize, Serialize)] +pub struct ListBrowserKernelsRequest { + /// 平台过滤,如 windows/darwin/linux,为空表示不过滤 + pub platform: Option, + /// 版本类型过滤,如 SIMPRINT_KERNEL_CHROMIUM,为空表示查询所有 SIMPRINT_KERNEL_* 类型 + pub type_code: Option, +} diff --git a/src-tauri/crates/business/src/entitys/common.rs b/src-tauri/crates/business/src/entitys/common.rs new file mode 100644 index 0000000..b5259dc --- /dev/null +++ b/src-tauri/crates/business/src/entitys/common.rs @@ -0,0 +1,65 @@ +use serde::{Deserialize, Serialize}; +use uuid::Uuid; + +// ========== 请求参数 ========== + +/// 分页请求参数 +#[derive(Debug, Clone, Deserialize, Serialize, Default)] +pub struct Pagination { + #[serde(default = "default_page")] + pub page: i64, + #[serde(default = "default_page_size")] + pub page_size: i64, + #[serde(default)] + pub sort_by: Option, + #[serde(default)] + pub sort_order: Option, +} + +fn default_page() -> i64 { + 1 +} + +fn default_page_size() -> i64 { + 20 +} + +/// UUID 请求参数 +#[derive(Debug, Clone, Deserialize, Serialize)] +pub struct UuidRequest { + pub uuid: Uuid, +} + +/// 批量 UUID 请求参数 +#[derive(Debug, Clone, Deserialize, Serialize)] +pub struct BatchUuidRequest { + pub uuids: Vec, +} + +// ========== 响应结构体 ========== + +/// 创建资源响应(返回新创建资源的 UUID) +#[derive(Debug, Clone, Serialize)] +pub struct CreateResponse { + pub uuid: Uuid, +} + +/// 创建资源响应(返回新创建资源的数字 ID) +#[derive(Debug, Clone, Serialize)] +pub struct IdResponse { + pub id: i32, +} + +/// 邀请响应 +#[derive(Debug, Clone, Serialize)] +pub struct InviteResponse { + pub invitation_uuid: Uuid, +} + +/// 批量导入响应 +#[derive(Debug, Clone, Serialize)] +pub struct BatchImportResponse { + pub success_count: i32, + pub failed_count: i32, + pub errors: Vec, +} diff --git a/src-tauri/crates/business/src/entitys/environments.rs b/src-tauri/crates/business/src/entitys/environments.rs new file mode 100644 index 0000000..7e2bb52 --- /dev/null +++ b/src-tauri/crates/business/src/entitys/environments.rs @@ -0,0 +1,235 @@ +use serde::{Deserialize, Serialize}; +use uuid::Uuid; + +use super::Pagination; + +/// 查询环境列表请求 +#[derive(Debug, Clone, Deserialize, Serialize)] +pub struct ListEnvironmentsRequest { + #[serde(flatten)] + pub pagination: Pagination, + pub filters: Option, +} + +/// 环境筛选条件 +#[derive(Debug, Clone, Deserialize, Serialize)] +pub struct EnvironmentFilters { + pub keyword: Option, + pub status: Option, + pub group_uuid: Option, + pub tag_uuids: Option>, + pub created_from: Option, + pub created_to: Option, +} + +/// 创建环境请求 +#[derive(Debug, Clone, Deserialize, Serialize)] +pub struct CreateEnvironmentRequest { + pub name: String, + pub description: Option, + pub group_uuid: Option, + pub tag_uuids: Option>, + pub account_uuids: Option>, + pub proxy_uuid: Option, // 代理 UUID(单个,可选) + pub cookies: Option>, + pub urls: Option>, + pub config: EnvironmentConfigRequest, +} + +/// 环境配置请求(对应 WindowConfig) +#[derive(Debug, Clone, Deserialize, Serialize)] +pub struct EnvironmentConfigRequest { + pub window_info: serde_json::Value, + pub basic_settings: serde_json::Value, + pub fingerprint_settings: serde_json::Value, + pub device_settings: serde_json::Value, + pub preference_settings: serde_json::Value, + #[serde(default)] + pub project_metadata: serde_json::Value, +} + +/// 批量创建环境请求 +#[derive(Debug, Clone, Deserialize, Serialize)] +pub struct BatchCreateEnvironmentRequest { + pub environments: Vec, // 环境创建请求数组 +} + +/// 更新环境请求 +#[derive(Debug, Clone, Deserialize, Serialize)] +pub struct UpdateEnvironmentRequest { + pub uuid: Uuid, + pub name: Option, + pub description: Option, + pub group_uuid: Option, + pub cookies: Option>, + pub urls: Option>, + pub config: Option, +} + +/// 设置环境代理请求 +#[derive(Debug, Clone, Deserialize, Serialize)] +pub struct SetEnvironmentProxyRequest { + pub uuid: Uuid, + pub proxy_uuid: Option, +} + +/// 设置环境账号请求 +#[derive(Debug, Clone, Deserialize, Serialize)] +pub struct SetEnvironmentAccountsRequest { + pub uuid: Uuid, + pub account_uuids: Vec, +} + +/// 分配标签请求 +#[derive(Debug, Clone, Deserialize, Serialize)] +pub struct AssignTagsRequest { + pub uuid: Uuid, + pub tag_uuids: Vec, +} + +/// 批量分配标签请求 +#[derive(Debug, Clone, Deserialize, Serialize)] +pub struct BatchAssignTagRequest { + pub env_uuids: Vec, + pub tag_uuid: Uuid, +} + +/// 批量移除标签请求 +#[derive(Debug, Clone, Deserialize, Serialize)] +pub struct BatchRemoveTagsRequest { + pub env_uuids: Vec, + pub tag_uuid: Option, +} + +/// 移除标签请求 +#[derive(Debug, Clone, Deserialize, Serialize)] +pub struct RemoveTagRequest { + pub uuid: Uuid, + pub tag_uuid: Uuid, +} + +/// 移动到分组请求 +#[derive(Debug, Clone, Deserialize, Serialize)] +pub struct MoveToGroupRequest { + pub uuid: Uuid, + pub group_uuid: Option, +} + +/// 批量移动到分组请求 +#[derive(Debug, Clone, Deserialize, Serialize)] +pub struct BatchMoveToGroupRequest { + pub env_uuids: Vec, + pub group_uuid: Uuid, +} + +// ============ Environment URLs ============ + +/// 添加环境 URL 请求 +#[derive(Debug, Clone, Deserialize, Serialize)] +pub struct AddEnvironmentUrlRequest { + pub environment_uuid: Uuid, + pub url: String, + pub title: Option, + pub sort_order: Option, +} + +/// 批量添加环境 URL 请求 +#[derive(Debug, Clone, Deserialize, Serialize)] +pub struct BatchAddEnvironmentUrlsRequest { + pub environment_uuid: Uuid, + pub urls: Vec, +} + +/// URL 输入 +#[derive(Debug, Clone, Deserialize, Serialize)] +pub struct UrlInput { + pub url: String, + pub title: Option, + pub sort_order: Option, +} + +/// 删除环境 URL 请求 +#[derive(Debug, Clone, Deserialize, Serialize)] +pub struct DeleteEnvironmentUrlRequest { + pub id: i32, +} + +/// 清空环境 URL 请求 +#[derive(Debug, Clone, Deserialize, Serialize)] +pub struct ClearEnvironmentUrlsRequest { + pub environment_uuid: Uuid, +} + +// ============ Environment Cookies ============ + +/// 添加环境 Cookie 请求 +#[derive(Debug, Clone, Deserialize, Serialize)] +pub struct AddEnvironmentCookieRequest { + pub environment_uuid: Uuid, + pub site: String, + pub cookie_text: String, +} + +/// 批量添加环境 Cookie 请求 +#[derive(Debug, Clone, Deserialize, Serialize)] +pub struct BatchAddEnvironmentCookiesRequest { + pub environment_uuid: Uuid, + pub cookies: Vec, +} + +/// Cookie 分组输入 +#[derive(Debug, Clone, Deserialize, Serialize)] +pub struct CookieGroupInput { + pub site: String, + pub cookie_text: String, +} + +/// 删除环境 Cookie 请求 +#[derive(Debug, Clone, Deserialize, Serialize)] +pub struct DeleteEnvironmentCookieRequest { + pub id: i32, +} + +/// 清空环境 Cookie 请求 +#[derive(Debug, Clone, Deserialize, Serialize)] +pub struct ClearEnvironmentCookiesRequest { + pub environment_uuid: Uuid, +} + +/// Cookie 输入结构(用于批量添加) +#[derive(Debug, Clone, Deserialize, Serialize)] +pub struct CookieInput { + pub site_input: String, + pub domain: String, + pub name: String, + pub value: String, + pub path: Option, + pub http_only: Option, + pub secure: Option, + pub same_site: Option, +} + +// ========== 响应结构体 ========== + +/// 环境列表响应(使用与环境详情一致的数据结构) +#[derive(Debug, Clone, Serialize)] +pub struct EnvironmentListResponse { + pub items: Vec, + pub total: i64, + pub page: i64, + pub page_size: i64, +} + +/// 环境详情响应(包含完整配置) +#[derive(Debug, Clone, Serialize, Deserialize)] +pub struct EnvironmentDetailResponse { + pub environment: crate::dto::EnvironmentDto, + pub config: Option, + pub cookies: Vec, + pub urls: Vec, + pub tags: Vec, + pub accounts: Vec, + pub group: Option, // 分组完整信息 + pub proxy: Option, // 代理完整信息 + pub extensions: Vec, // 扩展列表 +} diff --git a/src-tauri/crates/business/src/entitys/extensions.rs b/src-tauri/crates/business/src/entitys/extensions.rs new file mode 100644 index 0000000..c402acd --- /dev/null +++ b/src-tauri/crates/business/src/entitys/extensions.rs @@ -0,0 +1,228 @@ +use chrono::{DateTime, Utc}; +use rust_decimal::Decimal; +use serde::{Deserialize, Serialize}; +use uuid::Uuid; + +use super::Pagination; + +/// 创建扩展参数(模型层入参) +#[derive(Debug, Clone, Serialize, Deserialize)] +pub struct CreateExtensionParams { + pub extension_id: String, + pub name: String, + pub description: Option, + pub version: String, + pub category: String, + pub browser: String, + pub developer: Option, + pub homepage: Option, + pub icon_url: Option, + pub download_url: Option, + pub file_size: Option, + pub downloads_count: Option, + pub permissions: Option, + pub rating: Option, + pub changelog: Option, + pub published_at: Option>, + pub hash: Option, +} + +/// 更新扩展参数(模型层入参) +#[derive(Debug, Clone, Serialize, Deserialize)] +pub struct UpdateExtensionParams { + pub name: Option, + pub description: Option, + pub version: Option, + pub category: Option, + pub developer: Option, + pub homepage: Option, + pub icon_url: Option, + pub download_url: Option, + pub file_size: Option, + pub downloads_count: Option, + pub permissions: Option, + pub rating: Option, + pub changelog: Option, + pub published_at: Option>, + pub hash: Option, +} + +/// 查询扩展列表请求 +#[derive(Debug, Clone, Deserialize, Serialize)] +pub struct ListExtensionsRequest { + #[serde(flatten)] + pub pagination: Pagination, + pub filters: Option, +} + +/// 同步扩展响应 +#[derive(Debug, Clone, Serialize)] +pub struct SyncExtensionResponse { + /// 扩展 UUID + pub uuid: Uuid, + /// 扩展 ID + pub extension_id: String, + /// 是否为新创建 + pub created: bool, +} + +/// 扩展筛选条件 +#[derive(Debug, Clone, Deserialize, Serialize)] +pub struct ExtensionFilters { + pub keyword: Option, + pub category: Option, + pub installed_only: Option, + pub sort_by: Option, + pub sort_order: Option, +} + +/// 安装扩展请求 +#[derive(Debug, Clone, Deserialize, Serialize)] +pub struct InstallExtensionRequest { + pub extension_id: String, + /// 安装目标: user, team, group + pub target_type: Option, + /// 分组 UUID 数组(用于安装到分组,即使只有一个分组也需要传入数组) + #[serde(skip_serializing_if = "Option::is_none")] + pub group_ids: Option>, + /// 是否为团队共享(仅 target_type=group 时有效) + /// true: 团队所有成员可用,false: 仅创建者可用 + #[serde(skip_serializing_if = "Option::is_none")] + pub is_team_shared: Option, +} + +/// 卸载扩展请求 +#[derive(Debug, Clone, Deserialize, Serialize)] +pub struct UninstallExtensionRequest { + pub extension_id: String, + /// 卸载类型:user、team、group + /// 如果不指定,默认为 user + pub target_type: Option, + /// 目标 UUID(用于 team 和 group 类型) + /// - target_type=team: 表示 team_uuid + /// - target_type=group: 表示 group_uuid + #[serde(skip_serializing_if = "Option::is_none")] + pub target_uuid: Option, +} + +/// 更新扩展请求 +#[derive(Debug, Clone, Deserialize, Serialize)] +pub struct UpdateExtensionRequest { + pub extension_id: String, +} + +/// 批量更新扩展请求 +#[derive(Debug, Clone, Deserialize, Serialize)] +pub struct BatchUpdateExtensionsRequest { + pub extension_ids: Vec, +} + +/// 扩展 ID 请求 +#[derive(Debug, Clone, Deserialize, Serialize)] +pub struct ExtensionIdRequest { + pub extension_id: String, +} + +/// 禁用/启用扩展请求 +#[derive(Debug, Clone, Deserialize, Serialize)] +pub struct ToggleExtensionRequest { + pub extension_id: String, +} + +// ========== 响应结构体 ========== + +use crate::dto::ExtensionDto; + +/// 扩展列表响应 +#[derive(Debug, Clone, Serialize)] +pub struct ExtensionsListResponse { + pub items: Vec, + pub total: i64, + pub page: i64, + pub page_size: i64, +} + +/// 获取已安装扩展请求 +#[derive(Debug, Clone, Deserialize, Serialize)] +pub struct GetInstalledExtensionsRequest { + /// 范围过滤:all, user, team + #[serde(default = "default_scope")] + pub scope: String, +} + +fn default_scope() -> String { + "all".to_string() +} + +/// 已安装扩展响应 +#[derive(Debug, Clone, Serialize)] +pub struct InstalledExtensionsResponse { + pub user_extensions: Vec, + pub team_extensions: Vec, +} + +/// 已安装扩展项(包含完整扩展详情) +#[derive(Debug, Clone, Serialize)] +pub struct InstalledExtensionItem { + pub extension_id: String, + pub name: String, + pub version: String, + pub installed_version: String, + pub has_update: bool, + pub status: String, + pub installed_at: chrono::DateTime, + /// 扩展主页 URL + #[serde(skip_serializing_if = "Option::is_none")] + pub homepage: Option, + /// 扩展图标 URL + #[serde(skip_serializing_if = "Option::is_none")] + pub icon_url: Option, + /// 团队 UUID(如果是团队安装的) + #[serde(skip_serializing_if = "Option::is_none")] + pub team_uuid: Option, + /// 安装范围:user 或 team + pub scope: String, + /// 扩展描述 + #[serde(skip_serializing_if = "Option::is_none")] + pub description: Option, + /// 扩展分类 + #[serde(skip_serializing_if = "Option::is_none")] + pub category: Option, + /// 浏览器类型 + #[serde(skip_serializing_if = "Option::is_none")] + pub browser: Option, + /// 开发者/作者 + #[serde(skip_serializing_if = "Option::is_none")] + pub developer: Option, + /// 下载量 + #[serde(skip_serializing_if = "Option::is_none")] + pub downloads_count: Option, + /// 评分 + #[serde(skip_serializing_if = "Option::is_none")] + pub rating: Option, + /// 权限列表 + #[serde(skip_serializing_if = "Option::is_none")] + pub permissions: Option, + /// 文件大小 + #[serde(skip_serializing_if = "Option::is_none")] + pub file_size: Option, + /// 更新时间 + #[serde(skip_serializing_if = "Option::is_none")] + pub updated_at: Option>, + /// 关联的分组列表 + #[serde(skip_serializing_if = "Option::is_none")] + pub groups: Option>, +} + +/// 扩展关联的分组信息 +#[derive(Debug, Clone, Serialize)] +pub struct ExtensionGroup { + pub uuid: Uuid, + pub name: String, +} + +/// 批量更新响应 +#[derive(Debug, Clone, Serialize)] +pub struct BatchUpdateResponse { + pub updated: u64, +} diff --git a/src-tauri/crates/business/src/entitys/group_member_permissions.rs b/src-tauri/crates/business/src/entitys/group_member_permissions.rs new file mode 100644 index 0000000..2954e66 --- /dev/null +++ b/src-tauri/crates/business/src/entitys/group_member_permissions.rs @@ -0,0 +1,54 @@ +use serde::{Deserialize, Serialize}; +use uuid::Uuid; + +use super::Pagination; + +/// 授予分组权限请求 +#[derive(Debug, Clone, Deserialize, Serialize)] +pub struct GrantGroupPermissionRequest { + pub group_uuid: Uuid, + pub user_uuid: Uuid, + pub permission_type: String, // 'read', 'write', 'manage' +} + +/// 撤销分组权限请求 +#[derive(Debug, Clone, Deserialize, Serialize)] +pub struct RevokeGroupPermissionRequest { + pub group_uuid: Uuid, + pub user_uuid: Uuid, +} + +/// 查询用户分组权限请求 +#[derive(Debug, Clone, Deserialize, Serialize)] +pub struct ListUserGroupPermissionsRequest { + #[serde(flatten)] + pub pagination: Pagination, + pub user_uuid: Uuid, + pub group_uuid: Option, +} + +/// 检查分组权限请求 +#[derive(Debug, Clone, Deserialize, Serialize)] +pub struct CheckGroupPermissionRequest { + pub group_uuid: Uuid, + pub user_uuid: Uuid, + pub permission_type: String, // 'read', 'write', 'manage' +} + +// ========== 响应结构体 ========== + +/// 分组权限列表响应 +#[derive(Debug, Clone, Serialize)] +pub struct GroupPermissionListResponse { + pub items: Vec, + pub total: i64, + pub page: i64, + pub page_size: i64, +} + +/// 检查权限响应 +#[derive(Debug, Clone, Serialize)] +pub struct CheckPermissionResponse { + pub has_permission: bool, + pub permission_type: Option, +} diff --git a/src-tauri/crates/business/src/entitys/groups.rs b/src-tauri/crates/business/src/entitys/groups.rs new file mode 100644 index 0000000..5856f3f --- /dev/null +++ b/src-tauri/crates/business/src/entitys/groups.rs @@ -0,0 +1,36 @@ +use serde::{Deserialize, Serialize}; +use uuid::Uuid; + +use super::Pagination; + +/// 查询分组列表请求 +#[derive(Debug, Clone, Deserialize, Serialize)] +pub struct ListGroupsRequest { + #[serde(flatten)] + pub pagination: Pagination, + pub keyword: Option, +} + +/// 创建分组请求 +#[derive(Debug, Clone, Deserialize, Serialize)] +pub struct CreateGroupRequest { + pub name: String, + pub description: Option, +} + +/// 更新分组请求 +#[derive(Debug, Clone, Deserialize, Serialize)] +pub struct UpdateGroupRequest { + pub uuid: Uuid, + pub name: Option, + pub description: Option, + pub sort_order: Option, +} + +/// 分配分组到团队请求(已废弃,分组创建时即指定团队) +#[derive(Debug, Clone, Deserialize, Serialize)] +#[deprecated(note = "分组创建时即指定团队,不再需要分配")] +pub struct AssignGroupToTeamRequest { + pub uuid: Uuid, + pub team_uuid: Uuid, +} diff --git a/src-tauri/crates/business/src/entitys/local_api.rs b/src-tauri/crates/business/src/entitys/local_api.rs new file mode 100644 index 0000000..c2fba04 --- /dev/null +++ b/src-tauri/crates/business/src/entitys/local_api.rs @@ -0,0 +1,23 @@ +use serde::{Deserialize, Serialize}; + +#[derive(Debug, Clone, Default, Deserialize, Serialize)] +pub struct GetLocalApiConfigRequest {} + +#[derive(Debug, Clone, Default, Deserialize, Serialize)] +pub struct ResetLocalApiKeyRequest {} + +#[derive(Debug, Clone, Deserialize, Serialize)] +#[serde(rename_all = "camelCase")] +pub struct UpdateLocalApiConfigRequest { + pub enabled: Option, + pub port: Option, + pub remote_access: Option, + pub cors_origins: Option>, +} + +#[derive(Debug, Clone, Deserialize, Serialize)] +#[serde(rename_all = "camelCase")] +pub struct ValidateLocalApiKeyRequest { + pub api_key: String, + pub permission_code: String, +} diff --git a/src-tauri/crates/business/src/entitys/maintenance.rs b/src-tauri/crates/business/src/entitys/maintenance.rs new file mode 100644 index 0000000..dcb98ce --- /dev/null +++ b/src-tauri/crates/business/src/entitys/maintenance.rs @@ -0,0 +1,42 @@ +use chrono::{DateTime, Utc}; +use serde::{Deserialize, Serialize}; + +use crate::dto::maintenance::MaintenanceType; + +#[derive(Debug, Clone, Deserialize, Serialize)] +pub struct CreateMaintenanceRequest { + pub name: String, + pub description: Option, + pub status: String, + pub start_time: DateTime, + pub end_time: DateTime, + pub maintenance_type: MaintenanceType, +} + +/// 获取维护详情请求 +#[derive(Debug, Deserialize, Serialize)] +pub struct GetMaintenanceRequest { + pub id: i64, +} + +/// 查询维护列表请求 +#[derive(Debug, Deserialize, Serialize)] +pub struct ListMaintenancesRequest { + pub limit: Option, + pub offset: Option, +} + +/// 更新维护状态请求 +#[derive(Debug, Deserialize, Serialize)] +pub struct UpdateMaintenanceStatusRequest { + pub id: i64, + pub status: String, +} + +/// 结束维护请求(可为空 body) +#[derive(Debug, Default, Deserialize, Serialize)] +pub struct EndMaintenanceRequest {} + +/// 获取当前活跃维护请求(可为空 body) +#[derive(Debug, Default, Deserialize, Serialize)] +pub struct GetActiveMaintenanceRequest {} diff --git a/src-tauri/crates/business/src/entitys/messages.rs b/src-tauri/crates/business/src/entitys/messages.rs new file mode 100644 index 0000000..6d9832a --- /dev/null +++ b/src-tauri/crates/business/src/entitys/messages.rs @@ -0,0 +1,71 @@ +use serde::{Deserialize, Serialize}; +use uuid::Uuid; + +use super::Pagination; + +/// 创建消息请求 +#[derive(Debug, Clone, Deserialize, Serialize)] +pub struct CreateMessageRequest { + pub message_type: String, // team_invitation, system_notification, etc. + pub title: String, + pub content: Option, // JSON 格式的扩展数据 + pub recipient_uuids: Vec, // 接收者列表(recipient_type='single' 或 'multiple' 时使用) + pub recipient_type: String, // single, multiple, team, all + pub related_type: Option, + pub related_uuid: Option, + pub priority: Option, // low, normal, high, urgent + pub metadata: Option, // JSON 格式的扩展信息 +} + +/// 查询消息列表请求 +#[derive(Debug, Clone, Deserialize, Serialize)] +pub struct ListMessagesRequest { + #[serde(flatten)] + pub pagination: Pagination, + pub filters: Option, +} + +/// 消息筛选条件 +#[derive(Debug, Clone, Deserialize, Serialize)] +pub struct MessageFilters { + pub message_type: Option, + pub is_read: Option, + pub action_status: Option, + pub priority: Option, +} + +/// 标记消息为已读请求 +#[derive(Debug, Clone, Deserialize, Serialize)] +pub struct MarkMessageReadRequest { + pub message_uuid: Uuid, +} + +/// 批量标记已读请求 +#[derive(Debug, Clone, Deserialize, Serialize)] +pub struct BatchMarkReadRequest { + pub message_uuids: Vec, +} + +/// 处理消息请求(用于邀请类消息) +#[derive(Debug, Clone, Deserialize, Serialize)] +pub struct HandleMessageRequest { + pub message_uuid: Uuid, + pub action: String, // accept, reject +} + +/// 消息列表响应 +#[derive(Debug, Clone, Serialize)] +pub struct MessageListResponse { + pub items: Vec, + pub total: i64, + pub page: i64, + pub page_size: i64, +} + +/// 消息统计响应 +#[derive(Debug, Clone, Serialize)] +pub struct MessageStatsResponse { + pub total: i64, + pub unread: i64, + pub by_type: std::collections::HashMap, +} diff --git a/src-tauri/crates/business/src/entitys/mod.rs b/src-tauri/crates/business/src/entitys/mod.rs new file mode 100644 index 0000000..35b50e0 --- /dev/null +++ b/src-tauri/crates/business/src/entitys/mod.rs @@ -0,0 +1,57 @@ +pub mod maintenance; +pub mod strategy_types; +pub mod user; +pub mod version_types; +pub mod versions; + +// 新增模块 +pub mod accounts; +pub mod audit; +pub mod billing; +pub mod browser_kernel; +pub mod common; +pub mod environments; +pub mod extensions; +pub mod group_member_permissions; +pub mod groups; +pub mod local_api; +pub mod messages; +pub mod proxies; +pub mod proxy_visible_teams; +pub mod referral; +pub mod rpa; +pub mod settings; +pub mod tags; +pub mod teams; +pub mod templates; +pub mod workspace_quotas; +pub mod workspaces; + +pub use maintenance::*; +pub use strategy_types::*; +pub use user::*; +pub use version_types::*; +pub use versions::*; + +// 新增导出 +pub use accounts::*; +pub use audit::*; +pub use billing::*; +pub use browser_kernel::*; +pub use common::*; +pub use environments::*; +pub use extensions::*; +pub use group_member_permissions::*; +pub use groups::*; +pub use local_api::*; +pub use messages::*; +pub use proxies::*; +pub use proxy_visible_teams::*; +pub use referral::*; +pub use rpa::*; +pub use settings::*; +pub use tags::*; +pub use teams::*; +pub use templates::*; +pub use workspace_quotas::*; +pub use workspaces::*; diff --git a/src-tauri/crates/business/src/entitys/proxies.rs b/src-tauri/crates/business/src/entitys/proxies.rs new file mode 100644 index 0000000..40bafed --- /dev/null +++ b/src-tauri/crates/business/src/entitys/proxies.rs @@ -0,0 +1,80 @@ +use serde::{Deserialize, Serialize}; +use uuid::Uuid; + +use super::Pagination; + +/// 查询代理列表请求 +#[derive(Debug, Clone, Deserialize, Serialize)] +pub struct ListProxiesRequest { + #[serde(flatten)] + pub pagination: Pagination, + pub filters: Option, +} + +/// 代理筛选条件 +#[derive(Debug, Clone, Deserialize, Serialize)] +pub struct ProxyFilters { + pub keyword: Option, + pub proxy_type: Option, + pub status: Option, + pub country: Option, +} + +/// 创建代理请求 +#[derive(Debug, Clone, Deserialize, Serialize)] +pub struct CreateProxyRequest { + pub name: String, + pub host: String, + pub port: i32, + pub proxy_type: String, + pub username: Option, + pub password: Option, + pub country: Option, + pub city: Option, + pub ssh_key: Option, + pub ssh_passphrase: Option, +} + +/// 更新代理请求 +#[derive(Debug, Clone, Deserialize, Serialize)] +pub struct UpdateProxyRequest { + pub uuid: Uuid, + pub name: Option, + pub host: Option, + pub port: Option, + pub proxy_type: Option, + pub username: Option, + pub password: Option, + pub country: Option, + pub city: Option, +} + +/// 批量导入代理项(客户端已解析好的结构化数据) +#[derive(Debug, Clone, Deserialize, Serialize)] +pub struct BatchImportProxyItem { + pub name: String, + pub host: String, + pub port: i32, + pub proxy_type: String, + pub username: Option, + pub password: Option, + pub country: Option, + pub city: Option, +} + +/// 批量导入代理请求 +#[derive(Debug, Clone, Deserialize, Serialize)] +pub struct BatchImportProxiesRequest { + pub proxies: Vec, +} + +// ========== 响应结构体 ========== + +/// 代理列表响应 +#[derive(Debug, Clone, Serialize)] +pub struct ProxyListResponse { + pub items: Vec, + pub total: i64, + pub page: i64, + pub page_size: i64, +} diff --git a/src-tauri/crates/business/src/entitys/proxy_visible_teams.rs b/src-tauri/crates/business/src/entitys/proxy_visible_teams.rs new file mode 100644 index 0000000..687e4b5 --- /dev/null +++ b/src-tauri/crates/business/src/entitys/proxy_visible_teams.rs @@ -0,0 +1,50 @@ +use serde::{Deserialize, Serialize}; +use uuid::Uuid; + +/// 设置代理可见性请求 +#[derive(Debug, Clone, Deserialize, Serialize)] +pub struct SetProxyVisibleRequest { + pub proxy_uuid: Uuid, + pub team_uuid: Uuid, +} + +/// 移除代理可见性请求 +#[derive(Debug, Clone, Deserialize, Serialize)] +pub struct RemoveProxyVisibleRequest { + pub proxy_uuid: Uuid, + pub team_uuid: Uuid, +} + +/// 批量设置代理可见性请求 +#[derive(Debug, Clone, Deserialize, Serialize)] +pub struct BatchSetProxyVisibleRequest { + pub proxy_uuid: Uuid, + pub team_uuids: Vec, +} + +/// 查询代理可见团队请求 +#[derive(Debug, Clone, Deserialize, Serialize)] +pub struct ListProxyVisibleTeamsRequest { + pub proxy_uuid: Uuid, +} + +/// 查询可见代理请求 +#[derive(Debug, Clone, Deserialize, Serialize)] +pub struct ListVisibleProxiesRequest { + pub workspace_uuid: Uuid, + pub team_uuid: Option, +} + +// ========== 响应结构体 ========== + +/// 代理可见团队列表响应 +#[derive(Debug, Clone, Serialize)] +pub struct ProxyVisibleTeamListResponse { + pub items: Vec, +} + +/// 可见代理列表响应 +#[derive(Debug, Clone, Serialize)] +pub struct VisibleProxyListResponse { + pub items: Vec, +} diff --git a/src-tauri/crates/business/src/entitys/referral.rs b/src-tauri/crates/business/src/entitys/referral.rs new file mode 100644 index 0000000..ae5f517 --- /dev/null +++ b/src-tauri/crates/business/src/entitys/referral.rs @@ -0,0 +1,141 @@ +use serde::{Deserialize, Serialize}; +use uuid::Uuid; + +use super::Pagination; + +/// 切换推广链接请求 +#[derive(Debug, Clone, Deserialize, Serialize)] +pub struct SwitchReferralLinkRequest { + pub link_uuid: Uuid, +} + +/// 查询奖励记录请求 +#[derive(Debug, Clone, Deserialize, Serialize)] +pub struct ListReferralRewardsRequest { + #[serde(flatten)] + pub pagination: Pagination, + pub keyword: Option, + pub reward_type: Option, + pub status: Option, +} + +/// 查询被邀请用户请求 +#[derive(Debug, Clone, Deserialize, Serialize)] +pub struct ListReferredUsersRequest { + #[serde(flatten)] + pub pagination: Pagination, + pub keyword: Option, + pub status: Option, +} + +/// 兑换积分请求 +#[derive(Debug, Clone, Deserialize, Serialize)] +pub struct RedeemPointsRequest { + pub option_uuid: Uuid, + pub points: i32, +} + +// ========== 响应结构体 ========== + +use crate::dto::{ + RedeemOptionDto, RedeemRecordDto, ReferralLinkDto, ReferralLinkTierDto, ReferralRewardDto, + ReferredUserItemDto, UserReferralPointsDto, +}; +use rust_decimal::Decimal; + +/// 推广统计响应 +#[derive(Debug, Clone, Serialize)] +pub struct ReferralStatsResponse { + pub total_referrals: i32, + pub paid_referrals: i32, + pub total_consumption: Decimal, + pub last_30_days_consumption: Decimal, + pub total_rewards: i32, + pub available_points: i32, + pub current_tier: Option, + pub next_tier: Option, + pub upgrade_progress: i32, +} + +/// 推广积分摘要(用于看板聚合) +#[derive(Debug, Clone, Serialize)] +pub struct ReferralPointsSummary { + pub available_points: i32, + pub pending_points: i32, + pub total_rewards: i32, +} + +/// 推广看板聚合响应 +#[derive(Debug, Clone, Serialize)] +pub struct ReferralDashboardResponse { + pub stats: ReferralStatsResponse, + pub links: Vec, + pub current_link: Option, + pub tiers: Vec, + pub points: ReferralPointsSummary, +} + +/// 套餐页推广摘要响应 +#[derive(Debug, Clone, Serialize)] +pub struct ReferralPlanSummaryResponse { + /// 最近 30 天推广产生的消费金额对应的预估收益(按照当前层级 reward_rate 估算) + pub referral_value_last_30_days: Decimal, + /// 当前订阅套餐的月度价格(如果有订阅) + pub current_plan_monthly_price: Option, + /// 推广收益覆盖当前套餐费用的比例(0-1) + pub coverage_ratio: Option, +} + +/// 推广链接列表响应 +#[derive(Debug, Clone, Serialize)] +pub struct ReferralLinksListResponse { + pub items: Vec, + pub current_link: Option, +} + +/// 奖励记录列表响应 +#[derive(Debug, Clone, Serialize)] +pub struct ReferralRewardsListResponse { + pub items: Vec, + pub total: i64, + pub page: i64, + pub page_size: i64, +} + +/// 被邀请用户列表响应 +#[derive(Debug, Clone, Serialize)] +pub struct ReferredUsersListResponse { + pub items: Vec, + pub total: i64, + pub page: i64, + pub page_size: i64, +} + +/// 兑换选项列表响应 +#[derive(Debug, Clone, Serialize)] +pub struct RedeemOptionsListResponse { + pub items: Vec, +} + +/// 兑换响应 +#[derive(Debug, Clone, Serialize)] +pub struct RedeemResponse { + pub record_uuid: Uuid, + pub points_used: i32, + pub value: Decimal, +} + +/// 用户积分响应 +#[derive(Debug, Clone, Serialize)] +pub struct UserPointsResponse { + pub points: UserReferralPointsDto, +} + +/// 兑换记录列表响应 +#[derive(Debug, Clone, Serialize)] +pub struct RedeemRecordsListResponse { + pub items: Vec, + pub total: i64, + pub page: i64, + pub page_size: i64, +} diff --git a/src-tauri/crates/business/src/entitys/rpa.rs b/src-tauri/crates/business/src/entitys/rpa.rs new file mode 100644 index 0000000..ad830b4 --- /dev/null +++ b/src-tauri/crates/business/src/entitys/rpa.rs @@ -0,0 +1,135 @@ +use serde::{Deserialize, Serialize}; +use uuid::Uuid; + +use super::Pagination; + +#[derive(Debug, Clone, Deserialize, Serialize)] +pub struct ListRpaTasksRequest { + #[serde(flatten)] + pub pagination: Pagination, + pub filters: Option, +} + +#[derive(Debug, Clone, Deserialize, Serialize)] +pub struct RpaTaskFilters { + pub keyword: Option, + pub status: Option, + pub trigger_type: Option, + pub tags: Option>, +} + +#[derive(Debug, Clone, Deserialize, Serialize)] +pub struct CreateRpaTaskRequest { + pub name: String, + pub description: Option, + pub tags: Option>, + pub trigger_type: String, + pub schedule: Option, + pub cron_expression: Option, + pub run_mode: String, + pub retry_count: Option, + pub retry_interval: Option, + pub timeout: Option, + pub concurrency: Option, + pub stop_on_error: Option, + pub notify_on_complete: Option, + pub notify_on_error: Option, + pub environment_uuids: Option>, + pub steps: Option>, +} + +#[derive(Debug, Clone, Deserialize, Serialize)] +pub struct RpaTaskStepRequest { + pub step_type: String, + pub name: String, + pub config: serde_json::Value, + pub enabled: Option, + pub position_x: Option, + pub position_y: Option, + pub sort_order: Option, + pub next_step_uuid: Option, + pub branch_config: Option, +} + +#[derive(Debug, Clone, Deserialize, Serialize)] +pub struct UpdateRpaTaskRequest { + pub uuid: Uuid, + pub name: Option, + pub description: Option, + pub tags: Option>, + pub trigger_type: Option, + pub schedule: Option, + pub cron_expression: Option, + pub run_mode: Option, + pub retry_count: Option, + pub retry_interval: Option, + pub timeout: Option, + pub concurrency: Option, + pub stop_on_error: Option, + pub notify_on_complete: Option, + pub notify_on_error: Option, + pub environment_uuids: Option>, + pub steps: Option>, +} + +#[derive(Debug, Clone, Deserialize, Serialize)] +pub struct RunRpaTaskRequest { + pub uuid: Uuid, + pub environment_uuids: Option>, +} + +#[derive(Debug, Clone, Deserialize, Serialize)] +pub struct DuplicateRpaTaskRequest { + pub uuid: Uuid, + pub new_name: Option, +} + +#[derive(Debug, Clone, Deserialize, Serialize)] +pub struct ExportRpaTaskRequest { + pub uuid: Uuid, +} + +#[derive(Debug, Clone, Deserialize, Serialize)] +pub struct ImportRpaTaskRequest { + pub import_data: String, + pub name: Option, +} + +#[derive(Debug, Clone, Deserialize, Serialize)] +pub struct ListRpaRunsRequest { + pub task_uuid: Uuid, + #[serde(flatten)] + pub pagination: Pagination, + pub status: Option, +} + +use crate::dto::{RpaTaskDto, RpaTaskRunDto, RpaTaskStepDto}; + +#[derive(Debug, Clone, Serialize)] +pub struct RpaTaskListResponse { + pub items: Vec, + pub total: i64, + pub page: i64, + pub page_size: i64, +} + +#[derive(Debug, Clone, Serialize)] +pub struct RpaTaskDetailResponse { + pub task: RpaTaskDto, + pub steps: Vec, + pub environment_uuids: Vec, +} + +#[derive(Debug, Clone, Serialize)] +pub struct RpaRunsListResponse { + pub items: Vec, + pub total: i64, + pub page: i64, + pub page_size: i64, +} + +#[derive(Debug, Clone, Serialize)] +pub struct ExportRpaTaskResponse { + pub content: String, + pub filename: String, +} diff --git a/src-tauri/crates/business/src/entitys/settings.rs b/src-tauri/crates/business/src/entitys/settings.rs new file mode 100644 index 0000000..7786cc1 --- /dev/null +++ b/src-tauri/crates/business/src/entitys/settings.rs @@ -0,0 +1,30 @@ +use serde::{Deserialize, Serialize}; + +/// 更新用户偏好请求 +#[derive(Debug, Clone, Deserialize, Serialize)] +pub struct UpdatePreferencesRequest { + pub theme: Option, + pub language: Option, + pub notifications_enabled: Option, +} + +/// 更新用户信息请求 +#[derive(Debug, Clone, Deserialize, Serialize)] +pub struct UpdateProfileRequest { + pub nickname: Option, + pub avatar_hash: Option, +} + +/// 修改密码请求 +#[derive(Debug, Clone, Deserialize, Serialize)] +pub struct ChangePasswordRequest { + pub old_password: String, + pub new_password: String, +} + +/// 查询登录历史请求 +#[derive(Debug, Clone, Deserialize, Serialize)] +pub struct ListLoginHistoryRequest { + pub page: i32, + pub page_size: i32, +} diff --git a/src-tauri/crates/business/src/entitys/strategy_types.rs b/src-tauri/crates/business/src/entitys/strategy_types.rs new file mode 100644 index 0000000..94d1122 --- /dev/null +++ b/src-tauri/crates/business/src/entitys/strategy_types.rs @@ -0,0 +1,32 @@ +use serde::{Deserialize, Serialize}; + +/// 根据ID获取策略类型请求 +#[derive(Debug, Deserialize, Serialize)] +pub struct GetStrategyTypeByIdRequest { + pub id: i32, +} + +/// 根据代码获取策略类型请求 +#[derive(Debug, Deserialize, Serialize)] +pub struct GetStrategyTypeByCodeRequest { + pub code: String, +} + +/// 根据分类查询策略类型请求 +#[derive(Debug, Deserialize, Serialize)] +pub struct ListStrategyTypesByCategoryRequest { + pub category: String, +} + +/// 策略类型列表响应 +#[derive(Debug, Deserialize, Serialize)] +pub struct StrategyTypeListResponse { + pub list: Vec, +} + +/// 策略类型详细信息响应(包含配置示例) +#[derive(Debug, Deserialize, Serialize)] +pub struct StrategyTypeDetailResponse { + pub strategy_type: crate::dto::strategy_types::StrategyType, + pub config_example: String, // JSON 配置示例 +} diff --git a/src-tauri/crates/business/src/entitys/tags.rs b/src-tauri/crates/business/src/entitys/tags.rs new file mode 100644 index 0000000..49e6d29 --- /dev/null +++ b/src-tauri/crates/business/src/entitys/tags.rs @@ -0,0 +1,28 @@ +use serde::{Deserialize, Serialize}; +use uuid::Uuid; + +use super::Pagination; + +/// 查询标签列表请求 +#[derive(Debug, Clone, Deserialize, Serialize)] +pub struct ListTagsRequest { + #[serde(flatten)] + pub pagination: Pagination, + pub keyword: Option, +} + +/// 创建标签请求 +#[derive(Debug, Clone, Deserialize, Serialize)] +pub struct CreateTagRequest { + pub name: String, + pub color: Option, +} + +/// 更新标签请求 +#[derive(Debug, Clone, Deserialize, Serialize)] +pub struct UpdateTagRequest { + pub uuid: Uuid, + pub name: Option, + pub color: Option, + pub sort_order: Option, +} diff --git a/src-tauri/crates/business/src/entitys/teams.rs b/src-tauri/crates/business/src/entitys/teams.rs new file mode 100644 index 0000000..d28ced2 --- /dev/null +++ b/src-tauri/crates/business/src/entitys/teams.rs @@ -0,0 +1,130 @@ +use serde::{Deserialize, Serialize}; +use uuid::Uuid; + +use super::Pagination; + +/// 创建团队请求 +#[derive(Debug, Clone, Deserialize, Serialize)] +pub struct CreateTeamRequest { + pub workspace_uuid: Uuid, + pub name: String, + pub description: Option, +} + +/// 更新团队请求 +#[derive(Debug, Clone, Deserialize, Serialize)] +pub struct UpdateTeamRequest { + pub uuid: Uuid, + pub name: Option, + pub description: Option, + pub avatar_hash: Option, +} + +/// 切换团队请求 +#[derive(Debug, Clone, Deserialize, Serialize)] +pub struct SwitchTeamRequest { + pub team_uuid: Uuid, +} + +/// 查询团队成员请求 +#[derive(Debug, Clone, Deserialize, Serialize)] +pub struct ListTeamMembersRequest { + pub workspace_uuid: Uuid, + #[serde(flatten)] + pub pagination: Pagination, + pub filters: Option, +} + +/// 团队成员筛选条件 +#[derive(Debug, Clone, Deserialize, Serialize)] +pub struct TeamMemberFilters { + pub keyword: Option, + pub role: Option, + pub status: Option, +} + +/// 邀请成员请求 +#[derive(Debug, Clone, Deserialize, Serialize)] +pub struct InviteMemberRequest { + pub email: String, + pub role: String, +} + +/// 取消邀请请求 +#[derive(Debug, Clone, Deserialize, Serialize)] +pub struct CancelInviteRequest { + pub invitation_uuid: Uuid, +} + +/// 更新成员角色请求 +#[derive(Debug, Clone, Deserialize, Serialize)] +pub struct UpdateMemberRoleRequest { + pub member_uuid: Uuid, + pub role: String, +} + +/// 更新成员状态请求 +#[derive(Debug, Clone, Deserialize, Serialize)] +pub struct UpdateMemberStatusRequest { + pub member_uuid: Uuid, + pub status: String, +} + +/// 移除成员请求 +#[derive(Debug, Clone, Deserialize, Serialize)] +pub struct RemoveMemberRequest { + pub member_uuid: Uuid, +} + +/// 批量移除成员请求 +#[derive(Debug, Clone, Deserialize, Serialize)] +pub struct BatchRemoveMembersRequest { + pub member_uuids: Vec, +} + +/// 接受邀请请求 +#[derive(Debug, Clone, Deserialize, Serialize)] +pub struct AcceptInvitationRequest { + pub token: String, +} + +/// 拒绝邀请请求 +#[derive(Debug, Clone, Deserialize, Serialize)] +pub struct RejectInvitationRequest { + pub token: String, +} + +// ========== 响应结构体 ========== + +/// 团队列表响应 +#[derive(Debug, Clone, Serialize)] +pub struct TeamListResponse { + pub current_team_uuid: Option, + pub teams: Vec, +} + +/// 团队列表项 +#[derive(Debug, Clone, Serialize)] +pub struct TeamItem { + pub uuid: Uuid, + pub name: String, + pub description: Option, + pub role: String, + pub members_count: i64, + pub is_current: bool, +} + +/// 团队成员列表响应 +#[derive(Debug, Clone, Serialize)] +pub struct MemberListResponse { + pub items: Vec, + pub total: i64, + pub page: i64, + pub page_size: i64, +} + +/// 接受邀请响应 +#[derive(Debug, Clone, Serialize)] +pub struct AcceptInvitationResponse { + pub team_uuid: Uuid, +} diff --git a/src-tauri/crates/business/src/entitys/templates.rs b/src-tauri/crates/business/src/entitys/templates.rs new file mode 100644 index 0000000..66cdf32 --- /dev/null +++ b/src-tauri/crates/business/src/entitys/templates.rs @@ -0,0 +1,94 @@ +use serde::{Deserialize, Serialize}; +use uuid::Uuid; + +use super::Pagination; + +/// 查询模板列表请求 +#[derive(Debug, Clone, Deserialize, Serialize)] +pub struct ListTemplatesRequest { + #[serde(flatten)] + pub pagination: Pagination, + pub keyword: Option, + pub is_public: Option, +} + +/// 创建模板请求 +#[derive(Debug, Clone, Deserialize, Serialize)] +pub struct CreateTemplateRequest { + pub name: String, + pub description: Option, + pub is_public: Option, + /// 完整的环境详情数据(EnvironmentDetailResponse 的 JSON 格式) + /// 如果提供了 environment_uuid,则此字段会被忽略,后端会自动获取环境详情 + pub environment_data: Option, + /// 环境 UUID(如果提供,后端会自动获取该环境的完整详情数据) + pub environment_uuid: Option, +} + +/// 更新模板请求 +#[derive(Debug, Clone, Deserialize, Serialize)] +pub struct UpdateTemplateRequest { + pub uuid: Uuid, + pub name: Option, + pub description: Option, + pub is_public: Option, + pub config_json: Option, +} + +/// 应用模板请求(更新现有环境) +#[derive(Debug, Clone, Deserialize, Serialize)] +pub struct ApplyTemplateRequest { + pub template_uuid: Uuid, + pub environment_uuid: Uuid, +} + +/// 从模板创建环境请求 +#[derive(Debug, Clone, Deserialize, Serialize)] +pub struct CreateFromTemplateRequest { + pub template_uuid: Uuid, + pub name: Option, // 如果提供,将覆盖模板中的名称 + pub description: Option, // 如果提供,将覆盖模板中的描述 + pub group_uuid: Option, // 如果提供,将覆盖模板中的分组 +} + +/// 获取模板详情请求 +#[derive(Debug, Clone, Deserialize, Serialize)] +pub struct GetTemplateRequest { + pub uuid: Uuid, + /// 是否用于创建环境(如果为 true,将检查关联数据是否存在) + pub for_create: Option, +} + +// ========== 响应结构体 ========== + +/// 模板列表响应 +#[derive(Debug, Clone, Serialize)] +pub struct TemplateListResponse { + pub items: Vec, + pub total: i64, + pub page: i64, + pub page_size: i64, +} + +/// 关联数据状态 +#[derive(Debug, Clone, Serialize, Deserialize)] +pub struct AssociationsStatus { + /// 分组是否存在 + pub group_exists: bool, + /// 标签是否存在(按 UUID 映射) + pub tags_exist: std::collections::HashMap, + /// 账号是否存在(按 UUID 映射) + pub accounts_exist: std::collections::HashMap, + /// 代理是否存在 + pub proxy_exists: bool, +} + +/// 模板详情响应(包含关联数据状态) +#[derive(Debug, Clone, Serialize)] +pub struct TemplateDetailResponse { + /// 模板数据 + #[serde(flatten)] + pub template: crate::dto::TemplateDto, + /// 关联数据状态(仅在 for_create=true 时返回) + pub associations_status: Option, +} diff --git a/src-tauri/crates/business/src/entitys/user.rs b/src-tauri/crates/business/src/entitys/user.rs new file mode 100644 index 0000000..9623fff --- /dev/null +++ b/src-tauri/crates/business/src/entitys/user.rs @@ -0,0 +1,132 @@ +use chrono::{DateTime, Utc}; +use serde::{Deserialize, Serialize}; +use uuid::Uuid; + +/// 注册请求 +#[derive(Debug, Deserialize, Serialize, Default)] +pub struct RegisterRequest { + pub email: String, + pub password: String, + pub code: String, + #[serde(skip_serializing_if = "Option::is_none")] + pub nickname: Option, // 昵称(可选) + #[serde(skip_serializing_if = "Option::is_none")] + pub public_secret_key: Option, // 用户公钥(可选) + #[serde(skip_serializing_if = "Option::is_none")] + pub referral_code: Option, // 推荐码(可选) +} + +/// 基本登录请求(邮箱 + 密码) +#[derive(Debug, Deserialize, Serialize)] +pub struct BasicLoginData { + pub email: String, + pub password: String, + #[serde(skip_serializing_if = "Option::is_none")] + pub public_secret_key: Option, +} + +/// 记住密码登录请求(邮箱 + refresh_token) +#[derive(Debug, Deserialize, Serialize)] +pub struct RememberLoginData { + pub email: String, + pub refresh_token: String, + #[serde(skip_serializing_if = "Option::is_none")] + pub public_secret_key: Option, +} + +/// 登录请求(统一结构,通过枚举区分两种登录方式) +/// +/// 使用 serde 的 tag 特性,根据 "login_type" 字段自动反序列化为对应的变体 +#[derive(Debug, Deserialize, Serialize)] +#[serde(tag = "login_type", rename_all = "snake_case")] +pub enum LoginRequest { + /// 基本登录(邮箱 + 密码) + Basic(BasicLoginData), + /// 记住密码登录(邮箱 + refresh_token) + Remember(RememberLoginData), +} + +/// 刷新 Token 请求 +#[derive(Debug, Deserialize, Serialize, Default)] +pub struct RefreshTokenRequest { + pub refresh_token: String, +} + +/// 更新用户信息请求 +#[derive(Debug, Deserialize, Serialize, Default)] +pub struct UpdateUserRequest { + pub nickname: Option, + pub phone: Option, + pub email: Option, +} + +/// 修改密码请求 +#[derive(Debug, Deserialize, Serialize, Default)] +pub struct UpdatePasswordRequest { + pub old_password: String, + pub new_password: String, +} + +/// 校验当前用户密码请求 +#[derive(Debug, Deserialize, Serialize, Default)] +pub struct VerifyPasswordRequest { + pub password: String, +} + +/// 重置密码请求 +#[derive(Debug, Deserialize, Serialize, Default)] +pub struct ResetPasswordRequest { + pub email: String, + pub code: String, + pub new_password: String, +} + +/// 发送验证码请求 +#[derive(Debug, Deserialize, Serialize, Default)] +pub struct SendCodeRequest { + pub email: String, + pub r#type: String, // register 或 reset_password +} + +/// 用户信息响应 +#[derive(Debug, Serialize)] +pub struct UserResponse { + pub uuid: Uuid, + pub id: String, + pub nickname: Option, + pub email: String, + pub phone: Option, + pub avatar_hash: Option, + /// 头像完整 URL(当 avatar_hash 存在时由服务端拼接) + #[serde(skip_serializing_if = "Option::is_none")] + pub avatar_url: Option, + pub status: String, + pub created_at: DateTime, + pub updated_at: DateTime, + /// 当前团队详细信息(如果存在) + pub current_team: Option, + /// 当前工作空间详细信息(如果存在) + pub current_workspace: Option, +} + +/// 登录响应 +#[derive(Debug, Serialize)] +pub struct LoginResponse { + pub access_token: String, + pub refresh_token: String, + pub user_info: Option, +} + +/// 注册响应 +#[derive(Debug, Serialize)] +pub struct RegisterResponse { + pub access_token: String, + pub refresh_token: String, + pub user_info: Option, +} + +/// 密码校验响应 +#[derive(Debug, Serialize)] +pub struct VerifyPasswordResponse { + pub valid: bool, +} diff --git a/src-tauri/crates/business/src/entitys/version_types.rs b/src-tauri/crates/business/src/entitys/version_types.rs new file mode 100644 index 0000000..d796392 --- /dev/null +++ b/src-tauri/crates/business/src/entitys/version_types.rs @@ -0,0 +1,69 @@ +use serde::{Deserialize, Serialize}; + +/// 创建版本类型请求 +#[derive(Debug, Deserialize, Serialize)] +pub struct CreateVersionTypeRequest { + pub type_code: String, + pub type_name: String, + pub description: Option, + pub sort_order: Option, +} + +/// 更新版本类型请求 +#[derive(Debug, Deserialize, Serialize)] +pub struct UpdateVersionTypeRequest { + pub type_name: Option, + pub description: Option, + pub sort_order: Option, + pub is_active: Option, + pub is_auto_download: Option, +} + +/// 根据ID获取版本类型请求 +#[derive(Debug, Deserialize, Serialize)] +pub struct GetVersionTypeByIdRequest { + pub id: i32, +} + +/// 根据代码获取版本类型请求 +#[derive(Debug, Deserialize, Serialize)] +pub struct GetVersionTypeByCodeRequest { + pub type_code: String, +} + +/// 查询所有版本类型请求(可为空 body) +#[derive(Debug, Default, Deserialize, Serialize)] +pub struct ListAllVersionTypesRequest {} + +/// 查询激活的版本类型请求(可为空 body) +#[derive(Debug, Default, Deserialize, Serialize)] +pub struct ListActiveVersionTypesRequest {} + +/// 更新版本类型请求(含 id) +#[derive(Debug, Deserialize, Serialize)] +pub struct UpdateVersionTypeHandleRequest { + pub id: i32, + pub type_name: Option, + pub description: Option, + pub sort_order: Option, + pub is_active: Option, +} + +/// 删除版本类型请求 +#[derive(Debug, Deserialize, Serialize)] +pub struct DeleteVersionTypeRequest { + pub id: i32, +} + +/// 切换版本类型状态请求 +#[derive(Debug, Deserialize, Serialize)] +pub struct ToggleVersionTypeStatusRequest { + pub id: i32, + pub is_active: bool, +} + +/// 版本类型列表响应 +#[derive(Debug, Deserialize, Serialize)] +pub struct VersionTypeListResponse { + pub list: Vec, +} diff --git a/src-tauri/crates/business/src/entitys/versions.rs b/src-tauri/crates/business/src/entitys/versions.rs new file mode 100644 index 0000000..52b4a31 --- /dev/null +++ b/src-tauri/crates/business/src/entitys/versions.rs @@ -0,0 +1,132 @@ +use chrono::DateTime; +use serde::{Deserialize, Serialize}; + +/// 创建版本请求 +#[derive(Debug, Default, Deserialize, Serialize)] +pub struct CreateVersionRequest { + pub type_id: i32, + pub resource_name: String, + pub version: String, + pub name: Option, + pub notes: Option, + pub platform: Option, + pub url: Option, + pub hash: Option, + pub signature: Option, + pub install_path: Option, + pub file_size: Option, + pub pub_date: Option>, + pub arch: Option, + pub package_format: Option, + pub requires_extract: Option, + pub entrypoint_template: Option, + pub extract_root: Option, +} + +/// 更新版本请求 +#[derive(Debug, Deserialize, Serialize)] +pub struct UpdateVersionRequest { + pub name: Option, + pub notes: Option, + pub platform: Option, + pub url: Option, + pub hash: Option, + pub signature: Option, + pub install_path: Option, + pub file_size: Option, + pub status: Option, + pub is_latest: Option, + pub arch: Option, + pub package_format: Option, + pub requires_extract: Option, + pub entrypoint_template: Option, + pub extract_root: Option, +} + +/// 版本列表响应 +#[derive(Debug, Deserialize, Serialize)] +pub struct VersionListResponse { + pub total: i64, + pub list: Vec, +} + +/// 查询版本参数 +#[derive(Debug, Deserialize, Serialize)] +pub struct QueryVersionParams { + pub resource_name: Option, + pub platform: Option, + pub status: Option, +} + +/// 根据ID获取版本请求 +#[derive(Debug, Deserialize, Serialize)] +pub struct GetVersionByIdRequest { + pub id: i32, +} + +/// 根据资源名称和版本号获取版本请求 +#[derive(Debug, Deserialize, Serialize)] +pub struct GetVersionByNameAndVersionRequest { + pub resource_name: String, + pub version: String, +} + +/// 获取最新版本请求 +#[derive(Debug, Deserialize, Serialize)] +pub struct GetLatestVersionRequest { + pub resource_name: String, + pub platform: String, +} + +/// 查询版本列表请求 +#[derive(Debug, Deserialize, Serialize)] +pub struct ListVersionsRequest { + pub params: QueryVersionParams, + pub page_num: Option, + pub page_size: Option, +} + +/// 更新版本请求(含 id) +#[derive(Debug, Deserialize, Serialize)] +pub struct UpdateVersionHandleRequest { + pub id: i32, + pub name: Option, + pub notes: Option, + pub platform: Option, + pub url: Option, + pub hash: Option, + pub signature: Option, + pub install_path: Option, + pub file_size: Option, + pub status: Option, + pub is_latest: Option, +} + +/// 删除版本请求 +#[derive(Debug, Deserialize, Serialize)] +pub struct DeleteVersionRequest { + pub id: i32, +} + +/// 设置最新版本请求 +#[derive(Debug, Deserialize, Serialize)] +pub struct SetLatestVersionRequest { + pub type_id: i32, + pub resource_name: String, + pub version_id: i32, +} + +/// 版本回退请求 +#[derive(Debug, Deserialize, Serialize)] +pub struct RollbackVersionRequest { + pub type_id: i32, + pub resource_name: String, + pub target_version_id: i32, +} + +/// 版本对比请求 +#[derive(Debug, Deserialize, Serialize)] +pub struct CompareVersionsRequest { + pub version_id_1: i32, + pub version_id_2: i32, +} diff --git a/src-tauri/crates/business/src/entitys/workspace_quotas.rs b/src-tauri/crates/business/src/entitys/workspace_quotas.rs new file mode 100644 index 0000000..2346bf8 --- /dev/null +++ b/src-tauri/crates/business/src/entitys/workspace_quotas.rs @@ -0,0 +1,34 @@ +use serde::{Deserialize, Serialize}; +use uuid::Uuid; + +/// 获取工作空间配额请求 +#[derive(Debug, Clone, Deserialize, Serialize)] +pub struct GetWorkspaceQuotaRequest { + #[serde(skip_serializing_if = "Option::is_none")] + pub workspace_uuid: Option, +} + +/// 更新配额使用情况请求 +#[derive(Debug, Clone, Deserialize, Serialize)] +pub struct UpdateQuotaUsageRequest { + pub workspace_uuid: Uuid, + pub quota_type: String, // 'environments', 'proxies', 'team_members', 'rpa_tasks' + pub increment: bool, // true 为增加,false 为减少 + pub amount: i32, // 增加或减少的数量 +} + +// ========== 响应结构体 ========== + +/// 工作空间配额响应 +#[derive(Debug, Clone, Serialize)] +pub struct WorkspaceQuotaResponse { + pub workspace_uuid: Uuid, + pub max_environments: i32, + pub used_environments: i32, + pub max_team_members: i32, + pub used_team_members: i32, + pub max_proxies: i32, + pub used_proxies: i32, + pub max_rpa_tasks: i32, + pub used_rpa_tasks: i32, +} diff --git a/src-tauri/crates/business/src/entitys/workspaces.rs b/src-tauri/crates/business/src/entitys/workspaces.rs new file mode 100644 index 0000000..72f4367 --- /dev/null +++ b/src-tauri/crates/business/src/entitys/workspaces.rs @@ -0,0 +1,57 @@ +use serde::{Deserialize, Serialize}; +use uuid::Uuid; + +use super::Pagination; + +/// 创建工作空间请求 +#[derive(Debug, Clone, Deserialize, Serialize)] +pub struct CreateWorkspaceRequest { + pub name: String, + pub workspace_type: Option, +} + +/// 更新工作空间请求 +#[derive(Debug, Clone, Deserialize, Serialize)] +pub struct UpdateWorkspaceRequest { + pub uuid: Uuid, + pub name: Option, +} + +/// 切换工作空间请求 +#[derive(Debug, Clone, Deserialize, Serialize)] +pub struct SwitchWorkspaceRequest { + pub workspace_uuid: Uuid, +} + +/// 查询工作空间列表请求 +#[derive(Debug, Clone, Deserialize, Serialize)] +pub struct ListWorkspacesRequest { + #[serde(flatten)] + pub pagination: Pagination, + pub filters: Option, +} + +/// 工作空间筛选条件 +#[derive(Debug, Clone, Deserialize, Serialize)] +pub struct WorkspaceFilters { + pub workspace_type: Option, + pub keyword: Option, +} + +// ========== 响应结构体 ========== + +/// 工作空间列表响应 +#[derive(Debug, Clone, Serialize)] +pub struct WorkspaceListResponse { + pub current_workspace_uuid: Option, + pub workspaces: Vec, +} + +/// 工作空间列表项 +#[derive(Debug, Clone, Serialize)] +pub struct WorkspaceItem { + pub uuid: Uuid, + pub name: String, + pub workspace_type: String, + pub is_current: bool, +} diff --git a/src-tauri/crates/business/src/errors.rs b/src-tauri/crates/business/src/errors.rs new file mode 100644 index 0000000..6d22fa1 --- /dev/null +++ b/src-tauri/crates/business/src/errors.rs @@ -0,0 +1,105 @@ +use thiserror::Error; + +/// Simprint Server 错误类型 +#[derive(Error, Debug)] +pub enum SimprintError { + /// 用户不存在 + #[error("用户不存在")] + UserNotFound, + + /// 邮箱已被注册 + #[error("邮箱已被注册")] + EmailAlreadyExists, + + /// 邮箱或密码错误 + #[error("邮箱或密码错误")] + InvalidCredentials, + + /// 用户已被禁用 + #[error("用户已被禁用")] + UserDisabled, + + /// 验证码错误或已过期 + #[error("验证码错误或已过期")] + VerificationCodeExpired, + + /// 机器不存在 + #[error("机器不存在")] + MachineNotFound, + + /// 机器已被绑定 + #[error("机器已被绑定")] + MachineAlreadyBound, + + /// 用户未绑定到机器 + #[error("用户未绑定到机器")] + MachineNotBound, + + /// 版本不存在 + #[error("版本不存在")] + VersionNotFound, + + /// 版本已存在 + #[error("版本已存在")] + VersionAlreadyExists, + + /// 版本类型不存在 + #[error("版本类型不存在")] + VersionTypeNotFound, + + /// 版本号为空 + #[error("版本号不能为空")] + VersionEmpty, + + /// 资源名称为空 + #[error("资源名称不能为空")] + ResourceNameEmpty, + + /// 灰度发布不存在 + #[error("灰度发布不存在")] + GrayReleaseNotFound, + + /// 灰度分配失败 + #[error("灰度分配失败")] + GrayAllocationFailed, + + /// 策略类型不存在 + #[error("策略类型不存在")] + StrategyTypeNotFound, + + /// 策略配置无效 + #[error("策略配置无效")] + InvalidStrategyConfig, + + /// 维护不存在 + #[error("维护不存在")] + MaintenanceNotFound, + + /// 数据库操作失败 + #[error("数据库操作失败: {0}")] + DatabaseError(#[from] sqlx::Error), + + /// Anyhow错误 + #[error("操作失败: {0}")] + AnyhowError(#[from] anyhow::Error), + + /// JSON序列化错误 + #[error("JSON序列化错误: {0}")] + JsonError(#[from] serde_json::Error), + + /// 错误的请求 + #[error("错误的请求: {0}")] + InvalidRequest(String), + + /// 其他错误 + #[error("{0}")] + Other(String), +} + +impl From<&str> for SimprintError { + fn from(err: &str) -> Self { + SimprintError::Other(err.to_string()) + } +} + +// Note: Error conversion to Response is handled in handlers layer via map_err diff --git a/src-tauri/crates/business/src/handlers.rs b/src-tauri/crates/business/src/handlers.rs new file mode 100644 index 0000000..ecc88e7 --- /dev/null +++ b/src-tauri/crates/business/src/handlers.rs @@ -0,0 +1,49 @@ +mod health; +mod secret; +mod time; +mod users; + +// 新增模块 +pub mod accounts; +pub mod local_api; +pub mod audit; +pub mod billing; +pub mod browser_kernel; +pub mod environments; +pub mod extensions; +pub mod group_permissions; +pub mod messages; +pub mod preferences; +pub mod proxies; +pub mod proxy_visibility; +pub mod referral; +pub mod rpa; +pub mod teams; +pub mod templates; +pub mod workspace_quotas; +pub mod workspaces; + +pub use health::*; +pub use secret::*; +pub use time::*; +pub use users::*; + +// 新增导出 +pub use accounts::*; +pub use local_api::*; +pub use audit::*; +pub use billing::*; +pub use browser_kernel::*; +pub use environments::*; +pub use extensions::*; +pub use group_permissions::*; +pub use messages::*; +pub use preferences::*; +pub use proxies::*; +pub use proxy_visibility::*; +pub use referral::*; +pub use rpa::*; +pub use teams::*; +pub use templates::*; +pub use workspace_quotas::*; +pub use workspaces::*; diff --git a/src-tauri/crates/business/src/handlers/accounts.rs b/src-tauri/crates/business/src/handlers/accounts.rs new file mode 100644 index 0000000..47f1a9e --- /dev/null +++ b/src-tauri/crates/business/src/handlers/accounts.rs @@ -0,0 +1,145 @@ +use axum::{extract::Extension, extract::State}; + +use crate::entitys::{ + AccountListResponse, BatchImportAccountsRequest, BatchImportResponse, BatchUuidRequest, + CreateAccountRequest, CreateResponse, ListAccountsRequest, UpdateAccountRequest, UuidRequest, +}; +use crate::services; +use crate::state::RequestContext; +use crate::svc_ctx::SvcCtx; +use crate::utils::{Json, Response, Result}; + +/// 获取账号列表 +pub async fn get_accounts_handler( + State(svc_ctx): State, + Extension(ctx): Extension, + Json(payload): Json, +) -> Result { + let team_uuid = services::teams::get_current_team_service(&svc_ctx, ctx.user_uuid_unwrap()) + .await + .map_err(|e| Response::fail(Some(&e)))?; + + let (accounts, total) = services::accounts::get_accounts_service( + &svc_ctx, + ctx.user_uuid_unwrap(), + team_uuid, + &payload, + ) + .await + .map_err(|e| Response::fail(Some(&e)))?; + + Ok(Response::success( + Some("获取成功"), + Some(AccountListResponse { + items: accounts, + total, + page: payload.pagination.page, + page_size: payload.pagination.page_size, + }), + )) +} + +/// 获取账号详情 +pub async fn get_account_handler( + State(svc_ctx): State, + Json(payload): Json, +) -> Result { + let account = services::accounts::get_account_service(&svc_ctx, payload.uuid) + .await + .map_err(|e| Response::fail(Some(&e)))?; + + Ok(Response::success(Some("获取成功"), Some(account))) +} + +/// 创建账号 +pub async fn create_account_handler( + State(svc_ctx): State, + Extension(ctx): Extension, + Json(payload): Json, +) -> Result { + let team_uuid = services::teams::get_current_team_service(&svc_ctx, ctx.user_uuid_unwrap()) + .await + .map_err(|e| Response::fail(Some(&e)))?; + + let account_uuid = services::accounts::create_account_service( + &svc_ctx, + ctx.user_uuid_unwrap(), + team_uuid, + &payload, + ) + .await + .map_err(|e| Response::fail(Some(&e)))?; + + Ok(Response::success( + Some("创建成功"), + Some(CreateResponse { uuid: account_uuid }), + )) +} + +/// 更新账号 +pub async fn update_account_handler( + State(svc_ctx): State, + Extension(ctx): Extension, + Json(payload): Json, +) -> Result<()> { + services::accounts::update_account_service(&svc_ctx, &payload) + .await + .map_err(|e| Response::fail(Some(&e)))?; + + Ok(Response::success(Some("更新成功"), None)) +} + +/// 删除账号 +pub async fn delete_account_handler( + State(svc_ctx): State, + Extension(ctx): Extension, + Json(payload): Json, +) -> Result<()> { + services::accounts::delete_account_service(&svc_ctx, payload.uuid) + .await + .map_err(|e| Response::fail(Some(&e)))?; + + Ok(Response::success(Some("删除成功"), None)) +} + +/// 批量删除账号 +pub async fn batch_delete_accounts_handler( + State(svc_ctx): State, + Extension(ctx): Extension, + Json(payload): Json, +) -> Result { + let count = services::accounts::batch_delete_accounts_service(&svc_ctx, &payload.uuids) + .await + .map_err(|e| Response::fail(Some(&e)))?; + + Ok(Response::success(Some("删除成功"), Some(count))) +} + +/// 批量导入账号 +pub async fn batch_import_accounts_handler( + State(svc_ctx): State, + Extension(ctx): Extension, + Json(payload): Json, +) -> Result { + let team_uuid = services::teams::get_current_team_service(&svc_ctx, ctx.user_uuid_unwrap()) + .await + .map_err(|e| Response::fail(Some(&e)))?; + + let result = services::accounts::batch_import_accounts_service( + &svc_ctx, + ctx.user_uuid_unwrap(), + team_uuid, + &payload, + ) + .await + .map_err(|e| Response::fail(Some(&e)))?; + + Ok(Response::success( + Some("导入完成"), + Some(BatchImportResponse { + success_count: result.success_count, + failed_count: result.failed_count, + errors: result.errors, + }), + )) +} diff --git a/src-tauri/crates/business/src/handlers/audit.rs b/src-tauri/crates/business/src/handlers/audit.rs new file mode 100644 index 0000000..676d4dc --- /dev/null +++ b/src-tauri/crates/business/src/handlers/audit.rs @@ -0,0 +1,98 @@ +use axum::extract::{Extension, State}; + +use crate::dto::AuditLogDto; +use crate::entitys::{ + AuditLogsListResponse, AuditStatsRequest, AuditStatsResponse, ExportAuditLogsRequest, + ExportResponse, ListAuditLogsRequest, UuidRequest, +}; +use crate::services; +use crate::state::RequestContext; +use crate::svc_ctx::SvcCtx; +use crate::utils::{Json, Response, Result}; + +/// 获取审计日志列表 +pub async fn get_audit_logs_handler( + State(svc_ctx): State, + Extension(ctx): Extension, + Json(payload): Json, +) -> Result { + let current_user_uuid = ctx.user_uuid_unwrap(); + let team_uuid = services::teams::get_current_team_service(&svc_ctx, current_user_uuid) + .await + .map_err(|e| Response::fail(Some(&e)))?; + + let (items, total) = + services::audit::get_audit_logs_service(&svc_ctx, current_user_uuid, team_uuid, &payload) + .await + .map_err(|e| Response::fail(Some(&e)))?; + + Ok(Response::success( + Some("获取成功"), + Some(AuditLogsListResponse { + items, + total, + page: payload.pagination.page, + page_size: payload.pagination.page_size, + }), + )) +} + +/// 获取审计日志详情 +pub async fn get_audit_log_detail_handler( + State(svc_ctx): State, + Json(payload): Json, +) -> Result { + let log = services::audit::get_audit_log_service(&svc_ctx, payload.uuid) + .await + .map_err(|e| Response::fail(Some(&e)))?; + + Ok(Response::success(Some("获取成功"), Some(log))) +} + +/// 导出审计日志 +pub async fn export_audit_logs_handler( + State(svc_ctx): State, + Extension(ctx): Extension, + Json(payload): Json, +) -> Result { + let current_user_uuid = ctx.user_uuid_unwrap(); + let team_uuid = services::teams::get_current_team_service(&svc_ctx, current_user_uuid) + .await + .map_err(|e| Response::fail(Some(&e)))?; + + let (content, filename, mime_type) = services::audit::export_audit_logs_service( + &svc_ctx, + current_user_uuid, + team_uuid, + &payload, + ) + .await + .map_err(|e| Response::fail(Some(&e)))?; + + Ok(Response::success( + Some("导出成功"), + Some(ExportResponse { + content, + filename, + mime_type, + }), + )) +} + +/// 获取审计统计 +pub async fn get_audit_stats_handler( + State(svc_ctx): State, + Extension(ctx): Extension, + Json(_payload): Json, +) -> Result { + let current_user_uuid = ctx.user_uuid_unwrap(); + let team_uuid = services::teams::get_current_team_service(&svc_ctx, current_user_uuid) + .await + .map_err(|e| Response::fail(Some(&e)))?; + + let stats = services::audit::get_audit_stats_service(&svc_ctx, current_user_uuid, team_uuid) + .await + .map_err(|e| Response::fail(Some(&e)))?; + + Ok(Response::success(Some("获取成功"), Some(stats))) +} diff --git a/src-tauri/crates/business/src/handlers/billing.rs b/src-tauri/crates/business/src/handlers/billing.rs new file mode 100644 index 0000000..3ea78bd --- /dev/null +++ b/src-tauri/crates/business/src/handlers/billing.rs @@ -0,0 +1,409 @@ +use axum::extract::{Extension, State}; + +use crate::dto::{ + AutoRenewalServiceDto, PaymentOrderDto, SubscriptionDto, UserWalletDto, +}; +use crate::entitys::{ + AccountInfoResponse, CancelSubscriptionRequest, CreateOrderResponse, CreateRechargeOrderRequest, + GetPlanPriceRequest, GetPlansRequest, GetUserCouponsRequest, InvoicesListResponse, ListInvoicesRequest, + ListPaymentOrdersRequest, ListTransactionsRequest, PaymentOrdersListResponse, + PlanDetailResponse, PlanPriceResponse, PlansResponse, ResumeSubscriptionRequest, + SubscribePlanRequest, SubscribeResponse, ToggleAutoRenewRequest, TransactionsListResponse, + UserCouponsListResponse, UuidRequest, VerifyCouponRequest, VerifyCouponResponse, +}; +use crate::services; +use crate::state::RequestContext; +use crate::svc_ctx::SvcCtx; +use crate::utils::{Json, Response, Result}; + +// ============ Plans ============ + +/// 获取套餐列表(包含特性) +pub async fn get_plans_handler( + State(svc_ctx): State, + Extension(ctx): Extension, + Json(payload): Json, +) -> Result { + let user_uuid = ctx.user_uuid_unwrap(); + let plans_with_features = services::plans::get_plans_service( + &svc_ctx, + user_uuid, + payload.coupon_code.as_deref(), + &payload.billing_period, + ) + .await + .map_err(|e| Response::fail(Some(&e)))?; + + let plans = plans_with_features + .into_iter() + .map(|pwf| crate::entitys::PlanWithFeatures { + plan: pwf.plan, + features: pwf.features, + calculated_price: pwf.calculated_price, + }) + .collect(); + + Ok(Response::success( + Some("获取成功"), + Some(PlansResponse { plans }), + )) +} + +/// 获取套餐详情 +pub async fn get_plan_detail_handler( + State(svc_ctx): State, + Json(payload): Json, +) -> Result { + let (plan, features) = services::plans::get_plan_detail_service(&svc_ctx, payload.uuid) + .await + .map_err(|e| Response::fail(Some(&e)))?; + + Ok(Response::success( + Some("获取成功"), + Some(PlanDetailResponse { plan, features }), + )) +} + +// ============ Subscriptions ============ + +/// 获取当前订阅 +pub async fn get_current_subscription_handler( + State(svc_ctx): State, + Extension(ctx): Extension, +) -> Result> { + let workspace_uuid = ctx + .current_workspace_uuid + .ok_or_else(|| Response::fail(Some("请先选择工作空间")))?; + + let subscription = + services::subscriptions::get_workspace_subscription_service(&svc_ctx, workspace_uuid) + .await + .map_err(|e| Response::fail(Some(&e)))?; + + Ok(Response::success(Some("获取成功"), Some(subscription))) +} + +/// 订阅套餐 +pub async fn subscribe_plan_handler( + State(svc_ctx): State, + Extension(ctx): Extension, + Json(payload): Json, +) -> Result { + let workspace_uuid = ctx + .current_workspace_uuid + .ok_or_else(|| Response::fail(Some("请先选择工作空间")))?; + + let subscription_uuid = services::subscriptions::subscribe_plan_service( + &svc_ctx, + workspace_uuid, + ctx.user_uuid_unwrap(), + &payload, + ) + .await + .map_err(|e| Response::fail(Some(&e)))?; + + Ok(Response::success( + Some("订阅成功"), + Some(SubscribeResponse { subscription_uuid }), + )) +} + +/// 取消订阅 +pub async fn cancel_subscription_handler( + State(svc_ctx): State, + Extension(ctx): Extension, + Json(payload): Json, +) -> Result<()> { + services::subscriptions::cancel_subscription_service( + &svc_ctx, + ctx.user_uuid_unwrap(), + payload.subscription_uuid, + ) + .await + .map_err(|e| Response::fail(Some(&e)))?; + + Ok(Response::success(Some("取消成功"), None)) +} + +/// 恢复订阅 +pub async fn resume_subscription_handler( + State(svc_ctx): State, + Extension(ctx): Extension, + Json(payload): Json, +) -> Result<()> { + services::subscriptions::resume_subscription_service( + &svc_ctx, + ctx.user_uuid_unwrap(), + payload.subscription_uuid, + ) + .await + .map_err(|e| Response::fail(Some(&e)))?; + + Ok(Response::success(Some("恢复成功"), None)) +} + +/// 切换自动续费 +pub async fn toggle_auto_renew_handler( + State(svc_ctx): State, + Extension(ctx): Extension, + Json(payload): Json, +) -> Result<()> { + services::subscriptions::toggle_auto_renew_service( + &svc_ctx, + ctx.user_uuid_unwrap(), + payload.subscription_uuid, + payload.auto_renew, + ) + .await + .map_err(|e| Response::fail(Some(&e)))?; + + Ok(Response::success(Some("设置成功"), None)) +} + +// ============ Wallet ============ + +/// 获取钱包信息 +pub async fn get_wallet_handler( + State(svc_ctx): State, + Extension(ctx): Extension, +) -> Result { + let wallet = services::wallet::get_wallet_service(&svc_ctx, ctx.user_uuid_unwrap()) + .await + .map_err(|e| Response::fail(Some(&e)))?; + + Ok(Response::success(Some("获取成功"), Some(wallet))) +} + +/// 获取交易记录 +pub async fn get_transactions_handler( + State(svc_ctx): State, + Extension(ctx): Extension, + Json(payload): Json, +) -> Result { + let (items, total) = + services::wallet::get_transactions_service(&svc_ctx, ctx.user_uuid_unwrap(), &payload) + .await + .map_err(|e| Response::fail(Some(&e)))?; + + Ok(Response::success( + Some("获取成功"), + Some(TransactionsListResponse { + items, + total, + page: payload.pagination.page, + page_size: payload.pagination.page_size, + }), + )) +} + +// ============ Invoices ============ + +/// 获取发票列表 +pub async fn get_invoices_handler( + State(svc_ctx): State, + Extension(ctx): Extension, + Json(payload): Json, +) -> Result { + let (items, total) = + services::billing::get_invoices_service(&svc_ctx, ctx.user_uuid_unwrap(), &payload) + .await + .map_err(|e| Response::fail(Some(&e)))?; + + Ok(Response::success( + Some("获取成功"), + Some(InvoicesListResponse { + items, + total, + page: payload.pagination.page, + page_size: payload.pagination.page_size, + }), + )) +} + +// ============ Quotas ============ + +/// 获取工作空间配额 +pub async fn get_quota_handler( + State(svc_ctx): State, + Extension(ctx): Extension, +) -> Result { + let workspace_uuid = ctx + .current_workspace_uuid + .ok_or_else(|| Response::fail(Some("请先选择工作空间")))?; + + let quota = services::workspace_quotas::get_workspace_quota_service( + &svc_ctx, + &crate::entitys::GetWorkspaceQuotaRequest { + workspace_uuid: Some(workspace_uuid), + }, + ) + .await + .map_err(|e| Response::fail(Some(&e)))?; + + Ok(Response::success(Some("获取成功"), Some(quota))) +} + +// ============ Coupons ============ + +/// 验证优惠券 +pub async fn verify_coupon_handler( + State(svc_ctx): State, + Extension(ctx): Extension, + Json(payload): Json, +) -> Result { + let result = + services::coupons::validate_coupon_service(&svc_ctx, ctx.user_uuid_unwrap(), &payload) + .await + .map_err(|e| Response::fail(Some(&e)))?; + + Ok(Response::success( + Some("优惠券有效"), + Some(VerifyCouponResponse { + coupon_uuid: result.coupon.uuid, + discount_type: result.coupon.discount_type, + discount_value: result.coupon.discount_value, + discount_amount: result.discount_amount, + }), + )) +} + +/// 获取用户优惠券列表 +pub async fn get_user_coupons_handler( + State(svc_ctx): State, + Extension(ctx): Extension, + Json(payload): Json, +) -> Result { + let result = services::coupons::get_user_coupons_service( + &svc_ctx, + ctx.user_uuid_unwrap(), + &payload, + ) + .await + .map_err(|e| Response::fail(Some(&e)))?; + + Ok(Response::success(Some("获取成功"), Some(result))) +} + +/// 获取用户可用优惠券 +pub async fn get_available_coupons_handler( + State(svc_ctx): State, + Extension(ctx): Extension, +) -> Result> { + let result = services::coupons::get_available_coupons_service(&svc_ctx, ctx.user_uuid_unwrap()) + .await + .map_err(|e| Response::fail(Some(&e)))?; + + Ok(Response::success(Some("获取成功"), Some(result))) +} + +/// 获取套餐价格(包含折扣计算) +pub async fn get_plan_price_handler( + State(svc_ctx): State, + Extension(ctx): Extension, + Json(payload): Json, +) -> Result { + let result = services::plans::calculate_plan_price_service( + &svc_ctx, + ctx.user_uuid_unwrap(), + &payload, + ) + .await + .map_err(|e| Response::fail(Some(&e)))?; + + Ok(Response::success(Some("计算成功"), Some(result))) +} + +// ============ Payment Orders ============ + +/// 创建充值订单 +pub async fn create_recharge_order_handler( + State(svc_ctx): State, + Extension(ctx): Extension, + Json(payload): Json, +) -> Result { + let (order_uuid, order_no) = + services::orders::create_recharge_order_service(&svc_ctx, ctx.user_uuid_unwrap(), &payload) + .await + .map_err(|e| Response::fail(Some(&e)))?; + + Ok(Response::success( + Some("订单创建成功"), + Some(CreateOrderResponse { + order_uuid, + order_no, + }), + )) +} + +/// 获取支付订单列表 +pub async fn get_payment_orders_handler( + State(svc_ctx): State, + Extension(ctx): Extension, + Json(payload): Json, +) -> Result { + let (items, total) = + services::orders::get_payment_orders_service(&svc_ctx, ctx.user_uuid_unwrap(), &payload) + .await + .map_err(|e| Response::fail(Some(&e)))?; + + Ok(Response::success( + Some("获取成功"), + Some(PaymentOrdersListResponse { + items, + total, + page: payload.pagination.page, + page_size: payload.pagination.page_size, + }), + )) +} + +/// 查询订单状态 +pub async fn get_order_status_handler( + State(svc_ctx): State, + Extension(ctx): Extension, + Json(payload): Json, +) -> Result { + let order = + services::orders::get_order_status_service(&svc_ctx, ctx.user_uuid_unwrap(), payload.uuid) + .await + .map_err(|e| Response::fail(Some(&e)))?; + + Ok(Response::success(Some("获取成功"), Some(order))) +} + +// ============ Auto Renewal Services ============ + +/// 获取自动续费服务列表 +pub async fn get_auto_renewal_services_handler( + State(svc_ctx): State, + Extension(ctx): Extension, +) -> Result> { + let services = + services::billing::get_auto_renewal_services_service(&svc_ctx, ctx.user_uuid_unwrap()) + .await + .map_err(|e| Response::fail(Some(&e)))?; + + Ok(Response::success(Some("获取成功"), Some(services))) +} + +// ============ Account Info ============ + +/// 获取账户信息 +pub async fn get_account_info_handler( + State(svc_ctx): State, + Extension(ctx): Extension, +) -> Result { + let workspace_uuid = ctx + .current_workspace_uuid + .ok_or_else(|| Response::fail(Some("请先选择工作空间")))?; + + let account_info = services::billing::get_account_info_service( + &svc_ctx, + workspace_uuid, + ctx.user_uuid_unwrap(), + ) + .await + .map_err(|e| Response::fail(Some(&e)))?; + + Ok(Response::success(Some("获取成功"), Some(account_info))) +} diff --git a/src-tauri/crates/business/src/handlers/browser_kernel.rs b/src-tauri/crates/business/src/handlers/browser_kernel.rs new file mode 100644 index 0000000..da48f9b --- /dev/null +++ b/src-tauri/crates/business/src/handlers/browser_kernel.rs @@ -0,0 +1,23 @@ +use axum::extract::State; +use std::collections::HashMap; + +use crate::entitys::browser_kernel::ListBrowserKernelsRequest; +use crate::services::browser_kernel::get_browser_kernel_list_service; +use crate::svc_ctx::SvcCtx; +use crate::utils::{Json, Response, Result}; + +/// 查询浏览器内核最新版本列表 +pub async fn list_browser_kernels_handler( + State(svc_ctx): State, + Json(payload): Json, +) -> Result>> { + let kernels = get_browser_kernel_list_service( + &svc_ctx, + payload.platform, + payload.type_code, + ) + .await + .map_err(|e| Response::fail(Some(&e.to_string())))?; + + Ok(Response::success(Some("获取浏览器内核列表成功"), Some(kernels))) +} diff --git a/src-tauri/crates/business/src/handlers/environments.rs b/src-tauri/crates/business/src/handlers/environments.rs new file mode 100644 index 0000000..270ed50 --- /dev/null +++ b/src-tauri/crates/business/src/handlers/environments.rs @@ -0,0 +1,815 @@ +use axum::{extract::Extension, extract::State}; + +use crate::audit_log; +use crate::entitys::{ + AddEnvironmentCookieRequest, AddEnvironmentUrlRequest, AssignTagsRequest, + BatchAssignTagRequest, BatchCreateEnvironmentRequest, BatchMoveToGroupRequest, + BatchRemoveTagsRequest, BatchUuidRequest, ClearEnvironmentCookiesRequest, + ClearEnvironmentUrlsRequest, CreateEnvironmentRequest, CreateGroupRequest, CreateResponse, + DeleteEnvironmentCookieRequest, DeleteEnvironmentUrlRequest, EnvironmentDetailResponse, + EnvironmentListResponse, IdResponse, ListEnvironmentsRequest, MoveToGroupRequest, + RemoveTagRequest, SetEnvironmentAccountsRequest, SetEnvironmentProxyRequest, + UpdateEnvironmentRequest, UpdateGroupRequest, UpdateTagRequest, UuidRequest, +}; +use crate::services; +use crate::state::RequestContext; +use crate::svc_ctx::SvcCtx; +use crate::utils::{Json, Response, Result}; + +// ============ Groups ============ + +/// 创建分组 +pub async fn create_group_handler( + State(svc_ctx): State, + Extension(ctx): Extension, + Json(payload): Json, +) -> Result { + let workspace_uuid = ctx + .current_workspace_uuid + .ok_or_else(|| Response::fail(Some("请先选择工作空间")))?; + let team_uuid = ctx.current_team_uuid.ok_or_else(|| Response::fail(Some("请先选择团队")))?; + + let group_uuid = services::groups::create_group_service( + &svc_ctx, + ctx.user_uuid_unwrap(), + workspace_uuid, + team_uuid, + &payload, + ) + .await + .map_err(|e| Response::fail(Some(&e)))?; + + audit_log!( + &svc_ctx, + &ctx, + "create", + "group", + group_uuid, + &payload.name, + "创建分组" + ) + .await; + + Ok(Response::success( + Some("创建成功"), + Some(CreateResponse { uuid: group_uuid }), + )) +} + +/// 获取分组列表 +pub async fn get_groups_handler( + State(svc_ctx): State, + Extension(ctx): Extension, +) -> Result> { + // 从当前团队获取工作空间和团队 UUID + let team_uuid = services::teams::get_current_team_service(&svc_ctx, ctx.user_uuid_unwrap()) + .await + .map_err(|e| Response::fail(Some(&e)))? + .ok_or_else(|| Response::fail(Some("请先选择团队")))?; + + let team = services::teams::get_team_service(&svc_ctx, team_uuid) + .await + .map_err(|e| Response::fail(Some(&e)))?; + + let groups = + services::groups::get_groups_service(&svc_ctx, team.workspace_uuid, team_uuid, 1, 100) + .await + .map_err(|e| Response::fail(Some(&e)))?; + + Ok(Response::success(Some("获取成功"), Some(groups))) +} + +/// 更新分组 +pub async fn update_group_handler( + State(svc_ctx): State, + Extension(ctx): Extension, + Json(payload): Json, +) -> Result<()> { + let workspace_uuid = ctx + .current_workspace_uuid + .ok_or_else(|| Response::fail(Some("请先选择工作空间")))?; + let team_uuid = ctx.current_team_uuid.ok_or_else(|| Response::fail(Some("请先选择团队")))?; + + services::groups::update_group_service( + &svc_ctx, + workspace_uuid, + team_uuid, + ctx.user_uuid_unwrap(), + &payload, + ) + .await + .map_err(|e| Response::fail(Some(&e)))?; + + Ok(Response::success(Some("更新成功"), None)) +} + +/// 删除分组 +pub async fn delete_group_handler( + State(svc_ctx): State, + Extension(ctx): Extension, + Json(payload): Json, +) -> Result<()> { + let workspace_uuid = ctx + .current_workspace_uuid + .ok_or_else(|| Response::fail(Some("请先选择工作空间")))?; + let team_uuid = ctx.current_team_uuid.ok_or_else(|| Response::fail(Some("请先选择团队")))?; + + services::groups::delete_group_service( + &svc_ctx, + workspace_uuid, + team_uuid, + ctx.user_uuid_unwrap(), + payload.uuid, + ) + .await + .map_err(|e| Response::fail(Some(&e)))?; + + audit_log!(&svc_ctx, &ctx, "delete", "group", payload.uuid, "删除分组").await; + + Ok(Response::success(Some("删除成功"), None)) +} + +// ============ Tags ============ + +/// 创建标签 +pub async fn create_tag_handler( + State(svc_ctx): State, + Extension(ctx): Extension, + Json(payload): Json, +) -> Result { + let team_uuid = services::teams::get_current_team_service(&svc_ctx, ctx.user_uuid_unwrap()) + .await + .map_err(|e| Response::fail(Some(&e)))?; + + let tag_uuid = + services::tags::create_tag_service(&svc_ctx, ctx.user_uuid_unwrap(), team_uuid, &payload) + .await + .map_err(|e| Response::fail(Some(&e)))?; + + audit_log!( + &svc_ctx, + &ctx, + "create", + "tag", + tag_uuid, + &payload.name, + "创建标签" + ) + .await; + + Ok(Response::success( + Some("创建成功"), + Some(CreateResponse { uuid: tag_uuid }), + )) +} + +/// 获取标签列表 +pub async fn get_tags_handler( + State(svc_ctx): State, + Extension(ctx): Extension, +) -> Result> { + let team_uuid = services::teams::get_current_team_service(&svc_ctx, ctx.user_uuid_unwrap()) + .await + .map_err(|e| Response::fail(Some(&e)))?; + + let tags = services::tags::get_tags_service(&svc_ctx, ctx.user_uuid_unwrap(), team_uuid) + .await + .map_err(|e| Response::fail(Some(&e)))?; + + Ok(Response::success(Some("获取成功"), Some(tags))) +} + +/// 更新标签 +pub async fn update_tag_handler( + State(svc_ctx): State, + Extension(ctx): Extension, + Json(payload): Json, +) -> Result<()> { + services::tags::update_tag_service(&svc_ctx, &payload) + .await + .map_err(|e| Response::fail(Some(&e)))?; + + Ok(Response::success(Some("更新成功"), None)) +} + +/// 删除标签 +pub async fn delete_tag_handler( + State(svc_ctx): State, + Extension(ctx): Extension, + Json(payload): Json, +) -> Result<()> { + services::tags::delete_tag_service(&svc_ctx, payload.uuid) + .await + .map_err(|e| Response::fail(Some(&e)))?; + + audit_log!(&svc_ctx, &ctx, "delete", "tag", payload.uuid, "删除标签").await; + + Ok(Response::success(Some("删除成功"), None)) +} + +// ============ Environments ============ + +/// 获取环境列表 +pub async fn get_environments_handler( + State(svc_ctx): State, + Extension(ctx): Extension, + Json(payload): Json, +) -> Result { + let workspace_uuid = ctx + .current_workspace_uuid + .ok_or_else(|| Response::fail(Some("请先选择工作空间")))?; + let team_uuid = ctx.current_team_uuid.ok_or_else(|| Response::fail(Some("请先选择团队")))?; + + let (environments, total) = services::environments::get_environments_service( + &svc_ctx, + ctx.user_uuid_unwrap(), + workspace_uuid, + team_uuid, + &payload, + ) + .await + .map_err(|e| Response::fail(Some(&e)))?; + + Ok(Response::success( + Some("获取成功"), + Some(EnvironmentListResponse { + items: environments, + total, + page: payload.pagination.page, + page_size: payload.pagination.page_size, + }), + )) +} + +/// 获取环境详情 +pub async fn get_environment_handler( + State(svc_ctx): State, + Extension(ctx): Extension, + Json(payload): Json, +) -> Result { + let workspace_uuid = ctx + .current_workspace_uuid + .ok_or_else(|| Response::fail(Some("请先选择工作空间")))?; + let team_uuid = ctx.current_team_uuid.ok_or_else(|| Response::fail(Some("请先选择团队")))?; + + let detail = services::environments::get_environment_detail_service( + &svc_ctx, + workspace_uuid, + team_uuid, + ctx.user_uuid_unwrap(), + payload.uuid, + ) + .await + .map_err(|e| Response::fail(Some(&e)))?; + + Ok(Response::success( + Some("获取成功"), + Some(detail), + )) +} + +/// 批量获取环境详情 +pub async fn batch_get_environments_handler( + State(svc_ctx): State, + Extension(ctx): Extension, + Json(payload): Json, +) -> Result> { + let workspace_uuid = ctx + .current_workspace_uuid + .ok_or_else(|| Response::fail(Some("请先选择工作空间")))?; + let team_uuid = ctx.current_team_uuid.ok_or_else(|| Response::fail(Some("请先选择团队")))?; + + let mut results = std::collections::HashMap::new(); + + for uuid in payload.uuids { + // 对每个 UUID 尝试获取环境详情,失败则跳过 + let detail = async { + services::environments::get_environment_detail_service( + &svc_ctx, + workspace_uuid, + team_uuid, + ctx.user_uuid_unwrap(), + uuid, + ) + .await + .ok() + } + .await; + + // 只有成功获取的环境才加入结果 + if let Some(detail) = detail { + results.insert(uuid.to_string(), detail); + } + } + + Ok(Response::success(Some("获取成功"), Some(results))) +} + +/// 创建环境 +pub async fn create_environment_handler( + State(svc_ctx): State, + Extension(ctx): Extension, + Json(payload): Json, +) -> Result { + let workspace_uuid = ctx + .current_workspace_uuid + .ok_or_else(|| Response::fail(Some("请先选择工作空间")))?; + let team_uuid = ctx.current_team_uuid.ok_or_else(|| Response::fail(Some("请先选择团队")))?; + + let env_uuid = services::environments::create_environment_service( + &svc_ctx, + ctx.user_uuid_unwrap(), + workspace_uuid, + team_uuid, + &payload, + ) + .await + .map_err(|e| Response::fail(Some(&e)))?; + + audit_log!( + &svc_ctx, + &ctx, + "create", + "environment", + env_uuid, + &payload.name, + "创建环境" + ) + .await; + + Ok(Response::success( + Some("创建成功"), + Some(CreateResponse { uuid: env_uuid }), + )) +} + +/// 批量创建环境 +pub async fn batch_create_environments_handler( + State(svc_ctx): State, + Extension(ctx): Extension, + Json(payload): Json, +) -> Result> { + let workspace_uuid = ctx + .current_workspace_uuid + .ok_or_else(|| Response::fail(Some("请先选择工作空间")))?; + let team_uuid = ctx.current_team_uuid.ok_or_else(|| Response::fail(Some("请先选择团队")))?; + + let env_uuids = services::environments::batch_create_environments_service( + &svc_ctx, + ctx.user_uuid_unwrap(), + workspace_uuid, + team_uuid, + &payload, + ) + .await + .map_err(|e| Response::fail(Some(&e)))?; + + let count = env_uuids.len(); + let responses: Vec = + env_uuids.into_iter().map(|uuid| CreateResponse { uuid }).collect(); + + audit_log!( + &svc_ctx, + &ctx, + "batch_create", + "environment", + &format!("批量创建 {} 个环境", count) + ) + .await; + + Ok(Response::success(Some("批量创建成功"), Some(responses))) +} + +/// 更新环境 +pub async fn update_environment_handler( + State(svc_ctx): State, + Extension(ctx): Extension, + Json(payload): Json, +) -> Result<()> { + let workspace_uuid = ctx + .current_workspace_uuid + .ok_or_else(|| Response::fail(Some("请先选择工作空间")))?; + let team_uuid = ctx.current_team_uuid.ok_or_else(|| Response::fail(Some("请先选择团队")))?; + + services::environments::update_environment_service( + &svc_ctx, + workspace_uuid, + team_uuid, + ctx.user_uuid_unwrap(), + &payload, + ) + .await + .map_err(|e| Response::fail(Some(&e)))?; + + Ok(Response::success(Some("更新成功"), None)) +} + +/// 删除环境 +pub async fn delete_environment_handler( + State(svc_ctx): State, + Extension(ctx): Extension, + Json(payload): Json, +) -> Result<()> { + let workspace_uuid = ctx + .current_workspace_uuid + .ok_or_else(|| Response::fail(Some("请先选择工作空间")))?; + let team_uuid = ctx.current_team_uuid.ok_or_else(|| Response::fail(Some("请先选择团队")))?; + + services::environments::delete_environment_service( + &svc_ctx, + workspace_uuid, + team_uuid, + ctx.user_uuid_unwrap(), + payload.uuid, + ) + .await + .map_err(|e| Response::fail(Some(&e)))?; + + audit_log!( + &svc_ctx, + &ctx, + "delete", + "environment", + payload.uuid, + "删除环境" + ) + .await; + + Ok(Response::success(Some("删除成功"), None)) +} + +/// 批量删除环境 +pub async fn batch_delete_environments_handler( + State(svc_ctx): State, + Extension(ctx): Extension, + Json(payload): Json, +) -> Result { + let count = services::environments::batch_delete_environments_service(&svc_ctx, &payload.uuids) + .await + .map_err(|e| Response::fail(Some(&e)))?; + + audit_log!( + &svc_ctx, + &ctx, + "batch_delete", + "environment", + &format!("批量删除 {} 个环境", count) + ) + .await; + + Ok(Response::success(Some("删除成功"), Some(count))) +} + +// ============ Recycle Bin ============ + +/// 查询回收站环境列表 +pub async fn get_recycle_bin_environments_handler( + State(svc_ctx): State, + Extension(ctx): Extension, + Json(payload): Json, +) -> Result { + let workspace_uuid = ctx + .current_workspace_uuid + .ok_or_else(|| Response::fail(Some("请先选择工作空间")))?; + let team_uuid = ctx.current_team_uuid.ok_or_else(|| Response::fail(Some("请先选择团队")))?; + + let (environments, total) = services::environments::get_recycle_bin_environments_service( + &svc_ctx, + workspace_uuid, + team_uuid, + ctx.user_uuid_unwrap(), + &payload, + ) + .await + .map_err(|e| Response::fail(Some(&e)))?; + + Ok(Response::success( + None, + Some(EnvironmentListResponse { + items: environments, + total, + page: payload.pagination.page, + page_size: payload.pagination.page_size, + }), + )) +} + +/// 恢复环境 +pub async fn restore_environment_handler( + State(svc_ctx): State, + Extension(ctx): Extension, + Json(payload): Json, +) -> Result<()> { + services::environments::restore_environment_service(&svc_ctx, payload.uuid) + .await + .map_err(|e| Response::fail(Some(&e)))?; + + audit_log!( + &svc_ctx, + &ctx, + "restore", + "environment", + payload.uuid, + "恢复环境" + ) + .await; + + Ok(Response::success(Some("恢复成功"), None)) +} + +/// 批量恢复环境 +pub async fn batch_restore_environments_handler( + State(svc_ctx): State, + Extension(ctx): Extension, + Json(payload): Json, +) -> Result { + let count = services::environments::batch_restore_environments_service(&svc_ctx, &payload.uuids) + .await + .map_err(|e| Response::fail(Some(&e)))?; + + audit_log!( + &svc_ctx, + &ctx, + "batch_restore", + "environment", + &format!("批量恢复 {} 个环境", count) + ) + .await; + + Ok(Response::success(Some("恢复成功"), Some(count))) +} + +/// 永久删除环境 +pub async fn permanent_delete_environment_handler( + State(svc_ctx): State, + Extension(ctx): Extension, + Json(payload): Json, +) -> Result<()> { + let workspace_uuid = ctx + .current_workspace_uuid + .ok_or_else(|| Response::fail(Some("请先选择工作空间")))?; + + services::environments::permanent_delete_environment_service(&svc_ctx, payload.uuid, workspace_uuid) + .await + .map_err(|e| Response::fail(Some(&e)))?; + + audit_log!( + &svc_ctx, + &ctx, + "permanent_delete", + "environment", + payload.uuid, + "永久删除环境" + ) + .await; + + Ok(Response::success(Some("永久删除成功"), None)) +} + +/// 批量永久删除环境 +pub async fn batch_permanent_delete_environments_handler( + State(svc_ctx): State, + Extension(ctx): Extension, + Json(payload): Json, +) -> Result { + let workspace_uuid = ctx + .current_workspace_uuid + .ok_or_else(|| Response::fail(Some("请先选择工作空间")))?; + + let count = services::environments::batch_permanent_delete_environments_service( + &svc_ctx, + &payload.uuids, + workspace_uuid, + ) + .await + .map_err(|e| Response::fail(Some(&e)))?; + + audit_log!( + &svc_ctx, + &ctx, + "batch_permanent_delete", + "environment", + &format!("批量永久删除 {} 个环境", count) + ) + .await; + + Ok(Response::success(Some("永久删除成功"), Some(count))) +} + +/// 设置环境代理 +pub async fn set_proxy_handler( + State(svc_ctx): State, + Extension(ctx): Extension, + Json(payload): Json, +) -> Result<()> { + services::environments::set_environment_proxy_service(&svc_ctx, &payload) + .await + .map_err(|e| Response::fail(Some(&e)))?; + + Ok(Response::success(Some("设置成功"), None)) +} + +/// 分配标签 +pub async fn assign_tags_handler( + State(svc_ctx): State, + Extension(ctx): Extension, + Json(payload): Json, +) -> Result<()> { + services::environments::assign_tags_service(&svc_ctx, &payload) + .await + .map_err(|e| Response::fail(Some(&e)))?; + + Ok(Response::success(Some("分配成功"), None)) +} + +/// 移除标签 +pub async fn remove_tag_handler( + State(svc_ctx): State, + Extension(ctx): Extension, + Json(payload): Json, +) -> Result<()> { + services::environments::remove_tag_service(&svc_ctx, payload.uuid, payload.tag_uuid) + .await + .map_err(|e| Response::fail(Some(&e)))?; + + Ok(Response::success(Some("移除成功"), None)) +} + +/// 移动到分组 +pub async fn move_to_group_handler( + State(svc_ctx): State, + Extension(ctx): Extension, + Json(payload): Json, +) -> Result<()> { + services::environments::move_to_group_service(&svc_ctx, &payload) + .await + .map_err(|e| Response::fail(Some(&e)))?; + + Ok(Response::success(Some("移动成功"), None)) +} + +/// 批量移动到分组 +pub async fn batch_move_to_group_handler( + State(svc_ctx): State, + Extension(ctx): Extension, + Json(payload): Json, +) -> Result<()> { + services::environments::batch_move_to_group_service(&svc_ctx, &payload) + .await + .map_err(|e| Response::fail(Some(&e)))?; + + Ok(Response::success(Some("移动成功"), None)) +} + +/// 设置环境账号 +pub async fn set_environment_accounts_handler( + State(svc_ctx): State, + Extension(ctx): Extension, + Json(payload): Json, +) -> Result<()> { + services::accounts::set_environment_accounts_service( + &svc_ctx, + payload.uuid, + &payload.account_uuids, + ) + .await + .map_err(|e| Response::fail(Some(&e)))?; + + Ok(Response::success(Some("设置成功"), None)) +} + +/// 批量分配标签 +pub async fn batch_assign_tags_handler( + State(svc_ctx): State, + Extension(ctx): Extension, + Json(payload): Json, +) -> Result<()> { + services::environments::batch_assign_tags_service(&svc_ctx, &payload) + .await + .map_err(|e| Response::fail(Some(&e)))?; + + Ok(Response::success(Some("分配成功"), None)) +} + +/// 批量移除标签 +pub async fn batch_remove_tags_handler( + State(svc_ctx): State, + Extension(ctx): Extension, + Json(payload): Json, +) -> Result<()> { + services::environments::batch_remove_tags_service(&svc_ctx, &payload) + .await + .map_err(|e| Response::fail(Some(&e)))?; + + Ok(Response::success(Some("移除成功"), None)) +} + +// ============ Environment URLs ============ + +/// 添加环境 URL +pub async fn add_environment_url_handler( + State(svc_ctx): State, + Extension(ctx): Extension, + Json(payload): Json, +) -> Result { + let id = services::environments::add_environment_url_service(&svc_ctx, &payload) + .await + .map_err(|e| Response::fail(Some(&e)))?; + + Ok(Response::success(Some("添加成功"), Some(IdResponse { id }))) +} + +/// 获取环境 URL 列表 +pub async fn get_environment_urls_handler( + State(svc_ctx): State, + Json(payload): Json, +) -> Result> { + let urls = services::environments::get_environment_urls_service(&svc_ctx, payload.uuid) + .await + .map_err(|e| Response::fail(Some(&e)))?; + + Ok(Response::success(Some("获取成功"), Some(urls))) +} + +/// 删除环境 URL +pub async fn delete_environment_url_handler( + State(svc_ctx): State, + Extension(ctx): Extension, + Json(payload): Json, +) -> Result<()> { + services::environments::delete_environment_url_service(&svc_ctx, payload.id) + .await + .map_err(|e| Response::fail(Some(&e)))?; + + Ok(Response::success(Some("删除成功"), None)) +} + +/// 清空环境 URL +pub async fn clear_environment_urls_handler( + State(svc_ctx): State, + Extension(ctx): Extension, + Json(payload): Json, +) -> Result { + let count = + services::environments::clear_environment_urls_service(&svc_ctx, payload.environment_uuid) + .await + .map_err(|e| Response::fail(Some(&e)))?; + + Ok(Response::success(Some("清空成功"), Some(count))) +} + +// ============ Environment Cookies ============ + +/// 添加环境 Cookie +pub async fn add_environment_cookie_handler( + State(svc_ctx): State, + Extension(ctx): Extension, + Json(payload): Json, +) -> Result { + let id = services::environments::add_environment_cookie_service(&svc_ctx, &payload) + .await + .map_err(|e| Response::fail(Some(&e)))?; + + Ok(Response::success(Some("添加成功"), Some(IdResponse { id }))) +} + +/// 获取环境 Cookie 列表 +pub async fn get_environment_cookies_handler( + State(svc_ctx): State, + Json(payload): Json, +) -> Result> { + let cookies = services::environments::get_environment_cookies_service(&svc_ctx, payload.uuid) + .await + .map_err(|e| Response::fail(Some(&e)))?; + + Ok(Response::success(Some("获取成功"), Some(cookies))) +} + +/// 删除环境 Cookie +pub async fn delete_environment_cookie_handler( + State(svc_ctx): State, + Extension(ctx): Extension, + Json(payload): Json, +) -> Result<()> { + services::environments::delete_environment_cookie_service(&svc_ctx, payload.id) + .await + .map_err(|e| Response::fail(Some(&e)))?; + + Ok(Response::success(Some("删除成功"), None)) +} + +/// 清空环境 Cookies +pub async fn clear_environment_cookies_handler( + State(svc_ctx): State, + Extension(ctx): Extension, + Json(payload): Json, +) -> Result { + let count = services::environments::clear_environment_cookies_service( + &svc_ctx, + payload.environment_uuid, + ) + .await + .map_err(|e| Response::fail(Some(&e)))?; + + Ok(Response::success(Some("清空成功"), Some(count))) +} diff --git a/src-tauri/crates/business/src/handlers/extensions.rs b/src-tauri/crates/business/src/handlers/extensions.rs new file mode 100644 index 0000000..e15790b --- /dev/null +++ b/src-tauri/crates/business/src/handlers/extensions.rs @@ -0,0 +1,242 @@ +use axum::extract::{Extension, State}; + +use crate::dto::ExtensionDto; +use crate::entitys::{ + BatchUpdateExtensionsRequest, BatchUpdateResponse, ExtensionIdRequest, ExtensionsListResponse, + GetInstalledExtensionsRequest, InstallExtensionRequest, InstalledExtensionsResponse, + ListExtensionsRequest, ToggleExtensionRequest, UninstallExtensionRequest, UpdateExtensionRequest, +}; +use crate::services; +use crate::state::RequestContext; +use crate::svc_ctx::SvcCtx; +use crate::utils::{Json, Response, Result}; + +/// 获取扩展列表 +pub async fn get_extensions_handler( + State(svc_ctx): State, + Json(payload): Json, +) -> Result { + let (items, total) = services::extensions::get_extensions_service(&svc_ctx, &payload) + .await + .map_err(|e| Response::fail(Some(&e)))?; + + Ok(Response::success( + Some("获取成功"), + Some(ExtensionsListResponse { + items, + total, + page: payload.pagination.page, + page_size: payload.pagination.page_size, + }), + )) +} + +/// 获取扩展详情 +pub async fn get_extension_detail_handler( + State(svc_ctx): State, + Json(payload): Json, +) -> Result { + let extension = services::extensions::get_extension_service(&svc_ctx, &payload.extension_id) + .await + .map_err(|e| Response::fail(Some(&e)))?; + + Ok(Response::success(Some("获取成功"), Some(extension))) +} + +/// 获取扩展分类 +pub async fn get_extension_categories_handler( + State(svc_ctx): State, +) -> Result> { + let categories = services::extensions::get_extension_categories_service(&svc_ctx) + .await + .map_err(|e| Response::fail(Some(&e)))?; + + Ok(Response::success(Some("获取成功"), Some(categories))) +} + +/// 获取已安装的扩展 +pub async fn get_installed_extensions_handler( + State(svc_ctx): State, + Extension(ctx): Extension, + Json(payload): Json, +) -> Result { + let team_uuid = services::teams::get_current_team_service(&svc_ctx, ctx.user_uuid_unwrap()) + .await + .map_err(|e| Response::fail(Some(&e)))?; + + let (user_extensions, team_extensions) = match payload.scope.as_str() { + "user" | "personal" => { + let user_extensions = services::extensions::get_user_installed_extensions_service( + &svc_ctx, + ctx.user_uuid_unwrap(), + team_uuid, + ) + .await + .map_err(|e| Response::fail(Some(&e)))?; + (user_extensions, Vec::new()) + } + "team" => { + let team_uuid = team_uuid.ok_or_else(|| Response::fail(Some("未指定团队")))?; + let team_extensions = + services::extensions::get_team_installed_extensions_service(&svc_ctx, team_uuid, ctx.user_uuid_unwrap()) + .await + .map_err(|e| Response::fail(Some(&e)))?; + (Vec::new(), team_extensions) + } + _ => { + // "all" 或其他值,返回全部 + let user_extensions = services::extensions::get_user_installed_extensions_service( + &svc_ctx, + ctx.user_uuid_unwrap(), + team_uuid, + ) + .await + .map_err(|e| Response::fail(Some(&e)))?; + let team_extensions = if let Some(team_uuid) = team_uuid { + services::extensions::get_team_installed_extensions_service(&svc_ctx, team_uuid, ctx.user_uuid_unwrap()) + .await + .map_err(|e| Response::fail(Some(&e)))? + } else { + Vec::new() + }; + (user_extensions, team_extensions) + } + }; + + Ok(Response::success( + Some("获取成功"), + Some(InstalledExtensionsResponse { + user_extensions, + team_extensions, + }), + )) +} + +/// 安装扩展 +pub async fn install_extension_handler( + State(svc_ctx): State, + Extension(ctx): Extension, + Json(payload): Json, +) -> Result<()> { + let workspace_uuid = ctx.current_workspace_uuid.ok_or_else(|| Response::fail(Some("请先选择工作空间")))?; + let team_uuid = services::teams::get_current_team_service(&svc_ctx, ctx.user_uuid_unwrap()) + .await + .map_err(|e| Response::fail(Some(&e)))?; + + services::extensions::install_extension_service( + &svc_ctx, + ctx.user_uuid_unwrap(), + team_uuid, + &payload, + ) + .await + .map_err(|e| Response::fail(Some(&e)))?; + + Ok(Response::success(Some("安装成功"), None)) +} + +/// 卸载扩展 +pub async fn uninstall_extension_handler( + State(svc_ctx): State, + Extension(ctx): Extension, + Json(payload): Json, +) -> Result<()> { + let workspace_uuid = ctx.current_workspace_uuid.ok_or_else(|| Response::fail(Some("请先选择工作空间")))?; + let team_uuid = services::teams::get_current_team_service(&svc_ctx, ctx.user_uuid_unwrap()) + .await + .map_err(|e| Response::fail(Some(&e)))?; + + services::extensions::uninstall_extension_service( + &svc_ctx, + ctx.user_uuid_unwrap(), + team_uuid, + &payload, + ) + .await + .map_err(|e| Response::fail(Some(&e)))?; + + Ok(Response::success(Some("卸载成功"), None)) +} + +/// 更新扩展 +pub async fn update_extension_handler( + State(svc_ctx): State, + Extension(ctx): Extension, + Json(payload): Json, +) -> Result<()> { + services::extensions::update_extension_service( + &svc_ctx, + ctx.user_uuid_unwrap(), + &payload.extension_id, + ) + .await + .map_err(|e| Response::fail(Some(&e)))?; + + Ok(Response::success(Some("更新成功"), None)) +} + +/// 批量更新扩展 +pub async fn batch_update_extensions_handler( + State(svc_ctx): State, + Extension(ctx): Extension, + Json(payload): Json, +) -> Result { + let updated = services::extensions::batch_update_extensions_service( + &svc_ctx, + ctx.user_uuid_unwrap(), + &payload.extension_ids, + ) + .await + .map_err(|e| Response::fail(Some(&e)))?; + + Ok(Response::success( + Some("批量更新完成"), + Some(BatchUpdateResponse { updated }), + )) +} + +/// 禁用扩展(用户级别) +pub async fn disable_extension_handler( + State(svc_ctx): State, + Extension(ctx): Extension, + Json(payload): Json, +) -> Result<()> { + let team_uuid = services::teams::get_current_team_service(&svc_ctx, ctx.user_uuid_unwrap()) + .await + .map_err(|e| Response::fail(Some(&e)))? + .ok_or_else(|| Response::fail(Some("未指定团队")))?; + + services::extensions::disable_extension_service( + &svc_ctx, + ctx.user_uuid_unwrap(), + team_uuid, + &payload.extension_id, + ) + .await + .map_err(|e| Response::fail(Some(&e)))?; + + Ok(Response::success(Some("禁用成功"), None)) +} + +/// 启用扩展(用户级别) +pub async fn enable_extension_handler( + State(svc_ctx): State, + Extension(ctx): Extension, + Json(payload): Json, +) -> Result<()> { + let team_uuid = services::teams::get_current_team_service(&svc_ctx, ctx.user_uuid_unwrap()) + .await + .map_err(|e| Response::fail(Some(&e)))? + .ok_or_else(|| Response::fail(Some("未指定团队")))?; + + services::extensions::enable_extension_service( + &svc_ctx, + ctx.user_uuid_unwrap(), + team_uuid, + &payload.extension_id, + ) + .await + .map_err(|e| Response::fail(Some(&e)))?; + + Ok(Response::success(Some("启用成功"), None)) +} diff --git a/src-tauri/crates/business/src/handlers/group_permissions.rs b/src-tauri/crates/business/src/handlers/group_permissions.rs new file mode 100644 index 0000000..f9234b3 --- /dev/null +++ b/src-tauri/crates/business/src/handlers/group_permissions.rs @@ -0,0 +1,138 @@ +use axum::{extract::Extension, extract::State}; + +use crate::audit_log; +use crate::entitys::{ + CheckGroupPermissionRequest, GrantGroupPermissionRequest, ListUserGroupPermissionsRequest, + RevokeGroupPermissionRequest, +}; +use crate::services; +use crate::state::RequestContext; +use crate::svc_ctx::SvcCtx; +use crate::utils::{Json, Response, Result}; + +/// 授予分组权限 +pub async fn grant_group_permission_handler( + State(svc_ctx): State, + Extension(ctx): Extension, + Json(payload): Json, +) -> Result<()> { + services::group_permissions::grant_group_permission_service( + &svc_ctx, + ctx.user_uuid_unwrap(), + &payload, + ) + .await + .map_err(|e| Response::fail(Some(&e)))?; + + audit_log!( + &svc_ctx, + &ctx, + "grant", + "group_permission", + payload.group_uuid, + &payload.permission_type, + "授予分组权限" + ) + .await; + + Ok(Response::success(Some("授权成功"), None)) +} + +/// 撤销分组权限 +pub async fn revoke_group_permission_handler( + State(svc_ctx): State, + Extension(ctx): Extension, + Json(payload): Json, +) -> Result<()> { + services::group_permissions::revoke_group_permission_service( + &svc_ctx, + ctx.user_uuid_unwrap(), + &payload, + ) + .await + .map_err(|e| Response::fail(Some(&e)))?; + + audit_log!( + &svc_ctx, + &ctx, + "revoke", + "group_permission", + payload.group_uuid, + "", + "撤销分组权限" + ) + .await; + + Ok(Response::success(Some("撤销成功"), None)) +} + +/// 检查分组权限 +pub async fn check_group_permission_handler( + State(svc_ctx): State, + Extension(ctx): Extension, + Json(payload): Json, +) -> Result { + let workspace_uuid = ctx.current_workspace_uuid.ok_or_else(|| Response::fail(Some("请先选择工作空间")))?; + let has_permission = + services::group_permissions::check_group_permission_service(&svc_ctx, workspace_uuid, &payload) + .await + .map_err(|e| Response::fail(Some(&e)))?; + + Ok(Response::success( + None, + Some(crate::entitys::CheckPermissionResponse { + has_permission, + permission_type: if has_permission { + Some(payload.permission_type.clone()) + } else { + None + }, + }), + )) +} + +/// 查询用户的分组权限列表 +pub async fn list_user_group_permissions_handler( + State(svc_ctx): State, + Extension(_ctx): Extension, + Json(payload): Json, +) -> Result { + let permissions = + services::group_permissions::list_user_group_permissions_service(&svc_ctx, &payload) + .await + .map_err(|e| Response::fail(Some(&e)))?; + + let total = permissions.len() as i64; + + // 转换为详情 DTO(包含用户信息) + let mut permission_details = vec![]; + for perm in permissions { + let user_info = crate::models::user::fetch_user_info_by_uuid(&svc_ctx.db, perm.user_uuid) + .await + .ok() + .flatten(); + + permission_details.push(crate::dto::GroupMemberPermissionDetailDto { + group_uuid: perm.group_uuid, + workspace_uuid: perm.workspace_uuid, + team_uuid: perm.team_uuid, + user_uuid: perm.user_uuid, + permission_type: perm.permission_type, + granted_by: perm.granted_by, + user_name: user_info.as_ref().and_then(|u| u.nickname.clone()), + user_email: user_info.as_ref().map(|u| u.email.clone()), + created_at: perm.created_at, + updated_at: perm.updated_at, + }); + } + + Ok(Response::success( + None, + Some(crate::entitys::GroupPermissionListResponse { + items: permission_details, + total, + page: payload.pagination.page, + page_size: payload.pagination.page_size, + }), + )) +} diff --git a/src-tauri/crates/business/src/handlers/health.rs b/src-tauri/crates/business/src/handlers/health.rs new file mode 100644 index 0000000..dd6d333 --- /dev/null +++ b/src-tauri/crates/business/src/handlers/health.rs @@ -0,0 +1,15 @@ +use chrono::Utc; +use serde_json::{Value, json}; + +use crate::utils::{Response, Result}; + +/// 健康检查 +pub async fn health_check_handler() -> Result { + Ok(Response::success( + Some("服务正常"), + Some(json!({ + "status": "ok", + "timestamp": Utc::now().to_rfc3339(), + })), + )) +} diff --git a/src-tauri/crates/business/src/handlers/local_api.rs b/src-tauri/crates/business/src/handlers/local_api.rs new file mode 100644 index 0000000..25fce02 --- /dev/null +++ b/src-tauri/crates/business/src/handlers/local_api.rs @@ -0,0 +1,62 @@ +use axum::extract::{Extension, State}; + +use crate::entitys::{ + GetLocalApiConfigRequest, ResetLocalApiKeyRequest, UpdateLocalApiConfigRequest, + ValidateLocalApiKeyRequest, +}; +use crate::services; +use crate::state::RequestContext; +use crate::svc_ctx::SvcCtx; +use crate::utils::{Json, Response, Result}; + +pub async fn get_local_api_config_handler( + State(svc_ctx): State, + Extension(ctx): Extension, + Json(_payload): Json, +) -> Result { + let config = + services::local_api::get_local_api_config_service(&svc_ctx, ctx.user_uuid_unwrap()) + .await + .map_err(|e| Response::fail(Some(&e)))?; + + Ok(Response::success(None, Some(config))) +} + +pub async fn update_local_api_config_handler( + State(svc_ctx): State, + Extension(ctx): Extension, + Json(payload): Json, +) -> Result { + let config = services::local_api::update_local_api_config_service( + &svc_ctx, + ctx.user_uuid_unwrap(), + &payload, + ) + .await + .map_err(|e| Response::fail(Some(&e)))?; + + Ok(Response::success(Some("更新成功"), Some(config))) +} + +pub async fn reset_local_api_key_handler( + State(svc_ctx): State, + Extension(ctx): Extension, + Json(_payload): Json, +) -> Result { + let result = services::local_api::reset_local_api_key_service(&svc_ctx, ctx.user_uuid_unwrap()) + .await + .map_err(|e| Response::fail(Some(&e)))?; + + Ok(Response::success(Some("重置成功"), Some(result))) +} + +pub async fn validate_local_api_key_handler( + State(svc_ctx): State, + Json(payload): Json, +) -> Result { + let result = services::local_api::validate_local_api_key_service(&svc_ctx, &payload) + .await + .map_err(|e| Response::fail(Some(&e)))?; + + Ok(Response::success(None, Some(result))) +} diff --git a/src-tauri/crates/business/src/handlers/messages.rs b/src-tauri/crates/business/src/handlers/messages.rs new file mode 100644 index 0000000..fed1553 --- /dev/null +++ b/src-tauri/crates/business/src/handlers/messages.rs @@ -0,0 +1,133 @@ +use axum::{extract::Extension, extract::State}; + +use crate::entitys::{ + BatchMarkReadRequest, CreateMessageRequest, HandleMessageRequest, ListMessagesRequest, + MarkMessageReadRequest, MessageListResponse, MessageStatsResponse, +}; +use crate::services; +use crate::state::RequestContext; +use crate::svc_ctx::SvcCtx; +use crate::utils::{Json, Response, Result}; +use sqlx; + +/// 创建消息 +pub async fn create_message_handler( + State(svc_ctx): State, + Extension(ctx): Extension, + Json(payload): Json, +) -> Result { + let message_uuid = services::messages::create_message_service( + &svc_ctx, + Some(ctx.user_uuid_unwrap()), + &payload, + ) + .await + .map_err(|e| Response::fail(Some(&e)))?; + + Ok(Response::success( + Some("消息创建成功"), + Some(crate::entitys::CreateResponse { uuid: message_uuid }), + )) +} + +/// 获取用户消息列表 +pub async fn get_user_messages_handler( + State(svc_ctx): State, + Extension(ctx): Extension, + Json(payload): Json, +) -> Result { + let response = + services::messages::get_user_messages_service(&svc_ctx, ctx.user_uuid_unwrap(), &payload) + .await + .map_err(|e| Response::fail(Some(&e)))?; + + Ok(Response::success(Some("获取成功"), Some(response))) +} + +/// 标记消息为已读 +pub async fn mark_message_read_handler( + State(svc_ctx): State, + Extension(ctx): Extension, + Json(payload): Json, +) -> Result<()> { + services::messages::mark_message_read_service(&svc_ctx, ctx.user_uuid_unwrap(), &payload) + .await + .map_err(|e| Response::fail(Some(&e)))?; + + Ok(Response::success(Some("标记成功"), None)) +} + +/// 批量标记消息为已读 +pub async fn batch_mark_messages_read_handler( + State(svc_ctx): State, + Extension(ctx): Extension, + Json(payload): Json, +) -> Result<()> { + services::messages::batch_mark_messages_read_service( + &svc_ctx, + ctx.user_uuid_unwrap(), + &payload, + ) + .await + .map_err(|e| Response::fail(Some(&e)))?; + + Ok(Response::success(Some("批量标记成功"), None)) +} + +/// 处理消息(接受/拒绝) +pub async fn handle_message_handler( + State(svc_ctx): State, + Extension(ctx): Extension, + Json(payload): Json, +) -> Result<()> { + services::messages::handle_message_service(&svc_ctx, ctx.user_uuid_unwrap(), &payload) + .await + .map_err(|e| Response::fail(Some(&e)))?; + + Ok(Response::success(Some("处理成功"), None)) +} + +/// 获取用户消息统计 +pub async fn get_user_message_stats_handler( + State(svc_ctx): State, + Extension(ctx): Extension, +) -> Result { + let response = + services::messages::get_user_message_stats_service(&svc_ctx, ctx.user_uuid_unwrap()) + .await + .map_err(|e| Response::fail(Some(&e)))?; + + Ok(Response::success(Some("获取成功"), Some(response))) +} + +/// 删除消息 +pub async fn delete_message_handler( + State(svc_ctx): State, + Extension(ctx): Extension, + Json(payload): Json, +) -> Result<()> { + // 验证消息是否属于当前用户(通过查询 user_messages 表) + let user_message_exists: bool = sqlx::query_scalar( + r#" + SELECT EXISTS( + SELECT 1 FROM user_messages + WHERE message_uuid = $1 AND user_uuid = $2 + ) + "#, + ) + .bind(payload.uuid) + .bind(ctx.user_uuid_unwrap()) + .fetch_one(&svc_ctx.db) + .await + .map_err(|e| Response::fail(Some(&e.to_string())))?; + + if !user_message_exists { + return Err(Response::fail(Some("消息不存在或无权限"))); + } + + services::messages::delete_message_service(&svc_ctx, payload.uuid) + .await + .map_err(|e| Response::fail(Some(&e)))?; + + Ok(Response::success(Some("删除成功"), None)) +} diff --git a/src-tauri/crates/business/src/handlers/preferences.rs b/src-tauri/crates/business/src/handlers/preferences.rs new file mode 100644 index 0000000..3b3f5a0 --- /dev/null +++ b/src-tauri/crates/business/src/handlers/preferences.rs @@ -0,0 +1,38 @@ +use axum::extract::{Extension, State}; + +use crate::dto::UserPreferenceDto; +use crate::entitys::settings::UpdatePreferencesRequest; +use crate::services; +use crate::state::RequestContext; +use crate::svc_ctx::SvcCtx; +use crate::utils::{Json, Response, Result}; + +/// 获取用户偏好设置 +pub async fn get_preferences_handler( + State(svc_ctx): State, + Extension(ctx): Extension, +) -> Result { + let preferences = + services::preferences::get_preferences_service(&svc_ctx, ctx.user_uuid_unwrap()) + .await + .map_err(|e| Response::fail(Some(&e)))?; + + Ok(Response::success(Some("获取成功"), Some(preferences))) +} + +/// 更新用户偏好设置 +pub async fn update_preferences_handler( + State(svc_ctx): State, + Extension(ctx): Extension, + Json(payload): Json, +) -> Result { + let preferences = services::preferences::update_preferences_service( + &svc_ctx, + ctx.user_uuid_unwrap(), + &payload, + ) + .await + .map_err(|e| Response::fail(Some(&e)))?; + + Ok(Response::success(Some("更新成功"), Some(preferences))) +} diff --git a/src-tauri/crates/business/src/handlers/proxies.rs b/src-tauri/crates/business/src/handlers/proxies.rs new file mode 100644 index 0000000..d0ecd7e --- /dev/null +++ b/src-tauri/crates/business/src/handlers/proxies.rs @@ -0,0 +1,174 @@ +use axum::{extract::Extension, extract::State}; + +use crate::audit_log; +use crate::entitys::{ + BatchImportProxiesRequest, BatchImportResponse, BatchUuidRequest, CreateProxyRequest, + CreateResponse, ListProxiesRequest, ProxyListResponse, UpdateProxyRequest, UuidRequest, +}; +use crate::services; +use crate::state::RequestContext; +use crate::svc_ctx::SvcCtx; +use crate::utils::{Json, Response, Result}; + +/// 获取代理列表 +pub async fn get_proxies_handler( + State(svc_ctx): State, + Extension(ctx): Extension, + Json(payload): Json, +) -> Result { + let workspace_uuid = ctx + .current_workspace_uuid + .ok_or_else(|| Response::fail(Some("请先选择工作空间")))?; + + let (proxies, total) = + services::proxies::get_proxies_service( + &svc_ctx, + ctx.user_uuid_unwrap(), + workspace_uuid, + ctx.current_team_uuid, + &payload, + ) + .await + .map_err(|e| Response::fail(Some(&e)))?; + + Ok(Response::success( + Some("获取成功"), + Some(ProxyListResponse { + items: proxies, + total, + page: payload.pagination.page, + page_size: payload.pagination.page_size, + }), + )) +} + +/// 获取代理详情 +pub async fn get_proxy_handler( + State(svc_ctx): State, + Json(payload): Json, +) -> Result { + let proxy = services::proxies::get_proxy_service(&svc_ctx, payload.uuid) + .await + .map_err(|e| Response::fail(Some(&e)))?; + + Ok(Response::success(Some("获取成功"), Some(proxy))) +} + +/// 创建代理 +pub async fn create_proxy_handler( + State(svc_ctx): State, + Extension(ctx): Extension, + Json(payload): Json, +) -> Result { + let workspace_uuid = ctx.current_workspace_uuid.ok_or_else(|| Response::fail(Some("请先选择工作空间")))?; + let proxy_uuid = + services::proxies::create_proxy_service(&svc_ctx, ctx.user_uuid_unwrap(), workspace_uuid, &payload) + .await + .map_err(|e| Response::fail(Some(&e)))?; + + audit_log!( + &svc_ctx, + &ctx, + "create", + "proxy", + proxy_uuid, + &payload.name, + "创建代理" + ) + .await; + + Ok(Response::success( + Some("创建成功"), + Some(CreateResponse { uuid: proxy_uuid }), + )) +} + +/// 更新代理 +pub async fn update_proxy_handler( + State(svc_ctx): State, + Extension(ctx): Extension, + Json(payload): Json, +) -> Result<()> { + services::proxies::update_proxy_service(&svc_ctx, &payload) + .await + .map_err(|e| Response::fail(Some(&e)))?; + + Ok(Response::success(Some("更新成功"), None)) +} + +/// 删除代理 +pub async fn delete_proxy_handler( + State(svc_ctx): State, + Extension(ctx): Extension, + Json(payload): Json, +) -> Result<()> { + services::proxies::delete_proxy_service(&svc_ctx, payload.uuid) + .await + .map_err(|e| Response::fail(Some(&e)))?; + + audit_log!(&svc_ctx, &ctx, "delete", "proxy", payload.uuid, "删除代理").await; + + Ok(Response::success(Some("删除成功"), None)) +} + +/// 批量删除代理 +pub async fn batch_delete_proxies_handler( + State(svc_ctx): State, + Extension(ctx): Extension, + Json(payload): Json, +) -> Result { + let count = services::proxies::batch_delete_proxies_service(&svc_ctx, &payload.uuids) + .await + .map_err(|e| Response::fail(Some(&e)))?; + + audit_log!( + &svc_ctx, + &ctx, + "batch_delete", + "proxy", + &format!("批量删除 {} 个代理", count) + ) + .await; + + Ok(Response::success(Some("删除成功"), Some(count))) +} + +/// 批量导入代理 +pub async fn batch_import_proxies_handler( + State(svc_ctx): State, + Extension(ctx): Extension, + Json(payload): Json, +) -> Result { + // 获取工作空间 UUID(从请求或当前工作空间) + let workspace_uuid = ctx.current_workspace_uuid.ok_or_else(|| Response::fail(Some("请先选择工作空间")))?; + + let result = services::proxies::batch_import_proxies_service( + &svc_ctx, + ctx.user_uuid_unwrap(), + workspace_uuid, + &payload, + ) + .await + .map_err(|e| Response::fail(Some(&e)))?; + + audit_log!( + &svc_ctx, + &ctx, + "batch_import", + "proxy", + &format!( + "批量导入代理: 成功 {} 个, 失败 {} 个", + result.success_count, result.failed_count + ) + ) + .await; + + Ok(Response::success( + Some("导入完成"), + Some(BatchImportResponse { + success_count: result.success_count, + failed_count: result.failed_count, + errors: result.errors, + }), + )) +} diff --git a/src-tauri/crates/business/src/handlers/proxy_visibility.rs b/src-tauri/crates/business/src/handlers/proxy_visibility.rs new file mode 100644 index 0000000..1d37997 --- /dev/null +++ b/src-tauri/crates/business/src/handlers/proxy_visibility.rs @@ -0,0 +1,150 @@ +use axum::{extract::Extension, extract::State}; + +use crate::audit_log; +use crate::entitys::{ + BatchSetProxyVisibleRequest, ListProxyVisibleTeamsRequest, ListVisibleProxiesRequest, + RemoveProxyVisibleRequest, SetProxyVisibleRequest, +}; +use crate::services; +use crate::state::RequestContext; +use crate::svc_ctx::SvcCtx; +use crate::utils::{Json, Response, Result}; + +/// 设置代理对团队可见 +pub async fn set_proxy_visible_handler( + State(svc_ctx): State, + Extension(ctx): Extension, + Json(payload): Json, +) -> Result<()> { + services::proxy_visibility::set_proxy_visible_to_team_service( + &svc_ctx, + ctx.user_uuid_unwrap(), + &payload, + ) + .await + .map_err(|e| Response::fail(Some(&e)))?; + + audit_log!( + &svc_ctx, + &ctx, + "update", + "proxy_visibility", + payload.proxy_uuid, + "", + "设置代理可见性" + ) + .await; + + Ok(Response::success(Some("设置成功"), None)) +} + +/// 移除代理对团队的可见性 +pub async fn remove_proxy_visible_handler( + State(svc_ctx): State, + Extension(ctx): Extension, + Json(payload): Json, +) -> Result<()> { + services::proxy_visibility::remove_proxy_visible_from_team_service( + &svc_ctx, + ctx.user_uuid_unwrap(), + &payload, + ) + .await + .map_err(|e| Response::fail(Some(&e)))?; + + audit_log!( + &svc_ctx, + &ctx, + "update", + "proxy_visibility", + payload.proxy_uuid, + "", + "移除代理可见性" + ) + .await; + + Ok(Response::success(Some("移除成功"), None)) +} + +/// 批量设置代理可见性 +pub async fn batch_set_proxy_visible_handler( + State(svc_ctx): State, + Extension(ctx): Extension, + Json(payload): Json, +) -> Result<()> { + services::proxy_visibility::batch_set_proxy_visible_service( + &svc_ctx, + ctx.user_uuid_unwrap(), + &payload, + ) + .await + .map_err(|e| Response::fail(Some(&e)))?; + + audit_log!( + &svc_ctx, + &ctx, + "update", + "proxy_visibility", + payload.proxy_uuid, + "", + "批量设置代理可见性" + ) + .await; + + Ok(Response::success(Some("设置成功"), None)) +} + +/// 获取可见的代理列表 +pub async fn get_visible_proxies_handler( + State(svc_ctx): State, + Extension(ctx): Extension, + Json(payload): Json, +) -> Result { + let proxies = services::proxy_visibility::get_visible_proxies_service( + &svc_ctx, + ctx.user_uuid_unwrap(), + &payload, + ) + .await + .map_err(|e| Response::fail(Some(&e)))?; + + Ok(Response::success( + None, + Some(crate::entitys::VisibleProxyListResponse { items: proxies }), + )) +} + +/// 获取代理的可见团队列表 +pub async fn get_proxy_visible_teams_handler( + State(svc_ctx): State, + Extension(_ctx): Extension, + Json(payload): Json, +) -> Result { + let teams = crate::models::fetch_visible_teams_by_proxy(&svc_ctx.db, payload.proxy_uuid) + .await + .map_err(|e| Response::fail(Some(&e.to_string())))?; + + // 转换为详情 DTO(包含团队名称) + let mut team_details = vec![]; + for team in teams { + let team_info = crate::models::fetch_team_by_uuid(&svc_ctx.db, team.team_uuid) + .await + .ok() + .flatten(); + + team_details.push(crate::dto::ProxyVisibleTeamDetailDto { + proxy_uuid: team.proxy_uuid, + workspace_uuid: team.workspace_uuid, + team_uuid: team.team_uuid, + team_name: team_info.map(|t| t.name), + created_at: team.created_at, + }); + } + + Ok(Response::success( + None, + Some(crate::entitys::ProxyVisibleTeamListResponse { + items: team_details, + }), + )) +} diff --git a/src-tauri/crates/business/src/handlers/referral.rs b/src-tauri/crates/business/src/handlers/referral.rs new file mode 100644 index 0000000..4fb5123 --- /dev/null +++ b/src-tauri/crates/business/src/handlers/referral.rs @@ -0,0 +1,210 @@ +use axum::extract::{Extension, State}; + +use crate::dto::{RedeemOptionDto, ReferralLinkTierDto, UserReferralPointsDto}; +use crate::entitys::{ + ListReferralRewardsRequest, ListReferredUsersRequest, Pagination, RedeemPointsRequest, + RedeemRecordsListResponse, RedeemResponse, ReferralDashboardResponse, + ReferralLinksListResponse, ReferralPlanSummaryResponse, ReferralRewardsListResponse, + ReferralStatsResponse, ReferredUsersListResponse, SwitchReferralLinkRequest, +}; +use crate::services; +use crate::state::RequestContext; +use crate::svc_ctx::SvcCtx; +use crate::utils::{Json, Response, Result}; + +/// 获取推广统计 +pub async fn get_referral_stats_handler( + State(svc_ctx): State, + Extension(ctx): Extension, +) -> Result { + let stats = services::referral::get_referral_stats_service(&svc_ctx, ctx.user_uuid_unwrap()) + .await + .map_err(|e| Response::fail(Some(&e)))?; + + Ok(Response::success(Some("获取成功"), Some(stats))) +} + +/// 获取推广看板聚合数据 +pub async fn get_referral_dashboard_handler( + State(svc_ctx): State, + Extension(ctx): Extension, +) -> Result { + let dashboard = services::referral::get_referral_dashboard_service(&svc_ctx, ctx.user_uuid_unwrap()) + .await + .map_err(|e| Response::fail(Some(&e)))?; + + Ok(Response::success(Some("获取成功"), Some(dashboard))) +} + +/// 获取套餐页推广摘要信息 +pub async fn get_referral_plan_summary_handler( + State(svc_ctx): State, + Extension(ctx): Extension, +) -> Result { + let summary = + services::referral::get_referral_plan_summary_service(&svc_ctx, ctx.user_uuid_unwrap()) + .await + .map_err(|e| Response::fail(Some(&e)))?; + + Ok(Response::success(Some("获取成功"), Some(summary))) +} + +/// 获取推广链接层级配置 +pub async fn get_referral_tiers_handler( + State(svc_ctx): State, +) -> Result> { + let tiers = services::referral::get_referral_tiers_service(&svc_ctx) + .await + .map_err(|e| Response::fail(Some(&e)))?; + + Ok(Response::success(Some("获取成功"), Some(tiers))) +} + +/// 获取推广链接列表 +pub async fn get_referral_links_handler( + State(svc_ctx): State, + Extension(ctx): Extension, +) -> Result { + let (items, current_link) = + services::referral::get_referral_links_service(&svc_ctx, ctx.user_uuid_unwrap()) + .await + .map_err(|e| Response::fail(Some(&e)))?; + + Ok(Response::success( + Some("获取成功"), + Some(ReferralLinksListResponse { + items, + current_link, + }), + )) +} + +/// 切换当前推广链接 +pub async fn switch_referral_link_handler( + State(svc_ctx): State, + Extension(ctx): Extension, + Json(payload): Json, +) -> Result<()> { + services::referral::switch_referral_link_service( + &svc_ctx, + ctx.user_uuid_unwrap(), + payload.link_uuid, + ) + .await + .map_err(|e| Response::fail(Some(&e)))?; + + Ok(Response::success(Some("切换成功"), None)) +} + +/// 获取奖励记录列表 +pub async fn get_referral_rewards_handler( + State(svc_ctx): State, + Extension(ctx): Extension, + Json(payload): Json, +) -> Result { + let (items, total) = services::referral::get_referral_rewards_service( + &svc_ctx, + ctx.user_uuid_unwrap(), + &payload, + ) + .await + .map_err(|e| Response::fail(Some(&e)))?; + + Ok(Response::success( + Some("获取成功"), + Some(ReferralRewardsListResponse { + items, + total, + page: payload.pagination.page, + page_size: payload.pagination.page_size, + }), + )) +} + +/// 获取被邀请用户列表 +pub async fn get_referred_users_handler( + State(svc_ctx): State, + Extension(ctx): Extension, + Json(payload): Json, +) -> Result { + let (items, total) = + services::referral::get_referred_users_service(&svc_ctx, ctx.user_uuid_unwrap(), &payload) + .await + .map_err(|e| Response::fail(Some(&e)))?; + + Ok(Response::success( + Some("获取成功"), + Some(ReferredUsersListResponse { + items, + total, + page: payload.pagination.page, + page_size: payload.pagination.page_size, + }), + )) +} + +/// 获取用户积分 +pub async fn get_user_points_handler( + State(svc_ctx): State, + Extension(ctx): Extension, +) -> Result { + let points = services::referral::get_user_points_service(&svc_ctx, ctx.user_uuid_unwrap()) + .await + .map_err(|e| Response::fail(Some(&e)))?; + + Ok(Response::success(Some("获取成功"), Some(points))) +} + +/// 获取兑换选项 +pub async fn get_redeem_options_handler( + State(svc_ctx): State, +) -> Result> { + let options = services::referral::get_redeem_options_service(&svc_ctx) + .await + .map_err(|e| Response::fail(Some(&e)))?; + + Ok(Response::success(Some("获取成功"), Some(options))) +} + +/// 执行积分兑换 +pub async fn redeem_points_handler( + State(svc_ctx): State, + Extension(ctx): Extension, + Json(payload): Json, +) -> Result { + let (record_uuid, points_used, value) = + services::referral::redeem_points_service(&svc_ctx, ctx.user_uuid_unwrap(), &payload) + .await + .map_err(|e| Response::fail(Some(&e)))?; + + Ok(Response::success( + Some("兑换成功"), + Some(RedeemResponse { + record_uuid, + points_used, + value, + }), + )) +} + +/// 获取兑换记录 +pub async fn get_redeem_records_handler( + State(svc_ctx): State, + Extension(ctx): Extension, + Json(payload): Json, +) -> Result { + let (items, total) = + services::referral::get_redeem_records_service(&svc_ctx, ctx.user_uuid_unwrap(), &payload) + .await + .map_err(|e| Response::fail(Some(&e)))?; + + Ok(Response::success( + Some("获取成功"), + Some(RedeemRecordsListResponse { + items, + total, + page: payload.page, + page_size: payload.page_size, + }), + )) +} diff --git a/src-tauri/crates/business/src/handlers/rpa.rs b/src-tauri/crates/business/src/handlers/rpa.rs new file mode 100644 index 0000000..ddd08ea --- /dev/null +++ b/src-tauri/crates/business/src/handlers/rpa.rs @@ -0,0 +1,144 @@ +use axum::extract::{Extension, State}; + +use crate::entitys::{ + BatchUuidRequest, CreateResponse, CreateRpaTaskRequest, DuplicateRpaTaskRequest, + ExportRpaTaskRequest, ExportRpaTaskResponse, ListRpaTasksRequest, RpaTaskDetailResponse, + RpaTaskListResponse, UpdateRpaTaskRequest, UuidRequest, +}; +use crate::services; +use crate::state::RequestContext; +use crate::svc_ctx::SvcCtx; +use crate::utils::{Json, Response, Result}; + +pub async fn get_rpa_tasks_handler( + State(svc_ctx): State, + Extension(ctx): Extension, + Json(payload): Json, +) -> Result { + let team_uuid = services::teams::get_current_team_service(&svc_ctx, ctx.user_uuid_unwrap()) + .await + .map_err(|e| Response::fail(Some(&e)))?; + + let (items, total) = + services::rpa::get_rpa_tasks_service(&svc_ctx, ctx.user_uuid_unwrap(), team_uuid, &payload) + .await + .map_err(|e| Response::fail(Some(&e)))?; + + Ok(Response::success( + Some("ok"), + Some(RpaTaskListResponse { + items, + total, + page: payload.pagination.page, + page_size: payload.pagination.page_size, + }), + )) +} + +pub async fn get_rpa_task_handler( + State(svc_ctx): State, + Json(payload): Json, +) -> Result { + let (task, steps, environment_uuids) = + services::rpa::get_rpa_task_service(&svc_ctx, payload.uuid) + .await + .map_err(|e| Response::fail(Some(&e)))?; + + Ok(Response::success( + Some("ok"), + Some(RpaTaskDetailResponse { + task, + steps, + environment_uuids, + }), + )) +} + +pub async fn create_rpa_task_handler( + State(svc_ctx): State, + Extension(ctx): Extension, + Json(payload): Json, +) -> Result { + let team_uuid = services::teams::get_current_team_service(&svc_ctx, ctx.user_uuid_unwrap()) + .await + .map_err(|e| Response::fail(Some(&e)))?; + + let uuid = services::rpa::create_rpa_task_service( + &svc_ctx, + ctx.user_uuid_unwrap(), + team_uuid, + &payload, + ) + .await + .map_err(|e| Response::fail(Some(&e)))?; + + Ok(Response::success(Some("created"), Some(CreateResponse { uuid }))) +} + +pub async fn update_rpa_task_handler( + State(svc_ctx): State, + Json(payload): Json, +) -> Result<()> { + services::rpa::update_rpa_task_service(&svc_ctx, &payload) + .await + .map_err(|e| Response::fail(Some(&e)))?; + + Ok(Response::success(Some("updated"), None)) +} + +pub async fn delete_rpa_task_handler( + State(svc_ctx): State, + Json(payload): Json, +) -> Result<()> { + services::rpa::delete_rpa_task_service(&svc_ctx, payload.uuid) + .await + .map_err(|e| Response::fail(Some(&e)))?; + + Ok(Response::success(Some("deleted"), None)) +} + +pub async fn batch_delete_rpa_tasks_handler( + State(svc_ctx): State, + Json(payload): Json, +) -> Result<()> { + services::rpa::batch_delete_rpa_tasks_service(&svc_ctx, &payload.uuids) + .await + .map_err(|e| Response::fail(Some(&e)))?; + + Ok(Response::success(Some("deleted"), None)) +} + +pub async fn duplicate_rpa_task_handler( + State(svc_ctx): State, + Extension(ctx): Extension, + Json(payload): Json, +) -> Result { + let team_uuid = services::teams::get_current_team_service(&svc_ctx, ctx.user_uuid_unwrap()) + .await + .map_err(|e| Response::fail(Some(&e)))?; + + let uuid = services::rpa::duplicate_rpa_task_service( + &svc_ctx, + ctx.user_uuid_unwrap(), + team_uuid, + &payload, + ) + .await + .map_err(|e| Response::fail(Some(&e)))?; + + Ok(Response::success(Some("duplicated"), Some(CreateResponse { uuid }))) +} + +pub async fn export_rpa_task_handler( + State(svc_ctx): State, + Json(payload): Json, +) -> Result { + let (content, filename) = services::rpa::export_rpa_task_service(&svc_ctx, payload.uuid) + .await + .map_err(|e| Response::fail(Some(&e)))?; + + Ok(Response::success( + Some("exported"), + Some(ExportRpaTaskResponse { content, filename }), + )) +} diff --git a/src-tauri/crates/business/src/handlers/secret.rs b/src-tauri/crates/business/src/handlers/secret.rs new file mode 100644 index 0000000..09d968f --- /dev/null +++ b/src-tauri/crates/business/src/handlers/secret.rs @@ -0,0 +1,15 @@ +use axum::http::StatusCode; +use axum::response::IntoResponse; + +use crate::utils::get_rsa_secret_instance; + +/// 获取服务器公钥 +/// +/// 返回服务器的 RSA 公钥(PEM 格式),用于客户端加密数据 +/// +/// # Returns +/// 返回纯文本格式的 PEM 公钥字符串 +pub async fn get_public_key_handler() -> impl IntoResponse { + let public_key = get_rsa_secret_instance().get_public_key(); + (StatusCode::OK, public_key).into_response() +} diff --git a/src-tauri/crates/business/src/handlers/teams.rs b/src-tauri/crates/business/src/handlers/teams.rs new file mode 100644 index 0000000..63c27f0 --- /dev/null +++ b/src-tauri/crates/business/src/handlers/teams.rs @@ -0,0 +1,364 @@ +use axum::{extract::Extension, extract::State}; + +use crate::audit_log; +use crate::entitys::{ + AcceptInvitationRequest, AcceptInvitationResponse, CreateResponse, CreateTeamRequest, + InviteMemberRequest, InviteResponse, ListTeamMembersRequest, MemberListResponse, + RejectInvitationRequest, RemoveMemberRequest, SwitchTeamRequest, TeamItem, TeamListResponse, + UpdateMemberRoleRequest, UpdateTeamRequest, UuidRequest, +}; +use crate::services; +use crate::state::RequestContext; +use crate::svc_ctx::SvcCtx; +use crate::utils::{Json, Response, Result}; + +/// 创建团队 +pub async fn create_team_handler( + State(svc_ctx): State, + Extension(ctx): Extension, + Json(payload): Json, +) -> Result { + let team_uuid = + services::teams::create_team_service(&svc_ctx, ctx.user_uuid_unwrap(), &payload) + .await + .map_err(|e| Response::fail(Some(&e)))?; + + audit_log!( + &svc_ctx, + &ctx, + "create", + "team", + team_uuid, + &payload.name, + "创建团队" + ) + .await; + + Ok(Response::success( + Some("创建成功"), + Some(CreateResponse { uuid: team_uuid }), + )) +} + +/// 获取用户的所有团队 +pub async fn get_my_teams_handler( + State(svc_ctx): State, + Extension(ctx): Extension, +) -> Result { + let workspace_uuid = ctx.current_workspace_uuid.ok_or_else(|| Response::fail(Some("请先选择工作空间")))?; + let teams = services::teams::get_user_teams_service(&svc_ctx, workspace_uuid, ctx.user_uuid_unwrap()) + .await + .map_err(|e| Response::fail(Some(&e)))?; + + let current_team_uuid = + services::teams::get_current_team_service(&svc_ctx, ctx.user_uuid_unwrap()) + .await + .map_err(|e| Response::fail(Some(&e)))?; + + let mut team_items: Vec = vec![]; + + for t in &teams { + // 获取用户在该团队的角色 + let role = if t.owner_uuid == ctx.user_uuid_unwrap() { + "owner".to_string() + } else { + crate::models::fetch_team_member(&svc_ctx.db, workspace_uuid, t.uuid, ctx.user_uuid_unwrap()) + .await + .ok() + .flatten() + .map(|m| m.role) + .unwrap_or_else(|| "member".to_string()) + }; + + // 获取成员数量(不使用筛选,统计所有活跃成员) + let members_count = + crate::models::fetch_team_member_count(&svc_ctx.db, t.uuid, None, None, None) + .await + .unwrap_or(0); + + team_items.push(TeamItem { + uuid: t.uuid, + name: t.name.clone(), + description: t.description.clone(), + role, + members_count, + is_current: current_team_uuid == Some(t.uuid), + }); + } + + Ok(Response::success( + Some("获取成功"), + Some(TeamListResponse { + current_team_uuid, + teams: team_items, + }), + )) +} + +/// 切换团队 +pub async fn switch_team_handler( + State(svc_ctx): State, + Extension(ctx): Extension, + Json(payload): Json, +) -> Result<()> { + let workspace_uuid = ctx.current_workspace_uuid.ok_or_else(|| Response::fail(Some("请先选择工作空间")))?; + services::teams::switch_team_service(&svc_ctx, workspace_uuid, ctx.user_uuid_unwrap(), &payload) + .await + .map_err(|e| Response::fail(Some(&e)))?; + + Ok(Response::success(Some("切换成功"), None)) +} + +/// 获取团队详情 +pub async fn get_team_handler( + State(svc_ctx): State, + Json(payload): Json, +) -> Result { + let team = services::teams::get_team_service(&svc_ctx, payload.uuid) + .await + .map_err(|e| Response::fail(Some(&e)))?; + + Ok(Response::success(Some("获取成功"), Some(team))) +} + +/// 更新团队信息 +pub async fn update_team_handler( + State(svc_ctx): State, + Extension(ctx): Extension, + Json(payload): Json, +) -> Result<()> { + let workspace_uuid = ctx.current_workspace_uuid.ok_or_else(|| Response::fail(Some("请先选择工作空间")))?; + services::teams::update_team_service(&svc_ctx, workspace_uuid, ctx.user_uuid_unwrap(), &payload) + .await + .map_err(|e| Response::fail(Some(&e)))?; + + Ok(Response::success(Some("更新成功"), None)) +} + +/// 获取团队成员列表 +pub async fn get_team_members_handler( + State(svc_ctx): State, + Extension(ctx): Extension, + Json(payload): Json, +) -> Result { + // 获取用户当前团队 + let team_uuid = services::teams::get_current_team_service(&svc_ctx, ctx.user_uuid_unwrap()) + .await + .map_err(|e| Response::fail(Some(&e)))? + .ok_or_else(|| Response::fail(Some("请先选择团队")))?; + + let (members, total) = services::teams::get_team_members_service(&svc_ctx, team_uuid, &payload) + .await + .map_err(|e| Response::fail(Some(&e)))?; + + Ok(Response::success( + Some("获取成功"), + Some(MemberListResponse { + items: members, + total, + page: payload.pagination.page, + page_size: payload.pagination.page_size, + }), + )) +} + +/// 邀请成员 +pub async fn invite_member_handler( + State(svc_ctx): State, + Extension(ctx): Extension, + Json(payload): Json, +) -> Result { + let team_uuid = services::teams::get_current_team_service(&svc_ctx, ctx.user_uuid_unwrap()) + .await + .map_err(|e| Response::fail(Some(&e)))? + .ok_or_else(|| Response::fail(Some("请先选择团队")))?; + + let workspace_uuid = ctx.current_workspace_uuid.ok_or_else(|| Response::fail(Some("请先选择工作空间")))?; + let invitation_uuid = services::teams::invite_member_service( + &svc_ctx, + workspace_uuid, + team_uuid, + ctx.user_uuid_unwrap(), + &payload, + ) + .await + .map_err(|e| Response::fail(Some(&e)))?; + + audit_log!( + &svc_ctx, + &ctx, + "invite", + "team_member", + &format!("邀请成员: {}", payload.email) + ) + .await; + + Ok(Response::success( + Some("邀请已发送"), + Some(InviteResponse { invitation_uuid }), + )) +} + +/// 更新成员角色 +pub async fn update_member_role_handler( + State(svc_ctx): State, + Extension(ctx): Extension, + Json(payload): Json, +) -> Result { + let team_uuid = services::teams::get_current_team_service(&svc_ctx, ctx.user_uuid_unwrap()) + .await + .map_err(|e| Response::fail(Some(&e)))? + .ok_or_else(|| Response::fail(Some("请先选择团队")))?; + + let workspace_uuid = ctx.current_workspace_uuid.ok_or_else(|| Response::fail(Some("请先选择工作空间")))?; + let member = services::teams::update_member_role_service( + &svc_ctx, + workspace_uuid, + team_uuid, + ctx.user_uuid_unwrap(), + &payload, + ) + .await + .map_err(|e| Response::fail(Some(&e)))?; + + Ok(Response::success(Some("更新成功"), Some(member))) +} + +/// 移除成员 +pub async fn remove_member_handler( + State(svc_ctx): State, + Extension(ctx): Extension, + Json(payload): Json, +) -> Result<()> { + let team_uuid = services::teams::get_current_team_service(&svc_ctx, ctx.user_uuid_unwrap()) + .await + .map_err(|e| Response::fail(Some(&e)))? + .ok_or_else(|| Response::fail(Some("请先选择团队")))?; + + let workspace_uuid = ctx.current_workspace_uuid.ok_or_else(|| Response::fail(Some("请先选择工作空间")))?; + services::teams::remove_member_service( + &svc_ctx, + workspace_uuid, + team_uuid, + ctx.user_uuid_unwrap(), + payload.member_uuid, + ) + .await + .map_err(|e| Response::fail(Some(&e)))?; + + audit_log!( + &svc_ctx, + &ctx, + "remove", + "team_member", + payload.member_uuid, + "移除成员" + ) + .await; + + Ok(Response::success(Some("移除成功"), None)) +} + +/// 获取待处理邀请列表 +pub async fn get_pending_invitations_handler( + State(svc_ctx): State, + Extension(ctx): Extension, +) -> Result> { + let team_uuid = services::teams::get_current_team_service(&svc_ctx, ctx.user_uuid_unwrap()) + .await + .map_err(|e| Response::fail(Some(&e)))? + .ok_or_else(|| Response::fail(Some("请先选择团队")))?; + + let invitations = services::teams::get_pending_invitations_service(&svc_ctx, team_uuid) + .await + .map_err(|e| Response::fail(Some(&e)))?; + + Ok(Response::success(Some("获取成功"), Some(invitations))) +} + +/// 取消邀请 +pub async fn cancel_invitation_handler( + State(svc_ctx): State, + Json(payload): Json, +) -> Result<()> { + services::teams::cancel_invitation_service(&svc_ctx, payload.uuid) + .await + .map_err(|e| Response::fail(Some(&e)))?; + + Ok(Response::success(Some("邀请已取消"), None)) +} + +/// 接受邀请 +pub async fn accept_invitation_handler( + State(svc_ctx): State, + Extension(ctx): Extension, + Json(payload): Json, +) -> Result { + let team_uuid = services::teams::accept_invitation_service( + &svc_ctx, + ctx.user_uuid_unwrap(), + &payload.token, + ) + .await + .map_err(|e| Response::fail(Some(&e)))?; + + audit_log!( + &svc_ctx, + &ctx, + "accept_invitation", + "team", + team_uuid, + "接受团队邀请" + ) + .await; + + Ok(Response::success( + Some("已加入团队"), + Some(AcceptInvitationResponse { team_uuid }), + )) +} + +/// 拒绝邀请 +pub async fn reject_invitation_handler( + State(svc_ctx): State, + Extension(ctx): Extension, + Json(payload): Json, +) -> Result<()> { + services::teams::reject_invitation_service(&svc_ctx, ctx.user_uuid_unwrap(), &payload.token) + .await + .map_err(|e| Response::fail(Some(&e)))?; + + audit_log!(&svc_ctx, &ctx, "reject_invitation", "team", "拒绝团队邀请").await; + + Ok(Response::success(Some("已拒绝邀请"), None)) +} + +/// 退出团队 +pub async fn leave_team_handler( + State(svc_ctx): State, + Extension(ctx): Extension, +) -> Result<()> { + // 获取当前团队 + let current_team_uuid = + services::teams::get_current_team_service(&svc_ctx, ctx.user_uuid_unwrap()) + .await + .map_err(|e| Response::fail(Some(&e)))? + .ok_or_else(|| Response::fail(Some("当前没有选择团队")))?; + + let workspace_uuid = ctx.current_workspace_uuid.ok_or_else(|| Response::fail(Some("请先选择工作空间")))?; + services::teams::leave_team_service(&svc_ctx, workspace_uuid, current_team_uuid, ctx.user_uuid_unwrap()) + .await + .map_err(|e| Response::fail(Some(&e)))?; + + audit_log!( + &svc_ctx, + &ctx, + "leave", + "team", + current_team_uuid, + "退出团队" + ) + .await; + + Ok(Response::success(Some("已退出团队"), None)) +} diff --git a/src-tauri/crates/business/src/handlers/templates.rs b/src-tauri/crates/business/src/handlers/templates.rs new file mode 100644 index 0000000..d67d584 --- /dev/null +++ b/src-tauri/crates/business/src/handlers/templates.rs @@ -0,0 +1,157 @@ +use axum::{extract::Extension, extract::State}; + +use crate::entitys::{ + CreateFromTemplateRequest, CreateResponse, CreateTemplateRequest, GetTemplateRequest, + ListTemplatesRequest, TemplateDetailResponse, TemplateListResponse, UpdateTemplateRequest, + UuidRequest, +}; +use crate::services; +use crate::state::RequestContext; +use crate::svc_ctx::SvcCtx; +use crate::utils::{Json, Response, Result}; + +/// 获取模板列表 +pub async fn get_templates_handler( + State(svc_ctx): State, + Extension(ctx): Extension, + Json(payload): Json, +) -> Result { + let team_uuid = services::teams::get_current_team_service(&svc_ctx, ctx.user_uuid_unwrap()) + .await + .map_err(|e| Response::fail(Some(&e)))?; + + let (templates, total) = services::templates::get_templates_service( + &svc_ctx, + ctx.user_uuid_unwrap(), + team_uuid, + payload.is_public, + payload.pagination.page, + payload.pagination.page_size, + ) + .await + .map_err(|e| Response::fail(Some(&e)))?; + + Ok(Response::success( + Some("获取成功"), + Some(TemplateListResponse { + items: templates, + total, + page: payload.pagination.page, + page_size: payload.pagination.page_size, + }), + )) +} + +/// 获取模板详情 +pub async fn get_template_handler( + State(svc_ctx): State, + Json(payload): Json, +) -> Result { + let result = services::templates::get_template_service( + &svc_ctx, + payload.uuid, + payload.for_create.unwrap_or(false), + ) + .await + .map_err(|e| Response::fail(Some(&e)))?; + + Ok(Response::success(Some("获取成功"), Some(result))) +} + +/// 创建模板 +pub async fn create_template_handler( + State(svc_ctx): State, + Extension(ctx): Extension, + Json(payload): Json, +) -> Result { + let team_uuid = services::teams::get_current_team_service(&svc_ctx, ctx.user_uuid_unwrap()) + .await + .map_err(|e| Response::fail(Some(&e)))?; + + let template_uuid = services::templates::create_template_service( + &svc_ctx, + ctx.user_uuid_unwrap(), + team_uuid, + &payload, + ) + .await + .map_err(|e| Response::fail(Some(&e)))?; + + Ok(Response::success( + Some("创建成功"), + Some(CreateResponse { + uuid: template_uuid, + }), + )) +} + +/// 更新模板 +pub async fn update_template_handler( + State(svc_ctx): State, + Json(payload): Json, +) -> Result<()> { + services::templates::update_template_service(&svc_ctx, &payload) + .await + .map_err(|e| Response::fail(Some(&e)))?; + + Ok(Response::success(Some("更新成功"), None)) +} + +/// 删除模板 +pub async fn delete_template_handler( + State(svc_ctx): State, + Json(payload): Json, +) -> Result<()> { + services::templates::delete_template_service(&svc_ctx, payload.uuid) + .await + .map_err(|e| Response::fail(Some(&e)))?; + + Ok(Response::success(Some("删除成功"), None)) +} + +/// 应用模板到环境(更新环境配置为模板配置) +pub async fn apply_template_handler( + State(svc_ctx): State, + Extension(ctx): Extension, + Json(payload): Json, +) -> Result<()> { + let workspace_uuid = ctx.current_workspace_uuid.ok_or_else(|| Response::fail(Some("请先选择工作空间")))?; + let team_uuid = ctx.current_team_uuid.ok_or_else(|| Response::fail(Some("请先选择团队")))?; + + services::templates::apply_template_service( + &svc_ctx, + workspace_uuid, + team_uuid, + ctx.user_uuid_unwrap(), + &payload, + ) + .await + .map_err(|e| Response::fail(Some(&e)))?; + + Ok(Response::success(Some("应用成功"), None)) +} + +/// 从模板创建环境 +pub async fn create_from_template_handler( + State(svc_ctx): State, + Extension(ctx): Extension, + Json(payload): Json, +) -> Result { + let workspace_uuid = ctx.current_workspace_uuid.ok_or_else(|| Response::fail(Some("请先选择工作空间")))?; + let team_uuid = ctx.current_team_uuid.ok_or_else(|| Response::fail(Some("请先选择团队")))?; + + let env_uuid = services::templates::create_from_template_service( + &svc_ctx, + ctx.user_uuid_unwrap(), + workspace_uuid, + team_uuid, + &payload, + ) + .await + .map_err(|e| Response::fail(Some(&e)))?; + + Ok(Response::success( + Some("创建成功"), + Some(CreateResponse { uuid: env_uuid }), + )) +} diff --git a/src-tauri/crates/business/src/handlers/time.rs b/src-tauri/crates/business/src/handlers/time.rs new file mode 100644 index 0000000..7663890 --- /dev/null +++ b/src-tauri/crates/business/src/handlers/time.rs @@ -0,0 +1,10 @@ +use crate::{ + services::now_service, + utils::{Response, Result}, +}; + +pub async fn now_handle() -> Result { + let now = now_service(); + + Ok(Response::::success(Some("获取时间成功"), Some(now))) +} diff --git a/src-tauri/crates/business/src/handlers/users.rs b/src-tauri/crates/business/src/handlers/users.rs new file mode 100644 index 0000000..1646193 --- /dev/null +++ b/src-tauri/crates/business/src/handlers/users.rs @@ -0,0 +1,151 @@ +use axum::{extract::Extension, extract::State}; + +use crate::audit_log; +use crate::entitys::{ + LoginRequest, LoginResponse, RegisterRequest, RegisterResponse, ResetPasswordRequest, + SendCodeRequest, UpdatePasswordRequest, UpdateUserRequest, UserResponse, + VerifyPasswordRequest, VerifyPasswordResponse, +}; +use crate::services::audit::log_audit_anonymous; +use crate::services::users::{ + get_current_user_service, login_service, refresh_token_service, register_service, + reset_password_service, send_verification_code_service, update_password_service, + update_user_service, verify_password_service, +}; +use crate::state::RequestContext; +use crate::svc_ctx::SvcCtx; +use crate::utils::{Json, Response, Result}; + +/// 用户注册处理 +pub async fn register_handler( + State(svc_ctx): State, + Extension(ctx): Extension, + Json(payload): Json, +) -> Result { + let result = register_service(&svc_ctx, &payload) + .await + .map_err(|e| Response::fail(Some(&e.to_string())))?; + + // 记录审计日志(注册时使用返回的 user_uuid) + if let Some(ref user_info) = result.user_info { + log_audit_anonymous( + &svc_ctx, + &ctx, + user_info.uuid, + "register", + "user", + "用户注册", + ) + .await; + } + + Ok(Response::success(Some("注册成功"), Some(result))) +} + +/// 用户登录处理 +pub async fn login_handler( + State(svc_ctx): State, + Extension(ctx): Extension, + Json(payload): Json, +) -> Result { + let result = login_service(&svc_ctx, payload) + .await + .map_err(|e| Response::fail(Some(&e.to_string())))?; + + // 记录审计日志(登录时使用返回的 user_uuid) + if let Some(ref user_info) = result.user_info { + log_audit_anonymous(&svc_ctx, &ctx, user_info.uuid, "login", "user", "用户登录").await; + } + + Ok(Response::success(Some("登录成功"), Some(result))) +} + +/// 刷新 Token 处理 +pub async fn refresh_token_handler( + State(svc_ctx): State, + Json(payload): Json, +) -> Result { + let result = refresh_token_service(&svc_ctx, &payload.refresh_token) + .await + .map_err(|e| Response::fail(Some(&e.to_string())))?; + + Ok(Response::success(Some("刷新成功"), Some(result))) +} + +/// 获取当前用户信息处理 +pub async fn get_current_user_handler( + State(svc_ctx): State, + Extension(ctx): Extension, +) -> Result { + let user_info = get_current_user_service(&svc_ctx, ctx.user_uuid_unwrap()) + .await + .map_err(|e| Response::fail(Some(&e.to_string())))?; + + Ok(Response::success(Some("获取成功"), Some(user_info))) +} + +/// 更新用户信息处理 +pub async fn update_user_handler( + State(svc_ctx): State, + Extension(ctx): Extension, + Json(payload): Json, +) -> Result<()> { + update_user_service(&svc_ctx, ctx.user_uuid_unwrap(), &payload) + .await + .map_err(|e| Response::fail(Some(&e.to_string())))?; + + Ok(Response::success(Some("更新成功"), None)) +} + +/// 修改密码处理 +pub async fn update_password_handler( + State(svc_ctx): State, + Extension(ctx): Extension, + Json(payload): Json, +) -> Result<()> { + update_password_service(&svc_ctx, ctx.user_uuid_unwrap(), &payload) + .await + .map_err(|e| Response::fail(Some(&e.to_string())))?; + + // 记录审计日志 + audit_log!(&svc_ctx, &ctx, "update_password", "user", "修改密码").await; + + Ok(Response::success(Some("修改成功"), None)) +} + +/// 校验当前用户密码处理 +pub async fn verify_password_handler( + State(svc_ctx): State, + Extension(ctx): Extension, + Json(payload): Json, +) -> Result { + let result = verify_password_service(&svc_ctx, ctx.user_uuid_unwrap(), &payload) + .await + .map_err(|e| Response::fail(Some(&e.to_string())))?; + + Ok(Response::success(Some("校验成功"), Some(result))) +} + +/// 重置密码处理 +pub async fn reset_password_handler( + State(svc_ctx): State, + Json(payload): Json, +) -> Result<()> { + reset_password_service(&svc_ctx, &payload) + .await + .map_err(|e| Response::fail(Some(&e.to_string())))?; + + Ok(Response::success(Some("重置成功"), None)) +} + +/// 发送验证码处理 +pub async fn send_code_handler( + State(svc_ctx): State, + Json(payload): Json, +) -> Result<()> { + send_verification_code_service(&svc_ctx, &payload.email, &payload.r#type) + .await + .map_err(|e| Response::fail(Some(&e.to_string())))?; + + Ok(Response::success(Some("发送成功"), None)) +} diff --git a/src-tauri/crates/business/src/handlers/workspace_quotas.rs b/src-tauri/crates/business/src/handlers/workspace_quotas.rs new file mode 100644 index 0000000..a5e96b0 --- /dev/null +++ b/src-tauri/crates/business/src/handlers/workspace_quotas.rs @@ -0,0 +1,43 @@ +use axum::{extract::Extension, extract::State}; + +use crate::entitys::{GetWorkspaceQuotaRequest, UpdateQuotaUsageRequest}; +use crate::services; +use crate::state::RequestContext; +use crate::svc_ctx::SvcCtx; +use crate::utils::{Json, Response, Result}; + +/// 获取工作空间配额 +pub async fn get_workspace_quota_handler( + State(svc_ctx): State, + Extension(ctx): Extension, + Json(mut payload): Json, +) -> Result { + // 如果没有传递 workspace_uuid,则从 RequestContext 中获取 + if payload.workspace_uuid.is_none() { + payload.workspace_uuid = ctx.current_workspace_uuid; + } + + // 验证 workspace_uuid 是否存在 + if payload.workspace_uuid.is_none() { + return Err(Response::fail(Some("未提供工作空间 UUID"))); + } + + let quota = services::workspace_quotas::get_workspace_quota_service(&svc_ctx, &payload) + .await + .map_err(|e| Response::fail(Some(&e)))?; + + Ok(Response::success(None, Some(quota))) +} + +/// 更新配额使用情况 +pub async fn update_quota_usage_handler( + State(svc_ctx): State, + Extension(_ctx): Extension, + Json(payload): Json, +) -> Result<()> { + services::workspace_quotas::update_quota_usage_service(&svc_ctx, &payload) + .await + .map_err(|e| Response::fail(Some(&e)))?; + + Ok(Response::success(Some("更新成功"), None)) +} diff --git a/src-tauri/crates/business/src/handlers/workspaces.rs b/src-tauri/crates/business/src/handlers/workspaces.rs new file mode 100644 index 0000000..6fbb2a1 --- /dev/null +++ b/src-tauri/crates/business/src/handlers/workspaces.rs @@ -0,0 +1,179 @@ +use axum::{extract::Extension, extract::State}; + +use crate::audit_log; +use crate::entitys::{ + CreateResponse, CreateWorkspaceRequest, SwitchWorkspaceRequest, UpdateWorkspaceRequest, + WorkspaceItem, WorkspaceListResponse, +}; +use crate::services; +use crate::state::RequestContext; +use crate::svc_ctx::SvcCtx; +use crate::utils::{Json, Response, Result}; + +/// 创建工作空间 +pub async fn create_workspace_handler( + State(svc_ctx): State, + Extension(ctx): Extension, + Json(payload): Json, +) -> Result { + let workspace_uuid = + services::workspaces::create_workspace_service(&svc_ctx, ctx.user_uuid_unwrap(), &payload) + .await + .map_err(|e| Response::fail(Some(&e)))?; + + audit_log!( + &svc_ctx, + &ctx, + "create", + "workspace", + workspace_uuid, + &payload.name, + "创建工作空间" + ) + .await; + + Ok(Response::success( + Some("创建成功"), + Some(CreateResponse { + uuid: workspace_uuid, + }), + )) +} + +/// 获取用户的所有工作空间 +pub async fn get_my_workspaces_handler( + State(svc_ctx): State, + Extension(ctx): Extension, +) -> Result { + let user_uuid = ctx.user_uuid_unwrap(); + let workspaces = services::workspaces::get_user_workspaces_service(&svc_ctx, user_uuid) + .await + .map_err(|e| Response::fail(Some(&e)))?; + + // 从 RequestContext 获取当前工作空间 UUID(已在 auth 中间件中设置) + let mut current_workspace_uuid = ctx.current_workspace_uuid; + + // 如果用户没有设置当前工作空间,但有工作空间列表,自动设置为第一个工作空间 + if current_workspace_uuid.is_none() && !workspaces.is_empty() { + let first_workspace_uuid = workspaces[0].uuid; + // 更新数据库 + if let Err(e) = crate::models::user::set_user_current_workspace( + &svc_ctx.db, + user_uuid, + first_workspace_uuid, + ) + .await + { + tracing::warn!("Failed to set default workspace: {}", e); + } else { + current_workspace_uuid = Some(first_workspace_uuid); + } + } + + let workspace_items: Vec = workspaces + .iter() + .map(|w| WorkspaceItem { + uuid: w.uuid, + name: w.name.clone(), + workspace_type: w.workspace_type.clone(), + is_current: current_workspace_uuid == Some(w.uuid), + }) + .collect(); + + Ok(Response::success( + None, + Some(WorkspaceListResponse { + current_workspace_uuid, + workspaces: workspace_items, + }), + )) +} + +/// 获取工作空间详情 +pub async fn get_workspace_handler( + State(svc_ctx): State, + Extension(_ctx): Extension, + Json(payload): Json, +) -> Result { + let workspace = services::workspaces::get_workspace_service(&svc_ctx, payload.uuid) + .await + .map_err(|e| Response::fail(Some(&e)))?; + + Ok(Response::success(None, Some(workspace))) +} + +/// 更新工作空间 +pub async fn update_workspace_handler( + State(svc_ctx): State, + Extension(ctx): Extension, + Json(payload): Json, +) -> Result<()> { + services::workspaces::update_workspace_service(&svc_ctx, ctx.user_uuid_unwrap(), &payload) + .await + .map_err(|e| Response::fail(Some(&e)))?; + + audit_log!( + &svc_ctx, + &ctx, + "update", + "workspace", + payload.uuid, + &payload.name.as_deref().unwrap_or(""), + "更新工作空间" + ) + .await; + + Ok(Response::success(Some("更新成功"), None)) +} + +/// 删除工作空间 +pub async fn delete_workspace_handler( + State(svc_ctx): State, + Extension(ctx): Extension, + Json(payload): Json, +) -> Result<()> { + services::workspaces::delete_workspace_service(&svc_ctx, ctx.user_uuid_unwrap(), payload.uuid) + .await + .map_err(|e| Response::fail(Some(&e)))?; + + audit_log!( + &svc_ctx, + &ctx, + "delete", + "workspace", + payload.uuid, + "", + "删除工作空间" + ) + .await; + + Ok(Response::success(Some("删除成功"), None)) +} + +/// 切换工作空间 +pub async fn switch_workspace_handler( + State(svc_ctx): State, + Extension(ctx): Extension, + Json(payload): Json, +) -> Result<()> { + let user_uuid = ctx.user_uuid_unwrap(); + + // 检查用户是否是工作空间所有者 + let is_owner = services::workspaces::check_workspace_owner_service( + &svc_ctx, + payload.workspace_uuid, + user_uuid, + ) + .await + .map_err(|e| Response::fail(Some(&e)))?; + + if !is_owner { + return Err(Response::fail(Some("您不是该工作空间的所有者"))); + } + + services::workspaces::switch_workspace_service(&svc_ctx, payload.workspace_uuid, user_uuid) + .await + .map_err(|e| Response::fail(Some(&e)))?; + + Ok(Response::success(Some("切换成功"), None)) +} diff --git a/src-tauri/crates/business/src/lib.rs b/src-tauri/crates/business/src/lib.rs new file mode 100644 index 0000000..a525243 --- /dev/null +++ b/src-tauri/crates/business/src/lib.rs @@ -0,0 +1,25 @@ +use crate::utils::IConfig; + +pub mod app; +pub mod caches; +pub mod cli; +pub mod database; +pub mod dto; +pub mod entitys; +pub mod errors; +pub mod handlers; +pub mod middlewares; +pub mod models; +pub mod routes; +pub mod services; +pub mod state; +pub mod svc_ctx; +pub mod utils; + +pub use app::{serve, serve_on, serve_on_with_shutdown}; + +/// Initialize the response-encryption key used by the transitional HTTP API. +pub async fn init_encrypt_secret(config: &IConfig) { + let key_path = &config.app.encrypt_secret_location; + utils::init_rsa_secret(key_path).await; +} diff --git a/src-tauri/crates/business/src/main.rs b/src-tauri/crates/business/src/main.rs new file mode 100644 index 0000000..12d75c1 --- /dev/null +++ b/src-tauri/crates/business/src/main.rs @@ -0,0 +1,26 @@ +use clap::Parser; +use simprint_server::{ + cli::{Cli, Commands}, + serve, + utils::IConfig, +}; + +#[tokio::main] +async fn main() -> anyhow::Result<()> { + let cli = Cli::parse(); + let (config_path, command) = cli.command_or_default(); + let config = IConfig::build_by_filepath(&config_path).expect("failed to build config"); + + tracing_subscriber::fmt() + .with_max_level(tracing::Level::INFO) + .with_target(true) + .with_thread_ids(true) + .try_init() + .map_err(|error| anyhow::anyhow!(error.to_string()))?; + + match command { + Commands::Serve => serve(config).await?, + } + + Ok(()) +} diff --git a/src-tauri/crates/business/src/middlewares.rs b/src-tauri/crates/business/src/middlewares.rs new file mode 100644 index 0000000..f0e43f3 --- /dev/null +++ b/src-tauri/crates/business/src/middlewares.rs @@ -0,0 +1,42 @@ +mod auth; +mod cors; +mod decrypt; +mod encrypt; +mod local_api_auth; +mod logger; +mod real_ip; + +pub use auth::*; +pub use cors::*; +pub use decrypt::*; +pub use encrypt::*; +pub use local_api_auth::*; +pub use logger::*; +pub use real_ip::*; + +use axum::extract::{MatchedPath, Request}; + +/// 获取请求路径和请求方法 +pub fn get_request_path_and_method(req: &Request) -> (&str, &str) { + let path = req + .extensions() + .get::() + .map_or_else(|| req.uri().path(), |path| path.as_str()); + + let method = req.method().as_str(); + + (method, path) +} + +/// 从完整路径中提取业务路径(去除 /api/v*/ 前缀,保留前导斜杠) +/// 例如:/api/v1/environments -> /environments +pub fn extract_resource_path(path: &str) -> &str { + // 匹配 /api/v{数字}/ 格式的前缀 + if let Some(stripped) = path.strip_prefix("/api/v") { + // 找到第一个 / 后的内容(包含斜杠) + if let Some(pos) = stripped.find('/') { + return &stripped[pos..]; + } + } + path +} diff --git a/src-tauri/crates/business/src/middlewares/auth.rs b/src-tauri/crates/business/src/middlewares/auth.rs new file mode 100644 index 0000000..6d40a9c --- /dev/null +++ b/src-tauri/crates/business/src/middlewares/auth.rs @@ -0,0 +1,138 @@ +use std::str::FromStr; + +use axum::{ + extract::{Request, State}, + http::{StatusCode, header}, + middleware::Next, + response::Response, +}; +use uuid::Uuid; + +use crate::{ + middlewares::{extract_resource_path, get_request_path_and_method}, + services, + state::{CurrentUser, RequestContext}, + svc_ctx::SvcCtx, +}; + +pub async fn auth( + State(state): State, + mut req: Request, + next: Next, +) -> Result { + let (resource_method, resource_path) = get_request_path_and_method(&req); + + // 转换为拥有所有权的 String,避免借用冲突 + let resource_method = resource_method.to_string(); + let resource_path = resource_path.to_string(); + + if resource_method.eq("OPTIONS") { + return Ok(next.run(req).await); + } + + if req + .extensions() + .get::() + .and_then(|ctx| ctx.current_user.as_ref()) + .is_some() + { + return Ok(next.run(req).await); + } + + if crate::middlewares::has_local_api_auth_headers(&req) + && !extract_resource_path(&resource_path).starts_with("/local-api") + { + return Ok(next.run(req).await); + } + + // 判断请求的是否在白名单,如果是白名单就直接允许访问。 + { + let combine = format!("{}+{}", resource_method, resource_path); + let whitelists = &state.config.app.route_whitelists; + + if whitelists.contains(&combine) { + return Ok(next.run(req).await); + } + } + + let auth_header = req + .headers() + .get(header::AUTHORIZATION) + .and_then(|header| header.to_str().ok()) + .ok_or_else(|| StatusCode::UNAUTHORIZED)?; + + let token_str = if auth_header.starts_with("Bearer ") { + &auth_header[7..] + } else { + return Err(StatusCode::UNAUTHORIZED); + }; + + // 先判断token是否正常,如果正常再去与远程的匹配。匹配成功后返回uuid. + let secret = state.config.app.secret.as_bytes(); + let verify_token_response = services::verify_token_service(token_str, secret); + + if let Err(_e) = verify_token_response { + return Err(StatusCode::UNAUTHORIZED); + } + + let verify_token_response = verify_token_response.unwrap(); + + // TODO: 根据获取到的uuid可以获取到用户并调用检查权限的方法。 + // 2025-08-27 10:05:00: 权限检查可能移除, 该网关服务只面向用户 + let uuid = if let Ok(uuid) = Uuid::from_str(&verify_token_response) { + uuid + } else { + return Err(StatusCode::UNAUTHORIZED); + }; + + fill_authenticated_request_context(&state, &mut req, uuid, &resource_method, &resource_path) + .await; + + Ok(next.run(req).await) +} + +pub async fn fill_authenticated_request_context( + state: &SvcCtx, + req: &mut Request, + user_uuid: Uuid, + resource_method: &str, + resource_path: &str, +) { + if req.extensions().get::().is_none() { + req.extensions_mut().insert(RequestContext::default()); + } + + if let Some(ctx) = req.extensions_mut().get_mut::() { + let ip = ctx.ip_or_unknown().to_string(); + tracing::info!("auth user uuid: {}, ip: {}", user_uuid, ip); + + ctx.current_user = Some(CurrentUser { user_uuid }); + ctx.current_team_uuid = None; + ctx.current_workspace_uuid = None; + + let business_path = extract_resource_path(resource_path); + ctx.resource_identifier = Some(format!("{}+{}", resource_method, business_path)); + } + + let user_info = crate::models::user::fetch_user_info_by_uuid(&state.db, user_uuid) + .await + .ok() + .flatten(); + + if let Some(ctx) = req.extensions_mut().get_mut::() { + if let Some(user_info) = user_info { + ctx.current_team_uuid = user_info.current_team_uuid; + ctx.current_workspace_uuid = if let Some(ws_uuid) = user_info.current_workspace_uuid { + Some(ws_uuid) + } else if let Some(team_uuid) = user_info.current_team_uuid { + crate::models::teams::fetch_team_by_uuid(&state.db, team_uuid) + .await + .ok() + .flatten() + .map(|team| team.workspace_uuid) + } else { + None + }; + } + } +} diff --git a/src-tauri/crates/business/src/middlewares/cors.rs b/src-tauri/crates/business/src/middlewares/cors.rs new file mode 100644 index 0000000..b409a28 --- /dev/null +++ b/src-tauri/crates/business/src/middlewares/cors.rs @@ -0,0 +1,28 @@ +use axum::http::{ + HeaderName, Method, + header::{self}, +}; +use tower_http::cors::CorsLayer; + +pub fn cors() -> CorsLayer { + CorsLayer::new() + .allow_headers([ + header::CONTENT_TYPE, + header::AUTHORIZATION, + HeaderName::from_static("x-custom-header"), + ]) + // allow `GET` and `POST` when accessing the resource + .allow_methods([ + Method::GET, + Method::POST, + Method::PUT, + Method::DELETE, + Method::OPTIONS, + ]) + // allow requests from any origin + .allow_origin([ + "http://localhost:1420".parse().unwrap(), + "http://localhost:5173".parse().unwrap(), + "http://tauri.localhost".parse().unwrap(), + ]) +} diff --git a/src-tauri/crates/business/src/middlewares/decrypt.rs b/src-tauri/crates/business/src/middlewares/decrypt.rs new file mode 100644 index 0000000..6a21121 --- /dev/null +++ b/src-tauri/crates/business/src/middlewares/decrypt.rs @@ -0,0 +1,152 @@ +use axum::{ + body::{self, Body}, + extract::{Request, State}, + http::{HeaderMap, StatusCode, header}, + middleware::Next, + response::{IntoResponse, Response}, +}; +use serde::Deserialize; + +use crate::{ + svc_ctx::SvcCtx, + utils::{self, AesSecret, get_rsa_secret_instance}, +}; + +/// 被加密的请求的结构体 +#[derive(Debug, Deserialize)] +struct EncryptedRequest { + /// 是否加密 + encrypted: bool, + /// 加密的内容 + #[serde(default)] + data: String, + /// 使用服务器公钥加密后的AES密钥 + #[serde(default)] + key: String, +} + +/// 解密中间件 +pub async fn decrypt( + State(state): State, + req: Request, + next: Next, +) -> Result { + // 获取请求路径和请求方法 + { + // if state + // .config + // .client_gateway_service_config + // .resources + // .whitelists + // .contains(&combine) + // { + // return Ok(next.run(req).await); + // } + } + + // 检查内容是否是json + if !is_json_content_type(req.headers()) { + return Ok(next.run(req).await); + } + + // 读取请求体 + let (parts, body) = req.into_parts(); + let body_bytes = match body::to_bytes(body, 1024 * 1024 * 10).await { + // 限制请求体大小为10MB + Ok(bytes) => bytes, + Err(e) => { + tracing::error!("读取请求体失败: {}", e); + return Err(StatusCode::BAD_REQUEST); + } + }; + + match serde_json::from_slice::(&body_bytes) { + Ok(encrypted_request) => { + if !encrypted_request.encrypted { + // let req = Request::from_parts(parts, Body::from(body_bytes)); + // return Ok(next.run(req).await); + tracing::error!("请求未加密, 拒绝请求"); + return Err(StatusCode::BAD_REQUEST); + } + + // 获取服务器RSA实例以解密AES密钥 + let rsa_secret = get_rsa_secret_instance(); + + // 解密aes密钥(解密后是base64编码的字符串对应的字节数组) + // 解密失败后返回正常的json信息并设置状态码为400 + let aes_key = if let Ok(aes_key) = rsa_secret.decrypt(&encrypted_request.key) { + aes_key + } else { + let mut response = utils::Response::<()>::fail(Some("LASDE")).into_response(); + *response.status_mut() = StatusCode::UNPROCESSABLE_ENTITY; + return Ok(response); + }; + + let aes_key = std::str::from_utf8(&aes_key).map_err(|_| { + tracing::error!("AES密钥转换为字符串失败"); + StatusCode::BAD_REQUEST + })?; + + // 使用解密后的AES密钥创建AES实例 + let aes_secret = AesSecret::try_from(aes_key).map_err(|_| { + tracing::error!("创建AES实例失败"); + StatusCode::BAD_REQUEST + })?; + + // 使用aes密钥解密请求体 + let decrypted_data = aes_secret.decrypt(&encrypted_request.data).map_err(|e| { + tracing::error!("解密请求体失败: {:?}", e); + StatusCode::BAD_REQUEST + })?; + + // 将解密后的请求体转换为JSON + let mut decrypted_json_value: serde_json::Value = + serde_json::from_slice(&decrypted_data).map_err(|_| { + tracing::error!("解析解密数据为JSON失败"); + StatusCode::BAD_REQUEST + })?; + + // 获取api_secret + if let Some(api_secret) = decrypted_json_value.get("api_secret") { + // 检查api_secret是否与配置中的一致 + let app_secret = &state.config.app.secret; + if api_secret != &serde_json::Value::String(app_secret.clone()) { + tracing::error!("API密钥不匹配"); + return Err(StatusCode::UNAUTHORIZED); + } + + // 移除api_secret字段 + decrypted_json_value.as_object_mut().and_then(|obj| obj.remove("api_secret")); + } else { + tracing::error!("请求中缺少api_secret字段"); + return Err(StatusCode::BAD_REQUEST); + } + + // 重建请求体 + let json_bytes = serde_json::to_vec(&decrypted_json_value).map_err(|_| { + tracing::error!("序列化解密数据为JSON失败"); + StatusCode::BAD_REQUEST + })?; + + // 将解密后的请求体放入请求的扩展中 + let req = Request::from_parts(parts, Body::from(json_bytes)); + Ok(next.run(req).await) + } + Err(e) => { + // 如果解析失败,可能不是加密请求,直接通过 + // let req = Request::from_parts(parts, Body::from(body_bytes)); + // Ok(next.run(req).await) + tracing::error!("解析加密请求失败: {:?}", e); + return Err(StatusCode::BAD_REQUEST); + } + } +} + +/// 检查请求头是否为JSON内容类型 +fn is_json_content_type(headers: &HeaderMap) -> bool { + headers + .get(header::CONTENT_TYPE) + .and_then(|value| value.to_str().ok()) + .map(|content_type| content_type.starts_with("application/json")) + .unwrap_or(false) +} diff --git a/src-tauri/crates/business/src/middlewares/encrypt.rs b/src-tauri/crates/business/src/middlewares/encrypt.rs new file mode 100644 index 0000000..aec3286 --- /dev/null +++ b/src-tauri/crates/business/src/middlewares/encrypt.rs @@ -0,0 +1,126 @@ +// use arcadia_codegen::user_services::GetUserPublicKeyRequest; +// use arcadia_utils::secret::{aes::AesSecret, rsa}; + +use axum::{ + body, + extract::{Request, State}, + http::{StatusCode, header}, + middleware::Next, + response::{IntoResponse, Response}, +}; +use bytes::Bytes; +use serde_json::json; + +use crate::{ + services::get_user_public_key_service, + state::CurrentUser, + svc_ctx::SvcCtx, + utils::{AesSecret, get_rsa_secret_instance}, +}; + +/// 加密中间件 +pub async fn encrypt( + State(state): State, + req: Request, + next: Next, +) -> Result { + // 从请求中获取 CurrentUser(如果存在) + let current_user = req.extensions().get::().cloned(); + + // 获取请求路径和请求方法 + // { + // let (resource_method, resource_path) = get_request_path_and_method(&req); + // let combine = format!("{}+{}", resource_method, resource_path); + // if state.config.resources.whitelists.contains(&combine) { + // return Ok(next.run(req).await); + // } + // } + + let response = next.run(req).await; + + if let Some(current_user) = current_user { + match get_user_public_key_service(&state, ¤t_user.user_uuid).await { + Ok(public_key) => { + // 创建AES密钥实例 + let aes_secret = AesSecret::new(); + + // 读取响应体 + let (parts, body) = response.into_parts(); + + // 将整个响应体转换为字节 + let body_bytes = match body::to_bytes(body, usize::MAX).await { + Ok(bytes) => bytes, + Err(err) => { + // 无法读取响应体,返回错误 + tracing::error!("Failed to read response body: {}", err); + return Err(StatusCode::INTERNAL_SERVER_ERROR); + } + }; + + // 解析原始JSON响应 + if let Ok(json_value) = serde_json::from_slice::(&body_bytes) { + // 加密JSON内容 + if let Ok(encrypted_content) = + aes_secret.encrypt(json_value.to_string().as_bytes()) + { + // 获取AES密钥的Base64表示 + let aes_key_base64 = aes_secret.get_key_as_base64(); + + // 使用用户的公钥加密AES密钥 + if let Ok(encrypted_key) = get_rsa_secret_instance() + .encrypt_with_public_key(aes_key_base64.as_bytes(), &public_key) + { + // 构建加密响应 + let secure_response = json!({ + "data": encrypted_content, + "encrypted": true, + "key": encrypted_key + }); + + // 创建新的响应 + let mut new_response = axum::Json(secure_response).into_response(); + + // 复制原始响应的状态码 + *new_response.status_mut() = parts.status; + + // 添加内容类型头 + new_response.headers_mut().insert( + header::CONTENT_TYPE, + header::HeaderValue::from_static("application/json"), + ); + + return Ok(new_response); + } + } + } + + // 如果加密失败,回退到原始响应 + let fallback = fallback_to_unencrypted_response(body_bytes, parts.status); + return Ok(fallback); + } + Err(_) => { + return Ok(response); + } + } + } + + Ok(response) +} + +// 当加密失败时回退到未加密响应 +fn fallback_to_unencrypted_response(body: Bytes, status: StatusCode) -> Response { + // 尝试解析原始响应为JSON + let json_value = serde_json::from_slice::(&body) + .unwrap_or_else(|_| json!({"raw": String::from_utf8_lossy(&body).to_string()})); + + // 添加未加密标志 + let unencrypted_response = json!({ + "encrypted": false, + "data": json_value + }); + + let mut response = axum::Json(unencrypted_response).into_response(); + *response.status_mut() = status; + + response +} diff --git a/src-tauri/crates/business/src/middlewares/local_api_auth.rs b/src-tauri/crates/business/src/middlewares/local_api_auth.rs new file mode 100644 index 0000000..833b14e --- /dev/null +++ b/src-tauri/crates/business/src/middlewares/local_api_auth.rs @@ -0,0 +1,219 @@ +use axum::{ + extract::{Request, State}, + http::StatusCode, + middleware::Next, + response::Response, +}; +use chrono::Utc; + +use crate::{ + caches::{ + LocalApiKeyCache, LocalApiPermissionCache, get_local_api_key_cache, + get_local_api_permission_cache, get_local_api_permission_definition_cache, + get_local_api_rate_count, increment_local_api_rate_count, set_local_api_key_cache, + set_local_api_permission_cache, set_local_api_permission_definition_cache, + }, + middlewares::{fill_authenticated_request_context, get_request_path_and_method}, + models, + svc_ctx::SvcCtx, +}; + +const LOCAL_API_AUTH_HEADER: &str = "x-local-api-auth"; +const LOCAL_API_KEY_HEADER: &str = "x-local-api-key"; +const LOCAL_API_PERMISSION_HEADER: &str = "x-local-api-permission"; + +pub fn has_local_api_auth_headers(req: &Request) -> bool { + req.headers().contains_key(LOCAL_API_AUTH_HEADER) + || (req.headers().contains_key(LOCAL_API_KEY_HEADER) + && req.headers().contains_key(LOCAL_API_PERMISSION_HEADER)) +} + +fn is_local_api_management_path(path: &str) -> bool { + let business_path = crate::middlewares::extract_resource_path(path); + business_path.starts_with("/local-api") +} + +fn parse_local_api_auth(req: &Request) -> Option<(String, String)> { + if let Some(combined) = req + .headers() + .get(LOCAL_API_AUTH_HEADER) + .and_then(|value| value.to_str().ok()) + { + let (api_key, permission_code) = combined.split_once(':')?; + let api_key = api_key.trim(); + let permission_code = permission_code.trim(); + + if !api_key.is_empty() && !permission_code.is_empty() { + return Some((api_key.to_string(), permission_code.to_string())); + } + } + + let api_key = req + .headers() + .get(LOCAL_API_KEY_HEADER) + .and_then(|value| value.to_str().ok()) + .map(str::trim) + .filter(|value| !value.is_empty())?; + let permission_code = req + .headers() + .get(LOCAL_API_PERMISSION_HEADER) + .and_then(|value| value.to_str().ok()) + .map(str::trim) + .filter(|value| !value.is_empty())?; + + Some((api_key.to_string(), permission_code.to_string())) +} + +pub async fn local_api_auth( + State(state): State, + mut req: Request, + next: Next, +) -> Result { + let (resource_method, resource_path) = get_request_path_and_method(&req); + let resource_method = resource_method.to_string(); + let resource_path = resource_path.to_string(); + + if resource_method == "OPTIONS" || is_local_api_management_path(&resource_path) { + return Ok(next.run(req).await); + } + + let Some((api_key, permission_code)) = parse_local_api_auth(&req) else { + return Ok(next.run(req).await); + }; + + let user_uuid = authorize_local_api_request(&state, api_key.as_str(), permission_code.as_str()) + .await?; + + fill_authenticated_request_context( + &state, + &mut req, + user_uuid, + &resource_method, + &resource_path, + ) + .await; + + Ok(next.run(req).await) +} + +async fn authorize_local_api_request( + state: &SvcCtx, + api_key: &str, + permission_code: &str, +) -> Result { + let key_hash = models::local_api::hash_api_key(api_key); + let api_key = if let Some(cache) = get_local_api_key_cache(state, &key_hash) + .await + .map_err(|_| StatusCode::INTERNAL_SERVER_ERROR)? + { + cache + } else { + let db_api_key = models::local_api::fetch_api_key_by_hash(&state.db, &key_hash) + .await + .map_err(|_| StatusCode::INTERNAL_SERVER_ERROR)? + .ok_or(StatusCode::UNAUTHORIZED)?; + + let cache = LocalApiKeyCache { + id: db_api_key.id, + user_uuid: db_api_key.user_uuid, + is_active: db_api_key.is_active, + expires_at: db_api_key.expires_at, + daily_limit: db_api_key.daily_limit, + }; + set_local_api_key_cache(state, &key_hash, &cache) + .await + .map_err(|_| StatusCode::INTERNAL_SERVER_ERROR)?; + cache + }; + + if !api_key.is_active { + return Err(StatusCode::UNAUTHORIZED); + } + + if let Some(expires_at) = api_key.expires_at { + if expires_at < Utc::now() { + return Err(StatusCode::UNAUTHORIZED); + } + } + + if get_local_api_permission_definition_cache(state, permission_code) + .await + .map_err(|_| StatusCode::INTERNAL_SERVER_ERROR)? + .is_none() + { + let definition = models::local_api::fetch_permission_definition(&state.db, permission_code) + .await + .map_err(|_| StatusCode::INTERNAL_SERVER_ERROR)? + .ok_or(StatusCode::FORBIDDEN)?; + set_local_api_permission_definition_cache(state, permission_code, &definition) + .await + .map_err(|_| StatusCode::INTERNAL_SERVER_ERROR)?; + } + + let permission = if let Some(cache) = + get_local_api_permission_cache(state, api_key.id, permission_code) + .await + .map_err(|_| StatusCode::INTERNAL_SERVER_ERROR)? + { + cache + } else { + let db_permission = + models::local_api::fetch_api_key_permission(&state.db, api_key.id, permission_code) + .await + .map_err(|_| StatusCode::INTERNAL_SERVER_ERROR)? + .ok_or(StatusCode::FORBIDDEN)?; + let cache = LocalApiPermissionCache { + is_enabled: db_permission.is_enabled, + rate_limit_per_minute: db_permission.rate_limit_per_minute, + rate_limit_per_hour: db_permission.rate_limit_per_hour, + }; + set_local_api_permission_cache(state, api_key.id, permission_code, &cache) + .await + .map_err(|_| StatusCode::INTERNAL_SERVER_ERROR)?; + cache + }; + + if !permission.is_enabled { + return Err(StatusCode::FORBIDDEN); + } + + let now = Utc::now(); + let day_key = now.format("%Y%m%d").to_string(); + let hour_key = now.format("%Y%m%d%H").to_string(); + let minute_key = now.format("%Y%m%d%H%M").to_string(); + + let day_count = get_local_api_rate_count(state, api_key.id, None, "day", &day_key) + .await + .map_err(|_| StatusCode::INTERNAL_SERVER_ERROR)?; + if day_count >= i64::from(api_key.daily_limit) { + return Err(StatusCode::TOO_MANY_REQUESTS); + } + + let minute_count = + get_local_api_rate_count(state, api_key.id, Some(permission_code), "minute", &minute_key) + .await + .map_err(|_| StatusCode::INTERNAL_SERVER_ERROR)?; + if minute_count >= i64::from(permission.rate_limit_per_minute) { + return Err(StatusCode::TOO_MANY_REQUESTS); + } + + let hour_count = + get_local_api_rate_count(state, api_key.id, Some(permission_code), "hour", &hour_key) + .await + .map_err(|_| StatusCode::INTERNAL_SERVER_ERROR)?; + if hour_count >= i64::from(permission.rate_limit_per_hour) { + return Err(StatusCode::TOO_MANY_REQUESTS); + } + + increment_local_api_rate_count(state, api_key.id, Some(permission_code), "minute", &minute_key) + .await + .map_err(|_| StatusCode::INTERNAL_SERVER_ERROR)?; + increment_local_api_rate_count(state, api_key.id, Some(permission_code), "hour", &hour_key) + .await + .map_err(|_| StatusCode::INTERNAL_SERVER_ERROR)?; + increment_local_api_rate_count(state, api_key.id, None, "day", &day_key) + .await + .map_err(|_| StatusCode::INTERNAL_SERVER_ERROR)?; + + Ok(api_key.user_uuid) +} diff --git a/src-tauri/crates/business/src/middlewares/logger.rs b/src-tauri/crates/business/src/middlewares/logger.rs new file mode 100644 index 0000000..045f09c --- /dev/null +++ b/src-tauri/crates/business/src/middlewares/logger.rs @@ -0,0 +1,67 @@ +use axum::{extract::Request, http::StatusCode, middleware::Next, response::Response}; +use once_cell::sync::Lazy; +use tokio::{sync::mpsc, time::Instant}; + +#[derive(Debug)] +struct LogEntry { + method: String, + path: String, + status: u16, + latency: u64, +} + +/// 日志通道, 该通道用于发送日志信息 +static LOG_CHANNEL: Lazy> = Lazy::new(|| { + let (tx, rx) = mpsc::channel::(100_000); + spawn_log_processor(rx); + tx +}); + +fn spawn_log_processor(mut rx: mpsc::Receiver) { + // 使用线程而非tokio spawn + // tokio线程用于处理短暂的非阻塞的任务, 如果使用tokio spawn, 会导致整个异步运行时的性能 + std::thread::Builder::new() + .name("log-processor".to_string()) + .spawn(move || { + loop { + match rx.blocking_recv() { + Some(LogEntry { + method, + path, + status, + latency, + }) => { + tracing::info!( + "Request completed: {} {} status={} latency={:?}ms", + method, + path, + status, + latency + ); + } + _ => continue, + } + } + }) + .expect("Failed to spawn log processor thread"); +} + +pub async fn logger(req: Request, next: Next) -> Result { + let start = Instant::now(); + let method = req.method().to_string(); + let path = req.uri().path().to_string(); + + // 处理请求 + let response = next.run(req).await; + + let status = response.status().as_u16(); + + let latency = start.elapsed().as_millis() as u64; + let _ = LOG_CHANNEL.try_send(LogEntry { + method, + path, + status, + latency, + }); + Ok(response) +} diff --git a/src-tauri/crates/business/src/middlewares/real_ip.rs b/src-tauri/crates/business/src/middlewares/real_ip.rs new file mode 100644 index 0000000..5cae48a --- /dev/null +++ b/src-tauri/crates/business/src/middlewares/real_ip.rs @@ -0,0 +1,47 @@ +use std::net::SocketAddr; + +use axum::{ + extract::{ConnectInfo, Request}, + http::StatusCode, + middleware::Next, + response::Response, +}; + +use crate::state::{CurrentIpAddr, RequestContext}; + +pub async fn real_ip(mut req: Request, next: Next) -> Result { + // 验证来源 + let client_ip_opt = req + .extensions() + .get::>() + .map(|ConnectInfo(addr)| addr.ip()); + + // 检查ip地址是否是通过nginx转发(通过nginx就是127.0.0.1)和是否是本地地址 + if let Some(client_ip) = client_ip_opt { + let client_ip_str = client_ip.to_string(); + if &client_ip_str != "127.0.0.1" && !client_ip.is_loopback() { + return Err(StatusCode::FORBIDDEN); + } + } + + // 提取客户端的真实IP + let real_ip = req + .headers() + .get("x-forwarded-for") + .and_then(|v| v.to_str().ok()) + .and_then(|s| s.split(',').next()) + .map(str::trim) + .map(str::to_string) + .or_else(|| { + req.headers().get("x-real-ip").and_then(|v| v.to_str().ok()).map(str::to_string) + }); + + // 初始化 RequestContext 并设置 IP + let mut ctx = RequestContext::default(); + if let Some(ip) = real_ip { + ctx.current_ip_addr = Some(CurrentIpAddr { real_ip: ip }); + } + req.extensions_mut().insert(ctx); + + Ok(next.run(req).await) +} diff --git a/src-tauri/crates/business/src/models/accounts.rs b/src-tauri/crates/business/src/models/accounts.rs new file mode 100644 index 0000000..f75a8cd --- /dev/null +++ b/src-tauri/crates/business/src/models/accounts.rs @@ -0,0 +1,323 @@ +use sqlx::Error; +use uuid::Uuid; + +use crate::database::{Db as Postgres, Pool}; + +use crate::dto::PlatformAccountDto; + +/// 创建平台账号 +pub async fn insert_platform_account( + pool: &Pool, + user_uuid: Uuid, + team_uuid: Option, + platform_url: &str, + platform_name: Option<&str>, + account: &str, + password: Option<&str>, + remark: Option<&str>, +) -> Result { + let uuid: Uuid = sqlx::query_scalar( + r#" + INSERT INTO platform_accounts (user_uuid, team_uuid, platform_url, platform_name, + account, password, remark) + VALUES ($1, $2, $3, $4, $5, $6, $7) + RETURNING uuid; + "#, + ) + .bind(user_uuid) + .bind(team_uuid) + .bind(platform_url) + .bind(platform_name) + .bind(account) + .bind(password) + .bind(remark) + .fetch_one(pool) + .await?; + + Ok(uuid) +} + +/// 查询平台账号列表 +pub async fn fetch_platform_accounts( + pool: &Pool, + team_uuid: Option, + user_uuid: Uuid, + keyword: Option<&str>, + platform_name: Option<&str>, + status: Option<&str>, + offset: i64, + limit: i64, +) -> Result, Error> { + let keyword = keyword.map(|value| format!("%{}%", value.trim())); + + let recs = sqlx::query_as::<_, PlatformAccountDto>( + r#" + SELECT pa.id, pa.uuid, pa.user_uuid, pa.team_uuid, pa.platform_url, pa.platform_name, + pa.account, pa.password, pa.status, pa.remark, pa.usage_count, + (SELECT COUNT(*) FROM environment_accounts ea WHERE ea.account_uuid = pa.uuid) AS environments_count, + pa.last_used_at, pa.created_at, pa.updated_at, pa.deleted_at + FROM platform_accounts pa + WHERE (pa.team_uuid = $1 OR (pa.team_uuid IS NULL AND pa.user_uuid = $2)) + AND ( + $3::text IS NULL + OR pa.platform_url ILIKE $3 + OR COALESCE(pa.platform_name, '') ILIKE $3 + OR pa.account ILIKE $3 + OR COALESCE(pa.remark, '') ILIKE $3 + ) + AND ($4::varchar IS NULL OR pa.platform_name = $4) + AND ($5::varchar IS NULL OR pa.status = $5) + AND pa.deleted_at IS NULL + ORDER BY pa.created_at DESC + LIMIT $6 OFFSET $7 + "#, + ) + .bind(team_uuid) + .bind(user_uuid) + .bind(keyword) + .bind(platform_name) + .bind(status) + .bind(limit) + .bind(offset) + .fetch_all(pool) + .await?; + + Ok(recs) +} + +/// 查询平台账号总数 +pub async fn fetch_platform_accounts_count( + pool: &Pool, + team_uuid: Option, + user_uuid: Uuid, + keyword: Option<&str>, + platform_name: Option<&str>, + status: Option<&str>, +) -> Result { + let keyword = keyword.map(|value| format!("%{}%", value.trim())); + + let count: i64 = sqlx::query_scalar( + r#" + SELECT COUNT(*) FROM platform_accounts + WHERE (team_uuid = $1 OR (team_uuid IS NULL AND user_uuid = $2)) + AND ( + $3::text IS NULL + OR platform_url ILIKE $3 + OR COALESCE(platform_name, '') ILIKE $3 + OR account ILIKE $3 + OR COALESCE(remark, '') ILIKE $3 + ) + AND ($4::varchar IS NULL OR platform_name = $4) + AND ($5::varchar IS NULL OR status = $5) + AND deleted_at IS NULL + "#, + ) + .bind(team_uuid) + .bind(user_uuid) + .bind(keyword) + .bind(platform_name) + .bind(status) + .fetch_one(pool) + .await?; + + Ok(count) +} + +/// 根据 UUID 查询平台账号 +pub async fn fetch_platform_account_by_uuid( + pool: &Pool, + account_uuid: Uuid, +) -> Result, Error> { + let rec = sqlx::query_as::<_, PlatformAccountDto>( + r#" + SELECT pa.id, pa.uuid, pa.user_uuid, pa.team_uuid, pa.platform_url, pa.platform_name, + pa.account, pa.password, pa.status, pa.remark, pa.usage_count, + (SELECT COUNT(*) FROM environment_accounts ea WHERE ea.account_uuid = pa.uuid) AS environments_count, + pa.last_used_at, pa.created_at, pa.updated_at, pa.deleted_at + FROM platform_accounts pa + WHERE pa.uuid = $1 AND pa.deleted_at IS NULL + "#, + ) + .bind(account_uuid) + .fetch_optional(pool) + .await?; + + Ok(rec) +} + +/// 更新平台账号 +pub async fn update_platform_account( + pool: &Pool, + account_uuid: Uuid, + platform_url: Option<&str>, + platform_name: Option<&str>, + account: Option<&str>, + password: Option<&str>, + remark: Option<&str>, + status: Option<&str>, +) -> Result<(), Error> { + sqlx::query( + r#" + UPDATE platform_accounts + SET platform_url = COALESCE($1, platform_url), + platform_name = COALESCE($2, platform_name), + account = COALESCE($3, account), + password = COALESCE($4, password), + remark = COALESCE($5, remark), + status = COALESCE($6, status) + WHERE uuid = $7 AND deleted_at IS NULL + "#, + ) + .bind(platform_url) + .bind(platform_name) + .bind(account) + .bind(password) + .bind(remark) + .bind(status) + .bind(account_uuid) + .execute(pool) + .await?; + + Ok(()) +} + +/// 增加账号使用次数 +pub async fn increment_account_usage( + pool: &Pool, + account_uuid: Uuid, +) -> Result<(), Error> { + sqlx::query( + r#" + UPDATE platform_accounts + SET usage_count = usage_count + 1, last_used_at = CURRENT_TIMESTAMP + WHERE uuid = $1 AND deleted_at IS NULL + "#, + ) + .bind(account_uuid) + .execute(pool) + .await?; + + Ok(()) +} + +/// 软删除平台账号 +pub async fn delete_platform_account( + pool: &Pool, + account_uuid: Uuid, +) -> Result<(), Error> { + sqlx::query( + r#" + UPDATE platform_accounts SET deleted_at = CURRENT_TIMESTAMP + WHERE uuid = $1 AND deleted_at IS NULL + "#, + ) + .bind(account_uuid) + .execute(pool) + .await?; + + Ok(()) +} + +/// 批量软删除平台账号 +pub async fn batch_delete_platform_accounts( + pool: &Pool, + account_uuids: &[Uuid], +) -> Result { + let result = sqlx::query( + r#" + UPDATE platform_accounts SET deleted_at = CURRENT_TIMESTAMP + WHERE uuid = ANY($1) AND deleted_at IS NULL + "#, + ) + .bind(account_uuids) + .execute(pool) + .await?; + + Ok(result.rows_affected()) +} + +// ============ Environment Accounts ============ + +/// 关联环境和账号 +pub async fn insert_environment_account( + pool: &Pool, + env_uuid: Uuid, + account_uuid: Uuid, + sort_order: i32, +) -> Result<(), Error> { + sqlx::query( + r#" + INSERT INTO environment_accounts (environment_uuid, account_uuid, sort_order) + VALUES ($1, $2, $3) + ON CONFLICT (environment_uuid, account_uuid) DO UPDATE SET sort_order = $3; + "#, + ) + .bind(env_uuid) + .bind(account_uuid) + .bind(sort_order) + .execute(pool) + .await?; + + Ok(()) +} + +/// 移除环境和账号的关联 +pub async fn remove_environment_account( + pool: &Pool, + env_uuid: Uuid, + account_uuid: Uuid, +) -> Result<(), Error> { + sqlx::query( + r#" + DELETE FROM environment_accounts + WHERE environment_uuid = $1 AND account_uuid = $2; + "#, + ) + .bind(env_uuid) + .bind(account_uuid) + .execute(pool) + .await?; + + Ok(()) +} + +/// 清空环境的所有账号关联 +pub async fn clear_environment_accounts( + pool: &Pool, + env_uuid: Uuid, +) -> Result<(), Error> { + sqlx::query( + r#" + DELETE FROM environment_accounts WHERE environment_uuid = $1; + "#, + ) + .bind(env_uuid) + .execute(pool) + .await?; + + Ok(()) +} + +/// 查询环境关联的所有账号 +pub async fn fetch_environment_accounts( + pool: &Pool, + env_uuid: Uuid, +) -> Result, Error> { + let recs = sqlx::query_as::<_, PlatformAccountDto>( + r#" + SELECT pa.id, pa.uuid, pa.user_uuid, pa.team_uuid, pa.platform_url, pa.platform_name, + pa.account, pa.password, pa.status, pa.remark, pa.usage_count, + (SELECT COUNT(*) FROM environment_accounts ea2 WHERE ea2.account_uuid = pa.uuid) AS environments_count, + pa.last_used_at, pa.created_at, pa.updated_at, pa.deleted_at + FROM platform_accounts pa + INNER JOIN environment_accounts ea ON pa.uuid = ea.account_uuid + WHERE ea.environment_uuid = $1 AND pa.deleted_at IS NULL + ORDER BY ea.sort_order + "#, + ) + .bind(env_uuid) + .fetch_all(pool) + .await?; + + Ok(recs) +} diff --git a/src-tauri/crates/business/src/models/audit.rs b/src-tauri/crates/business/src/models/audit.rs new file mode 100644 index 0000000..e88280e --- /dev/null +++ b/src-tauri/crates/business/src/models/audit.rs @@ -0,0 +1,242 @@ +use sqlx::Error; +use uuid::Uuid; + +use crate::database::{Db as Postgres, Pool}; + +use crate::dto::AuditLogDto; + +/// 记录审计日志 +pub async fn insert_audit_log( + pool: &Pool, + user_uuid: Uuid, + team_uuid: Option, + action: &str, + target_type: &str, + target_uuid: Option, + target_name: Option<&str>, + details: Option<&str>, + changes: Option<&serde_json::Value>, + ip_address: Option<&str>, + user_agent: Option<&str>, + request_id: Option<&str>, +) -> Result { + let id: i64 = sqlx::query_scalar( + r#" + INSERT INTO audit_logs (user_uuid, team_uuid, action, target_type, target_uuid, + target_name, details, changes, ip_address, user_agent, request_id) + VALUES ($1, $2, $3, $4, $5, $6, $7, $8, $9, $10, $11) + RETURNING id; + "#, + ) + .bind(user_uuid) + .bind(team_uuid) + .bind(action) + .bind(target_type) + .bind(target_uuid) + .bind(target_name) + .bind(details) + .bind(changes) + .bind(ip_address) + .bind(user_agent) + .bind(request_id) + .fetch_one(pool) + .await?; + + Ok(id) +} + +/// 查询审计日志列表 +/// +/// 查询逻辑: +/// - 当前团队的所有审计日志 +/// - 加上当前用户的个人操作日志(team_uuid 为空的,如登录、注册等) +pub async fn fetch_audit_logs( + pool: &Pool, + current_user_uuid: Uuid, + team_uuid: Option, + user_uuid_filter: Option, + keyword: Option<&str>, + action: Option<&str>, + target_type: Option<&str>, + offset: i64, + limit: i64, +) -> Result, Error> { + let recs = sqlx::query_as::<_, AuditLogDto>( + r#" + SELECT + a.id, a.uuid, a.user_uuid, a.team_uuid, a.action, a.target_type, a.target_uuid, + a.target_name, a.details, a.changes, a.ip_address, a.user_agent, a.request_id, a.created_at, + ui.nickname AS user_name, ui.email AS user_email + FROM audit_logs a + LEFT JOIN user_infos ui ON a.user_uuid = ui.user_uuid + WHERE ( + ($2::uuid IS NULL OR a.team_uuid = $2) + OR (a.team_uuid IS NULL AND a.user_uuid = $1) + ) + AND ($3::uuid IS NULL OR a.user_uuid = $3) + AND ($4::text IS NULL OR COALESCE(a.details, '') ILIKE '%' || $4 || '%') + AND ($5::varchar IS NULL OR a.action = $5) + AND ($6::varchar IS NULL OR a.target_type = $6) + ORDER BY a.created_at DESC + LIMIT $7 OFFSET $8 + "#, + ) + .bind(current_user_uuid) + .bind(team_uuid) + .bind(user_uuid_filter) + .bind(keyword) + .bind(action) + .bind(target_type) + .bind(limit) + .bind(offset) + .fetch_all(pool) + .await?; + + Ok(recs) +} + +/// 查询审计日志总数 +pub async fn fetch_audit_logs_count( + pool: &Pool, + current_user_uuid: Uuid, + team_uuid: Option, + user_uuid_filter: Option, + keyword: Option<&str>, + action: Option<&str>, + target_type: Option<&str>, +) -> Result { + let count: i64 = sqlx::query_scalar( + r#" + SELECT COUNT(*) FROM audit_logs + WHERE ( + ($2::uuid IS NULL OR team_uuid = $2) + OR (team_uuid IS NULL AND user_uuid = $1) + ) + AND ($3::uuid IS NULL OR user_uuid = $3) + AND ($4::text IS NULL OR COALESCE(details, '') ILIKE '%' || $4 || '%') + AND ($5::varchar IS NULL OR action = $5) + AND ($6::varchar IS NULL OR target_type = $6) + "#, + ) + .bind(current_user_uuid) + .bind(team_uuid) + .bind(user_uuid_filter) + .bind(keyword) + .bind(action) + .bind(target_type) + .fetch_one(pool) + .await?; + + Ok(count) +} + +/// 根据 UUID 查询审计日志 +pub async fn fetch_audit_log_by_uuid( + pool: &Pool, + log_uuid: Uuid, +) -> Result, Error> { + let rec = sqlx::query_as::<_, AuditLogDto>( + r#" + SELECT + a.id, a.uuid, a.user_uuid, a.team_uuid, a.action, a.target_type, a.target_uuid, + a.target_name, a.details, a.changes, a.ip_address, a.user_agent, a.request_id, a.created_at, + ui.nickname AS user_name, ui.email AS user_email + FROM audit_logs a + LEFT JOIN user_infos ui ON a.user_uuid = ui.user_uuid + WHERE a.uuid = $1 + "#, + ) + .bind(log_uuid) + .fetch_optional(pool) + .await?; + + Ok(rec) +} + +/// 查询指定日期的审计日志数量 +pub async fn fetch_audit_logs_count_by_date( + pool: &Pool, + team_uuid: Option, + date: chrono::NaiveDate, +) -> Result { + let count: i64 = sqlx::query_scalar( + r#" + SELECT COUNT(*) FROM audit_logs + WHERE ($1::uuid IS NULL OR team_uuid = $1) + AND created_at::date = $2 + "#, + ) + .bind(team_uuid) + .bind(date) + .fetch_one(pool) + .await?; + + Ok(count) +} + +/// 查询指定日期之后的审计日志数量 +pub async fn fetch_audit_logs_count_since_date( + pool: &Pool, + team_uuid: Option, + since_date: chrono::NaiveDate, +) -> Result { + let count: i64 = sqlx::query_scalar( + r#" + SELECT COUNT(*) FROM audit_logs + WHERE ($1::uuid IS NULL OR team_uuid = $1) + AND created_at::date >= $2 + "#, + ) + .bind(team_uuid) + .bind(since_date) + .fetch_one(pool) + .await?; + + Ok(count) +} + +/// 查询热门操作类型 +pub async fn fetch_top_actions( + pool: &Pool, + team_uuid: Option, + limit: i64, +) -> Result, Error> { + let rows: Vec<(String, i64)> = sqlx::query_as( + r#" + SELECT action, COUNT(*) as count FROM audit_logs + WHERE ($1::uuid IS NULL OR team_uuid = $1) + GROUP BY action + ORDER BY count DESC + LIMIT $2 + "#, + ) + .bind(team_uuid) + .bind(limit) + .fetch_all(pool) + .await?; + + Ok(rows) +} + +/// 查询热门目标类型 +pub async fn fetch_top_target_types( + pool: &Pool, + team_uuid: Option, + limit: i64, +) -> Result, Error> { + let rows: Vec<(String, i64)> = sqlx::query_as( + r#" + SELECT target_type, COUNT(*) as count FROM audit_logs + WHERE ($1::uuid IS NULL OR team_uuid = $1) + GROUP BY target_type + ORDER BY count DESC + LIMIT $2 + "#, + ) + .bind(team_uuid) + .bind(limit) + .fetch_all(pool) + .await?; + + Ok(rows) +} diff --git a/src-tauri/crates/business/src/models/billing.rs b/src-tauri/crates/business/src/models/billing.rs new file mode 100644 index 0000000..3b271a7 --- /dev/null +++ b/src-tauri/crates/business/src/models/billing.rs @@ -0,0 +1,1073 @@ +use chrono::NaiveDate; +use rust_decimal::Decimal; +use sqlx::{Error, Row}; +use uuid::Uuid; + +use crate::database::{Db as Postgres, Pool}; + +use crate::dto::{ + AutoRenewalServiceDto, CouponDto, InvoiceDto, PaymentOrderDto, PlanDto, PlanFeatureDto, + SubscriptionDto, UserQuotaDto, UserWalletDto, WalletTransactionDto, +}; + +// ============ Plans ============ + +/// 查询所有可用套餐 +pub async fn fetch_plans(pool: &Pool) -> Result, Error> { + let recs = sqlx::query_as::<_, PlanDto>( + r#" + SELECT id, uuid, name, description, price_per_month, price_per_year, + currency, discount_monthly, discount_yearly, max_environments, + max_team_members, max_proxies, max_rpa_tasks, is_recommended, + sort_order, status, created_at, updated_at + FROM plans + WHERE status = 'active' + ORDER BY sort_order ASC, price_per_month ASC + "#, + ) + .fetch_all(pool) + .await?; + + Ok(recs) +} + +/// 根据 UUID 查询套餐 +pub async fn fetch_plan_by_uuid( + pool: &Pool, + plan_uuid: Uuid, +) -> Result, Error> { + let rec = sqlx::query_as::<_, PlanDto>( + r#" + SELECT id, uuid, name, description, price_per_month, price_per_year, + currency, discount_monthly, discount_yearly, max_environments, + max_team_members, max_proxies, max_rpa_tasks, is_recommended, + sort_order, status, created_at, updated_at + FROM plans + WHERE uuid = $1 + "#, + ) + .bind(plan_uuid) + .fetch_optional(pool) + .await?; + + Ok(rec) +} + +/// 查询套餐特性 +pub async fn fetch_plan_features( + pool: &Pool, + plan_uuid: Uuid, +) -> Result, Error> { + let recs = sqlx::query_as::<_, PlanFeatureDto>( + r#" + SELECT id, plan_uuid, feature_key, feature_name, feature_value, + is_included, sort_order, created_at + FROM plan_features + WHERE plan_uuid = $1 + ORDER BY sort_order ASC + "#, + ) + .bind(plan_uuid) + .fetch_all(pool) + .await?; + + Ok(recs) +} + +// ============ Subscriptions ============ + +/// 查询用户当前订阅(按用户) +pub async fn fetch_active_subscription( + pool: &Pool, + user_uuid: Uuid, +) -> Result, Error> { + let rec = sqlx::query_as::<_, SubscriptionDto>( + r#" + SELECT id, uuid, workspace_uuid, user_uuid, plan_uuid, billing_period, price, currency, + started_at, expires_at, next_billing_date, auto_renew, status, + cancelled_at, created_at, updated_at + FROM subscriptions + WHERE user_uuid = $1 AND status IN ('active', 'paused') + ORDER BY created_at DESC + LIMIT 1 + "#, + ) + .bind(user_uuid) + .fetch_optional(pool) + .await?; + + Ok(rec) +} + +/// 查询工作空间当前订阅 +pub async fn fetch_workspace_active_subscription( + pool: &Pool, + workspace_uuid: Uuid, +) -> Result, Error> { + let rec = sqlx::query_as::<_, SubscriptionDto>( + r#" + SELECT id, uuid, workspace_uuid, user_uuid, plan_uuid, billing_period, price, currency, + started_at, expires_at, next_billing_date, auto_renew, status, + cancelled_at, created_at, updated_at + FROM subscriptions + WHERE workspace_uuid = $1 AND status IN ('active', 'paused') + ORDER BY created_at DESC + LIMIT 1 + "#, + ) + .bind(workspace_uuid) + .fetch_optional(pool) + .await?; + + Ok(rec) +} + +/// 根据 UUID 查询订阅 +pub async fn fetch_subscription_by_uuid( + pool: &Pool, + subscription_uuid: Uuid, +) -> Result, Error> { + let rec = sqlx::query_as::<_, SubscriptionDto>( + r#" + SELECT id, uuid, workspace_uuid, user_uuid, plan_uuid, billing_period, price, currency, + started_at, expires_at, next_billing_date, auto_renew, status, + cancelled_at, created_at, updated_at + FROM subscriptions + WHERE uuid = $1 + "#, + ) + .bind(subscription_uuid) + .fetch_optional(pool) + .await?; + + Ok(rec) +} + +/// 创建订阅 +pub async fn insert_subscription( + pool: &Pool, + workspace_uuid: Uuid, + user_uuid: Uuid, + plan_uuid: Uuid, + billing_period: &str, + price: Decimal, + currency: &str, + expires_at: chrono::DateTime, + next_billing_date: NaiveDate, +) -> Result { + let uuid: Uuid = sqlx::query_scalar( + r#" + INSERT INTO subscriptions (workspace_uuid, user_uuid, plan_uuid, billing_period, price, currency, + started_at, expires_at, next_billing_date, auto_renew, status) + VALUES ($1, $2, $3, $4, $5, $6, CURRENT_TIMESTAMP, $7, $8, true, 'active') + RETURNING uuid; + "#, + ) + .bind(workspace_uuid) + .bind(user_uuid) + .bind(plan_uuid) + .bind(billing_period) + .bind(price) + .bind(currency) + .bind(expires_at) + .bind(next_billing_date) + .fetch_one(pool) + .await?; + + Ok(uuid) +} + +/// 取消订阅 +pub async fn cancel_subscription( + pool: &Pool, + subscription_uuid: Uuid, +) -> Result<(), Error> { + sqlx::query( + r#" + UPDATE subscriptions + SET status = 'cancelled', cancelled_at = CURRENT_TIMESTAMP, updated_at = CURRENT_TIMESTAMP + WHERE uuid = $1 + "#, + ) + .bind(subscription_uuid) + .execute(pool) + .await?; + + Ok(()) +} + +/// 恢复订阅 +pub async fn resume_subscription( + pool: &Pool, + subscription_uuid: Uuid, +) -> Result<(), Error> { + sqlx::query( + r#" + UPDATE subscriptions + SET status = 'active', cancelled_at = NULL, updated_at = CURRENT_TIMESTAMP + WHERE uuid = $1 + "#, + ) + .bind(subscription_uuid) + .execute(pool) + .await?; + + Ok(()) +} + +/// 切换自动续费 +pub async fn toggle_auto_renew( + pool: &Pool, + subscription_uuid: Uuid, + auto_renew: bool, +) -> Result<(), Error> { + sqlx::query( + r#" + UPDATE subscriptions + SET auto_renew = $1, updated_at = CURRENT_TIMESTAMP + WHERE uuid = $2 + "#, + ) + .bind(auto_renew) + .bind(subscription_uuid) + .execute(pool) + .await?; + + Ok(()) +} + +// ============ User Wallet ============ + +/// 获取用户钱包 +pub async fn fetch_user_wallet( + pool: &Pool, + user_uuid: Uuid, +) -> Result, Error> { + let rec = sqlx::query_as::<_, UserWalletDto>( + r#" + SELECT id, user_uuid, balance, currency, frozen_amount, auto_renewal_combined, + created_at, updated_at + FROM user_wallets + WHERE user_uuid = $1 + "#, + ) + .bind(user_uuid) + .fetch_optional(pool) + .await?; + + Ok(rec) +} + +/// 创建用户钱包 +pub async fn insert_user_wallet( + pool: &Pool, + user_uuid: Uuid, + currency: &str, +) -> Result { + let id: i32 = sqlx::query_scalar( + r#" + INSERT INTO user_wallets (user_uuid, balance, currency, frozen_amount, auto_renewal_combined) + VALUES ($1, 0, $2, 0, 0) + ON CONFLICT (user_uuid) DO NOTHING + RETURNING id; + "#, + ) + .bind(user_uuid) + .bind(currency) + .fetch_one(pool) + .await?; + + Ok(id) +} + +/// 更新钱包余额 +pub async fn update_wallet_balance( + pool: &Pool, + user_uuid: Uuid, + amount: Decimal, +) -> Result<(), Error> { + sqlx::query( + r#" + UPDATE user_wallets + SET balance = balance + $1, updated_at = CURRENT_TIMESTAMP + WHERE user_uuid = $2 + "#, + ) + .bind(amount) + .bind(user_uuid) + .execute(pool) + .await?; + + Ok(()) +} + +// ============ Wallet Transactions ============ + +/// 查询钱包交易记录 +pub async fn fetch_wallet_transactions( + pool: &Pool, + user_uuid: Uuid, + transaction_type: Option<&str>, + offset: i64, + limit: i64, +) -> Result, Error> { + let recs = sqlx::query_as::<_, WalletTransactionDto>( + r#" + SELECT id, uuid, user_uuid, transaction_type, amount, currency, + balance_before, balance_after, description, order_uuid, status, created_at + FROM wallet_transactions + WHERE user_uuid = $1 + AND ($2::varchar IS NULL OR transaction_type = $2) + ORDER BY created_at DESC + LIMIT $3 OFFSET $4 + "#, + ) + .bind(user_uuid) + .bind(transaction_type) + .bind(limit) + .bind(offset) + .fetch_all(pool) + .await?; + + Ok(recs) +} + +/// 查询交易记录总数 +pub async fn fetch_wallet_transactions_count( + pool: &Pool, + user_uuid: Uuid, + transaction_type: Option<&str>, +) -> Result { + let count: i64 = sqlx::query_scalar( + r#" + SELECT COUNT(*) FROM wallet_transactions + WHERE user_uuid = $1 + AND ($2::varchar IS NULL OR transaction_type = $2) + "#, + ) + .bind(user_uuid) + .bind(transaction_type) + .fetch_one(pool) + .await?; + + Ok(count) +} + +/// 插入钱包交易记录 +pub async fn insert_wallet_transaction( + pool: &Pool, + user_uuid: Uuid, + transaction_type: &str, + amount: Decimal, + currency: &str, + balance_before: Decimal, + balance_after: Decimal, + description: Option<&str>, + order_uuid: Option, +) -> Result { + let uuid: Uuid = sqlx::query_scalar( + r#" + INSERT INTO wallet_transactions (user_uuid, transaction_type, amount, currency, + balance_before, balance_after, description, order_uuid, status) + VALUES ($1, $2, $3, $4, $5, $6, $7, $8, 'completed') + RETURNING uuid; + "#, + ) + .bind(user_uuid) + .bind(transaction_type) + .bind(amount) + .bind(currency) + .bind(balance_before) + .bind(balance_after) + .bind(description) + .bind(order_uuid) + .fetch_one(pool) + .await?; + + Ok(uuid) +} + +// ============ Invoices ============ + +/// 查询发票列表 +pub async fn fetch_invoices( + pool: &Pool, + user_uuid: Uuid, + status: Option<&str>, + offset: i64, + limit: i64, +) -> Result, Error> { + let recs = sqlx::query_as::<_, InvoiceDto>( + r#" + SELECT id, uuid, user_uuid, invoice_number, amount, currency, + subscription_uuid, order_uuid, invoice_type, status, + issued_at, due_at, paid_at, invoice_url, created_at, updated_at + FROM invoices + WHERE user_uuid = $1 + AND ($2::varchar IS NULL OR status = $2) + ORDER BY created_at DESC + LIMIT $3 OFFSET $4 + "#, + ) + .bind(user_uuid) + .bind(status) + .bind(limit) + .bind(offset) + .fetch_all(pool) + .await?; + + Ok(recs) +} + +/// 查询发票总数 +pub async fn fetch_invoices_count( + pool: &Pool, + user_uuid: Uuid, + status: Option<&str>, +) -> Result { + let count: i64 = sqlx::query_scalar( + r#" + SELECT COUNT(*) FROM invoices + WHERE user_uuid = $1 + AND ($2::varchar IS NULL OR status = $2) + "#, + ) + .bind(user_uuid) + .bind(status) + .fetch_one(pool) + .await?; + + Ok(count) +} + +/// 创建发票 +pub async fn insert_invoice( + pool: &Pool, + user_uuid: Uuid, + invoice_number: &str, + amount: Decimal, + currency: &str, + subscription_uuid: Option, + order_uuid: Option, + invoice_type: &str, +) -> Result { + let uuid: Uuid = sqlx::query_scalar( + r#" + INSERT INTO invoices (user_uuid, invoice_number, amount, currency, + subscription_uuid, order_uuid, invoice_type, status, issued_at) + VALUES ($1, $2, $3, $4, $5, $6, $7, 'pending', CURRENT_TIMESTAMP) + RETURNING uuid; + "#, + ) + .bind(user_uuid) + .bind(invoice_number) + .bind(amount) + .bind(currency) + .bind(subscription_uuid) + .bind(order_uuid) + .bind(invoice_type) + .fetch_one(pool) + .await?; + + Ok(uuid) +} + +// ============ User Quotas ============ + +/// 获取用户配额 +pub async fn fetch_user_quota( + pool: &Pool, + user_uuid: Uuid, +) -> Result, Error> { + let rec = sqlx::query_as::<_, UserQuotaDto>( + r#" + SELECT id, user_uuid, max_environments, used_environments, max_team_members, + max_proxies, used_proxies, max_rpa_tasks, used_rpa_tasks, + created_at, updated_at + FROM user_quotas + WHERE user_uuid = $1 + "#, + ) + .bind(user_uuid) + .fetch_optional(pool) + .await?; + + Ok(rec) +} + +/// 创建或更新用户配额 +pub async fn upsert_user_quota( + pool: &Pool, + user_uuid: Uuid, + max_environments: i32, + max_team_members: i32, + max_proxies: i32, + max_rpa_tasks: i32, +) -> Result<(), Error> { + sqlx::query( + r#" + INSERT INTO user_quotas (user_uuid, max_environments, max_team_members, max_proxies, max_rpa_tasks) + VALUES ($1, $2, $3, $4, $5) + ON CONFLICT (user_uuid) DO UPDATE SET + max_environments = $2, + max_team_members = $3, + max_proxies = $4, + max_rpa_tasks = $5, + updated_at = CURRENT_TIMESTAMP + "#, + ) + .bind(user_uuid) + .bind(max_environments) + .bind(max_team_members) + .bind(max_proxies) + .bind(max_rpa_tasks) + .execute(pool) + .await?; + + Ok(()) +} + +// ============ Coupons ============ + +/// 根据优惠码查询优惠券 +pub async fn fetch_coupon_by_code( + pool: &Pool, + code: &str, +) -> Result, Error> { + let rec = sqlx::query_as::<_, CouponDto>( + r#" + SELECT id, uuid, code, name, description, discount_type, discount_value, min_amount, max_discount, + max_uses, used_count, max_uses_per_user, valid_from, valid_until, + applicable_to, status, created_at, updated_at + FROM coupons + WHERE code = $1 AND status = 'active' + "#, + ) + .bind(code) + .fetch_optional(pool) + .await?; + + Ok(rec) +} + +/// 根据 UUID 查询优惠券 +pub async fn fetch_coupon_by_uuid( + pool: &Pool, + coupon_uuid: Uuid, +) -> Result, Error> { + let rec = sqlx::query_as::<_, CouponDto>( + r#" + SELECT id, uuid, code, name, description, discount_type, discount_value, min_amount, max_discount, + max_uses, used_count, max_uses_per_user, valid_from, valid_until, + applicable_to, status, created_at, updated_at + FROM coupons + WHERE uuid = $1 AND status = 'active' + "#, + ) + .bind(coupon_uuid) + .fetch_optional(pool) + .await?; + + Ok(rec) +} + +/// 查询用户优惠券使用次数 +pub async fn fetch_coupon_user_usage_count( + pool: &Pool, + coupon_uuid: Uuid, + user_uuid: Uuid, +) -> Result { + let count: i64 = sqlx::query_scalar( + r#" + SELECT COUNT(*) FROM coupon_usages + WHERE coupon_uuid = $1 AND user_uuid = $2 + "#, + ) + .bind(coupon_uuid) + .bind(user_uuid) + .fetch_one(pool) + .await?; + + Ok(count as i32) +} + +/// 记录优惠券使用 +pub async fn insert_coupon_usage( + pool: &Pool, + coupon_uuid: Uuid, + user_uuid: Uuid, + order_uuid: Option, + discount_amount: Decimal, +) -> Result { + let id: i32 = sqlx::query_scalar( + r#" + INSERT INTO coupon_usages (coupon_uuid, user_uuid, order_uuid, discount_amount) + VALUES ($1, $2, $3, $4) + RETURNING id; + "#, + ) + .bind(coupon_uuid) + .bind(user_uuid) + .bind(order_uuid) + .bind(discount_amount) + .fetch_one(pool) + .await?; + + // 更新优惠券使用次数 + sqlx::query("UPDATE coupons SET used_count = used_count + 1 WHERE uuid = $1") + .bind(coupon_uuid) + .execute(pool) + .await?; + + Ok(id) +} + +// ============ User Coupons ============ + +/// 查询用户优惠券列表 +pub async fn fetch_user_coupons( + pool: &Pool, + user_uuid: Uuid, + status: Option<&str>, + page: i64, + page_size: i64, +) -> Result<(Vec, i64), Error> { + let offset = (page - 1) * page_size; + + let (items, total) = if let Some(s) = status { + let rows = sqlx::query( + r#" + SELECT + uc.id, uc.user_uuid, uc.coupon_uuid, uc.status, + uc.issued_at, uc.used_at, uc.expires_at, + c.code, c.name, c.description, c.discount_type, c.discount_value, c.min_amount, c.max_discount + FROM user_coupons uc + INNER JOIN coupons c ON uc.coupon_uuid = c.uuid + WHERE uc.user_uuid = $1 AND uc.status = $2 + ORDER BY uc.issued_at DESC + LIMIT $3 OFFSET $4 + "#, + ) + .bind(user_uuid) + .bind(s) + .bind(page_size) + .bind(offset) + .fetch_all(pool) + .await?; + + let items: Vec = rows + .into_iter() + .map(|row| crate::dto::UserCouponWithDetailsDto { + id: row.get(0), + user_uuid: row.get(1), + coupon_uuid: row.get(2), + status: row.get(3), + issued_at: row.get(4), + used_at: row.get(5), + expires_at: row.get(6), + code: row.get(7), + name: row.get(8), + description: row.get(9), + discount_type: row.get(10), + discount_value: row.get(11), + min_amount: row.get(12), + max_discount: row.get(13), + }) + .collect(); + + let total: i64 = sqlx::query_scalar( + r#" + SELECT COUNT(*) FROM user_coupons + WHERE user_uuid = $1 AND status = $2 + "#, + ) + .bind(user_uuid) + .bind(s) + .fetch_one(pool) + .await?; + + (items, total) + } else { + let rows = sqlx::query( + r#" + SELECT + uc.id, uc.user_uuid, uc.coupon_uuid, uc.status, + uc.issued_at, uc.used_at, uc.expires_at, + c.code, c.name, c.description, c.discount_type, c.discount_value, c.min_amount, c.max_discount + FROM user_coupons uc + INNER JOIN coupons c ON uc.coupon_uuid = c.uuid + WHERE uc.user_uuid = $1 + ORDER BY uc.issued_at DESC + LIMIT $2 OFFSET $3 + "#, + ) + .bind(user_uuid) + .bind(page_size) + .bind(offset) + .fetch_all(pool) + .await?; + + let items: Vec = rows + .into_iter() + .map(|row| crate::dto::UserCouponWithDetailsDto { + id: row.get(0), + user_uuid: row.get(1), + coupon_uuid: row.get(2), + status: row.get(3), + issued_at: row.get(4), + used_at: row.get(5), + expires_at: row.get(6), + code: row.get(7), + name: row.get(8), + description: row.get(9), + discount_type: row.get(10), + discount_value: row.get(11), + min_amount: row.get(12), + max_discount: row.get(13), + }) + .collect(); + + let total: i64 = sqlx::query_scalar( + r#" + SELECT COUNT(*) FROM user_coupons + WHERE user_uuid = $1 + "#, + ) + .bind(user_uuid) + .fetch_one(pool) + .await?; + + (items, total) + }; + + Ok((items, total)) +} + +/// 根据 UUID 查询用户优惠券 +pub async fn fetch_user_coupon_by_uuid( + pool: &Pool, + user_coupon_uuid: i32, +) -> Result, Error> { + let rec = sqlx::query_as::<_, crate::dto::UserCouponDto>( + r#" + SELECT id, user_uuid, coupon_uuid, status, issued_at, used_at, expires_at + FROM user_coupons + WHERE id = $1 + "#, + ) + .bind(user_coupon_uuid) + .fetch_optional(pool) + .await?; + + Ok(rec) +} + +/// 发放优惠券给用户 +pub async fn insert_user_coupon( + pool: &Pool, + user_uuid: Uuid, + coupon_uuid: Uuid, + expires_at: Option>, +) -> Result { + let id: i32 = sqlx::query_scalar( + r#" + INSERT INTO user_coupons (user_uuid, coupon_uuid, expires_at) + VALUES ($1, $2, $3) + ON CONFLICT (user_uuid, coupon_uuid) DO NOTHING + RETURNING id; + "#, + ) + .bind(user_uuid) + .bind(coupon_uuid) + .bind(expires_at) + .fetch_optional(pool) + .await? + .ok_or_else(|| { + Error::RowNotFound // 如果已存在,返回错误 + })?; + + Ok(id) +} + +/// 更新用户优惠券状态 +pub async fn update_user_coupon_status( + pool: &Pool, + user_uuid: Uuid, + coupon_uuid: Uuid, + status: &str, + used_at: Option>, +) -> Result<(), Error> { + sqlx::query( + r#" + UPDATE user_coupons + SET status = $1, used_at = $2, updated_at = CURRENT_TIMESTAMP + WHERE user_uuid = $3 AND coupon_uuid = $4 + "#, + ) + .bind(status) + .bind(used_at) + .bind(user_uuid) + .bind(coupon_uuid) + .execute(pool) + .await?; + + Ok(()) +} + +/// 批量发放优惠券 +pub async fn batch_insert_user_coupons( + pool: &Pool, + coupon_uuid: Uuid, + user_uuids: &[Uuid], + expires_at: Option>, +) -> Result { + let mut tx = pool.begin().await?; + let mut count = 0; + + for user_uuid in user_uuids { + let result = sqlx::query_scalar::<_, i32>( + r#" + INSERT INTO user_coupons (user_uuid, coupon_uuid, expires_at) + VALUES ($1, $2, $3) + ON CONFLICT (user_uuid, coupon_uuid) DO NOTHING + RETURNING id; + "#, + ) + .bind(user_uuid) + .bind(coupon_uuid) + .bind(expires_at) + .fetch_optional(&mut *tx) + .await?; + + if result.is_some() { + count += 1; + } + } + + tx.commit().await?; + Ok(count) +} + +/// 查询用户可用优惠券(未使用且未过期,包含优惠券详细信息) +pub async fn fetch_available_user_coupons( + pool: &Pool, + user_uuid: Uuid, +) -> Result, Error> { + let now = chrono::Utc::now(); + + let rows = sqlx::query( + r#" + SELECT + uc.id, uc.user_uuid, uc.coupon_uuid, uc.status, + uc.issued_at, uc.used_at, uc.expires_at, + c.code, c.name, c.description, c.discount_type, c.discount_value, c.min_amount, c.max_discount + FROM user_coupons uc + INNER JOIN coupons c ON uc.coupon_uuid = c.uuid + WHERE uc.user_uuid = $1 + AND uc.status = 'unused' + AND c.status = 'active' + AND (uc.expires_at IS NULL OR uc.expires_at > $2) + AND (c.valid_until IS NULL OR c.valid_until > $2) + AND c.valid_from <= $2 + ORDER BY uc.issued_at DESC + "#, + ) + .bind(user_uuid) + .bind(now) + .fetch_all(pool) + .await?; + + let items = rows + .into_iter() + .map(|row| crate::dto::UserCouponWithDetailsDto { + id: row.get(0), + user_uuid: row.get(1), + coupon_uuid: row.get(2), + status: row.get(3), + issued_at: row.get(4), + used_at: row.get(5), + expires_at: row.get(6), + code: row.get(7), + name: row.get(8), + description: row.get(9), + discount_type: row.get(10), + discount_value: row.get(11), + min_amount: row.get(12), + max_discount: row.get(13), + }) + .collect(); + + Ok(items) +} + +// ============ Payment Orders ============ + +/// 创建支付订单 +pub async fn insert_payment_order( + pool: &Pool, + order_no: &str, + user_uuid: Uuid, + order_type: &str, + amount: Decimal, + currency: &str, + payment_channel: Option<&str>, + description: Option<&str>, + subscription_uuid: Option, + coupon_uuid: Option, + original_amount: Option, + discount_amount: Option, +) -> Result { + let uuid: Uuid = sqlx::query_scalar( + r#" + INSERT INTO payment_orders (order_no, user_uuid, order_type, amount, currency, + status, payment_channel, description, subscription_uuid, + coupon_uuid, original_amount, discount_amount) + VALUES ($1, $2, $3, $4, $5, 'pending', $6, $7, $8, $9, $10, $11) + RETURNING uuid; + "#, + ) + .bind(order_no) + .bind(user_uuid) + .bind(order_type) + .bind(amount) + .bind(currency) + .bind(payment_channel) + .bind(description) + .bind(subscription_uuid) + .bind(coupon_uuid) + .bind(original_amount) + .bind(discount_amount) + .fetch_one(pool) + .await?; + + Ok(uuid) +} + +/// 查询支付订单列表 +pub async fn fetch_payment_orders( + pool: &Pool, + user_uuid: Uuid, + order_type: Option<&str>, + status: Option<&str>, + offset: i64, + limit: i64, +) -> Result, Error> { + let recs = sqlx::query_as::<_, PaymentOrderDto>( + r#" + SELECT id, uuid, order_no, user_uuid, order_type, amount, currency, status, + payment_channel, external_order_id, description, subscription_uuid, + coupon_uuid, original_amount, discount_amount, paid_at, refunded_at, + created_at, updated_at + FROM payment_orders + WHERE user_uuid = $1 + AND ($2::varchar IS NULL OR order_type = $2) + AND ($3::varchar IS NULL OR status = $3) + ORDER BY created_at DESC + LIMIT $4 OFFSET $5 + "#, + ) + .bind(user_uuid) + .bind(order_type) + .bind(status) + .bind(limit) + .bind(offset) + .fetch_all(pool) + .await?; + + Ok(recs) +} + +/// 查询支付订单总数 +pub async fn fetch_payment_orders_count( + pool: &Pool, + user_uuid: Uuid, + order_type: Option<&str>, + status: Option<&str>, +) -> Result { + let count: i64 = sqlx::query_scalar( + r#" + SELECT COUNT(*) FROM payment_orders + WHERE user_uuid = $1 + AND ($2::varchar IS NULL OR order_type = $2) + AND ($3::varchar IS NULL OR status = $3) + "#, + ) + .bind(user_uuid) + .bind(order_type) + .bind(status) + .fetch_one(pool) + .await?; + + Ok(count) +} + +/// 根据 UUID 查询支付订单 +pub async fn fetch_payment_order_by_uuid( + pool: &Pool, + order_uuid: Uuid, +) -> Result, Error> { + let rec = sqlx::query_as::<_, PaymentOrderDto>( + r#" + SELECT id, uuid, order_no, user_uuid, order_type, amount, currency, status, + payment_channel, external_order_id, description, subscription_uuid, + coupon_uuid, original_amount, discount_amount, paid_at, refunded_at, + created_at, updated_at + FROM payment_orders + WHERE uuid = $1 + "#, + ) + .bind(order_uuid) + .fetch_optional(pool) + .await?; + + Ok(rec) +} + +/// 更新订单状态 +pub async fn update_payment_order_status( + pool: &Pool, + order_uuid: Uuid, + status: &str, + external_order_id: Option<&str>, +) -> Result<(), Error> { + sqlx::query( + r#" + UPDATE payment_orders + SET status = $1, + external_order_id = COALESCE($2, external_order_id), + paid_at = CASE WHEN $1 = 'paid' THEN CURRENT_TIMESTAMP ELSE paid_at END, + updated_at = CURRENT_TIMESTAMP + WHERE uuid = $3 + "#, + ) + .bind(status) + .bind(external_order_id) + .bind(order_uuid) + .execute(pool) + .await?; + + Ok(()) +} + +// ============ Auto Renewal Services ============ + +/// 查询用户自动续费服务列表 +pub async fn fetch_auto_renewal_services( + pool: &Pool, + user_uuid: Uuid, +) -> Result, Error> { + let recs = sqlx::query_as::<_, AutoRenewalServiceDto>( + r#" + SELECT id, uuid, user_uuid, service_type, service_uuid, service_name, + renewal_price, currency, next_bill_date, status, created_at, updated_at + FROM auto_renewal_services + WHERE user_uuid = $1 AND status = 'active' + ORDER BY next_bill_date ASC + "#, + ) + .bind(user_uuid) + .fetch_all(pool) + .await?; + + Ok(recs) +} diff --git a/src-tauri/crates/business/src/models/environments.rs b/src-tauri/crates/business/src/models/environments.rs new file mode 100644 index 0000000..92a45d7 --- /dev/null +++ b/src-tauri/crates/business/src/models/environments.rs @@ -0,0 +1,1662 @@ +use sqlx::Error; +use uuid::Uuid; + +use crate::database::{Db as Postgres, Pool}; + +use crate::dto::{ + EnvironmentAccountRowDto, EnvironmentConfigDto, EnvironmentCookieDto, EnvironmentDto, + EnvironmentRowDto, EnvironmentTagRowDto, EnvironmentUrlDto, GroupDto, GroupRowDto, ProxyRowDto, + TagDto, TemplateDto, +}; +use crate::entitys::CookieInput; + +// ============ Groups ============ + +/// 创建分组 +pub async fn insert_group( + pool: &Pool, + workspace_uuid: Uuid, + team_uuid: Uuid, + name: &str, + description: Option<&str>, + created_by: Uuid, +) -> Result { + let uuid: Uuid = sqlx::query_scalar( + r#" + INSERT INTO groups (workspace_uuid, team_uuid, name, description, created_by) + VALUES ($1, $2, $3, $4, $5) + RETURNING uuid; + "#, + ) + .bind(workspace_uuid) + .bind(team_uuid) + .bind(name) + .bind(description) + .bind(created_by) + .fetch_one(pool) + .await?; + + Ok(uuid) +} + +/// 查询分组列表(工作空间级别) +pub async fn fetch_groups( + pool: &Pool, + workspace_uuid: Uuid, + team_uuid: Uuid, + offset: i64, + limit: i64, +) -> Result, Error> { + let recs = sqlx::query_as::<_, GroupDto>( + r#" + SELECT g.id, g.uuid, g.workspace_uuid, g.team_uuid, t.name AS team_name, + g.name, g.description, g.sort_order, + g.created_by, ui.nickname AS created_by_name, + (SELECT COUNT(*) FROM environments e WHERE e.group_uuid = g.uuid AND e.deleted_at IS NULL) AS environments_count, + g.created_at, g.updated_at, g.deleted_at + FROM groups g + LEFT JOIN teams t ON g.team_uuid = t.uuid + LEFT JOIN user_infos ui ON g.created_by = ui.user_uuid + WHERE g.workspace_uuid = $1 AND g.team_uuid = $2 AND g.deleted_at IS NULL + ORDER BY g.sort_order, g.name + LIMIT $3 OFFSET $4 + "#, + ) + .bind(workspace_uuid) + .bind(team_uuid) + .bind(limit) + .bind(offset) + .fetch_all(pool) + .await?; + + Ok(recs) +} + +/// 根据 UUID 查询分组 +pub async fn fetch_group_by_uuid( + pool: &Pool, + group_uuid: Uuid, +) -> Result, Error> { + let rec = sqlx::query_as::<_, GroupDto>( + r#" + SELECT g.id, g.uuid, g.workspace_uuid, g.team_uuid, t.name AS team_name, + g.name, g.description, g.sort_order, + g.created_by, ui.nickname AS created_by_name, + (SELECT COUNT(*) FROM environments e WHERE e.group_uuid = g.uuid AND e.deleted_at IS NULL) AS environments_count, + g.created_at, g.updated_at, g.deleted_at + FROM groups g + LEFT JOIN teams t ON g.team_uuid = t.uuid + LEFT JOIN user_infos ui ON g.created_by = ui.user_uuid + WHERE g.uuid = $1 AND g.deleted_at IS NULL + "#, + ) + .bind(group_uuid) + .fetch_optional(pool) + .await?; + + Ok(rec) +} + +/// 更新分组 +pub async fn update_group( + pool: &Pool, + group_uuid: Uuid, + name: Option<&str>, + description: Option<&str>, + sort_order: Option, +) -> Result<(), Error> { + sqlx::query( + r#" + UPDATE groups + SET name = COALESCE($1, name), + description = COALESCE($2, description), + sort_order = COALESCE($3, sort_order) + WHERE uuid = $4 AND deleted_at IS NULL + "#, + ) + .bind(name) + .bind(description) + .bind(sort_order) + .bind(group_uuid) + .execute(pool) + .await?; + + Ok(()) +} + +/// 软删除分组 +pub async fn delete_group(pool: &Pool, group_uuid: Uuid) -> Result<(), Error> { + sqlx::query( + r#" + UPDATE groups SET deleted_at = CURRENT_TIMESTAMP + WHERE uuid = $1 AND deleted_at IS NULL + "#, + ) + .bind(group_uuid) + .execute(pool) + .await?; + + Ok(()) +} + +// ============ Tags ============ + +/// 创建标签 +pub async fn insert_tag( + pool: &Pool, + user_uuid: Uuid, + team_uuid: Option, + name: &str, + color: Option<&str>, +) -> Result { + let uuid: Uuid = sqlx::query_scalar( + r#" + INSERT INTO tags (user_uuid, team_uuid, name, color) + VALUES ($1, $2, $3, $4) + RETURNING uuid; + "#, + ) + .bind(user_uuid) + .bind(team_uuid) + .bind(name) + .bind(color.unwrap_or("gray")) + .fetch_one(pool) + .await?; + + Ok(uuid) +} + +/// 查询标签列表 +pub async fn fetch_tags( + pool: &Pool, + team_uuid: Option, + user_uuid: Uuid, +) -> Result, Error> { + let recs = sqlx::query_as::<_, TagDto>( + r#" + SELECT id, uuid, user_uuid, team_uuid, name, color, sort_order, + environments_count, created_at, updated_at, deleted_at + FROM tags + WHERE (team_uuid = $1 OR (team_uuid IS NULL AND user_uuid = $2)) + AND deleted_at IS NULL + ORDER BY sort_order, name + "#, + ) + .bind(team_uuid) + .bind(user_uuid) + .fetch_all(pool) + .await?; + + Ok(recs) +} + +/// 根据 UUID 查询标签 +pub async fn fetch_tag_by_uuid( + pool: &Pool, + tag_uuid: Uuid, +) -> Result, Error> { + let rec = sqlx::query_as::<_, TagDto>( + r#" + SELECT id, uuid, user_uuid, team_uuid, name, color, sort_order, + environments_count, created_at, updated_at, deleted_at + FROM tags + WHERE uuid = $1 AND deleted_at IS NULL + "#, + ) + .bind(tag_uuid) + .fetch_optional(pool) + .await?; + + Ok(rec) +} + +/// 更新标签 +pub async fn update_tag( + pool: &Pool, + tag_uuid: Uuid, + name: Option<&str>, + color: Option<&str>, + sort_order: Option, +) -> Result<(), Error> { + sqlx::query( + r#" + UPDATE tags + SET name = COALESCE($1, name), + color = COALESCE($2, color), + sort_order = COALESCE($3, sort_order) + WHERE uuid = $4 AND deleted_at IS NULL + "#, + ) + .bind(name) + .bind(color) + .bind(sort_order) + .bind(tag_uuid) + .execute(pool) + .await?; + + Ok(()) +} + +/// 软删除标签 +pub async fn delete_tag(pool: &Pool, tag_uuid: Uuid) -> Result<(), Error> { + sqlx::query( + r#" + UPDATE tags SET deleted_at = CURRENT_TIMESTAMP + WHERE uuid = $1 AND deleted_at IS NULL + "#, + ) + .bind(tag_uuid) + .execute(pool) + .await?; + + Ok(()) +} + +// ============ Environments ============ + +/// 创建环境 +pub async fn insert_environment( + pool: &Pool, + workspace_uuid: Uuid, + user_uuid: Uuid, + team_uuid: Uuid, + name: &str, + description: Option<&str>, + group_uuid: Option, + proxy_uuid: Option, + system_info: Option<&str>, + kernel_info: Option<&str>, +) -> Result { + let uuid: Uuid = sqlx::query_scalar( + r#" + INSERT INTO environments (workspace_uuid, user_uuid, team_uuid, name, description, + group_uuid, proxy_uuid, system_info, kernel_info) + VALUES ($1, $2, $3, $4, $5, $6, $7, $8, $9) + RETURNING uuid; + "#, + ) + .bind(workspace_uuid) + .bind(user_uuid) + .bind(team_uuid) + .bind(name) + .bind(description) + .bind(group_uuid) + .bind(proxy_uuid) + .bind(system_info) + .bind(kernel_info) + .fetch_one(pool) + .await?; + + Ok(uuid) +} + +/// 查询环境列表(基础信息) +pub async fn fetch_environments_base( + pool: &Pool, + workspace_uuid: Uuid, + team_uuid: Uuid, + group_uuid: Option, + status: Option<&str>, + keyword: Option<&str>, + tag_uuids: Option<&[Uuid]>, + offset: i64, + limit: i64, +) -> Result, Error> { + let mut query = String::from( + r#" + SELECT DISTINCT + e.id, e.uuid, e.workspace_uuid, e.user_uuid, e.team_uuid, e.name, e.description, e.status, + e.system_info, e.kernel_info, e.fingerprint_summary, + e.group_uuid, e.proxy_uuid, + e.last_opened_at, e.created_at, e.updated_at + FROM environments e + "#, + ); + + // 如果有标签过滤,需要 JOIN environment_tags 表 + if tag_uuids.is_some() { + query.push_str(" LEFT JOIN environment_tags et ON e.uuid = et.environment_uuid "); + } + + query.push_str( + r#" + WHERE e.workspace_uuid = $1 + AND e.team_uuid = $2 + AND e.deleted_at IS NULL + "#, + ); + + let mut param_index = 3; + let mut conditions = Vec::new(); + + // 分组过滤 + if group_uuid.is_some() { + conditions.push(format!("e.group_uuid = ${}", param_index)); + param_index += 1; + } + + // 状态过滤 + if status.is_some() { + conditions.push(format!("e.status = ${}", param_index)); + param_index += 1; + } + + // 关键词搜索 + if keyword.is_some() { + conditions.push(format!( + "(e.name ILIKE ${} OR e.uuid::text ILIKE ${})", + param_index, param_index + )); + param_index += 1; + } + + // 标签过滤 + if let Some(tags) = tag_uuids { + if !tags.is_empty() { + conditions.push(format!("et.tag_uuid = ANY(${})", param_index)); + param_index += 1; + } + } + + // 添加所有条件 + for condition in conditions { + query.push_str(&format!(" AND {}", condition)); + } + + query.push_str(&format!( + " ORDER BY e.created_at DESC LIMIT ${} OFFSET ${}", + param_index, + param_index + 1 + )); + + // 构建查询 + let mut sql_query = sqlx::query_as::<_, EnvironmentRowDto>(&query) + .bind(workspace_uuid) + .bind(team_uuid); + + // 绑定参数 + if let Some(g) = group_uuid { + sql_query = sql_query.bind(g); + } + if let Some(s) = status { + sql_query = sql_query.bind(s); + } + if let Some(k) = keyword { + let search_pattern = format!("%{}%", k); + sql_query = sql_query.bind(search_pattern); + } + if let Some(tags) = tag_uuids { + if !tags.is_empty() { + sql_query = sql_query.bind(tags); + } + } + + sql_query = sql_query.bind(limit).bind(offset); + + let recs = sql_query.fetch_all(pool).await?; + + Ok(recs) +} + +/// 查询环境列表(基础) +pub async fn fetch_environments( + pool: &Pool, + workspace_uuid: Uuid, + team_uuid: Uuid, + group_uuid: Option, + status: Option<&str>, + offset: i64, + limit: i64, +) -> Result, Error> { + let recs = sqlx::query_as::<_, EnvironmentDto>( + r#" + SELECT id, uuid, workspace_uuid, user_uuid, team_uuid, name, description, + status, group_uuid, proxy_uuid, system_info, kernel_info, fingerprint_summary, + last_opened_at, created_at, updated_at, deleted_at + FROM environments + WHERE workspace_uuid = $1 AND team_uuid = $2 + AND ($3::uuid IS NULL OR group_uuid = $3) + AND ($4::varchar IS NULL OR status = $4) + AND deleted_at IS NULL + ORDER BY created_at DESC + LIMIT $5 OFFSET $6 + "#, + ) + .bind(workspace_uuid) + .bind(team_uuid) + .bind(group_uuid) + .bind(status) + .bind(limit) + .bind(offset) + .fetch_all(pool) + .await?; + + Ok(recs) +} + +/// 查询环境总数 +pub async fn fetch_environments_count( + pool: &Pool, + workspace_uuid: Uuid, + team_uuid: Uuid, + group_uuid: Option, + status: Option<&str>, + keyword: Option<&str>, + tag_uuids: Option<&[Uuid]>, +) -> Result { + let mut query = String::from( + r#" + SELECT COUNT(DISTINCT e.id) FROM environments e + "#, + ); + + // 如果有标签过滤,需要 JOIN environment_tags 表 + if tag_uuids.is_some() { + query.push_str(" LEFT JOIN environment_tags et ON e.uuid = et.environment_uuid "); + } + + query.push_str( + r#" + WHERE e.workspace_uuid = $1 + AND e.team_uuid = $2 + AND e.deleted_at IS NULL + "#, + ); + + let mut param_index = 3; + let mut conditions = Vec::new(); + + // 分组过滤 + if group_uuid.is_some() { + conditions.push(format!("e.group_uuid = ${}", param_index)); + param_index += 1; + } + + // 状态过滤 + if status.is_some() { + conditions.push(format!("e.status = ${}", param_index)); + param_index += 1; + } + + // 关键词搜索 + if keyword.is_some() { + conditions.push(format!( + "(e.name ILIKE ${} OR e.uuid::text ILIKE ${})", + param_index, param_index + )); + param_index += 1; + } + + // 标签过滤 + if let Some(tags) = tag_uuids { + if !tags.is_empty() { + conditions.push(format!("et.tag_uuid = ANY(${})", param_index)); + } + } + + // 添加所有条件 + for condition in conditions { + query.push_str(&format!(" AND {}", condition)); + } + + // 构建查询 + let mut sql_query = sqlx::query_scalar::<_, i64>(&query).bind(workspace_uuid).bind(team_uuid); + + // 绑定参数 + if let Some(g) = group_uuid { + sql_query = sql_query.bind(g); + } + if let Some(s) = status { + sql_query = sql_query.bind(s); + } + if let Some(k) = keyword { + let search_pattern = format!("%{}%", k); + sql_query = sql_query.bind(search_pattern); + } + if let Some(tags) = tag_uuids { + if !tags.is_empty() { + sql_query = sql_query.bind(tags); + } + } + + let count = sql_query.fetch_one(pool).await?; + + Ok(count) +} + +/// 根据 UUID 查询环境(不带工作空间过滤,用于内部查询) +pub async fn fetch_environment_by_uuid_unfiltered( + pool: &Pool, + env_uuid: Uuid, +) -> Result, Error> { + let rec = sqlx::query_as::<_, EnvironmentDto>( + r#" + SELECT id, uuid, workspace_uuid, user_uuid, team_uuid, name, description, + status, group_uuid, proxy_uuid, system_info, kernel_info, fingerprint_summary, + last_opened_at, created_at, updated_at, deleted_at + FROM environments + WHERE uuid = $1 AND deleted_at IS NULL + "#, + ) + .bind(env_uuid) + .fetch_optional(pool) + .await?; + + Ok(rec) +} + +/// 根据 UUID 查询环境(带工作空间过滤) +pub async fn fetch_environment_by_uuid( + pool: &Pool, + workspace_uuid: Uuid, + env_uuid: Uuid, +) -> Result, Error> { + let rec = sqlx::query_as::<_, EnvironmentDto>( + r#" + SELECT id, uuid, workspace_uuid, user_uuid, team_uuid, name, description, + status, group_uuid, proxy_uuid, system_info, kernel_info, fingerprint_summary, + last_opened_at, created_at, updated_at, deleted_at + FROM environments + WHERE uuid = $1 AND workspace_uuid = $2 AND deleted_at IS NULL + "#, + ) + .bind(env_uuid) + .bind(workspace_uuid) + .fetch_optional(pool) + .await?; + + Ok(rec) +} + +/// 更新环境基础信息 +pub async fn update_environment( + pool: &Pool, + env_uuid: Uuid, + name: Option<&str>, + description: Option<&str>, + group_uuid: Option, +) -> Result<(), Error> { + sqlx::query( + r#" + UPDATE environments + SET name = COALESCE($1, name), + description = COALESCE($2, description), + group_uuid = $3 + WHERE uuid = $4 AND deleted_at IS NULL + "#, + ) + .bind(name) + .bind(description) + .bind(group_uuid) + .bind(env_uuid) + .execute(pool) + .await?; + + Ok(()) +} + +/// 更新环境状态 +pub async fn update_environment_status( + pool: &Pool, + env_uuid: Uuid, + status: &str, +) -> Result<(), Error> { + sqlx::query( + r#" + UPDATE environments SET status = $1 + WHERE uuid = $2 AND deleted_at IS NULL + "#, + ) + .bind(status) + .bind(env_uuid) + .execute(pool) + .await?; + + Ok(()) +} + +/// 更新环境代理 +pub async fn update_environment_proxy( + pool: &Pool, + env_uuid: Uuid, + proxy_uuid: Option, +) -> Result<(), Error> { + sqlx::query( + r#" + UPDATE environments SET proxy_uuid = $1 + WHERE uuid = $2 AND deleted_at IS NULL + "#, + ) + .bind(proxy_uuid) + .bind(env_uuid) + .execute(pool) + .await?; + + Ok(()) +} + +/// 更新环境最后打开时间 +pub async fn update_environment_last_opened( + pool: &Pool, + env_uuid: Uuid, +) -> Result<(), Error> { + sqlx::query( + r#" + UPDATE environments SET last_opened_at = CURRENT_TIMESTAMP + WHERE uuid = $1 AND deleted_at IS NULL + "#, + ) + .bind(env_uuid) + .execute(pool) + .await?; + + Ok(()) +} + +/// 软删除环境 +pub async fn delete_environment(pool: &Pool, env_uuid: Uuid) -> Result<(), Error> { + sqlx::query( + r#" + UPDATE environments SET deleted_at = CURRENT_TIMESTAMP + WHERE uuid = $1 AND deleted_at IS NULL + "#, + ) + .bind(env_uuid) + .execute(pool) + .await?; + + Ok(()) +} + +/// 批量软删除环境 +pub async fn batch_delete_environments( + pool: &Pool, + env_uuids: &[Uuid], +) -> Result { + let result = sqlx::query( + r#" + UPDATE environments SET deleted_at = CURRENT_TIMESTAMP + WHERE uuid = ANY($1) AND deleted_at IS NULL + "#, + ) + .bind(env_uuids) + .execute(pool) + .await?; + + Ok(result.rows_affected()) +} + +// ============ Recycle Bin ============ + +/// 查询回收站环境列表(已删除但未永久删除) +pub async fn fetch_deleted_environments_base( + pool: &Pool, + workspace_uuid: Uuid, + team_uuid: Uuid, + group_uuid: Option, + keyword: Option<&str>, + offset: i64, + limit: i64, +) -> Result, Error> { + let mut query = String::from( + r#" + SELECT DISTINCT + e.id, e.uuid, e.workspace_uuid, e.user_uuid, e.team_uuid, e.name, e.description, e.status, + e.system_info, e.kernel_info, e.fingerprint_summary, + e.group_uuid, e.proxy_uuid, + e.last_opened_at, e.created_at, e.updated_at, e.deleted_at + FROM environments e + WHERE e.workspace_uuid = $1 + AND e.team_uuid = $2 + AND e.deleted_at IS NOT NULL + "#, + ); + + let mut param_index = 3; + let mut conditions = Vec::new(); + + // 分组过滤 + if group_uuid.is_some() { + conditions.push(format!("e.group_uuid = ${}", param_index)); + param_index += 1; + } + + // 关键词搜索 + if keyword.is_some() { + conditions.push(format!( + "(e.name ILIKE ${} OR e.uuid::text ILIKE ${})", + param_index, param_index + )); + param_index += 1; + } + + if !conditions.is_empty() { + query.push_str(" AND "); + query.push_str(&conditions.join(" AND ")); + } + + query.push_str(" ORDER BY e.deleted_at DESC LIMIT $"); + query.push_str(¶m_index.to_string()); + param_index += 1; + query.push_str(" OFFSET $"); + query.push_str(¶m_index.to_string()); + + let mut q = sqlx::query_as::<_, EnvironmentRowDto>(&query) + .bind(workspace_uuid) + .bind(team_uuid); + + if let Some(gid) = group_uuid { + q = q.bind(gid); + } + + if let Some(kw) = keyword { + let pattern = format!("%{}%", kw); + q = q.bind(pattern); + } + + q = q.bind(limit).bind(offset); + + let recs = q.fetch_all(pool).await?; + Ok(recs) +} + +/// 统计回收站环境总数 +pub async fn fetch_deleted_environments_count( + pool: &Pool, + workspace_uuid: Uuid, + team_uuid: Uuid, + group_uuid: Option, + keyword: Option<&str>, +) -> Result { + let mut query = String::from( + r#" + SELECT COUNT(DISTINCT e.id) + FROM environments e + WHERE e.workspace_uuid = $1 + AND e.team_uuid = $2 + AND e.deleted_at IS NOT NULL + "#, + ); + + let mut param_index = 3; + let mut conditions = Vec::new(); + + if group_uuid.is_some() { + conditions.push(format!("e.group_uuid = ${}", param_index)); + param_index += 1; + } + + if keyword.is_some() { + conditions.push(format!( + "(e.name ILIKE ${} OR e.uuid::text ILIKE ${})", + param_index, param_index + )); + param_index += 1; + } + + if !conditions.is_empty() { + query.push_str(" AND "); + query.push_str(&conditions.join(" AND ")); + } + + let mut q = sqlx::query_scalar::<_, i64>(&query).bind(workspace_uuid).bind(team_uuid); + + if let Some(gid) = group_uuid { + q = q.bind(gid); + } + + if let Some(kw) = keyword { + let pattern = format!("%{}%", kw); + q = q.bind(pattern); + } + + let count = q.fetch_one(pool).await?; + Ok(count) +} + +/// 恢复环境(将 deleted_at 设为 NULL) +pub async fn restore_environment(pool: &Pool, env_uuid: Uuid) -> Result<(), Error> { + sqlx::query( + r#" + UPDATE environments SET deleted_at = NULL + WHERE uuid = $1 AND deleted_at IS NOT NULL + "#, + ) + .bind(env_uuid) + .execute(pool) + .await?; + + Ok(()) +} + +/// 批量恢复环境 +pub async fn batch_restore_environments( + pool: &Pool, + env_uuids: &[Uuid], +) -> Result { + let result = sqlx::query( + r#" + UPDATE environments SET deleted_at = NULL + WHERE uuid = ANY($1) AND deleted_at IS NOT NULL + "#, + ) + .bind(env_uuids) + .execute(pool) + .await?; + + Ok(result.rows_affected()) +} + +/// 永久删除环境(真正的 DELETE) +pub async fn permanent_delete_environment( + pool: &Pool, + env_uuid: Uuid, +) -> Result<(), Error> { + // 先删除关联数据 + sqlx::query("DELETE FROM environment_tags WHERE environment_uuid = $1") + .bind(env_uuid) + .execute(pool) + .await?; + + sqlx::query("DELETE FROM environment_urls WHERE environment_uuid = $1") + .bind(env_uuid) + .execute(pool) + .await?; + + sqlx::query("DELETE FROM environment_cookies WHERE environment_uuid = $1") + .bind(env_uuid) + .execute(pool) + .await?; + + sqlx::query("DELETE FROM environment_configs WHERE environment_uuid = $1") + .bind(env_uuid) + .execute(pool) + .await?; + + sqlx::query("DELETE FROM environment_accounts WHERE environment_uuid = $1") + .bind(env_uuid) + .execute(pool) + .await?; + + // `environment_extensions` 已在扩展系统重构后移除。 + // 环境的扩展现在通过 user/team/group 绑定动态合并,不再需要清理环境级关联。 + + // 最后删除环境本身 + sqlx::query("DELETE FROM environments WHERE uuid = $1") + .bind(env_uuid) + .execute(pool) + .await?; + + Ok(()) +} + +/// 批量永久删除环境 +pub async fn batch_permanent_delete_environments( + pool: &Pool, + env_uuids: &[Uuid], +) -> Result { + // 先删除关联数据 + sqlx::query("DELETE FROM environment_tags WHERE environment_uuid = ANY($1)") + .bind(env_uuids) + .execute(pool) + .await?; + + sqlx::query("DELETE FROM environment_urls WHERE environment_uuid = ANY($1)") + .bind(env_uuids) + .execute(pool) + .await?; + + sqlx::query("DELETE FROM environment_cookies WHERE environment_uuid = ANY($1)") + .bind(env_uuids) + .execute(pool) + .await?; + + sqlx::query("DELETE FROM environment_configs WHERE environment_uuid = ANY($1)") + .bind(env_uuids) + .execute(pool) + .await?; + + sqlx::query("DELETE FROM environment_accounts WHERE environment_uuid = ANY($1)") + .bind(env_uuids) + .execute(pool) + .await?; + + // `environment_extensions` 已在扩展系统重构后移除。 + // 环境的扩展现在通过 user/team/group 绑定动态合并,不再需要清理环境级关联。 + + // 最后删除环境本身 + let result = sqlx::query("DELETE FROM environments WHERE uuid = ANY($1)") + .bind(env_uuids) + .execute(pool) + .await?; + + Ok(result.rows_affected()) +} + +// ============ Environment Configs ============ + +/// 创建或更新环境配置 +pub async fn upsert_environment_config( + pool: &Pool, + env_uuid: Uuid, + window_info: &serde_json::Value, + basic_settings: &serde_json::Value, + fingerprint_settings: &serde_json::Value, + device_settings: &serde_json::Value, + preference_settings: &serde_json::Value, + project_metadata: &serde_json::Value, +) -> Result { + let id: i32 = sqlx::query_scalar( + r#" + INSERT INTO environment_configs (environment_uuid, window_info, basic_settings, + fingerprint_settings, device_settings, preference_settings, + project_metadata) + VALUES ($1, $2, $3, $4, $5, $6, $7) + ON CONFLICT (environment_uuid) DO UPDATE SET + window_info = $2, + basic_settings = $3, + fingerprint_settings = $4, + device_settings = $5, + preference_settings = $6, + project_metadata = $7 + RETURNING id; + "#, + ) + .bind(env_uuid) + .bind(window_info) + .bind(basic_settings) + .bind(fingerprint_settings) + .bind(device_settings) + .bind(preference_settings) + .bind(project_metadata) + .fetch_one(pool) + .await?; + + Ok(id) +} + +/// 查询环境配置 +pub async fn fetch_environment_config( + pool: &Pool, + env_uuid: Uuid, +) -> Result, Error> { + let rec = sqlx::query_as::<_, EnvironmentConfigDto>( + r#" + SELECT id, environment_uuid, window_info, basic_settings, fingerprint_settings, + device_settings, preference_settings, project_metadata, created_at, updated_at + FROM environment_configs + WHERE environment_uuid = $1 + "#, + ) + .bind(env_uuid) + .fetch_optional(pool) + .await?; + + Ok(rec) +} + +/// 批量查询环境配置 +pub async fn fetch_environment_configs_by_uuids( + pool: &Pool, + env_uuids: &[Uuid], +) -> Result, Error> { + if env_uuids.is_empty() { + return Ok(vec![]); + } + + let recs = sqlx::query_as::<_, EnvironmentConfigDto>( + r#" + SELECT id, environment_uuid, window_info, basic_settings, fingerprint_settings, + device_settings, preference_settings, project_metadata, created_at, updated_at + FROM environment_configs + WHERE environment_uuid = ANY($1) + "#, + ) + .bind(env_uuids) + .fetch_all(pool) + .await?; + + Ok(recs) +} + +// ============ Environment Tags ============ + +/// 为环境添加标签 +pub async fn insert_environment_tag( + pool: &Pool, + env_uuid: Uuid, + tag_uuid: Uuid, +) -> Result<(), Error> { + sqlx::query( + r#" + INSERT INTO environment_tags (environment_uuid, tag_uuid) + VALUES ($1, $2) + ON CONFLICT DO NOTHING; + "#, + ) + .bind(env_uuid) + .bind(tag_uuid) + .execute(pool) + .await?; + + Ok(()) +} + +/// 移除环境的标签 +pub async fn remove_environment_tag( + pool: &Pool, + env_uuid: Uuid, + tag_uuid: Uuid, +) -> Result<(), Error> { + sqlx::query( + r#" + DELETE FROM environment_tags + WHERE environment_uuid = $1 AND tag_uuid = $2; + "#, + ) + .bind(env_uuid) + .bind(tag_uuid) + .execute(pool) + .await?; + + Ok(()) +} + +/// 清除环境的所有标签 +pub async fn clear_environment_tags(pool: &Pool, env_uuid: Uuid) -> Result<(), Error> { + sqlx::query( + r#" + DELETE FROM environment_tags + WHERE environment_uuid = $1; + "#, + ) + .bind(env_uuid) + .execute(pool) + .await?; + + Ok(()) +} + +/// 查询环境的所有标签 +pub async fn fetch_environment_tags( + pool: &Pool, + env_uuid: Uuid, +) -> Result, Error> { + let recs = sqlx::query_as::<_, TagDto>( + r#" + SELECT t.id, t.uuid, t.user_uuid, t.team_uuid, t.name, t.color, t.sort_order, + t.environments_count, t.created_at, t.updated_at, t.deleted_at + FROM tags t + INNER JOIN environment_tags et ON t.uuid = et.tag_uuid + WHERE et.environment_uuid = $1 AND t.deleted_at IS NULL + ORDER BY t.sort_order, t.name + "#, + ) + .bind(env_uuid) + .fetch_all(pool) + .await?; + + Ok(recs) +} + +/// 批量查询环境标签(完整标签信息) +pub async fn fetch_tags_for_environments( + pool: &Pool, + env_uuids: &[Uuid], +) -> Result, Error> { + if env_uuids.is_empty() { + return Ok(vec![]); + } + + let recs = sqlx::query_as::<_, EnvironmentTagRowDto>( + r#" + SELECT + et.environment_uuid, + t.id as tag_id, + t.uuid as tag_uuid, + t.name as tag_name, + t.color as tag_color, + t.sort_order as tag_sort_order, + t.user_uuid as tag_user_uuid, + t.team_uuid as tag_team_uuid, + t.environments_count as tag_environments_count, + t.created_at as tag_created_at, + t.updated_at as tag_updated_at, + t.deleted_at as tag_deleted_at + FROM environment_tags et + INNER JOIN tags t ON et.tag_uuid = t.uuid + WHERE et.environment_uuid = ANY($1) AND t.deleted_at IS NULL + ORDER BY t.sort_order, t.name + "#, + ) + .bind(env_uuids) + .fetch_all(pool) + .await?; + + Ok(recs) +} + +/// 批量查询环境账号(完整账号信息,排除敏感数据) +pub async fn fetch_accounts_for_environments( + pool: &Pool, + env_uuids: &[Uuid], +) -> Result, Error> { + if env_uuids.is_empty() { + return Ok(vec![]); + } + + let recs = sqlx::query_as::<_, EnvironmentAccountRowDto>( + r#" + SELECT + ea.environment_uuid, + pa.id as account_id, + pa.uuid as account_uuid, + pa.platform_url, + pa.platform_name, + pa.account, + pa.status as account_status, + pa.remark + FROM environment_accounts ea + INNER JOIN platform_accounts pa ON ea.account_uuid = pa.uuid + WHERE ea.environment_uuid = ANY($1) AND pa.deleted_at IS NULL + ORDER BY ea.sort_order + "#, + ) + .bind(env_uuids) + .fetch_all(pool) + .await?; + + Ok(recs) +} + +/// 批量查询分组 +pub async fn fetch_groups_by_uuids( + pool: &Pool, + group_uuids: &[Uuid], +) -> Result, Error> { + if group_uuids.is_empty() { + return Ok(vec![]); + } + + let recs = sqlx::query_as::<_, GroupRowDto>( + r#" + SELECT id, uuid, name, description, sort_order + FROM groups + WHERE uuid = ANY($1) AND deleted_at IS NULL + "#, + ) + .bind(group_uuids) + .fetch_all(pool) + .await?; + + Ok(recs) +} + +/// 批量查询代理 +pub async fn fetch_proxies_by_uuids( + pool: &Pool, + proxy_uuids: &[Uuid], +) -> Result, Error> { + if proxy_uuids.is_empty() { + return Ok(vec![]); + } + + let recs = sqlx::query_as::<_, ProxyRowDto>( + r#" + SELECT id, uuid, name, host, port, proxy_type, + username, password, + country, city, status, latency, last_check_ip + FROM proxies + WHERE uuid = ANY($1) AND deleted_at IS NULL + "#, + ) + .bind(proxy_uuids) + .fetch_all(pool) + .await?; + + Ok(recs) +} + +// ============ Templates ============ + +/// 创建模板 +pub async fn insert_template( + pool: &Pool, + user_uuid: Uuid, + team_uuid: Option, + name: &str, + description: Option<&str>, + is_public: bool, + system_info: Option<&str>, + kernel_info: Option<&str>, + config_json: &serde_json::Value, +) -> Result { + let uuid: Uuid = sqlx::query_scalar( + r#" + INSERT INTO templates (user_uuid, team_uuid, name, description, is_public, + system_info, kernel_info, config_json) + VALUES ($1, $2, $3, $4, $5, $6, $7, $8) + RETURNING uuid; + "#, + ) + .bind(user_uuid) + .bind(team_uuid) + .bind(name) + .bind(description) + .bind(is_public) + .bind(system_info) + .bind(kernel_info) + .bind(config_json) + .fetch_one(pool) + .await?; + + Ok(uuid) +} + +/// 查询模板列表 +pub async fn fetch_templates( + pool: &Pool, + team_uuid: Option, + user_uuid: Uuid, + is_public: Option, + offset: i64, + limit: i64, +) -> Result, Error> { + let recs = sqlx::query_as::<_, TemplateDto>( + r#" + SELECT id, uuid, user_uuid, team_uuid, name, description, is_public, + system_info, kernel_info, config_json, usage_count, created_at, updated_at, deleted_at + FROM templates + WHERE (team_uuid = $1 OR (team_uuid IS NULL AND user_uuid = $2) OR is_public = TRUE) + AND ($3::boolean IS NULL OR is_public = $3) + AND deleted_at IS NULL + ORDER BY usage_count DESC, created_at DESC + LIMIT $4 OFFSET $5 + "#, + ) + .bind(team_uuid) + .bind(user_uuid) + .bind(is_public) + .bind(limit) + .bind(offset) + .fetch_all(pool) + .await?; + + Ok(recs) +} + +/// 查询模板总数 +pub async fn fetch_templates_count( + pool: &Pool, + team_uuid: Option, + user_uuid: Uuid, + is_public: Option, +) -> Result { + let count: i64 = sqlx::query_scalar( + r#" + SELECT COUNT(*) + FROM templates + WHERE (team_uuid = $1 OR (team_uuid IS NULL AND user_uuid = $2) OR is_public = TRUE) + AND ($3::boolean IS NULL OR is_public = $3) + AND deleted_at IS NULL + "#, + ) + .bind(team_uuid) + .bind(user_uuid) + .bind(is_public) + .fetch_one(pool) + .await?; + + Ok(count) +} + +/// 根据 UUID 查询模板 +pub async fn fetch_template_by_uuid( + pool: &Pool, + template_uuid: Uuid, +) -> Result, Error> { + let rec = sqlx::query_as::<_, TemplateDto>( + r#" + SELECT id, uuid, user_uuid, team_uuid, name, description, is_public, + system_info, kernel_info, config_json, usage_count, created_at, updated_at, deleted_at + FROM templates + WHERE uuid = $1 AND deleted_at IS NULL + "#, + ) + .bind(template_uuid) + .fetch_optional(pool) + .await?; + + Ok(rec) +} + +/// 更新模板 +pub async fn update_template( + pool: &Pool, + template_uuid: Uuid, + name: Option<&str>, + description: Option<&str>, + is_public: Option, + config_json: Option<&serde_json::Value>, +) -> Result<(), Error> { + sqlx::query( + r#" + UPDATE templates + SET name = COALESCE($1, name), + description = COALESCE($2, description), + is_public = COALESCE($3, is_public), + config_json = COALESCE($4, config_json) + WHERE uuid = $5 AND deleted_at IS NULL + "#, + ) + .bind(name) + .bind(description) + .bind(is_public) + .bind(config_json) + .bind(template_uuid) + .execute(pool) + .await?; + + Ok(()) +} + +/// 增加模板使用次数 +pub async fn increment_template_usage( + pool: &Pool, + template_uuid: Uuid, +) -> Result<(), Error> { + sqlx::query( + r#" + UPDATE templates SET usage_count = usage_count + 1 + WHERE uuid = $1 AND deleted_at IS NULL + "#, + ) + .bind(template_uuid) + .execute(pool) + .await?; + + Ok(()) +} + +/// 软删除模板 +pub async fn delete_template(pool: &Pool, template_uuid: Uuid) -> Result<(), Error> { + sqlx::query( + r#" + UPDATE templates SET deleted_at = CURRENT_TIMESTAMP + WHERE uuid = $1 AND deleted_at IS NULL + "#, + ) + .bind(template_uuid) + .execute(pool) + .await?; + + Ok(()) +} + +// ============ Environment URLs ============ + +/// 添加环境 URL +pub async fn insert_environment_url( + pool: &Pool, + env_uuid: Uuid, + url: &str, + title: Option<&str>, + sort_order: Option, +) -> Result { + let id: i32 = sqlx::query_scalar( + r#" + INSERT INTO environment_urls (environment_uuid, url, title, sort_order) + VALUES ($1, $2, $3, $4) + RETURNING id; + "#, + ) + .bind(env_uuid) + .bind(url) + .bind(title) + .bind(sort_order.unwrap_or(0)) + .fetch_one(pool) + .await?; + + Ok(id) +} + +/// 批量添加环境 URL +pub async fn batch_insert_environment_urls( + pool: &Pool, + env_uuid: Uuid, + urls: &[(String, Option)], +) -> Result { + let mut count = 0; + for (idx, (url, title)) in urls.iter().enumerate() { + sqlx::query( + r#" + INSERT INTO environment_urls (environment_uuid, url, title, sort_order) + VALUES ($1, $2, $3, $4) + ON CONFLICT DO NOTHING; + "#, + ) + .bind(env_uuid) + .bind(url) + .bind(title.as_deref()) + .bind(idx as i32) + .execute(pool) + .await?; + count += 1; + } + Ok(count) +} + +/// 查询环境的所有 URL +pub async fn fetch_environment_urls( + pool: &Pool, + env_uuid: Uuid, +) -> Result, Error> { + let recs = sqlx::query_as::<_, EnvironmentUrlDto>( + r#" + SELECT id, environment_uuid, url, title, sort_order, created_at + FROM environment_urls + WHERE environment_uuid = $1 + ORDER BY sort_order, id + "#, + ) + .bind(env_uuid) + .fetch_all(pool) + .await?; + + Ok(recs) +} + +/// 批量查询环境的所有 URL +pub async fn fetch_environment_urls_by_uuids( + pool: &Pool, + env_uuids: &[Uuid], +) -> Result, Error> { + if env_uuids.is_empty() { + return Ok(vec![]); + } + + let recs = sqlx::query_as::<_, EnvironmentUrlDto>( + r#" + SELECT id, environment_uuid, url, title, sort_order, created_at + FROM environment_urls + WHERE environment_uuid = ANY($1) + ORDER BY environment_uuid, sort_order, id + "#, + ) + .bind(env_uuids) + .fetch_all(pool) + .await?; + + Ok(recs) +} + +/// 删除环境的 URL +pub async fn delete_environment_url(pool: &Pool, url_id: i32) -> Result<(), Error> { + sqlx::query( + r#" + DELETE FROM environment_urls WHERE id = $1 + "#, + ) + .bind(url_id) + .execute(pool) + .await?; + + Ok(()) +} + +/// 清空环境的所有 URL +pub async fn clear_environment_urls(pool: &Pool, env_uuid: Uuid) -> Result { + let result = sqlx::query( + r#" + DELETE FROM environment_urls WHERE environment_uuid = $1 + "#, + ) + .bind(env_uuid) + .execute(pool) + .await?; + + Ok(result.rows_affected()) +} + +// ============ Environment Cookies ============ + +/// 添加环境 Cookie +pub async fn insert_environment_cookie( + pool: &Pool, + env_uuid: Uuid, + site_input: &str, + domain: &str, + name: &str, + value: &str, + path: Option<&str>, + http_only: Option, + secure: Option, + same_site: Option<&str>, +) -> Result { + let id: i32 = sqlx::query_scalar( + r#" + INSERT INTO environment_cookies (environment_uuid, site_input, domain, name, value, path, http_only, secure, same_site) + VALUES ($1, $2, $3, $4, $5, $6, $7, $8, $9) + RETURNING id; + "#, + ) + .bind(env_uuid) + .bind(site_input) + .bind(domain) + .bind(name) + .bind(value) + .bind(path.unwrap_or("/")) + .bind(http_only.unwrap_or(false)) + .bind(secure.unwrap_or(false)) + .bind(same_site.unwrap_or("Lax")) + .fetch_one(pool) + .await?; + + Ok(id) +} + +/// 批量添加环境 Cookies +pub async fn batch_insert_environment_cookies( + pool: &Pool, + env_uuid: Uuid, + cookies: &[CookieInput], +) -> Result { + let mut count = 0; + for cookie in cookies { + sqlx::query( + r#" + INSERT INTO environment_cookies (environment_uuid, site_input, domain, name, value, path, http_only, secure, same_site) + VALUES ($1, $2, $3, $4, $5, $6, $7, $8, $9) + ON CONFLICT DO NOTHING; + "#, + ) + .bind(env_uuid) + .bind(&cookie.site_input) + .bind(&cookie.domain) + .bind(&cookie.name) + .bind(&cookie.value) + .bind(cookie.path.as_deref().unwrap_or("/")) + .bind(cookie.http_only.unwrap_or(false)) + .bind(cookie.secure.unwrap_or(false)) + .bind(cookie.same_site.as_deref().unwrap_or("Lax")) + .execute(pool) + .await?; + count += 1; + } + Ok(count) +} + +/// 查询环境的所有 Cookies +pub async fn fetch_environment_cookies( + pool: &Pool, + env_uuid: Uuid, +) -> Result, Error> { + let recs = sqlx::query_as::<_, EnvironmentCookieDto>( + r#" + SELECT id, environment_uuid, site_input, domain, name, value, path, expires_at, http_only, secure, same_site, created_at + FROM environment_cookies + WHERE environment_uuid = $1 + ORDER BY site_input, domain, name + "#, + ) + .bind(env_uuid) + .fetch_all(pool) + .await?; + + Ok(recs) +} + +/// 批量查询环境的所有 Cookies +pub async fn fetch_environment_cookies_by_uuids( + pool: &Pool, + env_uuids: &[Uuid], +) -> Result, Error> { + if env_uuids.is_empty() { + return Ok(vec![]); + } + + let recs = sqlx::query_as::<_, EnvironmentCookieDto>( + r#" + SELECT id, environment_uuid, site_input, domain, name, value, path, expires_at, http_only, secure, same_site, created_at + FROM environment_cookies + WHERE environment_uuid = ANY($1) + ORDER BY environment_uuid, site_input, domain, name + "#, + ) + .bind(env_uuids) + .fetch_all(pool) + .await?; + + Ok(recs) +} + +/// 删除环境的 Cookie +pub async fn delete_environment_cookie(pool: &Pool, cookie_id: i32) -> Result<(), Error> { + sqlx::query( + r#" + DELETE FROM environment_cookies WHERE id = $1 + "#, + ) + .bind(cookie_id) + .execute(pool) + .await?; + + Ok(()) +} + +/// 清空环境的所有 Cookies +pub async fn clear_environment_cookies( + pool: &Pool, + env_uuid: Uuid, +) -> Result { + let result = sqlx::query( + r#" + DELETE FROM environment_cookies WHERE environment_uuid = $1 + "#, + ) + .bind(env_uuid) + .execute(pool) + .await?; + + Ok(result.rows_affected()) +} diff --git a/src-tauri/crates/business/src/models/extensions.rs b/src-tauri/crates/business/src/models/extensions.rs new file mode 100644 index 0000000..b189eb4 --- /dev/null +++ b/src-tauri/crates/business/src/models/extensions.rs @@ -0,0 +1,614 @@ +use sqlx::Error; +use uuid::Uuid; + +use crate::database::{Db as Postgres, Pool}; + +use crate::entitys::extensions::{CreateExtensionParams, UpdateExtensionParams}; +use crate::dto::{ + ExtensionDto, GroupExtensionDto, TeamExtensionDto, UserExtensionDto, +}; + +// ============ Extensions CRUD ============ + +/// 创建扩展 +pub async fn create_extension( + pool: &Pool, + params: CreateExtensionParams, +) -> Result { + let rec = sqlx::query_as::<_, ExtensionDto>( + r#" + INSERT INTO extensions ( + extension_id, name, description, version, category, browser, + developer, homepage, icon_url, download_url, file_size, downloads_count, + permissions, rating, changelog, published_at, hash, status + ) + VALUES ($1, $2, $3, $4, $5, $6, $7, $8, $9, $10, $11, $12, $13, $14, $15, $16, $17, 'active') + RETURNING id, uuid, extension_id, name, description, version, category, browser, + developer, homepage, icon_url, download_url, file_size, downloads_count, + rating, permissions, status, changelog, published_at, hash, created_at, updated_at + "#, + ) + .bind(¶ms.extension_id) + .bind(¶ms.name) + .bind(¶ms.description) + .bind(¶ms.version) + .bind(¶ms.category) + .bind(¶ms.browser) + .bind(¶ms.developer) + .bind(¶ms.homepage) + .bind(¶ms.icon_url) + .bind(¶ms.download_url) + .bind(params.file_size) + .bind(params.downloads_count) + .bind(¶ms.permissions) + .bind(params.rating) + .bind(¶ms.changelog) + .bind(params.published_at) + .bind(¶ms.hash) + .fetch_one(pool) + .await?; + + Ok(rec) +} + +/// 更新扩展 +pub async fn update_extension( + pool: &Pool, + extension_id: &str, + params: UpdateExtensionParams, +) -> Result<(), Error> { + sqlx::query( + r#" + UPDATE extensions SET + name = COALESCE($2, name), + description = COALESCE($3, description), + version = COALESCE($4, version), + category = COALESCE($5, category), + developer = COALESCE($6, developer), + homepage = COALESCE($7, homepage), + icon_url = COALESCE($8, icon_url), + download_url = COALESCE($9, download_url), + file_size = COALESCE($10, file_size), + downloads_count = COALESCE($11, downloads_count), + permissions = COALESCE($12, permissions), + rating = COALESCE($13, rating), + changelog = COALESCE($14, changelog), + published_at = COALESCE($15, published_at), + hash = COALESCE($16, hash), + updated_at = CURRENT_TIMESTAMP + WHERE extension_id = $1 + "#, + ) + .bind(extension_id) + .bind(params.name) + .bind(params.description) + .bind(params.version) + .bind(params.category) + .bind(params.developer) + .bind(params.homepage) + .bind(params.icon_url) + .bind(params.download_url) + .bind(params.file_size) + .bind(params.downloads_count) + .bind(params.permissions) + .bind(params.rating) + .bind(params.changelog) + .bind(params.published_at) + .bind(params.hash) + .execute(pool) + .await?; + + Ok(()) +} + +/// 根据 extension_id 获取扩展(别名函数,用于同步检查) +pub async fn get_extension_by_extension_id( + pool: &Pool, + extension_id: &str, +) -> Result, Error> { + fetch_extension_by_id(pool, extension_id).await +} + +// ============ Extensions Query ============ + +/// 查询扩展列表 +pub async fn fetch_extensions( + pool: &Pool, + keyword: Option<&str>, + category: Option<&str>, + sort_by: Option<&str>, + sort_order: Option<&str>, + offset: i64, + limit: i64, +) -> Result, Error> { + let order_clause = match ( + sort_by.unwrap_or("downloads"), + sort_order.unwrap_or("desc").to_ascii_lowercase().as_str(), + ) { + ("rating", "asc") => "rating ASC NULLS LAST, created_at DESC", + ("rating", _) => "rating DESC NULLS LAST, created_at DESC", + ("name", "asc") => "name ASC, created_at DESC", + ("name", _) => "name DESC, created_at DESC", + ("newest", "asc") => "updated_at ASC NULLS LAST, created_at ASC", + ("newest", _) => "updated_at DESC NULLS LAST, created_at DESC", + ("downloads", "asc") => "downloads_count ASC NULLS LAST, created_at DESC", + _ => "downloads_count DESC NULLS LAST, created_at DESC", + }; + + let query = format!( + r#" + SELECT id, uuid, extension_id, name, description, version, category, browser, + developer, homepage, icon_url, download_url, file_size, downloads_count, + rating, permissions, status, changelog, published_at, hash, created_at, updated_at + FROM extensions + WHERE status = 'active' + AND ($1::varchar IS NULL OR name ILIKE '%' || $1 || '%' OR description ILIKE '%' || $1 || '%') + AND ($2::varchar IS NULL OR category = $2) + ORDER BY {} + LIMIT $3 OFFSET $4 + "#, + order_clause + ); + + let recs = sqlx::query_as::<_, ExtensionDto>(&query) + .bind(keyword) + .bind(category) + .bind(limit) + .bind(offset) + .fetch_all(pool) + .await?; + + Ok(recs) +} + +/// 查询扩展总数 +pub async fn fetch_extensions_count( + pool: &Pool, + keyword: Option<&str>, + category: Option<&str>, +) -> Result { + let count: i64 = sqlx::query_scalar( + r#" + SELECT COUNT(*) FROM extensions + WHERE status = 'active' + AND ($1::varchar IS NULL OR name ILIKE '%' || $1 || '%' OR description ILIKE '%' || $1 || '%') + AND ($2::varchar IS NULL OR category = $2) + "#, + ) + .bind(keyword) + .bind(category) + .fetch_one(pool) + .await?; + + Ok(count) +} + +/// 根据 extension_id 查询扩展 +pub async fn fetch_extension_by_id( + pool: &Pool, + extension_id: &str, +) -> Result, Error> { + let rec = sqlx::query_as::<_, ExtensionDto>( + r#" + SELECT id, uuid, extension_id, name, description, version, category, browser, + developer, homepage, icon_url, download_url, file_size, downloads_count, + rating, permissions, status, changelog, published_at, hash, created_at, updated_at + FROM extensions + WHERE extension_id = $1 + "#, + ) + .bind(extension_id) + .fetch_optional(pool) + .await?; + + Ok(rec) +} + +/// 获取扩展分类列表 +pub async fn fetch_extension_categories(pool: &Pool) -> Result, Error> { + let categories: Vec<(String,)> = sqlx::query_as( + r#" + SELECT DISTINCT category FROM extensions + WHERE status = 'active' + ORDER BY category + "#, + ) + .fetch_all(pool) + .await?; + + Ok(categories.into_iter().map(|(c,)| c).collect()) +} + +// ============ User Extensions ============ + +/// 查询用户已安装的扩展 +pub async fn fetch_user_extensions( + pool: &Pool, + user_uuid: Uuid, +) -> Result, Error> { + let recs = sqlx::query_as::<_, UserExtensionDto>( + r#" + SELECT id, user_uuid, extension_id, installed_version, status, installed_at, updated_at + FROM user_extensions + WHERE user_uuid = $1 AND status = 'active' + ORDER BY installed_at DESC + "#, + ) + .bind(user_uuid) + .fetch_all(pool) + .await?; + + Ok(recs) +} + +/// 安装用户扩展 +pub async fn insert_user_extension( + pool: &Pool, + user_uuid: Uuid, + extension_id: &str, + version: &str, +) -> Result { + let id: i32 = sqlx::query_scalar( + r#" + INSERT INTO user_extensions (user_uuid, extension_id, installed_version, status) + VALUES ($1, $2, $3, 'active') + ON CONFLICT (user_uuid, extension_id) DO UPDATE SET + installed_version = $3, + status = 'active', + updated_at = CURRENT_TIMESTAMP + RETURNING id; + "#, + ) + .bind(user_uuid) + .bind(extension_id) + .bind(version) + .fetch_one(pool) + .await?; + + Ok(id) +} + +/// 卸载用户扩展 +pub async fn delete_user_extension( + pool: &Pool, + user_uuid: Uuid, + extension_id: &str, +) -> Result<(), Error> { + sqlx::query( + r#" + UPDATE user_extensions SET status = 'inactive', updated_at = CURRENT_TIMESTAMP + WHERE user_uuid = $1 AND extension_id = $2 + "#, + ) + .bind(user_uuid) + .bind(extension_id) + .execute(pool) + .await?; + + Ok(()) +} + +// ============ Team Extensions ============ + +/// 查询团队已安装的扩展 +pub async fn fetch_team_extensions( + pool: &Pool, + team_uuid: Uuid, +) -> Result, Error> { + let recs = sqlx::query_as::<_, TeamExtensionDto>( + r#" + SELECT id, team_uuid, extension_id, installed_version, installed_by, status, installed_at, updated_at + FROM team_extensions + WHERE team_uuid = $1 AND status = 'active' + ORDER BY installed_at DESC + "#, + ) + .bind(team_uuid) + .fetch_all(pool) + .await?; + + Ok(recs) +} + +/// 安装团队扩展 +pub async fn insert_team_extension( + pool: &Pool, + team_uuid: Uuid, + extension_id: &str, + version: &str, + installed_by: Uuid, +) -> Result { + let id: i32 = sqlx::query_scalar( + r#" + INSERT INTO team_extensions (team_uuid, extension_id, installed_version, installed_by, status) + VALUES ($1, $2, $3, $4, 'active') + ON CONFLICT (team_uuid, extension_id) DO UPDATE SET + installed_version = $3, + installed_by = $4, + status = 'active', + updated_at = CURRENT_TIMESTAMP + RETURNING id; + "#, + ) + .bind(team_uuid) + .bind(extension_id) + .bind(version) + .bind(installed_by) + .fetch_one(pool) + .await?; + + Ok(id) +} + +/// 卸载团队扩展 +pub async fn delete_team_extension( + pool: &Pool, + team_uuid: Uuid, + extension_id: &str, +) -> Result<(), Error> { + sqlx::query( + r#" + UPDATE team_extensions SET status = 'inactive', updated_at = CURRENT_TIMESTAMP + WHERE team_uuid = $1 AND extension_id = $2 + "#, + ) + .bind(team_uuid) + .bind(extension_id) + .execute(pool) + .await?; + + Ok(()) +} + +// ============ Group Extensions ============ + +/// 查询分组已安装的扩展 +pub async fn fetch_group_extensions( + pool: &Pool, + group_uuid: Uuid, +) -> Result, Error> { + let recs = sqlx::query_as::<_, GroupExtensionDto>( + r#" + SELECT id, group_uuid, extension_id, installed_version, installed_by, status, is_team_shared, installed_at, updated_at + FROM group_extensions + WHERE group_uuid = $1 AND status = 'active' + ORDER BY installed_at DESC + "#, + ) + .bind(group_uuid) + .fetch_all(pool) + .await?; + + Ok(recs) +} + +/// 安装分组扩展 +pub async fn insert_group_extension( + pool: &Pool, + group_uuid: Uuid, + extension_id: &str, + version: &str, + installed_by: Uuid, + is_team_shared: bool, +) -> Result { + let id: i32 = sqlx::query_scalar( + r#" + INSERT INTO group_extensions (group_uuid, extension_id, installed_version, installed_by, is_team_shared, status) + VALUES ($1, $2, $3, $4, $5, 'active') + ON CONFLICT (group_uuid, extension_id) DO UPDATE SET + installed_version = $3, + installed_by = $4, + is_team_shared = $5, + status = 'active', + updated_at = CURRENT_TIMESTAMP + RETURNING id; + "#, + ) + .bind(group_uuid) + .bind(extension_id) + .bind(version) + .bind(installed_by) + .bind(is_team_shared) + .fetch_one(pool) + .await?; + + Ok(id) +} + +/// 卸载分组扩展(指定分组) +pub async fn delete_group_extension( + pool: &Pool, + group_uuid: Uuid, + extension_id: &str, +) -> Result<(), Error> { + sqlx::query( + r#" + UPDATE group_extensions SET status = 'inactive', updated_at = CURRENT_TIMESTAMP + WHERE group_uuid = $1 AND extension_id = $2 + "#, + ) + .bind(group_uuid) + .bind(extension_id) + .execute(pool) + .await?; + + Ok(()) +} + +/// 卸载分组扩展(根据扩展 ID 删除所有相关分组记录) +pub async fn delete_group_extensions_by_extension_id( + pool: &Pool, + extension_id: &str, +) -> Result<(), Error> { + sqlx::query( + r#" + UPDATE group_extensions SET status = 'inactive', updated_at = CURRENT_TIMESTAMP + WHERE extension_id = $1 AND status = 'active' + "#, + ) + .bind(extension_id) + .execute(pool) + .await?; + + Ok(()) +} + +/// 根据扩展 ID 查询所有关联的分组 UUID +pub async fn fetch_group_uuids_by_extension_id( + pool: &Pool, + extension_id: &str, +) -> Result, Error> { + let recs = sqlx::query_scalar::<_, Uuid>( + r#" + SELECT DISTINCT group_uuid + FROM group_extensions + WHERE extension_id = $1 AND status = 'active' + "#, + ) + .bind(extension_id) + .fetch_all(pool) + .await?; + + Ok(recs) +} + +/// 根据扩展 ID 查询团队共享的分组 UUID +pub async fn fetch_team_shared_group_uuids_by_extension_id( + pool: &Pool, + extension_id: &str, +) -> Result, Error> { + let recs = sqlx::query_scalar::<_, Uuid>( + r#" + SELECT DISTINCT group_uuid + FROM group_extensions + WHERE extension_id = $1 AND status = 'active' AND is_team_shared = true + "#, + ) + .bind(extension_id) + .fetch_all(pool) + .await?; + + Ok(recs) +} + +/// 查询用户相关的分组中安装的扩展 +/// 包括用户个人分组(无 team_uuid,由用户创建)和用户所在团队的分组 +pub async fn fetch_user_group_extensions( + pool: &Pool, + user_uuid: Uuid, + team_uuid: Option, +) -> Result, Error> { + let recs = sqlx::query_as::<_, GroupExtensionDto>( + r#" + SELECT ge.id, ge.group_uuid, ge.extension_id, ge.installed_version, ge.installed_by, ge.status, ge.is_team_shared, ge.installed_at, ge.updated_at + FROM group_extensions ge + INNER JOIN groups g ON ge.group_uuid = g.uuid + WHERE ge.status = 'active' + AND g.deleted_at IS NULL + AND (g.team_uuid = $1 OR (g.team_uuid IS NULL AND g.created_by = $2)) + ORDER BY ge.installed_at DESC + "#, + ) + .bind(team_uuid) + .bind(user_uuid) + .fetch_all(pool) + .await?; + + Ok(recs) +} + +/// 查询团队相关的分组中安装的扩展 +pub async fn fetch_team_group_extensions( + pool: &Pool, + team_uuid: Uuid, +) -> Result, Error> { + let recs = sqlx::query_as::<_, GroupExtensionDto>( + r#" + SELECT ge.id, ge.group_uuid, ge.extension_id, ge.installed_version, ge.installed_by, ge.status, ge.is_team_shared, ge.installed_at, ge.updated_at + FROM group_extensions ge + INNER JOIN groups g ON ge.group_uuid = g.uuid + WHERE ge.status = 'active' + AND ge.is_team_shared = true + AND g.deleted_at IS NULL + AND g.team_uuid = $1 + ORDER BY ge.installed_at DESC + "#, + ) + .bind(team_uuid) + .fetch_all(pool) + .await?; + + Ok(recs) +} + +// ============ User Team Extension Preferences ============ + +/// 查询用户禁用的团队插件列表 +pub async fn fetch_user_disabled_team_extensions( + pool: &Pool, + user_uuid: Uuid, + team_uuid: Uuid, +) -> Result, Error> { + let recs = sqlx::query_scalar::<_, String>( + r#" + SELECT extension_id + FROM user_team_extension_preferences + WHERE user_uuid = $1 AND team_uuid = $2 AND is_disabled = true + "#, + ) + .bind(user_uuid) + .bind(team_uuid) + .fetch_all(pool) + .await?; + + Ok(recs) +} + +/// 设置用户对团队插件的禁用状态 +pub async fn set_user_team_extension_preference( + pool: &Pool, + user_uuid: Uuid, + team_uuid: Uuid, + extension_id: &str, + is_disabled: bool, +) -> Result<(), Error> { + sqlx::query( + r#" + INSERT INTO user_team_extension_preferences (user_uuid, team_uuid, extension_id, is_disabled) + VALUES ($1, $2, $3, $4) + ON CONFLICT (user_uuid, team_uuid, extension_id) DO UPDATE SET + is_disabled = $4, + updated_at = CURRENT_TIMESTAMP + "#, + ) + .bind(user_uuid) + .bind(team_uuid) + .bind(extension_id) + .bind(is_disabled) + .execute(pool) + .await?; + + Ok(()) +} + +/// 删除用户对团队插件的偏好设置 +pub async fn delete_user_team_extension_preference( + pool: &Pool, + user_uuid: Uuid, + team_uuid: Uuid, + extension_id: &str, +) -> Result<(), Error> { + sqlx::query( + r#" + DELETE FROM user_team_extension_preferences + WHERE user_uuid = $1 AND team_uuid = $2 AND extension_id = $3 + "#, + ) + .bind(user_uuid) + .bind(team_uuid) + .bind(extension_id) + .execute(pool) + .await?; + + Ok(()) +} diff --git a/src-tauri/crates/business/src/models/group_member_permissions.rs b/src-tauri/crates/business/src/models/group_member_permissions.rs new file mode 100644 index 0000000..2922ea4 --- /dev/null +++ b/src-tauri/crates/business/src/models/group_member_permissions.rs @@ -0,0 +1,199 @@ +use sqlx::Error; +use uuid::Uuid; + +use crate::database::{Db as Postgres, Pool}; + +use crate::dto::GroupMemberPermissionDto; + +/// 授予分组权限 +pub async fn grant_group_permission( + pool: &Pool, + group_uuid: Uuid, + workspace_uuid: Uuid, + team_uuid: Uuid, + user_uuid: Uuid, + permission_type: &str, + granted_by: Uuid, +) -> Result<(), Error> { + sqlx::query( + r#" + INSERT INTO group_member_permissions ( + group_uuid, workspace_uuid, team_uuid, user_uuid, permission_type, granted_by + ) + VALUES ($1, $2, $3, $4, $5, $6) + ON CONFLICT (group_uuid, user_uuid) DO UPDATE SET + permission_type = EXCLUDED.permission_type, + granted_by = EXCLUDED.granted_by, + updated_at = CURRENT_TIMESTAMP + "#, + ) + .bind(group_uuid) + .bind(workspace_uuid) + .bind(team_uuid) + .bind(user_uuid) + .bind(permission_type) + .bind(granted_by) + .execute(pool) + .await?; + + Ok(()) +} + +/// 撤销分组权限 +pub async fn revoke_group_permission( + pool: &Pool, + group_uuid: Uuid, + user_uuid: Uuid, +) -> Result<(), Error> { + sqlx::query( + r#" + DELETE FROM group_member_permissions + WHERE group_uuid = $1 AND user_uuid = $2 + "#, + ) + .bind(group_uuid) + .bind(user_uuid) + .execute(pool) + .await?; + + Ok(()) +} + +/// 查询用户的分组权限列表 +pub async fn fetch_user_group_permissions( + pool: &Pool, + user_uuid: Uuid, + workspace_uuid: Option, + group_uuid: Option, +) -> Result, Error> { + let recs = sqlx::query_as::<_, GroupMemberPermissionDto>( + r#" + SELECT group_uuid, workspace_uuid, team_uuid, user_uuid, permission_type, granted_by, + created_at, updated_at + FROM group_member_permissions + WHERE user_uuid = $1 + AND ($2::uuid IS NULL OR workspace_uuid = $2) + AND ($3::uuid IS NULL OR group_uuid = $3) + ORDER BY created_at DESC + "#, + ) + .bind(user_uuid) + .bind(workspace_uuid) + .bind(group_uuid) + .fetch_all(pool) + .await?; + + Ok(recs) +} + +/// 检查用户是否有分组权限(工作空间级别) +pub async fn check_group_permission( + pool: &Pool, + workspace_uuid: Uuid, + group_uuid: Uuid, + user_uuid: Uuid, + permission_type: &str, +) -> Result { + // 首先检查用户是否是团队成员,以及是否是 Owner/Admin(自动拥有所有权限) + let is_owner_or_admin: bool = sqlx::query_scalar( + r#" + SELECT EXISTS ( + SELECT 1 FROM team_members tm + INNER JOIN groups g ON tm.team_uuid = g.team_uuid AND tm.workspace_uuid = g.workspace_uuid + WHERE g.uuid = $1 + AND g.workspace_uuid = $2 + AND tm.workspace_uuid = $2 + AND tm.user_uuid = $3 + AND tm.role IN ('owner', 'admin') + AND tm.deleted_at IS NULL + ) + "#, + ) + .bind(group_uuid) + .bind(workspace_uuid) + .bind(user_uuid) + .fetch_one(pool) + .await?; + + if is_owner_or_admin { + return Ok(true); + } + + // 检查显式权限(工作空间级别) + let has_permission = match permission_type { + "read" => { + // read 权限:检查是否有 read/write/manage 任一权限 + sqlx::query_scalar::<_, bool>( + r#" + SELECT EXISTS ( + SELECT 1 FROM group_member_permissions + WHERE group_uuid = $1 AND workspace_uuid = $2 AND user_uuid = $3 + ) + "#, + ) + .bind(group_uuid) + .bind(workspace_uuid) + .bind(user_uuid) + .fetch_one(pool) + .await? + } + "write" => { + // write 权限:检查是否有 write/manage 权限 + sqlx::query_scalar::<_, bool>( + r#" + SELECT EXISTS ( + SELECT 1 FROM group_member_permissions + WHERE group_uuid = $1 AND workspace_uuid = $2 AND user_uuid = $3 + AND permission_type IN ('write', 'manage') + ) + "#, + ) + .bind(group_uuid) + .bind(workspace_uuid) + .bind(user_uuid) + .fetch_one(pool) + .await? + } + "manage" => { + // manage 权限:检查是否有 manage 权限 + sqlx::query_scalar::<_, bool>( + r#" + SELECT EXISTS ( + SELECT 1 FROM group_member_permissions + WHERE group_uuid = $1 AND workspace_uuid = $2 AND user_uuid = $3 + AND permission_type = 'manage' + ) + "#, + ) + .bind(group_uuid) + .bind(workspace_uuid) + .bind(user_uuid) + .fetch_one(pool) + .await? + } + _ => false, + }; + + Ok(has_permission) +} + +/// 查询分组的所有权限 +pub async fn fetch_group_permissions( + pool: &Pool, + group_uuid: Uuid, +) -> Result, Error> { + let recs = sqlx::query_as::<_, GroupMemberPermissionDto>( + r#" + SELECT group_uuid, workspace_uuid, team_uuid, user_uuid, permission_type, granted_by, + created_at, updated_at + FROM group_member_permissions + WHERE group_uuid = $1 + ORDER BY created_at DESC + "#, + ) + .bind(group_uuid) + .fetch_all(pool) + .await?; + + Ok(recs) +} diff --git a/src-tauri/crates/business/src/models/local_api.rs b/src-tauri/crates/business/src/models/local_api.rs new file mode 100644 index 0000000..16601e3 --- /dev/null +++ b/src-tauri/crates/business/src/models/local_api.rs @@ -0,0 +1,343 @@ +use chrono::{DateTime, Utc}; +use serde_json::{Value, json}; +use sha2::{Digest, Sha256}; +use sqlx::Error; +use uuid::Uuid; + +use crate::database::{Db as Postgres, Pool}; + +use crate::dto::{ + LocalApiKeyDto, LocalApiKeyPermissionDto, LocalApiPermissionDefinitionDto, LocalApiSettingsDto, +}; + +pub async fn fetch_local_api_settings( + pool: &Pool, + user_uuid: Uuid, +) -> Result, Error> { + sqlx::query_as::<_, LocalApiSettingsDto>( + r#" + SELECT id, uuid, user_uuid, enabled, port, remote_access, cors_origins, created_at, updated_at, deleted_at + FROM user_local_api_settings + WHERE user_uuid = $1 AND deleted_at IS NULL + "#, + ) + .bind(user_uuid) + .fetch_optional(pool) + .await +} + +pub async fn upsert_local_api_settings( + pool: &Pool, + user_uuid: Uuid, + enabled: Option, + port: Option, + remote_access: Option, + cors_origins: Option<&Value>, +) -> Result<(), Error> { + sqlx::query( + r#" + INSERT INTO user_local_api_settings (user_uuid, enabled, port, remote_access, cors_origins) + VALUES ($1, COALESCE($2, FALSE), COALESCE($3, 8080), COALESCE($4, FALSE), COALESCE($5, '[]'::jsonb)) + ON CONFLICT (user_uuid) DO UPDATE SET + enabled = COALESCE($2, user_local_api_settings.enabled), + port = COALESCE($3, user_local_api_settings.port), + remote_access = COALESCE($4, user_local_api_settings.remote_access), + cors_origins = COALESCE($5, user_local_api_settings.cors_origins), + updated_at = CURRENT_TIMESTAMP + "#, + ) + .bind(user_uuid) + .bind(enabled) + .bind(port) + .bind(remote_access) + .bind(cors_origins) + .execute(pool) + .await?; + + Ok(()) +} + +pub async fn fetch_active_api_key( + pool: &Pool, + user_uuid: Uuid, +) -> Result, Error> { + sqlx::query_as::<_, LocalApiKeyDto>( + r#" + SELECT + id, uuid, user_uuid, key_prefix, key_hash, api_key, is_active, requests_today, + daily_limit, last_reset_date, last_used_at, expires_at, created_at, updated_at, deleted_at + FROM user_local_api_keys + WHERE user_uuid = $1 AND is_active = TRUE AND deleted_at IS NULL + ORDER BY id DESC + LIMIT 1 + "#, + ) + .bind(user_uuid) + .fetch_optional(pool) + .await +} + +pub async fn fetch_api_key_by_hash( + pool: &Pool, + key_hash: &str, +) -> Result, Error> { + sqlx::query_as::<_, LocalApiKeyDto>( + r#" + SELECT + id, uuid, user_uuid, key_prefix, key_hash, api_key, is_active, requests_today, + daily_limit, last_reset_date, last_used_at, expires_at, created_at, updated_at, deleted_at + FROM user_local_api_keys + WHERE key_hash = $1 AND is_active = TRUE AND deleted_at IS NULL + LIMIT 1 + "#, + ) + .bind(key_hash) + .fetch_optional(pool) + .await +} + +pub async fn deactivate_api_keys_for_user(pool: &Pool, user_uuid: Uuid) -> Result<(), Error> { + sqlx::query( + r#" + UPDATE user_local_api_keys + SET is_active = FALSE, updated_at = CURRENT_TIMESTAMP + WHERE user_uuid = $1 AND is_active = TRUE AND deleted_at IS NULL + "#, + ) + .bind(user_uuid) + .execute(pool) + .await?; + + Ok(()) +} + +pub async fn insert_api_key( + pool: &Pool, + user_uuid: Uuid, + key_prefix: &str, + key_hash: &str, + api_key: &str, + daily_limit: i32, +) -> Result { + sqlx::query_as::<_, LocalApiKeyDto>( + r#" + INSERT INTO user_local_api_keys (user_uuid, key_prefix, key_hash, api_key, daily_limit) + VALUES ($1, $2, $3, $4, $5) + RETURNING + id, uuid, user_uuid, key_prefix, key_hash, api_key, is_active, requests_today, + daily_limit, last_reset_date, last_used_at, expires_at, created_at, updated_at, deleted_at + "#, + ) + .bind(user_uuid) + .bind(key_prefix) + .bind(key_hash) + .bind(api_key) + .bind(daily_limit) + .fetch_one(pool) + .await +} + +pub async fn fetch_api_key_permission( + pool: &Pool, + api_key_id: i32, + permission_code: &str, +) -> Result, Error> { + sqlx::query_as::<_, LocalApiKeyPermissionDto>( + r#" + SELECT + id, uuid, api_key_id, permission_code, is_enabled, rate_limit_per_minute, + rate_limit_per_hour, created_at, updated_at, deleted_at + FROM user_local_api_key_permissions + WHERE api_key_id = $1 + AND permission_code = $2 + AND deleted_at IS NULL + LIMIT 1 + "#, + ) + .bind(api_key_id) + .bind(permission_code) + .fetch_optional(pool) + .await +} + +pub async fn insert_api_key_permission( + pool: &Pool, + api_key_id: i32, + permission_code: &str, + rate_limit_per_minute: i32, + rate_limit_per_hour: i32, +) -> Result<(), Error> { + sqlx::query( + r#" + INSERT INTO user_local_api_key_permissions ( + api_key_id, permission_code, is_enabled, rate_limit_per_minute, rate_limit_per_hour + ) + VALUES ($1, $2, TRUE, $3, $4) + ON CONFLICT (api_key_id, permission_code) DO NOTHING + "#, + ) + .bind(api_key_id) + .bind(permission_code) + .bind(rate_limit_per_minute) + .bind(rate_limit_per_hour) + .execute(pool) + .await?; + + Ok(()) +} + +pub async fn fetch_permission_definition( + pool: &Pool, + permission_code: &str, +) -> Result, Error> { + sqlx::query_as::<_, LocalApiPermissionDefinitionDto>( + r#" + SELECT + id, uuid, permission_code, name, description, default_enabled, + default_rate_limit_per_minute, default_rate_limit_per_hour, sort_order, + created_at, updated_at, deleted_at + FROM local_api_permission_definitions + WHERE permission_code = $1 AND deleted_at IS NULL + LIMIT 1 + "#, + ) + .bind(permission_code) + .fetch_optional(pool) + .await +} + +pub async fn fetch_permission_definitions( + pool: &Pool, +) -> Result, Error> { + sqlx::query_as::<_, LocalApiPermissionDefinitionDto>( + r#" + SELECT + id, uuid, permission_code, name, description, default_enabled, + default_rate_limit_per_minute, default_rate_limit_per_hour, sort_order, + created_at, updated_at, deleted_at + FROM local_api_permission_definitions + WHERE deleted_at IS NULL + ORDER BY sort_order ASC, id ASC + "#, + ) + .fetch_all(pool) + .await +} + +pub async fn reset_api_key_daily_usage( + pool: &Pool, + api_key_id: i32, + today: chrono::NaiveDate, +) -> Result<(), Error> { + sqlx::query( + r#" + UPDATE user_local_api_keys + SET requests_today = 0, last_reset_date = $2, updated_at = CURRENT_TIMESTAMP + WHERE id = $1 + "#, + ) + .bind(api_key_id) + .bind(today) + .execute(pool) + .await?; + + Ok(()) +} + +pub async fn increment_api_key_usage( + pool: &Pool, + api_key_id: i32, + used_at: DateTime, +) -> Result<(), Error> { + sqlx::query( + r#" + UPDATE user_local_api_keys + SET requests_today = requests_today + 1, last_used_at = $2, updated_at = CURRENT_TIMESTAMP + WHERE id = $1 + "#, + ) + .bind(api_key_id) + .bind(used_at) + .execute(pool) + .await?; + + Ok(()) +} + +pub async fn fetch_request_count( + pool: &Pool, + api_key_id: i32, + permission_code: &str, + window_type: &str, + window_start: DateTime, +) -> Result { + let count = sqlx::query_scalar::<_, i32>( + r#" + SELECT request_count + FROM user_local_api_request_counters + WHERE api_key_id = $1 + AND permission_code = $2 + AND window_type = $3 + AND window_start = $4 + LIMIT 1 + "#, + ) + .bind(api_key_id) + .bind(permission_code) + .bind(window_type) + .bind(window_start) + .fetch_optional(pool) + .await?; + + Ok(count.unwrap_or(0)) +} + +pub async fn increment_request_counter( + pool: &Pool, + api_key_id: i32, + permission_code: &str, + window_type: &str, + window_start: DateTime, +) -> Result<(), Error> { + sqlx::query( + r#" + INSERT INTO user_local_api_request_counters ( + api_key_id, permission_code, window_type, window_start, request_count, updated_at + ) + VALUES ($1, $2, $3, $4, 1, CURRENT_TIMESTAMP) + ON CONFLICT (api_key_id, permission_code, window_type, window_start) DO UPDATE SET + request_count = user_local_api_request_counters.request_count + 1, + updated_at = CURRENT_TIMESTAMP + "#, + ) + .bind(api_key_id) + .bind(permission_code) + .bind(window_type) + .bind(window_start) + .execute(pool) + .await?; + + Ok(()) +} + +pub fn build_cors_origins_value(origins: &[String]) -> Value { + json!(origins) +} + +pub fn parse_cors_origins(value: &Value) -> Vec { + value + .as_array() + .map(|items| { + items + .iter() + .filter_map(|item| item.as_str().map(ToOwned::to_owned)) + .collect() + }) + .unwrap_or_default() +} + +pub fn hash_api_key(api_key: &str) -> String { + let mut hasher = Sha256::new(); + hasher.update(api_key.as_bytes()); + hex::encode(hasher.finalize()) +} diff --git a/src-tauri/crates/business/src/models/maintenance.rs b/src-tauri/crates/business/src/models/maintenance.rs new file mode 100644 index 0000000..e4ed930 --- /dev/null +++ b/src-tauri/crates/business/src/models/maintenance.rs @@ -0,0 +1,180 @@ +use chrono::Utc; +use crate::database::{Db as Postgres, Pool}; + +use crate::{dto::maintenance::Maintenance, entitys::maintenance::CreateMaintenanceRequest}; + +pub async fn create_maintenance( + pool: &Pool, + request: CreateMaintenanceRequest, +) -> Result { + let now = Utc::now(); + + // 开始事务 + let mut tx = pool.begin().await?; + + // 关闭所有其他活跃的维护 + sqlx::query( + r#" + UPDATE maintenances + SET status = 'inactive', updated_at = $1 + WHERE status = 'active' + "#, + ) + .bind(now) + .execute(&mut *tx) + .await?; + + // 创建新的维护记录 + let maintenance = sqlx::query_as::<_, Maintenance>( + r#" + INSERT INTO maintenances (name, description, status, start_time, end_time, maintenance_type, created_at, updated_at) + VALUES ($1, $2, $3, $4, $5, $6, $7, $8) + RETURNING id, name, description, status, start_time, end_time, maintenance_type, created_at, updated_at + "# + ) + .bind(request.name) + .bind(request.description) + .bind(request.status) + .bind(request.start_time) + .bind(request.end_time) + .bind(String::from(request.maintenance_type)) + .bind(now) + .bind(now) + .fetch_one(&mut *tx) + .await?; + + // 提交事务 + tx.commit().await?; + + Ok(maintenance) +} + +pub async fn list_maintenances( + pool: &Pool, + limit: Option, + offset: Option, +) -> Result, sqlx::Error> { + let limit = limit.unwrap_or(50); + let offset = offset.unwrap_or(0); + + let maintenances = sqlx::query_as::<_, Maintenance>( + r#" + SELECT id, name, description, status, start_time, end_time, maintenance_type, + created_at, updated_at + FROM maintenances + ORDER BY created_at DESC + LIMIT $1 OFFSET $2 + "#, + ) + .bind(limit) + .bind(offset) + .fetch_all(pool) + .await?; + + Ok(maintenances) +} + +/// 结束维护 +pub async fn end_maintenance(pool: &Pool) -> Result { + let now = Utc::now(); + + // 关闭所有其他活跃的维护 + sqlx::query( + r#" + UPDATE maintenances + SET status = 'inactive', updated_at = $1 + WHERE status = 'active' + "#, + ) + .bind(now) + .execute(pool) + .await?; + + Ok(true) +} + +pub async fn update_maintenance_status( + pool: &Pool, + id: i64, + status: &str, +) -> Result { + let now = Utc::now(); + let result = sqlx::query( + r#" + UPDATE maintenances + SET status = $1, updated_at = $2 + WHERE id = $3 + "#, + ) + .bind(status) + .bind(now) + .bind(id) + .execute(pool) + .await?; + + Ok(result.rows_affected() > 0) +} + +pub async fn get_maintenance_by_id( + pool: &Pool, + id: i64, +) -> Result, anyhow::Error> { + let maintenance = sqlx::query_as::<_, Maintenance>( + r#" + SELECT id, name, description, status, start_time, end_time, maintenance_type, + created_at, updated_at + FROM maintenances + WHERE id = $1 + "#, + ) + .bind(id) + .fetch_optional(pool) + .await?; + + Ok(maintenance) +} + +pub async fn get_active_maintenances( + pool: &Pool, +) -> Result, anyhow::Error> { + let maintenance = sqlx::query_as::<_, Maintenance>( + r#" + SELECT id, name, description, status, start_time, end_time, maintenance_type, + created_at, updated_at + FROM maintenances + WHERE status = 'active' AND start_time <= NOW() AND end_time >= NOW() + ORDER BY created_at DESC + LIMIT 1 + "#, + ) + .fetch_optional(pool) + .await?; + + Ok(maintenance) +} + +pub async fn create_maintenances_table(pool: &Pool) -> Result<(), sqlx::Error> { + sqlx::query( + r#" + CREATE TABLE IF NOT EXISTS maintenances ( + id BIGSERIAL PRIMARY KEY, + name VARCHAR(100) NOT NULL, + description TEXT, + status VARCHAR(20) NOT NULL, + start_time TIMESTAMPTZ NOT NULL, + end_time TIMESTAMPTZ NOT NULL, + maintenance_type VARCHAR(20) NOT NULL, + created_at TIMESTAMPTZ DEFAULT NOW() NOT NULL, + updated_at TIMESTAMPTZ DEFAULT NOW() NOT NULL + ); + + CREATE INDEX IF NOT EXISTS idx_maintenances_status ON maintenances(status); + CREATE INDEX IF NOT EXISTS idx_maintenances_time ON maintenances(start_time, end_time); + CREATE INDEX IF NOT EXISTS idx_maintenances_type ON maintenances(maintenance_type); + "#, + ) + .execute(pool) + .await?; + + Ok(()) +} diff --git a/src-tauri/crates/business/src/models/messages.rs b/src-tauri/crates/business/src/models/messages.rs new file mode 100644 index 0000000..747a519 --- /dev/null +++ b/src-tauri/crates/business/src/models/messages.rs @@ -0,0 +1,413 @@ +use sqlx::Error; +use uuid::Uuid; + +use crate::database::{Db as Postgres, Pool}; + +use crate::dto::{MessageDto, UserMessageDto}; + +// ============ Messages ============ + +/// 创建消息 +pub async fn create_message( + pool: &Pool, + sender_uuid: Option, + message_type: &str, + title: &str, + content: Option<&str>, + recipient_type: &str, + related_type: Option<&str>, + related_uuid: Option, + priority: &str, + metadata: Option, +) -> Result { + let message_uuid: Uuid = sqlx::query_scalar( + r#" + INSERT INTO messages (message_type, title, content, sender_uuid, recipient_type, related_type, related_uuid, priority, metadata, status) + VALUES ($1, $2, $3, $4, $5, $6, $7, $8, $9, 'active') + RETURNING uuid; + "#, + ) + .bind(message_type) + .bind(title) + .bind(content) + .bind(sender_uuid) + .bind(recipient_type) + .bind(related_type) + .bind(related_uuid) + .bind(priority) + .bind(metadata) + .fetch_one(pool) + .await?; + + Ok(message_uuid) +} + +/// 添加消息接收者 +pub async fn add_message_recipient( + pool: &Pool, + message_uuid: Uuid, + user_uuid: Uuid, + action_status: Option<&str>, +) -> Result<(), Error> { + sqlx::query( + r#" + INSERT INTO user_messages (message_uuid, user_uuid, is_read, action_status) + VALUES ($1, $2, FALSE, $3) + ON CONFLICT (message_uuid, user_uuid) DO NOTHING; + "#, + ) + .bind(message_uuid) + .bind(user_uuid) + .bind(action_status) + .execute(pool) + .await?; + + Ok(()) +} + +/// 批量添加消息接收者 +pub async fn add_message_recipients( + pool: &Pool, + message_uuid: Uuid, + user_uuids: &[Uuid], + action_status: Option<&str>, +) -> Result<(), Error> { + let mut tx = pool.begin().await?; + + for user_uuid in user_uuids { + sqlx::query( + r#" + INSERT INTO user_messages (message_uuid, user_uuid, is_read, action_status) + VALUES ($1, $2, FALSE, $3) + ON CONFLICT (message_uuid, user_uuid) DO NOTHING; + "#, + ) + .bind(message_uuid) + .bind(user_uuid) + .bind(action_status) + .execute(&mut *tx) + .await?; + } + + tx.commit().await?; + + Ok(()) +} + +/// 查询用户消息列表 +pub async fn fetch_user_messages( + pool: &Pool, + user_uuid: Uuid, + offset: i64, + limit: i64, + message_type: Option<&str>, + is_read: Option, + action_status: Option<&str>, + priority: Option<&str>, +) -> Result, Error> { + let mut query = String::from( + r#" + SELECT + m.uuid AS message_uuid, + m.message_type, + m.title, + m.content, + m.sender_uuid, + m.related_type, + m.related_uuid, + m.metadata, + m.priority, + m.created_at AS message_created_at, + um.is_read, + um.read_at, + um.action_status, + um.action_at, + ui.nickname AS sender_name, + ui.email AS sender_email + FROM user_messages um + INNER JOIN messages m ON um.message_uuid = m.uuid + LEFT JOIN user_infos ui ON m.sender_uuid = ui.user_uuid + WHERE um.user_uuid = $1 AND m.deleted_at IS NULL + "#, + ); + + let mut param_count = 1; + + if message_type.is_some() { + param_count += 1; + query.push_str(&format!(" AND m.message_type = ${}", param_count)); + } + + if is_read.is_some() { + param_count += 1; + query.push_str(&format!(" AND um.is_read = ${}", param_count)); + } + + if action_status.is_some() { + param_count += 1; + query.push_str(&format!(" AND um.action_status = ${}", param_count)); + } + + if priority.is_some() { + param_count += 1; + query.push_str(&format!(" AND m.priority = ${}", param_count)); + } + + query.push_str(" ORDER BY m.created_at DESC"); + param_count += 1; + query.push_str(&format!(" LIMIT ${}", param_count)); + param_count += 1; + query.push_str(&format!(" OFFSET ${}", param_count)); + + // 构建查询参数 + let mut query_builder = sqlx::query_as::<_, UserMessageDto>(&query); + query_builder = query_builder.bind(user_uuid); + + if let Some(mt) = message_type { + query_builder = query_builder.bind(mt); + } + if let Some(ir) = is_read { + query_builder = query_builder.bind(ir); + } + if let Some(as_) = action_status { + query_builder = query_builder.bind(as_); + } + if let Some(p) = priority { + query_builder = query_builder.bind(p); + } + + query_builder = query_builder.bind(limit); + query_builder = query_builder.bind(offset); + + let recs = query_builder.fetch_all(pool).await?; + + Ok(recs) +} + +/// 查询用户消息总数 +pub async fn fetch_user_messages_count( + pool: &Pool, + user_uuid: Uuid, + message_type: Option<&str>, + is_read: Option, + action_status: Option<&str>, + priority: Option<&str>, +) -> Result { + let mut query = String::from( + r#" + SELECT COUNT(*) + FROM user_messages um + INNER JOIN messages m ON um.message_uuid = m.uuid + WHERE um.user_uuid = $1 AND m.deleted_at IS NULL + "#, + ); + + let mut param_count = 1; + + if message_type.is_some() { + param_count += 1; + query.push_str(&format!(" AND m.message_type = ${}", param_count)); + } + + if is_read.is_some() { + param_count += 1; + query.push_str(&format!(" AND um.is_read = ${}", param_count)); + } + + if action_status.is_some() { + param_count += 1; + query.push_str(&format!(" AND um.action_status = ${}", param_count)); + } + + if priority.is_some() { + param_count += 1; + query.push_str(&format!(" AND m.priority = ${}", param_count)); + } + + let mut query_builder = sqlx::query_scalar::<_, i64>(&query); + query_builder = query_builder.bind(user_uuid); + + if let Some(mt) = message_type { + query_builder = query_builder.bind(mt); + } + if let Some(ir) = is_read { + query_builder = query_builder.bind(ir); + } + if let Some(as_) = action_status { + query_builder = query_builder.bind(as_); + } + if let Some(p) = priority { + query_builder = query_builder.bind(p); + } + + let count = query_builder.fetch_one(pool).await?; + + Ok(count) +} + +/// 标记消息为已读 +pub async fn mark_message_read( + pool: &Pool, + message_uuid: Uuid, + user_uuid: Uuid, +) -> Result<(), Error> { + sqlx::query( + r#" + UPDATE user_messages + SET is_read = TRUE, read_at = NOW(), updated_at = NOW() + WHERE message_uuid = $1 AND user_uuid = $2 AND is_read = FALSE + "#, + ) + .bind(message_uuid) + .bind(user_uuid) + .execute(pool) + .await?; + + Ok(()) +} + +/// 批量标记消息为已读 +pub async fn batch_mark_messages_read( + pool: &Pool, + message_uuids: &[Uuid], + user_uuid: Uuid, +) -> Result<(), Error> { + let mut tx = pool.begin().await?; + + for message_uuid in message_uuids { + sqlx::query( + r#" + UPDATE user_messages + SET is_read = TRUE, read_at = NOW(), updated_at = NOW() + WHERE message_uuid = $1 AND user_uuid = $2 AND is_read = FALSE + "#, + ) + .bind(message_uuid) + .bind(user_uuid) + .execute(&mut *tx) + .await?; + } + + tx.commit().await?; + + Ok(()) +} + +/// 处理消息(接受/拒绝) +pub async fn handle_message( + pool: &Pool, + message_uuid: Uuid, + user_uuid: Uuid, + action: &str, +) -> Result<(), Error> { + let action_status = match action { + "accept" => "accepted", + "reject" => "rejected", + _ => return Err(Error::RowNotFound), + }; + + sqlx::query( + r#" + UPDATE user_messages + SET action_status = $1, action_at = NOW(), updated_at = NOW() + WHERE message_uuid = $2 AND user_uuid = $3 + "#, + ) + .bind(action_status) + .bind(message_uuid) + .bind(user_uuid) + .execute(pool) + .await?; + + Ok(()) +} + +/// 获取用户消息统计 +pub async fn fetch_user_message_stats( + pool: &Pool, + user_uuid: Uuid, +) -> Result<(i64, i64, std::collections::HashMap), Error> { + // 总消息数 + let total: i64 = sqlx::query_scalar( + r#" + SELECT COUNT(*) + FROM user_messages um + INNER JOIN messages m ON um.message_uuid = m.uuid + WHERE um.user_uuid = $1 AND m.deleted_at IS NULL + "#, + ) + .bind(user_uuid) + .fetch_one(pool) + .await?; + + // 未读消息数 + let unread: i64 = sqlx::query_scalar( + r#" + SELECT COUNT(*) + FROM user_messages um + INNER JOIN messages m ON um.message_uuid = m.uuid + WHERE um.user_uuid = $1 AND um.is_read = FALSE AND m.deleted_at IS NULL + "#, + ) + .bind(user_uuid) + .fetch_one(pool) + .await?; + + // 按类型统计 + let type_stats: Vec<(String, i64)> = sqlx::query_as( + r#" + SELECT m.message_type, COUNT(*) as count + FROM user_messages um + INNER JOIN messages m ON um.message_uuid = m.uuid + WHERE um.user_uuid = $1 AND m.deleted_at IS NULL + GROUP BY m.message_type + "#, + ) + .bind(user_uuid) + .fetch_all(pool) + .await?; + + let mut by_type = std::collections::HashMap::new(); + for (msg_type, count) in type_stats { + by_type.insert(msg_type, count); + } + + Ok((total, unread, by_type)) +} + +/// 删除消息(软删除) +pub async fn delete_message(pool: &Pool, message_uuid: Uuid) -> Result<(), Error> { + sqlx::query( + r#" + UPDATE messages + SET deleted_at = NOW(), status = 'deleted', updated_at = NOW() + WHERE uuid = $1 AND deleted_at IS NULL + "#, + ) + .bind(message_uuid) + .execute(pool) + .await?; + + Ok(()) +} + +/// 根据 UUID 查询消息 +pub async fn fetch_message_by_uuid( + pool: &Pool, + message_uuid: Uuid, +) -> Result, Error> { + let rec = sqlx::query_as::<_, MessageDto>( + r#" + SELECT id, uuid, message_type, title, content, sender_uuid, recipient_type, + related_type, related_uuid, metadata, status, priority, + created_at, updated_at, deleted_at + FROM messages + WHERE uuid = $1 AND deleted_at IS NULL + "#, + ) + .bind(message_uuid) + .fetch_optional(pool) + .await?; + + Ok(rec) +} diff --git a/src-tauri/crates/business/src/models/mod.rs b/src-tauri/crates/business/src/models/mod.rs new file mode 100644 index 0000000..b490d35 --- /dev/null +++ b/src-tauri/crates/business/src/models/mod.rs @@ -0,0 +1,43 @@ +pub mod maintenance; +pub mod strategy_types; +pub mod user; +pub mod version_types; +pub mod versions; + +// 新增模块 +pub mod accounts; +pub mod audit; +pub mod billing; +pub mod environments; +pub mod extensions; +pub mod group_member_permissions; +pub mod local_api; +pub mod messages; +pub mod preferences; +pub mod proxies; +pub mod proxy_visible_teams; +pub mod referral; +pub mod rpa; +pub mod teams; +pub mod workspace_quotas; +pub mod workspaces; + +pub use maintenance::*; +pub use strategy_types::*; +pub use user::*; +pub use version_types::*; +pub use versions::*; + +// 新增导出 +pub use accounts::*; +pub use audit::*; +pub use billing::*; +pub use environments::*; +pub use group_member_permissions::*; +pub use local_api::*; +pub use messages::*; +pub use proxies::*; +pub use proxy_visible_teams::*; +pub use teams::*; +pub use workspace_quotas::*; +pub use workspaces::*; diff --git a/src-tauri/crates/business/src/models/preferences.rs b/src-tauri/crates/business/src/models/preferences.rs new file mode 100644 index 0000000..e1e9541 --- /dev/null +++ b/src-tauri/crates/business/src/models/preferences.rs @@ -0,0 +1,54 @@ +use sqlx::Error; +use uuid::Uuid; + +use crate::database::{Db as Postgres, Pool}; + +use crate::dto::UserPreferenceDto; + +/// 获取用户偏好设置 +pub async fn fetch_user_preferences( + pool: &Pool, + user_uuid: Uuid, +) -> Result, Error> { + let rec = sqlx::query_as::<_, UserPreferenceDto>( + r#" + SELECT id, user_uuid, theme, language, notifications_enabled, created_at, updated_at + FROM user_preferences + WHERE user_uuid = $1 + "#, + ) + .bind(user_uuid) + .fetch_optional(pool) + .await?; + + Ok(rec) +} + +/// 创建或更新用户偏好设置 +pub async fn upsert_user_preferences( + pool: &Pool, + user_uuid: Uuid, + theme: Option<&str>, + language: Option<&str>, + notifications_enabled: Option, +) -> Result<(), Error> { + sqlx::query( + r#" + INSERT INTO user_preferences (user_uuid, theme, language, notifications_enabled) + VALUES ($1, COALESCE($2, 'system'), COALESCE($3, 'zh-CN'), COALESCE($4, true)) + ON CONFLICT (user_uuid) DO UPDATE SET + theme = COALESCE($2, user_preferences.theme), + language = COALESCE($3, user_preferences.language), + notifications_enabled = COALESCE($4, user_preferences.notifications_enabled), + updated_at = CURRENT_TIMESTAMP + "#, + ) + .bind(user_uuid) + .bind(theme) + .bind(language) + .bind(notifications_enabled) + .execute(pool) + .await?; + + Ok(()) +} diff --git a/src-tauri/crates/business/src/models/proxies.rs b/src-tauri/crates/business/src/models/proxies.rs new file mode 100644 index 0000000..c6561aa --- /dev/null +++ b/src-tauri/crates/business/src/models/proxies.rs @@ -0,0 +1,304 @@ +use sqlx::Error; +use uuid::Uuid; + +use crate::database::{Db as Postgres, Pool}; + +use crate::dto::{ProxyDto, ProxyHealthCheckDto}; + +/// 创建代理 +pub async fn insert_proxy( + pool: &Pool, + workspace_uuid: Uuid, + owner_uuid: Uuid, + name: &str, + host: &str, + port: i32, + proxy_type: &str, + username: Option<&str>, + password: Option<&str>, + country: Option<&str>, + city: Option<&str>, +) -> Result { + let uuid: Uuid = sqlx::query_scalar( + r#" + INSERT INTO proxies (workspace_uuid, owner_uuid, name, host, port, proxy_type, + username, password, country, city) + VALUES ($1, $2, $3, $4, $5, $6, $7, $8, $9, $10) + RETURNING uuid; + "#, + ) + .bind(workspace_uuid) + .bind(owner_uuid) + .bind(name) + .bind(host) + .bind(port) + .bind(proxy_type) + .bind(username) + .bind(password) + .bind(country) + .bind(city) + .fetch_one(pool) + .await?; + + Ok(uuid) +} + +/// 查询代理列表 +pub async fn fetch_proxies( + pool: &Pool, + workspace_uuid: Uuid, + proxy_type: Option<&str>, + status: Option<&str>, + offset: i64, + limit: i64, +) -> Result, Error> { + let recs = sqlx::query_as::<_, ProxyDto>( + r#" + SELECT p.id, p.uuid, p.workspace_uuid, p.owner_uuid, p.name, p.host, p.port, p.proxy_type, + p.username, p.password, p.ssh_key_encrypted, p.ssh_passphrase_encrypted, + p.country, p.city, p.status, p.latency, p.last_check_ip, p.last_checked_at, + (SELECT COUNT(*) FROM environments e WHERE e.proxy_uuid = p.uuid AND e.deleted_at IS NULL) AS environments_count, + p.created_at, p.updated_at, p.deleted_at + FROM proxies p + WHERE p.workspace_uuid = $1 + AND ($2::varchar IS NULL OR p.proxy_type = $2) + AND ($3::varchar IS NULL OR p.status = $3) + AND p.deleted_at IS NULL + ORDER BY p.created_at DESC + LIMIT $5 OFFSET $6 + "#, + ) + .bind(workspace_uuid) + .bind(proxy_type) + .bind(status) + .bind(limit) + .bind(offset) + .fetch_all(pool) + .await?; + + Ok(recs) +} + +/// 查询代理总数 +pub async fn fetch_proxies_count( + pool: &Pool, + workspace_uuid: Uuid, + proxy_type: Option<&str>, + status: Option<&str>, +) -> Result { + let count: i64 = sqlx::query_scalar( + r#" + SELECT COUNT(*) FROM proxies + WHERE workspace_uuid = $1 + AND ($2::varchar IS NULL OR proxy_type = $2) + AND ($3::varchar IS NULL OR status = $3) + AND deleted_at IS NULL + "#, + ) + .bind(workspace_uuid) + .bind(proxy_type) + .bind(status) + .fetch_one(pool) + .await?; + + Ok(count) +} + +/// 根据 UUID 查询代理 +pub async fn fetch_proxy_by_uuid( + pool: &Pool, + proxy_uuid: Uuid, +) -> Result, Error> { + let rec = sqlx::query_as::<_, ProxyDto>( + r#" + SELECT p.id, p.uuid, p.workspace_uuid, p.owner_uuid, p.name, p.host, p.port, p.proxy_type, + p.username, p.password, p.ssh_key_encrypted, p.ssh_passphrase_encrypted, + p.country, p.city, p.status, p.latency, p.last_check_ip, p.last_checked_at, + (SELECT COUNT(*) FROM environments e WHERE e.proxy_uuid = p.uuid AND e.deleted_at IS NULL) AS environments_count, + p.created_at, p.updated_at, p.deleted_at + FROM proxies p + WHERE p.uuid = $1 AND p.deleted_at IS NULL + "#, + ) + .bind(proxy_uuid) + .fetch_optional(pool) + .await?; + + Ok(rec) +} + +/// 更新代理 +pub async fn update_proxy( + pool: &Pool, + proxy_uuid: Uuid, + name: Option<&str>, + host: Option<&str>, + port: Option, + proxy_type: Option<&str>, + username: Option<&str>, + password: Option<&str>, + country: Option<&str>, + city: Option<&str>, +) -> Result<(), Error> { + sqlx::query( + r#" + UPDATE proxies + SET name = COALESCE($1, name), + host = COALESCE($2, host), + port = COALESCE($3, port), + proxy_type = COALESCE($4, proxy_type), + username = COALESCE($5, username), + password = COALESCE($6, password), + country = COALESCE($7, country), + city = COALESCE($8, city) + WHERE uuid = $9 AND deleted_at IS NULL + "#, + ) + .bind(name) + .bind(host) + .bind(port) + .bind(proxy_type) + .bind(username) + .bind(password) + .bind(country) + .bind(city) + .bind(proxy_uuid) + .execute(pool) + .await?; + + Ok(()) +} + +/// 更新代理检测结果 +pub async fn update_proxy_check_result( + pool: &Pool, + proxy_uuid: Uuid, + status: &str, + latency: Option, + ip_address: Option<&str>, + country: Option<&str>, + city: Option<&str>, +) -> Result<(), Error> { + sqlx::query( + r#" + UPDATE proxies + SET status = $1, + latency = $2, + last_check_ip = $3, + country = $4, + city = $5, + last_checked_at = CURRENT_TIMESTAMP + WHERE uuid = $6 AND deleted_at IS NULL + "#, + ) + .bind(status) + .bind(latency) + .bind(ip_address) + .bind(country) + .bind(city) + .bind(proxy_uuid) + .execute(pool) + .await?; + + Ok(()) +} + +/// 增加代理使用次数 +pub async fn increment_proxy_usage(pool: &Pool, proxy_uuid: Uuid) -> Result<(), Error> { + sqlx::query( + r#" + UPDATE proxies SET usage_count = usage_count + 1 + WHERE uuid = $1 AND deleted_at IS NULL + "#, + ) + .bind(proxy_uuid) + .execute(pool) + .await?; + + Ok(()) +} + +/// 软删除代理 +pub async fn delete_proxy(pool: &Pool, proxy_uuid: Uuid) -> Result<(), Error> { + sqlx::query( + r#" + UPDATE proxies SET deleted_at = CURRENT_TIMESTAMP + WHERE uuid = $1 AND deleted_at IS NULL + "#, + ) + .bind(proxy_uuid) + .execute(pool) + .await?; + + Ok(()) +} + +/// 批量软删除代理 +pub async fn batch_delete_proxies( + pool: &Pool, + proxy_uuids: &[Uuid], +) -> Result { + let result = sqlx::query( + r#" + UPDATE proxies SET deleted_at = CURRENT_TIMESTAMP + WHERE uuid = ANY($1) AND deleted_at IS NULL + "#, + ) + .bind(proxy_uuids) + .execute(pool) + .await?; + + Ok(result.rows_affected()) +} + +// ============ Proxy Health Checks ============ + +/// 记录代理健康检查 +pub async fn insert_proxy_health_check( + pool: &Pool, + proxy_uuid: Uuid, + status: &str, + latency: Option, + ip_address: Option<&str>, + error_message: Option<&str>, +) -> Result { + let id: i64 = sqlx::query_scalar( + r#" + INSERT INTO proxy_health_checks (proxy_uuid, status, latency, ip_address, error_message) + VALUES ($1, $2, $3, $4, $5) + RETURNING id; + "#, + ) + .bind(proxy_uuid) + .bind(status) + .bind(latency) + .bind(ip_address) + .bind(error_message) + .fetch_one(pool) + .await?; + + Ok(id) +} + +/// 查询代理健康检查历史 +pub async fn fetch_proxy_health_checks( + pool: &Pool, + proxy_uuid: Uuid, + limit: i64, +) -> Result, Error> { + let recs = sqlx::query_as::<_, ProxyHealthCheckDto>( + r#" + SELECT id, proxy_uuid, status, latency, ip_address, error_message, checked_at + FROM proxy_health_checks + WHERE proxy_uuid = $1 + ORDER BY checked_at DESC + LIMIT $2 + "#, + ) + .bind(proxy_uuid) + .bind(limit) + .fetch_all(pool) + .await?; + + Ok(recs) +} diff --git a/src-tauri/crates/business/src/models/proxy_visible_teams.rs b/src-tauri/crates/business/src/models/proxy_visible_teams.rs new file mode 100644 index 0000000..0696684 --- /dev/null +++ b/src-tauri/crates/business/src/models/proxy_visible_teams.rs @@ -0,0 +1,288 @@ +use sqlx::Error; +use uuid::Uuid; + +use crate::database::{Db as Postgres, Pool}; + +use crate::dto::{ProxyDto, ProxyVisibleTeamDto}; + +/// 添加代理可见团队 +pub async fn insert_proxy_visible_team( + pool: &Pool, + proxy_uuid: Uuid, + workspace_uuid: Uuid, + team_uuid: Uuid, +) -> Result<(), Error> { + sqlx::query( + r#" + INSERT INTO proxy_visible_teams (proxy_uuid, workspace_uuid, team_uuid) + VALUES ($1, $2, $3) + ON CONFLICT (proxy_uuid, team_uuid) DO NOTHING + "#, + ) + .bind(proxy_uuid) + .bind(workspace_uuid) + .bind(team_uuid) + .execute(pool) + .await?; + + Ok(()) +} + +/// 移除代理可见团队 +pub async fn remove_proxy_visible_team( + pool: &Pool, + proxy_uuid: Uuid, + team_uuid: Uuid, +) -> Result<(), Error> { + sqlx::query( + r#" + DELETE FROM proxy_visible_teams + WHERE proxy_uuid = $1 AND team_uuid = $2 + "#, + ) + .bind(proxy_uuid) + .bind(team_uuid) + .execute(pool) + .await?; + + Ok(()) +} + +/// 查询代理的可见团队列表 +pub async fn fetch_visible_teams_by_proxy( + pool: &Pool, + proxy_uuid: Uuid, +) -> Result, Error> { + let recs = sqlx::query_as::<_, ProxyVisibleTeamDto>( + r#" + SELECT proxy_uuid, workspace_uuid, team_uuid, created_at + FROM proxy_visible_teams + WHERE proxy_uuid = $1 + ORDER BY created_at + "#, + ) + .bind(proxy_uuid) + .fetch_all(pool) + .await?; + + Ok(recs) +} + +/// 查询团队可见的代理列表 +pub async fn fetch_visible_proxies_by_team( + pool: &Pool, + workspace_uuid: Uuid, + team_uuid: Uuid, +) -> Result, Error> { + let recs = sqlx::query_as::<_, ProxyDto>( + r#" + SELECT p.id, p.uuid, p.workspace_uuid, p.owner_uuid, p.name, p.host, p.port, p.proxy_type, + p.username, p.password, p.ssh_key_encrypted, p.ssh_passphrase_encrypted, + p.country, p.city, p.status, p.latency, p.last_check_ip, p.last_checked_at, + (SELECT COUNT(*) FROM environments e WHERE e.proxy_uuid = p.uuid AND e.deleted_at IS NULL) AS environments_count, + p.created_at, p.updated_at, p.deleted_at + FROM proxies p + INNER JOIN proxy_visible_teams pvt ON p.uuid = pvt.proxy_uuid + WHERE pvt.workspace_uuid = $1 AND pvt.team_uuid = $2 + AND p.deleted_at IS NULL + ORDER BY p.created_at DESC + "#, + ) + .bind(workspace_uuid) + .bind(team_uuid) + .fetch_all(pool) + .await?; + + Ok(recs) +} + +/// 检查代理对团队是否可见 +pub async fn check_proxy_visibility( + pool: &Pool, + proxy_uuid: Uuid, + workspace_uuid: Uuid, + team_uuid: Uuid, +) -> Result { + let count: i64 = sqlx::query_scalar( + r#" + SELECT COUNT(*) FROM proxy_visible_teams + WHERE proxy_uuid = $1 AND workspace_uuid = $2 AND team_uuid = $3 + "#, + ) + .bind(proxy_uuid) + .bind(workspace_uuid) + .bind(team_uuid) + .fetch_one(pool) + .await?; + + Ok(count > 0) +} + +/// 查询工作空间所有可见的代理(包括工作空间 Owner 和代理所有者的代理) +pub async fn fetch_visible_proxies_for_user( + pool: &Pool, + workspace_uuid: Uuid, + user_uuid: Uuid, + team_uuid: Option, +) -> Result, Error> { + // 工作空间 Owner 可以看到所有代理 + // 代理所有者可以看到自己的代理 + // 团队成员可以看到 proxy_visible_teams 中包含其团队的代理 + let recs = sqlx::query_as::<_, ProxyDto>( + r#" + SELECT DISTINCT p.id, p.uuid, p.workspace_uuid, p.owner_uuid, p.name, p.host, p.port, p.proxy_type, + p.username, p.password, p.ssh_key_encrypted, p.ssh_passphrase_encrypted, + p.country, p.city, p.status, p.latency, p.last_check_ip, p.last_checked_at, + (SELECT COUNT(*) FROM environments e WHERE e.proxy_uuid = p.uuid AND e.deleted_at IS NULL) AS environments_count, + p.created_at, p.updated_at, p.deleted_at + FROM proxies p + WHERE p.workspace_uuid = $1 + AND p.deleted_at IS NULL + AND ( + -- 工作空间 Owner + EXISTS ( + SELECT 1 FROM workspaces w + WHERE w.uuid = $1 AND w.owner_uuid = $2 AND w.deleted_at IS NULL + ) + -- 代理所有者 + OR p.owner_uuid = $2 + -- 团队成员可见的代理 + OR ( + $3 IS NOT NULL + AND EXISTS ( + SELECT 1 FROM proxy_visible_teams pvt + WHERE pvt.proxy_uuid = p.uuid + AND pvt.workspace_uuid = $1 + AND pvt.team_uuid = $3 + ) + ) + ) + ORDER BY p.created_at DESC + "#, + ) + .bind(workspace_uuid) + .bind(user_uuid) + .bind(team_uuid) + .fetch_all(pool) + .await?; + + Ok(recs) +} + +/// 分页查询用户可见的代理列表,并支持名称搜索和筛选 +pub async fn fetch_visible_proxies_for_user_paginated( + pool: &Pool, + workspace_uuid: Uuid, + user_uuid: Uuid, + team_uuid: Option, + name_keyword: Option<&str>, + proxy_type: Option<&str>, + status: Option<&str>, + country: Option<&str>, + offset: i64, + limit: i64, +) -> Result, Error> { + let name_keyword = name_keyword.map(|keyword| format!("%{}%", keyword.trim())); + + let recs = sqlx::query_as::<_, ProxyDto>( + r#" + SELECT DISTINCT p.id, p.uuid, p.workspace_uuid, p.owner_uuid, p.name, p.host, p.port, p.proxy_type, + p.username, p.password, p.ssh_key_encrypted, p.ssh_passphrase_encrypted, + p.country, p.city, p.status, p.latency, p.last_check_ip, p.last_checked_at, + (SELECT COUNT(*) FROM environments e WHERE e.proxy_uuid = p.uuid AND e.deleted_at IS NULL) AS environments_count, + p.created_at, p.updated_at, p.deleted_at + FROM proxies p + WHERE p.workspace_uuid = $1 + AND p.deleted_at IS NULL + AND ($4::text IS NULL OR p.name ILIKE $4) + AND ($5::text IS NULL OR p.proxy_type = $5) + AND ($6::text IS NULL OR p.status = $6) + AND ($7::text IS NULL OR p.country = $7) + AND ( + EXISTS ( + SELECT 1 FROM workspaces w + WHERE w.uuid = $1 AND w.owner_uuid = $2 AND w.deleted_at IS NULL + ) + OR p.owner_uuid = $2 + OR ( + $3 IS NOT NULL + AND EXISTS ( + SELECT 1 FROM proxy_visible_teams pvt + WHERE pvt.proxy_uuid = p.uuid + AND pvt.workspace_uuid = $1 + AND pvt.team_uuid = $3 + ) + ) + ) + ORDER BY p.created_at DESC + OFFSET $8 LIMIT $9 + "#, + ) + .bind(workspace_uuid) + .bind(user_uuid) + .bind(team_uuid) + .bind(name_keyword) + .bind(proxy_type) + .bind(status) + .bind(country) + .bind(offset) + .bind(limit) + .fetch_all(pool) + .await?; + + Ok(recs) +} + +/// 查询用户可见代理总数,并支持名称搜索和筛选 +pub async fn fetch_visible_proxies_for_user_count( + pool: &Pool, + workspace_uuid: Uuid, + user_uuid: Uuid, + team_uuid: Option, + name_keyword: Option<&str>, + proxy_type: Option<&str>, + status: Option<&str>, + country: Option<&str>, +) -> Result { + let name_keyword = name_keyword.map(|keyword| format!("%{}%", keyword.trim())); + + let count: i64 = sqlx::query_scalar( + r#" + SELECT COUNT(DISTINCT p.uuid) + FROM proxies p + WHERE p.workspace_uuid = $1 + AND p.deleted_at IS NULL + AND ($4::text IS NULL OR p.name ILIKE $4) + AND ($5::text IS NULL OR p.proxy_type = $5) + AND ($6::text IS NULL OR p.status = $6) + AND ($7::text IS NULL OR p.country = $7) + AND ( + EXISTS ( + SELECT 1 FROM workspaces w + WHERE w.uuid = $1 AND w.owner_uuid = $2 AND w.deleted_at IS NULL + ) + OR p.owner_uuid = $2 + OR ( + $3 IS NOT NULL + AND EXISTS ( + SELECT 1 FROM proxy_visible_teams pvt + WHERE pvt.proxy_uuid = p.uuid + AND pvt.workspace_uuid = $1 + AND pvt.team_uuid = $3 + ) + ) + ) + "#, + ) + .bind(workspace_uuid) + .bind(user_uuid) + .bind(team_uuid) + .bind(name_keyword) + .bind(proxy_type) + .bind(status) + .bind(country) + .fetch_one(pool) + .await?; + + Ok(count) +} diff --git a/src-tauri/crates/business/src/models/referral.rs b/src-tauri/crates/business/src/models/referral.rs new file mode 100644 index 0000000..f6827de --- /dev/null +++ b/src-tauri/crates/business/src/models/referral.rs @@ -0,0 +1,569 @@ +use chrono::{DateTime, Utc}; +use rust_decimal::Decimal; +use sqlx::Error; +use uuid::Uuid; + +use crate::database::{Db as Postgres, Pool}; + +use crate::dto::{ + RedeemOptionDto, RedeemRecordDto, ReferralLinkDto, ReferralLinkTierDto, ReferralRewardDto, + UserReferralPointsDto, +}; +use crate::dto::ReferredUserRow; + +// ============ Referral Link Tiers ============ + +/// 查询所有层级 +pub async fn fetch_referral_tiers( + pool: &Pool, +) -> Result, Error> { + let recs = sqlx::query_as::<_, ReferralLinkTierDto>( + r#" + SELECT id, uuid, name, unlock_threshold, reward_rate, discount_rate, + description, sort_order, created_at, updated_at + FROM referral_link_tiers + ORDER BY unlock_threshold ASC + "#, + ) + .fetch_all(pool) + .await?; + + Ok(recs) +} + +/// 根据用户当前已解锁的层级,更新其推广链接的解锁状态。 +/// +/// 约定: +/// - 同一用户的所有推广链接默认创建完成(最多 4 条),分别对应不同 tier_uuid; +/// - 这里根据传入的 unlocked_tier_uuids,将属于这些层级的链接标记为 unlocked = TRUE, +/// 其他链接统一标记为 FALSE。 +pub async fn update_referral_links_unlock_status_for_user( + pool: &Pool, + user_uuid: Uuid, + unlocked_tier_uuids: &[Uuid], +) -> Result<(), Error> { + // 先将该用户所有链接标记为未解锁 + sqlx::query( + r#" + UPDATE referral_links + SET unlocked = FALSE + WHERE user_uuid = $1; + "#, + ) + .bind(user_uuid) + .execute(pool) + .await?; + + // 再根据传入的 tier_uuid 集合解锁对应链接 + if !unlocked_tier_uuids.is_empty() { + sqlx::query( + r#" + UPDATE referral_links + SET unlocked = TRUE + WHERE user_uuid = $1 + AND tier_uuid = ANY($2::uuid[]); + "#, + ) + .bind(user_uuid) + .bind(unlocked_tier_uuids) + .execute(pool) + .await?; + } + + Ok(()) +} + +/// 根据解锁阈值查询层级 +pub async fn fetch_tier_by_threshold( + pool: &Pool, + threshold: i32, +) -> Result, Error> { + let rec = sqlx::query_as::<_, ReferralLinkTierDto>( + r#" + SELECT id, uuid, name, unlock_threshold, reward_rate, discount_rate, + description, sort_order, created_at, updated_at + FROM referral_link_tiers + WHERE unlock_threshold <= $1 + ORDER BY unlock_threshold DESC + LIMIT 1 + "#, + ) + .bind(threshold) + .fetch_optional(pool) + .await?; + + Ok(rec) +} + +/// 查询下一个层级 +pub async fn fetch_next_tier( + pool: &Pool, + current_threshold: i32, +) -> Result, Error> { + let rec = sqlx::query_as::<_, ReferralLinkTierDto>( + r#" + SELECT id, uuid, name, unlock_threshold, reward_rate, discount_rate, + description, sort_order, created_at, updated_at + FROM referral_link_tiers + WHERE unlock_threshold > $1 + ORDER BY unlock_threshold ASC + LIMIT 1 + "#, + ) + .bind(current_threshold) + .fetch_optional(pool) + .await?; + + Ok(rec) +} + +// ============ Referral Links ============ + +/// 查询用户的推广链接列表 +pub async fn fetch_user_referral_links( + pool: &Pool, + user_uuid: Uuid, +) -> Result, Error> { + // 为了保证前端展示顺序与层级解锁顺序一致,这里按对应层级的 unlock_threshold 排序, + // 对于没有 tier_uuid 的链接,则退而按创建时间排序并排在最后。 + let recs = sqlx::query_as::<_, ReferralLinkDto>( + r#" + SELECT rl.id, + rl.uuid, + rl.user_uuid, + rl.code, + rl.url, + rl.tier_uuid, + rl.unlocked, + rl.is_current, + rl.reward_rate, + rl.discount_rate, + rl.registered_users, + rl.paid_users, + rl.total_consumption, + rl.last_30_days_consumption, + rl.created_at, + rl.updated_at + FROM referral_links rl + LEFT JOIN referral_link_tiers t + ON rl.tier_uuid = t.uuid + WHERE rl.user_uuid = $1 + ORDER BY + t.unlock_threshold ASC NULLS LAST, + rl.created_at ASC + "#, + ) + .bind(user_uuid) + .fetch_all(pool) + .await?; + + Ok(recs) +} + +/// 查询当前推广链接 +pub async fn fetch_current_referral_link( + pool: &Pool, + user_uuid: Uuid, +) -> Result, Error> { + let rec = sqlx::query_as::<_, ReferralLinkDto>( + r#" + SELECT id, uuid, user_uuid, code, url, tier_uuid, unlocked, is_current, + reward_rate, discount_rate, registered_users, paid_users, + total_consumption, last_30_days_consumption, created_at, updated_at + FROM referral_links + WHERE user_uuid = $1 AND is_current = true + "#, + ) + .bind(user_uuid) + .fetch_optional(pool) + .await?; + + Ok(rec) +} + +/// 切换当前推广链接 +pub async fn switch_current_referral_link( + pool: &Pool, + user_uuid: Uuid, + link_uuid: Uuid, +) -> Result<(), Error> { + // 先取消所有当前链接 + sqlx::query("UPDATE referral_links SET is_current = false WHERE user_uuid = $1") + .bind(user_uuid) + .execute(pool) + .await?; + + // 设置新的当前链接 + sqlx::query("UPDATE referral_links SET is_current = true WHERE uuid = $1 AND user_uuid = $2") + .bind(link_uuid) + .bind(user_uuid) + .execute(pool) + .await?; + + Ok(()) +} + +// ============ User Referrals ============ + +/// 查询被邀请用户列表 +pub async fn fetch_referred_users( + pool: &Pool, + inviter_uuid: Uuid, + keyword: Option<&str>, + status: Option<&str>, + offset: i64, + limit: i64, +) -> Result, Error> { + let recs = sqlx::query_as::<_, ReferredUserRow>( + r#" + SELECT + ur.id, + ui.email, + ur.status, + ur.link_uuid, + ur.total_consumption, + ur.last_30_days_consumption, + ur.registered_at + FROM user_referrals ur + JOIN user_infos ui ON ui.user_uuid = ur.invitee_uuid + WHERE ur.inviter_uuid = $1 + AND ($2::text IS NULL OR ui.email ILIKE '%' || $2 || '%') + AND ($3::varchar IS NULL OR ur.status = $3) + ORDER BY registered_at DESC + LIMIT $4 OFFSET $5 + "#, + ) + .bind(inviter_uuid) + .bind(keyword) + .bind(status) + .bind(limit) + .bind(offset) + .fetch_all(pool) + .await?; + + Ok(recs) +} + +/// 查询被邀请用户总数 +pub async fn fetch_referred_users_count( + pool: &Pool, + inviter_uuid: Uuid, + keyword: Option<&str>, + status: Option<&str>, +) -> Result { + let count: i64 = sqlx::query_scalar( + r#" + SELECT COUNT(*) + FROM user_referrals ur + JOIN user_infos ui ON ui.user_uuid = ur.invitee_uuid + WHERE ur.inviter_uuid = $1 + AND ($2::text IS NULL OR ui.email ILIKE '%' || $2 || '%') + AND ($3::varchar IS NULL OR ur.status = $3) + "#, + ) + .bind(inviter_uuid) + .bind(keyword) + .bind(status) + .fetch_one(pool) + .await?; + + Ok(count) +} + +/// 获取推广统计 +pub async fn fetch_referral_stats( + pool: &Pool, + user_uuid: Uuid, +) -> Result<(i32, i32, Decimal, Decimal), Error> { + let stats: (i64, i64, Option, Option) = sqlx::query_as( + r#" + SELECT + COUNT(*) as total_referrals, + COUNT(CASE WHEN status = 'paid' THEN 1 END) as paid_referrals, + COALESCE(SUM(total_consumption), 0) as total_consumption, + COALESCE(SUM(last_30_days_consumption), 0) as last_30_days_consumption + FROM user_referrals + WHERE inviter_uuid = $1 + "#, + ) + .bind(user_uuid) + .fetch_one(pool) + .await?; + + Ok(( + stats.0 as i32, + stats.1 as i32, + stats.2.unwrap_or(Decimal::ZERO), + stats.3.unwrap_or(Decimal::ZERO), + )) +} + +// ============ Referral Rewards ============ + +/// 查询奖励记录 +pub async fn fetch_referral_rewards( + pool: &Pool, + user_uuid: Uuid, + keyword: Option<&str>, + reward_type: Option<&str>, + status: Option<&str>, + offset: i64, + limit: i64, +) -> Result, Error> { + let recs = sqlx::query_as::<_, ReferralRewardDto>( + r#" + SELECT id, uuid, user_uuid, reward_type, points, description, + referred_user_uuid, link_uuid, status, created_at + FROM referral_rewards + WHERE user_uuid = $1 + AND ($2::text IS NULL OR COALESCE(description, '') ILIKE '%' || $2 || '%') + AND ($3::varchar IS NULL OR reward_type = $3) + AND ($4::varchar IS NULL OR status = $4) + ORDER BY created_at DESC + LIMIT $5 OFFSET $6 + "#, + ) + .bind(user_uuid) + .bind(keyword) + .bind(reward_type) + .bind(status) + .bind(limit) + .bind(offset) + .fetch_all(pool) + .await?; + + Ok(recs) +} + +/// 查询奖励记录总数 +pub async fn fetch_referral_rewards_count( + pool: &Pool, + user_uuid: Uuid, + keyword: Option<&str>, + reward_type: Option<&str>, + status: Option<&str>, +) -> Result { + let count: i64 = sqlx::query_scalar( + r#" + SELECT COUNT(*) FROM referral_rewards + WHERE user_uuid = $1 + AND ($2::text IS NULL OR COALESCE(description, '') ILIKE '%' || $2 || '%') + AND ($3::varchar IS NULL OR reward_type = $3) + AND ($4::varchar IS NULL OR status = $4) + "#, + ) + .bind(user_uuid) + .bind(keyword) + .bind(reward_type) + .bind(status) + .fetch_one(pool) + .await?; + + Ok(count) +} + +/// 获取总奖励积分 +pub async fn fetch_total_reward_points( + pool: &Pool, + user_uuid: Uuid, +) -> Result { + let total: i64 = sqlx::query_scalar( + r#" + SELECT COALESCE(SUM(points), 0) FROM referral_rewards + WHERE user_uuid = $1 AND status = 'completed' + "#, + ) + .bind(user_uuid) + .fetch_one(pool) + .await?; + + Ok(total as i32) +} + +// ============ User Referral Points ============ + +/// 获取用户积分 +pub async fn fetch_user_points( + pool: &Pool, + user_uuid: Uuid, +) -> Result, Error> { + let rec = sqlx::query_as::<_, UserReferralPointsDto>( + r#" + SELECT id, user_uuid, total_points, available_points, used_points, + pending_points, created_at, updated_at + FROM user_referral_points + WHERE user_uuid = $1 + "#, + ) + .bind(user_uuid) + .fetch_optional(pool) + .await?; + + Ok(rec) +} + +/// 创建或更新用户积分 +pub async fn upsert_user_points( + pool: &Pool, + user_uuid: Uuid, + total_points: i32, + available_points: i32, + used_points: i32, + pending_points: i32, +) -> Result<(), Error> { + sqlx::query( + r#" + INSERT INTO user_referral_points (user_uuid, total_points, available_points, used_points, pending_points) + VALUES ($1, $2, $3, $4, $5) + ON CONFLICT (user_uuid) DO UPDATE SET + total_points = $2, + available_points = $3, + used_points = $4, + pending_points = $5, + updated_at = CURRENT_TIMESTAMP + "#, + ) + .bind(user_uuid) + .bind(total_points) + .bind(available_points) + .bind(used_points) + .bind(pending_points) + .execute(pool) + .await?; + + Ok(()) +} + +/// 扣减积分 +pub async fn deduct_user_points( + pool: &Pool, + user_uuid: Uuid, + points: i32, +) -> Result<(), Error> { + sqlx::query( + r#" + UPDATE user_referral_points + SET available_points = available_points - $1, + used_points = used_points + $1, + updated_at = CURRENT_TIMESTAMP + WHERE user_uuid = $2 AND available_points >= $1 + "#, + ) + .bind(points) + .bind(user_uuid) + .execute(pool) + .await?; + + Ok(()) +} + +// ============ Redeem Options ============ + +/// 查询兑换选项 +pub async fn fetch_redeem_options(pool: &Pool) -> Result, Error> { + let recs = sqlx::query_as::<_, RedeemOptionDto>( + r#" + SELECT id, uuid, redeem_type, name, description, points_required, + value, currency, exchange_rate, status, sort_order, created_at, updated_at + FROM redeem_options + WHERE status = 'active' + ORDER BY sort_order ASC + "#, + ) + .fetch_all(pool) + .await?; + + Ok(recs) +} + +/// 根据 UUID 查询兑换选项 +pub async fn fetch_redeem_option_by_uuid( + pool: &Pool, + option_uuid: Uuid, +) -> Result, Error> { + let rec = sqlx::query_as::<_, RedeemOptionDto>( + r#" + SELECT id, uuid, redeem_type, name, description, points_required, + value, currency, exchange_rate, status, sort_order, created_at, updated_at + FROM redeem_options + WHERE uuid = $1 + "#, + ) + .bind(option_uuid) + .fetch_optional(pool) + .await?; + + Ok(rec) +} + +// ============ Redeem Records ============ + +/// 创建兑换记录 +pub async fn insert_redeem_record( + pool: &Pool, + user_uuid: Uuid, + option_uuid: Uuid, + points_used: i32, + value: Decimal, + currency: Option<&str>, +) -> Result { + let uuid: Uuid = sqlx::query_scalar( + r#" + INSERT INTO redeem_records (user_uuid, option_uuid, points_used, value, currency, status) + VALUES ($1, $2, $3, $4, $5, 'completed') + RETURNING uuid; + "#, + ) + .bind(user_uuid) + .bind(option_uuid) + .bind(points_used) + .bind(value) + .bind(currency) + .fetch_one(pool) + .await?; + + Ok(uuid) +} + +/// 查询兑换记录 +pub async fn fetch_redeem_records( + pool: &Pool, + user_uuid: Uuid, + offset: i64, + limit: i64, +) -> Result, Error> { + let recs = sqlx::query_as::<_, RedeemRecordDto>( + r#" + SELECT id, uuid, user_uuid, option_uuid, points_used, value, + currency, status, created_at, completed_at + FROM redeem_records + WHERE user_uuid = $1 + ORDER BY created_at DESC + LIMIT $2 OFFSET $3 + "#, + ) + .bind(user_uuid) + .bind(limit) + .bind(offset) + .fetch_all(pool) + .await?; + + Ok(recs) +} + +/// 查询兑换记录总数 +pub async fn fetch_redeem_records_count( + pool: &Pool, + user_uuid: Uuid, +) -> Result { + let count: i64 = sqlx::query_scalar( + r#" + SELECT COUNT(*) FROM redeem_records WHERE user_uuid = $1 + "#, + ) + .bind(user_uuid) + .fetch_one(pool) + .await?; + + Ok(count) +} diff --git a/src-tauri/crates/business/src/models/rpa.rs b/src-tauri/crates/business/src/models/rpa.rs new file mode 100644 index 0000000..e751d1d --- /dev/null +++ b/src-tauri/crates/business/src/models/rpa.rs @@ -0,0 +1,543 @@ +use sqlx::Error; +use uuid::Uuid; + +use crate::database::{Db as Postgres, Pool}; + +use crate::dto::{RpaTaskDto, RpaTaskEnvironmentDto, RpaTaskRunDto, RpaTaskStepDto}; + +// ============ RPA Tasks ============ + +/// 创建 RPA 任务 +pub async fn insert_rpa_task( + pool: &Pool, + user_uuid: Uuid, + team_uuid: Option, + name: &str, + description: Option<&str>, + tags: Option<&serde_json::Value>, + trigger_type: &str, + schedule: Option<&str>, + cron_expression: Option<&str>, + run_mode: &str, + retry_count: Option, + retry_interval: Option, + timeout: Option, + concurrency: Option, + stop_on_error: Option, + notify_on_complete: Option, + notify_on_error: Option, +) -> Result { + let uuid: Uuid = sqlx::query_scalar( + r#" + INSERT INTO rpa_tasks (user_uuid, team_uuid, name, description, tags, trigger_type, + schedule, cron_expression, run_mode, retry_count, retry_interval, + timeout, concurrency, stop_on_error, notify_on_complete, notify_on_error) + VALUES ($1, $2, $3, $4, $5, $6, $7, $8, $9, $10, $11, $12, $13, $14, $15, $16) + RETURNING uuid; + "#, + ) + .bind(user_uuid) + .bind(team_uuid) + .bind(name) + .bind(description) + .bind(tags) + .bind(trigger_type) + .bind(schedule) + .bind(cron_expression) + .bind(run_mode) + .bind(retry_count) + .bind(retry_interval) + .bind(timeout) + .bind(concurrency) + .bind(stop_on_error) + .bind(notify_on_complete) + .bind(notify_on_error) + .fetch_one(pool) + .await?; + + Ok(uuid) +} + +/// 查询 RPA 任务列表 +pub async fn fetch_rpa_tasks( + pool: &Pool, + team_uuid: Option, + user_uuid: Uuid, + keyword: Option<&str>, + status: Option<&str>, + trigger_type: Option<&str>, + offset: i64, + limit: i64, +) -> Result, Error> { + let recs = sqlx::query_as::<_, RpaTaskDto>( + r#" + SELECT id, uuid, user_uuid, team_uuid, name, description, tags, trigger_type, + schedule, cron_expression, run_mode, retry_count, retry_interval, timeout, + concurrency, stop_on_error, notify_on_complete, notify_on_error, status, + run_count, success_count, last_run_at, next_run_at, created_at, updated_at, deleted_at + , (SELECT COUNT(*) FROM rpa_task_environments rte WHERE rte.task_uuid = rpa_tasks.uuid) AS environment_count + FROM rpa_tasks + WHERE (team_uuid = $1 OR (team_uuid IS NULL AND user_uuid = $2)) + AND ($3::varchar IS NULL OR name ILIKE $3 OR COALESCE(description, '') ILIKE $3) + AND ($4::varchar IS NULL OR status = $4) + AND ($5::varchar IS NULL OR trigger_type = $5) + AND deleted_at IS NULL + ORDER BY created_at DESC + LIMIT $6 OFFSET $7 + "#, + ) + .bind(team_uuid) + .bind(user_uuid) + .bind(keyword.map(|k| format!("%{}%", k))) + .bind(status) + .bind(trigger_type) + .bind(limit) + .bind(offset) + .fetch_all(pool) + .await?; + + Ok(recs) +} + +/// 查询 RPA 任务总数 +pub async fn fetch_rpa_tasks_count( + pool: &Pool, + team_uuid: Option, + user_uuid: Uuid, + keyword: Option<&str>, + status: Option<&str>, + trigger_type: Option<&str>, +) -> Result { + let count: i64 = sqlx::query_scalar( + r#" + SELECT COUNT(*) FROM rpa_tasks + WHERE (team_uuid = $1 OR (team_uuid IS NULL AND user_uuid = $2)) + AND ($3::varchar IS NULL OR status = $3) + AND ($4::varchar IS NULL OR trigger_type = $4) + AND ($5::varchar IS NULL OR name ILIKE $5 OR COALESCE(description, '') ILIKE $5) + AND deleted_at IS NULL + "#, + ) + .bind(team_uuid) + .bind(user_uuid) + .bind(status) + .bind(trigger_type) + .bind(keyword.map(|k| format!("%{}%", k))) + .fetch_one(pool) + .await?; + + Ok(count) +} + +/// 根据 UUID 查询 RPA 任务 +pub async fn fetch_rpa_task_by_uuid( + pool: &Pool, + task_uuid: Uuid, +) -> Result, Error> { + let rec = sqlx::query_as::<_, RpaTaskDto>( + r#" + SELECT id, uuid, user_uuid, team_uuid, name, description, tags, trigger_type, + schedule, cron_expression, run_mode, retry_count, retry_interval, timeout, + concurrency, stop_on_error, notify_on_complete, notify_on_error, status, + run_count, success_count, last_run_at, next_run_at, created_at, updated_at, deleted_at + , (SELECT COUNT(*) FROM rpa_task_environments rte WHERE rte.task_uuid = rpa_tasks.uuid) AS environment_count + FROM rpa_tasks + WHERE uuid = $1 AND deleted_at IS NULL + "#, + ) + .bind(task_uuid) + .fetch_optional(pool) + .await?; + + Ok(rec) +} + +/// 更新 RPA 任务 +pub async fn update_rpa_task( + pool: &Pool, + task_uuid: Uuid, + name: Option<&str>, + description: Option<&str>, + tags: Option<&serde_json::Value>, + trigger_type: Option<&str>, + schedule: Option<&str>, + cron_expression: Option<&str>, + run_mode: Option<&str>, + retry_count: Option, + retry_interval: Option, + timeout: Option, + concurrency: Option, + stop_on_error: Option, + notify_on_complete: Option, + notify_on_error: Option, +) -> Result<(), Error> { + sqlx::query( + r#" + UPDATE rpa_tasks + SET name = COALESCE($1, name), + description = COALESCE($2, description), + tags = COALESCE($3, tags), + trigger_type = COALESCE($4, trigger_type), + schedule = COALESCE($5, schedule), + cron_expression = COALESCE($6, cron_expression), + run_mode = COALESCE($7, run_mode), + retry_count = COALESCE($8, retry_count), + retry_interval = COALESCE($9, retry_interval), + timeout = COALESCE($10, timeout), + concurrency = COALESCE($11, concurrency), + stop_on_error = COALESCE($12, stop_on_error), + notify_on_complete = COALESCE($13, notify_on_complete), + notify_on_error = COALESCE($14, notify_on_error), + updated_at = CURRENT_TIMESTAMP + WHERE uuid = $15 AND deleted_at IS NULL + "#, + ) + .bind(name) + .bind(description) + .bind(tags) + .bind(trigger_type) + .bind(schedule) + .bind(cron_expression) + .bind(run_mode) + .bind(retry_count) + .bind(retry_interval) + .bind(timeout) + .bind(concurrency) + .bind(stop_on_error) + .bind(notify_on_complete) + .bind(notify_on_error) + .bind(task_uuid) + .execute(pool) + .await?; + + Ok(()) +} + +/// 更新任务状态 +pub async fn update_rpa_task_status( + pool: &Pool, + task_uuid: Uuid, + status: &str, +) -> Result<(), Error> { + sqlx::query( + r#" + UPDATE rpa_tasks + SET status = $1, updated_at = CURRENT_TIMESTAMP + WHERE uuid = $2 AND deleted_at IS NULL + "#, + ) + .bind(status) + .bind(task_uuid) + .execute(pool) + .await?; + + Ok(()) +} + +/// 软删除 RPA 任务 +pub async fn delete_rpa_task(pool: &Pool, task_uuid: Uuid) -> Result<(), Error> { + sqlx::query( + r#" + UPDATE rpa_tasks SET deleted_at = CURRENT_TIMESTAMP + WHERE uuid = $1 AND deleted_at IS NULL + "#, + ) + .bind(task_uuid) + .execute(pool) + .await?; + + Ok(()) +} + +/// 批量软删除 RPA 任务 +pub async fn batch_delete_rpa_tasks( + pool: &Pool, + task_uuids: &[Uuid], +) -> Result { + let result = sqlx::query( + r#" + UPDATE rpa_tasks SET deleted_at = CURRENT_TIMESTAMP + WHERE uuid = ANY($1) AND deleted_at IS NULL + "#, + ) + .bind(task_uuids) + .execute(pool) + .await?; + + Ok(result.rows_affected()) +} + +// ============ RPA Task Steps ============ + +/// 插入任务步骤 +pub async fn insert_rpa_task_step( + pool: &Pool, + task_uuid: Uuid, + step_type: &str, + name: &str, + config: &serde_json::Value, + enabled: Option, + position_x: Option, + position_y: Option, + sort_order: Option, + next_step_uuid: Option, + branch_config: Option<&serde_json::Value>, +) -> Result { + let uuid: Uuid = sqlx::query_scalar( + r#" + INSERT INTO rpa_task_steps (task_uuid, step_type, name, config, enabled, + position_x, position_y, sort_order) + VALUES ($1, $2, $3, $4, $5, $6, $7, $8) + RETURNING uuid; + "#, + ) + .bind(task_uuid) + .bind(step_type) + .bind(name) + .bind(config) + .bind(enabled.unwrap_or(true)) + .bind(position_x) + .bind(position_y) + .bind(sort_order) + .bind(next_step_uuid) + .bind(branch_config) + .fetch_one(pool) + .await?; + + Ok(uuid) +} + +/// 查询任务步骤列表 +pub async fn fetch_rpa_task_steps( + pool: &Pool, + task_uuid: Uuid, +) -> Result, Error> { + let recs = sqlx::query_as::<_, RpaTaskStepDto>( + r#" + SELECT id, uuid, task_uuid, step_type, name, config, enabled, + position_x, position_y, sort_order, next_step_uuid, branch_config, + created_at, updated_at + FROM rpa_task_steps + WHERE task_uuid = $1 + ORDER BY sort_order ASC, id ASC + "#, + ) + .bind(task_uuid) + .fetch_all(pool) + .await?; + + Ok(recs) +} + +/// 删除任务所有步骤 +pub async fn delete_rpa_task_steps(pool: &Pool, task_uuid: Uuid) -> Result<(), Error> { + sqlx::query("DELETE FROM rpa_task_steps WHERE task_uuid = $1") + .bind(task_uuid) + .execute(pool) + .await?; + + Ok(()) +} + +// ============ RPA Task Environments ============ + +/// 添加任务环境关联 +pub async fn insert_rpa_task_environment( + pool: &Pool, + task_uuid: Uuid, + environment_uuid: Uuid, + sort_order: Option, +) -> Result { + let id: i32 = sqlx::query_scalar( + r#" + INSERT INTO rpa_task_environments (task_uuid, environment_uuid, sort_order) + VALUES ($1, $2, $3) + RETURNING id; + "#, + ) + .bind(task_uuid) + .bind(environment_uuid) + .bind(sort_order) + .fetch_one(pool) + .await?; + + Ok(id) +} + +/// 查询任务环境关联 +pub async fn fetch_rpa_task_environments( + pool: &Pool, + task_uuid: Uuid, +) -> Result, Error> { + let recs = sqlx::query_as::<_, RpaTaskEnvironmentDto>( + r#" + SELECT id, task_uuid, environment_uuid, sort_order, created_at + FROM rpa_task_environments + WHERE task_uuid = $1 + ORDER BY sort_order ASC, id ASC + "#, + ) + .bind(task_uuid) + .fetch_all(pool) + .await?; + + Ok(recs) +} + +/// 删除任务所有环境关联 +pub async fn delete_rpa_task_environments( + pool: &Pool, + task_uuid: Uuid, +) -> Result<(), Error> { + sqlx::query("DELETE FROM rpa_task_environments WHERE task_uuid = $1") + .bind(task_uuid) + .execute(pool) + .await?; + + Ok(()) +} + +// ============ RPA Task Runs ============ + +/// 创建任务执行记录 +pub async fn insert_rpa_task_run( + pool: &Pool, + task_uuid: Uuid, + total_steps: i32, +) -> Result { + let uuid: Uuid = sqlx::query_scalar( + r#" + INSERT INTO rpa_task_runs (task_uuid, status, total_steps, completed_steps, failed_steps) + VALUES ($1, 'running', $2, 0, 0) + RETURNING uuid; + "#, + ) + .bind(task_uuid) + .bind(total_steps) + .fetch_one(pool) + .await?; + + // 更新任务最后运行时间和运行次数 + sqlx::query( + r#" + UPDATE rpa_tasks + SET last_run_at = CURRENT_TIMESTAMP, + run_count = COALESCE(run_count, 0) + 1 + WHERE uuid = $1 + "#, + ) + .bind(task_uuid) + .execute(pool) + .await?; + + Ok(uuid) +} + +/// 查询任务执行记录列表 +pub async fn fetch_rpa_task_runs( + pool: &Pool, + task_uuid: Uuid, + status: Option<&str>, + offset: i64, + limit: i64, +) -> Result, Error> { + let recs = sqlx::query_as::<_, RpaTaskRunDto>( + r#" + SELECT id, uuid, task_uuid, status, total_steps, completed_steps, failed_steps, + started_at, finished_at, duration_ms, result_summary, error_message, logs + FROM rpa_task_runs + WHERE task_uuid = $1 + AND ($2::varchar IS NULL OR status = $2) + ORDER BY started_at DESC + LIMIT $3 OFFSET $4 + "#, + ) + .bind(task_uuid) + .bind(status) + .bind(limit) + .bind(offset) + .fetch_all(pool) + .await?; + + Ok(recs) +} + +/// 查询任务执行记录总数 +pub async fn fetch_rpa_task_runs_count( + pool: &Pool, + task_uuid: Uuid, + status: Option<&str>, +) -> Result { + let count: i64 = sqlx::query_scalar( + r#" + SELECT COUNT(*) FROM rpa_task_runs + WHERE task_uuid = $1 + AND ($2::varchar IS NULL OR status = $2) + "#, + ) + .bind(task_uuid) + .bind(status) + .fetch_one(pool) + .await?; + + Ok(count) +} + +/// 根据 UUID 查询执行记录 +pub async fn fetch_rpa_task_run_by_uuid( + pool: &Pool, + run_uuid: Uuid, +) -> Result, Error> { + let rec = sqlx::query_as::<_, RpaTaskRunDto>( + r#" + SELECT id, uuid, task_uuid, status, total_steps, completed_steps, failed_steps, + started_at, finished_at, duration_ms, result_summary, error_message, logs + FROM rpa_task_runs + WHERE uuid = $1 + "#, + ) + .bind(run_uuid) + .fetch_optional(pool) + .await?; + + Ok(rec) +} + +/// 更新执行记录状态 +pub async fn update_rpa_task_run_status( + pool: &Pool, + run_uuid: Uuid, + status: &str, + completed_steps: i32, + failed_steps: i32, + result_summary: Option<&str>, + error_message: Option<&str>, +) -> Result<(), Error> { + sqlx::query( + r#" + UPDATE rpa_task_runs + SET status = $1, + completed_steps = $2, + failed_steps = $3, + result_summary = $4, + error_message = $5, + finished_at = CASE WHEN $1 IN ('completed', 'failed', 'stopped') THEN CURRENT_TIMESTAMP ELSE finished_at END, + duration_ms = CASE WHEN $1 IN ('completed', 'failed', 'stopped') + THEN EXTRACT(EPOCH FROM (CURRENT_TIMESTAMP - started_at)) * 1000 + ELSE duration_ms END + WHERE uuid = $6 + "#, + ) + .bind(status) + .bind(completed_steps) + .bind(failed_steps) + .bind(result_summary) + .bind(error_message) + .bind(run_uuid) + .execute(pool) + .await?; + + Ok(()) +} + + diff --git a/src-tauri/crates/business/src/models/strategy_types.rs b/src-tauri/crates/business/src/models/strategy_types.rs new file mode 100644 index 0000000..c9c4cbc --- /dev/null +++ b/src-tauri/crates/business/src/models/strategy_types.rs @@ -0,0 +1,58 @@ +use crate::dto::strategy_types::StrategyType; +use sqlx::Error; + +use crate::database::{Db as Postgres, Pool}; + +/// 根据ID查询策略类型 +pub async fn query_strategy_type_by_id( + pool: &Pool, + id: i32, +) -> Result { + let strategy_type: StrategyType = sqlx::query_as("SELECT * FROM strategy_types WHERE id = $1") + .bind(id) + .fetch_one(pool) + .await?; + + Ok(strategy_type) +} + +/// 根据code查询策略类型 +pub async fn query_strategy_type_by_code( + pool: &Pool, + code: &str, +) -> Result { + let strategy_type: StrategyType = + sqlx::query_as("SELECT * FROM strategy_types WHERE code = $1") + .bind(code) + .fetch_one(pool) + .await?; + + Ok(strategy_type) +} + +/// 查询所有可用的策略类型(只查询激活的) +pub async fn query_available_strategy_types( + pool: &Pool, +) -> Result, Error> { + let strategy_types: Vec = + sqlx::query_as("SELECT * FROM strategy_types WHERE is_active = true ORDER BY code") + .fetch_all(pool) + .await?; + + Ok(strategy_types) +} + +/// 根据分类查询策略类型 +pub async fn query_strategy_types_by_category( + pool: &Pool, + category: &str, +) -> Result, Error> { + let strategy_types: Vec = sqlx::query_as( + "SELECT * FROM strategy_types WHERE category = $1 AND is_active = true ORDER BY code", + ) + .bind(category) + .fetch_all(pool) + .await?; + + Ok(strategy_types) +} diff --git a/src-tauri/crates/business/src/models/teams.rs b/src-tauri/crates/business/src/models/teams.rs new file mode 100644 index 0000000..fedf7a0 --- /dev/null +++ b/src-tauri/crates/business/src/models/teams.rs @@ -0,0 +1,839 @@ +use sqlx::Error; +use uuid::Uuid; + +use crate::database::{Db as Postgres, Pool}; + +use crate::dto::{LoginHistoryDto, TeamDto, TeamInvitationDto, TeamMemberDto}; +use crate::entitys::CreateTeamRequest; + +// ============ Teams ============ + +/// 创建团队 +pub async fn insert_team( + pool: &Pool, + owner_uuid: Uuid, + payload: &CreateTeamRequest, +) -> Result { + let mut tx = pool.begin().await?; + + // 1. 创建团队 + let team_uuid: Uuid = sqlx::query_scalar( + r#" + INSERT INTO teams (workspace_uuid, name, description, owner_uuid) + VALUES ($1, $2, $3, $4) + RETURNING uuid; + "#, + ) + .bind(payload.workspace_uuid) + .bind(&payload.name) + .bind(&payload.description) + .bind(owner_uuid) + .fetch_one(&mut *tx) + .await?; + + // 2. 添加所有者为成员 + sqlx::query( + r#" + INSERT INTO team_members (team_uuid, workspace_uuid, user_uuid, role, status) + VALUES ($1, $2, $3, 'owner', 'active'); + "#, + ) + .bind(team_uuid) + .bind(payload.workspace_uuid) + .bind(owner_uuid) + .execute(&mut *tx) + .await?; + + // 3. 设置为用户当前团队 + sqlx::query( + r#" + UPDATE user_infos SET current_team_uuid = $1 WHERE user_uuid = $2; + "#, + ) + .bind(team_uuid) + .bind(owner_uuid) + .execute(&mut *tx) + .await?; + + tx.commit().await?; + + Ok(team_uuid) +} + +/// 根据 UUID 查询团队 +pub async fn fetch_team_by_uuid( + pool: &Pool, + team_uuid: Uuid, +) -> Result, Error> { + let rec = sqlx::query_as::<_, TeamDto>( + r#" + SELECT id, uuid, workspace_uuid, name, description, owner_uuid, avatar_hash, + status, created_at, updated_at, deleted_at + FROM teams + WHERE uuid = $1 AND deleted_at IS NULL + "#, + ) + .bind(team_uuid) + .fetch_optional(pool) + .await?; + + Ok(rec) +} + +/// 查询用户在工作空间中所属的所有团队(工作空间级别) +pub async fn fetch_user_teams( + pool: &Pool, + workspace_uuid: Uuid, + user_uuid: Uuid, +) -> Result, Error> { + let recs = sqlx::query_as::<_, TeamDto>( + r#" + SELECT t.id, t.uuid, t.workspace_uuid, t.name, t.description, t.owner_uuid, t.avatar_hash, + t.status, t.created_at, t.updated_at, t.deleted_at + FROM teams t + INNER JOIN team_members tm ON t.uuid = tm.team_uuid + WHERE tm.workspace_uuid = $1 AND tm.user_uuid = $2 AND t.deleted_at IS NULL AND tm.deleted_at IS NULL + ORDER BY t.created_at + "#, + ) + .bind(workspace_uuid) + .bind(user_uuid) + .fetch_all(pool) + .await?; + + Ok(recs) +} + +/// 查询用户当前团队 +pub async fn fetch_user_current_team( + pool: &Pool, + user_uuid: Uuid, +) -> Result, Error> { + let rec: Option = sqlx::query_scalar( + r#" + SELECT current_team_uuid FROM user_infos + WHERE user_uuid = $1 + "#, + ) + .bind(user_uuid) + .fetch_optional(pool) + .await?; + + Ok(rec) +} + +/// 设置用户当前团队 +pub async fn set_user_current_team( + pool: &Pool, + user_uuid: Uuid, + team_uuid: Uuid, +) -> Result<(), Error> { + sqlx::query( + r#" + UPDATE user_infos SET current_team_uuid = $1 WHERE user_uuid = $2 + "#, + ) + .bind(team_uuid) + .bind(user_uuid) + .execute(pool) + .await?; + + Ok(()) +} + +/// 清除用户当前团队 +pub async fn clear_user_current_team(pool: &Pool, user_uuid: Uuid) -> Result<(), Error> { + sqlx::query( + r#" + UPDATE user_infos SET current_team_uuid = NULL WHERE user_uuid = $1 + "#, + ) + .bind(user_uuid) + .execute(pool) + .await?; + + Ok(()) +} + +/// 更新团队信息 +pub async fn update_team( + pool: &Pool, + team_uuid: Uuid, + name: Option<&str>, + description: Option<&str>, + avatar_hash: Option<&str>, +) -> Result<(), Error> { + let mut query = String::from("UPDATE teams SET updated_at = NOW()"); + let mut params: Vec + Send + Sync>> = vec![]; + + if let Some(n) = name { + query.push_str(", name = $"); + params.push(Box::new(n)); + query.push_str(&format!("{}", params.len())); + } + + if let Some(d) = description { + query.push_str(", description = $"); + params.push(Box::new(d)); + query.push_str(&format!("{}", params.len())); + } + + if let Some(a) = avatar_hash { + query.push_str(", avatar_hash = $"); + params.push(Box::new(a)); + query.push_str(&format!("{}", params.len())); + } + + query.push_str(" WHERE uuid = $"); + params.push(Box::new(team_uuid)); + query.push_str(&format!("{}", params.len())); + + // 这里需要动态构建查询,但 sqlx 不支持动态查询,所以使用条件分支 + if name.is_some() + && description.is_some() + && avatar_hash.is_some() + { + sqlx::query( + r#" + UPDATE teams + SET name = $1, description = $2, avatar_hash = $3, updated_at = NOW() + WHERE uuid = $4 + "#, + ) + .bind(name.unwrap()) + .bind(description.unwrap()) + .bind(avatar_hash.unwrap()) + .bind(team_uuid) + .execute(pool) + .await?; + } else if name.is_some() && description.is_some() { + sqlx::query( + r#" + UPDATE teams + SET name = $1, description = $2, updated_at = NOW() + WHERE uuid = $3 + "#, + ) + .bind(name.unwrap()) + .bind(description.unwrap()) + .bind(team_uuid) + .execute(pool) + .await?; + } else if name.is_some() { + sqlx::query( + r#" + UPDATE teams + SET name = $1, updated_at = NOW() + WHERE uuid = $2 + "#, + ) + .bind(name.unwrap()) + .bind(team_uuid) + .execute(pool) + .await?; + } + + Ok(()) +} + +// ============ Team Members ============ + +/// 获取团队成员数量 +pub async fn fetch_team_member_count( + pool: &Pool, + team_uuid: Uuid, + keyword: Option<&str>, + role: Option<&str>, + status: Option<&str>, +) -> Result { + let mut query = String::from( + r#" + SELECT COUNT(*) FROM team_members tm + LEFT JOIN user_infos ui ON tm.user_uuid = ui.user_uuid + WHERE tm.team_uuid = $1 AND tm.deleted_at IS NULL + "#, + ); + + let mut param_index = 2; + + // 默认只查询 active 状态 + if status.is_some() { + query.push_str(&format!(" AND tm.status = ${}", param_index)); + param_index += 1; + } else { + query.push_str(" AND tm.status = 'active'"); + } + + if role.is_some() { + query.push_str(&format!(" AND tm.role = ${}", param_index)); + param_index += 1; + } + + if keyword.is_some() { + query.push_str(&format!( + " AND (ui.nickname ILIKE ${} OR ui.email ILIKE ${})", + param_index, param_index + )); + } + + let mut query_builder = sqlx::query_scalar::<_, i64>(&query).bind(team_uuid); + + if let Some(s) = status { + query_builder = query_builder.bind(s); + } + + if let Some(r) = role { + query_builder = query_builder.bind(r); + } + + if let Some(k) = keyword { + let keyword_pattern = format!("%{}%", k); + query_builder = query_builder.bind(keyword_pattern); + } + + let count = query_builder.fetch_one(pool).await?; + + Ok(count) +} + +/// 查询团队成员列表(关联用户信息,支持筛选) +pub async fn fetch_team_members( + pool: &Pool, + team_uuid: Uuid, + offset: i64, + limit: i64, + keyword: Option<&str>, + role: Option<&str>, + status: Option<&str>, +) -> Result, Error> { + let mut query = String::from( + r#" + SELECT + tm.id, + tm.team_uuid, + tm.workspace_uuid, + tm.user_uuid, + tm.role, + tm.joined_at, + tm.invited_by, + tm.status, + tm.created_at, + tm.updated_at, + tm.deleted_at, + ui.nickname AS name, + ui.email AS email, + ui.avatar_hash AS avatar + FROM team_members tm + LEFT JOIN user_infos ui ON tm.user_uuid = ui.user_uuid + WHERE tm.team_uuid = $1 AND tm.deleted_at IS NULL + "#, + ); + + let mut param_index = 2; + + // 默认只查询 active 状态 + if status.is_some() { + query.push_str(&format!(" AND tm.status = ${}", param_index)); + param_index += 1; + } else { + query.push_str(" AND tm.status = 'active'"); + } + + if role.is_some() { + query.push_str(&format!(" AND tm.role = ${}", param_index)); + param_index += 1; + } + + if keyword.is_some() { + query.push_str(&format!( + " AND (ui.nickname ILIKE ${} OR ui.email ILIKE ${})", + param_index, param_index + )); + param_index += 1; + } + + query.push_str(&format!( + r#" + ORDER BY + CASE tm.role + WHEN 'owner' THEN 1 + WHEN 'admin' THEN 2 + WHEN 'editor' THEN 3 + ELSE 4 + END, + tm.joined_at + LIMIT ${} OFFSET ${} + "#, + param_index, + param_index + 1 + )); + + let mut query_builder = sqlx::query_as::<_, TeamMemberDto>(&query).bind(team_uuid); + + if let Some(s) = status { + query_builder = query_builder.bind(s); + } + + if let Some(r) = role { + query_builder = query_builder.bind(r); + } + + if let Some(k) = keyword { + let keyword_pattern = format!("%{}%", k); + query_builder = query_builder.bind(keyword_pattern); + } + + query_builder = query_builder.bind(limit).bind(offset); + + let recs = query_builder.fetch_all(pool).await?; + + Ok(recs) +} + +/// 查询用户在团队中的成员信息(工作空间级别) +pub async fn fetch_team_member( + pool: &Pool, + workspace_uuid: Uuid, + team_uuid: Uuid, + user_uuid: Uuid, +) -> Result, Error> { + let rec = sqlx::query_as::<_, TeamMemberDto>( + r#" + SELECT + tm.id, + tm.team_uuid, + tm.workspace_uuid, + tm.user_uuid, + tm.role, + tm.joined_at, + tm.invited_by, + tm.status, + tm.created_at, + tm.updated_at, + tm.deleted_at, + ui.nickname AS name, + ui.email AS email, + ui.avatar_hash AS avatar + FROM team_members tm + LEFT JOIN user_infos ui ON tm.user_uuid = ui.user_uuid + WHERE tm.workspace_uuid = $1 AND tm.team_uuid = $2 AND tm.user_uuid = $3 AND tm.deleted_at IS NULL + "#, + ) + .bind(workspace_uuid) + .bind(team_uuid) + .bind(user_uuid) + .fetch_optional(pool) + .await?; + + Ok(rec) +} + +/// 添加团队成员 +pub async fn insert_team_member( + pool: &Pool, + team_uuid: Uuid, + user_uuid: Uuid, + role: &str, + invited_by: Option, +) -> Result { + let id: i32 = sqlx::query_scalar( + r#" + INSERT INTO team_members (team_uuid, workspace_uuid, user_uuid, role, invited_by, status) + VALUES ($1, (SELECT workspace_uuid FROM teams WHERE uuid = $1), $2, $3, $4, 'active') + RETURNING id; + "#, + ) + .bind(team_uuid) + .bind(user_uuid) + .bind(role) + .bind(invited_by) + .fetch_one(pool) + .await?; + + Ok(id) +} + +/// 更新成员角色 +pub async fn update_member_role( + pool: &Pool, + team_uuid: Uuid, + user_uuid: Uuid, + role: &str, +) -> Result<(), Error> { + sqlx::query( + r#" + UPDATE team_members + SET role = $1, updated_at = NOW() + WHERE team_uuid = $2 AND user_uuid = $3 AND deleted_at IS NULL + "#, + ) + .bind(role) + .bind(team_uuid) + .bind(user_uuid) + .execute(pool) + .await?; + + Ok(()) +} + +/// 更新成员状态 +pub async fn update_member_status( + pool: &Pool, + team_uuid: Uuid, + user_uuid: Uuid, + status: &str, +) -> Result<(), Error> { + sqlx::query( + r#" + UPDATE team_members + SET status = $1 + WHERE team_uuid = $2 AND user_uuid = $3 AND deleted_at IS NULL + "#, + ) + .bind(status) + .bind(team_uuid) + .bind(user_uuid) + .execute(pool) + .await?; + + Ok(()) +} + +/// 移除成员(软删除) +pub async fn remove_team_member( + pool: &Pool, + team_uuid: Uuid, + user_uuid: Uuid, +) -> Result<(), Error> { + sqlx::query( + r#" + UPDATE team_members + SET deleted_at = NOW(), status = 'inactive' + WHERE team_uuid = $1 AND user_uuid = $2 AND deleted_at IS NULL + "#, + ) + .bind(team_uuid) + .bind(user_uuid) + .execute(pool) + .await?; + + Ok(()) +} + +// ============ Team Invitations ============ + +/// 创建团队邀请 +pub async fn insert_team_invitation( + pool: &Pool, + team_uuid: Uuid, + email: &str, + role: &str, + invited_by: Uuid, + token: &str, + expires_at: chrono::DateTime, +) -> Result { + let uuid: Uuid = sqlx::query_scalar( + r#" + INSERT INTO team_invitations (team_uuid, email, role, invited_by, token, expires_at, status) + VALUES ($1, $2, $3, $4, $5, $6, 'pending') + RETURNING uuid; + "#, + ) + .bind(team_uuid) + .bind(email) + .bind(role) + .bind(invited_by) + .bind(token) + .bind(expires_at) + .fetch_one(pool) + .await?; + + Ok(uuid) +} + +/// 查询团队邀请 +pub async fn fetch_team_invitation_by_token( + pool: &Pool, + token: &str, +) -> Result, Error> { + let rec = sqlx::query_as::<_, TeamInvitationDto>( + r#" + SELECT id, uuid, team_uuid, email, role, invited_by, token, expires_at, status, accepted_at, created_at, updated_at + FROM team_invitations + WHERE token = $1 AND deleted_at IS NULL + "#, + ) + .bind(token) + .fetch_optional(pool) + .await?; + + Ok(rec) +} + +/// 查询团队的待处理邀请 +pub async fn fetch_pending_invitations( + pool: &Pool, + team_uuid: Uuid, +) -> Result, Error> { + let recs = sqlx::query_as::<_, TeamInvitationDto>( + r#" + SELECT id, uuid, team_uuid, email, role, invited_by, token, expires_at, status, accepted_at, created_at, updated_at + FROM team_invitations + WHERE team_uuid = $1 AND status = 'pending' AND deleted_at IS NULL + ORDER BY created_at DESC + "#, + ) + .bind(team_uuid) + .fetch_all(pool) + .await?; + + Ok(recs) +} + +/// 检查是否有待处理的邀请 +pub async fn has_pending_invitation( + pool: &Pool, + team_uuid: Uuid, + email: &str, +) -> Result { + let count: i64 = sqlx::query_scalar( + r#" + SELECT COUNT(*) FROM team_invitations + WHERE team_uuid = $1 AND email = $2 AND status = 'pending' AND deleted_at IS NULL + "#, + ) + .bind(team_uuid) + .bind(email) + .fetch_one(pool) + .await?; + + Ok(count > 0) +} + +/// 更新邀请状态 +pub async fn update_invitation_status( + pool: &Pool, + invitation_uuid: Uuid, + status: &str, +) -> Result<(), Error> { + sqlx::query( + r#" + UPDATE team_invitations + SET status = $1, updated_at = NOW() + WHERE uuid = $2 AND deleted_at IS NULL + "#, + ) + .bind(status) + .bind(invitation_uuid) + .execute(pool) + .await?; + + Ok(()) +} + +/// 查询邀请(通过 token,别名函数) +pub async fn fetch_invitation_by_token( + pool: &Pool, + token: &str, +) -> Result, Error> { + fetch_team_invitation_by_token(pool, token).await +} + +/// 取消邀请 +pub async fn cancel_team_invitation( + pool: &Pool, + invitation_uuid: Uuid, +) -> Result<(), Error> { + sqlx::query( + r#" + UPDATE team_invitations + SET status = 'cancelled', deleted_at = NOW() + WHERE uuid = $1 AND deleted_at IS NULL + "#, + ) + .bind(invitation_uuid) + .execute(pool) + .await?; + + Ok(()) +} + +/// 接受邀请 +pub async fn accept_team_invitation( + pool: &Pool, + invitation_uuid: Uuid, + user_uuid: Uuid, + workspace_uuid: Uuid, +) -> Result { + let mut tx = pool.begin().await?; + + // 1. 获取邀请信息 + let invitation = sqlx::query_as::<_, TeamInvitationDto>( + r#" + SELECT id, uuid, team_uuid, email, role, invited_by, token, expires_at, status, accepted_at, created_at, updated_at + FROM team_invitations + WHERE uuid = $1 AND deleted_at IS NULL + FOR UPDATE + "#, + ) + .bind(invitation_uuid) + .fetch_optional(&mut *tx) + .await?; + + let invitation = invitation.ok_or_else(|| Error::RowNotFound)?; + + // 2. 检查邀请状态 + if invitation.status != "pending" { + return Err(Error::RowNotFound); + } + + // 3. 检查是否过期 + if invitation.expires_at < chrono::Utc::now() { + return Err(Error::RowNotFound); + } + + // 4. 添加成员(如果已存在则更新状态为 active) + sqlx::query( + r#" + INSERT INTO team_members (team_uuid, workspace_uuid, user_uuid, role, invited_by, status) + VALUES ($1, $2, $3, $4, $5, 'active') + ON CONFLICT (team_uuid, user_uuid, workspace_uuid) DO UPDATE SET + role = EXCLUDED.role, + invited_by = EXCLUDED.invited_by, + status = 'active', + deleted_at = NULL, + updated_at = NOW() + "#, + ) + .bind(invitation.team_uuid) + .bind(workspace_uuid) + .bind(user_uuid) + .bind(&invitation.role) + .bind(invitation.invited_by) + .execute(&mut *tx) + .await?; + + // 5. 更新邀请状态 + sqlx::query( + r#" + UPDATE team_invitations + SET status = 'accepted', accepted_at = NOW() + WHERE uuid = $1 + "#, + ) + .bind(invitation_uuid) + .execute(&mut *tx) + .await?; + + tx.commit().await?; + + Ok(invitation.team_uuid) +} + +/// 拒绝邀请 +pub async fn reject_team_invitation( + pool: &Pool, + invitation_uuid: Uuid, + _user_uuid: Uuid, +) -> Result<(), Error> { + let mut tx = pool.begin().await?; + + // 1. 获取邀请信息 + let invitation = sqlx::query_as::<_, TeamInvitationDto>( + r#" + SELECT id, uuid, team_uuid, email, role, invited_by, token, expires_at, status, accepted_at, created_at, updated_at + FROM team_invitations + WHERE uuid = $1 AND deleted_at IS NULL + FOR UPDATE + "#, + ) + .bind(invitation_uuid) + .fetch_optional(&mut *tx) + .await?; + + let invitation = invitation.ok_or_else(|| Error::RowNotFound)?; + + // 2. 检查邀请状态(只有 pending 状态的邀请才能被拒绝) + if invitation.status != "pending" { + return Err(Error::RowNotFound); + } + + // 3. 检查是否过期(过期的邀请也可以标记为拒绝) + // 这里不检查过期时间,允许拒绝已过期的邀请 + + // 4. 更新邀请状态为 rejected + sqlx::query( + r#" + UPDATE team_invitations + SET status = 'rejected', updated_at = NOW() + WHERE uuid = $1 + "#, + ) + .bind(invitation_uuid) + .execute(&mut *tx) + .await?; + + tx.commit().await?; + + Ok(()) +} + +// ============ Login History ============ + +/// 记录登录历史 +pub async fn insert_login_history( + pool: &Pool, + user_uuid: Uuid, + ip_address: &str, + device_info: Option<&str>, + user_agent: Option<&str>, + location: Option<&str>, + country: Option<&str>, + city: Option<&str>, + success: bool, + failure_reason: Option<&str>, +) -> Result { + let id: i64 = sqlx::query_scalar( + r#" + INSERT INTO login_history (user_uuid, ip_address, device_info, user_agent, location, country, city, success, failure_reason) + VALUES ($1, $2, $3, $4, $5, $6, $7, $8, $9) + RETURNING id; + "#, + ) + .bind(user_uuid) + .bind(ip_address) + .bind(device_info) + .bind(user_agent) + .bind(location) + .bind(country) + .bind(city) + .bind(success) + .bind(failure_reason) + .fetch_one(pool) + .await?; + + Ok(id) +} + +/// 查询用户登录历史 +pub async fn fetch_user_login_history( + pool: &Pool, + user_uuid: Uuid, + limit: i64, +) -> Result, Error> { + let recs = sqlx::query_as::<_, LoginHistoryDto>( + r#" + SELECT id, user_uuid, ip_address, device_info, user_agent, location, country, city, success, failure_reason, created_at + FROM login_history + WHERE user_uuid = $1 + ORDER BY created_at DESC + LIMIT $2 + "#, + ) + .bind(user_uuid) + .bind(limit) + .fetch_all(pool) + .await?; + + Ok(recs) +} diff --git a/src-tauri/crates/business/src/models/user.rs b/src-tauri/crates/business/src/models/user.rs new file mode 100644 index 0000000..02a9ad5 --- /dev/null +++ b/src-tauri/crates/business/src/models/user.rs @@ -0,0 +1,533 @@ +use rust_decimal::Decimal; +use sqlx::Error; +use uuid::Uuid; + +use crate::database::{Db as Postgres, Pool}; + +use crate::dto::{LocalApiPermissionDefinitionDto, UserDto, UserInfoDto}; +use crate::entitys::{RegisterRequest, UpdateUserRequest}; + +/// 插入用户基础信息 +pub async fn insert_user(pool: &Pool, user_id: String) -> Result { + let uuid: Uuid = sqlx::query_scalar("INSERT INTO users (id) VALUES ($1) RETURNING uuid;") + .bind(user_id) + .fetch_one(pool) + .await?; + + Ok(uuid) +} + +/// 插入用户详细信息 +pub async fn insert_user_info( + pool: &Pool, + user_uuid: Uuid, + payload: &RegisterRequest, + password_hash: &str, +) -> Result { + let id: i32 = sqlx::query_scalar( + r#" + INSERT INTO user_infos (user_uuid, email, password, nickname, status) + VALUES ($1, $2, $3, $4, 'active') + RETURNING id; + "#, + ) + .bind(user_uuid) + .bind(&payload.email) + .bind(password_hash) + .bind(&payload.nickname) + .fetch_one(pool) + .await?; + + Ok(id) +} + +/// 使用事务创建用户(users + user_infos + 初始化数据) +/// +/// 初始化数据包括: +/// - user_wallets: 用户钱包 +/// - user_quotas: 用户配额(免费套餐) +/// - user_preferences: 用户偏好设置 +/// - user_referral_points: 推广积分 +/// - referral_links: 推广链接(关联青铜层级) +/// - teams: 个人团队(每个用户自动创建一个团队) +pub async fn create_user_with_info( + pool: &Pool, + user_id: String, + payload: &RegisterRequest, + password_hash: &str, + quota: &crate::utils::WorkspaceQuotaValues, +) -> Result { + // 为用户初始化推广链接时,需要读取所有推广层级配置。 + // 这里复用 referral 模块中已经定义好的 DTO / 查询函数,避免在 models 中新增本地 struct 类型。 + let tiers = crate::models::referral::fetch_referral_tiers(pool).await?; + let local_api_permission_definitions = + crate::models::local_api::fetch_permission_definitions(pool).await?; + + let mut tx = pool.begin().await?; + + // 1. 创建用户基础记录 + let user_uuid: Uuid = sqlx::query_scalar("INSERT INTO users (id) VALUES ($1) RETURNING uuid;") + .bind(&user_id) + .fetch_one(&mut *tx) + .await?; + + // 2. 创建用户详细信息 + sqlx::query( + r#" + INSERT INTO user_infos (user_uuid, email, password, nickname, status) + VALUES ($1, $2, $3, $4, 'active'); + "#, + ) + .bind(user_uuid) + .bind(&payload.email) + .bind(password_hash) + .bind(&payload.nickname) + .execute(&mut *tx) + .await?; + + // 3. 初始化用户钱包 + sqlx::query( + r#" + INSERT INTO user_wallets (user_uuid, balance, currency, frozen_amount) + VALUES ($1, 0, 'CNY', 0); + "#, + ) + .bind(user_uuid) + .execute(&mut *tx) + .await?; + + // 4. 初始化用户配额(已废弃,配额移至 workspace_quotas,在工作空间创建时初始化) + + // 5. 初始化用户偏好设置 + sqlx::query( + r#" + INSERT INTO user_preferences (user_uuid, theme, language, notifications_enabled) + VALUES ($1, 'system', 'zh-CN', TRUE); + "#, + ) + .bind(user_uuid) + .execute(&mut *tx) + .await?; + + // 6. 初始化本地 API 配置 + sqlx::query( + r#" + INSERT INTO user_local_api_settings (user_uuid, enabled, port, remote_access, cors_origins) + VALUES ($1, FALSE, 8080, FALSE, '[]'::jsonb); + "#, + ) + .bind(user_uuid) + .execute(&mut *tx) + .await?; + + // 7. 初始化本地 API 密钥 + let local_api_key = generate_local_api_key(); + let local_api_key_hash = crate::models::local_api::hash_api_key(&local_api_key); + let local_api_key_prefix = local_api_key.chars().take(16).collect::(); + let local_api_key_id: i32 = sqlx::query_scalar( + r#" + INSERT INTO user_local_api_keys (user_uuid, key_prefix, key_hash, api_key, daily_limit) + VALUES ($1, $2, $3, $4, 1000) + RETURNING id; + "#, + ) + .bind(user_uuid) + .bind(&local_api_key_prefix) + .bind(&local_api_key_hash) + .bind(&local_api_key) + .fetch_one(&mut *tx) + .await?; + + // 8. 初始化本地 API 权限记录 + insert_local_api_permissions( + &mut tx, + local_api_key_id, + &local_api_permission_definitions, + ) + .await?; + + // 9. 初始化推广积分 + sqlx::query( + r#" + INSERT INTO user_referral_points (user_uuid, total_points, available_points, used_points, pending_points) + VALUES ($1, 0, 0, 0, 0); + "#, + ) + .bind(user_uuid) + .execute(&mut *tx) + .await?; + + // 10. 创建推广链接(根据层级配置创建最多 4 个链接) + // + // 规则: + // - 始终为用户预创建所有层级对应的推广链接(默认最多 4 个) + // - 只有第一个层级的链接默认解锁并设为当前链接 + // - 奖励比例 / 折扣比例从对应层级配置中继承,便于后续统一调整 + // + // 生成基础邀请码(第一个链接使用此码,其余在此基础上追加序号后缀) + let base_invite_code = format!("INV{}", &user_uuid.to_string()[..8].to_uppercase()); + + // 根据层级配置为用户预创建推广链接(最多 4 个) + for (idx, tier) in tiers.iter().enumerate() { + if idx >= 4 { + // 目前产品形态只需要 4 个推广链接,多余层级忽略 + break; + } + + let code = if idx == 0 { + base_invite_code.clone() + } else { + // 保证唯一性的同时,便于用户识别不同链接 + format!("{}-{}", base_invite_code, idx + 1) + }; + + // 只有第一个层级默认解锁并设为当前链接,其余待后续根据阈值自动解锁 + let is_current = idx == 0; + let unlocked = idx == 0; + + sqlx::query( + r#" + INSERT INTO referral_links ( + user_uuid, + code, + tier_uuid, + is_current, + unlocked, + reward_rate, + discount_rate + ) + VALUES ($1, $2, $3, $4, $5, $6, $7); + "#, + ) + .bind(user_uuid) + .bind(&code) + .bind(tier.uuid) + .bind(is_current) + .bind(unlocked) + .bind(tier.reward_rate) + .bind(tier.discount_rate) + .execute(&mut *tx) + .await?; + } + + // 11. 处理推荐人关系(如果有邀请码) + if let Some(ref referrer_code) = payload.referral_code { + // 查找推荐人 + let referrer: Option<(Uuid, Uuid)> = sqlx::query_as( + r#" + SELECT user_uuid, uuid FROM referral_links WHERE code = $1 AND is_current = TRUE; + "#, + ) + .bind(referrer_code) + .fetch_optional(&mut *tx) + .await?; + + if let Some((referrer_uuid, referral_link_uuid)) = referrer { + // 创建推荐关系(表列名:inviter_uuid=推荐人, invitee_uuid=被邀请人, link_uuid=推广链接) + sqlx::query( + r#" + INSERT INTO user_referrals (invitee_uuid, inviter_uuid, link_uuid, status) + VALUES ($1, $2, $3, 'registered'); + "#, + ) + .bind(user_uuid) + .bind(referrer_uuid) + .bind(referral_link_uuid) + .execute(&mut *tx) + .await?; + + // 更新推荐链接统计 + sqlx::query( + r#" + UPDATE referral_links SET registered_users = registered_users + 1 WHERE uuid = $1; + "#, + ) + .bind(referral_link_uuid) + .execute(&mut *tx) + .await?; + } + } + + // 12. 创建个人团队(每个用户都应该有一个团队) + // 团队名称使用用户昵称,如果没有昵称则使用邮箱前缀 + let team_name = + payload.nickname.as_ref().map(|n| format!("{} 的团队", n)).unwrap_or_else(|| { + format!( + "{} 的团队", + payload.email.split('@').next().unwrap_or("用户") + ) + }); + + // 13. 创建个人工作空间 + let workspace_name = format!( + "{} 的工作空间", + payload.nickname.as_deref().unwrap_or("用户") + ); + let workspace_uuid: Uuid = sqlx::query_scalar( + r#" + INSERT INTO workspaces (name, owner_uuid, workspace_type) + VALUES ($1, $2, 'personal') + RETURNING uuid; + "#, + ) + .bind(&workspace_name) + .bind(user_uuid) + .fetch_one(&mut *tx) + .await?; + + // 14. 创建工作空间配额(使用传入的配额配置) + sqlx::query( + r#" + INSERT INTO workspace_quotas (workspace_uuid, max_environments, max_team_members, max_proxies, max_rpa_tasks) + VALUES ($1, $2, $3, $4, $5); + "#, + ) + .bind(workspace_uuid) + .bind(quota.max_environments) + .bind(quota.max_team_members) + .bind(quota.max_proxies) + .bind(quota.max_rpa_tasks) + .execute(&mut *tx) + .await?; + + // 15. 创建个人团队(每个用户自动创建一个团队) + let team_uuid: Uuid = sqlx::query_scalar( + r#" + INSERT INTO teams (workspace_uuid, name, description, owner_uuid) + VALUES ($1, $2, $3, $4) + RETURNING uuid; + "#, + ) + .bind(workspace_uuid) + .bind(&team_name) + .bind(Some("个人团队")) + .bind(user_uuid) + .fetch_one(&mut *tx) + .await?; + + // 16. 添加用户为团队成员(owner 角色) + sqlx::query( + r#" + INSERT INTO team_members (team_uuid, workspace_uuid, user_uuid, role, status) + VALUES ($1, $2, $3, 'owner', 'active'); + "#, + ) + .bind(team_uuid) + .bind(workspace_uuid) + .bind(user_uuid) + .execute(&mut *tx) + .await?; + + // 17. 设置为用户当前团队和工作空间 + sqlx::query( + r#" + UPDATE user_infos SET current_team_uuid = $1, current_workspace_uuid = $2 WHERE user_uuid = $3; + "#, + ) + .bind(team_uuid) + .bind(workspace_uuid) + .bind(user_uuid) + .execute(&mut *tx) + .await?; + + tx.commit().await?; + + Ok(user_uuid) +} + +async fn insert_local_api_permissions( + tx: &mut sqlx::Transaction<'_, Postgres>, + api_key_id: i32, + definitions: &[LocalApiPermissionDefinitionDto], +) -> Result<(), Error> { + for definition in definitions { + sqlx::query( + r#" + INSERT INTO user_local_api_key_permissions ( + api_key_id, permission_code, is_enabled, rate_limit_per_minute, rate_limit_per_hour + ) + VALUES ($1, $2, $3, $4, $5) + ON CONFLICT (api_key_id, permission_code) DO NOTHING; + "#, + ) + .bind(api_key_id) + .bind(definition.permission_code.as_str()) + .bind(definition.default_enabled) + .bind(definition.default_rate_limit_per_minute) + .bind(definition.default_rate_limit_per_hour) + .execute(&mut **tx) + .await?; + } + + Ok(()) +} + +fn generate_local_api_key() -> String { + let raw = Uuid::new_v4().simple().to_string(); + format!("sk_local_{}", raw) +} + + +/// 根据 UUID 查询用户 +pub async fn fetch_user_by_uuid( + pool: &Pool, + user_uuid: Uuid, +) -> Result, Error> { + let rec = sqlx::query_as::<_, UserDto>( + r#" + SELECT uuid, id, created_at, updated_at, deleted_at + FROM users + WHERE uuid = $1 AND deleted_at IS NULL + "#, + ) + .bind(user_uuid) + .fetch_optional(pool) + .await?; + + Ok(rec) +} + +/// 查询用户详细信息 +pub async fn fetch_user_info_by_uuid( + pool: &Pool, + user_uuid: Uuid, +) -> Result, Error> { + let rec = sqlx::query_as::<_, UserInfoDto>( + r#" + SELECT id, user_uuid, nickname, email, phone, password, avatar_hash, status, + current_team_uuid, current_workspace_uuid, created_at, updated_at, deleted_at + FROM user_infos + WHERE user_uuid = $1 AND deleted_at IS NULL + "#, + ) + .bind(user_uuid) + .fetch_optional(pool) + .await?; + + Ok(rec) +} + +/// 根据邮箱查询用户详细信息 +pub async fn fetch_user_info_by_email( + pool: &Pool, + email: &str, +) -> Result, Error> { + let rec = sqlx::query_as::<_, UserInfoDto>( + r#" + SELECT id, user_uuid, nickname, email, phone, password, avatar_hash, status, + current_team_uuid, current_workspace_uuid, created_at, updated_at, deleted_at + FROM user_infos + WHERE email = $1 AND deleted_at IS NULL + "#, + ) + .bind(email) + .fetch_optional(pool) + .await?; + + Ok(rec) +} + +/// 更新用户信息 +pub async fn update_user_info( + pool: &Pool, + user_uuid: Uuid, + payload: &UpdateUserRequest, +) -> Result<(), Error> { + sqlx::query( + r#" + UPDATE user_infos + SET nickname = COALESCE($1, nickname), + phone = COALESCE($2, phone), + email = COALESCE($3, email), + updated_at = CURRENT_TIMESTAMP + WHERE user_uuid = $4 AND deleted_at IS NULL + "#, + ) + .bind(payload.nickname.as_deref()) + .bind(payload.phone.as_deref()) + .bind(payload.email.as_deref()) + .bind(user_uuid) + .execute(pool) + .await?; + + Ok(()) +} + +/// 更新密码 +pub async fn update_password( + pool: &Pool, + user_uuid: Uuid, + password_hash: &str, +) -> Result<(), Error> { + sqlx::query( + r#" + UPDATE user_infos + SET password = $1 + WHERE user_uuid = $2 AND deleted_at IS NULL + "#, + ) + .bind(password_hash) + .bind(user_uuid) + .execute(pool) + .await?; + + Ok(()) +} + +/// 设置用户当前工作空间 +pub async fn fetch_user_current_workspace( + pool: &Pool, + user_uuid: Uuid, +) -> Result, Error> { + let rec: Option = sqlx::query_scalar( + r#" + SELECT current_workspace_uuid FROM user_infos + WHERE user_uuid = $1 + "#, + ) + .bind(user_uuid) + .fetch_optional(pool) + .await?; + + Ok(rec) +} + +pub async fn set_user_current_workspace( + pool: &Pool, + user_uuid: Uuid, + workspace_uuid: Uuid, +) -> Result<(), Error> { + sqlx::query( + r#" + UPDATE user_infos SET current_workspace_uuid = $1 WHERE user_uuid = $2 + "#, + ) + .bind(workspace_uuid) + .bind(user_uuid) + .execute(pool) + .await?; + + Ok(()) +} + +pub async fn set_user_current_workspace_and_team( + pool: &Pool, + user_uuid: Uuid, + workspace_uuid: Uuid, + team_uuid: Uuid, +) -> Result<(), Error> { + sqlx::query( + r#" + UPDATE user_infos + SET current_workspace_uuid = $1, current_team_uuid = $2 + WHERE user_uuid = $3 + "#, + ) + .bind(workspace_uuid) + .bind(team_uuid) + .bind(user_uuid) + .execute(pool) + .await?; + + Ok(()) +} diff --git a/src-tauri/crates/business/src/models/version_types.rs b/src-tauri/crates/business/src/models/version_types.rs new file mode 100644 index 0000000..418819b --- /dev/null +++ b/src-tauri/crates/business/src/models/version_types.rs @@ -0,0 +1,126 @@ +use crate::dto::version_types::VersionType; +use crate::entitys::version_types::{CreateVersionTypeRequest, UpdateVersionTypeRequest}; +use sqlx::Error; + +use crate::database::{Db as Postgres, Pool}; + +/// 插入新版本类型 +pub async fn insert_version_type( + pool: &Pool, + request: &CreateVersionTypeRequest, +) -> Result { + let sql = " + INSERT INTO version_types ( + type_code, type_name, description, sort_order, is_active + ) VALUES ( + $1, $2, $3, $4, true + ) RETURNING id + "; + + let result: (i32,) = sqlx::query_as(sql) + .bind(&request.type_code) + .bind(&request.type_name) + .bind(&request.description) + .bind(request.sort_order.unwrap_or(0)) + .fetch_one(pool) + .await?; + + Ok(result.0) +} + +/// 根据ID查询版本类型 +pub async fn query_version_type_by_id( + pool: &Pool, + id: i32, +) -> Result { + let version_type: VersionType = sqlx::query_as("SELECT * FROM version_types WHERE id = $1") + .bind(id) + .fetch_one(pool) + .await?; + + Ok(version_type) +} + +/// 根据代码查询版本类型 +pub async fn query_version_type_by_code( + pool: &Pool, + type_code: &str, +) -> Result { + let version_type: VersionType = + sqlx::query_as("SELECT * FROM version_types WHERE type_code = $1") + .bind(type_code) + .fetch_one(pool) + .await?; + + Ok(version_type) +} + +/// 查询所有版本类型 +pub async fn query_all_version_types(pool: &Pool) -> Result, Error> { + let version_types: Vec = + sqlx::query_as("SELECT * FROM version_types ORDER BY sort_order ASC, id ASC") + .fetch_all(pool) + .await?; + + Ok(version_types) +} + +/// 查询激活的版本类型 +pub async fn query_active_version_types(pool: &Pool) -> Result, Error> { + let version_types: Vec = sqlx::query_as( + "SELECT * FROM version_types WHERE is_active = true ORDER BY sort_order ASC, id ASC", + ) + .fetch_all(pool) + .await?; + + Ok(version_types) +} + +/// 更新版本类型 +pub async fn update_version_type( + pool: &Pool, + id: i32, + request: &UpdateVersionTypeRequest, +) -> Result { + let sql = " + UPDATE version_types SET + type_name = COALESCE($1, type_name), + description = COALESCE($2, description), + sort_order = COALESCE($3, sort_order), + is_active = COALESCE($4, is_active) + WHERE id = $5 + "; + + let row = sqlx::query(sql) + .bind(&request.type_name) + .bind(&request.description) + .bind(request.sort_order) + .bind(request.is_active) + .bind(id) + .execute(pool) + .await?; + + Ok(row.rows_affected() == 1) +} + +/// 删除版本类型 +pub async fn delete_version_type(pool: &Pool, id: i32) -> Result { + let sql = "DELETE FROM version_types WHERE id = $1"; + let row = sqlx::query(sql).bind(id).execute(pool).await?; + Ok(row.rows_affected() == 1) +} + +/// 激活/停用版本类型 +pub async fn toggle_version_type_status( + pool: &Pool, + id: i32, + is_active: bool, +) -> Result { + let sql = "UPDATE version_types SET is_active = $1 WHERE id = $2"; + let row = sqlx::query(sql) + .bind(is_active) + .bind(id) + .execute(pool) + .await?; + Ok(row.rows_affected() == 1) +} diff --git a/src-tauri/crates/business/src/models/versions.rs b/src-tauri/crates/business/src/models/versions.rs new file mode 100644 index 0000000..093e61e --- /dev/null +++ b/src-tauri/crates/business/src/models/versions.rs @@ -0,0 +1,551 @@ +use crate::dto::versions::Version; +use crate::entitys::versions::{CreateVersionRequest, UpdateVersionRequest}; +use chrono::{DateTime, Utc}; +use sqlx::Error; + +use crate::database::{Db as Postgres, Pool}; + +/// 插入新版本 +pub async fn insert_version( + pool: &Pool, + request: &CreateVersionRequest, +) -> Result { + let sql = " + INSERT INTO versions ( + type_id, resource_name, version, name, notes, platform, url, hash, + signature, install_path, file_size, pub_date, + arch, package_format, requires_extract, created_at + ) VALUES ( + $1, $2, $3, $4, $5, $6, $7, $8, $9, $10, $11, $12, + $13, $14, $15, NOW() + ) RETURNING id + "; + + let result: (i32,) = sqlx::query_as(sql) + .bind(request.type_id) + .bind(&request.resource_name) + .bind(&request.version) + .bind(&request.name) + .bind(&request.notes) + .bind(&request.platform) + .bind(&request.url) + .bind(&request.hash) + .bind(&request.signature) + .bind(&request.install_path) + .bind(request.file_size) + .bind(request.pub_date) + .bind(&request.arch) + .bind(&request.package_format) + .bind(request.requires_extract.unwrap_or(true)) + .fetch_one(pool) + .await?; + + Ok(result.0) +} + +/// 根据ID查询版本 +pub async fn query_version_by_id(pool: &Pool, id: i32) -> Result { + let version: Version = sqlx::query_as("SELECT * FROM versions WHERE id = $1") + .bind(id) + .fetch_one(pool) + .await?; + + Ok(version) +} + +/// 根据资源名称和版本号查询 +pub async fn query_version_by_name_and_version( + pool: &Pool, + resource_name: &str, + version: &str, +) -> Result { + let version_data: Version = + sqlx::query_as("SELECT * FROM versions WHERE resource_name = $1 AND version = $2") + .bind(resource_name) + .bind(version) + .fetch_one(pool) + .await?; + + Ok(version_data) +} + +/// 查询最新版本 +pub async fn query_latest_version( + pool: &Pool, + resource_name: &str, + platform: &str, +) -> Result, Error> { + let version: Option = sqlx::query_as( + "SELECT * FROM versions + WHERE resource_name = $1 AND platform = $2 AND status = 'active' + ORDER BY pub_date DESC LIMIT 1", + ) + .bind(resource_name) + .bind(platform) + .fetch_optional(pool) + .await?; + + Ok(version) +} + +/// 查询版本列表(分页) +pub async fn query_versions( + pool: &Pool, + resource_name: Option<&str>, + platform: Option<&str>, + status: Option<&str>, + page_num: i32, + page_size: i32, +) -> Result<(i64, Vec), Error> { + // 构建查询条件 + let mut where_clauses = vec!["deleted_at IS NULL".to_string()]; + let mut param_index = 1; + + if let Some(_name) = resource_name { + where_clauses.push(format!("resource_name = ${}", param_index)); + param_index += 1; + } + if let Some(_plat) = platform { + where_clauses.push(format!("platform = ${}", param_index)); + param_index += 1; + } + if let Some(_st) = status { + where_clauses.push(format!("status = ${}", param_index)); + param_index += 1; + } + + let where_sql = where_clauses.join(" AND "); + + // 获取总数 + let count_sql = format!("SELECT COUNT(*) FROM versions WHERE {}", where_sql); + let count_result: (i64,) = match (resource_name, platform, status) { + (Some(n), Some(p), Some(s)) => { + sqlx::query_as(&count_sql) + .bind(n) + .bind(p) + .bind(s) + .fetch_one(pool) + .await? + } + (Some(n), Some(p), None) => { + sqlx::query_as(&count_sql) + .bind(n) + .bind(p) + .fetch_one(pool) + .await? + } + (Some(n), None, Some(s)) => { + sqlx::query_as(&count_sql) + .bind(n) + .bind(s) + .fetch_one(pool) + .await? + } + (Some(n), None, None) => sqlx::query_as(&count_sql).bind(n).fetch_one(pool).await?, + (None, Some(p), Some(s)) => { + sqlx::query_as(&count_sql) + .bind(p) + .bind(s) + .fetch_one(pool) + .await? + } + (None, Some(p), None) => sqlx::query_as(&count_sql).bind(p).fetch_one(pool).await?, + (None, None, Some(s)) => sqlx::query_as(&count_sql).bind(s).fetch_one(pool).await?, + (None, None, None) => sqlx::query_as(&count_sql).fetch_one(pool).await?, + }; + + // 获取分页列表 + let list_sql = format!( + "SELECT * FROM versions WHERE {} ORDER BY created_at DESC LIMIT ${} OFFSET ${}", + where_sql, + param_index, + param_index + 1 + ); + let versions: Vec = match (resource_name, platform, status) { + (Some(n), Some(p), Some(s)) => { + sqlx::query_as(&list_sql) + .bind(n) + .bind(p) + .bind(s) + .bind(page_size) + .bind((page_num - 1) * page_size) + .fetch_all(pool) + .await? + } + (Some(n), Some(p), None) => { + sqlx::query_as(&list_sql) + .bind(n) + .bind(p) + .bind(page_size) + .bind((page_num - 1) * page_size) + .fetch_all(pool) + .await? + } + (Some(n), None, Some(s)) => { + sqlx::query_as(&list_sql) + .bind(n) + .bind(s) + .bind(page_size) + .bind((page_num - 1) * page_size) + .fetch_all(pool) + .await? + } + (Some(n), None, None) => { + sqlx::query_as(&list_sql) + .bind(n) + .bind(page_size) + .bind((page_num - 1) * page_size) + .fetch_all(pool) + .await? + } + (None, Some(p), Some(s)) => { + sqlx::query_as(&list_sql) + .bind(p) + .bind(s) + .bind(page_size) + .bind((page_num - 1) * page_size) + .fetch_all(pool) + .await? + } + (None, Some(p), None) => { + sqlx::query_as(&list_sql) + .bind(p) + .bind(page_size) + .bind((page_num - 1) * page_size) + .fetch_all(pool) + .await? + } + (None, None, Some(s)) => { + sqlx::query_as(&list_sql) + .bind(s) + .bind(page_size) + .bind((page_num - 1) * page_size) + .fetch_all(pool) + .await? + } + (None, None, None) => { + sqlx::query_as(&list_sql) + .bind(page_size) + .bind((page_num - 1) * page_size) + .fetch_all(pool) + .await? + } + }; + + Ok((count_result.0, versions)) +} + +/// 更新版本 +pub async fn update_version( + pool: &Pool, + id: i32, + request: &UpdateVersionRequest, +) -> Result { + let sql = " + UPDATE versions SET + name = COALESCE($1, name), + notes = COALESCE($2, notes), + platform = COALESCE($3, platform), + url = COALESCE($4, url), + hash = COALESCE($5, hash), + signature = COALESCE($6, signature), + install_path = COALESCE($7, install_path), + file_size = COALESCE($8, file_size), + status = COALESCE($9, status), + is_latest = COALESCE($10, is_latest), + updated_at = NOW() + WHERE id = $11 + "; + + let row = sqlx::query(sql) + .bind(&request.name) + .bind(&request.notes) + .bind(&request.platform) + .bind(&request.url) + .bind(&request.hash) + .bind(&request.signature) + .bind(&request.install_path) + .bind(request.file_size) + .bind(&request.status) + .bind(request.is_latest) + .bind(id) + .execute(pool) + .await?; + + Ok(row.rows_affected() == 1) +} + +/// 软删除版本 +pub async fn delete_version(pool: &Pool, id: i32) -> Result { + // 非逻辑删除 + let sql = "DELETE FROM versions WHERE id = $1"; + let row = sqlx::query(sql).bind(id).execute(pool).await?; + Ok(row.rows_affected() == 1) +} + +/// 设置某个资源为最新版本 +pub async fn set_as_latest_version( + pool: &Pool, + type_id: i32, + resource_name: &str, + version_id: i32, +) -> Result { + let mut tx = pool.begin().await?; + + // 先取消其他版本的最新状态 + sqlx::query( + "UPDATE versions SET is_latest = false, updated_at = NOW() + WHERE type_id = $1 AND resource_name = $2 AND id != $3", + ) + .bind(type_id) + .bind(resource_name) + .bind(version_id) + .execute(&mut *tx) + .await?; + + // 设置当前版本为最新 + let row = sqlx::query("UPDATE versions SET is_latest = true, updated_at = NOW() WHERE id = $1") + .bind(version_id) + .execute(&mut *tx) + .await?; + + tx.commit().await?; + Ok(row.rows_affected() == 1) +} + +/// 查询所有激活版本类型对应平台的最新版本 +/// 使用 is_latest 字段优先,按 (type_code, resource_name) 分组 +/// 返回 (type_code, resource_name, Version) 元组列表 +pub async fn query_all_active_latest_versions( + pool: &Pool, + platform: &str, +) -> Result, Error> { + // 自定义结构体用于接收查询结果 + #[derive(sqlx::FromRow)] + struct VersionWithTypeCode { + type_code: String, + // Version 的所有字段 + id: i32, + type_id: i32, + resource_name: String, + version: String, + name: Option, + notes: Option, + platform: Option, + url: Option, + hash: Option, + signature: Option, + install_path: Option, + file_size: Option, + is_latest: bool, + status: String, + pub_date: Option>, + created_at: DateTime, + updated_at: Option>, + deleted_at: Option>, + arch: Option, + package_format: Option, + requires_extract: bool, + entrypoint_template: Option, + extract_root: Option, + } + + let results: Vec = sqlx::query_as( + r#" + SELECT DISTINCT ON (vt.type_code, v.resource_name) + vt.type_code, + v.id, v.type_id, v.resource_name, v.version, v.name, v.notes, + v.platform, v.url, v.hash, v.signature, v.install_path, + v.file_size, v.is_latest, v.status, v.pub_date, + v.created_at, v.updated_at, v.deleted_at, + v.arch, v.package_format, v.requires_extract, v.entrypoint_template, v.extract_root + FROM versions v + INNER JOIN version_types vt ON v.type_id = vt.id + WHERE vt.is_active = true + AND v.platform = $1 + AND v.status = 'active' + AND v.deleted_at IS NULL + ORDER BY vt.type_code, v.resource_name, v.is_latest DESC, v.id DESC + "#, + ) + .bind(platform) + .fetch_all(pool) + .await?; + + // 转换为 (String, String, Version) 元组 + let converted: Vec<(String, String, Version)> = results + .into_iter() + .map(|r| { + ( + r.type_code.clone(), + r.resource_name.clone(), + Version { + id: r.id, + type_id: r.type_id, + resource_name: r.resource_name, + version: r.version, + name: r.name.clone(), + notes: r.notes, + platform: r.platform, + url: r.url, + hash: r.hash, + signature: r.signature, + install_path: r.install_path, + file_size: r.file_size, + is_latest: r.is_latest, + status: r.status, + pub_date: r.pub_date, + created_at: r.created_at, + updated_at: r.updated_at, + deleted_at: r.deleted_at, + arch: r.arch, + package_format: r.package_format, + requires_extract: r.requires_extract, + entrypoint_template: r.entrypoint_template, + extract_root: r.extract_root, + }, + ) + }) + .collect(); + + Ok(converted) +} + +/// 查询浏览器内核类型的最新版本列表 +/// type_code_filter 为 None 时筛选 type_code LIKE 'SIMPRINT_KERNEL_%',为 Some 时精确匹配 +/// 按 (type_code, resource_name) 分组,每组取最新版本 +/// 最新判定:is_latest 优先,其次 COALESCE(pub_date, created_at),最后 id +/// platform 为可选,None 表示不按平台过滤 +pub async fn query_browser_kernel_latest_versions( + pool: &Pool, + platform: Option<&str>, + type_code_filter: Option<&str>, +) -> Result, Error> { + #[derive(sqlx::FromRow)] + struct VersionWithTypeCode { + type_code: String, + id: i32, + type_id: i32, + resource_name: String, + version: String, + name: Option, + notes: Option, + platform: Option, + url: Option, + hash: Option, + signature: Option, + install_path: Option, + file_size: Option, + is_latest: bool, + status: String, + pub_date: Option>, + created_at: DateTime, + updated_at: Option>, + deleted_at: Option>, + arch: Option, + package_format: Option, + requires_extract: bool, + entrypoint_template: Option, + extract_root: Option, + } + + const SELECT_AND_ORDER: &str = r#" + SELECT DISTINCT ON (vt.type_code, v.resource_name) + vt.type_code, + v.id, v.type_id, v.resource_name, v.version, v.name, v.notes, + v.platform, v.url, v.hash, v.signature, v.install_path, + v.file_size, v.is_latest, v.status, v.pub_date, + v.created_at, v.updated_at, v.deleted_at, + v.arch, v.package_format, v.requires_extract, v.entrypoint_template, v.extract_root + FROM versions v + INNER JOIN version_types vt ON v.type_id = vt.id + "#; + + let results: Vec = match (platform, type_code_filter) { + (Some(plat), Some(tc)) => { + sqlx::query_as(&format!( + "{} WHERE vt.type_code = $1 AND vt.is_active = true AND v.platform = $2 \ + AND v.status = 'active' AND v.deleted_at IS NULL \ + ORDER BY vt.type_code, v.resource_name, v.is_latest DESC, \ + COALESCE(v.pub_date, v.created_at) DESC NULLS LAST, v.id DESC", + SELECT_AND_ORDER + )) + .bind(tc) + .bind(plat) + .fetch_all(pool) + .await? + } + (Some(plat), None) => { + sqlx::query_as(&format!( + "{} WHERE vt.type_code LIKE 'SIMPRINT_KERNEL_%' AND vt.is_active = true \ + AND v.platform = $1 AND v.status = 'active' AND v.deleted_at IS NULL \ + ORDER BY vt.type_code, v.resource_name, v.is_latest DESC, \ + COALESCE(v.pub_date, v.created_at) DESC NULLS LAST, v.id DESC", + SELECT_AND_ORDER + )) + .bind(plat) + .fetch_all(pool) + .await? + } + (None, Some(tc)) => { + sqlx::query_as(&format!( + "{} WHERE vt.type_code = $1 AND vt.is_active = true \ + AND v.status = 'active' AND v.deleted_at IS NULL \ + ORDER BY vt.type_code, v.resource_name, v.is_latest DESC, \ + COALESCE(v.pub_date, v.created_at) DESC NULLS LAST, v.id DESC", + SELECT_AND_ORDER + )) + .bind(tc) + .fetch_all(pool) + .await? + } + (None, None) => { + sqlx::query_as(&format!( + "{} WHERE vt.type_code LIKE 'SIMPRINT_KERNEL_%' AND vt.is_active = true \ + AND v.status = 'active' AND v.deleted_at IS NULL \ + ORDER BY vt.type_code, v.resource_name, v.is_latest DESC, \ + COALESCE(v.pub_date, v.created_at) DESC NULLS LAST, v.id DESC", + SELECT_AND_ORDER + )) + .fetch_all(pool) + .await? + } + }; + + let converted: Vec<(String, String, Version)> = results + .into_iter() + .map(|r| { + ( + r.type_code.clone(), + r.resource_name.clone(), + Version { + id: r.id, + type_id: r.type_id, + resource_name: r.resource_name, + version: r.version, + name: r.name.clone(), + notes: r.notes, + platform: r.platform, + url: r.url, + hash: r.hash, + signature: r.signature, + install_path: r.install_path, + file_size: r.file_size, + is_latest: r.is_latest, + status: r.status, + pub_date: r.pub_date, + created_at: r.created_at, + updated_at: r.updated_at, + deleted_at: r.deleted_at, + arch: r.arch, + package_format: r.package_format, + requires_extract: r.requires_extract, + entrypoint_template: r.entrypoint_template, + extract_root: r.extract_root, + }, + ) + }) + .collect(); + + Ok(converted) +} diff --git a/src-tauri/crates/business/src/models/workspace_quotas.rs b/src-tauri/crates/business/src/models/workspace_quotas.rs new file mode 100644 index 0000000..863f705 --- /dev/null +++ b/src-tauri/crates/business/src/models/workspace_quotas.rs @@ -0,0 +1,235 @@ +use sqlx::Error; +use uuid::Uuid; + +use crate::database::{Db as Postgres, Pool}; + +use crate::dto::WorkspaceQuotaDto; + +/// 创建或更新工作空间配额 +pub async fn insert_or_update_workspace_quota( + pool: &Pool, + workspace_uuid: Uuid, + max_environments: i32, + max_team_members: i32, + max_proxies: i32, + max_rpa_tasks: i32, +) -> Result<(), Error> { + sqlx::query( + r#" + INSERT INTO workspace_quotas ( + workspace_uuid, max_environments, max_team_members, max_proxies, max_rpa_tasks + ) + VALUES ($1, $2, $3, $4, $5) + ON CONFLICT (workspace_uuid) DO UPDATE SET + max_environments = EXCLUDED.max_environments, + max_team_members = EXCLUDED.max_team_members, + max_proxies = EXCLUDED.max_proxies, + max_rpa_tasks = EXCLUDED.max_rpa_tasks, + updated_at = CURRENT_TIMESTAMP + "#, + ) + .bind(workspace_uuid) + .bind(max_environments) + .bind(max_team_members) + .bind(max_proxies) + .bind(max_rpa_tasks) + .execute(pool) + .await?; + + Ok(()) +} + +/// 查询工作空间配额 +pub async fn fetch_workspace_quota( + pool: &Pool, + workspace_uuid: Uuid, +) -> Result, Error> { + let rec = sqlx::query_as::<_, WorkspaceQuotaDto>( + r#" + SELECT workspace_uuid, max_environments, used_environments, + max_team_members, used_team_members, + max_proxies, used_proxies, + max_rpa_tasks, used_rpa_tasks, + created_at, updated_at + FROM workspace_quotas + WHERE workspace_uuid = $1 + "#, + ) + .bind(workspace_uuid) + .fetch_optional(pool) + .await?; + + Ok(rec) +} + +/// 增加环境使用数 +pub async fn increment_used_environments( + pool: &Pool, + workspace_uuid: Uuid, + amount: i32, +) -> Result<(), Error> { + sqlx::query( + r#" + UPDATE workspace_quotas + SET used_environments = used_environments + $1, + updated_at = CURRENT_TIMESTAMP + WHERE workspace_uuid = $2 + "#, + ) + .bind(amount) + .bind(workspace_uuid) + .execute(pool) + .await?; + + Ok(()) +} + +/// 减少环境使用数 +pub async fn decrement_used_environments( + pool: &Pool, + workspace_uuid: Uuid, + amount: i32, +) -> Result<(), Error> { + sqlx::query( + r#" + UPDATE workspace_quotas + SET used_environments = GREATEST(0, used_environments - $1), + updated_at = CURRENT_TIMESTAMP + WHERE workspace_uuid = $2 + "#, + ) + .bind(amount) + .bind(workspace_uuid) + .execute(pool) + .await?; + + Ok(()) +} + +/// 增加代理使用数 +pub async fn increment_used_proxies( + pool: &Pool, + workspace_uuid: Uuid, + amount: i32, +) -> Result<(), Error> { + sqlx::query( + r#" + UPDATE workspace_quotas + SET used_proxies = used_proxies + $1, + updated_at = CURRENT_TIMESTAMP + WHERE workspace_uuid = $2 + "#, + ) + .bind(amount) + .bind(workspace_uuid) + .execute(pool) + .await?; + + Ok(()) +} + +/// 减少代理使用数 +pub async fn decrement_used_proxies( + pool: &Pool, + workspace_uuid: Uuid, + amount: i32, +) -> Result<(), Error> { + sqlx::query( + r#" + UPDATE workspace_quotas + SET used_proxies = GREATEST(0, used_proxies - $1), + updated_at = CURRENT_TIMESTAMP + WHERE workspace_uuid = $2 + "#, + ) + .bind(amount) + .bind(workspace_uuid) + .execute(pool) + .await?; + + Ok(()) +} + +/// 更新团队成员使用数(统计所有团队的活跃成员) +pub async fn update_used_team_members( + pool: &Pool, + workspace_uuid: Uuid, +) -> Result<(), Error> { + sqlx::query( + r#" + UPDATE workspace_quotas wq + SET used_team_members = ( + SELECT COUNT(DISTINCT tm.user_uuid) + FROM team_members tm + INNER JOIN teams t ON tm.team_uuid = t.uuid + WHERE t.workspace_uuid = wq.workspace_uuid + AND tm.deleted_at IS NULL + AND t.deleted_at IS NULL + ), + updated_at = CURRENT_TIMESTAMP + WHERE wq.workspace_uuid = $1 + "#, + ) + .bind(workspace_uuid) + .execute(pool) + .await?; + + Ok(()) +} + +/// 检查配额是否充足 +pub async fn check_quota( + pool: &Pool, + workspace_uuid: Uuid, + quota_type: &str, +) -> Result { + let result = match quota_type { + "environments" => sqlx::query_scalar::<_, bool>( + r#" + SELECT used_environments < max_environments + FROM workspace_quotas + WHERE workspace_uuid = $1 + "#, + ) + .bind(workspace_uuid) + .fetch_optional(pool) + .await? + .unwrap_or(false), + "proxies" => sqlx::query_scalar::<_, bool>( + r#" + SELECT used_proxies < max_proxies + FROM workspace_quotas + WHERE workspace_uuid = $1 + "#, + ) + .bind(workspace_uuid) + .fetch_optional(pool) + .await? + .unwrap_or(false), + "team_members" => sqlx::query_scalar::<_, bool>( + r#" + SELECT used_team_members < max_team_members + FROM workspace_quotas + WHERE workspace_uuid = $1 + "#, + ) + .bind(workspace_uuid) + .fetch_optional(pool) + .await? + .unwrap_or(false), + "rpa_tasks" => sqlx::query_scalar::<_, bool>( + r#" + SELECT used_rpa_tasks < max_rpa_tasks + FROM workspace_quotas + WHERE workspace_uuid = $1 + "#, + ) + .bind(workspace_uuid) + .fetch_optional(pool) + .await? + .unwrap_or(false), + _ => false, + }; + + Ok(result) +} diff --git a/src-tauri/crates/business/src/models/workspaces.rs b/src-tauri/crates/business/src/models/workspaces.rs new file mode 100644 index 0000000..3f07957 --- /dev/null +++ b/src-tauri/crates/business/src/models/workspaces.rs @@ -0,0 +1,123 @@ +use sqlx::Error; +use uuid::Uuid; + +use crate::database::{Db as Postgres, Pool}; + +use crate::dto::WorkspaceDto; +use crate::entitys::CreateWorkspaceRequest; + +/// 创建工作空间 +pub async fn insert_workspace( + pool: &Pool, + owner_uuid: Uuid, + payload: &CreateWorkspaceRequest, +) -> Result { + let workspace_uuid: Uuid = sqlx::query_scalar( + r#" + INSERT INTO workspaces (name, owner_uuid, workspace_type) + VALUES ($1, $2, $3) + RETURNING uuid; + "#, + ) + .bind(&payload.name) + .bind(owner_uuid) + .bind(payload.workspace_type.as_deref().unwrap_or("personal")) + .fetch_one(pool) + .await?; + + Ok(workspace_uuid) +} + +/// 根据 UUID 查询工作空间 +pub async fn fetch_workspace_by_uuid( + pool: &Pool, + workspace_uuid: Uuid, +) -> Result, Error> { + let rec = sqlx::query_as::<_, WorkspaceDto>( + r#" + SELECT uuid, name, owner_uuid, workspace_type, created_at, updated_at, deleted_at + FROM workspaces + WHERE uuid = $1 AND deleted_at IS NULL + "#, + ) + .bind(workspace_uuid) + .fetch_optional(pool) + .await?; + + Ok(rec) +} + +/// 查询用户所属的所有工作空间 +pub async fn fetch_user_workspaces( + pool: &Pool, + user_uuid: Uuid, +) -> Result, Error> { + let recs = sqlx::query_as::<_, WorkspaceDto>( + r#" + SELECT uuid, name, owner_uuid, workspace_type, created_at, updated_at, deleted_at + FROM workspaces + WHERE owner_uuid = $1 AND deleted_at IS NULL + ORDER BY created_at + "#, + ) + .bind(user_uuid) + .fetch_all(pool) + .await?; + + Ok(recs) +} + +/// 更新工作空间 +pub async fn update_workspace( + pool: &Pool, + workspace_uuid: Uuid, + name: Option<&str>, +) -> Result<(), Error> { + if let Some(name) = name { + sqlx::query( + r#" + UPDATE workspaces SET name = $1 WHERE uuid = $2 AND deleted_at IS NULL + "#, + ) + .bind(name) + .bind(workspace_uuid) + .execute(pool) + .await?; + } + + Ok(()) +} + +/// 删除工作空间(软删除) +pub async fn delete_workspace(pool: &Pool, workspace_uuid: Uuid) -> Result<(), Error> { + sqlx::query( + r#" + UPDATE workspaces SET deleted_at = CURRENT_TIMESTAMP WHERE uuid = $1 + "#, + ) + .bind(workspace_uuid) + .execute(pool) + .await?; + + Ok(()) +} + +/// 检查用户是否是工作空间所有者 +pub async fn check_workspace_owner( + pool: &Pool, + workspace_uuid: Uuid, + user_uuid: Uuid, +) -> Result { + let count: i64 = sqlx::query_scalar( + r#" + SELECT COUNT(*) FROM workspaces + WHERE uuid = $1 AND owner_uuid = $2 AND deleted_at IS NULL + "#, + ) + .bind(workspace_uuid) + .bind(user_uuid) + .fetch_one(pool) + .await?; + + Ok(count > 0) +} diff --git a/src-tauri/crates/business/src/routes.rs b/src-tauri/crates/business/src/routes.rs new file mode 100644 index 0000000..39269a6 --- /dev/null +++ b/src-tauri/crates/business/src/routes.rs @@ -0,0 +1,169 @@ +pub mod health; +pub mod secret; +pub mod time; +pub mod users; + +// 新增模块 +pub mod accounts; +pub mod local_api; +pub mod audit; +pub mod billing; +pub mod browser_kernel; +pub mod environments; +pub mod extensions; +pub mod group_permissions; +pub mod messages; +pub mod preferences; +pub mod proxies; +pub mod proxy_visibility; +pub mod referral; +pub mod rpa; +pub mod teams; +pub mod templates; +pub mod workspace_quotas; +pub mod workspaces; + +// route core +pub mod route { + use std::borrow::Cow; + + use axum::{Router, routing::MethodRouter}; + + use crate::svc_ctx::SvcCtx; + + // can support request methods enum + #[derive(Debug, Clone)] + pub enum RequestMethod { + GET, + POST, + PUT, + DELETE, + PATCH, + } + + /// route item + pub struct RouteItem { + pub path: &'static str, + pub method: RequestMethod, + pub handler: MethodRouter, + } + + /// route group + pub struct RouteGroup { + pub prefix: &'static str, + pub routes: Vec, + } + + /// meta route + pub struct MetaRoute { + pub prefix: Cow<'static, str>, + pub routes: Vec, + } + + impl MetaRoute { + pub fn new(prefix: String) -> Self { + Self { + prefix: Cow::Owned(prefix), + routes: vec![], + } + } + + pub fn add_route_group(&mut self, group: RouteGroup) -> () { + self.routes.push(group); + } + + pub fn count(&self) -> usize { + self.routes.iter().map(|group| group.routes.len()).sum() + } + + pub fn build(&self) -> Router { + let mut root_router_child = Router::new(); + + for contain in &self.routes { + let RouteGroup { prefix, routes } = contain; + + let parent_prefix = prefix.to_string(); + let mut child_router = Router::new(); + + for item in routes { + let RouteItem { + path, + method, + handler, + } = item; + + tracing::info!("{:?}+{}{}{}", method, self.prefix, parent_prefix, path,); + match method { + RequestMethod::GET => { + child_router = child_router.route(path, handler.clone()); + } + RequestMethod::POST => { + child_router = child_router.route(path, handler.clone()); + } + RequestMethod::PUT => { + child_router = child_router.route(path, handler.clone()); + } + RequestMethod::DELETE => { + child_router = child_router.route(path, handler.clone()); + } + RequestMethod::PATCH => { + child_router = child_router.route(path, handler.clone()); + } + } + } + + root_router_child = + root_router_child.merge(Router::new().nest(prefix, child_router)); + } + + Router::new().nest(self.prefix.as_ref(), root_router_child) + } + } + + impl RouteGroup { + pub fn new(prefix: &'static str) -> Self { + Self { + prefix, + routes: vec![], + } + } + + pub fn add_route_item(&mut self, item: RouteItem) { + self.routes.push(item); + } + } + + impl RouteItem { + pub fn new( + path: &'static str, + method: RequestMethod, + handler: MethodRouter, + ) -> Self { + Self { + path, + method, + handler, + } + } + + pub fn get(path: &'static str, handler: MethodRouter) -> Self { + Self::new(path, RequestMethod::GET, handler) + } + + pub fn post(path: &'static str, handler: MethodRouter) -> Self { + Self::new(path, RequestMethod::POST, handler) + } + + pub fn put(path: &'static str, handler: MethodRouter) -> Self { + Self::new(path, RequestMethod::PUT, handler) + } + + pub fn delete(path: &'static str, handler: MethodRouter) -> Self { + Self::new(path, RequestMethod::DELETE, handler) + } + + pub fn patch(path: &'static str, handler: MethodRouter) -> Self { + Self::new(path, RequestMethod::PATCH, handler) + } + } +} diff --git a/src-tauri/crates/business/src/routes/accounts.rs b/src-tauri/crates/business/src/routes/accounts.rs new file mode 100644 index 0000000..27b9f86 --- /dev/null +++ b/src-tauri/crates/business/src/routes/accounts.rs @@ -0,0 +1,37 @@ +use axum::routing::post; + +use crate::handlers::{ + batch_delete_accounts_handler, batch_import_accounts_handler, create_account_handler, + delete_account_handler, get_account_handler, get_accounts_handler, update_account_handler, +}; +use crate::routes::route::{self, MetaRoute}; + +/// 注册账号相关的路由 +pub fn register_routes(meta_route: &mut MetaRoute) { + let mut account_route = route::RouteGroup::new("/accounts"); + + account_route.add_route_item(route::RouteItem::post("/list", post(get_accounts_handler))); + account_route.add_route_item(route::RouteItem::post("/detail", post(get_account_handler))); + account_route.add_route_item(route::RouteItem::post( + "/create", + post(create_account_handler), + )); + account_route.add_route_item(route::RouteItem::post( + "/update", + post(update_account_handler), + )); + account_route.add_route_item(route::RouteItem::post( + "/delete", + post(delete_account_handler), + )); + account_route.add_route_item(route::RouteItem::post( + "/batch-delete", + post(batch_delete_accounts_handler), + )); + account_route.add_route_item(route::RouteItem::post( + "/batch-import", + post(batch_import_accounts_handler), + )); + + meta_route.add_route_group(account_route); +} diff --git a/src-tauri/crates/business/src/routes/audit.rs b/src-tauri/crates/business/src/routes/audit.rs new file mode 100644 index 0000000..66f462c --- /dev/null +++ b/src-tauri/crates/business/src/routes/audit.rs @@ -0,0 +1,31 @@ +use axum::routing::post; + +use crate::handlers::{ + export_audit_logs_handler, get_audit_log_detail_handler, get_audit_logs_handler, + get_audit_stats_handler, +}; +use crate::routes::route::{self, MetaRoute}; + +/// 注册审计日志相关的路由 +pub fn register_routes(meta_route: &mut MetaRoute) { + let mut audit_route = route::RouteGroup::new("/audit"); + + audit_route.add_route_item(route::RouteItem::post( + "/logs", + post(get_audit_logs_handler), + )); + audit_route.add_route_item(route::RouteItem::post( + "/logs/detail", + post(get_audit_log_detail_handler), + )); + audit_route.add_route_item(route::RouteItem::post( + "/logs/export", + post(export_audit_logs_handler), + )); + audit_route.add_route_item(route::RouteItem::post( + "/stats", + post(get_audit_stats_handler), + )); + + meta_route.add_route_group(audit_route); +} diff --git a/src-tauri/crates/business/src/routes/billing.rs b/src-tauri/crates/business/src/routes/billing.rs new file mode 100644 index 0000000..fa63a7f --- /dev/null +++ b/src-tauri/crates/business/src/routes/billing.rs @@ -0,0 +1,108 @@ +use axum::routing::post; + +use crate::handlers::{ + cancel_subscription_handler, create_recharge_order_handler, get_account_info_handler, + get_auto_renewal_services_handler, get_available_coupons_handler, + get_current_subscription_handler, get_invoices_handler, get_order_status_handler, + get_payment_orders_handler, get_plan_detail_handler, get_plan_price_handler, + get_plans_handler, get_quota_handler, get_transactions_handler, get_user_coupons_handler, + get_wallet_handler, resume_subscription_handler, subscribe_plan_handler, + toggle_auto_renew_handler, verify_coupon_handler, +}; +use crate::routes::route::{self, MetaRoute}; + +/// 注册计费相关的路由 +pub fn register_routes(meta_route: &mut MetaRoute) { + let mut billing_route = route::RouteGroup::new("/billing"); + + // 套餐 + billing_route.add_route_item(route::RouteItem::post("/plans", post(get_plans_handler))); + billing_route.add_route_item(route::RouteItem::post( + "/plans/detail", + post(get_plan_detail_handler), + )); + billing_route.add_route_item(route::RouteItem::post( + "/plans/price", + post(get_plan_price_handler), + )); + + // 订阅 + billing_route.add_route_item(route::RouteItem::post( + "/subscription", + post(get_current_subscription_handler), + )); + billing_route.add_route_item(route::RouteItem::post( + "/subscription/subscribe", + post(subscribe_plan_handler), + )); + billing_route.add_route_item(route::RouteItem::post( + "/subscription/cancel", + post(cancel_subscription_handler), + )); + billing_route.add_route_item(route::RouteItem::post( + "/subscription/resume", + post(resume_subscription_handler), + )); + billing_route.add_route_item(route::RouteItem::post( + "/subscription/toggle-auto-renew", + post(toggle_auto_renew_handler), + )); + + // 钱包 + billing_route.add_route_item(route::RouteItem::post("/wallet", post(get_wallet_handler))); + billing_route.add_route_item(route::RouteItem::post( + "/wallet/transactions", + post(get_transactions_handler), + )); + + // 发票 + billing_route.add_route_item(route::RouteItem::post( + "/invoices", + post(get_invoices_handler), + )); + + // 配额 + billing_route.add_route_item(route::RouteItem::post("/quota", post(get_quota_handler))); + + // 优惠券 + billing_route.add_route_item(route::RouteItem::post( + "/coupon/verify", + post(verify_coupon_handler), + )); + billing_route.add_route_item(route::RouteItem::post( + "/coupons/my-coupons", + post(get_user_coupons_handler), + )); + billing_route.add_route_item(route::RouteItem::post( + "/coupons/available", + post(get_available_coupons_handler), + )); + + // 支付订单 + billing_route.add_route_item(route::RouteItem::post( + "/orders", + post(get_payment_orders_handler), + )); + billing_route.add_route_item(route::RouteItem::post( + "/orders/create-recharge", + post(create_recharge_order_handler), + )); + billing_route.add_route_item(route::RouteItem::post( + "/orders/status", + post(get_order_status_handler), + )); + + // 自动续费服务 + billing_route.add_route_item(route::RouteItem::post( + "/auto-renewal-services", + post(get_auto_renewal_services_handler), + )); + + // 账户信息 + billing_route.add_route_item(route::RouteItem::post( + "/account-info", + post(get_account_info_handler), + )); + + meta_route.add_route_group(billing_route); +} diff --git a/src-tauri/crates/business/src/routes/browser_kernel.rs b/src-tauri/crates/business/src/routes/browser_kernel.rs new file mode 100644 index 0000000..38536ee --- /dev/null +++ b/src-tauri/crates/business/src/routes/browser_kernel.rs @@ -0,0 +1,16 @@ +use axum::routing::post; + +use crate::handlers::browser_kernel::list_browser_kernels_handler; +use crate::routes::route::{self, MetaRoute}; + +/// 注册浏览器内核相关路由 +pub fn register_routes(meta_route: &mut MetaRoute) { + let mut browser_kernel_route = route::RouteGroup::new("/browser-kernels"); + + browser_kernel_route.add_route_item(route::RouteItem::post( + "/list", + post(list_browser_kernels_handler), + )); + + meta_route.add_route_group(browser_kernel_route); +} diff --git a/src-tauri/crates/business/src/routes/environments.rs b/src-tauri/crates/business/src/routes/environments.rs new file mode 100644 index 0000000..0bb933a --- /dev/null +++ b/src-tauri/crates/business/src/routes/environments.rs @@ -0,0 +1,172 @@ +use axum::routing::post; + +use crate::handlers::{ + add_environment_cookie_handler, add_environment_url_handler, assign_tags_handler, + batch_assign_tags_handler, batch_create_environments_handler, + batch_delete_environments_handler, batch_get_environments_handler, + batch_move_to_group_handler, batch_permanent_delete_environments_handler, + batch_remove_tags_handler, batch_restore_environments_handler, + clear_environment_cookies_handler, clear_environment_urls_handler, + create_environment_handler, create_group_handler, create_tag_handler, + delete_environment_cookie_handler, delete_environment_handler, + delete_environment_url_handler, delete_group_handler, delete_tag_handler, + get_environment_cookies_handler, get_environment_handler, get_environment_urls_handler, + get_environments_handler, get_groups_handler, get_recycle_bin_environments_handler, + get_tags_handler, move_to_group_handler, permanent_delete_environment_handler, + remove_tag_handler, restore_environment_handler, set_environment_accounts_handler, + set_proxy_handler, update_environment_handler, update_group_handler, update_tag_handler, +}; +use crate::routes::route::{self, MetaRoute}; + +/// 注册环境相关的路由 +pub fn register_routes(meta_route: &mut MetaRoute) { + // 分组路由 + let mut group_route = route::RouteGroup::new("/groups"); + group_route.add_route_item(route::RouteItem::post("/list", post(get_groups_handler))); + group_route.add_route_item(route::RouteItem::post( + "/create", + post(create_group_handler), + )); + group_route.add_route_item(route::RouteItem::post( + "/update", + post(update_group_handler), + )); + group_route.add_route_item(route::RouteItem::post( + "/delete", + post(delete_group_handler), + )); + meta_route.add_route_group(group_route); + + // 标签路由 + let mut tag_route = route::RouteGroup::new("/tags"); + tag_route.add_route_item(route::RouteItem::post("/list", post(get_tags_handler))); + tag_route.add_route_item(route::RouteItem::post("/create", post(create_tag_handler))); + tag_route.add_route_item(route::RouteItem::post("/update", post(update_tag_handler))); + tag_route.add_route_item(route::RouteItem::post("/delete", post(delete_tag_handler))); + meta_route.add_route_group(tag_route); + + // 环境路由 + let mut env_route = route::RouteGroup::new("/environments"); + env_route.add_route_item(route::RouteItem::post( + "/list", + post(get_environments_handler), + )); + env_route.add_route_item(route::RouteItem::post( + "/detail", + post(get_environment_handler), + )); + env_route.add_route_item(route::RouteItem::post( + "/batch-detail", + post(batch_get_environments_handler), + )); + env_route.add_route_item(route::RouteItem::post( + "/create", + post(create_environment_handler), + )); + env_route.add_route_item(route::RouteItem::post( + "/batch-create", + post(batch_create_environments_handler), + )); + env_route.add_route_item(route::RouteItem::post( + "/update", + post(update_environment_handler), + )); + env_route.add_route_item(route::RouteItem::post( + "/delete", + post(delete_environment_handler), + )); + env_route.add_route_item(route::RouteItem::post( + "/batch-delete", + post(batch_delete_environments_handler), + )); + env_route.add_route_item(route::RouteItem::post( + "/set-proxy", + post(set_proxy_handler), + )); + env_route.add_route_item(route::RouteItem::post( + "/assign-tags", + post(assign_tags_handler), + )); + env_route.add_route_item(route::RouteItem::post( + "/remove-tag", + post(remove_tag_handler), + )); + env_route.add_route_item(route::RouteItem::post( + "/move-to-group", + post(move_to_group_handler), + )); + env_route.add_route_item(route::RouteItem::post( + "/batch-move-to-group", + post(batch_move_to_group_handler), + )); + env_route.add_route_item(route::RouteItem::post( + "/set-accounts", + post(set_environment_accounts_handler), + )); + env_route.add_route_item(route::RouteItem::post( + "/batch-assign-tags", + post(batch_assign_tags_handler), + )); + env_route.add_route_item(route::RouteItem::post( + "/batch-remove-tags", + post(batch_remove_tags_handler), + )); + // URL 管理 + env_route.add_route_item(route::RouteItem::post( + "/urls/list", + post(get_environment_urls_handler), + )); + env_route.add_route_item(route::RouteItem::post( + "/urls/add", + post(add_environment_url_handler), + )); + env_route.add_route_item(route::RouteItem::post( + "/urls/delete", + post(delete_environment_url_handler), + )); + env_route.add_route_item(route::RouteItem::post( + "/urls/clear", + post(clear_environment_urls_handler), + )); + // Cookie 管理 + env_route.add_route_item(route::RouteItem::post( + "/cookies/list", + post(get_environment_cookies_handler), + )); + env_route.add_route_item(route::RouteItem::post( + "/cookies/add", + post(add_environment_cookie_handler), + )); + env_route.add_route_item(route::RouteItem::post( + "/cookies/delete", + post(delete_environment_cookie_handler), + )); + env_route.add_route_item(route::RouteItem::post( + "/cookies/clear", + post(clear_environment_cookies_handler), + )); + + // 回收站路由(作为环境路由的子路由) + env_route.add_route_item(route::RouteItem::post( + "/recycle-bin/list", + post(get_recycle_bin_environments_handler), + )); + env_route.add_route_item(route::RouteItem::post( + "/recycle-bin/restore", + post(restore_environment_handler), + )); + env_route.add_route_item(route::RouteItem::post( + "/recycle-bin/batch-restore", + post(batch_restore_environments_handler), + )); + env_route.add_route_item(route::RouteItem::post( + "/recycle-bin/permanent-delete", + post(permanent_delete_environment_handler), + )); + env_route.add_route_item(route::RouteItem::post( + "/recycle-bin/batch-permanent-delete", + post(batch_permanent_delete_environments_handler), + )); + + meta_route.add_route_group(env_route); +} diff --git a/src-tauri/crates/business/src/routes/extensions.rs b/src-tauri/crates/business/src/routes/extensions.rs new file mode 100644 index 0000000..f68f010 --- /dev/null +++ b/src-tauri/crates/business/src/routes/extensions.rs @@ -0,0 +1,64 @@ +use axum::routing::post; + +use crate::handlers::{ + batch_update_extensions_handler, disable_extension_handler, enable_extension_handler, + get_extension_categories_handler, get_extension_detail_handler, get_extensions_handler, + get_installed_extensions_handler, install_extension_handler, uninstall_extension_handler, + update_extension_handler, +}; +use crate::routes::route::{self, MetaRoute}; + +/// 注册扩展管理相关的路由 +pub fn register_routes(meta_route: &mut MetaRoute) { + let mut ext_route = route::RouteGroup::new("/extensions"); + + // 扩展市场 + ext_route.add_route_item(route::RouteItem::post( + "/list", + post(get_extensions_handler), + )); + ext_route.add_route_item(route::RouteItem::post( + "/detail", + post(get_extension_detail_handler), + )); + ext_route.add_route_item(route::RouteItem::post( + "/categories", + post(get_extension_categories_handler), + )); + + // 已安装扩展 + ext_route.add_route_item(route::RouteItem::post( + "/installed", + post(get_installed_extensions_handler), + )); + + // 安装/卸载/更新 + ext_route.add_route_item(route::RouteItem::post( + "/install", + post(install_extension_handler), + )); + ext_route.add_route_item(route::RouteItem::post( + "/uninstall", + post(uninstall_extension_handler), + )); + ext_route.add_route_item(route::RouteItem::post( + "/update", + post(update_extension_handler), + )); + ext_route.add_route_item(route::RouteItem::post( + "/batch-update", + post(batch_update_extensions_handler), + )); + + // 禁用/启用扩展 + ext_route.add_route_item(route::RouteItem::post( + "/disable", + post(disable_extension_handler), + )); + ext_route.add_route_item(route::RouteItem::post( + "/enable", + post(enable_extension_handler), + )); + + meta_route.add_route_group(ext_route); +} diff --git a/src-tauri/crates/business/src/routes/group_permissions.rs b/src-tauri/crates/business/src/routes/group_permissions.rs new file mode 100644 index 0000000..5ffcc53 --- /dev/null +++ b/src-tauri/crates/business/src/routes/group_permissions.rs @@ -0,0 +1,28 @@ +use axum::routing::post; + +use crate::handlers::group_permissions; +use crate::routes::route::{self, MetaRoute}; + +/// 注册分组权限相关的路由 +pub fn register_routes(meta_route: &mut MetaRoute) { + let mut group_permission_route = route::RouteGroup::new("/group-permissions"); + + group_permission_route.add_route_item(route::RouteItem::post( + "/grant", + post(group_permissions::grant_group_permission_handler), + )); + group_permission_route.add_route_item(route::RouteItem::post( + "/revoke", + post(group_permissions::revoke_group_permission_handler), + )); + group_permission_route.add_route_item(route::RouteItem::post( + "/check", + post(group_permissions::check_group_permission_handler), + )); + group_permission_route.add_route_item(route::RouteItem::post( + "/list", + post(group_permissions::list_user_group_permissions_handler), + )); + + meta_route.add_route_group(group_permission_route); +} diff --git a/src-tauri/crates/business/src/routes/health.rs b/src-tauri/crates/business/src/routes/health.rs new file mode 100644 index 0000000..415ca9d --- /dev/null +++ b/src-tauri/crates/business/src/routes/health.rs @@ -0,0 +1,13 @@ +use axum::routing::get; + +use crate::handlers::health_check_handler; +use crate::routes::route::{self, MetaRoute}; + +/// 注册健康检查路由 +pub fn register_routes(meta_route: &mut MetaRoute) -> () { + let mut health_route = route::RouteGroup::new("/health"); + + health_route.add_route_item(route::RouteItem::get("/", get(health_check_handler))); + + meta_route.add_route_group(health_route); +} diff --git a/src-tauri/crates/business/src/routes/local_api.rs b/src-tauri/crates/business/src/routes/local_api.rs new file mode 100644 index 0000000..c637c1a --- /dev/null +++ b/src-tauri/crates/business/src/routes/local_api.rs @@ -0,0 +1,27 @@ +use axum::routing::post; + +use crate::handlers::local_api; +use crate::routes::route::{self, MetaRoute}; + +pub fn register_routes(meta_route: &mut MetaRoute) { + let mut local_api_route = route::RouteGroup::new("/local-api"); + + local_api_route.add_route_item(route::RouteItem::post( + "/get", + post(local_api::get_local_api_config_handler), + )); + local_api_route.add_route_item(route::RouteItem::post( + "/update", + post(local_api::update_local_api_config_handler), + )); + local_api_route.add_route_item(route::RouteItem::post( + "/reset-api-key", + post(local_api::reset_local_api_key_handler), + )); + local_api_route.add_route_item(route::RouteItem::post( + "/validate", + post(local_api::validate_local_api_key_handler), + )); + + meta_route.add_route_group(local_api_route); +} diff --git a/src-tauri/crates/business/src/routes/messages.rs b/src-tauri/crates/business/src/routes/messages.rs new file mode 100644 index 0000000..fb1a13b --- /dev/null +++ b/src-tauri/crates/business/src/routes/messages.rs @@ -0,0 +1,45 @@ +use axum::routing::post; + +use crate::handlers::{ + batch_mark_messages_read_handler, create_message_handler, delete_message_handler, + get_user_message_stats_handler, get_user_messages_handler, handle_message_handler, + mark_message_read_handler, +}; +use crate::routes::route::{self, MetaRoute}; + +/// 注册消息相关的路由 +pub fn register_routes(meta_route: &mut MetaRoute) { + let mut message_route = route::RouteGroup::new("/messages"); + + // 消息管理 + message_route.add_route_item(route::RouteItem::post( + "/create", + post(create_message_handler), + )); + message_route.add_route_item(route::RouteItem::post( + "/list", + post(get_user_messages_handler), + )); + message_route.add_route_item(route::RouteItem::post( + "/stats", + post(get_user_message_stats_handler), + )); + message_route.add_route_item(route::RouteItem::post( + "/read", + post(mark_message_read_handler), + )); + message_route.add_route_item(route::RouteItem::post( + "/batch-read", + post(batch_mark_messages_read_handler), + )); + message_route.add_route_item(route::RouteItem::post( + "/handle", + post(handle_message_handler), + )); + message_route.add_route_item(route::RouteItem::post( + "/delete", + post(delete_message_handler), + )); + + meta_route.add_route_group(message_route); +} diff --git a/src-tauri/crates/business/src/routes/preferences.rs b/src-tauri/crates/business/src/routes/preferences.rs new file mode 100644 index 0000000..a813823 --- /dev/null +++ b/src-tauri/crates/business/src/routes/preferences.rs @@ -0,0 +1,20 @@ +use axum::routing::post; + +use crate::handlers::{get_preferences_handler, update_preferences_handler}; +use crate::routes::route::{self, MetaRoute}; + +/// 注册用户偏好设置相关的路由 +pub fn register_routes(meta_route: &mut MetaRoute) { + let mut pref_route = route::RouteGroup::new("/preferences"); + + pref_route.add_route_item(route::RouteItem::post( + "/get", + post(get_preferences_handler), + )); + pref_route.add_route_item(route::RouteItem::post( + "/update", + post(update_preferences_handler), + )); + + meta_route.add_route_group(pref_route); +} diff --git a/src-tauri/crates/business/src/routes/proxies.rs b/src-tauri/crates/business/src/routes/proxies.rs new file mode 100644 index 0000000..5428403 --- /dev/null +++ b/src-tauri/crates/business/src/routes/proxies.rs @@ -0,0 +1,37 @@ +use axum::routing::post; + +use crate::handlers::{ + batch_delete_proxies_handler, batch_import_proxies_handler, create_proxy_handler, + delete_proxy_handler, get_proxies_handler, get_proxy_handler, update_proxy_handler, +}; +use crate::routes::route::{self, MetaRoute}; + +/// 注册代理相关的路由 +pub fn register_routes(meta_route: &mut MetaRoute) { + let mut proxy_route = route::RouteGroup::new("/proxies"); + + proxy_route.add_route_item(route::RouteItem::post("/list", post(get_proxies_handler))); + proxy_route.add_route_item(route::RouteItem::post("/detail", post(get_proxy_handler))); + proxy_route.add_route_item(route::RouteItem::post( + "/create", + post(create_proxy_handler), + )); + proxy_route.add_route_item(route::RouteItem::post( + "/update", + post(update_proxy_handler), + )); + proxy_route.add_route_item(route::RouteItem::post( + "/delete", + post(delete_proxy_handler), + )); + proxy_route.add_route_item(route::RouteItem::post( + "/batch-delete", + post(batch_delete_proxies_handler), + )); + proxy_route.add_route_item(route::RouteItem::post( + "/batch-import", + post(batch_import_proxies_handler), + )); + + meta_route.add_route_group(proxy_route); +} diff --git a/src-tauri/crates/business/src/routes/proxy_visibility.rs b/src-tauri/crates/business/src/routes/proxy_visibility.rs new file mode 100644 index 0000000..7720c88 --- /dev/null +++ b/src-tauri/crates/business/src/routes/proxy_visibility.rs @@ -0,0 +1,32 @@ +use axum::routing::post; + +use crate::handlers::proxy_visibility; +use crate::routes::route::{self, MetaRoute}; + +/// 注册代理可见性相关的路由 +pub fn register_routes(meta_route: &mut MetaRoute) { + let mut proxy_visibility_route = route::RouteGroup::new("/proxy-visibility"); + + proxy_visibility_route.add_route_item(route::RouteItem::post( + "/set", + post(proxy_visibility::set_proxy_visible_handler), + )); + proxy_visibility_route.add_route_item(route::RouteItem::post( + "/remove", + post(proxy_visibility::remove_proxy_visible_handler), + )); + proxy_visibility_route.add_route_item(route::RouteItem::post( + "/batch-set", + post(proxy_visibility::batch_set_proxy_visible_handler), + )); + proxy_visibility_route.add_route_item(route::RouteItem::post( + "/list-visible", + post(proxy_visibility::get_visible_proxies_handler), + )); + proxy_visibility_route.add_route_item(route::RouteItem::post( + "/list-teams", + post(proxy_visibility::get_proxy_visible_teams_handler), + )); + + meta_route.add_route_group(proxy_visibility_route); +} diff --git a/src-tauri/crates/business/src/routes/referral.rs b/src-tauri/crates/business/src/routes/referral.rs new file mode 100644 index 0000000..c969862 --- /dev/null +++ b/src-tauri/crates/business/src/routes/referral.rs @@ -0,0 +1,82 @@ +use axum::routing::post; + +use crate::handlers::{ + get_redeem_options_handler, get_redeem_records_handler, get_referral_dashboard_handler, + get_referral_links_handler, get_referral_plan_summary_handler, get_referral_rewards_handler, + get_referral_stats_handler, get_referral_tiers_handler, get_referred_users_handler, + get_user_points_handler, redeem_points_handler, switch_referral_link_handler, +}; +use crate::routes::route::{self, MetaRoute}; + +/// 注册推荐计划相关的路由 +pub fn register_routes(meta_route: &mut MetaRoute) { + let mut referral_route = route::RouteGroup::new("/referral"); + + // 统计 + referral_route.add_route_item(route::RouteItem::post( + "/stats", + post(get_referral_stats_handler), + )); + + // 统计看板聚合 + referral_route.add_route_item(route::RouteItem::post( + "/dashboard", + post(get_referral_dashboard_handler), + )); + + // 套餐页推广摘要 + referral_route.add_route_item(route::RouteItem::post( + "/summary-for-plan", + post(get_referral_plan_summary_handler), + )); + + // 层级配置 + referral_route.add_route_item(route::RouteItem::post( + "/tiers", + post(get_referral_tiers_handler), + )); + + // 推广链接 + referral_route.add_route_item(route::RouteItem::post( + "/links", + post(get_referral_links_handler), + )); + referral_route.add_route_item(route::RouteItem::post( + "/links/switch", + post(switch_referral_link_handler), + )); + + // 奖励记录 + referral_route.add_route_item(route::RouteItem::post( + "/rewards", + post(get_referral_rewards_handler), + )); + + // 被邀请用户 + referral_route.add_route_item(route::RouteItem::post( + "/users", + post(get_referred_users_handler), + )); + + // 积分 + referral_route.add_route_item(route::RouteItem::post( + "/points", + post(get_user_points_handler), + )); + + // 兑换选项 + referral_route.add_route_item(route::RouteItem::post( + "/redeem/options", + post(get_redeem_options_handler), + )); + referral_route.add_route_item(route::RouteItem::post( + "/redeem", + post(redeem_points_handler), + )); + referral_route.add_route_item(route::RouteItem::post( + "/redeem/records", + post(get_redeem_records_handler), + )); + + meta_route.add_route_group(referral_route); +} diff --git a/src-tauri/crates/business/src/routes/rpa.rs b/src-tauri/crates/business/src/routes/rpa.rs new file mode 100644 index 0000000..ecb308a --- /dev/null +++ b/src-tauri/crates/business/src/routes/rpa.rs @@ -0,0 +1,44 @@ +use axum::routing::post; + +use crate::handlers::{ + batch_delete_rpa_tasks_handler, create_rpa_task_handler, delete_rpa_task_handler, + duplicate_rpa_task_handler, export_rpa_task_handler, get_rpa_task_handler, + get_rpa_tasks_handler, update_rpa_task_handler, +}; +use crate::routes::route::{self, MetaRoute}; + +pub fn register_routes(meta_route: &mut MetaRoute) { + let mut rpa_route = route::RouteGroup::new("/rpa"); + + rpa_route.add_route_item(route::RouteItem::post("/tasks", post(get_rpa_tasks_handler))); + rpa_route.add_route_item(route::RouteItem::post( + "/tasks/detail", + post(get_rpa_task_handler), + )); + rpa_route.add_route_item(route::RouteItem::post( + "/tasks/create", + post(create_rpa_task_handler), + )); + rpa_route.add_route_item(route::RouteItem::post( + "/tasks/update", + post(update_rpa_task_handler), + )); + rpa_route.add_route_item(route::RouteItem::post( + "/tasks/delete", + post(delete_rpa_task_handler), + )); + rpa_route.add_route_item(route::RouteItem::post( + "/tasks/batch-delete", + post(batch_delete_rpa_tasks_handler), + )); + rpa_route.add_route_item(route::RouteItem::post( + "/tasks/duplicate", + post(duplicate_rpa_task_handler), + )); + rpa_route.add_route_item(route::RouteItem::post( + "/tasks/export", + post(export_rpa_task_handler), + )); + + meta_route.add_route_group(rpa_route); +} diff --git a/src-tauri/crates/business/src/routes/secret.rs b/src-tauri/crates/business/src/routes/secret.rs new file mode 100644 index 0000000..f2f6b0c --- /dev/null +++ b/src-tauri/crates/business/src/routes/secret.rs @@ -0,0 +1,16 @@ +use axum::routing::get; + +use crate::handlers::get_public_key_handler; +use crate::routes::route::{self, MetaRoute}; + +/// 注册 secret 相关的路由 +pub fn register_routes(meta_route: &mut MetaRoute) -> () { + let mut secret_route = route::RouteGroup::new("/secret"); + + secret_route.add_route_item(route::RouteItem::get( + "/public/key", + get(get_public_key_handler), + )); + + meta_route.add_route_group(secret_route); +} diff --git a/src-tauri/crates/business/src/routes/teams.rs b/src-tauri/crates/business/src/routes/teams.rs new file mode 100644 index 0000000..01018d8 --- /dev/null +++ b/src-tauri/crates/business/src/routes/teams.rs @@ -0,0 +1,61 @@ +use axum::routing::post; + +use crate::handlers::{ + accept_invitation_handler, cancel_invitation_handler, create_team_handler, + get_my_teams_handler, get_pending_invitations_handler, get_team_handler, + get_team_members_handler, invite_member_handler, leave_team_handler, reject_invitation_handler, + remove_member_handler, switch_team_handler, update_member_role_handler, update_team_handler, +}; +use crate::routes::route::{self, MetaRoute}; + +/// 注册团队相关的路由 +pub fn register_routes(meta_route: &mut MetaRoute) { + let mut team_route = route::RouteGroup::new("/teams"); + + // 团队管理 + team_route.add_route_item(route::RouteItem::post("/create", post(create_team_handler))); + team_route.add_route_item(route::RouteItem::post( + "/my-teams", + post(get_my_teams_handler), + )); + team_route.add_route_item(route::RouteItem::post("/switch", post(switch_team_handler))); + team_route.add_route_item(route::RouteItem::post("/detail", post(get_team_handler))); + team_route.add_route_item(route::RouteItem::post("/update", post(update_team_handler))); + team_route.add_route_item(route::RouteItem::post("/leave", post(leave_team_handler))); + + // 成员管理 + team_route.add_route_item(route::RouteItem::post( + "/members", + post(get_team_members_handler), + )); + team_route.add_route_item(route::RouteItem::post( + "/invite", + post(invite_member_handler), + )); + team_route.add_route_item(route::RouteItem::post( + "/invitations", + post(get_pending_invitations_handler), + )); + team_route.add_route_item(route::RouteItem::post( + "/invitation/cancel", + post(cancel_invitation_handler), + )); + team_route.add_route_item(route::RouteItem::post( + "/invitation/accept", + post(accept_invitation_handler), + )); + team_route.add_route_item(route::RouteItem::post( + "/invitation/reject", + post(reject_invitation_handler), + )); + team_route.add_route_item(route::RouteItem::post( + "/member/role", + post(update_member_role_handler), + )); + team_route.add_route_item(route::RouteItem::post( + "/member/remove", + post(remove_member_handler), + )); + + meta_route.add_route_group(team_route); +} diff --git a/src-tauri/crates/business/src/routes/templates.rs b/src-tauri/crates/business/src/routes/templates.rs new file mode 100644 index 0000000..36ad572 --- /dev/null +++ b/src-tauri/crates/business/src/routes/templates.rs @@ -0,0 +1,40 @@ +use axum::routing::post; + +use crate::handlers::{ + apply_template_handler, create_from_template_handler, create_template_handler, + delete_template_handler, get_template_handler, get_templates_handler, update_template_handler, +}; +use crate::routes::route::{self, MetaRoute}; + +/// 注册模板相关的路由 +pub fn register_routes(meta_route: &mut MetaRoute) { + let mut template_route = route::RouteGroup::new("/templates"); + + template_route.add_route_item(route::RouteItem::post("/list", post(get_templates_handler))); + template_route.add_route_item(route::RouteItem::post( + "/detail", + post(get_template_handler), + )); + template_route.add_route_item(route::RouteItem::post( + "/create", + post(create_template_handler), + )); + template_route.add_route_item(route::RouteItem::post( + "/update", + post(update_template_handler), + )); + template_route.add_route_item(route::RouteItem::post( + "/delete", + post(delete_template_handler), + )); + template_route.add_route_item(route::RouteItem::post( + "/apply", + post(apply_template_handler), + )); + template_route.add_route_item(route::RouteItem::post( + "/create-from", + post(create_from_template_handler), + )); + + meta_route.add_route_group(template_route); +} diff --git a/src-tauri/crates/business/src/routes/time.rs b/src-tauri/crates/business/src/routes/time.rs new file mode 100644 index 0000000..6045d39 --- /dev/null +++ b/src-tauri/crates/business/src/routes/time.rs @@ -0,0 +1,12 @@ +use axum::routing::get; + +use crate::handlers::now_handle; +use crate::routes::route::{self, MetaRoute}; + +pub fn register_routes(meta_route: &mut MetaRoute) -> () { + let mut time_route = route::RouteGroup::new("/time"); + + time_route.add_route_item(route::RouteItem::get("/now", get(now_handle))); + + meta_route.add_route_group(time_route); +} diff --git a/src-tauri/crates/business/src/routes/users.rs b/src-tauri/crates/business/src/routes/users.rs new file mode 100644 index 0000000..5def5c3 --- /dev/null +++ b/src-tauri/crates/business/src/routes/users.rs @@ -0,0 +1,56 @@ +use axum::routing::post; + +use crate::handlers::{ + get_current_user_handler, login_handler, refresh_token_handler, register_handler, + reset_password_handler, send_code_handler, update_password_handler, update_user_handler, + verify_password_handler, +}; +use crate::routes::route::{self, MetaRoute}; + +/// 注册用户相关的路由 +pub fn register_routes(meta_route: &mut MetaRoute) -> () { + let mut user_route = route::RouteGroup::new("/users"); + + user_route.add_route_item(route::RouteItem::post("/register", post(register_handler))); + + user_route.add_route_item(route::RouteItem::post("/login", post(login_handler))); + + user_route.add_route_item(route::RouteItem::post( + "/refresh-credentials", + post(refresh_token_handler), + )); + + user_route.add_route_item(route::RouteItem::post( + "/me", + post(get_current_user_handler), + )); + + user_route.add_route_item(route::RouteItem::post("/update", post(update_user_handler))); + + user_route.add_route_item(route::RouteItem::post( + "/password", + post(update_password_handler), + )); + + user_route.add_route_item(route::RouteItem::post( + "/verify-password", + post(verify_password_handler), + )); + + user_route.add_route_item(route::RouteItem::post( + "/reset-password", + post(reset_password_handler), + )); + + user_route.add_route_item(route::RouteItem::post( + "/register-send-code", + post(send_code_handler), + )); + + user_route.add_route_item(route::RouteItem::post( + "/reset-password-send-code", + post(send_code_handler), + )); + + meta_route.add_route_group(user_route); +} diff --git a/src-tauri/crates/business/src/routes/workspace_quotas.rs b/src-tauri/crates/business/src/routes/workspace_quotas.rs new file mode 100644 index 0000000..fef9771 --- /dev/null +++ b/src-tauri/crates/business/src/routes/workspace_quotas.rs @@ -0,0 +1,20 @@ +use axum::routing::post; + +use crate::handlers::workspace_quotas; +use crate::routes::route::{self, MetaRoute}; + +/// 注册工作空间配额相关的路由 +pub fn register_routes(meta_route: &mut MetaRoute) { + let mut quota_route = route::RouteGroup::new("/workspace-quotas"); + + quota_route.add_route_item(route::RouteItem::post( + "/get", + post(workspace_quotas::get_workspace_quota_handler), + )); + quota_route.add_route_item(route::RouteItem::post( + "/update", + post(workspace_quotas::update_quota_usage_handler), + )); + + meta_route.add_route_group(quota_route); +} diff --git a/src-tauri/crates/business/src/routes/workspaces.rs b/src-tauri/crates/business/src/routes/workspaces.rs new file mode 100644 index 0000000..2b57dd2 --- /dev/null +++ b/src-tauri/crates/business/src/routes/workspaces.rs @@ -0,0 +1,36 @@ +use axum::routing::post; + +use crate::handlers::workspaces; +use crate::routes::route::{self, MetaRoute}; + +/// 注册工作空间相关的路由 +pub fn register_routes(meta_route: &mut MetaRoute) { + let mut workspace_route = route::RouteGroup::new("/workspaces"); + + workspace_route.add_route_item(route::RouteItem::post( + "/create", + post(workspaces::create_workspace_handler), + )); + workspace_route.add_route_item(route::RouteItem::post( + "/list", + post(workspaces::get_my_workspaces_handler), + )); + workspace_route.add_route_item(route::RouteItem::post( + "/get", + post(workspaces::get_workspace_handler), + )); + workspace_route.add_route_item(route::RouteItem::post( + "/update", + post(workspaces::update_workspace_handler), + )); + workspace_route.add_route_item(route::RouteItem::post( + "/delete", + post(workspaces::delete_workspace_handler), + )); + workspace_route.add_route_item(route::RouteItem::post( + "/switch", + post(workspaces::switch_workspace_handler), + )); + + meta_route.add_route_group(workspace_route); +} diff --git a/src-tauri/crates/business/src/services.rs b/src-tauri/crates/business/src/services.rs new file mode 100644 index 0000000..5b91379 --- /dev/null +++ b/src-tauri/crates/business/src/services.rs @@ -0,0 +1,62 @@ +mod maintenance; +mod strategy_types; +mod time; +pub mod users; +mod version_types; +mod versions; + +// 新增模块 +pub mod accounts; +pub mod audit; +pub mod billing; +pub mod browser_kernel; +pub mod coupons; +pub mod environments; +pub mod extensions; +pub mod group_permissions; +pub mod groups; +pub mod local_api; +pub mod messages; +pub mod orders; +pub mod plans; +pub mod preferences; +pub mod proxies; +pub mod proxy_visibility; +pub mod referral; +pub mod rpa; +pub mod subscriptions; +pub mod tags; +pub mod teams; +pub mod templates; +pub mod wallet; +pub mod workspace_quotas; +pub mod workspaces; + +pub use maintenance::*; +pub use strategy_types::*; +pub use time::*; +pub use users::*; +pub use version_types::*; +pub use versions::*; + +// 新增导出 +pub use accounts::*; +pub use audit::*; +pub use billing::*; +pub use browser_kernel::*; +pub use coupons::*; +pub use environments::*; +pub use group_permissions::*; +pub use groups::*; +pub use messages::*; +pub use orders::*; +pub use plans::*; +pub use proxies::*; +pub use proxy_visibility::*; +pub use subscriptions::*; +pub use tags::*; +pub use teams::*; +pub use templates::*; +pub use wallet::*; +pub use workspace_quotas::*; +pub use workspaces::*; diff --git a/src-tauri/crates/business/src/services/accounts.rs b/src-tauri/crates/business/src/services/accounts.rs new file mode 100644 index 0000000..a552bb3 --- /dev/null +++ b/src-tauri/crates/business/src/services/accounts.rs @@ -0,0 +1,210 @@ +use uuid::Uuid; + +use crate::dto::PlatformAccountDto; +use crate::entitys::{ + BatchImportAccountsRequest, CreateAccountRequest, ListAccountsRequest, UpdateAccountRequest, +}; +use crate::models; +use crate::svc_ctx::SvcCtx; + +/// 创建账号 +pub async fn create_account_service( + svc_ctx: &SvcCtx, + user_uuid: Uuid, + team_uuid: Option, + payload: &CreateAccountRequest, +) -> Result { + models::insert_platform_account( + &svc_ctx.db, + user_uuid, + team_uuid, + &payload.platform_url, + payload.platform_name.as_deref(), + &payload.account, + payload.password.as_deref(), + payload.remark.as_deref(), + ) + .await + .map_err(|e| e.to_string()) +} + +/// 获取账号列表 +pub async fn get_accounts_service( + svc_ctx: &SvcCtx, + user_uuid: Uuid, + team_uuid: Option, + payload: &ListAccountsRequest, +) -> Result<(Vec, i64), String> { + let page = payload.pagination.page.max(1); + let page_size = payload.pagination.page_size.max(1); + let offset = (page - 1) * page_size; + + let keyword = payload + .filters + .as_ref() + .and_then(|f| f.keyword.as_deref()) + .map(str::trim) + .filter(|value| !value.is_empty()); + let platform_name = payload + .filters + .as_ref() + .and_then(|f| f.platform_name.as_deref()) + .map(str::trim) + .filter(|value| !value.is_empty()); + let status = payload + .filters + .as_ref() + .and_then(|f| f.status.as_deref()) + .map(str::trim) + .filter(|value| !value.is_empty()); + + let accounts = models::fetch_platform_accounts( + &svc_ctx.db, + team_uuid, + user_uuid, + keyword, + platform_name, + status, + offset, + page_size, + ) + .await + .map_err(|e| e.to_string())?; + + let total = models::fetch_platform_accounts_count( + &svc_ctx.db, + team_uuid, + user_uuid, + keyword, + platform_name, + status, + ) + .await + .map_err(|e| e.to_string())?; + + Ok((accounts, total)) +} + +/// 获取账号详情 +pub async fn get_account_service( + svc_ctx: &SvcCtx, + account_uuid: Uuid, +) -> Result { + models::fetch_platform_account_by_uuid(&svc_ctx.db, account_uuid) + .await + .map_err(|e| e.to_string())? + .ok_or_else(|| "账号不存在".to_string()) +} + +/// 更新账号 +pub async fn update_account_service( + svc_ctx: &SvcCtx, + payload: &UpdateAccountRequest, +) -> Result<(), String> { + models::update_platform_account( + &svc_ctx.db, + payload.uuid, + payload.platform_url.as_deref(), + payload.platform_name.as_deref(), + payload.account.as_deref(), + payload.password.as_deref(), + payload.remark.as_deref(), + payload.status.as_deref(), + ) + .await + .map_err(|e| e.to_string()) +} + +/// 删除账号 +pub async fn delete_account_service(svc_ctx: &SvcCtx, account_uuid: Uuid) -> Result<(), String> { + models::delete_platform_account(&svc_ctx.db, account_uuid) + .await + .map_err(|e| e.to_string()) +} + +/// 批量删除账号 +pub async fn batch_delete_accounts_service( + svc_ctx: &SvcCtx, + account_uuids: &[Uuid], +) -> Result { + models::batch_delete_platform_accounts(&svc_ctx.db, account_uuids) + .await + .map_err(|e| e.to_string()) +} + +/// 获取环境关联的账号 +pub async fn get_environment_accounts_service( + svc_ctx: &SvcCtx, + env_uuid: Uuid, +) -> Result, String> { + models::fetch_environment_accounts(&svc_ctx.db, env_uuid) + .await + .map_err(|e| e.to_string()) +} + +/// 设置环境关联的账号 +pub async fn set_environment_accounts_service( + svc_ctx: &SvcCtx, + env_uuid: Uuid, + account_uuids: &[Uuid], +) -> Result<(), String> { + // 清空现有关联 + models::accounts::clear_environment_accounts(&svc_ctx.db, env_uuid) + .await + .map_err(|e| e.to_string())?; + + // 添加新关联 + for (idx, account_uuid) in account_uuids.iter().enumerate() { + models::accounts::insert_environment_account( + &svc_ctx.db, + env_uuid, + *account_uuid, + idx as i32, + ) + .await + .map_err(|e| e.to_string())?; + } + + Ok(()) +} + +/// 批量导入账号 +/// +/// 接收客户端已解析好的账号列表,直接保存到数据库 +pub async fn batch_import_accounts_service( + svc_ctx: &SvcCtx, + user_uuid: Uuid, + team_uuid: Option, + payload: &BatchImportAccountsRequest, +) -> Result { + let mut success_count = 0; + let mut failed_count = 0; + let mut errors: Vec = vec![]; + for (index, account) in payload.accounts.iter().enumerate() { + let result = models::insert_platform_account( + &svc_ctx.db, + user_uuid, + team_uuid, + &account.platform_url, + account.platform_name.as_deref(), + &account.account, + account.password.as_deref(), + account.remark.as_deref(), + ) + .await; + + match result { + Ok(_) => success_count += 1, + Err(e) => { + failed_count += 1; + errors.push(format!("第 {} 项: {}", index + 1, e)); + } + } + } + + Ok(crate::entitys::BatchImportResponse { + success_count, + failed_count, + errors, + }) +} diff --git a/src-tauri/crates/business/src/services/audit.rs b/src-tauri/crates/business/src/services/audit.rs new file mode 100644 index 0000000..4284829 --- /dev/null +++ b/src-tauri/crates/business/src/services/audit.rs @@ -0,0 +1,352 @@ +use chrono::Datelike; +use uuid::Uuid; + +use crate::dto::AuditLogDto; +use crate::entitys::{ + ActionCount, AuditStatsResponse, ExportAuditLogsRequest, ListAuditLogsRequest, TargetTypeCount, +}; +use crate::models; +use crate::state::RequestContext; +use crate::svc_ctx::SvcCtx; + +/// 审计日志宏 - 简化审计日志记录 +/// +/// # 用法 +/// ```rust +/// // 基础用法: action, target_type, detail +/// audit_log!(svc_ctx, ctx, "login", "user", "用户登录"); +/// +/// // 带目标 UUID +/// audit_log!(svc_ctx, ctx, "delete", "environment", env_uuid, "删除环境"); +/// +/// // 带目标 UUID 和名称 +/// audit_log!(svc_ctx, ctx, "delete", "environment", env_uuid, "生产环境", "删除环境"); +/// ``` +#[macro_export] +macro_rules! audit_log { + // 基础: action, target_type, detail + ($svc_ctx:expr, $ctx:expr, $action:expr, $target_type:expr, $detail:expr) => { + $crate::services::audit::log_audit( + $svc_ctx, + $ctx, + $action, + $target_type, + None, + None, + $detail, + ) + }; + // 带目标 UUID: action, target_type, target_uuid, detail + ($svc_ctx:expr, $ctx:expr, $action:expr, $target_type:expr, $target_uuid:expr, $detail:expr) => { + $crate::services::audit::log_audit( + $svc_ctx, + $ctx, + $action, + $target_type, + Some($target_uuid), + None, + $detail, + ) + }; + // 完整: action, target_type, target_uuid, target_name, detail + ($svc_ctx:expr, $ctx:expr, $action:expr, $target_type:expr, $target_uuid:expr, $target_name:expr, $detail:expr) => { + $crate::services::audit::log_audit( + $svc_ctx, + $ctx, + $action, + $target_type, + Some($target_uuid), + Some($target_name), + $detail, + ) + }; +} + +/// 记录审计日志(宏的内部实现) +/// +/// 自动从 RequestContext 中提取 user_uuid、team_uuid、ip_address +pub async fn log_audit( + svc_ctx: &SvcCtx, + ctx: &RequestContext, + action: &str, + target_type: &str, + target_uuid: Option, + target_name: Option<&str>, + detail: &str, +) { + // 从 ctx 中提取数据 + let user_uuid = match ctx.user_uuid() { + Some(uuid) => uuid, + None => { + tracing::warn!("audit_log: user_uuid is None, skipping audit log"); + return; + } + }; + + let team_uuid = ctx.current_team_uuid; + let ip_address = ctx.ip(); + + // 异步记录,忽略错误(审计失败不应影响业务) + if let Err(e) = log_action_service( + svc_ctx, + user_uuid, + team_uuid, + action, + target_type, + target_uuid, + target_name, + Some(detail), + None, // changes + ip_address, + None, // user_agent + None, // request_id + ) + .await + { + tracing::error!("audit_log failed: {}", e); + } +} + +/// 记录审计日志(允许无用户,用于登录/注册等场景) +pub async fn log_audit_anonymous( + svc_ctx: &SvcCtx, + ctx: &RequestContext, + user_uuid: Uuid, + action: &str, + target_type: &str, + detail: &str, +) { + let ip_address = ctx.ip(); + + if let Err(e) = log_action_service( + svc_ctx, + user_uuid, + None, + action, + target_type, + Some(user_uuid), + None, + Some(detail), + None, + ip_address, + None, + None, + ) + .await + { + tracing::error!("audit_log_anonymous failed: {}", e); + } +} + +/// 记录审计日志 +pub async fn log_action_service( + svc_ctx: &SvcCtx, + user_uuid: Uuid, + team_uuid: Option, + action: &str, + target_type: &str, + target_uuid: Option, + target_name: Option<&str>, + details: Option<&str>, + changes: Option<&serde_json::Value>, + ip_address: Option<&str>, + user_agent: Option<&str>, + request_id: Option<&str>, +) -> Result { + models::insert_audit_log( + &svc_ctx.db, + user_uuid, + team_uuid, + action, + target_type, + target_uuid, + target_name, + details, + changes, + ip_address, + user_agent, + request_id, + ) + .await + .map_err(|e| e.to_string()) +} + +/// 获取审计日志列表 +pub async fn get_audit_logs_service( + svc_ctx: &SvcCtx, + current_user_uuid: Uuid, + team_uuid: Option, + payload: &ListAuditLogsRequest, +) -> Result<(Vec, i64), String> { + let offset = (payload.pagination.page - 1) * payload.pagination.page_size; + + let user_uuid_filter = payload.filters.as_ref().and_then(|f| f.user_uuid); + let keyword = payload.filters.as_ref().and_then(|f| f.keyword.as_deref()); + let action = payload.filters.as_ref().and_then(|f| f.action.as_deref()); + let target_type = payload.filters.as_ref().and_then(|f| f.target_type.as_deref()); + + let logs = models::fetch_audit_logs( + &svc_ctx.db, + current_user_uuid, + team_uuid, + user_uuid_filter, + keyword, + action, + target_type, + offset, + payload.pagination.page_size, + ) + .await + .map_err(|e| e.to_string())?; + + let total = models::fetch_audit_logs_count( + &svc_ctx.db, + current_user_uuid, + team_uuid, + user_uuid_filter, + keyword, + action, + target_type, + ) + .await + .map_err(|e| e.to_string())?; + + Ok((logs, total)) +} + +/// 获取审计日志详情 +pub async fn get_audit_log_service( + svc_ctx: &SvcCtx, + log_uuid: Uuid, +) -> Result { + models::fetch_audit_log_by_uuid(&svc_ctx.db, log_uuid) + .await + .map_err(|e| e.to_string())? + .ok_or_else(|| "审计日志不存在".to_string()) +} + +/// 获取审计统计 +pub async fn get_audit_stats_service( + svc_ctx: &SvcCtx, + current_user_uuid: Uuid, + team_uuid: Option, +) -> Result { + // 总数 + let total_logs = + models::fetch_audit_logs_count( + &svc_ctx.db, + current_user_uuid, + team_uuid, + None, + None, + None, + None, + ) + .await + .map_err(|e| e.to_string())?; + + // 今日数量 + let today = chrono::Utc::now().date_naive(); + let logs_today = models::fetch_audit_logs_count_by_date(&svc_ctx.db, team_uuid, today) + .await + .map_err(|e| e.to_string())?; + + // 本周数量 + let week_start = today - chrono::Duration::days(today.weekday().num_days_from_monday() as i64); + let logs_this_week = + models::fetch_audit_logs_count_since_date(&svc_ctx.db, team_uuid, week_start) + .await + .map_err(|e| e.to_string())?; + + // 本月数量 + let month_start = chrono::NaiveDate::from_ymd_opt(today.year(), today.month(), 1).unwrap(); + let logs_this_month = + models::fetch_audit_logs_count_since_date(&svc_ctx.db, team_uuid, month_start) + .await + .map_err(|e| e.to_string())?; + + // 热门操作(Top 5) + let top_actions_raw = models::fetch_top_actions(&svc_ctx.db, team_uuid, 5) + .await + .map_err(|e| e.to_string())?; + + // 热门目标类型(Top 5) + let top_target_types_raw = models::fetch_top_target_types(&svc_ctx.db, team_uuid, 5) + .await + .map_err(|e| e.to_string())?; + + Ok(AuditStatsResponse { + total_logs, + logs_today, + logs_this_week, + logs_this_month, + top_actions: top_actions_raw + .into_iter() + .map(|(action, count)| ActionCount { action, count }) + .collect(), + top_target_types: top_target_types_raw + .into_iter() + .map(|(target_type, count)| TargetTypeCount { target_type, count }) + .collect(), + }) +} + +/// 导出审计日志 +pub async fn export_audit_logs_service( + svc_ctx: &SvcCtx, + current_user_uuid: Uuid, + team_uuid: Option, + payload: &ExportAuditLogsRequest, +) -> Result<(String, String, String), String> { + // 构建查询请求 + let list_request = ListAuditLogsRequest { + pagination: crate::entitys::Pagination { + page: 1, + page_size: payload.max_records.unwrap_or(1000) as i64, + sort_by: None, + sort_order: None, + }, + filters: payload.filters.clone(), + }; + + let (logs, _total) = + get_audit_logs_service(svc_ctx, current_user_uuid, team_uuid, &list_request).await?; + + // 根据格式生成导出内容 + let content = match payload.format.as_str() { + "csv" => export_to_csv(&logs), + "json" => serde_json::to_string_pretty(&logs).unwrap_or_default(), + _ => return Err("不支持的导出格式".to_string()), + }; + + let filename = format!( + "audit_logs_{}.{}", + chrono::Utc::now().format("%Y%m%d%H%M%S"), + payload.format + ); + + let mime_type = match payload.format.as_str() { + "csv" => "text/csv".to_string(), + "json" => "application/json".to_string(), + _ => "text/plain".to_string(), + }; + + Ok((content, filename, mime_type)) +} + +fn export_to_csv(logs: &[AuditLogDto]) -> String { + let mut csv = String::from("时间,用户UUID,操作,目标类型,目标名称,详情,IP地址\n"); + for log in logs { + csv.push_str(&format!( + "{},{},{},{},{},{},{}\n", + log.created_at.format("%Y-%m-%d %H:%M:%S"), + log.user_uuid, + log.action, + log.target_type, + log.target_name.as_deref().unwrap_or(""), + log.details.as_deref().unwrap_or("").replace(',', ";"), + log.ip_address.as_deref().unwrap_or(""), + )); + } + csv +} diff --git a/src-tauri/crates/business/src/services/billing.rs b/src-tauri/crates/business/src/services/billing.rs new file mode 100644 index 0000000..cccf4cb --- /dev/null +++ b/src-tauri/crates/business/src/services/billing.rs @@ -0,0 +1,122 @@ +use rust_decimal::Decimal; +use uuid::Uuid; + +use crate::dto::{AutoRenewalServiceDto, InvoiceDto, UserQuotaDto}; +use crate::entitys::{AccountInfoResponse, ListInvoicesRequest}; +use crate::models; +use crate::svc_ctx::SvcCtx; + +// ============ Invoices ============ + +/// 获取发票列表 +pub async fn get_invoices_service( + svc_ctx: &SvcCtx, + user_uuid: Uuid, + payload: &ListInvoicesRequest, +) -> Result<(Vec, i64), String> { + let offset = (payload.pagination.page - 1) * payload.pagination.page_size; + + let invoices = models::billing::fetch_invoices( + &svc_ctx.db, + user_uuid, + payload.status.as_deref(), + offset, + payload.pagination.page_size, + ) + .await + .map_err(|e| e.to_string())?; + + let total = + models::billing::fetch_invoices_count(&svc_ctx.db, user_uuid, payload.status.as_deref()) + .await + .map_err(|e| e.to_string())?; + + Ok((invoices, total)) +} + +// ============ Quotas ============ + +/// 获取用户配额 +pub async fn get_quota_service(svc_ctx: &SvcCtx, user_uuid: Uuid) -> Result { + models::billing::fetch_user_quota(&svc_ctx.db, user_uuid) + .await + .map_err(|e| e.to_string())? + .ok_or_else(|| "用户配额不存在".to_string()) +} + +// ============ Auto Renewal Services ============ + +/// 获取自动续费服务列表 +pub async fn get_auto_renewal_services_service( + svc_ctx: &SvcCtx, + user_uuid: Uuid, +) -> Result, String> { + models::billing::fetch_auto_renewal_services(&svc_ctx.db, user_uuid) + .await + .map_err(|e| e.to_string()) +} + +/// 获取账户信息(聚合钱包、配额、订阅、用户信息) +pub async fn get_account_info_service( + svc_ctx: &SvcCtx, + workspace_uuid: Uuid, + user_uuid: Uuid, +) -> Result { + // 1. 获取用户信息(邮箱等) + let user_info = models::user::fetch_user_info_by_uuid(&svc_ctx.db, user_uuid) + .await + .map_err(|e| e.to_string())? + .ok_or_else(|| "用户不存在".to_string())?; + + // 2. 获取钱包信息 + let wallet = models::billing::fetch_user_wallet(&svc_ctx.db, user_uuid) + .await + .map_err(|e| e.to_string())?; + + // 如果钱包不存在,创建一个 + let wallet = if wallet.is_none() { + models::billing::insert_user_wallet(&svc_ctx.db, user_uuid, "CNY") + .await + .map_err(|e| e.to_string())?; + + models::billing::fetch_user_wallet(&svc_ctx.db, user_uuid) + .await + .map_err(|e| e.to_string())? + .ok_or_else(|| "创建钱包失败".to_string())? + } else { + wallet.ok_or_else(|| "钱包不存在".to_string())? + }; + + // 3. 获取工作空间配额 + let quota = models::fetch_workspace_quota(&svc_ctx.db, workspace_uuid) + .await + .map_err(|e| e.to_string())? + .ok_or_else(|| "工作空间配额不存在".to_string())?; + + // 4. 获取工作空间订阅 + let subscription = models::billing::fetch_workspace_active_subscription(&svc_ctx.db, workspace_uuid) + .await + .map_err(|e| e.to_string())?; + + // 5. 计算月结费用(订阅价格) + let monthly_billing = subscription + .as_ref() + .map(|s| { + if s.billing_period == "yearly" { + s.price / Decimal::from(12) + } else { + s.price + } + }) + .unwrap_or(Decimal::ZERO); + + Ok(AccountInfoResponse { + email: user_info.email, + wallet_balance: wallet.balance, + gift_balance: Decimal::ZERO, // TODO: 如果有赠送金字段,从钱包获取 + currency: wallet.currency, + subscription, + quota, + monthly_billing, + }) +} diff --git a/src-tauri/crates/business/src/services/browser_kernel.rs b/src-tauri/crates/business/src/services/browser_kernel.rs new file mode 100644 index 0000000..316947d --- /dev/null +++ b/src-tauri/crates/business/src/services/browser_kernel.rs @@ -0,0 +1,41 @@ +use crate::{ + dto::versions::Version, + errors::SimprintError, + svc_ctx::SvcCtx, + utils::get_objects::get_version_resource_url, +}; +use std::collections::HashMap; + +/// 查询浏览器内核最新版本列表 +/// 筛选 SIMPRINT_KERNEL_* 类型(或指定 type_code),按 resource_name 分组取最新 +/// 返回 HashMap>,已填充可下载 URL +pub async fn get_browser_kernel_list_service( + svc_ctx: &SvcCtx, + platform: Option, + type_code: Option, +) -> Result>, SimprintError> { + let results = crate::models::versions::query_browser_kernel_latest_versions( + &svc_ctx.db, + platform.as_deref(), + type_code.as_deref(), + ) + .await + .map_err(|_| SimprintError::Other("查询浏览器内核列表失败".to_string()))?; + + let mut map: HashMap> = HashMap::new(); + + let storage_config = &svc_ctx.config.storage; + + for (type_code, _resource_name, version) in results { + map.entry(type_code).or_insert_with(Vec::new).push(Version { + url: Some(get_version_resource_url( + &storage_config.public_base_url, + &storage_config.version_root, + &version.url.unwrap_or_default(), + )), + ..version + }); + } + + Ok(map) +} diff --git a/src-tauri/crates/business/src/services/coupons.rs b/src-tauri/crates/business/src/services/coupons.rs new file mode 100644 index 0000000..66adc71 --- /dev/null +++ b/src-tauri/crates/business/src/services/coupons.rs @@ -0,0 +1,171 @@ +use chrono::Utc; +use rust_decimal::Decimal; +use uuid::Uuid; + +use crate::entitys::{ + BatchIssueCouponRequest, CouponValidationResult, GetUserCouponsRequest, IssueCouponRequest, + UserCouponsListResponse, VerifyCouponRequest, +}; +use crate::models; +use crate::svc_ctx::SvcCtx; + +/// 验证优惠券 +pub async fn validate_coupon_service( + svc_ctx: &SvcCtx, + user_uuid: Uuid, + payload: &VerifyCouponRequest, +) -> Result { + let coupon = models::billing::fetch_coupon_by_code(&svc_ctx.db, &payload.code) + .await + .map_err(|e| e.to_string())? + .ok_or_else(|| "优惠券不存在".to_string())?; + + // 检查有效期 + let now = Utc::now(); + if coupon.valid_from > now { + return Err("优惠券尚未生效".to_string()); + } + if let Some(valid_until) = coupon.valid_until { + if valid_until < now { + return Err("优惠券已过期".to_string()); + } + } + + // 检查使用次数限制 + if let Some(max_uses) = coupon.max_uses { + if coupon.used_count >= max_uses { + return Err("优惠券已达使用上限".to_string()); + } + } + + // 检查单用户使用次数限制 + if let Some(max_uses_per_user) = coupon.max_uses_per_user { + let user_usage = + models::billing::fetch_coupon_user_usage_count(&svc_ctx.db, coupon.uuid, user_uuid) + .await + .map_err(|e| e.to_string())?; + if user_usage >= max_uses_per_user { + return Err("您已达该优惠券使用上限".to_string()); + } + } + + // 检查最低消费 + if let Some(min_amount) = coupon.min_amount { + if payload.amount < min_amount { + return Err(format!("订单金额需满 {} 元才可使用此优惠券", min_amount)); + } + } + + // 计算折扣金额 + let mut discount_amount = match coupon.discount_type.as_str() { + "percentage" => payload.amount * coupon.discount_value / Decimal::from(100), + "fixed" => coupon.discount_value, + _ => Decimal::ZERO, + }; + + // 检查最大折扣限制 + if let Some(max_discount) = coupon.max_discount { + if discount_amount > max_discount { + discount_amount = max_discount; + } + } + + Ok(CouponValidationResult { + coupon, + discount_amount, + }) +} + +/// 获取用户优惠券列表 +pub async fn get_user_coupons_service( + svc_ctx: &SvcCtx, + user_uuid: Uuid, + payload: &GetUserCouponsRequest, +) -> Result { + let (items, total) = models::billing::fetch_user_coupons( + &svc_ctx.db, + user_uuid, + payload.status.as_deref(), + payload.pagination.page, + payload.pagination.page_size, + ) + .await + .map_err(|e| e.to_string())?; + + Ok(UserCouponsListResponse { + items, + total, + page: payload.pagination.page, + page_size: payload.pagination.page_size, + }) +} + +/// 给单个用户发放优惠券 +pub async fn issue_coupon_to_user_service( + svc_ctx: &SvcCtx, + payload: &IssueCouponRequest, +) -> Result { + // 验证优惠券存在且有效 + let coupon = models::billing::fetch_coupon_by_uuid(&svc_ctx.db, payload.coupon_uuid) + .await + .map_err(|e| e.to_string())? + .ok_or_else(|| "优惠券不存在或已失效".to_string())?; + + // 如果提供了过期时间,使用它;否则从优惠券继承 + let expires_at = payload.expires_at.or(coupon.valid_until); + + let id = models::billing::insert_user_coupon( + &svc_ctx.db, + payload.user_uuid, + payload.coupon_uuid, + expires_at, + ) + .await + .map_err(|e| { + if e.to_string().contains("RowNotFound") { + "该用户已拥有此优惠券".to_string() + } else { + e.to_string() + } + })?; + + Ok(id) +} + +/// 批量发放优惠券 +pub async fn batch_issue_coupons_service( + svc_ctx: &SvcCtx, + payload: &BatchIssueCouponRequest, +) -> Result { + // 验证优惠券存在且有效 + let coupon = models::billing::fetch_coupon_by_uuid(&svc_ctx.db, payload.coupon_uuid) + .await + .map_err(|e| e.to_string())? + .ok_or_else(|| "优惠券不存在或已失效".to_string())?; + + // 如果提供了过期时间,使用它;否则从优惠券继承 + let expires_at = payload.expires_at.or(coupon.valid_until); + + let count = models::billing::batch_insert_user_coupons( + &svc_ctx.db, + payload.coupon_uuid, + &payload.user_uuids, + expires_at, + ) + .await + .map_err(|e| e.to_string())?; + + Ok(count) +} + +/// 获取用户可用优惠券(自动过滤过期和已使用的) +pub async fn get_available_coupons_service( + svc_ctx: &SvcCtx, + user_uuid: Uuid, +) -> Result, String> { + let items = models::billing::fetch_available_user_coupons(&svc_ctx.db, user_uuid) + .await + .map_err(|e| e.to_string())?; + + Ok(items) +} diff --git a/src-tauri/crates/business/src/services/environments.rs b/src-tauri/crates/business/src/services/environments.rs new file mode 100644 index 0000000..5c8dec9 --- /dev/null +++ b/src-tauri/crates/business/src/services/environments.rs @@ -0,0 +1,1621 @@ +use std::collections::HashMap; +use url::Url; +use uuid::Uuid; + +use crate::dto::{ + EnvironmentConfigDto, EnvironmentCookieDto, EnvironmentCookieGroupDto, EnvironmentDto, + EnvironmentUrlDto, GroupSummaryDto, PlatformAccountDto, ProxySummaryDto, TagDto, +}; +use crate::entitys::{ + AddEnvironmentCookieRequest, AddEnvironmentUrlRequest, AssignTagsRequest, + BatchAssignTagRequest, BatchCreateEnvironmentRequest, BatchMoveToGroupRequest, + BatchRemoveTagsRequest, CookieGroupInput, CookieInput, CreateEnvironmentRequest, + ListEnvironmentsRequest, MoveToGroupRequest, SetEnvironmentProxyRequest, + UpdateEnvironmentRequest, UrlInput, +}; +use crate::models; +use crate::services::accounts; +use crate::svc_ctx::SvcCtx; + +// ============ Environments ============ + +/// 创建环境 +pub async fn create_environment_service( + svc_ctx: &SvcCtx, + user_uuid: Uuid, + workspace_uuid: Uuid, + team_uuid: Uuid, + payload: &CreateEnvironmentRequest, +) -> Result { + // 1. 检查用户是否在当前工作空间的团队中(工作空间级别) + let team_member = + models::teams::fetch_team_member(&svc_ctx.db, workspace_uuid, team_uuid, user_uuid) + .await + .map_err(|e| e.to_string())? + .ok_or_else(|| "您不是该团队的成员".to_string())?; + + // 2. 权限检查 + if let Some(group_uuid) = payload.group_uuid { + // 如果指定了分组,检查用户是否有目标分组的 write 或 manage 权限 + let has_write = models::check_group_permission( + &svc_ctx.db, + workspace_uuid, + group_uuid, + user_uuid, + "write", + ) + .await + .map_err(|e| e.to_string())?; + + let has_manage = models::check_group_permission( + &svc_ctx.db, + workspace_uuid, + group_uuid, + user_uuid, + "manage", + ) + .await + .map_err(|e| e.to_string())?; + + if !has_write && !has_manage { + return Err("您没有在该分组中创建环境的权限".to_string()); + } + } else { + // 如果未指定分组,检查用户是否有团队级别的环境创建权限(Editor/Admin/Owner) + let can_create = matches!(team_member.role.as_str(), "owner" | "admin" | "editor"); + if !can_create { + return Err("您没有创建环境的权限,需要 Editor 及以上角色".to_string()); + } + } + + // 3. 检查工作空间配额是否充足 + let quota_available = models::check_quota(&svc_ctx.db, workspace_uuid, "environments") + .await + .map_err(|e| e.to_string())?; + if !quota_available { + return Err("工作空间环境配额不足,无法创建新环境".to_string()); + } + + // 提取系统信息 + let system_info = payload + .config + .window_info + .get("system") + .and_then(|v| v.as_str()) + .map(|s| s.to_string()); + let kernel_info = payload + .config + .window_info + .get("kernel") + .and_then(|v| v.as_str()) + .map(|s| s.to_string()); + + // 创建环境 + let env_uuid = models::insert_environment( + &svc_ctx.db, + workspace_uuid, + user_uuid, + team_uuid, + &payload.name, + payload.description.as_deref(), + payload.group_uuid, + payload.proxy_uuid, + system_info.as_deref(), + kernel_info.as_deref(), + ) + .await + .map_err(|e| e.to_string())?; + + // 创建环境配置 + models::upsert_environment_config( + &svc_ctx.db, + env_uuid, + &payload.config.window_info, + &payload.config.basic_settings, + &payload.config.fingerprint_settings, + &payload.config.device_settings, + &payload.config.preference_settings, + &payload.config.project_metadata, + ) + .await + .map_err(|e| e.to_string())?; + + replace_environment_urls(svc_ctx, env_uuid, payload.urls.as_deref()).await?; + replace_environment_cookies(svc_ctx, env_uuid, payload.cookies.as_deref()).await?; + + // 分配标签 + if let Some(tag_uuids) = &payload.tag_uuids { + for tag_uuid in tag_uuids { + let _ = models::insert_environment_tag(&svc_ctx.db, env_uuid, *tag_uuid).await; + } + } + + // 关联账号 + if let Some(account_uuids) = &payload.account_uuids { + for (idx, account_uuid) in account_uuids.iter().enumerate() { + let _ = models::accounts::insert_environment_account( + &svc_ctx.db, + env_uuid, + *account_uuid, + idx as i32, + ) + .await; + } + } + + // 4. 更新工作空间配额(创建后增加使用数) + models::increment_used_environments(&svc_ctx.db, workspace_uuid, 1) + .await + .map_err(|e| format!("更新配额失败: {}", e))?; + + Ok(env_uuid) +} + +#[derive(Debug, Clone)] +struct CookieSiteTarget { + site_input: String, + domain: String, + path: String, + secure: bool, +} + +fn normalize_cookie_path(path: &str) -> String { + if path.trim().is_empty() || path == "/" { + "/".to_string() + } else if path.starts_with('/') { + path.to_string() + } else { + format!("/{}", path) + } +} + +fn parse_cookie_site(site: &str) -> Result { + let site = site.trim(); + if site.is_empty() { + return Err("Cookie 目标网页/域名不能为空".to_string()); + } + + if let Ok(parsed) = Url::parse(site) { + let host = parsed + .host_str() + .ok_or_else(|| format!("无效的 Cookie 目标网页: {}", site))?; + + return Ok(CookieSiteTarget { + site_input: site.to_string(), + domain: host.to_string(), + path: normalize_cookie_path(parsed.path()), + secure: parsed.scheme().eq_ignore_ascii_case("https"), + }); + } + + if !site.contains("://") { + if site.starts_with('.') && !site.contains('/') && !site.chars().any(|ch| ch.is_whitespace()) { + return Ok(CookieSiteTarget { + site_input: site.to_string(), + domain: site.to_string(), + path: "/".to_string(), + secure: false, + }); + } + + let candidate = format!("https://{}", site); + if let Ok(parsed) = Url::parse(&candidate) { + if let Some(host) = parsed.host_str() { + return Ok(CookieSiteTarget { + site_input: site.to_string(), + domain: host.to_string(), + path: normalize_cookie_path(parsed.path()), + secure: false, + }); + } + } + } + + Err(format!("无效的 Cookie 目标网页/域名: {}", site)) +} + +fn is_cookie_attribute(part: &str) -> bool { + let lower = part.trim().to_lowercase(); + matches!( + lower.as_str(), + "secure" | "httponly" | "http-only" | "partitioned" + ) || lower.starts_with("domain=") + || lower.starts_with("path=") + || lower.starts_with("expires=") + || lower.starts_with("max-age=") + || lower.starts_with("samesite=") +} + +fn parse_cookie_name_value(part: &str) -> Result<(String, String), String> { + let Some(eq_pos) = part.find('=') else { + return Err(format!("无效的 Cookie 格式: {}", part)); + }; + + let name = part[..eq_pos].trim(); + let value = part[eq_pos + 1..].trim(); + if name.is_empty() { + return Err(format!("Cookie 名称不能为空: {}", part)); + } + + Ok((name.to_string(), value.to_string())) +} + +fn parse_cookie_line_with_attrs( + line: &str, + site_target: &CookieSiteTarget, +) -> Result { + let parts: Vec<&str> = line.split(';').map(|part| part.trim()).filter(|part| !part.is_empty()).collect(); + let (name, value) = parse_cookie_name_value( + parts + .first() + .copied() + .ok_or_else(|| "Cookie 内容不能为空".to_string())?, + )?; + + let mut domain = site_target.domain.clone(); + let mut path = site_target.path.clone(); + let mut http_only = false; + let mut secure = site_target.secure; + let mut same_site = Some("Lax".to_string()); + + for part in parts.iter().skip(1) { + let lower = part.to_lowercase(); + if lower.starts_with("domain=") { + domain = part[7..].trim().to_string(); + } else if lower.starts_with("path=") { + path = normalize_cookie_path(part[5..].trim()); + } else if lower == "secure" { + secure = true; + } else if lower == "httponly" || lower == "http-only" { + http_only = true; + } else if lower.starts_with("samesite=") { + same_site = Some(part[9..].trim().to_string()); + } + } + + Ok(CookieInput { + site_input: site_target.site_input.clone(), + domain, + name, + value, + path: Some(path), + http_only: Some(http_only), + secure: Some(secure), + same_site, + }) +} + +fn parse_cookie_group(group: &CookieGroupInput) -> Result, String> { + let site_target = parse_cookie_site(&group.site)?; + let cookie_text = group.cookie_text.trim(); + if cookie_text.is_empty() { + return Err("Cookie 内容不能为空".to_string()); + } + + let mut cookies = Vec::new(); + + for line in cookie_text + .lines() + .map(|line| line.trim()) + .filter(|line| !line.is_empty()) + { + let parts: Vec<&str> = line + .split(';') + .map(|part| part.trim()) + .filter(|part| !part.is_empty()) + .collect(); + + if parts.is_empty() { + continue; + } + + let has_attr_style = parts.len() > 1 && parts.iter().skip(1).all(|part| is_cookie_attribute(part)); + if has_attr_style { + cookies.push(parse_cookie_line_with_attrs(line, &site_target)?); + continue; + } + + for part in parts { + let (name, value) = parse_cookie_name_value(part)?; + cookies.push(CookieInput { + site_input: site_target.site_input.clone(), + domain: site_target.domain.clone(), + name, + value, + path: Some(site_target.path.clone()), + http_only: Some(false), + secure: Some(site_target.secure), + same_site: Some("Lax".to_string()), + }); + } + } + + if cookies.is_empty() { + return Err("Cookie 内容不能为空".to_string()); + } + + Ok(cookies) +} + +fn format_cookie_row(cookie: &EnvironmentCookieDto, site_target: Option<&CookieSiteTarget>) -> String { + let mut parts = vec![format!("{}={}", cookie.name, cookie.value)]; + + let default_domain = site_target.map(|target| target.domain.as_str()).unwrap_or(""); + let default_path = site_target.map(|target| target.path.as_str()).unwrap_or("/"); + let default_secure = site_target.map(|target| target.secure).unwrap_or(false); + + if !cookie.domain.trim().is_empty() && cookie.domain != default_domain { + parts.push(format!("domain={}", cookie.domain)); + } + + let cookie_path = cookie.path.as_deref().unwrap_or("/"); + if cookie_path != default_path { + parts.push(format!("path={}", cookie_path)); + } + + if cookie.secure.unwrap_or(false) != default_secure && cookie.secure.unwrap_or(false) { + parts.push("secure".to_string()); + } + + if cookie.http_only.unwrap_or(false) { + parts.push("httpOnly".to_string()); + } + + if let Some(same_site) = cookie + .same_site + .as_deref() + .filter(|same_site| !same_site.trim().is_empty() && *same_site != "Lax") + { + parts.push(format!("sameSite={}", same_site)); + } + + parts.join("; ") +} + +fn group_cookie_rows(cookie_rows: Vec) -> Vec { + let mut grouped: HashMap> = HashMap::new(); + + for cookie in cookie_rows { + grouped + .entry(cookie.site_input.clone()) + .or_default() + .push(cookie); + } + + let mut items: Vec = grouped + .into_iter() + .map(|(site, cookies)| { + let site_target = parse_cookie_site(&site).ok(); + let simple_only = cookies.iter().all(|cookie| { + let default_domain = site_target + .as_ref() + .map(|target| target.domain.as_str()) + .unwrap_or(""); + let default_path = site_target + .as_ref() + .map(|target| target.path.as_str()) + .unwrap_or("/"); + let default_secure = site_target.as_ref().map(|target| target.secure).unwrap_or(false); + + cookie.domain == default_domain + && cookie.path.as_deref().unwrap_or("/") == default_path + && cookie.secure.unwrap_or(false) == default_secure + && !cookie.http_only.unwrap_or(false) + && cookie + .same_site + .as_deref() + .map(|same_site| same_site.eq_ignore_ascii_case("lax")) + .unwrap_or(true) + && cookie.expires_at.is_none() + }); + + let parts: Vec = cookies + .iter() + .map(|cookie| format_cookie_row(cookie, site_target.as_ref())) + .collect(); + + EnvironmentCookieGroupDto { + site, + cookie_text: if simple_only { + parts.join("; ") + } else { + parts.join("\n") + }, + } + }) + .collect(); + + items.sort_by(|left, right| left.site.cmp(&right.site)); + items +} + +async fn replace_environment_urls( + svc_ctx: &SvcCtx, + env_uuid: Uuid, + urls: Option<&[UrlInput]>, +) -> Result<(), String> { + let Some(urls) = urls else { + return Ok(()); + }; + + models::clear_environment_urls(&svc_ctx.db, env_uuid) + .await + .map_err(|e| format!("清空 URLs 失败: {}", e))?; + + for (idx, item) in urls.iter().enumerate() { + let url = item.url.trim(); + if url.is_empty() { + continue; + } + + models::insert_environment_url( + &svc_ctx.db, + env_uuid, + url, + item.title.as_deref(), + item.sort_order.or(Some(idx as i32)), + ) + .await + .map_err(|e| format!("保存 URL 失败: {}", e))?; + } + + Ok(()) +} + +async fn replace_environment_cookies( + svc_ctx: &SvcCtx, + env_uuid: Uuid, + cookie_groups: Option<&[CookieGroupInput]>, +) -> Result<(), String> { + let Some(cookie_groups) = cookie_groups else { + return Ok(()); + }; + + models::clear_environment_cookies(&svc_ctx.db, env_uuid) + .await + .map_err(|e| format!("清空 Cookies 失败: {}", e))?; + + let mut parsed_cookies = Vec::new(); + for group in cookie_groups { + let mut items = parse_cookie_group(group)?; + parsed_cookies.append(&mut items); + } + + if parsed_cookies.is_empty() { + return Ok(()); + } + + models::batch_insert_environment_cookies(&svc_ctx.db, env_uuid, &parsed_cookies) + .await + .map_err(|e| format!("保存 Cookies 失败: {}", e))?; + + Ok(()) +} + +/// 获取环境列表(包含完整关联数据) +pub async fn get_environments_service( + svc_ctx: &SvcCtx, + user_uuid: Uuid, + workspace_uuid: Uuid, + team_uuid: Uuid, + payload: &ListEnvironmentsRequest, +) -> Result<(Vec, i64), String> { + // 1. 检查用户是否在当前工作空间的团队中 + let team_member = + models::teams::fetch_team_member(&svc_ctx.db, workspace_uuid, team_uuid, user_uuid) + .await + .map_err(|e| e.to_string())? + .ok_or_else(|| "您不是该团队的成员".to_string())?; + + let offset = (payload.pagination.page - 1) * payload.pagination.page_size; + + let group_uuid = payload.filters.as_ref().and_then(|f| f.group_uuid); + let status = payload.filters.as_ref().and_then(|f| f.status.as_deref()); + let keyword = payload.filters.as_ref().and_then(|f| f.keyword.as_deref()); + let tag_uuids = payload.filters.as_ref().and_then(|f| f.tag_uuids.as_deref()); + + // 2. 查询环境总数(用于分页) + let total_count = models::fetch_environments_count( + &svc_ctx.db, + workspace_uuid, + team_uuid, + group_uuid, + status, + keyword, + tag_uuids, + ) + .await + .map_err(|e| e.to_string())?; + + // 3. 查询环境基础列表 + let env_rows = models::fetch_environments_base( + &svc_ctx.db, + workspace_uuid, + team_uuid, + group_uuid, + status, + keyword, + tag_uuids, + offset, + payload.pagination.page_size, + ) + .await + .map_err(|e| e.to_string())?; + + // 2. 权限过滤:根据分组权限过滤环境 + let is_owner_or_admin = matches!(team_member.role.as_str(), "owner" | "admin"); + + // 收集所有需要检查权限的分组 UUID(去重) + let unique_group_uuids: Vec = env_rows + .iter() + .filter_map(|row| row.group_uuid) + .collect::>() + .into_iter() + .collect(); + + // 批量检查分组权限(如果不是 Owner/Admin) + let mut group_permissions_cache: std::collections::HashMap = + std::collections::HashMap::new(); + if !is_owner_or_admin && !unique_group_uuids.is_empty() { + for group_uuid in unique_group_uuids { + let has_permission = models::check_group_permission( + &svc_ctx.db, + workspace_uuid, + group_uuid, + user_uuid, + "read", + ) + .await + .map_err(|e| e.to_string())?; + group_permissions_cache.insert(group_uuid, has_permission); + } + } + + // 8. 过滤无权限的环境 + let filtered_env_rows: Vec<_> = env_rows + .into_iter() + .filter(|row| { + if let Some(group_uuid) = row.group_uuid { + // 如果环境有分组,检查用户是否有分组的 read 权限 + if is_owner_or_admin { + true // Owner/Admin 自动拥有所有分组权限 + } else { + *group_permissions_cache.get(&group_uuid).unwrap_or(&false) + } + } else { + // 如果环境无分组,所有团队成员都可以查看(已在团队成员检查中验证) + true + } + }) + .collect(); + + // 9. 重新收集关联 UUID(基于过滤后的环境) + let env_uuids: Vec = filtered_env_rows.iter().map(|e| e.uuid).collect(); + let group_uuids: Vec = filtered_env_rows.iter().filter_map(|e| e.group_uuid).collect(); + let proxy_uuids: Vec = filtered_env_rows.iter().filter_map(|e| e.proxy_uuid).collect(); + + // 10. 重新查询关联数据(基于过滤后的环境) + // 批量查询环境配置 + let mut configs_map: HashMap = HashMap::new(); + if !env_uuids.is_empty() { + let config_rows = models::fetch_environment_configs_by_uuids(&svc_ctx.db, &env_uuids) + .await + .map_err(|e| e.to_string())?; + for config in config_rows { + configs_map.insert(config.environment_uuid, config); + } + } + + // 批量查询分组 + let group_rows = if !group_uuids.is_empty() { + models::fetch_groups_by_uuids(&svc_ctx.db, &group_uuids) + .await + .map_err(|e| e.to_string())? + } else { + vec![] + }; + let groups_map: HashMap = group_rows.into_iter().map(|g| (g.uuid, g)).collect(); + + // 批量查询代理 + let proxy_rows = if !proxy_uuids.is_empty() { + models::fetch_proxies_by_uuids(&svc_ctx.db, &proxy_uuids) + .await + .map_err(|e| e.to_string())? + } else { + vec![] + }; + let proxies_map: HashMap = proxy_rows.into_iter().map(|p| (p.uuid, p)).collect(); + + // 批量查询标签 + let tag_rows = if !env_uuids.is_empty() { + models::fetch_tags_for_environments(&svc_ctx.db, &env_uuids) + .await + .map_err(|e| e.to_string())? + } else { + vec![] + }; + + // 按环境分组标签 + let mut tags_map: HashMap> = HashMap::new(); + for tag_row in tag_rows { + tags_map.entry(tag_row.environment_uuid).or_default().push(TagDto { + id: tag_row.tag_id, + uuid: tag_row.tag_uuid, + user_uuid: tag_row.tag_user_uuid, + team_uuid: tag_row.tag_team_uuid, + name: tag_row.tag_name, + color: tag_row.tag_color, + sort_order: tag_row.tag_sort_order, + environments_count: tag_row.tag_environments_count, + created_at: tag_row.tag_created_at, + updated_at: tag_row.tag_updated_at, + deleted_at: tag_row.tag_deleted_at, + }); + } + + let mut urls_map: HashMap> = HashMap::new(); + if !env_uuids.is_empty() { + let url_rows = models::fetch_environment_urls_by_uuids(&svc_ctx.db, &env_uuids) + .await + .map_err(|e| e.to_string())?; + for url in url_rows { + urls_map.entry(url.environment_uuid).or_default().push(url); + } + } + + let mut cookies_map: HashMap> = HashMap::new(); + if !env_uuids.is_empty() { + let cookie_rows = models::fetch_environment_cookies_by_uuids(&svc_ctx.db, &env_uuids) + .await + .map_err(|e| e.to_string())?; + let mut grouped_rows: HashMap> = HashMap::new(); + for cookie in cookie_rows { + grouped_rows + .entry(cookie.environment_uuid) + .or_default() + .push(cookie); + } + for (environment_uuid, rows) in grouped_rows { + cookies_map + .insert(environment_uuid, group_cookie_rows(rows)); + } + } + + // 批量查询账号 + let mut accounts_map: HashMap> = HashMap::new(); + for env_uuid in &env_uuids { + let accounts = accounts::get_environment_accounts_service(&svc_ctx, *env_uuid) + .await + .unwrap_or_default(); + accounts_map.insert(*env_uuid, accounts); + } + + // 批量查询扩展(为每个环境动态合并插件) + let mut extensions_map: HashMap> = + HashMap::new(); + for row in &filtered_env_rows { + let extensions = crate::services::extensions::get_environment_extensions_service( + &svc_ctx, + user_uuid, + team_uuid, + row.group_uuid, + ) + .await + .unwrap_or_default(); + extensions_map.insert(row.uuid, extensions); + } + + // 11. 组装完整数据(使用与环境详情一致的数据结构) + let environments: Vec = filtered_env_rows + .into_iter() + .map(|row| { + // 构建 EnvironmentDto + let environment = EnvironmentDto { + id: row.id, + uuid: row.uuid, + workspace_uuid: row.workspace_uuid, + user_uuid: row.user_uuid, + team_uuid: row.team_uuid, + name: row.name, + description: row.description, + status: row.status, + group_uuid: row.group_uuid, + proxy_uuid: row.proxy_uuid, + system_info: row.system_info, + kernel_info: row.kernel_info, + fingerprint_summary: row.fingerprint_summary, + last_opened_at: row.last_opened_at, + created_at: row.created_at, + updated_at: row.updated_at, + deleted_at: None, + }; + + // 分组详情 + let group = row.group_uuid.and_then(|uuid| { + groups_map.get(&uuid).map(|g| GroupSummaryDto { + id: g.id, + uuid: g.uuid, + name: g.name.clone(), + description: g.description.clone(), + sort_order: g.sort_order, + }) + }); + + // 代理详情 + let proxy = row.proxy_uuid.and_then(|uuid| { + proxies_map.get(&uuid).map(|p| ProxySummaryDto { + id: p.id, + uuid: p.uuid, + name: p.name.clone(), + host: p.host.clone(), + port: p.port, + proxy_type: p.proxy_type.clone(), + username: p.username.clone(), + password: p.password.clone(), + country: p.country.clone(), + city: p.city.clone(), + status: p.status.clone(), + latency: p.latency, + last_check_ip: p.last_check_ip.clone(), + }) + }); + + crate::entitys::EnvironmentDetailResponse { + environment, + config: configs_map.remove(&row.uuid), // 返回配置信息,用于传递给指纹浏览器内核 + cookies: cookies_map.remove(&row.uuid).unwrap_or_default(), + urls: urls_map.remove(&row.uuid).unwrap_or_default(), + tags: tags_map.remove(&row.uuid).unwrap_or_default(), + accounts: accounts_map.remove(&row.uuid).unwrap_or_default(), + group, + proxy, + extensions: extensions_map.remove(&row.uuid).unwrap_or_default(), + } + }) + .collect(); + + // 返回过滤后的环境列表和数据库总数(用于分页显示) + // 注意:total 是数据库中符合条件的总数,不考虑权限过滤 + // 权限过滤只影响当前页返回的数据,不影响总数统计 + Ok((environments, total_count)) +} + +/// 获取环境详情 +pub async fn get_environment_service( + svc_ctx: &SvcCtx, + workspace_uuid: Uuid, + team_uuid: Uuid, + user_uuid: Uuid, + env_uuid: Uuid, +) -> Result { + // 1. 检查用户是否在当前工作空间的团队中 + let _team_member = + models::teams::fetch_team_member(&svc_ctx.db, workspace_uuid, team_uuid, user_uuid) + .await + .map_err(|e| e.to_string())? + .ok_or_else(|| "您不是该团队的成员".to_string())?; + + // 2. 查询环境(带工作空间过滤) + let environment = models::fetch_environment_by_uuid(&svc_ctx.db, workspace_uuid, env_uuid) + .await + .map_err(|e| e.to_string())? + .ok_or_else(|| "环境不存在或不属于当前工作空间".to_string())?; + + // 3. 验证环境属于指定团队 + if environment.team_uuid != team_uuid { + return Err("环境不属于指定团队".to_string()); + } + + // 4. 权限检查 + if let Some(group_uuid) = environment.group_uuid { + // 如果环境有分组,检查用户是否有分组的 read/write/manage 权限 + // Owner/Admin 自动拥有所有分组权限(已在 check_group_permission 中处理) + let has_permission = models::check_group_permission( + &svc_ctx.db, + workspace_uuid, + group_uuid, + user_uuid, + "read", + ) + .await + .map_err(|e| e.to_string())?; + + if !has_permission { + return Err("您没有查看该环境的权限".to_string()); + } + } + // 如果环境无分组,所有团队成员都可以查看(已在团队成员检查中验证) + + Ok(environment) +} + +/// 获取环境配置 +pub async fn get_environment_config_service( + svc_ctx: &SvcCtx, + env_uuid: Uuid, +) -> Result { + models::fetch_environment_config(&svc_ctx.db, env_uuid) + .await + .map_err(|e| e.to_string())? + .ok_or_else(|| "环境配置不存在".to_string()) +} + +/// 获取环境的标签 +pub async fn get_environment_tags_service( + svc_ctx: &SvcCtx, + env_uuid: Uuid, +) -> Result, String> { + models::fetch_environment_tags(&svc_ctx.db, env_uuid) + .await + .map_err(|e| e.to_string()) +} + +/// 获取环境详情(包含完整关联数据) +pub async fn get_environment_detail_service( + svc_ctx: &SvcCtx, + workspace_uuid: Uuid, + team_uuid: Uuid, + user_uuid: Uuid, + env_uuid: Uuid, +) -> Result { + let environment = + get_environment_service(svc_ctx, workspace_uuid, team_uuid, user_uuid, env_uuid).await?; + + let config = get_environment_config_service(svc_ctx, env_uuid).await.ok(); + let cookies = get_environment_cookies_service(svc_ctx, env_uuid) + .await + .unwrap_or_default(); + let urls = get_environment_urls_service(svc_ctx, env_uuid) + .await + .unwrap_or_default(); + + let tags = get_environment_tags_service(svc_ctx, env_uuid).await?; + + let accounts = accounts::get_environment_accounts_service(svc_ctx, env_uuid) + .await + .unwrap_or_default(); + + // 获取分组信息 + let group = if let Some(group_uuid) = environment.group_uuid { + get_group_summary_service(svc_ctx, group_uuid).await.ok() + } else { + None + }; + + // 获取代理信息 + let proxy = if let Some(proxy_uuid) = environment.proxy_uuid { + get_proxy_summary_service(svc_ctx, proxy_uuid).await.ok() + } else { + None + }; + + // 获取扩展列表 + let extensions = crate::services::extensions::get_environment_extensions_service( + svc_ctx, + user_uuid, + team_uuid, + environment.group_uuid, + ) + .await + .unwrap_or_default(); + + Ok(crate::entitys::EnvironmentDetailResponse { + environment, + config, + cookies, + urls, + tags, + accounts, + group, + proxy, + extensions, + }) +} + +/// 获取分组摘要信息 +pub async fn get_group_summary_service( + svc_ctx: &SvcCtx, + group_uuid: Uuid, +) -> Result { + let group = models::fetch_group_by_uuid(&svc_ctx.db, group_uuid) + .await + .map_err(|e| e.to_string())? + .ok_or_else(|| "分组不存在".to_string())?; + + Ok(crate::dto::GroupSummaryDto { + id: group.id, + uuid: group.uuid, + name: group.name, + description: group.description, + sort_order: group.sort_order, + }) +} + +/// 获取代理摘要信息 +pub async fn get_proxy_summary_service( + svc_ctx: &SvcCtx, + proxy_uuid: Uuid, +) -> Result { + let proxy = crate::models::proxies::fetch_proxy_by_uuid(&svc_ctx.db, proxy_uuid) + .await + .map_err(|e| e.to_string())? + .ok_or_else(|| "代理不存在".to_string())?; + + Ok(crate::dto::ProxySummaryDto { + id: proxy.id, + uuid: proxy.uuid, + name: proxy.name, + host: proxy.host, + port: proxy.port, + proxy_type: proxy.proxy_type, + username: proxy.username, + password: proxy.password, + country: proxy.country, + city: proxy.city, + status: proxy.status, + latency: proxy.latency, + last_check_ip: proxy.last_check_ip, + }) +} + +/// 更新环境 +pub async fn update_environment_service( + svc_ctx: &SvcCtx, + workspace_uuid: Uuid, + team_uuid: Uuid, + user_uuid: Uuid, + payload: &UpdateEnvironmentRequest, +) -> Result<(), String> { + // 1. 检查用户是否在当前工作空间的团队中 + let team_member = + models::teams::fetch_team_member(&svc_ctx.db, workspace_uuid, team_uuid, user_uuid) + .await + .map_err(|e| e.to_string())? + .ok_or_else(|| "您不是该团队的成员".to_string())?; + + // 2. 查询环境(带工作空间过滤) + let environment = models::fetch_environment_by_uuid(&svc_ctx.db, workspace_uuid, payload.uuid) + .await + .map_err(|e| e.to_string())? + .ok_or_else(|| "环境不存在或不属于当前工作空间".to_string())?; + + // 3. 验证环境属于指定团队 + if environment.team_uuid != team_uuid { + return Err("环境不属于指定团队".to_string()); + } + + // 4. 权限检查 + if let Some(group_uuid) = environment.group_uuid { + // 如果环境有分组,检查用户是否有分组的 write 或 manage 权限 + let has_write = models::check_group_permission( + &svc_ctx.db, + workspace_uuid, + group_uuid, + user_uuid, + "write", + ) + .await + .map_err(|e| e.to_string())?; + + let has_manage = models::check_group_permission( + &svc_ctx.db, + workspace_uuid, + group_uuid, + user_uuid, + "manage", + ) + .await + .map_err(|e| e.to_string())?; + + if !has_write && !has_manage { + return Err("您没有编辑该环境的权限".to_string()); + } + } else { + // 如果环境无分组,检查用户是否有团队级别的编辑权限(Editor/Admin/Owner) + let can_edit = matches!(team_member.role.as_str(), "owner" | "admin" | "editor"); + if !can_edit { + return Err("您没有编辑环境的权限,需要 Editor 及以上角色".to_string()); + } + } + + models::update_environment( + &svc_ctx.db, + payload.uuid, + payload.name.as_deref(), + payload.description.as_deref(), + payload.group_uuid, + ) + .await + .map_err(|e| e.to_string())?; + + // 更新配置 + if let Some(config) = &payload.config { + models::upsert_environment_config( + &svc_ctx.db, + payload.uuid, + &config.window_info, + &config.basic_settings, + &config.fingerprint_settings, + &config.device_settings, + &config.preference_settings, + &config.project_metadata, + ) + .await + .map_err(|e| e.to_string())?; + } + + replace_environment_urls(svc_ctx, payload.uuid, payload.urls.as_deref()).await?; + replace_environment_cookies(svc_ctx, payload.uuid, payload.cookies.as_deref()).await?; + + Ok(()) +} + +/// 设置环境代理 +pub async fn set_environment_proxy_service( + svc_ctx: &SvcCtx, + payload: &SetEnvironmentProxyRequest, +) -> Result<(), String> { + models::update_environment_proxy(&svc_ctx.db, payload.uuid, payload.proxy_uuid) + .await + .map_err(|e| e.to_string()) +} + +/// 分配标签 +pub async fn assign_tags_service( + svc_ctx: &SvcCtx, + payload: &AssignTagsRequest, +) -> Result<(), String> { + for tag_uuid in &payload.tag_uuids { + models::insert_environment_tag(&svc_ctx.db, payload.uuid, *tag_uuid) + .await + .map_err(|e| e.to_string())?; + } + Ok(()) +} + +/// 移除标签 +pub async fn remove_tag_service( + svc_ctx: &SvcCtx, + env_uuid: Uuid, + tag_uuid: Uuid, +) -> Result<(), String> { + models::remove_environment_tag(&svc_ctx.db, env_uuid, tag_uuid) + .await + .map_err(|e| e.to_string()) +} + +/// 移动到分组 +pub async fn move_to_group_service( + svc_ctx: &SvcCtx, + payload: &MoveToGroupRequest, +) -> Result<(), String> { + models::update_environment(&svc_ctx.db, payload.uuid, None, None, payload.group_uuid) + .await + .map_err(|e| e.to_string()) +} + +/// 批量移动到分组 +pub async fn batch_move_to_group_service( + svc_ctx: &SvcCtx, + payload: &BatchMoveToGroupRequest, +) -> Result<(), String> { + for env_uuid in &payload.env_uuids { + models::update_environment(&svc_ctx.db, *env_uuid, None, None, Some(payload.group_uuid)) + .await + .map_err(|e| e.to_string())?; + } + Ok(()) +} + +/// 批量分配标签 +pub async fn batch_assign_tags_service( + svc_ctx: &SvcCtx, + payload: &BatchAssignTagRequest, +) -> Result<(), String> { + for env_uuid in &payload.env_uuids { + models::insert_environment_tag(&svc_ctx.db, *env_uuid, payload.tag_uuid) + .await + .map_err(|e| e.to_string())?; + } + Ok(()) +} + +/// 批量移除标签 +pub async fn batch_remove_tags_service( + svc_ctx: &SvcCtx, + payload: &BatchRemoveTagsRequest, +) -> Result<(), String> { + if let Some(tag_uuid) = payload.tag_uuid { + // 移除指定的标签 + for env_uuid in &payload.env_uuids { + models::remove_environment_tag(&svc_ctx.db, *env_uuid, tag_uuid) + .await + .map_err(|e| e.to_string())?; + } + } else { + // 移除所有标签 + for env_uuid in &payload.env_uuids { + models::clear_environment_tags(&svc_ctx.db, *env_uuid) + .await + .map_err(|e| e.to_string())?; + } + } + Ok(()) +} + +/// 删除环境 +pub async fn delete_environment_service( + svc_ctx: &SvcCtx, + workspace_uuid: Uuid, + team_uuid: Uuid, + user_uuid: Uuid, + env_uuid: Uuid, +) -> Result<(), String> { + // 1. 检查用户是否在当前工作空间的团队中 + let team_member = + models::teams::fetch_team_member(&svc_ctx.db, workspace_uuid, team_uuid, user_uuid) + .await + .map_err(|e| e.to_string())? + .ok_or_else(|| "您不是该团队的成员".to_string())?; + + // 2. 查询环境(带工作空间过滤) + let environment = models::fetch_environment_by_uuid(&svc_ctx.db, workspace_uuid, env_uuid) + .await + .map_err(|e| e.to_string())? + .ok_or_else(|| "环境不存在或不属于当前工作空间".to_string())?; + + // 3. 验证环境属于指定团队 + if environment.team_uuid != team_uuid { + return Err("环境不属于指定团队".to_string()); + } + + // 4. 权限检查 + if let Some(group_uuid) = environment.group_uuid { + // 如果环境有分组,检查用户是否有分组的 manage 权限 + let has_manage = models::check_group_permission( + &svc_ctx.db, + workspace_uuid, + group_uuid, + user_uuid, + "manage", + ) + .await + .map_err(|e| e.to_string())?; + + if !has_manage { + return Err("您没有删除该环境的权限".to_string()); + } + } else { + // 如果环境无分组,检查用户是否有团队级别的删除权限(Owner/Admin) + let can_delete = matches!(team_member.role.as_str(), "owner" | "admin"); + if !can_delete { + return Err("您没有删除环境的权限,需要 Owner 或 Admin 角色".to_string()); + } + } + + // 5. 删除环境 + models::delete_environment(&svc_ctx.db, env_uuid) + .await + .map_err(|e| e.to_string())?; + + // 6. 更新工作空间配额(删除后减少使用数) + models::decrement_used_environments(&svc_ctx.db, workspace_uuid, 1) + .await + .map_err(|e| format!("更新配额失败: {}", e))?; + + Ok(()) +} + +/// 批量删除环境 +pub async fn batch_delete_environments_service( + svc_ctx: &SvcCtx, + env_uuids: &[Uuid], +) -> Result { + models::batch_delete_environments(&svc_ctx.db, env_uuids) + .await + .map_err(|e| e.to_string()) +} + +// ============ Recycle Bin ============ + +/// 查询回收站环境列表 +pub async fn get_recycle_bin_environments_service( + svc_ctx: &SvcCtx, + workspace_uuid: Uuid, + team_uuid: Uuid, + user_uuid: Uuid, + payload: &ListEnvironmentsRequest, +) -> Result<(Vec, i64), String> { + // 1. 提取过滤参数 + let group_uuid = payload.filters.as_ref().and_then(|f| f.group_uuid); + let keyword = payload.filters.as_ref().and_then(|f| f.keyword.as_deref()); + + // 2. 计算分页参数 + let page = payload.pagination.page; + let page_size = payload.pagination.page_size; + let offset = (page - 1) * page_size; + let limit = page_size; + + // 3. 查询回收站环境总数 + let total_count = models::fetch_deleted_environments_count( + &svc_ctx.db, + workspace_uuid, + team_uuid, + group_uuid, + keyword, + ) + .await + .map_err(|e| e.to_string())?; + + // 4. 查询回收站环境基础列表 + let env_rows = models::fetch_deleted_environments_base( + &svc_ctx.db, + workspace_uuid, + team_uuid, + group_uuid, + keyword, + offset, + limit, + ) + .await + .map_err(|e| e.to_string())?; + + if env_rows.is_empty() { + return Ok((vec![], total_count)); + } + + // 5. 获取所有环境的 UUID + let env_uuids: Vec = env_rows.iter().map(|e| e.uuid).collect(); + let group_uuids: Vec = env_rows.iter().filter_map(|e| e.group_uuid).collect(); + let proxy_uuids: Vec = env_rows.iter().filter_map(|e| e.proxy_uuid).collect(); + + // 6. 批量查询分组信息 + let group_rows = if !group_uuids.is_empty() { + models::fetch_groups_by_uuids(&svc_ctx.db, &group_uuids) + .await + .map_err(|e| e.to_string())? + } else { + vec![] + }; + let group_map: HashMap = group_rows + .into_iter() + .map(|g| { + ( + g.uuid, + GroupSummaryDto { + id: g.id, + uuid: g.uuid, + name: g.name, + description: g.description, + sort_order: g.sort_order, + }, + ) + }) + .collect(); + + // 7. 批量查询代理信息 + let proxy_rows = if !proxy_uuids.is_empty() { + models::fetch_proxies_by_uuids(&svc_ctx.db, &proxy_uuids) + .await + .map_err(|e| e.to_string())? + } else { + vec![] + }; + let proxy_map: HashMap = proxy_rows + .into_iter() + .map(|p| { + ( + p.uuid, + ProxySummaryDto { + id: p.id, + uuid: p.uuid, + name: p.name, + host: p.host, + port: p.port, + proxy_type: p.proxy_type, + username: p.username, + password: p.password, + country: p.country, + city: p.city, + status: p.status, + latency: p.latency, + last_check_ip: p.last_check_ip, + }, + ) + }) + .collect(); + + // 8. 批量查询标签信息 + let tag_rows = if !env_uuids.is_empty() { + models::fetch_tags_for_environments(&svc_ctx.db, &env_uuids) + .await + .map_err(|e| e.to_string())? + } else { + vec![] + }; + let mut env_tags_map: HashMap> = HashMap::new(); + for tag_row in tag_rows { + env_tags_map.entry(tag_row.environment_uuid).or_default().push(TagDto { + id: tag_row.tag_id, + uuid: tag_row.tag_uuid, + user_uuid: tag_row.tag_user_uuid, + team_uuid: tag_row.tag_team_uuid, + name: tag_row.tag_name, + color: tag_row.tag_color, + sort_order: tag_row.tag_sort_order, + environments_count: tag_row.tag_environments_count, + created_at: tag_row.tag_created_at, + updated_at: tag_row.tag_updated_at, + deleted_at: tag_row.tag_deleted_at, + }); + } + + let mut env_urls_map: HashMap> = HashMap::new(); + let url_rows = models::fetch_environment_urls_by_uuids(&svc_ctx.db, &env_uuids) + .await + .map_err(|e| e.to_string())?; + for url in url_rows { + env_urls_map + .entry(url.environment_uuid) + .or_default() + .push(url); + } + + let mut env_cookies_map: HashMap> = HashMap::new(); + let cookie_rows = models::fetch_environment_cookies_by_uuids(&svc_ctx.db, &env_uuids) + .await + .map_err(|e| e.to_string())?; + let mut grouped_cookie_rows: HashMap> = HashMap::new(); + for cookie in cookie_rows { + grouped_cookie_rows + .entry(cookie.environment_uuid) + .or_default() + .push(cookie); + } + for (environment_uuid, rows) in grouped_cookie_rows { + env_cookies_map + .insert(environment_uuid, group_cookie_rows(rows)); + } + + // 9. 批量查询账号信息 + let mut env_accounts_map: HashMap> = HashMap::new(); + for env_uuid in &env_uuids { + let accounts = accounts::get_environment_accounts_service(svc_ctx, *env_uuid) + .await + .unwrap_or_default(); + if !accounts.is_empty() { + env_accounts_map.insert(*env_uuid, accounts); + } + } + + // 10. 组装完整的环境信息 + let environments: Vec = env_rows + .into_iter() + .map(|row| { + let group = row.group_uuid.and_then(|gid| group_map.get(&gid).cloned()); + let proxy = row.proxy_uuid.and_then(|pid| proxy_map.get(&pid).cloned()); + let tags = env_tags_map.get(&row.uuid).cloned().unwrap_or_default(); + let accounts = env_accounts_map.get(&row.uuid).cloned().unwrap_or_default(); + + crate::entitys::EnvironmentDetailResponse { + environment: EnvironmentDto { + id: row.id, + uuid: row.uuid, + workspace_uuid: row.workspace_uuid, + user_uuid: row.user_uuid, + team_uuid: row.team_uuid, + name: row.name, + description: row.description, + status: row.status, + group_uuid: row.group_uuid, + proxy_uuid: row.proxy_uuid, + system_info: row.system_info, + kernel_info: row.kernel_info, + fingerprint_summary: row.fingerprint_summary, + last_opened_at: row.last_opened_at, + created_at: row.created_at, + updated_at: row.updated_at, + deleted_at: None, + }, + config: None, + cookies: env_cookies_map.remove(&row.uuid).unwrap_or_default(), + urls: env_urls_map.remove(&row.uuid).unwrap_or_default(), + tags, + accounts, + group, + proxy, + extensions: vec![], // 回收站不需要返回扩展数据 + } + }) + .collect(); + + Ok((environments, total_count)) +} + +/// 恢复环境 +pub async fn restore_environment_service(svc_ctx: &SvcCtx, env_uuid: Uuid) -> Result<(), String> { + models::restore_environment(&svc_ctx.db, env_uuid) + .await + .map_err(|e| e.to_string()) +} + +/// 批量恢复环境 +pub async fn batch_restore_environments_service( + svc_ctx: &SvcCtx, + env_uuids: &[Uuid], +) -> Result { + models::batch_restore_environments(&svc_ctx.db, env_uuids) + .await + .map_err(|e| e.to_string()) +} + +/// 永久删除环境 +pub async fn permanent_delete_environment_service( + svc_ctx: &SvcCtx, + env_uuid: Uuid, + workspace_uuid: Uuid, +) -> Result<(), String> { + // 永久删除环境 + models::permanent_delete_environment(&svc_ctx.db, env_uuid) + .await + .map_err(|e| e.to_string())?; + + // 更新工作空间配额(永久删除后减少使用数) + models::decrement_used_environments(&svc_ctx.db, workspace_uuid, 1) + .await + .map_err(|e| format!("更新配额失败: {}", e))?; + + Ok(()) +} + +/// 批量永久删除环境 +pub async fn batch_permanent_delete_environments_service( + svc_ctx: &SvcCtx, + env_uuids: &[Uuid], + workspace_uuid: Uuid, +) -> Result { + let count = models::batch_permanent_delete_environments(&svc_ctx.db, env_uuids) + .await + .map_err(|e| e.to_string())?; + + // 更新工作空间配额 + if count > 0 { + models::decrement_used_environments(&svc_ctx.db, workspace_uuid, count as i32) + .await + .map_err(|e| format!("更新配额失败: {}", e))?; + } + + Ok(count) +} + +// ============ Environment URLs ============ + +/// 添加环境 URL +pub async fn add_environment_url_service( + svc_ctx: &SvcCtx, + payload: &AddEnvironmentUrlRequest, +) -> Result { + models::insert_environment_url( + &svc_ctx.db, + payload.environment_uuid, + &payload.url, + payload.title.as_deref(), + payload.sort_order, + ) + .await + .map_err(|e| e.to_string()) +} + +/// 获取环境的所有 URL +pub async fn get_environment_urls_service( + svc_ctx: &SvcCtx, + env_uuid: Uuid, +) -> Result, String> { + models::fetch_environment_urls(&svc_ctx.db, env_uuid) + .await + .map_err(|e| e.to_string()) +} + +/// 删除环境 URL +pub async fn delete_environment_url_service(svc_ctx: &SvcCtx, url_id: i32) -> Result<(), String> { + models::delete_environment_url(&svc_ctx.db, url_id) + .await + .map_err(|e| e.to_string()) +} + +/// 清空环境的所有 URL +pub async fn clear_environment_urls_service( + svc_ctx: &SvcCtx, + env_uuid: Uuid, +) -> Result { + models::clear_environment_urls(&svc_ctx.db, env_uuid) + .await + .map_err(|e| e.to_string()) +} + +// ============ Environment Cookies ============ + +/// 添加环境 Cookie +pub async fn add_environment_cookie_service( + svc_ctx: &SvcCtx, + payload: &AddEnvironmentCookieRequest, +) -> Result { + let cookies = parse_cookie_group(&CookieGroupInput { + site: payload.site.clone(), + cookie_text: payload.cookie_text.clone(), + })?; + + let mut last_id = 0; + for cookie in cookies { + last_id = models::insert_environment_cookie( + &svc_ctx.db, + payload.environment_uuid, + &cookie.site_input, + &cookie.domain, + &cookie.name, + &cookie.value, + cookie.path.as_deref(), + cookie.http_only, + cookie.secure, + cookie.same_site.as_deref(), + ) + .await + .map_err(|e| e.to_string())?; + } + + Ok(last_id) +} + +/// 获取环境的所有 Cookies +pub async fn get_environment_cookies_service( + svc_ctx: &SvcCtx, + env_uuid: Uuid, +) -> Result, String> { + let rows = models::fetch_environment_cookies(&svc_ctx.db, env_uuid) + .await + .map_err(|e| e.to_string())?; + + Ok(group_cookie_rows(rows)) +} + +/// 删除环境 Cookie +pub async fn delete_environment_cookie_service( + svc_ctx: &SvcCtx, + cookie_id: i32, +) -> Result<(), String> { + models::delete_environment_cookie(&svc_ctx.db, cookie_id) + .await + .map_err(|e| e.to_string()) +} + +/// 清空环境的所有 Cookies +pub async fn clear_environment_cookies_service( + svc_ctx: &SvcCtx, + env_uuid: Uuid, +) -> Result { + models::clear_environment_cookies(&svc_ctx.db, env_uuid) + .await + .map_err(|e| e.to_string()) +} + +/// 批量创建环境 +pub async fn batch_create_environments_service( + svc_ctx: &SvcCtx, + user_uuid: Uuid, + workspace_uuid: Uuid, + team_uuid: Uuid, + payload: &BatchCreateEnvironmentRequest, +) -> Result, String> { + let mut created_uuids = Vec::new(); + + for env_request in &payload.environments { + let env_uuid = + create_environment_service(svc_ctx, user_uuid, workspace_uuid, team_uuid, env_request) + .await + .map_err(|e| format!("创建环境 '{}' 失败: {}", env_request.name, e))?; + + created_uuids.push(env_uuid); + } + + Ok(created_uuids) +} diff --git a/src-tauri/crates/business/src/services/extensions.rs b/src-tauri/crates/business/src/services/extensions.rs new file mode 100644 index 0000000..9742b3a --- /dev/null +++ b/src-tauri/crates/business/src/services/extensions.rs @@ -0,0 +1,1030 @@ +use uuid::Uuid; + +use crate::dto::ExtensionDto; +use crate::entitys::{ + ExtensionGroup, InstallExtensionRequest, InstalledExtensionItem, ListExtensionsRequest, + UninstallExtensionRequest, +}; +use crate::models; +use crate::models::environments as env_models; +use crate::svc_ctx::SvcCtx; +use crate::utils::storage::get_objects; + +/// 获取扩展列表 +pub async fn get_extensions_service( + svc_ctx: &SvcCtx, + payload: &ListExtensionsRequest, +) -> Result<(Vec, i64), String> { + let offset = (payload.pagination.page - 1) * payload.pagination.page_size; + + let keyword = payload.filters.as_ref().and_then(|f| f.keyword.as_deref()); + let category = payload.filters.as_ref().and_then(|f| f.category.as_deref()); + let sort_by = payload.filters.as_ref().and_then(|f| f.sort_by.as_deref()); + let sort_order = payload.filters.as_ref().and_then(|f| f.sort_order.as_deref()); + + let mut extensions = models::extensions::fetch_extensions( + &svc_ctx.db, + keyword, + category, + sort_by, + sort_order, + offset, + payload.pagination.page_size, + ) + .await + .map_err(|e| e.to_string())?; + + // 将 object path 转换为完整 URL + let public_base_url = svc_ctx.config.storage.public_base_url.as_str(); + let extension_root = svc_ctx.config.storage.extension_root.as_str(); + ExtensionDto::transform_urls_batch(&mut extensions, public_base_url, extension_root); + + let total = models::extensions::fetch_extensions_count(&svc_ctx.db, keyword, category) + .await + .map_err(|e| e.to_string())?; + + Ok((extensions, total)) +} + +/// 获取扩展详情 +pub async fn get_extension_service( + svc_ctx: &SvcCtx, + extension_id: &str, +) -> Result { + let mut extension = models::extensions::fetch_extension_by_id(&svc_ctx.db, extension_id) + .await + .map_err(|e| e.to_string())? + .ok_or_else(|| "扩展不存在".to_string())?; + + // 将 object path 转换为完整 URL + let public_base_url = svc_ctx.config.storage.public_base_url.as_str(); + let extension_root = svc_ctx.config.storage.extension_root.as_str(); + extension.transform_urls(&public_base_url, &extension_root); + + Ok(extension) +} + +/// 获取扩展分类 +pub async fn get_extension_categories_service(svc_ctx: &SvcCtx) -> Result, String> { + models::extensions::fetch_extension_categories(&svc_ctx.db) + .await + .map_err(|e| e.to_string()) +} + +/// 获取用户已安装的扩展 +pub async fn get_user_installed_extensions_service( + svc_ctx: &SvcCtx, + user_uuid: Uuid, + team_uuid: Option, +) -> Result, String> { + let public_base_url = svc_ctx.config.storage.public_base_url.as_str(); + let extension_root = svc_ctx.config.storage.extension_root.as_str(); + + // 查询用户直接安装的扩展 + let user_installed = models::extensions::fetch_user_extensions(&svc_ctx.db, user_uuid) + .await + .map_err(|e| e.to_string())?; + + // 查询用户相关的分组中安装的扩展 + let group_installed = + models::extensions::fetch_user_group_extensions(&svc_ctx.db, user_uuid, team_uuid) + .await + .map_err(|e| e.to_string())?; + + // 使用 HashMap 去重,以 extension_id 为 key + use std::collections::HashMap; + let mut extension_map: HashMap = HashMap::new(); + + // 处理用户直接安装的扩展 + for ue in user_installed { + if let Ok(Some(ext)) = + models::extensions::fetch_extension_by_id(&svc_ctx.db, &ue.extension_id).await + { + // 转换图标 URL + let mut icon_url = ext.icon_url.clone(); + if let Some(path) = &icon_url { + if !path.is_empty() && !path.starts_with("http") { + icon_url = Some(get_objects::get_extension_icon_url( + &public_base_url, + &extension_root, + path, + )); + } + } + + // 查询关联的分组 + let group_uuids = models::extensions::fetch_group_uuids_by_extension_id( + &svc_ctx.db, + &ue.extension_id, + ) + .await + .unwrap_or_default(); + + let mut groups = Vec::new(); + for group_uuid in group_uuids { + if let Ok(Some(group)) = + env_models::fetch_group_by_uuid(&svc_ctx.db, group_uuid).await + { + groups.push(ExtensionGroup { + uuid: group.uuid, + name: group.name, + }); + } + } + + let has_update = ext.version != ue.installed_version; + extension_map.insert( + ue.extension_id.clone(), + InstalledExtensionItem { + extension_id: ue.extension_id, + name: ext.name, + version: ext.version, + installed_version: ue.installed_version, + has_update, + status: ue.status, + installed_at: ue.installed_at, + homepage: ext.homepage, + icon_url, + team_uuid: None, + scope: "user".to_string(), + description: ext.description, + category: Some(ext.category), + browser: Some(ext.browser), + developer: ext.developer, + downloads_count: ext.downloads_count, + rating: ext.rating, + permissions: ext.permissions, + file_size: ext.file_size, + updated_at: Some(ext.updated_at), + groups: if groups.is_empty() { + None + } else { + Some(groups) + }, + }, + ); + } + } + + // 处理分组安装的扩展 + for ge in group_installed { + // 如果已存在(用户直接安装),分组信息已经包含在内,直接跳过 + if extension_map.contains_key(&ge.extension_id) { + continue; + } + + // 只处理新扩展(仅安装在分组中,用户未直接安装) + if let Ok(Some(ext)) = + models::extensions::fetch_extension_by_id(&svc_ctx.db, &ge.extension_id).await + { + // 转换图标 URL + let mut icon_url = ext.icon_url.clone(); + if let Some(path) = &icon_url { + if !path.is_empty() && !path.starts_with("http") { + icon_url = Some(get_objects::get_extension_icon_url( + &public_base_url, + &extension_root, + path, + )); + } + } + + // 查询关联的分组 + let group_uuids = models::extensions::fetch_group_uuids_by_extension_id( + &svc_ctx.db, + &ge.extension_id, + ) + .await + .unwrap_or_default(); + + let mut groups = Vec::new(); + let mut is_team_group = false; + for group_uuid in group_uuids { + if let Ok(Some(group)) = + env_models::fetch_group_by_uuid(&svc_ctx.db, group_uuid).await + { + if !group.team_uuid.is_nil() { + is_team_group = true; + } + groups.push(ExtensionGroup { + uuid: group.uuid, + name: group.name, + }); + } + } + + let has_update = ext.version != ge.installed_version; + let scope = if is_team_group { + "group-team".to_string() + } else { + "group-personal".to_string() + }; + extension_map.insert( + ge.extension_id.clone(), + InstalledExtensionItem { + extension_id: ge.extension_id, + name: ext.name, + version: ext.version, + installed_version: ge.installed_version, + has_update, + status: ge.status, + installed_at: ge.installed_at, + homepage: ext.homepage, + icon_url, + team_uuid: None, + scope, + description: ext.description, + category: Some(ext.category), + browser: Some(ext.browser), + developer: ext.developer, + downloads_count: ext.downloads_count, + rating: ext.rating, + permissions: ext.permissions, + file_size: ext.file_size, + updated_at: Some(ext.updated_at), + groups: if groups.is_empty() { + None + } else { + Some(groups) + }, + }, + ); + } + } + + Ok(extension_map.into_values().collect()) +} + +/// 获取团队已安装的扩展 +pub async fn get_team_installed_extensions_service( + svc_ctx: &SvcCtx, + team_uuid: Uuid, + user_uuid: Uuid, +) -> Result, String> { + let public_base_url = svc_ctx.config.storage.public_base_url.as_str(); + let extension_root = svc_ctx.config.storage.extension_root.as_str(); + + // 查询团队直接安装的扩展 + let team_installed = models::extensions::fetch_team_extensions(&svc_ctx.db, team_uuid) + .await + .map_err(|e| e.to_string())?; + + // 查询团队相关的分组中安装的扩展 + let group_installed = models::extensions::fetch_team_group_extensions(&svc_ctx.db, team_uuid) + .await + .map_err(|e| e.to_string())?; + + // 查询用户禁用的团队插件列表 + let user_disabled_extensions = + models::extensions::fetch_user_disabled_team_extensions(&svc_ctx.db, user_uuid, team_uuid) + .await + .map_err(|e| e.to_string())?; + let user_disabled_set: std::collections::HashSet = + user_disabled_extensions.into_iter().collect(); + + // 使用 HashMap 去重,以 extension_id 为 key + use std::collections::HashMap; + let mut extension_map: HashMap = HashMap::new(); + + // 处理团队直接安装的扩展 + for te in team_installed { + if let Ok(Some(ext)) = + models::extensions::fetch_extension_by_id(&svc_ctx.db, &te.extension_id).await + { + // 转换图标 URL + let mut icon_url = ext.icon_url.clone(); + if let Some(path) = &icon_url { + if !path.is_empty() && !path.starts_with("http") { + icon_url = Some(get_objects::get_extension_icon_url( + &public_base_url, + &extension_root, + path, + )); + } + } + + // 查询关联的分组(仅查询团队共享的分组) + let group_uuids = models::extensions::fetch_team_shared_group_uuids_by_extension_id( + &svc_ctx.db, + &te.extension_id, + ) + .await + .unwrap_or_default(); + + let mut groups = Vec::new(); + for group_uuid in group_uuids { + if let Ok(Some(group)) = + env_models::fetch_group_by_uuid(&svc_ctx.db, group_uuid).await + { + groups.push(ExtensionGroup { + uuid: group.uuid, + name: group.name, + }); + } + } + + let has_update = ext.version != te.installed_version; + + // 检查用户是否禁用了这个团队插件 + let status = if user_disabled_set.contains(&te.extension_id) { + "disabled".to_string() + } else { + te.status + }; + + extension_map.insert( + te.extension_id.clone(), + InstalledExtensionItem { + extension_id: te.extension_id, + name: ext.name, + version: ext.version, + installed_version: te.installed_version, + has_update, + status, + installed_at: te.installed_at, + homepage: ext.homepage, + icon_url, + team_uuid: Some(team_uuid), + scope: "team".to_string(), + description: ext.description, + category: Some(ext.category), + browser: Some(ext.browser), + developer: ext.developer, + downloads_count: ext.downloads_count, + rating: ext.rating, + permissions: ext.permissions, + file_size: ext.file_size, + updated_at: Some(ext.updated_at), + groups: if groups.is_empty() { + None + } else { + Some(groups) + }, + }, + ); + } + } + + // 处理分组安装的扩展 + for ge in group_installed { + // 如果已存在(团队直接安装),分组信息已经包含在内,直接跳过 + if extension_map.contains_key(&ge.extension_id) { + continue; + } + + // 只处理新扩展(仅安装在分组中,团队未直接安装) + if let Ok(Some(ext)) = + models::extensions::fetch_extension_by_id(&svc_ctx.db, &ge.extension_id).await + { + // 转换图标 URL + let mut icon_url = ext.icon_url.clone(); + if let Some(path) = &icon_url { + if !path.is_empty() && !path.starts_with("http") { + icon_url = Some(get_objects::get_extension_icon_url( + &public_base_url, + &extension_root, + path, + )); + } + } + + // 查询关联的分组 + let group_uuids = models::extensions::fetch_group_uuids_by_extension_id( + &svc_ctx.db, + &ge.extension_id, + ) + .await + .unwrap_or_default(); + + let mut groups = Vec::new(); + for group_uuid in group_uuids { + if let Ok(Some(group)) = + env_models::fetch_group_by_uuid(&svc_ctx.db, group_uuid).await + { + groups.push(ExtensionGroup { + uuid: group.uuid, + name: group.name, + }); + } + } + + let has_update = ext.version != ge.installed_version; + let scope = if ge.is_team_shared { + "group-team".to_string() + } else { + "group-personal".to_string() + }; + + // 检查用户是否禁用了这个团队插件 + let status = if user_disabled_set.contains(&ge.extension_id) { + "disabled".to_string() + } else { + ge.status + }; + + extension_map.insert( + ge.extension_id.clone(), + InstalledExtensionItem { + extension_id: ge.extension_id, + name: ext.name, + version: ext.version, + installed_version: ge.installed_version, + has_update, + status, + installed_at: ge.installed_at, + homepage: ext.homepage, + icon_url, + team_uuid: Some(team_uuid), + scope, + description: ext.description, + category: Some(ext.category), + browser: Some(ext.browser), + developer: ext.developer, + downloads_count: ext.downloads_count, + rating: ext.rating, + permissions: ext.permissions, + file_size: ext.file_size, + updated_at: Some(ext.updated_at), + groups: if groups.is_empty() { + None + } else { + Some(groups) + }, + }, + ); + } + } + + Ok(extension_map.into_values().collect()) +} + +/// 安装扩展 +pub async fn install_extension_service( + svc_ctx: &SvcCtx, + user_uuid: Uuid, + team_uuid: Option, + payload: &InstallExtensionRequest, +) -> Result<(), String> { + // 获取扩展信息 + let extension = models::extensions::fetch_extension_by_id(&svc_ctx.db, &payload.extension_id) + .await + .map_err(|e| e.to_string())? + .ok_or_else(|| "扩展不存在".to_string())?; + + let target_type = payload.target_type.as_deref().unwrap_or("user"); + + match target_type { + "user" => { + models::extensions::insert_user_extension( + &svc_ctx.db, + user_uuid, + &payload.extension_id, + &extension.version, + ) + .await + .map_err(|e| e.to_string())?; + } + "team" => { + // 权限检查:检查用户是否为 owner/admin + let team_uuid = team_uuid.ok_or_else(|| "未指定团队".to_string())?; + + // 获取工作空间 UUID + let team = crate::models::teams::fetch_team_by_uuid(&svc_ctx.db, team_uuid) + .await + .map_err(|e| e.to_string())? + .ok_or_else(|| "团队不存在".to_string())?; + let workspace_uuid = team.workspace_uuid; + + // 查询用户在团队中的角色 + let member = crate::models::teams::fetch_team_member( + &svc_ctx.db, + workspace_uuid, + team_uuid, + user_uuid, + ) + .await + .map_err(|e| e.to_string())? + .ok_or_else(|| "您不是该团队成员".to_string())?; + + // 检查角色权限 + if member.role != "owner" && member.role != "admin" { + return Err("权限不足:只有团队所有者或管理员可以安装团队插件".to_string()); + } + + models::extensions::insert_team_extension( + &svc_ctx.db, + team_uuid, + &payload.extension_id, + &extension.version, + user_uuid, + ) + .await + .map_err(|e| e.to_string())?; + } + "group" => { + // 权限检查 + let is_team_shared = payload.is_team_shared.unwrap_or(false); + + // 必须提供分组数组,即使只有一个分组也需要传入数组 + let group_ids = payload.group_ids.as_ref().ok_or_else(|| "未指定分组".to_string())?; + if group_ids.is_empty() { + return Err("分组列表不能为空".to_string()); + } + + for group_uuid in group_ids { + // 检查权限 + // - is_team_shared=true: 需要"管理"权限 + // - is_team_shared=false: 需要"编辑"权限 + let required_permission = if is_team_shared { "manage" } else { "write" }; + + // 获取工作空间 UUID(从团队获取) + let workspace_uuid = if let Some(team_uuid) = team_uuid { + let team = crate::models::teams::fetch_team_by_uuid(&svc_ctx.db, team_uuid) + .await + .map_err(|e| e.to_string())? + .ok_or_else(|| "团队不存在".to_string())?; + team.workspace_uuid + } else { + return Err("未指定团队".to_string()); + }; + + let has_permission = crate::models::group_member_permissions::check_group_permission( + &svc_ctx.db, + workspace_uuid, + *group_uuid, + user_uuid, + required_permission, + ) + .await + .map_err(|e| e.to_string())?; + + if !has_permission { + let msg = if is_team_shared { + "权限不足:安装团队共享插件需要分组的管理权限" + } else { + "权限不足:安装个人插件需要分组的编辑权限" + }; + return Err(msg.to_string()); + } + + models::extensions::insert_group_extension( + &svc_ctx.db, + *group_uuid, + &payload.extension_id, + &extension.version, + user_uuid, + is_team_shared, + ) + .await + .map_err(|e| e.to_string())?; + } + } + _ => return Err("无效的安装目标类型".to_string()), + } + + // 添加审计日志 + let details = format!("安装扩展: {}", payload.extension_id); + let changes = serde_json::json!({ + "extension_id": payload.extension_id, + "version": extension.version, + "target_type": target_type, + "is_team_shared": payload.is_team_shared, + }); + + let _ = crate::models::audit::insert_audit_log( + &svc_ctx.db, + user_uuid, + team_uuid, + "install_extension", + target_type, + None, + Some(&payload.extension_id), + Some(&details), + Some(&changes), + None, + None, + None, + ) + .await; + + Ok(()) +} + +/// 卸载扩展 +pub async fn uninstall_extension_service( + svc_ctx: &SvcCtx, + user_uuid: Uuid, + team_uuid: Option, + payload: &UninstallExtensionRequest, +) -> Result<(), String> { + let target_type = payload.target_type.as_deref().unwrap_or("user"); + + match target_type { + "user" => { + models::extensions::delete_user_extension( + &svc_ctx.db, + user_uuid, + &payload.extension_id, + ) + .await + .map_err(|e| e.to_string())?; + } + "team" => { + // 权限检查:检查用户是否为 owner/admin + let team_uuid = team_uuid.ok_or_else(|| "未指定团队".to_string())?; + + // 获取工作空间 UUID + let team = crate::models::teams::fetch_team_by_uuid(&svc_ctx.db, team_uuid) + .await + .map_err(|e| e.to_string())? + .ok_or_else(|| "团队不存在".to_string())?; + let workspace_uuid = team.workspace_uuid; + + // 查询用户在团队中的角色 + let member = crate::models::teams::fetch_team_member( + &svc_ctx.db, + workspace_uuid, + team_uuid, + user_uuid, + ) + .await + .map_err(|e| e.to_string())? + .ok_or_else(|| "您不是该团队成员".to_string())?; + + // 检查角色权限 + if member.role != "owner" && member.role != "admin" { + return Err("权限不足:只有团队所有者或管理员可以卸载团队插件".to_string()); + } + + models::extensions::delete_team_extension( + &svc_ctx.db, + team_uuid, + &payload.extension_id, + ) + .await + .map_err(|e| e.to_string())?; + } + "group" => { + // 权限检查 + let group_uuid = payload.target_uuid.ok_or_else(|| "未指定分组".to_string())?; + + // 查询该分组扩展的 is_team_shared 状态 + let group_extensions = models::extensions::fetch_group_extensions(&svc_ctx.db, group_uuid) + .await + .map_err(|e| e.to_string())?; + + let group_ext = group_extensions + .iter() + .find(|ge| ge.extension_id == payload.extension_id) + .ok_or_else(|| "该分组未安装此扩展".to_string())?; + + // 根据 is_team_shared 检查权限 + let required_permission = if group_ext.is_team_shared { "manage" } else { "write" }; + + // 获取工作空间 UUID + let workspace_uuid = if let Some(team_uuid) = team_uuid { + let team = crate::models::teams::fetch_team_by_uuid(&svc_ctx.db, team_uuid) + .await + .map_err(|e| e.to_string())? + .ok_or_else(|| "团队不存在".to_string())?; + team.workspace_uuid + } else { + return Err("未指定团队".to_string()); + }; + + let has_permission = crate::models::group_member_permissions::check_group_permission( + &svc_ctx.db, + workspace_uuid, + group_uuid, + user_uuid, + required_permission, + ) + .await + .map_err(|e| e.to_string())?; + + if !has_permission { + let msg = if group_ext.is_team_shared { + "权限不足:卸载团队共享插件需要分组的管理权限" + } else { + "权限不足:卸载个人插件需要分组的编辑权限" + }; + return Err(msg.to_string()); + } + + models::extensions::delete_group_extension( + &svc_ctx.db, + group_uuid, + &payload.extension_id, + ) + .await + .map_err(|e| e.to_string())?; + } + _ => return Err("无效的卸载目标类型".to_string()), + } + + // 添加审计日志 + let details = format!("卸载扩展: {}", payload.extension_id); + let changes = serde_json::json!({ + "extension_id": payload.extension_id, + "target_type": target_type, + }); + + let _ = crate::models::audit::insert_audit_log( + &svc_ctx.db, + user_uuid, + team_uuid, + "uninstall_extension", + target_type, + payload.target_uuid, + Some(&payload.extension_id), + Some(&details), + Some(&changes), + None, + None, + None, + ) + .await; + + Ok(()) +} + +/// 更新扩展 +pub async fn update_extension_service( + svc_ctx: &SvcCtx, + user_uuid: Uuid, + extension_id: &str, +) -> Result<(), String> { + // 获取最新扩展信息 + let extension = models::extensions::fetch_extension_by_id(&svc_ctx.db, extension_id) + .await + .map_err(|e| e.to_string())? + .ok_or_else(|| "扩展不存在".to_string())?; + + // 更新用户扩展版本 + models::extensions::insert_user_extension( + &svc_ctx.db, + user_uuid, + extension_id, + &extension.version, + ) + .await + .map_err(|e| e.to_string())?; + + Ok(()) +} + +/// 批量更新扩展 +pub async fn batch_update_extensions_service( + svc_ctx: &SvcCtx, + user_uuid: Uuid, + extension_ids: &[String], +) -> Result { + let mut updated = 0u64; + + for extension_id in extension_ids { + if update_extension_service(svc_ctx, user_uuid, extension_id).await.is_ok() { + updated += 1; + } + } + + Ok(updated) +} + +/// 获取环境的扩展列表(动态合并 4 个层级) +/// +/// 合并逻辑: +/// 1. 用户个人全局插件 +/// 2. 团队全局插件 +/// 3. 该环境所属分组的个人插件 +/// 4. 该环境所属分组的团队插件 +pub async fn get_environment_extensions_service( + svc_ctx: &SvcCtx, + user_uuid: Uuid, + team_uuid: Uuid, + group_uuid: Option, +) -> Result, String> { + use std::collections::HashMap; + + let public_base_url = svc_ctx.config.storage.public_base_url.as_str(); + let extension_root = svc_ctx.config.storage.extension_root.as_str(); + + // 使用 HashMap 去重,key 为 extension_id + let mut extension_map: HashMap = HashMap::new(); + + // 0. 查询用户禁用的插件列表 + let disabled_extensions: std::collections::HashSet = sqlx::query_scalar::<_, String>( + r#" + SELECT extension_id FROM user_extensions + WHERE user_uuid = $1 AND status = 'disabled' + "#, + ) + .bind(user_uuid) + .fetch_all(&svc_ctx.db) + .await + .map_err(|e| e.to_string())? + .into_iter() + .collect(); + + // 1. 查询用户全局插件(排除 disabled 状态) + let user_extensions = models::extensions::fetch_user_extensions(&svc_ctx.db, user_uuid) + .await + .map_err(|e| e.to_string())?; + + for ue in user_extensions { + if let Ok(Some(ext)) = models::extensions::fetch_extension_by_id(&svc_ctx.db, &ue.extension_id).await { + let mut icon_url = ext.icon_url.clone(); + if let Some(path) = &icon_url { + if !path.is_empty() && !path.starts_with("http") { + icon_url = Some(crate::utils::storage::get_objects::get_extension_icon_url( + public_base_url, + extension_root, + path, + )); + } + } + + let mut download_url = ext.download_url.clone(); + if let Some(path) = &download_url { + if !path.is_empty() && !path.starts_with("http") { + download_url = Some(crate::utils::storage::get_objects::get_extension_crx_url( + public_base_url, + extension_root, + path, + )); + } + } + + extension_map.insert( + ue.extension_id.clone(), + crate::dto::environments::ExtensionSummaryDto { + extension_id: ue.extension_id, + name: ext.name, + version: ext.version, + icon_url, + download_url, + hash: ext.hash, + scope: "user".to_string(), + }, + ); + } + } + + // 2. 查询团队全局插件 + let team_extensions = models::extensions::fetch_team_extensions(&svc_ctx.db, team_uuid) + .await + .map_err(|e| e.to_string())?; + + for te in team_extensions { + // 跳过用户禁用的插件 + if disabled_extensions.contains(&te.extension_id) { + continue; + } + + if let Ok(Some(ext)) = models::extensions::fetch_extension_by_id(&svc_ctx.db, &te.extension_id).await { + let mut icon_url = ext.icon_url.clone(); + if let Some(path) = &icon_url { + if !path.is_empty() && !path.starts_with("http") { + icon_url = Some(crate::utils::storage::get_objects::get_extension_icon_url( + public_base_url, + extension_root, + path, + )); + } + } + + let mut download_url = ext.download_url.clone(); + if let Some(path) = &download_url { + if !path.is_empty() && !path.starts_with("http") { + download_url = Some(crate::utils::storage::get_objects::get_extension_crx_url( + public_base_url, + extension_root, + path, + )); + } + } + + extension_map.insert( + te.extension_id.clone(), + crate::dto::environments::ExtensionSummaryDto { + extension_id: te.extension_id, + name: ext.name, + version: ext.version, + icon_url, + download_url, + hash: ext.hash, + scope: "team".to_string(), + }, + ); + } + } + + // 3. 如果环境有分组,查询分组插件 + if let Some(group_uuid) = group_uuid { + let group_extensions = models::extensions::fetch_group_extensions(&svc_ctx.db, group_uuid) + .await + .map_err(|e| e.to_string())?; + + for ge in group_extensions { + // 跳过用户禁用的插件 + if disabled_extensions.contains(&ge.extension_id) { + continue; + } + + if let Ok(Some(ext)) = models::extensions::fetch_extension_by_id(&svc_ctx.db, &ge.extension_id).await { + let mut icon_url = ext.icon_url.clone(); + if let Some(path) = &icon_url { + if !path.is_empty() && !path.starts_with("http") { + icon_url = Some(crate::utils::storage::get_objects::get_extension_icon_url( + public_base_url, + extension_root, + path, + )); + } + } + + let mut download_url = ext.download_url.clone(); + if let Some(path) = &download_url { + if !path.is_empty() && !path.starts_with("http") { + download_url = Some(crate::utils::storage::get_objects::get_extension_crx_url( + public_base_url, + extension_root, + path, + )); + } + } + + let scope = if ge.is_team_shared { + "group-team".to_string() + } else { + "group-personal".to_string() + }; + + extension_map.insert( + ge.extension_id.clone(), + crate::dto::environments::ExtensionSummaryDto { + extension_id: ge.extension_id, + name: ext.name, + version: ext.version, + icon_url, + download_url, + hash: ext.hash, + scope, + }, + ); + } + } + } + + Ok(extension_map.into_values().collect()) +} + +/// 禁用扩展(用户级别) +/// +/// 用户可以禁用团队插件,通过在 user_team_extension_preferences 中设置 is_disabled = true +pub async fn disable_extension_service( + svc_ctx: &SvcCtx, + user_uuid: Uuid, + team_uuid: Uuid, + extension_id: &str, +) -> Result<(), String> { + // 检查扩展是否存在 + let _extension = models::extensions::fetch_extension_by_id(&svc_ctx.db, extension_id) + .await + .map_err(|e| e.to_string())? + .ok_or_else(|| "扩展不存在".to_string())?; + + // 设置用户对团队插件的禁用状态 + models::extensions::set_user_team_extension_preference( + &svc_ctx.db, + user_uuid, + team_uuid, + extension_id, + true, + ) + .await + .map_err(|e| e.to_string())?; + + Ok(()) +} + +/// 启用扩展(用户级别) +/// +/// 删除用户对团队插件的禁用设置 +pub async fn enable_extension_service( + svc_ctx: &SvcCtx, + user_uuid: Uuid, + team_uuid: Uuid, + extension_id: &str, +) -> Result<(), String> { + // 删除用户对团队插件的偏好设置(或设置为 false) + models::extensions::delete_user_team_extension_preference( + &svc_ctx.db, + user_uuid, + team_uuid, + extension_id, + ) + .await + .map_err(|e| e.to_string())?; + + Ok(()) +} diff --git a/src-tauri/crates/business/src/services/group_permissions.rs b/src-tauri/crates/business/src/services/group_permissions.rs new file mode 100644 index 0000000..697af9a --- /dev/null +++ b/src-tauri/crates/business/src/services/group_permissions.rs @@ -0,0 +1,139 @@ +use uuid::Uuid; + +use crate::dto::GroupMemberPermissionDto; +use crate::entitys::{ + CheckGroupPermissionRequest, GrantGroupPermissionRequest, ListUserGroupPermissionsRequest, + RevokeGroupPermissionRequest, +}; +use crate::models; +use crate::svc_ctx::SvcCtx; + +/// 授予分组权限 +pub async fn grant_group_permission_service( + svc_ctx: &SvcCtx, + user_uuid: Uuid, + payload: &GrantGroupPermissionRequest, +) -> Result<(), String> { + // 检查权限:只有团队 Owner/Admin 或拥有分组 manage 权限的用户可以授权 + let group = models::fetch_group_by_uuid(&svc_ctx.db, payload.group_uuid) + .await + .map_err(|e| e.to_string())? + .ok_or_else(|| "分组不存在".to_string())?; + + // 检查用户是否是团队成员(工作空间级别) + let team_member = models::fetch_team_member( + &svc_ctx.db, + group.workspace_uuid, + group.team_uuid, + user_uuid, + ) + .await + .map_err(|e| e.to_string())? + .ok_or_else(|| "您不是该团队的成员".to_string())?; + + // Owner/Admin 自动拥有所有权限 + let can_grant = team_member.role == "owner" + || team_member.role == "admin" + || models::check_group_permission( + &svc_ctx.db, + group.workspace_uuid, + payload.group_uuid, + user_uuid, + "manage", + ) + .await + .map_err(|e| e.to_string())?; + + if !can_grant { + return Err("您没有权限授予分组权限".to_string()); + } + + models::grant_group_permission( + &svc_ctx.db, + payload.group_uuid, + group.workspace_uuid, + group.team_uuid, + payload.user_uuid, + &payload.permission_type, + user_uuid, + ) + .await + .map_err(|e| e.to_string()) +} + +/// 撤销分组权限 +pub async fn revoke_group_permission_service( + svc_ctx: &SvcCtx, + user_uuid: Uuid, + payload: &RevokeGroupPermissionRequest, +) -> Result<(), String> { + // 检查权限:只有团队 Owner/Admin 或拥有分组 manage 权限的用户可以撤销权限 + let group = models::fetch_group_by_uuid(&svc_ctx.db, payload.group_uuid) + .await + .map_err(|e| e.to_string())? + .ok_or_else(|| "分组不存在".to_string())?; + + // 检查用户是否是团队成员(工作空间级别) + let team_member = models::fetch_team_member( + &svc_ctx.db, + group.workspace_uuid, + group.team_uuid, + user_uuid, + ) + .await + .map_err(|e| e.to_string())? + .ok_or_else(|| "您不是该团队的成员".to_string())?; + + // Owner/Admin 自动拥有所有权限 + let can_revoke = team_member.role == "owner" + || team_member.role == "admin" + || models::check_group_permission( + &svc_ctx.db, + group.workspace_uuid, + payload.group_uuid, + user_uuid, + "manage", + ) + .await + .map_err(|e| e.to_string())?; + + if !can_revoke { + return Err("您没有权限撤销分组权限".to_string()); + } + + models::revoke_group_permission(&svc_ctx.db, payload.group_uuid, payload.user_uuid) + .await + .map_err(|e| e.to_string()) +} + +/// 检查分组权限 +pub async fn check_group_permission_service( + svc_ctx: &SvcCtx, + workspace_uuid: Uuid, + payload: &CheckGroupPermissionRequest, +) -> Result { + models::check_group_permission( + &svc_ctx.db, + workspace_uuid, + payload.group_uuid, + payload.user_uuid, + &payload.permission_type, + ) + .await + .map_err(|e| e.to_string()) +} + +/// 查询用户的分组权限列表 +pub async fn list_user_group_permissions_service( + svc_ctx: &SvcCtx, + payload: &ListUserGroupPermissionsRequest, +) -> Result, String> { + models::fetch_user_group_permissions( + &svc_ctx.db, + payload.user_uuid, + None, // workspace_uuid 可以从 payload 中获取,如果需要的话 + payload.group_uuid, + ) + .await + .map_err(|e| e.to_string()) +} diff --git a/src-tauri/crates/business/src/services/groups.rs b/src-tauri/crates/business/src/services/groups.rs new file mode 100644 index 0000000..fefbf3d --- /dev/null +++ b/src-tauri/crates/business/src/services/groups.rs @@ -0,0 +1,158 @@ +use uuid::Uuid; + +use crate::dto::GroupDto; +use crate::entitys::{CreateGroupRequest, UpdateGroupRequest}; +use crate::models; +use crate::svc_ctx::SvcCtx; + +/// 创建分组 +pub async fn create_group_service( + svc_ctx: &SvcCtx, + user_uuid: Uuid, + workspace_uuid: Uuid, + team_uuid: Uuid, + payload: &CreateGroupRequest, +) -> Result { + // 1. 检查用户是否在当前工作空间的团队中 + let team_member = models::fetch_team_member(&svc_ctx.db, workspace_uuid, team_uuid, user_uuid) + .await + .map_err(|e| e.to_string())? + .ok_or_else(|| "您不是该团队的成员".to_string())?; + + // 2. 检查用户是否是团队 Owner/Admin(只有 Owner/Admin 可以创建分组) + let can_create = matches!(team_member.role.as_str(), "owner" | "admin"); + if !can_create { + return Err("您没有创建分组的权限,需要 Owner 或 Admin 角色".to_string()); + } + + models::insert_group( + &svc_ctx.db, + workspace_uuid, + team_uuid, + &payload.name, + payload.description.as_deref(), + user_uuid, + ) + .await + .map_err(|e| e.to_string()) +} + +/// 获取分组列表 +pub async fn get_groups_service( + svc_ctx: &SvcCtx, + workspace_uuid: Uuid, + team_uuid: Uuid, + page: i64, + page_size: i64, +) -> Result, String> { + let offset = (page - 1) * page_size; + models::fetch_groups(&svc_ctx.db, workspace_uuid, team_uuid, offset, page_size) + .await + .map_err(|e| e.to_string()) +} + +/// 获取分组详情 +pub async fn get_group_service(svc_ctx: &SvcCtx, group_uuid: Uuid) -> Result { + models::fetch_group_by_uuid(&svc_ctx.db, group_uuid) + .await + .map_err(|e| e.to_string())? + .ok_or_else(|| "分组不存在".to_string()) +} + +/// 更新分组 +pub async fn update_group_service( + svc_ctx: &SvcCtx, + workspace_uuid: Uuid, + team_uuid: Uuid, + user_uuid: Uuid, + payload: &UpdateGroupRequest, +) -> Result<(), String> { + // 1. 检查用户是否在当前工作空间的团队中 + let team_member = models::fetch_team_member(&svc_ctx.db, workspace_uuid, team_uuid, user_uuid) + .await + .map_err(|e| e.to_string())? + .ok_or_else(|| "您不是该团队的成员".to_string())?; + + // 2. 查询分组 + let group = models::fetch_group_by_uuid(&svc_ctx.db, payload.uuid) + .await + .map_err(|e| e.to_string())? + .ok_or_else(|| "分组不存在".to_string())?; + + // 3. 验证分组属于指定工作空间和团队 + if group.workspace_uuid != workspace_uuid || group.team_uuid != team_uuid { + return Err("分组不属于指定工作空间或团队".to_string()); + } + + // 4. 检查用户是否是团队 Owner/Admin 或拥有分组的 manage 权限 + let is_owner_or_admin = matches!(team_member.role.as_str(), "owner" | "admin"); + let has_manage = if !is_owner_or_admin { + models::check_group_permission( + &svc_ctx.db, + workspace_uuid, + payload.uuid, + user_uuid, + "manage", + ) + .await + .map_err(|e| e.to_string())? + } else { + true + }; + + if !has_manage { + return Err("您没有管理该分组的权限".to_string()); + } + + models::update_group( + &svc_ctx.db, + payload.uuid, + payload.name.as_deref(), + payload.description.as_deref(), + payload.sort_order, + ) + .await + .map_err(|e| e.to_string()) +} + +/// 删除分组 +pub async fn delete_group_service( + svc_ctx: &SvcCtx, + workspace_uuid: Uuid, + team_uuid: Uuid, + user_uuid: Uuid, + group_uuid: Uuid, +) -> Result<(), String> { + // 1. 检查用户是否在当前工作空间的团队中 + let team_member = models::fetch_team_member(&svc_ctx.db, workspace_uuid, team_uuid, user_uuid) + .await + .map_err(|e| e.to_string())? + .ok_or_else(|| "您不是该团队的成员".to_string())?; + + // 2. 查询分组 + let group = models::fetch_group_by_uuid(&svc_ctx.db, group_uuid) + .await + .map_err(|e| e.to_string())? + .ok_or_else(|| "分组不存在".to_string())?; + + // 3. 验证分组属于指定工作空间和团队 + if group.workspace_uuid != workspace_uuid || group.team_uuid != team_uuid { + return Err("分组不属于指定工作空间或团队".to_string()); + } + + // 4. 检查用户是否是团队 Owner/Admin 或拥有分组的 manage 权限 + let is_owner_or_admin = matches!(team_member.role.as_str(), "owner" | "admin"); + let has_manage = if !is_owner_or_admin { + models::check_group_permission(&svc_ctx.db, workspace_uuid, group_uuid, user_uuid, "manage") + .await + .map_err(|e| e.to_string())? + } else { + true + }; + + if !has_manage { + return Err("您没有删除该分组的权限".to_string()); + } + + models::delete_group(&svc_ctx.db, group_uuid).await.map_err(|e| e.to_string()) +} diff --git a/src-tauri/crates/business/src/services/local_api.rs b/src-tauri/crates/business/src/services/local_api.rs new file mode 100644 index 0000000..a16023a --- /dev/null +++ b/src-tauri/crates/business/src/services/local_api.rs @@ -0,0 +1,459 @@ +use chrono::Utc; +use uuid::Uuid; + +use crate::caches::{ + LocalApiKeyCache, LocalApiPermissionCache, delete_local_api_key_cache, + delete_local_api_permission_caches_for_key, + get_local_api_key_cache, get_local_api_permission_cache, get_local_api_permission_definition_cache, + get_local_api_rate_count, increment_local_api_rate_count, set_local_api_key_cache, + set_local_api_permission_cache, set_local_api_permission_definition_cache, +}; +use crate::dto::{LocalApiConfigDto, ResetLocalApiKeyDto, ValidateLocalApiKeyDto}; +use crate::entitys::{UpdateLocalApiConfigRequest, ValidateLocalApiKeyRequest}; +use crate::models; +use crate::svc_ctx::SvcCtx; + +const DEFAULT_DAILY_LIMIT: i32 = 1000; + +pub async fn get_local_api_config_service( + svc_ctx: &SvcCtx, + user_uuid: Uuid, +) -> Result { + init_local_api_for_user_service(svc_ctx, user_uuid).await?; + + let settings = models::local_api::fetch_local_api_settings(&svc_ctx.db, user_uuid) + .await + .map_err(|e| e.to_string())? + .ok_or_else(|| "API 服务配置不存在".to_string())?; + + let mut api_key = models::local_api::fetch_active_api_key(&svc_ctx.db, user_uuid) + .await + .map_err(|e| e.to_string())? + .ok_or_else(|| "API 密钥不存在".to_string())?; + + if api_key.api_key.is_none() { + rotate_local_api_key_for_user(svc_ctx, user_uuid).await?; + api_key = models::local_api::fetch_active_api_key(&svc_ctx.db, user_uuid) + .await + .map_err(|e| e.to_string())? + .ok_or_else(|| "API 密钥不存在".to_string())?; + } + + Ok(LocalApiConfigDto { + enabled: settings.enabled, + api_key: api_key + .api_key + .clone() + .ok_or_else(|| "API 密钥不存在".to_string())?, + port: settings.port, + remote_access: settings.remote_access, + cors_origins: models::local_api::parse_cors_origins(&settings.cors_origins), + requests_today: get_local_api_rate_count( + svc_ctx, + api_key.id, + None, + "day", + &Utc::now().format("%Y%m%d").to_string(), + ) + .await + .ok() + .map(|count| count as i32) + .unwrap_or(api_key.requests_today), + daily_limit: api_key.daily_limit, + }) +} + +pub async fn update_local_api_config_service( + svc_ctx: &SvcCtx, + user_uuid: Uuid, + payload: &UpdateLocalApiConfigRequest, +) -> Result { + if let Some(port) = payload.port { + if !(1..=65535).contains(&port) { + return Err("端口范围无效".to_string()); + } + } + + init_local_api_for_user_service(svc_ctx, user_uuid).await?; + + let cors_origins = payload + .cors_origins + .as_ref() + .map(|origins| models::local_api::build_cors_origins_value(origins)); + + models::local_api::upsert_local_api_settings( + &svc_ctx.db, + user_uuid, + payload.enabled, + payload.port, + payload.remote_access, + cors_origins.as_ref(), + ) + .await + .map_err(|e| e.to_string())?; + + get_local_api_config_service(svc_ctx, user_uuid).await +} + +pub async fn reset_local_api_key_service( + svc_ctx: &SvcCtx, + user_uuid: Uuid, +) -> Result { + init_local_api_for_user_service(svc_ctx, user_uuid).await?; + + if let Some(current_key) = models::local_api::fetch_active_api_key(&svc_ctx.db, user_uuid) + .await + .map_err(|e| e.to_string())? + { + delete_local_api_key_cache(svc_ctx, ¤t_key.key_hash) + .await + .map_err(|e| e.to_string())?; + delete_local_api_permission_caches_for_key(svc_ctx, current_key.id) + .await + .map_err(|e| e.to_string())?; + } + + models::local_api::deactivate_api_keys_for_user(&svc_ctx.db, user_uuid) + .await + .map_err(|e| e.to_string())?; + + let api_key = generate_api_key(); + let key_hash = models::local_api::hash_api_key(&api_key); + let key_prefix = api_key.chars().take(16).collect::(); + let created_key = models::local_api::insert_api_key( + &svc_ctx.db, + user_uuid, + &key_prefix, + &key_hash, + &api_key, + DEFAULT_DAILY_LIMIT, + ) + .await + .map_err(|e| e.to_string())?; + set_local_api_key_cache( + svc_ctx, + &key_hash, + &LocalApiKeyCache { + id: created_key.id, + user_uuid: created_key.user_uuid, + is_active: created_key.is_active, + expires_at: created_key.expires_at, + daily_limit: created_key.daily_limit, + }, + ) + .await + .map_err(|e| e.to_string())?; + + ensure_default_permissions(svc_ctx, created_key.id).await?; + + Ok(ResetLocalApiKeyDto { + api_key, + }) +} + +pub async fn validate_local_api_key_service( + svc_ctx: &SvcCtx, + payload: &ValidateLocalApiKeyRequest, +) -> Result { + let key_hash = models::local_api::hash_api_key(payload.api_key.as_str()); + let api_key = if let Some(cache) = get_local_api_key_cache(svc_ctx, &key_hash) + .await + .map_err(|e| e.to_string())? + { + cache + } else { + let db_api_key = models::local_api::fetch_api_key_by_hash(&svc_ctx.db, &key_hash) + .await + .map_err(|e| e.to_string())? + .ok_or_else(|| "API 密钥无效".to_string())?; + let cache = LocalApiKeyCache { + id: db_api_key.id, + user_uuid: db_api_key.user_uuid, + is_active: db_api_key.is_active, + expires_at: db_api_key.expires_at, + daily_limit: db_api_key.daily_limit, + }; + set_local_api_key_cache(svc_ctx, &key_hash, &cache) + .await + .map_err(|e| e.to_string())?; + cache + }; + + if !api_key.is_active { + return Err("API 密钥已停用".to_string()); + } + + if let Some(expires_at) = api_key.expires_at { + if expires_at < Utc::now() { + return Err("API 密钥已过期".to_string()); + } + } + + let day_key = Utc::now().format("%Y%m%d").to_string(); + let day_count = get_local_api_rate_count(svc_ctx, api_key.id, None, "day", &day_key) + .await + .map_err(|e| e.to_string())?; + if day_count >= i64::from(api_key.daily_limit) { + return Err("API 密钥已达到今日调用上限".to_string()); + } + + let definition = if let Some(cache) = + get_local_api_permission_definition_cache(svc_ctx, &payload.permission_code) + .await + .map_err(|e| e.to_string())? + { + cache + } else { + let definition = models::local_api::fetch_permission_definition( + &svc_ctx.db, + &payload.permission_code, + ) + .await + .map_err(|e| e.to_string())? + .ok_or_else(|| "未知的 permissionCode".to_string())?; + set_local_api_permission_definition_cache(svc_ctx, &payload.permission_code, &definition) + .await + .map_err(|e| e.to_string())?; + definition + }; + + let permission = if let Some(cache) = + get_local_api_permission_cache(svc_ctx, api_key.id, &payload.permission_code) + .await + .map_err(|e| e.to_string())? + { + cache + } else { + let db_permission = fetch_effective_permission(svc_ctx, api_key.id, &payload.permission_code) + .await? + .ok_or_else(|| "当前密钥无权访问该接口".to_string())?; + let cache = LocalApiPermissionCache { + is_enabled: db_permission.is_enabled, + rate_limit_per_minute: db_permission.rate_limit_per_minute, + rate_limit_per_hour: db_permission.rate_limit_per_hour, + }; + set_local_api_permission_cache(svc_ctx, api_key.id, &payload.permission_code, &cache) + .await + .map_err(|e| e.to_string())?; + cache + }; + + if !permission.is_enabled { + return Err("当前密钥已被禁止访问该接口".to_string()); + } + + let now = Utc::now(); + let minute_key = now.format("%Y%m%d%H%M").to_string(); + let hour_key = now.format("%Y%m%d%H").to_string(); + + let minute_count = get_local_api_rate_count( + svc_ctx, + api_key.id, + Some(payload.permission_code.as_str()), + "minute", + &minute_key, + ) + .await + .map_err(|e| e.to_string())?; + if minute_count >= i64::from(permission.rate_limit_per_minute) { + return Err("接口每分钟调用次数已达上限".to_string()); + } + + let hour_count = get_local_api_rate_count( + svc_ctx, + api_key.id, + Some(payload.permission_code.as_str()), + "hour", + &hour_key, + ) + .await + .map_err(|e| e.to_string())?; + if hour_count >= i64::from(permission.rate_limit_per_hour) { + return Err("接口每小时调用次数已达上限".to_string()); + } + + increment_local_api_rate_count( + svc_ctx, + api_key.id, + Some(payload.permission_code.as_str()), + "minute", + &minute_key, + ) + .await + .map_err(|e| e.to_string())?; + increment_local_api_rate_count( + svc_ctx, + api_key.id, + Some(payload.permission_code.as_str()), + "hour", + &hour_key, + ) + .await + .map_err(|e| e.to_string())?; + increment_local_api_rate_count(svc_ctx, api_key.id, None, "day", &day_key) + .await + .map_err(|e| e.to_string())?; + + Ok(ValidateLocalApiKeyDto { + valid: true, + permission_code: definition.permission_code, + requests_today: (day_count + 1) as i32, + daily_limit: api_key.daily_limit, + rate_limit_per_minute: permission.rate_limit_per_minute, + rate_limit_per_hour: permission.rate_limit_per_hour, + }) +} + +pub async fn init_local_api_for_user_service( + svc_ctx: &SvcCtx, + user_uuid: Uuid, +) -> Result<(), String> { + models::local_api::upsert_local_api_settings( + &svc_ctx.db, + user_uuid, + None, + None, + None, + None, + ) + .await + .map_err(|e| e.to_string())?; + + let current_key = models::local_api::fetch_active_api_key(&svc_ctx.db, user_uuid) + .await + .map_err(|e| e.to_string())?; + + if let Some(api_key) = current_key { + if api_key.api_key.is_none() { + rotate_local_api_key_for_user(svc_ctx, user_uuid).await?; + return Ok(()); + } + ensure_default_permissions(svc_ctx, api_key.id).await?; + return Ok(()); + } + + let api_key = generate_api_key(); + let key_hash = models::local_api::hash_api_key(&api_key); + let key_prefix = api_key.chars().take(16).collect::(); + + let created_key = models::local_api::insert_api_key( + &svc_ctx.db, + user_uuid, + &key_prefix, + &key_hash, + &api_key, + DEFAULT_DAILY_LIMIT, + ) + .await + .map_err(|e| e.to_string())?; + set_local_api_key_cache( + svc_ctx, + &key_hash, + &LocalApiKeyCache { + id: created_key.id, + user_uuid: created_key.user_uuid, + is_active: created_key.is_active, + expires_at: created_key.expires_at, + daily_limit: created_key.daily_limit, + }, + ) + .await + .map_err(|e| e.to_string())?; + + ensure_default_permissions(svc_ctx, created_key.id).await +} + +async fn ensure_default_permissions(svc_ctx: &SvcCtx, api_key_id: i32) -> Result<(), String> { + let definitions = models::local_api::fetch_permission_definitions(&svc_ctx.db) + .await + .map_err(|e| e.to_string())?; + + for definition in definitions { + models::local_api::insert_api_key_permission( + &svc_ctx.db, + api_key_id, + definition.permission_code.as_str(), + definition.default_rate_limit_per_minute, + definition.default_rate_limit_per_hour, + ) + .await + .map_err(|e| e.to_string())?; + set_local_api_permission_definition_cache( + svc_ctx, + definition.permission_code.as_str(), + &definition, + ) + .await + .map_err(|e| e.to_string())?; + set_local_api_permission_cache( + svc_ctx, + api_key_id, + definition.permission_code.as_str(), + &LocalApiPermissionCache { + is_enabled: true, + rate_limit_per_minute: definition.default_rate_limit_per_minute, + rate_limit_per_hour: definition.default_rate_limit_per_hour, + }, + ) + .await + .map_err(|e| e.to_string())?; + } + + Ok(()) +} + +async fn fetch_effective_permission( + svc_ctx: &SvcCtx, + api_key_id: i32, + permission_code: &str, +) -> Result, String> { + models::local_api::fetch_api_key_permission(&svc_ctx.db, api_key_id, permission_code) + .await + .map_err(|e| e.to_string()) +} + +fn generate_api_key() -> String { + let raw = Uuid::new_v4().simple().to_string(); + format!("sk_local_{}", raw) +} + +async fn rotate_local_api_key_for_user( + svc_ctx: &SvcCtx, + user_uuid: Uuid, +) -> Result { + models::local_api::deactivate_api_keys_for_user(&svc_ctx.db, user_uuid) + .await + .map_err(|e| e.to_string())?; + + let api_key = generate_api_key(); + let key_hash = models::local_api::hash_api_key(&api_key); + let key_prefix = api_key.chars().take(16).collect::(); + + let created_key = models::local_api::insert_api_key( + &svc_ctx.db, + user_uuid, + &key_prefix, + &key_hash, + &api_key, + DEFAULT_DAILY_LIMIT, + ) + .await + .map_err(|e| e.to_string())?; + set_local_api_key_cache( + svc_ctx, + &key_hash, + &LocalApiKeyCache { + id: created_key.id, + user_uuid: created_key.user_uuid, + is_active: created_key.is_active, + expires_at: created_key.expires_at, + daily_limit: created_key.daily_limit, + }, + ) + .await + .map_err(|e| e.to_string())?; + + ensure_default_permissions(svc_ctx, created_key.id).await?; + + Ok(ResetLocalApiKeyDto { api_key }) +} diff --git a/src-tauri/crates/business/src/services/maintenance.rs b/src-tauri/crates/business/src/services/maintenance.rs new file mode 100644 index 0000000..0721d3e --- /dev/null +++ b/src-tauri/crates/business/src/services/maintenance.rs @@ -0,0 +1,78 @@ +use crate::{ + dto::maintenance::Maintenance, entitys::maintenance::CreateMaintenanceRequest, + errors::SimprintError, svc_ctx::SvcCtx, +}; + +/// 创建维护 +pub async fn create_maintenance_service( + svc_ctx: &SvcCtx, + request: CreateMaintenanceRequest, +) -> Result { + let maintenance = crate::models::maintenance::create_maintenance(&svc_ctx.db, request) + .await + .map_err(|e| SimprintError::InvalidRequest(format!("创建维护失败: {}", e)))?; + + Ok(maintenance.id) +} + +/// 根据ID查询维护 +pub async fn get_maintenance_by_id_service( + svc_ctx: &SvcCtx, + id: i64, +) -> Result, SimprintError> { + let maintenance = crate::models::maintenance::get_maintenance_by_id(&svc_ctx.db, id) + .await + .map_err(|e| SimprintError::InvalidRequest(format!("查询维护失败: {}", e)))?; + + Ok(maintenance) +} + +/// 查询维护列表 +pub async fn list_maintenances_service( + svc_ctx: &SvcCtx, + limit: Option, + offset: Option, +) -> Result, SimprintError> { + let maintenances = crate::models::maintenance::list_maintenances( + &svc_ctx.db, + limit.map(|l| l as i64), + offset.map(|o| o as i64), + ) + .await + .map_err(|e| SimprintError::InvalidRequest(format!("查询维护列表失败: {}", e)))?; + + Ok(maintenances) +} + +/// 更新维护状态 +pub async fn update_maintenance_status_service( + svc_ctx: &SvcCtx, + id: i64, + status: String, +) -> Result { + let success = crate::models::maintenance::update_maintenance_status(&svc_ctx.db, id, &status) + .await + .map_err(|e| SimprintError::InvalidRequest(format!("更新维护状态失败: {}", e)))?; + + Ok(success) +} + +/// 结束维护 +pub async fn end_maintenance_service(svc_ctx: &SvcCtx) -> Result { + let success = crate::models::maintenance::end_maintenance(&svc_ctx.db) + .await + .map_err(|e| SimprintError::InvalidRequest(format!("结束维护失败: {}", e)))?; + + Ok(success) +} + +/// 获取当前活跃维护 +pub async fn get_active_maintenance_service( + svc_ctx: &SvcCtx, +) -> Result, SimprintError> { + let maintenance = crate::models::maintenance::get_active_maintenances(&svc_ctx.db) + .await + .map_err(|e| SimprintError::InvalidRequest(format!("查询活跃维护失败: {}", e)))?; + + Ok(maintenance) +} diff --git a/src-tauri/crates/business/src/services/messages.rs b/src-tauri/crates/business/src/services/messages.rs new file mode 100644 index 0000000..a5cf2f5 --- /dev/null +++ b/src-tauri/crates/business/src/services/messages.rs @@ -0,0 +1,187 @@ +use uuid::Uuid; + +// DTOs are used through entitys +use crate::entitys::{ + BatchMarkReadRequest, CreateMessageRequest, HandleMessageRequest, ListMessagesRequest, + MarkMessageReadRequest, MessageListResponse, MessageStatsResponse, +}; +use crate::models; +use crate::svc_ctx::SvcCtx; + +/// 创建消息 +pub async fn create_message_service( + svc_ctx: &SvcCtx, + sender_uuid: Option, + payload: &CreateMessageRequest, +) -> Result { + let priority = payload.priority.as_deref().unwrap_or("normal"); + + // 创建消息 + let message_uuid = models::create_message( + &svc_ctx.db, + sender_uuid, + &payload.message_type, + &payload.title, + payload.content.as_deref(), + &payload.recipient_type, + payload.related_type.as_deref(), + payload.related_uuid, + priority, + payload.metadata.clone(), + ) + .await + .map_err(|e| e.to_string())?; + + // 根据接收者类型添加接收者 + match payload.recipient_type.as_str() { + "single" | "multiple" => { + if payload.recipient_uuids.is_empty() { + return Err("接收者列表不能为空".to_string()); + } + + // 对于邀请类消息,设置 action_status 为 pending + let action_status = if payload.message_type == "team_invitation" { + Some("pending") + } else { + None + }; + + models::add_message_recipients( + &svc_ctx.db, + message_uuid, + &payload.recipient_uuids, + action_status, + ) + .await + .map_err(|e| e.to_string())?; + } + "team" => { + // 团队消息由数据库触发器自动分发,无需手动添加 + if payload.related_type.as_deref() != Some("team") || payload.related_uuid.is_none() { + return Err("团队消息必须指定 related_type='team' 和 related_uuid".to_string()); + } + } + "all" => { + // 系统广播消息,需要为所有用户创建关联记录 + // 这里可以通过应用层逻辑实现,或者使用数据库触发器 + // 暂时返回错误,提示需要特殊处理 + return Err("系统广播消息暂不支持".to_string()); + } + _ => { + return Err(format!("不支持的接收者类型: {}", payload.recipient_type)); + } + } + + Ok(message_uuid) +} + +/// 获取用户消息列表 +pub async fn get_user_messages_service( + svc_ctx: &SvcCtx, + user_uuid: Uuid, + payload: &ListMessagesRequest, +) -> Result { + let offset = (payload.pagination.page - 1) * payload.pagination.page_size; + + let filters = payload.filters.as_ref(); + + let messages = models::fetch_user_messages( + &svc_ctx.db, + user_uuid, + offset, + payload.pagination.page_size, + filters.and_then(|f| f.message_type.as_deref()), + filters.and_then(|f| f.is_read), + filters.and_then(|f| f.action_status.as_deref()), + filters.and_then(|f| f.priority.as_deref()), + ) + .await + .map_err(|e| e.to_string())?; + + let total = models::fetch_user_messages_count( + &svc_ctx.db, + user_uuid, + filters.and_then(|f| f.message_type.as_deref()), + filters.and_then(|f| f.is_read), + filters.and_then(|f| f.action_status.as_deref()), + filters.and_then(|f| f.priority.as_deref()), + ) + .await + .map_err(|e| e.to_string())?; + + Ok(MessageListResponse { + items: messages, + total, + page: payload.pagination.page, + page_size: payload.pagination.page_size, + }) +} + +/// 标记消息为已读 +pub async fn mark_message_read_service( + svc_ctx: &SvcCtx, + user_uuid: Uuid, + payload: &MarkMessageReadRequest, +) -> Result<(), String> { + models::mark_message_read(&svc_ctx.db, payload.message_uuid, user_uuid) + .await + .map_err(|e| e.to_string()) +} + +/// 批量标记消息为已读 +pub async fn batch_mark_messages_read_service( + svc_ctx: &SvcCtx, + user_uuid: Uuid, + payload: &BatchMarkReadRequest, +) -> Result<(), String> { + if payload.message_uuids.is_empty() { + return Err("消息列表不能为空".to_string()); + } + + models::batch_mark_messages_read(&svc_ctx.db, &payload.message_uuids, user_uuid) + .await + .map_err(|e| e.to_string()) +} + +/// 处理消息(接受/拒绝) +pub async fn handle_message_service( + svc_ctx: &SvcCtx, + user_uuid: Uuid, + payload: &HandleMessageRequest, +) -> Result<(), String> { + if payload.action != "accept" && payload.action != "reject" { + return Err("操作类型必须是 'accept' 或 'reject'".to_string()); + } + + models::handle_message( + &svc_ctx.db, + payload.message_uuid, + user_uuid, + &payload.action, + ) + .await + .map_err(|e| e.to_string()) +} + +/// 获取用户消息统计 +pub async fn get_user_message_stats_service( + svc_ctx: &SvcCtx, + user_uuid: Uuid, +) -> Result { + let (total, unread, by_type) = models::fetch_user_message_stats(&svc_ctx.db, user_uuid) + .await + .map_err(|e| e.to_string())?; + + Ok(MessageStatsResponse { + total, + unread, + by_type, + }) +} + +/// 删除消息 +pub async fn delete_message_service(svc_ctx: &SvcCtx, message_uuid: Uuid) -> Result<(), String> { + models::delete_message(&svc_ctx.db, message_uuid) + .await + .map_err(|e| e.to_string()) +} diff --git a/src-tauri/crates/business/src/services/orders.rs b/src-tauri/crates/business/src/services/orders.rs new file mode 100644 index 0000000..49d44b4 --- /dev/null +++ b/src-tauri/crates/business/src/services/orders.rs @@ -0,0 +1,83 @@ +use uuid::Uuid; + +use crate::dto::PaymentOrderDto; +use crate::entitys::{CreateRechargeOrderRequest, ListPaymentOrdersRequest}; +use crate::models; +use crate::svc_ctx::SvcCtx; + +/// 创建充值订单 +pub async fn create_recharge_order_service( + svc_ctx: &SvcCtx, + user_uuid: Uuid, + payload: &CreateRechargeOrderRequest, +) -> Result<(Uuid, String), String> { + let order_no = format!("RCH-{}", chrono::Utc::now().format("%Y%m%d%H%M%S%f")); + + let order_uuid = models::billing::insert_payment_order( + &svc_ctx.db, + &order_no, + user_uuid, + "recharge", + payload.amount, + "CNY", + Some(&payload.payment_channel), + Some("钱包充值"), + None, + None, + None, + None, + ) + .await + .map_err(|e| e.to_string())?; + + Ok((order_uuid, order_no)) +} + +/// 获取支付订单列表 +pub async fn get_payment_orders_service( + svc_ctx: &SvcCtx, + user_uuid: Uuid, + payload: &ListPaymentOrdersRequest, +) -> Result<(Vec, i64), String> { + let offset = (payload.pagination.page - 1) * payload.pagination.page_size; + + let orders = models::billing::fetch_payment_orders( + &svc_ctx.db, + user_uuid, + payload.order_type.as_deref(), + payload.status.as_deref(), + offset, + payload.pagination.page_size, + ) + .await + .map_err(|e| e.to_string())?; + + let total = models::billing::fetch_payment_orders_count( + &svc_ctx.db, + user_uuid, + payload.order_type.as_deref(), + payload.status.as_deref(), + ) + .await + .map_err(|e| e.to_string())?; + + Ok((orders, total)) +} + +/// 查询订单状态 +pub async fn get_order_status_service( + svc_ctx: &SvcCtx, + user_uuid: Uuid, + order_uuid: Uuid, +) -> Result { + let order = models::billing::fetch_payment_order_by_uuid(&svc_ctx.db, order_uuid) + .await + .map_err(|e| e.to_string())? + .ok_or_else(|| "订单不存在".to_string())?; + + if order.user_uuid != user_uuid { + return Err("无权查看此订单".to_string()); + } + + Ok(order) +} diff --git a/src-tauri/crates/business/src/services/plans.rs b/src-tauri/crates/business/src/services/plans.rs new file mode 100644 index 0000000..0a1b5de --- /dev/null +++ b/src-tauri/crates/business/src/services/plans.rs @@ -0,0 +1,152 @@ +use rust_decimal::Decimal; +use uuid::Uuid; + +use crate::dto::{CouponDto, PlanDto, PlanFeatureDto}; +use crate::entitys::{GetPlanPriceRequest, PlanPriceResponse, VerifyCouponRequest}; +use crate::models; +use crate::services::coupons::validate_coupon_service; +use crate::svc_ctx::SvcCtx; + +/// 带特性的套餐结构 +#[derive(Debug, Clone)] +pub struct PlanWithFeatures { + pub plan: PlanDto, + pub features: Vec, + pub calculated_price: Option, +} + +/// 获取套餐列表(包含特性) +pub async fn get_plans_service( + svc_ctx: &SvcCtx, + user_uuid: Uuid, + coupon_code: Option<&str>, + billing_period: &str, +) -> Result, String> { + let plans = models::billing::fetch_plans(&svc_ctx.db).await.map_err(|e| e.to_string())?; + + let mut plans_with_features = Vec::new(); + + for plan in plans { + let features = models::billing::fetch_plan_features(&svc_ctx.db, plan.uuid) + .await + .map_err(|e| e.to_string())?; + + // 如果提供了优惠券代码,计算价格 + let calculated_price = if let Some(code) = coupon_code { + let price_result = calculate_plan_price_service( + svc_ctx, + user_uuid, + &GetPlanPriceRequest { + plan_uuid: plan.uuid, + billing_period: billing_period.to_string(), + coupon_code: Some(code.to_string()), + }, + ) + .await + .ok(); // 如果计算失败,返回 None,不影响套餐列表返回 + + price_result.map(|price| crate::entitys::PlanPriceInfo { + original_price: price.original_price, + plan_discount: price.plan_discount, + coupon_discount: price.coupon_discount, + final_price: price.final_price, + total_saved: price.total_saved, + billing_period: billing_period.to_string(), + }) + } else { + None + }; + + plans_with_features.push(PlanWithFeatures { + plan, + features, + calculated_price, + }); + } + + Ok(plans_with_features) +} + +/// 获取套餐详情(包含特性) +pub async fn get_plan_detail_service( + svc_ctx: &SvcCtx, + plan_uuid: Uuid, +) -> Result<(PlanDto, Vec), String> { + let plan = models::billing::fetch_plan_by_uuid(&svc_ctx.db, plan_uuid) + .await + .map_err(|e| e.to_string())? + .ok_or_else(|| "套餐不存在".to_string())?; + + let features = models::billing::fetch_plan_features(&svc_ctx.db, plan_uuid) + .await + .map_err(|e| e.to_string())?; + + Ok((plan, features)) +} + +/// 计算套餐最终价格 +pub async fn calculate_plan_price_service( + svc_ctx: &SvcCtx, + user_uuid: Uuid, + payload: &GetPlanPriceRequest, +) -> Result { + // 1. 获取套餐信息 + let plan = models::billing::fetch_plan_by_uuid(&svc_ctx.db, payload.plan_uuid) + .await + .map_err(|e| e.to_string())? + .ok_or_else(|| "套餐不存在".to_string())?; + + if plan.status != "active" { + return Err("该套餐已下架".to_string()); + } + + // 2. 计算基础价格(考虑套餐级折扣) + let (base_price, plan_discount_percent) = match payload.billing_period.as_str() { + "monthly" => ( + plan.price_per_month, + plan.discount_monthly.unwrap_or(Decimal::ZERO), + ), + "yearly" => ( + plan.price_per_year, + plan.discount_yearly.unwrap_or(Decimal::ZERO), + ), + _ => return Err("无效的计费周期".to_string()), + }; + + // 3. 应用套餐级折扣 + let price_after_plan_discount = + base_price * (Decimal::from(100) - plan_discount_percent) / Decimal::from(100); + let plan_discount = base_price - price_after_plan_discount; + + // 4. 验证并应用优惠券(如果有) + let mut coupon_discount = Decimal::ZERO; + let mut coupon_info: Option = None; + + if let Some(coupon_code) = &payload.coupon_code { + let coupon_result = validate_coupon_service( + svc_ctx, + user_uuid, + &VerifyCouponRequest { + code: coupon_code.clone(), + amount: price_after_plan_discount, // 在套餐折扣后的价格上应用优惠券 + }, + ) + .await?; + + coupon_discount = coupon_result.discount_amount; + coupon_info = Some(coupon_result.coupon); + } + + // 5. 计算最终价格 + let final_price = price_after_plan_discount - coupon_discount; + let total_saved = plan_discount + coupon_discount; + + Ok(PlanPriceResponse { + original_price: base_price, + plan_discount, + coupon_discount, + final_price: final_price.max(Decimal::ZERO), // 确保价格不为负 + total_saved, + coupon_info, + }) +} diff --git a/src-tauri/crates/business/src/services/preferences.rs b/src-tauri/crates/business/src/services/preferences.rs new file mode 100644 index 0000000..eb08873 --- /dev/null +++ b/src-tauri/crates/business/src/services/preferences.rs @@ -0,0 +1,49 @@ +use uuid::Uuid; + +use crate::dto::UserPreferenceDto; +use crate::entitys::settings::UpdatePreferencesRequest; +use crate::models; +use crate::svc_ctx::SvcCtx; + +/// 获取用户偏好设置 +pub async fn get_preferences_service( + svc_ctx: &SvcCtx, + user_uuid: Uuid, +) -> Result { + let preferences = models::preferences::fetch_user_preferences(&svc_ctx.db, user_uuid) + .await + .map_err(|e| e.to_string())?; + + // 如果不存在,创建默认设置 + if preferences.is_none() { + models::preferences::upsert_user_preferences(&svc_ctx.db, user_uuid, None, None, None) + .await + .map_err(|e| e.to_string())?; + + return models::preferences::fetch_user_preferences(&svc_ctx.db, user_uuid) + .await + .map_err(|e| e.to_string())? + .ok_or_else(|| "创建偏好设置失败".to_string()); + } + + preferences.ok_or_else(|| "偏好设置不存在".to_string()) +} + +/// 更新用户偏好设置 +pub async fn update_preferences_service( + svc_ctx: &SvcCtx, + user_uuid: Uuid, + payload: &UpdatePreferencesRequest, +) -> Result { + models::preferences::upsert_user_preferences( + &svc_ctx.db, + user_uuid, + payload.theme.as_deref(), + payload.language.as_deref(), + payload.notifications_enabled, + ) + .await + .map_err(|e| e.to_string())?; + + get_preferences_service(svc_ctx, user_uuid).await +} diff --git a/src-tauri/crates/business/src/services/proxies.rs b/src-tauri/crates/business/src/services/proxies.rs new file mode 100644 index 0000000..2507fea --- /dev/null +++ b/src-tauri/crates/business/src/services/proxies.rs @@ -0,0 +1,234 @@ +use uuid::Uuid; + +use crate::dto::ProxyDto; +use crate::entitys::{ + BatchImportProxiesRequest, CreateProxyRequest, ListProxiesRequest, UpdateProxyRequest, +}; +use crate::models; +use crate::svc_ctx::SvcCtx; + +/// 创建代理 +pub async fn create_proxy_service( + svc_ctx: &SvcCtx, + owner_uuid: Uuid, + workspace_uuid: Uuid, + payload: &CreateProxyRequest, +) -> Result { + // 1. 检查工作空间代理配额是否充足 + let quota_available = models::check_quota(&svc_ctx.db, workspace_uuid, "proxies") + .await + .map_err(|e| e.to_string())?; + if !quota_available { + return Err("工作空间代理配额不足,无法创建新代理".to_string()); + } + + // 2. 创建代理 + let proxy_uuid = models::insert_proxy( + &svc_ctx.db, + workspace_uuid, + owner_uuid, + &payload.name, + &payload.host, + payload.port, + &payload.proxy_type, + payload.username.as_deref(), + payload.password.as_deref(), + payload.country.as_deref(), + payload.city.as_deref(), + ) + .await + .map_err(|e| e.to_string())?; + + // 3. 更新工作空间配额(创建后增加使用数) + models::increment_used_proxies(&svc_ctx.db, workspace_uuid, 1) + .await + .map_err(|e| format!("更新配额失败: {}", e))?; + + Ok(proxy_uuid) +} + +/// 获取代理列表(根据可见性过滤) +pub async fn get_proxies_service( + svc_ctx: &SvcCtx, + user_uuid: Uuid, + workspace_uuid: Uuid, + current_team_uuid: Option, + payload: &ListProxiesRequest, +) -> Result<(Vec, i64), String> { + let filters = payload.filters.as_ref(); + let keyword = filters + .and_then(|f| f.keyword.as_deref()) + .map(str::trim) + .filter(|value| !value.is_empty()); + let proxy_type = filters + .and_then(|f| f.proxy_type.as_deref()) + .map(str::trim) + .filter(|value| !value.is_empty()); + let status = filters + .and_then(|f| f.status.as_deref()) + .map(str::trim) + .filter(|value| !value.is_empty()); + let country = filters + .and_then(|f| f.country.as_deref()) + .map(str::trim) + .filter(|value| !value.is_empty()); + + let page = payload.pagination.page.max(1); + let page_size = payload.pagination.page_size.max(1); + let offset = (page - 1) * page_size; + + let proxies = models::fetch_visible_proxies_for_user_paginated( + &svc_ctx.db, + workspace_uuid, + user_uuid, + current_team_uuid, + keyword, + proxy_type, + status, + country, + offset, + page_size, + ) + .await + .map_err(|e| e.to_string())?; + + let total = models::fetch_visible_proxies_for_user_count( + &svc_ctx.db, + workspace_uuid, + user_uuid, + current_team_uuid, + keyword, + proxy_type, + status, + country, + ) + .await + .map_err(|e| e.to_string())?; + + Ok((proxies, total)) +} + +/// 获取代理详情 +pub async fn get_proxy_service(svc_ctx: &SvcCtx, proxy_uuid: Uuid) -> Result { + models::fetch_proxy_by_uuid(&svc_ctx.db, proxy_uuid) + .await + .map_err(|e| e.to_string())? + .ok_or_else(|| "代理不存在".to_string()) +} + +/// 更新代理 +pub async fn update_proxy_service( + svc_ctx: &SvcCtx, + payload: &UpdateProxyRequest, +) -> Result<(), String> { + models::update_proxy( + &svc_ctx.db, + payload.uuid, + payload.name.as_deref(), + payload.host.as_deref(), + payload.port, + payload.proxy_type.as_deref(), + payload.username.as_deref(), + payload.password.as_deref(), + payload.country.as_deref(), + payload.city.as_deref(), + ) + .await + .map_err(|e| e.to_string()) +} + +/// 删除代理 +pub async fn delete_proxy_service(svc_ctx: &SvcCtx, proxy_uuid: Uuid) -> Result<(), String> { + // 1. 获取代理信息(用于获取 workspace_uuid) + let proxy = models::fetch_proxy_by_uuid(&svc_ctx.db, proxy_uuid) + .await + .map_err(|e| e.to_string())? + .ok_or_else(|| "代理不存在".to_string())?; + + let workspace_uuid = proxy.workspace_uuid; + + // 2. 删除代理 + models::delete_proxy(&svc_ctx.db, proxy_uuid).await.map_err(|e| e.to_string())?; + + // 3. 更新工作空间配额(删除后减少使用数) + models::decrement_used_proxies(&svc_ctx.db, workspace_uuid, 1) + .await + .map_err(|e| format!("更新配额失败: {}", e))?; + + Ok(()) +} + +/// 批量删除代理 +pub async fn batch_delete_proxies_service( + svc_ctx: &SvcCtx, + proxy_uuids: &[Uuid], +) -> Result { + models::batch_delete_proxies(&svc_ctx.db, proxy_uuids) + .await + .map_err(|e| e.to_string()) +} + +/// 批量导入代理 +/// +/// 接收客户端已解析好的代理列表,直接保存到数据库 +pub async fn batch_import_proxies_service( + svc_ctx: &SvcCtx, + owner_uuid: Uuid, + workspace_uuid: Uuid, + payload: &BatchImportProxiesRequest, +) -> Result { + // 1. 检查工作空间代理配额是否充足(检查是否有足够配额导入所有代理) + let import_count = payload.proxies.len() as i32; + let quota = models::fetch_workspace_quota(&svc_ctx.db, workspace_uuid) + .await + .map_err(|e| e.to_string())? + .ok_or_else(|| "工作空间配额不存在".to_string())?; + + if quota.used_proxies + import_count > quota.max_proxies { + return Err(format!( + "工作空间代理配额不足,当前已使用 {}/{},无法导入 {} 个代理", + quota.used_proxies, quota.max_proxies, import_count + )); + } + + let mut success_count = 0; + let mut failed_count = 0; + let mut errors: Vec = vec![]; + for (index, proxy) in payload.proxies.iter().enumerate() { + let result = models::insert_proxy( + &svc_ctx.db, + workspace_uuid, + owner_uuid, + &proxy.name, + &proxy.host, + proxy.port, + &proxy.proxy_type, + proxy.username.as_deref(), + proxy.password.as_deref(), + proxy.country.as_deref(), + proxy.city.as_deref(), + ) + .await; + + match result { + Ok(_) => { + success_count += 1; + // 更新配额(每成功导入一个代理就增加配额使用数) + if let Err(e) = models::increment_used_proxies(&svc_ctx.db, workspace_uuid, 1).await + { + errors.push(format!("第 {} 项导入成功但更新配额失败: {}", index + 1, e)); + } + } + Err(e) => { + failed_count += 1; + errors.push(format!("第 {} 项: {}", index + 1, e)); + } + } + } + + Ok(crate::entitys::BatchImportResponse { + success_count, + failed_count, + errors, + }) +} diff --git a/src-tauri/crates/business/src/services/proxy_visibility.rs b/src-tauri/crates/business/src/services/proxy_visibility.rs new file mode 100644 index 0000000..7c126fd --- /dev/null +++ b/src-tauri/crates/business/src/services/proxy_visibility.rs @@ -0,0 +1,143 @@ +use uuid::Uuid; + +use crate::dto::ProxyDto; +use crate::entitys::{ + BatchSetProxyVisibleRequest, ListVisibleProxiesRequest, + RemoveProxyVisibleRequest, SetProxyVisibleRequest, +}; +use crate::models; +use crate::svc_ctx::SvcCtx; + +/// 设置代理对团队可见 +pub async fn set_proxy_visible_to_team_service( + svc_ctx: &SvcCtx, + user_uuid: Uuid, + payload: &SetProxyVisibleRequest, +) -> Result<(), String> { + // 检查权限:只有代理所有者或工作空间所有者可以设置可见性 + let proxy = models::fetch_proxy_by_uuid(&svc_ctx.db, payload.proxy_uuid) + .await + .map_err(|e| e.to_string())? + .ok_or_else(|| "代理不存在".to_string())?; + + let is_owner = proxy.owner_uuid == user_uuid; + let is_workspace_owner = + models::check_workspace_owner(&svc_ctx.db, proxy.workspace_uuid, user_uuid) + .await + .map_err(|e| e.to_string())?; + + if !is_owner && !is_workspace_owner { + return Err("只有代理所有者或工作空间所有者可以设置可见性".to_string()); + } + + // 获取团队的工作空间 + let team = models::fetch_team_by_uuid(&svc_ctx.db, payload.team_uuid) + .await + .map_err(|e| e.to_string())? + .ok_or_else(|| "团队不存在".to_string())?; + + if team.workspace_uuid != proxy.workspace_uuid { + return Err("团队和工作空间不匹配".to_string()); + } + + models::insert_proxy_visible_team( + &svc_ctx.db, + payload.proxy_uuid, + proxy.workspace_uuid, + payload.team_uuid, + ) + .await + .map_err(|e| e.to_string()) +} + +/// 移除代理对团队的可见性 +pub async fn remove_proxy_visible_from_team_service( + svc_ctx: &SvcCtx, + user_uuid: Uuid, + payload: &RemoveProxyVisibleRequest, +) -> Result<(), String> { + // 检查权限:只有代理所有者或工作空间所有者可以移除可见性 + let proxy = models::fetch_proxy_by_uuid(&svc_ctx.db, payload.proxy_uuid) + .await + .map_err(|e| e.to_string())? + .ok_or_else(|| "代理不存在".to_string())?; + + let is_owner = proxy.owner_uuid == user_uuid; + let is_workspace_owner = + models::check_workspace_owner(&svc_ctx.db, proxy.workspace_uuid, user_uuid) + .await + .map_err(|e| e.to_string())?; + + if !is_owner && !is_workspace_owner { + return Err("只有代理所有者或工作空间所有者可以移除可见性".to_string()); + } + + models::remove_proxy_visible_team(&svc_ctx.db, payload.proxy_uuid, payload.team_uuid) + .await + .map_err(|e| e.to_string()) +} + +/// 批量设置代理可见性 +pub async fn batch_set_proxy_visible_service( + svc_ctx: &SvcCtx, + user_uuid: Uuid, + payload: &BatchSetProxyVisibleRequest, +) -> Result<(), String> { + // 检查权限 + let proxy = models::fetch_proxy_by_uuid(&svc_ctx.db, payload.proxy_uuid) + .await + .map_err(|e| e.to_string())? + .ok_or_else(|| "代理不存在".to_string())?; + + let is_owner = proxy.owner_uuid == user_uuid; + let is_workspace_owner = + models::check_workspace_owner(&svc_ctx.db, proxy.workspace_uuid, user_uuid) + .await + .map_err(|e| e.to_string())?; + + if !is_owner && !is_workspace_owner { + return Err("只有代理所有者或工作空间所有者可以设置可见性".to_string()); + } + + // 批量设置可见性 + for team_uuid in &payload.team_uuids { + models::insert_proxy_visible_team( + &svc_ctx.db, + payload.proxy_uuid, + proxy.workspace_uuid, + *team_uuid, + ) + .await + .map_err(|e| e.to_string())?; + } + + Ok(()) +} + +/// 获取可见的代理列表 +pub async fn get_visible_proxies_service( + svc_ctx: &SvcCtx, + user_uuid: Uuid, + payload: &ListVisibleProxiesRequest, +) -> Result, String> { + models::fetch_visible_proxies_for_user( + &svc_ctx.db, + payload.workspace_uuid, + user_uuid, + payload.team_uuid, + ) + .await + .map_err(|e| e.to_string()) +} + +/// 检查代理可见性 +pub async fn check_proxy_visibility_service( + svc_ctx: &SvcCtx, + proxy_uuid: Uuid, + workspace_uuid: Uuid, + team_uuid: Uuid, +) -> Result { + models::check_proxy_visibility(&svc_ctx.db, proxy_uuid, workspace_uuid, team_uuid) + .await + .map_err(|e| e.to_string()) +} diff --git a/src-tauri/crates/business/src/services/referral.rs b/src-tauri/crates/business/src/services/referral.rs new file mode 100644 index 0000000..ac304af --- /dev/null +++ b/src-tauri/crates/business/src/services/referral.rs @@ -0,0 +1,420 @@ +use rust_decimal::prelude::ToPrimitive; +use rust_decimal::Decimal; +use uuid::Uuid; + +use crate::dto::{ + RedeemOptionDto, RedeemRecordDto, ReferralLinkDto, ReferralLinkTierDto, ReferralRewardDto, + ReferredUserItemDto, UserReferralPointsDto, +}; +use crate::entitys::{ + ListReferralRewardsRequest, ListReferredUsersRequest, Pagination, RedeemPointsRequest, + ReferralDashboardResponse, ReferralPointsSummary, ReferralStatsResponse, +}; +use crate::models; +use crate::svc_ctx::SvcCtx; +use crate::services::subscriptions::get_current_subscription_service; + +/// 获取推广统计 +pub async fn get_referral_stats_service( + svc_ctx: &SvcCtx, + user_uuid: Uuid, +) -> Result { + // 获取基本统计数据 + let (total_referrals, paid_referrals, total_consumption, last_30_days_consumption) = + models::referral::fetch_referral_stats(&svc_ctx.db, user_uuid) + .await + .map_err(|e| e.to_string())?; + + // 获取总奖励积分 + let total_rewards = models::referral::fetch_total_reward_points(&svc_ctx.db, user_uuid) + .await + .map_err(|e| e.to_string())?; + + // 获取用户积分 + let points = models::referral::fetch_user_points(&svc_ctx.db, user_uuid) + .await + .map_err(|e| e.to_string())?; + let available_points = points.map(|p| p.available_points).unwrap_or(0); + + // 获取当前层级和下一层级 + let current_tier = models::referral::fetch_tier_by_threshold(&svc_ctx.db, paid_referrals) + .await + .map_err(|e| e.to_string())?; + + let current_threshold = current_tier.as_ref().map(|t| t.unlock_threshold).unwrap_or(0); + let next_tier = models::referral::fetch_next_tier(&svc_ctx.db, current_threshold) + .await + .map_err(|e| e.to_string())?; + + // 计算升级进度 + let upgrade_progress = if let Some(ref next) = next_tier { + let progress = (paid_referrals - current_threshold) as f64 + / (next.unlock_threshold - current_threshold) as f64 + * 100.0; + progress as i32 + } else { + 100 + }; + + Ok(ReferralStatsResponse { + total_referrals, + paid_referrals, + total_consumption, + last_30_days_consumption, + total_rewards, + available_points, + current_tier, + next_tier, + upgrade_progress, + }) +} + +/// 获取推广链接层级配置 +pub async fn get_referral_tiers_service( + svc_ctx: &SvcCtx, +) -> Result, String> { + models::referral::fetch_referral_tiers(&svc_ctx.db) + .await + .map_err(|e| e.to_string()) +} + +/// 获取推广链接列表 +pub async fn get_referral_links_service( + svc_ctx: &SvcCtx, + user_uuid: Uuid, +) -> Result<(Vec, Option), String> { + // 根据当前统计数据计算已解锁层级,并同步更新该用户所有推广链接的 unlocked 状态 + // + // 解锁规则: + // - 对于 unlock_threshold <= paid_referrals 的层级视为已解锁 + // - 这些层级对应的推广链接(按 tier_uuid 关联)统一标记为 unlocked = TRUE + // - 其余链接统一标记为 unlocked = FALSE + let (_total_referrals, paid_referrals, _total_consumption, _last_30_days_consumption) = + models::referral::fetch_referral_stats(&svc_ctx.db, user_uuid) + .await + .map_err(|e| e.to_string())?; + + let tiers = models::referral::fetch_referral_tiers(&svc_ctx.db) + .await + .map_err(|e| e.to_string())?; + + let unlocked_tier_uuids: Vec = tiers + .into_iter() + .filter(|t| t.unlock_threshold <= paid_referrals) + .map(|t| t.uuid) + .collect(); + + models::referral::update_referral_links_unlock_status_for_user( + &svc_ctx.db, + user_uuid, + &unlocked_tier_uuids, + ) + .await + .map_err(|e| e.to_string())?; + + let mut links = models::referral::fetch_user_referral_links(&svc_ctx.db, user_uuid) + .await + .map_err(|e| e.to_string())?; + + let mut current_link = models::referral::fetch_current_referral_link(&svc_ctx.db, user_uuid) + .await + .map_err(|e| e.to_string())?; + + // 如果配置了推广链接前缀,则在返回前为每个链接拼接完整 URL + if let Some(prefix) = svc_ctx.config.app.referral_link_prefix.as_deref() { + let prefix = prefix.trim_end_matches('/'); + + for link in &mut links { + link.url = Some(format!("{prefix}?referral_code={}", link.code)); + } + + if let Some(ref mut cl) = current_link { + cl.url = Some(format!("{prefix}?referral_code={}", cl.code)); + } + } + + Ok((links, current_link)) +} + +/// 获取推广看板聚合数据 +pub async fn get_referral_dashboard_service( + svc_ctx: &SvcCtx, + user_uuid: Uuid, +) -> Result { + // 基础统计(含 available_points 和 total_rewards) + let stats = get_referral_stats_service(svc_ctx, user_uuid).await?; + + // 推广链接及当前链接 + let (links, current_link) = get_referral_links_service(svc_ctx, user_uuid).await?; + + // 层级配置 + let tiers = get_referral_tiers_service(svc_ctx).await?; + + // 读取 pending_points,用于前端展示“审核中”积分 + let pending_points = models::referral::fetch_user_points(&svc_ctx.db, user_uuid) + .await + .ok() + .flatten() + .map(|p| p.pending_points) + .unwrap_or(0); + + let points = ReferralPointsSummary { + available_points: stats.available_points, + pending_points, + total_rewards: stats.total_rewards, + }; + + Ok(ReferralDashboardResponse { + stats, + links, + current_link, + tiers, + points, + }) +} + +/// 计算最近 30 天推广带来的预估收益,以及与当前套餐价格的覆盖比例 +pub async fn get_referral_plan_summary_service( + svc_ctx: &SvcCtx, + user_uuid: Uuid, +) -> Result { + // 基于推广统计获取最近 30 天消费金额和当前层级 + let stats = get_referral_stats_service(svc_ctx, user_uuid).await?; + + let last_30_days_consumption = stats.last_30_days_consumption; + + // 使用当前层级的 reward_rate 估算可得收益(如果没有当前层级,则按 0 估算) + let reward_rate = stats + .current_tier + .as_ref() + .map(|tier| tier.reward_rate) + .unwrap_or(Decimal::ZERO); + + // reward_rate 以百分比存储,例如 10 表示 10% + let referral_value_last_30_days = + last_30_days_consumption * reward_rate / Decimal::from(100u32); + + // 获取当前订阅,估算月度套餐价格 + let current_subscription = + get_current_subscription_service(svc_ctx, user_uuid).await.unwrap_or(None); + + let current_plan_monthly_price = current_subscription.as_ref().map(|sub| sub.price); + + // 计算覆盖比例 + let coverage_ratio = current_plan_monthly_price.map(|price| { + if price.is_zero() { + Decimal::ZERO + } else { + (referral_value_last_30_days / price).max(Decimal::ZERO) + } + }); + + Ok(crate::entitys::ReferralPlanSummaryResponse { + referral_value_last_30_days, + current_plan_monthly_price, + coverage_ratio, + }) +} + +/// 切换当前推广链接 +pub async fn switch_referral_link_service( + svc_ctx: &SvcCtx, + user_uuid: Uuid, + link_uuid: Uuid, +) -> Result<(), String> { + models::referral::switch_current_referral_link(&svc_ctx.db, user_uuid, link_uuid) + .await + .map_err(|e| e.to_string()) +} + +/// 获取奖励记录列表 +pub async fn get_referral_rewards_service( + svc_ctx: &SvcCtx, + user_uuid: Uuid, + payload: &ListReferralRewardsRequest, +) -> Result<(Vec, i64), String> { + let offset = (payload.pagination.page - 1) * payload.pagination.page_size; + + let rewards = models::referral::fetch_referral_rewards( + &svc_ctx.db, + user_uuid, + payload.keyword.as_deref(), + payload.reward_type.as_deref(), + payload.status.as_deref(), + offset, + payload.pagination.page_size, + ) + .await + .map_err(|e| e.to_string())?; + + let total = models::referral::fetch_referral_rewards_count( + &svc_ctx.db, + user_uuid, + payload.keyword.as_deref(), + payload.reward_type.as_deref(), + payload.status.as_deref(), + ) + .await + .map_err(|e| e.to_string())?; + + Ok((rewards, total)) +} + +/// 获取被邀请用户列表 +pub async fn get_referred_users_service( + svc_ctx: &SvcCtx, + user_uuid: Uuid, + payload: &ListReferredUsersRequest, +) -> Result<(Vec, i64), String> { + let offset = (payload.pagination.page - 1) * payload.pagination.page_size; + + let rows = models::referral::fetch_referred_users( + &svc_ctx.db, + user_uuid, + payload.keyword.as_deref(), + payload.status.as_deref(), + offset, + payload.pagination.page_size, + ) + .await + .map_err(|e| e.to_string())?; + + let users: Vec = rows + .into_iter() + .map(|r| ReferredUserItemDto { + id: r.id.to_string(), + email: r.email, + registered_at: r.registered_at, + status: r.status, + total_consumption: r.total_consumption.and_then(|d| d.to_f64()).unwrap_or(0.0), + last_30_days_consumption: r + .last_30_days_consumption + .and_then(|d| d.to_f64()) + .unwrap_or(0.0), + link_id: r.link_uuid.map(|u| u.to_string()).unwrap_or_default(), + }) + .collect(); + + let total = models::referral::fetch_referred_users_count( + &svc_ctx.db, + user_uuid, + payload.keyword.as_deref(), + payload.status.as_deref(), + ) + .await + .map_err(|e| e.to_string())?; + + Ok((users, total)) +} + +/// 获取用户积分 +pub async fn get_user_points_service( + svc_ctx: &SvcCtx, + user_uuid: Uuid, +) -> Result { + let points = models::referral::fetch_user_points(&svc_ctx.db, user_uuid) + .await + .map_err(|e| e.to_string())?; + + // 如果不存在则创建 + if points.is_none() { + models::referral::upsert_user_points(&svc_ctx.db, user_uuid, 0, 0, 0, 0) + .await + .map_err(|e| e.to_string())?; + + return models::referral::fetch_user_points(&svc_ctx.db, user_uuid) + .await + .map_err(|e| e.to_string())? + .ok_or_else(|| "创建积分记录失败".to_string()); + } + + points.ok_or_else(|| "积分记录不存在".to_string()) +} + +/// 获取兑换选项 +pub async fn get_redeem_options_service(svc_ctx: &SvcCtx) -> Result, String> { + models::referral::fetch_redeem_options(&svc_ctx.db) + .await + .map_err(|e| e.to_string()) +} + +/// 执行积分兑换 +pub async fn redeem_points_service( + svc_ctx: &SvcCtx, + user_uuid: Uuid, + payload: &RedeemPointsRequest, +) -> Result<(Uuid, i32, Decimal), String> { + // 获取兑换选项 + let option = models::referral::fetch_redeem_option_by_uuid(&svc_ctx.db, payload.option_uuid) + .await + .map_err(|e| e.to_string())? + .ok_or_else(|| "兑换选项不存在".to_string())?; + + if option.status != "active" { + return Err("该兑换选项已下架".to_string()); + } + + // 检查积分是否满足最低要求 + if payload.points < option.points_required { + return Err(format!( + "积分不足,最低需要 {} 积分", + option.points_required + )); + } + + // 获取用户积分 + let points = models::referral::fetch_user_points(&svc_ctx.db, user_uuid) + .await + .map_err(|e| e.to_string())? + .ok_or_else(|| "积分记录不存在".to_string())?; + + if points.available_points < payload.points { + return Err("可用积分不足".to_string()); + } + + // 计算兑换价值 + let value = Decimal::from(payload.points) / Decimal::from(option.exchange_rate); + + // 扣减积分 + models::referral::deduct_user_points(&svc_ctx.db, user_uuid, payload.points) + .await + .map_err(|e| e.to_string())?; + + // 创建兑换记录 + let record_uuid = models::referral::insert_redeem_record( + &svc_ctx.db, + user_uuid, + payload.option_uuid, + payload.points, + value, + option.currency.as_deref(), + ) + .await + .map_err(|e| e.to_string())?; + + Ok((record_uuid, payload.points, value)) +} + +/// 获取兑换记录 +pub async fn get_redeem_records_service( + svc_ctx: &SvcCtx, + user_uuid: Uuid, + pagination: &Pagination, +) -> Result<(Vec, i64), String> { + let offset = (pagination.page - 1) * pagination.page_size; + + let records = models::referral::fetch_redeem_records( + &svc_ctx.db, + user_uuid, + offset, + pagination.page_size, + ) + .await + .map_err(|e| e.to_string())?; + + let total = models::referral::fetch_redeem_records_count(&svc_ctx.db, user_uuid) + .await + .map_err(|e| e.to_string())?; + + Ok((records, total)) +} diff --git a/src-tauri/crates/business/src/services/rpa.rs b/src-tauri/crates/business/src/services/rpa.rs new file mode 100644 index 0000000..4b58e8e --- /dev/null +++ b/src-tauri/crates/business/src/services/rpa.rs @@ -0,0 +1,354 @@ +use uuid::Uuid; + +use crate::dto::{RpaTaskDto, RpaTaskStepDto}; +use crate::entitys::{ + CreateRpaTaskRequest, DuplicateRpaTaskRequest, ListRpaTasksRequest, UpdateRpaTaskRequest, +}; +use crate::models; +use crate::svc_ctx::SvcCtx; + +/// List RPA tasks. +pub async fn get_rpa_tasks_service( + svc_ctx: &SvcCtx, + user_uuid: Uuid, + team_uuid: Option, + payload: &ListRpaTasksRequest, +) -> Result<(Vec, i64), String> { + let offset = (payload.pagination.page - 1) * payload.pagination.page_size; + + let keyword = payload.filters.as_ref().and_then(|f| f.keyword.as_deref()); + let status = payload.filters.as_ref().and_then(|f| f.status.as_deref()); + let trigger_type = payload.filters.as_ref().and_then(|f| f.trigger_type.as_deref()); + + let tasks = models::rpa::fetch_rpa_tasks( + &svc_ctx.db, + team_uuid, + user_uuid, + keyword, + status, + trigger_type, + offset, + payload.pagination.page_size, + ) + .await + .map_err(|e| e.to_string())?; + + let total = + models::rpa::fetch_rpa_tasks_count( + &svc_ctx.db, + team_uuid, + user_uuid, + keyword, + status, + trigger_type, + ) + .await + .map_err(|e| e.to_string())?; + + Ok((tasks, total)) +} + +/// Get RPA task detail. +pub async fn get_rpa_task_service( + svc_ctx: &SvcCtx, + task_uuid: Uuid, +) -> Result<(RpaTaskDto, Vec, Vec), String> { + let task = models::rpa::fetch_rpa_task_by_uuid(&svc_ctx.db, task_uuid) + .await + .map_err(|e| e.to_string())? + .ok_or_else(|| "RPA task not found".to_string())?; + + let steps = models::rpa::fetch_rpa_task_steps(&svc_ctx.db, task_uuid) + .await + .map_err(|e| e.to_string())?; + + let environments = models::rpa::fetch_rpa_task_environments(&svc_ctx.db, task_uuid) + .await + .map_err(|e| e.to_string())?; + + let environment_uuids: Vec = + environments.into_iter().map(|e| e.environment_uuid).collect(); + + Ok((task, steps, environment_uuids)) +} + +/// Create an RPA task. +pub async fn create_rpa_task_service( + svc_ctx: &SvcCtx, + user_uuid: Uuid, + team_uuid: Option, + payload: &CreateRpaTaskRequest, +) -> Result { + let tags_json = payload.tags.as_ref().map(|t| serde_json::json!(t)); + + let task_uuid = models::rpa::insert_rpa_task( + &svc_ctx.db, + user_uuid, + team_uuid, + &payload.name, + payload.description.as_deref(), + tags_json.as_ref(), + &payload.trigger_type, + payload.schedule.as_deref(), + payload.cron_expression.as_deref(), + &payload.run_mode, + payload.retry_count, + payload.retry_interval, + payload.timeout, + payload.concurrency, + payload.stop_on_error, + payload.notify_on_complete, + payload.notify_on_error, + ) + .await + .map_err(|e| e.to_string())?; + + // Persist ordered steps. + if let Some(steps) = &payload.steps { + for (i, step) in steps.iter().enumerate() { + models::rpa::insert_rpa_task_step( + &svc_ctx.db, + task_uuid, + &step.step_type, + &step.name, + &step.config, + step.enabled, + step.position_x, + step.position_y, + Some(step.sort_order.unwrap_or(i as i32)), + step.next_step_uuid, + step.branch_config.as_ref(), + ) + .await + .map_err(|e| e.to_string())?; + } + } + + // Persist environment bindings. + if let Some(env_uuids) = &payload.environment_uuids { + for (i, env_uuid) in env_uuids.iter().enumerate() { + models::rpa::insert_rpa_task_environment( + &svc_ctx.db, + task_uuid, + *env_uuid, + Some(i as i32), + ) + .await + .map_err(|e| e.to_string())?; + } + } + + Ok(task_uuid) +} + +/// Update an RPA task. +pub async fn update_rpa_task_service( + svc_ctx: &SvcCtx, + payload: &UpdateRpaTaskRequest, +) -> Result<(), String> { + let tags_json = payload.tags.as_ref().map(|t| serde_json::json!(t)); + + models::rpa::update_rpa_task( + &svc_ctx.db, + payload.uuid, + payload.name.as_deref(), + payload.description.as_deref(), + tags_json.as_ref(), + payload.trigger_type.as_deref(), + payload.schedule.as_deref(), + payload.cron_expression.as_deref(), + payload.run_mode.as_deref(), + payload.retry_count, + payload.retry_interval, + payload.timeout, + payload.concurrency, + payload.stop_on_error, + payload.notify_on_complete, + payload.notify_on_error, + ) + .await + .map_err(|e| e.to_string())?; + + // Replace stored steps when the caller sends a full step list. + if let Some(steps) = &payload.steps { + // Remove previous step rows first. + models::rpa::delete_rpa_task_steps(&svc_ctx.db, payload.uuid) + .await + .map_err(|e| e.to_string())?; + + // Insert the new step rows. + for (i, step) in steps.iter().enumerate() { + models::rpa::insert_rpa_task_step( + &svc_ctx.db, + payload.uuid, + &step.step_type, + &step.name, + &step.config, + step.enabled, + step.position_x, + step.position_y, + Some(step.sort_order.unwrap_or(i as i32)), + step.next_step_uuid, + step.branch_config.as_ref(), + ) + .await + .map_err(|e| e.to_string())?; + } + } + + // Replace environment bindings when provided. + if let Some(env_uuids) = &payload.environment_uuids { + // Remove previous environment bindings. + models::rpa::delete_rpa_task_environments(&svc_ctx.db, payload.uuid) + .await + .map_err(|e| e.to_string())?; + + // Insert the new environment bindings. + for (i, env_uuid) in env_uuids.iter().enumerate() { + models::rpa::insert_rpa_task_environment( + &svc_ctx.db, + payload.uuid, + *env_uuid, + Some(i as i32), + ) + .await + .map_err(|e| e.to_string())?; + } + } + + Ok(()) +} + +/// Soft-delete an RPA task. +pub async fn delete_rpa_task_service(svc_ctx: &SvcCtx, task_uuid: Uuid) -> Result<(), String> { + models::rpa::delete_rpa_task(&svc_ctx.db, task_uuid) + .await + .map_err(|e| e.to_string()) +} + +/// Soft-delete RPA tasks in batch. +pub async fn batch_delete_rpa_tasks_service( + svc_ctx: &SvcCtx, + task_uuids: &[Uuid], +) -> Result { + models::rpa::batch_delete_rpa_tasks(&svc_ctx.db, task_uuids) + .await + .map_err(|e| e.to_string()) +} + +/// Duplicate an RPA task. +pub async fn duplicate_rpa_task_service( + svc_ctx: &SvcCtx, + user_uuid: Uuid, + team_uuid: Option, + payload: &DuplicateRpaTaskRequest, +) -> Result { + // Load source task data. + let (task, steps, environment_uuids) = get_rpa_task_service(svc_ctx, payload.uuid).await?; + + let new_name = payload + .new_name + .clone() + .unwrap_or_else(|| format!("{} (copy)", task.name)); + + // Create duplicated task row. + let new_task_uuid = models::rpa::insert_rpa_task( + &svc_ctx.db, + user_uuid, + team_uuid, + &new_name, + task.description.as_deref(), + task.tags.as_ref(), + &task.trigger_type, + task.schedule.as_deref(), + task.cron_expression.as_deref(), + &task.run_mode, + task.retry_count, + task.retry_interval, + task.timeout, + task.concurrency, + task.stop_on_error, + task.notify_on_complete, + task.notify_on_error, + ) + .await + .map_err(|e| e.to_string())?; + + // Copy steps. + for step in steps { + models::rpa::insert_rpa_task_step( + &svc_ctx.db, + new_task_uuid, + &step.step_type, + &step.name, + &step.config, + step.enabled, + step.position_x, + step.position_y, + step.sort_order, + step.next_step_uuid, + step.branch_config.as_ref(), + ) + .await + .map_err(|e| e.to_string())?; + } + + // Copy environment bindings. + for (i, env_uuid) in environment_uuids.iter().enumerate() { + models::rpa::insert_rpa_task_environment( + &svc_ctx.db, + new_task_uuid, + *env_uuid, + Some(i as i32), + ) + .await + .map_err(|e| e.to_string())?; + } + + Ok(new_task_uuid) +} + +/// Export an RPA task. +pub async fn export_rpa_task_service( + svc_ctx: &SvcCtx, + task_uuid: Uuid, +) -> Result<(String, String), String> { + let (task, steps, environment_uuids) = get_rpa_task_service(svc_ctx, task_uuid).await?; + + let export_data = serde_json::json!({ + "name": task.name, + "description": task.description, + "tags": task.tags, + "trigger_type": task.trigger_type, + "schedule": task.schedule, + "cron_expression": task.cron_expression, + "run_mode": task.run_mode, + "retry_count": task.retry_count, + "retry_interval": task.retry_interval, + "timeout": task.timeout, + "concurrency": task.concurrency, + "stop_on_error": task.stop_on_error, + "notify_on_complete": task.notify_on_complete, + "notify_on_error": task.notify_on_error, + "steps": steps.iter().map(|s| serde_json::json!({ + "step_type": s.step_type, + "name": s.name, + "config": s.config, + "enabled": s.enabled, + "position_x": s.position_x, + "position_y": s.position_y, + "sort_order": s.sort_order, + "next_step_uuid": s.next_step_uuid, + "branch_config": s.branch_config, + })).collect::>(), + "environment_uuids": environment_uuids, + }); + + let content = serde_json::to_string_pretty(&export_data).map_err(|e| e.to_string())?; + let filename = format!("rpa_task_{}.json", task_uuid); + + Ok((content, filename)) +} + + + diff --git a/src-tauri/crates/business/src/services/strategy_types.rs b/src-tauri/crates/business/src/services/strategy_types.rs new file mode 100644 index 0000000..841dd14 --- /dev/null +++ b/src-tauri/crates/business/src/services/strategy_types.rs @@ -0,0 +1,50 @@ +use crate::{ + dto::strategy_types::StrategyType, entitys::strategy_types::*, errors::SimprintError, + svc_ctx::SvcCtx, +}; + +/// 根据ID查询策略类型 +pub async fn get_strategy_type_by_id( + svc_ctx: &SvcCtx, + id: i32, +) -> Result { + let strategy_type = crate::models::strategy_types::query_strategy_type_by_id(&svc_ctx.db, id) + .await + .map_err(|_| SimprintError::StrategyTypeNotFound)?; + + Ok(strategy_type) +} + +/// 根据code查询策略类型 +pub async fn get_strategy_type_by_code( + svc_ctx: &SvcCtx, + code: &str, +) -> Result { + let strategy_type = + crate::models::strategy_types::query_strategy_type_by_code(&svc_ctx.db, &code) + .await + .map_err(|_| SimprintError::StrategyTypeNotFound)?; + + Ok(strategy_type) +} + +/// 查询所有可用策略类型 +pub async fn query_available_strategy_types( + svc_ctx: &SvcCtx, +) -> Result { + let list = crate::models::strategy_types::query_available_strategy_types(&svc_ctx.db).await?; + + Ok(StrategyTypeListResponse { list }) +} + +/// 根据分类查询策略类型 +pub async fn query_strategy_types_by_category( + svc_ctx: &SvcCtx, + category: &str, +) -> Result { + let list = + crate::models::strategy_types::query_strategy_types_by_category(&svc_ctx.db, &category) + .await?; + + Ok(StrategyTypeListResponse { list }) +} diff --git a/src-tauri/crates/business/src/services/subscriptions.rs b/src-tauri/crates/business/src/services/subscriptions.rs new file mode 100644 index 0000000..725659e --- /dev/null +++ b/src-tauri/crates/business/src/services/subscriptions.rs @@ -0,0 +1,275 @@ +use chrono::Utc; +use rust_decimal::Decimal; +use uuid::Uuid; + +use crate::dto::SubscriptionDto; +use crate::entitys::{SubscribePlanRequest, VerifyCouponRequest}; +use crate::models; +use crate::svc_ctx::SvcCtx; + +use super::coupons::validate_coupon_service; +use super::wallet::deduct_wallet_service; +use crate::models::workspace_quotas; + +/// 获取当前订阅(按用户) +pub async fn get_current_subscription_service( + svc_ctx: &SvcCtx, + user_uuid: Uuid, +) -> Result, String> { + models::billing::fetch_active_subscription(&svc_ctx.db, user_uuid) + .await + .map_err(|e| e.to_string()) +} + +/// 获取工作空间当前订阅 +pub async fn get_workspace_subscription_service( + svc_ctx: &SvcCtx, + workspace_uuid: Uuid, +) -> Result, String> { + models::billing::fetch_workspace_active_subscription(&svc_ctx.db, workspace_uuid) + .await + .map_err(|e| e.to_string()) +} + +/// 订阅套餐 +pub async fn subscribe_plan_service( + svc_ctx: &SvcCtx, + workspace_uuid: Uuid, + user_uuid: Uuid, + payload: &SubscribePlanRequest, +) -> Result { + // 1. 获取套餐信息 + let plan = models::billing::fetch_plan_by_uuid(&svc_ctx.db, payload.plan_uuid) + .await + .map_err(|e| e.to_string())? + .ok_or_else(|| "套餐不存在".to_string())?; + + if plan.status != "active" { + return Err("该套餐已下架".to_string()); + } + + // 2. 计算基础价格和套餐折扣 + let (base_price, plan_discount_percent, duration_days) = match payload.billing_period.as_str() { + "monthly" => ( + plan.price_per_month, + plan.discount_monthly.unwrap_or(Decimal::ZERO), + 30, + ), + "yearly" => ( + plan.price_per_year, + plan.discount_yearly.unwrap_or(Decimal::ZERO), + 365, + ), + _ => return Err("无效的计费周期".to_string()), + }; + + // 应用套餐级折扣 + let price_after_plan_discount = + base_price * (Decimal::from(100) - plan_discount_percent) / Decimal::from(100); + + // 3. 验证优惠券(如果有) + let mut final_price = price_after_plan_discount; + let mut coupon_uuid: Option = None; + let mut coupon_discount_amount = Decimal::ZERO; + + if let Some(coupon_code) = &payload.coupon_code { + let coupon_result = validate_coupon_service( + svc_ctx, + user_uuid, + &VerifyCouponRequest { + code: coupon_code.clone(), + amount: price_after_plan_discount, // 在套餐折扣后的价格上应用优惠券 + }, + ) + .await?; + + coupon_uuid = Some(coupon_result.coupon.uuid); + coupon_discount_amount = coupon_result.discount_amount; + final_price = price_after_plan_discount - coupon_discount_amount; + } + + // 4. 检查支付方式并验证钱包余额(仅钱包支付需要检查) + let payment_method = payload.payment_method.as_deref().unwrap_or("wallet"); + let is_wallet_payment = payment_method == "wallet"; + + if is_wallet_payment { + let wallet = models::billing::fetch_user_wallet(&svc_ctx.db, user_uuid) + .await + .map_err(|e| e.to_string())?; + + let wallet_balance = wallet.map(|w| w.balance).unwrap_or(Decimal::ZERO); + if wallet_balance < final_price { + return Err("钱包余额不足,请先充值".to_string()); + } + } + + // 5. 取消工作空间现有订阅 + if let Some(existing) = models::billing::fetch_workspace_active_subscription(&svc_ctx.db, workspace_uuid) + .await + .map_err(|e| e.to_string())? + { + models::billing::cancel_subscription(&svc_ctx.db, existing.uuid) + .await + .map_err(|e| e.to_string())?; + } + + // 6. 计算到期时间 + let expires_at = Utc::now() + chrono::Duration::days(duration_days); + let next_billing_date = expires_at.date_naive(); + + // 7. 创建订阅 + let subscription_uuid = models::billing::insert_subscription( + &svc_ctx.db, + workspace_uuid, + user_uuid, + payload.plan_uuid, + &payload.billing_period, + final_price, + &plan.currency, + expires_at, + next_billing_date, + ) + .await + .map_err(|e| e.to_string())?; + + // 7.1. 记录优惠券使用(如果有) + if let Some(coupon_uuid_val) = coupon_uuid { + // 记录优惠券使用 + models::billing::insert_coupon_usage( + &svc_ctx.db, + coupon_uuid_val, + user_uuid, + Some(subscription_uuid), + coupon_discount_amount, + ) + .await + .map_err(|e| e.to_string())?; + + // 更新用户优惠券状态为 used(如果是从 user_coupons 表发放的) + models::billing::update_user_coupon_status( + &svc_ctx.db, + user_uuid, + coupon_uuid_val, + "used", + Some(Utc::now()), + ) + .await + .map_err(|e| e.to_string())?; + } + + // 8. 扣减钱包余额(仅钱包支付需要扣减) + if is_wallet_payment { + deduct_wallet_service( + svc_ctx, + user_uuid, + final_price, + "订阅套餐", + Some(subscription_uuid), + ) + .await?; + } + + // 9. 更新工作空间配额 + workspace_quotas::insert_or_update_workspace_quota( + &svc_ctx.db, + workspace_uuid, + plan.max_environments, + plan.max_team_members, + plan.max_proxies, + plan.max_rpa_tasks, + ) + .await + .map_err(|e| e.to_string())?; + + // 10. 创建发票 + let invoice_number = format!("INV-{}", chrono::Utc::now().format("%Y%m%d%H%M%S")); + models::billing::insert_invoice( + &svc_ctx.db, + user_uuid, + &invoice_number, + final_price, + &plan.currency, + Some(subscription_uuid), + None, + "subscription", + ) + .await + .map_err(|e| e.to_string())?; + + Ok(subscription_uuid) +} + +/// 取消订阅 +pub async fn cancel_subscription_service( + svc_ctx: &SvcCtx, + user_uuid: Uuid, + subscription_uuid: Uuid, +) -> Result<(), String> { + // 验证订阅属于当前用户 + let subscription = models::billing::fetch_subscription_by_uuid(&svc_ctx.db, subscription_uuid) + .await + .map_err(|e| e.to_string())? + .ok_or_else(|| "订阅不存在".to_string())?; + + if subscription.user_uuid != user_uuid { + return Err("无权操作此订阅".to_string()); + } + + if subscription.status == "cancelled" { + return Err("订阅已取消".to_string()); + } + + models::billing::cancel_subscription(&svc_ctx.db, subscription_uuid) + .await + .map_err(|e| e.to_string()) +} + +/// 恢复订阅 +pub async fn resume_subscription_service( + svc_ctx: &SvcCtx, + user_uuid: Uuid, + subscription_uuid: Uuid, +) -> Result<(), String> { + let subscription = models::billing::fetch_subscription_by_uuid(&svc_ctx.db, subscription_uuid) + .await + .map_err(|e| e.to_string())? + .ok_or_else(|| "订阅不存在".to_string())?; + + if subscription.user_uuid != user_uuid { + return Err("无权操作此订阅".to_string()); + } + + if subscription.status != "cancelled" { + return Err("只能恢复已取消的订阅".to_string()); + } + + // 检查是否过期 + if subscription.expires_at < Utc::now() { + return Err("订阅已过期,请重新订阅".to_string()); + } + + models::billing::resume_subscription(&svc_ctx.db, subscription_uuid) + .await + .map_err(|e| e.to_string()) +} + +/// 切换自动续费 +pub async fn toggle_auto_renew_service( + svc_ctx: &SvcCtx, + user_uuid: Uuid, + subscription_uuid: Uuid, + auto_renew: bool, +) -> Result<(), String> { + let subscription = models::billing::fetch_subscription_by_uuid(&svc_ctx.db, subscription_uuid) + .await + .map_err(|e| e.to_string())? + .ok_or_else(|| "订阅不存在".to_string())?; + + if subscription.user_uuid != user_uuid { + return Err("无权操作此订阅".to_string()); + } + + models::billing::toggle_auto_renew(&svc_ctx.db, subscription_uuid, auto_renew) + .await + .map_err(|e| e.to_string()) +} diff --git a/src-tauri/crates/business/src/services/tags.rs b/src-tauri/crates/business/src/services/tags.rs new file mode 100644 index 0000000..0f782a8 --- /dev/null +++ b/src-tauri/crates/business/src/services/tags.rs @@ -0,0 +1,64 @@ +use uuid::Uuid; + +use crate::dto::TagDto; +use crate::entitys::tags::{CreateTagRequest, UpdateTagRequest}; +use crate::models; +use crate::svc_ctx::SvcCtx; + +/// 创建标签 +pub async fn create_tag_service( + svc_ctx: &SvcCtx, + user_uuid: Uuid, + team_uuid: Option, + payload: &CreateTagRequest, +) -> Result { + models::insert_tag( + &svc_ctx.db, + user_uuid, + team_uuid, + &payload.name, + payload.color.as_deref(), + ) + .await + .map_err(|e| e.to_string()) +} + +/// 获取标签列表 +pub async fn get_tags_service( + svc_ctx: &SvcCtx, + user_uuid: Uuid, + team_uuid: Option, +) -> Result, String> { + models::fetch_tags(&svc_ctx.db, team_uuid, user_uuid) + .await + .map_err(|e| e.to_string()) +} + +/// 获取标签详情 +pub async fn get_tag_service(svc_ctx: &SvcCtx, tag_uuid: Uuid) -> Result { + models::fetch_tag_by_uuid(&svc_ctx.db, tag_uuid) + .await + .map_err(|e| e.to_string())? + .ok_or_else(|| "标签不存在".to_string()) +} + +/// 更新标签 +pub async fn update_tag_service( + svc_ctx: &SvcCtx, + payload: &UpdateTagRequest, +) -> Result<(), String> { + models::update_tag( + &svc_ctx.db, + payload.uuid, + payload.name.as_deref(), + payload.color.as_deref(), + payload.sort_order, + ) + .await + .map_err(|e| e.to_string()) +} + +/// 删除标签 +pub async fn delete_tag_service(svc_ctx: &SvcCtx, tag_uuid: Uuid) -> Result<(), String> { + models::delete_tag(&svc_ctx.db, tag_uuid).await.map_err(|e| e.to_string()) +} diff --git a/src-tauri/crates/business/src/services/teams.rs b/src-tauri/crates/business/src/services/teams.rs new file mode 100644 index 0000000..0859ef1 --- /dev/null +++ b/src-tauri/crates/business/src/services/teams.rs @@ -0,0 +1,495 @@ +use uuid::Uuid; + +use crate::dto::{TeamDto, TeamInvitationDto, TeamMemberDto}; +use crate::entitys::{ + CreateTeamRequest, InviteMemberRequest, ListTeamMembersRequest, SwitchTeamRequest, + UpdateMemberRoleRequest, UpdateTeamRequest, +}; +use crate::models; +use crate::svc_ctx::SvcCtx; + +/// 创建团队 +pub async fn create_team_service( + svc_ctx: &SvcCtx, + user_uuid: Uuid, + payload: &CreateTeamRequest, +) -> Result { + models::insert_team(&svc_ctx.db, user_uuid, payload) + .await + .map_err(|e| e.to_string()) +} + +/// 获取团队详情 +pub async fn get_team_service(svc_ctx: &SvcCtx, team_uuid: Uuid) -> Result { + models::fetch_team_by_uuid(&svc_ctx.db, team_uuid) + .await + .map_err(|e| e.to_string())? + .ok_or_else(|| "团队不存在".to_string()) +} + +/// 获取用户所属的所有团队(工作空间级别) +pub async fn get_user_teams_service( + svc_ctx: &SvcCtx, + workspace_uuid: Uuid, + user_uuid: Uuid, +) -> Result, String> { + models::fetch_user_teams(&svc_ctx.db, workspace_uuid, user_uuid) + .await + .map_err(|e| e.to_string()) +} + +/// 获取用户当前团队 +pub async fn get_current_team_service( + svc_ctx: &SvcCtx, + user_uuid: Uuid, +) -> Result, String> { + models::fetch_user_current_team(&svc_ctx.db, user_uuid) + .await + .map_err(|e| e.to_string()) +} + +/// 切换团队 +pub async fn switch_team_service( + svc_ctx: &SvcCtx, + workspace_uuid: Uuid, + user_uuid: Uuid, + payload: &SwitchTeamRequest, +) -> Result<(), String> { + // 验证用户是否是团队成员(工作空间级别) + let member = models::fetch_team_member(&svc_ctx.db, workspace_uuid, payload.team_uuid, user_uuid) + .await + .map_err(|e| e.to_string())?; + + if member.is_none() { + return Err("您不是该团队的成员".to_string()); + } + + models::set_user_current_team(&svc_ctx.db, user_uuid, payload.team_uuid) + .await + .map_err(|e| e.to_string()) +} + +/// 更新团队信息 +pub async fn update_team_service( + svc_ctx: &SvcCtx, + workspace_uuid: Uuid, + user_uuid: Uuid, + payload: &UpdateTeamRequest, +) -> Result<(), String> { + // 检查权限(仅所有者和管理员可以更新) + let member = models::fetch_team_member(&svc_ctx.db, workspace_uuid, payload.uuid, user_uuid) + .await + .map_err(|e| e.to_string())? + .ok_or_else(|| "您不是该团队的成员".to_string())?; + + if member.role != "owner" && member.role != "admin" { + return Err("权限不足".to_string()); + } + + models::update_team( + &svc_ctx.db, + payload.uuid, + payload.name.as_deref(), + payload.description.as_deref(), + payload.avatar_hash.as_deref(), + ) + .await + .map_err(|e| e.to_string()) +} + +/// 获取团队成员列表 +pub async fn get_team_members_service( + svc_ctx: &SvcCtx, + team_uuid: Uuid, + payload: &ListTeamMembersRequest, +) -> Result<(Vec, i64), String> { + let offset = (payload.pagination.page - 1) * payload.pagination.page_size; + + // 提取筛选条件 + let keyword = payload.filters.as_ref().and_then(|f| f.keyword.as_deref()); + let role = payload.filters.as_ref().and_then(|f| f.role.as_deref()); + let status = payload.filters.as_ref().and_then(|f| f.status.as_deref()); + + let members = models::fetch_team_members( + &svc_ctx.db, + team_uuid, + offset, + payload.pagination.page_size, + keyword, + role, + status, + ) + .await + .map_err(|e| e.to_string())?; + + let total = models::fetch_team_member_count(&svc_ctx.db, team_uuid, keyword, role, status) + .await + .map_err(|e| e.to_string())?; + + Ok((members, total)) +} + +/// 邀请成员 +pub async fn invite_member_service( + svc_ctx: &SvcCtx, + workspace_uuid: Uuid, + team_uuid: Uuid, + inviter_uuid: Uuid, + payload: &InviteMemberRequest, +) -> Result { + // 检查邀请者权限(工作空间级别) + let member = models::fetch_team_member(&svc_ctx.db, workspace_uuid, team_uuid, inviter_uuid) + .await + .map_err(|e| e.to_string())? + .ok_or_else(|| "您不是该团队的成员".to_string())?; + + if member.role != "owner" && member.role != "admin" { + return Err("权限不足".to_string()); + } + + // 检查是否已有待处理的邀请 + if models::has_pending_invitation(&svc_ctx.db, team_uuid, &payload.email) + .await + .map_err(|e| e.to_string())? + { + return Err("该邮箱已有待处理的邀请".to_string()); + } + + // 检查用户是否已是成员 + let invited_user_info = + crate::models::user::fetch_user_info_by_email(&svc_ctx.db, &payload.email) + .await + .map_err(|e| e.to_string())?; + + // 检查用户是否存在 + let user_info = invited_user_info.ok_or_else(|| "该邮箱未注册,请先注册账号".to_string())?; + + // 检查用户是否已是团队成员 + if models::fetch_team_member(&svc_ctx.db, workspace_uuid, team_uuid, user_info.user_uuid) + .await + .map_err(|e| e.to_string())? + .is_some() + { + return Err("该用户已是团队成员".to_string()); + } + + // 获取团队信息 + let team = models::fetch_team_by_uuid(&svc_ctx.db, team_uuid) + .await + .map_err(|e| e.to_string())? + .ok_or_else(|| "团队不存在".to_string())?; + + // 获取邀请者信息 + let inviter_info = crate::models::user::fetch_user_info_by_uuid(&svc_ctx.db, inviter_uuid) + .await + .map_err(|e| e.to_string())? + .ok_or_else(|| "邀请者信息不存在".to_string())?; + + // 生成邀请 token + let token = uuid::Uuid::new_v4().to_string(); + let expires_at = chrono::Utc::now() + chrono::Duration::days(7); + + // 插入邀请记录 + let invitation_uuid = models::insert_team_invitation( + &svc_ctx.db, + team_uuid, + &payload.email, + &payload.role, + inviter_uuid, + &token, + expires_at, + ) + .await + .map_err(|e| e.to_string())?; + + // 1. 发送邮件通知 + let smtp_config = svc_ctx.config.clone().smtp.ok_or_else(|| "邮箱配置不存在".to_string())?; + + let inviter_name: &str = + inviter_info.nickname.as_deref().unwrap_or_else(|| inviter_info.email.as_str()); + let inviter_email: &str = inviter_info.email.as_str(); + + let email_title = format!("团队邀请:{} 邀请您加入团队", inviter_name); + let email_body = format!( + "

团队邀请

您好,

{}({})邀请您加入团队 {}

邀请角色:{}

邀请有效期:7 天

接受邀请

如果您不想接受此邀请,可以忽略此邮件。

", + inviter_name, inviter_email, team.name, payload.role + ); + + // 发送邮件(失败不影响邀请流程) + let _ = crate::utils::send_email( + &smtp_config.smtp_username, + &smtp_config.smtp_password, + &smtp_config.smtp_server, + &payload.email, + &email_title, + &email_body, + ); + + // 2. 如果用户已注册,发送团队邀请消息通知 + let _ = crate::services::messages::create_message_service( + svc_ctx, + Some(inviter_uuid), + &crate::entitys::CreateMessageRequest { + message_type: "team_invitation".to_string(), + title: format!("{} 邀请您加入团队 {}", inviter_name, team.name), + content: Some(format!( + "{}({})邀请您加入团队 {},角色:{}", + inviter_name, inviter_email, team.name, payload.role + )), + recipient_uuids: vec![user_info.user_uuid], + recipient_type: "single".to_string(), + related_type: Some("team".to_string()), + related_uuid: Some(team_uuid), + priority: Some("normal".to_string()), + metadata: Some(serde_json::json!({ + "invitation_uuid": invitation_uuid.to_string(), + "team_name": team.name, + "role": payload.role, + "inviter_name": inviter_name, + "inviter_email": inviter_email, + "token": token, + })), + }, + ) + .await; + // 消息创建失败不影响邀请流程,使用 _ 忽略错误 + + Ok(invitation_uuid) +} + +/// 取消邀请 +pub async fn cancel_invitation_service( + svc_ctx: &SvcCtx, + invitation_uuid: Uuid, +) -> Result<(), String> { + models::cancel_team_invitation(&svc_ctx.db, invitation_uuid) + .await + .map_err(|e| e.to_string()) +} + +/// 接受邀请 +pub async fn accept_invitation_service( + svc_ctx: &SvcCtx, + user_uuid: Uuid, + token: &str, +) -> Result { + // 查找邀请 + let invitation = models::fetch_team_invitation_by_token(&svc_ctx.db, token) + .await + .map_err(|e| e.to_string())? + .ok_or_else(|| "邀请不存在或已过期".to_string())?; + + // 获取被邀请用户的当前工作空间 + let user_workspace_uuid = crate::models::user::fetch_user_current_workspace(&svc_ctx.db, user_uuid) + .await + .map_err(|e| e.to_string())? + .ok_or_else(|| "请先选择工作空间".to_string())?; + + // 检查用户是否已经是该工作空间的团队成员(如果是,则不需要检查配额) + let existing_member = models::fetch_team_member( + &svc_ctx.db, + user_workspace_uuid, + invitation.team_uuid, + user_uuid, + ) + .await + .map_err(|e| e.to_string())?; + + // 如果用户还不是该工作空间的任何团队的成员,需要检查配额 + if existing_member.is_none() { + // 检查工作空间成员配额是否充足 + let quota_available = models::check_quota(&svc_ctx.db, user_workspace_uuid, "team_members") + .await + .map_err(|e| e.to_string())?; + if !quota_available { + return Err("工作空间成员配额不足,无法接受邀请".to_string()); + } + } + + // 使用 accept_team_invitation 函数处理整个流程 + let team_uuid = models::accept_team_invitation(&svc_ctx.db, invitation.uuid, user_uuid, user_workspace_uuid) + .await + .map_err(|e| e.to_string())?; + + // 更新成员配额(重新计算所有团队的活跃成员总数) + // 注意:只有在用户之前不是该工作空间的成员时才需要更新配额 + if existing_member.is_none() { + models::update_used_team_members(&svc_ctx.db, user_workspace_uuid) + .await + .map_err(|e| format!("更新配额失败: {}", e))?; + } + + Ok(team_uuid) +} + +/// 拒绝邀请 +pub async fn reject_invitation_service( + svc_ctx: &SvcCtx, + user_uuid: Uuid, + token: &str, +) -> Result<(), String> { + // 查找邀请 + let invitation = models::fetch_team_invitation_by_token(&svc_ctx.db, token) + .await + .map_err(|e| e.to_string())? + .ok_or_else(|| "邀请不存在或已过期".to_string())?; + + // 使用 reject_team_invitation 函数处理整个流程 + models::reject_team_invitation(&svc_ctx.db, invitation.uuid, user_uuid) + .await + .map_err(|e| e.to_string()) +} + +/// 更新成员角色 +pub async fn update_member_role_service( + svc_ctx: &SvcCtx, + workspace_uuid: Uuid, + team_uuid: Uuid, + operator_uuid: Uuid, + payload: &UpdateMemberRoleRequest, +) -> Result { + // 检查操作者权限(工作空间级别) + let operator = models::fetch_team_member(&svc_ctx.db, workspace_uuid, team_uuid, operator_uuid) + .await + .map_err(|e| e.to_string())? + .ok_or_else(|| "您不是该团队的成员".to_string())?; + + if operator.role != "owner" && operator.role != "admin" { + return Err("权限不足".to_string()); + } + + // 不能修改所有者角色 + let target = models::fetch_team_member(&svc_ctx.db, workspace_uuid, team_uuid, payload.member_uuid) + .await + .map_err(|e| e.to_string())? + .ok_or_else(|| "成员不存在".to_string())?; + + if target.role == "owner" { + return Err("不能修改所有者角色".to_string()); + } + + // 管理员不能设置其他管理员 + if operator.role == "admin" && payload.role == "admin" { + return Err("管理员不能设置其他管理员".to_string()); + } + + models::update_member_role(&svc_ctx.db, team_uuid, payload.member_uuid, &payload.role) + .await + .map_err(|e| e.to_string())?; + + // 返回更新后的成员信息 + models::fetch_team_member(&svc_ctx.db, workspace_uuid, team_uuid, payload.member_uuid) + .await + .map_err(|e| e.to_string())? + .ok_or_else(|| "获取更新后的成员信息失败".to_string()) +} + +/// 移除成员 +pub async fn remove_member_service( + svc_ctx: &SvcCtx, + workspace_uuid: Uuid, + team_uuid: Uuid, + operator_uuid: Uuid, + member_uuid: Uuid, +) -> Result<(), String> { + // 检查操作者权限(工作空间级别) + let operator = models::fetch_team_member(&svc_ctx.db, workspace_uuid, team_uuid, operator_uuid) + .await + .map_err(|e| e.to_string())? + .ok_or_else(|| "您不是该团队的成员".to_string())?; + + if operator.role != "owner" && operator.role != "admin" { + return Err("权限不足".to_string()); + } + + // 不能移除所有者 + let target = models::fetch_team_member(&svc_ctx.db, workspace_uuid, team_uuid, member_uuid) + .await + .map_err(|e| e.to_string())? + .ok_or_else(|| "成员不存在".to_string())?; + + if target.role == "owner" { + return Err("不能移除所有者".to_string()); + } + + // 管理员不能移除其他管理员 + if operator.role == "admin" && target.role == "admin" { + return Err("管理员不能移除其他管理员".to_string()); + } + + models::remove_team_member(&svc_ctx.db, team_uuid, member_uuid) + .await + .map_err(|e| e.to_string())?; + + // 更新成员配额(重新计算所有团队的活跃成员总数) + models::update_used_team_members(&svc_ctx.db, workspace_uuid) + .await + .map_err(|e| format!("更新配额失败: {}", e))?; + + Ok(()) +} + +/// 退出团队 +pub async fn leave_team_service( + svc_ctx: &SvcCtx, + workspace_uuid: Uuid, + team_uuid: Uuid, + user_uuid: Uuid, +) -> Result<(), String> { + // 检查用户是否是团队成员(工作空间级别) + let member = models::fetch_team_member(&svc_ctx.db, workspace_uuid, team_uuid, user_uuid) + .await + .map_err(|e| e.to_string())? + .ok_or_else(|| "您不是该团队的成员".to_string())?; + + // 所有者不能退出团队 + if member.role == "owner" { + return Err("团队所有者不能退出团队,请先转移所有权或解散团队".to_string()); + } + + // 移除成员 + models::remove_team_member(&svc_ctx.db, team_uuid, user_uuid) + .await + .map_err(|e| e.to_string())?; + + // 更新成员配额(重新计算所有团队的活跃成员总数) + models::update_used_team_members(&svc_ctx.db, workspace_uuid) + .await + .map_err(|e| format!("更新配额失败: {}", e))?; + + // 如果当前团队是用户的活跃团队,则需要切换到其他团队 + let current_team = models::fetch_user_current_team(&svc_ctx.db, user_uuid) + .await + .map_err(|e| e.to_string())?; + + if current_team == Some(team_uuid) { + // 获取用户的其他团队(工作空间级别) + let teams = models::fetch_user_teams(&svc_ctx.db, workspace_uuid, user_uuid) + .await + .map_err(|e| e.to_string())?; + + if let Some(first_team) = teams.first() { + // 切换到第一个可用团队 + models::set_user_current_team(&svc_ctx.db, user_uuid, first_team.uuid) + .await + .map_err(|e| e.to_string())?; + } else { + // 没有其他团队,清除当前团队 + models::clear_user_current_team(&svc_ctx.db, user_uuid) + .await + .map_err(|e| e.to_string())?; + } + } + + Ok(()) +} + +/// 获取待处理邀请列表 +pub async fn get_pending_invitations_service( + svc_ctx: &SvcCtx, + team_uuid: Uuid, +) -> Result, String> { + models::fetch_pending_invitations(&svc_ctx.db, team_uuid) + .await + .map_err(|e| e.to_string()) +} diff --git a/src-tauri/crates/business/src/services/templates.rs b/src-tauri/crates/business/src/services/templates.rs new file mode 100644 index 0000000..27f9d12 --- /dev/null +++ b/src-tauri/crates/business/src/services/templates.rs @@ -0,0 +1,381 @@ +use uuid::Uuid; + +use crate::dto::TemplateDto; +use crate::entitys::{ + ApplyTemplateRequest, AssociationsStatus, CreateFromTemplateRequest, CreateTemplateRequest, + TemplateDetailResponse, UpdateTemplateRequest, +}; +use crate::models; +use crate::services; +use crate::svc_ctx::SvcCtx; + +/// 创建模板 +pub async fn create_template_service( + svc_ctx: &SvcCtx, + user_uuid: Uuid, + team_uuid: Option, + payload: &CreateTemplateRequest, +) -> Result { + // 确定要存储的完整数据 + let environment_data_json: serde_json::Value = if let Some(env_uuid) = payload.environment_uuid + { + // 如果提供了环境 UUID,先查询环境获取 workspace_uuid 和 team_uuid + let env = models::fetch_environment_by_uuid_unfiltered(&svc_ctx.db, env_uuid) + .await + .map_err(|e| format!("查询环境失败: {}", e))? + .ok_or_else(|| "环境不存在".to_string())?; + + // 获取完整的环境详情(带权限检查) + let env_detail = services::environments::get_environment_detail_service( + svc_ctx, + env.workspace_uuid, + env.team_uuid, + user_uuid, + env_uuid, + ) + .await + .map_err(|e| format!("获取环境详情失败: {}", e))?; + serde_json::to_value(&env_detail).map_err(|e| format!("序列化环境详情失败: {}", e))? + } else if let Some(ref env_data) = payload.environment_data { + // 如果直接提供了环境详情数据,使用它 + env_data.clone() + } else { + return Err("必须提供 environment_uuid 或 environment_data 之一".to_string()); + }; + + // 从环境数据中提取摘要信息(从标准结构 config.window_info 中提取) + let system_info = environment_data_json + .get("config") + .and_then(|v| v.get("window_info")) + .and_then(|v| v.get("system")) + .and_then(|v| v.as_str()) + .map(|s| s.to_string()); + + let kernel_info = environment_data_json + .get("config") + .and_then(|v| v.get("window_info")) + .and_then(|v| v.get("kernel")) + .and_then(|v| v.as_str()) + .map(|s| s.to_string()); + + models::insert_template( + &svc_ctx.db, + user_uuid, + team_uuid, + &payload.name, + payload.description.as_deref(), + payload.is_public.unwrap_or(false), + system_info.as_deref(), + kernel_info.as_deref(), + &environment_data_json, + ) + .await + .map_err(|e| e.to_string()) +} + +/// 获取模板列表 +pub async fn get_templates_service( + svc_ctx: &SvcCtx, + user_uuid: Uuid, + team_uuid: Option, + is_public: Option, + page: i64, + page_size: i64, +) -> Result<(Vec, i64), String> { + let offset = (page - 1) * page_size; + + let templates = models::fetch_templates( + &svc_ctx.db, + team_uuid, + user_uuid, + is_public, + offset, + page_size, + ) + .await + .map_err(|e| e.to_string())?; + + let total = models::fetch_templates_count(&svc_ctx.db, team_uuid, user_uuid, is_public) + .await + .map_err(|e| e.to_string())?; + + Ok((templates, total)) +} + +/// 获取模板详情 +pub async fn get_template_service( + svc_ctx: &SvcCtx, + template_uuid: Uuid, + for_create: bool, +) -> Result { + let template = models::fetch_template_by_uuid(&svc_ctx.db, template_uuid) + .await + .map_err(|e| e.to_string())? + .ok_or_else(|| "模板不存在".to_string())?; + + // 如果 for_create 为 true,检查关联数据是否存在 + let associations_status = if for_create { + Some(check_template_associations(svc_ctx, &template).await?) + } else { + None + }; + + Ok(TemplateDetailResponse { + template, + associations_status, + }) +} + +/// 检查模板关联数据是否存在 +async fn check_template_associations( + svc_ctx: &SvcCtx, + template: &TemplateDto, +) -> Result { + // 解析模板中的环境详情数据 + let env_detail: crate::entitys::EnvironmentDetailResponse = + serde_json::from_value(template.config_json.clone()) + .map_err(|e| format!("解析模板数据失败: {}", e))?; + + // 检查分组是否存在 + let group_exists = if let Some(group_uuid) = env_detail.environment.group_uuid { + models::fetch_group_by_uuid(&svc_ctx.db, group_uuid) + .await + .map_err(|e| e.to_string())? + .is_some() + } else { + false + }; + + // 检查标签是否存在 + let mut tags_exist = std::collections::HashMap::new(); + for tag in &env_detail.tags { + let exists = models::fetch_tag_by_uuid(&svc_ctx.db, tag.uuid) + .await + .map_err(|e| e.to_string())? + .is_some(); + tags_exist.insert(tag.uuid, exists); + } + + // 检查账号是否存在 + let mut accounts_exist = std::collections::HashMap::new(); + for account in &env_detail.accounts { + let exists = models::fetch_platform_account_by_uuid(&svc_ctx.db, account.uuid) + .await + .map_err(|e| e.to_string())? + .is_some(); + accounts_exist.insert(account.uuid, exists); + } + + // 检查代理是否存在 + let proxy_exists = if let Some(proxy_uuid) = env_detail.environment.proxy_uuid { + models::fetch_proxy_by_uuid(&svc_ctx.db, proxy_uuid) + .await + .map_err(|e| e.to_string())? + .is_some() + } else { + false + }; + + Ok(AssociationsStatus { + group_exists, + tags_exist, + accounts_exist, + proxy_exists, + }) +} + +/// 更新模板 +pub async fn update_template_service( + svc_ctx: &SvcCtx, + payload: &UpdateTemplateRequest, +) -> Result<(), String> { + models::update_template( + &svc_ctx.db, + payload.uuid, + payload.name.as_deref(), + payload.description.as_deref(), + payload.is_public, + payload.config_json.as_ref(), + ) + .await + .map_err(|e| e.to_string()) +} + +/// 删除模板 +pub async fn delete_template_service(svc_ctx: &SvcCtx, template_uuid: Uuid) -> Result<(), String> { + models::delete_template(&svc_ctx.db, template_uuid) + .await + .map_err(|e| e.to_string()) +} + +/// 应用模板到现有环境(更新环境配置) +pub async fn apply_template_service( + svc_ctx: &SvcCtx, + workspace_uuid: Uuid, + team_uuid: Uuid, + user_uuid: Uuid, + payload: &ApplyTemplateRequest, +) -> Result<(), String> { + // 获取模板配置(不需要检查关联数据) + let template_response = get_template_service(svc_ctx, payload.template_uuid, false).await?; + let template = &template_response.template; + + // 解析模板中的环境详情数据 + // 先转换为字符串再反序列化,确保类型正确 + let env_detail: crate::entitys::EnvironmentDetailResponse = serde_json::from_str( + &serde_json::to_string(&template.config_json) + .map_err(|e| format!("序列化模板数据失败: {}", e))?, + ) + .map_err(|e| format!("解析模板数据失败: {}", e))?; + + // 更新环境配置 + let update_req = crate::entitys::UpdateEnvironmentRequest { + uuid: payload.environment_uuid, + name: None, + description: None, + group_uuid: None, + cookies: Some( + env_detail + .cookies + .iter() + .map(|item| crate::entitys::CookieGroupInput { + site: item.site.clone(), + cookie_text: item.cookie_text.clone(), + }) + .collect(), + ), + urls: Some( + env_detail + .urls + .iter() + .map(|item| crate::entitys::UrlInput { + url: item.url.clone(), + title: item.title.clone(), + sort_order: item.sort_order, + }) + .collect(), + ), + config: env_detail.config.map(|config| crate::entitys::EnvironmentConfigRequest { + window_info: config.window_info, + basic_settings: config.basic_settings, + fingerprint_settings: config.fingerprint_settings, + device_settings: config.device_settings, + preference_settings: config.preference_settings, + project_metadata: config.project_metadata, + }), + }; + + services::environments::update_environment_service( + svc_ctx, + workspace_uuid, + team_uuid, + user_uuid, + &update_req, + ) + .await + .map_err(|e| e.to_string())?; + + // 增加模板使用次数 + let _ = models::increment_template_usage(&svc_ctx.db, payload.template_uuid).await; + + Ok(()) +} + +/// 从模板创建环境 +pub async fn create_from_template_service( + svc_ctx: &SvcCtx, + user_uuid: Uuid, + workspace_uuid: Uuid, + team_uuid: Uuid, + payload: &CreateFromTemplateRequest, +) -> Result { + // 获取模板(不需要检查关联数据,因为这是直接创建,不经过前端表单) + let template_response = get_template_service(svc_ctx, payload.template_uuid, false).await?; + let template = &template_response.template; + + // 解析模板中的环境详情数据 + // 先转换为字符串再反序列化,确保类型正确 + let env_detail: crate::entitys::EnvironmentDetailResponse = serde_json::from_str( + &serde_json::to_string(&template.config_json) + .map_err(|e| format!("序列化模板数据失败: {}", e))?, + ) + .map_err(|e| format!("解析模板数据失败: {}", e))?; + + // 使用提供的参数覆盖模板中的值 + let env_name = payload.name.as_deref().unwrap_or(&env_detail.environment.name); + let env_description = + payload.description.as_ref().or(env_detail.environment.description.as_ref()); + let group_uuid = payload.group_uuid.or(env_detail.environment.group_uuid); + let proxy_uuid = env_detail.environment.proxy_uuid; + + // 提取标签 UUIDs + let tag_uuids: Vec = env_detail.tags.iter().map(|tag| tag.uuid).collect(); + + // 提取账号 UUIDs + let account_uuids: Vec = env_detail.accounts.iter().map(|acc| acc.uuid).collect(); + + // 构建创建环境请求 + let config = env_detail.config.ok_or_else(|| "模板中缺少配置信息".to_string())?; + + let create_req = crate::entitys::CreateEnvironmentRequest { + name: env_name.to_string(), + description: env_description.cloned(), + group_uuid, + tag_uuids: if tag_uuids.is_empty() { + None + } else { + Some(tag_uuids) + }, + account_uuids: if account_uuids.is_empty() { + None + } else { + Some(account_uuids) + }, + proxy_uuid, + cookies: Some( + env_detail + .cookies + .iter() + .map(|item| crate::entitys::CookieGroupInput { + site: item.site.clone(), + cookie_text: item.cookie_text.clone(), + }) + .collect(), + ), + urls: Some( + env_detail + .urls + .iter() + .map(|item| crate::entitys::UrlInput { + url: item.url.clone(), + title: item.title.clone(), + sort_order: item.sort_order, + }) + .collect(), + ), + config: crate::entitys::EnvironmentConfigRequest { + window_info: config.window_info, + basic_settings: config.basic_settings, + fingerprint_settings: config.fingerprint_settings, + device_settings: config.device_settings, + preference_settings: config.preference_settings, + project_metadata: config.project_metadata, + }, + }; + + // 创建环境 + let env_uuid = services::environments::create_environment_service( + svc_ctx, + user_uuid, + workspace_uuid, + team_uuid, + &create_req, + ) + .await + .map_err(|e| e.to_string())?; + + // 增加模板使用次数 + let _ = models::increment_template_usage(&svc_ctx.db, payload.template_uuid).await; + + Ok(env_uuid) +} diff --git a/src-tauri/crates/business/src/services/time.rs b/src-tauri/crates/business/src/services/time.rs new file mode 100644 index 0000000..2782dde --- /dev/null +++ b/src-tauri/crates/business/src/services/time.rs @@ -0,0 +1,4 @@ +pub fn now_service() -> String { + let now = chrono::Utc::now().to_string(); + now +} diff --git a/src-tauri/crates/business/src/services/users.rs b/src-tauri/crates/business/src/services/users.rs new file mode 100644 index 0000000..f4c11cd --- /dev/null +++ b/src-tauri/crates/business/src/services/users.rs @@ -0,0 +1,498 @@ +use uuid::Uuid; + +use crate::caches::{ + get_register_code, get_reset_password_code, get_user_public_key, set_register_code, + set_reset_password_code, set_user_public_key, +}; +use crate::entitys::{ + LoginRequest, LoginResponse, RegisterRequest, RegisterResponse, ResetPasswordRequest, + UpdatePasswordRequest, UpdateUserRequest, UserResponse, VerifyPasswordRequest, + VerifyPasswordResponse, +}; +use crate::models::{ + billing, + user::{ + create_user_with_info, fetch_user_by_uuid, fetch_user_info_by_email, + fetch_user_info_by_uuid, update_password, update_user_info, + }, +}; +use crate::services::messages; +use crate::svc_ctx::SvcCtx; +use crate::utils::{ + encryption_password, generate_token, random_six_number_code, send_email, verify_password, +}; + +/// 通用登录逻辑(内部辅助函数) +/// +/// 处理登录、注册、记住密码登录的共同逻辑: +/// - 生成 Token +/// - 保存公钥到缓存 +/// - 获取用户信息 +async fn common_login_logic( + svc_ctx: &SvcCtx, + user_uuid: Uuid, + public_key: Option<&String>, +) -> Result<(String, String, Option), anyhow::Error> { + let secret = svc_ctx.config.app.secret.as_bytes(); + + // 1. 生成 Token + let access_token = generate_token(&user_uuid.to_string(), 60 * 60 * 2, secret)?; // 2 小时 + let refresh_token = generate_token(&user_uuid.to_string(), 60 * 60 * 24 * 7, secret)?; // 7 天 + + // 2. 如果提供了公钥,保存到缓存 + if let Some(ref public_key) = public_key { + if !public_key.is_empty() { + let _ = set_user_public_key(svc_ctx, &user_uuid, public_key).await; + // 保存失败不影响登录流程,只记录错误 + } + } + + // 3. 获取用户信息 + let user_info = get_current_user_service(svc_ctx, user_uuid).await.ok(); + + Ok((access_token, refresh_token, user_info)) +} + +/// 用户注册服务 +/// +/// # Arguments +/// * `svc_ctx` - 服务上下文 +/// * `payload` - 注册请求数据 +/// +/// # Returns +/// 返回注册响应 +pub async fn register_service( + svc_ctx: &SvcCtx, + payload: &RegisterRequest, +) -> Result { + let pool = &svc_ctx.db; + // 1. 验证验证码 + let cached_code = get_register_code(svc_ctx, &payload.email).await?; + if cached_code != Some(payload.code.clone()) { + return Err(anyhow::anyhow!("验证码错误或已过期")); + } + + // 2. 检查邮箱是否已存在 + let existing_user = fetch_user_info_by_email(pool, &payload.email).await?; + if existing_user.is_some() { + return Err(anyhow::anyhow!("邮箱已被注册")); + } + + // 3. 加密密码 + let password_hash = encryption_password(&payload.password)?; + + // 4. 生成用户 ID(使用 UUID 的前8位作为用户 ID) + let user_id = format!("USER{}", Uuid::new_v4().to_string()[..8].to_uppercase()); + + // 5. 创建用户(传入默认配额配置) + let quota = &svc_ctx.config.workspace_quota.default; + let user_uuid = create_user_with_info(pool, user_id, payload, &password_hash, quota).await?; + + // 5.1. 自动发放新用户优惠券(失败不影响注册流程) + let _ = issue_welcome_coupons(svc_ctx, user_uuid).await; + + // 6. 执行通用登录逻辑 + let (access_token, refresh_token, user_info) = + common_login_logic(svc_ctx, user_uuid, payload.public_secret_key.as_ref()).await?; + + // 7. 发送欢迎系统通知 + let _ = messages::create_message_service( + svc_ctx, + None, // 系统消息,无发送者 + &crate::entitys::CreateMessageRequest { + message_type: "system_notification".to_string(), + title: "欢迎加入".to_string(), + content: Some("感谢您的注册!我们很高兴您能加入我们。祝您使用愉快!".to_string()), + recipient_uuids: vec![user_uuid], + recipient_type: "single".to_string(), + related_type: None, + related_uuid: None, + priority: Some("normal".to_string()), + metadata: None, + }, + ) + .await; + // 注意:消息创建失败不影响注册流程,使用 _ 忽略错误 + + Ok(RegisterResponse { + access_token, + refresh_token, + user_info, + }) +} + +/// 自动发放新用户优惠券 +/// +/// 在用户注册时自动发放欢迎优惠券,失败不影响注册流程 +async fn issue_welcome_coupons(svc_ctx: &SvcCtx, user_uuid: Uuid) -> Result<(), anyhow::Error> { + // 新用户优惠券代码列表(可根据需要配置) + let welcome_coupon_codes = vec!["WELCOME10", "FIRST10"]; + + for coupon_code in welcome_coupon_codes { + // 查询优惠券 + if let Some(coupon) = billing::fetch_coupon_by_code(&svc_ctx.db, coupon_code).await? { + // 发放优惠券给用户(继承优惠券的过期时间) + let _ = billing::insert_user_coupon( + &svc_ctx.db, + user_uuid, + coupon.uuid, + coupon.valid_until, + ) + .await; + // 注意:如果用户已拥有该优惠券(重复发放),忽略错误 + } + } + + Ok(()) +} + +/// 用户登录服务(统一处理两种登录方式) +/// +/// # Arguments +/// * `svc_ctx` - 服务上下文 +/// * `payload` - 登录请求数据 +/// +/// # Returns +/// 返回登录响应 +pub async fn login_service( + svc_ctx: &SvcCtx, + payload: LoginRequest, +) -> Result { + let pool = &svc_ctx.db; + let (user_uuid, public_secret_key) = match payload { + LoginRequest::Basic(data) => { + // 基本登录:验证邮箱和密码 + // 1. 查询用户 + let user_info = fetch_user_info_by_email(pool, &data.email) + .await? + .ok_or_else(|| anyhow::anyhow!("账号不存在"))?; + + // 2. 验证密码 + if !verify_password(&data.password, &user_info.password) { + return Err(anyhow::anyhow!("密码错误")); + } + + // 3. 检查用户状态 + if user_info.status != "active" { + return Err(anyhow::anyhow!("用户已被禁用")); + } + + (user_info.user_uuid, data.public_secret_key) + } + LoginRequest::Remember(data) => { + // 记住密码登录:验证 refresh_token + // 1. 验证 refresh_token + let secret = svc_ctx.config.app.secret.as_bytes(); + let user_uuid_str = verify_token_service(&data.refresh_token, secret)?; + let user_uuid = Uuid::parse_str(&user_uuid_str)?; + + // 2. 查询用户信息 + let user_info = fetch_user_info_by_uuid(pool, user_uuid) + .await? + .ok_or_else(|| anyhow::anyhow!("账号不存在"))?; + + // 3. 验证邮箱是否匹配 + if user_info.email != data.email { + return Err(anyhow::anyhow!("邮箱不匹配")); + } + + // 4. 检查用户状态 + if user_info.status != "active" { + return Err(anyhow::anyhow!("用户已被禁用")); + } + + (user_uuid, data.public_secret_key) + } + }; + + // 执行通用登录逻辑 + let (access_token, refresh_token, user_response) = + common_login_logic(svc_ctx, user_uuid, public_secret_key.as_ref()).await?; + + Ok(LoginResponse { + access_token, + refresh_token, + user_info: user_response, + }) +} + +/// 验证 Token 服务 +/// +/// # Arguments +/// * `token` - 要验证的 token +/// * `secret` - JWT 密钥 +/// +/// # Returns +/// 返回用户 UUID +pub fn verify_token_service(token: &str, secret: &[u8]) -> Result { + crate::utils::verify_token(token, secret) +} + +/// 刷新 Token 服务 +/// +/// # Arguments +/// * `pool` - 数据库连接池 +/// * `svc_ctx` - 服务上下文 +/// * `refresh_token` - 刷新令牌 +/// +/// # Returns +/// 返回登录响应 +pub async fn refresh_token_service( + svc_ctx: &SvcCtx, + refresh_token: &str, +) -> Result { + let pool = &svc_ctx.db; + // 1. 验证 refresh_token + let secret = svc_ctx.config.app.secret.as_bytes(); + let user_uuid_str = verify_token_service(refresh_token, secret)?; + + // 2. 验证用户是否存在 + let user_uuid = Uuid::parse_str(&user_uuid_str)?; + let user = fetch_user_by_uuid(pool, user_uuid).await?; + if user.is_none() { + return Err(anyhow::anyhow!("用户不存在")); + } + + // 3. 生成新的 Token + let access_token = generate_token(&user_uuid_str, 60 * 60 * 2, secret)?; // 2 小时 + let new_refresh_token = generate_token(&user_uuid_str, 60 * 60 * 24 * 7, secret)?; // 7 天 + + Ok(LoginResponse { + access_token, + refresh_token: new_refresh_token, + user_info: None, + }) +} + +/// 获取当前用户信息服务 +/// +/// # Arguments +/// * `svc_ctx` - 服务上下文 +/// * `user_uuid` - 用户 UUID +/// +/// # Returns +/// 返回用户信息响应(包含当前团队信息) +pub async fn get_current_user_service( + svc_ctx: &SvcCtx, + user_uuid: Uuid, +) -> Result { + let pool = &svc_ctx.db; + let user = fetch_user_by_uuid(pool, user_uuid) + .await? + .ok_or_else(|| anyhow::anyhow!("用户不存在"))?; + + let user_info = fetch_user_info_by_uuid(pool, user_uuid) + .await? + .ok_or_else(|| anyhow::anyhow!("用户信息不存在"))?; + + // 获取当前团队详细信息 + let current_team = if let Some(team_uuid) = user_info.current_team_uuid { + // 查询团队详细信息 + crate::models::teams::fetch_team_by_uuid(pool, team_uuid).await.ok().flatten() + } else { + None + }; + + // 获取当前工作空间详细信息 + let current_workspace = if let Some(workspace_uuid) = user_info.current_workspace_uuid { + // 查询工作空间详细信息 + crate::models::workspaces::fetch_workspace_by_uuid(pool, workspace_uuid) + .await + .ok() + .flatten() + } else { + None + }; + + let avatar_url = user_info.avatar_hash.as_ref().map(|hash| { + crate::utils::storage::get_objects::get_avatar_url( + &svc_ctx.config.storage.public_base_url, + &svc_ctx.config.storage.avatar_root, + hash, + ) + }); + + Ok(UserResponse { + uuid: user.uuid, + id: user.id, + nickname: user_info.nickname, + email: user_info.email, + phone: user_info.phone, + avatar_hash: user_info.avatar_hash, + avatar_url, + status: user_info.status, + created_at: user.created_at, + updated_at: user.updated_at, + current_team, + current_workspace, + }) +} + +/// 更新用户信息服务 +/// +/// # Arguments +/// * `pool` - 数据库连接池 +/// * `user_uuid` - 用户 UUID +/// * `payload` - 更新请求数据 +pub async fn update_user_service( + svc_ctx: &SvcCtx, + user_uuid: Uuid, + payload: &UpdateUserRequest, +) -> Result<(), anyhow::Error> { + let pool = &svc_ctx.db; + update_user_info(pool, user_uuid, payload).await?; + Ok(()) +} + +/// 修改密码服务 +/// +/// # Arguments +/// * `pool` - 数据库连接池 +/// * `user_uuid` - 用户 UUID +/// * `payload` - 修改密码请求数据 +pub async fn update_password_service( + svc_ctx: &SvcCtx, + user_uuid: Uuid, + payload: &UpdatePasswordRequest, +) -> Result<(), anyhow::Error> { + let pool = &svc_ctx.db; + // 1. 查询用户 + let user_info = fetch_user_info_by_uuid(pool, user_uuid) + .await? + .ok_or_else(|| anyhow::anyhow!("用户不存在"))?; + + // 2. 验证旧密码 + if !verify_password(&payload.old_password, &user_info.password) { + return Err(anyhow::anyhow!("原密码错误")); + } + + // 3. 加密新密码 + let password_hash = encryption_password(&payload.new_password)?; + + // 4. 更新密码 + update_password(pool, user_uuid, &password_hash).await?; + + Ok(()) +} + +/// 校验当前用户密码 +pub async fn verify_password_service( + svc_ctx: &SvcCtx, + user_uuid: Uuid, + payload: &VerifyPasswordRequest, +) -> Result { + let user_info = fetch_user_info_by_uuid(&svc_ctx.db, user_uuid) + .await? + .ok_or_else(|| anyhow::anyhow!("用户不存在"))?; + + if user_info.status != "active" { + return Err(anyhow::anyhow!("用户已被禁用")); + } + + Ok(VerifyPasswordResponse { + valid: verify_password(&payload.password, &user_info.password), + }) +} + +/// 重置密码服务 +/// +/// # Arguments +/// * `pool` - 数据库连接池 +/// * `svc_ctx` - 服务上下文 +/// * `payload` - 重置密码请求数据 +pub async fn reset_password_service( + svc_ctx: &SvcCtx, + payload: &ResetPasswordRequest, +) -> Result<(), anyhow::Error> { + let pool = &svc_ctx.db; + // 1. 验证验证码 + let cached_code = get_reset_password_code(svc_ctx, &payload.email).await?; + if cached_code != Some(payload.code.clone()) { + return Err(anyhow::anyhow!("验证码错误或已过期")); + } + + // 2. 查询用户 + let user_info = fetch_user_info_by_email(pool, &payload.email) + .await? + .ok_or_else(|| anyhow::anyhow!("用户不存在"))?; + + // 3. 加密新密码 + let password_hash = encryption_password(&payload.new_password)?; + + // 4. 更新密码 + update_password(pool, user_info.user_uuid, &password_hash).await?; + + Ok(()) +} + +/// 发送验证码服务 +/// +/// # Arguments +/// * `svc_ctx` - 服务上下文 +/// * `email` - 邮箱地址 +/// * `code_type` - 验证码类型(register 或 reset_password) +pub async fn send_verification_code_service( + svc_ctx: &SvcCtx, + email: &str, + code_type: &str, +) -> Result<(), anyhow::Error> { + if code_type == "reset_password" { + let user_exists = fetch_user_info_by_email(&svc_ctx.db, email).await?.is_some(); + if !user_exists { + return Err(anyhow::anyhow!("用户不存在")); + } + } + + let smtp_config = svc_ctx + .config + .clone() + .smtp + .ok_or_else(|| anyhow::anyhow!("邮箱配置不存在"))?; + + // 1. 生成验证码 + let code = random_six_number_code(); + + // 2. 存储验证码到 Redis + match code_type { + "register" => set_register_code(svc_ctx, email, &code).await?, + "reset_password" => set_reset_password_code(svc_ctx, email, &code).await?, + _ => return Err(anyhow::anyhow!("无效的验证码类型")), + } + + // 3. 发送邮件 + let title = match code_type { + "register" => "注册验证码", + "reset_password" => "重置密码验证码", + _ => "验证码", + }; + let body = format!("您的验证码是:{},有效期 5 分钟。", code); + + send_email( + &smtp_config.smtp_username, + &smtp_config.smtp_password, + &smtp_config.smtp_server, + email, + title, + &body, + )?; + + Ok(()) +} + +/// 获取用户公钥 +/// +/// # Arguments +/// * `svc_ctx` - 服务上下文 +/// * `user_uuid` - 用户对应的UUID +/// +/// # Returns +/// 返回用户公钥,如果获取成功则返回公钥,否则返回错误 +pub async fn get_user_public_key_service( + svc_ctx: &SvcCtx, + user_uuid: &Uuid, +) -> Result { + let public_key = get_user_public_key(svc_ctx, user_uuid) + .await? + .ok_or_else(|| anyhow::anyhow!("用户公钥不存在"))?; + Ok(public_key) +} diff --git a/src-tauri/crates/business/src/services/version_types.rs b/src-tauri/crates/business/src/services/version_types.rs new file mode 100644 index 0000000..125829f --- /dev/null +++ b/src-tauri/crates/business/src/services/version_types.rs @@ -0,0 +1,123 @@ +use crate::{ + dto::version_types::VersionType, entitys::version_types::*, errors::SimprintError, + svc_ctx::SvcCtx, +}; + +/// 创建版本类型 +pub async fn create_version_type( + svc_ctx: &SvcCtx, + request: CreateVersionTypeRequest, +) -> Result { + // 验证类型代码 + if request.type_code.is_empty() { + return Err(SimprintError::Other("版本类型代码不能为空".to_string())); + } + + // 检查版本类型是否已存在 + let exists = + crate::models::version_types::query_version_type_by_code(&svc_ctx.db, &request.type_code) + .await + .ok(); + + if exists.is_some() { + return Err(SimprintError::Other("版本类型已存在".to_string())); + } + + // 创建版本类型 + let id = crate::models::version_types::insert_version_type(&svc_ctx.db, &request).await?; + + Ok(id) +} + +/// 根据ID查询版本类型 +pub async fn get_version_type_by_id( + svc_ctx: &SvcCtx, + id: i32, +) -> Result { + let version_type = crate::models::version_types::query_version_type_by_id(&svc_ctx.db, id) + .await + .map_err(|_| SimprintError::Other("版本类型不存在".to_string()))?; + + Ok(version_type) +} + +/// 根据代码查询版本类型 +pub async fn get_version_type_by_code( + svc_ctx: &SvcCtx, + type_code: String, +) -> Result { + let version_type = + crate::models::version_types::query_version_type_by_code(&svc_ctx.db, &type_code) + .await + .map_err(|_| SimprintError::Other("版本类型不存在".to_string()))?; + + Ok(version_type) +} + +/// 查询所有版本类型 +pub async fn query_all_version_types_service( + svc_ctx: &SvcCtx, +) -> Result { + let list = crate::models::version_types::query_all_version_types(&svc_ctx.db).await?; + + Ok(VersionTypeListResponse { list }) +} + +/// 查询激活的版本类型 +pub async fn query_active_version_types_service( + svc_ctx: &SvcCtx, +) -> Result { + let list = crate::models::version_types::query_active_version_types(&svc_ctx.db).await?; + + Ok(VersionTypeListResponse { list }) +} + +/// 更新版本类型 +pub async fn update_version_type_service( + svc_ctx: &SvcCtx, + id: i32, + request: UpdateVersionTypeRequest, +) -> Result { + // 检查版本类型是否存在 + crate::models::version_types::query_version_type_by_id(&svc_ctx.db, id) + .await + .map_err(|_| SimprintError::Other("版本类型不存在".to_string()))?; + + // 更新版本类型 + let success = + crate::models::version_types::update_version_type(&svc_ctx.db, id, &request).await?; + + if !success { + return Err(SimprintError::Other("更新版本类型失败".to_string())); + } + + Ok(true) +} + +/// 删除版本类型 +pub async fn delete_version_type_service(svc_ctx: &SvcCtx, id: i32) -> Result { + let success = crate::models::version_types::delete_version_type(&svc_ctx.db, id).await?; + + if !success { + return Err(SimprintError::Other("删除版本类型失败".to_string())); + } + + Ok(true) +} + +/// 激活/停用版本类型 +pub async fn toggle_version_type_status_service( + svc_ctx: &SvcCtx, + id: i32, + is_active: bool, +) -> Result { + let success = + crate::models::version_types::toggle_version_type_status(&svc_ctx.db, id, is_active) + .await?; + + if !success { + return Err(SimprintError::Other("激活/停用版本类型失败".to_string())); + } + + Ok(true) +} diff --git a/src-tauri/crates/business/src/services/versions.rs b/src-tauri/crates/business/src/services/versions.rs new file mode 100644 index 0000000..7d6fd6e --- /dev/null +++ b/src-tauri/crates/business/src/services/versions.rs @@ -0,0 +1,240 @@ +use crate::{ + dto::versions::Version, entitys::versions::*, errors::SimprintError, svc_ctx::SvcCtx, + utils::get_objects::get_version_resource_url, +}; +use std::collections::HashMap; + +/// 创建版本 +pub async fn create_version( + svc_ctx: &SvcCtx, + request: CreateVersionRequest, +) -> Result { + // 验证版本号 + if request.version.is_empty() { + return Err(SimprintError::VersionEmpty); + } + + // 验证资源名称 + if request.resource_name.is_empty() { + return Err(SimprintError::ResourceNameEmpty); + } + + // 检查版本是否已存在 + let exists = crate::models::versions::query_version_by_name_and_version( + &svc_ctx.db, + &request.resource_name, + &request.version, + ) + .await + .ok(); + + if exists.is_some() { + return Err(SimprintError::VersionAlreadyExists); + } + + // 创建版本 + let id = crate::models::versions::insert_version(&svc_ctx.db, &request).await?; + + Ok(id) +} + +/// 根据ID查询版本 +pub async fn get_version_by_id(svc_ctx: &SvcCtx, id: i32) -> Result { + let mut version = crate::models::versions::query_version_by_id(&svc_ctx.db, id) + .await + .map_err(|_| SimprintError::VersionNotFound)?; + + let storage_config = &svc_ctx.config.storage; + let object_path = version.url.clone().unwrap_or_default(); + version.url = Some(get_version_resource_url( + &storage_config.public_base_url, + &storage_config.version_root, + &object_path, + )); + + Ok(version) +} + +/// 根据资源名称和版本号查询版本 +pub async fn get_version_by_name_and_version( + svc_ctx: &SvcCtx, + resource_name: String, + version: String, +) -> Result { + let version_data = crate::models::versions::query_version_by_name_and_version( + &svc_ctx.db, + &resource_name, + &version, + ) + .await + .map_err(|_| SimprintError::VersionNotFound)?; + + Ok(version_data) +} + +/// 查询最新版本 +pub async fn get_latest_version( + svc_ctx: &SvcCtx, + resource_name: String, + platform: String, +) -> Result { + let version = + crate::models::versions::query_latest_version(&svc_ctx.db, &resource_name, &platform) + .await + .map_err(|_| SimprintError::VersionNotFound)?; + + version.ok_or(SimprintError::VersionNotFound) +} + +/// 查询版本列表 +pub async fn query_versions_service( + svc_ctx: &SvcCtx, + params: QueryVersionParams, + page_num: i32, + page_size: i32, +) -> Result { + let (total, list) = crate::models::versions::query_versions( + &svc_ctx.db, + params.resource_name.as_deref(), + params.platform.as_deref(), + params.status.as_deref(), + page_num, + page_size, + ) + .await?; + + Ok(VersionListResponse { total, list }) +} + +/// 更新版本 +pub async fn update_version_service( + svc_ctx: &SvcCtx, + id: i32, + request: UpdateVersionRequest, +) -> Result { + // 检查版本是否存在 + crate::models::versions::query_version_by_id(&svc_ctx.db, id) + .await + .map_err(|_| SimprintError::VersionNotFound)?; + + // 更新版本 + let success = crate::models::versions::update_version(&svc_ctx.db, id, &request).await?; + + if !success { + return Err(SimprintError::VersionNotFound); + } + + Ok(true) +} + +/// 删除版本 +pub async fn delete_version_service(svc_ctx: &SvcCtx, id: i32) -> Result { + let success = crate::models::versions::delete_version(&svc_ctx.db, id).await?; + + if !success { + return Err(SimprintError::VersionNotFound); + } + + Ok(true) +} + +/// 设置某版本为最新版本 +pub async fn set_latest_version_service( + svc_ctx: &SvcCtx, + type_id: i32, + resource_name: String, + version_id: i32, +) -> Result { + // 检查版本是否存在 + crate::models::versions::query_version_by_id(&svc_ctx.db, version_id) + .await + .map_err(|_| SimprintError::VersionNotFound)?; + + // 设置为最新版本 + let success = crate::models::versions::set_as_latest_version( + &svc_ctx.db, + type_id, + &resource_name, + version_id, + ) + .await?; + + if !success { + return Err(SimprintError::Other("设置最新版本失败".to_string())); + } + + Ok(true) +} + +/// 版本回退到指定版本 +pub async fn rollback_version_service( + svc_ctx: &SvcCtx, + type_id: i32, + resource_name: String, + target_version_id: i32, +) -> Result { + // 检查目标版本是否存在 + let target_version = + crate::models::versions::query_version_by_id(&svc_ctx.db, target_version_id) + .await + .map_err(|_| SimprintError::VersionNotFound)?; + + // 检查版本是否属于同一资源 + if target_version.type_id != type_id || target_version.resource_name != resource_name { + return Err(SimprintError::VersionNotFound); + } + + // 设置目标版本为最新 + let success = + set_latest_version_service(svc_ctx, type_id, resource_name, target_version_id).await?; + + if !success { + return Err(SimprintError::Other("版本回退失败".to_string())); + } + + Ok(true) +} + +/// 版本差异对比 +pub async fn compare_versions_service( + svc_ctx: &SvcCtx, + version_id_1: i32, + version_id_2: i32, +) -> Result<(Version, Version), SimprintError> { + let version_1 = crate::models::versions::query_version_by_id(&svc_ctx.db, version_id_1) + .await + .map_err(|_| SimprintError::VersionNotFound)?; + + let version_2 = crate::models::versions::query_version_by_id(&svc_ctx.db, version_id_2) + .await + .map_err(|_| SimprintError::VersionNotFound)?; + + Ok((version_1, version_2)) +} + +/// 查询所有激活版本类型的最新版本 +pub async fn get_all_active_latest_versions_service( + svc_ctx: &SvcCtx, + platform: String, +) -> Result>, SimprintError> { + let results = + crate::models::versions::query_all_active_latest_versions(&svc_ctx.db, &platform).await?; + + // 转换为 HashMap> + let mut map: HashMap> = HashMap::new(); + + let storage_config = &svc_ctx.config.storage; + + for (type_code, _resource_name, version) in results { + map.entry(type_code).or_insert_with(Vec::new).push(Version { + url: Some(get_version_resource_url( + &storage_config.public_base_url, + &storage_config.version_root, + &version.url.unwrap_or_default(), + )), + ..version + }); + } + + Ok(map) +} diff --git a/src-tauri/crates/business/src/services/wallet.rs b/src-tauri/crates/business/src/services/wallet.rs new file mode 100644 index 0000000..b7c4fa7 --- /dev/null +++ b/src-tauri/crates/business/src/services/wallet.rs @@ -0,0 +1,103 @@ +use rust_decimal::Decimal; +use uuid::Uuid; + +use crate::dto::{UserWalletDto, WalletTransactionDto}; +use crate::entitys::ListTransactionsRequest; +use crate::models; +use crate::svc_ctx::SvcCtx; + +/// 获取钱包信息 +pub async fn get_wallet_service( + svc_ctx: &SvcCtx, + user_uuid: Uuid, +) -> Result { + let wallet = models::billing::fetch_user_wallet(&svc_ctx.db, user_uuid) + .await + .map_err(|e| e.to_string())?; + + // 如果钱包不存在,创建一个 + if wallet.is_none() { + models::billing::insert_user_wallet(&svc_ctx.db, user_uuid, "CNY") + .await + .map_err(|e| e.to_string())?; + + return models::billing::fetch_user_wallet(&svc_ctx.db, user_uuid) + .await + .map_err(|e| e.to_string())? + .ok_or_else(|| "创建钱包失败".to_string()); + } + + wallet.ok_or_else(|| "钱包不存在".to_string()) +} + +/// 扣减钱包余额 +pub async fn deduct_wallet_service( + svc_ctx: &SvcCtx, + user_uuid: Uuid, + amount: Decimal, + description: &str, + order_uuid: Option, +) -> Result<(), String> { + let wallet = models::billing::fetch_user_wallet(&svc_ctx.db, user_uuid) + .await + .map_err(|e| e.to_string())? + .ok_or_else(|| "钱包不存在".to_string())?; + + let balance_before = wallet.balance; + let balance_after = balance_before - amount; + + if balance_after < Decimal::ZERO { + return Err("余额不足".to_string()); + } + + // 扣减余额 + models::billing::update_wallet_balance(&svc_ctx.db, user_uuid, -amount) + .await + .map_err(|e| e.to_string())?; + + // 记录交易 + models::billing::insert_wallet_transaction( + &svc_ctx.db, + user_uuid, + "debit", + -amount, + &wallet.currency, + balance_before, + balance_after, + Some(description), + order_uuid, + ) + .await + .map_err(|e| e.to_string())?; + + Ok(()) +} + +/// 获取交易记录 +pub async fn get_transactions_service( + svc_ctx: &SvcCtx, + user_uuid: Uuid, + payload: &ListTransactionsRequest, +) -> Result<(Vec, i64), String> { + let offset = (payload.pagination.page - 1) * payload.pagination.page_size; + + let transactions = models::billing::fetch_wallet_transactions( + &svc_ctx.db, + user_uuid, + payload.transaction_type.as_deref(), + offset, + payload.pagination.page_size, + ) + .await + .map_err(|e| e.to_string())?; + + let total = models::billing::fetch_wallet_transactions_count( + &svc_ctx.db, + user_uuid, + payload.transaction_type.as_deref(), + ) + .await + .map_err(|e| e.to_string())?; + + Ok((transactions, total)) +} diff --git a/src-tauri/crates/business/src/services/workspace_quotas.rs b/src-tauri/crates/business/src/services/workspace_quotas.rs new file mode 100644 index 0000000..7fb0b85 --- /dev/null +++ b/src-tauri/crates/business/src/services/workspace_quotas.rs @@ -0,0 +1,72 @@ +use uuid::Uuid; + +use crate::dto::WorkspaceQuotaDto; +use crate::entitys::{GetWorkspaceQuotaRequest, UpdateQuotaUsageRequest}; +use crate::models; +use crate::svc_ctx::SvcCtx; + +/// 获取工作空间配额 +pub async fn get_workspace_quota_service( + svc_ctx: &SvcCtx, + payload: &GetWorkspaceQuotaRequest, +) -> Result { + let workspace_uuid = payload + .workspace_uuid + .ok_or_else(|| "工作空间 UUID 不能为空".to_string())?; + + models::fetch_workspace_quota(&svc_ctx.db, workspace_uuid) + .await + .map_err(|e| e.to_string())? + .ok_or_else(|| "配额不存在".to_string()) +} + +/// 检查配额是否充足 +pub async fn check_quota_service( + svc_ctx: &SvcCtx, + workspace_uuid: Uuid, + quota_type: &str, +) -> Result { + models::check_quota(&svc_ctx.db, workspace_uuid, quota_type) + .await + .map_err(|e| e.to_string()) +} + +/// 更新配额使用情况 +pub async fn update_quota_usage_service( + svc_ctx: &SvcCtx, + payload: &UpdateQuotaUsageRequest, +) -> Result<(), String> { + match payload.quota_type.as_str() { + "environments" => { + if payload.increment { + models::increment_used_environments( + &svc_ctx.db, + payload.workspace_uuid, + payload.amount, + ) + .await + } else { + models::decrement_used_environments( + &svc_ctx.db, + payload.workspace_uuid, + payload.amount, + ) + .await + } + } + "proxies" => { + if payload.increment { + models::increment_used_proxies(&svc_ctx.db, payload.workspace_uuid, payload.amount) + .await + } else { + models::decrement_used_proxies(&svc_ctx.db, payload.workspace_uuid, payload.amount) + .await + } + } + "team_members" => { + models::update_used_team_members(&svc_ctx.db, payload.workspace_uuid).await + } + _ => return Err("不支持的配额类型".to_string()), + } + .map_err(|e| e.to_string()) +} diff --git a/src-tauri/crates/business/src/services/workspaces.rs b/src-tauri/crates/business/src/services/workspaces.rs new file mode 100644 index 0000000..8603371 --- /dev/null +++ b/src-tauri/crates/business/src/services/workspaces.rs @@ -0,0 +1,184 @@ +use uuid::Uuid; + +use crate::dto::WorkspaceDto; +use crate::entitys::{CreateTeamRequest, CreateWorkspaceRequest, UpdateWorkspaceRequest}; +use crate::models; +use crate::svc_ctx::SvcCtx; + +/// 创建工作空间 +pub async fn create_workspace_service( + svc_ctx: &SvcCtx, + user_uuid: Uuid, + payload: &CreateWorkspaceRequest, +) -> Result { + // 获取用户信息,用于生成团队名称 + let user_info = models::user::fetch_user_info_by_uuid(&svc_ctx.db, user_uuid) + .await + .map_err(|e| e.to_string())? + .ok_or_else(|| "用户不存在".to_string())?; + + // 生成团队名称 + let team_name = user_info + .nickname + .as_ref() + .map(|n| format!("{} 的团队", n)) + .unwrap_or_else(|| { + format!( + "{} 的团队", + user_info + .email + .split('@') + .next() + .unwrap_or("用户") + ) + }); + + // 创建工作空间 + let workspace_uuid = models::insert_workspace(&svc_ctx.db, user_uuid, payload) + .await + .map_err(|e| e.to_string())?; + + // 创建默认配额(从配置读取) + let quota = &svc_ctx.config.workspace_quota.default; + models::insert_or_update_workspace_quota( + &svc_ctx.db, + workspace_uuid, + quota.max_environments, + quota.max_team_members, + quota.max_proxies, + quota.max_rpa_tasks, + ) + .await + .map_err(|e| e.to_string())?; + + // 创建默认团队(每个工作空间自动创建一个团队) + let team_request = CreateTeamRequest { + workspace_uuid, + name: team_name, + description: Some("默认团队".to_string()), + }; + let team_uuid = models::insert_team(&svc_ctx.db, user_uuid, &team_request) + .await + .map_err(|e| e.to_string())?; + + // 设置用户当前工作空间和团队,确保上下文始终一致。 + models::user::set_user_current_workspace_and_team(&svc_ctx.db, user_uuid, workspace_uuid, team_uuid) + .await + .map_err(|e| e.to_string())?; + + Ok(workspace_uuid) +} + +/// 获取工作空间详情 +pub async fn get_workspace_service( + svc_ctx: &SvcCtx, + workspace_uuid: Uuid, +) -> Result { + models::fetch_workspace_by_uuid(&svc_ctx.db, workspace_uuid) + .await + .map_err(|e| e.to_string())? + .ok_or_else(|| "工作空间不存在".to_string()) +} + +/// 获取用户所属的所有工作空间 +pub async fn get_user_workspaces_service( + svc_ctx: &SvcCtx, + user_uuid: Uuid, +) -> Result, String> { + models::fetch_user_workspaces(&svc_ctx.db, user_uuid) + .await + .map_err(|e| e.to_string()) +} + +/// 更新工作空间 +pub async fn update_workspace_service( + svc_ctx: &SvcCtx, + user_uuid: Uuid, + payload: &UpdateWorkspaceRequest, +) -> Result<(), String> { + // 检查权限(只有所有者可以更新) + let workspace = models::fetch_workspace_by_uuid(&svc_ctx.db, payload.uuid) + .await + .map_err(|e| e.to_string())? + .ok_or_else(|| "工作空间不存在".to_string())?; + + if workspace.owner_uuid != user_uuid { + return Err("只有工作空间所有者可以更新".to_string()); + } + + models::update_workspace(&svc_ctx.db, payload.uuid, payload.name.as_deref()) + .await + .map_err(|e| e.to_string()) +} + +/// 删除工作空间(软删除) +pub async fn delete_workspace_service( + svc_ctx: &SvcCtx, + user_uuid: Uuid, + workspace_uuid: Uuid, +) -> Result<(), String> { + // 检查权限(只有所有者可以删除) + let workspace = models::fetch_workspace_by_uuid(&svc_ctx.db, workspace_uuid) + .await + .map_err(|e| e.to_string())? + .ok_or_else(|| "工作空间不存在".to_string())?; + + if workspace.owner_uuid != user_uuid { + return Err("只有工作空间所有者可以删除".to_string()); + } + + let current_workspace_uuid = models::user::fetch_user_current_workspace(&svc_ctx.db, user_uuid) + .await + .map_err(|e| e.to_string())?; + + if current_workspace_uuid == Some(workspace_uuid) { + return Err("不能删除当前正在使用的工作空间,请先切换到其他工作空间".to_string()); + } + + // 检查用户是否只有一个工作空间,如果是则不允许删除 + let user_workspaces = models::fetch_user_workspaces(&svc_ctx.db, user_uuid) + .await + .map_err(|e| e.to_string())?; + + if user_workspaces.len() <= 1 { + return Err("至少需要保留一个工作空间,无法删除最后一个工作空间".to_string()); + } + + models::delete_workspace(&svc_ctx.db, workspace_uuid) + .await + .map_err(|e| e.to_string()) +} + +/// 检查用户是否是工作空间所有者 +pub async fn check_workspace_owner_service( + svc_ctx: &SvcCtx, + workspace_uuid: Uuid, + user_uuid: Uuid, +) -> Result { + models::check_workspace_owner(&svc_ctx.db, workspace_uuid, user_uuid) + .await + .map_err(|e| e.to_string()) +} + +pub async fn switch_workspace_service( + svc_ctx: &SvcCtx, + workspace_uuid: Uuid, + user_uuid: Uuid, +) -> Result<(), String> { + let teams = models::fetch_user_teams(&svc_ctx.db, workspace_uuid, user_uuid) + .await + .map_err(|e| e.to_string())?; + + let current_team_uuid = models::fetch_user_current_team(&svc_ctx.db, user_uuid) + .await + .map_err(|e| e.to_string())?; + + let team_uuid = current_team_uuid + .filter(|current| teams.iter().any(|team| team.uuid == *current)) + .or_else(|| teams.first().map(|team| team.uuid)) + .ok_or_else(|| "该工作空间下没有可用团队".to_string())?; + + models::user::set_user_current_workspace_and_team(&svc_ctx.db, user_uuid, workspace_uuid, team_uuid) + .await + .map_err(|e| e.to_string()) +} diff --git a/src-tauri/crates/business/src/state.rs b/src-tauri/crates/business/src/state.rs new file mode 100644 index 0000000..e4a9b97 --- /dev/null +++ b/src-tauri/crates/business/src/state.rs @@ -0,0 +1,82 @@ +use uuid::Uuid; + +/// 当前用户 +#[derive(Debug, Clone)] +pub struct CurrentUser { + pub user_uuid: Uuid, +} + +/// 当前工作空间 +#[derive(Debug, Clone)] +pub struct CurrentWorkspace { + pub workspace_uuid: Uuid, +} + +/// 当前 IP 地址 +#[derive(Debug, Clone)] +pub struct CurrentIpAddr { + pub real_ip: String, +} + +/// 请求上下文 - 包含所有请求相关的上下文信息 +/// +/// 在中间件中逐步填充,handler 中通过 Extension 获取 +#[derive(Debug, Clone, Default)] +pub struct RequestContext { + /// 当前用户信息 + pub current_user: Option, + /// 当前 IP 地址 + pub current_ip_addr: Option, + /// 当前团队 UUID + pub current_team_uuid: Option, + /// 当前工作空间 UUID + pub current_workspace_uuid: Option, + /// 资源标识符:method+path(去除 /api/v*/ 前缀,保留前导斜杠) + /// 例如:POST+/environments, GET+/proxies + pub resource_identifier: Option, +} + +impl RequestContext { + /// 获取用户 UUID(如果已认证) + pub fn user_uuid(&self) -> Option { + self.current_user.as_ref().map(|u| u.user_uuid) + } + + /// 获取用户 UUID,如果未认证则 panic + pub fn user_uuid_unwrap(&self) -> Uuid { + self.current_user.as_ref().expect("用户未认证").user_uuid + } + + /// 获取 IP 地址 + pub fn ip(&self) -> Option<&str> { + self.current_ip_addr.as_ref().map(|i| i.real_ip.as_str()) + } + + /// 获取 IP 地址,如果不存在则返回 "unknown" + pub fn ip_or_unknown(&self) -> &str { + self.ip().unwrap_or("unknown") + } + + /// 获取工作空间 UUID + pub fn workspace_uuid(&self) -> Option { + self.current_workspace_uuid + } + + /// 获取工作空间 UUID,如果不存在则 panic + pub fn workspace_uuid_unwrap(&self) -> Uuid { + self.current_workspace_uuid.expect("工作空间未设置") + } + + /// 获取资源标识符 + pub fn resource_identifier(&self) -> Option<&str> { + self.resource_identifier.as_deref() + } + + /// 获取资源路径(从资源标识符中提取路径部分) + /// 例如:POST+/environments -> /environments + pub fn resource_path(&self) -> Option<&str> { + self.resource_identifier + .as_ref() + .and_then(|id| id.split_once('+').map(|(_, path)| path)) + } +} diff --git a/src-tauri/crates/business/src/svc_ctx.rs b/src-tauri/crates/business/src/svc_ctx.rs new file mode 100644 index 0000000..bd84a75 --- /dev/null +++ b/src-tauri/crates/business/src/svc_ctx.rs @@ -0,0 +1,30 @@ +use crate::{ + caches::CacheStore, + database::{self, DbPool}, + utils::{DatabaseConfig, IConfig}, +}; + +/// Shared resources used by handlers and services. +#[derive(Clone)] +pub struct SvcCtx { + pub config: IConfig, + pub db: DbPool, + pub cache: CacheStore, +} + +impl SvcCtx { + pub async fn new(config: &IConfig) -> Result { + let db = Self::create_db(&config.database).await?; + let cache = CacheStore::memory(); + + Ok(Self { + config: config.clone(), + db, + cache, + }) + } + + pub async fn create_db(config: &DatabaseConfig) -> Result { + database::connect(config).await + } +} diff --git a/src-tauri/crates/business/src/utils.rs b/src-tauri/crates/business/src/utils.rs new file mode 100644 index 0000000..ef5f795 --- /dev/null +++ b/src-tauri/crates/business/src/utils.rs @@ -0,0 +1,50 @@ +mod config; +mod extractor; +mod jwt; +mod password; +mod responses; +mod secret; +pub mod storage; + +pub use config::*; +pub use extractor::*; +pub use jwt::*; +pub use password::*; +pub use responses::*; +pub use secret::*; +pub use storage::*; + +/// 向指定邮箱发送验证码 +pub fn send_email( + smtp_username: &str, + smtp_password: &str, + smtp_server: &str, + to: &str, + title: &str, + body: &str, +) -> std::result::Result { + use lettre::message::header::ContentType; + use lettre::transport::smtp::authentication::Credentials; + use lettre::{Message, SmtpTransport, Transport}; + + let email = Message::builder() + .from(smtp_username.parse().map_err(|_| anyhow::anyhow!("from email is failed."))?) + .to(to.parse().map_err(|_| anyhow::anyhow!("receive email is failed."))?) + .subject(title) + .header(ContentType::TEXT_HTML) + .body(String::from(body))?; + let creds = Credentials::new(smtp_username.to_string(), smtp_password.to_string()); + let mailer = SmtpTransport::relay(smtp_server).unwrap().credentials(creds).build(); + + match mailer.send(&email) { + Ok(_) => Ok(true), + Err(_e) => Err(anyhow::anyhow!("email send failed.")), + } +} + +/// 随机生成6位的数字编码 +pub fn random_six_number_code() -> String { + let mut rng = rand::rng(); + let random_number: i32 = rand::Rng::random_range(&mut rng, 100000..1000000); + format!("{}", random_number) +} diff --git a/src-tauri/crates/business/src/utils/config.rs b/src-tauri/crates/business/src/utils/config.rs new file mode 100644 index 0000000..a964ca4 --- /dev/null +++ b/src-tauri/crates/business/src/utils/config.rs @@ -0,0 +1,114 @@ +use config::{Config, ConfigError}; +use serde::Deserialize; + +/// 数据库配置 +#[derive(Debug, Clone, Deserialize)] +pub struct DatabaseConfig { + pub url: String, + pub max_connections: u32, + pub min_connections: u32, + pub max_lifetime: u64, + pub acquire_timeout: u64, + pub idle_timeout: u64, +} + +/// Download-resource URL configuration retained by the transitional HTTP API. +#[derive(Debug, Clone, Deserialize)] +pub struct StorageConfig { + #[serde(default)] + pub public_base_url: String, + #[serde(default = "default_avatar_root")] + pub avatar_root: String, + #[serde(default = "default_extension_root")] + pub extension_root: String, + #[serde(default = "default_version_root")] + pub version_root: String, +} + +fn default_avatar_root() -> String { + "avatars".to_string() +} + +fn default_extension_root() -> String { + "extensions".to_string() +} + +fn default_version_root() -> String { + "versions".to_string() +} + +/// SMTP 配置 +#[derive(Debug, Clone, Deserialize)] +pub struct SmtpConfig { + pub smtp_server: String, + pub smtp_username: String, + pub smtp_password: String, +} + +/// 工作空间默认配额配置 +#[derive(Debug, Clone, Deserialize)] +pub struct WorkspaceQuotaConfig { + /// 默认配额(新用户注册和手动创建工作空间都使用此配置) + #[serde(default = "default_workspace_quota")] + pub default: WorkspaceQuotaValues, +} + +/// 工作空间配额值 +#[derive(Debug, Clone, Deserialize)] +pub struct WorkspaceQuotaValues { + pub max_environments: i32, + pub max_team_members: i32, + pub max_proxies: i32, + pub max_rpa_tasks: i32, +} + +fn default_workspace_quota() -> WorkspaceQuotaValues { + WorkspaceQuotaValues { + max_environments: 8, + max_team_members: 1, + max_proxies: 99999, + max_rpa_tasks: 99999, + } +} + +/// 应用配置 +#[derive(Debug, Clone, Deserialize)] +pub struct AppConfig { + pub name: String, + pub port: u16, + pub secret: String, + pub prefix: String, + pub encrypt_secret_location: String, + pub route_whitelists: Vec, + /// 推广链接前缀,例如: https://www.example.com/register + /// 实际推广链接将拼接为: {referral_link_prefix}?referral_code={code} + pub referral_link_prefix: Option, +} + +#[derive(Debug, Clone, Deserialize)] +pub struct IConfig { + pub app: AppConfig, + pub database: DatabaseConfig, + pub storage: StorageConfig, + pub smtp: Option, + #[serde(default = "default_workspace_quota_config")] + pub workspace_quota: WorkspaceQuotaConfig, +} + +fn default_workspace_quota_config() -> WorkspaceQuotaConfig { + WorkspaceQuotaConfig { + default: default_workspace_quota(), + } +} + +impl IConfig { + pub fn build_by_filepath(config_path: &str) -> Result { + let config = Config::builder() + .add_source(config::File::with_name(config_path)) + .add_source(config::File::with_name(".").required(false)) + .add_source(config::Environment::with_prefix("APP")) + .build()?; + + config.try_deserialize() + } +} diff --git a/src-tauri/crates/business/src/utils/extractor.rs b/src-tauri/crates/business/src/utils/extractor.rs new file mode 100644 index 0000000..78f8b82 --- /dev/null +++ b/src-tauri/crates/business/src/utils/extractor.rs @@ -0,0 +1,43 @@ +use axum::{ + extract::{self, FromRequest, Request, rejection::JsonRejection}, + http::StatusCode, +}; +use serde::{Serialize, de::DeserializeOwned}; + +use crate::utils::Response; + +/// 自定义Extractor +/// +/// 不使用默认的JSON extractor, 通过该extract提取可以在提取成功或失败时完成额外的操作。 +pub struct Json(pub T); + +impl FromRequest for Json +where + T: DeserializeOwned + Serialize, + S: Send + Sync, +{ + type Rejection = Response; + + async fn from_request(req: Request, state: &S) -> Result { + match extract::Json::::from_request(req, state).await { + Ok(body) => Ok(Self(body.0)), + Err(rejection) => { + let (status, message) = match rejection { + JsonRejection::JsonDataError(e) => { + eprintln!("{:?}", e); + (StatusCode::BAD_REQUEST, "请求参数错误") + } + JsonRejection::JsonSyntaxError(_) => { + (StatusCode::BAD_REQUEST, "请求参数语法错误") + } + JsonRejection::MissingJsonContentType(_) => { + (StatusCode::BAD_REQUEST, "缺少请求参数") + } + _ => (StatusCode::OK, ""), + }; + + Err(Response::fail_with_statu_code(Some(message), status)) + } + } + } +} diff --git a/src-tauri/crates/business/src/utils/jwt.rs b/src-tauri/crates/business/src/utils/jwt.rs new file mode 100644 index 0000000..9bea4b8 --- /dev/null +++ b/src-tauri/crates/business/src/utils/jwt.rs @@ -0,0 +1,59 @@ +use jwt::Token; + +// 生成token +pub fn generate_token( + user_uuid: &str, + expiration: u64, + secret: &[u8], +) -> Result { + use aes_gcm::KeyInit; + use hmac::Hmac; + use jwt::SignWithKey; + use std::collections::BTreeMap; + + let key: Hmac = Hmac::new_from_slice(secret).unwrap(); + + let mut claims = BTreeMap::new(); + claims.insert("uuid", format!("{}", user_uuid)); + claims.insert( + "exp", + format!("{}", chrono::Utc::now().timestamp() + expiration as i64), + ); + claims.insert("iat", format!("{}", chrono::Utc::now().timestamp())); + + let header = jwt::Header { + algorithm: jwt::AlgorithmType::Hs256, + ..Default::default() + }; + + Token::new(header, claims).sign_with_key(&key).map(|v| v.as_str().to_string()) +} + +// 校验token +pub fn verify_token(token_str: &str, secret: &[u8]) -> Result { + use aes_gcm::KeyInit; + use hmac::Hmac; + use jwt::VerifyWithKey; + use sha2::Sha256; + use std::collections::BTreeMap; + + let key: Hmac = Hmac::new_from_slice(secret)?; + let claims: BTreeMap = token_str.verify_with_key(&key)?; + let exp_str = claims.get("exp"); + + // 检查过期时间 + let exp = exp_str + .ok_or_else(|| anyhow::anyhow!("token expired."))? + .parse::() + .map_err(|_| anyhow::anyhow!("token expired."))?; + let current_time = chrono::Utc::now().timestamp() as u64; + if current_time > exp { + return Err(anyhow::anyhow!("token expired.")); + } + + let uuid = claims.get("uuid"); + match uuid { + Some(uuid) => return Ok(uuid.to_string()), + None => Err(anyhow::anyhow!("token parse failed.")), + } +} diff --git a/src-tauri/crates/business/src/utils/password.rs b/src-tauri/crates/business/src/utils/password.rs new file mode 100644 index 0000000..89fd423 --- /dev/null +++ b/src-tauri/crates/business/src/utils/password.rs @@ -0,0 +1,26 @@ +use argon2::{ + Argon2, + password_hash::{PasswordHash, PasswordHasher, PasswordVerifier, SaltString, rand_core::OsRng}, +}; + +/// 对密码进行加密 +pub fn encryption_password(password: &str) -> Result { + let salt = SaltString::generate(&mut OsRng); + + let argon2 = Argon2::default(); + + match argon2.hash_password(password.as_bytes(), &salt) { + Ok(password_hash) => Ok(password_hash.to_string()), + Err(_) => Err(anyhow::anyhow!("密码异常")), + } +} + +pub fn verify_password(password: &str, password_hash: &str) -> bool { + let parsed_hash = if let Ok(parsed_hash) = PasswordHash::new(&password_hash) { + parsed_hash + } else { + return false; + }; + + Argon2::default().verify_password(password.as_bytes(), &parsed_hash).is_ok() +} diff --git a/src-tauri/crates/business/src/utils/responses.rs b/src-tauri/crates/business/src/utils/responses.rs new file mode 100644 index 0000000..065b83d --- /dev/null +++ b/src-tauri/crates/business/src/utils/responses.rs @@ -0,0 +1,126 @@ +use axum::{ + extract::multipart::MultipartError, + http::StatusCode, + response::{IntoResponse, Json}, +}; +use serde::{Deserialize, Serialize}; +use serde_json::json; +use std::fmt::{Debug, Display}; + +const SUCCESS_CODE: i32 = 1; +const FAIL_CODE: i32 = 0; + +pub type Result = axum::response::Result, Response>; + +/// 统一响应类型 +#[derive(Debug, Serialize, Clone, Deserialize)] +pub struct Response { + pub status_code: u16, + pub code: i32, + pub data: Option, + pub message: Option, +} + +impl Response { + /// 操作成功对应的响应类型 + pub fn success(message: Option<&str>, data: Option) -> Self { + Response { + code: SUCCESS_CODE, + message: message.map(|v| v.to_string()), + data, + status_code: 200, + } + } + + /// 操作失败对应的响应类型 + pub fn fail(message: Option<&str>) -> Self { + Response { + code: FAIL_CODE, + message: message.map(|v| v.to_string()), + data: None, + status_code: 200, + } + } + + /// 操作失败对应的响应类型 + pub fn fail_with_statu_code(message: Option<&str>, statu_code: StatusCode) -> Self { + Response { + code: FAIL_CODE, + message: message.map(|v| v.to_string()), + data: None, + status_code: statu_code.as_u16(), + } + } + + /// with status code + pub fn with_status_code(self, status_code: StatusCode) -> Self { + let mut response = self; + response.status_code = status_code.as_u16(); + response + } +} + +// 允许直接打印和to_string +impl Display for Response { + fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result { + write!( + f, + "Response {{ status_code: {}, code: {}, message: {:?}, has_data: {} }}", + self.status_code, + self.code, + self.message, + self.data.is_some() + ) + } +} + +// 当实现该类型后可以直接将Response作为axum路由处理函数的返回值,会自动调用该trait的into_response方法最终返回json. +impl IntoResponse for Response +where + T: Serialize, +{ + fn into_response(self) -> axum::response::Response { + let mut content_json = json!({ "code": self.code }); + + // 如果 message 存在,添加到 content_json + if let Some(message) = &self.message { + content_json["message"] = json!(message); + } + + // 如果 data 存在,添加到 content_json + if let Some(data) = &self.data { + content_json["data"] = json!(data); + } + + match StatusCode::from_u16(self.status_code) { + Ok(status) => (status, Json(content_json)), + Err(_) => (StatusCode::BAD_REQUEST, Json(content_json)), + } + .into_response() + } +} + +/// 实现From +impl From for Response<()> { + fn from(value: MultipartError) -> Self { + Response::fail_with_statu_code( + Some(format!("请求参数错误: {:?}", value.body_text()).as_ref()), + value.status(), + ) + } +} + +#[cfg(test)] +mod tests { + use super::Response; + + #[test] + fn display_response_without_recursion() { + let response = Response::success(Some("ok"), Some(42)); + + assert_eq!( + response.to_string(), + "Response { status_code: 200, code: 1, message: Some(\"ok\"), has_data: true }" + ); + } +} diff --git a/src-tauri/crates/business/src/utils/secret/aes.rs b/src-tauri/crates/business/src/utils/secret/aes.rs new file mode 100644 index 0000000..23174a9 --- /dev/null +++ b/src-tauri/crates/business/src/utils/secret/aes.rs @@ -0,0 +1,216 @@ +use aes_gcm::{ + Aes256Gcm, Key, Nonce, + aead::{Aead, AeadCore, KeyInit, OsRng}, +}; +use anyhow::Context; +use base64::{Engine as _, engine::general_purpose::STANDARD as BASE64_STANDARD}; +use std::fmt::Debug; + +const AES_KEY_SIZE: usize = 32; +const NONCE_SIZE: usize = 12; + +/// key本身是字节数组, 先将字节数组转换成base64编码得字符串,然后再通过非对称公钥加密再转换成base64编码的字符串 +#[derive(Debug, Clone, Default)] +pub struct AesSecret { + key: Key, // 直接存储 GenericArray 以避免重复转换 +} + +impl AesSecret { + /// 生成一个新的 AES 密钥实例 + pub fn new() -> Self { + let key_bytes = Aes256Gcm::generate_key(&mut OsRng); + AesSecret { key: key_bytes } + } + + /// 从已有的密钥字节创建 AesSecret 实例 + /// 输入的 key_bytes 必须是正确的长度 (例如 32 字节 for AES-256) + pub fn from_bytes(key_bytes: &[u8]) -> Result { + if key_bytes.len() != AES_KEY_SIZE { + return Err(anyhow::anyhow!( + "Invalid key size: expected {}, got {}", + AES_KEY_SIZE, + key_bytes.len() + )); + } + Ok(AesSecret { + key: Key::::clone_from_slice(key_bytes), + }) + } + + /// 获取 AES 密钥的 Base64 编码字符串 + pub fn get_key_as_base64(&self) -> String { + BASE64_STANDARD.encode(self.key.as_slice()) + } + + /// 使用 AES 密钥加密数据 + /// 返回: 加密后的数据 Vec与Nonce Vec拼接的 Vec + pub fn encrypt(&self, data: &[u8]) -> Result { + let cipher = Aes256Gcm::new(&self.key); + // 为每次加密生成新的、唯一的 Nonce + let nonce_bytes = Aes256Gcm::generate_nonce(&mut OsRng); // 生成随机 Nonce + let nonce_instance = Nonce::from_slice(nonce_bytes.as_slice()); + + let ciphertext = cipher.encrypt(nonce_instance, data).map_err(|e| { + tracing::error!("Failed to encrypt data: {:?}", e); + // 使用 context 来添加上下文信息 + anyhow::anyhow!("Failed to encrypt data: {:?}", e) + })?; + + let mut result = Vec::with_capacity(ciphertext.len() + NONCE_SIZE); + result.extend_from_slice(nonce_bytes.as_slice()); + result.extend_from_slice(&ciphertext); + + // 将结果转换为 Base64 编码字符串 + let result = BASE64_STANDARD.encode(&result); + + Ok(result) + } + + /// 使用 AES 密钥和提供的 Nonce 解密数据 + pub fn decrypt(&self, base64_data: &str) -> Result, anyhow::Error> { + // 先对Base64编码的字符串进行解码 + let data = BASE64_STANDARD.decode(base64_data).context("Failed to decode base64 data")?; + + if data.len() < NONCE_SIZE { + return Err(anyhow::anyhow!("Invalid data")); + } + + let (nonce, ciphertext) = data.split_at(NONCE_SIZE); + let cipher = Aes256Gcm::new(&self.key); + let nonce_instance = Nonce::from_slice(nonce); + + cipher.decrypt(nonce_instance, ciphertext).map_err(|e| { + tracing::error!("Failed to decrypt data: {:?}", e); + anyhow::anyhow!("Failed to decrypt data: {:?}", e) + }) + } +} + +/// 从 Base64 编码的密钥字符串创建 AesSecret +impl TryFrom<&str> for AesSecret { + type Error = anyhow::Error; + + fn try_from(key_base64: &str) -> Result { + let decoded_key_bytes = BASE64_STANDARD + .decode(key_base64) + .context("Failed to decode base64 key string")?; + + Self::from_bytes(&decoded_key_bytes) + } +} + +#[cfg(test)] +mod tests { + + use super::*; + + #[test] + fn test_encrypt_decrypt_workflow() { + let secret = AesSecret::new(); + let plaintext = b"Hello, secure world of AES-GCM!"; // 字节数组 + + // 加密 + let encrypt_str = secret.encrypt(plaintext).expect("Encryption failed"); + println!("encrypt_str: {}", encrypt_str); + + // 解密 + let decrypted_bytes = secret.decrypt(&encrypt_str).unwrap(); + + // 将解密后的字节数组转换回字符串 + let original_str = String::from_utf8(decrypted_bytes).unwrap(); + println!("解密后的数据: {}", original_str); + } + + #[test] + fn test_encrypt_decrypt_json_workflow() { + let secret = AesSecret::new(); + + // 创建一个要序列化的结构体 + #[derive(serde::Serialize, serde::Deserialize, Debug, PartialEq)] + struct TestData { + message: String, + value: i32, + } + + let test_data = TestData { + message: "Hello, secure world of AES-GCM!".to_string(), + value: 42, + }; + + // 序列化为JSON + let json_bytes = serde_json::to_vec(&test_data).expect("Serialization failed"); + + // 加密JSON数据 + let encrypted = secret.encrypt(&json_bytes).expect("Encryption failed"); + println!("Encrypted: {}", encrypted); + + // 解密 + let decrypted = secret.decrypt(&encrypted).expect("Decryption failed"); + + // 从JSON反序列化 + let restored_data: TestData = + serde_json::from_slice(&decrypted).expect("Deserialization failed"); + println!("Restored: {:?}", restored_data); + + // 确认数据一致 + assert_eq!(test_data, restored_data); + } + + /// 测试加密流程 + #[test] + fn test_login_payload_encrypt() { + use rsa::pkcs1::DecodeRsaPublicKey; + + let secret = AesSecret::new(); + let key = secret.get_key_as_base64(); + + // 测试通过base64编码的字符串创建AesSecret实例 + // let _ = AesSecret::try_from(key.as_str()).expect("创建AES实例失败"); + + #[derive(serde::Serialize, serde::Deserialize, Debug)] + struct LoginRequest { + email: String, + password: String, + public_secret_key: String, + } + let payload = LoginRequest { + email: "liusnew@gmail.com".to_string(), + password: "liusNew57~".to_string(), + public_secret_key: "-----BEGIN RSA PUBLIC KEY-----\nMIIBCgKCAQEAseI9vA7iTxOMb5Y2xCL7BOGr1by9qEH4EfP9Bj90gxDmY8yRsVK/\no2g+i95oQxzdvdvpPAocKlQv2FEbZaqFr2Q4vy1cLrM0B1NTZl1/hGcmPSofLT9g\nmnjzP60ikY40Dxq+YXAxXZ4s2M+9thNnFr4OydacHEPEkTklcBQBglopSXc1yqHU\nARyCQ3/VxQrfh215vIPgMg2f6PH741zXFaIJjucXR8wJVySo7aZhlBTOVz5GzV0b\naWh31zA47ivXh84OXIEI+CKDUSnvsa8SCRMRs8LgaO1Xktv4yCfHHpo8Zoy+KdW0\nLJxP11G+3f9RsYRpjdmsleyHuYYS07suqwIDAQAB\n-----END RSA PUBLIC KEY-----\n".to_string(), + }; + + let json_bytes = serde_json::to_vec(&payload).expect("Serialization failed"); + let encrypted = secret.encrypt(&json_bytes).expect("Encryption failed"); + + // 下面连续的代码对应: crate::secret::rsa::get_rsa_secret_instance() + let public_key_path = + std::path::Path::new(env!("CARGO_MANIFEST_DIR")).join("assets/secret/public_key.pem"); + let public_key_str = std::fs::read(public_key_path).expect("读取公钥失败"); + let public_key = String::from_utf8(public_key_str).expect("转换公钥失败"); + let public_key = rsa::RsaPublicKey::from_pkcs1_pem(&public_key) + .map_err(|e| { + tracing::error!("Failed to parse public key: {:?}", e); + anyhow::anyhow!("Failed to parse public key: {:?}", e) + }) + .unwrap(); + let mut rng = rsa::rand_core::OsRng::default(); + let encrypted_data = public_key + .encrypt(&mut rng, rsa::Pkcs1v15Encrypt, key.as_bytes()) + .map_err(|e| { + tracing::error!("Failed to encrypt data: {:?}", e); + anyhow::anyhow!("Failed to encrypt data: {:?}", e) + }) + .unwrap(); + + // 通过一个非对称公钥加密key + let encrypted_key = base64::engine::general_purpose::STANDARD.encode(&encrypted_data); + + let result = serde_json::json!({ + "data": encrypted, + "encrypted": true, + "key": encrypted_key, + }); + let result = serde_json::to_string_pretty(&result).unwrap(); + eprintln!("{}", result); + } +} diff --git a/src-tauri/crates/business/src/utils/secret/mod.rs b/src-tauri/crates/business/src/utils/secret/mod.rs new file mode 100644 index 0000000..e800b7d --- /dev/null +++ b/src-tauri/crates/business/src/utils/secret/mod.rs @@ -0,0 +1,5 @@ +mod aes; +mod rsa; + +pub use aes::*; +pub use rsa::*; diff --git a/src-tauri/crates/business/src/utils/secret/rsa.rs b/src-tauri/crates/business/src/utils/secret/rsa.rs new file mode 100644 index 0000000..5c82c43 --- /dev/null +++ b/src-tauri/crates/business/src/utils/secret/rsa.rs @@ -0,0 +1,173 @@ +use std::{fmt::Debug, fs, path::Path}; + +use base64::Engine; +use rsa::{ + Pkcs1v15Encrypt, RsaPrivateKey, + pkcs1::{DecodeRsaPrivateKey, DecodeRsaPublicKey, EncodeRsaPrivateKey, EncodeRsaPublicKey}, +}; +use tokio::sync::OnceCell; + +#[derive(Debug, Clone)] +pub struct RsaSecret { + pub private_key: RsaPrivateKey, + pub public_key: String, +} + +impl RsaSecret { + pub fn new(key_path: &str) -> Result { + let key_dir = Path::new(key_path); + let private_key_path = key_dir.join("private_key.pem"); + let public_key_path = key_dir.join("public_key.pem"); + + // 如果已经有密钥文件,优先复用,避免每次启动刷新 + if private_key_path.exists() && public_key_path.exists() { + let private_key_pem = fs::read_to_string(&private_key_path)?; + let private_key = RsaPrivateKey::from_pkcs1_pem(&private_key_pem)?; + let public_key_pem = fs::read_to_string(&public_key_path)?; + + return Ok(RsaSecret { + private_key, + public_key: public_key_pem, + }); + } + + // 否则生成新的密钥对 + if !key_dir.exists() { + fs::create_dir_all(key_dir)?; + } + + let mut rng = rsa::rand_core::OsRng::default(); + let bits = 2048; + let private_key = RsaPrivateKey::new(&mut rng, bits)?; + let public_key = private_key.to_public_key(); + + let private_key_pem = private_key.to_pkcs1_pem(rsa::pkcs8::LineEnding::LF)?.to_string(); + let public_key_pem = public_key.to_pkcs1_pem(rsa::pkcs8::LineEnding::LF)?; + + fs::write(&private_key_path, &private_key_pem)?; + fs::write(&public_key_path, &public_key_pem)?; + + Ok(RsaSecret { + private_key, + public_key: public_key_pem, + }) + } + + /// 获取公钥对 + pub fn get_public_key(&self) -> String { + self.public_key.clone() + } + + /// 获取私钥对 + pub fn get_private_key(&self) -> RsaPrivateKey { + self.private_key.clone() + } + + /// 解密为默认的&[u8] + pub fn decrypt(&self, data: &str) -> Result, anyhow::Error> { + // base64 解码 + let data = base64::engine::general_purpose::STANDARD.decode(data).map_err(|e| { + tracing::error!("Failed to decode base64 data: {:?}", e); + anyhow::anyhow!("Failed to decode base64 data: {:?}", e) + })?; + + let private_key = &self.private_key; + let decrypted_data = private_key.decrypt(Pkcs1v15Encrypt, &data)?; + + Ok(decrypted_data) + } + + /// 使用公钥加密 + pub fn encrypt(&self, data: &[u8]) -> Result { + let public_key = &self.private_key.to_public_key(); + + let mut rng = rsa::rand_core::OsRng::default(); + let encrypted_data = public_key.encrypt(&mut rng, Pkcs1v15Encrypt, data).map_err(|e| { + tracing::error!("Failed to encrypt data: {:?}", e); + anyhow::anyhow!("Failed to encrypt data: {:?}", e) + })?; + + let encoded_data = base64::engine::general_purpose::STANDARD.encode(&encrypted_data); + + Ok(encoded_data) + } + + /// 使用公钥加密, 公钥接收到的公钥 + pub fn encrypt_with_public_key( + &self, + data: &[u8], + public_key: &str, + ) -> Result { + let public_key = rsa::RsaPublicKey::from_pkcs1_pem(public_key).map_err(|e| { + tracing::error!("Failed to parse public key: {:?}", e); + anyhow::anyhow!("Failed to parse public key: {:?}", e) + })?; + + let mut rng = rsa::rand_core::OsRng::default(); + let encrypted_data = public_key.encrypt(&mut rng, Pkcs1v15Encrypt, data).map_err(|e| { + tracing::error!("Failed to encrypt data: {:?}", e); + anyhow::anyhow!("Failed to encrypt data: {:?}", e) + })?; + + let encoded_data = base64::engine::general_purpose::STANDARD.encode(&encrypted_data); + + Ok(encoded_data) + } +} + +/// 全局的 RSA 密钥对 +pub static RSA_SECRET_INSTANCE: OnceCell = OnceCell::const_new(); + +/// 初始化全局的 RSA 密钥对 +pub async fn init_rsa_secret(key_path: &str) -> &'static RsaSecret { + RSA_SECRET_INSTANCE + .get_or_init(|| async { RsaSecret::new(key_path).unwrap() }) + .await +} + +/// 获取全局的 RSA 密钥管理实例 +pub fn get_rsa_secret_instance() -> &'static RsaSecret { + match RSA_SECRET_INSTANCE.get() { + Some(secret_instance) => secret_instance, + None => { + tracing::debug!("RSA_SECRET_INSTANCE is not initialized"); + std::process::exit(-1); + } + } +} + +#[cfg(test)] +mod tests { + + use serde::{Deserialize, Serialize}; + + use super::*; + + #[tokio::test] + async fn test_rsa_secret_build() { + #[derive(Serialize, Deserialize, Clone, Debug)] + struct TestStruct { + name: String, + age: u32, + } + + let secret_path = std::path::Path::new(env!("CARGO_MANIFEST_DIR")).join("assets/secret"); + init_rsa_secret(secret_path.to_str().expect("invalid secret path")).await; + + let secret_instance = get_rsa_secret_instance(); + + let data = TestStruct { + name: "test".to_string(), + age: 18, + }; + + let data = serde_json::to_value(data).unwrap().to_string(); + let data = data.as_bytes(); + + let encrypt_data = secret_instance.encrypt(&data).unwrap(); + eprintln!("public_key: {:?}", encrypt_data); + + let res = secret_instance.decrypt(&encrypt_data).unwrap(); + eprintln!("data: {:?}", res); + } +} diff --git a/src-tauri/crates/business/src/utils/storage.rs b/src-tauri/crates/business/src/utils/storage.rs new file mode 100644 index 0000000..1f67535 --- /dev/null +++ b/src-tauri/crates/business/src/utils/storage.rs @@ -0,0 +1,6 @@ +//! Helpers for turning stored resource paths into download URLs. +//! +//! The imported service never exposed an upload API, so object-storage +//! clients do not belong in the local-first application. + +pub mod get_objects; diff --git a/src-tauri/crates/business/src/utils/storage/get_objects.rs b/src-tauri/crates/business/src/utils/storage/get_objects.rs new file mode 100644 index 0000000..caaf5b5 --- /dev/null +++ b/src-tauri/crates/business/src/utils/storage/get_objects.rs @@ -0,0 +1,84 @@ +//! 对象存储 URL 获取模块 +//! +//! 提供获取对象存储资源访问 URL 的功能 + +fn ensure_rooted_path(root: &str, object_path: &str) -> String { + let root = root.trim_matches('/'); + let object_path = object_path.trim_start_matches('/'); + if object_path.is_empty() { + return root.to_string(); + } + if object_path == root || object_path.starts_with(&format!("{}/", root)) { + object_path.to_string() + } else { + format!("{}/{}", root, object_path) + } +} + +/// 获取对象的完整 URL +/// +/// # Arguments +/// - `public_base_url`: 对象存储资源访问基础 URL +/// - `object_path`: 对象路径 +/// +/// # Returns +/// 返回完整的访问 URL +pub fn get_object_url(public_base_url: &str, object_path: &str) -> String { + format!( + "{}/{}", + public_base_url.trim_end_matches('/'), + object_path.trim_start_matches('/') + ) +} + +/// 获取扩展 CRX 文件的完整 URL +/// +/// # Arguments +/// - `public_base_url`: 对象存储资源访问基础 URL +/// - `extension_root`: 扩展对象根路径 +/// - `object_path`: CRX 对象路径(数据库中存储的路径) +pub fn get_extension_crx_url( + public_base_url: &str, + extension_root: &str, + object_path: &str, +) -> String { + get_object_url(public_base_url, &ensure_rooted_path(extension_root, object_path)) +} + +/// 获取扩展图标的完整 URL +/// +/// # Arguments +/// - `public_base_url`: 对象存储资源访问基础 URL +/// - `extension_root`: 扩展对象根路径 +/// - `object_path`: 图标对象路径(数据库中存储的路径) +pub fn get_extension_icon_url( + public_base_url: &str, + extension_root: &str, + object_path: &str, +) -> String { + get_object_url(public_base_url, &ensure_rooted_path(extension_root, object_path)) +} + +/// 获取头像的完整 URL +/// +/// # Arguments +/// - `public_base_url`: 对象存储资源访问基础 URL +/// - `avatar_root`: 头像对象根路径 +/// - `resource_hash`: 头像文件哈希 +pub fn get_avatar_url(public_base_url: &str, avatar_root: &str, resource_hash: &str) -> String { + get_object_url(public_base_url, &ensure_rooted_path(avatar_root, resource_hash)) +} + +/// 获取版本资源的完整 URL +/// +/// # Arguments +/// - `public_base_url`: 对象存储资源访问基础 URL +/// - `version_root`: 版本资源对象根路径 +/// - `object_path`: 版本资源对象路径(数据库中存储的路径) +pub fn get_version_resource_url( + public_base_url: &str, + version_root: &str, + object_path: &str, +) -> String { + get_object_url(public_base_url, &ensure_rooted_path(version_root, object_path)) +} From 076689e16b977d352bd64e4cb229b5d5371b6fa9 Mon Sep 17 00:00:00 2001 From: lius-new Date: Wed, 12 Aug 2026 11:43:55 +0800 Subject: [PATCH 53/60] refactor: embed business crate in Tauri core Rename the copied crate to business, wire it into simprint, and remove the standalone HTTP, deployment, and transport-encryption shell while preserving models, services, DTOs, entities, and migrations. --- src-tauri/Cargo.lock | 859 +++++++++++++++-- src-tauri/Cargo.toml | 1 + src-tauri/crates/business/.cargo/config.toml | 2 - src-tauri/crates/business/.dockerignore | 51 - src-tauri/crates/business/.editorconfig | 22 - .../.github/workflows/publish-ghcr-image.yml | 78 -- src-tauri/crates/business/.gitignore | 39 - src-tauri/crates/business/Cargo.toml | 123 +-- src-tauri/crates/business/Dockerfile | 36 - src-tauri/crates/business/README.md | 125 --- src-tauri/crates/business/README.zh-CN.md | 125 --- .../business/assets/secret/private_key.pem | 27 - .../business/assets/secret/public_key.pem | 8 - src-tauri/crates/business/build_docker.py | 421 --------- src-tauri/crates/business/clippy.toml | 7 - .../business/configs/config.example.toml | 44 - .../configs/config.local.example.toml | 40 - src-tauri/crates/business/docker-compose.yml | 68 -- .../crates/business/docs/database-design.md | 109 --- .../docs/extension-system-refactor.md | 256 ----- .../business/docs/public-key-endpoint-plan.md | 279 ------ ...rkspace-architecture-refactoring-report.md | 686 -------------- .../business/docs/workspace-architecture.md | 884 ------------------ src-tauri/crates/business/src/app.rs | 113 --- src-tauri/crates/business/src/cli.rs | 33 - src-tauri/crates/business/src/handlers.rs | 49 - .../crates/business/src/handlers/accounts.rs | 145 --- .../crates/business/src/handlers/audit.rs | 98 -- .../crates/business/src/handlers/billing.rs | 409 -------- .../business/src/handlers/browser_kernel.rs | 23 - .../business/src/handlers/environments.rs | 815 ---------------- .../business/src/handlers/extensions.rs | 242 ----- .../src/handlers/group_permissions.rs | 138 --- .../crates/business/src/handlers/health.rs | 15 - .../crates/business/src/handlers/local_api.rs | 62 -- .../crates/business/src/handlers/messages.rs | 133 --- .../business/src/handlers/preferences.rs | 38 - .../crates/business/src/handlers/proxies.rs | 174 ---- .../business/src/handlers/proxy_visibility.rs | 150 --- .../crates/business/src/handlers/referral.rs | 210 ----- src-tauri/crates/business/src/handlers/rpa.rs | 144 --- .../crates/business/src/handlers/secret.rs | 15 - .../crates/business/src/handlers/teams.rs | 364 -------- .../crates/business/src/handlers/templates.rs | 157 ---- .../crates/business/src/handlers/time.rs | 10 - .../crates/business/src/handlers/users.rs | 151 --- .../business/src/handlers/workspace_quotas.rs | 43 - .../business/src/handlers/workspaces.rs | 179 ---- src-tauri/crates/business/src/lib.rs | 15 - src-tauri/crates/business/src/main.rs | 26 - src-tauri/crates/business/src/middlewares.rs | 42 - .../crates/business/src/middlewares/auth.rs | 138 --- .../crates/business/src/middlewares/cors.rs | 28 - .../business/src/middlewares/decrypt.rs | 152 --- .../business/src/middlewares/encrypt.rs | 126 --- .../src/middlewares/local_api_auth.rs | 219 ----- .../crates/business/src/middlewares/logger.rs | 67 -- .../business/src/middlewares/real_ip.rs | 47 - src-tauri/crates/business/src/routes.rs | 169 ---- .../crates/business/src/routes/accounts.rs | 37 - src-tauri/crates/business/src/routes/audit.rs | 31 - .../crates/business/src/routes/billing.rs | 108 --- .../business/src/routes/browser_kernel.rs | 16 - .../business/src/routes/environments.rs | 172 ---- .../crates/business/src/routes/extensions.rs | 64 -- .../business/src/routes/group_permissions.rs | 28 - .../crates/business/src/routes/health.rs | 13 - .../crates/business/src/routes/local_api.rs | 27 - .../crates/business/src/routes/messages.rs | 45 - .../crates/business/src/routes/preferences.rs | 20 - .../crates/business/src/routes/proxies.rs | 37 - .../business/src/routes/proxy_visibility.rs | 32 - .../crates/business/src/routes/referral.rs | 82 -- src-tauri/crates/business/src/routes/rpa.rs | 44 - .../crates/business/src/routes/secret.rs | 16 - src-tauri/crates/business/src/routes/teams.rs | 61 -- .../crates/business/src/routes/templates.rs | 40 - src-tauri/crates/business/src/routes/time.rs | 12 - src-tauri/crates/business/src/routes/users.rs | 56 -- .../business/src/routes/workspace_quotas.rs | 20 - .../crates/business/src/routes/workspaces.rs | 36 - src-tauri/crates/business/src/utils.rs | 24 +- .../crates/business/src/utils/extractor.rs | 43 - .../crates/business/src/utils/responses.rs | 126 --- .../crates/business/src/utils/secret/aes.rs | 216 ----- .../crates/business/src/utils/secret/mod.rs | 5 - .../crates/business/src/utils/secret/rsa.rs | 173 ---- 87 files changed, 813 insertions(+), 10000 deletions(-) delete mode 100644 src-tauri/crates/business/.cargo/config.toml delete mode 100644 src-tauri/crates/business/.dockerignore delete mode 100644 src-tauri/crates/business/.editorconfig delete mode 100644 src-tauri/crates/business/.github/workflows/publish-ghcr-image.yml delete mode 100644 src-tauri/crates/business/.gitignore delete mode 100644 src-tauri/crates/business/Dockerfile delete mode 100644 src-tauri/crates/business/README.md delete mode 100644 src-tauri/crates/business/README.zh-CN.md delete mode 100644 src-tauri/crates/business/assets/secret/private_key.pem delete mode 100644 src-tauri/crates/business/assets/secret/public_key.pem delete mode 100644 src-tauri/crates/business/build_docker.py delete mode 100644 src-tauri/crates/business/clippy.toml delete mode 100644 src-tauri/crates/business/configs/config.example.toml delete mode 100644 src-tauri/crates/business/configs/config.local.example.toml delete mode 100644 src-tauri/crates/business/docker-compose.yml delete mode 100644 src-tauri/crates/business/docs/database-design.md delete mode 100644 src-tauri/crates/business/docs/extension-system-refactor.md delete mode 100644 src-tauri/crates/business/docs/public-key-endpoint-plan.md delete mode 100644 src-tauri/crates/business/docs/workspace-architecture-refactoring-report.md delete mode 100644 src-tauri/crates/business/docs/workspace-architecture.md delete mode 100644 src-tauri/crates/business/src/app.rs delete mode 100644 src-tauri/crates/business/src/cli.rs delete mode 100644 src-tauri/crates/business/src/handlers.rs delete mode 100644 src-tauri/crates/business/src/handlers/accounts.rs delete mode 100644 src-tauri/crates/business/src/handlers/audit.rs delete mode 100644 src-tauri/crates/business/src/handlers/billing.rs delete mode 100644 src-tauri/crates/business/src/handlers/browser_kernel.rs delete mode 100644 src-tauri/crates/business/src/handlers/environments.rs delete mode 100644 src-tauri/crates/business/src/handlers/extensions.rs delete mode 100644 src-tauri/crates/business/src/handlers/group_permissions.rs delete mode 100644 src-tauri/crates/business/src/handlers/health.rs delete mode 100644 src-tauri/crates/business/src/handlers/local_api.rs delete mode 100644 src-tauri/crates/business/src/handlers/messages.rs delete mode 100644 src-tauri/crates/business/src/handlers/preferences.rs delete mode 100644 src-tauri/crates/business/src/handlers/proxies.rs delete mode 100644 src-tauri/crates/business/src/handlers/proxy_visibility.rs delete mode 100644 src-tauri/crates/business/src/handlers/referral.rs delete mode 100644 src-tauri/crates/business/src/handlers/rpa.rs delete mode 100644 src-tauri/crates/business/src/handlers/secret.rs delete mode 100644 src-tauri/crates/business/src/handlers/teams.rs delete mode 100644 src-tauri/crates/business/src/handlers/templates.rs delete mode 100644 src-tauri/crates/business/src/handlers/time.rs delete mode 100644 src-tauri/crates/business/src/handlers/users.rs delete mode 100644 src-tauri/crates/business/src/handlers/workspace_quotas.rs delete mode 100644 src-tauri/crates/business/src/handlers/workspaces.rs delete mode 100644 src-tauri/crates/business/src/main.rs delete mode 100644 src-tauri/crates/business/src/middlewares.rs delete mode 100644 src-tauri/crates/business/src/middlewares/auth.rs delete mode 100644 src-tauri/crates/business/src/middlewares/cors.rs delete mode 100644 src-tauri/crates/business/src/middlewares/decrypt.rs delete mode 100644 src-tauri/crates/business/src/middlewares/encrypt.rs delete mode 100644 src-tauri/crates/business/src/middlewares/local_api_auth.rs delete mode 100644 src-tauri/crates/business/src/middlewares/logger.rs delete mode 100644 src-tauri/crates/business/src/middlewares/real_ip.rs delete mode 100644 src-tauri/crates/business/src/routes.rs delete mode 100644 src-tauri/crates/business/src/routes/accounts.rs delete mode 100644 src-tauri/crates/business/src/routes/audit.rs delete mode 100644 src-tauri/crates/business/src/routes/billing.rs delete mode 100644 src-tauri/crates/business/src/routes/browser_kernel.rs delete mode 100644 src-tauri/crates/business/src/routes/environments.rs delete mode 100644 src-tauri/crates/business/src/routes/extensions.rs delete mode 100644 src-tauri/crates/business/src/routes/group_permissions.rs delete mode 100644 src-tauri/crates/business/src/routes/health.rs delete mode 100644 src-tauri/crates/business/src/routes/local_api.rs delete mode 100644 src-tauri/crates/business/src/routes/messages.rs delete mode 100644 src-tauri/crates/business/src/routes/preferences.rs delete mode 100644 src-tauri/crates/business/src/routes/proxies.rs delete mode 100644 src-tauri/crates/business/src/routes/proxy_visibility.rs delete mode 100644 src-tauri/crates/business/src/routes/referral.rs delete mode 100644 src-tauri/crates/business/src/routes/rpa.rs delete mode 100644 src-tauri/crates/business/src/routes/secret.rs delete mode 100644 src-tauri/crates/business/src/routes/teams.rs delete mode 100644 src-tauri/crates/business/src/routes/templates.rs delete mode 100644 src-tauri/crates/business/src/routes/time.rs delete mode 100644 src-tauri/crates/business/src/routes/users.rs delete mode 100644 src-tauri/crates/business/src/routes/workspace_quotas.rs delete mode 100644 src-tauri/crates/business/src/routes/workspaces.rs delete mode 100644 src-tauri/crates/business/src/utils/extractor.rs delete mode 100644 src-tauri/crates/business/src/utils/responses.rs delete mode 100644 src-tauri/crates/business/src/utils/secret/aes.rs delete mode 100644 src-tauri/crates/business/src/utils/secret/mod.rs delete mode 100644 src-tauri/crates/business/src/utils/secret/rsa.rs diff --git a/src-tauri/Cargo.lock b/src-tauri/Cargo.lock index 682c846..060255a 100644 --- a/src-tauri/Cargo.lock +++ b/src-tauri/Cargo.lock @@ -14,7 +14,7 @@ version = "0.5.2" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "d122413f284cf2d62fb1b7db97e02edb8cda96d769b16e443a4f6195e35662b0" dependencies = [ - "crypto-common", + "crypto-common 0.1.7", "generic-array", ] @@ -78,6 +78,12 @@ 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_log-sys" version = "0.3.2" @@ -121,9 +127,9 @@ dependencies = [ [[package]] name = "anstyle" -version = "1.0.13" +version = "1.0.14" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "5192cca8006f1fd4f7237516f40fa183bb07f8fbdfedaa0036de5ea9b0b45e78" +checksum = "940b3a0ca603d1eade50a4846a2afffd5ef57a9feac2c0e2ec2e14f9ead76000" [[package]] name = "anstyle-parse" @@ -190,6 +196,18 @@ dependencies = [ "x11rb", ] +[[package]] +name = "argon2" +version = "0.5.3" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "3c3610892ee6e0cbce8ae2700349fcf8f98adb0dbfbee85aec3c9179d29cc072" +dependencies = [ + "base64ct", + "blake2", + "cpufeatures 0.2.17", + "password-hash", +] + [[package]] name = "arraydeque" version = "0.5.1" @@ -228,9 +246,9 @@ dependencies = [ [[package]] name = "async-executor" -version = "1.13.3" +version = "1.14.0" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "497c00e0fd83a72a79a39fcbd8e3e2f055d6f6c7e025f3b3d91f4f8e76527fb8" +checksum = "c96bf972d85afc50bf5ab8fe2d54d1586b4e0b46c97c50a0c9e71e2f7bcd812a" dependencies = [ "async-task", "concurrent-queue", @@ -253,7 +271,7 @@ dependencies = [ "futures-lite", "parking", "polling", - "rustix", + "rustix 1.1.4", "slab", "windows-sys 0.61.2", ] @@ -284,7 +302,7 @@ dependencies = [ "cfg-if", "event-listener", "futures-lite", - "rustix", + "rustix 1.1.4", ] [[package]] @@ -310,7 +328,7 @@ dependencies = [ "cfg-if", "futures-core", "futures-io", - "rustix", + "rustix 1.1.4", "signal-hook-registry", "slab", "windows-sys 0.61.2", @@ -356,6 +374,15 @@ dependencies = [ "system-deps", ] +[[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" @@ -431,6 +458,12 @@ dependencies = [ "tracing", ] +[[package]] +name = "base64" +version = "0.13.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "9e1b586273c5702936fe7b7d6896644d8be71e6314cfe09d3167c95f712589e8" + [[package]] name = "base64" version = "0.21.7" @@ -443,6 +476,12 @@ version = "0.22.1" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "72b3254f16251a8381aa12e40e3c4d2f0199f8c6508fbecb9d91f575e0fbb8c6" +[[package]] +name = "base64" +version = "0.23.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "ac07cdecf99051d9a5238b80f35af32cdeba5b336e55d957b318b50137e18da5" + [[package]] name = "base64ct" version = "1.8.2" @@ -476,6 +515,15 @@ dependencies = [ "wyz", ] +[[package]] +name = "blake2" +version = "0.10.6" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "46502ad458c9a52b69d4d4d32775c788b7a1b85e8bc9d482d92250fc0e3f8efe" +dependencies = [ + "digest 0.10.7", +] + [[package]] name = "block-buffer" version = "0.10.4" @@ -485,6 +533,15 @@ 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 = "block2" version = "0.6.2" @@ -557,6 +614,33 @@ version = "3.19.1" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "5dd9dc738b7a8311c7ade152424974d8115f2cdad61e8dab8dac9f2362298510" +[[package]] +name = "business" +version = "0.1.0" +dependencies = [ + "aes-gcm", + "anyhow", + "argon2", + "chrono", + "config", + "dashmap", + "hex", + "hmac 0.12.1", + "jwt", + "lettre", + "rand 0.9.2", + "rust_decimal", + "serde", + "serde_json", + "sha2 0.10.9", + "sqlx", + "thiserror 2.0.17", + "tokio", + "tracing", + "url", + "uuid", +] + [[package]] name = "byte-unit" version = "5.2.0" @@ -786,7 +870,7 @@ version = "0.4.4" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "773f3b9af64447d2ce9850330c473515014aa235e6a783b02db81ff39e4a3dad" dependencies = [ - "crypto-common", + "crypto-common 0.1.7", "inout", ] @@ -799,6 +883,12 @@ dependencies = [ "error-code", ] +[[package]] +name = "cmov" +version = "0.5.4" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "0c9ea0ac24bc397ab3c98583a3c9ba74fa56b09a4449bbe172b9b1ddb016027a" + [[package]] name = "colorchoice" version = "1.0.4" @@ -850,6 +940,12 @@ version = "0.9.6" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "c2459377285ad874054d797f3ccebf984978aa39129f6eafde5cdc8315b612f8" +[[package]] +name = "const-oid" +version = "0.10.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "a6ef517f0926dd24a1582492c791b6a4818a4d94e789a334894aa15b0d12f55c" + [[package]] name = "const-random" version = "0.1.18" @@ -1002,6 +1098,15 @@ dependencies = [ "crossbeam-utils", ] +[[package]] +name = "crossbeam-queue" +version = "0.3.13" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "803d13fb3b09d88be9f4dbc29062c66b19bf7170867ceb746d2a8689bf6c7a26" +dependencies = [ + "crossbeam-utils", +] + [[package]] name = "crossbeam-utils" version = "0.8.21" @@ -1025,6 +1130,15 @@ dependencies = [ "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 = "cssparser" version = "0.29.6" @@ -1071,6 +1185,15 @@ dependencies = [ "cipher", ] +[[package]] +name = "ctutils" +version = "0.4.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "7d5515a3834141de9eafb9717ad39eea8247b5674e6066c404e8c4b365d2a29e" +dependencies = [ + "cmov", +] + [[package]] name = "darling" version = "0.21.3" @@ -1140,6 +1263,20 @@ dependencies = [ "syn 2.0.114", ] +[[package]] +name = "dashmap" +version = "6.1.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "5041cc499144891f3790297212f32a74fb938e5136a14943f338ef9e0ae276cf" +dependencies = [ + "cfg-if", + "crossbeam-utils", + "hashbrown 0.14.5", + "lock_api", + "once_cell", + "parking_lot_core", +] + [[package]] name = "dbus" version = "0.9.11" @@ -1173,7 +1310,7 @@ version = "0.7.10" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "e7c1832837b905bbfb5101e07cc24c8deddf52f93225eee6ead5f4d63d53ddcb" dependencies = [ - "const-oid", + "const-oid 0.9.6", "pem-rfc7468", "zeroize", ] @@ -1218,12 +1355,24 @@ version = "0.10.7" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "9ed9a281f7bc9b7576e61468ba615a66a5c8cfdff42420a70aa82701a3b1e292" dependencies = [ - "block-buffer", - "const-oid", - "crypto-common", + "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", + "const-oid 0.10.2", + "crypto-common 0.2.2", + "ctutils", +] + [[package]] name = "directories" version = "6.0.0" @@ -1335,6 +1484,12 @@ dependencies = [ "const-random", ] +[[package]] +name = "dotenvy" +version = "0.15.7" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "1aaf95b3e5c8f23aa320147307562d361db0ae0d51242340f558153b4eb2439b" + [[package]] name = "downcast-rs" version = "1.2.1" @@ -1382,6 +1537,25 @@ name = "either" version = "1.15.0" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "48c757948c5ede0e46177b7add2e67155f70e33c07fea8284df6576da70b3719" +dependencies = [ + "serde", +] + +[[package]] +name = "email-encoding" +version = "0.4.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "420b9da095f052ea597503e39073b5b3c522f7db933fbac202d91d24492693fd" +dependencies = [ + "base64 0.23.1", + "memchr", +] + +[[package]] +name = "email_address" +version = "0.2.9" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "e079f19b08ca6239f47f8ba8509c11cf3ea30095831f7fed61441475edd8c449" [[package]] name = "embed-resource" @@ -1509,13 +1683,23 @@ version = "3.3.2" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "dea2df4cf52843e0452895c455a1a2cfbb842a1e7329671acf418fdc53ed4c59" +[[package]] +name = "etcetera" +version = "0.8.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "136d1b5283a1ab77bd9257427ffd09d8667ced0570b6f938942bc7568ed5b943" +dependencies = [ + "cfg-if", + "home", + "windows-sys 0.48.0", +] + [[package]] name = "event-listener" -version = "5.4.1" +version = "5.4.2" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "e13b66accf52311f30a0db42147dadea9850cb48cd070028831ae5f5d4b856ab" +checksum = "5a23add41df1562121a9393cb065eab5146a1242410f23a644851e90cfd669d2" dependencies = [ - "concurrent-queue", "parking", "pin-project-lite", ] @@ -1530,6 +1714,12 @@ dependencies = [ "pin-project-lite", ] +[[package]] +name = "fallible-iterator" +version = "0.2.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "4443176a9f2c162692bd3d352d745ef9413eec5782a80d8fd6f8a1ac692a07f7" + [[package]] name = "fastrand" version = "2.3.0" @@ -1606,6 +1796,17 @@ dependencies = [ "miniz_oxide", ] +[[package]] +name = "flume" +version = "0.11.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "da0e4dd2a88388a1f4ccc7c9ce104604dab68d9f408dc34cd45823d5a9069095" +dependencies = [ + "futures-core", + "futures-sink", + "spin", +] + [[package]] name = "fnv" version = "1.0.7" @@ -1727,6 +1928,17 @@ dependencies = [ "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.31" @@ -1911,7 +2123,7 @@ version = "1.1.0" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "1bd49230192a3797a9a4d6abe9b3eed6f7fa4c8a8a4947977c6f80025f92cbd8" dependencies = [ - "rustix", + "rustix 1.1.4", "windows-link 0.2.1", ] @@ -2185,6 +2397,8 @@ version = "0.15.5" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "9229cfe53dfd69f0609a49f65461bd93001ea1ef889cd5529dd176593f5338a1" dependencies = [ + "allocator-api2", + "equivalent", "foldhash", ] @@ -2233,7 +2447,7 @@ version = "0.12.4" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "7b5f8eb2ad728638ea2c7d47a21db23b7b58a72ed6a38256b8a1849f15fbbdf7" dependencies = [ - "hmac", + "hmac 0.12.1", ] [[package]] @@ -2242,7 +2456,36 @@ version = "0.12.1" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "6c49c37c09c17a53d937dfbb742eb3a961d65a994e6bcdcf37e7399d0cc8ab5e" dependencies = [ - "digest", + "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 = "home" +version = "0.5.12" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "cc627f471c528ff0c4a49e1d5e60450c8f6461dd6d10ba9dcd3a61d3dff7728d" +dependencies = [ + "windows-sys 0.61.2", +] + +[[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 0.2.1", ] [[package]] @@ -2302,6 +2545,15 @@ version = "1.0.3" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "df3b46402a9d5adb4c86a0cf463f42e19994e3ee891101b1841f30a545cb49a9" +[[package]] +name = "hybrid-array" +version = "0.4.14" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "707114b52a152fa7bdb290cd7cd5912d9467273b6d74e21b8d81aca1f8533f6b" +dependencies = [ + "typenum", +] + [[package]] name = "hyper" version = "1.8.1" @@ -2396,7 +2648,7 @@ dependencies = [ "js-sys", "log", "wasm-bindgen", - "windows-core 0.62.2", + "windows-core 0.61.2", ] [[package]] @@ -2744,11 +2996,12 @@ dependencies = [ [[package]] name = "js-sys" -version = "0.3.83" +version = "0.3.102" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "464a3709c7f55f1f721e5389aa6ea4e3bc6aba669353300af094b29ffbdde1d8" +checksum = "03d04c30968dffe80775bd4d7fb676131cd04a1fb46d2686dbffbaec2d9dfd31" dependencies = [ - "once_cell", + "cfg-if", + "futures-util", "wasm-bindgen", ] @@ -2785,6 +3038,21 @@ dependencies = [ "serde_json", ] +[[package]] +name = "jwt" +version = "0.16.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "6204285f77fe7d9784db3fdc449ecce1a0114927a51d5a41c4c7a292011c015f" +dependencies = [ + "base64 0.13.1", + "crypto-common 0.1.7", + "digest 0.10.7", + "hmac 0.12.1", + "serde", + "serde_json", + "sha2 0.10.9", +] + [[package]] name = "keyboard-types" version = "0.7.0" @@ -2838,6 +3106,30 @@ version = "0.1.0" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "09edd9e8b54e49e587e4f6295a7d29c3ea94d469cb40ab8ca70b288248a81db2" +[[package]] +name = "lettre" +version = "0.11.23" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "f2c646bd5cc763b1087b15493e29a64be6147ba8f19342004fa52048ee596eae" +dependencies = [ + "base64 0.23.1", + "email-encoding", + "email_address", + "fastrand", + "futures-util", + "hostname", + "httpdate", + "idna", + "mime", + "native-tls", + "nom", + "percent-encoding", + "quoted_printable", + "socket2", + "tokio", + "url", +] + [[package]] name = "libappindicator" version = "0.9.0" @@ -2864,9 +3156,9 @@ dependencies = [ [[package]] name = "libc" -version = "0.2.179" +version = "0.2.189" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "c5a2d376baa530d1238d133232d15e239abad80d05838b4b59354e5268af431f" +checksum = "3eaf3ede3fee6db1a4c2ee091bf8a8b4dccdc6d17f656fb07896ee72867612f2" [[package]] name = "libdbus-sys" @@ -2901,13 +3193,31 @@ checksum = "3d0b95e02c851351f877147b7deea7b1afb1df71b63aa5f8270716e0c5720616" dependencies = [ "bitflags 2.11.1", "libc", + "redox_syscall 0.7.5", +] + +[[package]] +name = "libsqlite3-sys" +version = "0.30.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "2e99fb7a497b1e3339bc746195567ed8d3e24945ecd636e3619d20b9de9e9149" +dependencies = [ + "cc", + "pkg-config", + "vcpkg", ] [[package]] name = "linux-raw-sys" -version = "0.11.0" +version = "0.4.15" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "d26c52dbd32dccf2d10cac7725f8eae5296885fb5703b261f7d0a0739ec807ab" + +[[package]] +name = "linux-raw-sys" +version = "0.12.1" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "df1d3c3b53da64cf5760482273a98e575c651a67eec7f77df96b5b642de8f039" +checksum = "32a66949e030da00e8c7d4434b251670a91556f4144941d37452769c25d58a53" [[package]] name = "litemap" @@ -3012,6 +3322,26 @@ version = "0.8.4" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "47e1ffaa40ddd1f3ed91f717a33c8c0ee23fff369e3aa8772b9605cc1d22f4c3" +[[package]] +name = "md-5" +version = "0.10.6" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "d89e7ee0cfbedfc4da3340218492196241d89eefb6dab27de5df917a6d2e78cf" +dependencies = [ + "cfg-if", + "digest 0.10.7", +] + +[[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 = "memchr" version = "2.7.6" @@ -3639,11 +3969,22 @@ checksum = "2621685985a2ebf1c516881c026032ac7deafcda1a2c9b7850dc81e3dfcb64c1" dependencies = [ "cfg-if", "libc", - "redox_syscall", + "redox_syscall 0.5.18", "smallvec", "windows-link 0.2.1", ] +[[package]] +name = "password-hash" +version = "0.5.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "346f04948ba92c43e8469c1ee6736c7563d71012b17d40745260fe106aac2166" +dependencies = [ + "base64ct", + "rand_core 0.6.4", + "subtle", +] + [[package]] name = "pastey" version = "0.2.1" @@ -3662,8 +4003,8 @@ version = "0.12.2" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "f8ed6a7761f76e3b9f92dfb0a60a6a6477c61024b775147ff0973a02653abaf2" dependencies = [ - "digest", - "hmac", + "digest 0.10.7", + "hmac 0.12.1", ] [[package]] @@ -3721,7 +4062,7 @@ source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "602113b5b5e8621770cfd490cfd90b9f84ab29bd2b0e49ad83eb6d186cef2365" dependencies = [ "pest", - "sha2", + "sha2 0.10.9", ] [[package]] @@ -3883,9 +4224,9 @@ checksum = "8b870d8c151b6f2fb93e84a13146138f05d02ed11c7e7c54f8826aaaf7c9f184" [[package]] name = "piper" -version = "0.2.4" +version = "0.2.5" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "96c8c490f422ef9a4efd2cb5b42b76c8613d7e7dfc1caf667b8a3350a5acc066" +checksum = "c835479a4443ded371d6c535cbfd8d31ad92c5d23ae9770a61bc155e4992a3c1" dependencies = [ "atomic-waker", "fastrand", @@ -3921,13 +4262,13 @@ checksum = "7edddbd0b52d732b21ad9a5fab5c704c14cd949e5e9a1ec5929a24fded1b904c" [[package]] name = "plist" -version = "1.8.0" +version = "1.7.0" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "740ebea15c5d1428f910cd1a5f52cebf8d25006245ed8ade92702f4943d91e07" +checksum = "42cf17e9a1800f5f396bc67d193dc9411b59012a5876445ef450d449881e1016" dependencies = [ "base64 0.22.1", "indexmap 2.13.0", - "quick-xml", + "quick-xml 0.32.0", "serde", "time", ] @@ -3968,7 +4309,7 @@ dependencies = [ "concurrent-queue", "hermit-abi", "pin-project-lite", - "rustix", + "rustix 1.1.4", "windows-sys 0.61.2", ] @@ -3999,6 +4340,35 @@ dependencies = [ "portable-atomic", ] +[[package]] +name = "postgres-protocol" +version = "0.6.12" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "08808e3c483c46e999108051c78334f473d5adb59d78bb80a1268c7e6aa6c514" +dependencies = [ + "base64 0.22.1", + "byteorder", + "bytes", + "fallible-iterator", + "hmac 0.13.0", + "md-5 0.11.0", + "memchr", + "rand 0.10.0", + "sha2 0.11.0", + "stringprep", +] + +[[package]] +name = "postgres-types" +version = "0.2.14" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "851ca9db4932932d69f3ea811b1abe63087a0f740a47692619dd40d4899b68be" +dependencies = [ + "bytes", + "fallible-iterator", + "postgres-protocol", +] + [[package]] name = "potential_utf" version = "0.1.4" @@ -4100,9 +4470,9 @@ checksum = "dc375e1527247fe1a97d8b7156678dfe7c1af2fc075c9a4db3690ecd2a148068" [[package]] name = "proc-macro2" -version = "1.0.105" +version = "1.0.107" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "535d180e0ecab6268a3e718bb9fd44db66bbbc256257165fc699dadf70d16fe7" +checksum = "985e7ec9bb745e6ce6535b544d84d6cd6f7ad8bd711c398938ae983b91a766d9" dependencies = [ "unicode-ident", ] @@ -4142,6 +4512,15 @@ version = "2.0.1" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "a993555f31e5a609f617c12db6250dedcac1b0a85076912c436e6fc9b2c8e6a3" +[[package]] +name = "quick-xml" +version = "0.32.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "1d3a6e5838b60e0e8fa7a43f22ade549a37d61f8bdbe636d0d7816191de969c2" +dependencies = [ + "memchr", +] + [[package]] name = "quick-xml" version = "0.38.4" @@ -4208,13 +4587,19 @@ dependencies = [ [[package]] name = "quote" -version = "1.0.43" +version = "1.0.47" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "dc74d9a594b72ae6656596548f56f667211f8a97b3d4c3d467150794690dc40a" +checksum = "1fbf4db142a473a8d80c26bbf18454ed458bf8d26c8219c331daecfdbd079001" 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" @@ -4386,6 +4771,15 @@ dependencies = [ "bitflags 2.11.1", ] +[[package]] +name = "redox_syscall" +version = "0.7.5" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "4666a1a60d8412eab19d94f6d13dcc9cea0a5ef4fdf6a5db306537413c661b1b" +dependencies = [ + "bitflags 2.11.1", +] + [[package]] name = "redox_users" version = "0.4.6" @@ -4665,8 +5059,8 @@ version = "0.9.10" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "b8573f03f5883dcaebdfcf4725caa1ecb9c15b2ef50c43a07b816e06799bb12d" dependencies = [ - "const-oid", - "digest", + "const-oid 0.9.6", + "digest 0.10.7", "num-bigint-dig", "num-integer", "num-traits", @@ -4716,6 +5110,7 @@ dependencies = [ "borsh", "bytes", "num-traits", + "postgres-types", "rand 0.8.5", "rkyv", "serde", @@ -4739,14 +5134,27 @@ dependencies = [ [[package]] name = "rustix" -version = "1.1.3" +version = "0.38.44" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "146c9e247ccc180c1f61615433868c99f3de3ae256a30a43b49f67c2d9171f34" +checksum = "fdb5bc1ae2baa591800df16c9ca78619bf65c0488b41b96ccec5d11220d8c154" dependencies = [ "bitflags 2.11.1", "errno", "libc", - "linux-raw-sys", + "linux-raw-sys 0.4.15", + "windows-sys 0.59.0", +] + +[[package]] +name = "rustix" +version = "1.1.4" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "b6fe4565b9518b83ef4f91bb47ce29620ca828bd32cb7e408f0062e9930ba190" +dependencies = [ + "bitflags 2.11.1", + "errno", + "libc", + "linux-raw-sys 0.12.1", "windows-sys 0.61.2", ] @@ -4787,9 +5195,9 @@ dependencies = [ [[package]] name = "rustversion" -version = "1.0.22" +version = "1.0.23" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "b39cdef0fa800fc44525c84ccb54a029961a8215f9619753635a9c0d2538d46d" +checksum = "cf54715a573b99ac80df0bc206da022bcd442c974952c7b9720069370852e21f" [[package]] name = "ryu" @@ -5168,7 +5576,7 @@ checksum = "e3bf829a2d51ab4a5ddf1352d8470c140cadc8301b2ae1789db023f01cedd6ba" dependencies = [ "cfg-if", "cpufeatures 0.2.17", - "digest", + "digest 0.10.7", ] [[package]] @@ -5179,7 +5587,18 @@ checksum = "a7507d819769d01a365ab707794a4084392c824f54a7a6a7862f8c3d0892b283" dependencies = [ "cfg-if", "cpufeatures 0.2.17", - "digest", + "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]] @@ -5204,7 +5623,7 @@ version = "2.2.0" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "77549399552de45a898a580c1b41d445bf730df867cc44e6c0233bbc4b8329de" dependencies = [ - "digest", + "digest 0.10.7", "rand_core 0.6.4", ] @@ -5229,6 +5648,7 @@ dependencies = [ "anyhow", "axum", "base64 0.22.1", + "business", "bytes", "chrono", "config", @@ -5256,7 +5676,7 @@ dependencies = [ "serde_bytes", "serde_json", "serde_yaml", - "sha2", + "sha2 0.10.9", "smbios-lib", "sysinfo", "tauri", @@ -5304,6 +5724,9 @@ name = "smallvec" version = "1.15.1" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "67b1b7a3b5fe4f1376887184045fcf45c69e92af734b7aaddc05fb777b6fbd03" +dependencies = [ + "serde", +] [[package]] name = "smbios-lib" @@ -5346,7 +5769,7 @@ dependencies = [ "objc2-foundation", "objc2-quartz-core", "raw-window-handle", - "redox_syscall", + "redox_syscall 0.5.18", "tracing", "wasm-bindgen", "web-sys", @@ -5384,6 +5807,9 @@ name = "spin" version = "0.9.8" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "6980e8d7511241f8acf4aebddbb1ff938df5eebe98691418c4468d0b72a96a67" +dependencies = [ + "lock_api", +] [[package]] name = "spki" @@ -5395,6 +5821,205 @@ dependencies = [ "der", ] +[[package]] +name = "sqlx" +version = "0.8.6" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "1fefb893899429669dcdd979aff487bd78f4064e5e7907e4269081e0ef7d97dc" +dependencies = [ + "sqlx-core", + "sqlx-macros", + "sqlx-mysql", + "sqlx-postgres", + "sqlx-sqlite", +] + +[[package]] +name = "sqlx-core" +version = "0.8.6" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "ee6798b1838b6a0f69c007c133b8df5866302197e404e8b6ee8ed3e3a5e68dc6" +dependencies = [ + "base64 0.22.1", + "bytes", + "chrono", + "crc", + "crossbeam-queue", + "either", + "event-listener", + "futures-core", + "futures-intrusive", + "futures-io", + "futures-util", + "hashbrown 0.15.5", + "hashlink", + "indexmap 2.13.0", + "log", + "memchr", + "once_cell", + "percent-encoding", + "rust_decimal", + "serde", + "serde_json", + "sha2 0.10.9", + "smallvec", + "thiserror 2.0.17", + "tokio", + "tokio-stream", + "tracing", + "url", + "uuid", +] + +[[package]] +name = "sqlx-macros" +version = "0.8.6" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "a2d452988ccaacfbf5e0bdbc348fb91d7c8af5bee192173ac3636b5fb6e6715d" +dependencies = [ + "proc-macro2", + "quote", + "sqlx-core", + "sqlx-macros-core", + "syn 2.0.114", +] + +[[package]] +name = "sqlx-macros-core" +version = "0.8.6" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "19a9c1841124ac5a61741f96e1d9e2ec77424bf323962dd894bdb93f37d5219b" +dependencies = [ + "dotenvy", + "either", + "heck 0.5.0", + "hex", + "once_cell", + "proc-macro2", + "quote", + "serde", + "serde_json", + "sha2 0.10.9", + "sqlx-core", + "sqlx-mysql", + "sqlx-postgres", + "sqlx-sqlite", + "syn 2.0.114", + "tokio", + "url", +] + +[[package]] +name = "sqlx-mysql" +version = "0.8.6" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "aa003f0038df784eb8fecbbac13affe3da23b45194bd57dba231c8f48199c526" +dependencies = [ + "atoi", + "base64 0.22.1", + "bitflags 2.11.1", + "byteorder", + "bytes", + "chrono", + "crc", + "digest 0.10.7", + "dotenvy", + "either", + "futures-channel", + "futures-core", + "futures-io", + "futures-util", + "generic-array", + "hex", + "hkdf", + "hmac 0.12.1", + "itoa", + "log", + "md-5 0.10.6", + "memchr", + "once_cell", + "percent-encoding", + "rand 0.8.5", + "rsa", + "rust_decimal", + "serde", + "sha1", + "sha2 0.10.9", + "smallvec", + "sqlx-core", + "stringprep", + "thiserror 2.0.17", + "tracing", + "uuid", + "whoami", +] + +[[package]] +name = "sqlx-postgres" +version = "0.8.6" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "db58fcd5a53cf07c184b154801ff91347e4c30d17a3562a635ff028ad5deda46" +dependencies = [ + "atoi", + "base64 0.22.1", + "bitflags 2.11.1", + "byteorder", + "chrono", + "crc", + "dotenvy", + "etcetera", + "futures-channel", + "futures-core", + "futures-util", + "hex", + "hkdf", + "hmac 0.12.1", + "home", + "itoa", + "log", + "md-5 0.10.6", + "memchr", + "once_cell", + "rand 0.8.5", + "rust_decimal", + "serde", + "serde_json", + "sha2 0.10.9", + "smallvec", + "sqlx-core", + "stringprep", + "thiserror 2.0.17", + "tracing", + "uuid", + "whoami", +] + +[[package]] +name = "sqlx-sqlite" +version = "0.8.6" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "c2d12fe70b2c1b4401038055f90f151b78208de1f9f89a7dbfd41587a10c3eea" +dependencies = [ + "atoi", + "chrono", + "flume", + "futures-channel", + "futures-core", + "futures-executor", + "futures-intrusive", + "futures-util", + "libsqlite3-sys", + "log", + "percent-encoding", + "serde", + "serde_urlencoded", + "sqlx-core", + "thiserror 2.0.17", + "tracing", + "url", + "uuid", +] + [[package]] name = "sse-stream" version = "0.2.1" @@ -5445,6 +6070,17 @@ dependencies = [ "quote", ] +[[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" @@ -5711,7 +6347,7 @@ dependencies = [ "semver", "serde", "serde_json", - "sha2", + "sha2 0.10.9", "syn 2.0.114", "tauri-utils", "thiserror 2.0.17", @@ -6049,15 +6685,16 @@ dependencies = [ [[package]] name = "tempfile" -version = "3.24.0" +version = "3.15.0" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "655da9c7eb6305c55742045d5a8d2037996d61d8de95806335c7c86ce0f82e9c" +checksum = "9a8a559c81686f576e8cd0290cd2a24a2a9ad80c98b3478856500fcbd7acd704" dependencies = [ + "cfg-if", "fastrand", - "getrandom 0.3.4", + "getrandom 0.2.16", "once_cell", - "rustix", - "windows-sys 0.61.2", + "rustix 0.38.44", + "windows-sys 0.59.0", ] [[package]] @@ -6496,9 +7133,9 @@ checksum = "bc7d623258602320d5c55d1bc22793b57daff0ec7efc270ea7d55ce1d5f5471c" [[package]] name = "typenum" -version = "1.19.0" +version = "1.20.1" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "562d481066bde0658276a35467c4af00bdc6ee726305698a55b86e61d7ad82bb" +checksum = "b6f5e870be6c3b371b77fe0ee0bafb859fa4964b4404c27de1d380043c4dda20" [[package]] name = "ucd-trie" @@ -6564,12 +7201,33 @@ version = "2.9.0" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "dbc4bc3a9f746d862c45cb89d705aa10f187bb96c76001afab07a0d35ce60142" +[[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.22" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "9312f7c4f6ff9069b165498234ce8be658059c6728633667c526e27dc2cf1df5" +[[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 = "unicode-segmentation" version = "1.12.0" @@ -6594,7 +7252,7 @@ version = "0.5.1" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "fc1de2c688dc15305988b563c3854064043356019f97a4b46276fe734c4f07ea" dependencies = [ - "crypto-common", + "crypto-common 0.1.7", "subtle", ] @@ -6661,12 +7319,13 @@ checksum = "06abde3611657adf66d383f00b093d7faecc7fa57071cce2578660c9f1010821" [[package]] name = "uuid" -version = "1.19.0" +version = "1.24.0" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "e2e054861b4bd027cd373e18e8d8d8e6548085000e41290d95ce0c373a654b4a" +checksum = "bf3923a6f5c4c6382e0b653c4117f48d631ea17f38ed86e2a828e6f7412f5239" dependencies = [ - "getrandom 0.3.4", + "getrandom 0.4.2", "js-sys", + "rand 0.10.0", "serde_core", "wasm-bindgen", ] @@ -6764,11 +7423,17 @@ dependencies = [ "wit-bindgen 0.51.0", ] +[[package]] +name = "wasite" +version = "0.1.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "b8dad83b4f25e74f184f64c43b150b91efe7647395b42289f38e50566d82855b" + [[package]] name = "wasm-bindgen" -version = "0.2.106" +version = "0.2.125" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "0d759f433fa64a2d763d1340820e46e111a7a5ab75f993d1852d70b03dbb80fd" +checksum = "8ddb3f79143bced6de84270411622a2699cee572fc0875aeaf1e7867cf9fca1a" dependencies = [ "cfg-if", "once_cell", @@ -6779,22 +7444,19 @@ dependencies = [ [[package]] name = "wasm-bindgen-futures" -version = "0.4.56" +version = "0.4.75" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "836d9622d604feee9e5de25ac10e3ea5f2d65b41eac0d9ce72eb5deae707ce7c" +checksum = "503b14d284f2c8dac03b819967e155ea753f573586193b2b2c95990cb5d69280" dependencies = [ - "cfg-if", "js-sys", - "once_cell", "wasm-bindgen", - "web-sys", ] [[package]] name = "wasm-bindgen-macro" -version = "0.2.106" +version = "0.2.125" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "48cb0d2638f8baedbc542ed444afc0644a29166f1595371af4fecf8ce1e7eeb3" +checksum = "4e21a184b13fb19e157296e2c46056aec9092264fab83e4ba59e68c61b323c3d" dependencies = [ "quote", "wasm-bindgen-macro-support", @@ -6802,9 +7464,9 @@ dependencies = [ [[package]] name = "wasm-bindgen-macro-support" -version = "0.2.106" +version = "0.2.125" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "cefb59d5cd5f92d9dcf80e4683949f15ca4b511f4ac0a6e14d4e1ac60c6ecd40" +checksum = "fecefd9c35bd935a20fc3fc344b5f29138961e4f47fb03297d88f2587afb5ebd" dependencies = [ "bumpalo", "proc-macro2", @@ -6815,9 +7477,9 @@ dependencies = [ [[package]] name = "wasm-bindgen-shared" -version = "0.2.106" +version = "0.2.125" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "cbc538057e648b67f72a982e708d485b2efa771e1ac05fec311f9f63e5800db4" +checksum = "23939e44bb9a5d7576fa2b563dc2e136628f1224e88a8deed09e04858b77871f" dependencies = [ "unicode-ident", ] @@ -6877,7 +7539,7 @@ checksum = "fee64194ccd96bf648f42a65a7e589547096dfa702f7cadef84347b66ad164f9" dependencies = [ "cc", "downcast-rs", - "rustix", + "rustix 1.1.4", "smallvec", "wayland-sys", ] @@ -6889,7 +7551,7 @@ source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "b8e6faa537fbb6c186cb9f1d41f2f811a4120d1b57ec61f50da451a0c5122bec" dependencies = [ "bitflags 2.11.1", - "rustix", + "rustix 1.1.4", "wayland-backend", "wayland-scanner", ] @@ -6926,7 +7588,7 @@ source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "5423e94b6a63e68e439803a3e153a9252d5ead12fd853334e2ad33997e3889e3" dependencies = [ "proc-macro2", - "quick-xml", + "quick-xml 0.38.4", "quote", ] @@ -6941,9 +7603,9 @@ dependencies = [ [[package]] name = "web-sys" -version = "0.3.83" +version = "0.3.102" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "9b32828d774c412041098d182a8b38b16ea816958e07cf40eec2bc080ae137ac" +checksum = "a6430a72df5eb332242960fe84b3002a241163998241eb596d4f739b9757061d" dependencies = [ "js-sys", "wasm-bindgen", @@ -7054,6 +7716,16 @@ version = "0.1.12" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "a28ac98ddc8b9274cb41bb4d9d4d5c425b6020c50c46f25559911905610b4a88" +[[package]] +name = "whoami" +version = "1.6.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "5d4a4db5077702ca3015d3d02d74974948aba2ad9e12ab7df718ee64ccd7e97d" +dependencies = [ + "libredox", + "wasite", +] + [[package]] name = "winapi" version = "0.3.9" @@ -7157,19 +7829,6 @@ dependencies = [ "windows-strings 0.4.2", ] -[[package]] -name = "windows-core" -version = "0.62.2" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "b8e83a14d34d0623b51dce9581199302a221863196a1dde71a7663a4c2be9deb" -dependencies = [ - "windows-implement 0.60.2", - "windows-interface 0.59.3", - "windows-link 0.2.1", - "windows-result 0.4.1", - "windows-strings 0.5.1", -] - [[package]] name = "windows-future" version = "0.2.1" @@ -7779,7 +8438,7 @@ dependencies = [ "libc", "log", "os_pipe", - "rustix", + "rustix 1.1.4", "thiserror 2.0.17", "tree_magic_mini", "wayland-backend", @@ -7825,7 +8484,7 @@ dependencies = [ "once_cell", "percent-encoding", "raw-window-handle", - "sha2", + "sha2 0.10.9", "soup3", "tao-macros", "thiserror 2.0.17", @@ -7876,7 +8535,7 @@ source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "9993aa5be5a26815fe2c3eacfc1fde061fc1a1f094bf1ad2a18bf9c495dd7414" dependencies = [ "gethostname", - "rustix", + "rustix 1.1.4", "x11rb-protocol", ] @@ -8100,7 +8759,7 @@ dependencies = [ "displaydoc", "flate2", "getrandom 0.3.4", - "hmac", + "hmac 0.12.1", "indexmap 2.13.0", "lzma-rs", "memchr", diff --git a/src-tauri/Cargo.toml b/src-tauri/Cargo.toml index 88fe3b6..9ad0657 100644 --- a/src-tauri/Cargo.toml +++ b/src-tauri/Cargo.toml @@ -87,6 +87,7 @@ rmp-serde = "1.3" # MessagePack 序列化 serde_bytes = "0.11" # 字节数组序列化 indexmap = { version = "2.12.0", features = ["serde"] } runtime = { path = "crates/runtime" } +business = { path = "crates/business" } hkdf = "0.12" diff --git a/src-tauri/crates/business/.cargo/config.toml b/src-tauri/crates/business/.cargo/config.toml deleted file mode 100644 index ac5c657..0000000 --- a/src-tauri/crates/business/.cargo/config.toml +++ /dev/null @@ -1,2 +0,0 @@ -[target.x86_64-pc-windows-msvc] -linker = "rust-lld" \ No newline at end of file diff --git a/src-tauri/crates/business/.dockerignore b/src-tauri/crates/business/.dockerignore deleted file mode 100644 index a9a5c4e..0000000 --- a/src-tauri/crates/business/.dockerignore +++ /dev/null @@ -1,51 +0,0 @@ -# Git -.git -.gitignore - -# Rust -target/ -**/*.rs.bk -*.pdb -Cargo.lock - -# IDE -.vscode/ -.idea/ -*.swp -*.swo -*~ - -# OS -.DS_Store -Thumbs.db - -# 日志文件 -*.log - -# 数据库文件 -*.db -*.sqlite -*.sqlite3 - -# Docker -Dockerfile -docker-compose.yml -.dockerignore - -# 文档 -README.md -AGENTS.md -docs/ - -# 测试和开发文件 -*.test.toml -*.dev.toml - - - - - - - - - diff --git a/src-tauri/crates/business/.editorconfig b/src-tauri/crates/business/.editorconfig deleted file mode 100644 index c52266d..0000000 --- a/src-tauri/crates/business/.editorconfig +++ /dev/null @@ -1,22 +0,0 @@ -root = true - -[*] -charset = utf-8 -end_of_line = lf -insert_final_newline = true -trim_trailing_whitespace = true -indent_style = space -indent_size = 4 - -[*.{json,yml,yaml,toml}] -indent_size = 2 - -[*.md] -trim_trailing_whitespace = false - -[*.rs] -indent_size = 4 - - - - diff --git a/src-tauri/crates/business/.github/workflows/publish-ghcr-image.yml b/src-tauri/crates/business/.github/workflows/publish-ghcr-image.yml deleted file mode 100644 index cddf1d3..0000000 --- a/src-tauri/crates/business/.github/workflows/publish-ghcr-image.yml +++ /dev/null @@ -1,78 +0,0 @@ -name: Publish GHCR Image - -on: - push: - tags: - - "v*" - -permissions: - contents: read - packages: write - -jobs: - publish: - name: Build and Publish Image - runs-on: ubuntu-latest - - steps: - - name: Checkout repository - uses: actions/checkout@v4 - - - name: Set image name - run: echo "IMAGE_NAME=ghcr.io/${GITHUB_REPOSITORY_OWNER,,}/simprint-server" >> "$GITHUB_ENV" - - - name: Log in to GitHub Container Registry - uses: docker/login-action@v3 - with: - registry: ghcr.io - username: ${{ github.actor }} - password: ${{ secrets.GITHUB_TOKEN }} - - - name: Extract Docker metadata - id: meta - uses: docker/metadata-action@v5 - with: - images: ${{ env.IMAGE_NAME }} - tags: | - type=ref,event=tag - type=match,pattern=v(.*),group=1 - type=raw,value=latest - type=sha,prefix=sha- - - - name: Build release binary in Debian Bullseye - run: | - docker run --rm \ - -v "$PWD:/workspace" \ - -w /workspace \ - rust:1.88-bullseye \ - bash -c ' - export PATH="/usr/local/cargo/bin:/usr/local/rustup/bin:$PATH" && - apt-get update && - apt-get install -y --no-install-recommends pkg-config libssl-dev && - cargo --version && - cargo build --release --bin simprint-server - ' - - - name: Prepare Docker build context - run: | - rm -rf .dist/docker-context - mkdir -p .dist/docker-context - cp Dockerfile .dist/docker-context/ - cp docker-compose.yml .dist/docker-context/ - cp .dockerignore .dist/docker-context/ - cp target/release/simprint-server .dist/docker-context/simprint-server - - - name: Set up Docker Buildx - uses: docker/setup-buildx-action@v3 - - - name: Build and push image - uses: docker/build-push-action@v6 - with: - context: .dist/docker-context - file: .dist/docker-context/Dockerfile - push: true - platforms: linux/amd64 - build-args: | - BINARY_NAME=simprint-server - tags: ${{ steps.meta.outputs.tags }} - labels: ${{ steps.meta.outputs.labels }} diff --git a/src-tauri/crates/business/.gitignore b/src-tauri/crates/business/.gitignore deleted file mode 100644 index 53141d7..0000000 --- a/src-tauri/crates/business/.gitignore +++ /dev/null @@ -1,39 +0,0 @@ -# Rust 编译输出 -/target/ -**/*.rs.bk -*.pdb - -# Cargo -Cargo.lock - -# IDE -.vscode/ -.idea/ -*.swp -*.swo -*~ - -# OS -.DS_Store -Thumbs.db - -# 日志文件 -*.log - -# 数据库文件(如果使用本地数据库) -*.db -*.sqlite -*.sqlite3 - -# 编译后的二进制文件 -console-gateway -simprint-server -update-gateway - -# Docker 镜像文件 -simprint-server-docker-*.tar.gz - -# 配置文件:仅保留示例配置可提交 -configs/* -!configs/*.example.toml - diff --git a/src-tauri/crates/business/Cargo.toml b/src-tauri/crates/business/Cargo.toml index 416b5ed..82582c9 100644 --- a/src-tauri/crates/business/Cargo.toml +++ b/src-tauri/crates/business/Cargo.toml @@ -1,86 +1,51 @@ -[package] -name = "simprint-server" -version = "0.1.0" -edition = "2024" +[package] +name = "business" +version = "0.1.0" +edition = "2024" +publish = false + +[lib] +name = "business" +path = "src/lib.rs" -[lib] -name = "simprint_server" -path = "src/lib.rs" - -[dependencies] -axum = { version = "0.8", features = ["macros", "multipart", "ws"] } -tower = "0.5.2" -tower-http = { version = "0.6", features = ["cors", "default"] } -tokio = { version = "1", features = [ - "macros", - "rt-multi-thread", - "sync", - "io-util", -] } -tokio-stream = "0.1" -tokio-tungstenite = "0.26.2" -tokio-util = { version = "0.7" } -serde = { version = "^1.0.217", features = ["derive"] } -serde_json = "^1" -base64 = "0.22.1" -mio = "1.0.1" -thiserror = "2" -anyhow = "1.0.93" -chrono = { version = "0.4", features = ["serde"] } -rand = "0.9" -rand_chacha = "0.9" -md5 = "0.8" -jwt = "0.16.0" -argon2 = "0.5.3" -aes-gcm = "0.10.3" -rsa = { version = "0.9.8", features = ["sha2"] } -sha2 = "0.10.8" -sha1 = "0.10.6" -hmac = "0.12.1" -hex = "0.4" -pkcs1 = "0.7" -pkcs8 = "0.10" -pem = "3.0" -config = "0.15" -tracing = "0.1" -tracing-subscriber = { version = "0.3", features = ["env-filter"] } -tracing-appender = "=0.2" -once_cell = "1.20.2" -sqlx = { version = "0.8", features = [ - "sqlite", - "mysql", - "postgres", - "runtime-tokio", - "chrono", - "json", - "uuid", - "ipnetwork", - "rust_decimal", - "bigdecimal", -] } +[dependencies] +tokio = { version = "1", features = [ + "macros", + "rt-multi-thread", +] } +serde = { version = "^1.0.217", features = ["derive"] } +serde_json = "^1" +thiserror = "2" +anyhow = "1.0.93" +chrono = { version = "0.4", features = ["serde"] } +rand = "0.9" +jwt = "0.16.0" +argon2 = "0.5.3" +aes-gcm = "0.10.3" +sha2 = "0.10.8" +hmac = "0.12.1" +hex = "0.4" +config = "0.15" +tracing = "0.1" +sqlx = { version = "0.8", features = [ + "sqlite", + "postgres", + "runtime-tokio", + "chrono", + "json", + "uuid", + "rust_decimal", +] } uuid = { version = "1.11.0", features = [ "v4", "fast-rng", "macro-diagnostics", "serde", ] } -regex = "1.5.4" -lettre = "0.11" -bytes = "1.10.0" -dashmap = "6.1.0" -rcon = { version = "0.6.0", features = ["rt-tokio", "rt-async-std"] } -tungstenite = "0.26.2" -textnonce = "1.0.0" -url = "2.5.4" -urlencoding = "2.1.3" -reqwest = { version = "0.12", features = ["json"] } -lazy_static = "1.5" -rust_decimal = { version = "1.39.0", features = [ - "serde-with-str", - "db-postgres", -] } -clap = { version = "4.0", features = ["derive"] } - -[profile.dev] -incremental = false -debug = 1 +lettre = "0.11" +dashmap = "6.1.0" +url = "2.5.4" +rust_decimal = { version = "1.39.0", features = [ + "serde-with-str", + "db-postgres", +] } diff --git a/src-tauri/crates/business/Dockerfile b/src-tauri/crates/business/Dockerfile deleted file mode 100644 index f7dd63b..0000000 --- a/src-tauri/crates/business/Dockerfile +++ /dev/null @@ -1,36 +0,0 @@ -# 运行时镜像 -FROM debian:bullseye-slim - -# 临时禁用 APT 的 GPG 验证以解决密钥环问题 -# 安装运行时依赖(这些包来自官方 Debian 仓库) -RUN echo 'Acquire::AllowInsecureRepositories "true";' > /etc/apt/apt.conf.d/99allow-insecure \ - && echo 'APT::Get::AllowUnauthenticated "true";' >> /etc/apt/apt.conf.d/99allow-insecure \ - && apt-get update --allow-releaseinfo-change \ - && apt-get install -y --no-install-recommends \ - ca-certificates \ - libssl1.1 \ - && rm -f /etc/apt/apt.conf.d/99allow-insecure \ - && rm -rf /var/lib/apt/lists/* - -# 创建应用用户 -RUN useradd -m -u 1000 appuser - -# 设置工作目录 -WORKDIR /app - -# 构建参数:指定要复制的二进制文件名 -ARG BINARY_NAME - -# 从本地根目录复制已编译的二进制文件到固定路径 -COPY --chown=appuser:appuser ${BINARY_NAME} /app/app - -# 设置文件权限 -RUN mkdir -p /app/assets/secret /app/configs \ - && chown -R appuser:appuser /app/assets /app/configs \ - && chmod +x /app/app - -# 切换到非 root 用户 -USER appuser - -# 统一入口,配置文件路径通过 docker-compose 的 command 参数传入 -ENTRYPOINT ["/app/app"] diff --git a/src-tauri/crates/business/README.md b/src-tauri/crates/business/README.md deleted file mode 100644 index 0a02b3b..0000000 --- a/src-tauri/crates/business/README.md +++ /dev/null @@ -1,125 +0,0 @@ -
-

Simprint Server

-

Self-hosted backend service for Simprint workspaces, accounts, environments, proxies, and related runtime APIs.

-

- Language Rust 2024 - Framework Axum 0.8 - Database PostgreSQL - Cache Redis -

-

- English | 简体中文 -

-
- ---- - -## Introduction - -Simprint Server is the backend service used by Simprint clients and self-hosted deployments. It exposes the application API, manages authentication and persistent data, initializes encryption and storage resources, and runs embedded database migrations during startup. - -It is intended for operators who want to run Simprint inside their own infrastructure instead of depending on a shared hosted backend. The service is configured through a local TOML file and is designed to work with PostgreSQL, Redis, and S3-compatible object storage. - -## Why Simprint Server? - -Running Simprint in a self-hosted setup usually requires more than just an HTTP server: - -- You need control over API availability, credentials, and storage infrastructure. -- You need deployment artifacts that are safe to publish without leaking real environment configuration. -- You need database schema upgrades to happen predictably during release and restart. -- You need one backend entry point that can serve workspace, environment, proxy, and account-related APIs together. - -Simprint Server is built around those constraints: a single Rust service, a config-first deployment model, embedded migrations, and packaging that only ships a publish-safe example configuration. - -## Features - -- **Core application API**: Serves account, workspace, team, environment, proxy, template, preference, message, extension, and local runtime endpoints from one process. -- **Authentication and secret initialization**: Supports login-related flows, token refresh, route whitelists, and RSA secret bootstrap on first startup. -- **Embedded database migrations**: Executes `sqlx` migrations automatically before the HTTP server starts accepting traffic. -- **S3-compatible storage integration**: Configures external object storage for avatars, extension assets, and version-related files. -- **Redis-backed runtime coordination**: Uses Redis for runtime coordination and cache-oriented service flows. -- **Docker-oriented release packaging**: Generates a deployment archive with `Dockerfile`, `docker-compose.yml`, and `configs/config.toml` copied from `configs/config.example.toml`. -- **Config-first execution**: Runs locally and in containers with the same `-f ` startup model. - -## Quick Start - -### Prerequisites - -- Rust toolchain -- PostgreSQL 16+ or a compatible PostgreSQL instance -- Redis 7+ -- S3-compatible object storage -- Optional SMTP server for email-related flows - -### One-line self-hosted server install - -Linux servers can bootstrap the self-hosted backend with: - -```bash -curl -fsSL https://raw.githubusercontent.com/Simprint/simprint/main/deploy/install-server.sh | bash # Update the client config afterwards, for example: base_url = http://127.0.0.1:40041/api/ -``` - -### Run locally - -```bash -cp configs/config.example.toml configs/config.local.toml -# edit configs/config.local.toml -cargo run -- -f configs/config.local.toml -``` - -The example configuration listens on port `40041` and uses the `/api/v1` prefix by default. - -### Build a Docker release package - -Use: - -```bash -uv run python build_docker.py -``` - -The default build produces: - -- `./simprint-server` -- `./simprint-server-docker-*.tar.gz` - -You can also use options such as: - -```bash -uv run python build_docker.py --clean -uv run python build_docker.py --no-package -uv run python build_docker.py --format zip -uv run python build_docker.py --dev --no-package -``` - -The packaged `configs/config.toml` is generated from `configs/config.example.toml`, and real environment-specific config files are intentionally not included in the release archive. - -## Status - -Simprint Server was originally developed as part of a private commercial backend stack. This repository is now being prepared for a public open-source release, and the documentation is being rewritten to make standalone self-hosted deployment easier to understand. - -Some modules and naming still reflect earlier internal deployment assumptions. The current direction is to keep the client-facing gateway service deployable as an independent repository with a cleaner public-facing setup. - -## Contributing - -This repository is still in an open-source refactoring phase, but issues and pull requests are welcome. - -High-value contribution areas include: - -- Self-hosted deployment docs and onboarding improvements -- Test coverage and regression verification -- API documentation and route-level usage examples -- Packaging, release, and CI improvements - -Useful entry points when exploring the codebase: - -- `src/main.rs` -- `src/cli.rs` -- `configs/config.example.toml` -- `build_docker.py` -- `docs/` - -## License - -This project is licensed under the GNU Affero General Public License v3.0 (AGPLv3). - -If you want to use Simprint Server in a way that does not comply with the AGPLv3 obligations, including distributing modified versions or providing modified versions as a closed-source service, please contact us for a commercial license. diff --git a/src-tauri/crates/business/README.zh-CN.md b/src-tauri/crates/business/README.zh-CN.md deleted file mode 100644 index f52f655..0000000 --- a/src-tauri/crates/business/README.zh-CN.md +++ /dev/null @@ -1,125 +0,0 @@ -
-

Simprint Server

-

面向 Simprint 工作区、账号体系、环境管理、代理资源和运行时接口的自托管后端服务。

-

- Language Rust 2024 - Framework Axum 0.8 - Database PostgreSQL - Cache Redis -

-

- English | 简体中文 -

-
- ---- - -## Introduction - -Simprint Server 是 Simprint 客户端和私有化部署场景使用的后端服务。它负责暴露应用 API、管理认证与持久化数据、初始化加密和存储相关资源,并在启动时自动执行内嵌的数据库迁移。 - -它面向希望把 Simprint 部署在自有基础设施中的使用者,而不是依赖共享托管后端。服务通过本地 TOML 配置文件驱动,默认围绕 PostgreSQL、Redis 和兼容 S3 的对象存储来组织运行环境。 - -## Why Simprint Server? - -想把 Simprint 作为自托管服务落地,通常不只是“起一个 HTTP 服务”这么简单: - -- 你需要控制 API 可用性、认证凭据和对象存储基础设施。 -- 你需要一套可以公开发布、但不会泄露真实环境配置的部署产物。 -- 你需要在发布和重启过程中稳定地完成数据库结构升级。 -- 你需要一个统一的后端入口来承载工作区、环境、代理和账号相关接口。 - -Simprint Server 的设计就是围绕这些约束展开的:单个 Rust 服务、配置优先的部署模型、内嵌数据库迁移,以及仅打包可公开示例配置的发布流程。 - -## Features - -- **核心应用 API**:在一个进程中承载账号、工作区、团队、环境、代理、模板、偏好、消息、扩展和本地运行时等接口。 -- **认证与密钥初始化**:支持登录相关流程、令牌刷新、白名单路由以及首次启动时的 RSA 密钥初始化。 -- **内嵌数据库迁移**:在 HTTP 服务开始接收流量前自动执行 `sqlx` migrations。 -- **兼容 S3 的对象存储集成**:为头像、扩展资源和版本相关文件接入外部对象存储。 -- **基于 Redis 的运行时协同**:使用 Redis 承担运行时协同和缓存类服务能力。 -- **面向 Docker 的发布打包**:生成包含 `Dockerfile`、`docker-compose.yml` 以及由 `configs/config.example.toml` 复制出的 `configs/config.toml` 的部署包。 -- **配置优先的运行方式**:本地运行和容器运行都使用同一套 `-f ` 启动模型。 - -## Quick Start - -### Prerequisites - -- Rust toolchain -- PostgreSQL 16+ 或兼容的 PostgreSQL 实例 -- Redis 7+ -- 兼容 S3 的对象存储 -- 可选的 SMTP 服务,用于邮件相关流程 - -### 一键安装自托管服务端 - -Linux 服务器可直接执行: - -```bash -curl -fsSL https://raw.githubusercontent.com/Simprint/simprint/main/deploy/install-server.sh | bash # 请修改客户端的配置, 如: base_url = http://127.0.0.1:40041/api/ -``` - -### 本地运行 - -```bash -cp configs/config.example.toml configs/config.local.toml -# 修改 configs/config.local.toml -cargo run -- -f configs/config.local.toml -``` - -示例配置默认监听 `40041` 端口,并使用 `/api/v1` 作为接口前缀。 - -### 构建 Docker 发布包 - -使用: - -```bash -uv run python build_docker.py -``` - -默认构建产物包括: - -- `./simprint-server` -- `./simprint-server-docker-*.tar.gz` - -也可以使用这些常见参数: - -```bash -uv run python build_docker.py --clean -uv run python build_docker.py --no-package -uv run python build_docker.py --format zip -uv run python build_docker.py --dev --no-package -``` - -打包后的 `configs/config.toml` 来自仓库中的 `configs/config.example.toml`,真实环境配置文件不会被包含在对外发布的部署包中。 - -## Status - -Simprint Server 最初是作为私有商业后端体系的一部分开发的。当前这个仓库正在为公开开源发布做整理,文档也在同步重写,以便外部使用者更容易理解和部署独立的自托管版本。 - -仓库中的部分模块划分和命名,仍然会带有早期内部部署模型的痕迹。当前方向是把面向客户端的网关服务整理成一个可以独立部署、便于公开协作的仓库。 - -## Contributing - -这个仓库目前仍处于开源重构阶段,但已经欢迎通过 Issue 和 Pull Request 参与改进。 - -当前更有价值的贡献方向包括: - -- 自托管部署文档和上手流程优化 -- 测试覆盖和回归验证补充 -- API 文档和路由级使用示例完善 -- 打包、发布和 CI 流程改进 - -如果你准备快速建立上下文,建议先看这些入口: - -- `src/main.rs` -- `src/cli.rs` -- `configs/config.example.toml` -- `build_docker.py` -- `docs/` - -## License - -本项目采用 GNU Affero General Public License v3.0 (AGPLv3) 进行许可。 - -如果你希望在不履行 AGPLv3 义务的前提下使用 Simprint Server,包括分发修改版本或以闭源服务形式提供修改版本,请联系获取商业许可。 diff --git a/src-tauri/crates/business/assets/secret/private_key.pem b/src-tauri/crates/business/assets/secret/private_key.pem deleted file mode 100644 index f5c4974..0000000 --- a/src-tauri/crates/business/assets/secret/private_key.pem +++ /dev/null @@ -1,27 +0,0 @@ ------BEGIN RSA PRIVATE KEY----- -MIIEpQIBAAKCAQEArd/zJ0+jxE+q7YV8nNIjfMBtu7wSYbIjUa2FnuxwbxVuDLZP -qe8zMAojrimNGkdswVdcgQl/0E5c561hgTU54a7b2PWhobiM4YBY/jUcy+ufCv4m -qp71kSCsCazAdhKP65xvuu9hkIcJyHEIDASGAYLHKjjj1zbHmZhL5uKOWLnwLbNc -0ujTPlS5McxZeF65B9IWzJa5RmRMNlYgAKYsPlIwPpy2uglXWGdX3LejMpv5qXpC -QUKjrDi1AVOD+R6HIo6J4YMXEpOKbJpiamOpWn4oZLtGTTxAqA1qO43sNpJFKMC5 -4QjO7DMlfnbs3f5oYVjBExVQuqQAsgPMHLA8zQIDAQABAoIBAAd4l/oYcfD9e1q5 -YaFbZir2GcD3U9Le1KWtzxAFGM+tHA1vx/yFSq3FwcK6BlYau5GTL2ZcAiHxPAy4 -Zngm0VXhLZEk/Mz2IRePbEOABLy+YxcV1JOAQQz7WmkQAzUPlx+ImURvBMIxCzqR -oMbjat6lv+kQiLm2oNz4rko9ceBeCsAuuEREbmKMMubFeVgDvmtHOmbZlqsfHktd -BbB2zcvj5B7PpGth0jf9/3CoM0UYoT0hXtZ9s9y0HUyO2SDuRpUsWcIjzuZLVuiV -349z6/dNlVsXRIDLku1OPuyDTbfmFyfY5jCrU7idCet9n+BmyEEVuKJ12ZBwL2PF -KyhstAECgYEA5oOKMGu5G9cx2CdN113pyJW5syM5JLwwyPIXw5Xfv9IeEBYY1di4 -5BCZvsy4ez+CoFpCgFS+aOXo7vtAG5H4F9vOLgk9wBcijACChMwGqffWM5vB/ouA -EtymQe76YA7uBw048aoVJVsOp3dGRvix68BFs5gjMFvOjQD9J3gTnP8CgYEAwRlM -xNvZqDkVSvPqIlTnQ3GU9r7SK2JnBTkIcna68f71lEa38sl5ZUwlpkaTKaZhgA77 -CHtgYRnzOlOEWGxLkd82RinyVhBUKD9LfSJpDOkPiFwwlwYXehQGC69Ib4AFyHoS -JG+KiTVv5freKPv3VhiH/LyJwrU4YGw/d8VaCjMCgYEAlvdlBGs4cyRPb5nmH/tQ -hd6RHOIfpZBefuwWZjB8tlr891oRb9Qc2riIiG35EDa67RvP284kWfzgvcrs5GGH -0tBQytOgjnJYXMpksGYSozQ+I9SJi5R/D1tUw2+oqEp+1z1woszaRnnJMiIqc4ai -t5xXydQEj8JAlxYjtbqtVa8CgYEAtRPVktGb1Y3aMtRy3kkCKZPMnmqpSffYJeSq -0DQY8TAm+Sor+6gFiAGVWMzb2fXlfqINtJGF+ujL1wlUlVrQrvVDvx4824oqcSeR -0cHAA1RWtYfGJQmYYGmAldqEsdK7GZmng7V5k1uiGGddh89ozLrqYw4mnYk1We6I -wfc2jVMCgYEAr0So34S3siiljBx9afNXnEl4DOmTWDCOMlnz7Ru+egQ02M20DHEg -iRwHqBVzTiqncVpJPXIgcitNq7DRfbQCnkLuBfd30ygATk0wYkxyQp/mb7G9Fm3x -BOdFPOMTt5yQhO7nXJH0vt2sO5cUqKy1s6pBCGU1VIxFBuFWe4TGDKs= ------END RSA PRIVATE KEY----- diff --git a/src-tauri/crates/business/assets/secret/public_key.pem b/src-tauri/crates/business/assets/secret/public_key.pem deleted file mode 100644 index 560721f..0000000 --- a/src-tauri/crates/business/assets/secret/public_key.pem +++ /dev/null @@ -1,8 +0,0 @@ ------BEGIN RSA PUBLIC KEY----- -MIIBCgKCAQEArd/zJ0+jxE+q7YV8nNIjfMBtu7wSYbIjUa2FnuxwbxVuDLZPqe8z -MAojrimNGkdswVdcgQl/0E5c561hgTU54a7b2PWhobiM4YBY/jUcy+ufCv4mqp71 -kSCsCazAdhKP65xvuu9hkIcJyHEIDASGAYLHKjjj1zbHmZhL5uKOWLnwLbNc0ujT -PlS5McxZeF65B9IWzJa5RmRMNlYgAKYsPlIwPpy2uglXWGdX3LejMpv5qXpCQUKj -rDi1AVOD+R6HIo6J4YMXEpOKbJpiamOpWn4oZLtGTTxAqA1qO43sNpJFKMC54QjO -7DMlfnbs3f5oYVjBExVQuqQAsgPMHLA8zQIDAQAB ------END RSA PUBLIC KEY----- diff --git a/src-tauri/crates/business/build_docker.py b/src-tauri/crates/business/build_docker.py deleted file mode 100644 index 11c24f1..0000000 --- a/src-tauri/crates/business/build_docker.py +++ /dev/null @@ -1,421 +0,0 @@ -#!/usr/bin/env python3 -""" -构建脚本:编译 Rust 服务并生成包含运行容器所需文件的压缩包。 - -使用方法: - python build_docker.py [--release] [--clean] [--no-package] - -参数: - --release: 使用 release 模式编译(默认) - --clean: 构建前清理旧的二进制文件 - --no-package: 不生成压缩包,只编译和复制文件 -""" - -import os -import sys -import shutil -import subprocess -import argparse -import zipfile -import tarfile -from pathlib import Path -from datetime import datetime - -# 需要编译的服务列表(本仓库仅 simprint-server) -SERVICES = [ - "simprint-server", -] - -# 项目根目录 -PROJECT_ROOT = Path(__file__).parent.resolve() -TARGET_DIR = PROJECT_ROOT / "target" -RELEASE_DIR = TARGET_DIR / "release" - -# 需要打包到压缩包的文件和目录 -PACKAGE_FILES = [ - "Dockerfile", - "docker-compose.yml", - ".dockerignore", -] - -PACKAGE_CONFIG_FILES = [ - ("configs/config.example.toml", "configs/config.toml"), -] - -# 需要打包的二进制文件(在构建后复制) -PACKAGE_BINARIES = SERVICES - - -def print_step(message: str): - """打印步骤信息""" - print(f"\n{'='*60}") - print(f" {message}") - print(f"{'='*60}\n") - - -def check_cargo(): - """检查 cargo 是否可用""" - try: - result = subprocess.run( - ["cargo", "--version"], - capture_output=True, - text=True, - check=True, - ) - print(f"✓ 找到 Cargo: {result.stdout.strip()}") - return True - except (subprocess.CalledProcessError, FileNotFoundError): - print("✗ 错误: 未找到 Cargo,请确保已安装 Rust 工具链") - return False - - -def clean_binaries(): - """清理项目根目录中的旧二进制文件""" - print_step("清理旧的二进制文件") - removed = [] - for service in SERVICES: - binary_path = PROJECT_ROOT / service - if binary_path.exists(): - binary_path.unlink() - removed.append(service) - print(f" 删除: {binary_path}") - - if not removed: - print(" 没有需要清理的文件") - else: - print(f" 已清理 {len(removed)} 个文件") - - -def build_service(service: str, release: bool = True) -> bool: - """编译单个服务""" - build_mode = "release" if release else "dev" - print(f" 编译 {service} ({build_mode} 模式)...") - - cmd = ["cargo", "build"] - if release: - cmd.append("--release") - cmd.extend(["--bin", service]) - - try: - result = subprocess.run( - cmd, - cwd=PROJECT_ROOT, - check=True, - capture_output=True, - text=True, - ) - print(f" ✓ {service} 编译成功") - return True - except subprocess.CalledProcessError as e: - print(f" ✗ {service} 编译失败") - if e.stderr: - print(f" 错误信息: {e.stderr}") - return False - - -def copy_binary(service: str, release: bool = True, dest_dir: Path = None) -> bool: - """将编译好的二进制文件复制到指定目录""" - if dest_dir is None: - dest_dir = PROJECT_ROOT - - source_dir = RELEASE_DIR if release else TARGET_DIR / "debug" - source_path = source_dir / service - dest_path = dest_dir / service - - if not source_path.exists(): - print(f" ✗ 错误: 源文件不存在: {source_path}") - return False - - try: - shutil.copy2(source_path, dest_path) - # 设置可执行权限 - os.chmod(dest_path, 0o755) - print(f" ✓ 已复制: {service} -> {dest_path}") - return True - except Exception as e: - print(f" ✗ 复制失败: {e}") - return False - - -def create_package(package_dir: Path, output_format: str = "zip") -> Path: - """创建压缩包""" - timestamp = datetime.now().strftime("%Y%m%d_%H%M%S") - package_name = f"simprint-server-docker-{timestamp}" - - if output_format == "zip": - archive_path = PROJECT_ROOT / f"{package_name}.zip" - print_step(f"创建 ZIP 压缩包: {archive_path.name}") - - with zipfile.ZipFile(archive_path, "w", zipfile.ZIP_DEFLATED) as zipf: - for root, dirs, files in os.walk(package_dir): - # 跳过隐藏目录 - dirs[:] = [d for d in dirs if not d.startswith(".")] - - for file in files: - file_path = Path(root) / file - arcname = file_path.relative_to(package_dir) - zipf.write(file_path, arcname) - print(f" 添加: {arcname}") - - elif output_format == "tar.gz": - archive_path = PROJECT_ROOT / f"{package_name}.tar.gz" - print_step(f"创建 TAR.GZ 压缩包: {archive_path.name}") - - with tarfile.open(archive_path, "w:gz") as tar: - for root, dirs, files in os.walk(package_dir): - # 跳过隐藏目录 - dirs[:] = [d for d in dirs if not d.startswith(".")] - - for file in files: - file_path = Path(root) / file - arcname = file_path.relative_to(package_dir) - tar.add(file_path, arcname=arcname, recursive=False) - print(f" 添加: {arcname}") - - else: - raise ValueError(f"不支持的压缩格式: {output_format}") - - size_mb = archive_path.stat().st_size / (1024 * 1024) - print(f"\n✓ 压缩包创建成功: {archive_path.name} ({size_mb:.2f} MB)") - return archive_path - - -def prepare_package_directory() -> Path: - """准备打包目录,复制所有必需的文件""" - print_step("准备打包目录") - - package_dir = PROJECT_ROOT / "docker-package" - - # 清理旧的打包目录 - if package_dir.exists(): - shutil.rmtree(package_dir) - - package_dir.mkdir(exist_ok=True) - print(f" 创建打包目录: {package_dir}") - - # 复制必需的文件和目录 - copied_count = 0 - for item in PACKAGE_FILES: - source = PROJECT_ROOT / item - dest = package_dir / item - - if not source.exists(): - print(f" ⚠ 警告: {item} 不存在,跳过") - continue - - try: - if source.is_dir(): - shutil.copytree(source, dest, dirs_exist_ok=True) - print(f" ✓ 复制目录: {item}") - else: - shutil.copy2(source, dest) - print(f" ✓ 复制文件: {item}") - copied_count += 1 - except Exception as e: - print(f" ✗ 复制失败 {item}: {e}") - - # 仅复制对外发布需要的示例配置文件 - print(f"\n 复制配置模板:") - for source_item, dest_item in PACKAGE_CONFIG_FILES: - source = PROJECT_ROOT / source_item - dest = package_dir / dest_item - - if not source.exists(): - print(f" ⚠ 警告: {source_item} 不存在,跳过") - continue - - try: - dest.parent.mkdir(parents=True, exist_ok=True) - shutil.copy2(source, dest) - print(f" ✓ 复制文件: {source_item} -> {dest_item}") - copied_count += 1 - except Exception as e: - print(f" ✗ 复制失败 {source_item}: {e}") - - # 复制二进制文件 - print(f"\n 复制二进制文件:") - for binary in PACKAGE_BINARIES: - source = PROJECT_ROOT / binary - if source.exists(): - dest = package_dir / binary - shutil.copy2(source, dest) - os.chmod(dest, 0o755) - print(f" ✓ 复制: {binary}") - else: - print(f" ✗ 错误: {binary} 不存在") - - # 创建 README 文件 - readme_content = f"""# Simprint Server Docker 部署包 - -本压缩包包含运行 Simprint Server(客户端网关)容器所需的所有文件。 - -## 包含内容 - -- 二进制文件: {', '.join(SERVICES)} -- Dockerfile: Docker 镜像构建文件 -- docker-compose.yml: Docker Compose 配置文件 -- configs/config.toml: 配置模板 -- 自动生成的密钥会保存在 Docker 卷中,不随部署包分发 - -## 使用方法 - -1. 解压压缩包到目标目录 - -2. 确保 `configs/config.toml` 中的数据库、Redis、对象存储地址正确 - -3. 构建 Docker 镜像: - ```bash - docker-compose build - ``` - -4. 启动服务: - ```bash - docker-compose up -d - ``` - -5. 查看服务状态: - ```bash - docker-compose ps - ``` - -服务启动时会自动执行数据库迁移。 - -6. 查看日志: - ```bash - docker-compose logs -f simprint-server - ``` - -## 服务端口 - -- 客户端网关 (simprint-server): 40041 - -## 注意事项 - -- 请确保已安装 Docker 和 Docker Compose -- 配置文件中的外部服务地址需要根据实际环境调整 -- 首次运行前请检查 configs/ 目录下的配置文件 - -生成时间: {datetime.now().strftime("%Y-%m-%d %H:%M:%S")} -""" - - readme_path = package_dir / "README.md" - readme_path.write_text(readme_content, encoding="utf-8") - print(f" ✓ 创建: README.md") - - print(f"\n✓ 打包目录准备完成,共 {copied_count + len(PACKAGE_BINARIES)} 个项目") - return package_dir - - -def main(): - parser = argparse.ArgumentParser( - description="构建 Rust 服务二进制文件供 Docker 使用" - ) - parser.add_argument( - "--release", - action="store_true", - default=True, - help="使用 release 模式编译(默认)", - ) - parser.add_argument( - "--dev", - action="store_true", - help="使用 dev 模式编译", - ) - parser.add_argument( - "--clean", - action="store_true", - help="构建前清理旧的二进制文件", - ) - parser.add_argument( - "--no-package", - action="store_true", - help="不生成压缩包,只编译和复制文件到项目根目录", - ) - parser.add_argument( - "--format", - choices=["zip", "tar.gz"], - default="tar.gz", - help="压缩包格式 (默认: tar.gz)", - ) - args = parser.parse_args() - - # 确定编译模式 - release_mode = args.release and not args.dev - - print_step("Simprint Server Docker 构建脚本") - print(f"项目目录: {PROJECT_ROOT}") - print(f"编译模式: {'release' if release_mode else 'dev'}") - - # 检查 cargo - if not check_cargo(): - sys.exit(1) - - # 清理旧文件 - if args.clean: - clean_binaries() - - # 编译所有服务 - print_step("编译服务") - build_success = True - for service in SERVICES: - if not build_service(service, release_mode): - build_success = False - - if not build_success: - print("\n✗ 服务编译失败,请检查错误信息") - sys.exit(1) - - # 复制二进制文件 - print_step("复制二进制文件到项目根目录") - copy_success = True - for service in SERVICES: - if not copy_binary(service, release_mode): - copy_success = False - - if not copy_success: - print("\n✗ 文件复制失败") - sys.exit(1) - - # 验证文件 - print_step("验证构建结果") - all_exist = True - for service in SERVICES: - binary_path = PROJECT_ROOT / service - if binary_path.exists(): - size = binary_path.stat().st_size / (1024 * 1024) # MB - print(f" ✓ {service}: {size:.2f} MB") - else: - print(f" ✗ {service}: 文件不存在") - all_exist = False - - if not all_exist: - print("\n✗ 构建验证失败") - sys.exit(1) - - # 生成压缩包 - if not args.no_package: - package_dir = prepare_package_directory() - archive_path = create_package(package_dir, args.format) - - # 清理打包目录 - print_step("清理临时文件") - shutil.rmtree(package_dir) - print(" ✓ 已清理临时打包目录") - - print_step("构建完成!") - print(f"\n✓ 压缩包已生成: {archive_path.name}") - print(f" 位置: {archive_path}") - print("\n现在可以:") - print(" 1. 将压缩包传输到目标服务器") - print(" 2. 解压后运行: docker-compose up -d") - else: - print_step("构建完成!") - print("\n现在可以使用以下命令构建 Docker 镜像:") - print(" docker-compose build") - print("\n或者启动服务:") - print(" docker-compose up -d") - - -if __name__ == "__main__": - main() - diff --git a/src-tauri/crates/business/clippy.toml b/src-tauri/crates/business/clippy.toml deleted file mode 100644 index 5efb527..0000000 --- a/src-tauri/crates/business/clippy.toml +++ /dev/null @@ -1,7 +0,0 @@ -# Clippy 配置 -avoid-breaking-exported-api = false -msrv = "1.70.0" - - - - diff --git a/src-tauri/crates/business/configs/config.example.toml b/src-tauri/crates/business/configs/config.example.toml deleted file mode 100644 index 42a2128..0000000 --- a/src-tauri/crates/business/configs/config.example.toml +++ /dev/null @@ -1,44 +0,0 @@ -[app] -name = "simprint-server" -port = 40041 -secret = "change-this-to-a-long-random-secret" -prefix = "/api/v1" -encrypt_secret_location = "./assets/secret" -route_whitelists = [ - "POST+/api/v1/users/login", - "POST+/api/v1/users/register", - "POST+/api/v1/users/register-send-code", - "POST+/api/v1/users/reset-password", - "POST+/api/v1/users/reset-password-send-code", - "POST+/api/v1/users/refresh-credentials", - "GET+/api/v1/secret/public/key", - "GET+/api/v1/time/now", - "POST+/api/v1/versions/check" -] -referral_link_prefix = "https://your-domain.example/download" - -[database] -url = "postgres://simprint:change-me@postgres:5432/simprintdb" -max_connections = 25 -min_connections = 5 -max_lifetime = 3000 -acquire_timeout = 30 -idle_timeout = 600 - -[storage] -public_base_url = "https://your-resource-download-host.example" -avatar_root = "avatars" -extension_root = "extensions" -version_root = "versions" - -[smtp] -smtp_server = "smtp.example.com" -smtp_username = "noreply@example.com" -smtp_password = "change-me" - -[workspace_quota] -[workspace_quota.default] -max_environments = 99999 -max_team_members = 99999 -max_proxies = 99999 -max_rpa_tasks = 99999 diff --git a/src-tauri/crates/business/configs/config.local.example.toml b/src-tauri/crates/business/configs/config.local.example.toml deleted file mode 100644 index 1c7b789..0000000 --- a/src-tauri/crates/business/configs/config.local.example.toml +++ /dev/null @@ -1,40 +0,0 @@ -[app] -name = "simprint-server-local" -port = 40041 -secret = "change-this-to-a-long-random-secret" -prefix = "/api/v1" -encrypt_secret_location = "./assets/secret" -route_whitelists = [ - "POST+/api/v1/users/login", - "POST+/api/v1/users/register", - "POST+/api/v1/users/register-send-code", - "POST+/api/v1/users/reset-password", - "POST+/api/v1/users/reset-password-send-code", - "POST+/api/v1/users/refresh-credentials", - "GET+/api/v1/secret/public/key", - "GET+/api/v1/time/now", - "POST+/api/v1/versions/check", -] -referral_link_prefix = "http://127.0.0.1:40041/download" - -# PostgreSQL remains the only external dependency during this migration stage. -[database] -url = "postgres://simprint:change-me@127.0.0.1:5432/simprintdb" -max_connections = 10 -min_connections = 1 -max_lifetime = 3000 -acquire_timeout = 30 -idle_timeout = 600 - -[storage] -public_base_url = "https://your-resource-download-host.example" -avatar_root = "avatars" -extension_root = "extensions" -version_root = "versions" - -[workspace_quota] -[workspace_quota.default] -max_environments = 99999 -max_team_members = 99999 -max_proxies = 99999 -max_rpa_tasks = 99999 diff --git a/src-tauri/crates/business/docker-compose.yml b/src-tauri/crates/business/docker-compose.yml deleted file mode 100644 index db174ca..0000000 --- a/src-tauri/crates/business/docker-compose.yml +++ /dev/null @@ -1,68 +0,0 @@ -version: '3.8' - -services: - postgres: - image: postgres:16-alpine - container_name: simprint-postgres - environment: - POSTGRES_DB: simprintdb - POSTGRES_USER: simprint - POSTGRES_PASSWORD: change-me - volumes: - - simprint-postgres-data:/var/lib/postgresql/data - healthcheck: - test: ["CMD-SHELL", "pg_isready -U simprint -d simprintdb"] - interval: 10s - timeout: 5s - retries: 5 - restart: unless-stopped - networks: - - simprint-network - - redis: - image: redis:7-alpine - container_name: simprint-redis - command: ["redis-server", "--appendonly", "yes"] - volumes: - - simprint-redis-data:/data - healthcheck: - test: ["CMD", "redis-cli", "ping"] - interval: 10s - timeout: 5s - retries: 5 - restart: unless-stopped - networks: - - simprint-network - - simprint-server: - build: - context: . - dockerfile: Dockerfile - args: - BINARY_NAME: simprint-server - container_name: simprint-client-gateway - command: ["-f=/app/configs/config.toml"] - depends_on: - postgres: - condition: service_healthy - redis: - condition: service_healthy - ports: - - "40041:40041" - volumes: - - ./configs:/app/configs:ro - - simprint-secret-data:/app/assets/secret - environment: - - RUST_LOG=info - restart: unless-stopped - networks: - - simprint-network - -networks: - simprint-network: - name: simprint-network - -volumes: - simprint-postgres-data: - simprint-redis-data: - simprint-secret-data: diff --git a/src-tauri/crates/business/docs/database-design.md b/src-tauri/crates/business/docs/database-design.md deleted file mode 100644 index b9c349e..0000000 --- a/src-tauri/crates/business/docs/database-design.md +++ /dev/null @@ -1,109 +0,0 @@ -# 数据库设计文档 - -本文档描述了 Simprint Server 项目的数据库表结构设计。 - -## 表结构定义规范 - -### users - -用户基础信息表 - -存储用户的基础标识信息,采用 UUID 作为主键,支持全局唯一标识。 - -**字段说明**: - -- `uuid` - UUID 类型,主键,全局唯一标识 -- `id` - 字符串类型,用户 ID,唯一标识,可用于业务查询(如:USER001) -- `created_at` - 时间戳类型,创建时间,NOT NULL -- `updated_at` - 时间戳类型,更新时间,NOT NULL -- `deleted_at` - 时间戳类型,删除时间(软删除),可为 NULL - -**索引**: - -- 主键索引:`uuid` -- 唯一索引:`id` -- 索引:`deleted_at`(用于软删除查询优化) - -**约束**: - -- `id` 必须唯一且不为空 -- `created_at` 和 `updated_at` 由数据库自动管理 - -### user_infos - -用户详细信息表 - -存储用户的详细业务信息,包括登录凭证、联系方式、个人信息等。通过 `user_uuid` 与 `users` 表关联。 - -**字段说明**: - -- `id` - 自增整数类型,主键 -- `user_uuid` - UUID 类型,外键,关联 `users.uuid`,NOT NULL -- `nickname` - 字符串类型,昵称,可为 NULL -- `email` - 字符串类型,邮箱地址,NOT NULL,唯一索引,用于登录 -- `phone` - 字符串类型,手机号,可为 NULL(可选字段) -- `password` - 字符串类型,密码(Argon2 加密存储),NOT NULL -- `avatar_hash` - 字符串类型,头像文件 hash(MinIO 中存储的文件名,无后缀),可为 NULL -- `status` - 字符串类型,用户状态(active/inactive/banned),默认 'active',NOT NULL -- `created_at` - 时间戳类型,创建时间,NOT NULL -- `updated_at` - 时间戳类型,更新时间,NOT NULL -- `deleted_at` - 时间戳类型,删除时间(软删除),可为 NULL - -**索引**: - -- 主键索引:`id` -- 唯一索引:`user_uuid`(一对一关系) -- 唯一索引:`email`(邮箱唯一,NOT NULL,用于登录) -- 索引:`deleted_at`(用于软删除查询优化) -- 索引:`status`(用于状态查询) - -**约束**: - -- `user_uuid` 必须唯一,确保与 `users` 表一对一关系 -- `email` 必须唯一且不为空(用于登录) -- `password` 必须不为空 -- `status` 默认值为 'active' - -**关于头像字段的说明**: - -- `avatar_hash` 字段用于存储 MinIO 中头像文件的 hash 值(文件名,无后缀) -- 目前阶段不实现头像功能,该字段可为 NULL -- 未来实现头像功能时,头像文件将存储在 MinIO 的 `avatar_bucket` 中,文件名为 hash 值 -- 获取头像 URL 时,通过配置的 `minio.resource_url` 和 `avatar_bucket` 拼接完整 URL - -**表关系**: - -- `user_infos.user_uuid` → `users.uuid`(一对一关系,级联删除) - -## 设计说明 - -### 软删除策略 - -- 所有表都支持软删除,通过 `deleted_at` 字段标记 -- 查询时默认过滤 `deleted_at IS NULL` 的记录 -- 物理删除仅在必要时进行(如数据清理) - -### 用户标识 - -- `users.uuid`:系统内部使用的全局唯一标识(UUID) -- `users.id`:业务层使用的用户 ID(字符串,如 USER001) -- `user_infos.phone`:用户登录使用的手机号 - -### 会话管理 - -- Refresh Token 存储在 Redis 中,不在数据库表中 -- 会话相关信息(设备信息、登录时间等)可在 Redis 中管理 -- 如需持久化会话记录,可考虑添加 `user_sessions` 表 - -### 验证码管理 - -- 验证码存储在 Redis 中,设置过期时间(如 5 分钟) -- 格式:`verification_code:{phone}:{type}`(type: register/reset_password) -- 不在数据库表中存储验证码信息 - -### 扩展性考虑 - -- 用户权限:如需权限系统,可添加 `user_permissions` 表 -- 用户角色:如需角色系统,可添加 `user_roles` 表和关联表 -- 登录历史:如需记录登录历史,可添加 `user_login_history` 表 -- 设备管理:如需管理用户设备,可添加 `user_devices` 表 diff --git a/src-tauri/crates/business/docs/extension-system-refactor.md b/src-tauri/crates/business/docs/extension-system-refactor.md deleted file mode 100644 index d13656b..0000000 --- a/src-tauri/crates/business/docs/extension-system-refactor.md +++ /dev/null @@ -1,256 +0,0 @@ -# 插件系统重构方案 - -## 插件绑定的核心思路 - -### 两个关键维度 - -1. **安装范围**:决定插件作用于哪些环境 - - 全局:所有环境 - - 指定分组:特定分组的环境 - -2. **可见性**:决定插件是个人使用还是团队共享 - - 为团队安装(勾选):团队所有成员可用 - - 不勾选:仅当前用户可用 - -### 四种绑定关系 - -| 安装范围 | 为团队安装 | 绑定关系 | 效果 | -|---------|-----------|---------|------| -| 全局 | ✅ | 插件 ↔ 团队 | 团队所有成员的所有环境都有此插件 | -| 全局 | ❌ | 插件 ↔ 用户 | 仅当前用户的所有环境有此插件 | -| 指定分组 | ✅ | 插件 ↔ 分组(团队共享) | 团队所有成员在该分组下的环境都有此插件 | -| 指定分组 | ❌ | 插件 ↔ 分组(个人私有) | 仅当前用户在该分组下的环境有此插件 | - -### 关键设计原则 - -1. **环境不直接绑定插件** - 环境通过用户/团队/分组间接获得插件 -2. **动态合并** - 环境列表查询时动态合并多个层级的插件 -3. **优先级规则**(从高到低): - - 分组团队插件 - - 分组个人插件 - - 团队全局插件 - - 用户全局插件 - -### 环境获取插件的逻辑 - -当查询某个环境的插件列表时,需要合并: -1. 用户个人全局插件 -2. 团队全局插件 -3. 该环境所属分组的个人插件(如果有) -4. 该环境所属分组的团队插件(如果有) - -### 实际场景举例 - -**场景1:公司统一插件** -- 全局 + 为团队安装 ✅ -- 所有人所有环境都有 - -**场景2:个人常用插件** -- 全局 + 为团队安装 ❌ -- 只有自己所有环境有 - -**场景3:测试分组的团队插件** -- 指定分组(测试分组)+ 为团队安装 ✅ -- 所有人在测试分组的环境都有 - -**场景4:个人在某分组的特殊插件** -- 指定分组(工作分组)+ 为团队安装 ❌ -- 只有自己在工作分组的环境有 - -## 需要补充考虑的场景 - -### 1. 插件卸载场景 - -**问题:** -- 如果用户卸载了一个"团队全局插件",是只对自己生效还是影响整个团队? -- 如果用户卸载了"分组团队插件",其他团队成员还能用吗? - -**建议方案:** -- 团队插件只有所有者/管理员能卸载,卸载后影响所有人 -- 普通用户不能禁用团队插件 -- 个人插件可以直接卸载 - -### 2. 插件更新场景 - -**问题:** -- 团队插件更新到新版本,是否自动更新所有成员? -- 个人插件和团队插件版本冲突怎么办? - -**建议方案:** -- 团队插件更新后,所有成员自动使用新版本 -- 如果个人安装了同一插件的不同版本,个人版本优先 - -### 3. 权限管理场景 - -**问题:** -- 谁可以为团队安装插件? -- 谁可以为分组安装团队共享的插件? - -**建议方案:** -- 团队全局插件:只有所有者/管理员可以安装/卸载 -- 分组团队插件:拥有该分组 manage 权限的用户可以安装/卸载 -- 个人插件:所有用户都可以安装/卸载 - -### 4. 分组变更场景 - -**问题:** -- 环境从分组A移动到分组B,插件如何变化? -- 环境从有分组变为无分组,插件如何变化? - -**建议方案:** -- 环境移动分组后,插件列表自动更新(因为是动态查询的) -- 分组A的插件失效,分组B的插件生效 -- 全局插件始终生效 - -### 5. 用户离开团队场景 - -**问题:** -- 用户离开团队后,之前安装的"分组个人插件"怎么办? -- 用户的环境还能访问吗? - -**建议方案:** -- 用户离开团队,其环境也应该被删除或转移 -- 分组个人插件随环境一起处理 - -### 6. 插件冲突场景 - -**问题:** -- 同一个插件,团队安装了v1.0,个人安装了v2.0,用哪个? -- 分组团队安装了v1.0,分组个人安装了v2.0,用哪个? - -**建议方案:** -- 按优先级规则:分组团队 > 分组个人 > 团队全局 > 用户全局 -- 同一层级只能有一个版本 - -### 7. 插件商店的"已安装"标识 - -**问题:** -- 插件商店显示某个插件"已安装",但实际上可能是: - - 用户个人全局安装 - - 团队全局安装 - - 某个分组安装 - - 多个层级都安装了 - -**建议方案:** -- 显示详细的安装信息: - - "已安装(全局-团队)" - - "已安装(工作分组-个人)" - - "已安装(多处)" - 点击查看详情 - -### 8. 批量操作场景 - -**问题:** -- 能否批量为多个分组安装同一个插件? -- 能否批量卸载某个插件的所有安装? - -**建议方案:** -- 支持多选分组批量安装 -- 已安装列表支持批量卸载(但要区分权限) - -### 9. 插件依赖场景 - -**问题:** -- 插件A依赖插件B,如何处理? -- 卸载插件B时,插件A怎么办? - -**建议方案:** -- 安装时自动检查并提示安装依赖 -- 卸载时检查是否有其他插件依赖,给出警告 - -### 10. 环境启动时的插件加载 - -**问题:** -- 环境启动时,如何知道要加载哪些插件? -- 插件数据是实时查询还是缓存? - -**建议方案:** -- 环境列表接口返回每个环境的插件列表 -- 浏览器内核启动时根据这个列表加载插件 -- 插件变更后,需要重启环境才能生效 - -### 11. 插件状态管理 - -**问题:** -- 插件可能有:已安装、已启用/禁用、待更新等状态 -- 团队插件被禁用,是全局禁用还是个人禁用? - -**建议方案:** -- 团队插件的状态是全局的(所有者/管理员控制) -- 普通用户不能禁用团队插件 -- 个人插件可以自由启用/禁用 - -### 12. 审计和追溯场景 - -**问题:** -- 谁在什么时候为团队安装了什么插件? -- 插件被卸载后,如何追溯? - -**建议方案:** -- 所有插件操作记录到审计日志 -- 包括:安装者、安装时间、安装范围、是否为团队安装 - ---- - -## 实现状态 - -### 已实现功能 - -#### 1. 数据库表结构 -- ✅ `user_extensions` - 用户全局插件 -- ✅ `team_extensions` - 团队全局插件 -- ✅ `group_extensions` - 分组插件(支持 `is_team_shared` 字段区分团队/个人) -- ❌ ~~`environment_extensions`~~ - 已删除(环境不直接绑定插件) - -#### 2. 核心功能 -- ✅ 安装扩展到 user/team/group -- ✅ 卸载扩展(按目标类型) -- ✅ 查询已安装扩展(用户维度、团队维度) -- ✅ 支持批量安装到多个分组 -- ✅ `is_team_shared` 字段区分分组团队插件和分组个人插件 - -#### 3. Scope 字段语义 -返回给前端的 `scope` 字段值: -- `user` - 用户全局插件 -- `team` - 团队全局插件 -- `group-personal` - 分组个人插件(`is_team_shared=false`) -- `group-team` - 分组团队插件(`is_team_shared=true`) - -#### 4. 权限检查 -- ✅ 团队全局插件:检查用户是否为 owner/admin -- ✅ 分组团队共享插件:检查用户是否有分组的"管理"权限 -- ✅ 分组个人插件:检查用户是否有分组的"编辑"权限 - -#### 5. 审计日志 -- ✅ 安装扩展时记录审计日志 -- ✅ 卸载扩展时记录审计日志 -- ✅ 使用现有的 `audit_logs` 表和 `insert_audit_log` 函数 - -### 未实现功能 - -#### 1. 环境获取插件的动态合并逻辑 -- ❌ 环境列表接口未返回每个环境的插件列表 -- ❌ 缺少"获取某个环境的插件列表"接口 -- 需要实现:查询环境时动态合并 4 个层级的插件 - - 用户个人全局插件 - - 团队全局插件 - - 该环境所属分组的个人插件 - - 该环境所属分组的团队插件 - -#### 2. 优先级规则和版本冲突处理 -- ❌ 未实现优先级覆盖逻辑 -- 当前阶段不考虑版本问题 - -#### 3. 插件更新机制 -- ❌ 只能更新用户扩展 -- ❌ 没有团队扩展更新逻辑 -- 当前阶段不考虑更新逻辑 - -#### 4. 插件状态管理 -- ⚠️ 只有 `active/inactive` 状态 -- ❌ 没有区分"团队插件禁用"和"个人禁用" - -### 与原方案的差异 - -1. **审计日志**:使用现有的 `audit_logs` 表,而不是创建新的 `extension_audit_logs` 表 -2. **权限字段**:使用现有的"管理、编辑、查看"权限,而不是新增 `manage` 权限 -3. **环境插件加载**:暂未实现环境启动时的插件动态合并逻辑 diff --git a/src-tauri/crates/business/docs/public-key-endpoint-plan.md b/src-tauri/crates/business/docs/public-key-endpoint-plan.md deleted file mode 100644 index 9d5d39f..0000000 --- a/src-tauri/crates/business/docs/public-key-endpoint-plan.md +++ /dev/null @@ -1,279 +0,0 @@ -# 获取公钥 API 端点实现计划(已更新) - -## 概述 - -本计划用于实现 `GET /api/v1/secret/public/key` API 端点,该端点用于获取服务器的 RSA 公钥。客户端可以使用此公钥加密敏感数据,然后发送到服务器。 - -## 当前状态分析 - -### 已有功能 - -1. ✅ **路由配置**:路由已在配置文件中加入白名单(`configs/config.dev.toml` 和 `configs/config.prod.toml`) -2. ✅ **RSA 密钥工具**:`src/utils/secret/rsa.rs` 中已有 `RsaSecret` 结构和 `get_public_key()` 方法 -3. ✅ **全局 RSA 实例**:`get_rsa_secret_instance()` 函数可以获取全局 RSA 密钥实例 -4. ✅ **客户端实现参考**:`src-tauri` 中已有客户端获取公钥的实现,可作为参考 - -### 缺失功能 - -1. ❌ **Handler 函数**:缺少处理获取公钥请求的 handler 函数 -2. ❌ **路由注册**:缺少注册 `/secret/public/key` 路由的代码 -3. ❌ **Service 函数**:可选,可以直接在 handler 中调用工具函数 - -## 重要发现:客户端期望的响应格式 - -通过分析 `src-tauri/src/infrastructure/storage/credential.rs` 中的客户端实现,发现了关键信息: - -### 客户端代码分析 - -```rust -// 客户端发送 GET 请求 -let response = client.get(url).send().await?; - -// 客户端期望纯文本响应(不是 JSON) -let public_key = response.text().await?; - -// 客户端验证响应格式 -if !public_key.contains("-----BEGIN RSA PUBLIC KEY-----") { - return Err("服务器响应错误".to_string()); -} -``` - -### 关键结论 - -1. **响应格式**:客户端期望**纯文本响应**(`text/plain`),而不是 JSON 格式 -2. **响应内容**:直接返回 PEM 格式的公钥字符串,例如: - ``` - -----BEGIN RSA PUBLIC KEY----- - MIIBCgKCAQEA... - -----END RSA PUBLIC KEY----- - ``` -3. **路由路径**:`secret/public/key`(相对路径,完整路径为 `/api/v1/secret/public/key`) -4. **请求拦截器**:客户端的请求拦截器会跳过 `/secret/public/key` 路径的加密处理(见 `src-tauri/src/infrastructure/network/interceptors/request.rs`) - -## 实现方案 - -### 方案选择 - -由于获取服务器公钥是一个简单的操作,且客户端期望纯文本响应,建议: - -- **直接在 handler 中调用工具函数**,无需创建额外的 service 层 -- **返回纯文本响应**,使用 Axum 的 `PlainText` 或直接使用 `String` 实现 `IntoResponse` - -### 目录结构 - -参考现有的 `time` 模块结构: - -``` -src/ -├── handlers/ -│ ├── time.rs (已有) -│ └── secret.rs (新建) -├── routes/ -│ ├── time.rs (已有) -│ └── secret.rs (新建) -├── handlers.rs -└── routes.rs -``` - -## 实现步骤 - -### 步骤 1:创建 Handler 函数 - -**文件**:`src/handlers/secret.rs` - -**功能**: - -- 从全局 RSA 实例获取公钥 -- 返回纯文本响应(PEM 格式的公钥字符串) - -**代码结构**: - -```rust -use axum::response::{IntoResponse, Response}; -use axum::http::StatusCode; -use crate::utils::get_rsa_secret_instance; - -pub async fn get_public_key_handler() -> impl IntoResponse { - let public_key = get_rsa_secret_instance().get_public_key(); - (StatusCode::OK, public_key).into_response() -} -``` - -**说明**: - -- 使用 `impl IntoResponse` 作为返回类型,可以返回纯文本 -- `(StatusCode::OK, public_key)` 会创建一个状态码为 200 的纯文本响应 -- Axum 会自动设置 `Content-Type: text/plain; charset=utf-8` - -### 步骤 2:创建路由注册模块 - -**文件**:`src/routes/secret.rs` - -**功能**: - -- 注册 `/public/key` 路由 -- 使用 GET 方法 -- 绑定到 `get_public_key_handler` - -**代码结构**: - -```rust -use axum::routing::get; -use crate::handlers::get_public_key_handler; -use crate::routes::route::{self, MetaRoute}; - -pub fn register_routes(meta_route: &mut MetaRoute) -> () { - let mut secret_route = route::RouteGroup::new("/secret"); - - secret_route.add_route_item(route::RouteItem::get("/public/key", get(get_public_key_handler))); - - meta_route.add_route_group(secret_route); -} -``` - -### 步骤 3:注册模块 - -**文件**:`src/handlers.rs` - -```rust -mod time; -mod secret; // 新增 - -pub use time::*; -pub use secret::*; // 新增 -``` - -**文件**:`src/routes.rs` - -```rust -mod time; -mod secret; // 新增 - -pub fn all_routes(svc_ctx: &SvcCtx) -> Router { - // ... - time::register_routes(&mut meta_route); - secret::register_routes(&mut meta_route); // 新增 - // ... -} -``` - -### 步骤 4:验证和测试 - -1. **编译检查**:`cargo check` -2. **代码格式化**:`cargo fmt` -3. **Lint 检查**:`cargo clippy` -4. **功能测试**: - - 启动服务器 - - 使用 curl 或 Postman 测试端点: - ```bash - curl http://localhost:40041/api/v1/secret/public/key - ``` - - 验证返回的是 PEM 格式的公钥字符串 - - 验证响应头 `Content-Type` 为 `text/plain` - -## API 响应格式 - -### 成功响应(HTTP 200) - -**响应头**: - -``` -Content-Type: text/plain; charset=utf-8 -``` - -**响应体**(纯文本): - -``` ------BEGIN RSA PUBLIC KEY----- -MIIBCgKCAQEAseI9vA7iTxOMb5Y2xCL7BOGr1by9qEH4EfP9Bj90gxDmY8yRsVK/ -o2g+i95oQxzdvdvpPAocKlQv2FEbZaqFr2Q4vy1cLrM0B1NTZl1/hGcmPSofLT9g -mnjzP60ikY40Dxq+YXAxXZ4s2M+9thNnFr4OydacHEPEkTklcBQBglopSXc1yqHU -ARyCQ3/VxQrfh215vIPgMg2f6PH741zXFaIJjucXR8wJVySo7aZhlBTOVz5GzV0b -aWh31zA47ivXh84OXIEI+CKDUSnvsa8SCRMRs8LgaO1Xktv4yCfHHpo8Zoy+KdW0 -LJxP11G+3f9RsYRpjdmsleyHuYYS07suqwIDAQAB ------END RSA PUBLIC KEY----- -``` - -### 错误处理 - -如果 RSA 实例未初始化,`get_rsa_secret_instance()` 会直接退出程序(在启动时已初始化,正常情况下不会发生)。 - -## 注意事项 - -1. **响应格式的重要性**: - - ⚠️ **必须返回纯文本**,不能使用 `Response` 类型(它会转换为 JSON) - - 客户端使用 `response.text().await` 期望纯文本响应 - - 客户端会验证响应包含 `-----BEGIN RSA PUBLIC KEY-----` - -2. **安全性**: - - 该端点返回的是服务器的**公钥**,是公开信息,可以安全地暴露 - - 路由已在白名单中,无需认证即可访问 - - 客户端的请求拦截器会跳过此路径的加密处理 - -3. **性能**: - - 公钥是静态数据,从内存中读取,性能开销很小 - - 无需数据库查询 - -4. **代码风格**: - - 遵循项目现有的代码风格(参考 `time.rs` 的实现) - - 但注意响应格式不同(纯文本 vs JSON) - - 添加适当的文档注释 - -5. **测试环境差异**: - - `config.test.toml` 中使用的是 `/api/v2/secret/public/key` - - 如果需要支持 v2 版本,可以在同一 handler 中注册多个路由,或创建单独的 handler - -## 客户端兼容性 - -### 客户端实现位置 - -- **文件**:`src-tauri/src/infrastructure/storage/credential.rs` -- **函数**:`fetch_server_public_key()` -- **调用时机**:应用启动时(`init_server_public_key()`) - -### 客户端验证逻辑 - -```rust -// 1. 发送 GET 请求 -let response = client.get(url).send().await?; - -// 2. 获取纯文本响应 -let public_key = response.text().await?; - -// 3. 验证公钥格式 -if !public_key.contains("-----BEGIN RSA PUBLIC KEY-----") { - return Err("服务器响应错误".to_string()); -} - -// 4. 存储公钥 -*SERVER_PUBLIC_KEY.write().unwrap() = Some(public_key.clone()); -``` - -## 后续优化建议(可选) - -1. **HTTP 缓存**:公钥是静态的,可以考虑添加 HTTP 缓存头(如 `Cache-Control: public, max-age=3600`) -2. **版本控制**:如果需要支持 v2 API,可以创建 `routes/secret_v2.rs` -3. **文档**:添加 OpenAPI/Swagger 文档(如果项目使用) - -## 相关文件清单 - -### 需要创建的文件 - -- `src/handlers/secret.rs` -- `src/routes/secret.rs` - -### 需要修改的文件 - -- `src/handlers.rs` - 添加 `mod secret;` 和 `pub use secret::*;` -- `src/routes.rs` - 添加 `mod secret;` 和调用 `secret::register_routes()` - -### 相关配置文件(无需修改,已配置) - -- `configs/config.dev.toml` - 已添加路由白名单 -- `configs/config.prod.toml` - 已添加路由白名单 -- `configs/config.test.toml` - 已添加路由白名单(v2 版本) - -### 客户端参考代码 - -- `src-tauri/src/infrastructure/storage/credential.rs` - 客户端获取公钥的实现 -- `src-tauri/src/infrastructure/network/interceptors/request.rs` - 请求拦截器(跳过公钥端点加密) diff --git a/src-tauri/crates/business/docs/workspace-architecture-refactoring-report.md b/src-tauri/crates/business/docs/workspace-architecture-refactoring-report.md deleted file mode 100644 index d1c1cd2..0000000 --- a/src-tauri/crates/business/docs/workspace-architecture-refactoring-report.md +++ /dev/null @@ -1,686 +0,0 @@ -# 工作空间架构重构变更报告 - -**重构日期**:2025-01-25 -**重构范围**:工作空间架构全面重构 -**文档版本**:v1.0 - ---- - -## 概述 - -本次重构将系统架构从"用户-团队-环境"模式升级为"用户-工作空间-团队-分组-环境"的多层级结构,实现了更灵活的资源管理、权限控制和计费体系。 - ---- - -## 1. 架构设计变更 - -### 1.1 新增架构文档 - -- **文件**:`simprint-server/docs/workspace-architecture.md` -- **内容**:完整的工作空间架构设计文档,包括: - - 核心概念和层级结构 - - 实体关系图(ASCII 格式) - - 实体详细设计 - - 权限控制体系 - - 配额管理 - - 代理可见性管理 - - 数据模型设计要点 - -### 1.2 核心设计原则 - -1. **工作空间是资源隔离边界**:所有资源(环境、代理、配额)都归属于工作空间 -2. **团队是协作组织单元**:团队成员共享团队资源,通过分组进行细粒度管理 -3. **分组是环境分类容器**:分组用于对团队内的环境进行分类和权限控制(可选) -4. **环境是核心操作单元**:所有业务操作围绕环境展开,环境必须归属团队 - ---- - -## 2. 数据库变更 - -### 2.1 新增表 - -#### 2.1.1 工作空间表(workspaces) - -- **迁移文件**:`20250125000001_create_workspaces.sql` -- **字段**: - - `uuid`:工作空间唯一标识 - - `name`:工作空间名称 - - `owner_uuid`:所有者用户 UUID - - `workspace_type`:工作空间类型(personal/team/enterprise) - - `created_at`、`updated_at`、`deleted_at`:时间戳 - -#### 2.1.2 工作空间配额表(workspace_quotas) - -- **迁移文件**:`20250125000002_create_workspace_quotas.sql` -- **字段**: - - `workspace_uuid`:工作空间 UUID(主键) - - `max_environments`、`used_environments`:环境配额 - - `max_team_members`、`used_team_members`:成员配额 - - `max_proxies`、`used_proxies`:代理配额 - - `max_rpa_tasks`、`used_rpa_tasks`:RPA 任务配额 - -#### 2.1.3 代理可见团队关联表(proxy_visible_teams) - -- **迁移文件**:`20250125000003_create_proxy_visible_teams.sql` -- **字段**: - - `proxy_uuid`:代理 UUID - - `workspace_uuid`:工作空间 UUID(冗余,便于查询) - - `team_uuid`:团队 UUID - - `created_at`:创建时间 -- **唯一约束**:`UNIQUE (proxy_uuid, team_uuid)` - -#### 2.1.4 分组权限表(group_member_permissions) - -- **迁移文件**:`20250125000004_create_group_member_permissions.sql` -- **字段**: - - `group_uuid`:分组 UUID - - `workspace_uuid`:工作空间 UUID(冗余,便于查询) - - `team_uuid`:团队 UUID(冗余,便于查询) - - `user_uuid`:用户 UUID - - `permission_type`:权限类型(read/write/manage) - - `granted_by`:授权者 UUID - - `created_at`、`updated_at`:时间戳 -- **唯一约束**:`UNIQUE (group_uuid, user_uuid)` - -### 2.2 修改现有表 - -#### 2.2.1 团队表(teams) - -- **迁移文件**:`20250125000005_alter_teams_add_workspace.sql` -- **新增字段**: - - `workspace_uuid`:所属工作空间 UUID -- **移除字段**: - - `max_members`、`max_environments`、`max_proxies`:配额移至 `workspace_quotas` - - `default_proxy_uuid`:不再需要默认代理 - -#### 2.2.2 团队成员表(team_members) - -- **迁移文件**:`20250125000006_alter_team_members_add_workspace.sql` -- **新增字段**: - - `workspace_uuid`:工作空间 UUID(冗余,便于查询) -- **移除字段**: - - `environment_count`、`group_count`:统计字段,可通过查询计算 -- **更新唯一约束**:`UNIQUE (team_uuid, user_uuid, workspace_uuid)` - -#### 2.2.3 分组表(groups) - -- **迁移文件**:`20250125000007_alter_groups_add_workspace.sql` -- **新增字段**: - - `workspace_uuid`:所属工作空间 UUID(冗余,便于查询) -- **移除字段**: - - `user_uuid`:分组属于团队,不属于用户 - - `default_proxy_uuid`:不再需要默认代理 - - `color`:前端可自行管理 -- **约束**:确保 `team_uuid` 为 NOT NULL - -#### 2.2.4 环境表(environments) - -- **迁移文件**:`20250125000008_alter_environments_add_workspace.sql` -- **新增字段**: - - `workspace_uuid`:所属工作空间 UUID(冗余,便于查询) -- **约束**:确保 `team_uuid` 为 NOT NULL - -#### 2.2.5 代理表(proxies) - -- **迁移文件**:`20250125000009_alter_proxies_add_workspace.sql` -- **新增字段**: - - `workspace_uuid`:所属工作空间 UUID(必须) - - `owner_uuid`:代理所有者 UUID(重命名自 `user_uuid`) -- **移除字段**: - - `team_uuid`:代理属于工作空间,不属于团队 - - `usage_count`:可通过查询计算 - -#### 2.2.6 订阅表(subscriptions) - -- **迁移文件**:`20250125000010_alter_subscriptions_add_workspace.sql` -- **新增字段**: - - `workspace_uuid`:所属工作空间 UUID - -#### 2.2.7 用户信息表(user_infos) - -- **迁移文件**:`20250125000013_alter_user_infos_add_current_workspace.sql` -- **新增字段**: - - `current_workspace_uuid`:用户当前工作空间 UUID - -### 2.3 数据迁移 - -#### 2.3.1 废弃用户配额表 - -- **迁移文件**:`20250125000011_deprecate_user_quotas.sql` -- **操作**:将 `user_quotas` 表重命名为 `deprecated_user_quotas` - -#### 2.3.2 数据迁移脚本 - -- **迁移文件**:`20250125000012_migrate_to_workspaces.sql` -- **操作内容**: - 1. 为每个现有用户创建默认个人工作空间 - 2. 为每个工作空间创建默认配额 - 3. 将现有 `user_quotas` 数据迁移到 `workspace_quotas` - 4. 更新所有相关表的 `workspace_uuid` 字段 - 5. 添加外键约束 - 6. 设置 `workspace_uuid` 为 NOT NULL - ---- - -## 3. 代码层变更 - -### 3.1 DTO 层(Data Transfer Object) - -#### 3.1.1 新增 DTO - -- `simprint-server/src/dto/workspaces.rs`:工作空间 DTO -- `simprint-server/src/dto/workspace_quotas.rs`:工作空间配额 DTO -- `simprint-server/src/dto/proxy_visible_teams.rs`:代理可见团队 DTO -- `simprint-server/src/dto/group_member_permissions.rs`:分组权限 DTO - -#### 3.1.2 修改现有 DTO - -- `simprint-server/src/dto/teams.rs`:添加 `workspace_uuid`,移除配额相关字段 -- `simprint-server/src/dto/groups.rs`:添加 `workspace_uuid`,移除 `user_uuid`、`default_proxy_uuid`、`color` -- `simprint-server/src/dto/environments.rs`:添加 `workspace_uuid` -- `simprint-server/src/dto/proxies.rs`:添加 `workspace_uuid`、`owner_uuid`,移除 `team_uuid` -- `simprint-server/src/dto/subscriptions.rs`:添加 `workspace_uuid` -- `simprint-server/src/dto/user.rs`:添加 `current_workspace_uuid` - -### 3.2 Entity 层(请求/响应实体) - -#### 3.2.1 新增 Entity - -- `simprint-server/src/entitys/workspaces.rs`:工作空间相关请求/响应 -- `simprint-server/src/entitys/workspace_quotas.rs`:工作空间配额响应 -- `simprint-server/src/entitys/proxy_visible_teams.rs`:代理可见性请求/响应 -- `simprint-server/src/entitys/group_member_permissions.rs`:分组权限请求/响应 - -#### 3.2.2 修改现有 Entity - -- `simprint-server/src/entitys/teams.rs`:添加 `workspace_uuid`,移除 `default_proxy_uuid` -- `simprint-server/src/entitys/groups.rs`:添加 `workspace_uuid`、`team_uuid`,移除 `default_proxy_uuid` -- `simprint-server/src/entitys/environments.rs`:添加 `workspace_uuid`、`team_uuid` -- `simprint-server/src/entitys/proxies.rs`:添加 `workspace_uuid` -- `simprint-server/src/entitys/subscriptions.rs`:添加 `workspace_uuid` - -### 3.3 Model 层(数据库操作) - -#### 3.3.1 新增 Model - -- `simprint-server/src/models/workspaces.rs`:工作空间数据库操作 -- `simprint-server/src/models/workspace_quotas.rs`:工作空间配额数据库操作 -- `simprint-server/src/models/proxy_visible_teams.rs`:代理可见性数据库操作 -- `simprint-server/src/models/group_member_permissions.rs`:分组权限数据库操作 - -#### 3.3.2 修改现有 Model - -- `simprint-server/src/models/teams.rs`: - - `fetch_team_member`:添加 `workspace_uuid` 参数(工作空间级别隔离) - - `insert_team`:添加 `workspace_uuid` 参数 - - `fetch_team_by_uuid`:添加 `workspace_uuid` 到 SELECT - - `fetch_user_teams`:添加 `workspace_uuid` 过滤 - - 移除配额相关逻辑 - -- `simprint-server/src/models/team_members.rs`: - - 所有函数添加 `workspace_uuid` 参数 - - SQL 查询包含 `workspace_uuid` 过滤 - -- `simprint-server/src/models/groups.rs`: - - `insert_group`:添加 `workspace_uuid`、`team_uuid` 参数,移除 `user_uuid` - - `fetch_groups`:使用 `workspace_uuid` 和 `team_uuid` 过滤 - - 移除 `default_proxy_uuid` 相关逻辑 - -- `simprint-server/src/models/environments.rs`: - - `insert_environment`:添加 `workspace_uuid` 参数 - - `fetch_environment_by_uuid`:添加 `workspace_uuid` 参数进行过滤 - - 新增 `fetch_environment_by_uuid_unfiltered`:用于内部查询 - - 所有查询函数添加 `workspace_uuid` 过滤 - -- `simprint-server/src/models/proxies.rs`: - - `insert_proxy`:添加 `workspace_uuid` 参数,`user_uuid` 改为 `owner_uuid`,移除 `team_uuid` - - `fetch_proxies`:使用 `workspace_uuid` 和 `owner_uuid` 过滤 - - 移除 `usage_count` 相关逻辑 - -- `simprint-server/src/models/subscriptions.rs`: - - 所有函数添加 `workspace_uuid` 参数 - -- `simprint-server/src/models/user.rs`: - - `create_user_with_info`:创建默认工作空间和配额 - - `fetch_user_info_by_uuid`、`fetch_user_info_by_email`:添加 `current_workspace_uuid` 到 SELECT - -### 3.4 Service 层(业务逻辑) - -#### 3.4.1 新增 Service - -- `simprint-server/src/services/workspaces.rs`:工作空间业务逻辑 -- `simprint-server/src/services/workspace_quotas.rs`:工作空间配额业务逻辑 -- `simprint-server/src/services/proxy_visibility.rs`:代理可见性业务逻辑 -- `simprint-server/src/services/group_permissions.rs`:分组权限业务逻辑 - -#### 3.4.2 修改现有 Service - -**环境服务(environments.rs)**: - -- `create_environment_service`: - - 添加工作空间级别团队成员检查 - - 添加分组权限检查(write/manage) - - 添加团队角色权限检查(Editor/Admin/Owner) - - 添加配额检查和更新 -- `get_environment_service`: - - 添加工作空间过滤 - - 添加分组 read 权限检查 -- `get_environments_service`: - - 添加批量权限过滤(根据分组权限) -- `update_environment_service`: - - 添加编辑权限检查(分组 write/manage 或团队角色) -- `delete_environment_service`: - - 添加删除权限检查(分组 manage 或团队 Owner/Admin) - - 添加配额更新 - -**分组服务(groups.rs)**: - -- `create_group_service`:添加 Owner/Admin 权限检查 -- `update_group_service`:添加 Owner/Admin 或 manage 权限检查 -- `delete_group_service`:添加 Owner/Admin 或 manage 权限检查 - -**团队服务(teams.rs)**: - -- 所有函数添加 `workspace_uuid` 参数 -- `switch_team_service`:更新 `current_workspace_uuid` - -**代理服务(proxies.rs)**: - -- `create_proxy_service`:添加配额检查和更新 -- `delete_proxy_service`:添加配额更新 -- `batch_import_proxies_service`:添加配额检查和更新 - -**模板服务(templates.rs)**: - -- `create_template_service`:从源环境获取 `workspace_uuid` 和 `team_uuid` 进行权限检查 -- `apply_template_service`:添加权限检查参数 - -### 3.5 Handler 层(API 端点) - -#### 3.5.1 新增 Handler - -- `simprint-server/src/handlers/client/workspaces.rs`:工作空间 API 端点 -- `simprint-server/src/handlers/client/workspace_quotas.rs`:工作空间配额 API 端点 -- `simprint-server/src/handlers/client/proxy_visibility.rs`:代理可见性 API 端点 -- `simprint-server/src/handlers/client/group_permissions.rs`:分组权限 API 端点 - -#### 3.5.2 修改现有 Handler - -- `simprint-server/src/handlers/client/teams.rs`:使用 `workspace_uuid` 从 `RequestContext` 获取 -- `simprint-server/src/handlers/client/environments.rs`:使用 `workspace_uuid` 和 `team_uuid` 从 `RequestContext` 获取 -- `simprint-server/src/handlers/client/proxies.rs`:使用 `workspace_uuid` 和 `owner_uuid` 从 `RequestContext` 获取 -- `simprint-server/src/handlers/client/templates.rs`:添加权限检查参数 - -### 3.6 Route 层(路由注册) - -#### 3.6.1 新增 Route - -- `simprint-server/src/routes/client/workspaces.rs`:工作空间路由 -- `simprint-server/src/routes/client/workspace_quotas.rs`:工作空间配额路由 -- `simprint-server/src/routes/client/proxy_visibility.rs`:代理可见性路由 -- `simprint-server/src/routes/client/group_permissions.rs`:分组权限路由 - -#### 3.6.2 修改现有 Route - -- `simprint-server/src/routes/client/mod.rs`:注册新路由模块 -- `simprint-server/src/main.rs`:注册所有新路由 - -### 3.7 中间件和状态管理 - -#### 3.7.1 状态管理(state.rs) - -- 添加 `CurrentWorkspace` 结构体 -- `RequestContext` 添加 `current_workspace_uuid` 字段 - -#### 3.7.2 认证中间件(middlewares/auth.rs) - -- 从 `user_infos` 获取 `current_workspace_uuid` 并设置到 `RequestContext` - ---- - -## 4. 核心功能实现 - -### 4.1 权限检查体系 - -#### 4.1.1 工作空间级别隔离 - -- 所有团队成员关系查询都包含 `workspace_uuid` 过滤 -- `fetch_team_member` 函数签名包含 `workspace_uuid` 参数 -- 确保用户在不同工作空间中有不同的团队成员身份 - -#### 4.1.2 环境权限检查 - -- **创建环境**: - - 检查用户是否在当前工作空间的团队中 - - 如果指定分组,检查分组 write/manage 权限 - - 如果未指定分组,检查团队角色权限(Editor/Admin/Owner) - - 检查工作空间配额 -- **查看环境**: - - 检查用户是否在当前工作空间的团队中 - - 如果环境有分组,检查分组 read 权限 - - Owner/Admin 自动拥有所有分组权限 - - 无分组环境,所有团队成员都可以查看 -- **更新环境**: - - 检查分组 write/manage 权限或团队角色权限(Editor/Admin/Owner) -- **删除环境**: - - 检查分组 manage 权限或团队 Owner/Admin 角色 - -#### 4.1.3 分组权限检查 - -- **创建分组**:只有 Owner/Admin 可以创建 -- **更新分组**:Owner/Admin 或拥有 manage 权限 -- **删除分组**:Owner/Admin 或拥有 manage 权限 -- **分组权限函数**:`check_group_permission` 自动处理 Owner/Admin 权限 - -### 4.2 配额管理体系 - -#### 4.2.1 环境配额 - -- **检查**:`check_quota(workspace_uuid, "environments")` -- **更新**: - - 创建环境:`increment_used_environments(workspace_uuid, 1)` - - 删除环境:`decrement_used_environments(workspace_uuid, 1)` - -#### 4.2.2 代理配额 - -- **检查**:`check_quota(workspace_uuid, "proxies")` -- **更新**: - - 创建代理:`increment_used_proxies(workspace_uuid, 1)` - - 删除代理:`decrement_used_proxies(workspace_uuid, 1)` - - 批量导入:每成功导入一个代理就更新一次 - -#### 4.2.3 成员配额 - -- **检查**:`check_quota(workspace_uuid, "team_members")` -- **更新**: - - 接受邀请:`update_used_team_members(workspace_uuid)`(仅当用户之前不是该工作空间的成员时) - - 移除成员:`update_used_team_members(workspace_uuid)` - - 退出团队:`update_used_team_members(workspace_uuid)` -- **注意**:成员配额是统计所有团队的活跃成员总数,需要重新计算 - -### 4.3 代理可见性管理 - -#### 4.3.1 可见性规则 - -1. **工作空间 Owner**:可以看到所有代理 -2. **代理所有者**:可以看到自己的代理(无论是否在可见列表中) -3. **团队成员**:只能看到 `proxy_visible_teams` 中包含其团队的代理 -4. **否则**:不可见 - -#### 4.3.2 可见性设置 - -- 只有代理所有者或工作空间所有者可以设置可见性 -- 通过 `proxy_visible_teams` 关联表控制可见性 -- 支持批量设置可见性 - -### 4.4 工作空间管理 - -#### 4.4.1 工作空间创建 - -- 用户注册时自动创建个人工作空间 -- 支持创建不同类型的工作空间(personal/team/enterprise) -- 自动创建默认配额 - -#### 4.4.2 工作空间切换 - -- 用户可以在多个工作空间之间切换 -- `user_infos.current_workspace_uuid` 记录当前工作空间 -- 认证中间件自动设置当前工作空间 - ---- - -## 5. API 端点变更 - -### 5.1 新增 API 端点 - -#### 5.1.1 工作空间相关 - -- `POST /workspaces/create`:创建工作空间 -- `POST /workspaces/list`:获取用户的工作空间列表 -- `POST /workspaces/get`:获取工作空间详情 -- `POST /workspaces/update`:更新工作空间 -- `POST /workspaces/delete`:删除工作空间 -- `POST /workspaces/switch`:切换工作空间 - -#### 5.1.2 工作空间配额相关 - -- `POST /workspace-quotas/get`:获取工作空间配额 -- `POST /workspace-quotas/update`:更新配额使用情况 - -#### 5.1.3 代理可见性相关 - -- `POST /proxy-visibility/set`:设置代理对团队可见 -- `POST /proxy-visibility/remove`:移除代理对团队的可见性 -- `POST /proxy-visibility/batch-set`:批量设置代理可见性 -- `POST /proxy-visibility/list-visible`:获取可见的代理列表 -- `POST /proxy-visibility/list-teams`:获取代理的可见团队列表 - -#### 5.1.4 分组权限相关 - -- `POST /group-permissions/grant`:授予分组权限 -- `POST /group-permissions/revoke`:撤销分组权限 -- `POST /group-permissions/check`:检查分组权限 -- `POST /group-permissions/list`:列出用户的分组权限 - -### 5.2 修改现有 API 端点 - -所有现有 API 端点现在都需要: - -- 从 `RequestContext` 获取 `current_workspace_uuid` -- 从 `RequestContext` 获取 `current_team_uuid`(如适用) -- 传递 `workspace_uuid` 和 `team_uuid` 到服务层 - ---- - -## 6. 数据模型设计要点 - -### 6.1 冗余字段设计 - -为了优化查询性能,以下表包含冗余字段: - -- `groups.workspace_uuid`:避免 JOIN 查询 -- `environments.workspace_uuid`:便于直接查询工作空间的环境 -- `team_members.workspace_uuid`:便于直接查询工作空间内的团队成员关系(工作空间级别的隔离) -- `group_member_permissions.workspace_uuid`、`team_uuid`:便于权限查询 -- `proxy_visible_teams.workspace_uuid`:便于直接查询工作空间内的代理可见性 - -### 6.2 软删除策略 - -所有表都支持软删除: - -- 使用 `deleted_at` 字段标记删除 -- 查询时默认过滤已删除记录 -- 支持数据恢复 - -### 6.3 时间戳管理 - -所有表都包含时间戳字段: - -- `created_at`:创建时间 -- `updated_at`:更新时间(自动更新) -- `deleted_at`:软删除时间(如适用) - -### 6.4 UUID 主键 - -所有表使用 UUID 作为主键: - -- 全局唯一标识 -- 避免 ID 冲突 -- 支持分布式系统 - ---- - -## 7. 关键设计决策 - -### 7.1 工作空间级别隔离 - -**决策**:团队成员关系是工作空间级别的,不是全局的。 - -**原因**: - -- 用户在不同工作空间中可能有不同的团队成员身份和角色 -- 提供更好的资源隔离和权限控制 -- 支持多工作空间场景 - -**实现**: - -- `team_members` 表包含 `workspace_uuid` 字段(冗余) -- 所有团队成员查询都包含 `workspace_uuid` 过滤 -- 唯一约束包含 `workspace_uuid`:`UNIQUE (team_uuid, user_uuid, workspace_uuid)` - -### 7.2 环境直接归属团队 - -**决策**:环境必须直接归属团队,分组是可选的分类容器。 - -**原因**: - -- 简化数据模型 -- 提供更清晰的权限控制 -- 支持无分组场景 - -**实现**: - -- `environments.team_uuid` 为 NOT NULL -- `environments.group_uuid` 为可选(NULL 表示直接归属团队) -- 权限控制:有分组用分组权限,无分组用团队权限 - -### 7.3 代理属于工作空间 - -**决策**:代理属于工作空间,不属于团队,通过可见性控制访问。 - -**原因**: - -- 代理是工作空间级别的资源 -- 支持跨团队共享代理 -- 提供更灵活的代理管理 - -**实现**: - -- `proxies.workspace_uuid` 为必须 -- `proxies.owner_uuid` 记录代理所有者 -- `proxy_visible_teams` 关联表控制可见性 - -### 7.4 配额基于工作空间 - -**决策**:配额是工作空间级别的,所有团队共享。 - -**原因**: - -- 简化配额管理 -- 支持统一计费 -- 便于资源分配 - -**实现**: - -- `workspace_quotas` 表存储工作空间配额 -- 所有配额检查和更新都基于 `workspace_uuid` -- 成员配额统计所有团队的活跃成员总数 - ---- - -## 8. 迁移和兼容性 - -### 8.1 数据迁移 - -- 为每个现有用户创建默认个人工作空间 -- 将现有 `user_quotas` 数据迁移到 `workspace_quotas` -- 更新所有相关表的 `workspace_uuid` 字段 -- 确保数据完整性 - -### 8.2 向后兼容 - -- 保留 `deprecated_user_quotas` 表用于历史数据 -- 迁移脚本确保所有现有数据都有对应的 `workspace_uuid` -- 用户注册时自动创建个人工作空间 - ---- - -## 9. 测试建议 - -### 9.1 单元测试 - -- 工作空间级别隔离测试 -- 权限检查函数测试 -- 配额管理函数测试 - -### 9.2 集成测试 - -- 代理可见性逻辑测试 -- 分组权限控制测试 -- 工作空间切换测试 - -### 9.3 安全测试 - -- 跨工作空间访问测试 -- 权限绕过测试 -- 配额绕过测试 - ---- - -## 10. 已知问题和限制 - -### 10.1 数据库约束 - -- 迁移脚本中注释掉了 `team_uuid` 的 NOT NULL 约束 -- **建议**:在数据迁移完成后,确保所有环境都有 `team_uuid`,然后添加 NOT NULL 约束 - -### 10.2 性能优化 - -- 冗余字段设计已优化查询性能 -- 建议添加适当的索引以进一步提升性能 - ---- - -## 11. 后续工作 - -### 11.1 待完成 - -- [ ] 在数据迁移完成后,添加 `team_uuid` 的 NOT NULL 约束 -- [ ] 添加单元测试和集成测试 -- [ ] 进行安全审查 - -### 11.2 优化建议 - -- [ ] 添加缓存层以提升性能 -- [ ] 优化配额统计查询 -- [ ] 添加配额使用情况监控 - ---- - -## 12. 总结 - -本次重构实现了完整的工作空间架构,包括: - -✅ **核心功能**: - -- 工作空间管理 -- 工作空间级别隔离 -- 权限检查体系 -- 配额管理体系 -- 代理可见性管理 - -✅ **数据模型**: - -- 4 个新表 -- 7 个表的结构修改 -- 完整的数据迁移脚本 - -✅ **代码实现**: - -- 4 个新的 Service 模块 -- 4 个新的 Handler 模块 -- 4 个新的 Route 模块 -- 所有现有模块的更新 - -✅ **API 端点**: - -- 15+ 个新的 API 端点 -- 所有现有端点的更新 - -所有实现都符合 `workspace-architecture.md` 文档的设计要求,确保了系统的可扩展性、安全性和可维护性。 - ---- - -**报告生成时间**:2025-01-25 -**报告版本**:v1.0 -**文档维护者**:开发团队 diff --git a/src-tauri/crates/business/docs/workspace-architecture.md b/src-tauri/crates/business/docs/workspace-architecture.md deleted file mode 100644 index 3403ff9..0000000 --- a/src-tauri/crates/business/docs/workspace-architecture.md +++ /dev/null @@ -1,884 +0,0 @@ -# 工作空间架构设计 - -## 概述 - -本文档描述了 Simprint 系统的核心架构设计,采用"用户-工作空间-团队-分组-环境"的多层级结构,实现灵活的资源管理、权限控制和计费体系。 - -## 核心概念 - -### 层级结构 - -``` -用户(User) - └─ 工作空间(Workspace) - ├─ 团队(Team) - │ ├─ 成员(Team Members) - │ └─ 分组(Group) - │ └─ 环境(Environment) - ├─ 代理(Proxy) - ├─ 配额(Quota) - └─ 订阅(Subscription) -``` - -### 关键设计原则 - -1. **工作空间是资源隔离边界**:所有资源(环境、代理、配额)都归属于工作空间 -2. **团队是协作组织单元**:团队成员共享团队资源,通过分组进行细粒度管理 -3. **分组是环境分类容器**:分组用于对团队内的环境进行分类和权限控制 -4. **环境是核心操作单元**:所有业务操作围绕环境展开 - -## 实体关系图 - -### 核心实体关系 - -``` - ┌───────────────────────┐ - │ users │ - │ (用户基础信息) │ - └───────────┬───────────┘ - │ - │ 1:N - │ - ┌───────────────────────────┼───────────────────────────┐ - │ │ │ - │ │ │ - ▼ ▼ ▼ - ┌───────────────┐ ┌───────────────┐ ┌───────────────┐ - │ workspaces │ │ user_infos │ │ user_wallets │ - │ (工作空间) │ │ (用户详情) │ │ (用户钱包) │ - └───────┬───────┘ └───────────────┘ └───────────────┘ - │ - │ 1:N (owner) - │ - ▼ - ┌───────────────┐ - │ teams │◄──────────┐ - │ (团队) │ │ - └───────┬───────┘ │ - │ │ N:M - │ │ - ┌───────────┼───────────┐ │ - │ │ │ │ - │ 1:N │ 1:N │ │ - │ (可选) │ (必须) │ │ - │ │ │ │ - ▼ ▼ │ │ -┌───────────────┐ ┌───────────────┐ │ -│ groups │ │ environments │ │ -│ (分组) │ │ (环境) │ │ -│ (可选分类) │ │ (直接归属团队) │ │ -└───────┬───────┘ └───────┬───────┘ │ - │ │ │ - │ N:1 (可选) │ N:1 (必须)│ - │ (环境可选归属分组)│ (环境必须归属团队)│ - │ │ │ - └─────────┬───────┘ │ - │ │ - │ │ - │ - │ - ┌───────────────┴───────────────┐ - │ │ - ▼ ▼ - ┌───────────────┐ ┌───────────────┐ - │team_members │──────────────►│ users │ - │(团队成员关系) │ │ (用户) │ - │(工作空间级别) │ │ │ - └───────┬───────┘ └───────────────┘ - │ - │ N:1 (冗余) - │ (通过 team.workspace_uuid) - │ - ▼ - ┌───────────────┐ - │ workspaces │ - │ (工作空间) │ - └───────────────┘ -``` - -### 工作空间资源关系 - -``` - ┌───────────────┐ - │ workspaces │ - │ (工作空间) │ - └───────┬───────┘ - │ - ┌───────────────────┼───────────────────┐ - │ │ │ - │ 1:N │ 1:1 │ 1:N - │ │ │ - ▼ ▼ ▼ -┌───────────────┐ ┌───────────────┐ ┌───────────────┐ -│ teams │ │workspace_quotas│ │ subscriptions │ -│ (团队) │ │ (工作空间配额) │ │ (订阅) │ -└───────┬───────┘ └───────────────┘ └───────────────┘ - │ - │ 1:N - │ - ▼ -┌───────────────┐ -│ groups │ -│ (分组) │ -└───────┬───────┘ - │ - │ 1:N (可选) - │ (分组可以没有环境) - │ - ▼ -┌───────────────┐ -│ environments │ -│ (环境) │ -└───────┬───────┘ - │ - │ N:1 (必须) - │ (环境必须属于团队) - │ - ▼ -┌───────────────┐ -│ teams │ -│ (团队) │ -└───────────────┘ -``` - -### 代理资源关系 - -``` - ┌───────────────┐ - │ workspaces │ - │ (工作空间) │ - └───────┬───────┘ - │ - │ 1:N - │ - ▼ - ┌───────────────┐ - │ proxies │ - │ (代理) │ - │ (工作空间级别) │ - └───────┬───────┘ - │ - ┌───────────────────┼───────────────────┐ - │ │ │ - │ N:1 (可选) │ N:M │ N:1 - │ (环境选择代理) │ (可见性控制) │ (所有者) - │ │ │ - ▼ ▼ ▼ -┌───────────────┐ ┌───────────────┐ ┌───────────────┐ -│ environments │ │proxy_visible_ │ │ users │ -│ (环境) │ │teams │ │ (用户) │ -│ │ │(代理可见团队) │ │ (所有者) │ -└───────────────┘ └───────┬───────┘ └───────────────┘ - │ - │ N:1 - │ - ▼ - ┌───────────────┐ - │ teams │ - │ (团队) │ - └───────────────┘ -``` - -### 权限控制关系 - -``` - ┌───────────────┐ - │ workspaces │ - │ (工作空间) │ - └───────┬───────┘ - │ - │ 1:N - │ - ▼ - ┌───────────────┐ - │ teams │ - │ (团队) │ - └───────┬───────┘ - │ - ┌───────────────────┼───────────────────┐ - │ │ │ - │ 1:N │ N:M │ 1:N - │ │ (工作空间级别) │ - │ │ │ - ▼ ▼ ▼ -┌───────────────┐ ┌───────────────┐ ┌───────────────┐ -│ groups │ │team_members │ │group_member_ │ -│ (分组) │ │(团队成员关系) │ │permissions │ -│ │ │(工作空间级别) │ │(分组权限) │ -└───────┬───────┘ └───────┬───────┘ └───────┬───────┘ - │ │ │ - │ N:1 │ N:1 │ N:1 - │ │ │ - ▼ ▼ ▼ -┌───────────────┐ ┌───────────────┐ ┌───────────────┐ -│ environments │ │ users │ │ users │ -│ (环境) │ │ (用户) │ │ (用户) │ -└───────────────┘ └───────────────┘ └───────────────┘ -``` - -## 实体详细设计 - -### 1. 用户(Users) - -**职责**:系统的基础实体,代表使用系统的个人。 - -**关键属性**: - -- `uuid`:用户唯一标识 -- `id`:业务层用户ID -- 与工作空间的关系:一个用户可以有多个工作空间(作为 Owner) - -**关系**: - -- 1:N → 工作空间(作为 Owner) -- N:M → 团队(通过 team_members,工作空间级别) -- N:M → 分组权限(通过 group_member_permissions,工作空间级别) - -**设计要点**: - -- 用户在不同工作空间中,可能有不同的团队成员身份 -- 团队成员关系是工作空间级别的,不是全局的 - -### 2. 工作空间(Workspaces) - -**职责**:资源隔离的顶层容器,所有资源都归属于工作空间。 - -**关键属性**: - -- `uuid`:工作空间唯一标识 -- `name`:工作空间名称 -- `owner_uuid`:所有者用户 UUID -- `workspace_type`:工作空间类型(personal/team/enterprise) - -**关系**: - -- N:1 → 用户(Owner) -- 1:N → 团队 -- 1:1 → 工作空间配额 -- 1:N → 订阅 -- 1:N → 代理(工作空间级别) - -**设计要点**: - -- 工作空间是计费和配额的基础单元 -- 工作空间内的所有资源共享配额 -- 工作空间 Owner 拥有最高权限 - -### 3. 团队(Teams) - -**职责**:工作空间内的协作组织单元,管理团队成员和团队资源。 - -**关键属性**: - -- `uuid`:团队唯一标识 -- `workspace_uuid`:所属工作空间 UUID -- `name`:团队名称 -- `owner_uuid`:团队所有者 UUID -- `description`:团队描述 - -**关系**: - -- N:1 → 工作空间 -- N:1 → 用户(Owner) -- N:M → 用户(通过 team_members) -- 1:N → 分组(可选,团队可以没有分组) -- 1:N → 环境(直接关联,环境必须属于团队) - -**设计要点**: - -- 团队是权限管理的基础单元 -- 团队成员通过 team_members 表关联 -- 团队 Owner/Admin 可以管理团队内的分组和权限 - -### 4. 分组(Groups) - -**职责**:团队内环境的分类容器,用于环境组织和权限控制(可选功能)。 - -**关键属性**: - -- `uuid`:分组唯一标识 -- `workspace_uuid`:所属工作空间 UUID(冗余,便于查询) -- `team_uuid`:所属团队 UUID -- `name`:分组名称 -- `description`:分组描述 - -**关系**: - -- N:1 → 工作空间 -- N:1 → 团队 -- 1:N → 环境(可选,分组可以没有环境) -- N:M → 用户(通过 group_member_permissions) - -**设计要点**: - -- 分组属于团队,用于对团队内的环境进行分类 -- **分组可以独立存在,不一定要有环境**(分组可以预先创建,等待环境加入) -- 分组权限控制团队成员对分组内环境的访问 -- 分组是可选功能,环境可以不使用分组直接归属团队 - -### 5. 环境(Environments) - -**职责**:系统的核心操作单元,所有业务操作围绕环境展开。 - -**关键属性**: - -- `uuid`:环境唯一标识 -- `workspace_uuid`:所属工作空间 UUID(冗余,便于查询) -- `team_uuid`:所属团队 UUID(必须) -- `group_uuid`:所属分组 UUID(可选,NULL 表示直接归属团队) -- `proxy_uuid`:环境使用的代理 UUID(可选,用户明确选择) -- `name`:环境名称 -- `status`:环境状态 - -**关系**: - -- N:1 → 工作空间 -- N:1 → 团队(必须) -- N:1 → 分组(可选) -- N:1 → 代理(可选) - -**设计要点**: - -- **环境必须直接归属团队**(`team_uuid` NOT NULL) -- **环境可以属于某个分组**(`group_uuid` 可选,NULL 表示未分组) -- **环境直接归属团队时,使用团队级别的权限控制** -- **环境可以设置代理**(`proxy_uuid` 可选),用户从可见的代理列表中选择 -- **没有代理继承概念**,用户必须明确选择代理 - -### 6. 代理(Proxies) - -**职责**:网络代理资源,为环境提供网络访问能力。 - -**关键属性**: - -- `uuid`:代理唯一标识 -- `workspace_uuid`:所属工作空间 UUID(必须) -- `owner_uuid`:代理所有者 UUID(创建者) -- `name`:代理名称 -- `proxy_type`:代理类型 -- `host`、`port`:代理地址 -- `status`:代理状态(active/inactive/testing/error) - -**关系**: - -- N:1 → 工作空间(必须) -- N:1 → 用户(Owner,创建者) -- 1:N → 环境(通过环境选择,可选) -- N:M → 团队(通过 proxy_visible_teams,控制可见性) - -**设计要点**: - -- **代理属于工作空间**,不属于团队(`team_uuid` 不存在) -- **代理默认不可见**,只有所有者可以看到 -- **代理可见性通过关联表控制**:`proxy_visible_teams` 表存储代理对哪些团队可见 -- **代理所有者可以设置可见性**:可以设置代理对特定团队可见 -- **工作空间 Owner 可以看到所有代理**,并可以管理所有代理 -- **创建环境时,代理是可选的**,用户从可见的代理列表中选择 -- **没有继承概念**,用户必须明确选择代理 - -### 7. 团队成员(Team Members) - -**职责**:用户与团队的关联关系,定义用户在团队中的角色(工作空间级别)。 - -**关键属性**: - -- `team_uuid`:团队 UUID -- `workspace_uuid`:工作空间 UUID(冗余,便于查询) -- `user_uuid`:用户 UUID -- `role`:角色(owner/admin/editor/viewer) -- `status`:状态(active/pending/inactive) - -**关系**: - -- N:1 → 团队 -- N:1 → 用户 -- N:1 → 工作空间(冗余,通过 team.workspace_uuid 关联) - -**设计要点**: - -- **团队成员关系是工作空间级别的**:用户在某个工作空间加入团队,只在该工作空间内属于该团队 -- 同一用户在不同工作空间中,可能有不同的团队成员身份和角色 -- 定义用户在团队中的角色和权限 -- Owner/Admin 拥有团队管理权限 -- Editor 可以创建和编辑环境 -- Viewer 只能查看环境 -- `workspace_uuid` 作为冗余字段,便于直接查询工作空间内的团队成员关系 - -### 8. 分组权限(Group Member Permissions) - -**职责**:控制团队成员对分组的访问权限,实现细粒度权限控制。 - -**关键属性**: - -- `group_uuid`:分组 UUID -- `workspace_uuid`:工作空间 UUID(冗余,便于查询) -- `team_uuid`:团队 UUID(冗余,便于查询) -- `user_uuid`:用户 UUID -- `permission_type`:权限类型(read/write/manage) -- `granted_by`:授权者 UUID - -**关系**: - -- N:1 → 分组 -- N:1 → 用户 -- N:1 → 工作空间(冗余) -- N:1 → 团队(冗余) - -**设计要点**: - -- 只有团队成员才能被授予分组权限 -- Owner/Admin 自动拥有所有分组权限(无需显式授权) -- Editor/Viewer 需要显式授权才能访问分组 -- 权限类型:read(查看)、write(创建/编辑)、manage(管理分组) - -### 9. 工作空间配额(Workspace Quotas) - -**职责**:定义工作空间的资源配额限制。 - -**关键属性**: - -- `workspace_uuid`:工作空间 UUID -- `max_environments`:最大环境数 -- `used_environments`:已使用环境数 -- `max_team_members`:最大成员数(所有团队总和) -- `max_proxies`:最大代理数 -- `used_proxies`:已使用代理数 -- `max_rpa_tasks`:最大 RPA 任务数 -- `used_rpa_tasks`:已使用 RPA 任务数 - -**关系**: - -- 1:1 → 工作空间 - -**设计要点**: - -- 配额是工作空间级别的,所有团队共享 -- 配额由订阅套餐决定 -- 配额使用情况实时统计 - -### 10. 订阅(Subscriptions) - -**职责**:工作空间的订阅信息,关联套餐和计费。 - -**关键属性**: - -- `uuid`:订阅唯一标识 -- `workspace_uuid`:工作空间 UUID -- `user_uuid`:订阅者用户 UUID(用于记录) -- `plan_uuid`:套餐 UUID -- `billing_period`:计费周期 -- `status`:订阅状态 - -**关系**: - -- N:1 → 工作空间 -- N:1 → 用户(订阅者) -- N:1 → 套餐 - -**设计要点**: - -- 订阅关联到工作空间,而非用户 -- 工作空间 Owner 负责订阅和计费 -- 订阅决定工作空间的配额 - -## 权限控制体系 - -### 权限层级 - -``` -工作空间级别 - └─ Owner:拥有工作空间的所有权限 - ├─ 管理团队 - ├─ 管理订阅和配额 - └─ 管理工作空间级别的代理 - -团队级别 - └─ Owner/Admin:拥有团队的所有权限 - ├─ 管理团队成员 - ├─ 管理分组 - └─ 管理分组权限 - └─ Editor:可以创建和编辑环境 - └─ Viewer:只能查看环境 - -分组级别 - └─ Manage:可以管理分组(编辑分组、授权用户) - └─ Write:可以创建和编辑分组内的环境 - └─ Read:可以查看分组内的环境 -``` - -### 权限检查流程 - -**重要前提**:所有权限检查都在当前工作空间上下文中进行。 - -**创建环境**: - -1. 检查用户是否在当前工作空间的团队中(`team_members.workspace_uuid = 当前工作空间`) -2. 如果指定了分组(`group_uuid` 不为 NULL): - - 检查用户是否有目标分组的 write 或 manage 权限(在当前工作空间上下文中) -3. 如果未指定分组(`group_uuid` 为 NULL): - - 检查用户是否有团队级别的环境创建权限(Editor/Admin/Owner) -4. 检查工作空间配额是否充足 - -**查看环境**: - -1. 检查用户是否在当前工作空间的团队中 -2. 如果环境有分组(`group_uuid` 不为 NULL): - - 检查用户是否有分组的 read/write/manage 权限(在当前工作空间上下文中) - - Owner/Admin 自动拥有所有分组权限 -3. 如果环境无分组(`group_uuid` 为 NULL): - - 检查用户是否有团队级别的查看权限(所有团队成员都可以查看) - -**管理分组**: - -1. 检查用户是否在当前工作空间的团队中 -2. 检查用户是否是团队 Owner/Admin 或拥有分组的 manage 权限(在当前工作空间上下文中) - -**未分组环境的权限规则**: - -- Owner/Admin:拥有所有权限(创建、编辑、删除) -- Editor:可以创建和编辑环境 -- Viewer:只能查看环境 -- 所有团队成员默认可以查看未分组环境 - -### 工作空间级别的团队成员关系 - -**核心设计**: - -- 用户在某个工作空间加入团队,只在该工作空间内属于该团队 -- 同一用户在不同工作空间中,可能有不同的团队成员身份和角色 -- 团队成员关系通过 `team_members` 表关联,包含 `workspace_uuid` 字段(冗余,便于查询) - -**示例场景**: - -``` -用户A: - - 在工作空间1:团队X的 Owner - - 在工作空间2:团队Y的 Editor - - 在工作空间3:团队Z的 Viewer -``` - -**查询逻辑**: - -- 查询用户在某工作空间的团队:`WHERE user_uuid = $1 AND workspace_uuid = $2` -- 查询团队在某工作空间的成员:`WHERE team_uuid = $1 AND workspace_uuid = $2` -- 通过 `team.workspace_uuid` 可以间接关联,但冗余 `workspace_uuid` 字段可以提高查询性能 - -## 配额管理 - -### 配额层级 - -``` -工作空间配额(workspace_quotas) - └─ 所有团队共享 - └─ 由订阅套餐决定 - └─ 实时统计使用情况 -``` - -### 配额检查 - -**环境配额**: - -- 统计工作空间内所有团队的环境总数 -- 检查 `used_environments < max_environments` - -**代理配额**: - -- 统计工作空间内的代理总数 -- 检查 `used_proxies < max_proxies` - -**成员配额**: - -- 统计工作空间内所有团队的活跃成员总数 -- 检查 `used_team_members < max_team_members` - -## 代理管理 - -### 代理可见性设计 - -**核心设计**: - -- 代理属于工作空间,不属于团队 -- 代理默认不可见,只有所有者可以看到 -- 代理所有者可以设置代理对特定团队可见 -- 通过 `proxy_visible_teams` 关联表控制可见性 - -**可见性规则**: - -``` -代理可见性检查(优先级从高到低): -1. 工作空间 Owner:可以看到所有代理 -2. 代理所有者:可以看到自己的代理(无论是否在可见列表中) -3. 团队成员:只能看到 proxy_visible_teams 中包含其团队的代理 -4. 否则:不可见 -``` - -**数据模型**: - -```sql --- 代理表 -proxies: - - workspace_uuid(必须) - - owner_uuid(必须,创建者) - - name, proxy_type, host, port 等 - --- 代理可见团队关联表 -proxy_visible_teams: - - proxy_uuid - - workspace_uuid(冗余,便于查询) - - team_uuid - - UNIQUE (proxy_uuid, team_uuid) -``` - -### 代理使用流程 - -**创建环境时的代理选择**: - -1. 用户选择当前工作空间 -2. 用户选择当前团队 -3. 系统查询用户可见的代理: - - 工作空间 Owner:所有代理 - - 代理所有者:自己的代理 - - 团队成员:`proxy_visible_teams` 中包含其团队的代理 -4. 如果没有任何可见的代理: - - 显示"没有可用的代理" - - 提供"上传新代理"选项 -5. 用户从可见代理列表中选择(可选,代理不是必须的) -6. 环境创建时,`proxy_uuid` 设置为用户选择的代理(如果选择了) - -**代理选择逻辑**: - -- 代理是可选的,创建环境时可以不选择代理 -- 用户从可见的代理列表中选择 -- 没有继承概念,用户必须明确选择 -- 如果代理不可见,用户无法选择 - -## 数据模型设计要点 - -### 1. 冗余字段设计 - -为了优化查询性能,部分表包含冗余字段: - -- `groups.workspace_uuid`:冗余字段,避免 JOIN 查询 -- `environments.workspace_uuid`:冗余字段,便于直接查询工作空间的环境 -- `team_members.workspace_uuid`:冗余字段,便于直接查询工作空间内的团队成员关系(工作空间级别的隔离) -- `group_member_permissions.workspace_uuid`、`team_uuid`:冗余字段,便于权限查询 -- `proxy_visible_teams.workspace_uuid`:冗余字段,便于直接查询工作空间内的代理可见性 - -**工作空间级别的隔离设计**: - -- `team_members` 表包含 `workspace_uuid` 字段(冗余),确保团队成员关系是工作空间级别的 -- 查询时始终需要指定 `workspace_uuid`,确保数据隔离 -- 通过 `team.workspace_uuid` 可以间接关联,但冗余字段提高查询性能 - -### 2. 软删除策略 - -所有表都支持软删除: - -- 使用 `deleted_at` 字段标记删除 -- 查询时默认过滤已删除记录 -- 支持数据恢复 - -### 3. 时间戳管理 - -所有表都包含时间戳字段: - -- `created_at`:创建时间 -- `updated_at`:更新时间(自动更新) -- 部分表包含 `deleted_at`(软删除时间) - -### 4. UUID 主键 - -所有表使用 UUID 作为主键: - -- 全局唯一标识 -- 避免 ID 冲突 -- 支持分布式系统 - -## 设计优势 - -### 1. 清晰的层级结构 - -- 用户 → 工作空间 → 团队 → 分组 → 环境 -- 每一层都有明确的职责和边界 -- 易于理解和维护 - -### 2. 灵活的权限控制 - -- 工作空间级别:Owner 管理 -- 团队级别:角色权限 -- 分组级别:细粒度权限 -- 支持复杂的权限场景 - -### 3. 统一的资源管理 - -- 所有资源归属于工作空间 -- 配额和计费基于工作空间 -- 资源隔离清晰 - -### 4. 良好的扩展性 - -- 支持多工作空间 -- 支持多团队协作 -- 支持细粒度权限控制 -- 易于扩展新功能 - -### 5. 符合业务逻辑 - -- 工作空间 = 企业账户/项目空间 -- 团队 = 部门/协作组 -- 分组 = 环境分类 -- 环境 = 核心操作单元 - -## 分组与环境的关系设计 - -### 设计原则 - -1. **环境直接归属团队**:环境必须属于某个团队(`team_uuid` NOT NULL),这是环境的基础归属关系。 - -2. **分组是可选分类容器**:环境可以可选归属分组(`group_uuid` 可选),分组用于环境分类和权限控制,但不是必需的。 - -3. **分组可以独立存在**:分组可以预先创建,不一定要有环境。团队管理者可以创建分组并设置权限,等待环境加入。 - -4. **灵活的权限控制**: - - 有分组的环境:使用分组权限控制 - - 无分组的环境:使用团队级别权限控制 - -### 使用场景 - -**场景1:环境直接归属团队(无分组)** - -``` -团队 - └─ 环境1(group_uuid = NULL) - └─ 环境2(group_uuid = NULL) - └─ 环境3(group_uuid = NULL) -``` - -- 适用于简单场景,不需要复杂分类 -- 权限控制基于团队角色(Owner/Admin/Editor/Viewer) -- 所有团队成员可以查看,Editor 及以上可以创建/编辑 - -**场景2:环境归属分组** - -``` -团队 - ├─ 分组A - │ └─ 环境1(group_uuid = 分组A) - │ └─ 环境2(group_uuid = 分组A) - └─ 分组B - └─ 环境3(group_uuid = 分组B) -``` - -- 适用于需要环境分类的场景 -- 权限控制基于分组权限(read/write/manage) -- 可以实现细粒度的权限控制 - -**场景3:混合模式** - -``` -团队 - ├─ 环境1(group_uuid = NULL,直接归属团队) - ├─ 分组A - │ └─ 环境2(group_uuid = 分组A) - └─ 分组B(空分组,等待环境加入) -``` - -- 部分环境使用分组,部分环境直接归属团队 -- 提供最大的灵活性 - -### 降级方案 - -如果某些场景需要强制使用分组,可以采用以下降级方案: - -1. **默认分组**:团队创建时自动创建一个"默认分组" -2. **强制分组**:创建环境时,如果未指定分组,自动归属默认分组 -3. **透明处理**:前端可以隐藏默认分组,让用户感觉环境直接归属团队 - -但更推荐的设计是:**环境可以直接归属团队,分组是可选的分类功能**。 - -## 代理可见性管理 - -### 核心设计 - -**代理归属**: - -- 代理属于工作空间,不属于团队(`team_uuid` 不存在) -- 代理有所有者(`owner_uuid`),创建者即为所有者 -- 代理默认不可见,只有所有者可以看到 - -**可见性控制**: - -- 代理所有者可以设置代理对特定团队可见(通过 `proxy_visible_teams` 关联表) -- 工作空间 Owner 可以看到所有代理,并可以管理所有代理 -- 团队成员只能看到 `proxy_visible_teams` 中包含其团队的代理 - -**数据模型**: - -```sql --- 代理表 -proxies: - - uuid - - workspace_uuid(必须) - - owner_uuid(必须,创建者) - - name, proxy_type, host, port, status 等 - --- 代理可见团队关联表 -proxy_visible_teams: - - proxy_uuid - - workspace_uuid(冗余,便于查询) - - team_uuid - - UNIQUE (proxy_uuid, team_uuid) - - FOREIGN KEY (proxy_uuid) REFERENCES proxies(uuid) ON DELETE CASCADE - - FOREIGN KEY (team_uuid) REFERENCES teams(uuid) ON DELETE CASCADE -``` - -**可见性检查逻辑**(优先级从高到低): - -1. **工作空间 Owner**:可以看到所有代理 -2. **代理所有者**:可以看到自己的代理(无论是否在可见列表中) -3. **团队成员**:只能看到 `proxy_visible_teams` 中包含其团队的代理 -4. **否则**:不可见 - -### 使用流程 - -**创建环境时的代理选择**: - -1. 用户选择当前工作空间 -2. 用户选择当前团队 -3. 系统查询用户可见的代理: - - 工作空间 Owner:所有代理 - - 代理所有者:自己的代理 - - 团队成员:`proxy_visible_teams` 中包含其团队的代理 -4. 如果没有任何可见的代理: - - 显示"没有可用的代理" - - 提供"上传新代理"选项 -5. 用户从可见代理列表中选择(可选,代理不是必须的) -6. 环境创建时,`proxy_uuid` 设置为用户选择的代理(如果选择了) - -**代理管理**: - -- 代理所有者可以添加/移除团队的可见性 -- 通过 `proxy_visible_teams` 表管理可见性 -- 团队删除时,关联的可见性记录自动删除(ON DELETE CASCADE) -- 代理删除时,关联的可见性记录自动删除(ON DELETE CASCADE) - -**设计要点**: - -- **代理是可选的**,创建环境时可以不选择代理 -- **没有继承概念**,用户必须明确选择代理 -- **代理默认不可见**,需要主动设置为可见 -- **管理员创建代理后,需要设置为可见,团队成员才能看到** -- **团队成员创建环境时,只看到可见的代理列表** - -## 总结 - -本架构设计通过"用户-工作空间-团队-分组-环境"的多层级结构,实现了灵活的资源管理、权限控制和计费体系。工作空间作为资源隔离的顶层容器,团队作为协作组织单元,分组作为可选的环境分类容器,环境作为核心操作单元直接归属团队,形成了一个清晰、可扩展的系统架构。 - -**核心设计特点**: - -- 环境必须直接归属团队,分组是可选的分类功能 -- 分组可以独立存在,不一定要有环境 -- 灵活的权限控制:有分组用分组权限,无分组用团队权限 -- 支持混合使用:部分环境使用分组,部分环境直接归属团队 - -该架构既支持个人用户的使用场景,也支持企业级的多团队协作场景,同时通过细粒度的权限控制,确保了资源的安全性和可控性。 diff --git a/src-tauri/crates/business/src/app.rs b/src-tauri/crates/business/src/app.rs deleted file mode 100644 index e784aa5..0000000 --- a/src-tauri/crates/business/src/app.rs +++ /dev/null @@ -1,113 +0,0 @@ -use std::{future::Future, net::SocketAddr}; - -use axum::{Router, middleware}; - -use crate::{ - init_encrypt_secret, middlewares, - routes::route::MetaRoute, - routes::{ - accounts, audit, billing, browser_kernel, environments, extensions, group_permissions, - local_api, messages, preferences, proxies, proxy_visibility, referral, rpa, secret, teams, - templates, time, users, workspace_quotas, workspaces, - }, - svc_ctx::SvcCtx, - utils::IConfig, -}; - -static MIGRATOR: sqlx::migrate::Migrator = sqlx::migrate!(); - -/// Start the service using its configured port on every network interface. -/// -/// This preserves the original standalone server behavior. Embedded callers -/// should prefer [`serve_on`] or [`serve_on_with_shutdown`] and bind to a -/// loopback address explicitly. -pub async fn serve(config: IConfig) -> anyhow::Result<()> { - let address = SocketAddr::from(([0, 0, 0, 0], config.app.port)); - serve_on(config, address).await -} - -/// Start the service on an explicit address. -pub async fn serve_on(config: IConfig, address: SocketAddr) -> anyhow::Result<()> { - serve_on_with_shutdown(config, address, std::future::pending()).await -} - -/// Start the service on an explicit address and stop it gracefully when the -/// supplied shutdown future completes. -pub async fn serve_on_with_shutdown( - config: IConfig, - address: SocketAddr, - shutdown: F, -) -> anyhow::Result<()> -where - F: Future + Send + 'static, -{ - let svc_ctx = SvcCtx::new(&config).await?; - tracing::info!("Running embedded database migrations"); - MIGRATOR.run(&svc_ctx.db).await?; - tracing::info!("Embedded database migrations completed"); - - init_encrypt_secret(&config).await; - - let listener = tokio::net::TcpListener::bind(address).await?; - let bound_address = listener.local_addr()?; - - let app = register_all_routes(&svc_ctx); - let app = register_middlewares(&svc_ctx, app); - let app = app.with_state(svc_ctx); - - tracing::info!("Starting server on {}", bound_address); - - axum::serve(listener, app).with_graceful_shutdown(shutdown).await?; - Ok(()) -} - -fn register_all_routes(svc_ctx: &SvcCtx) -> Router { - let mut meta_route = MetaRoute::new(svc_ctx.config.app.prefix.clone()); - - secret::register_routes(&mut meta_route); - time::register_routes(&mut meta_route); - users::register_routes(&mut meta_route); - local_api::register_routes(&mut meta_route); - workspaces::register_routes(&mut meta_route); - workspace_quotas::register_routes(&mut meta_route); - teams::register_routes(&mut meta_route); - browser_kernel::register_routes(&mut meta_route); - environments::register_routes(&mut meta_route); - proxies::register_routes(&mut meta_route); - proxy_visibility::register_routes(&mut meta_route); - group_permissions::register_routes(&mut meta_route); - accounts::register_routes(&mut meta_route); - templates::register_routes(&mut meta_route); - billing::register_routes(&mut meta_route); - audit::register_routes(&mut meta_route); - rpa::register_routes(&mut meta_route); - referral::register_routes(&mut meta_route); - extensions::register_routes(&mut meta_route); - preferences::register_routes(&mut meta_route); - messages::register_routes(&mut meta_route); - - tracing::info!("---------- {:?} ----------", meta_route.count()); - meta_route.build() -} - -fn register_middlewares(svc_ctx: &SvcCtx, app: Router) -> Router { - app.route_layer(middleware::from_fn_with_state( - svc_ctx.clone(), - middlewares::encrypt, - )) - .route_layer(middleware::from_fn_with_state( - svc_ctx.clone(), - middlewares::auth, - )) - .route_layer(middleware::from_fn_with_state( - svc_ctx.clone(), - middlewares::local_api_auth, - )) - .route_layer(middleware::from_fn_with_state( - svc_ctx.clone(), - middlewares::decrypt, - )) - .route_layer(middleware::from_fn(middlewares::real_ip)) - .route_layer(middleware::from_fn(middlewares::logger)) - .layer(middlewares::cors()) -} diff --git a/src-tauri/crates/business/src/cli.rs b/src-tauri/crates/business/src/cli.rs deleted file mode 100644 index 6df61d2..0000000 --- a/src-tauri/crates/business/src/cli.rs +++ /dev/null @@ -1,33 +0,0 @@ -use clap::{Parser, Subcommand}; - -/// Simprint Server CLI -#[derive(Debug, Parser)] -#[command(name = "simprint-server")] -#[command(author = "Simprint Team")] -#[command(version)] -#[command(about = "Simprint Server - 客户端网关服务")] -pub struct Cli { - /// 配置文件路径 - #[arg(short = 'f', long = "config", help = "配置文件路径 (TOML 格式)")] - pub config: String, - - /// 子命令 - #[command(subcommand)] - pub command: Option, -} - -/// 可用的子命令 -#[derive(Debug, Subcommand)] -pub enum Commands { - /// 启动 HTTP 服务 - Serve, -} - -impl Cli { - /// 获取命令,如果没有指定则默认为 Serve - pub fn command_or_default(self) -> (String, Commands) { - let config = self.config; - let command = self.command.unwrap_or(Commands::Serve); - (config, command) - } -} diff --git a/src-tauri/crates/business/src/handlers.rs b/src-tauri/crates/business/src/handlers.rs deleted file mode 100644 index ecc88e7..0000000 --- a/src-tauri/crates/business/src/handlers.rs +++ /dev/null @@ -1,49 +0,0 @@ -mod health; -mod secret; -mod time; -mod users; - -// 新增模块 -pub mod accounts; -pub mod local_api; -pub mod audit; -pub mod billing; -pub mod browser_kernel; -pub mod environments; -pub mod extensions; -pub mod group_permissions; -pub mod messages; -pub mod preferences; -pub mod proxies; -pub mod proxy_visibility; -pub mod referral; -pub mod rpa; -pub mod teams; -pub mod templates; -pub mod workspace_quotas; -pub mod workspaces; - -pub use health::*; -pub use secret::*; -pub use time::*; -pub use users::*; - -// 新增导出 -pub use accounts::*; -pub use local_api::*; -pub use audit::*; -pub use billing::*; -pub use browser_kernel::*; -pub use environments::*; -pub use extensions::*; -pub use group_permissions::*; -pub use messages::*; -pub use preferences::*; -pub use proxies::*; -pub use proxy_visibility::*; -pub use referral::*; -pub use rpa::*; -pub use teams::*; -pub use templates::*; -pub use workspace_quotas::*; -pub use workspaces::*; diff --git a/src-tauri/crates/business/src/handlers/accounts.rs b/src-tauri/crates/business/src/handlers/accounts.rs deleted file mode 100644 index 47f1a9e..0000000 --- a/src-tauri/crates/business/src/handlers/accounts.rs +++ /dev/null @@ -1,145 +0,0 @@ -use axum::{extract::Extension, extract::State}; - -use crate::entitys::{ - AccountListResponse, BatchImportAccountsRequest, BatchImportResponse, BatchUuidRequest, - CreateAccountRequest, CreateResponse, ListAccountsRequest, UpdateAccountRequest, UuidRequest, -}; -use crate::services; -use crate::state::RequestContext; -use crate::svc_ctx::SvcCtx; -use crate::utils::{Json, Response, Result}; - -/// 获取账号列表 -pub async fn get_accounts_handler( - State(svc_ctx): State, - Extension(ctx): Extension, - Json(payload): Json, -) -> Result { - let team_uuid = services::teams::get_current_team_service(&svc_ctx, ctx.user_uuid_unwrap()) - .await - .map_err(|e| Response::fail(Some(&e)))?; - - let (accounts, total) = services::accounts::get_accounts_service( - &svc_ctx, - ctx.user_uuid_unwrap(), - team_uuid, - &payload, - ) - .await - .map_err(|e| Response::fail(Some(&e)))?; - - Ok(Response::success( - Some("获取成功"), - Some(AccountListResponse { - items: accounts, - total, - page: payload.pagination.page, - page_size: payload.pagination.page_size, - }), - )) -} - -/// 获取账号详情 -pub async fn get_account_handler( - State(svc_ctx): State, - Json(payload): Json, -) -> Result { - let account = services::accounts::get_account_service(&svc_ctx, payload.uuid) - .await - .map_err(|e| Response::fail(Some(&e)))?; - - Ok(Response::success(Some("获取成功"), Some(account))) -} - -/// 创建账号 -pub async fn create_account_handler( - State(svc_ctx): State, - Extension(ctx): Extension, - Json(payload): Json, -) -> Result { - let team_uuid = services::teams::get_current_team_service(&svc_ctx, ctx.user_uuid_unwrap()) - .await - .map_err(|e| Response::fail(Some(&e)))?; - - let account_uuid = services::accounts::create_account_service( - &svc_ctx, - ctx.user_uuid_unwrap(), - team_uuid, - &payload, - ) - .await - .map_err(|e| Response::fail(Some(&e)))?; - - Ok(Response::success( - Some("创建成功"), - Some(CreateResponse { uuid: account_uuid }), - )) -} - -/// 更新账号 -pub async fn update_account_handler( - State(svc_ctx): State, - Extension(ctx): Extension, - Json(payload): Json, -) -> Result<()> { - services::accounts::update_account_service(&svc_ctx, &payload) - .await - .map_err(|e| Response::fail(Some(&e)))?; - - Ok(Response::success(Some("更新成功"), None)) -} - -/// 删除账号 -pub async fn delete_account_handler( - State(svc_ctx): State, - Extension(ctx): Extension, - Json(payload): Json, -) -> Result<()> { - services::accounts::delete_account_service(&svc_ctx, payload.uuid) - .await - .map_err(|e| Response::fail(Some(&e)))?; - - Ok(Response::success(Some("删除成功"), None)) -} - -/// 批量删除账号 -pub async fn batch_delete_accounts_handler( - State(svc_ctx): State, - Extension(ctx): Extension, - Json(payload): Json, -) -> Result { - let count = services::accounts::batch_delete_accounts_service(&svc_ctx, &payload.uuids) - .await - .map_err(|e| Response::fail(Some(&e)))?; - - Ok(Response::success(Some("删除成功"), Some(count))) -} - -/// 批量导入账号 -pub async fn batch_import_accounts_handler( - State(svc_ctx): State, - Extension(ctx): Extension, - Json(payload): Json, -) -> Result { - let team_uuid = services::teams::get_current_team_service(&svc_ctx, ctx.user_uuid_unwrap()) - .await - .map_err(|e| Response::fail(Some(&e)))?; - - let result = services::accounts::batch_import_accounts_service( - &svc_ctx, - ctx.user_uuid_unwrap(), - team_uuid, - &payload, - ) - .await - .map_err(|e| Response::fail(Some(&e)))?; - - Ok(Response::success( - Some("导入完成"), - Some(BatchImportResponse { - success_count: result.success_count, - failed_count: result.failed_count, - errors: result.errors, - }), - )) -} diff --git a/src-tauri/crates/business/src/handlers/audit.rs b/src-tauri/crates/business/src/handlers/audit.rs deleted file mode 100644 index 676d4dc..0000000 --- a/src-tauri/crates/business/src/handlers/audit.rs +++ /dev/null @@ -1,98 +0,0 @@ -use axum::extract::{Extension, State}; - -use crate::dto::AuditLogDto; -use crate::entitys::{ - AuditLogsListResponse, AuditStatsRequest, AuditStatsResponse, ExportAuditLogsRequest, - ExportResponse, ListAuditLogsRequest, UuidRequest, -}; -use crate::services; -use crate::state::RequestContext; -use crate::svc_ctx::SvcCtx; -use crate::utils::{Json, Response, Result}; - -/// 获取审计日志列表 -pub async fn get_audit_logs_handler( - State(svc_ctx): State, - Extension(ctx): Extension, - Json(payload): Json, -) -> Result { - let current_user_uuid = ctx.user_uuid_unwrap(); - let team_uuid = services::teams::get_current_team_service(&svc_ctx, current_user_uuid) - .await - .map_err(|e| Response::fail(Some(&e)))?; - - let (items, total) = - services::audit::get_audit_logs_service(&svc_ctx, current_user_uuid, team_uuid, &payload) - .await - .map_err(|e| Response::fail(Some(&e)))?; - - Ok(Response::success( - Some("获取成功"), - Some(AuditLogsListResponse { - items, - total, - page: payload.pagination.page, - page_size: payload.pagination.page_size, - }), - )) -} - -/// 获取审计日志详情 -pub async fn get_audit_log_detail_handler( - State(svc_ctx): State, - Json(payload): Json, -) -> Result { - let log = services::audit::get_audit_log_service(&svc_ctx, payload.uuid) - .await - .map_err(|e| Response::fail(Some(&e)))?; - - Ok(Response::success(Some("获取成功"), Some(log))) -} - -/// 导出审计日志 -pub async fn export_audit_logs_handler( - State(svc_ctx): State, - Extension(ctx): Extension, - Json(payload): Json, -) -> Result { - let current_user_uuid = ctx.user_uuid_unwrap(); - let team_uuid = services::teams::get_current_team_service(&svc_ctx, current_user_uuid) - .await - .map_err(|e| Response::fail(Some(&e)))?; - - let (content, filename, mime_type) = services::audit::export_audit_logs_service( - &svc_ctx, - current_user_uuid, - team_uuid, - &payload, - ) - .await - .map_err(|e| Response::fail(Some(&e)))?; - - Ok(Response::success( - Some("导出成功"), - Some(ExportResponse { - content, - filename, - mime_type, - }), - )) -} - -/// 获取审计统计 -pub async fn get_audit_stats_handler( - State(svc_ctx): State, - Extension(ctx): Extension, - Json(_payload): Json, -) -> Result { - let current_user_uuid = ctx.user_uuid_unwrap(); - let team_uuid = services::teams::get_current_team_service(&svc_ctx, current_user_uuid) - .await - .map_err(|e| Response::fail(Some(&e)))?; - - let stats = services::audit::get_audit_stats_service(&svc_ctx, current_user_uuid, team_uuid) - .await - .map_err(|e| Response::fail(Some(&e)))?; - - Ok(Response::success(Some("获取成功"), Some(stats))) -} diff --git a/src-tauri/crates/business/src/handlers/billing.rs b/src-tauri/crates/business/src/handlers/billing.rs deleted file mode 100644 index 3ea78bd..0000000 --- a/src-tauri/crates/business/src/handlers/billing.rs +++ /dev/null @@ -1,409 +0,0 @@ -use axum::extract::{Extension, State}; - -use crate::dto::{ - AutoRenewalServiceDto, PaymentOrderDto, SubscriptionDto, UserWalletDto, -}; -use crate::entitys::{ - AccountInfoResponse, CancelSubscriptionRequest, CreateOrderResponse, CreateRechargeOrderRequest, - GetPlanPriceRequest, GetPlansRequest, GetUserCouponsRequest, InvoicesListResponse, ListInvoicesRequest, - ListPaymentOrdersRequest, ListTransactionsRequest, PaymentOrdersListResponse, - PlanDetailResponse, PlanPriceResponse, PlansResponse, ResumeSubscriptionRequest, - SubscribePlanRequest, SubscribeResponse, ToggleAutoRenewRequest, TransactionsListResponse, - UserCouponsListResponse, UuidRequest, VerifyCouponRequest, VerifyCouponResponse, -}; -use crate::services; -use crate::state::RequestContext; -use crate::svc_ctx::SvcCtx; -use crate::utils::{Json, Response, Result}; - -// ============ Plans ============ - -/// 获取套餐列表(包含特性) -pub async fn get_plans_handler( - State(svc_ctx): State, - Extension(ctx): Extension, - Json(payload): Json, -) -> Result { - let user_uuid = ctx.user_uuid_unwrap(); - let plans_with_features = services::plans::get_plans_service( - &svc_ctx, - user_uuid, - payload.coupon_code.as_deref(), - &payload.billing_period, - ) - .await - .map_err(|e| Response::fail(Some(&e)))?; - - let plans = plans_with_features - .into_iter() - .map(|pwf| crate::entitys::PlanWithFeatures { - plan: pwf.plan, - features: pwf.features, - calculated_price: pwf.calculated_price, - }) - .collect(); - - Ok(Response::success( - Some("获取成功"), - Some(PlansResponse { plans }), - )) -} - -/// 获取套餐详情 -pub async fn get_plan_detail_handler( - State(svc_ctx): State, - Json(payload): Json, -) -> Result { - let (plan, features) = services::plans::get_plan_detail_service(&svc_ctx, payload.uuid) - .await - .map_err(|e| Response::fail(Some(&e)))?; - - Ok(Response::success( - Some("获取成功"), - Some(PlanDetailResponse { plan, features }), - )) -} - -// ============ Subscriptions ============ - -/// 获取当前订阅 -pub async fn get_current_subscription_handler( - State(svc_ctx): State, - Extension(ctx): Extension, -) -> Result> { - let workspace_uuid = ctx - .current_workspace_uuid - .ok_or_else(|| Response::fail(Some("请先选择工作空间")))?; - - let subscription = - services::subscriptions::get_workspace_subscription_service(&svc_ctx, workspace_uuid) - .await - .map_err(|e| Response::fail(Some(&e)))?; - - Ok(Response::success(Some("获取成功"), Some(subscription))) -} - -/// 订阅套餐 -pub async fn subscribe_plan_handler( - State(svc_ctx): State, - Extension(ctx): Extension, - Json(payload): Json, -) -> Result { - let workspace_uuid = ctx - .current_workspace_uuid - .ok_or_else(|| Response::fail(Some("请先选择工作空间")))?; - - let subscription_uuid = services::subscriptions::subscribe_plan_service( - &svc_ctx, - workspace_uuid, - ctx.user_uuid_unwrap(), - &payload, - ) - .await - .map_err(|e| Response::fail(Some(&e)))?; - - Ok(Response::success( - Some("订阅成功"), - Some(SubscribeResponse { subscription_uuid }), - )) -} - -/// 取消订阅 -pub async fn cancel_subscription_handler( - State(svc_ctx): State, - Extension(ctx): Extension, - Json(payload): Json, -) -> Result<()> { - services::subscriptions::cancel_subscription_service( - &svc_ctx, - ctx.user_uuid_unwrap(), - payload.subscription_uuid, - ) - .await - .map_err(|e| Response::fail(Some(&e)))?; - - Ok(Response::success(Some("取消成功"), None)) -} - -/// 恢复订阅 -pub async fn resume_subscription_handler( - State(svc_ctx): State, - Extension(ctx): Extension, - Json(payload): Json, -) -> Result<()> { - services::subscriptions::resume_subscription_service( - &svc_ctx, - ctx.user_uuid_unwrap(), - payload.subscription_uuid, - ) - .await - .map_err(|e| Response::fail(Some(&e)))?; - - Ok(Response::success(Some("恢复成功"), None)) -} - -/// 切换自动续费 -pub async fn toggle_auto_renew_handler( - State(svc_ctx): State, - Extension(ctx): Extension, - Json(payload): Json, -) -> Result<()> { - services::subscriptions::toggle_auto_renew_service( - &svc_ctx, - ctx.user_uuid_unwrap(), - payload.subscription_uuid, - payload.auto_renew, - ) - .await - .map_err(|e| Response::fail(Some(&e)))?; - - Ok(Response::success(Some("设置成功"), None)) -} - -// ============ Wallet ============ - -/// 获取钱包信息 -pub async fn get_wallet_handler( - State(svc_ctx): State, - Extension(ctx): Extension, -) -> Result { - let wallet = services::wallet::get_wallet_service(&svc_ctx, ctx.user_uuid_unwrap()) - .await - .map_err(|e| Response::fail(Some(&e)))?; - - Ok(Response::success(Some("获取成功"), Some(wallet))) -} - -/// 获取交易记录 -pub async fn get_transactions_handler( - State(svc_ctx): State, - Extension(ctx): Extension, - Json(payload): Json, -) -> Result { - let (items, total) = - services::wallet::get_transactions_service(&svc_ctx, ctx.user_uuid_unwrap(), &payload) - .await - .map_err(|e| Response::fail(Some(&e)))?; - - Ok(Response::success( - Some("获取成功"), - Some(TransactionsListResponse { - items, - total, - page: payload.pagination.page, - page_size: payload.pagination.page_size, - }), - )) -} - -// ============ Invoices ============ - -/// 获取发票列表 -pub async fn get_invoices_handler( - State(svc_ctx): State, - Extension(ctx): Extension, - Json(payload): Json, -) -> Result { - let (items, total) = - services::billing::get_invoices_service(&svc_ctx, ctx.user_uuid_unwrap(), &payload) - .await - .map_err(|e| Response::fail(Some(&e)))?; - - Ok(Response::success( - Some("获取成功"), - Some(InvoicesListResponse { - items, - total, - page: payload.pagination.page, - page_size: payload.pagination.page_size, - }), - )) -} - -// ============ Quotas ============ - -/// 获取工作空间配额 -pub async fn get_quota_handler( - State(svc_ctx): State, - Extension(ctx): Extension, -) -> Result { - let workspace_uuid = ctx - .current_workspace_uuid - .ok_or_else(|| Response::fail(Some("请先选择工作空间")))?; - - let quota = services::workspace_quotas::get_workspace_quota_service( - &svc_ctx, - &crate::entitys::GetWorkspaceQuotaRequest { - workspace_uuid: Some(workspace_uuid), - }, - ) - .await - .map_err(|e| Response::fail(Some(&e)))?; - - Ok(Response::success(Some("获取成功"), Some(quota))) -} - -// ============ Coupons ============ - -/// 验证优惠券 -pub async fn verify_coupon_handler( - State(svc_ctx): State, - Extension(ctx): Extension, - Json(payload): Json, -) -> Result { - let result = - services::coupons::validate_coupon_service(&svc_ctx, ctx.user_uuid_unwrap(), &payload) - .await - .map_err(|e| Response::fail(Some(&e)))?; - - Ok(Response::success( - Some("优惠券有效"), - Some(VerifyCouponResponse { - coupon_uuid: result.coupon.uuid, - discount_type: result.coupon.discount_type, - discount_value: result.coupon.discount_value, - discount_amount: result.discount_amount, - }), - )) -} - -/// 获取用户优惠券列表 -pub async fn get_user_coupons_handler( - State(svc_ctx): State, - Extension(ctx): Extension, - Json(payload): Json, -) -> Result { - let result = services::coupons::get_user_coupons_service( - &svc_ctx, - ctx.user_uuid_unwrap(), - &payload, - ) - .await - .map_err(|e| Response::fail(Some(&e)))?; - - Ok(Response::success(Some("获取成功"), Some(result))) -} - -/// 获取用户可用优惠券 -pub async fn get_available_coupons_handler( - State(svc_ctx): State, - Extension(ctx): Extension, -) -> Result> { - let result = services::coupons::get_available_coupons_service(&svc_ctx, ctx.user_uuid_unwrap()) - .await - .map_err(|e| Response::fail(Some(&e)))?; - - Ok(Response::success(Some("获取成功"), Some(result))) -} - -/// 获取套餐价格(包含折扣计算) -pub async fn get_plan_price_handler( - State(svc_ctx): State, - Extension(ctx): Extension, - Json(payload): Json, -) -> Result { - let result = services::plans::calculate_plan_price_service( - &svc_ctx, - ctx.user_uuid_unwrap(), - &payload, - ) - .await - .map_err(|e| Response::fail(Some(&e)))?; - - Ok(Response::success(Some("计算成功"), Some(result))) -} - -// ============ Payment Orders ============ - -/// 创建充值订单 -pub async fn create_recharge_order_handler( - State(svc_ctx): State, - Extension(ctx): Extension, - Json(payload): Json, -) -> Result { - let (order_uuid, order_no) = - services::orders::create_recharge_order_service(&svc_ctx, ctx.user_uuid_unwrap(), &payload) - .await - .map_err(|e| Response::fail(Some(&e)))?; - - Ok(Response::success( - Some("订单创建成功"), - Some(CreateOrderResponse { - order_uuid, - order_no, - }), - )) -} - -/// 获取支付订单列表 -pub async fn get_payment_orders_handler( - State(svc_ctx): State, - Extension(ctx): Extension, - Json(payload): Json, -) -> Result { - let (items, total) = - services::orders::get_payment_orders_service(&svc_ctx, ctx.user_uuid_unwrap(), &payload) - .await - .map_err(|e| Response::fail(Some(&e)))?; - - Ok(Response::success( - Some("获取成功"), - Some(PaymentOrdersListResponse { - items, - total, - page: payload.pagination.page, - page_size: payload.pagination.page_size, - }), - )) -} - -/// 查询订单状态 -pub async fn get_order_status_handler( - State(svc_ctx): State, - Extension(ctx): Extension, - Json(payload): Json, -) -> Result { - let order = - services::orders::get_order_status_service(&svc_ctx, ctx.user_uuid_unwrap(), payload.uuid) - .await - .map_err(|e| Response::fail(Some(&e)))?; - - Ok(Response::success(Some("获取成功"), Some(order))) -} - -// ============ Auto Renewal Services ============ - -/// 获取自动续费服务列表 -pub async fn get_auto_renewal_services_handler( - State(svc_ctx): State, - Extension(ctx): Extension, -) -> Result> { - let services = - services::billing::get_auto_renewal_services_service(&svc_ctx, ctx.user_uuid_unwrap()) - .await - .map_err(|e| Response::fail(Some(&e)))?; - - Ok(Response::success(Some("获取成功"), Some(services))) -} - -// ============ Account Info ============ - -/// 获取账户信息 -pub async fn get_account_info_handler( - State(svc_ctx): State, - Extension(ctx): Extension, -) -> Result { - let workspace_uuid = ctx - .current_workspace_uuid - .ok_or_else(|| Response::fail(Some("请先选择工作空间")))?; - - let account_info = services::billing::get_account_info_service( - &svc_ctx, - workspace_uuid, - ctx.user_uuid_unwrap(), - ) - .await - .map_err(|e| Response::fail(Some(&e)))?; - - Ok(Response::success(Some("获取成功"), Some(account_info))) -} diff --git a/src-tauri/crates/business/src/handlers/browser_kernel.rs b/src-tauri/crates/business/src/handlers/browser_kernel.rs deleted file mode 100644 index da48f9b..0000000 --- a/src-tauri/crates/business/src/handlers/browser_kernel.rs +++ /dev/null @@ -1,23 +0,0 @@ -use axum::extract::State; -use std::collections::HashMap; - -use crate::entitys::browser_kernel::ListBrowserKernelsRequest; -use crate::services::browser_kernel::get_browser_kernel_list_service; -use crate::svc_ctx::SvcCtx; -use crate::utils::{Json, Response, Result}; - -/// 查询浏览器内核最新版本列表 -pub async fn list_browser_kernels_handler( - State(svc_ctx): State, - Json(payload): Json, -) -> Result>> { - let kernels = get_browser_kernel_list_service( - &svc_ctx, - payload.platform, - payload.type_code, - ) - .await - .map_err(|e| Response::fail(Some(&e.to_string())))?; - - Ok(Response::success(Some("获取浏览器内核列表成功"), Some(kernels))) -} diff --git a/src-tauri/crates/business/src/handlers/environments.rs b/src-tauri/crates/business/src/handlers/environments.rs deleted file mode 100644 index 270ed50..0000000 --- a/src-tauri/crates/business/src/handlers/environments.rs +++ /dev/null @@ -1,815 +0,0 @@ -use axum::{extract::Extension, extract::State}; - -use crate::audit_log; -use crate::entitys::{ - AddEnvironmentCookieRequest, AddEnvironmentUrlRequest, AssignTagsRequest, - BatchAssignTagRequest, BatchCreateEnvironmentRequest, BatchMoveToGroupRequest, - BatchRemoveTagsRequest, BatchUuidRequest, ClearEnvironmentCookiesRequest, - ClearEnvironmentUrlsRequest, CreateEnvironmentRequest, CreateGroupRequest, CreateResponse, - DeleteEnvironmentCookieRequest, DeleteEnvironmentUrlRequest, EnvironmentDetailResponse, - EnvironmentListResponse, IdResponse, ListEnvironmentsRequest, MoveToGroupRequest, - RemoveTagRequest, SetEnvironmentAccountsRequest, SetEnvironmentProxyRequest, - UpdateEnvironmentRequest, UpdateGroupRequest, UpdateTagRequest, UuidRequest, -}; -use crate::services; -use crate::state::RequestContext; -use crate::svc_ctx::SvcCtx; -use crate::utils::{Json, Response, Result}; - -// ============ Groups ============ - -/// 创建分组 -pub async fn create_group_handler( - State(svc_ctx): State, - Extension(ctx): Extension, - Json(payload): Json, -) -> Result { - let workspace_uuid = ctx - .current_workspace_uuid - .ok_or_else(|| Response::fail(Some("请先选择工作空间")))?; - let team_uuid = ctx.current_team_uuid.ok_or_else(|| Response::fail(Some("请先选择团队")))?; - - let group_uuid = services::groups::create_group_service( - &svc_ctx, - ctx.user_uuid_unwrap(), - workspace_uuid, - team_uuid, - &payload, - ) - .await - .map_err(|e| Response::fail(Some(&e)))?; - - audit_log!( - &svc_ctx, - &ctx, - "create", - "group", - group_uuid, - &payload.name, - "创建分组" - ) - .await; - - Ok(Response::success( - Some("创建成功"), - Some(CreateResponse { uuid: group_uuid }), - )) -} - -/// 获取分组列表 -pub async fn get_groups_handler( - State(svc_ctx): State, - Extension(ctx): Extension, -) -> Result> { - // 从当前团队获取工作空间和团队 UUID - let team_uuid = services::teams::get_current_team_service(&svc_ctx, ctx.user_uuid_unwrap()) - .await - .map_err(|e| Response::fail(Some(&e)))? - .ok_or_else(|| Response::fail(Some("请先选择团队")))?; - - let team = services::teams::get_team_service(&svc_ctx, team_uuid) - .await - .map_err(|e| Response::fail(Some(&e)))?; - - let groups = - services::groups::get_groups_service(&svc_ctx, team.workspace_uuid, team_uuid, 1, 100) - .await - .map_err(|e| Response::fail(Some(&e)))?; - - Ok(Response::success(Some("获取成功"), Some(groups))) -} - -/// 更新分组 -pub async fn update_group_handler( - State(svc_ctx): State, - Extension(ctx): Extension, - Json(payload): Json, -) -> Result<()> { - let workspace_uuid = ctx - .current_workspace_uuid - .ok_or_else(|| Response::fail(Some("请先选择工作空间")))?; - let team_uuid = ctx.current_team_uuid.ok_or_else(|| Response::fail(Some("请先选择团队")))?; - - services::groups::update_group_service( - &svc_ctx, - workspace_uuid, - team_uuid, - ctx.user_uuid_unwrap(), - &payload, - ) - .await - .map_err(|e| Response::fail(Some(&e)))?; - - Ok(Response::success(Some("更新成功"), None)) -} - -/// 删除分组 -pub async fn delete_group_handler( - State(svc_ctx): State, - Extension(ctx): Extension, - Json(payload): Json, -) -> Result<()> { - let workspace_uuid = ctx - .current_workspace_uuid - .ok_or_else(|| Response::fail(Some("请先选择工作空间")))?; - let team_uuid = ctx.current_team_uuid.ok_or_else(|| Response::fail(Some("请先选择团队")))?; - - services::groups::delete_group_service( - &svc_ctx, - workspace_uuid, - team_uuid, - ctx.user_uuid_unwrap(), - payload.uuid, - ) - .await - .map_err(|e| Response::fail(Some(&e)))?; - - audit_log!(&svc_ctx, &ctx, "delete", "group", payload.uuid, "删除分组").await; - - Ok(Response::success(Some("删除成功"), None)) -} - -// ============ Tags ============ - -/// 创建标签 -pub async fn create_tag_handler( - State(svc_ctx): State, - Extension(ctx): Extension, - Json(payload): Json, -) -> Result { - let team_uuid = services::teams::get_current_team_service(&svc_ctx, ctx.user_uuid_unwrap()) - .await - .map_err(|e| Response::fail(Some(&e)))?; - - let tag_uuid = - services::tags::create_tag_service(&svc_ctx, ctx.user_uuid_unwrap(), team_uuid, &payload) - .await - .map_err(|e| Response::fail(Some(&e)))?; - - audit_log!( - &svc_ctx, - &ctx, - "create", - "tag", - tag_uuid, - &payload.name, - "创建标签" - ) - .await; - - Ok(Response::success( - Some("创建成功"), - Some(CreateResponse { uuid: tag_uuid }), - )) -} - -/// 获取标签列表 -pub async fn get_tags_handler( - State(svc_ctx): State, - Extension(ctx): Extension, -) -> Result> { - let team_uuid = services::teams::get_current_team_service(&svc_ctx, ctx.user_uuid_unwrap()) - .await - .map_err(|e| Response::fail(Some(&e)))?; - - let tags = services::tags::get_tags_service(&svc_ctx, ctx.user_uuid_unwrap(), team_uuid) - .await - .map_err(|e| Response::fail(Some(&e)))?; - - Ok(Response::success(Some("获取成功"), Some(tags))) -} - -/// 更新标签 -pub async fn update_tag_handler( - State(svc_ctx): State, - Extension(ctx): Extension, - Json(payload): Json, -) -> Result<()> { - services::tags::update_tag_service(&svc_ctx, &payload) - .await - .map_err(|e| Response::fail(Some(&e)))?; - - Ok(Response::success(Some("更新成功"), None)) -} - -/// 删除标签 -pub async fn delete_tag_handler( - State(svc_ctx): State, - Extension(ctx): Extension, - Json(payload): Json, -) -> Result<()> { - services::tags::delete_tag_service(&svc_ctx, payload.uuid) - .await - .map_err(|e| Response::fail(Some(&e)))?; - - audit_log!(&svc_ctx, &ctx, "delete", "tag", payload.uuid, "删除标签").await; - - Ok(Response::success(Some("删除成功"), None)) -} - -// ============ Environments ============ - -/// 获取环境列表 -pub async fn get_environments_handler( - State(svc_ctx): State, - Extension(ctx): Extension, - Json(payload): Json, -) -> Result { - let workspace_uuid = ctx - .current_workspace_uuid - .ok_or_else(|| Response::fail(Some("请先选择工作空间")))?; - let team_uuid = ctx.current_team_uuid.ok_or_else(|| Response::fail(Some("请先选择团队")))?; - - let (environments, total) = services::environments::get_environments_service( - &svc_ctx, - ctx.user_uuid_unwrap(), - workspace_uuid, - team_uuid, - &payload, - ) - .await - .map_err(|e| Response::fail(Some(&e)))?; - - Ok(Response::success( - Some("获取成功"), - Some(EnvironmentListResponse { - items: environments, - total, - page: payload.pagination.page, - page_size: payload.pagination.page_size, - }), - )) -} - -/// 获取环境详情 -pub async fn get_environment_handler( - State(svc_ctx): State, - Extension(ctx): Extension, - Json(payload): Json, -) -> Result { - let workspace_uuid = ctx - .current_workspace_uuid - .ok_or_else(|| Response::fail(Some("请先选择工作空间")))?; - let team_uuid = ctx.current_team_uuid.ok_or_else(|| Response::fail(Some("请先选择团队")))?; - - let detail = services::environments::get_environment_detail_service( - &svc_ctx, - workspace_uuid, - team_uuid, - ctx.user_uuid_unwrap(), - payload.uuid, - ) - .await - .map_err(|e| Response::fail(Some(&e)))?; - - Ok(Response::success( - Some("获取成功"), - Some(detail), - )) -} - -/// 批量获取环境详情 -pub async fn batch_get_environments_handler( - State(svc_ctx): State, - Extension(ctx): Extension, - Json(payload): Json, -) -> Result> { - let workspace_uuid = ctx - .current_workspace_uuid - .ok_or_else(|| Response::fail(Some("请先选择工作空间")))?; - let team_uuid = ctx.current_team_uuid.ok_or_else(|| Response::fail(Some("请先选择团队")))?; - - let mut results = std::collections::HashMap::new(); - - for uuid in payload.uuids { - // 对每个 UUID 尝试获取环境详情,失败则跳过 - let detail = async { - services::environments::get_environment_detail_service( - &svc_ctx, - workspace_uuid, - team_uuid, - ctx.user_uuid_unwrap(), - uuid, - ) - .await - .ok() - } - .await; - - // 只有成功获取的环境才加入结果 - if let Some(detail) = detail { - results.insert(uuid.to_string(), detail); - } - } - - Ok(Response::success(Some("获取成功"), Some(results))) -} - -/// 创建环境 -pub async fn create_environment_handler( - State(svc_ctx): State, - Extension(ctx): Extension, - Json(payload): Json, -) -> Result { - let workspace_uuid = ctx - .current_workspace_uuid - .ok_or_else(|| Response::fail(Some("请先选择工作空间")))?; - let team_uuid = ctx.current_team_uuid.ok_or_else(|| Response::fail(Some("请先选择团队")))?; - - let env_uuid = services::environments::create_environment_service( - &svc_ctx, - ctx.user_uuid_unwrap(), - workspace_uuid, - team_uuid, - &payload, - ) - .await - .map_err(|e| Response::fail(Some(&e)))?; - - audit_log!( - &svc_ctx, - &ctx, - "create", - "environment", - env_uuid, - &payload.name, - "创建环境" - ) - .await; - - Ok(Response::success( - Some("创建成功"), - Some(CreateResponse { uuid: env_uuid }), - )) -} - -/// 批量创建环境 -pub async fn batch_create_environments_handler( - State(svc_ctx): State, - Extension(ctx): Extension, - Json(payload): Json, -) -> Result> { - let workspace_uuid = ctx - .current_workspace_uuid - .ok_or_else(|| Response::fail(Some("请先选择工作空间")))?; - let team_uuid = ctx.current_team_uuid.ok_or_else(|| Response::fail(Some("请先选择团队")))?; - - let env_uuids = services::environments::batch_create_environments_service( - &svc_ctx, - ctx.user_uuid_unwrap(), - workspace_uuid, - team_uuid, - &payload, - ) - .await - .map_err(|e| Response::fail(Some(&e)))?; - - let count = env_uuids.len(); - let responses: Vec = - env_uuids.into_iter().map(|uuid| CreateResponse { uuid }).collect(); - - audit_log!( - &svc_ctx, - &ctx, - "batch_create", - "environment", - &format!("批量创建 {} 个环境", count) - ) - .await; - - Ok(Response::success(Some("批量创建成功"), Some(responses))) -} - -/// 更新环境 -pub async fn update_environment_handler( - State(svc_ctx): State, - Extension(ctx): Extension, - Json(payload): Json, -) -> Result<()> { - let workspace_uuid = ctx - .current_workspace_uuid - .ok_or_else(|| Response::fail(Some("请先选择工作空间")))?; - let team_uuid = ctx.current_team_uuid.ok_or_else(|| Response::fail(Some("请先选择团队")))?; - - services::environments::update_environment_service( - &svc_ctx, - workspace_uuid, - team_uuid, - ctx.user_uuid_unwrap(), - &payload, - ) - .await - .map_err(|e| Response::fail(Some(&e)))?; - - Ok(Response::success(Some("更新成功"), None)) -} - -/// 删除环境 -pub async fn delete_environment_handler( - State(svc_ctx): State, - Extension(ctx): Extension, - Json(payload): Json, -) -> Result<()> { - let workspace_uuid = ctx - .current_workspace_uuid - .ok_or_else(|| Response::fail(Some("请先选择工作空间")))?; - let team_uuid = ctx.current_team_uuid.ok_or_else(|| Response::fail(Some("请先选择团队")))?; - - services::environments::delete_environment_service( - &svc_ctx, - workspace_uuid, - team_uuid, - ctx.user_uuid_unwrap(), - payload.uuid, - ) - .await - .map_err(|e| Response::fail(Some(&e)))?; - - audit_log!( - &svc_ctx, - &ctx, - "delete", - "environment", - payload.uuid, - "删除环境" - ) - .await; - - Ok(Response::success(Some("删除成功"), None)) -} - -/// 批量删除环境 -pub async fn batch_delete_environments_handler( - State(svc_ctx): State, - Extension(ctx): Extension, - Json(payload): Json, -) -> Result { - let count = services::environments::batch_delete_environments_service(&svc_ctx, &payload.uuids) - .await - .map_err(|e| Response::fail(Some(&e)))?; - - audit_log!( - &svc_ctx, - &ctx, - "batch_delete", - "environment", - &format!("批量删除 {} 个环境", count) - ) - .await; - - Ok(Response::success(Some("删除成功"), Some(count))) -} - -// ============ Recycle Bin ============ - -/// 查询回收站环境列表 -pub async fn get_recycle_bin_environments_handler( - State(svc_ctx): State, - Extension(ctx): Extension, - Json(payload): Json, -) -> Result { - let workspace_uuid = ctx - .current_workspace_uuid - .ok_or_else(|| Response::fail(Some("请先选择工作空间")))?; - let team_uuid = ctx.current_team_uuid.ok_or_else(|| Response::fail(Some("请先选择团队")))?; - - let (environments, total) = services::environments::get_recycle_bin_environments_service( - &svc_ctx, - workspace_uuid, - team_uuid, - ctx.user_uuid_unwrap(), - &payload, - ) - .await - .map_err(|e| Response::fail(Some(&e)))?; - - Ok(Response::success( - None, - Some(EnvironmentListResponse { - items: environments, - total, - page: payload.pagination.page, - page_size: payload.pagination.page_size, - }), - )) -} - -/// 恢复环境 -pub async fn restore_environment_handler( - State(svc_ctx): State, - Extension(ctx): Extension, - Json(payload): Json, -) -> Result<()> { - services::environments::restore_environment_service(&svc_ctx, payload.uuid) - .await - .map_err(|e| Response::fail(Some(&e)))?; - - audit_log!( - &svc_ctx, - &ctx, - "restore", - "environment", - payload.uuid, - "恢复环境" - ) - .await; - - Ok(Response::success(Some("恢复成功"), None)) -} - -/// 批量恢复环境 -pub async fn batch_restore_environments_handler( - State(svc_ctx): State, - Extension(ctx): Extension, - Json(payload): Json, -) -> Result { - let count = services::environments::batch_restore_environments_service(&svc_ctx, &payload.uuids) - .await - .map_err(|e| Response::fail(Some(&e)))?; - - audit_log!( - &svc_ctx, - &ctx, - "batch_restore", - "environment", - &format!("批量恢复 {} 个环境", count) - ) - .await; - - Ok(Response::success(Some("恢复成功"), Some(count))) -} - -/// 永久删除环境 -pub async fn permanent_delete_environment_handler( - State(svc_ctx): State, - Extension(ctx): Extension, - Json(payload): Json, -) -> Result<()> { - let workspace_uuid = ctx - .current_workspace_uuid - .ok_or_else(|| Response::fail(Some("请先选择工作空间")))?; - - services::environments::permanent_delete_environment_service(&svc_ctx, payload.uuid, workspace_uuid) - .await - .map_err(|e| Response::fail(Some(&e)))?; - - audit_log!( - &svc_ctx, - &ctx, - "permanent_delete", - "environment", - payload.uuid, - "永久删除环境" - ) - .await; - - Ok(Response::success(Some("永久删除成功"), None)) -} - -/// 批量永久删除环境 -pub async fn batch_permanent_delete_environments_handler( - State(svc_ctx): State, - Extension(ctx): Extension, - Json(payload): Json, -) -> Result { - let workspace_uuid = ctx - .current_workspace_uuid - .ok_or_else(|| Response::fail(Some("请先选择工作空间")))?; - - let count = services::environments::batch_permanent_delete_environments_service( - &svc_ctx, - &payload.uuids, - workspace_uuid, - ) - .await - .map_err(|e| Response::fail(Some(&e)))?; - - audit_log!( - &svc_ctx, - &ctx, - "batch_permanent_delete", - "environment", - &format!("批量永久删除 {} 个环境", count) - ) - .await; - - Ok(Response::success(Some("永久删除成功"), Some(count))) -} - -/// 设置环境代理 -pub async fn set_proxy_handler( - State(svc_ctx): State, - Extension(ctx): Extension, - Json(payload): Json, -) -> Result<()> { - services::environments::set_environment_proxy_service(&svc_ctx, &payload) - .await - .map_err(|e| Response::fail(Some(&e)))?; - - Ok(Response::success(Some("设置成功"), None)) -} - -/// 分配标签 -pub async fn assign_tags_handler( - State(svc_ctx): State, - Extension(ctx): Extension, - Json(payload): Json, -) -> Result<()> { - services::environments::assign_tags_service(&svc_ctx, &payload) - .await - .map_err(|e| Response::fail(Some(&e)))?; - - Ok(Response::success(Some("分配成功"), None)) -} - -/// 移除标签 -pub async fn remove_tag_handler( - State(svc_ctx): State, - Extension(ctx): Extension, - Json(payload): Json, -) -> Result<()> { - services::environments::remove_tag_service(&svc_ctx, payload.uuid, payload.tag_uuid) - .await - .map_err(|e| Response::fail(Some(&e)))?; - - Ok(Response::success(Some("移除成功"), None)) -} - -/// 移动到分组 -pub async fn move_to_group_handler( - State(svc_ctx): State, - Extension(ctx): Extension, - Json(payload): Json, -) -> Result<()> { - services::environments::move_to_group_service(&svc_ctx, &payload) - .await - .map_err(|e| Response::fail(Some(&e)))?; - - Ok(Response::success(Some("移动成功"), None)) -} - -/// 批量移动到分组 -pub async fn batch_move_to_group_handler( - State(svc_ctx): State, - Extension(ctx): Extension, - Json(payload): Json, -) -> Result<()> { - services::environments::batch_move_to_group_service(&svc_ctx, &payload) - .await - .map_err(|e| Response::fail(Some(&e)))?; - - Ok(Response::success(Some("移动成功"), None)) -} - -/// 设置环境账号 -pub async fn set_environment_accounts_handler( - State(svc_ctx): State, - Extension(ctx): Extension, - Json(payload): Json, -) -> Result<()> { - services::accounts::set_environment_accounts_service( - &svc_ctx, - payload.uuid, - &payload.account_uuids, - ) - .await - .map_err(|e| Response::fail(Some(&e)))?; - - Ok(Response::success(Some("设置成功"), None)) -} - -/// 批量分配标签 -pub async fn batch_assign_tags_handler( - State(svc_ctx): State, - Extension(ctx): Extension, - Json(payload): Json, -) -> Result<()> { - services::environments::batch_assign_tags_service(&svc_ctx, &payload) - .await - .map_err(|e| Response::fail(Some(&e)))?; - - Ok(Response::success(Some("分配成功"), None)) -} - -/// 批量移除标签 -pub async fn batch_remove_tags_handler( - State(svc_ctx): State, - Extension(ctx): Extension, - Json(payload): Json, -) -> Result<()> { - services::environments::batch_remove_tags_service(&svc_ctx, &payload) - .await - .map_err(|e| Response::fail(Some(&e)))?; - - Ok(Response::success(Some("移除成功"), None)) -} - -// ============ Environment URLs ============ - -/// 添加环境 URL -pub async fn add_environment_url_handler( - State(svc_ctx): State, - Extension(ctx): Extension, - Json(payload): Json, -) -> Result { - let id = services::environments::add_environment_url_service(&svc_ctx, &payload) - .await - .map_err(|e| Response::fail(Some(&e)))?; - - Ok(Response::success(Some("添加成功"), Some(IdResponse { id }))) -} - -/// 获取环境 URL 列表 -pub async fn get_environment_urls_handler( - State(svc_ctx): State, - Json(payload): Json, -) -> Result> { - let urls = services::environments::get_environment_urls_service(&svc_ctx, payload.uuid) - .await - .map_err(|e| Response::fail(Some(&e)))?; - - Ok(Response::success(Some("获取成功"), Some(urls))) -} - -/// 删除环境 URL -pub async fn delete_environment_url_handler( - State(svc_ctx): State, - Extension(ctx): Extension, - Json(payload): Json, -) -> Result<()> { - services::environments::delete_environment_url_service(&svc_ctx, payload.id) - .await - .map_err(|e| Response::fail(Some(&e)))?; - - Ok(Response::success(Some("删除成功"), None)) -} - -/// 清空环境 URL -pub async fn clear_environment_urls_handler( - State(svc_ctx): State, - Extension(ctx): Extension, - Json(payload): Json, -) -> Result { - let count = - services::environments::clear_environment_urls_service(&svc_ctx, payload.environment_uuid) - .await - .map_err(|e| Response::fail(Some(&e)))?; - - Ok(Response::success(Some("清空成功"), Some(count))) -} - -// ============ Environment Cookies ============ - -/// 添加环境 Cookie -pub async fn add_environment_cookie_handler( - State(svc_ctx): State, - Extension(ctx): Extension, - Json(payload): Json, -) -> Result { - let id = services::environments::add_environment_cookie_service(&svc_ctx, &payload) - .await - .map_err(|e| Response::fail(Some(&e)))?; - - Ok(Response::success(Some("添加成功"), Some(IdResponse { id }))) -} - -/// 获取环境 Cookie 列表 -pub async fn get_environment_cookies_handler( - State(svc_ctx): State, - Json(payload): Json, -) -> Result> { - let cookies = services::environments::get_environment_cookies_service(&svc_ctx, payload.uuid) - .await - .map_err(|e| Response::fail(Some(&e)))?; - - Ok(Response::success(Some("获取成功"), Some(cookies))) -} - -/// 删除环境 Cookie -pub async fn delete_environment_cookie_handler( - State(svc_ctx): State, - Extension(ctx): Extension, - Json(payload): Json, -) -> Result<()> { - services::environments::delete_environment_cookie_service(&svc_ctx, payload.id) - .await - .map_err(|e| Response::fail(Some(&e)))?; - - Ok(Response::success(Some("删除成功"), None)) -} - -/// 清空环境 Cookies -pub async fn clear_environment_cookies_handler( - State(svc_ctx): State, - Extension(ctx): Extension, - Json(payload): Json, -) -> Result { - let count = services::environments::clear_environment_cookies_service( - &svc_ctx, - payload.environment_uuid, - ) - .await - .map_err(|e| Response::fail(Some(&e)))?; - - Ok(Response::success(Some("清空成功"), Some(count))) -} diff --git a/src-tauri/crates/business/src/handlers/extensions.rs b/src-tauri/crates/business/src/handlers/extensions.rs deleted file mode 100644 index e15790b..0000000 --- a/src-tauri/crates/business/src/handlers/extensions.rs +++ /dev/null @@ -1,242 +0,0 @@ -use axum::extract::{Extension, State}; - -use crate::dto::ExtensionDto; -use crate::entitys::{ - BatchUpdateExtensionsRequest, BatchUpdateResponse, ExtensionIdRequest, ExtensionsListResponse, - GetInstalledExtensionsRequest, InstallExtensionRequest, InstalledExtensionsResponse, - ListExtensionsRequest, ToggleExtensionRequest, UninstallExtensionRequest, UpdateExtensionRequest, -}; -use crate::services; -use crate::state::RequestContext; -use crate::svc_ctx::SvcCtx; -use crate::utils::{Json, Response, Result}; - -/// 获取扩展列表 -pub async fn get_extensions_handler( - State(svc_ctx): State, - Json(payload): Json, -) -> Result { - let (items, total) = services::extensions::get_extensions_service(&svc_ctx, &payload) - .await - .map_err(|e| Response::fail(Some(&e)))?; - - Ok(Response::success( - Some("获取成功"), - Some(ExtensionsListResponse { - items, - total, - page: payload.pagination.page, - page_size: payload.pagination.page_size, - }), - )) -} - -/// 获取扩展详情 -pub async fn get_extension_detail_handler( - State(svc_ctx): State, - Json(payload): Json, -) -> Result { - let extension = services::extensions::get_extension_service(&svc_ctx, &payload.extension_id) - .await - .map_err(|e| Response::fail(Some(&e)))?; - - Ok(Response::success(Some("获取成功"), Some(extension))) -} - -/// 获取扩展分类 -pub async fn get_extension_categories_handler( - State(svc_ctx): State, -) -> Result> { - let categories = services::extensions::get_extension_categories_service(&svc_ctx) - .await - .map_err(|e| Response::fail(Some(&e)))?; - - Ok(Response::success(Some("获取成功"), Some(categories))) -} - -/// 获取已安装的扩展 -pub async fn get_installed_extensions_handler( - State(svc_ctx): State, - Extension(ctx): Extension, - Json(payload): Json, -) -> Result { - let team_uuid = services::teams::get_current_team_service(&svc_ctx, ctx.user_uuid_unwrap()) - .await - .map_err(|e| Response::fail(Some(&e)))?; - - let (user_extensions, team_extensions) = match payload.scope.as_str() { - "user" | "personal" => { - let user_extensions = services::extensions::get_user_installed_extensions_service( - &svc_ctx, - ctx.user_uuid_unwrap(), - team_uuid, - ) - .await - .map_err(|e| Response::fail(Some(&e)))?; - (user_extensions, Vec::new()) - } - "team" => { - let team_uuid = team_uuid.ok_or_else(|| Response::fail(Some("未指定团队")))?; - let team_extensions = - services::extensions::get_team_installed_extensions_service(&svc_ctx, team_uuid, ctx.user_uuid_unwrap()) - .await - .map_err(|e| Response::fail(Some(&e)))?; - (Vec::new(), team_extensions) - } - _ => { - // "all" 或其他值,返回全部 - let user_extensions = services::extensions::get_user_installed_extensions_service( - &svc_ctx, - ctx.user_uuid_unwrap(), - team_uuid, - ) - .await - .map_err(|e| Response::fail(Some(&e)))?; - let team_extensions = if let Some(team_uuid) = team_uuid { - services::extensions::get_team_installed_extensions_service(&svc_ctx, team_uuid, ctx.user_uuid_unwrap()) - .await - .map_err(|e| Response::fail(Some(&e)))? - } else { - Vec::new() - }; - (user_extensions, team_extensions) - } - }; - - Ok(Response::success( - Some("获取成功"), - Some(InstalledExtensionsResponse { - user_extensions, - team_extensions, - }), - )) -} - -/// 安装扩展 -pub async fn install_extension_handler( - State(svc_ctx): State, - Extension(ctx): Extension, - Json(payload): Json, -) -> Result<()> { - let workspace_uuid = ctx.current_workspace_uuid.ok_or_else(|| Response::fail(Some("请先选择工作空间")))?; - let team_uuid = services::teams::get_current_team_service(&svc_ctx, ctx.user_uuid_unwrap()) - .await - .map_err(|e| Response::fail(Some(&e)))?; - - services::extensions::install_extension_service( - &svc_ctx, - ctx.user_uuid_unwrap(), - team_uuid, - &payload, - ) - .await - .map_err(|e| Response::fail(Some(&e)))?; - - Ok(Response::success(Some("安装成功"), None)) -} - -/// 卸载扩展 -pub async fn uninstall_extension_handler( - State(svc_ctx): State, - Extension(ctx): Extension, - Json(payload): Json, -) -> Result<()> { - let workspace_uuid = ctx.current_workspace_uuid.ok_or_else(|| Response::fail(Some("请先选择工作空间")))?; - let team_uuid = services::teams::get_current_team_service(&svc_ctx, ctx.user_uuid_unwrap()) - .await - .map_err(|e| Response::fail(Some(&e)))?; - - services::extensions::uninstall_extension_service( - &svc_ctx, - ctx.user_uuid_unwrap(), - team_uuid, - &payload, - ) - .await - .map_err(|e| Response::fail(Some(&e)))?; - - Ok(Response::success(Some("卸载成功"), None)) -} - -/// 更新扩展 -pub async fn update_extension_handler( - State(svc_ctx): State, - Extension(ctx): Extension, - Json(payload): Json, -) -> Result<()> { - services::extensions::update_extension_service( - &svc_ctx, - ctx.user_uuid_unwrap(), - &payload.extension_id, - ) - .await - .map_err(|e| Response::fail(Some(&e)))?; - - Ok(Response::success(Some("更新成功"), None)) -} - -/// 批量更新扩展 -pub async fn batch_update_extensions_handler( - State(svc_ctx): State, - Extension(ctx): Extension, - Json(payload): Json, -) -> Result { - let updated = services::extensions::batch_update_extensions_service( - &svc_ctx, - ctx.user_uuid_unwrap(), - &payload.extension_ids, - ) - .await - .map_err(|e| Response::fail(Some(&e)))?; - - Ok(Response::success( - Some("批量更新完成"), - Some(BatchUpdateResponse { updated }), - )) -} - -/// 禁用扩展(用户级别) -pub async fn disable_extension_handler( - State(svc_ctx): State, - Extension(ctx): Extension, - Json(payload): Json, -) -> Result<()> { - let team_uuid = services::teams::get_current_team_service(&svc_ctx, ctx.user_uuid_unwrap()) - .await - .map_err(|e| Response::fail(Some(&e)))? - .ok_or_else(|| Response::fail(Some("未指定团队")))?; - - services::extensions::disable_extension_service( - &svc_ctx, - ctx.user_uuid_unwrap(), - team_uuid, - &payload.extension_id, - ) - .await - .map_err(|e| Response::fail(Some(&e)))?; - - Ok(Response::success(Some("禁用成功"), None)) -} - -/// 启用扩展(用户级别) -pub async fn enable_extension_handler( - State(svc_ctx): State, - Extension(ctx): Extension, - Json(payload): Json, -) -> Result<()> { - let team_uuid = services::teams::get_current_team_service(&svc_ctx, ctx.user_uuid_unwrap()) - .await - .map_err(|e| Response::fail(Some(&e)))? - .ok_or_else(|| Response::fail(Some("未指定团队")))?; - - services::extensions::enable_extension_service( - &svc_ctx, - ctx.user_uuid_unwrap(), - team_uuid, - &payload.extension_id, - ) - .await - .map_err(|e| Response::fail(Some(&e)))?; - - Ok(Response::success(Some("启用成功"), None)) -} diff --git a/src-tauri/crates/business/src/handlers/group_permissions.rs b/src-tauri/crates/business/src/handlers/group_permissions.rs deleted file mode 100644 index f9234b3..0000000 --- a/src-tauri/crates/business/src/handlers/group_permissions.rs +++ /dev/null @@ -1,138 +0,0 @@ -use axum::{extract::Extension, extract::State}; - -use crate::audit_log; -use crate::entitys::{ - CheckGroupPermissionRequest, GrantGroupPermissionRequest, ListUserGroupPermissionsRequest, - RevokeGroupPermissionRequest, -}; -use crate::services; -use crate::state::RequestContext; -use crate::svc_ctx::SvcCtx; -use crate::utils::{Json, Response, Result}; - -/// 授予分组权限 -pub async fn grant_group_permission_handler( - State(svc_ctx): State, - Extension(ctx): Extension, - Json(payload): Json, -) -> Result<()> { - services::group_permissions::grant_group_permission_service( - &svc_ctx, - ctx.user_uuid_unwrap(), - &payload, - ) - .await - .map_err(|e| Response::fail(Some(&e)))?; - - audit_log!( - &svc_ctx, - &ctx, - "grant", - "group_permission", - payload.group_uuid, - &payload.permission_type, - "授予分组权限" - ) - .await; - - Ok(Response::success(Some("授权成功"), None)) -} - -/// 撤销分组权限 -pub async fn revoke_group_permission_handler( - State(svc_ctx): State, - Extension(ctx): Extension, - Json(payload): Json, -) -> Result<()> { - services::group_permissions::revoke_group_permission_service( - &svc_ctx, - ctx.user_uuid_unwrap(), - &payload, - ) - .await - .map_err(|e| Response::fail(Some(&e)))?; - - audit_log!( - &svc_ctx, - &ctx, - "revoke", - "group_permission", - payload.group_uuid, - "", - "撤销分组权限" - ) - .await; - - Ok(Response::success(Some("撤销成功"), None)) -} - -/// 检查分组权限 -pub async fn check_group_permission_handler( - State(svc_ctx): State, - Extension(ctx): Extension, - Json(payload): Json, -) -> Result { - let workspace_uuid = ctx.current_workspace_uuid.ok_or_else(|| Response::fail(Some("请先选择工作空间")))?; - let has_permission = - services::group_permissions::check_group_permission_service(&svc_ctx, workspace_uuid, &payload) - .await - .map_err(|e| Response::fail(Some(&e)))?; - - Ok(Response::success( - None, - Some(crate::entitys::CheckPermissionResponse { - has_permission, - permission_type: if has_permission { - Some(payload.permission_type.clone()) - } else { - None - }, - }), - )) -} - -/// 查询用户的分组权限列表 -pub async fn list_user_group_permissions_handler( - State(svc_ctx): State, - Extension(_ctx): Extension, - Json(payload): Json, -) -> Result { - let permissions = - services::group_permissions::list_user_group_permissions_service(&svc_ctx, &payload) - .await - .map_err(|e| Response::fail(Some(&e)))?; - - let total = permissions.len() as i64; - - // 转换为详情 DTO(包含用户信息) - let mut permission_details = vec![]; - for perm in permissions { - let user_info = crate::models::user::fetch_user_info_by_uuid(&svc_ctx.db, perm.user_uuid) - .await - .ok() - .flatten(); - - permission_details.push(crate::dto::GroupMemberPermissionDetailDto { - group_uuid: perm.group_uuid, - workspace_uuid: perm.workspace_uuid, - team_uuid: perm.team_uuid, - user_uuid: perm.user_uuid, - permission_type: perm.permission_type, - granted_by: perm.granted_by, - user_name: user_info.as_ref().and_then(|u| u.nickname.clone()), - user_email: user_info.as_ref().map(|u| u.email.clone()), - created_at: perm.created_at, - updated_at: perm.updated_at, - }); - } - - Ok(Response::success( - None, - Some(crate::entitys::GroupPermissionListResponse { - items: permission_details, - total, - page: payload.pagination.page, - page_size: payload.pagination.page_size, - }), - )) -} diff --git a/src-tauri/crates/business/src/handlers/health.rs b/src-tauri/crates/business/src/handlers/health.rs deleted file mode 100644 index dd6d333..0000000 --- a/src-tauri/crates/business/src/handlers/health.rs +++ /dev/null @@ -1,15 +0,0 @@ -use chrono::Utc; -use serde_json::{Value, json}; - -use crate::utils::{Response, Result}; - -/// 健康检查 -pub async fn health_check_handler() -> Result { - Ok(Response::success( - Some("服务正常"), - Some(json!({ - "status": "ok", - "timestamp": Utc::now().to_rfc3339(), - })), - )) -} diff --git a/src-tauri/crates/business/src/handlers/local_api.rs b/src-tauri/crates/business/src/handlers/local_api.rs deleted file mode 100644 index 25fce02..0000000 --- a/src-tauri/crates/business/src/handlers/local_api.rs +++ /dev/null @@ -1,62 +0,0 @@ -use axum::extract::{Extension, State}; - -use crate::entitys::{ - GetLocalApiConfigRequest, ResetLocalApiKeyRequest, UpdateLocalApiConfigRequest, - ValidateLocalApiKeyRequest, -}; -use crate::services; -use crate::state::RequestContext; -use crate::svc_ctx::SvcCtx; -use crate::utils::{Json, Response, Result}; - -pub async fn get_local_api_config_handler( - State(svc_ctx): State, - Extension(ctx): Extension, - Json(_payload): Json, -) -> Result { - let config = - services::local_api::get_local_api_config_service(&svc_ctx, ctx.user_uuid_unwrap()) - .await - .map_err(|e| Response::fail(Some(&e)))?; - - Ok(Response::success(None, Some(config))) -} - -pub async fn update_local_api_config_handler( - State(svc_ctx): State, - Extension(ctx): Extension, - Json(payload): Json, -) -> Result { - let config = services::local_api::update_local_api_config_service( - &svc_ctx, - ctx.user_uuid_unwrap(), - &payload, - ) - .await - .map_err(|e| Response::fail(Some(&e)))?; - - Ok(Response::success(Some("更新成功"), Some(config))) -} - -pub async fn reset_local_api_key_handler( - State(svc_ctx): State, - Extension(ctx): Extension, - Json(_payload): Json, -) -> Result { - let result = services::local_api::reset_local_api_key_service(&svc_ctx, ctx.user_uuid_unwrap()) - .await - .map_err(|e| Response::fail(Some(&e)))?; - - Ok(Response::success(Some("重置成功"), Some(result))) -} - -pub async fn validate_local_api_key_handler( - State(svc_ctx): State, - Json(payload): Json, -) -> Result { - let result = services::local_api::validate_local_api_key_service(&svc_ctx, &payload) - .await - .map_err(|e| Response::fail(Some(&e)))?; - - Ok(Response::success(None, Some(result))) -} diff --git a/src-tauri/crates/business/src/handlers/messages.rs b/src-tauri/crates/business/src/handlers/messages.rs deleted file mode 100644 index fed1553..0000000 --- a/src-tauri/crates/business/src/handlers/messages.rs +++ /dev/null @@ -1,133 +0,0 @@ -use axum::{extract::Extension, extract::State}; - -use crate::entitys::{ - BatchMarkReadRequest, CreateMessageRequest, HandleMessageRequest, ListMessagesRequest, - MarkMessageReadRequest, MessageListResponse, MessageStatsResponse, -}; -use crate::services; -use crate::state::RequestContext; -use crate::svc_ctx::SvcCtx; -use crate::utils::{Json, Response, Result}; -use sqlx; - -/// 创建消息 -pub async fn create_message_handler( - State(svc_ctx): State, - Extension(ctx): Extension, - Json(payload): Json, -) -> Result { - let message_uuid = services::messages::create_message_service( - &svc_ctx, - Some(ctx.user_uuid_unwrap()), - &payload, - ) - .await - .map_err(|e| Response::fail(Some(&e)))?; - - Ok(Response::success( - Some("消息创建成功"), - Some(crate::entitys::CreateResponse { uuid: message_uuid }), - )) -} - -/// 获取用户消息列表 -pub async fn get_user_messages_handler( - State(svc_ctx): State, - Extension(ctx): Extension, - Json(payload): Json, -) -> Result { - let response = - services::messages::get_user_messages_service(&svc_ctx, ctx.user_uuid_unwrap(), &payload) - .await - .map_err(|e| Response::fail(Some(&e)))?; - - Ok(Response::success(Some("获取成功"), Some(response))) -} - -/// 标记消息为已读 -pub async fn mark_message_read_handler( - State(svc_ctx): State, - Extension(ctx): Extension, - Json(payload): Json, -) -> Result<()> { - services::messages::mark_message_read_service(&svc_ctx, ctx.user_uuid_unwrap(), &payload) - .await - .map_err(|e| Response::fail(Some(&e)))?; - - Ok(Response::success(Some("标记成功"), None)) -} - -/// 批量标记消息为已读 -pub async fn batch_mark_messages_read_handler( - State(svc_ctx): State, - Extension(ctx): Extension, - Json(payload): Json, -) -> Result<()> { - services::messages::batch_mark_messages_read_service( - &svc_ctx, - ctx.user_uuid_unwrap(), - &payload, - ) - .await - .map_err(|e| Response::fail(Some(&e)))?; - - Ok(Response::success(Some("批量标记成功"), None)) -} - -/// 处理消息(接受/拒绝) -pub async fn handle_message_handler( - State(svc_ctx): State, - Extension(ctx): Extension, - Json(payload): Json, -) -> Result<()> { - services::messages::handle_message_service(&svc_ctx, ctx.user_uuid_unwrap(), &payload) - .await - .map_err(|e| Response::fail(Some(&e)))?; - - Ok(Response::success(Some("处理成功"), None)) -} - -/// 获取用户消息统计 -pub async fn get_user_message_stats_handler( - State(svc_ctx): State, - Extension(ctx): Extension, -) -> Result { - let response = - services::messages::get_user_message_stats_service(&svc_ctx, ctx.user_uuid_unwrap()) - .await - .map_err(|e| Response::fail(Some(&e)))?; - - Ok(Response::success(Some("获取成功"), Some(response))) -} - -/// 删除消息 -pub async fn delete_message_handler( - State(svc_ctx): State, - Extension(ctx): Extension, - Json(payload): Json, -) -> Result<()> { - // 验证消息是否属于当前用户(通过查询 user_messages 表) - let user_message_exists: bool = sqlx::query_scalar( - r#" - SELECT EXISTS( - SELECT 1 FROM user_messages - WHERE message_uuid = $1 AND user_uuid = $2 - ) - "#, - ) - .bind(payload.uuid) - .bind(ctx.user_uuid_unwrap()) - .fetch_one(&svc_ctx.db) - .await - .map_err(|e| Response::fail(Some(&e.to_string())))?; - - if !user_message_exists { - return Err(Response::fail(Some("消息不存在或无权限"))); - } - - services::messages::delete_message_service(&svc_ctx, payload.uuid) - .await - .map_err(|e| Response::fail(Some(&e)))?; - - Ok(Response::success(Some("删除成功"), None)) -} diff --git a/src-tauri/crates/business/src/handlers/preferences.rs b/src-tauri/crates/business/src/handlers/preferences.rs deleted file mode 100644 index 3b3f5a0..0000000 --- a/src-tauri/crates/business/src/handlers/preferences.rs +++ /dev/null @@ -1,38 +0,0 @@ -use axum::extract::{Extension, State}; - -use crate::dto::UserPreferenceDto; -use crate::entitys::settings::UpdatePreferencesRequest; -use crate::services; -use crate::state::RequestContext; -use crate::svc_ctx::SvcCtx; -use crate::utils::{Json, Response, Result}; - -/// 获取用户偏好设置 -pub async fn get_preferences_handler( - State(svc_ctx): State, - Extension(ctx): Extension, -) -> Result { - let preferences = - services::preferences::get_preferences_service(&svc_ctx, ctx.user_uuid_unwrap()) - .await - .map_err(|e| Response::fail(Some(&e)))?; - - Ok(Response::success(Some("获取成功"), Some(preferences))) -} - -/// 更新用户偏好设置 -pub async fn update_preferences_handler( - State(svc_ctx): State, - Extension(ctx): Extension, - Json(payload): Json, -) -> Result { - let preferences = services::preferences::update_preferences_service( - &svc_ctx, - ctx.user_uuid_unwrap(), - &payload, - ) - .await - .map_err(|e| Response::fail(Some(&e)))?; - - Ok(Response::success(Some("更新成功"), Some(preferences))) -} diff --git a/src-tauri/crates/business/src/handlers/proxies.rs b/src-tauri/crates/business/src/handlers/proxies.rs deleted file mode 100644 index d0ecd7e..0000000 --- a/src-tauri/crates/business/src/handlers/proxies.rs +++ /dev/null @@ -1,174 +0,0 @@ -use axum::{extract::Extension, extract::State}; - -use crate::audit_log; -use crate::entitys::{ - BatchImportProxiesRequest, BatchImportResponse, BatchUuidRequest, CreateProxyRequest, - CreateResponse, ListProxiesRequest, ProxyListResponse, UpdateProxyRequest, UuidRequest, -}; -use crate::services; -use crate::state::RequestContext; -use crate::svc_ctx::SvcCtx; -use crate::utils::{Json, Response, Result}; - -/// 获取代理列表 -pub async fn get_proxies_handler( - State(svc_ctx): State, - Extension(ctx): Extension, - Json(payload): Json, -) -> Result { - let workspace_uuid = ctx - .current_workspace_uuid - .ok_or_else(|| Response::fail(Some("请先选择工作空间")))?; - - let (proxies, total) = - services::proxies::get_proxies_service( - &svc_ctx, - ctx.user_uuid_unwrap(), - workspace_uuid, - ctx.current_team_uuid, - &payload, - ) - .await - .map_err(|e| Response::fail(Some(&e)))?; - - Ok(Response::success( - Some("获取成功"), - Some(ProxyListResponse { - items: proxies, - total, - page: payload.pagination.page, - page_size: payload.pagination.page_size, - }), - )) -} - -/// 获取代理详情 -pub async fn get_proxy_handler( - State(svc_ctx): State, - Json(payload): Json, -) -> Result { - let proxy = services::proxies::get_proxy_service(&svc_ctx, payload.uuid) - .await - .map_err(|e| Response::fail(Some(&e)))?; - - Ok(Response::success(Some("获取成功"), Some(proxy))) -} - -/// 创建代理 -pub async fn create_proxy_handler( - State(svc_ctx): State, - Extension(ctx): Extension, - Json(payload): Json, -) -> Result { - let workspace_uuid = ctx.current_workspace_uuid.ok_or_else(|| Response::fail(Some("请先选择工作空间")))?; - let proxy_uuid = - services::proxies::create_proxy_service(&svc_ctx, ctx.user_uuid_unwrap(), workspace_uuid, &payload) - .await - .map_err(|e| Response::fail(Some(&e)))?; - - audit_log!( - &svc_ctx, - &ctx, - "create", - "proxy", - proxy_uuid, - &payload.name, - "创建代理" - ) - .await; - - Ok(Response::success( - Some("创建成功"), - Some(CreateResponse { uuid: proxy_uuid }), - )) -} - -/// 更新代理 -pub async fn update_proxy_handler( - State(svc_ctx): State, - Extension(ctx): Extension, - Json(payload): Json, -) -> Result<()> { - services::proxies::update_proxy_service(&svc_ctx, &payload) - .await - .map_err(|e| Response::fail(Some(&e)))?; - - Ok(Response::success(Some("更新成功"), None)) -} - -/// 删除代理 -pub async fn delete_proxy_handler( - State(svc_ctx): State, - Extension(ctx): Extension, - Json(payload): Json, -) -> Result<()> { - services::proxies::delete_proxy_service(&svc_ctx, payload.uuid) - .await - .map_err(|e| Response::fail(Some(&e)))?; - - audit_log!(&svc_ctx, &ctx, "delete", "proxy", payload.uuid, "删除代理").await; - - Ok(Response::success(Some("删除成功"), None)) -} - -/// 批量删除代理 -pub async fn batch_delete_proxies_handler( - State(svc_ctx): State, - Extension(ctx): Extension, - Json(payload): Json, -) -> Result { - let count = services::proxies::batch_delete_proxies_service(&svc_ctx, &payload.uuids) - .await - .map_err(|e| Response::fail(Some(&e)))?; - - audit_log!( - &svc_ctx, - &ctx, - "batch_delete", - "proxy", - &format!("批量删除 {} 个代理", count) - ) - .await; - - Ok(Response::success(Some("删除成功"), Some(count))) -} - -/// 批量导入代理 -pub async fn batch_import_proxies_handler( - State(svc_ctx): State, - Extension(ctx): Extension, - Json(payload): Json, -) -> Result { - // 获取工作空间 UUID(从请求或当前工作空间) - let workspace_uuid = ctx.current_workspace_uuid.ok_or_else(|| Response::fail(Some("请先选择工作空间")))?; - - let result = services::proxies::batch_import_proxies_service( - &svc_ctx, - ctx.user_uuid_unwrap(), - workspace_uuid, - &payload, - ) - .await - .map_err(|e| Response::fail(Some(&e)))?; - - audit_log!( - &svc_ctx, - &ctx, - "batch_import", - "proxy", - &format!( - "批量导入代理: 成功 {} 个, 失败 {} 个", - result.success_count, result.failed_count - ) - ) - .await; - - Ok(Response::success( - Some("导入完成"), - Some(BatchImportResponse { - success_count: result.success_count, - failed_count: result.failed_count, - errors: result.errors, - }), - )) -} diff --git a/src-tauri/crates/business/src/handlers/proxy_visibility.rs b/src-tauri/crates/business/src/handlers/proxy_visibility.rs deleted file mode 100644 index 1d37997..0000000 --- a/src-tauri/crates/business/src/handlers/proxy_visibility.rs +++ /dev/null @@ -1,150 +0,0 @@ -use axum::{extract::Extension, extract::State}; - -use crate::audit_log; -use crate::entitys::{ - BatchSetProxyVisibleRequest, ListProxyVisibleTeamsRequest, ListVisibleProxiesRequest, - RemoveProxyVisibleRequest, SetProxyVisibleRequest, -}; -use crate::services; -use crate::state::RequestContext; -use crate::svc_ctx::SvcCtx; -use crate::utils::{Json, Response, Result}; - -/// 设置代理对团队可见 -pub async fn set_proxy_visible_handler( - State(svc_ctx): State, - Extension(ctx): Extension, - Json(payload): Json, -) -> Result<()> { - services::proxy_visibility::set_proxy_visible_to_team_service( - &svc_ctx, - ctx.user_uuid_unwrap(), - &payload, - ) - .await - .map_err(|e| Response::fail(Some(&e)))?; - - audit_log!( - &svc_ctx, - &ctx, - "update", - "proxy_visibility", - payload.proxy_uuid, - "", - "设置代理可见性" - ) - .await; - - Ok(Response::success(Some("设置成功"), None)) -} - -/// 移除代理对团队的可见性 -pub async fn remove_proxy_visible_handler( - State(svc_ctx): State, - Extension(ctx): Extension, - Json(payload): Json, -) -> Result<()> { - services::proxy_visibility::remove_proxy_visible_from_team_service( - &svc_ctx, - ctx.user_uuid_unwrap(), - &payload, - ) - .await - .map_err(|e| Response::fail(Some(&e)))?; - - audit_log!( - &svc_ctx, - &ctx, - "update", - "proxy_visibility", - payload.proxy_uuid, - "", - "移除代理可见性" - ) - .await; - - Ok(Response::success(Some("移除成功"), None)) -} - -/// 批量设置代理可见性 -pub async fn batch_set_proxy_visible_handler( - State(svc_ctx): State, - Extension(ctx): Extension, - Json(payload): Json, -) -> Result<()> { - services::proxy_visibility::batch_set_proxy_visible_service( - &svc_ctx, - ctx.user_uuid_unwrap(), - &payload, - ) - .await - .map_err(|e| Response::fail(Some(&e)))?; - - audit_log!( - &svc_ctx, - &ctx, - "update", - "proxy_visibility", - payload.proxy_uuid, - "", - "批量设置代理可见性" - ) - .await; - - Ok(Response::success(Some("设置成功"), None)) -} - -/// 获取可见的代理列表 -pub async fn get_visible_proxies_handler( - State(svc_ctx): State, - Extension(ctx): Extension, - Json(payload): Json, -) -> Result { - let proxies = services::proxy_visibility::get_visible_proxies_service( - &svc_ctx, - ctx.user_uuid_unwrap(), - &payload, - ) - .await - .map_err(|e| Response::fail(Some(&e)))?; - - Ok(Response::success( - None, - Some(crate::entitys::VisibleProxyListResponse { items: proxies }), - )) -} - -/// 获取代理的可见团队列表 -pub async fn get_proxy_visible_teams_handler( - State(svc_ctx): State, - Extension(_ctx): Extension, - Json(payload): Json, -) -> Result { - let teams = crate::models::fetch_visible_teams_by_proxy(&svc_ctx.db, payload.proxy_uuid) - .await - .map_err(|e| Response::fail(Some(&e.to_string())))?; - - // 转换为详情 DTO(包含团队名称) - let mut team_details = vec![]; - for team in teams { - let team_info = crate::models::fetch_team_by_uuid(&svc_ctx.db, team.team_uuid) - .await - .ok() - .flatten(); - - team_details.push(crate::dto::ProxyVisibleTeamDetailDto { - proxy_uuid: team.proxy_uuid, - workspace_uuid: team.workspace_uuid, - team_uuid: team.team_uuid, - team_name: team_info.map(|t| t.name), - created_at: team.created_at, - }); - } - - Ok(Response::success( - None, - Some(crate::entitys::ProxyVisibleTeamListResponse { - items: team_details, - }), - )) -} diff --git a/src-tauri/crates/business/src/handlers/referral.rs b/src-tauri/crates/business/src/handlers/referral.rs deleted file mode 100644 index 4fb5123..0000000 --- a/src-tauri/crates/business/src/handlers/referral.rs +++ /dev/null @@ -1,210 +0,0 @@ -use axum::extract::{Extension, State}; - -use crate::dto::{RedeemOptionDto, ReferralLinkTierDto, UserReferralPointsDto}; -use crate::entitys::{ - ListReferralRewardsRequest, ListReferredUsersRequest, Pagination, RedeemPointsRequest, - RedeemRecordsListResponse, RedeemResponse, ReferralDashboardResponse, - ReferralLinksListResponse, ReferralPlanSummaryResponse, ReferralRewardsListResponse, - ReferralStatsResponse, ReferredUsersListResponse, SwitchReferralLinkRequest, -}; -use crate::services; -use crate::state::RequestContext; -use crate::svc_ctx::SvcCtx; -use crate::utils::{Json, Response, Result}; - -/// 获取推广统计 -pub async fn get_referral_stats_handler( - State(svc_ctx): State, - Extension(ctx): Extension, -) -> Result { - let stats = services::referral::get_referral_stats_service(&svc_ctx, ctx.user_uuid_unwrap()) - .await - .map_err(|e| Response::fail(Some(&e)))?; - - Ok(Response::success(Some("获取成功"), Some(stats))) -} - -/// 获取推广看板聚合数据 -pub async fn get_referral_dashboard_handler( - State(svc_ctx): State, - Extension(ctx): Extension, -) -> Result { - let dashboard = services::referral::get_referral_dashboard_service(&svc_ctx, ctx.user_uuid_unwrap()) - .await - .map_err(|e| Response::fail(Some(&e)))?; - - Ok(Response::success(Some("获取成功"), Some(dashboard))) -} - -/// 获取套餐页推广摘要信息 -pub async fn get_referral_plan_summary_handler( - State(svc_ctx): State, - Extension(ctx): Extension, -) -> Result { - let summary = - services::referral::get_referral_plan_summary_service(&svc_ctx, ctx.user_uuid_unwrap()) - .await - .map_err(|e| Response::fail(Some(&e)))?; - - Ok(Response::success(Some("获取成功"), Some(summary))) -} - -/// 获取推广链接层级配置 -pub async fn get_referral_tiers_handler( - State(svc_ctx): State, -) -> Result> { - let tiers = services::referral::get_referral_tiers_service(&svc_ctx) - .await - .map_err(|e| Response::fail(Some(&e)))?; - - Ok(Response::success(Some("获取成功"), Some(tiers))) -} - -/// 获取推广链接列表 -pub async fn get_referral_links_handler( - State(svc_ctx): State, - Extension(ctx): Extension, -) -> Result { - let (items, current_link) = - services::referral::get_referral_links_service(&svc_ctx, ctx.user_uuid_unwrap()) - .await - .map_err(|e| Response::fail(Some(&e)))?; - - Ok(Response::success( - Some("获取成功"), - Some(ReferralLinksListResponse { - items, - current_link, - }), - )) -} - -/// 切换当前推广链接 -pub async fn switch_referral_link_handler( - State(svc_ctx): State, - Extension(ctx): Extension, - Json(payload): Json, -) -> Result<()> { - services::referral::switch_referral_link_service( - &svc_ctx, - ctx.user_uuid_unwrap(), - payload.link_uuid, - ) - .await - .map_err(|e| Response::fail(Some(&e)))?; - - Ok(Response::success(Some("切换成功"), None)) -} - -/// 获取奖励记录列表 -pub async fn get_referral_rewards_handler( - State(svc_ctx): State, - Extension(ctx): Extension, - Json(payload): Json, -) -> Result { - let (items, total) = services::referral::get_referral_rewards_service( - &svc_ctx, - ctx.user_uuid_unwrap(), - &payload, - ) - .await - .map_err(|e| Response::fail(Some(&e)))?; - - Ok(Response::success( - Some("获取成功"), - Some(ReferralRewardsListResponse { - items, - total, - page: payload.pagination.page, - page_size: payload.pagination.page_size, - }), - )) -} - -/// 获取被邀请用户列表 -pub async fn get_referred_users_handler( - State(svc_ctx): State, - Extension(ctx): Extension, - Json(payload): Json, -) -> Result { - let (items, total) = - services::referral::get_referred_users_service(&svc_ctx, ctx.user_uuid_unwrap(), &payload) - .await - .map_err(|e| Response::fail(Some(&e)))?; - - Ok(Response::success( - Some("获取成功"), - Some(ReferredUsersListResponse { - items, - total, - page: payload.pagination.page, - page_size: payload.pagination.page_size, - }), - )) -} - -/// 获取用户积分 -pub async fn get_user_points_handler( - State(svc_ctx): State, - Extension(ctx): Extension, -) -> Result { - let points = services::referral::get_user_points_service(&svc_ctx, ctx.user_uuid_unwrap()) - .await - .map_err(|e| Response::fail(Some(&e)))?; - - Ok(Response::success(Some("获取成功"), Some(points))) -} - -/// 获取兑换选项 -pub async fn get_redeem_options_handler( - State(svc_ctx): State, -) -> Result> { - let options = services::referral::get_redeem_options_service(&svc_ctx) - .await - .map_err(|e| Response::fail(Some(&e)))?; - - Ok(Response::success(Some("获取成功"), Some(options))) -} - -/// 执行积分兑换 -pub async fn redeem_points_handler( - State(svc_ctx): State, - Extension(ctx): Extension, - Json(payload): Json, -) -> Result { - let (record_uuid, points_used, value) = - services::referral::redeem_points_service(&svc_ctx, ctx.user_uuid_unwrap(), &payload) - .await - .map_err(|e| Response::fail(Some(&e)))?; - - Ok(Response::success( - Some("兑换成功"), - Some(RedeemResponse { - record_uuid, - points_used, - value, - }), - )) -} - -/// 获取兑换记录 -pub async fn get_redeem_records_handler( - State(svc_ctx): State, - Extension(ctx): Extension, - Json(payload): Json, -) -> Result { - let (items, total) = - services::referral::get_redeem_records_service(&svc_ctx, ctx.user_uuid_unwrap(), &payload) - .await - .map_err(|e| Response::fail(Some(&e)))?; - - Ok(Response::success( - Some("获取成功"), - Some(RedeemRecordsListResponse { - items, - total, - page: payload.page, - page_size: payload.page_size, - }), - )) -} diff --git a/src-tauri/crates/business/src/handlers/rpa.rs b/src-tauri/crates/business/src/handlers/rpa.rs deleted file mode 100644 index ddd08ea..0000000 --- a/src-tauri/crates/business/src/handlers/rpa.rs +++ /dev/null @@ -1,144 +0,0 @@ -use axum::extract::{Extension, State}; - -use crate::entitys::{ - BatchUuidRequest, CreateResponse, CreateRpaTaskRequest, DuplicateRpaTaskRequest, - ExportRpaTaskRequest, ExportRpaTaskResponse, ListRpaTasksRequest, RpaTaskDetailResponse, - RpaTaskListResponse, UpdateRpaTaskRequest, UuidRequest, -}; -use crate::services; -use crate::state::RequestContext; -use crate::svc_ctx::SvcCtx; -use crate::utils::{Json, Response, Result}; - -pub async fn get_rpa_tasks_handler( - State(svc_ctx): State, - Extension(ctx): Extension, - Json(payload): Json, -) -> Result { - let team_uuid = services::teams::get_current_team_service(&svc_ctx, ctx.user_uuid_unwrap()) - .await - .map_err(|e| Response::fail(Some(&e)))?; - - let (items, total) = - services::rpa::get_rpa_tasks_service(&svc_ctx, ctx.user_uuid_unwrap(), team_uuid, &payload) - .await - .map_err(|e| Response::fail(Some(&e)))?; - - Ok(Response::success( - Some("ok"), - Some(RpaTaskListResponse { - items, - total, - page: payload.pagination.page, - page_size: payload.pagination.page_size, - }), - )) -} - -pub async fn get_rpa_task_handler( - State(svc_ctx): State, - Json(payload): Json, -) -> Result { - let (task, steps, environment_uuids) = - services::rpa::get_rpa_task_service(&svc_ctx, payload.uuid) - .await - .map_err(|e| Response::fail(Some(&e)))?; - - Ok(Response::success( - Some("ok"), - Some(RpaTaskDetailResponse { - task, - steps, - environment_uuids, - }), - )) -} - -pub async fn create_rpa_task_handler( - State(svc_ctx): State, - Extension(ctx): Extension, - Json(payload): Json, -) -> Result { - let team_uuid = services::teams::get_current_team_service(&svc_ctx, ctx.user_uuid_unwrap()) - .await - .map_err(|e| Response::fail(Some(&e)))?; - - let uuid = services::rpa::create_rpa_task_service( - &svc_ctx, - ctx.user_uuid_unwrap(), - team_uuid, - &payload, - ) - .await - .map_err(|e| Response::fail(Some(&e)))?; - - Ok(Response::success(Some("created"), Some(CreateResponse { uuid }))) -} - -pub async fn update_rpa_task_handler( - State(svc_ctx): State, - Json(payload): Json, -) -> Result<()> { - services::rpa::update_rpa_task_service(&svc_ctx, &payload) - .await - .map_err(|e| Response::fail(Some(&e)))?; - - Ok(Response::success(Some("updated"), None)) -} - -pub async fn delete_rpa_task_handler( - State(svc_ctx): State, - Json(payload): Json, -) -> Result<()> { - services::rpa::delete_rpa_task_service(&svc_ctx, payload.uuid) - .await - .map_err(|e| Response::fail(Some(&e)))?; - - Ok(Response::success(Some("deleted"), None)) -} - -pub async fn batch_delete_rpa_tasks_handler( - State(svc_ctx): State, - Json(payload): Json, -) -> Result<()> { - services::rpa::batch_delete_rpa_tasks_service(&svc_ctx, &payload.uuids) - .await - .map_err(|e| Response::fail(Some(&e)))?; - - Ok(Response::success(Some("deleted"), None)) -} - -pub async fn duplicate_rpa_task_handler( - State(svc_ctx): State, - Extension(ctx): Extension, - Json(payload): Json, -) -> Result { - let team_uuid = services::teams::get_current_team_service(&svc_ctx, ctx.user_uuid_unwrap()) - .await - .map_err(|e| Response::fail(Some(&e)))?; - - let uuid = services::rpa::duplicate_rpa_task_service( - &svc_ctx, - ctx.user_uuid_unwrap(), - team_uuid, - &payload, - ) - .await - .map_err(|e| Response::fail(Some(&e)))?; - - Ok(Response::success(Some("duplicated"), Some(CreateResponse { uuid }))) -} - -pub async fn export_rpa_task_handler( - State(svc_ctx): State, - Json(payload): Json, -) -> Result { - let (content, filename) = services::rpa::export_rpa_task_service(&svc_ctx, payload.uuid) - .await - .map_err(|e| Response::fail(Some(&e)))?; - - Ok(Response::success( - Some("exported"), - Some(ExportRpaTaskResponse { content, filename }), - )) -} diff --git a/src-tauri/crates/business/src/handlers/secret.rs b/src-tauri/crates/business/src/handlers/secret.rs deleted file mode 100644 index 09d968f..0000000 --- a/src-tauri/crates/business/src/handlers/secret.rs +++ /dev/null @@ -1,15 +0,0 @@ -use axum::http::StatusCode; -use axum::response::IntoResponse; - -use crate::utils::get_rsa_secret_instance; - -/// 获取服务器公钥 -/// -/// 返回服务器的 RSA 公钥(PEM 格式),用于客户端加密数据 -/// -/// # Returns -/// 返回纯文本格式的 PEM 公钥字符串 -pub async fn get_public_key_handler() -> impl IntoResponse { - let public_key = get_rsa_secret_instance().get_public_key(); - (StatusCode::OK, public_key).into_response() -} diff --git a/src-tauri/crates/business/src/handlers/teams.rs b/src-tauri/crates/business/src/handlers/teams.rs deleted file mode 100644 index 63c27f0..0000000 --- a/src-tauri/crates/business/src/handlers/teams.rs +++ /dev/null @@ -1,364 +0,0 @@ -use axum::{extract::Extension, extract::State}; - -use crate::audit_log; -use crate::entitys::{ - AcceptInvitationRequest, AcceptInvitationResponse, CreateResponse, CreateTeamRequest, - InviteMemberRequest, InviteResponse, ListTeamMembersRequest, MemberListResponse, - RejectInvitationRequest, RemoveMemberRequest, SwitchTeamRequest, TeamItem, TeamListResponse, - UpdateMemberRoleRequest, UpdateTeamRequest, UuidRequest, -}; -use crate::services; -use crate::state::RequestContext; -use crate::svc_ctx::SvcCtx; -use crate::utils::{Json, Response, Result}; - -/// 创建团队 -pub async fn create_team_handler( - State(svc_ctx): State, - Extension(ctx): Extension, - Json(payload): Json, -) -> Result { - let team_uuid = - services::teams::create_team_service(&svc_ctx, ctx.user_uuid_unwrap(), &payload) - .await - .map_err(|e| Response::fail(Some(&e)))?; - - audit_log!( - &svc_ctx, - &ctx, - "create", - "team", - team_uuid, - &payload.name, - "创建团队" - ) - .await; - - Ok(Response::success( - Some("创建成功"), - Some(CreateResponse { uuid: team_uuid }), - )) -} - -/// 获取用户的所有团队 -pub async fn get_my_teams_handler( - State(svc_ctx): State, - Extension(ctx): Extension, -) -> Result { - let workspace_uuid = ctx.current_workspace_uuid.ok_or_else(|| Response::fail(Some("请先选择工作空间")))?; - let teams = services::teams::get_user_teams_service(&svc_ctx, workspace_uuid, ctx.user_uuid_unwrap()) - .await - .map_err(|e| Response::fail(Some(&e)))?; - - let current_team_uuid = - services::teams::get_current_team_service(&svc_ctx, ctx.user_uuid_unwrap()) - .await - .map_err(|e| Response::fail(Some(&e)))?; - - let mut team_items: Vec = vec![]; - - for t in &teams { - // 获取用户在该团队的角色 - let role = if t.owner_uuid == ctx.user_uuid_unwrap() { - "owner".to_string() - } else { - crate::models::fetch_team_member(&svc_ctx.db, workspace_uuid, t.uuid, ctx.user_uuid_unwrap()) - .await - .ok() - .flatten() - .map(|m| m.role) - .unwrap_or_else(|| "member".to_string()) - }; - - // 获取成员数量(不使用筛选,统计所有活跃成员) - let members_count = - crate::models::fetch_team_member_count(&svc_ctx.db, t.uuid, None, None, None) - .await - .unwrap_or(0); - - team_items.push(TeamItem { - uuid: t.uuid, - name: t.name.clone(), - description: t.description.clone(), - role, - members_count, - is_current: current_team_uuid == Some(t.uuid), - }); - } - - Ok(Response::success( - Some("获取成功"), - Some(TeamListResponse { - current_team_uuid, - teams: team_items, - }), - )) -} - -/// 切换团队 -pub async fn switch_team_handler( - State(svc_ctx): State, - Extension(ctx): Extension, - Json(payload): Json, -) -> Result<()> { - let workspace_uuid = ctx.current_workspace_uuid.ok_or_else(|| Response::fail(Some("请先选择工作空间")))?; - services::teams::switch_team_service(&svc_ctx, workspace_uuid, ctx.user_uuid_unwrap(), &payload) - .await - .map_err(|e| Response::fail(Some(&e)))?; - - Ok(Response::success(Some("切换成功"), None)) -} - -/// 获取团队详情 -pub async fn get_team_handler( - State(svc_ctx): State, - Json(payload): Json, -) -> Result { - let team = services::teams::get_team_service(&svc_ctx, payload.uuid) - .await - .map_err(|e| Response::fail(Some(&e)))?; - - Ok(Response::success(Some("获取成功"), Some(team))) -} - -/// 更新团队信息 -pub async fn update_team_handler( - State(svc_ctx): State, - Extension(ctx): Extension, - Json(payload): Json, -) -> Result<()> { - let workspace_uuid = ctx.current_workspace_uuid.ok_or_else(|| Response::fail(Some("请先选择工作空间")))?; - services::teams::update_team_service(&svc_ctx, workspace_uuid, ctx.user_uuid_unwrap(), &payload) - .await - .map_err(|e| Response::fail(Some(&e)))?; - - Ok(Response::success(Some("更新成功"), None)) -} - -/// 获取团队成员列表 -pub async fn get_team_members_handler( - State(svc_ctx): State, - Extension(ctx): Extension, - Json(payload): Json, -) -> Result { - // 获取用户当前团队 - let team_uuid = services::teams::get_current_team_service(&svc_ctx, ctx.user_uuid_unwrap()) - .await - .map_err(|e| Response::fail(Some(&e)))? - .ok_or_else(|| Response::fail(Some("请先选择团队")))?; - - let (members, total) = services::teams::get_team_members_service(&svc_ctx, team_uuid, &payload) - .await - .map_err(|e| Response::fail(Some(&e)))?; - - Ok(Response::success( - Some("获取成功"), - Some(MemberListResponse { - items: members, - total, - page: payload.pagination.page, - page_size: payload.pagination.page_size, - }), - )) -} - -/// 邀请成员 -pub async fn invite_member_handler( - State(svc_ctx): State, - Extension(ctx): Extension, - Json(payload): Json, -) -> Result { - let team_uuid = services::teams::get_current_team_service(&svc_ctx, ctx.user_uuid_unwrap()) - .await - .map_err(|e| Response::fail(Some(&e)))? - .ok_or_else(|| Response::fail(Some("请先选择团队")))?; - - let workspace_uuid = ctx.current_workspace_uuid.ok_or_else(|| Response::fail(Some("请先选择工作空间")))?; - let invitation_uuid = services::teams::invite_member_service( - &svc_ctx, - workspace_uuid, - team_uuid, - ctx.user_uuid_unwrap(), - &payload, - ) - .await - .map_err(|e| Response::fail(Some(&e)))?; - - audit_log!( - &svc_ctx, - &ctx, - "invite", - "team_member", - &format!("邀请成员: {}", payload.email) - ) - .await; - - Ok(Response::success( - Some("邀请已发送"), - Some(InviteResponse { invitation_uuid }), - )) -} - -/// 更新成员角色 -pub async fn update_member_role_handler( - State(svc_ctx): State, - Extension(ctx): Extension, - Json(payload): Json, -) -> Result { - let team_uuid = services::teams::get_current_team_service(&svc_ctx, ctx.user_uuid_unwrap()) - .await - .map_err(|e| Response::fail(Some(&e)))? - .ok_or_else(|| Response::fail(Some("请先选择团队")))?; - - let workspace_uuid = ctx.current_workspace_uuid.ok_or_else(|| Response::fail(Some("请先选择工作空间")))?; - let member = services::teams::update_member_role_service( - &svc_ctx, - workspace_uuid, - team_uuid, - ctx.user_uuid_unwrap(), - &payload, - ) - .await - .map_err(|e| Response::fail(Some(&e)))?; - - Ok(Response::success(Some("更新成功"), Some(member))) -} - -/// 移除成员 -pub async fn remove_member_handler( - State(svc_ctx): State, - Extension(ctx): Extension, - Json(payload): Json, -) -> Result<()> { - let team_uuid = services::teams::get_current_team_service(&svc_ctx, ctx.user_uuid_unwrap()) - .await - .map_err(|e| Response::fail(Some(&e)))? - .ok_or_else(|| Response::fail(Some("请先选择团队")))?; - - let workspace_uuid = ctx.current_workspace_uuid.ok_or_else(|| Response::fail(Some("请先选择工作空间")))?; - services::teams::remove_member_service( - &svc_ctx, - workspace_uuid, - team_uuid, - ctx.user_uuid_unwrap(), - payload.member_uuid, - ) - .await - .map_err(|e| Response::fail(Some(&e)))?; - - audit_log!( - &svc_ctx, - &ctx, - "remove", - "team_member", - payload.member_uuid, - "移除成员" - ) - .await; - - Ok(Response::success(Some("移除成功"), None)) -} - -/// 获取待处理邀请列表 -pub async fn get_pending_invitations_handler( - State(svc_ctx): State, - Extension(ctx): Extension, -) -> Result> { - let team_uuid = services::teams::get_current_team_service(&svc_ctx, ctx.user_uuid_unwrap()) - .await - .map_err(|e| Response::fail(Some(&e)))? - .ok_or_else(|| Response::fail(Some("请先选择团队")))?; - - let invitations = services::teams::get_pending_invitations_service(&svc_ctx, team_uuid) - .await - .map_err(|e| Response::fail(Some(&e)))?; - - Ok(Response::success(Some("获取成功"), Some(invitations))) -} - -/// 取消邀请 -pub async fn cancel_invitation_handler( - State(svc_ctx): State, - Json(payload): Json, -) -> Result<()> { - services::teams::cancel_invitation_service(&svc_ctx, payload.uuid) - .await - .map_err(|e| Response::fail(Some(&e)))?; - - Ok(Response::success(Some("邀请已取消"), None)) -} - -/// 接受邀请 -pub async fn accept_invitation_handler( - State(svc_ctx): State, - Extension(ctx): Extension, - Json(payload): Json, -) -> Result { - let team_uuid = services::teams::accept_invitation_service( - &svc_ctx, - ctx.user_uuid_unwrap(), - &payload.token, - ) - .await - .map_err(|e| Response::fail(Some(&e)))?; - - audit_log!( - &svc_ctx, - &ctx, - "accept_invitation", - "team", - team_uuid, - "接受团队邀请" - ) - .await; - - Ok(Response::success( - Some("已加入团队"), - Some(AcceptInvitationResponse { team_uuid }), - )) -} - -/// 拒绝邀请 -pub async fn reject_invitation_handler( - State(svc_ctx): State, - Extension(ctx): Extension, - Json(payload): Json, -) -> Result<()> { - services::teams::reject_invitation_service(&svc_ctx, ctx.user_uuid_unwrap(), &payload.token) - .await - .map_err(|e| Response::fail(Some(&e)))?; - - audit_log!(&svc_ctx, &ctx, "reject_invitation", "team", "拒绝团队邀请").await; - - Ok(Response::success(Some("已拒绝邀请"), None)) -} - -/// 退出团队 -pub async fn leave_team_handler( - State(svc_ctx): State, - Extension(ctx): Extension, -) -> Result<()> { - // 获取当前团队 - let current_team_uuid = - services::teams::get_current_team_service(&svc_ctx, ctx.user_uuid_unwrap()) - .await - .map_err(|e| Response::fail(Some(&e)))? - .ok_or_else(|| Response::fail(Some("当前没有选择团队")))?; - - let workspace_uuid = ctx.current_workspace_uuid.ok_or_else(|| Response::fail(Some("请先选择工作空间")))?; - services::teams::leave_team_service(&svc_ctx, workspace_uuid, current_team_uuid, ctx.user_uuid_unwrap()) - .await - .map_err(|e| Response::fail(Some(&e)))?; - - audit_log!( - &svc_ctx, - &ctx, - "leave", - "team", - current_team_uuid, - "退出团队" - ) - .await; - - Ok(Response::success(Some("已退出团队"), None)) -} diff --git a/src-tauri/crates/business/src/handlers/templates.rs b/src-tauri/crates/business/src/handlers/templates.rs deleted file mode 100644 index d67d584..0000000 --- a/src-tauri/crates/business/src/handlers/templates.rs +++ /dev/null @@ -1,157 +0,0 @@ -use axum::{extract::Extension, extract::State}; - -use crate::entitys::{ - CreateFromTemplateRequest, CreateResponse, CreateTemplateRequest, GetTemplateRequest, - ListTemplatesRequest, TemplateDetailResponse, TemplateListResponse, UpdateTemplateRequest, - UuidRequest, -}; -use crate::services; -use crate::state::RequestContext; -use crate::svc_ctx::SvcCtx; -use crate::utils::{Json, Response, Result}; - -/// 获取模板列表 -pub async fn get_templates_handler( - State(svc_ctx): State, - Extension(ctx): Extension, - Json(payload): Json, -) -> Result { - let team_uuid = services::teams::get_current_team_service(&svc_ctx, ctx.user_uuid_unwrap()) - .await - .map_err(|e| Response::fail(Some(&e)))?; - - let (templates, total) = services::templates::get_templates_service( - &svc_ctx, - ctx.user_uuid_unwrap(), - team_uuid, - payload.is_public, - payload.pagination.page, - payload.pagination.page_size, - ) - .await - .map_err(|e| Response::fail(Some(&e)))?; - - Ok(Response::success( - Some("获取成功"), - Some(TemplateListResponse { - items: templates, - total, - page: payload.pagination.page, - page_size: payload.pagination.page_size, - }), - )) -} - -/// 获取模板详情 -pub async fn get_template_handler( - State(svc_ctx): State, - Json(payload): Json, -) -> Result { - let result = services::templates::get_template_service( - &svc_ctx, - payload.uuid, - payload.for_create.unwrap_or(false), - ) - .await - .map_err(|e| Response::fail(Some(&e)))?; - - Ok(Response::success(Some("获取成功"), Some(result))) -} - -/// 创建模板 -pub async fn create_template_handler( - State(svc_ctx): State, - Extension(ctx): Extension, - Json(payload): Json, -) -> Result { - let team_uuid = services::teams::get_current_team_service(&svc_ctx, ctx.user_uuid_unwrap()) - .await - .map_err(|e| Response::fail(Some(&e)))?; - - let template_uuid = services::templates::create_template_service( - &svc_ctx, - ctx.user_uuid_unwrap(), - team_uuid, - &payload, - ) - .await - .map_err(|e| Response::fail(Some(&e)))?; - - Ok(Response::success( - Some("创建成功"), - Some(CreateResponse { - uuid: template_uuid, - }), - )) -} - -/// 更新模板 -pub async fn update_template_handler( - State(svc_ctx): State, - Json(payload): Json, -) -> Result<()> { - services::templates::update_template_service(&svc_ctx, &payload) - .await - .map_err(|e| Response::fail(Some(&e)))?; - - Ok(Response::success(Some("更新成功"), None)) -} - -/// 删除模板 -pub async fn delete_template_handler( - State(svc_ctx): State, - Json(payload): Json, -) -> Result<()> { - services::templates::delete_template_service(&svc_ctx, payload.uuid) - .await - .map_err(|e| Response::fail(Some(&e)))?; - - Ok(Response::success(Some("删除成功"), None)) -} - -/// 应用模板到环境(更新环境配置为模板配置) -pub async fn apply_template_handler( - State(svc_ctx): State, - Extension(ctx): Extension, - Json(payload): Json, -) -> Result<()> { - let workspace_uuid = ctx.current_workspace_uuid.ok_or_else(|| Response::fail(Some("请先选择工作空间")))?; - let team_uuid = ctx.current_team_uuid.ok_or_else(|| Response::fail(Some("请先选择团队")))?; - - services::templates::apply_template_service( - &svc_ctx, - workspace_uuid, - team_uuid, - ctx.user_uuid_unwrap(), - &payload, - ) - .await - .map_err(|e| Response::fail(Some(&e)))?; - - Ok(Response::success(Some("应用成功"), None)) -} - -/// 从模板创建环境 -pub async fn create_from_template_handler( - State(svc_ctx): State, - Extension(ctx): Extension, - Json(payload): Json, -) -> Result { - let workspace_uuid = ctx.current_workspace_uuid.ok_or_else(|| Response::fail(Some("请先选择工作空间")))?; - let team_uuid = ctx.current_team_uuid.ok_or_else(|| Response::fail(Some("请先选择团队")))?; - - let env_uuid = services::templates::create_from_template_service( - &svc_ctx, - ctx.user_uuid_unwrap(), - workspace_uuid, - team_uuid, - &payload, - ) - .await - .map_err(|e| Response::fail(Some(&e)))?; - - Ok(Response::success( - Some("创建成功"), - Some(CreateResponse { uuid: env_uuid }), - )) -} diff --git a/src-tauri/crates/business/src/handlers/time.rs b/src-tauri/crates/business/src/handlers/time.rs deleted file mode 100644 index 7663890..0000000 --- a/src-tauri/crates/business/src/handlers/time.rs +++ /dev/null @@ -1,10 +0,0 @@ -use crate::{ - services::now_service, - utils::{Response, Result}, -}; - -pub async fn now_handle() -> Result { - let now = now_service(); - - Ok(Response::::success(Some("获取时间成功"), Some(now))) -} diff --git a/src-tauri/crates/business/src/handlers/users.rs b/src-tauri/crates/business/src/handlers/users.rs deleted file mode 100644 index 1646193..0000000 --- a/src-tauri/crates/business/src/handlers/users.rs +++ /dev/null @@ -1,151 +0,0 @@ -use axum::{extract::Extension, extract::State}; - -use crate::audit_log; -use crate::entitys::{ - LoginRequest, LoginResponse, RegisterRequest, RegisterResponse, ResetPasswordRequest, - SendCodeRequest, UpdatePasswordRequest, UpdateUserRequest, UserResponse, - VerifyPasswordRequest, VerifyPasswordResponse, -}; -use crate::services::audit::log_audit_anonymous; -use crate::services::users::{ - get_current_user_service, login_service, refresh_token_service, register_service, - reset_password_service, send_verification_code_service, update_password_service, - update_user_service, verify_password_service, -}; -use crate::state::RequestContext; -use crate::svc_ctx::SvcCtx; -use crate::utils::{Json, Response, Result}; - -/// 用户注册处理 -pub async fn register_handler( - State(svc_ctx): State, - Extension(ctx): Extension, - Json(payload): Json, -) -> Result { - let result = register_service(&svc_ctx, &payload) - .await - .map_err(|e| Response::fail(Some(&e.to_string())))?; - - // 记录审计日志(注册时使用返回的 user_uuid) - if let Some(ref user_info) = result.user_info { - log_audit_anonymous( - &svc_ctx, - &ctx, - user_info.uuid, - "register", - "user", - "用户注册", - ) - .await; - } - - Ok(Response::success(Some("注册成功"), Some(result))) -} - -/// 用户登录处理 -pub async fn login_handler( - State(svc_ctx): State, - Extension(ctx): Extension, - Json(payload): Json, -) -> Result { - let result = login_service(&svc_ctx, payload) - .await - .map_err(|e| Response::fail(Some(&e.to_string())))?; - - // 记录审计日志(登录时使用返回的 user_uuid) - if let Some(ref user_info) = result.user_info { - log_audit_anonymous(&svc_ctx, &ctx, user_info.uuid, "login", "user", "用户登录").await; - } - - Ok(Response::success(Some("登录成功"), Some(result))) -} - -/// 刷新 Token 处理 -pub async fn refresh_token_handler( - State(svc_ctx): State, - Json(payload): Json, -) -> Result { - let result = refresh_token_service(&svc_ctx, &payload.refresh_token) - .await - .map_err(|e| Response::fail(Some(&e.to_string())))?; - - Ok(Response::success(Some("刷新成功"), Some(result))) -} - -/// 获取当前用户信息处理 -pub async fn get_current_user_handler( - State(svc_ctx): State, - Extension(ctx): Extension, -) -> Result { - let user_info = get_current_user_service(&svc_ctx, ctx.user_uuid_unwrap()) - .await - .map_err(|e| Response::fail(Some(&e.to_string())))?; - - Ok(Response::success(Some("获取成功"), Some(user_info))) -} - -/// 更新用户信息处理 -pub async fn update_user_handler( - State(svc_ctx): State, - Extension(ctx): Extension, - Json(payload): Json, -) -> Result<()> { - update_user_service(&svc_ctx, ctx.user_uuid_unwrap(), &payload) - .await - .map_err(|e| Response::fail(Some(&e.to_string())))?; - - Ok(Response::success(Some("更新成功"), None)) -} - -/// 修改密码处理 -pub async fn update_password_handler( - State(svc_ctx): State, - Extension(ctx): Extension, - Json(payload): Json, -) -> Result<()> { - update_password_service(&svc_ctx, ctx.user_uuid_unwrap(), &payload) - .await - .map_err(|e| Response::fail(Some(&e.to_string())))?; - - // 记录审计日志 - audit_log!(&svc_ctx, &ctx, "update_password", "user", "修改密码").await; - - Ok(Response::success(Some("修改成功"), None)) -} - -/// 校验当前用户密码处理 -pub async fn verify_password_handler( - State(svc_ctx): State, - Extension(ctx): Extension, - Json(payload): Json, -) -> Result { - let result = verify_password_service(&svc_ctx, ctx.user_uuid_unwrap(), &payload) - .await - .map_err(|e| Response::fail(Some(&e.to_string())))?; - - Ok(Response::success(Some("校验成功"), Some(result))) -} - -/// 重置密码处理 -pub async fn reset_password_handler( - State(svc_ctx): State, - Json(payload): Json, -) -> Result<()> { - reset_password_service(&svc_ctx, &payload) - .await - .map_err(|e| Response::fail(Some(&e.to_string())))?; - - Ok(Response::success(Some("重置成功"), None)) -} - -/// 发送验证码处理 -pub async fn send_code_handler( - State(svc_ctx): State, - Json(payload): Json, -) -> Result<()> { - send_verification_code_service(&svc_ctx, &payload.email, &payload.r#type) - .await - .map_err(|e| Response::fail(Some(&e.to_string())))?; - - Ok(Response::success(Some("发送成功"), None)) -} diff --git a/src-tauri/crates/business/src/handlers/workspace_quotas.rs b/src-tauri/crates/business/src/handlers/workspace_quotas.rs deleted file mode 100644 index a5e96b0..0000000 --- a/src-tauri/crates/business/src/handlers/workspace_quotas.rs +++ /dev/null @@ -1,43 +0,0 @@ -use axum::{extract::Extension, extract::State}; - -use crate::entitys::{GetWorkspaceQuotaRequest, UpdateQuotaUsageRequest}; -use crate::services; -use crate::state::RequestContext; -use crate::svc_ctx::SvcCtx; -use crate::utils::{Json, Response, Result}; - -/// 获取工作空间配额 -pub async fn get_workspace_quota_handler( - State(svc_ctx): State, - Extension(ctx): Extension, - Json(mut payload): Json, -) -> Result { - // 如果没有传递 workspace_uuid,则从 RequestContext 中获取 - if payload.workspace_uuid.is_none() { - payload.workspace_uuid = ctx.current_workspace_uuid; - } - - // 验证 workspace_uuid 是否存在 - if payload.workspace_uuid.is_none() { - return Err(Response::fail(Some("未提供工作空间 UUID"))); - } - - let quota = services::workspace_quotas::get_workspace_quota_service(&svc_ctx, &payload) - .await - .map_err(|e| Response::fail(Some(&e)))?; - - Ok(Response::success(None, Some(quota))) -} - -/// 更新配额使用情况 -pub async fn update_quota_usage_handler( - State(svc_ctx): State, - Extension(_ctx): Extension, - Json(payload): Json, -) -> Result<()> { - services::workspace_quotas::update_quota_usage_service(&svc_ctx, &payload) - .await - .map_err(|e| Response::fail(Some(&e)))?; - - Ok(Response::success(Some("更新成功"), None)) -} diff --git a/src-tauri/crates/business/src/handlers/workspaces.rs b/src-tauri/crates/business/src/handlers/workspaces.rs deleted file mode 100644 index 6fbb2a1..0000000 --- a/src-tauri/crates/business/src/handlers/workspaces.rs +++ /dev/null @@ -1,179 +0,0 @@ -use axum::{extract::Extension, extract::State}; - -use crate::audit_log; -use crate::entitys::{ - CreateResponse, CreateWorkspaceRequest, SwitchWorkspaceRequest, UpdateWorkspaceRequest, - WorkspaceItem, WorkspaceListResponse, -}; -use crate::services; -use crate::state::RequestContext; -use crate::svc_ctx::SvcCtx; -use crate::utils::{Json, Response, Result}; - -/// 创建工作空间 -pub async fn create_workspace_handler( - State(svc_ctx): State, - Extension(ctx): Extension, - Json(payload): Json, -) -> Result { - let workspace_uuid = - services::workspaces::create_workspace_service(&svc_ctx, ctx.user_uuid_unwrap(), &payload) - .await - .map_err(|e| Response::fail(Some(&e)))?; - - audit_log!( - &svc_ctx, - &ctx, - "create", - "workspace", - workspace_uuid, - &payload.name, - "创建工作空间" - ) - .await; - - Ok(Response::success( - Some("创建成功"), - Some(CreateResponse { - uuid: workspace_uuid, - }), - )) -} - -/// 获取用户的所有工作空间 -pub async fn get_my_workspaces_handler( - State(svc_ctx): State, - Extension(ctx): Extension, -) -> Result { - let user_uuid = ctx.user_uuid_unwrap(); - let workspaces = services::workspaces::get_user_workspaces_service(&svc_ctx, user_uuid) - .await - .map_err(|e| Response::fail(Some(&e)))?; - - // 从 RequestContext 获取当前工作空间 UUID(已在 auth 中间件中设置) - let mut current_workspace_uuid = ctx.current_workspace_uuid; - - // 如果用户没有设置当前工作空间,但有工作空间列表,自动设置为第一个工作空间 - if current_workspace_uuid.is_none() && !workspaces.is_empty() { - let first_workspace_uuid = workspaces[0].uuid; - // 更新数据库 - if let Err(e) = crate::models::user::set_user_current_workspace( - &svc_ctx.db, - user_uuid, - first_workspace_uuid, - ) - .await - { - tracing::warn!("Failed to set default workspace: {}", e); - } else { - current_workspace_uuid = Some(first_workspace_uuid); - } - } - - let workspace_items: Vec = workspaces - .iter() - .map(|w| WorkspaceItem { - uuid: w.uuid, - name: w.name.clone(), - workspace_type: w.workspace_type.clone(), - is_current: current_workspace_uuid == Some(w.uuid), - }) - .collect(); - - Ok(Response::success( - None, - Some(WorkspaceListResponse { - current_workspace_uuid, - workspaces: workspace_items, - }), - )) -} - -/// 获取工作空间详情 -pub async fn get_workspace_handler( - State(svc_ctx): State, - Extension(_ctx): Extension, - Json(payload): Json, -) -> Result { - let workspace = services::workspaces::get_workspace_service(&svc_ctx, payload.uuid) - .await - .map_err(|e| Response::fail(Some(&e)))?; - - Ok(Response::success(None, Some(workspace))) -} - -/// 更新工作空间 -pub async fn update_workspace_handler( - State(svc_ctx): State, - Extension(ctx): Extension, - Json(payload): Json, -) -> Result<()> { - services::workspaces::update_workspace_service(&svc_ctx, ctx.user_uuid_unwrap(), &payload) - .await - .map_err(|e| Response::fail(Some(&e)))?; - - audit_log!( - &svc_ctx, - &ctx, - "update", - "workspace", - payload.uuid, - &payload.name.as_deref().unwrap_or(""), - "更新工作空间" - ) - .await; - - Ok(Response::success(Some("更新成功"), None)) -} - -/// 删除工作空间 -pub async fn delete_workspace_handler( - State(svc_ctx): State, - Extension(ctx): Extension, - Json(payload): Json, -) -> Result<()> { - services::workspaces::delete_workspace_service(&svc_ctx, ctx.user_uuid_unwrap(), payload.uuid) - .await - .map_err(|e| Response::fail(Some(&e)))?; - - audit_log!( - &svc_ctx, - &ctx, - "delete", - "workspace", - payload.uuid, - "", - "删除工作空间" - ) - .await; - - Ok(Response::success(Some("删除成功"), None)) -} - -/// 切换工作空间 -pub async fn switch_workspace_handler( - State(svc_ctx): State, - Extension(ctx): Extension, - Json(payload): Json, -) -> Result<()> { - let user_uuid = ctx.user_uuid_unwrap(); - - // 检查用户是否是工作空间所有者 - let is_owner = services::workspaces::check_workspace_owner_service( - &svc_ctx, - payload.workspace_uuid, - user_uuid, - ) - .await - .map_err(|e| Response::fail(Some(&e)))?; - - if !is_owner { - return Err(Response::fail(Some("您不是该工作空间的所有者"))); - } - - services::workspaces::switch_workspace_service(&svc_ctx, payload.workspace_uuid, user_uuid) - .await - .map_err(|e| Response::fail(Some(&e)))?; - - Ok(Response::success(Some("切换成功"), None)) -} diff --git a/src-tauri/crates/business/src/lib.rs b/src-tauri/crates/business/src/lib.rs index a525243..3957594 100644 --- a/src-tauri/crates/business/src/lib.rs +++ b/src-tauri/crates/business/src/lib.rs @@ -1,25 +1,10 @@ -use crate::utils::IConfig; - -pub mod app; pub mod caches; -pub mod cli; pub mod database; pub mod dto; pub mod entitys; pub mod errors; -pub mod handlers; -pub mod middlewares; pub mod models; -pub mod routes; pub mod services; pub mod state; pub mod svc_ctx; pub mod utils; - -pub use app::{serve, serve_on, serve_on_with_shutdown}; - -/// Initialize the response-encryption key used by the transitional HTTP API. -pub async fn init_encrypt_secret(config: &IConfig) { - let key_path = &config.app.encrypt_secret_location; - utils::init_rsa_secret(key_path).await; -} diff --git a/src-tauri/crates/business/src/main.rs b/src-tauri/crates/business/src/main.rs deleted file mode 100644 index 12d75c1..0000000 --- a/src-tauri/crates/business/src/main.rs +++ /dev/null @@ -1,26 +0,0 @@ -use clap::Parser; -use simprint_server::{ - cli::{Cli, Commands}, - serve, - utils::IConfig, -}; - -#[tokio::main] -async fn main() -> anyhow::Result<()> { - let cli = Cli::parse(); - let (config_path, command) = cli.command_or_default(); - let config = IConfig::build_by_filepath(&config_path).expect("failed to build config"); - - tracing_subscriber::fmt() - .with_max_level(tracing::Level::INFO) - .with_target(true) - .with_thread_ids(true) - .try_init() - .map_err(|error| anyhow::anyhow!(error.to_string()))?; - - match command { - Commands::Serve => serve(config).await?, - } - - Ok(()) -} diff --git a/src-tauri/crates/business/src/middlewares.rs b/src-tauri/crates/business/src/middlewares.rs deleted file mode 100644 index f0e43f3..0000000 --- a/src-tauri/crates/business/src/middlewares.rs +++ /dev/null @@ -1,42 +0,0 @@ -mod auth; -mod cors; -mod decrypt; -mod encrypt; -mod local_api_auth; -mod logger; -mod real_ip; - -pub use auth::*; -pub use cors::*; -pub use decrypt::*; -pub use encrypt::*; -pub use local_api_auth::*; -pub use logger::*; -pub use real_ip::*; - -use axum::extract::{MatchedPath, Request}; - -/// 获取请求路径和请求方法 -pub fn get_request_path_and_method(req: &Request) -> (&str, &str) { - let path = req - .extensions() - .get::() - .map_or_else(|| req.uri().path(), |path| path.as_str()); - - let method = req.method().as_str(); - - (method, path) -} - -/// 从完整路径中提取业务路径(去除 /api/v*/ 前缀,保留前导斜杠) -/// 例如:/api/v1/environments -> /environments -pub fn extract_resource_path(path: &str) -> &str { - // 匹配 /api/v{数字}/ 格式的前缀 - if let Some(stripped) = path.strip_prefix("/api/v") { - // 找到第一个 / 后的内容(包含斜杠) - if let Some(pos) = stripped.find('/') { - return &stripped[pos..]; - } - } - path -} diff --git a/src-tauri/crates/business/src/middlewares/auth.rs b/src-tauri/crates/business/src/middlewares/auth.rs deleted file mode 100644 index 6d40a9c..0000000 --- a/src-tauri/crates/business/src/middlewares/auth.rs +++ /dev/null @@ -1,138 +0,0 @@ -use std::str::FromStr; - -use axum::{ - extract::{Request, State}, - http::{StatusCode, header}, - middleware::Next, - response::Response, -}; -use uuid::Uuid; - -use crate::{ - middlewares::{extract_resource_path, get_request_path_and_method}, - services, - state::{CurrentUser, RequestContext}, - svc_ctx::SvcCtx, -}; - -pub async fn auth( - State(state): State, - mut req: Request, - next: Next, -) -> Result { - let (resource_method, resource_path) = get_request_path_and_method(&req); - - // 转换为拥有所有权的 String,避免借用冲突 - let resource_method = resource_method.to_string(); - let resource_path = resource_path.to_string(); - - if resource_method.eq("OPTIONS") { - return Ok(next.run(req).await); - } - - if req - .extensions() - .get::() - .and_then(|ctx| ctx.current_user.as_ref()) - .is_some() - { - return Ok(next.run(req).await); - } - - if crate::middlewares::has_local_api_auth_headers(&req) - && !extract_resource_path(&resource_path).starts_with("/local-api") - { - return Ok(next.run(req).await); - } - - // 判断请求的是否在白名单,如果是白名单就直接允许访问。 - { - let combine = format!("{}+{}", resource_method, resource_path); - let whitelists = &state.config.app.route_whitelists; - - if whitelists.contains(&combine) { - return Ok(next.run(req).await); - } - } - - let auth_header = req - .headers() - .get(header::AUTHORIZATION) - .and_then(|header| header.to_str().ok()) - .ok_or_else(|| StatusCode::UNAUTHORIZED)?; - - let token_str = if auth_header.starts_with("Bearer ") { - &auth_header[7..] - } else { - return Err(StatusCode::UNAUTHORIZED); - }; - - // 先判断token是否正常,如果正常再去与远程的匹配。匹配成功后返回uuid. - let secret = state.config.app.secret.as_bytes(); - let verify_token_response = services::verify_token_service(token_str, secret); - - if let Err(_e) = verify_token_response { - return Err(StatusCode::UNAUTHORIZED); - } - - let verify_token_response = verify_token_response.unwrap(); - - // TODO: 根据获取到的uuid可以获取到用户并调用检查权限的方法。 - // 2025-08-27 10:05:00: 权限检查可能移除, 该网关服务只面向用户 - let uuid = if let Ok(uuid) = Uuid::from_str(&verify_token_response) { - uuid - } else { - return Err(StatusCode::UNAUTHORIZED); - }; - - fill_authenticated_request_context(&state, &mut req, uuid, &resource_method, &resource_path) - .await; - - Ok(next.run(req).await) -} - -pub async fn fill_authenticated_request_context( - state: &SvcCtx, - req: &mut Request, - user_uuid: Uuid, - resource_method: &str, - resource_path: &str, -) { - if req.extensions().get::().is_none() { - req.extensions_mut().insert(RequestContext::default()); - } - - if let Some(ctx) = req.extensions_mut().get_mut::() { - let ip = ctx.ip_or_unknown().to_string(); - tracing::info!("auth user uuid: {}, ip: {}", user_uuid, ip); - - ctx.current_user = Some(CurrentUser { user_uuid }); - ctx.current_team_uuid = None; - ctx.current_workspace_uuid = None; - - let business_path = extract_resource_path(resource_path); - ctx.resource_identifier = Some(format!("{}+{}", resource_method, business_path)); - } - - let user_info = crate::models::user::fetch_user_info_by_uuid(&state.db, user_uuid) - .await - .ok() - .flatten(); - - if let Some(ctx) = req.extensions_mut().get_mut::() { - if let Some(user_info) = user_info { - ctx.current_team_uuid = user_info.current_team_uuid; - ctx.current_workspace_uuid = if let Some(ws_uuid) = user_info.current_workspace_uuid { - Some(ws_uuid) - } else if let Some(team_uuid) = user_info.current_team_uuid { - crate::models::teams::fetch_team_by_uuid(&state.db, team_uuid) - .await - .ok() - .flatten() - .map(|team| team.workspace_uuid) - } else { - None - }; - } - } -} diff --git a/src-tauri/crates/business/src/middlewares/cors.rs b/src-tauri/crates/business/src/middlewares/cors.rs deleted file mode 100644 index b409a28..0000000 --- a/src-tauri/crates/business/src/middlewares/cors.rs +++ /dev/null @@ -1,28 +0,0 @@ -use axum::http::{ - HeaderName, Method, - header::{self}, -}; -use tower_http::cors::CorsLayer; - -pub fn cors() -> CorsLayer { - CorsLayer::new() - .allow_headers([ - header::CONTENT_TYPE, - header::AUTHORIZATION, - HeaderName::from_static("x-custom-header"), - ]) - // allow `GET` and `POST` when accessing the resource - .allow_methods([ - Method::GET, - Method::POST, - Method::PUT, - Method::DELETE, - Method::OPTIONS, - ]) - // allow requests from any origin - .allow_origin([ - "http://localhost:1420".parse().unwrap(), - "http://localhost:5173".parse().unwrap(), - "http://tauri.localhost".parse().unwrap(), - ]) -} diff --git a/src-tauri/crates/business/src/middlewares/decrypt.rs b/src-tauri/crates/business/src/middlewares/decrypt.rs deleted file mode 100644 index 6a21121..0000000 --- a/src-tauri/crates/business/src/middlewares/decrypt.rs +++ /dev/null @@ -1,152 +0,0 @@ -use axum::{ - body::{self, Body}, - extract::{Request, State}, - http::{HeaderMap, StatusCode, header}, - middleware::Next, - response::{IntoResponse, Response}, -}; -use serde::Deserialize; - -use crate::{ - svc_ctx::SvcCtx, - utils::{self, AesSecret, get_rsa_secret_instance}, -}; - -/// 被加密的请求的结构体 -#[derive(Debug, Deserialize)] -struct EncryptedRequest { - /// 是否加密 - encrypted: bool, - /// 加密的内容 - #[serde(default)] - data: String, - /// 使用服务器公钥加密后的AES密钥 - #[serde(default)] - key: String, -} - -/// 解密中间件 -pub async fn decrypt( - State(state): State, - req: Request, - next: Next, -) -> Result { - // 获取请求路径和请求方法 - { - // if state - // .config - // .client_gateway_service_config - // .resources - // .whitelists - // .contains(&combine) - // { - // return Ok(next.run(req).await); - // } - } - - // 检查内容是否是json - if !is_json_content_type(req.headers()) { - return Ok(next.run(req).await); - } - - // 读取请求体 - let (parts, body) = req.into_parts(); - let body_bytes = match body::to_bytes(body, 1024 * 1024 * 10).await { - // 限制请求体大小为10MB - Ok(bytes) => bytes, - Err(e) => { - tracing::error!("读取请求体失败: {}", e); - return Err(StatusCode::BAD_REQUEST); - } - }; - - match serde_json::from_slice::(&body_bytes) { - Ok(encrypted_request) => { - if !encrypted_request.encrypted { - // let req = Request::from_parts(parts, Body::from(body_bytes)); - // return Ok(next.run(req).await); - tracing::error!("请求未加密, 拒绝请求"); - return Err(StatusCode::BAD_REQUEST); - } - - // 获取服务器RSA实例以解密AES密钥 - let rsa_secret = get_rsa_secret_instance(); - - // 解密aes密钥(解密后是base64编码的字符串对应的字节数组) - // 解密失败后返回正常的json信息并设置状态码为400 - let aes_key = if let Ok(aes_key) = rsa_secret.decrypt(&encrypted_request.key) { - aes_key - } else { - let mut response = utils::Response::<()>::fail(Some("LASDE")).into_response(); - *response.status_mut() = StatusCode::UNPROCESSABLE_ENTITY; - return Ok(response); - }; - - let aes_key = std::str::from_utf8(&aes_key).map_err(|_| { - tracing::error!("AES密钥转换为字符串失败"); - StatusCode::BAD_REQUEST - })?; - - // 使用解密后的AES密钥创建AES实例 - let aes_secret = AesSecret::try_from(aes_key).map_err(|_| { - tracing::error!("创建AES实例失败"); - StatusCode::BAD_REQUEST - })?; - - // 使用aes密钥解密请求体 - let decrypted_data = aes_secret.decrypt(&encrypted_request.data).map_err(|e| { - tracing::error!("解密请求体失败: {:?}", e); - StatusCode::BAD_REQUEST - })?; - - // 将解密后的请求体转换为JSON - let mut decrypted_json_value: serde_json::Value = - serde_json::from_slice(&decrypted_data).map_err(|_| { - tracing::error!("解析解密数据为JSON失败"); - StatusCode::BAD_REQUEST - })?; - - // 获取api_secret - if let Some(api_secret) = decrypted_json_value.get("api_secret") { - // 检查api_secret是否与配置中的一致 - let app_secret = &state.config.app.secret; - if api_secret != &serde_json::Value::String(app_secret.clone()) { - tracing::error!("API密钥不匹配"); - return Err(StatusCode::UNAUTHORIZED); - } - - // 移除api_secret字段 - decrypted_json_value.as_object_mut().and_then(|obj| obj.remove("api_secret")); - } else { - tracing::error!("请求中缺少api_secret字段"); - return Err(StatusCode::BAD_REQUEST); - } - - // 重建请求体 - let json_bytes = serde_json::to_vec(&decrypted_json_value).map_err(|_| { - tracing::error!("序列化解密数据为JSON失败"); - StatusCode::BAD_REQUEST - })?; - - // 将解密后的请求体放入请求的扩展中 - let req = Request::from_parts(parts, Body::from(json_bytes)); - Ok(next.run(req).await) - } - Err(e) => { - // 如果解析失败,可能不是加密请求,直接通过 - // let req = Request::from_parts(parts, Body::from(body_bytes)); - // Ok(next.run(req).await) - tracing::error!("解析加密请求失败: {:?}", e); - return Err(StatusCode::BAD_REQUEST); - } - } -} - -/// 检查请求头是否为JSON内容类型 -fn is_json_content_type(headers: &HeaderMap) -> bool { - headers - .get(header::CONTENT_TYPE) - .and_then(|value| value.to_str().ok()) - .map(|content_type| content_type.starts_with("application/json")) - .unwrap_or(false) -} diff --git a/src-tauri/crates/business/src/middlewares/encrypt.rs b/src-tauri/crates/business/src/middlewares/encrypt.rs deleted file mode 100644 index aec3286..0000000 --- a/src-tauri/crates/business/src/middlewares/encrypt.rs +++ /dev/null @@ -1,126 +0,0 @@ -// use arcadia_codegen::user_services::GetUserPublicKeyRequest; -// use arcadia_utils::secret::{aes::AesSecret, rsa}; - -use axum::{ - body, - extract::{Request, State}, - http::{StatusCode, header}, - middleware::Next, - response::{IntoResponse, Response}, -}; -use bytes::Bytes; -use serde_json::json; - -use crate::{ - services::get_user_public_key_service, - state::CurrentUser, - svc_ctx::SvcCtx, - utils::{AesSecret, get_rsa_secret_instance}, -}; - -/// 加密中间件 -pub async fn encrypt( - State(state): State, - req: Request, - next: Next, -) -> Result { - // 从请求中获取 CurrentUser(如果存在) - let current_user = req.extensions().get::().cloned(); - - // 获取请求路径和请求方法 - // { - // let (resource_method, resource_path) = get_request_path_and_method(&req); - // let combine = format!("{}+{}", resource_method, resource_path); - // if state.config.resources.whitelists.contains(&combine) { - // return Ok(next.run(req).await); - // } - // } - - let response = next.run(req).await; - - if let Some(current_user) = current_user { - match get_user_public_key_service(&state, ¤t_user.user_uuid).await { - Ok(public_key) => { - // 创建AES密钥实例 - let aes_secret = AesSecret::new(); - - // 读取响应体 - let (parts, body) = response.into_parts(); - - // 将整个响应体转换为字节 - let body_bytes = match body::to_bytes(body, usize::MAX).await { - Ok(bytes) => bytes, - Err(err) => { - // 无法读取响应体,返回错误 - tracing::error!("Failed to read response body: {}", err); - return Err(StatusCode::INTERNAL_SERVER_ERROR); - } - }; - - // 解析原始JSON响应 - if let Ok(json_value) = serde_json::from_slice::(&body_bytes) { - // 加密JSON内容 - if let Ok(encrypted_content) = - aes_secret.encrypt(json_value.to_string().as_bytes()) - { - // 获取AES密钥的Base64表示 - let aes_key_base64 = aes_secret.get_key_as_base64(); - - // 使用用户的公钥加密AES密钥 - if let Ok(encrypted_key) = get_rsa_secret_instance() - .encrypt_with_public_key(aes_key_base64.as_bytes(), &public_key) - { - // 构建加密响应 - let secure_response = json!({ - "data": encrypted_content, - "encrypted": true, - "key": encrypted_key - }); - - // 创建新的响应 - let mut new_response = axum::Json(secure_response).into_response(); - - // 复制原始响应的状态码 - *new_response.status_mut() = parts.status; - - // 添加内容类型头 - new_response.headers_mut().insert( - header::CONTENT_TYPE, - header::HeaderValue::from_static("application/json"), - ); - - return Ok(new_response); - } - } - } - - // 如果加密失败,回退到原始响应 - let fallback = fallback_to_unencrypted_response(body_bytes, parts.status); - return Ok(fallback); - } - Err(_) => { - return Ok(response); - } - } - } - - Ok(response) -} - -// 当加密失败时回退到未加密响应 -fn fallback_to_unencrypted_response(body: Bytes, status: StatusCode) -> Response { - // 尝试解析原始响应为JSON - let json_value = serde_json::from_slice::(&body) - .unwrap_or_else(|_| json!({"raw": String::from_utf8_lossy(&body).to_string()})); - - // 添加未加密标志 - let unencrypted_response = json!({ - "encrypted": false, - "data": json_value - }); - - let mut response = axum::Json(unencrypted_response).into_response(); - *response.status_mut() = status; - - response -} diff --git a/src-tauri/crates/business/src/middlewares/local_api_auth.rs b/src-tauri/crates/business/src/middlewares/local_api_auth.rs deleted file mode 100644 index 833b14e..0000000 --- a/src-tauri/crates/business/src/middlewares/local_api_auth.rs +++ /dev/null @@ -1,219 +0,0 @@ -use axum::{ - extract::{Request, State}, - http::StatusCode, - middleware::Next, - response::Response, -}; -use chrono::Utc; - -use crate::{ - caches::{ - LocalApiKeyCache, LocalApiPermissionCache, get_local_api_key_cache, - get_local_api_permission_cache, get_local_api_permission_definition_cache, - get_local_api_rate_count, increment_local_api_rate_count, set_local_api_key_cache, - set_local_api_permission_cache, set_local_api_permission_definition_cache, - }, - middlewares::{fill_authenticated_request_context, get_request_path_and_method}, - models, - svc_ctx::SvcCtx, -}; - -const LOCAL_API_AUTH_HEADER: &str = "x-local-api-auth"; -const LOCAL_API_KEY_HEADER: &str = "x-local-api-key"; -const LOCAL_API_PERMISSION_HEADER: &str = "x-local-api-permission"; - -pub fn has_local_api_auth_headers(req: &Request) -> bool { - req.headers().contains_key(LOCAL_API_AUTH_HEADER) - || (req.headers().contains_key(LOCAL_API_KEY_HEADER) - && req.headers().contains_key(LOCAL_API_PERMISSION_HEADER)) -} - -fn is_local_api_management_path(path: &str) -> bool { - let business_path = crate::middlewares::extract_resource_path(path); - business_path.starts_with("/local-api") -} - -fn parse_local_api_auth(req: &Request) -> Option<(String, String)> { - if let Some(combined) = req - .headers() - .get(LOCAL_API_AUTH_HEADER) - .and_then(|value| value.to_str().ok()) - { - let (api_key, permission_code) = combined.split_once(':')?; - let api_key = api_key.trim(); - let permission_code = permission_code.trim(); - - if !api_key.is_empty() && !permission_code.is_empty() { - return Some((api_key.to_string(), permission_code.to_string())); - } - } - - let api_key = req - .headers() - .get(LOCAL_API_KEY_HEADER) - .and_then(|value| value.to_str().ok()) - .map(str::trim) - .filter(|value| !value.is_empty())?; - let permission_code = req - .headers() - .get(LOCAL_API_PERMISSION_HEADER) - .and_then(|value| value.to_str().ok()) - .map(str::trim) - .filter(|value| !value.is_empty())?; - - Some((api_key.to_string(), permission_code.to_string())) -} - -pub async fn local_api_auth( - State(state): State, - mut req: Request, - next: Next, -) -> Result { - let (resource_method, resource_path) = get_request_path_and_method(&req); - let resource_method = resource_method.to_string(); - let resource_path = resource_path.to_string(); - - if resource_method == "OPTIONS" || is_local_api_management_path(&resource_path) { - return Ok(next.run(req).await); - } - - let Some((api_key, permission_code)) = parse_local_api_auth(&req) else { - return Ok(next.run(req).await); - }; - - let user_uuid = authorize_local_api_request(&state, api_key.as_str(), permission_code.as_str()) - .await?; - - fill_authenticated_request_context( - &state, - &mut req, - user_uuid, - &resource_method, - &resource_path, - ) - .await; - - Ok(next.run(req).await) -} - -async fn authorize_local_api_request( - state: &SvcCtx, - api_key: &str, - permission_code: &str, -) -> Result { - let key_hash = models::local_api::hash_api_key(api_key); - let api_key = if let Some(cache) = get_local_api_key_cache(state, &key_hash) - .await - .map_err(|_| StatusCode::INTERNAL_SERVER_ERROR)? - { - cache - } else { - let db_api_key = models::local_api::fetch_api_key_by_hash(&state.db, &key_hash) - .await - .map_err(|_| StatusCode::INTERNAL_SERVER_ERROR)? - .ok_or(StatusCode::UNAUTHORIZED)?; - - let cache = LocalApiKeyCache { - id: db_api_key.id, - user_uuid: db_api_key.user_uuid, - is_active: db_api_key.is_active, - expires_at: db_api_key.expires_at, - daily_limit: db_api_key.daily_limit, - }; - set_local_api_key_cache(state, &key_hash, &cache) - .await - .map_err(|_| StatusCode::INTERNAL_SERVER_ERROR)?; - cache - }; - - if !api_key.is_active { - return Err(StatusCode::UNAUTHORIZED); - } - - if let Some(expires_at) = api_key.expires_at { - if expires_at < Utc::now() { - return Err(StatusCode::UNAUTHORIZED); - } - } - - if get_local_api_permission_definition_cache(state, permission_code) - .await - .map_err(|_| StatusCode::INTERNAL_SERVER_ERROR)? - .is_none() - { - let definition = models::local_api::fetch_permission_definition(&state.db, permission_code) - .await - .map_err(|_| StatusCode::INTERNAL_SERVER_ERROR)? - .ok_or(StatusCode::FORBIDDEN)?; - set_local_api_permission_definition_cache(state, permission_code, &definition) - .await - .map_err(|_| StatusCode::INTERNAL_SERVER_ERROR)?; - } - - let permission = if let Some(cache) = - get_local_api_permission_cache(state, api_key.id, permission_code) - .await - .map_err(|_| StatusCode::INTERNAL_SERVER_ERROR)? - { - cache - } else { - let db_permission = - models::local_api::fetch_api_key_permission(&state.db, api_key.id, permission_code) - .await - .map_err(|_| StatusCode::INTERNAL_SERVER_ERROR)? - .ok_or(StatusCode::FORBIDDEN)?; - let cache = LocalApiPermissionCache { - is_enabled: db_permission.is_enabled, - rate_limit_per_minute: db_permission.rate_limit_per_minute, - rate_limit_per_hour: db_permission.rate_limit_per_hour, - }; - set_local_api_permission_cache(state, api_key.id, permission_code, &cache) - .await - .map_err(|_| StatusCode::INTERNAL_SERVER_ERROR)?; - cache - }; - - if !permission.is_enabled { - return Err(StatusCode::FORBIDDEN); - } - - let now = Utc::now(); - let day_key = now.format("%Y%m%d").to_string(); - let hour_key = now.format("%Y%m%d%H").to_string(); - let minute_key = now.format("%Y%m%d%H%M").to_string(); - - let day_count = get_local_api_rate_count(state, api_key.id, None, "day", &day_key) - .await - .map_err(|_| StatusCode::INTERNAL_SERVER_ERROR)?; - if day_count >= i64::from(api_key.daily_limit) { - return Err(StatusCode::TOO_MANY_REQUESTS); - } - - let minute_count = - get_local_api_rate_count(state, api_key.id, Some(permission_code), "minute", &minute_key) - .await - .map_err(|_| StatusCode::INTERNAL_SERVER_ERROR)?; - if minute_count >= i64::from(permission.rate_limit_per_minute) { - return Err(StatusCode::TOO_MANY_REQUESTS); - } - - let hour_count = - get_local_api_rate_count(state, api_key.id, Some(permission_code), "hour", &hour_key) - .await - .map_err(|_| StatusCode::INTERNAL_SERVER_ERROR)?; - if hour_count >= i64::from(permission.rate_limit_per_hour) { - return Err(StatusCode::TOO_MANY_REQUESTS); - } - - increment_local_api_rate_count(state, api_key.id, Some(permission_code), "minute", &minute_key) - .await - .map_err(|_| StatusCode::INTERNAL_SERVER_ERROR)?; - increment_local_api_rate_count(state, api_key.id, Some(permission_code), "hour", &hour_key) - .await - .map_err(|_| StatusCode::INTERNAL_SERVER_ERROR)?; - increment_local_api_rate_count(state, api_key.id, None, "day", &day_key) - .await - .map_err(|_| StatusCode::INTERNAL_SERVER_ERROR)?; - - Ok(api_key.user_uuid) -} diff --git a/src-tauri/crates/business/src/middlewares/logger.rs b/src-tauri/crates/business/src/middlewares/logger.rs deleted file mode 100644 index 045f09c..0000000 --- a/src-tauri/crates/business/src/middlewares/logger.rs +++ /dev/null @@ -1,67 +0,0 @@ -use axum::{extract::Request, http::StatusCode, middleware::Next, response::Response}; -use once_cell::sync::Lazy; -use tokio::{sync::mpsc, time::Instant}; - -#[derive(Debug)] -struct LogEntry { - method: String, - path: String, - status: u16, - latency: u64, -} - -/// 日志通道, 该通道用于发送日志信息 -static LOG_CHANNEL: Lazy> = Lazy::new(|| { - let (tx, rx) = mpsc::channel::(100_000); - spawn_log_processor(rx); - tx -}); - -fn spawn_log_processor(mut rx: mpsc::Receiver) { - // 使用线程而非tokio spawn - // tokio线程用于处理短暂的非阻塞的任务, 如果使用tokio spawn, 会导致整个异步运行时的性能 - std::thread::Builder::new() - .name("log-processor".to_string()) - .spawn(move || { - loop { - match rx.blocking_recv() { - Some(LogEntry { - method, - path, - status, - latency, - }) => { - tracing::info!( - "Request completed: {} {} status={} latency={:?}ms", - method, - path, - status, - latency - ); - } - _ => continue, - } - } - }) - .expect("Failed to spawn log processor thread"); -} - -pub async fn logger(req: Request, next: Next) -> Result { - let start = Instant::now(); - let method = req.method().to_string(); - let path = req.uri().path().to_string(); - - // 处理请求 - let response = next.run(req).await; - - let status = response.status().as_u16(); - - let latency = start.elapsed().as_millis() as u64; - let _ = LOG_CHANNEL.try_send(LogEntry { - method, - path, - status, - latency, - }); - Ok(response) -} diff --git a/src-tauri/crates/business/src/middlewares/real_ip.rs b/src-tauri/crates/business/src/middlewares/real_ip.rs deleted file mode 100644 index 5cae48a..0000000 --- a/src-tauri/crates/business/src/middlewares/real_ip.rs +++ /dev/null @@ -1,47 +0,0 @@ -use std::net::SocketAddr; - -use axum::{ - extract::{ConnectInfo, Request}, - http::StatusCode, - middleware::Next, - response::Response, -}; - -use crate::state::{CurrentIpAddr, RequestContext}; - -pub async fn real_ip(mut req: Request, next: Next) -> Result { - // 验证来源 - let client_ip_opt = req - .extensions() - .get::>() - .map(|ConnectInfo(addr)| addr.ip()); - - // 检查ip地址是否是通过nginx转发(通过nginx就是127.0.0.1)和是否是本地地址 - if let Some(client_ip) = client_ip_opt { - let client_ip_str = client_ip.to_string(); - if &client_ip_str != "127.0.0.1" && !client_ip.is_loopback() { - return Err(StatusCode::FORBIDDEN); - } - } - - // 提取客户端的真实IP - let real_ip = req - .headers() - .get("x-forwarded-for") - .and_then(|v| v.to_str().ok()) - .and_then(|s| s.split(',').next()) - .map(str::trim) - .map(str::to_string) - .or_else(|| { - req.headers().get("x-real-ip").and_then(|v| v.to_str().ok()).map(str::to_string) - }); - - // 初始化 RequestContext 并设置 IP - let mut ctx = RequestContext::default(); - if let Some(ip) = real_ip { - ctx.current_ip_addr = Some(CurrentIpAddr { real_ip: ip }); - } - req.extensions_mut().insert(ctx); - - Ok(next.run(req).await) -} diff --git a/src-tauri/crates/business/src/routes.rs b/src-tauri/crates/business/src/routes.rs deleted file mode 100644 index 39269a6..0000000 --- a/src-tauri/crates/business/src/routes.rs +++ /dev/null @@ -1,169 +0,0 @@ -pub mod health; -pub mod secret; -pub mod time; -pub mod users; - -// 新增模块 -pub mod accounts; -pub mod local_api; -pub mod audit; -pub mod billing; -pub mod browser_kernel; -pub mod environments; -pub mod extensions; -pub mod group_permissions; -pub mod messages; -pub mod preferences; -pub mod proxies; -pub mod proxy_visibility; -pub mod referral; -pub mod rpa; -pub mod teams; -pub mod templates; -pub mod workspace_quotas; -pub mod workspaces; - -// route core -pub mod route { - use std::borrow::Cow; - - use axum::{Router, routing::MethodRouter}; - - use crate::svc_ctx::SvcCtx; - - // can support request methods enum - #[derive(Debug, Clone)] - pub enum RequestMethod { - GET, - POST, - PUT, - DELETE, - PATCH, - } - - /// route item - pub struct RouteItem { - pub path: &'static str, - pub method: RequestMethod, - pub handler: MethodRouter, - } - - /// route group - pub struct RouteGroup { - pub prefix: &'static str, - pub routes: Vec, - } - - /// meta route - pub struct MetaRoute { - pub prefix: Cow<'static, str>, - pub routes: Vec, - } - - impl MetaRoute { - pub fn new(prefix: String) -> Self { - Self { - prefix: Cow::Owned(prefix), - routes: vec![], - } - } - - pub fn add_route_group(&mut self, group: RouteGroup) -> () { - self.routes.push(group); - } - - pub fn count(&self) -> usize { - self.routes.iter().map(|group| group.routes.len()).sum() - } - - pub fn build(&self) -> Router { - let mut root_router_child = Router::new(); - - for contain in &self.routes { - let RouteGroup { prefix, routes } = contain; - - let parent_prefix = prefix.to_string(); - let mut child_router = Router::new(); - - for item in routes { - let RouteItem { - path, - method, - handler, - } = item; - - tracing::info!("{:?}+{}{}{}", method, self.prefix, parent_prefix, path,); - match method { - RequestMethod::GET => { - child_router = child_router.route(path, handler.clone()); - } - RequestMethod::POST => { - child_router = child_router.route(path, handler.clone()); - } - RequestMethod::PUT => { - child_router = child_router.route(path, handler.clone()); - } - RequestMethod::DELETE => { - child_router = child_router.route(path, handler.clone()); - } - RequestMethod::PATCH => { - child_router = child_router.route(path, handler.clone()); - } - } - } - - root_router_child = - root_router_child.merge(Router::new().nest(prefix, child_router)); - } - - Router::new().nest(self.prefix.as_ref(), root_router_child) - } - } - - impl RouteGroup { - pub fn new(prefix: &'static str) -> Self { - Self { - prefix, - routes: vec![], - } - } - - pub fn add_route_item(&mut self, item: RouteItem) { - self.routes.push(item); - } - } - - impl RouteItem { - pub fn new( - path: &'static str, - method: RequestMethod, - handler: MethodRouter, - ) -> Self { - Self { - path, - method, - handler, - } - } - - pub fn get(path: &'static str, handler: MethodRouter) -> Self { - Self::new(path, RequestMethod::GET, handler) - } - - pub fn post(path: &'static str, handler: MethodRouter) -> Self { - Self::new(path, RequestMethod::POST, handler) - } - - pub fn put(path: &'static str, handler: MethodRouter) -> Self { - Self::new(path, RequestMethod::PUT, handler) - } - - pub fn delete(path: &'static str, handler: MethodRouter) -> Self { - Self::new(path, RequestMethod::DELETE, handler) - } - - pub fn patch(path: &'static str, handler: MethodRouter) -> Self { - Self::new(path, RequestMethod::PATCH, handler) - } - } -} diff --git a/src-tauri/crates/business/src/routes/accounts.rs b/src-tauri/crates/business/src/routes/accounts.rs deleted file mode 100644 index 27b9f86..0000000 --- a/src-tauri/crates/business/src/routes/accounts.rs +++ /dev/null @@ -1,37 +0,0 @@ -use axum::routing::post; - -use crate::handlers::{ - batch_delete_accounts_handler, batch_import_accounts_handler, create_account_handler, - delete_account_handler, get_account_handler, get_accounts_handler, update_account_handler, -}; -use crate::routes::route::{self, MetaRoute}; - -/// 注册账号相关的路由 -pub fn register_routes(meta_route: &mut MetaRoute) { - let mut account_route = route::RouteGroup::new("/accounts"); - - account_route.add_route_item(route::RouteItem::post("/list", post(get_accounts_handler))); - account_route.add_route_item(route::RouteItem::post("/detail", post(get_account_handler))); - account_route.add_route_item(route::RouteItem::post( - "/create", - post(create_account_handler), - )); - account_route.add_route_item(route::RouteItem::post( - "/update", - post(update_account_handler), - )); - account_route.add_route_item(route::RouteItem::post( - "/delete", - post(delete_account_handler), - )); - account_route.add_route_item(route::RouteItem::post( - "/batch-delete", - post(batch_delete_accounts_handler), - )); - account_route.add_route_item(route::RouteItem::post( - "/batch-import", - post(batch_import_accounts_handler), - )); - - meta_route.add_route_group(account_route); -} diff --git a/src-tauri/crates/business/src/routes/audit.rs b/src-tauri/crates/business/src/routes/audit.rs deleted file mode 100644 index 66f462c..0000000 --- a/src-tauri/crates/business/src/routes/audit.rs +++ /dev/null @@ -1,31 +0,0 @@ -use axum::routing::post; - -use crate::handlers::{ - export_audit_logs_handler, get_audit_log_detail_handler, get_audit_logs_handler, - get_audit_stats_handler, -}; -use crate::routes::route::{self, MetaRoute}; - -/// 注册审计日志相关的路由 -pub fn register_routes(meta_route: &mut MetaRoute) { - let mut audit_route = route::RouteGroup::new("/audit"); - - audit_route.add_route_item(route::RouteItem::post( - "/logs", - post(get_audit_logs_handler), - )); - audit_route.add_route_item(route::RouteItem::post( - "/logs/detail", - post(get_audit_log_detail_handler), - )); - audit_route.add_route_item(route::RouteItem::post( - "/logs/export", - post(export_audit_logs_handler), - )); - audit_route.add_route_item(route::RouteItem::post( - "/stats", - post(get_audit_stats_handler), - )); - - meta_route.add_route_group(audit_route); -} diff --git a/src-tauri/crates/business/src/routes/billing.rs b/src-tauri/crates/business/src/routes/billing.rs deleted file mode 100644 index fa63a7f..0000000 --- a/src-tauri/crates/business/src/routes/billing.rs +++ /dev/null @@ -1,108 +0,0 @@ -use axum::routing::post; - -use crate::handlers::{ - cancel_subscription_handler, create_recharge_order_handler, get_account_info_handler, - get_auto_renewal_services_handler, get_available_coupons_handler, - get_current_subscription_handler, get_invoices_handler, get_order_status_handler, - get_payment_orders_handler, get_plan_detail_handler, get_plan_price_handler, - get_plans_handler, get_quota_handler, get_transactions_handler, get_user_coupons_handler, - get_wallet_handler, resume_subscription_handler, subscribe_plan_handler, - toggle_auto_renew_handler, verify_coupon_handler, -}; -use crate::routes::route::{self, MetaRoute}; - -/// 注册计费相关的路由 -pub fn register_routes(meta_route: &mut MetaRoute) { - let mut billing_route = route::RouteGroup::new("/billing"); - - // 套餐 - billing_route.add_route_item(route::RouteItem::post("/plans", post(get_plans_handler))); - billing_route.add_route_item(route::RouteItem::post( - "/plans/detail", - post(get_plan_detail_handler), - )); - billing_route.add_route_item(route::RouteItem::post( - "/plans/price", - post(get_plan_price_handler), - )); - - // 订阅 - billing_route.add_route_item(route::RouteItem::post( - "/subscription", - post(get_current_subscription_handler), - )); - billing_route.add_route_item(route::RouteItem::post( - "/subscription/subscribe", - post(subscribe_plan_handler), - )); - billing_route.add_route_item(route::RouteItem::post( - "/subscription/cancel", - post(cancel_subscription_handler), - )); - billing_route.add_route_item(route::RouteItem::post( - "/subscription/resume", - post(resume_subscription_handler), - )); - billing_route.add_route_item(route::RouteItem::post( - "/subscription/toggle-auto-renew", - post(toggle_auto_renew_handler), - )); - - // 钱包 - billing_route.add_route_item(route::RouteItem::post("/wallet", post(get_wallet_handler))); - billing_route.add_route_item(route::RouteItem::post( - "/wallet/transactions", - post(get_transactions_handler), - )); - - // 发票 - billing_route.add_route_item(route::RouteItem::post( - "/invoices", - post(get_invoices_handler), - )); - - // 配额 - billing_route.add_route_item(route::RouteItem::post("/quota", post(get_quota_handler))); - - // 优惠券 - billing_route.add_route_item(route::RouteItem::post( - "/coupon/verify", - post(verify_coupon_handler), - )); - billing_route.add_route_item(route::RouteItem::post( - "/coupons/my-coupons", - post(get_user_coupons_handler), - )); - billing_route.add_route_item(route::RouteItem::post( - "/coupons/available", - post(get_available_coupons_handler), - )); - - // 支付订单 - billing_route.add_route_item(route::RouteItem::post( - "/orders", - post(get_payment_orders_handler), - )); - billing_route.add_route_item(route::RouteItem::post( - "/orders/create-recharge", - post(create_recharge_order_handler), - )); - billing_route.add_route_item(route::RouteItem::post( - "/orders/status", - post(get_order_status_handler), - )); - - // 自动续费服务 - billing_route.add_route_item(route::RouteItem::post( - "/auto-renewal-services", - post(get_auto_renewal_services_handler), - )); - - // 账户信息 - billing_route.add_route_item(route::RouteItem::post( - "/account-info", - post(get_account_info_handler), - )); - - meta_route.add_route_group(billing_route); -} diff --git a/src-tauri/crates/business/src/routes/browser_kernel.rs b/src-tauri/crates/business/src/routes/browser_kernel.rs deleted file mode 100644 index 38536ee..0000000 --- a/src-tauri/crates/business/src/routes/browser_kernel.rs +++ /dev/null @@ -1,16 +0,0 @@ -use axum::routing::post; - -use crate::handlers::browser_kernel::list_browser_kernels_handler; -use crate::routes::route::{self, MetaRoute}; - -/// 注册浏览器内核相关路由 -pub fn register_routes(meta_route: &mut MetaRoute) { - let mut browser_kernel_route = route::RouteGroup::new("/browser-kernels"); - - browser_kernel_route.add_route_item(route::RouteItem::post( - "/list", - post(list_browser_kernels_handler), - )); - - meta_route.add_route_group(browser_kernel_route); -} diff --git a/src-tauri/crates/business/src/routes/environments.rs b/src-tauri/crates/business/src/routes/environments.rs deleted file mode 100644 index 0bb933a..0000000 --- a/src-tauri/crates/business/src/routes/environments.rs +++ /dev/null @@ -1,172 +0,0 @@ -use axum::routing::post; - -use crate::handlers::{ - add_environment_cookie_handler, add_environment_url_handler, assign_tags_handler, - batch_assign_tags_handler, batch_create_environments_handler, - batch_delete_environments_handler, batch_get_environments_handler, - batch_move_to_group_handler, batch_permanent_delete_environments_handler, - batch_remove_tags_handler, batch_restore_environments_handler, - clear_environment_cookies_handler, clear_environment_urls_handler, - create_environment_handler, create_group_handler, create_tag_handler, - delete_environment_cookie_handler, delete_environment_handler, - delete_environment_url_handler, delete_group_handler, delete_tag_handler, - get_environment_cookies_handler, get_environment_handler, get_environment_urls_handler, - get_environments_handler, get_groups_handler, get_recycle_bin_environments_handler, - get_tags_handler, move_to_group_handler, permanent_delete_environment_handler, - remove_tag_handler, restore_environment_handler, set_environment_accounts_handler, - set_proxy_handler, update_environment_handler, update_group_handler, update_tag_handler, -}; -use crate::routes::route::{self, MetaRoute}; - -/// 注册环境相关的路由 -pub fn register_routes(meta_route: &mut MetaRoute) { - // 分组路由 - let mut group_route = route::RouteGroup::new("/groups"); - group_route.add_route_item(route::RouteItem::post("/list", post(get_groups_handler))); - group_route.add_route_item(route::RouteItem::post( - "/create", - post(create_group_handler), - )); - group_route.add_route_item(route::RouteItem::post( - "/update", - post(update_group_handler), - )); - group_route.add_route_item(route::RouteItem::post( - "/delete", - post(delete_group_handler), - )); - meta_route.add_route_group(group_route); - - // 标签路由 - let mut tag_route = route::RouteGroup::new("/tags"); - tag_route.add_route_item(route::RouteItem::post("/list", post(get_tags_handler))); - tag_route.add_route_item(route::RouteItem::post("/create", post(create_tag_handler))); - tag_route.add_route_item(route::RouteItem::post("/update", post(update_tag_handler))); - tag_route.add_route_item(route::RouteItem::post("/delete", post(delete_tag_handler))); - meta_route.add_route_group(tag_route); - - // 环境路由 - let mut env_route = route::RouteGroup::new("/environments"); - env_route.add_route_item(route::RouteItem::post( - "/list", - post(get_environments_handler), - )); - env_route.add_route_item(route::RouteItem::post( - "/detail", - post(get_environment_handler), - )); - env_route.add_route_item(route::RouteItem::post( - "/batch-detail", - post(batch_get_environments_handler), - )); - env_route.add_route_item(route::RouteItem::post( - "/create", - post(create_environment_handler), - )); - env_route.add_route_item(route::RouteItem::post( - "/batch-create", - post(batch_create_environments_handler), - )); - env_route.add_route_item(route::RouteItem::post( - "/update", - post(update_environment_handler), - )); - env_route.add_route_item(route::RouteItem::post( - "/delete", - post(delete_environment_handler), - )); - env_route.add_route_item(route::RouteItem::post( - "/batch-delete", - post(batch_delete_environments_handler), - )); - env_route.add_route_item(route::RouteItem::post( - "/set-proxy", - post(set_proxy_handler), - )); - env_route.add_route_item(route::RouteItem::post( - "/assign-tags", - post(assign_tags_handler), - )); - env_route.add_route_item(route::RouteItem::post( - "/remove-tag", - post(remove_tag_handler), - )); - env_route.add_route_item(route::RouteItem::post( - "/move-to-group", - post(move_to_group_handler), - )); - env_route.add_route_item(route::RouteItem::post( - "/batch-move-to-group", - post(batch_move_to_group_handler), - )); - env_route.add_route_item(route::RouteItem::post( - "/set-accounts", - post(set_environment_accounts_handler), - )); - env_route.add_route_item(route::RouteItem::post( - "/batch-assign-tags", - post(batch_assign_tags_handler), - )); - env_route.add_route_item(route::RouteItem::post( - "/batch-remove-tags", - post(batch_remove_tags_handler), - )); - // URL 管理 - env_route.add_route_item(route::RouteItem::post( - "/urls/list", - post(get_environment_urls_handler), - )); - env_route.add_route_item(route::RouteItem::post( - "/urls/add", - post(add_environment_url_handler), - )); - env_route.add_route_item(route::RouteItem::post( - "/urls/delete", - post(delete_environment_url_handler), - )); - env_route.add_route_item(route::RouteItem::post( - "/urls/clear", - post(clear_environment_urls_handler), - )); - // Cookie 管理 - env_route.add_route_item(route::RouteItem::post( - "/cookies/list", - post(get_environment_cookies_handler), - )); - env_route.add_route_item(route::RouteItem::post( - "/cookies/add", - post(add_environment_cookie_handler), - )); - env_route.add_route_item(route::RouteItem::post( - "/cookies/delete", - post(delete_environment_cookie_handler), - )); - env_route.add_route_item(route::RouteItem::post( - "/cookies/clear", - post(clear_environment_cookies_handler), - )); - - // 回收站路由(作为环境路由的子路由) - env_route.add_route_item(route::RouteItem::post( - "/recycle-bin/list", - post(get_recycle_bin_environments_handler), - )); - env_route.add_route_item(route::RouteItem::post( - "/recycle-bin/restore", - post(restore_environment_handler), - )); - env_route.add_route_item(route::RouteItem::post( - "/recycle-bin/batch-restore", - post(batch_restore_environments_handler), - )); - env_route.add_route_item(route::RouteItem::post( - "/recycle-bin/permanent-delete", - post(permanent_delete_environment_handler), - )); - env_route.add_route_item(route::RouteItem::post( - "/recycle-bin/batch-permanent-delete", - post(batch_permanent_delete_environments_handler), - )); - - meta_route.add_route_group(env_route); -} diff --git a/src-tauri/crates/business/src/routes/extensions.rs b/src-tauri/crates/business/src/routes/extensions.rs deleted file mode 100644 index f68f010..0000000 --- a/src-tauri/crates/business/src/routes/extensions.rs +++ /dev/null @@ -1,64 +0,0 @@ -use axum::routing::post; - -use crate::handlers::{ - batch_update_extensions_handler, disable_extension_handler, enable_extension_handler, - get_extension_categories_handler, get_extension_detail_handler, get_extensions_handler, - get_installed_extensions_handler, install_extension_handler, uninstall_extension_handler, - update_extension_handler, -}; -use crate::routes::route::{self, MetaRoute}; - -/// 注册扩展管理相关的路由 -pub fn register_routes(meta_route: &mut MetaRoute) { - let mut ext_route = route::RouteGroup::new("/extensions"); - - // 扩展市场 - ext_route.add_route_item(route::RouteItem::post( - "/list", - post(get_extensions_handler), - )); - ext_route.add_route_item(route::RouteItem::post( - "/detail", - post(get_extension_detail_handler), - )); - ext_route.add_route_item(route::RouteItem::post( - "/categories", - post(get_extension_categories_handler), - )); - - // 已安装扩展 - ext_route.add_route_item(route::RouteItem::post( - "/installed", - post(get_installed_extensions_handler), - )); - - // 安装/卸载/更新 - ext_route.add_route_item(route::RouteItem::post( - "/install", - post(install_extension_handler), - )); - ext_route.add_route_item(route::RouteItem::post( - "/uninstall", - post(uninstall_extension_handler), - )); - ext_route.add_route_item(route::RouteItem::post( - "/update", - post(update_extension_handler), - )); - ext_route.add_route_item(route::RouteItem::post( - "/batch-update", - post(batch_update_extensions_handler), - )); - - // 禁用/启用扩展 - ext_route.add_route_item(route::RouteItem::post( - "/disable", - post(disable_extension_handler), - )); - ext_route.add_route_item(route::RouteItem::post( - "/enable", - post(enable_extension_handler), - )); - - meta_route.add_route_group(ext_route); -} diff --git a/src-tauri/crates/business/src/routes/group_permissions.rs b/src-tauri/crates/business/src/routes/group_permissions.rs deleted file mode 100644 index 5ffcc53..0000000 --- a/src-tauri/crates/business/src/routes/group_permissions.rs +++ /dev/null @@ -1,28 +0,0 @@ -use axum::routing::post; - -use crate::handlers::group_permissions; -use crate::routes::route::{self, MetaRoute}; - -/// 注册分组权限相关的路由 -pub fn register_routes(meta_route: &mut MetaRoute) { - let mut group_permission_route = route::RouteGroup::new("/group-permissions"); - - group_permission_route.add_route_item(route::RouteItem::post( - "/grant", - post(group_permissions::grant_group_permission_handler), - )); - group_permission_route.add_route_item(route::RouteItem::post( - "/revoke", - post(group_permissions::revoke_group_permission_handler), - )); - group_permission_route.add_route_item(route::RouteItem::post( - "/check", - post(group_permissions::check_group_permission_handler), - )); - group_permission_route.add_route_item(route::RouteItem::post( - "/list", - post(group_permissions::list_user_group_permissions_handler), - )); - - meta_route.add_route_group(group_permission_route); -} diff --git a/src-tauri/crates/business/src/routes/health.rs b/src-tauri/crates/business/src/routes/health.rs deleted file mode 100644 index 415ca9d..0000000 --- a/src-tauri/crates/business/src/routes/health.rs +++ /dev/null @@ -1,13 +0,0 @@ -use axum::routing::get; - -use crate::handlers::health_check_handler; -use crate::routes::route::{self, MetaRoute}; - -/// 注册健康检查路由 -pub fn register_routes(meta_route: &mut MetaRoute) -> () { - let mut health_route = route::RouteGroup::new("/health"); - - health_route.add_route_item(route::RouteItem::get("/", get(health_check_handler))); - - meta_route.add_route_group(health_route); -} diff --git a/src-tauri/crates/business/src/routes/local_api.rs b/src-tauri/crates/business/src/routes/local_api.rs deleted file mode 100644 index c637c1a..0000000 --- a/src-tauri/crates/business/src/routes/local_api.rs +++ /dev/null @@ -1,27 +0,0 @@ -use axum::routing::post; - -use crate::handlers::local_api; -use crate::routes::route::{self, MetaRoute}; - -pub fn register_routes(meta_route: &mut MetaRoute) { - let mut local_api_route = route::RouteGroup::new("/local-api"); - - local_api_route.add_route_item(route::RouteItem::post( - "/get", - post(local_api::get_local_api_config_handler), - )); - local_api_route.add_route_item(route::RouteItem::post( - "/update", - post(local_api::update_local_api_config_handler), - )); - local_api_route.add_route_item(route::RouteItem::post( - "/reset-api-key", - post(local_api::reset_local_api_key_handler), - )); - local_api_route.add_route_item(route::RouteItem::post( - "/validate", - post(local_api::validate_local_api_key_handler), - )); - - meta_route.add_route_group(local_api_route); -} diff --git a/src-tauri/crates/business/src/routes/messages.rs b/src-tauri/crates/business/src/routes/messages.rs deleted file mode 100644 index fb1a13b..0000000 --- a/src-tauri/crates/business/src/routes/messages.rs +++ /dev/null @@ -1,45 +0,0 @@ -use axum::routing::post; - -use crate::handlers::{ - batch_mark_messages_read_handler, create_message_handler, delete_message_handler, - get_user_message_stats_handler, get_user_messages_handler, handle_message_handler, - mark_message_read_handler, -}; -use crate::routes::route::{self, MetaRoute}; - -/// 注册消息相关的路由 -pub fn register_routes(meta_route: &mut MetaRoute) { - let mut message_route = route::RouteGroup::new("/messages"); - - // 消息管理 - message_route.add_route_item(route::RouteItem::post( - "/create", - post(create_message_handler), - )); - message_route.add_route_item(route::RouteItem::post( - "/list", - post(get_user_messages_handler), - )); - message_route.add_route_item(route::RouteItem::post( - "/stats", - post(get_user_message_stats_handler), - )); - message_route.add_route_item(route::RouteItem::post( - "/read", - post(mark_message_read_handler), - )); - message_route.add_route_item(route::RouteItem::post( - "/batch-read", - post(batch_mark_messages_read_handler), - )); - message_route.add_route_item(route::RouteItem::post( - "/handle", - post(handle_message_handler), - )); - message_route.add_route_item(route::RouteItem::post( - "/delete", - post(delete_message_handler), - )); - - meta_route.add_route_group(message_route); -} diff --git a/src-tauri/crates/business/src/routes/preferences.rs b/src-tauri/crates/business/src/routes/preferences.rs deleted file mode 100644 index a813823..0000000 --- a/src-tauri/crates/business/src/routes/preferences.rs +++ /dev/null @@ -1,20 +0,0 @@ -use axum::routing::post; - -use crate::handlers::{get_preferences_handler, update_preferences_handler}; -use crate::routes::route::{self, MetaRoute}; - -/// 注册用户偏好设置相关的路由 -pub fn register_routes(meta_route: &mut MetaRoute) { - let mut pref_route = route::RouteGroup::new("/preferences"); - - pref_route.add_route_item(route::RouteItem::post( - "/get", - post(get_preferences_handler), - )); - pref_route.add_route_item(route::RouteItem::post( - "/update", - post(update_preferences_handler), - )); - - meta_route.add_route_group(pref_route); -} diff --git a/src-tauri/crates/business/src/routes/proxies.rs b/src-tauri/crates/business/src/routes/proxies.rs deleted file mode 100644 index 5428403..0000000 --- a/src-tauri/crates/business/src/routes/proxies.rs +++ /dev/null @@ -1,37 +0,0 @@ -use axum::routing::post; - -use crate::handlers::{ - batch_delete_proxies_handler, batch_import_proxies_handler, create_proxy_handler, - delete_proxy_handler, get_proxies_handler, get_proxy_handler, update_proxy_handler, -}; -use crate::routes::route::{self, MetaRoute}; - -/// 注册代理相关的路由 -pub fn register_routes(meta_route: &mut MetaRoute) { - let mut proxy_route = route::RouteGroup::new("/proxies"); - - proxy_route.add_route_item(route::RouteItem::post("/list", post(get_proxies_handler))); - proxy_route.add_route_item(route::RouteItem::post("/detail", post(get_proxy_handler))); - proxy_route.add_route_item(route::RouteItem::post( - "/create", - post(create_proxy_handler), - )); - proxy_route.add_route_item(route::RouteItem::post( - "/update", - post(update_proxy_handler), - )); - proxy_route.add_route_item(route::RouteItem::post( - "/delete", - post(delete_proxy_handler), - )); - proxy_route.add_route_item(route::RouteItem::post( - "/batch-delete", - post(batch_delete_proxies_handler), - )); - proxy_route.add_route_item(route::RouteItem::post( - "/batch-import", - post(batch_import_proxies_handler), - )); - - meta_route.add_route_group(proxy_route); -} diff --git a/src-tauri/crates/business/src/routes/proxy_visibility.rs b/src-tauri/crates/business/src/routes/proxy_visibility.rs deleted file mode 100644 index 7720c88..0000000 --- a/src-tauri/crates/business/src/routes/proxy_visibility.rs +++ /dev/null @@ -1,32 +0,0 @@ -use axum::routing::post; - -use crate::handlers::proxy_visibility; -use crate::routes::route::{self, MetaRoute}; - -/// 注册代理可见性相关的路由 -pub fn register_routes(meta_route: &mut MetaRoute) { - let mut proxy_visibility_route = route::RouteGroup::new("/proxy-visibility"); - - proxy_visibility_route.add_route_item(route::RouteItem::post( - "/set", - post(proxy_visibility::set_proxy_visible_handler), - )); - proxy_visibility_route.add_route_item(route::RouteItem::post( - "/remove", - post(proxy_visibility::remove_proxy_visible_handler), - )); - proxy_visibility_route.add_route_item(route::RouteItem::post( - "/batch-set", - post(proxy_visibility::batch_set_proxy_visible_handler), - )); - proxy_visibility_route.add_route_item(route::RouteItem::post( - "/list-visible", - post(proxy_visibility::get_visible_proxies_handler), - )); - proxy_visibility_route.add_route_item(route::RouteItem::post( - "/list-teams", - post(proxy_visibility::get_proxy_visible_teams_handler), - )); - - meta_route.add_route_group(proxy_visibility_route); -} diff --git a/src-tauri/crates/business/src/routes/referral.rs b/src-tauri/crates/business/src/routes/referral.rs deleted file mode 100644 index c969862..0000000 --- a/src-tauri/crates/business/src/routes/referral.rs +++ /dev/null @@ -1,82 +0,0 @@ -use axum::routing::post; - -use crate::handlers::{ - get_redeem_options_handler, get_redeem_records_handler, get_referral_dashboard_handler, - get_referral_links_handler, get_referral_plan_summary_handler, get_referral_rewards_handler, - get_referral_stats_handler, get_referral_tiers_handler, get_referred_users_handler, - get_user_points_handler, redeem_points_handler, switch_referral_link_handler, -}; -use crate::routes::route::{self, MetaRoute}; - -/// 注册推荐计划相关的路由 -pub fn register_routes(meta_route: &mut MetaRoute) { - let mut referral_route = route::RouteGroup::new("/referral"); - - // 统计 - referral_route.add_route_item(route::RouteItem::post( - "/stats", - post(get_referral_stats_handler), - )); - - // 统计看板聚合 - referral_route.add_route_item(route::RouteItem::post( - "/dashboard", - post(get_referral_dashboard_handler), - )); - - // 套餐页推广摘要 - referral_route.add_route_item(route::RouteItem::post( - "/summary-for-plan", - post(get_referral_plan_summary_handler), - )); - - // 层级配置 - referral_route.add_route_item(route::RouteItem::post( - "/tiers", - post(get_referral_tiers_handler), - )); - - // 推广链接 - referral_route.add_route_item(route::RouteItem::post( - "/links", - post(get_referral_links_handler), - )); - referral_route.add_route_item(route::RouteItem::post( - "/links/switch", - post(switch_referral_link_handler), - )); - - // 奖励记录 - referral_route.add_route_item(route::RouteItem::post( - "/rewards", - post(get_referral_rewards_handler), - )); - - // 被邀请用户 - referral_route.add_route_item(route::RouteItem::post( - "/users", - post(get_referred_users_handler), - )); - - // 积分 - referral_route.add_route_item(route::RouteItem::post( - "/points", - post(get_user_points_handler), - )); - - // 兑换选项 - referral_route.add_route_item(route::RouteItem::post( - "/redeem/options", - post(get_redeem_options_handler), - )); - referral_route.add_route_item(route::RouteItem::post( - "/redeem", - post(redeem_points_handler), - )); - referral_route.add_route_item(route::RouteItem::post( - "/redeem/records", - post(get_redeem_records_handler), - )); - - meta_route.add_route_group(referral_route); -} diff --git a/src-tauri/crates/business/src/routes/rpa.rs b/src-tauri/crates/business/src/routes/rpa.rs deleted file mode 100644 index ecb308a..0000000 --- a/src-tauri/crates/business/src/routes/rpa.rs +++ /dev/null @@ -1,44 +0,0 @@ -use axum::routing::post; - -use crate::handlers::{ - batch_delete_rpa_tasks_handler, create_rpa_task_handler, delete_rpa_task_handler, - duplicate_rpa_task_handler, export_rpa_task_handler, get_rpa_task_handler, - get_rpa_tasks_handler, update_rpa_task_handler, -}; -use crate::routes::route::{self, MetaRoute}; - -pub fn register_routes(meta_route: &mut MetaRoute) { - let mut rpa_route = route::RouteGroup::new("/rpa"); - - rpa_route.add_route_item(route::RouteItem::post("/tasks", post(get_rpa_tasks_handler))); - rpa_route.add_route_item(route::RouteItem::post( - "/tasks/detail", - post(get_rpa_task_handler), - )); - rpa_route.add_route_item(route::RouteItem::post( - "/tasks/create", - post(create_rpa_task_handler), - )); - rpa_route.add_route_item(route::RouteItem::post( - "/tasks/update", - post(update_rpa_task_handler), - )); - rpa_route.add_route_item(route::RouteItem::post( - "/tasks/delete", - post(delete_rpa_task_handler), - )); - rpa_route.add_route_item(route::RouteItem::post( - "/tasks/batch-delete", - post(batch_delete_rpa_tasks_handler), - )); - rpa_route.add_route_item(route::RouteItem::post( - "/tasks/duplicate", - post(duplicate_rpa_task_handler), - )); - rpa_route.add_route_item(route::RouteItem::post( - "/tasks/export", - post(export_rpa_task_handler), - )); - - meta_route.add_route_group(rpa_route); -} diff --git a/src-tauri/crates/business/src/routes/secret.rs b/src-tauri/crates/business/src/routes/secret.rs deleted file mode 100644 index f2f6b0c..0000000 --- a/src-tauri/crates/business/src/routes/secret.rs +++ /dev/null @@ -1,16 +0,0 @@ -use axum::routing::get; - -use crate::handlers::get_public_key_handler; -use crate::routes::route::{self, MetaRoute}; - -/// 注册 secret 相关的路由 -pub fn register_routes(meta_route: &mut MetaRoute) -> () { - let mut secret_route = route::RouteGroup::new("/secret"); - - secret_route.add_route_item(route::RouteItem::get( - "/public/key", - get(get_public_key_handler), - )); - - meta_route.add_route_group(secret_route); -} diff --git a/src-tauri/crates/business/src/routes/teams.rs b/src-tauri/crates/business/src/routes/teams.rs deleted file mode 100644 index 01018d8..0000000 --- a/src-tauri/crates/business/src/routes/teams.rs +++ /dev/null @@ -1,61 +0,0 @@ -use axum::routing::post; - -use crate::handlers::{ - accept_invitation_handler, cancel_invitation_handler, create_team_handler, - get_my_teams_handler, get_pending_invitations_handler, get_team_handler, - get_team_members_handler, invite_member_handler, leave_team_handler, reject_invitation_handler, - remove_member_handler, switch_team_handler, update_member_role_handler, update_team_handler, -}; -use crate::routes::route::{self, MetaRoute}; - -/// 注册团队相关的路由 -pub fn register_routes(meta_route: &mut MetaRoute) { - let mut team_route = route::RouteGroup::new("/teams"); - - // 团队管理 - team_route.add_route_item(route::RouteItem::post("/create", post(create_team_handler))); - team_route.add_route_item(route::RouteItem::post( - "/my-teams", - post(get_my_teams_handler), - )); - team_route.add_route_item(route::RouteItem::post("/switch", post(switch_team_handler))); - team_route.add_route_item(route::RouteItem::post("/detail", post(get_team_handler))); - team_route.add_route_item(route::RouteItem::post("/update", post(update_team_handler))); - team_route.add_route_item(route::RouteItem::post("/leave", post(leave_team_handler))); - - // 成员管理 - team_route.add_route_item(route::RouteItem::post( - "/members", - post(get_team_members_handler), - )); - team_route.add_route_item(route::RouteItem::post( - "/invite", - post(invite_member_handler), - )); - team_route.add_route_item(route::RouteItem::post( - "/invitations", - post(get_pending_invitations_handler), - )); - team_route.add_route_item(route::RouteItem::post( - "/invitation/cancel", - post(cancel_invitation_handler), - )); - team_route.add_route_item(route::RouteItem::post( - "/invitation/accept", - post(accept_invitation_handler), - )); - team_route.add_route_item(route::RouteItem::post( - "/invitation/reject", - post(reject_invitation_handler), - )); - team_route.add_route_item(route::RouteItem::post( - "/member/role", - post(update_member_role_handler), - )); - team_route.add_route_item(route::RouteItem::post( - "/member/remove", - post(remove_member_handler), - )); - - meta_route.add_route_group(team_route); -} diff --git a/src-tauri/crates/business/src/routes/templates.rs b/src-tauri/crates/business/src/routes/templates.rs deleted file mode 100644 index 36ad572..0000000 --- a/src-tauri/crates/business/src/routes/templates.rs +++ /dev/null @@ -1,40 +0,0 @@ -use axum::routing::post; - -use crate::handlers::{ - apply_template_handler, create_from_template_handler, create_template_handler, - delete_template_handler, get_template_handler, get_templates_handler, update_template_handler, -}; -use crate::routes::route::{self, MetaRoute}; - -/// 注册模板相关的路由 -pub fn register_routes(meta_route: &mut MetaRoute) { - let mut template_route = route::RouteGroup::new("/templates"); - - template_route.add_route_item(route::RouteItem::post("/list", post(get_templates_handler))); - template_route.add_route_item(route::RouteItem::post( - "/detail", - post(get_template_handler), - )); - template_route.add_route_item(route::RouteItem::post( - "/create", - post(create_template_handler), - )); - template_route.add_route_item(route::RouteItem::post( - "/update", - post(update_template_handler), - )); - template_route.add_route_item(route::RouteItem::post( - "/delete", - post(delete_template_handler), - )); - template_route.add_route_item(route::RouteItem::post( - "/apply", - post(apply_template_handler), - )); - template_route.add_route_item(route::RouteItem::post( - "/create-from", - post(create_from_template_handler), - )); - - meta_route.add_route_group(template_route); -} diff --git a/src-tauri/crates/business/src/routes/time.rs b/src-tauri/crates/business/src/routes/time.rs deleted file mode 100644 index 6045d39..0000000 --- a/src-tauri/crates/business/src/routes/time.rs +++ /dev/null @@ -1,12 +0,0 @@ -use axum::routing::get; - -use crate::handlers::now_handle; -use crate::routes::route::{self, MetaRoute}; - -pub fn register_routes(meta_route: &mut MetaRoute) -> () { - let mut time_route = route::RouteGroup::new("/time"); - - time_route.add_route_item(route::RouteItem::get("/now", get(now_handle))); - - meta_route.add_route_group(time_route); -} diff --git a/src-tauri/crates/business/src/routes/users.rs b/src-tauri/crates/business/src/routes/users.rs deleted file mode 100644 index 5def5c3..0000000 --- a/src-tauri/crates/business/src/routes/users.rs +++ /dev/null @@ -1,56 +0,0 @@ -use axum::routing::post; - -use crate::handlers::{ - get_current_user_handler, login_handler, refresh_token_handler, register_handler, - reset_password_handler, send_code_handler, update_password_handler, update_user_handler, - verify_password_handler, -}; -use crate::routes::route::{self, MetaRoute}; - -/// 注册用户相关的路由 -pub fn register_routes(meta_route: &mut MetaRoute) -> () { - let mut user_route = route::RouteGroup::new("/users"); - - user_route.add_route_item(route::RouteItem::post("/register", post(register_handler))); - - user_route.add_route_item(route::RouteItem::post("/login", post(login_handler))); - - user_route.add_route_item(route::RouteItem::post( - "/refresh-credentials", - post(refresh_token_handler), - )); - - user_route.add_route_item(route::RouteItem::post( - "/me", - post(get_current_user_handler), - )); - - user_route.add_route_item(route::RouteItem::post("/update", post(update_user_handler))); - - user_route.add_route_item(route::RouteItem::post( - "/password", - post(update_password_handler), - )); - - user_route.add_route_item(route::RouteItem::post( - "/verify-password", - post(verify_password_handler), - )); - - user_route.add_route_item(route::RouteItem::post( - "/reset-password", - post(reset_password_handler), - )); - - user_route.add_route_item(route::RouteItem::post( - "/register-send-code", - post(send_code_handler), - )); - - user_route.add_route_item(route::RouteItem::post( - "/reset-password-send-code", - post(send_code_handler), - )); - - meta_route.add_route_group(user_route); -} diff --git a/src-tauri/crates/business/src/routes/workspace_quotas.rs b/src-tauri/crates/business/src/routes/workspace_quotas.rs deleted file mode 100644 index fef9771..0000000 --- a/src-tauri/crates/business/src/routes/workspace_quotas.rs +++ /dev/null @@ -1,20 +0,0 @@ -use axum::routing::post; - -use crate::handlers::workspace_quotas; -use crate::routes::route::{self, MetaRoute}; - -/// 注册工作空间配额相关的路由 -pub fn register_routes(meta_route: &mut MetaRoute) { - let mut quota_route = route::RouteGroup::new("/workspace-quotas"); - - quota_route.add_route_item(route::RouteItem::post( - "/get", - post(workspace_quotas::get_workspace_quota_handler), - )); - quota_route.add_route_item(route::RouteItem::post( - "/update", - post(workspace_quotas::update_quota_usage_handler), - )); - - meta_route.add_route_group(quota_route); -} diff --git a/src-tauri/crates/business/src/routes/workspaces.rs b/src-tauri/crates/business/src/routes/workspaces.rs deleted file mode 100644 index 2b57dd2..0000000 --- a/src-tauri/crates/business/src/routes/workspaces.rs +++ /dev/null @@ -1,36 +0,0 @@ -use axum::routing::post; - -use crate::handlers::workspaces; -use crate::routes::route::{self, MetaRoute}; - -/// 注册工作空间相关的路由 -pub fn register_routes(meta_route: &mut MetaRoute) { - let mut workspace_route = route::RouteGroup::new("/workspaces"); - - workspace_route.add_route_item(route::RouteItem::post( - "/create", - post(workspaces::create_workspace_handler), - )); - workspace_route.add_route_item(route::RouteItem::post( - "/list", - post(workspaces::get_my_workspaces_handler), - )); - workspace_route.add_route_item(route::RouteItem::post( - "/get", - post(workspaces::get_workspace_handler), - )); - workspace_route.add_route_item(route::RouteItem::post( - "/update", - post(workspaces::update_workspace_handler), - )); - workspace_route.add_route_item(route::RouteItem::post( - "/delete", - post(workspaces::delete_workspace_handler), - )); - workspace_route.add_route_item(route::RouteItem::post( - "/switch", - post(workspaces::switch_workspace_handler), - )); - - meta_route.add_route_group(workspace_route); -} diff --git a/src-tauri/crates/business/src/utils.rs b/src-tauri/crates/business/src/utils.rs index ef5f795..c51c2f1 100644 --- a/src-tauri/crates/business/src/utils.rs +++ b/src-tauri/crates/business/src/utils.rs @@ -1,18 +1,12 @@ -mod config; -mod extractor; -mod jwt; -mod password; -mod responses; -mod secret; -pub mod storage; - -pub use config::*; -pub use extractor::*; -pub use jwt::*; -pub use password::*; -pub use responses::*; -pub use secret::*; -pub use storage::*; +mod config; +mod jwt; +mod password; +pub mod storage; + +pub use config::*; +pub use jwt::*; +pub use password::*; +pub use storage::*; /// 向指定邮箱发送验证码 pub fn send_email( diff --git a/src-tauri/crates/business/src/utils/extractor.rs b/src-tauri/crates/business/src/utils/extractor.rs deleted file mode 100644 index 78f8b82..0000000 --- a/src-tauri/crates/business/src/utils/extractor.rs +++ /dev/null @@ -1,43 +0,0 @@ -use axum::{ - extract::{self, FromRequest, Request, rejection::JsonRejection}, - http::StatusCode, -}; -use serde::{Serialize, de::DeserializeOwned}; - -use crate::utils::Response; - -/// 自定义Extractor -/// -/// 不使用默认的JSON extractor, 通过该extract提取可以在提取成功或失败时完成额外的操作。 -pub struct Json(pub T); - -impl FromRequest for Json -where - T: DeserializeOwned + Serialize, - S: Send + Sync, -{ - type Rejection = Response; - - async fn from_request(req: Request, state: &S) -> Result { - match extract::Json::::from_request(req, state).await { - Ok(body) => Ok(Self(body.0)), - Err(rejection) => { - let (status, message) = match rejection { - JsonRejection::JsonDataError(e) => { - eprintln!("{:?}", e); - (StatusCode::BAD_REQUEST, "请求参数错误") - } - JsonRejection::JsonSyntaxError(_) => { - (StatusCode::BAD_REQUEST, "请求参数语法错误") - } - JsonRejection::MissingJsonContentType(_) => { - (StatusCode::BAD_REQUEST, "缺少请求参数") - } - _ => (StatusCode::OK, ""), - }; - - Err(Response::fail_with_statu_code(Some(message), status)) - } - } - } -} diff --git a/src-tauri/crates/business/src/utils/responses.rs b/src-tauri/crates/business/src/utils/responses.rs deleted file mode 100644 index 065b83d..0000000 --- a/src-tauri/crates/business/src/utils/responses.rs +++ /dev/null @@ -1,126 +0,0 @@ -use axum::{ - extract::multipart::MultipartError, - http::StatusCode, - response::{IntoResponse, Json}, -}; -use serde::{Deserialize, Serialize}; -use serde_json::json; -use std::fmt::{Debug, Display}; - -const SUCCESS_CODE: i32 = 1; -const FAIL_CODE: i32 = 0; - -pub type Result = axum::response::Result, Response>; - -/// 统一响应类型 -#[derive(Debug, Serialize, Clone, Deserialize)] -pub struct Response { - pub status_code: u16, - pub code: i32, - pub data: Option, - pub message: Option, -} - -impl Response { - /// 操作成功对应的响应类型 - pub fn success(message: Option<&str>, data: Option) -> Self { - Response { - code: SUCCESS_CODE, - message: message.map(|v| v.to_string()), - data, - status_code: 200, - } - } - - /// 操作失败对应的响应类型 - pub fn fail(message: Option<&str>) -> Self { - Response { - code: FAIL_CODE, - message: message.map(|v| v.to_string()), - data: None, - status_code: 200, - } - } - - /// 操作失败对应的响应类型 - pub fn fail_with_statu_code(message: Option<&str>, statu_code: StatusCode) -> Self { - Response { - code: FAIL_CODE, - message: message.map(|v| v.to_string()), - data: None, - status_code: statu_code.as_u16(), - } - } - - /// with status code - pub fn with_status_code(self, status_code: StatusCode) -> Self { - let mut response = self; - response.status_code = status_code.as_u16(); - response - } -} - -// 允许直接打印和to_string -impl Display for Response { - fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result { - write!( - f, - "Response {{ status_code: {}, code: {}, message: {:?}, has_data: {} }}", - self.status_code, - self.code, - self.message, - self.data.is_some() - ) - } -} - -// 当实现该类型后可以直接将Response作为axum路由处理函数的返回值,会自动调用该trait的into_response方法最终返回json. -impl IntoResponse for Response -where - T: Serialize, -{ - fn into_response(self) -> axum::response::Response { - let mut content_json = json!({ "code": self.code }); - - // 如果 message 存在,添加到 content_json - if let Some(message) = &self.message { - content_json["message"] = json!(message); - } - - // 如果 data 存在,添加到 content_json - if let Some(data) = &self.data { - content_json["data"] = json!(data); - } - - match StatusCode::from_u16(self.status_code) { - Ok(status) => (status, Json(content_json)), - Err(_) => (StatusCode::BAD_REQUEST, Json(content_json)), - } - .into_response() - } -} - -/// 实现From -impl From for Response<()> { - fn from(value: MultipartError) -> Self { - Response::fail_with_statu_code( - Some(format!("请求参数错误: {:?}", value.body_text()).as_ref()), - value.status(), - ) - } -} - -#[cfg(test)] -mod tests { - use super::Response; - - #[test] - fn display_response_without_recursion() { - let response = Response::success(Some("ok"), Some(42)); - - assert_eq!( - response.to_string(), - "Response { status_code: 200, code: 1, message: Some(\"ok\"), has_data: true }" - ); - } -} diff --git a/src-tauri/crates/business/src/utils/secret/aes.rs b/src-tauri/crates/business/src/utils/secret/aes.rs deleted file mode 100644 index 23174a9..0000000 --- a/src-tauri/crates/business/src/utils/secret/aes.rs +++ /dev/null @@ -1,216 +0,0 @@ -use aes_gcm::{ - Aes256Gcm, Key, Nonce, - aead::{Aead, AeadCore, KeyInit, OsRng}, -}; -use anyhow::Context; -use base64::{Engine as _, engine::general_purpose::STANDARD as BASE64_STANDARD}; -use std::fmt::Debug; - -const AES_KEY_SIZE: usize = 32; -const NONCE_SIZE: usize = 12; - -/// key本身是字节数组, 先将字节数组转换成base64编码得字符串,然后再通过非对称公钥加密再转换成base64编码的字符串 -#[derive(Debug, Clone, Default)] -pub struct AesSecret { - key: Key, // 直接存储 GenericArray 以避免重复转换 -} - -impl AesSecret { - /// 生成一个新的 AES 密钥实例 - pub fn new() -> Self { - let key_bytes = Aes256Gcm::generate_key(&mut OsRng); - AesSecret { key: key_bytes } - } - - /// 从已有的密钥字节创建 AesSecret 实例 - /// 输入的 key_bytes 必须是正确的长度 (例如 32 字节 for AES-256) - pub fn from_bytes(key_bytes: &[u8]) -> Result { - if key_bytes.len() != AES_KEY_SIZE { - return Err(anyhow::anyhow!( - "Invalid key size: expected {}, got {}", - AES_KEY_SIZE, - key_bytes.len() - )); - } - Ok(AesSecret { - key: Key::::clone_from_slice(key_bytes), - }) - } - - /// 获取 AES 密钥的 Base64 编码字符串 - pub fn get_key_as_base64(&self) -> String { - BASE64_STANDARD.encode(self.key.as_slice()) - } - - /// 使用 AES 密钥加密数据 - /// 返回: 加密后的数据 Vec与Nonce Vec拼接的 Vec - pub fn encrypt(&self, data: &[u8]) -> Result { - let cipher = Aes256Gcm::new(&self.key); - // 为每次加密生成新的、唯一的 Nonce - let nonce_bytes = Aes256Gcm::generate_nonce(&mut OsRng); // 生成随机 Nonce - let nonce_instance = Nonce::from_slice(nonce_bytes.as_slice()); - - let ciphertext = cipher.encrypt(nonce_instance, data).map_err(|e| { - tracing::error!("Failed to encrypt data: {:?}", e); - // 使用 context 来添加上下文信息 - anyhow::anyhow!("Failed to encrypt data: {:?}", e) - })?; - - let mut result = Vec::with_capacity(ciphertext.len() + NONCE_SIZE); - result.extend_from_slice(nonce_bytes.as_slice()); - result.extend_from_slice(&ciphertext); - - // 将结果转换为 Base64 编码字符串 - let result = BASE64_STANDARD.encode(&result); - - Ok(result) - } - - /// 使用 AES 密钥和提供的 Nonce 解密数据 - pub fn decrypt(&self, base64_data: &str) -> Result, anyhow::Error> { - // 先对Base64编码的字符串进行解码 - let data = BASE64_STANDARD.decode(base64_data).context("Failed to decode base64 data")?; - - if data.len() < NONCE_SIZE { - return Err(anyhow::anyhow!("Invalid data")); - } - - let (nonce, ciphertext) = data.split_at(NONCE_SIZE); - let cipher = Aes256Gcm::new(&self.key); - let nonce_instance = Nonce::from_slice(nonce); - - cipher.decrypt(nonce_instance, ciphertext).map_err(|e| { - tracing::error!("Failed to decrypt data: {:?}", e); - anyhow::anyhow!("Failed to decrypt data: {:?}", e) - }) - } -} - -/// 从 Base64 编码的密钥字符串创建 AesSecret -impl TryFrom<&str> for AesSecret { - type Error = anyhow::Error; - - fn try_from(key_base64: &str) -> Result { - let decoded_key_bytes = BASE64_STANDARD - .decode(key_base64) - .context("Failed to decode base64 key string")?; - - Self::from_bytes(&decoded_key_bytes) - } -} - -#[cfg(test)] -mod tests { - - use super::*; - - #[test] - fn test_encrypt_decrypt_workflow() { - let secret = AesSecret::new(); - let plaintext = b"Hello, secure world of AES-GCM!"; // 字节数组 - - // 加密 - let encrypt_str = secret.encrypt(plaintext).expect("Encryption failed"); - println!("encrypt_str: {}", encrypt_str); - - // 解密 - let decrypted_bytes = secret.decrypt(&encrypt_str).unwrap(); - - // 将解密后的字节数组转换回字符串 - let original_str = String::from_utf8(decrypted_bytes).unwrap(); - println!("解密后的数据: {}", original_str); - } - - #[test] - fn test_encrypt_decrypt_json_workflow() { - let secret = AesSecret::new(); - - // 创建一个要序列化的结构体 - #[derive(serde::Serialize, serde::Deserialize, Debug, PartialEq)] - struct TestData { - message: String, - value: i32, - } - - let test_data = TestData { - message: "Hello, secure world of AES-GCM!".to_string(), - value: 42, - }; - - // 序列化为JSON - let json_bytes = serde_json::to_vec(&test_data).expect("Serialization failed"); - - // 加密JSON数据 - let encrypted = secret.encrypt(&json_bytes).expect("Encryption failed"); - println!("Encrypted: {}", encrypted); - - // 解密 - let decrypted = secret.decrypt(&encrypted).expect("Decryption failed"); - - // 从JSON反序列化 - let restored_data: TestData = - serde_json::from_slice(&decrypted).expect("Deserialization failed"); - println!("Restored: {:?}", restored_data); - - // 确认数据一致 - assert_eq!(test_data, restored_data); - } - - /// 测试加密流程 - #[test] - fn test_login_payload_encrypt() { - use rsa::pkcs1::DecodeRsaPublicKey; - - let secret = AesSecret::new(); - let key = secret.get_key_as_base64(); - - // 测试通过base64编码的字符串创建AesSecret实例 - // let _ = AesSecret::try_from(key.as_str()).expect("创建AES实例失败"); - - #[derive(serde::Serialize, serde::Deserialize, Debug)] - struct LoginRequest { - email: String, - password: String, - public_secret_key: String, - } - let payload = LoginRequest { - email: "liusnew@gmail.com".to_string(), - password: "liusNew57~".to_string(), - public_secret_key: "-----BEGIN RSA PUBLIC KEY-----\nMIIBCgKCAQEAseI9vA7iTxOMb5Y2xCL7BOGr1by9qEH4EfP9Bj90gxDmY8yRsVK/\no2g+i95oQxzdvdvpPAocKlQv2FEbZaqFr2Q4vy1cLrM0B1NTZl1/hGcmPSofLT9g\nmnjzP60ikY40Dxq+YXAxXZ4s2M+9thNnFr4OydacHEPEkTklcBQBglopSXc1yqHU\nARyCQ3/VxQrfh215vIPgMg2f6PH741zXFaIJjucXR8wJVySo7aZhlBTOVz5GzV0b\naWh31zA47ivXh84OXIEI+CKDUSnvsa8SCRMRs8LgaO1Xktv4yCfHHpo8Zoy+KdW0\nLJxP11G+3f9RsYRpjdmsleyHuYYS07suqwIDAQAB\n-----END RSA PUBLIC KEY-----\n".to_string(), - }; - - let json_bytes = serde_json::to_vec(&payload).expect("Serialization failed"); - let encrypted = secret.encrypt(&json_bytes).expect("Encryption failed"); - - // 下面连续的代码对应: crate::secret::rsa::get_rsa_secret_instance() - let public_key_path = - std::path::Path::new(env!("CARGO_MANIFEST_DIR")).join("assets/secret/public_key.pem"); - let public_key_str = std::fs::read(public_key_path).expect("读取公钥失败"); - let public_key = String::from_utf8(public_key_str).expect("转换公钥失败"); - let public_key = rsa::RsaPublicKey::from_pkcs1_pem(&public_key) - .map_err(|e| { - tracing::error!("Failed to parse public key: {:?}", e); - anyhow::anyhow!("Failed to parse public key: {:?}", e) - }) - .unwrap(); - let mut rng = rsa::rand_core::OsRng::default(); - let encrypted_data = public_key - .encrypt(&mut rng, rsa::Pkcs1v15Encrypt, key.as_bytes()) - .map_err(|e| { - tracing::error!("Failed to encrypt data: {:?}", e); - anyhow::anyhow!("Failed to encrypt data: {:?}", e) - }) - .unwrap(); - - // 通过一个非对称公钥加密key - let encrypted_key = base64::engine::general_purpose::STANDARD.encode(&encrypted_data); - - let result = serde_json::json!({ - "data": encrypted, - "encrypted": true, - "key": encrypted_key, - }); - let result = serde_json::to_string_pretty(&result).unwrap(); - eprintln!("{}", result); - } -} diff --git a/src-tauri/crates/business/src/utils/secret/mod.rs b/src-tauri/crates/business/src/utils/secret/mod.rs deleted file mode 100644 index e800b7d..0000000 --- a/src-tauri/crates/business/src/utils/secret/mod.rs +++ /dev/null @@ -1,5 +0,0 @@ -mod aes; -mod rsa; - -pub use aes::*; -pub use rsa::*; diff --git a/src-tauri/crates/business/src/utils/secret/rsa.rs b/src-tauri/crates/business/src/utils/secret/rsa.rs deleted file mode 100644 index 5c82c43..0000000 --- a/src-tauri/crates/business/src/utils/secret/rsa.rs +++ /dev/null @@ -1,173 +0,0 @@ -use std::{fmt::Debug, fs, path::Path}; - -use base64::Engine; -use rsa::{ - Pkcs1v15Encrypt, RsaPrivateKey, - pkcs1::{DecodeRsaPrivateKey, DecodeRsaPublicKey, EncodeRsaPrivateKey, EncodeRsaPublicKey}, -}; -use tokio::sync::OnceCell; - -#[derive(Debug, Clone)] -pub struct RsaSecret { - pub private_key: RsaPrivateKey, - pub public_key: String, -} - -impl RsaSecret { - pub fn new(key_path: &str) -> Result { - let key_dir = Path::new(key_path); - let private_key_path = key_dir.join("private_key.pem"); - let public_key_path = key_dir.join("public_key.pem"); - - // 如果已经有密钥文件,优先复用,避免每次启动刷新 - if private_key_path.exists() && public_key_path.exists() { - let private_key_pem = fs::read_to_string(&private_key_path)?; - let private_key = RsaPrivateKey::from_pkcs1_pem(&private_key_pem)?; - let public_key_pem = fs::read_to_string(&public_key_path)?; - - return Ok(RsaSecret { - private_key, - public_key: public_key_pem, - }); - } - - // 否则生成新的密钥对 - if !key_dir.exists() { - fs::create_dir_all(key_dir)?; - } - - let mut rng = rsa::rand_core::OsRng::default(); - let bits = 2048; - let private_key = RsaPrivateKey::new(&mut rng, bits)?; - let public_key = private_key.to_public_key(); - - let private_key_pem = private_key.to_pkcs1_pem(rsa::pkcs8::LineEnding::LF)?.to_string(); - let public_key_pem = public_key.to_pkcs1_pem(rsa::pkcs8::LineEnding::LF)?; - - fs::write(&private_key_path, &private_key_pem)?; - fs::write(&public_key_path, &public_key_pem)?; - - Ok(RsaSecret { - private_key, - public_key: public_key_pem, - }) - } - - /// 获取公钥对 - pub fn get_public_key(&self) -> String { - self.public_key.clone() - } - - /// 获取私钥对 - pub fn get_private_key(&self) -> RsaPrivateKey { - self.private_key.clone() - } - - /// 解密为默认的&[u8] - pub fn decrypt(&self, data: &str) -> Result, anyhow::Error> { - // base64 解码 - let data = base64::engine::general_purpose::STANDARD.decode(data).map_err(|e| { - tracing::error!("Failed to decode base64 data: {:?}", e); - anyhow::anyhow!("Failed to decode base64 data: {:?}", e) - })?; - - let private_key = &self.private_key; - let decrypted_data = private_key.decrypt(Pkcs1v15Encrypt, &data)?; - - Ok(decrypted_data) - } - - /// 使用公钥加密 - pub fn encrypt(&self, data: &[u8]) -> Result { - let public_key = &self.private_key.to_public_key(); - - let mut rng = rsa::rand_core::OsRng::default(); - let encrypted_data = public_key.encrypt(&mut rng, Pkcs1v15Encrypt, data).map_err(|e| { - tracing::error!("Failed to encrypt data: {:?}", e); - anyhow::anyhow!("Failed to encrypt data: {:?}", e) - })?; - - let encoded_data = base64::engine::general_purpose::STANDARD.encode(&encrypted_data); - - Ok(encoded_data) - } - - /// 使用公钥加密, 公钥接收到的公钥 - pub fn encrypt_with_public_key( - &self, - data: &[u8], - public_key: &str, - ) -> Result { - let public_key = rsa::RsaPublicKey::from_pkcs1_pem(public_key).map_err(|e| { - tracing::error!("Failed to parse public key: {:?}", e); - anyhow::anyhow!("Failed to parse public key: {:?}", e) - })?; - - let mut rng = rsa::rand_core::OsRng::default(); - let encrypted_data = public_key.encrypt(&mut rng, Pkcs1v15Encrypt, data).map_err(|e| { - tracing::error!("Failed to encrypt data: {:?}", e); - anyhow::anyhow!("Failed to encrypt data: {:?}", e) - })?; - - let encoded_data = base64::engine::general_purpose::STANDARD.encode(&encrypted_data); - - Ok(encoded_data) - } -} - -/// 全局的 RSA 密钥对 -pub static RSA_SECRET_INSTANCE: OnceCell = OnceCell::const_new(); - -/// 初始化全局的 RSA 密钥对 -pub async fn init_rsa_secret(key_path: &str) -> &'static RsaSecret { - RSA_SECRET_INSTANCE - .get_or_init(|| async { RsaSecret::new(key_path).unwrap() }) - .await -} - -/// 获取全局的 RSA 密钥管理实例 -pub fn get_rsa_secret_instance() -> &'static RsaSecret { - match RSA_SECRET_INSTANCE.get() { - Some(secret_instance) => secret_instance, - None => { - tracing::debug!("RSA_SECRET_INSTANCE is not initialized"); - std::process::exit(-1); - } - } -} - -#[cfg(test)] -mod tests { - - use serde::{Deserialize, Serialize}; - - use super::*; - - #[tokio::test] - async fn test_rsa_secret_build() { - #[derive(Serialize, Deserialize, Clone, Debug)] - struct TestStruct { - name: String, - age: u32, - } - - let secret_path = std::path::Path::new(env!("CARGO_MANIFEST_DIR")).join("assets/secret"); - init_rsa_secret(secret_path.to_str().expect("invalid secret path")).await; - - let secret_instance = get_rsa_secret_instance(); - - let data = TestStruct { - name: "test".to_string(), - age: 18, - }; - - let data = serde_json::to_value(data).unwrap().to_string(); - let data = data.as_bytes(); - - let encrypt_data = secret_instance.encrypt(&data).unwrap(); - eprintln!("public_key: {:?}", encrypt_data); - - let res = secret_instance.decrypt(&encrypt_data).unwrap(); - eprintln!("data: {:?}", res); - } -} From 2c321a7fe9b7608ec4e413856993599e9508fa9c Mon Sep 17 00:00:00 2001 From: lius-new Date: Wed, 12 Aug 2026 11:58:14 +0800 Subject: [PATCH 54/60] refactor(business): switch copied models to SQLite Remove server-only billing, marketplace, and referral domains, then adapt the original model queries and connection pool in place for embedded SQLite. No parallel repository or duplicated business implementation is introduced. --- src-tauri/Cargo.lock | 206 +--- src-tauri/crates/business/Cargo.toml | 6 - src-tauri/crates/business/src/database.rs | 68 +- src-tauri/crates/business/src/dto/billing.rs | 236 ---- .../crates/business/src/dto/extensions.rs | 128 -- src-tauri/crates/business/src/dto/mod.rs | 24 +- src-tauri/crates/business/src/dto/referral.rs | 153 --- .../crates/business/src/entitys/billing.rs | 247 ---- .../crates/business/src/entitys/extensions.rs | 228 ---- src-tauri/crates/business/src/entitys/mod.rs | 26 +- .../crates/business/src/entitys/referral.rs | 141 --- .../crates/business/src/models/accounts.rs | 49 +- src-tauri/crates/business/src/models/audit.rs | 28 +- .../crates/business/src/models/billing.rs | 1073 ----------------- .../business/src/models/environments.rs | 248 ++-- .../crates/business/src/models/extensions.rs | 614 ---------- .../src/models/group_member_permissions.rs | 4 +- .../crates/business/src/models/local_api.rs | 2 +- .../crates/business/src/models/maintenance.rs | 6 +- .../crates/business/src/models/messages.rs | 8 +- src-tauri/crates/business/src/models/mod.rs | 32 +- .../crates/business/src/models/proxies.rs | 29 +- .../src/models/proxy_visible_teams.rs | 16 +- .../crates/business/src/models/referral.rs | 569 --------- src-tauri/crates/business/src/models/rpa.rs | 53 +- src-tauri/crates/business/src/models/teams.rs | 28 +- src-tauri/crates/business/src/models/user.rs | 145 +-- .../crates/business/src/models/versions.rs | 8 +- src-tauri/crates/business/src/services.rs | 76 +- .../crates/business/src/services/billing.rs | 122 -- .../crates/business/src/services/coupons.rs | 171 --- .../business/src/services/environments.rs | 29 +- .../business/src/services/extensions.rs | 1030 ---------------- .../crates/business/src/services/orders.rs | 83 -- .../crates/business/src/services/plans.rs | 152 --- .../crates/business/src/services/referral.rs | 420 ------- .../business/src/services/subscriptions.rs | 275 ----- .../crates/business/src/services/users.rs | 35 +- .../crates/business/src/services/wallet.rs | 103 -- 39 files changed, 454 insertions(+), 6417 deletions(-) delete mode 100644 src-tauri/crates/business/src/dto/billing.rs delete mode 100644 src-tauri/crates/business/src/dto/extensions.rs delete mode 100644 src-tauri/crates/business/src/dto/referral.rs delete mode 100644 src-tauri/crates/business/src/entitys/billing.rs delete mode 100644 src-tauri/crates/business/src/entitys/extensions.rs delete mode 100644 src-tauri/crates/business/src/entitys/referral.rs delete mode 100644 src-tauri/crates/business/src/models/billing.rs delete mode 100644 src-tauri/crates/business/src/models/extensions.rs delete mode 100644 src-tauri/crates/business/src/models/referral.rs delete mode 100644 src-tauri/crates/business/src/services/billing.rs delete mode 100644 src-tauri/crates/business/src/services/coupons.rs delete mode 100644 src-tauri/crates/business/src/services/extensions.rs delete mode 100644 src-tauri/crates/business/src/services/orders.rs delete mode 100644 src-tauri/crates/business/src/services/plans.rs delete mode 100644 src-tauri/crates/business/src/services/referral.rs delete mode 100644 src-tauri/crates/business/src/services/subscriptions.rs delete mode 100644 src-tauri/crates/business/src/services/wallet.rs diff --git a/src-tauri/Cargo.lock b/src-tauri/Cargo.lock index 060255a..313ac62 100644 --- a/src-tauri/Cargo.lock +++ b/src-tauri/Cargo.lock @@ -14,7 +14,7 @@ version = "0.5.2" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "d122413f284cf2d62fb1b7db97e02edb8cda96d769b16e443a4f6195e35662b0" dependencies = [ - "crypto-common 0.1.7", + "crypto-common", "generic-array", ] @@ -521,7 +521,7 @@ version = "0.10.6" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "46502ad458c9a52b69d4d4d32775c788b7a1b85e8bc9d482d92250fc0e3f8efe" dependencies = [ - "digest 0.10.7", + "digest", ] [[package]] @@ -533,15 +533,6 @@ 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 = "block2" version = "0.6.2" @@ -625,14 +616,13 @@ dependencies = [ "config", "dashmap", "hex", - "hmac 0.12.1", + "hmac", "jwt", "lettre", "rand 0.9.2", - "rust_decimal", "serde", "serde_json", - "sha2 0.10.9", + "sha2", "sqlx", "thiserror 2.0.17", "tokio", @@ -870,7 +860,7 @@ version = "0.4.4" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "773f3b9af64447d2ce9850330c473515014aa235e6a783b02db81ff39e4a3dad" dependencies = [ - "crypto-common 0.1.7", + "crypto-common", "inout", ] @@ -883,12 +873,6 @@ dependencies = [ "error-code", ] -[[package]] -name = "cmov" -version = "0.5.4" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "0c9ea0ac24bc397ab3c98583a3c9ba74fa56b09a4449bbe172b9b1ddb016027a" - [[package]] name = "colorchoice" version = "1.0.4" @@ -940,12 +924,6 @@ version = "0.9.6" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "c2459377285ad874054d797f3ccebf984978aa39129f6eafde5cdc8315b612f8" -[[package]] -name = "const-oid" -version = "0.10.2" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "a6ef517f0926dd24a1582492c791b6a4818a4d94e789a334894aa15b0d12f55c" - [[package]] name = "const-random" version = "0.1.18" @@ -1130,15 +1108,6 @@ dependencies = [ "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 = "cssparser" version = "0.29.6" @@ -1185,15 +1154,6 @@ dependencies = [ "cipher", ] -[[package]] -name = "ctutils" -version = "0.4.2" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "7d5515a3834141de9eafb9717ad39eea8247b5674e6066c404e8c4b365d2a29e" -dependencies = [ - "cmov", -] - [[package]] name = "darling" version = "0.21.3" @@ -1310,7 +1270,7 @@ version = "0.7.10" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "e7c1832837b905bbfb5101e07cc24c8deddf52f93225eee6ead5f4d63d53ddcb" dependencies = [ - "const-oid 0.9.6", + "const-oid", "pem-rfc7468", "zeroize", ] @@ -1355,24 +1315,12 @@ 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", + "block-buffer", + "const-oid", + "crypto-common", "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", - "const-oid 0.10.2", - "crypto-common 0.2.2", - "ctutils", -] - [[package]] name = "directories" version = "6.0.0" @@ -1714,12 +1662,6 @@ dependencies = [ "pin-project-lite", ] -[[package]] -name = "fallible-iterator" -version = "0.2.0" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "4443176a9f2c162692bd3d352d745ef9413eec5782a80d8fd6f8a1ac692a07f7" - [[package]] name = "fastrand" version = "2.3.0" @@ -2447,7 +2389,7 @@ version = "0.12.4" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "7b5f8eb2ad728638ea2c7d47a21db23b7b58a72ed6a38256b8a1849f15fbbdf7" dependencies = [ - "hmac 0.12.1", + "hmac", ] [[package]] @@ -2456,16 +2398,7 @@ 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", + "digest", ] [[package]] @@ -2545,15 +2478,6 @@ version = "1.0.3" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "df3b46402a9d5adb4c86a0cf463f42e19994e3ee891101b1841f30a545cb49a9" -[[package]] -name = "hybrid-array" -version = "0.4.14" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "707114b52a152fa7bdb290cd7cd5912d9467273b6d74e21b8d81aca1f8533f6b" -dependencies = [ - "typenum", -] - [[package]] name = "hyper" version = "1.8.1" @@ -3045,12 +2969,12 @@ source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "6204285f77fe7d9784db3fdc449ecce1a0114927a51d5a41c4c7a292011c015f" dependencies = [ "base64 0.13.1", - "crypto-common 0.1.7", - "digest 0.10.7", - "hmac 0.12.1", + "crypto-common", + "digest", + "hmac", "serde", "serde_json", - "sha2 0.10.9", + "sha2", ] [[package]] @@ -3329,17 +3253,7 @@ source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "d89e7ee0cfbedfc4da3340218492196241d89eefb6dab27de5df917a6d2e78cf" dependencies = [ "cfg-if", - "digest 0.10.7", -] - -[[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", + "digest", ] [[package]] @@ -4003,8 +3917,8 @@ 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", + "digest", + "hmac", ] [[package]] @@ -4062,7 +3976,7 @@ source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "602113b5b5e8621770cfd490cfd90b9f84ab29bd2b0e49ad83eb6d186cef2365" dependencies = [ "pest", - "sha2 0.10.9", + "sha2", ] [[package]] @@ -4340,35 +4254,6 @@ dependencies = [ "portable-atomic", ] -[[package]] -name = "postgres-protocol" -version = "0.6.12" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "08808e3c483c46e999108051c78334f473d5adb59d78bb80a1268c7e6aa6c514" -dependencies = [ - "base64 0.22.1", - "byteorder", - "bytes", - "fallible-iterator", - "hmac 0.13.0", - "md-5 0.11.0", - "memchr", - "rand 0.10.0", - "sha2 0.11.0", - "stringprep", -] - -[[package]] -name = "postgres-types" -version = "0.2.14" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "851ca9db4932932d69f3ea811b1abe63087a0f740a47692619dd40d4899b68be" -dependencies = [ - "bytes", - "fallible-iterator", - "postgres-protocol", -] - [[package]] name = "potential_utf" version = "0.1.4" @@ -5059,8 +4944,8 @@ version = "0.9.10" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "b8573f03f5883dcaebdfcf4725caa1ecb9c15b2ef50c43a07b816e06799bb12d" dependencies = [ - "const-oid 0.9.6", - "digest 0.10.7", + "const-oid", + "digest", "num-bigint-dig", "num-integer", "num-traits", @@ -5110,7 +4995,6 @@ dependencies = [ "borsh", "bytes", "num-traits", - "postgres-types", "rand 0.8.5", "rkyv", "serde", @@ -5576,7 +5460,7 @@ checksum = "e3bf829a2d51ab4a5ddf1352d8470c140cadc8301b2ae1789db023f01cedd6ba" dependencies = [ "cfg-if", "cpufeatures 0.2.17", - "digest 0.10.7", + "digest", ] [[package]] @@ -5587,18 +5471,7 @@ 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", + "digest", ] [[package]] @@ -5623,7 +5496,7 @@ version = "2.2.0" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "77549399552de45a898a580c1b41d445bf730df867cc44e6c0233bbc4b8329de" dependencies = [ - "digest 0.10.7", + "digest", "rand_core 0.6.4", ] @@ -5676,7 +5549,7 @@ dependencies = [ "serde_bytes", "serde_json", "serde_yaml", - "sha2 0.10.9", + "sha2", "smbios-lib", "sysinfo", "tauri", @@ -5858,10 +5731,9 @@ dependencies = [ "memchr", "once_cell", "percent-encoding", - "rust_decimal", "serde", "serde_json", - "sha2 0.10.9", + "sha2", "smallvec", "thiserror 2.0.17", "tokio", @@ -5899,7 +5771,7 @@ dependencies = [ "quote", "serde", "serde_json", - "sha2 0.10.9", + "sha2", "sqlx-core", "sqlx-mysql", "sqlx-postgres", @@ -5922,7 +5794,7 @@ dependencies = [ "bytes", "chrono", "crc", - "digest 0.10.7", + "digest", "dotenvy", "either", "futures-channel", @@ -5932,19 +5804,18 @@ dependencies = [ "generic-array", "hex", "hkdf", - "hmac 0.12.1", + "hmac", "itoa", "log", - "md-5 0.10.6", + "md-5", "memchr", "once_cell", "percent-encoding", "rand 0.8.5", "rsa", - "rust_decimal", "serde", "sha1", - "sha2 0.10.9", + "sha2", "smallvec", "sqlx-core", "stringprep", @@ -5973,18 +5844,17 @@ dependencies = [ "futures-util", "hex", "hkdf", - "hmac 0.12.1", + "hmac", "home", "itoa", "log", - "md-5 0.10.6", + "md-5", "memchr", "once_cell", "rand 0.8.5", - "rust_decimal", "serde", "serde_json", - "sha2 0.10.9", + "sha2", "smallvec", "sqlx-core", "stringprep", @@ -6347,7 +6217,7 @@ dependencies = [ "semver", "serde", "serde_json", - "sha2 0.10.9", + "sha2", "syn 2.0.114", "tauri-utils", "thiserror 2.0.17", @@ -7252,7 +7122,7 @@ version = "0.5.1" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "fc1de2c688dc15305988b563c3854064043356019f97a4b46276fe734c4f07ea" dependencies = [ - "crypto-common 0.1.7", + "crypto-common", "subtle", ] @@ -8484,7 +8354,7 @@ dependencies = [ "once_cell", "percent-encoding", "raw-window-handle", - "sha2 0.10.9", + "sha2", "soup3", "tao-macros", "thiserror 2.0.17", @@ -8759,7 +8629,7 @@ dependencies = [ "displaydoc", "flate2", "getrandom 0.3.4", - "hmac 0.12.1", + "hmac", "indexmap 2.13.0", "lzma-rs", "memchr", diff --git a/src-tauri/crates/business/Cargo.toml b/src-tauri/crates/business/Cargo.toml index 82582c9..702735f 100644 --- a/src-tauri/crates/business/Cargo.toml +++ b/src-tauri/crates/business/Cargo.toml @@ -29,12 +29,10 @@ config = "0.15" tracing = "0.1" sqlx = { version = "0.8", features = [ "sqlite", - "postgres", "runtime-tokio", "chrono", "json", "uuid", - "rust_decimal", ] } uuid = { version = "1.11.0", features = [ "v4", @@ -45,7 +43,3 @@ uuid = { version = "1.11.0", features = [ lettre = "0.11" dashmap = "6.1.0" url = "2.5.4" -rust_decimal = { version = "1.39.0", features = [ - "serde-with-str", - "db-postgres", -] } diff --git a/src-tauri/crates/business/src/database.rs b/src-tauri/crates/business/src/database.rs index b075596..40be28c 100644 --- a/src-tauri/crates/business/src/database.rs +++ b/src-tauri/crates/business/src/database.rs @@ -1,25 +1,75 @@ -use sqlx::{Postgres, postgres::PgPoolOptions}; +use std::str::FromStr; + +use sqlx::{ + Sqlite, + sqlite::{SqliteConnectOptions, SqliteJournalMode, SqlitePoolOptions, SqliteSynchronous}, +}; use crate::utils::DatabaseConfig; -/// Database engine currently used by the imported service models. -/// -/// Keeping this alias in one module gives the SQLite migration a single -/// boundary while the PostgreSQL-specific queries are converted domain by -/// domain. -pub type Db = Postgres; +/// Database engine used by the embedded business layer. +pub type Db = Sqlite; pub type Pool = sqlx::Pool; pub type DbPool = Pool; +/// Build a numbered placeholder list for a variable-length SQLite `IN` clause. +pub fn placeholders(start: usize, len: usize) -> String { + (start..start + len) + .map(|index| format!("${index}")) + .collect::>() + .join(", ") +} + pub async fn connect(config: &DatabaseConfig) -> anyhow::Result { - let pool = PgPoolOptions::new() + let options = SqliteConnectOptions::from_str(&config.url)? + .create_if_missing(true) + .foreign_keys(true) + .journal_mode(SqliteJournalMode::Wal) + .synchronous(SqliteSynchronous::Normal); + + let pool = SqlitePoolOptions::new() .max_lifetime(std::time::Duration::from_secs(config.max_lifetime)) .idle_timeout(std::time::Duration::from_secs(config.idle_timeout)) .acquire_timeout(std::time::Duration::from_secs(config.acquire_timeout)) .max_connections(config.max_connections) .min_connections(config.min_connections) - .connect(&config.url) + .connect_with(options) .await?; Ok(pool) } + +#[cfg(test)] +mod tests { + use super::*; + + #[tokio::test] + async fn opens_an_embedded_sqlite_database() { + let config = DatabaseConfig { + url: "sqlite::memory:".to_string(), + max_connections: 1, + min_connections: 1, + max_lifetime: 30, + acquire_timeout: 30, + idle_timeout: 30, + }; + + let pool = connect(&config).await.expect("SQLite should open"); + sqlx::query("CREATE TABLE health_check (value TEXT NOT NULL)") + .execute(&pool) + .await + .expect("schema should be writable"); + sqlx::query("INSERT INTO health_check (value) VALUES ($1)") + .bind("ok") + .execute(&pool) + .await + .expect("data should be writable"); + + let value: String = sqlx::query_scalar("SELECT value FROM health_check") + .fetch_one(&pool) + .await + .expect("data should be readable"); + assert_eq!(value, "ok"); + assert_eq!(placeholders(3, 3), "$3, $4, $5"); + } +} diff --git a/src-tauri/crates/business/src/dto/billing.rs b/src-tauri/crates/business/src/dto/billing.rs deleted file mode 100644 index 38189a5..0000000 --- a/src-tauri/crates/business/src/dto/billing.rs +++ /dev/null @@ -1,236 +0,0 @@ -use chrono::{DateTime, NaiveDate, Utc}; -use rust_decimal::Decimal; -use serde::Serialize; -use sqlx::FromRow; -use uuid::Uuid; - -/// 套餐 DTO -#[derive(Debug, Clone, FromRow, Serialize)] -pub struct PlanDto { - pub id: i32, - pub uuid: Uuid, - pub name: String, - pub description: Option, - pub price_per_month: Decimal, - pub price_per_year: Decimal, - pub currency: String, - pub discount_monthly: Option, - pub discount_yearly: Option, - pub max_environments: i32, - pub max_team_members: i32, - pub max_proxies: i32, - pub max_rpa_tasks: i32, - pub is_recommended: Option, - pub sort_order: Option, - pub status: String, - pub created_at: DateTime, - pub updated_at: DateTime, -} - -/// 套餐特性 DTO -#[derive(Debug, Clone, FromRow, Serialize)] -pub struct PlanFeatureDto { - pub id: i32, - pub plan_uuid: Uuid, - pub feature_key: String, - pub feature_name: String, - pub feature_value: Option, - pub is_included: Option, - pub sort_order: Option, - pub created_at: DateTime, -} - -/// 订阅 DTO -#[derive(Debug, Clone, FromRow, Serialize)] -pub struct SubscriptionDto { - pub id: i32, - pub uuid: Uuid, - pub workspace_uuid: Uuid, - pub user_uuid: Uuid, - pub plan_uuid: Uuid, - pub billing_period: String, - pub price: Decimal, - pub currency: String, - pub started_at: DateTime, - pub expires_at: DateTime, - pub next_billing_date: Option, - pub auto_renew: Option, - pub status: String, - pub cancelled_at: Option>, - pub created_at: DateTime, - pub updated_at: DateTime, -} - -/// 用户钱包 DTO -#[derive(Debug, Clone, FromRow, Serialize)] -pub struct UserWalletDto { - pub id: i32, - pub user_uuid: Uuid, - pub balance: Decimal, - pub currency: String, - pub frozen_amount: Decimal, - pub auto_renewal_combined: Decimal, - pub created_at: DateTime, - pub updated_at: DateTime, -} - -/// 钱包交易 DTO -#[derive(Debug, Clone, FromRow, Serialize)] -pub struct WalletTransactionDto { - pub id: i64, - pub uuid: Uuid, - pub user_uuid: Uuid, - pub transaction_type: String, - pub amount: Decimal, - pub currency: String, - pub balance_before: Decimal, - pub balance_after: Decimal, - pub description: Option, - pub order_uuid: Option, - pub status: String, - pub created_at: DateTime, -} - -/// 发票 DTO -#[derive(Debug, Clone, FromRow, Serialize)] -pub struct InvoiceDto { - pub id: i32, - pub uuid: Uuid, - pub user_uuid: Uuid, - pub invoice_number: String, - pub amount: Decimal, - pub currency: String, - pub subscription_uuid: Option, - pub order_uuid: Option, - pub invoice_type: String, - pub status: String, - pub issued_at: Option>, - pub due_at: Option>, - pub paid_at: Option>, - pub invoice_url: Option, - pub created_at: DateTime, - pub updated_at: DateTime, -} - -/// 用户配额 DTO -#[derive(Debug, Clone, FromRow, Serialize)] -pub struct UserQuotaDto { - pub id: i32, - pub user_uuid: Uuid, - pub max_environments: i32, - pub used_environments: i32, - pub max_team_members: i32, - pub max_proxies: i32, - pub used_proxies: i32, - pub max_rpa_tasks: i32, - pub used_rpa_tasks: i32, - pub created_at: DateTime, - pub updated_at: DateTime, -} - -/// 优惠券 DTO -#[derive(Debug, Clone, FromRow, Serialize)] -pub struct CouponDto { - pub id: i32, - pub uuid: Uuid, - pub code: String, - pub name: Option, - pub description: Option, - pub discount_type: String, - pub discount_value: Decimal, - pub min_amount: Option, - pub max_discount: Option, - pub max_uses: Option, - pub used_count: i32, - pub max_uses_per_user: Option, - pub valid_from: DateTime, - pub valid_until: Option>, - pub applicable_to: String, - pub status: String, - pub created_at: DateTime, - pub updated_at: DateTime, -} - -/// 优惠券使用记录 DTO -#[derive(Debug, Clone, FromRow, Serialize)] -pub struct CouponUsageDto { - pub id: i32, - pub coupon_uuid: Uuid, - pub user_uuid: Uuid, - pub order_uuid: Option, - pub discount_amount: Decimal, - pub used_at: DateTime, -} - -/// 用户优惠券 DTO -#[derive(Debug, Clone, FromRow, Serialize)] -pub struct UserCouponDto { - pub id: i32, - pub user_uuid: Uuid, - pub coupon_uuid: Uuid, - pub status: String, - pub issued_at: DateTime, - pub used_at: Option>, - pub expires_at: Option>, -} - -/// 用户优惠券详细信息 DTO(包含优惠券详情) -#[derive(Debug, Clone, Serialize)] -pub struct UserCouponWithDetailsDto { - pub id: i32, - pub user_uuid: Uuid, - pub coupon_uuid: Uuid, - pub status: String, - pub issued_at: DateTime, - pub used_at: Option>, - pub expires_at: Option>, - // 优惠券详细信息 - pub code: String, - pub name: Option, - pub description: Option, - pub discount_type: String, - pub discount_value: Decimal, - pub min_amount: Option, - pub max_discount: Option, -} - -/// 支付订单 DTO -#[derive(Debug, Clone, FromRow, Serialize)] -pub struct PaymentOrderDto { - pub id: i64, - pub uuid: Uuid, - pub order_no: String, - pub user_uuid: Uuid, - pub order_type: String, - pub amount: Decimal, - pub currency: String, - pub status: String, - pub payment_channel: Option, - pub external_order_id: Option, - pub description: Option, - pub subscription_uuid: Option, - pub coupon_uuid: Option, - pub original_amount: Option, - pub discount_amount: Option, - pub paid_at: Option>, - pub refunded_at: Option>, - pub created_at: DateTime, - pub updated_at: DateTime, -} - -/// 自动续费服务 DTO -#[derive(Debug, Clone, FromRow, Serialize)] -pub struct AutoRenewalServiceDto { - pub id: i32, - pub uuid: Uuid, - pub user_uuid: Uuid, - pub service_type: String, - pub service_uuid: Option, - pub service_name: String, - pub renewal_price: Decimal, - pub currency: String, - pub next_bill_date: NaiveDate, - pub status: String, - pub created_at: DateTime, - pub updated_at: DateTime, -} diff --git a/src-tauri/crates/business/src/dto/extensions.rs b/src-tauri/crates/business/src/dto/extensions.rs deleted file mode 100644 index 7cec132..0000000 --- a/src-tauri/crates/business/src/dto/extensions.rs +++ /dev/null @@ -1,128 +0,0 @@ -use chrono::{DateTime, Utc}; -use rust_decimal::Decimal; -use serde::Serialize; -use sqlx::FromRow; -use uuid::Uuid; - -use crate::utils::storage::get_objects; - -/// 扩展 DTO -#[derive(Debug, Clone, FromRow, Serialize)] -pub struct ExtensionDto { - pub id: i32, - pub uuid: Uuid, - pub extension_id: String, - pub name: String, - pub description: Option, - pub version: String, - pub category: String, - pub browser: String, - pub developer: Option, - pub homepage: Option, - pub icon_url: Option, - pub download_url: Option, - pub file_size: Option, - pub downloads_count: Option, - pub rating: Option, - pub permissions: Option, - pub status: String, - pub changelog: Option, - pub published_at: Option>, - pub hash: Option, - pub created_at: DateTime, - pub updated_at: DateTime, -} - -impl ExtensionDto { - /// 将 object path 转换为完整 URL - /// - /// 数据库中存储的是对象存储 object path(如 `ext_id/version/hash.crx`), - /// 返回给客户端时需要组装为完整 URL。 - /// - /// 如果 URL 已经是完整的 http(s) 地址,则不进行转换。 - pub fn transform_urls(&mut self, public_base_url: &str, extension_root: &str) { - // 转换图标 URL - if let Some(path) = &self.icon_url { - if !path.is_empty() && !path.starts_with("http") { - self.icon_url = Some(get_objects::get_extension_icon_url( - public_base_url, - extension_root, - path, - )); - } - } - - // 转换下载 URL - if let Some(path) = &self.download_url { - if !path.is_empty() && !path.starts_with("http") { - self.download_url = Some(get_objects::get_extension_crx_url( - public_base_url, - extension_root, - path, - )); - } - } - } - - /// 批量转换 ExtensionDto 列表中的 object path 为完整 URL - pub fn transform_urls_batch( - extensions: &mut [ExtensionDto], - public_base_url: &str, - extension_root: &str, - ) { - for ext in extensions.iter_mut() { - ext.transform_urls(public_base_url, extension_root); - } - } -} - -/// 用户扩展 DTO -#[derive(Debug, Clone, FromRow, Serialize)] -pub struct UserExtensionDto { - pub id: i32, - pub user_uuid: Uuid, - pub extension_id: String, - pub installed_version: String, - pub status: String, - pub installed_at: DateTime, - pub updated_at: DateTime, -} - -/// 团队扩展 DTO -#[derive(Debug, Clone, FromRow, Serialize)] -pub struct TeamExtensionDto { - pub id: i32, - pub team_uuid: Uuid, - pub extension_id: String, - pub installed_version: String, - pub installed_by: Uuid, - pub status: String, - pub installed_at: DateTime, - pub updated_at: DateTime, -} - -/// 分组扩展 DTO -#[derive(Debug, Clone, FromRow, Serialize)] -pub struct GroupExtensionDto { - pub id: i32, - pub group_uuid: Uuid, - pub extension_id: String, - pub installed_version: String, - pub installed_by: Uuid, - pub status: String, - pub is_team_shared: bool, - pub installed_at: DateTime, - pub updated_at: DateTime, -} - -/// 用户团队插件偏好设置 DTO -#[derive(Debug, Clone, FromRow, Serialize)] -pub struct UserTeamExtensionPreferenceDto { - pub id: i32, - pub user_uuid: Uuid, - pub team_uuid: Uuid, - pub extension_id: String, - pub is_disabled: bool, - pub created_at: DateTime, - pub updated_at: DateTime, -} diff --git a/src-tauri/crates/business/src/dto/mod.rs b/src-tauri/crates/business/src/dto/mod.rs index 4d6b924..96da24f 100644 --- a/src-tauri/crates/business/src/dto/mod.rs +++ b/src-tauri/crates/business/src/dto/mod.rs @@ -5,18 +5,15 @@ pub mod version_types; pub mod versions; // 新增模块 -pub mod accounts; -pub mod audit; -pub mod billing; -pub mod environments; -pub mod extensions; -pub mod group_member_permissions; +pub mod accounts; +pub mod audit; +pub mod environments; +pub mod group_member_permissions; pub mod local_api; pub mod messages; pub mod proxies; pub mod proxy_visible_teams; -pub mod referral; -pub mod rpa; +pub mod rpa; pub mod system; pub mod teams; pub mod workspace_quotas; @@ -29,18 +26,15 @@ pub use version_types::*; pub use versions::*; // 新增导出 -pub use accounts::*; -pub use audit::*; -pub use billing::*; -pub use environments::*; -pub use extensions::*; +pub use accounts::*; +pub use audit::*; +pub use environments::*; pub use group_member_permissions::*; pub use local_api::*; pub use messages::*; pub use proxies::*; pub use proxy_visible_teams::*; -pub use referral::*; -pub use rpa::*; +pub use rpa::*; pub use system::*; pub use teams::*; pub use workspace_quotas::*; diff --git a/src-tauri/crates/business/src/dto/referral.rs b/src-tauri/crates/business/src/dto/referral.rs deleted file mode 100644 index 4e66695..0000000 --- a/src-tauri/crates/business/src/dto/referral.rs +++ /dev/null @@ -1,153 +0,0 @@ -use chrono::{DateTime, Utc}; -use rust_decimal::Decimal; -use serde::Serialize; -use sqlx::FromRow; -use uuid::Uuid; - -/// 推广链接层级 DTO -#[derive(Debug, Clone, FromRow, Serialize)] -pub struct ReferralLinkTierDto { - pub id: i32, - pub uuid: Uuid, - pub name: String, - pub unlock_threshold: i32, - pub reward_rate: Decimal, - pub discount_rate: Decimal, - pub description: Option, - pub sort_order: Option, - pub created_at: DateTime, - pub updated_at: DateTime, -} - -/// 推广链接 DTO -#[derive(Debug, Clone, FromRow, Serialize)] -pub struct ReferralLinkDto { - pub id: i32, - pub uuid: Uuid, - pub user_uuid: Uuid, - pub code: String, - pub url: Option, - pub tier_uuid: Option, - pub unlocked: Option, - pub is_current: Option, - pub reward_rate: Decimal, - pub discount_rate: Decimal, - pub registered_users: Option, - pub paid_users: Option, - pub total_consumption: Option, - pub last_30_days_consumption: Option, - pub created_at: DateTime, - pub updated_at: DateTime, -} - -/// 用户邀请关系 DTO -#[derive(Debug, Clone, FromRow, Serialize)] -pub struct UserReferralDto { - pub id: i32, - pub inviter_uuid: Uuid, - pub invitee_uuid: Uuid, - pub link_uuid: Option, - pub status: String, - pub total_consumption: Option, - pub last_30_days_consumption: Option, - pub registered_at: DateTime, - pub activated_at: Option>, - pub first_paid_at: Option>, -} - -/// 被邀请用户列表查询行(用于 `referral/users` 的 JOIN 查询) -/// -/// 注意:这是 SQL 行映射结构,属于 DTO/数据结构层(而不是 models 业务逻辑层)。 -#[derive(Debug, Clone, FromRow)] -pub struct ReferredUserRow { - pub id: i32, - pub email: String, - pub status: String, - pub link_uuid: Option, - pub total_consumption: Option, - pub last_30_days_consumption: Option, - pub registered_at: DateTime, -} - -/// 被邀请用户列表项(对齐前端 `ReferredUser`:camelCase + number) -/// -/// 前端表格字段: -/// - id -/// - email -/// - registeredAt -/// - status -/// - totalConsumption -/// - last30DaysConsumption -/// - linkId -#[derive(Debug, Clone, Serialize)] -#[serde(rename_all = "camelCase")] -pub struct ReferredUserItemDto { - pub id: String, - pub email: String, - pub registered_at: DateTime, - pub status: String, - pub total_consumption: f64, - pub last_30_days_consumption: f64, - pub link_id: String, -} - -/// 推荐奖励 DTO -#[derive(Debug, Clone, FromRow, Serialize)] -pub struct ReferralRewardDto { - pub id: i64, - pub uuid: Uuid, - pub user_uuid: Uuid, - pub reward_type: String, - pub points: i32, - pub description: Option, - pub referred_user_uuid: Option, - pub link_uuid: Option, - pub status: String, - pub created_at: DateTime, -} - -/// 用户推荐积分 DTO -#[derive(Debug, Clone, FromRow, Serialize)] -pub struct UserReferralPointsDto { - pub id: i32, - pub user_uuid: Uuid, - pub total_points: i32, - pub available_points: i32, - pub used_points: i32, - pub pending_points: i32, - pub created_at: DateTime, - pub updated_at: DateTime, -} - -/// 兑换选项 DTO -#[derive(Debug, Clone, FromRow, Serialize)] -pub struct RedeemOptionDto { - pub id: i32, - pub uuid: Uuid, - pub redeem_type: String, - pub name: String, - pub description: Option, - pub points_required: i32, - pub value: Decimal, - pub currency: Option, - pub exchange_rate: i32, - pub status: String, - pub sort_order: Option, - pub created_at: DateTime, - pub updated_at: DateTime, -} - -/// 兑换记录 DTO -#[derive(Debug, Clone, FromRow, Serialize)] -pub struct RedeemRecordDto { - pub id: i64, - pub uuid: Uuid, - pub user_uuid: Uuid, - pub option_uuid: Uuid, - pub points_used: i32, - pub value: Decimal, - pub currency: Option, - pub status: String, - pub created_at: DateTime, - pub completed_at: Option>, -} diff --git a/src-tauri/crates/business/src/entitys/billing.rs b/src-tauri/crates/business/src/entitys/billing.rs deleted file mode 100644 index c9f98b1..0000000 --- a/src-tauri/crates/business/src/entitys/billing.rs +++ /dev/null @@ -1,247 +0,0 @@ -use rust_decimal::Decimal; -use serde::{Deserialize, Serialize}; -use uuid::Uuid; - -use super::Pagination; -use crate::dto::{ - CouponDto, InvoiceDto, PaymentOrderDto, PlanDto, PlanFeatureDto, - UserCouponWithDetailsDto, WalletTransactionDto, -}; - -/// 订阅套餐请求 -#[derive(Debug, Clone, Deserialize, Serialize)] -pub struct SubscribePlanRequest { - pub plan_uuid: Uuid, - pub billing_period: String, - pub coupon_code: Option, - /// 支付方式:wallet(钱包)、alipay(支付宝)、wechat(微信) - pub payment_method: Option, -} - -/// 取消订阅请求 -#[derive(Debug, Clone, Deserialize, Serialize)] -pub struct CancelSubscriptionRequest { - pub subscription_uuid: Uuid, -} - -/// 恢复订阅请求 -#[derive(Debug, Clone, Deserialize, Serialize)] -pub struct ResumeSubscriptionRequest { - pub subscription_uuid: Uuid, -} - -/// 切换自动续费请求 -#[derive(Debug, Clone, Deserialize, Serialize)] -pub struct ToggleAutoRenewRequest { - pub subscription_uuid: Uuid, - pub auto_renew: bool, -} - -/// 查询交易记录请求 -#[derive(Debug, Clone, Deserialize, Serialize)] -pub struct ListTransactionsRequest { - #[serde(flatten)] - pub pagination: Pagination, - pub transaction_type: Option, -} - -/// 查询发票列表请求 -#[derive(Debug, Clone, Deserialize, Serialize)] -pub struct ListInvoicesRequest { - #[serde(flatten)] - pub pagination: Pagination, - pub status: Option, -} - -/// 验证优惠券请求 -#[derive(Debug, Clone, Deserialize, Serialize)] -pub struct VerifyCouponRequest { - pub code: String, - pub amount: Decimal, -} - -/// 创建充值订单请求 -#[derive(Debug, Clone, Deserialize, Serialize)] -pub struct CreateRechargeOrderRequest { - pub amount: Decimal, - pub payment_channel: String, -} - -/// 查询支付订单请求 -#[derive(Debug, Clone, Deserialize, Serialize)] -pub struct ListPaymentOrdersRequest { - #[serde(flatten)] - pub pagination: Pagination, - pub order_type: Option, - pub status: Option, -} - -/// 获取用户优惠券列表请求 -#[derive(Debug, Clone, Deserialize, Serialize)] -pub struct GetUserCouponsRequest { - #[serde(flatten)] - pub pagination: Pagination, - pub status: Option, // unused, used, expired -} - -/// 发放优惠券请求(管理员功能) -#[derive(Debug, Clone, Deserialize, Serialize)] -pub struct IssueCouponRequest { - pub coupon_uuid: Uuid, - pub user_uuid: Uuid, - pub expires_at: Option>, // 可选的自定义过期时间 -} - -/// 批量发放优惠券请求(管理员功能) -#[derive(Debug, Clone, Deserialize, Serialize)] -pub struct BatchIssueCouponRequest { - pub coupon_uuid: Uuid, - pub user_uuids: Vec, - pub expires_at: Option>, // 可选的自定义过期时间 -} - -/// 获取套餐价格请求 -#[derive(Debug, Clone, Deserialize, Serialize)] -pub struct GetPlanPriceRequest { - pub plan_uuid: Uuid, - pub billing_period: String, // monthly, yearly - pub coupon_code: Option, -} - -/// 获取套餐列表请求 -#[derive(Debug, Clone, Deserialize, Serialize)] -pub struct GetPlansRequest { - /// 优惠券代码(可选) - pub coupon_code: Option, - /// 计费周期(用于计算价格,默认为 monthly) - #[serde(default = "default_billing_period")] - pub billing_period: String, -} - -fn default_billing_period() -> String { - "monthly".to_string() -} - -// ========== 响应结构体 ========== - -/// 带特性的套餐结构 -#[derive(Debug, Clone, Serialize)] -pub struct PlanWithFeatures { - pub plan: PlanDto, - pub features: Vec, - /// 计算后的价格信息(如果提供了优惠券代码) - #[serde(skip_serializing_if = "Option::is_none")] - pub calculated_price: Option, -} - -/// 套餐价格信息(用于套餐列表响应) -#[derive(Debug, Clone, Serialize)] -pub struct PlanPriceInfo { - pub original_price: Decimal, - pub plan_discount: Decimal, - pub coupon_discount: Decimal, - pub final_price: Decimal, - pub total_saved: Decimal, - pub billing_period: String, // monthly, yearly -} - -/// 套餐列表响应 -#[derive(Debug, Clone, Serialize)] -pub struct PlansResponse { - pub plans: Vec, -} - -/// 套餐详情响应 -#[derive(Debug, Clone, Serialize)] -pub struct PlanDetailResponse { - pub plan: PlanDto, - pub features: Vec, -} - -/// 订阅响应 -#[derive(Debug, Clone, Serialize)] -pub struct SubscribeResponse { - pub subscription_uuid: Uuid, -} - -/// 交易记录列表响应 -#[derive(Debug, Clone, Serialize)] -pub struct TransactionsListResponse { - pub items: Vec, - pub total: i64, - pub page: i64, - pub page_size: i64, -} - -/// 发票列表响应 -#[derive(Debug, Clone, Serialize)] -pub struct InvoicesListResponse { - pub items: Vec, - pub total: i64, - pub page: i64, - pub page_size: i64, -} - -/// 优惠券验证响应 -#[derive(Debug, Clone, Serialize)] -pub struct VerifyCouponResponse { - pub coupon_uuid: Uuid, - pub discount_type: String, - pub discount_value: rust_decimal::Decimal, - pub discount_amount: rust_decimal::Decimal, -} - -/// 创建订单响应 -#[derive(Debug, Clone, Serialize)] -pub struct CreateOrderResponse { - pub order_uuid: Uuid, - pub order_no: String, -} - -/// 支付订单列表响应 -#[derive(Debug, Clone, Serialize)] -pub struct PaymentOrdersListResponse { - pub items: Vec, - pub total: i64, - pub page: i64, - pub page_size: i64, -} - -/// 优惠券验证结果 -#[derive(Debug, Clone, Serialize)] -pub struct CouponValidationResult { - pub coupon: CouponDto, - pub discount_amount: rust_decimal::Decimal, -} - -/// 账户信息响应 -#[derive(Debug, Clone, Serialize)] -pub struct AccountInfoResponse { - pub email: String, - pub wallet_balance: rust_decimal::Decimal, - pub gift_balance: rust_decimal::Decimal, - pub currency: String, - pub subscription: Option, - pub quota: crate::dto::WorkspaceQuotaDto, - pub monthly_billing: rust_decimal::Decimal, -} - -/// 用户优惠券列表响应 -#[derive(Debug, Clone, Serialize)] -pub struct UserCouponsListResponse { - pub items: Vec, - pub total: i64, - pub page: i64, - pub page_size: i64, -} - -/// 套餐价格响应 -#[derive(Debug, Clone, Serialize)] -pub struct PlanPriceResponse { - pub original_price: Decimal, - pub plan_discount: Decimal, // 套餐级折扣金额 - pub coupon_discount: Decimal, // 优惠券折扣金额 - pub final_price: Decimal, // 最终价格 - pub total_saved: Decimal, // 总节省金额 - pub coupon_info: Option, // 优惠券信息(如果使用了优惠券) -} diff --git a/src-tauri/crates/business/src/entitys/extensions.rs b/src-tauri/crates/business/src/entitys/extensions.rs deleted file mode 100644 index c402acd..0000000 --- a/src-tauri/crates/business/src/entitys/extensions.rs +++ /dev/null @@ -1,228 +0,0 @@ -use chrono::{DateTime, Utc}; -use rust_decimal::Decimal; -use serde::{Deserialize, Serialize}; -use uuid::Uuid; - -use super::Pagination; - -/// 创建扩展参数(模型层入参) -#[derive(Debug, Clone, Serialize, Deserialize)] -pub struct CreateExtensionParams { - pub extension_id: String, - pub name: String, - pub description: Option, - pub version: String, - pub category: String, - pub browser: String, - pub developer: Option, - pub homepage: Option, - pub icon_url: Option, - pub download_url: Option, - pub file_size: Option, - pub downloads_count: Option, - pub permissions: Option, - pub rating: Option, - pub changelog: Option, - pub published_at: Option>, - pub hash: Option, -} - -/// 更新扩展参数(模型层入参) -#[derive(Debug, Clone, Serialize, Deserialize)] -pub struct UpdateExtensionParams { - pub name: Option, - pub description: Option, - pub version: Option, - pub category: Option, - pub developer: Option, - pub homepage: Option, - pub icon_url: Option, - pub download_url: Option, - pub file_size: Option, - pub downloads_count: Option, - pub permissions: Option, - pub rating: Option, - pub changelog: Option, - pub published_at: Option>, - pub hash: Option, -} - -/// 查询扩展列表请求 -#[derive(Debug, Clone, Deserialize, Serialize)] -pub struct ListExtensionsRequest { - #[serde(flatten)] - pub pagination: Pagination, - pub filters: Option, -} - -/// 同步扩展响应 -#[derive(Debug, Clone, Serialize)] -pub struct SyncExtensionResponse { - /// 扩展 UUID - pub uuid: Uuid, - /// 扩展 ID - pub extension_id: String, - /// 是否为新创建 - pub created: bool, -} - -/// 扩展筛选条件 -#[derive(Debug, Clone, Deserialize, Serialize)] -pub struct ExtensionFilters { - pub keyword: Option, - pub category: Option, - pub installed_only: Option, - pub sort_by: Option, - pub sort_order: Option, -} - -/// 安装扩展请求 -#[derive(Debug, Clone, Deserialize, Serialize)] -pub struct InstallExtensionRequest { - pub extension_id: String, - /// 安装目标: user, team, group - pub target_type: Option, - /// 分组 UUID 数组(用于安装到分组,即使只有一个分组也需要传入数组) - #[serde(skip_serializing_if = "Option::is_none")] - pub group_ids: Option>, - /// 是否为团队共享(仅 target_type=group 时有效) - /// true: 团队所有成员可用,false: 仅创建者可用 - #[serde(skip_serializing_if = "Option::is_none")] - pub is_team_shared: Option, -} - -/// 卸载扩展请求 -#[derive(Debug, Clone, Deserialize, Serialize)] -pub struct UninstallExtensionRequest { - pub extension_id: String, - /// 卸载类型:user、team、group - /// 如果不指定,默认为 user - pub target_type: Option, - /// 目标 UUID(用于 team 和 group 类型) - /// - target_type=team: 表示 team_uuid - /// - target_type=group: 表示 group_uuid - #[serde(skip_serializing_if = "Option::is_none")] - pub target_uuid: Option, -} - -/// 更新扩展请求 -#[derive(Debug, Clone, Deserialize, Serialize)] -pub struct UpdateExtensionRequest { - pub extension_id: String, -} - -/// 批量更新扩展请求 -#[derive(Debug, Clone, Deserialize, Serialize)] -pub struct BatchUpdateExtensionsRequest { - pub extension_ids: Vec, -} - -/// 扩展 ID 请求 -#[derive(Debug, Clone, Deserialize, Serialize)] -pub struct ExtensionIdRequest { - pub extension_id: String, -} - -/// 禁用/启用扩展请求 -#[derive(Debug, Clone, Deserialize, Serialize)] -pub struct ToggleExtensionRequest { - pub extension_id: String, -} - -// ========== 响应结构体 ========== - -use crate::dto::ExtensionDto; - -/// 扩展列表响应 -#[derive(Debug, Clone, Serialize)] -pub struct ExtensionsListResponse { - pub items: Vec, - pub total: i64, - pub page: i64, - pub page_size: i64, -} - -/// 获取已安装扩展请求 -#[derive(Debug, Clone, Deserialize, Serialize)] -pub struct GetInstalledExtensionsRequest { - /// 范围过滤:all, user, team - #[serde(default = "default_scope")] - pub scope: String, -} - -fn default_scope() -> String { - "all".to_string() -} - -/// 已安装扩展响应 -#[derive(Debug, Clone, Serialize)] -pub struct InstalledExtensionsResponse { - pub user_extensions: Vec, - pub team_extensions: Vec, -} - -/// 已安装扩展项(包含完整扩展详情) -#[derive(Debug, Clone, Serialize)] -pub struct InstalledExtensionItem { - pub extension_id: String, - pub name: String, - pub version: String, - pub installed_version: String, - pub has_update: bool, - pub status: String, - pub installed_at: chrono::DateTime, - /// 扩展主页 URL - #[serde(skip_serializing_if = "Option::is_none")] - pub homepage: Option, - /// 扩展图标 URL - #[serde(skip_serializing_if = "Option::is_none")] - pub icon_url: Option, - /// 团队 UUID(如果是团队安装的) - #[serde(skip_serializing_if = "Option::is_none")] - pub team_uuid: Option, - /// 安装范围:user 或 team - pub scope: String, - /// 扩展描述 - #[serde(skip_serializing_if = "Option::is_none")] - pub description: Option, - /// 扩展分类 - #[serde(skip_serializing_if = "Option::is_none")] - pub category: Option, - /// 浏览器类型 - #[serde(skip_serializing_if = "Option::is_none")] - pub browser: Option, - /// 开发者/作者 - #[serde(skip_serializing_if = "Option::is_none")] - pub developer: Option, - /// 下载量 - #[serde(skip_serializing_if = "Option::is_none")] - pub downloads_count: Option, - /// 评分 - #[serde(skip_serializing_if = "Option::is_none")] - pub rating: Option, - /// 权限列表 - #[serde(skip_serializing_if = "Option::is_none")] - pub permissions: Option, - /// 文件大小 - #[serde(skip_serializing_if = "Option::is_none")] - pub file_size: Option, - /// 更新时间 - #[serde(skip_serializing_if = "Option::is_none")] - pub updated_at: Option>, - /// 关联的分组列表 - #[serde(skip_serializing_if = "Option::is_none")] - pub groups: Option>, -} - -/// 扩展关联的分组信息 -#[derive(Debug, Clone, Serialize)] -pub struct ExtensionGroup { - pub uuid: Uuid, - pub name: String, -} - -/// 批量更新响应 -#[derive(Debug, Clone, Serialize)] -pub struct BatchUpdateResponse { - pub updated: u64, -} diff --git a/src-tauri/crates/business/src/entitys/mod.rs b/src-tauri/crates/business/src/entitys/mod.rs index 35b50e0..a86aea2 100644 --- a/src-tauri/crates/business/src/entitys/mod.rs +++ b/src-tauri/crates/business/src/entitys/mod.rs @@ -5,21 +5,18 @@ pub mod version_types; pub mod versions; // 新增模块 -pub mod accounts; -pub mod audit; -pub mod billing; -pub mod browser_kernel; +pub mod accounts; +pub mod audit; +pub mod browser_kernel; pub mod common; pub mod environments; -pub mod extensions; -pub mod group_member_permissions; +pub mod group_member_permissions; pub mod groups; pub mod local_api; pub mod messages; pub mod proxies; pub mod proxy_visible_teams; -pub mod referral; -pub mod rpa; +pub mod rpa; pub mod settings; pub mod tags; pub mod teams; @@ -34,21 +31,18 @@ pub use version_types::*; pub use versions::*; // 新增导出 -pub use accounts::*; -pub use audit::*; -pub use billing::*; -pub use browser_kernel::*; +pub use accounts::*; +pub use audit::*; +pub use browser_kernel::*; pub use common::*; pub use environments::*; -pub use extensions::*; -pub use group_member_permissions::*; +pub use group_member_permissions::*; pub use groups::*; pub use local_api::*; pub use messages::*; pub use proxies::*; pub use proxy_visible_teams::*; -pub use referral::*; -pub use rpa::*; +pub use rpa::*; pub use settings::*; pub use tags::*; pub use teams::*; diff --git a/src-tauri/crates/business/src/entitys/referral.rs b/src-tauri/crates/business/src/entitys/referral.rs deleted file mode 100644 index ae5f517..0000000 --- a/src-tauri/crates/business/src/entitys/referral.rs +++ /dev/null @@ -1,141 +0,0 @@ -use serde::{Deserialize, Serialize}; -use uuid::Uuid; - -use super::Pagination; - -/// 切换推广链接请求 -#[derive(Debug, Clone, Deserialize, Serialize)] -pub struct SwitchReferralLinkRequest { - pub link_uuid: Uuid, -} - -/// 查询奖励记录请求 -#[derive(Debug, Clone, Deserialize, Serialize)] -pub struct ListReferralRewardsRequest { - #[serde(flatten)] - pub pagination: Pagination, - pub keyword: Option, - pub reward_type: Option, - pub status: Option, -} - -/// 查询被邀请用户请求 -#[derive(Debug, Clone, Deserialize, Serialize)] -pub struct ListReferredUsersRequest { - #[serde(flatten)] - pub pagination: Pagination, - pub keyword: Option, - pub status: Option, -} - -/// 兑换积分请求 -#[derive(Debug, Clone, Deserialize, Serialize)] -pub struct RedeemPointsRequest { - pub option_uuid: Uuid, - pub points: i32, -} - -// ========== 响应结构体 ========== - -use crate::dto::{ - RedeemOptionDto, RedeemRecordDto, ReferralLinkDto, ReferralLinkTierDto, ReferralRewardDto, - ReferredUserItemDto, UserReferralPointsDto, -}; -use rust_decimal::Decimal; - -/// 推广统计响应 -#[derive(Debug, Clone, Serialize)] -pub struct ReferralStatsResponse { - pub total_referrals: i32, - pub paid_referrals: i32, - pub total_consumption: Decimal, - pub last_30_days_consumption: Decimal, - pub total_rewards: i32, - pub available_points: i32, - pub current_tier: Option, - pub next_tier: Option, - pub upgrade_progress: i32, -} - -/// 推广积分摘要(用于看板聚合) -#[derive(Debug, Clone, Serialize)] -pub struct ReferralPointsSummary { - pub available_points: i32, - pub pending_points: i32, - pub total_rewards: i32, -} - -/// 推广看板聚合响应 -#[derive(Debug, Clone, Serialize)] -pub struct ReferralDashboardResponse { - pub stats: ReferralStatsResponse, - pub links: Vec, - pub current_link: Option, - pub tiers: Vec, - pub points: ReferralPointsSummary, -} - -/// 套餐页推广摘要响应 -#[derive(Debug, Clone, Serialize)] -pub struct ReferralPlanSummaryResponse { - /// 最近 30 天推广产生的消费金额对应的预估收益(按照当前层级 reward_rate 估算) - pub referral_value_last_30_days: Decimal, - /// 当前订阅套餐的月度价格(如果有订阅) - pub current_plan_monthly_price: Option, - /// 推广收益覆盖当前套餐费用的比例(0-1) - pub coverage_ratio: Option, -} - -/// 推广链接列表响应 -#[derive(Debug, Clone, Serialize)] -pub struct ReferralLinksListResponse { - pub items: Vec, - pub current_link: Option, -} - -/// 奖励记录列表响应 -#[derive(Debug, Clone, Serialize)] -pub struct ReferralRewardsListResponse { - pub items: Vec, - pub total: i64, - pub page: i64, - pub page_size: i64, -} - -/// 被邀请用户列表响应 -#[derive(Debug, Clone, Serialize)] -pub struct ReferredUsersListResponse { - pub items: Vec, - pub total: i64, - pub page: i64, - pub page_size: i64, -} - -/// 兑换选项列表响应 -#[derive(Debug, Clone, Serialize)] -pub struct RedeemOptionsListResponse { - pub items: Vec, -} - -/// 兑换响应 -#[derive(Debug, Clone, Serialize)] -pub struct RedeemResponse { - pub record_uuid: Uuid, - pub points_used: i32, - pub value: Decimal, -} - -/// 用户积分响应 -#[derive(Debug, Clone, Serialize)] -pub struct UserPointsResponse { - pub points: UserReferralPointsDto, -} - -/// 兑换记录列表响应 -#[derive(Debug, Clone, Serialize)] -pub struct RedeemRecordsListResponse { - pub items: Vec, - pub total: i64, - pub page: i64, - pub page_size: i64, -} diff --git a/src-tauri/crates/business/src/models/accounts.rs b/src-tauri/crates/business/src/models/accounts.rs index f75a8cd..4164ddb 100644 --- a/src-tauri/crates/business/src/models/accounts.rs +++ b/src-tauri/crates/business/src/models/accounts.rs @@ -1,7 +1,7 @@ use sqlx::Error; use uuid::Uuid; -use crate::database::{Db as Postgres, Pool}; +use crate::database::{Db as Postgres, Pool, placeholders}; use crate::dto::PlatformAccountDto; @@ -59,14 +59,14 @@ pub async fn fetch_platform_accounts( FROM platform_accounts pa WHERE (pa.team_uuid = $1 OR (pa.team_uuid IS NULL AND pa.user_uuid = $2)) AND ( - $3::text IS NULL - OR pa.platform_url ILIKE $3 - OR COALESCE(pa.platform_name, '') ILIKE $3 - OR pa.account ILIKE $3 - OR COALESCE(pa.remark, '') ILIKE $3 + $3 IS NULL + OR pa.platform_url LIKE $3 + OR COALESCE(pa.platform_name, '') LIKE $3 + OR pa.account LIKE $3 + OR COALESCE(pa.remark, '') LIKE $3 ) - AND ($4::varchar IS NULL OR pa.platform_name = $4) - AND ($5::varchar IS NULL OR pa.status = $5) + AND ($4 IS NULL OR pa.platform_name = $4) + AND ($5 IS NULL OR pa.status = $5) AND pa.deleted_at IS NULL ORDER BY pa.created_at DESC LIMIT $6 OFFSET $7 @@ -101,14 +101,14 @@ pub async fn fetch_platform_accounts_count( SELECT COUNT(*) FROM platform_accounts WHERE (team_uuid = $1 OR (team_uuid IS NULL AND user_uuid = $2)) AND ( - $3::text IS NULL - OR platform_url ILIKE $3 - OR COALESCE(platform_name, '') ILIKE $3 - OR account ILIKE $3 - OR COALESCE(remark, '') ILIKE $3 + $3 IS NULL + OR platform_url LIKE $3 + OR COALESCE(platform_name, '') LIKE $3 + OR account LIKE $3 + OR COALESCE(remark, '') LIKE $3 ) - AND ($4::varchar IS NULL OR platform_name = $4) - AND ($5::varchar IS NULL OR status = $5) + AND ($4 IS NULL OR platform_name = $4) + AND ($5 IS NULL OR status = $5) AND deleted_at IS NULL "#, ) @@ -223,15 +223,22 @@ pub async fn batch_delete_platform_accounts( pool: &Pool, account_uuids: &[Uuid], ) -> Result { - let result = sqlx::query( + if account_uuids.is_empty() { + return Ok(0); + } + + let statement = format!( r#" UPDATE platform_accounts SET deleted_at = CURRENT_TIMESTAMP - WHERE uuid = ANY($1) AND deleted_at IS NULL + WHERE uuid IN ({}) AND deleted_at IS NULL "#, - ) - .bind(account_uuids) - .execute(pool) - .await?; + placeholders(1, account_uuids.len()) + ); + let mut query = sqlx::query(&statement); + for uuid in account_uuids { + query = query.bind(uuid); + } + let result = query.execute(pool).await?; Ok(result.rows_affected()) } diff --git a/src-tauri/crates/business/src/models/audit.rs b/src-tauri/crates/business/src/models/audit.rs index e88280e..0fd74b9 100644 --- a/src-tauri/crates/business/src/models/audit.rs +++ b/src-tauri/crates/business/src/models/audit.rs @@ -70,13 +70,13 @@ pub async fn fetch_audit_logs( FROM audit_logs a LEFT JOIN user_infos ui ON a.user_uuid = ui.user_uuid WHERE ( - ($2::uuid IS NULL OR a.team_uuid = $2) + ($2 IS NULL OR a.team_uuid = $2) OR (a.team_uuid IS NULL AND a.user_uuid = $1) ) - AND ($3::uuid IS NULL OR a.user_uuid = $3) - AND ($4::text IS NULL OR COALESCE(a.details, '') ILIKE '%' || $4 || '%') - AND ($5::varchar IS NULL OR a.action = $5) - AND ($6::varchar IS NULL OR a.target_type = $6) + AND ($3 IS NULL OR a.user_uuid = $3) + AND ($4 IS NULL OR COALESCE(a.details, '') LIKE '%' || $4 || '%') + AND ($5 IS NULL OR a.action = $5) + AND ($6 IS NULL OR a.target_type = $6) ORDER BY a.created_at DESC LIMIT $7 OFFSET $8 "#, @@ -109,13 +109,13 @@ pub async fn fetch_audit_logs_count( r#" SELECT COUNT(*) FROM audit_logs WHERE ( - ($2::uuid IS NULL OR team_uuid = $2) + ($2 IS NULL OR team_uuid = $2) OR (team_uuid IS NULL AND user_uuid = $1) ) - AND ($3::uuid IS NULL OR user_uuid = $3) - AND ($4::text IS NULL OR COALESCE(details, '') ILIKE '%' || $4 || '%') - AND ($5::varchar IS NULL OR action = $5) - AND ($6::varchar IS NULL OR target_type = $6) + AND ($3 IS NULL OR user_uuid = $3) + AND ($4 IS NULL OR COALESCE(details, '') LIKE '%' || $4 || '%') + AND ($5 IS NULL OR action = $5) + AND ($6 IS NULL OR target_type = $6) "#, ) .bind(current_user_uuid) @@ -162,7 +162,7 @@ pub async fn fetch_audit_logs_count_by_date( let count: i64 = sqlx::query_scalar( r#" SELECT COUNT(*) FROM audit_logs - WHERE ($1::uuid IS NULL OR team_uuid = $1) + WHERE ($1 IS NULL OR team_uuid = $1) AND created_at::date = $2 "#, ) @@ -183,7 +183,7 @@ pub async fn fetch_audit_logs_count_since_date( let count: i64 = sqlx::query_scalar( r#" SELECT COUNT(*) FROM audit_logs - WHERE ($1::uuid IS NULL OR team_uuid = $1) + WHERE ($1 IS NULL OR team_uuid = $1) AND created_at::date >= $2 "#, ) @@ -204,7 +204,7 @@ pub async fn fetch_top_actions( let rows: Vec<(String, i64)> = sqlx::query_as( r#" SELECT action, COUNT(*) as count FROM audit_logs - WHERE ($1::uuid IS NULL OR team_uuid = $1) + WHERE ($1 IS NULL OR team_uuid = $1) GROUP BY action ORDER BY count DESC LIMIT $2 @@ -227,7 +227,7 @@ pub async fn fetch_top_target_types( let rows: Vec<(String, i64)> = sqlx::query_as( r#" SELECT target_type, COUNT(*) as count FROM audit_logs - WHERE ($1::uuid IS NULL OR team_uuid = $1) + WHERE ($1 IS NULL OR team_uuid = $1) GROUP BY target_type ORDER BY count DESC LIMIT $2 diff --git a/src-tauri/crates/business/src/models/billing.rs b/src-tauri/crates/business/src/models/billing.rs deleted file mode 100644 index 3b271a7..0000000 --- a/src-tauri/crates/business/src/models/billing.rs +++ /dev/null @@ -1,1073 +0,0 @@ -use chrono::NaiveDate; -use rust_decimal::Decimal; -use sqlx::{Error, Row}; -use uuid::Uuid; - -use crate::database::{Db as Postgres, Pool}; - -use crate::dto::{ - AutoRenewalServiceDto, CouponDto, InvoiceDto, PaymentOrderDto, PlanDto, PlanFeatureDto, - SubscriptionDto, UserQuotaDto, UserWalletDto, WalletTransactionDto, -}; - -// ============ Plans ============ - -/// 查询所有可用套餐 -pub async fn fetch_plans(pool: &Pool) -> Result, Error> { - let recs = sqlx::query_as::<_, PlanDto>( - r#" - SELECT id, uuid, name, description, price_per_month, price_per_year, - currency, discount_monthly, discount_yearly, max_environments, - max_team_members, max_proxies, max_rpa_tasks, is_recommended, - sort_order, status, created_at, updated_at - FROM plans - WHERE status = 'active' - ORDER BY sort_order ASC, price_per_month ASC - "#, - ) - .fetch_all(pool) - .await?; - - Ok(recs) -} - -/// 根据 UUID 查询套餐 -pub async fn fetch_plan_by_uuid( - pool: &Pool, - plan_uuid: Uuid, -) -> Result, Error> { - let rec = sqlx::query_as::<_, PlanDto>( - r#" - SELECT id, uuid, name, description, price_per_month, price_per_year, - currency, discount_monthly, discount_yearly, max_environments, - max_team_members, max_proxies, max_rpa_tasks, is_recommended, - sort_order, status, created_at, updated_at - FROM plans - WHERE uuid = $1 - "#, - ) - .bind(plan_uuid) - .fetch_optional(pool) - .await?; - - Ok(rec) -} - -/// 查询套餐特性 -pub async fn fetch_plan_features( - pool: &Pool, - plan_uuid: Uuid, -) -> Result, Error> { - let recs = sqlx::query_as::<_, PlanFeatureDto>( - r#" - SELECT id, plan_uuid, feature_key, feature_name, feature_value, - is_included, sort_order, created_at - FROM plan_features - WHERE plan_uuid = $1 - ORDER BY sort_order ASC - "#, - ) - .bind(plan_uuid) - .fetch_all(pool) - .await?; - - Ok(recs) -} - -// ============ Subscriptions ============ - -/// 查询用户当前订阅(按用户) -pub async fn fetch_active_subscription( - pool: &Pool, - user_uuid: Uuid, -) -> Result, Error> { - let rec = sqlx::query_as::<_, SubscriptionDto>( - r#" - SELECT id, uuid, workspace_uuid, user_uuid, plan_uuid, billing_period, price, currency, - started_at, expires_at, next_billing_date, auto_renew, status, - cancelled_at, created_at, updated_at - FROM subscriptions - WHERE user_uuid = $1 AND status IN ('active', 'paused') - ORDER BY created_at DESC - LIMIT 1 - "#, - ) - .bind(user_uuid) - .fetch_optional(pool) - .await?; - - Ok(rec) -} - -/// 查询工作空间当前订阅 -pub async fn fetch_workspace_active_subscription( - pool: &Pool, - workspace_uuid: Uuid, -) -> Result, Error> { - let rec = sqlx::query_as::<_, SubscriptionDto>( - r#" - SELECT id, uuid, workspace_uuid, user_uuid, plan_uuid, billing_period, price, currency, - started_at, expires_at, next_billing_date, auto_renew, status, - cancelled_at, created_at, updated_at - FROM subscriptions - WHERE workspace_uuid = $1 AND status IN ('active', 'paused') - ORDER BY created_at DESC - LIMIT 1 - "#, - ) - .bind(workspace_uuid) - .fetch_optional(pool) - .await?; - - Ok(rec) -} - -/// 根据 UUID 查询订阅 -pub async fn fetch_subscription_by_uuid( - pool: &Pool, - subscription_uuid: Uuid, -) -> Result, Error> { - let rec = sqlx::query_as::<_, SubscriptionDto>( - r#" - SELECT id, uuid, workspace_uuid, user_uuid, plan_uuid, billing_period, price, currency, - started_at, expires_at, next_billing_date, auto_renew, status, - cancelled_at, created_at, updated_at - FROM subscriptions - WHERE uuid = $1 - "#, - ) - .bind(subscription_uuid) - .fetch_optional(pool) - .await?; - - Ok(rec) -} - -/// 创建订阅 -pub async fn insert_subscription( - pool: &Pool, - workspace_uuid: Uuid, - user_uuid: Uuid, - plan_uuid: Uuid, - billing_period: &str, - price: Decimal, - currency: &str, - expires_at: chrono::DateTime, - next_billing_date: NaiveDate, -) -> Result { - let uuid: Uuid = sqlx::query_scalar( - r#" - INSERT INTO subscriptions (workspace_uuid, user_uuid, plan_uuid, billing_period, price, currency, - started_at, expires_at, next_billing_date, auto_renew, status) - VALUES ($1, $2, $3, $4, $5, $6, CURRENT_TIMESTAMP, $7, $8, true, 'active') - RETURNING uuid; - "#, - ) - .bind(workspace_uuid) - .bind(user_uuid) - .bind(plan_uuid) - .bind(billing_period) - .bind(price) - .bind(currency) - .bind(expires_at) - .bind(next_billing_date) - .fetch_one(pool) - .await?; - - Ok(uuid) -} - -/// 取消订阅 -pub async fn cancel_subscription( - pool: &Pool, - subscription_uuid: Uuid, -) -> Result<(), Error> { - sqlx::query( - r#" - UPDATE subscriptions - SET status = 'cancelled', cancelled_at = CURRENT_TIMESTAMP, updated_at = CURRENT_TIMESTAMP - WHERE uuid = $1 - "#, - ) - .bind(subscription_uuid) - .execute(pool) - .await?; - - Ok(()) -} - -/// 恢复订阅 -pub async fn resume_subscription( - pool: &Pool, - subscription_uuid: Uuid, -) -> Result<(), Error> { - sqlx::query( - r#" - UPDATE subscriptions - SET status = 'active', cancelled_at = NULL, updated_at = CURRENT_TIMESTAMP - WHERE uuid = $1 - "#, - ) - .bind(subscription_uuid) - .execute(pool) - .await?; - - Ok(()) -} - -/// 切换自动续费 -pub async fn toggle_auto_renew( - pool: &Pool, - subscription_uuid: Uuid, - auto_renew: bool, -) -> Result<(), Error> { - sqlx::query( - r#" - UPDATE subscriptions - SET auto_renew = $1, updated_at = CURRENT_TIMESTAMP - WHERE uuid = $2 - "#, - ) - .bind(auto_renew) - .bind(subscription_uuid) - .execute(pool) - .await?; - - Ok(()) -} - -// ============ User Wallet ============ - -/// 获取用户钱包 -pub async fn fetch_user_wallet( - pool: &Pool, - user_uuid: Uuid, -) -> Result, Error> { - let rec = sqlx::query_as::<_, UserWalletDto>( - r#" - SELECT id, user_uuid, balance, currency, frozen_amount, auto_renewal_combined, - created_at, updated_at - FROM user_wallets - WHERE user_uuid = $1 - "#, - ) - .bind(user_uuid) - .fetch_optional(pool) - .await?; - - Ok(rec) -} - -/// 创建用户钱包 -pub async fn insert_user_wallet( - pool: &Pool, - user_uuid: Uuid, - currency: &str, -) -> Result { - let id: i32 = sqlx::query_scalar( - r#" - INSERT INTO user_wallets (user_uuid, balance, currency, frozen_amount, auto_renewal_combined) - VALUES ($1, 0, $2, 0, 0) - ON CONFLICT (user_uuid) DO NOTHING - RETURNING id; - "#, - ) - .bind(user_uuid) - .bind(currency) - .fetch_one(pool) - .await?; - - Ok(id) -} - -/// 更新钱包余额 -pub async fn update_wallet_balance( - pool: &Pool, - user_uuid: Uuid, - amount: Decimal, -) -> Result<(), Error> { - sqlx::query( - r#" - UPDATE user_wallets - SET balance = balance + $1, updated_at = CURRENT_TIMESTAMP - WHERE user_uuid = $2 - "#, - ) - .bind(amount) - .bind(user_uuid) - .execute(pool) - .await?; - - Ok(()) -} - -// ============ Wallet Transactions ============ - -/// 查询钱包交易记录 -pub async fn fetch_wallet_transactions( - pool: &Pool, - user_uuid: Uuid, - transaction_type: Option<&str>, - offset: i64, - limit: i64, -) -> Result, Error> { - let recs = sqlx::query_as::<_, WalletTransactionDto>( - r#" - SELECT id, uuid, user_uuid, transaction_type, amount, currency, - balance_before, balance_after, description, order_uuid, status, created_at - FROM wallet_transactions - WHERE user_uuid = $1 - AND ($2::varchar IS NULL OR transaction_type = $2) - ORDER BY created_at DESC - LIMIT $3 OFFSET $4 - "#, - ) - .bind(user_uuid) - .bind(transaction_type) - .bind(limit) - .bind(offset) - .fetch_all(pool) - .await?; - - Ok(recs) -} - -/// 查询交易记录总数 -pub async fn fetch_wallet_transactions_count( - pool: &Pool, - user_uuid: Uuid, - transaction_type: Option<&str>, -) -> Result { - let count: i64 = sqlx::query_scalar( - r#" - SELECT COUNT(*) FROM wallet_transactions - WHERE user_uuid = $1 - AND ($2::varchar IS NULL OR transaction_type = $2) - "#, - ) - .bind(user_uuid) - .bind(transaction_type) - .fetch_one(pool) - .await?; - - Ok(count) -} - -/// 插入钱包交易记录 -pub async fn insert_wallet_transaction( - pool: &Pool, - user_uuid: Uuid, - transaction_type: &str, - amount: Decimal, - currency: &str, - balance_before: Decimal, - balance_after: Decimal, - description: Option<&str>, - order_uuid: Option, -) -> Result { - let uuid: Uuid = sqlx::query_scalar( - r#" - INSERT INTO wallet_transactions (user_uuid, transaction_type, amount, currency, - balance_before, balance_after, description, order_uuid, status) - VALUES ($1, $2, $3, $4, $5, $6, $7, $8, 'completed') - RETURNING uuid; - "#, - ) - .bind(user_uuid) - .bind(transaction_type) - .bind(amount) - .bind(currency) - .bind(balance_before) - .bind(balance_after) - .bind(description) - .bind(order_uuid) - .fetch_one(pool) - .await?; - - Ok(uuid) -} - -// ============ Invoices ============ - -/// 查询发票列表 -pub async fn fetch_invoices( - pool: &Pool, - user_uuid: Uuid, - status: Option<&str>, - offset: i64, - limit: i64, -) -> Result, Error> { - let recs = sqlx::query_as::<_, InvoiceDto>( - r#" - SELECT id, uuid, user_uuid, invoice_number, amount, currency, - subscription_uuid, order_uuid, invoice_type, status, - issued_at, due_at, paid_at, invoice_url, created_at, updated_at - FROM invoices - WHERE user_uuid = $1 - AND ($2::varchar IS NULL OR status = $2) - ORDER BY created_at DESC - LIMIT $3 OFFSET $4 - "#, - ) - .bind(user_uuid) - .bind(status) - .bind(limit) - .bind(offset) - .fetch_all(pool) - .await?; - - Ok(recs) -} - -/// 查询发票总数 -pub async fn fetch_invoices_count( - pool: &Pool, - user_uuid: Uuid, - status: Option<&str>, -) -> Result { - let count: i64 = sqlx::query_scalar( - r#" - SELECT COUNT(*) FROM invoices - WHERE user_uuid = $1 - AND ($2::varchar IS NULL OR status = $2) - "#, - ) - .bind(user_uuid) - .bind(status) - .fetch_one(pool) - .await?; - - Ok(count) -} - -/// 创建发票 -pub async fn insert_invoice( - pool: &Pool, - user_uuid: Uuid, - invoice_number: &str, - amount: Decimal, - currency: &str, - subscription_uuid: Option, - order_uuid: Option, - invoice_type: &str, -) -> Result { - let uuid: Uuid = sqlx::query_scalar( - r#" - INSERT INTO invoices (user_uuid, invoice_number, amount, currency, - subscription_uuid, order_uuid, invoice_type, status, issued_at) - VALUES ($1, $2, $3, $4, $5, $6, $7, 'pending', CURRENT_TIMESTAMP) - RETURNING uuid; - "#, - ) - .bind(user_uuid) - .bind(invoice_number) - .bind(amount) - .bind(currency) - .bind(subscription_uuid) - .bind(order_uuid) - .bind(invoice_type) - .fetch_one(pool) - .await?; - - Ok(uuid) -} - -// ============ User Quotas ============ - -/// 获取用户配额 -pub async fn fetch_user_quota( - pool: &Pool, - user_uuid: Uuid, -) -> Result, Error> { - let rec = sqlx::query_as::<_, UserQuotaDto>( - r#" - SELECT id, user_uuid, max_environments, used_environments, max_team_members, - max_proxies, used_proxies, max_rpa_tasks, used_rpa_tasks, - created_at, updated_at - FROM user_quotas - WHERE user_uuid = $1 - "#, - ) - .bind(user_uuid) - .fetch_optional(pool) - .await?; - - Ok(rec) -} - -/// 创建或更新用户配额 -pub async fn upsert_user_quota( - pool: &Pool, - user_uuid: Uuid, - max_environments: i32, - max_team_members: i32, - max_proxies: i32, - max_rpa_tasks: i32, -) -> Result<(), Error> { - sqlx::query( - r#" - INSERT INTO user_quotas (user_uuid, max_environments, max_team_members, max_proxies, max_rpa_tasks) - VALUES ($1, $2, $3, $4, $5) - ON CONFLICT (user_uuid) DO UPDATE SET - max_environments = $2, - max_team_members = $3, - max_proxies = $4, - max_rpa_tasks = $5, - updated_at = CURRENT_TIMESTAMP - "#, - ) - .bind(user_uuid) - .bind(max_environments) - .bind(max_team_members) - .bind(max_proxies) - .bind(max_rpa_tasks) - .execute(pool) - .await?; - - Ok(()) -} - -// ============ Coupons ============ - -/// 根据优惠码查询优惠券 -pub async fn fetch_coupon_by_code( - pool: &Pool, - code: &str, -) -> Result, Error> { - let rec = sqlx::query_as::<_, CouponDto>( - r#" - SELECT id, uuid, code, name, description, discount_type, discount_value, min_amount, max_discount, - max_uses, used_count, max_uses_per_user, valid_from, valid_until, - applicable_to, status, created_at, updated_at - FROM coupons - WHERE code = $1 AND status = 'active' - "#, - ) - .bind(code) - .fetch_optional(pool) - .await?; - - Ok(rec) -} - -/// 根据 UUID 查询优惠券 -pub async fn fetch_coupon_by_uuid( - pool: &Pool, - coupon_uuid: Uuid, -) -> Result, Error> { - let rec = sqlx::query_as::<_, CouponDto>( - r#" - SELECT id, uuid, code, name, description, discount_type, discount_value, min_amount, max_discount, - max_uses, used_count, max_uses_per_user, valid_from, valid_until, - applicable_to, status, created_at, updated_at - FROM coupons - WHERE uuid = $1 AND status = 'active' - "#, - ) - .bind(coupon_uuid) - .fetch_optional(pool) - .await?; - - Ok(rec) -} - -/// 查询用户优惠券使用次数 -pub async fn fetch_coupon_user_usage_count( - pool: &Pool, - coupon_uuid: Uuid, - user_uuid: Uuid, -) -> Result { - let count: i64 = sqlx::query_scalar( - r#" - SELECT COUNT(*) FROM coupon_usages - WHERE coupon_uuid = $1 AND user_uuid = $2 - "#, - ) - .bind(coupon_uuid) - .bind(user_uuid) - .fetch_one(pool) - .await?; - - Ok(count as i32) -} - -/// 记录优惠券使用 -pub async fn insert_coupon_usage( - pool: &Pool, - coupon_uuid: Uuid, - user_uuid: Uuid, - order_uuid: Option, - discount_amount: Decimal, -) -> Result { - let id: i32 = sqlx::query_scalar( - r#" - INSERT INTO coupon_usages (coupon_uuid, user_uuid, order_uuid, discount_amount) - VALUES ($1, $2, $3, $4) - RETURNING id; - "#, - ) - .bind(coupon_uuid) - .bind(user_uuid) - .bind(order_uuid) - .bind(discount_amount) - .fetch_one(pool) - .await?; - - // 更新优惠券使用次数 - sqlx::query("UPDATE coupons SET used_count = used_count + 1 WHERE uuid = $1") - .bind(coupon_uuid) - .execute(pool) - .await?; - - Ok(id) -} - -// ============ User Coupons ============ - -/// 查询用户优惠券列表 -pub async fn fetch_user_coupons( - pool: &Pool, - user_uuid: Uuid, - status: Option<&str>, - page: i64, - page_size: i64, -) -> Result<(Vec, i64), Error> { - let offset = (page - 1) * page_size; - - let (items, total) = if let Some(s) = status { - let rows = sqlx::query( - r#" - SELECT - uc.id, uc.user_uuid, uc.coupon_uuid, uc.status, - uc.issued_at, uc.used_at, uc.expires_at, - c.code, c.name, c.description, c.discount_type, c.discount_value, c.min_amount, c.max_discount - FROM user_coupons uc - INNER JOIN coupons c ON uc.coupon_uuid = c.uuid - WHERE uc.user_uuid = $1 AND uc.status = $2 - ORDER BY uc.issued_at DESC - LIMIT $3 OFFSET $4 - "#, - ) - .bind(user_uuid) - .bind(s) - .bind(page_size) - .bind(offset) - .fetch_all(pool) - .await?; - - let items: Vec = rows - .into_iter() - .map(|row| crate::dto::UserCouponWithDetailsDto { - id: row.get(0), - user_uuid: row.get(1), - coupon_uuid: row.get(2), - status: row.get(3), - issued_at: row.get(4), - used_at: row.get(5), - expires_at: row.get(6), - code: row.get(7), - name: row.get(8), - description: row.get(9), - discount_type: row.get(10), - discount_value: row.get(11), - min_amount: row.get(12), - max_discount: row.get(13), - }) - .collect(); - - let total: i64 = sqlx::query_scalar( - r#" - SELECT COUNT(*) FROM user_coupons - WHERE user_uuid = $1 AND status = $2 - "#, - ) - .bind(user_uuid) - .bind(s) - .fetch_one(pool) - .await?; - - (items, total) - } else { - let rows = sqlx::query( - r#" - SELECT - uc.id, uc.user_uuid, uc.coupon_uuid, uc.status, - uc.issued_at, uc.used_at, uc.expires_at, - c.code, c.name, c.description, c.discount_type, c.discount_value, c.min_amount, c.max_discount - FROM user_coupons uc - INNER JOIN coupons c ON uc.coupon_uuid = c.uuid - WHERE uc.user_uuid = $1 - ORDER BY uc.issued_at DESC - LIMIT $2 OFFSET $3 - "#, - ) - .bind(user_uuid) - .bind(page_size) - .bind(offset) - .fetch_all(pool) - .await?; - - let items: Vec = rows - .into_iter() - .map(|row| crate::dto::UserCouponWithDetailsDto { - id: row.get(0), - user_uuid: row.get(1), - coupon_uuid: row.get(2), - status: row.get(3), - issued_at: row.get(4), - used_at: row.get(5), - expires_at: row.get(6), - code: row.get(7), - name: row.get(8), - description: row.get(9), - discount_type: row.get(10), - discount_value: row.get(11), - min_amount: row.get(12), - max_discount: row.get(13), - }) - .collect(); - - let total: i64 = sqlx::query_scalar( - r#" - SELECT COUNT(*) FROM user_coupons - WHERE user_uuid = $1 - "#, - ) - .bind(user_uuid) - .fetch_one(pool) - .await?; - - (items, total) - }; - - Ok((items, total)) -} - -/// 根据 UUID 查询用户优惠券 -pub async fn fetch_user_coupon_by_uuid( - pool: &Pool, - user_coupon_uuid: i32, -) -> Result, Error> { - let rec = sqlx::query_as::<_, crate::dto::UserCouponDto>( - r#" - SELECT id, user_uuid, coupon_uuid, status, issued_at, used_at, expires_at - FROM user_coupons - WHERE id = $1 - "#, - ) - .bind(user_coupon_uuid) - .fetch_optional(pool) - .await?; - - Ok(rec) -} - -/// 发放优惠券给用户 -pub async fn insert_user_coupon( - pool: &Pool, - user_uuid: Uuid, - coupon_uuid: Uuid, - expires_at: Option>, -) -> Result { - let id: i32 = sqlx::query_scalar( - r#" - INSERT INTO user_coupons (user_uuid, coupon_uuid, expires_at) - VALUES ($1, $2, $3) - ON CONFLICT (user_uuid, coupon_uuid) DO NOTHING - RETURNING id; - "#, - ) - .bind(user_uuid) - .bind(coupon_uuid) - .bind(expires_at) - .fetch_optional(pool) - .await? - .ok_or_else(|| { - Error::RowNotFound // 如果已存在,返回错误 - })?; - - Ok(id) -} - -/// 更新用户优惠券状态 -pub async fn update_user_coupon_status( - pool: &Pool, - user_uuid: Uuid, - coupon_uuid: Uuid, - status: &str, - used_at: Option>, -) -> Result<(), Error> { - sqlx::query( - r#" - UPDATE user_coupons - SET status = $1, used_at = $2, updated_at = CURRENT_TIMESTAMP - WHERE user_uuid = $3 AND coupon_uuid = $4 - "#, - ) - .bind(status) - .bind(used_at) - .bind(user_uuid) - .bind(coupon_uuid) - .execute(pool) - .await?; - - Ok(()) -} - -/// 批量发放优惠券 -pub async fn batch_insert_user_coupons( - pool: &Pool, - coupon_uuid: Uuid, - user_uuids: &[Uuid], - expires_at: Option>, -) -> Result { - let mut tx = pool.begin().await?; - let mut count = 0; - - for user_uuid in user_uuids { - let result = sqlx::query_scalar::<_, i32>( - r#" - INSERT INTO user_coupons (user_uuid, coupon_uuid, expires_at) - VALUES ($1, $2, $3) - ON CONFLICT (user_uuid, coupon_uuid) DO NOTHING - RETURNING id; - "#, - ) - .bind(user_uuid) - .bind(coupon_uuid) - .bind(expires_at) - .fetch_optional(&mut *tx) - .await?; - - if result.is_some() { - count += 1; - } - } - - tx.commit().await?; - Ok(count) -} - -/// 查询用户可用优惠券(未使用且未过期,包含优惠券详细信息) -pub async fn fetch_available_user_coupons( - pool: &Pool, - user_uuid: Uuid, -) -> Result, Error> { - let now = chrono::Utc::now(); - - let rows = sqlx::query( - r#" - SELECT - uc.id, uc.user_uuid, uc.coupon_uuid, uc.status, - uc.issued_at, uc.used_at, uc.expires_at, - c.code, c.name, c.description, c.discount_type, c.discount_value, c.min_amount, c.max_discount - FROM user_coupons uc - INNER JOIN coupons c ON uc.coupon_uuid = c.uuid - WHERE uc.user_uuid = $1 - AND uc.status = 'unused' - AND c.status = 'active' - AND (uc.expires_at IS NULL OR uc.expires_at > $2) - AND (c.valid_until IS NULL OR c.valid_until > $2) - AND c.valid_from <= $2 - ORDER BY uc.issued_at DESC - "#, - ) - .bind(user_uuid) - .bind(now) - .fetch_all(pool) - .await?; - - let items = rows - .into_iter() - .map(|row| crate::dto::UserCouponWithDetailsDto { - id: row.get(0), - user_uuid: row.get(1), - coupon_uuid: row.get(2), - status: row.get(3), - issued_at: row.get(4), - used_at: row.get(5), - expires_at: row.get(6), - code: row.get(7), - name: row.get(8), - description: row.get(9), - discount_type: row.get(10), - discount_value: row.get(11), - min_amount: row.get(12), - max_discount: row.get(13), - }) - .collect(); - - Ok(items) -} - -// ============ Payment Orders ============ - -/// 创建支付订单 -pub async fn insert_payment_order( - pool: &Pool, - order_no: &str, - user_uuid: Uuid, - order_type: &str, - amount: Decimal, - currency: &str, - payment_channel: Option<&str>, - description: Option<&str>, - subscription_uuid: Option, - coupon_uuid: Option, - original_amount: Option, - discount_amount: Option, -) -> Result { - let uuid: Uuid = sqlx::query_scalar( - r#" - INSERT INTO payment_orders (order_no, user_uuid, order_type, amount, currency, - status, payment_channel, description, subscription_uuid, - coupon_uuid, original_amount, discount_amount) - VALUES ($1, $2, $3, $4, $5, 'pending', $6, $7, $8, $9, $10, $11) - RETURNING uuid; - "#, - ) - .bind(order_no) - .bind(user_uuid) - .bind(order_type) - .bind(amount) - .bind(currency) - .bind(payment_channel) - .bind(description) - .bind(subscription_uuid) - .bind(coupon_uuid) - .bind(original_amount) - .bind(discount_amount) - .fetch_one(pool) - .await?; - - Ok(uuid) -} - -/// 查询支付订单列表 -pub async fn fetch_payment_orders( - pool: &Pool, - user_uuid: Uuid, - order_type: Option<&str>, - status: Option<&str>, - offset: i64, - limit: i64, -) -> Result, Error> { - let recs = sqlx::query_as::<_, PaymentOrderDto>( - r#" - SELECT id, uuid, order_no, user_uuid, order_type, amount, currency, status, - payment_channel, external_order_id, description, subscription_uuid, - coupon_uuid, original_amount, discount_amount, paid_at, refunded_at, - created_at, updated_at - FROM payment_orders - WHERE user_uuid = $1 - AND ($2::varchar IS NULL OR order_type = $2) - AND ($3::varchar IS NULL OR status = $3) - ORDER BY created_at DESC - LIMIT $4 OFFSET $5 - "#, - ) - .bind(user_uuid) - .bind(order_type) - .bind(status) - .bind(limit) - .bind(offset) - .fetch_all(pool) - .await?; - - Ok(recs) -} - -/// 查询支付订单总数 -pub async fn fetch_payment_orders_count( - pool: &Pool, - user_uuid: Uuid, - order_type: Option<&str>, - status: Option<&str>, -) -> Result { - let count: i64 = sqlx::query_scalar( - r#" - SELECT COUNT(*) FROM payment_orders - WHERE user_uuid = $1 - AND ($2::varchar IS NULL OR order_type = $2) - AND ($3::varchar IS NULL OR status = $3) - "#, - ) - .bind(user_uuid) - .bind(order_type) - .bind(status) - .fetch_one(pool) - .await?; - - Ok(count) -} - -/// 根据 UUID 查询支付订单 -pub async fn fetch_payment_order_by_uuid( - pool: &Pool, - order_uuid: Uuid, -) -> Result, Error> { - let rec = sqlx::query_as::<_, PaymentOrderDto>( - r#" - SELECT id, uuid, order_no, user_uuid, order_type, amount, currency, status, - payment_channel, external_order_id, description, subscription_uuid, - coupon_uuid, original_amount, discount_amount, paid_at, refunded_at, - created_at, updated_at - FROM payment_orders - WHERE uuid = $1 - "#, - ) - .bind(order_uuid) - .fetch_optional(pool) - .await?; - - Ok(rec) -} - -/// 更新订单状态 -pub async fn update_payment_order_status( - pool: &Pool, - order_uuid: Uuid, - status: &str, - external_order_id: Option<&str>, -) -> Result<(), Error> { - sqlx::query( - r#" - UPDATE payment_orders - SET status = $1, - external_order_id = COALESCE($2, external_order_id), - paid_at = CASE WHEN $1 = 'paid' THEN CURRENT_TIMESTAMP ELSE paid_at END, - updated_at = CURRENT_TIMESTAMP - WHERE uuid = $3 - "#, - ) - .bind(status) - .bind(external_order_id) - .bind(order_uuid) - .execute(pool) - .await?; - - Ok(()) -} - -// ============ Auto Renewal Services ============ - -/// 查询用户自动续费服务列表 -pub async fn fetch_auto_renewal_services( - pool: &Pool, - user_uuid: Uuid, -) -> Result, Error> { - let recs = sqlx::query_as::<_, AutoRenewalServiceDto>( - r#" - SELECT id, uuid, user_uuid, service_type, service_uuid, service_name, - renewal_price, currency, next_bill_date, status, created_at, updated_at - FROM auto_renewal_services - WHERE user_uuid = $1 AND status = 'active' - ORDER BY next_bill_date ASC - "#, - ) - .bind(user_uuid) - .fetch_all(pool) - .await?; - - Ok(recs) -} diff --git a/src-tauri/crates/business/src/models/environments.rs b/src-tauri/crates/business/src/models/environments.rs index 92a45d7..55c8433 100644 --- a/src-tauri/crates/business/src/models/environments.rs +++ b/src-tauri/crates/business/src/models/environments.rs @@ -1,7 +1,7 @@ use sqlx::Error; use uuid::Uuid; -use crate::database::{Db as Postgres, Pool}; +use crate::database::{Db as Postgres, Pool, placeholders}; use crate::dto::{ EnvironmentAccountRowDto, EnvironmentConfigDto, EnvironmentCookieDto, EnvironmentDto, @@ -344,7 +344,7 @@ pub async fn fetch_environments_base( // 关键词搜索 if keyword.is_some() { conditions.push(format!( - "(e.name ILIKE ${} OR e.uuid::text ILIKE ${})", + "(LOWER(e.name) LIKE LOWER(${}) OR CAST(e.uuid AS TEXT) LIKE LOWER(${}))", param_index, param_index )); param_index += 1; @@ -353,8 +353,11 @@ pub async fn fetch_environments_base( // 标签过滤 if let Some(tags) = tag_uuids { if !tags.is_empty() { - conditions.push(format!("et.tag_uuid = ANY(${})", param_index)); - param_index += 1; + conditions.push(format!( + "et.tag_uuid IN ({})", + placeholders(param_index, tags.len()) + )); + param_index += tags.len(); } } @@ -387,7 +390,9 @@ pub async fn fetch_environments_base( } if let Some(tags) = tag_uuids { if !tags.is_empty() { - sql_query = sql_query.bind(tags); + for tag_uuid in tags { + sql_query = sql_query.bind(tag_uuid); + } } } @@ -415,8 +420,8 @@ pub async fn fetch_environments( last_opened_at, created_at, updated_at, deleted_at FROM environments WHERE workspace_uuid = $1 AND team_uuid = $2 - AND ($3::uuid IS NULL OR group_uuid = $3) - AND ($4::varchar IS NULL OR status = $4) + AND ($3 IS NULL OR group_uuid = $3) + AND ($4 IS NULL OR status = $4) AND deleted_at IS NULL ORDER BY created_at DESC LIMIT $5 OFFSET $6 @@ -481,7 +486,7 @@ pub async fn fetch_environments_count( // 关键词搜索 if keyword.is_some() { conditions.push(format!( - "(e.name ILIKE ${} OR e.uuid::text ILIKE ${})", + "(LOWER(e.name) LIKE LOWER(${}) OR CAST(e.uuid AS TEXT) LIKE LOWER(${}))", param_index, param_index )); param_index += 1; @@ -490,7 +495,10 @@ pub async fn fetch_environments_count( // 标签过滤 if let Some(tags) = tag_uuids { if !tags.is_empty() { - conditions.push(format!("et.tag_uuid = ANY(${})", param_index)); + conditions.push(format!( + "et.tag_uuid IN ({})", + placeholders(param_index, tags.len()) + )); } } @@ -515,7 +523,9 @@ pub async fn fetch_environments_count( } if let Some(tags) = tag_uuids { if !tags.is_empty() { - sql_query = sql_query.bind(tags); + for tag_uuid in tags { + sql_query = sql_query.bind(tag_uuid); + } } } @@ -673,15 +683,22 @@ pub async fn batch_delete_environments( pool: &Pool, env_uuids: &[Uuid], ) -> Result { - let result = sqlx::query( + if env_uuids.is_empty() { + return Ok(0); + } + + let statement = format!( r#" UPDATE environments SET deleted_at = CURRENT_TIMESTAMP - WHERE uuid = ANY($1) AND deleted_at IS NULL + WHERE uuid IN ({}) AND deleted_at IS NULL "#, - ) - .bind(env_uuids) - .execute(pool) - .await?; + placeholders(1, env_uuids.len()) + ); + let mut query = sqlx::query(&statement); + for uuid in env_uuids { + query = query.bind(uuid); + } + let result = query.execute(pool).await?; Ok(result.rows_affected()) } @@ -724,7 +741,7 @@ pub async fn fetch_deleted_environments_base( // 关键词搜索 if keyword.is_some() { conditions.push(format!( - "(e.name ILIKE ${} OR e.uuid::text ILIKE ${})", + "(LOWER(e.name) LIKE LOWER(${}) OR CAST(e.uuid AS TEXT) LIKE LOWER(${}))", param_index, param_index )); param_index += 1; @@ -788,7 +805,7 @@ pub async fn fetch_deleted_environments_count( if keyword.is_some() { conditions.push(format!( - "(e.name ILIKE ${} OR e.uuid::text ILIKE ${})", + "(LOWER(e.name) LIKE LOWER(${}) OR CAST(e.uuid AS TEXT) LIKE LOWER(${}))", param_index, param_index )); param_index += 1; @@ -834,15 +851,22 @@ pub async fn batch_restore_environments( pool: &Pool, env_uuids: &[Uuid], ) -> Result { - let result = sqlx::query( + if env_uuids.is_empty() { + return Ok(0); + } + + let statement = format!( r#" UPDATE environments SET deleted_at = NULL - WHERE uuid = ANY($1) AND deleted_at IS NOT NULL + WHERE uuid IN ({}) AND deleted_at IS NOT NULL "#, - ) - .bind(env_uuids) - .execute(pool) - .await?; + placeholders(1, env_uuids.len()) + ); + let mut query = sqlx::query(&statement); + for uuid in env_uuids { + query = query.bind(uuid); + } + let result = query.execute(pool).await?; Ok(result.rows_affected()) } @@ -895,44 +919,55 @@ pub async fn batch_permanent_delete_environments( pool: &Pool, env_uuids: &[Uuid], ) -> Result { - // 先删除关联数据 - sqlx::query("DELETE FROM environment_tags WHERE environment_uuid = ANY($1)") - .bind(env_uuids) - .execute(pool) - .await?; - - sqlx::query("DELETE FROM environment_urls WHERE environment_uuid = ANY($1)") - .bind(env_uuids) - .execute(pool) - .await?; - - sqlx::query("DELETE FROM environment_cookies WHERE environment_uuid = ANY($1)") - .bind(env_uuids) - .execute(pool) - .await?; - - sqlx::query("DELETE FROM environment_configs WHERE environment_uuid = ANY($1)") - .bind(env_uuids) - .execute(pool) - .await?; + if env_uuids.is_empty() { + return Ok(0); + } - sqlx::query("DELETE FROM environment_accounts WHERE environment_uuid = ANY($1)") - .bind(env_uuids) - .execute(pool) - .await?; + // 先删除关联数据 + for table in [ + "environment_tags", + "environment_urls", + "environment_cookies", + "environment_configs", + "environment_accounts", + ] { + delete_environment_relations(pool, table, env_uuids).await?; + } // `environment_extensions` 已在扩展系统重构后移除。 // 环境的扩展现在通过 user/team/group 绑定动态合并,不再需要清理环境级关联。 // 最后删除环境本身 - let result = sqlx::query("DELETE FROM environments WHERE uuid = ANY($1)") - .bind(env_uuids) - .execute(pool) - .await?; + let statement = format!( + "DELETE FROM environments WHERE uuid IN ({})", + placeholders(1, env_uuids.len()) + ); + let mut query = sqlx::query(&statement); + for uuid in env_uuids { + query = query.bind(uuid); + } + let result = query.execute(pool).await?; Ok(result.rows_affected()) } +async fn delete_environment_relations( + pool: &Pool, + table: &str, + env_uuids: &[Uuid], +) -> Result<(), Error> { + let statement = format!( + "DELETE FROM {table} WHERE environment_uuid IN ({})", + placeholders(1, env_uuids.len()) + ); + let mut query = sqlx::query(&statement); + for uuid in env_uuids { + query = query.bind(uuid); + } + query.execute(pool).await?; + Ok(()) +} + // ============ Environment Configs ============ /// 创建或更新环境配置 @@ -1004,17 +1039,20 @@ pub async fn fetch_environment_configs_by_uuids( return Ok(vec![]); } - let recs = sqlx::query_as::<_, EnvironmentConfigDto>( + let statement = format!( r#" SELECT id, environment_uuid, window_info, basic_settings, fingerprint_settings, device_settings, preference_settings, project_metadata, created_at, updated_at FROM environment_configs - WHERE environment_uuid = ANY($1) + WHERE environment_uuid IN ({}) "#, - ) - .bind(env_uuids) - .fetch_all(pool) - .await?; + placeholders(1, env_uuids.len()) + ); + let mut query = sqlx::query_as::<_, EnvironmentConfigDto>(&statement); + for uuid in env_uuids { + query = query.bind(uuid); + } + let recs = query.fetch_all(pool).await?; Ok(recs) } @@ -1108,7 +1146,7 @@ pub async fn fetch_tags_for_environments( return Ok(vec![]); } - let recs = sqlx::query_as::<_, EnvironmentTagRowDto>( + let statement = format!( r#" SELECT et.environment_uuid, @@ -1125,13 +1163,16 @@ pub async fn fetch_tags_for_environments( t.deleted_at as tag_deleted_at FROM environment_tags et INNER JOIN tags t ON et.tag_uuid = t.uuid - WHERE et.environment_uuid = ANY($1) AND t.deleted_at IS NULL + WHERE et.environment_uuid IN ({}) AND t.deleted_at IS NULL ORDER BY t.sort_order, t.name "#, - ) - .bind(env_uuids) - .fetch_all(pool) - .await?; + placeholders(1, env_uuids.len()) + ); + let mut query = sqlx::query_as::<_, EnvironmentTagRowDto>(&statement); + for uuid in env_uuids { + query = query.bind(uuid); + } + let recs = query.fetch_all(pool).await?; Ok(recs) } @@ -1145,7 +1186,7 @@ pub async fn fetch_accounts_for_environments( return Ok(vec![]); } - let recs = sqlx::query_as::<_, EnvironmentAccountRowDto>( + let statement = format!( r#" SELECT ea.environment_uuid, @@ -1158,13 +1199,16 @@ pub async fn fetch_accounts_for_environments( pa.remark FROM environment_accounts ea INNER JOIN platform_accounts pa ON ea.account_uuid = pa.uuid - WHERE ea.environment_uuid = ANY($1) AND pa.deleted_at IS NULL + WHERE ea.environment_uuid IN ({}) AND pa.deleted_at IS NULL ORDER BY ea.sort_order "#, - ) - .bind(env_uuids) - .fetch_all(pool) - .await?; + placeholders(1, env_uuids.len()) + ); + let mut query = sqlx::query_as::<_, EnvironmentAccountRowDto>(&statement); + for uuid in env_uuids { + query = query.bind(uuid); + } + let recs = query.fetch_all(pool).await?; Ok(recs) } @@ -1178,16 +1222,19 @@ pub async fn fetch_groups_by_uuids( return Ok(vec![]); } - let recs = sqlx::query_as::<_, GroupRowDto>( + let statement = format!( r#" SELECT id, uuid, name, description, sort_order FROM groups - WHERE uuid = ANY($1) AND deleted_at IS NULL + WHERE uuid IN ({}) AND deleted_at IS NULL "#, - ) - .bind(group_uuids) - .fetch_all(pool) - .await?; + placeholders(1, group_uuids.len()) + ); + let mut query = sqlx::query_as::<_, GroupRowDto>(&statement); + for uuid in group_uuids { + query = query.bind(uuid); + } + let recs = query.fetch_all(pool).await?; Ok(recs) } @@ -1201,18 +1248,21 @@ pub async fn fetch_proxies_by_uuids( return Ok(vec![]); } - let recs = sqlx::query_as::<_, ProxyRowDto>( + let statement = format!( r#" SELECT id, uuid, name, host, port, proxy_type, username, password, country, city, status, latency, last_check_ip FROM proxies - WHERE uuid = ANY($1) AND deleted_at IS NULL + WHERE uuid IN ({}) AND deleted_at IS NULL "#, - ) - .bind(proxy_uuids) - .fetch_all(pool) - .await?; + placeholders(1, proxy_uuids.len()) + ); + let mut query = sqlx::query_as::<_, ProxyRowDto>(&statement); + for uuid in proxy_uuids { + query = query.bind(uuid); + } + let recs = query.fetch_all(pool).await?; Ok(recs) } @@ -1268,7 +1318,7 @@ pub async fn fetch_templates( system_info, kernel_info, config_json, usage_count, created_at, updated_at, deleted_at FROM templates WHERE (team_uuid = $1 OR (team_uuid IS NULL AND user_uuid = $2) OR is_public = TRUE) - AND ($3::boolean IS NULL OR is_public = $3) + AND ($3 IS NULL OR is_public = $3) AND deleted_at IS NULL ORDER BY usage_count DESC, created_at DESC LIMIT $4 OFFSET $5 @@ -1297,7 +1347,7 @@ pub async fn fetch_templates_count( SELECT COUNT(*) FROM templates WHERE (team_uuid = $1 OR (team_uuid IS NULL AND user_uuid = $2) OR is_public = TRUE) - AND ($3::boolean IS NULL OR is_public = $3) + AND ($3 IS NULL OR is_public = $3) AND deleted_at IS NULL "#, ) @@ -1475,17 +1525,20 @@ pub async fn fetch_environment_urls_by_uuids( return Ok(vec![]); } - let recs = sqlx::query_as::<_, EnvironmentUrlDto>( + let statement = format!( r#" SELECT id, environment_uuid, url, title, sort_order, created_at FROM environment_urls - WHERE environment_uuid = ANY($1) + WHERE environment_uuid IN ({}) ORDER BY environment_uuid, sort_order, id "#, - ) - .bind(env_uuids) - .fetch_all(pool) - .await?; + placeholders(1, env_uuids.len()) + ); + let mut query = sqlx::query_as::<_, EnvironmentUrlDto>(&statement); + for uuid in env_uuids { + query = query.bind(uuid); + } + let recs = query.fetch_all(pool).await?; Ok(recs) } @@ -1615,17 +1668,20 @@ pub async fn fetch_environment_cookies_by_uuids( return Ok(vec![]); } - let recs = sqlx::query_as::<_, EnvironmentCookieDto>( + let statement = format!( r#" SELECT id, environment_uuid, site_input, domain, name, value, path, expires_at, http_only, secure, same_site, created_at FROM environment_cookies - WHERE environment_uuid = ANY($1) + WHERE environment_uuid IN ({}) ORDER BY environment_uuid, site_input, domain, name "#, - ) - .bind(env_uuids) - .fetch_all(pool) - .await?; + placeholders(1, env_uuids.len()) + ); + let mut query = sqlx::query_as::<_, EnvironmentCookieDto>(&statement); + for uuid in env_uuids { + query = query.bind(uuid); + } + let recs = query.fetch_all(pool).await?; Ok(recs) } diff --git a/src-tauri/crates/business/src/models/extensions.rs b/src-tauri/crates/business/src/models/extensions.rs deleted file mode 100644 index b189eb4..0000000 --- a/src-tauri/crates/business/src/models/extensions.rs +++ /dev/null @@ -1,614 +0,0 @@ -use sqlx::Error; -use uuid::Uuid; - -use crate::database::{Db as Postgres, Pool}; - -use crate::entitys::extensions::{CreateExtensionParams, UpdateExtensionParams}; -use crate::dto::{ - ExtensionDto, GroupExtensionDto, TeamExtensionDto, UserExtensionDto, -}; - -// ============ Extensions CRUD ============ - -/// 创建扩展 -pub async fn create_extension( - pool: &Pool, - params: CreateExtensionParams, -) -> Result { - let rec = sqlx::query_as::<_, ExtensionDto>( - r#" - INSERT INTO extensions ( - extension_id, name, description, version, category, browser, - developer, homepage, icon_url, download_url, file_size, downloads_count, - permissions, rating, changelog, published_at, hash, status - ) - VALUES ($1, $2, $3, $4, $5, $6, $7, $8, $9, $10, $11, $12, $13, $14, $15, $16, $17, 'active') - RETURNING id, uuid, extension_id, name, description, version, category, browser, - developer, homepage, icon_url, download_url, file_size, downloads_count, - rating, permissions, status, changelog, published_at, hash, created_at, updated_at - "#, - ) - .bind(¶ms.extension_id) - .bind(¶ms.name) - .bind(¶ms.description) - .bind(¶ms.version) - .bind(¶ms.category) - .bind(¶ms.browser) - .bind(¶ms.developer) - .bind(¶ms.homepage) - .bind(¶ms.icon_url) - .bind(¶ms.download_url) - .bind(params.file_size) - .bind(params.downloads_count) - .bind(¶ms.permissions) - .bind(params.rating) - .bind(¶ms.changelog) - .bind(params.published_at) - .bind(¶ms.hash) - .fetch_one(pool) - .await?; - - Ok(rec) -} - -/// 更新扩展 -pub async fn update_extension( - pool: &Pool, - extension_id: &str, - params: UpdateExtensionParams, -) -> Result<(), Error> { - sqlx::query( - r#" - UPDATE extensions SET - name = COALESCE($2, name), - description = COALESCE($3, description), - version = COALESCE($4, version), - category = COALESCE($5, category), - developer = COALESCE($6, developer), - homepage = COALESCE($7, homepage), - icon_url = COALESCE($8, icon_url), - download_url = COALESCE($9, download_url), - file_size = COALESCE($10, file_size), - downloads_count = COALESCE($11, downloads_count), - permissions = COALESCE($12, permissions), - rating = COALESCE($13, rating), - changelog = COALESCE($14, changelog), - published_at = COALESCE($15, published_at), - hash = COALESCE($16, hash), - updated_at = CURRENT_TIMESTAMP - WHERE extension_id = $1 - "#, - ) - .bind(extension_id) - .bind(params.name) - .bind(params.description) - .bind(params.version) - .bind(params.category) - .bind(params.developer) - .bind(params.homepage) - .bind(params.icon_url) - .bind(params.download_url) - .bind(params.file_size) - .bind(params.downloads_count) - .bind(params.permissions) - .bind(params.rating) - .bind(params.changelog) - .bind(params.published_at) - .bind(params.hash) - .execute(pool) - .await?; - - Ok(()) -} - -/// 根据 extension_id 获取扩展(别名函数,用于同步检查) -pub async fn get_extension_by_extension_id( - pool: &Pool, - extension_id: &str, -) -> Result, Error> { - fetch_extension_by_id(pool, extension_id).await -} - -// ============ Extensions Query ============ - -/// 查询扩展列表 -pub async fn fetch_extensions( - pool: &Pool, - keyword: Option<&str>, - category: Option<&str>, - sort_by: Option<&str>, - sort_order: Option<&str>, - offset: i64, - limit: i64, -) -> Result, Error> { - let order_clause = match ( - sort_by.unwrap_or("downloads"), - sort_order.unwrap_or("desc").to_ascii_lowercase().as_str(), - ) { - ("rating", "asc") => "rating ASC NULLS LAST, created_at DESC", - ("rating", _) => "rating DESC NULLS LAST, created_at DESC", - ("name", "asc") => "name ASC, created_at DESC", - ("name", _) => "name DESC, created_at DESC", - ("newest", "asc") => "updated_at ASC NULLS LAST, created_at ASC", - ("newest", _) => "updated_at DESC NULLS LAST, created_at DESC", - ("downloads", "asc") => "downloads_count ASC NULLS LAST, created_at DESC", - _ => "downloads_count DESC NULLS LAST, created_at DESC", - }; - - let query = format!( - r#" - SELECT id, uuid, extension_id, name, description, version, category, browser, - developer, homepage, icon_url, download_url, file_size, downloads_count, - rating, permissions, status, changelog, published_at, hash, created_at, updated_at - FROM extensions - WHERE status = 'active' - AND ($1::varchar IS NULL OR name ILIKE '%' || $1 || '%' OR description ILIKE '%' || $1 || '%') - AND ($2::varchar IS NULL OR category = $2) - ORDER BY {} - LIMIT $3 OFFSET $4 - "#, - order_clause - ); - - let recs = sqlx::query_as::<_, ExtensionDto>(&query) - .bind(keyword) - .bind(category) - .bind(limit) - .bind(offset) - .fetch_all(pool) - .await?; - - Ok(recs) -} - -/// 查询扩展总数 -pub async fn fetch_extensions_count( - pool: &Pool, - keyword: Option<&str>, - category: Option<&str>, -) -> Result { - let count: i64 = sqlx::query_scalar( - r#" - SELECT COUNT(*) FROM extensions - WHERE status = 'active' - AND ($1::varchar IS NULL OR name ILIKE '%' || $1 || '%' OR description ILIKE '%' || $1 || '%') - AND ($2::varchar IS NULL OR category = $2) - "#, - ) - .bind(keyword) - .bind(category) - .fetch_one(pool) - .await?; - - Ok(count) -} - -/// 根据 extension_id 查询扩展 -pub async fn fetch_extension_by_id( - pool: &Pool, - extension_id: &str, -) -> Result, Error> { - let rec = sqlx::query_as::<_, ExtensionDto>( - r#" - SELECT id, uuid, extension_id, name, description, version, category, browser, - developer, homepage, icon_url, download_url, file_size, downloads_count, - rating, permissions, status, changelog, published_at, hash, created_at, updated_at - FROM extensions - WHERE extension_id = $1 - "#, - ) - .bind(extension_id) - .fetch_optional(pool) - .await?; - - Ok(rec) -} - -/// 获取扩展分类列表 -pub async fn fetch_extension_categories(pool: &Pool) -> Result, Error> { - let categories: Vec<(String,)> = sqlx::query_as( - r#" - SELECT DISTINCT category FROM extensions - WHERE status = 'active' - ORDER BY category - "#, - ) - .fetch_all(pool) - .await?; - - Ok(categories.into_iter().map(|(c,)| c).collect()) -} - -// ============ User Extensions ============ - -/// 查询用户已安装的扩展 -pub async fn fetch_user_extensions( - pool: &Pool, - user_uuid: Uuid, -) -> Result, Error> { - let recs = sqlx::query_as::<_, UserExtensionDto>( - r#" - SELECT id, user_uuid, extension_id, installed_version, status, installed_at, updated_at - FROM user_extensions - WHERE user_uuid = $1 AND status = 'active' - ORDER BY installed_at DESC - "#, - ) - .bind(user_uuid) - .fetch_all(pool) - .await?; - - Ok(recs) -} - -/// 安装用户扩展 -pub async fn insert_user_extension( - pool: &Pool, - user_uuid: Uuid, - extension_id: &str, - version: &str, -) -> Result { - let id: i32 = sqlx::query_scalar( - r#" - INSERT INTO user_extensions (user_uuid, extension_id, installed_version, status) - VALUES ($1, $2, $3, 'active') - ON CONFLICT (user_uuid, extension_id) DO UPDATE SET - installed_version = $3, - status = 'active', - updated_at = CURRENT_TIMESTAMP - RETURNING id; - "#, - ) - .bind(user_uuid) - .bind(extension_id) - .bind(version) - .fetch_one(pool) - .await?; - - Ok(id) -} - -/// 卸载用户扩展 -pub async fn delete_user_extension( - pool: &Pool, - user_uuid: Uuid, - extension_id: &str, -) -> Result<(), Error> { - sqlx::query( - r#" - UPDATE user_extensions SET status = 'inactive', updated_at = CURRENT_TIMESTAMP - WHERE user_uuid = $1 AND extension_id = $2 - "#, - ) - .bind(user_uuid) - .bind(extension_id) - .execute(pool) - .await?; - - Ok(()) -} - -// ============ Team Extensions ============ - -/// 查询团队已安装的扩展 -pub async fn fetch_team_extensions( - pool: &Pool, - team_uuid: Uuid, -) -> Result, Error> { - let recs = sqlx::query_as::<_, TeamExtensionDto>( - r#" - SELECT id, team_uuid, extension_id, installed_version, installed_by, status, installed_at, updated_at - FROM team_extensions - WHERE team_uuid = $1 AND status = 'active' - ORDER BY installed_at DESC - "#, - ) - .bind(team_uuid) - .fetch_all(pool) - .await?; - - Ok(recs) -} - -/// 安装团队扩展 -pub async fn insert_team_extension( - pool: &Pool, - team_uuid: Uuid, - extension_id: &str, - version: &str, - installed_by: Uuid, -) -> Result { - let id: i32 = sqlx::query_scalar( - r#" - INSERT INTO team_extensions (team_uuid, extension_id, installed_version, installed_by, status) - VALUES ($1, $2, $3, $4, 'active') - ON CONFLICT (team_uuid, extension_id) DO UPDATE SET - installed_version = $3, - installed_by = $4, - status = 'active', - updated_at = CURRENT_TIMESTAMP - RETURNING id; - "#, - ) - .bind(team_uuid) - .bind(extension_id) - .bind(version) - .bind(installed_by) - .fetch_one(pool) - .await?; - - Ok(id) -} - -/// 卸载团队扩展 -pub async fn delete_team_extension( - pool: &Pool, - team_uuid: Uuid, - extension_id: &str, -) -> Result<(), Error> { - sqlx::query( - r#" - UPDATE team_extensions SET status = 'inactive', updated_at = CURRENT_TIMESTAMP - WHERE team_uuid = $1 AND extension_id = $2 - "#, - ) - .bind(team_uuid) - .bind(extension_id) - .execute(pool) - .await?; - - Ok(()) -} - -// ============ Group Extensions ============ - -/// 查询分组已安装的扩展 -pub async fn fetch_group_extensions( - pool: &Pool, - group_uuid: Uuid, -) -> Result, Error> { - let recs = sqlx::query_as::<_, GroupExtensionDto>( - r#" - SELECT id, group_uuid, extension_id, installed_version, installed_by, status, is_team_shared, installed_at, updated_at - FROM group_extensions - WHERE group_uuid = $1 AND status = 'active' - ORDER BY installed_at DESC - "#, - ) - .bind(group_uuid) - .fetch_all(pool) - .await?; - - Ok(recs) -} - -/// 安装分组扩展 -pub async fn insert_group_extension( - pool: &Pool, - group_uuid: Uuid, - extension_id: &str, - version: &str, - installed_by: Uuid, - is_team_shared: bool, -) -> Result { - let id: i32 = sqlx::query_scalar( - r#" - INSERT INTO group_extensions (group_uuid, extension_id, installed_version, installed_by, is_team_shared, status) - VALUES ($1, $2, $3, $4, $5, 'active') - ON CONFLICT (group_uuid, extension_id) DO UPDATE SET - installed_version = $3, - installed_by = $4, - is_team_shared = $5, - status = 'active', - updated_at = CURRENT_TIMESTAMP - RETURNING id; - "#, - ) - .bind(group_uuid) - .bind(extension_id) - .bind(version) - .bind(installed_by) - .bind(is_team_shared) - .fetch_one(pool) - .await?; - - Ok(id) -} - -/// 卸载分组扩展(指定分组) -pub async fn delete_group_extension( - pool: &Pool, - group_uuid: Uuid, - extension_id: &str, -) -> Result<(), Error> { - sqlx::query( - r#" - UPDATE group_extensions SET status = 'inactive', updated_at = CURRENT_TIMESTAMP - WHERE group_uuid = $1 AND extension_id = $2 - "#, - ) - .bind(group_uuid) - .bind(extension_id) - .execute(pool) - .await?; - - Ok(()) -} - -/// 卸载分组扩展(根据扩展 ID 删除所有相关分组记录) -pub async fn delete_group_extensions_by_extension_id( - pool: &Pool, - extension_id: &str, -) -> Result<(), Error> { - sqlx::query( - r#" - UPDATE group_extensions SET status = 'inactive', updated_at = CURRENT_TIMESTAMP - WHERE extension_id = $1 AND status = 'active' - "#, - ) - .bind(extension_id) - .execute(pool) - .await?; - - Ok(()) -} - -/// 根据扩展 ID 查询所有关联的分组 UUID -pub async fn fetch_group_uuids_by_extension_id( - pool: &Pool, - extension_id: &str, -) -> Result, Error> { - let recs = sqlx::query_scalar::<_, Uuid>( - r#" - SELECT DISTINCT group_uuid - FROM group_extensions - WHERE extension_id = $1 AND status = 'active' - "#, - ) - .bind(extension_id) - .fetch_all(pool) - .await?; - - Ok(recs) -} - -/// 根据扩展 ID 查询团队共享的分组 UUID -pub async fn fetch_team_shared_group_uuids_by_extension_id( - pool: &Pool, - extension_id: &str, -) -> Result, Error> { - let recs = sqlx::query_scalar::<_, Uuid>( - r#" - SELECT DISTINCT group_uuid - FROM group_extensions - WHERE extension_id = $1 AND status = 'active' AND is_team_shared = true - "#, - ) - .bind(extension_id) - .fetch_all(pool) - .await?; - - Ok(recs) -} - -/// 查询用户相关的分组中安装的扩展 -/// 包括用户个人分组(无 team_uuid,由用户创建)和用户所在团队的分组 -pub async fn fetch_user_group_extensions( - pool: &Pool, - user_uuid: Uuid, - team_uuid: Option, -) -> Result, Error> { - let recs = sqlx::query_as::<_, GroupExtensionDto>( - r#" - SELECT ge.id, ge.group_uuid, ge.extension_id, ge.installed_version, ge.installed_by, ge.status, ge.is_team_shared, ge.installed_at, ge.updated_at - FROM group_extensions ge - INNER JOIN groups g ON ge.group_uuid = g.uuid - WHERE ge.status = 'active' - AND g.deleted_at IS NULL - AND (g.team_uuid = $1 OR (g.team_uuid IS NULL AND g.created_by = $2)) - ORDER BY ge.installed_at DESC - "#, - ) - .bind(team_uuid) - .bind(user_uuid) - .fetch_all(pool) - .await?; - - Ok(recs) -} - -/// 查询团队相关的分组中安装的扩展 -pub async fn fetch_team_group_extensions( - pool: &Pool, - team_uuid: Uuid, -) -> Result, Error> { - let recs = sqlx::query_as::<_, GroupExtensionDto>( - r#" - SELECT ge.id, ge.group_uuid, ge.extension_id, ge.installed_version, ge.installed_by, ge.status, ge.is_team_shared, ge.installed_at, ge.updated_at - FROM group_extensions ge - INNER JOIN groups g ON ge.group_uuid = g.uuid - WHERE ge.status = 'active' - AND ge.is_team_shared = true - AND g.deleted_at IS NULL - AND g.team_uuid = $1 - ORDER BY ge.installed_at DESC - "#, - ) - .bind(team_uuid) - .fetch_all(pool) - .await?; - - Ok(recs) -} - -// ============ User Team Extension Preferences ============ - -/// 查询用户禁用的团队插件列表 -pub async fn fetch_user_disabled_team_extensions( - pool: &Pool, - user_uuid: Uuid, - team_uuid: Uuid, -) -> Result, Error> { - let recs = sqlx::query_scalar::<_, String>( - r#" - SELECT extension_id - FROM user_team_extension_preferences - WHERE user_uuid = $1 AND team_uuid = $2 AND is_disabled = true - "#, - ) - .bind(user_uuid) - .bind(team_uuid) - .fetch_all(pool) - .await?; - - Ok(recs) -} - -/// 设置用户对团队插件的禁用状态 -pub async fn set_user_team_extension_preference( - pool: &Pool, - user_uuid: Uuid, - team_uuid: Uuid, - extension_id: &str, - is_disabled: bool, -) -> Result<(), Error> { - sqlx::query( - r#" - INSERT INTO user_team_extension_preferences (user_uuid, team_uuid, extension_id, is_disabled) - VALUES ($1, $2, $3, $4) - ON CONFLICT (user_uuid, team_uuid, extension_id) DO UPDATE SET - is_disabled = $4, - updated_at = CURRENT_TIMESTAMP - "#, - ) - .bind(user_uuid) - .bind(team_uuid) - .bind(extension_id) - .bind(is_disabled) - .execute(pool) - .await?; - - Ok(()) -} - -/// 删除用户对团队插件的偏好设置 -pub async fn delete_user_team_extension_preference( - pool: &Pool, - user_uuid: Uuid, - team_uuid: Uuid, - extension_id: &str, -) -> Result<(), Error> { - sqlx::query( - r#" - DELETE FROM user_team_extension_preferences - WHERE user_uuid = $1 AND team_uuid = $2 AND extension_id = $3 - "#, - ) - .bind(user_uuid) - .bind(team_uuid) - .bind(extension_id) - .execute(pool) - .await?; - - Ok(()) -} diff --git a/src-tauri/crates/business/src/models/group_member_permissions.rs b/src-tauri/crates/business/src/models/group_member_permissions.rs index 2922ea4..c0520aa 100644 --- a/src-tauri/crates/business/src/models/group_member_permissions.rs +++ b/src-tauri/crates/business/src/models/group_member_permissions.rs @@ -72,8 +72,8 @@ pub async fn fetch_user_group_permissions( created_at, updated_at FROM group_member_permissions WHERE user_uuid = $1 - AND ($2::uuid IS NULL OR workspace_uuid = $2) - AND ($3::uuid IS NULL OR group_uuid = $3) + AND ($2 IS NULL OR workspace_uuid = $2) + AND ($3 IS NULL OR group_uuid = $3) ORDER BY created_at DESC "#, ) diff --git a/src-tauri/crates/business/src/models/local_api.rs b/src-tauri/crates/business/src/models/local_api.rs index 16601e3..6515a73 100644 --- a/src-tauri/crates/business/src/models/local_api.rs +++ b/src-tauri/crates/business/src/models/local_api.rs @@ -37,7 +37,7 @@ pub async fn upsert_local_api_settings( sqlx::query( r#" INSERT INTO user_local_api_settings (user_uuid, enabled, port, remote_access, cors_origins) - VALUES ($1, COALESCE($2, FALSE), COALESCE($3, 8080), COALESCE($4, FALSE), COALESCE($5, '[]'::jsonb)) + VALUES ($1, COALESCE($2, FALSE), COALESCE($3, 8080), COALESCE($4, FALSE), COALESCE($5, '[]')) ON CONFLICT (user_uuid) DO UPDATE SET enabled = COALESCE($2, user_local_api_settings.enabled), port = COALESCE($3, user_local_api_settings.port), diff --git a/src-tauri/crates/business/src/models/maintenance.rs b/src-tauri/crates/business/src/models/maintenance.rs index e4ed930..2ee4ac7 100644 --- a/src-tauri/crates/business/src/models/maintenance.rs +++ b/src-tauri/crates/business/src/models/maintenance.rs @@ -142,7 +142,7 @@ pub async fn get_active_maintenances( SELECT id, name, description, status, start_time, end_time, maintenance_type, created_at, updated_at FROM maintenances - WHERE status = 'active' AND start_time <= NOW() AND end_time >= NOW() + WHERE status = 'active' AND start_time <= CURRENT_TIMESTAMP AND end_time >= CURRENT_TIMESTAMP ORDER BY created_at DESC LIMIT 1 "#, @@ -164,8 +164,8 @@ pub async fn create_maintenances_table(pool: &Pool) -> Result<(), sqlx start_time TIMESTAMPTZ NOT NULL, end_time TIMESTAMPTZ NOT NULL, maintenance_type VARCHAR(20) NOT NULL, - created_at TIMESTAMPTZ DEFAULT NOW() NOT NULL, - updated_at TIMESTAMPTZ DEFAULT NOW() NOT NULL + created_at TEXT DEFAULT CURRENT_TIMESTAMP NOT NULL, + updated_at TEXT DEFAULT CURRENT_TIMESTAMP NOT NULL ); CREATE INDEX IF NOT EXISTS idx_maintenances_status ON maintenances(status); diff --git a/src-tauri/crates/business/src/models/messages.rs b/src-tauri/crates/business/src/models/messages.rs index 747a519..fc9a68b 100644 --- a/src-tauri/crates/business/src/models/messages.rs +++ b/src-tauri/crates/business/src/models/messages.rs @@ -254,7 +254,7 @@ pub async fn mark_message_read( sqlx::query( r#" UPDATE user_messages - SET is_read = TRUE, read_at = NOW(), updated_at = NOW() + SET is_read = TRUE, read_at = CURRENT_TIMESTAMP, updated_at = CURRENT_TIMESTAMP WHERE message_uuid = $1 AND user_uuid = $2 AND is_read = FALSE "#, ) @@ -278,7 +278,7 @@ pub async fn batch_mark_messages_read( sqlx::query( r#" UPDATE user_messages - SET is_read = TRUE, read_at = NOW(), updated_at = NOW() + SET is_read = TRUE, read_at = CURRENT_TIMESTAMP, updated_at = CURRENT_TIMESTAMP WHERE message_uuid = $1 AND user_uuid = $2 AND is_read = FALSE "#, ) @@ -309,7 +309,7 @@ pub async fn handle_message( sqlx::query( r#" UPDATE user_messages - SET action_status = $1, action_at = NOW(), updated_at = NOW() + SET action_status = $1, action_at = CURRENT_TIMESTAMP, updated_at = CURRENT_TIMESTAMP WHERE message_uuid = $2 AND user_uuid = $3 "#, ) @@ -380,7 +380,7 @@ pub async fn delete_message(pool: &Pool, message_uuid: Uuid) -> Result sqlx::query( r#" UPDATE messages - SET deleted_at = NOW(), status = 'deleted', updated_at = NOW() + SET deleted_at = CURRENT_TIMESTAMP, status = 'deleted', updated_at = CURRENT_TIMESTAMP WHERE uuid = $1 AND deleted_at IS NULL "#, ) diff --git a/src-tauri/crates/business/src/models/mod.rs b/src-tauri/crates/business/src/models/mod.rs index b490d35..050a982 100644 --- a/src-tauri/crates/business/src/models/mod.rs +++ b/src-tauri/crates/business/src/models/mod.rs @@ -5,19 +5,16 @@ pub mod version_types; pub mod versions; // 新增模块 -pub mod accounts; -pub mod audit; -pub mod billing; -pub mod environments; -pub mod extensions; -pub mod group_member_permissions; -pub mod local_api; -pub mod messages; -pub mod preferences; -pub mod proxies; -pub mod proxy_visible_teams; -pub mod referral; -pub mod rpa; +pub mod accounts; +pub mod audit; +pub mod environments; +pub mod group_member_permissions; +pub mod local_api; +pub mod messages; +pub mod preferences; +pub mod proxies; +pub mod proxy_visible_teams; +pub mod rpa; pub mod teams; pub mod workspace_quotas; pub mod workspaces; @@ -29,11 +26,10 @@ pub use version_types::*; pub use versions::*; // 新增导出 -pub use accounts::*; -pub use audit::*; -pub use billing::*; -pub use environments::*; -pub use group_member_permissions::*; +pub use accounts::*; +pub use audit::*; +pub use environments::*; +pub use group_member_permissions::*; pub use local_api::*; pub use messages::*; pub use proxies::*; diff --git a/src-tauri/crates/business/src/models/proxies.rs b/src-tauri/crates/business/src/models/proxies.rs index c6561aa..a30e78d 100644 --- a/src-tauri/crates/business/src/models/proxies.rs +++ b/src-tauri/crates/business/src/models/proxies.rs @@ -1,7 +1,7 @@ use sqlx::Error; use uuid::Uuid; -use crate::database::{Db as Postgres, Pool}; +use crate::database::{Db as Postgres, Pool, placeholders}; use crate::dto::{ProxyDto, ProxyHealthCheckDto}; @@ -61,8 +61,8 @@ pub async fn fetch_proxies( p.created_at, p.updated_at, p.deleted_at FROM proxies p WHERE p.workspace_uuid = $1 - AND ($2::varchar IS NULL OR p.proxy_type = $2) - AND ($3::varchar IS NULL OR p.status = $3) + AND ($2 IS NULL OR p.proxy_type = $2) + AND ($3 IS NULL OR p.status = $3) AND p.deleted_at IS NULL ORDER BY p.created_at DESC LIMIT $5 OFFSET $6 @@ -90,8 +90,8 @@ pub async fn fetch_proxies_count( r#" SELECT COUNT(*) FROM proxies WHERE workspace_uuid = $1 - AND ($2::varchar IS NULL OR proxy_type = $2) - AND ($3::varchar IS NULL OR status = $3) + AND ($2 IS NULL OR proxy_type = $2) + AND ($3 IS NULL OR status = $3) AND deleted_at IS NULL "#, ) @@ -238,15 +238,22 @@ pub async fn batch_delete_proxies( pool: &Pool, proxy_uuids: &[Uuid], ) -> Result { - let result = sqlx::query( + if proxy_uuids.is_empty() { + return Ok(0); + } + + let statement = format!( r#" UPDATE proxies SET deleted_at = CURRENT_TIMESTAMP - WHERE uuid = ANY($1) AND deleted_at IS NULL + WHERE uuid IN ({}) AND deleted_at IS NULL "#, - ) - .bind(proxy_uuids) - .execute(pool) - .await?; + placeholders(1, proxy_uuids.len()) + ); + let mut query = sqlx::query(&statement); + for uuid in proxy_uuids { + query = query.bind(uuid); + } + let result = query.execute(pool).await?; Ok(result.rows_affected()) } diff --git a/src-tauri/crates/business/src/models/proxy_visible_teams.rs b/src-tauri/crates/business/src/models/proxy_visible_teams.rs index 0696684..92f2e9d 100644 --- a/src-tauri/crates/business/src/models/proxy_visible_teams.rs +++ b/src-tauri/crates/business/src/models/proxy_visible_teams.rs @@ -194,10 +194,10 @@ pub async fn fetch_visible_proxies_for_user_paginated( FROM proxies p WHERE p.workspace_uuid = $1 AND p.deleted_at IS NULL - AND ($4::text IS NULL OR p.name ILIKE $4) - AND ($5::text IS NULL OR p.proxy_type = $5) - AND ($6::text IS NULL OR p.status = $6) - AND ($7::text IS NULL OR p.country = $7) + AND ($4 IS NULL OR p.name LIKE $4) + AND ($5 IS NULL OR p.proxy_type = $5) + AND ($6 IS NULL OR p.status = $6) + AND ($7 IS NULL OR p.country = $7) AND ( EXISTS ( SELECT 1 FROM workspaces w @@ -252,10 +252,10 @@ pub async fn fetch_visible_proxies_for_user_count( FROM proxies p WHERE p.workspace_uuid = $1 AND p.deleted_at IS NULL - AND ($4::text IS NULL OR p.name ILIKE $4) - AND ($5::text IS NULL OR p.proxy_type = $5) - AND ($6::text IS NULL OR p.status = $6) - AND ($7::text IS NULL OR p.country = $7) + AND ($4 IS NULL OR p.name LIKE $4) + AND ($5 IS NULL OR p.proxy_type = $5) + AND ($6 IS NULL OR p.status = $6) + AND ($7 IS NULL OR p.country = $7) AND ( EXISTS ( SELECT 1 FROM workspaces w diff --git a/src-tauri/crates/business/src/models/referral.rs b/src-tauri/crates/business/src/models/referral.rs deleted file mode 100644 index f6827de..0000000 --- a/src-tauri/crates/business/src/models/referral.rs +++ /dev/null @@ -1,569 +0,0 @@ -use chrono::{DateTime, Utc}; -use rust_decimal::Decimal; -use sqlx::Error; -use uuid::Uuid; - -use crate::database::{Db as Postgres, Pool}; - -use crate::dto::{ - RedeemOptionDto, RedeemRecordDto, ReferralLinkDto, ReferralLinkTierDto, ReferralRewardDto, - UserReferralPointsDto, -}; -use crate::dto::ReferredUserRow; - -// ============ Referral Link Tiers ============ - -/// 查询所有层级 -pub async fn fetch_referral_tiers( - pool: &Pool, -) -> Result, Error> { - let recs = sqlx::query_as::<_, ReferralLinkTierDto>( - r#" - SELECT id, uuid, name, unlock_threshold, reward_rate, discount_rate, - description, sort_order, created_at, updated_at - FROM referral_link_tiers - ORDER BY unlock_threshold ASC - "#, - ) - .fetch_all(pool) - .await?; - - Ok(recs) -} - -/// 根据用户当前已解锁的层级,更新其推广链接的解锁状态。 -/// -/// 约定: -/// - 同一用户的所有推广链接默认创建完成(最多 4 条),分别对应不同 tier_uuid; -/// - 这里根据传入的 unlocked_tier_uuids,将属于这些层级的链接标记为 unlocked = TRUE, -/// 其他链接统一标记为 FALSE。 -pub async fn update_referral_links_unlock_status_for_user( - pool: &Pool, - user_uuid: Uuid, - unlocked_tier_uuids: &[Uuid], -) -> Result<(), Error> { - // 先将该用户所有链接标记为未解锁 - sqlx::query( - r#" - UPDATE referral_links - SET unlocked = FALSE - WHERE user_uuid = $1; - "#, - ) - .bind(user_uuid) - .execute(pool) - .await?; - - // 再根据传入的 tier_uuid 集合解锁对应链接 - if !unlocked_tier_uuids.is_empty() { - sqlx::query( - r#" - UPDATE referral_links - SET unlocked = TRUE - WHERE user_uuid = $1 - AND tier_uuid = ANY($2::uuid[]); - "#, - ) - .bind(user_uuid) - .bind(unlocked_tier_uuids) - .execute(pool) - .await?; - } - - Ok(()) -} - -/// 根据解锁阈值查询层级 -pub async fn fetch_tier_by_threshold( - pool: &Pool, - threshold: i32, -) -> Result, Error> { - let rec = sqlx::query_as::<_, ReferralLinkTierDto>( - r#" - SELECT id, uuid, name, unlock_threshold, reward_rate, discount_rate, - description, sort_order, created_at, updated_at - FROM referral_link_tiers - WHERE unlock_threshold <= $1 - ORDER BY unlock_threshold DESC - LIMIT 1 - "#, - ) - .bind(threshold) - .fetch_optional(pool) - .await?; - - Ok(rec) -} - -/// 查询下一个层级 -pub async fn fetch_next_tier( - pool: &Pool, - current_threshold: i32, -) -> Result, Error> { - let rec = sqlx::query_as::<_, ReferralLinkTierDto>( - r#" - SELECT id, uuid, name, unlock_threshold, reward_rate, discount_rate, - description, sort_order, created_at, updated_at - FROM referral_link_tiers - WHERE unlock_threshold > $1 - ORDER BY unlock_threshold ASC - LIMIT 1 - "#, - ) - .bind(current_threshold) - .fetch_optional(pool) - .await?; - - Ok(rec) -} - -// ============ Referral Links ============ - -/// 查询用户的推广链接列表 -pub async fn fetch_user_referral_links( - pool: &Pool, - user_uuid: Uuid, -) -> Result, Error> { - // 为了保证前端展示顺序与层级解锁顺序一致,这里按对应层级的 unlock_threshold 排序, - // 对于没有 tier_uuid 的链接,则退而按创建时间排序并排在最后。 - let recs = sqlx::query_as::<_, ReferralLinkDto>( - r#" - SELECT rl.id, - rl.uuid, - rl.user_uuid, - rl.code, - rl.url, - rl.tier_uuid, - rl.unlocked, - rl.is_current, - rl.reward_rate, - rl.discount_rate, - rl.registered_users, - rl.paid_users, - rl.total_consumption, - rl.last_30_days_consumption, - rl.created_at, - rl.updated_at - FROM referral_links rl - LEFT JOIN referral_link_tiers t - ON rl.tier_uuid = t.uuid - WHERE rl.user_uuid = $1 - ORDER BY - t.unlock_threshold ASC NULLS LAST, - rl.created_at ASC - "#, - ) - .bind(user_uuid) - .fetch_all(pool) - .await?; - - Ok(recs) -} - -/// 查询当前推广链接 -pub async fn fetch_current_referral_link( - pool: &Pool, - user_uuid: Uuid, -) -> Result, Error> { - let rec = sqlx::query_as::<_, ReferralLinkDto>( - r#" - SELECT id, uuid, user_uuid, code, url, tier_uuid, unlocked, is_current, - reward_rate, discount_rate, registered_users, paid_users, - total_consumption, last_30_days_consumption, created_at, updated_at - FROM referral_links - WHERE user_uuid = $1 AND is_current = true - "#, - ) - .bind(user_uuid) - .fetch_optional(pool) - .await?; - - Ok(rec) -} - -/// 切换当前推广链接 -pub async fn switch_current_referral_link( - pool: &Pool, - user_uuid: Uuid, - link_uuid: Uuid, -) -> Result<(), Error> { - // 先取消所有当前链接 - sqlx::query("UPDATE referral_links SET is_current = false WHERE user_uuid = $1") - .bind(user_uuid) - .execute(pool) - .await?; - - // 设置新的当前链接 - sqlx::query("UPDATE referral_links SET is_current = true WHERE uuid = $1 AND user_uuid = $2") - .bind(link_uuid) - .bind(user_uuid) - .execute(pool) - .await?; - - Ok(()) -} - -// ============ User Referrals ============ - -/// 查询被邀请用户列表 -pub async fn fetch_referred_users( - pool: &Pool, - inviter_uuid: Uuid, - keyword: Option<&str>, - status: Option<&str>, - offset: i64, - limit: i64, -) -> Result, Error> { - let recs = sqlx::query_as::<_, ReferredUserRow>( - r#" - SELECT - ur.id, - ui.email, - ur.status, - ur.link_uuid, - ur.total_consumption, - ur.last_30_days_consumption, - ur.registered_at - FROM user_referrals ur - JOIN user_infos ui ON ui.user_uuid = ur.invitee_uuid - WHERE ur.inviter_uuid = $1 - AND ($2::text IS NULL OR ui.email ILIKE '%' || $2 || '%') - AND ($3::varchar IS NULL OR ur.status = $3) - ORDER BY registered_at DESC - LIMIT $4 OFFSET $5 - "#, - ) - .bind(inviter_uuid) - .bind(keyword) - .bind(status) - .bind(limit) - .bind(offset) - .fetch_all(pool) - .await?; - - Ok(recs) -} - -/// 查询被邀请用户总数 -pub async fn fetch_referred_users_count( - pool: &Pool, - inviter_uuid: Uuid, - keyword: Option<&str>, - status: Option<&str>, -) -> Result { - let count: i64 = sqlx::query_scalar( - r#" - SELECT COUNT(*) - FROM user_referrals ur - JOIN user_infos ui ON ui.user_uuid = ur.invitee_uuid - WHERE ur.inviter_uuid = $1 - AND ($2::text IS NULL OR ui.email ILIKE '%' || $2 || '%') - AND ($3::varchar IS NULL OR ur.status = $3) - "#, - ) - .bind(inviter_uuid) - .bind(keyword) - .bind(status) - .fetch_one(pool) - .await?; - - Ok(count) -} - -/// 获取推广统计 -pub async fn fetch_referral_stats( - pool: &Pool, - user_uuid: Uuid, -) -> Result<(i32, i32, Decimal, Decimal), Error> { - let stats: (i64, i64, Option, Option) = sqlx::query_as( - r#" - SELECT - COUNT(*) as total_referrals, - COUNT(CASE WHEN status = 'paid' THEN 1 END) as paid_referrals, - COALESCE(SUM(total_consumption), 0) as total_consumption, - COALESCE(SUM(last_30_days_consumption), 0) as last_30_days_consumption - FROM user_referrals - WHERE inviter_uuid = $1 - "#, - ) - .bind(user_uuid) - .fetch_one(pool) - .await?; - - Ok(( - stats.0 as i32, - stats.1 as i32, - stats.2.unwrap_or(Decimal::ZERO), - stats.3.unwrap_or(Decimal::ZERO), - )) -} - -// ============ Referral Rewards ============ - -/// 查询奖励记录 -pub async fn fetch_referral_rewards( - pool: &Pool, - user_uuid: Uuid, - keyword: Option<&str>, - reward_type: Option<&str>, - status: Option<&str>, - offset: i64, - limit: i64, -) -> Result, Error> { - let recs = sqlx::query_as::<_, ReferralRewardDto>( - r#" - SELECT id, uuid, user_uuid, reward_type, points, description, - referred_user_uuid, link_uuid, status, created_at - FROM referral_rewards - WHERE user_uuid = $1 - AND ($2::text IS NULL OR COALESCE(description, '') ILIKE '%' || $2 || '%') - AND ($3::varchar IS NULL OR reward_type = $3) - AND ($4::varchar IS NULL OR status = $4) - ORDER BY created_at DESC - LIMIT $5 OFFSET $6 - "#, - ) - .bind(user_uuid) - .bind(keyword) - .bind(reward_type) - .bind(status) - .bind(limit) - .bind(offset) - .fetch_all(pool) - .await?; - - Ok(recs) -} - -/// 查询奖励记录总数 -pub async fn fetch_referral_rewards_count( - pool: &Pool, - user_uuid: Uuid, - keyword: Option<&str>, - reward_type: Option<&str>, - status: Option<&str>, -) -> Result { - let count: i64 = sqlx::query_scalar( - r#" - SELECT COUNT(*) FROM referral_rewards - WHERE user_uuid = $1 - AND ($2::text IS NULL OR COALESCE(description, '') ILIKE '%' || $2 || '%') - AND ($3::varchar IS NULL OR reward_type = $3) - AND ($4::varchar IS NULL OR status = $4) - "#, - ) - .bind(user_uuid) - .bind(keyword) - .bind(reward_type) - .bind(status) - .fetch_one(pool) - .await?; - - Ok(count) -} - -/// 获取总奖励积分 -pub async fn fetch_total_reward_points( - pool: &Pool, - user_uuid: Uuid, -) -> Result { - let total: i64 = sqlx::query_scalar( - r#" - SELECT COALESCE(SUM(points), 0) FROM referral_rewards - WHERE user_uuid = $1 AND status = 'completed' - "#, - ) - .bind(user_uuid) - .fetch_one(pool) - .await?; - - Ok(total as i32) -} - -// ============ User Referral Points ============ - -/// 获取用户积分 -pub async fn fetch_user_points( - pool: &Pool, - user_uuid: Uuid, -) -> Result, Error> { - let rec = sqlx::query_as::<_, UserReferralPointsDto>( - r#" - SELECT id, user_uuid, total_points, available_points, used_points, - pending_points, created_at, updated_at - FROM user_referral_points - WHERE user_uuid = $1 - "#, - ) - .bind(user_uuid) - .fetch_optional(pool) - .await?; - - Ok(rec) -} - -/// 创建或更新用户积分 -pub async fn upsert_user_points( - pool: &Pool, - user_uuid: Uuid, - total_points: i32, - available_points: i32, - used_points: i32, - pending_points: i32, -) -> Result<(), Error> { - sqlx::query( - r#" - INSERT INTO user_referral_points (user_uuid, total_points, available_points, used_points, pending_points) - VALUES ($1, $2, $3, $4, $5) - ON CONFLICT (user_uuid) DO UPDATE SET - total_points = $2, - available_points = $3, - used_points = $4, - pending_points = $5, - updated_at = CURRENT_TIMESTAMP - "#, - ) - .bind(user_uuid) - .bind(total_points) - .bind(available_points) - .bind(used_points) - .bind(pending_points) - .execute(pool) - .await?; - - Ok(()) -} - -/// 扣减积分 -pub async fn deduct_user_points( - pool: &Pool, - user_uuid: Uuid, - points: i32, -) -> Result<(), Error> { - sqlx::query( - r#" - UPDATE user_referral_points - SET available_points = available_points - $1, - used_points = used_points + $1, - updated_at = CURRENT_TIMESTAMP - WHERE user_uuid = $2 AND available_points >= $1 - "#, - ) - .bind(points) - .bind(user_uuid) - .execute(pool) - .await?; - - Ok(()) -} - -// ============ Redeem Options ============ - -/// 查询兑换选项 -pub async fn fetch_redeem_options(pool: &Pool) -> Result, Error> { - let recs = sqlx::query_as::<_, RedeemOptionDto>( - r#" - SELECT id, uuid, redeem_type, name, description, points_required, - value, currency, exchange_rate, status, sort_order, created_at, updated_at - FROM redeem_options - WHERE status = 'active' - ORDER BY sort_order ASC - "#, - ) - .fetch_all(pool) - .await?; - - Ok(recs) -} - -/// 根据 UUID 查询兑换选项 -pub async fn fetch_redeem_option_by_uuid( - pool: &Pool, - option_uuid: Uuid, -) -> Result, Error> { - let rec = sqlx::query_as::<_, RedeemOptionDto>( - r#" - SELECT id, uuid, redeem_type, name, description, points_required, - value, currency, exchange_rate, status, sort_order, created_at, updated_at - FROM redeem_options - WHERE uuid = $1 - "#, - ) - .bind(option_uuid) - .fetch_optional(pool) - .await?; - - Ok(rec) -} - -// ============ Redeem Records ============ - -/// 创建兑换记录 -pub async fn insert_redeem_record( - pool: &Pool, - user_uuid: Uuid, - option_uuid: Uuid, - points_used: i32, - value: Decimal, - currency: Option<&str>, -) -> Result { - let uuid: Uuid = sqlx::query_scalar( - r#" - INSERT INTO redeem_records (user_uuid, option_uuid, points_used, value, currency, status) - VALUES ($1, $2, $3, $4, $5, 'completed') - RETURNING uuid; - "#, - ) - .bind(user_uuid) - .bind(option_uuid) - .bind(points_used) - .bind(value) - .bind(currency) - .fetch_one(pool) - .await?; - - Ok(uuid) -} - -/// 查询兑换记录 -pub async fn fetch_redeem_records( - pool: &Pool, - user_uuid: Uuid, - offset: i64, - limit: i64, -) -> Result, Error> { - let recs = sqlx::query_as::<_, RedeemRecordDto>( - r#" - SELECT id, uuid, user_uuid, option_uuid, points_used, value, - currency, status, created_at, completed_at - FROM redeem_records - WHERE user_uuid = $1 - ORDER BY created_at DESC - LIMIT $2 OFFSET $3 - "#, - ) - .bind(user_uuid) - .bind(limit) - .bind(offset) - .fetch_all(pool) - .await?; - - Ok(recs) -} - -/// 查询兑换记录总数 -pub async fn fetch_redeem_records_count( - pool: &Pool, - user_uuid: Uuid, -) -> Result { - let count: i64 = sqlx::query_scalar( - r#" - SELECT COUNT(*) FROM redeem_records WHERE user_uuid = $1 - "#, - ) - .bind(user_uuid) - .fetch_one(pool) - .await?; - - Ok(count) -} diff --git a/src-tauri/crates/business/src/models/rpa.rs b/src-tauri/crates/business/src/models/rpa.rs index e751d1d..5aebb03 100644 --- a/src-tauri/crates/business/src/models/rpa.rs +++ b/src-tauri/crates/business/src/models/rpa.rs @@ -1,7 +1,7 @@ use sqlx::Error; use uuid::Uuid; -use crate::database::{Db as Postgres, Pool}; +use crate::database::{Db as Postgres, Pool, placeholders}; use crate::dto::{RpaTaskDto, RpaTaskEnvironmentDto, RpaTaskRunDto, RpaTaskStepDto}; @@ -78,9 +78,9 @@ pub async fn fetch_rpa_tasks( , (SELECT COUNT(*) FROM rpa_task_environments rte WHERE rte.task_uuid = rpa_tasks.uuid) AS environment_count FROM rpa_tasks WHERE (team_uuid = $1 OR (team_uuid IS NULL AND user_uuid = $2)) - AND ($3::varchar IS NULL OR name ILIKE $3 OR COALESCE(description, '') ILIKE $3) - AND ($4::varchar IS NULL OR status = $4) - AND ($5::varchar IS NULL OR trigger_type = $5) + AND ($3 IS NULL OR name LIKE $3 OR COALESCE(description, '') LIKE $3) + AND ($4 IS NULL OR status = $4) + AND ($5 IS NULL OR trigger_type = $5) AND deleted_at IS NULL ORDER BY created_at DESC LIMIT $6 OFFSET $7 @@ -112,9 +112,9 @@ pub async fn fetch_rpa_tasks_count( r#" SELECT COUNT(*) FROM rpa_tasks WHERE (team_uuid = $1 OR (team_uuid IS NULL AND user_uuid = $2)) - AND ($3::varchar IS NULL OR status = $3) - AND ($4::varchar IS NULL OR trigger_type = $4) - AND ($5::varchar IS NULL OR name ILIKE $5 OR COALESCE(description, '') ILIKE $5) + AND ($3 IS NULL OR status = $3) + AND ($4 IS NULL OR trigger_type = $4) + AND ($5 IS NULL OR name LIKE $5 OR COALESCE(description, '') LIKE $5) AND deleted_at IS NULL "#, ) @@ -250,19 +250,26 @@ pub async fn delete_rpa_task(pool: &Pool, task_uuid: Uuid) -> Result<( } /// 批量软删除 RPA 任务 -pub async fn batch_delete_rpa_tasks( - pool: &Pool, - task_uuids: &[Uuid], -) -> Result { - let result = sqlx::query( - r#" - UPDATE rpa_tasks SET deleted_at = CURRENT_TIMESTAMP - WHERE uuid = ANY($1) AND deleted_at IS NULL - "#, - ) - .bind(task_uuids) - .execute(pool) - .await?; +pub async fn batch_delete_rpa_tasks( + pool: &Pool, + task_uuids: &[Uuid], +) -> Result { + if task_uuids.is_empty() { + return Ok(0); + } + + let statement = format!( + r#" + UPDATE rpa_tasks SET deleted_at = CURRENT_TIMESTAMP + WHERE uuid IN ({}) AND deleted_at IS NULL + "#, + placeholders(1, task_uuids.len()) + ); + let mut query = sqlx::query(&statement); + for uuid in task_uuids { + query = query.bind(uuid); + } + let result = query.execute(pool).await?; Ok(result.rows_affected()) } @@ -447,7 +454,7 @@ pub async fn fetch_rpa_task_runs( started_at, finished_at, duration_ms, result_summary, error_message, logs FROM rpa_task_runs WHERE task_uuid = $1 - AND ($2::varchar IS NULL OR status = $2) + AND ($2 IS NULL OR status = $2) ORDER BY started_at DESC LIMIT $3 OFFSET $4 "#, @@ -472,7 +479,7 @@ pub async fn fetch_rpa_task_runs_count( r#" SELECT COUNT(*) FROM rpa_task_runs WHERE task_uuid = $1 - AND ($2::varchar IS NULL OR status = $2) + AND ($2 IS NULL OR status = $2) "#, ) .bind(task_uuid) @@ -523,7 +530,7 @@ pub async fn update_rpa_task_run_status( error_message = $5, finished_at = CASE WHEN $1 IN ('completed', 'failed', 'stopped') THEN CURRENT_TIMESTAMP ELSE finished_at END, duration_ms = CASE WHEN $1 IN ('completed', 'failed', 'stopped') - THEN EXTRACT(EPOCH FROM (CURRENT_TIMESTAMP - started_at)) * 1000 + THEN (julianday(CURRENT_TIMESTAMP) - julianday(started_at)) * 86400000 ELSE duration_ms END WHERE uuid = $6 "#, diff --git a/src-tauri/crates/business/src/models/teams.rs b/src-tauri/crates/business/src/models/teams.rs index fedf7a0..7cf2cca 100644 --- a/src-tauri/crates/business/src/models/teams.rs +++ b/src-tauri/crates/business/src/models/teams.rs @@ -163,7 +163,7 @@ pub async fn update_team( description: Option<&str>, avatar_hash: Option<&str>, ) -> Result<(), Error> { - let mut query = String::from("UPDATE teams SET updated_at = NOW()"); + let mut query = String::from("UPDATE teams SET updated_at = CURRENT_TIMESTAMP"); let mut params: Vec + Send + Sync>> = vec![]; if let Some(n) = name { @@ -196,7 +196,7 @@ pub async fn update_team( sqlx::query( r#" UPDATE teams - SET name = $1, description = $2, avatar_hash = $3, updated_at = NOW() + SET name = $1, description = $2, avatar_hash = $3, updated_at = CURRENT_TIMESTAMP WHERE uuid = $4 "#, ) @@ -210,7 +210,7 @@ pub async fn update_team( sqlx::query( r#" UPDATE teams - SET name = $1, description = $2, updated_at = NOW() + SET name = $1, description = $2, updated_at = CURRENT_TIMESTAMP WHERE uuid = $3 "#, ) @@ -223,7 +223,7 @@ pub async fn update_team( sqlx::query( r#" UPDATE teams - SET name = $1, updated_at = NOW() + SET name = $1, updated_at = CURRENT_TIMESTAMP WHERE uuid = $2 "#, ) @@ -271,7 +271,7 @@ pub async fn fetch_team_member_count( if keyword.is_some() { query.push_str(&format!( - " AND (ui.nickname ILIKE ${} OR ui.email ILIKE ${})", + " AND (ui.nickname LIKE ${} OR ui.email LIKE ${})", param_index, param_index )); } @@ -346,7 +346,7 @@ pub async fn fetch_team_members( if keyword.is_some() { query.push_str(&format!( - " AND (ui.nickname ILIKE ${} OR ui.email ILIKE ${})", + " AND (ui.nickname LIKE ${} OR ui.email LIKE ${})", param_index, param_index )); param_index += 1; @@ -463,7 +463,7 @@ pub async fn update_member_role( sqlx::query( r#" UPDATE team_members - SET role = $1, updated_at = NOW() + SET role = $1, updated_at = CURRENT_TIMESTAMP WHERE team_uuid = $2 AND user_uuid = $3 AND deleted_at IS NULL "#, ) @@ -508,7 +508,7 @@ pub async fn remove_team_member( sqlx::query( r#" UPDATE team_members - SET deleted_at = NOW(), status = 'inactive' + SET deleted_at = CURRENT_TIMESTAMP, status = 'inactive' WHERE team_uuid = $1 AND user_uuid = $2 AND deleted_at IS NULL "#, ) @@ -619,7 +619,7 @@ pub async fn update_invitation_status( sqlx::query( r#" UPDATE team_invitations - SET status = $1, updated_at = NOW() + SET status = $1, updated_at = CURRENT_TIMESTAMP WHERE uuid = $2 AND deleted_at IS NULL "#, ) @@ -647,7 +647,7 @@ pub async fn cancel_team_invitation( sqlx::query( r#" UPDATE team_invitations - SET status = 'cancelled', deleted_at = NOW() + SET status = 'cancelled', deleted_at = CURRENT_TIMESTAMP WHERE uuid = $1 AND deleted_at IS NULL "#, ) @@ -673,7 +673,6 @@ pub async fn accept_team_invitation( SELECT id, uuid, team_uuid, email, role, invited_by, token, expires_at, status, accepted_at, created_at, updated_at FROM team_invitations WHERE uuid = $1 AND deleted_at IS NULL - FOR UPDATE "#, ) .bind(invitation_uuid) @@ -702,7 +701,7 @@ pub async fn accept_team_invitation( invited_by = EXCLUDED.invited_by, status = 'active', deleted_at = NULL, - updated_at = NOW() + updated_at = CURRENT_TIMESTAMP "#, ) .bind(invitation.team_uuid) @@ -717,7 +716,7 @@ pub async fn accept_team_invitation( sqlx::query( r#" UPDATE team_invitations - SET status = 'accepted', accepted_at = NOW() + SET status = 'accepted', accepted_at = CURRENT_TIMESTAMP WHERE uuid = $1 "#, ) @@ -744,7 +743,6 @@ pub async fn reject_team_invitation( SELECT id, uuid, team_uuid, email, role, invited_by, token, expires_at, status, accepted_at, created_at, updated_at FROM team_invitations WHERE uuid = $1 AND deleted_at IS NULL - FOR UPDATE "#, ) .bind(invitation_uuid) @@ -765,7 +763,7 @@ pub async fn reject_team_invitation( sqlx::query( r#" UPDATE team_invitations - SET status = 'rejected', updated_at = NOW() + SET status = 'rejected', updated_at = CURRENT_TIMESTAMP WHERE uuid = $1 "#, ) diff --git a/src-tauri/crates/business/src/models/user.rs b/src-tauri/crates/business/src/models/user.rs index 02a9ad5..02fe30d 100644 --- a/src-tauri/crates/business/src/models/user.rs +++ b/src-tauri/crates/business/src/models/user.rs @@ -1,4 +1,3 @@ -use rust_decimal::Decimal; use sqlx::Error; use uuid::Uuid; @@ -44,11 +43,7 @@ pub async fn insert_user_info( /// 使用事务创建用户(users + user_infos + 初始化数据) /// /// 初始化数据包括: -/// - user_wallets: 用户钱包 -/// - user_quotas: 用户配额(免费套餐) /// - user_preferences: 用户偏好设置 -/// - user_referral_points: 推广积分 -/// - referral_links: 推广链接(关联青铜层级) /// - teams: 个人团队(每个用户自动创建一个团队) pub async fn create_user_with_info( pool: &Pool, @@ -57,9 +52,6 @@ pub async fn create_user_with_info( password_hash: &str, quota: &crate::utils::WorkspaceQuotaValues, ) -> Result { - // 为用户初始化推广链接时,需要读取所有推广层级配置。 - // 这里复用 referral 模块中已经定义好的 DTO / 查询函数,避免在 models 中新增本地 struct 类型。 - let tiers = crate::models::referral::fetch_referral_tiers(pool).await?; let local_api_permission_definitions = crate::models::local_api::fetch_permission_definitions(pool).await?; @@ -85,20 +77,7 @@ pub async fn create_user_with_info( .execute(&mut *tx) .await?; - // 3. 初始化用户钱包 - sqlx::query( - r#" - INSERT INTO user_wallets (user_uuid, balance, currency, frozen_amount) - VALUES ($1, 0, 'CNY', 0); - "#, - ) - .bind(user_uuid) - .execute(&mut *tx) - .await?; - - // 4. 初始化用户配额(已废弃,配额移至 workspace_quotas,在工作空间创建时初始化) - - // 5. 初始化用户偏好设置 + // 3. 初始化用户偏好设置 sqlx::query( r#" INSERT INTO user_preferences (user_uuid, theme, language, notifications_enabled) @@ -109,18 +88,18 @@ pub async fn create_user_with_info( .execute(&mut *tx) .await?; - // 6. 初始化本地 API 配置 + // 4. 初始化本地 API 配置 sqlx::query( r#" INSERT INTO user_local_api_settings (user_uuid, enabled, port, remote_access, cors_origins) - VALUES ($1, FALSE, 8080, FALSE, '[]'::jsonb); + VALUES ($1, FALSE, 8080, FALSE, '[]'); "#, ) .bind(user_uuid) .execute(&mut *tx) .await?; - // 7. 初始化本地 API 密钥 + // 5. 初始化本地 API 密钥 let local_api_key = generate_local_api_key(); let local_api_key_hash = crate::models::local_api::hash_api_key(&local_api_key); let local_api_key_prefix = local_api_key.chars().take(16).collect::(); @@ -138,7 +117,7 @@ pub async fn create_user_with_info( .fetch_one(&mut *tx) .await?; - // 8. 初始化本地 API 权限记录 + // 6. 初始化本地 API 权限记录 insert_local_api_permissions( &mut tx, local_api_key_id, @@ -146,109 +125,7 @@ pub async fn create_user_with_info( ) .await?; - // 9. 初始化推广积分 - sqlx::query( - r#" - INSERT INTO user_referral_points (user_uuid, total_points, available_points, used_points, pending_points) - VALUES ($1, 0, 0, 0, 0); - "#, - ) - .bind(user_uuid) - .execute(&mut *tx) - .await?; - - // 10. 创建推广链接(根据层级配置创建最多 4 个链接) - // - // 规则: - // - 始终为用户预创建所有层级对应的推广链接(默认最多 4 个) - // - 只有第一个层级的链接默认解锁并设为当前链接 - // - 奖励比例 / 折扣比例从对应层级配置中继承,便于后续统一调整 - // - // 生成基础邀请码(第一个链接使用此码,其余在此基础上追加序号后缀) - let base_invite_code = format!("INV{}", &user_uuid.to_string()[..8].to_uppercase()); - - // 根据层级配置为用户预创建推广链接(最多 4 个) - for (idx, tier) in tiers.iter().enumerate() { - if idx >= 4 { - // 目前产品形态只需要 4 个推广链接,多余层级忽略 - break; - } - - let code = if idx == 0 { - base_invite_code.clone() - } else { - // 保证唯一性的同时,便于用户识别不同链接 - format!("{}-{}", base_invite_code, idx + 1) - }; - - // 只有第一个层级默认解锁并设为当前链接,其余待后续根据阈值自动解锁 - let is_current = idx == 0; - let unlocked = idx == 0; - - sqlx::query( - r#" - INSERT INTO referral_links ( - user_uuid, - code, - tier_uuid, - is_current, - unlocked, - reward_rate, - discount_rate - ) - VALUES ($1, $2, $3, $4, $5, $6, $7); - "#, - ) - .bind(user_uuid) - .bind(&code) - .bind(tier.uuid) - .bind(is_current) - .bind(unlocked) - .bind(tier.reward_rate) - .bind(tier.discount_rate) - .execute(&mut *tx) - .await?; - } - - // 11. 处理推荐人关系(如果有邀请码) - if let Some(ref referrer_code) = payload.referral_code { - // 查找推荐人 - let referrer: Option<(Uuid, Uuid)> = sqlx::query_as( - r#" - SELECT user_uuid, uuid FROM referral_links WHERE code = $1 AND is_current = TRUE; - "#, - ) - .bind(referrer_code) - .fetch_optional(&mut *tx) - .await?; - - if let Some((referrer_uuid, referral_link_uuid)) = referrer { - // 创建推荐关系(表列名:inviter_uuid=推荐人, invitee_uuid=被邀请人, link_uuid=推广链接) - sqlx::query( - r#" - INSERT INTO user_referrals (invitee_uuid, inviter_uuid, link_uuid, status) - VALUES ($1, $2, $3, 'registered'); - "#, - ) - .bind(user_uuid) - .bind(referrer_uuid) - .bind(referral_link_uuid) - .execute(&mut *tx) - .await?; - - // 更新推荐链接统计 - sqlx::query( - r#" - UPDATE referral_links SET registered_users = registered_users + 1 WHERE uuid = $1; - "#, - ) - .bind(referral_link_uuid) - .execute(&mut *tx) - .await?; - } - } - - // 12. 创建个人团队(每个用户都应该有一个团队) + // 7. 创建个人团队(每个用户都应该有一个团队) // 团队名称使用用户昵称,如果没有昵称则使用邮箱前缀 let team_name = payload.nickname.as_ref().map(|n| format!("{} 的团队", n)).unwrap_or_else(|| { @@ -258,7 +135,7 @@ pub async fn create_user_with_info( ) }); - // 13. 创建个人工作空间 + // 8. 创建个人工作空间 let workspace_name = format!( "{} 的工作空间", payload.nickname.as_deref().unwrap_or("用户") @@ -275,7 +152,7 @@ pub async fn create_user_with_info( .fetch_one(&mut *tx) .await?; - // 14. 创建工作空间配额(使用传入的配额配置) + // 9. 创建工作空间配额(使用传入的配额配置) sqlx::query( r#" INSERT INTO workspace_quotas (workspace_uuid, max_environments, max_team_members, max_proxies, max_rpa_tasks) @@ -290,7 +167,7 @@ pub async fn create_user_with_info( .execute(&mut *tx) .await?; - // 15. 创建个人团队(每个用户自动创建一个团队) + // 10. 创建个人团队(每个用户自动创建一个团队) let team_uuid: Uuid = sqlx::query_scalar( r#" INSERT INTO teams (workspace_uuid, name, description, owner_uuid) @@ -305,7 +182,7 @@ pub async fn create_user_with_info( .fetch_one(&mut *tx) .await?; - // 16. 添加用户为团队成员(owner 角色) + // 11. 添加用户为团队成员(owner 角色) sqlx::query( r#" INSERT INTO team_members (team_uuid, workspace_uuid, user_uuid, role, status) @@ -318,7 +195,7 @@ pub async fn create_user_with_info( .execute(&mut *tx) .await?; - // 17. 设置为用户当前团队和工作空间 + // 12. 设置为用户当前团队和工作空间 sqlx::query( r#" UPDATE user_infos SET current_team_uuid = $1, current_workspace_uuid = $2 WHERE user_uuid = $3; diff --git a/src-tauri/crates/business/src/models/versions.rs b/src-tauri/crates/business/src/models/versions.rs index 093e61e..2646196 100644 --- a/src-tauri/crates/business/src/models/versions.rs +++ b/src-tauri/crates/business/src/models/versions.rs @@ -17,7 +17,7 @@ pub async fn insert_version( arch, package_format, requires_extract, created_at ) VALUES ( $1, $2, $3, $4, $5, $6, $7, $8, $9, $10, $11, $12, - $13, $14, $15, NOW() + $13, $14, $15, CURRENT_TIMESTAMP ) RETURNING id "; @@ -253,7 +253,7 @@ pub async fn update_version( file_size = COALESCE($8, file_size), status = COALESCE($9, status), is_latest = COALESCE($10, is_latest), - updated_at = NOW() + updated_at = CURRENT_TIMESTAMP WHERE id = $11 "; @@ -294,7 +294,7 @@ pub async fn set_as_latest_version( // 先取消其他版本的最新状态 sqlx::query( - "UPDATE versions SET is_latest = false, updated_at = NOW() + "UPDATE versions SET is_latest = false, updated_at = CURRENT_TIMESTAMP WHERE type_id = $1 AND resource_name = $2 AND id != $3", ) .bind(type_id) @@ -304,7 +304,7 @@ pub async fn set_as_latest_version( .await?; // 设置当前版本为最新 - let row = sqlx::query("UPDATE versions SET is_latest = true, updated_at = NOW() WHERE id = $1") + let row = sqlx::query("UPDATE versions SET is_latest = true, updated_at = CURRENT_TIMESTAMP WHERE id = $1") .bind(version_id) .execute(&mut *tx) .await?; diff --git a/src-tauri/crates/business/src/services.rs b/src-tauri/crates/business/src/services.rs index 5b91379..8909958 100644 --- a/src-tauri/crates/business/src/services.rs +++ b/src-tauri/crates/business/src/services.rs @@ -6,31 +6,23 @@ mod version_types; mod versions; // 新增模块 -pub mod accounts; -pub mod audit; -pub mod billing; -pub mod browser_kernel; -pub mod coupons; -pub mod environments; -pub mod extensions; -pub mod group_permissions; -pub mod groups; -pub mod local_api; -pub mod messages; -pub mod orders; -pub mod plans; -pub mod preferences; -pub mod proxies; -pub mod proxy_visibility; -pub mod referral; -pub mod rpa; -pub mod subscriptions; -pub mod tags; -pub mod teams; -pub mod templates; -pub mod wallet; -pub mod workspace_quotas; -pub mod workspaces; +pub mod accounts; +pub mod audit; +pub mod browser_kernel; +pub mod environments; +pub mod group_permissions; +pub mod groups; +pub mod local_api; +pub mod messages; +pub mod preferences; +pub mod proxies; +pub mod proxy_visibility; +pub mod rpa; +pub mod tags; +pub mod teams; +pub mod templates; +pub mod workspace_quotas; +pub mod workspaces; pub use maintenance::*; pub use strategy_types::*; @@ -40,23 +32,17 @@ pub use version_types::*; pub use versions::*; // 新增导出 -pub use accounts::*; -pub use audit::*; -pub use billing::*; -pub use browser_kernel::*; -pub use coupons::*; -pub use environments::*; -pub use group_permissions::*; -pub use groups::*; -pub use messages::*; -pub use orders::*; -pub use plans::*; -pub use proxies::*; -pub use proxy_visibility::*; -pub use subscriptions::*; -pub use tags::*; -pub use teams::*; -pub use templates::*; -pub use wallet::*; -pub use workspace_quotas::*; -pub use workspaces::*; +pub use accounts::*; +pub use audit::*; +pub use browser_kernel::*; +pub use environments::*; +pub use group_permissions::*; +pub use groups::*; +pub use messages::*; +pub use proxies::*; +pub use proxy_visibility::*; +pub use tags::*; +pub use teams::*; +pub use templates::*; +pub use workspace_quotas::*; +pub use workspaces::*; diff --git a/src-tauri/crates/business/src/services/billing.rs b/src-tauri/crates/business/src/services/billing.rs deleted file mode 100644 index cccf4cb..0000000 --- a/src-tauri/crates/business/src/services/billing.rs +++ /dev/null @@ -1,122 +0,0 @@ -use rust_decimal::Decimal; -use uuid::Uuid; - -use crate::dto::{AutoRenewalServiceDto, InvoiceDto, UserQuotaDto}; -use crate::entitys::{AccountInfoResponse, ListInvoicesRequest}; -use crate::models; -use crate::svc_ctx::SvcCtx; - -// ============ Invoices ============ - -/// 获取发票列表 -pub async fn get_invoices_service( - svc_ctx: &SvcCtx, - user_uuid: Uuid, - payload: &ListInvoicesRequest, -) -> Result<(Vec, i64), String> { - let offset = (payload.pagination.page - 1) * payload.pagination.page_size; - - let invoices = models::billing::fetch_invoices( - &svc_ctx.db, - user_uuid, - payload.status.as_deref(), - offset, - payload.pagination.page_size, - ) - .await - .map_err(|e| e.to_string())?; - - let total = - models::billing::fetch_invoices_count(&svc_ctx.db, user_uuid, payload.status.as_deref()) - .await - .map_err(|e| e.to_string())?; - - Ok((invoices, total)) -} - -// ============ Quotas ============ - -/// 获取用户配额 -pub async fn get_quota_service(svc_ctx: &SvcCtx, user_uuid: Uuid) -> Result { - models::billing::fetch_user_quota(&svc_ctx.db, user_uuid) - .await - .map_err(|e| e.to_string())? - .ok_or_else(|| "用户配额不存在".to_string()) -} - -// ============ Auto Renewal Services ============ - -/// 获取自动续费服务列表 -pub async fn get_auto_renewal_services_service( - svc_ctx: &SvcCtx, - user_uuid: Uuid, -) -> Result, String> { - models::billing::fetch_auto_renewal_services(&svc_ctx.db, user_uuid) - .await - .map_err(|e| e.to_string()) -} - -/// 获取账户信息(聚合钱包、配额、订阅、用户信息) -pub async fn get_account_info_service( - svc_ctx: &SvcCtx, - workspace_uuid: Uuid, - user_uuid: Uuid, -) -> Result { - // 1. 获取用户信息(邮箱等) - let user_info = models::user::fetch_user_info_by_uuid(&svc_ctx.db, user_uuid) - .await - .map_err(|e| e.to_string())? - .ok_or_else(|| "用户不存在".to_string())?; - - // 2. 获取钱包信息 - let wallet = models::billing::fetch_user_wallet(&svc_ctx.db, user_uuid) - .await - .map_err(|e| e.to_string())?; - - // 如果钱包不存在,创建一个 - let wallet = if wallet.is_none() { - models::billing::insert_user_wallet(&svc_ctx.db, user_uuid, "CNY") - .await - .map_err(|e| e.to_string())?; - - models::billing::fetch_user_wallet(&svc_ctx.db, user_uuid) - .await - .map_err(|e| e.to_string())? - .ok_or_else(|| "创建钱包失败".to_string())? - } else { - wallet.ok_or_else(|| "钱包不存在".to_string())? - }; - - // 3. 获取工作空间配额 - let quota = models::fetch_workspace_quota(&svc_ctx.db, workspace_uuid) - .await - .map_err(|e| e.to_string())? - .ok_or_else(|| "工作空间配额不存在".to_string())?; - - // 4. 获取工作空间订阅 - let subscription = models::billing::fetch_workspace_active_subscription(&svc_ctx.db, workspace_uuid) - .await - .map_err(|e| e.to_string())?; - - // 5. 计算月结费用(订阅价格) - let monthly_billing = subscription - .as_ref() - .map(|s| { - if s.billing_period == "yearly" { - s.price / Decimal::from(12) - } else { - s.price - } - }) - .unwrap_or(Decimal::ZERO); - - Ok(AccountInfoResponse { - email: user_info.email, - wallet_balance: wallet.balance, - gift_balance: Decimal::ZERO, // TODO: 如果有赠送金字段,从钱包获取 - currency: wallet.currency, - subscription, - quota, - monthly_billing, - }) -} diff --git a/src-tauri/crates/business/src/services/coupons.rs b/src-tauri/crates/business/src/services/coupons.rs deleted file mode 100644 index 66adc71..0000000 --- a/src-tauri/crates/business/src/services/coupons.rs +++ /dev/null @@ -1,171 +0,0 @@ -use chrono::Utc; -use rust_decimal::Decimal; -use uuid::Uuid; - -use crate::entitys::{ - BatchIssueCouponRequest, CouponValidationResult, GetUserCouponsRequest, IssueCouponRequest, - UserCouponsListResponse, VerifyCouponRequest, -}; -use crate::models; -use crate::svc_ctx::SvcCtx; - -/// 验证优惠券 -pub async fn validate_coupon_service( - svc_ctx: &SvcCtx, - user_uuid: Uuid, - payload: &VerifyCouponRequest, -) -> Result { - let coupon = models::billing::fetch_coupon_by_code(&svc_ctx.db, &payload.code) - .await - .map_err(|e| e.to_string())? - .ok_or_else(|| "优惠券不存在".to_string())?; - - // 检查有效期 - let now = Utc::now(); - if coupon.valid_from > now { - return Err("优惠券尚未生效".to_string()); - } - if let Some(valid_until) = coupon.valid_until { - if valid_until < now { - return Err("优惠券已过期".to_string()); - } - } - - // 检查使用次数限制 - if let Some(max_uses) = coupon.max_uses { - if coupon.used_count >= max_uses { - return Err("优惠券已达使用上限".to_string()); - } - } - - // 检查单用户使用次数限制 - if let Some(max_uses_per_user) = coupon.max_uses_per_user { - let user_usage = - models::billing::fetch_coupon_user_usage_count(&svc_ctx.db, coupon.uuid, user_uuid) - .await - .map_err(|e| e.to_string())?; - if user_usage >= max_uses_per_user { - return Err("您已达该优惠券使用上限".to_string()); - } - } - - // 检查最低消费 - if let Some(min_amount) = coupon.min_amount { - if payload.amount < min_amount { - return Err(format!("订单金额需满 {} 元才可使用此优惠券", min_amount)); - } - } - - // 计算折扣金额 - let mut discount_amount = match coupon.discount_type.as_str() { - "percentage" => payload.amount * coupon.discount_value / Decimal::from(100), - "fixed" => coupon.discount_value, - _ => Decimal::ZERO, - }; - - // 检查最大折扣限制 - if let Some(max_discount) = coupon.max_discount { - if discount_amount > max_discount { - discount_amount = max_discount; - } - } - - Ok(CouponValidationResult { - coupon, - discount_amount, - }) -} - -/// 获取用户优惠券列表 -pub async fn get_user_coupons_service( - svc_ctx: &SvcCtx, - user_uuid: Uuid, - payload: &GetUserCouponsRequest, -) -> Result { - let (items, total) = models::billing::fetch_user_coupons( - &svc_ctx.db, - user_uuid, - payload.status.as_deref(), - payload.pagination.page, - payload.pagination.page_size, - ) - .await - .map_err(|e| e.to_string())?; - - Ok(UserCouponsListResponse { - items, - total, - page: payload.pagination.page, - page_size: payload.pagination.page_size, - }) -} - -/// 给单个用户发放优惠券 -pub async fn issue_coupon_to_user_service( - svc_ctx: &SvcCtx, - payload: &IssueCouponRequest, -) -> Result { - // 验证优惠券存在且有效 - let coupon = models::billing::fetch_coupon_by_uuid(&svc_ctx.db, payload.coupon_uuid) - .await - .map_err(|e| e.to_string())? - .ok_or_else(|| "优惠券不存在或已失效".to_string())?; - - // 如果提供了过期时间,使用它;否则从优惠券继承 - let expires_at = payload.expires_at.or(coupon.valid_until); - - let id = models::billing::insert_user_coupon( - &svc_ctx.db, - payload.user_uuid, - payload.coupon_uuid, - expires_at, - ) - .await - .map_err(|e| { - if e.to_string().contains("RowNotFound") { - "该用户已拥有此优惠券".to_string() - } else { - e.to_string() - } - })?; - - Ok(id) -} - -/// 批量发放优惠券 -pub async fn batch_issue_coupons_service( - svc_ctx: &SvcCtx, - payload: &BatchIssueCouponRequest, -) -> Result { - // 验证优惠券存在且有效 - let coupon = models::billing::fetch_coupon_by_uuid(&svc_ctx.db, payload.coupon_uuid) - .await - .map_err(|e| e.to_string())? - .ok_or_else(|| "优惠券不存在或已失效".to_string())?; - - // 如果提供了过期时间,使用它;否则从优惠券继承 - let expires_at = payload.expires_at.or(coupon.valid_until); - - let count = models::billing::batch_insert_user_coupons( - &svc_ctx.db, - payload.coupon_uuid, - &payload.user_uuids, - expires_at, - ) - .await - .map_err(|e| e.to_string())?; - - Ok(count) -} - -/// 获取用户可用优惠券(自动过滤过期和已使用的) -pub async fn get_available_coupons_service( - svc_ctx: &SvcCtx, - user_uuid: Uuid, -) -> Result, String> { - let items = models::billing::fetch_available_user_coupons(&svc_ctx.db, user_uuid) - .await - .map_err(|e| e.to_string())?; - - Ok(items) -} diff --git a/src-tauri/crates/business/src/services/environments.rs b/src-tauri/crates/business/src/services/environments.rs index 5c8dec9..d710ca6 100644 --- a/src-tauri/crates/business/src/services/environments.rs +++ b/src-tauri/crates/business/src/services/environments.rs @@ -690,21 +690,6 @@ pub async fn get_environments_service( accounts_map.insert(*env_uuid, accounts); } - // 批量查询扩展(为每个环境动态合并插件) - let mut extensions_map: HashMap> = - HashMap::new(); - for row in &filtered_env_rows { - let extensions = crate::services::extensions::get_environment_extensions_service( - &svc_ctx, - user_uuid, - team_uuid, - row.group_uuid, - ) - .await - .unwrap_or_default(); - extensions_map.insert(row.uuid, extensions); - } - // 11. 组装完整数据(使用与环境详情一致的数据结构) let environments: Vec = filtered_env_rows .into_iter() @@ -769,7 +754,7 @@ pub async fn get_environments_service( accounts: accounts_map.remove(&row.uuid).unwrap_or_default(), group, proxy, - extensions: extensions_map.remove(&row.uuid).unwrap_or_default(), + extensions: Vec::new(), } }) .collect(); @@ -889,16 +874,6 @@ pub async fn get_environment_detail_service( None }; - // 获取扩展列表 - let extensions = crate::services::extensions::get_environment_extensions_service( - svc_ctx, - user_uuid, - team_uuid, - environment.group_uuid, - ) - .await - .unwrap_or_default(); - Ok(crate::entitys::EnvironmentDetailResponse { environment, config, @@ -908,7 +883,7 @@ pub async fn get_environment_detail_service( accounts, group, proxy, - extensions, + extensions: Vec::new(), }) } diff --git a/src-tauri/crates/business/src/services/extensions.rs b/src-tauri/crates/business/src/services/extensions.rs deleted file mode 100644 index 9742b3a..0000000 --- a/src-tauri/crates/business/src/services/extensions.rs +++ /dev/null @@ -1,1030 +0,0 @@ -use uuid::Uuid; - -use crate::dto::ExtensionDto; -use crate::entitys::{ - ExtensionGroup, InstallExtensionRequest, InstalledExtensionItem, ListExtensionsRequest, - UninstallExtensionRequest, -}; -use crate::models; -use crate::models::environments as env_models; -use crate::svc_ctx::SvcCtx; -use crate::utils::storage::get_objects; - -/// 获取扩展列表 -pub async fn get_extensions_service( - svc_ctx: &SvcCtx, - payload: &ListExtensionsRequest, -) -> Result<(Vec, i64), String> { - let offset = (payload.pagination.page - 1) * payload.pagination.page_size; - - let keyword = payload.filters.as_ref().and_then(|f| f.keyword.as_deref()); - let category = payload.filters.as_ref().and_then(|f| f.category.as_deref()); - let sort_by = payload.filters.as_ref().and_then(|f| f.sort_by.as_deref()); - let sort_order = payload.filters.as_ref().and_then(|f| f.sort_order.as_deref()); - - let mut extensions = models::extensions::fetch_extensions( - &svc_ctx.db, - keyword, - category, - sort_by, - sort_order, - offset, - payload.pagination.page_size, - ) - .await - .map_err(|e| e.to_string())?; - - // 将 object path 转换为完整 URL - let public_base_url = svc_ctx.config.storage.public_base_url.as_str(); - let extension_root = svc_ctx.config.storage.extension_root.as_str(); - ExtensionDto::transform_urls_batch(&mut extensions, public_base_url, extension_root); - - let total = models::extensions::fetch_extensions_count(&svc_ctx.db, keyword, category) - .await - .map_err(|e| e.to_string())?; - - Ok((extensions, total)) -} - -/// 获取扩展详情 -pub async fn get_extension_service( - svc_ctx: &SvcCtx, - extension_id: &str, -) -> Result { - let mut extension = models::extensions::fetch_extension_by_id(&svc_ctx.db, extension_id) - .await - .map_err(|e| e.to_string())? - .ok_or_else(|| "扩展不存在".to_string())?; - - // 将 object path 转换为完整 URL - let public_base_url = svc_ctx.config.storage.public_base_url.as_str(); - let extension_root = svc_ctx.config.storage.extension_root.as_str(); - extension.transform_urls(&public_base_url, &extension_root); - - Ok(extension) -} - -/// 获取扩展分类 -pub async fn get_extension_categories_service(svc_ctx: &SvcCtx) -> Result, String> { - models::extensions::fetch_extension_categories(&svc_ctx.db) - .await - .map_err(|e| e.to_string()) -} - -/// 获取用户已安装的扩展 -pub async fn get_user_installed_extensions_service( - svc_ctx: &SvcCtx, - user_uuid: Uuid, - team_uuid: Option, -) -> Result, String> { - let public_base_url = svc_ctx.config.storage.public_base_url.as_str(); - let extension_root = svc_ctx.config.storage.extension_root.as_str(); - - // 查询用户直接安装的扩展 - let user_installed = models::extensions::fetch_user_extensions(&svc_ctx.db, user_uuid) - .await - .map_err(|e| e.to_string())?; - - // 查询用户相关的分组中安装的扩展 - let group_installed = - models::extensions::fetch_user_group_extensions(&svc_ctx.db, user_uuid, team_uuid) - .await - .map_err(|e| e.to_string())?; - - // 使用 HashMap 去重,以 extension_id 为 key - use std::collections::HashMap; - let mut extension_map: HashMap = HashMap::new(); - - // 处理用户直接安装的扩展 - for ue in user_installed { - if let Ok(Some(ext)) = - models::extensions::fetch_extension_by_id(&svc_ctx.db, &ue.extension_id).await - { - // 转换图标 URL - let mut icon_url = ext.icon_url.clone(); - if let Some(path) = &icon_url { - if !path.is_empty() && !path.starts_with("http") { - icon_url = Some(get_objects::get_extension_icon_url( - &public_base_url, - &extension_root, - path, - )); - } - } - - // 查询关联的分组 - let group_uuids = models::extensions::fetch_group_uuids_by_extension_id( - &svc_ctx.db, - &ue.extension_id, - ) - .await - .unwrap_or_default(); - - let mut groups = Vec::new(); - for group_uuid in group_uuids { - if let Ok(Some(group)) = - env_models::fetch_group_by_uuid(&svc_ctx.db, group_uuid).await - { - groups.push(ExtensionGroup { - uuid: group.uuid, - name: group.name, - }); - } - } - - let has_update = ext.version != ue.installed_version; - extension_map.insert( - ue.extension_id.clone(), - InstalledExtensionItem { - extension_id: ue.extension_id, - name: ext.name, - version: ext.version, - installed_version: ue.installed_version, - has_update, - status: ue.status, - installed_at: ue.installed_at, - homepage: ext.homepage, - icon_url, - team_uuid: None, - scope: "user".to_string(), - description: ext.description, - category: Some(ext.category), - browser: Some(ext.browser), - developer: ext.developer, - downloads_count: ext.downloads_count, - rating: ext.rating, - permissions: ext.permissions, - file_size: ext.file_size, - updated_at: Some(ext.updated_at), - groups: if groups.is_empty() { - None - } else { - Some(groups) - }, - }, - ); - } - } - - // 处理分组安装的扩展 - for ge in group_installed { - // 如果已存在(用户直接安装),分组信息已经包含在内,直接跳过 - if extension_map.contains_key(&ge.extension_id) { - continue; - } - - // 只处理新扩展(仅安装在分组中,用户未直接安装) - if let Ok(Some(ext)) = - models::extensions::fetch_extension_by_id(&svc_ctx.db, &ge.extension_id).await - { - // 转换图标 URL - let mut icon_url = ext.icon_url.clone(); - if let Some(path) = &icon_url { - if !path.is_empty() && !path.starts_with("http") { - icon_url = Some(get_objects::get_extension_icon_url( - &public_base_url, - &extension_root, - path, - )); - } - } - - // 查询关联的分组 - let group_uuids = models::extensions::fetch_group_uuids_by_extension_id( - &svc_ctx.db, - &ge.extension_id, - ) - .await - .unwrap_or_default(); - - let mut groups = Vec::new(); - let mut is_team_group = false; - for group_uuid in group_uuids { - if let Ok(Some(group)) = - env_models::fetch_group_by_uuid(&svc_ctx.db, group_uuid).await - { - if !group.team_uuid.is_nil() { - is_team_group = true; - } - groups.push(ExtensionGroup { - uuid: group.uuid, - name: group.name, - }); - } - } - - let has_update = ext.version != ge.installed_version; - let scope = if is_team_group { - "group-team".to_string() - } else { - "group-personal".to_string() - }; - extension_map.insert( - ge.extension_id.clone(), - InstalledExtensionItem { - extension_id: ge.extension_id, - name: ext.name, - version: ext.version, - installed_version: ge.installed_version, - has_update, - status: ge.status, - installed_at: ge.installed_at, - homepage: ext.homepage, - icon_url, - team_uuid: None, - scope, - description: ext.description, - category: Some(ext.category), - browser: Some(ext.browser), - developer: ext.developer, - downloads_count: ext.downloads_count, - rating: ext.rating, - permissions: ext.permissions, - file_size: ext.file_size, - updated_at: Some(ext.updated_at), - groups: if groups.is_empty() { - None - } else { - Some(groups) - }, - }, - ); - } - } - - Ok(extension_map.into_values().collect()) -} - -/// 获取团队已安装的扩展 -pub async fn get_team_installed_extensions_service( - svc_ctx: &SvcCtx, - team_uuid: Uuid, - user_uuid: Uuid, -) -> Result, String> { - let public_base_url = svc_ctx.config.storage.public_base_url.as_str(); - let extension_root = svc_ctx.config.storage.extension_root.as_str(); - - // 查询团队直接安装的扩展 - let team_installed = models::extensions::fetch_team_extensions(&svc_ctx.db, team_uuid) - .await - .map_err(|e| e.to_string())?; - - // 查询团队相关的分组中安装的扩展 - let group_installed = models::extensions::fetch_team_group_extensions(&svc_ctx.db, team_uuid) - .await - .map_err(|e| e.to_string())?; - - // 查询用户禁用的团队插件列表 - let user_disabled_extensions = - models::extensions::fetch_user_disabled_team_extensions(&svc_ctx.db, user_uuid, team_uuid) - .await - .map_err(|e| e.to_string())?; - let user_disabled_set: std::collections::HashSet = - user_disabled_extensions.into_iter().collect(); - - // 使用 HashMap 去重,以 extension_id 为 key - use std::collections::HashMap; - let mut extension_map: HashMap = HashMap::new(); - - // 处理团队直接安装的扩展 - for te in team_installed { - if let Ok(Some(ext)) = - models::extensions::fetch_extension_by_id(&svc_ctx.db, &te.extension_id).await - { - // 转换图标 URL - let mut icon_url = ext.icon_url.clone(); - if let Some(path) = &icon_url { - if !path.is_empty() && !path.starts_with("http") { - icon_url = Some(get_objects::get_extension_icon_url( - &public_base_url, - &extension_root, - path, - )); - } - } - - // 查询关联的分组(仅查询团队共享的分组) - let group_uuids = models::extensions::fetch_team_shared_group_uuids_by_extension_id( - &svc_ctx.db, - &te.extension_id, - ) - .await - .unwrap_or_default(); - - let mut groups = Vec::new(); - for group_uuid in group_uuids { - if let Ok(Some(group)) = - env_models::fetch_group_by_uuid(&svc_ctx.db, group_uuid).await - { - groups.push(ExtensionGroup { - uuid: group.uuid, - name: group.name, - }); - } - } - - let has_update = ext.version != te.installed_version; - - // 检查用户是否禁用了这个团队插件 - let status = if user_disabled_set.contains(&te.extension_id) { - "disabled".to_string() - } else { - te.status - }; - - extension_map.insert( - te.extension_id.clone(), - InstalledExtensionItem { - extension_id: te.extension_id, - name: ext.name, - version: ext.version, - installed_version: te.installed_version, - has_update, - status, - installed_at: te.installed_at, - homepage: ext.homepage, - icon_url, - team_uuid: Some(team_uuid), - scope: "team".to_string(), - description: ext.description, - category: Some(ext.category), - browser: Some(ext.browser), - developer: ext.developer, - downloads_count: ext.downloads_count, - rating: ext.rating, - permissions: ext.permissions, - file_size: ext.file_size, - updated_at: Some(ext.updated_at), - groups: if groups.is_empty() { - None - } else { - Some(groups) - }, - }, - ); - } - } - - // 处理分组安装的扩展 - for ge in group_installed { - // 如果已存在(团队直接安装),分组信息已经包含在内,直接跳过 - if extension_map.contains_key(&ge.extension_id) { - continue; - } - - // 只处理新扩展(仅安装在分组中,团队未直接安装) - if let Ok(Some(ext)) = - models::extensions::fetch_extension_by_id(&svc_ctx.db, &ge.extension_id).await - { - // 转换图标 URL - let mut icon_url = ext.icon_url.clone(); - if let Some(path) = &icon_url { - if !path.is_empty() && !path.starts_with("http") { - icon_url = Some(get_objects::get_extension_icon_url( - &public_base_url, - &extension_root, - path, - )); - } - } - - // 查询关联的分组 - let group_uuids = models::extensions::fetch_group_uuids_by_extension_id( - &svc_ctx.db, - &ge.extension_id, - ) - .await - .unwrap_or_default(); - - let mut groups = Vec::new(); - for group_uuid in group_uuids { - if let Ok(Some(group)) = - env_models::fetch_group_by_uuid(&svc_ctx.db, group_uuid).await - { - groups.push(ExtensionGroup { - uuid: group.uuid, - name: group.name, - }); - } - } - - let has_update = ext.version != ge.installed_version; - let scope = if ge.is_team_shared { - "group-team".to_string() - } else { - "group-personal".to_string() - }; - - // 检查用户是否禁用了这个团队插件 - let status = if user_disabled_set.contains(&ge.extension_id) { - "disabled".to_string() - } else { - ge.status - }; - - extension_map.insert( - ge.extension_id.clone(), - InstalledExtensionItem { - extension_id: ge.extension_id, - name: ext.name, - version: ext.version, - installed_version: ge.installed_version, - has_update, - status, - installed_at: ge.installed_at, - homepage: ext.homepage, - icon_url, - team_uuid: Some(team_uuid), - scope, - description: ext.description, - category: Some(ext.category), - browser: Some(ext.browser), - developer: ext.developer, - downloads_count: ext.downloads_count, - rating: ext.rating, - permissions: ext.permissions, - file_size: ext.file_size, - updated_at: Some(ext.updated_at), - groups: if groups.is_empty() { - None - } else { - Some(groups) - }, - }, - ); - } - } - - Ok(extension_map.into_values().collect()) -} - -/// 安装扩展 -pub async fn install_extension_service( - svc_ctx: &SvcCtx, - user_uuid: Uuid, - team_uuid: Option, - payload: &InstallExtensionRequest, -) -> Result<(), String> { - // 获取扩展信息 - let extension = models::extensions::fetch_extension_by_id(&svc_ctx.db, &payload.extension_id) - .await - .map_err(|e| e.to_string())? - .ok_or_else(|| "扩展不存在".to_string())?; - - let target_type = payload.target_type.as_deref().unwrap_or("user"); - - match target_type { - "user" => { - models::extensions::insert_user_extension( - &svc_ctx.db, - user_uuid, - &payload.extension_id, - &extension.version, - ) - .await - .map_err(|e| e.to_string())?; - } - "team" => { - // 权限检查:检查用户是否为 owner/admin - let team_uuid = team_uuid.ok_or_else(|| "未指定团队".to_string())?; - - // 获取工作空间 UUID - let team = crate::models::teams::fetch_team_by_uuid(&svc_ctx.db, team_uuid) - .await - .map_err(|e| e.to_string())? - .ok_or_else(|| "团队不存在".to_string())?; - let workspace_uuid = team.workspace_uuid; - - // 查询用户在团队中的角色 - let member = crate::models::teams::fetch_team_member( - &svc_ctx.db, - workspace_uuid, - team_uuid, - user_uuid, - ) - .await - .map_err(|e| e.to_string())? - .ok_or_else(|| "您不是该团队成员".to_string())?; - - // 检查角色权限 - if member.role != "owner" && member.role != "admin" { - return Err("权限不足:只有团队所有者或管理员可以安装团队插件".to_string()); - } - - models::extensions::insert_team_extension( - &svc_ctx.db, - team_uuid, - &payload.extension_id, - &extension.version, - user_uuid, - ) - .await - .map_err(|e| e.to_string())?; - } - "group" => { - // 权限检查 - let is_team_shared = payload.is_team_shared.unwrap_or(false); - - // 必须提供分组数组,即使只有一个分组也需要传入数组 - let group_ids = payload.group_ids.as_ref().ok_or_else(|| "未指定分组".to_string())?; - if group_ids.is_empty() { - return Err("分组列表不能为空".to_string()); - } - - for group_uuid in group_ids { - // 检查权限 - // - is_team_shared=true: 需要"管理"权限 - // - is_team_shared=false: 需要"编辑"权限 - let required_permission = if is_team_shared { "manage" } else { "write" }; - - // 获取工作空间 UUID(从团队获取) - let workspace_uuid = if let Some(team_uuid) = team_uuid { - let team = crate::models::teams::fetch_team_by_uuid(&svc_ctx.db, team_uuid) - .await - .map_err(|e| e.to_string())? - .ok_or_else(|| "团队不存在".to_string())?; - team.workspace_uuid - } else { - return Err("未指定团队".to_string()); - }; - - let has_permission = crate::models::group_member_permissions::check_group_permission( - &svc_ctx.db, - workspace_uuid, - *group_uuid, - user_uuid, - required_permission, - ) - .await - .map_err(|e| e.to_string())?; - - if !has_permission { - let msg = if is_team_shared { - "权限不足:安装团队共享插件需要分组的管理权限" - } else { - "权限不足:安装个人插件需要分组的编辑权限" - }; - return Err(msg.to_string()); - } - - models::extensions::insert_group_extension( - &svc_ctx.db, - *group_uuid, - &payload.extension_id, - &extension.version, - user_uuid, - is_team_shared, - ) - .await - .map_err(|e| e.to_string())?; - } - } - _ => return Err("无效的安装目标类型".to_string()), - } - - // 添加审计日志 - let details = format!("安装扩展: {}", payload.extension_id); - let changes = serde_json::json!({ - "extension_id": payload.extension_id, - "version": extension.version, - "target_type": target_type, - "is_team_shared": payload.is_team_shared, - }); - - let _ = crate::models::audit::insert_audit_log( - &svc_ctx.db, - user_uuid, - team_uuid, - "install_extension", - target_type, - None, - Some(&payload.extension_id), - Some(&details), - Some(&changes), - None, - None, - None, - ) - .await; - - Ok(()) -} - -/// 卸载扩展 -pub async fn uninstall_extension_service( - svc_ctx: &SvcCtx, - user_uuid: Uuid, - team_uuid: Option, - payload: &UninstallExtensionRequest, -) -> Result<(), String> { - let target_type = payload.target_type.as_deref().unwrap_or("user"); - - match target_type { - "user" => { - models::extensions::delete_user_extension( - &svc_ctx.db, - user_uuid, - &payload.extension_id, - ) - .await - .map_err(|e| e.to_string())?; - } - "team" => { - // 权限检查:检查用户是否为 owner/admin - let team_uuid = team_uuid.ok_or_else(|| "未指定团队".to_string())?; - - // 获取工作空间 UUID - let team = crate::models::teams::fetch_team_by_uuid(&svc_ctx.db, team_uuid) - .await - .map_err(|e| e.to_string())? - .ok_or_else(|| "团队不存在".to_string())?; - let workspace_uuid = team.workspace_uuid; - - // 查询用户在团队中的角色 - let member = crate::models::teams::fetch_team_member( - &svc_ctx.db, - workspace_uuid, - team_uuid, - user_uuid, - ) - .await - .map_err(|e| e.to_string())? - .ok_or_else(|| "您不是该团队成员".to_string())?; - - // 检查角色权限 - if member.role != "owner" && member.role != "admin" { - return Err("权限不足:只有团队所有者或管理员可以卸载团队插件".to_string()); - } - - models::extensions::delete_team_extension( - &svc_ctx.db, - team_uuid, - &payload.extension_id, - ) - .await - .map_err(|e| e.to_string())?; - } - "group" => { - // 权限检查 - let group_uuid = payload.target_uuid.ok_or_else(|| "未指定分组".to_string())?; - - // 查询该分组扩展的 is_team_shared 状态 - let group_extensions = models::extensions::fetch_group_extensions(&svc_ctx.db, group_uuid) - .await - .map_err(|e| e.to_string())?; - - let group_ext = group_extensions - .iter() - .find(|ge| ge.extension_id == payload.extension_id) - .ok_or_else(|| "该分组未安装此扩展".to_string())?; - - // 根据 is_team_shared 检查权限 - let required_permission = if group_ext.is_team_shared { "manage" } else { "write" }; - - // 获取工作空间 UUID - let workspace_uuid = if let Some(team_uuid) = team_uuid { - let team = crate::models::teams::fetch_team_by_uuid(&svc_ctx.db, team_uuid) - .await - .map_err(|e| e.to_string())? - .ok_or_else(|| "团队不存在".to_string())?; - team.workspace_uuid - } else { - return Err("未指定团队".to_string()); - }; - - let has_permission = crate::models::group_member_permissions::check_group_permission( - &svc_ctx.db, - workspace_uuid, - group_uuid, - user_uuid, - required_permission, - ) - .await - .map_err(|e| e.to_string())?; - - if !has_permission { - let msg = if group_ext.is_team_shared { - "权限不足:卸载团队共享插件需要分组的管理权限" - } else { - "权限不足:卸载个人插件需要分组的编辑权限" - }; - return Err(msg.to_string()); - } - - models::extensions::delete_group_extension( - &svc_ctx.db, - group_uuid, - &payload.extension_id, - ) - .await - .map_err(|e| e.to_string())?; - } - _ => return Err("无效的卸载目标类型".to_string()), - } - - // 添加审计日志 - let details = format!("卸载扩展: {}", payload.extension_id); - let changes = serde_json::json!({ - "extension_id": payload.extension_id, - "target_type": target_type, - }); - - let _ = crate::models::audit::insert_audit_log( - &svc_ctx.db, - user_uuid, - team_uuid, - "uninstall_extension", - target_type, - payload.target_uuid, - Some(&payload.extension_id), - Some(&details), - Some(&changes), - None, - None, - None, - ) - .await; - - Ok(()) -} - -/// 更新扩展 -pub async fn update_extension_service( - svc_ctx: &SvcCtx, - user_uuid: Uuid, - extension_id: &str, -) -> Result<(), String> { - // 获取最新扩展信息 - let extension = models::extensions::fetch_extension_by_id(&svc_ctx.db, extension_id) - .await - .map_err(|e| e.to_string())? - .ok_or_else(|| "扩展不存在".to_string())?; - - // 更新用户扩展版本 - models::extensions::insert_user_extension( - &svc_ctx.db, - user_uuid, - extension_id, - &extension.version, - ) - .await - .map_err(|e| e.to_string())?; - - Ok(()) -} - -/// 批量更新扩展 -pub async fn batch_update_extensions_service( - svc_ctx: &SvcCtx, - user_uuid: Uuid, - extension_ids: &[String], -) -> Result { - let mut updated = 0u64; - - for extension_id in extension_ids { - if update_extension_service(svc_ctx, user_uuid, extension_id).await.is_ok() { - updated += 1; - } - } - - Ok(updated) -} - -/// 获取环境的扩展列表(动态合并 4 个层级) -/// -/// 合并逻辑: -/// 1. 用户个人全局插件 -/// 2. 团队全局插件 -/// 3. 该环境所属分组的个人插件 -/// 4. 该环境所属分组的团队插件 -pub async fn get_environment_extensions_service( - svc_ctx: &SvcCtx, - user_uuid: Uuid, - team_uuid: Uuid, - group_uuid: Option, -) -> Result, String> { - use std::collections::HashMap; - - let public_base_url = svc_ctx.config.storage.public_base_url.as_str(); - let extension_root = svc_ctx.config.storage.extension_root.as_str(); - - // 使用 HashMap 去重,key 为 extension_id - let mut extension_map: HashMap = HashMap::new(); - - // 0. 查询用户禁用的插件列表 - let disabled_extensions: std::collections::HashSet = sqlx::query_scalar::<_, String>( - r#" - SELECT extension_id FROM user_extensions - WHERE user_uuid = $1 AND status = 'disabled' - "#, - ) - .bind(user_uuid) - .fetch_all(&svc_ctx.db) - .await - .map_err(|e| e.to_string())? - .into_iter() - .collect(); - - // 1. 查询用户全局插件(排除 disabled 状态) - let user_extensions = models::extensions::fetch_user_extensions(&svc_ctx.db, user_uuid) - .await - .map_err(|e| e.to_string())?; - - for ue in user_extensions { - if let Ok(Some(ext)) = models::extensions::fetch_extension_by_id(&svc_ctx.db, &ue.extension_id).await { - let mut icon_url = ext.icon_url.clone(); - if let Some(path) = &icon_url { - if !path.is_empty() && !path.starts_with("http") { - icon_url = Some(crate::utils::storage::get_objects::get_extension_icon_url( - public_base_url, - extension_root, - path, - )); - } - } - - let mut download_url = ext.download_url.clone(); - if let Some(path) = &download_url { - if !path.is_empty() && !path.starts_with("http") { - download_url = Some(crate::utils::storage::get_objects::get_extension_crx_url( - public_base_url, - extension_root, - path, - )); - } - } - - extension_map.insert( - ue.extension_id.clone(), - crate::dto::environments::ExtensionSummaryDto { - extension_id: ue.extension_id, - name: ext.name, - version: ext.version, - icon_url, - download_url, - hash: ext.hash, - scope: "user".to_string(), - }, - ); - } - } - - // 2. 查询团队全局插件 - let team_extensions = models::extensions::fetch_team_extensions(&svc_ctx.db, team_uuid) - .await - .map_err(|e| e.to_string())?; - - for te in team_extensions { - // 跳过用户禁用的插件 - if disabled_extensions.contains(&te.extension_id) { - continue; - } - - if let Ok(Some(ext)) = models::extensions::fetch_extension_by_id(&svc_ctx.db, &te.extension_id).await { - let mut icon_url = ext.icon_url.clone(); - if let Some(path) = &icon_url { - if !path.is_empty() && !path.starts_with("http") { - icon_url = Some(crate::utils::storage::get_objects::get_extension_icon_url( - public_base_url, - extension_root, - path, - )); - } - } - - let mut download_url = ext.download_url.clone(); - if let Some(path) = &download_url { - if !path.is_empty() && !path.starts_with("http") { - download_url = Some(crate::utils::storage::get_objects::get_extension_crx_url( - public_base_url, - extension_root, - path, - )); - } - } - - extension_map.insert( - te.extension_id.clone(), - crate::dto::environments::ExtensionSummaryDto { - extension_id: te.extension_id, - name: ext.name, - version: ext.version, - icon_url, - download_url, - hash: ext.hash, - scope: "team".to_string(), - }, - ); - } - } - - // 3. 如果环境有分组,查询分组插件 - if let Some(group_uuid) = group_uuid { - let group_extensions = models::extensions::fetch_group_extensions(&svc_ctx.db, group_uuid) - .await - .map_err(|e| e.to_string())?; - - for ge in group_extensions { - // 跳过用户禁用的插件 - if disabled_extensions.contains(&ge.extension_id) { - continue; - } - - if let Ok(Some(ext)) = models::extensions::fetch_extension_by_id(&svc_ctx.db, &ge.extension_id).await { - let mut icon_url = ext.icon_url.clone(); - if let Some(path) = &icon_url { - if !path.is_empty() && !path.starts_with("http") { - icon_url = Some(crate::utils::storage::get_objects::get_extension_icon_url( - public_base_url, - extension_root, - path, - )); - } - } - - let mut download_url = ext.download_url.clone(); - if let Some(path) = &download_url { - if !path.is_empty() && !path.starts_with("http") { - download_url = Some(crate::utils::storage::get_objects::get_extension_crx_url( - public_base_url, - extension_root, - path, - )); - } - } - - let scope = if ge.is_team_shared { - "group-team".to_string() - } else { - "group-personal".to_string() - }; - - extension_map.insert( - ge.extension_id.clone(), - crate::dto::environments::ExtensionSummaryDto { - extension_id: ge.extension_id, - name: ext.name, - version: ext.version, - icon_url, - download_url, - hash: ext.hash, - scope, - }, - ); - } - } - } - - Ok(extension_map.into_values().collect()) -} - -/// 禁用扩展(用户级别) -/// -/// 用户可以禁用团队插件,通过在 user_team_extension_preferences 中设置 is_disabled = true -pub async fn disable_extension_service( - svc_ctx: &SvcCtx, - user_uuid: Uuid, - team_uuid: Uuid, - extension_id: &str, -) -> Result<(), String> { - // 检查扩展是否存在 - let _extension = models::extensions::fetch_extension_by_id(&svc_ctx.db, extension_id) - .await - .map_err(|e| e.to_string())? - .ok_or_else(|| "扩展不存在".to_string())?; - - // 设置用户对团队插件的禁用状态 - models::extensions::set_user_team_extension_preference( - &svc_ctx.db, - user_uuid, - team_uuid, - extension_id, - true, - ) - .await - .map_err(|e| e.to_string())?; - - Ok(()) -} - -/// 启用扩展(用户级别) -/// -/// 删除用户对团队插件的禁用设置 -pub async fn enable_extension_service( - svc_ctx: &SvcCtx, - user_uuid: Uuid, - team_uuid: Uuid, - extension_id: &str, -) -> Result<(), String> { - // 删除用户对团队插件的偏好设置(或设置为 false) - models::extensions::delete_user_team_extension_preference( - &svc_ctx.db, - user_uuid, - team_uuid, - extension_id, - ) - .await - .map_err(|e| e.to_string())?; - - Ok(()) -} diff --git a/src-tauri/crates/business/src/services/orders.rs b/src-tauri/crates/business/src/services/orders.rs deleted file mode 100644 index 49d44b4..0000000 --- a/src-tauri/crates/business/src/services/orders.rs +++ /dev/null @@ -1,83 +0,0 @@ -use uuid::Uuid; - -use crate::dto::PaymentOrderDto; -use crate::entitys::{CreateRechargeOrderRequest, ListPaymentOrdersRequest}; -use crate::models; -use crate::svc_ctx::SvcCtx; - -/// 创建充值订单 -pub async fn create_recharge_order_service( - svc_ctx: &SvcCtx, - user_uuid: Uuid, - payload: &CreateRechargeOrderRequest, -) -> Result<(Uuid, String), String> { - let order_no = format!("RCH-{}", chrono::Utc::now().format("%Y%m%d%H%M%S%f")); - - let order_uuid = models::billing::insert_payment_order( - &svc_ctx.db, - &order_no, - user_uuid, - "recharge", - payload.amount, - "CNY", - Some(&payload.payment_channel), - Some("钱包充值"), - None, - None, - None, - None, - ) - .await - .map_err(|e| e.to_string())?; - - Ok((order_uuid, order_no)) -} - -/// 获取支付订单列表 -pub async fn get_payment_orders_service( - svc_ctx: &SvcCtx, - user_uuid: Uuid, - payload: &ListPaymentOrdersRequest, -) -> Result<(Vec, i64), String> { - let offset = (payload.pagination.page - 1) * payload.pagination.page_size; - - let orders = models::billing::fetch_payment_orders( - &svc_ctx.db, - user_uuid, - payload.order_type.as_deref(), - payload.status.as_deref(), - offset, - payload.pagination.page_size, - ) - .await - .map_err(|e| e.to_string())?; - - let total = models::billing::fetch_payment_orders_count( - &svc_ctx.db, - user_uuid, - payload.order_type.as_deref(), - payload.status.as_deref(), - ) - .await - .map_err(|e| e.to_string())?; - - Ok((orders, total)) -} - -/// 查询订单状态 -pub async fn get_order_status_service( - svc_ctx: &SvcCtx, - user_uuid: Uuid, - order_uuid: Uuid, -) -> Result { - let order = models::billing::fetch_payment_order_by_uuid(&svc_ctx.db, order_uuid) - .await - .map_err(|e| e.to_string())? - .ok_or_else(|| "订单不存在".to_string())?; - - if order.user_uuid != user_uuid { - return Err("无权查看此订单".to_string()); - } - - Ok(order) -} diff --git a/src-tauri/crates/business/src/services/plans.rs b/src-tauri/crates/business/src/services/plans.rs deleted file mode 100644 index 0a1b5de..0000000 --- a/src-tauri/crates/business/src/services/plans.rs +++ /dev/null @@ -1,152 +0,0 @@ -use rust_decimal::Decimal; -use uuid::Uuid; - -use crate::dto::{CouponDto, PlanDto, PlanFeatureDto}; -use crate::entitys::{GetPlanPriceRequest, PlanPriceResponse, VerifyCouponRequest}; -use crate::models; -use crate::services::coupons::validate_coupon_service; -use crate::svc_ctx::SvcCtx; - -/// 带特性的套餐结构 -#[derive(Debug, Clone)] -pub struct PlanWithFeatures { - pub plan: PlanDto, - pub features: Vec, - pub calculated_price: Option, -} - -/// 获取套餐列表(包含特性) -pub async fn get_plans_service( - svc_ctx: &SvcCtx, - user_uuid: Uuid, - coupon_code: Option<&str>, - billing_period: &str, -) -> Result, String> { - let plans = models::billing::fetch_plans(&svc_ctx.db).await.map_err(|e| e.to_string())?; - - let mut plans_with_features = Vec::new(); - - for plan in plans { - let features = models::billing::fetch_plan_features(&svc_ctx.db, plan.uuid) - .await - .map_err(|e| e.to_string())?; - - // 如果提供了优惠券代码,计算价格 - let calculated_price = if let Some(code) = coupon_code { - let price_result = calculate_plan_price_service( - svc_ctx, - user_uuid, - &GetPlanPriceRequest { - plan_uuid: plan.uuid, - billing_period: billing_period.to_string(), - coupon_code: Some(code.to_string()), - }, - ) - .await - .ok(); // 如果计算失败,返回 None,不影响套餐列表返回 - - price_result.map(|price| crate::entitys::PlanPriceInfo { - original_price: price.original_price, - plan_discount: price.plan_discount, - coupon_discount: price.coupon_discount, - final_price: price.final_price, - total_saved: price.total_saved, - billing_period: billing_period.to_string(), - }) - } else { - None - }; - - plans_with_features.push(PlanWithFeatures { - plan, - features, - calculated_price, - }); - } - - Ok(plans_with_features) -} - -/// 获取套餐详情(包含特性) -pub async fn get_plan_detail_service( - svc_ctx: &SvcCtx, - plan_uuid: Uuid, -) -> Result<(PlanDto, Vec), String> { - let plan = models::billing::fetch_plan_by_uuid(&svc_ctx.db, plan_uuid) - .await - .map_err(|e| e.to_string())? - .ok_or_else(|| "套餐不存在".to_string())?; - - let features = models::billing::fetch_plan_features(&svc_ctx.db, plan_uuid) - .await - .map_err(|e| e.to_string())?; - - Ok((plan, features)) -} - -/// 计算套餐最终价格 -pub async fn calculate_plan_price_service( - svc_ctx: &SvcCtx, - user_uuid: Uuid, - payload: &GetPlanPriceRequest, -) -> Result { - // 1. 获取套餐信息 - let plan = models::billing::fetch_plan_by_uuid(&svc_ctx.db, payload.plan_uuid) - .await - .map_err(|e| e.to_string())? - .ok_or_else(|| "套餐不存在".to_string())?; - - if plan.status != "active" { - return Err("该套餐已下架".to_string()); - } - - // 2. 计算基础价格(考虑套餐级折扣) - let (base_price, plan_discount_percent) = match payload.billing_period.as_str() { - "monthly" => ( - plan.price_per_month, - plan.discount_monthly.unwrap_or(Decimal::ZERO), - ), - "yearly" => ( - plan.price_per_year, - plan.discount_yearly.unwrap_or(Decimal::ZERO), - ), - _ => return Err("无效的计费周期".to_string()), - }; - - // 3. 应用套餐级折扣 - let price_after_plan_discount = - base_price * (Decimal::from(100) - plan_discount_percent) / Decimal::from(100); - let plan_discount = base_price - price_after_plan_discount; - - // 4. 验证并应用优惠券(如果有) - let mut coupon_discount = Decimal::ZERO; - let mut coupon_info: Option = None; - - if let Some(coupon_code) = &payload.coupon_code { - let coupon_result = validate_coupon_service( - svc_ctx, - user_uuid, - &VerifyCouponRequest { - code: coupon_code.clone(), - amount: price_after_plan_discount, // 在套餐折扣后的价格上应用优惠券 - }, - ) - .await?; - - coupon_discount = coupon_result.discount_amount; - coupon_info = Some(coupon_result.coupon); - } - - // 5. 计算最终价格 - let final_price = price_after_plan_discount - coupon_discount; - let total_saved = plan_discount + coupon_discount; - - Ok(PlanPriceResponse { - original_price: base_price, - plan_discount, - coupon_discount, - final_price: final_price.max(Decimal::ZERO), // 确保价格不为负 - total_saved, - coupon_info, - }) -} diff --git a/src-tauri/crates/business/src/services/referral.rs b/src-tauri/crates/business/src/services/referral.rs deleted file mode 100644 index ac304af..0000000 --- a/src-tauri/crates/business/src/services/referral.rs +++ /dev/null @@ -1,420 +0,0 @@ -use rust_decimal::prelude::ToPrimitive; -use rust_decimal::Decimal; -use uuid::Uuid; - -use crate::dto::{ - RedeemOptionDto, RedeemRecordDto, ReferralLinkDto, ReferralLinkTierDto, ReferralRewardDto, - ReferredUserItemDto, UserReferralPointsDto, -}; -use crate::entitys::{ - ListReferralRewardsRequest, ListReferredUsersRequest, Pagination, RedeemPointsRequest, - ReferralDashboardResponse, ReferralPointsSummary, ReferralStatsResponse, -}; -use crate::models; -use crate::svc_ctx::SvcCtx; -use crate::services::subscriptions::get_current_subscription_service; - -/// 获取推广统计 -pub async fn get_referral_stats_service( - svc_ctx: &SvcCtx, - user_uuid: Uuid, -) -> Result { - // 获取基本统计数据 - let (total_referrals, paid_referrals, total_consumption, last_30_days_consumption) = - models::referral::fetch_referral_stats(&svc_ctx.db, user_uuid) - .await - .map_err(|e| e.to_string())?; - - // 获取总奖励积分 - let total_rewards = models::referral::fetch_total_reward_points(&svc_ctx.db, user_uuid) - .await - .map_err(|e| e.to_string())?; - - // 获取用户积分 - let points = models::referral::fetch_user_points(&svc_ctx.db, user_uuid) - .await - .map_err(|e| e.to_string())?; - let available_points = points.map(|p| p.available_points).unwrap_or(0); - - // 获取当前层级和下一层级 - let current_tier = models::referral::fetch_tier_by_threshold(&svc_ctx.db, paid_referrals) - .await - .map_err(|e| e.to_string())?; - - let current_threshold = current_tier.as_ref().map(|t| t.unlock_threshold).unwrap_or(0); - let next_tier = models::referral::fetch_next_tier(&svc_ctx.db, current_threshold) - .await - .map_err(|e| e.to_string())?; - - // 计算升级进度 - let upgrade_progress = if let Some(ref next) = next_tier { - let progress = (paid_referrals - current_threshold) as f64 - / (next.unlock_threshold - current_threshold) as f64 - * 100.0; - progress as i32 - } else { - 100 - }; - - Ok(ReferralStatsResponse { - total_referrals, - paid_referrals, - total_consumption, - last_30_days_consumption, - total_rewards, - available_points, - current_tier, - next_tier, - upgrade_progress, - }) -} - -/// 获取推广链接层级配置 -pub async fn get_referral_tiers_service( - svc_ctx: &SvcCtx, -) -> Result, String> { - models::referral::fetch_referral_tiers(&svc_ctx.db) - .await - .map_err(|e| e.to_string()) -} - -/// 获取推广链接列表 -pub async fn get_referral_links_service( - svc_ctx: &SvcCtx, - user_uuid: Uuid, -) -> Result<(Vec, Option), String> { - // 根据当前统计数据计算已解锁层级,并同步更新该用户所有推广链接的 unlocked 状态 - // - // 解锁规则: - // - 对于 unlock_threshold <= paid_referrals 的层级视为已解锁 - // - 这些层级对应的推广链接(按 tier_uuid 关联)统一标记为 unlocked = TRUE - // - 其余链接统一标记为 unlocked = FALSE - let (_total_referrals, paid_referrals, _total_consumption, _last_30_days_consumption) = - models::referral::fetch_referral_stats(&svc_ctx.db, user_uuid) - .await - .map_err(|e| e.to_string())?; - - let tiers = models::referral::fetch_referral_tiers(&svc_ctx.db) - .await - .map_err(|e| e.to_string())?; - - let unlocked_tier_uuids: Vec = tiers - .into_iter() - .filter(|t| t.unlock_threshold <= paid_referrals) - .map(|t| t.uuid) - .collect(); - - models::referral::update_referral_links_unlock_status_for_user( - &svc_ctx.db, - user_uuid, - &unlocked_tier_uuids, - ) - .await - .map_err(|e| e.to_string())?; - - let mut links = models::referral::fetch_user_referral_links(&svc_ctx.db, user_uuid) - .await - .map_err(|e| e.to_string())?; - - let mut current_link = models::referral::fetch_current_referral_link(&svc_ctx.db, user_uuid) - .await - .map_err(|e| e.to_string())?; - - // 如果配置了推广链接前缀,则在返回前为每个链接拼接完整 URL - if let Some(prefix) = svc_ctx.config.app.referral_link_prefix.as_deref() { - let prefix = prefix.trim_end_matches('/'); - - for link in &mut links { - link.url = Some(format!("{prefix}?referral_code={}", link.code)); - } - - if let Some(ref mut cl) = current_link { - cl.url = Some(format!("{prefix}?referral_code={}", cl.code)); - } - } - - Ok((links, current_link)) -} - -/// 获取推广看板聚合数据 -pub async fn get_referral_dashboard_service( - svc_ctx: &SvcCtx, - user_uuid: Uuid, -) -> Result { - // 基础统计(含 available_points 和 total_rewards) - let stats = get_referral_stats_service(svc_ctx, user_uuid).await?; - - // 推广链接及当前链接 - let (links, current_link) = get_referral_links_service(svc_ctx, user_uuid).await?; - - // 层级配置 - let tiers = get_referral_tiers_service(svc_ctx).await?; - - // 读取 pending_points,用于前端展示“审核中”积分 - let pending_points = models::referral::fetch_user_points(&svc_ctx.db, user_uuid) - .await - .ok() - .flatten() - .map(|p| p.pending_points) - .unwrap_or(0); - - let points = ReferralPointsSummary { - available_points: stats.available_points, - pending_points, - total_rewards: stats.total_rewards, - }; - - Ok(ReferralDashboardResponse { - stats, - links, - current_link, - tiers, - points, - }) -} - -/// 计算最近 30 天推广带来的预估收益,以及与当前套餐价格的覆盖比例 -pub async fn get_referral_plan_summary_service( - svc_ctx: &SvcCtx, - user_uuid: Uuid, -) -> Result { - // 基于推广统计获取最近 30 天消费金额和当前层级 - let stats = get_referral_stats_service(svc_ctx, user_uuid).await?; - - let last_30_days_consumption = stats.last_30_days_consumption; - - // 使用当前层级的 reward_rate 估算可得收益(如果没有当前层级,则按 0 估算) - let reward_rate = stats - .current_tier - .as_ref() - .map(|tier| tier.reward_rate) - .unwrap_or(Decimal::ZERO); - - // reward_rate 以百分比存储,例如 10 表示 10% - let referral_value_last_30_days = - last_30_days_consumption * reward_rate / Decimal::from(100u32); - - // 获取当前订阅,估算月度套餐价格 - let current_subscription = - get_current_subscription_service(svc_ctx, user_uuid).await.unwrap_or(None); - - let current_plan_monthly_price = current_subscription.as_ref().map(|sub| sub.price); - - // 计算覆盖比例 - let coverage_ratio = current_plan_monthly_price.map(|price| { - if price.is_zero() { - Decimal::ZERO - } else { - (referral_value_last_30_days / price).max(Decimal::ZERO) - } - }); - - Ok(crate::entitys::ReferralPlanSummaryResponse { - referral_value_last_30_days, - current_plan_monthly_price, - coverage_ratio, - }) -} - -/// 切换当前推广链接 -pub async fn switch_referral_link_service( - svc_ctx: &SvcCtx, - user_uuid: Uuid, - link_uuid: Uuid, -) -> Result<(), String> { - models::referral::switch_current_referral_link(&svc_ctx.db, user_uuid, link_uuid) - .await - .map_err(|e| e.to_string()) -} - -/// 获取奖励记录列表 -pub async fn get_referral_rewards_service( - svc_ctx: &SvcCtx, - user_uuid: Uuid, - payload: &ListReferralRewardsRequest, -) -> Result<(Vec, i64), String> { - let offset = (payload.pagination.page - 1) * payload.pagination.page_size; - - let rewards = models::referral::fetch_referral_rewards( - &svc_ctx.db, - user_uuid, - payload.keyword.as_deref(), - payload.reward_type.as_deref(), - payload.status.as_deref(), - offset, - payload.pagination.page_size, - ) - .await - .map_err(|e| e.to_string())?; - - let total = models::referral::fetch_referral_rewards_count( - &svc_ctx.db, - user_uuid, - payload.keyword.as_deref(), - payload.reward_type.as_deref(), - payload.status.as_deref(), - ) - .await - .map_err(|e| e.to_string())?; - - Ok((rewards, total)) -} - -/// 获取被邀请用户列表 -pub async fn get_referred_users_service( - svc_ctx: &SvcCtx, - user_uuid: Uuid, - payload: &ListReferredUsersRequest, -) -> Result<(Vec, i64), String> { - let offset = (payload.pagination.page - 1) * payload.pagination.page_size; - - let rows = models::referral::fetch_referred_users( - &svc_ctx.db, - user_uuid, - payload.keyword.as_deref(), - payload.status.as_deref(), - offset, - payload.pagination.page_size, - ) - .await - .map_err(|e| e.to_string())?; - - let users: Vec = rows - .into_iter() - .map(|r| ReferredUserItemDto { - id: r.id.to_string(), - email: r.email, - registered_at: r.registered_at, - status: r.status, - total_consumption: r.total_consumption.and_then(|d| d.to_f64()).unwrap_or(0.0), - last_30_days_consumption: r - .last_30_days_consumption - .and_then(|d| d.to_f64()) - .unwrap_or(0.0), - link_id: r.link_uuid.map(|u| u.to_string()).unwrap_or_default(), - }) - .collect(); - - let total = models::referral::fetch_referred_users_count( - &svc_ctx.db, - user_uuid, - payload.keyword.as_deref(), - payload.status.as_deref(), - ) - .await - .map_err(|e| e.to_string())?; - - Ok((users, total)) -} - -/// 获取用户积分 -pub async fn get_user_points_service( - svc_ctx: &SvcCtx, - user_uuid: Uuid, -) -> Result { - let points = models::referral::fetch_user_points(&svc_ctx.db, user_uuid) - .await - .map_err(|e| e.to_string())?; - - // 如果不存在则创建 - if points.is_none() { - models::referral::upsert_user_points(&svc_ctx.db, user_uuid, 0, 0, 0, 0) - .await - .map_err(|e| e.to_string())?; - - return models::referral::fetch_user_points(&svc_ctx.db, user_uuid) - .await - .map_err(|e| e.to_string())? - .ok_or_else(|| "创建积分记录失败".to_string()); - } - - points.ok_or_else(|| "积分记录不存在".to_string()) -} - -/// 获取兑换选项 -pub async fn get_redeem_options_service(svc_ctx: &SvcCtx) -> Result, String> { - models::referral::fetch_redeem_options(&svc_ctx.db) - .await - .map_err(|e| e.to_string()) -} - -/// 执行积分兑换 -pub async fn redeem_points_service( - svc_ctx: &SvcCtx, - user_uuid: Uuid, - payload: &RedeemPointsRequest, -) -> Result<(Uuid, i32, Decimal), String> { - // 获取兑换选项 - let option = models::referral::fetch_redeem_option_by_uuid(&svc_ctx.db, payload.option_uuid) - .await - .map_err(|e| e.to_string())? - .ok_or_else(|| "兑换选项不存在".to_string())?; - - if option.status != "active" { - return Err("该兑换选项已下架".to_string()); - } - - // 检查积分是否满足最低要求 - if payload.points < option.points_required { - return Err(format!( - "积分不足,最低需要 {} 积分", - option.points_required - )); - } - - // 获取用户积分 - let points = models::referral::fetch_user_points(&svc_ctx.db, user_uuid) - .await - .map_err(|e| e.to_string())? - .ok_or_else(|| "积分记录不存在".to_string())?; - - if points.available_points < payload.points { - return Err("可用积分不足".to_string()); - } - - // 计算兑换价值 - let value = Decimal::from(payload.points) / Decimal::from(option.exchange_rate); - - // 扣减积分 - models::referral::deduct_user_points(&svc_ctx.db, user_uuid, payload.points) - .await - .map_err(|e| e.to_string())?; - - // 创建兑换记录 - let record_uuid = models::referral::insert_redeem_record( - &svc_ctx.db, - user_uuid, - payload.option_uuid, - payload.points, - value, - option.currency.as_deref(), - ) - .await - .map_err(|e| e.to_string())?; - - Ok((record_uuid, payload.points, value)) -} - -/// 获取兑换记录 -pub async fn get_redeem_records_service( - svc_ctx: &SvcCtx, - user_uuid: Uuid, - pagination: &Pagination, -) -> Result<(Vec, i64), String> { - let offset = (pagination.page - 1) * pagination.page_size; - - let records = models::referral::fetch_redeem_records( - &svc_ctx.db, - user_uuid, - offset, - pagination.page_size, - ) - .await - .map_err(|e| e.to_string())?; - - let total = models::referral::fetch_redeem_records_count(&svc_ctx.db, user_uuid) - .await - .map_err(|e| e.to_string())?; - - Ok((records, total)) -} diff --git a/src-tauri/crates/business/src/services/subscriptions.rs b/src-tauri/crates/business/src/services/subscriptions.rs deleted file mode 100644 index 725659e..0000000 --- a/src-tauri/crates/business/src/services/subscriptions.rs +++ /dev/null @@ -1,275 +0,0 @@ -use chrono::Utc; -use rust_decimal::Decimal; -use uuid::Uuid; - -use crate::dto::SubscriptionDto; -use crate::entitys::{SubscribePlanRequest, VerifyCouponRequest}; -use crate::models; -use crate::svc_ctx::SvcCtx; - -use super::coupons::validate_coupon_service; -use super::wallet::deduct_wallet_service; -use crate::models::workspace_quotas; - -/// 获取当前订阅(按用户) -pub async fn get_current_subscription_service( - svc_ctx: &SvcCtx, - user_uuid: Uuid, -) -> Result, String> { - models::billing::fetch_active_subscription(&svc_ctx.db, user_uuid) - .await - .map_err(|e| e.to_string()) -} - -/// 获取工作空间当前订阅 -pub async fn get_workspace_subscription_service( - svc_ctx: &SvcCtx, - workspace_uuid: Uuid, -) -> Result, String> { - models::billing::fetch_workspace_active_subscription(&svc_ctx.db, workspace_uuid) - .await - .map_err(|e| e.to_string()) -} - -/// 订阅套餐 -pub async fn subscribe_plan_service( - svc_ctx: &SvcCtx, - workspace_uuid: Uuid, - user_uuid: Uuid, - payload: &SubscribePlanRequest, -) -> Result { - // 1. 获取套餐信息 - let plan = models::billing::fetch_plan_by_uuid(&svc_ctx.db, payload.plan_uuid) - .await - .map_err(|e| e.to_string())? - .ok_or_else(|| "套餐不存在".to_string())?; - - if plan.status != "active" { - return Err("该套餐已下架".to_string()); - } - - // 2. 计算基础价格和套餐折扣 - let (base_price, plan_discount_percent, duration_days) = match payload.billing_period.as_str() { - "monthly" => ( - plan.price_per_month, - plan.discount_monthly.unwrap_or(Decimal::ZERO), - 30, - ), - "yearly" => ( - plan.price_per_year, - plan.discount_yearly.unwrap_or(Decimal::ZERO), - 365, - ), - _ => return Err("无效的计费周期".to_string()), - }; - - // 应用套餐级折扣 - let price_after_plan_discount = - base_price * (Decimal::from(100) - plan_discount_percent) / Decimal::from(100); - - // 3. 验证优惠券(如果有) - let mut final_price = price_after_plan_discount; - let mut coupon_uuid: Option = None; - let mut coupon_discount_amount = Decimal::ZERO; - - if let Some(coupon_code) = &payload.coupon_code { - let coupon_result = validate_coupon_service( - svc_ctx, - user_uuid, - &VerifyCouponRequest { - code: coupon_code.clone(), - amount: price_after_plan_discount, // 在套餐折扣后的价格上应用优惠券 - }, - ) - .await?; - - coupon_uuid = Some(coupon_result.coupon.uuid); - coupon_discount_amount = coupon_result.discount_amount; - final_price = price_after_plan_discount - coupon_discount_amount; - } - - // 4. 检查支付方式并验证钱包余额(仅钱包支付需要检查) - let payment_method = payload.payment_method.as_deref().unwrap_or("wallet"); - let is_wallet_payment = payment_method == "wallet"; - - if is_wallet_payment { - let wallet = models::billing::fetch_user_wallet(&svc_ctx.db, user_uuid) - .await - .map_err(|e| e.to_string())?; - - let wallet_balance = wallet.map(|w| w.balance).unwrap_or(Decimal::ZERO); - if wallet_balance < final_price { - return Err("钱包余额不足,请先充值".to_string()); - } - } - - // 5. 取消工作空间现有订阅 - if let Some(existing) = models::billing::fetch_workspace_active_subscription(&svc_ctx.db, workspace_uuid) - .await - .map_err(|e| e.to_string())? - { - models::billing::cancel_subscription(&svc_ctx.db, existing.uuid) - .await - .map_err(|e| e.to_string())?; - } - - // 6. 计算到期时间 - let expires_at = Utc::now() + chrono::Duration::days(duration_days); - let next_billing_date = expires_at.date_naive(); - - // 7. 创建订阅 - let subscription_uuid = models::billing::insert_subscription( - &svc_ctx.db, - workspace_uuid, - user_uuid, - payload.plan_uuid, - &payload.billing_period, - final_price, - &plan.currency, - expires_at, - next_billing_date, - ) - .await - .map_err(|e| e.to_string())?; - - // 7.1. 记录优惠券使用(如果有) - if let Some(coupon_uuid_val) = coupon_uuid { - // 记录优惠券使用 - models::billing::insert_coupon_usage( - &svc_ctx.db, - coupon_uuid_val, - user_uuid, - Some(subscription_uuid), - coupon_discount_amount, - ) - .await - .map_err(|e| e.to_string())?; - - // 更新用户优惠券状态为 used(如果是从 user_coupons 表发放的) - models::billing::update_user_coupon_status( - &svc_ctx.db, - user_uuid, - coupon_uuid_val, - "used", - Some(Utc::now()), - ) - .await - .map_err(|e| e.to_string())?; - } - - // 8. 扣减钱包余额(仅钱包支付需要扣减) - if is_wallet_payment { - deduct_wallet_service( - svc_ctx, - user_uuid, - final_price, - "订阅套餐", - Some(subscription_uuid), - ) - .await?; - } - - // 9. 更新工作空间配额 - workspace_quotas::insert_or_update_workspace_quota( - &svc_ctx.db, - workspace_uuid, - plan.max_environments, - plan.max_team_members, - plan.max_proxies, - plan.max_rpa_tasks, - ) - .await - .map_err(|e| e.to_string())?; - - // 10. 创建发票 - let invoice_number = format!("INV-{}", chrono::Utc::now().format("%Y%m%d%H%M%S")); - models::billing::insert_invoice( - &svc_ctx.db, - user_uuid, - &invoice_number, - final_price, - &plan.currency, - Some(subscription_uuid), - None, - "subscription", - ) - .await - .map_err(|e| e.to_string())?; - - Ok(subscription_uuid) -} - -/// 取消订阅 -pub async fn cancel_subscription_service( - svc_ctx: &SvcCtx, - user_uuid: Uuid, - subscription_uuid: Uuid, -) -> Result<(), String> { - // 验证订阅属于当前用户 - let subscription = models::billing::fetch_subscription_by_uuid(&svc_ctx.db, subscription_uuid) - .await - .map_err(|e| e.to_string())? - .ok_or_else(|| "订阅不存在".to_string())?; - - if subscription.user_uuid != user_uuid { - return Err("无权操作此订阅".to_string()); - } - - if subscription.status == "cancelled" { - return Err("订阅已取消".to_string()); - } - - models::billing::cancel_subscription(&svc_ctx.db, subscription_uuid) - .await - .map_err(|e| e.to_string()) -} - -/// 恢复订阅 -pub async fn resume_subscription_service( - svc_ctx: &SvcCtx, - user_uuid: Uuid, - subscription_uuid: Uuid, -) -> Result<(), String> { - let subscription = models::billing::fetch_subscription_by_uuid(&svc_ctx.db, subscription_uuid) - .await - .map_err(|e| e.to_string())? - .ok_or_else(|| "订阅不存在".to_string())?; - - if subscription.user_uuid != user_uuid { - return Err("无权操作此订阅".to_string()); - } - - if subscription.status != "cancelled" { - return Err("只能恢复已取消的订阅".to_string()); - } - - // 检查是否过期 - if subscription.expires_at < Utc::now() { - return Err("订阅已过期,请重新订阅".to_string()); - } - - models::billing::resume_subscription(&svc_ctx.db, subscription_uuid) - .await - .map_err(|e| e.to_string()) -} - -/// 切换自动续费 -pub async fn toggle_auto_renew_service( - svc_ctx: &SvcCtx, - user_uuid: Uuid, - subscription_uuid: Uuid, - auto_renew: bool, -) -> Result<(), String> { - let subscription = models::billing::fetch_subscription_by_uuid(&svc_ctx.db, subscription_uuid) - .await - .map_err(|e| e.to_string())? - .ok_or_else(|| "订阅不存在".to_string())?; - - if subscription.user_uuid != user_uuid { - return Err("无权操作此订阅".to_string()); - } - - models::billing::toggle_auto_renew(&svc_ctx.db, subscription_uuid, auto_renew) - .await - .map_err(|e| e.to_string()) -} diff --git a/src-tauri/crates/business/src/services/users.rs b/src-tauri/crates/business/src/services/users.rs index f4c11cd..b596ce5 100644 --- a/src-tauri/crates/business/src/services/users.rs +++ b/src-tauri/crates/business/src/services/users.rs @@ -9,9 +9,8 @@ use crate::entitys::{ UpdatePasswordRequest, UpdateUserRequest, UserResponse, VerifyPasswordRequest, VerifyPasswordResponse, }; -use crate::models::{ - billing, - user::{ +use crate::models::{ + user::{ create_user_with_info, fetch_user_by_uuid, fetch_user_info_by_email, fetch_user_info_by_uuid, update_password, update_user_info, }, @@ -88,10 +87,7 @@ pub async fn register_service( let quota = &svc_ctx.config.workspace_quota.default; let user_uuid = create_user_with_info(pool, user_id, payload, &password_hash, quota).await?; - // 5.1. 自动发放新用户优惠券(失败不影响注册流程) - let _ = issue_welcome_coupons(svc_ctx, user_uuid).await; - - // 6. 执行通用登录逻辑 + // 6. 执行通用登录逻辑 let (access_token, refresh_token, user_info) = common_login_logic(svc_ctx, user_uuid, payload.public_secret_key.as_ref()).await?; @@ -121,31 +117,6 @@ pub async fn register_service( }) } -/// 自动发放新用户优惠券 -/// -/// 在用户注册时自动发放欢迎优惠券,失败不影响注册流程 -async fn issue_welcome_coupons(svc_ctx: &SvcCtx, user_uuid: Uuid) -> Result<(), anyhow::Error> { - // 新用户优惠券代码列表(可根据需要配置) - let welcome_coupon_codes = vec!["WELCOME10", "FIRST10"]; - - for coupon_code in welcome_coupon_codes { - // 查询优惠券 - if let Some(coupon) = billing::fetch_coupon_by_code(&svc_ctx.db, coupon_code).await? { - // 发放优惠券给用户(继承优惠券的过期时间) - let _ = billing::insert_user_coupon( - &svc_ctx.db, - user_uuid, - coupon.uuid, - coupon.valid_until, - ) - .await; - // 注意:如果用户已拥有该优惠券(重复发放),忽略错误 - } - } - - Ok(()) -} - /// 用户登录服务(统一处理两种登录方式) /// /// # Arguments diff --git a/src-tauri/crates/business/src/services/wallet.rs b/src-tauri/crates/business/src/services/wallet.rs deleted file mode 100644 index b7c4fa7..0000000 --- a/src-tauri/crates/business/src/services/wallet.rs +++ /dev/null @@ -1,103 +0,0 @@ -use rust_decimal::Decimal; -use uuid::Uuid; - -use crate::dto::{UserWalletDto, WalletTransactionDto}; -use crate::entitys::ListTransactionsRequest; -use crate::models; -use crate::svc_ctx::SvcCtx; - -/// 获取钱包信息 -pub async fn get_wallet_service( - svc_ctx: &SvcCtx, - user_uuid: Uuid, -) -> Result { - let wallet = models::billing::fetch_user_wallet(&svc_ctx.db, user_uuid) - .await - .map_err(|e| e.to_string())?; - - // 如果钱包不存在,创建一个 - if wallet.is_none() { - models::billing::insert_user_wallet(&svc_ctx.db, user_uuid, "CNY") - .await - .map_err(|e| e.to_string())?; - - return models::billing::fetch_user_wallet(&svc_ctx.db, user_uuid) - .await - .map_err(|e| e.to_string())? - .ok_or_else(|| "创建钱包失败".to_string()); - } - - wallet.ok_or_else(|| "钱包不存在".to_string()) -} - -/// 扣减钱包余额 -pub async fn deduct_wallet_service( - svc_ctx: &SvcCtx, - user_uuid: Uuid, - amount: Decimal, - description: &str, - order_uuid: Option, -) -> Result<(), String> { - let wallet = models::billing::fetch_user_wallet(&svc_ctx.db, user_uuid) - .await - .map_err(|e| e.to_string())? - .ok_or_else(|| "钱包不存在".to_string())?; - - let balance_before = wallet.balance; - let balance_after = balance_before - amount; - - if balance_after < Decimal::ZERO { - return Err("余额不足".to_string()); - } - - // 扣减余额 - models::billing::update_wallet_balance(&svc_ctx.db, user_uuid, -amount) - .await - .map_err(|e| e.to_string())?; - - // 记录交易 - models::billing::insert_wallet_transaction( - &svc_ctx.db, - user_uuid, - "debit", - -amount, - &wallet.currency, - balance_before, - balance_after, - Some(description), - order_uuid, - ) - .await - .map_err(|e| e.to_string())?; - - Ok(()) -} - -/// 获取交易记录 -pub async fn get_transactions_service( - svc_ctx: &SvcCtx, - user_uuid: Uuid, - payload: &ListTransactionsRequest, -) -> Result<(Vec, i64), String> { - let offset = (payload.pagination.page - 1) * payload.pagination.page_size; - - let transactions = models::billing::fetch_wallet_transactions( - &svc_ctx.db, - user_uuid, - payload.transaction_type.as_deref(), - offset, - payload.pagination.page_size, - ) - .await - .map_err(|e| e.to_string())?; - - let total = models::billing::fetch_wallet_transactions_count( - &svc_ctx.db, - user_uuid, - payload.transaction_type.as_deref(), - ) - .await - .map_err(|e| e.to_string())?; - - Ok((transactions, total)) -} From 4b356b683c551dfa62999b2d75828133f84f907e Mon Sep 17 00:00:00 2001 From: lius-new Date: Wed, 12 Aug 2026 12:45:04 +0800 Subject: [PATCH 55/60] refactor(business): complete local-first migration --- src-tauri/Cargo.lock | 138 - src-tauri/Cargo.toml | 3 - src-tauri/crates/business/Cargo.toml | 8 - .../20250101000001_create_users.sql | 44 +- .../20250101000002_create_user_infos.sql | 56 +- ...50101000003_create_machine_users_table.sql | 41 - ...50101000004_create_version_types_table.sql | 26 - .../20250101000005_create_versions_table.sql | 53 - ...50101000006_create_gray_releases_table.sql | 44 - ...0101000007_create_gray_resources_table.sql | 24 - ..._create_machine_gray_allocations_table.sql | 33 - ...250101000009_create_maintenances_table.sql | 33 - ...0101000010_create_strategy_types_table.sql | 31 - ...50101000011_insert_strategy_types_data.sql | 63 - .../20250120000001_create_teams.sql | 69 +- .../20250120000002_create_team_members.sql | 75 +- ...20250120000003_create_team_invitations.sql | 71 +- .../20250120000004_create_login_history.sql | 29 - ...0005_alter_user_infos_add_current_team.sql | 32 +- .../20250120000006_create_groups.sql | 74 +- .../migrations/20250120000007_create_tags.sql | 59 +- .../20250120000008_create_proxies.sql | 94 +- ...50120000009_create_proxy_health_checks.sql | 45 +- .../20250120000010_create_environments.sql | 93 +- ...50120000011_create_environment_configs.sql | 63 +- ...20250120000012_create_environment_tags.sql | 41 +- ...20250120000013_create_environment_urls.sql | 35 +- ...50120000014_create_environment_cookies.sql | 47 +- .../20250120000015_create_templates.sql | 73 +- ...0250120000016_create_platform_accounts.sql | 77 +- ...0120000017_create_environment_accounts.sql | 43 +- .../20250120000018_create_extensions.sql | 48 - .../20250120000019_create_user_extensions.sql | 28 - .../20250120000020_create_team_extensions.sql | 31 - ...20250120000021_create_group_extensions.sql | 31 - ...20000022_create_environment_extensions.sql | 28 - .../20250120000023_create_plans.sql | 40 - .../20250120000024_create_plan_features.sql | 25 - .../20250120000025_create_subscriptions.sql | 40 - .../20250120000026_create_user_wallets.sql | 27 - ...50120000027_create_wallet_transactions.sql | 33 - .../20250120000028_create_invoices.sql | 44 - .../20250120000029_create_user_quotas.sql | 31 - .../20250120000030_create_coupons.sql | 42 - .../20250120000031_create_coupon_usages.sql | 21 - .../20250120000032_create_payment_orders.sql | 50 - ...120000033_create_auto_renewal_services.sql | 36 - .../20250120000034_create_rpa_tasks.sql | 115 +- .../20250120000035_create_rpa_task_steps.sql | 75 +- ...120000036_create_rpa_task_environments.sql | 39 +- .../20250120000037_create_rpa_task_runs.sql | 63 +- ...50120000038_create_referral_link_tiers.sql | 31 - .../20250120000039_create_referral_links.sql | 42 - .../20250120000040_create_user_referrals.sql | 32 - ...20250120000041_create_referral_rewards.sql | 33 - ...0120000042_create_user_referral_points.sql | 28 - .../20250120000043_create_redeem_options.sql | 36 - .../20250120000044_create_redeem_records.sql | 29 - .../20250120000045_create_audit_logs.sql | 73 +- .../20250120000046_create_api_keys.sql | 43 - ...20250120000047_create_user_preferences.sql | 49 +- .../20250120000048_create_system_configs.sql | 23 - .../20250120000049_add_foreign_keys.sql | 40 +- .../20250120000050_insert_default_data.sql | 37 - ...120000051_remove_icon_and_color_fields.sql | 19 +- .../20250123000001_create_console_admins.sql | 28 - ...50123000002_create_console_permissions.sql | 51 - ...20250123000003_create_console_api_keys.sql | 41 - .../20250124000001_create_messages.sql | 286 +- ...002_add_deleted_at_to_team_invitations.sql | 17 +- .../20250125000001_create_workspaces.sql | 57 +- ...20250125000002_create_workspace_quotas.sql | 73 +- ...50125000003_create_proxy_visible_teams.sql | 50 +- ...000004_create_group_member_permissions.sql | 78 +- ...250125000005_alter_teams_add_workspace.sql | 37 +- ...00006_alter_team_members_add_workspace.sql | 41 +- ...50125000007_alter_groups_add_workspace.sql | 48 +- ...00008_alter_environments_add_workspace.sql | 25 +- ...0125000009_alter_proxies_add_workspace.sql | 57 +- ...0010_alter_subscriptions_add_workspace.sql | 13 - .../20250125000011_deprecate_user_quotas.sql | 8 - .../20250125000012_migrate_to_workspaces.sql | 416 --- ...alter_user_infos_add_current_workspace.sql | 76 +- ...001_insert_default_plans_with_features.sql | 432 --- .../20250126000002_create_user_coupons.sql | 27 - .../20250126000003_insert_default_coupons.sql | 37 - ...0127000001_add_coupon_name_description.sql | 13 - ...127000002_reset_referral_default_tiers.sql | 25 - ...50127000003_create_machine_tags_tables.sql | 53 - ...ter_version_types_add_is_auto_download.sql | 7 - ...00002_alter_versions_add_kernel_fields.sql | 16 - ...204000003_insert_default_version_types.sql | 8 - ...3000001_add_updated_at_to_user_coupons.sql | 9 - ...6000001_fix_extensions_status_comments.sql | 18 - ...0306000002_drop_environment_extensions.sql | 12 - ...add_is_team_shared_to_group_extensions.sql | 11 - ...date_extension_status_support_disabled.sql | 7 - ...create_user_team_extension_preferences.sql | 23 - .../20260308000001_add_hash_to_extensions.sql | 8 - ..._alter_versions_require_publish_fields.sql | 15 - ...000001_create_local_api_service_tables.sql | 209 +- ...2_initialize_local_api_default_configs.sql | 13 - ...reate_local_api_permission_definitions.sql | 46 +- ...alize_local_api_permission_definitions.sql | 110 +- ...000005_replace_masked_key_with_api_key.sql | 18 +- ...001_replace_encrypted_password_columns.sql | 11 - ...506000002_drop_machine_and_gray_tables.sql | 6 - ..._insert_default_browser_kernel_version.sql | 67 - ..._add_site_input_to_environment_cookies.sql | 10 +- .../crates/business/src/caches/local_api.rs | 204 -- src-tauri/crates/business/src/caches/mod.rs | 7 - src-tauri/crates/business/src/caches/store.rs | 221 -- src-tauri/crates/business/src/caches/user.rs | 75 - src-tauri/crates/business/src/database.rs | 159 + src-tauri/crates/business/src/dispatcher.rs | 1589 +++++++++ .../crates/business/src/dto/maintenance.rs | 49 - src-tauri/crates/business/src/dto/mod.rs | 12 +- .../crates/business/src/dto/strategy_types.rs | 92 - .../crates/business/src/dto/version_types.rs | 14 - src-tauri/crates/business/src/dto/versions.rs | 31 - .../business/src/entitys/browser_kernel.rs | 10 - .../business/src/entitys/maintenance.rs | 42 - src-tauri/crates/business/src/entitys/mod.rs | 14 +- .../business/src/entitys/strategy_types.rs | 32 - .../business/src/entitys/version_types.rs | 69 - .../crates/business/src/entitys/versions.rs | 132 - src-tauri/crates/business/src/lib.rs | 2 +- .../business/src/models/environments.rs | 1 - .../crates/business/src/models/maintenance.rs | 180 - src-tauri/crates/business/src/models/mod.rs | 12 +- .../crates/business/src/models/proxies.rs | 2 +- .../src/models/proxy_visible_teams.rs | 4 +- .../business/src/models/strategy_types.rs | 58 - .../business/src/models/version_types.rs | 126 - .../crates/business/src/models/versions.rs | 551 --- src-tauri/crates/business/src/services.rs | 16 +- .../crates/business/src/services/audit.rs | 649 ++-- .../business/src/services/browser_kernel.rs | 41 - .../business/src/services/environments.rs | 3169 ++++++++--------- .../src/services/group_permissions.rs | 299 +- .../crates/business/src/services/local_api.rs | 691 ++-- .../business/src/services/maintenance.rs | 78 - .../business/src/services/proxy_visibility.rs | 310 +- .../business/src/services/strategy_types.rs | 50 - .../crates/business/src/services/teams.rs | 16 - .../crates/business/src/services/users.rs | 469 --- .../business/src/services/version_types.rs | 123 - .../crates/business/src/services/versions.rs | 240 -- .../business/src/services/workspaces.rs | 2 +- src-tauri/crates/business/src/svc_ctx.rs | 95 +- src-tauri/crates/business/src/utils.rs | 41 - src-tauri/crates/business/src/utils/config.rs | 118 +- src-tauri/crates/business/src/utils/jwt.rs | 59 - .../crates/business/src/utils/password.rs | 26 - .../crates/business/src/utils/storage.rs | 6 - .../business/src/utils/storage/get_objects.rs | 84 - src-tauri/src/app/mod.rs | 12 + src-tauri/src/commands/local_api.rs | 10 +- src-tauri/src/commands/network.rs | 24 +- src-tauri/src/core/paths.rs | 7 + src-tauri/src/local_api/client/main_server.rs | 28 +- src-tauri/src/local_api/manager/runtime.rs | 30 +- src-tauri/src/local_api/middleware/auth.rs | 10 +- src-tauri/src/local_api/services/groups.rs | 31 +- src-tauri/src/local_api/services/mod.rs | 12 +- src-tauri/src/mcp/manager.rs | 25 +- 166 files changed, 5762 insertions(+), 10289 deletions(-) delete mode 100644 src-tauri/crates/business/migrations/20250101000003_create_machine_users_table.sql delete mode 100644 src-tauri/crates/business/migrations/20250101000004_create_version_types_table.sql delete mode 100644 src-tauri/crates/business/migrations/20250101000005_create_versions_table.sql delete mode 100644 src-tauri/crates/business/migrations/20250101000006_create_gray_releases_table.sql delete mode 100644 src-tauri/crates/business/migrations/20250101000007_create_gray_resources_table.sql delete mode 100644 src-tauri/crates/business/migrations/20250101000008_create_machine_gray_allocations_table.sql delete mode 100644 src-tauri/crates/business/migrations/20250101000009_create_maintenances_table.sql delete mode 100644 src-tauri/crates/business/migrations/20250101000010_create_strategy_types_table.sql delete mode 100644 src-tauri/crates/business/migrations/20250101000011_insert_strategy_types_data.sql delete mode 100644 src-tauri/crates/business/migrations/20250120000004_create_login_history.sql delete mode 100644 src-tauri/crates/business/migrations/20250120000018_create_extensions.sql delete mode 100644 src-tauri/crates/business/migrations/20250120000019_create_user_extensions.sql delete mode 100644 src-tauri/crates/business/migrations/20250120000020_create_team_extensions.sql delete mode 100644 src-tauri/crates/business/migrations/20250120000021_create_group_extensions.sql delete mode 100644 src-tauri/crates/business/migrations/20250120000022_create_environment_extensions.sql delete mode 100644 src-tauri/crates/business/migrations/20250120000023_create_plans.sql delete mode 100644 src-tauri/crates/business/migrations/20250120000024_create_plan_features.sql delete mode 100644 src-tauri/crates/business/migrations/20250120000025_create_subscriptions.sql delete mode 100644 src-tauri/crates/business/migrations/20250120000026_create_user_wallets.sql delete mode 100644 src-tauri/crates/business/migrations/20250120000027_create_wallet_transactions.sql delete mode 100644 src-tauri/crates/business/migrations/20250120000028_create_invoices.sql delete mode 100644 src-tauri/crates/business/migrations/20250120000029_create_user_quotas.sql delete mode 100644 src-tauri/crates/business/migrations/20250120000030_create_coupons.sql delete mode 100644 src-tauri/crates/business/migrations/20250120000031_create_coupon_usages.sql delete mode 100644 src-tauri/crates/business/migrations/20250120000032_create_payment_orders.sql delete mode 100644 src-tauri/crates/business/migrations/20250120000033_create_auto_renewal_services.sql delete mode 100644 src-tauri/crates/business/migrations/20250120000038_create_referral_link_tiers.sql delete mode 100644 src-tauri/crates/business/migrations/20250120000039_create_referral_links.sql delete mode 100644 src-tauri/crates/business/migrations/20250120000040_create_user_referrals.sql delete mode 100644 src-tauri/crates/business/migrations/20250120000041_create_referral_rewards.sql delete mode 100644 src-tauri/crates/business/migrations/20250120000042_create_user_referral_points.sql delete mode 100644 src-tauri/crates/business/migrations/20250120000043_create_redeem_options.sql delete mode 100644 src-tauri/crates/business/migrations/20250120000044_create_redeem_records.sql delete mode 100644 src-tauri/crates/business/migrations/20250120000046_create_api_keys.sql delete mode 100644 src-tauri/crates/business/migrations/20250120000048_create_system_configs.sql delete mode 100644 src-tauri/crates/business/migrations/20250120000050_insert_default_data.sql delete mode 100644 src-tauri/crates/business/migrations/20250123000001_create_console_admins.sql delete mode 100644 src-tauri/crates/business/migrations/20250123000002_create_console_permissions.sql delete mode 100644 src-tauri/crates/business/migrations/20250123000003_create_console_api_keys.sql delete mode 100644 src-tauri/crates/business/migrations/20250125000010_alter_subscriptions_add_workspace.sql delete mode 100644 src-tauri/crates/business/migrations/20250125000011_deprecate_user_quotas.sql delete mode 100644 src-tauri/crates/business/migrations/20250125000012_migrate_to_workspaces.sql delete mode 100644 src-tauri/crates/business/migrations/20250126000001_insert_default_plans_with_features.sql delete mode 100644 src-tauri/crates/business/migrations/20250126000002_create_user_coupons.sql delete mode 100644 src-tauri/crates/business/migrations/20250126000003_insert_default_coupons.sql delete mode 100644 src-tauri/crates/business/migrations/20250127000001_add_coupon_name_description.sql delete mode 100644 src-tauri/crates/business/migrations/20250127000002_reset_referral_default_tiers.sql delete mode 100644 src-tauri/crates/business/migrations/20250127000003_create_machine_tags_tables.sql delete mode 100644 src-tauri/crates/business/migrations/20250204000001_alter_version_types_add_is_auto_download.sql delete mode 100644 src-tauri/crates/business/migrations/20250204000002_alter_versions_add_kernel_fields.sql delete mode 100644 src-tauri/crates/business/migrations/20250204000003_insert_default_version_types.sql delete mode 100644 src-tauri/crates/business/migrations/20260213000001_add_updated_at_to_user_coupons.sql delete mode 100644 src-tauri/crates/business/migrations/20260306000001_fix_extensions_status_comments.sql delete mode 100644 src-tauri/crates/business/migrations/20260306000002_drop_environment_extensions.sql delete mode 100644 src-tauri/crates/business/migrations/20260306000003_add_is_team_shared_to_group_extensions.sql delete mode 100644 src-tauri/crates/business/migrations/20260306000004_update_extension_status_support_disabled.sql delete mode 100644 src-tauri/crates/business/migrations/20260306000005_create_user_team_extension_preferences.sql delete mode 100644 src-tauri/crates/business/migrations/20260308000001_add_hash_to_extensions.sql delete mode 100644 src-tauri/crates/business/migrations/20260314000001_alter_versions_require_publish_fields.sql delete mode 100644 src-tauri/crates/business/migrations/20260316000002_initialize_local_api_default_configs.sql delete mode 100644 src-tauri/crates/business/migrations/20260506000001_replace_encrypted_password_columns.sql delete mode 100644 src-tauri/crates/business/migrations/20260506000002_drop_machine_and_gray_tables.sql delete mode 100644 src-tauri/crates/business/migrations/20260508000001_insert_default_browser_kernel_version.sql delete mode 100644 src-tauri/crates/business/src/caches/local_api.rs delete mode 100644 src-tauri/crates/business/src/caches/mod.rs delete mode 100644 src-tauri/crates/business/src/caches/store.rs delete mode 100644 src-tauri/crates/business/src/caches/user.rs create mode 100644 src-tauri/crates/business/src/dispatcher.rs delete mode 100644 src-tauri/crates/business/src/dto/maintenance.rs delete mode 100644 src-tauri/crates/business/src/dto/strategy_types.rs delete mode 100644 src-tauri/crates/business/src/dto/version_types.rs delete mode 100644 src-tauri/crates/business/src/dto/versions.rs delete mode 100644 src-tauri/crates/business/src/entitys/browser_kernel.rs delete mode 100644 src-tauri/crates/business/src/entitys/maintenance.rs delete mode 100644 src-tauri/crates/business/src/entitys/strategy_types.rs delete mode 100644 src-tauri/crates/business/src/entitys/version_types.rs delete mode 100644 src-tauri/crates/business/src/entitys/versions.rs delete mode 100644 src-tauri/crates/business/src/models/maintenance.rs delete mode 100644 src-tauri/crates/business/src/models/strategy_types.rs delete mode 100644 src-tauri/crates/business/src/models/version_types.rs delete mode 100644 src-tauri/crates/business/src/models/versions.rs delete mode 100644 src-tauri/crates/business/src/services/browser_kernel.rs delete mode 100644 src-tauri/crates/business/src/services/maintenance.rs delete mode 100644 src-tauri/crates/business/src/services/strategy_types.rs delete mode 100644 src-tauri/crates/business/src/services/users.rs delete mode 100644 src-tauri/crates/business/src/services/version_types.rs delete mode 100644 src-tauri/crates/business/src/services/versions.rs delete mode 100644 src-tauri/crates/business/src/utils/jwt.rs delete mode 100644 src-tauri/crates/business/src/utils/password.rs delete mode 100644 src-tauri/crates/business/src/utils/storage.rs delete mode 100644 src-tauri/crates/business/src/utils/storage/get_objects.rs diff --git a/src-tauri/Cargo.lock b/src-tauri/Cargo.lock index 313ac62..6331bb0 100644 --- a/src-tauri/Cargo.lock +++ b/src-tauri/Cargo.lock @@ -196,18 +196,6 @@ dependencies = [ "x11rb", ] -[[package]] -name = "argon2" -version = "0.5.3" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "3c3610892ee6e0cbce8ae2700349fcf8f98adb0dbfbee85aec3c9179d29cc072" -dependencies = [ - "base64ct", - "blake2", - "cpufeatures 0.2.17", - "password-hash", -] - [[package]] name = "arraydeque" version = "0.5.1" @@ -458,12 +446,6 @@ dependencies = [ "tracing", ] -[[package]] -name = "base64" -version = "0.13.1" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "9e1b586273c5702936fe7b7d6896644d8be71e6314cfe09d3167c95f712589e8" - [[package]] name = "base64" version = "0.21.7" @@ -476,12 +458,6 @@ version = "0.22.1" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "72b3254f16251a8381aa12e40e3c4d2f0199f8c6508fbecb9d91f575e0fbb8c6" -[[package]] -name = "base64" -version = "0.23.1" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "ac07cdecf99051d9a5238b80f35af32cdeba5b336e55d957b318b50137e18da5" - [[package]] name = "base64ct" version = "1.8.2" @@ -515,15 +491,6 @@ dependencies = [ "wyz", ] -[[package]] -name = "blake2" -version = "0.10.6" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "46502ad458c9a52b69d4d4d32775c788b7a1b85e8bc9d482d92250fc0e3f8efe" -dependencies = [ - "digest", -] - [[package]] name = "block-buffer" version = "0.10.4" @@ -609,17 +576,9 @@ checksum = "5dd9dc738b7a8311c7ade152424974d8115f2cdad61e8dab8dac9f2362298510" name = "business" version = "0.1.0" dependencies = [ - "aes-gcm", "anyhow", - "argon2", "chrono", - "config", - "dashmap", "hex", - "hmac", - "jwt", - "lettre", - "rand 0.9.2", "serde", "serde_json", "sha2", @@ -1223,20 +1182,6 @@ dependencies = [ "syn 2.0.114", ] -[[package]] -name = "dashmap" -version = "6.1.0" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "5041cc499144891f3790297212f32a74fb938e5136a14943f338ef9e0ae276cf" -dependencies = [ - "cfg-if", - "crossbeam-utils", - "hashbrown 0.14.5", - "lock_api", - "once_cell", - "parking_lot_core", -] - [[package]] name = "dbus" version = "0.9.11" @@ -1489,22 +1434,6 @@ dependencies = [ "serde", ] -[[package]] -name = "email-encoding" -version = "0.4.2" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "420b9da095f052ea597503e39073b5b3c522f7db933fbac202d91d24492693fd" -dependencies = [ - "base64 0.23.1", - "memchr", -] - -[[package]] -name = "email_address" -version = "0.2.9" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "e079f19b08ca6239f47f8ba8509c11cf3ea30095831f7fed61441475edd8c449" - [[package]] name = "embed-resource" version = "2.5.2" @@ -2410,17 +2339,6 @@ dependencies = [ "windows-sys 0.61.2", ] -[[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 0.2.1", -] - [[package]] name = "html5ever" version = "0.29.1" @@ -2962,21 +2880,6 @@ dependencies = [ "serde_json", ] -[[package]] -name = "jwt" -version = "0.16.0" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "6204285f77fe7d9784db3fdc449ecce1a0114927a51d5a41c4c7a292011c015f" -dependencies = [ - "base64 0.13.1", - "crypto-common", - "digest", - "hmac", - "serde", - "serde_json", - "sha2", -] - [[package]] name = "keyboard-types" version = "0.7.0" @@ -3030,30 +2933,6 @@ version = "0.1.0" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "09edd9e8b54e49e587e4f6295a7d29c3ea94d469cb40ab8ca70b288248a81db2" -[[package]] -name = "lettre" -version = "0.11.23" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "f2c646bd5cc763b1087b15493e29a64be6147ba8f19342004fa52048ee596eae" -dependencies = [ - "base64 0.23.1", - "email-encoding", - "email_address", - "fastrand", - "futures-util", - "hostname", - "httpdate", - "idna", - "mime", - "native-tls", - "nom", - "percent-encoding", - "quoted_printable", - "socket2", - "tokio", - "url", -] - [[package]] name = "libappindicator" version = "0.9.0" @@ -3888,17 +3767,6 @@ dependencies = [ "windows-link 0.2.1", ] -[[package]] -name = "password-hash" -version = "0.5.0" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "346f04948ba92c43e8469c1ee6736c7563d71012b17d40745260fe106aac2166" -dependencies = [ - "base64ct", - "rand_core 0.6.4", - "subtle", -] - [[package]] name = "pastey" version = "0.2.1" @@ -4479,12 +4347,6 @@ 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" diff --git a/src-tauri/Cargo.toml b/src-tauri/Cargo.toml index 9ad0657..42794c9 100644 --- a/src-tauri/Cargo.toml +++ b/src-tauri/Cargo.toml @@ -121,9 +121,6 @@ unsafe_code = "warn" [lints.clippy] all = "warn" -[rustfmt] -use_small_heuristics = "Default" - [features] development = [] production = [] diff --git a/src-tauri/crates/business/Cargo.toml b/src-tauri/crates/business/Cargo.toml index 702735f..ddf7b09 100644 --- a/src-tauri/crates/business/Cargo.toml +++ b/src-tauri/crates/business/Cargo.toml @@ -18,14 +18,8 @@ serde_json = "^1" thiserror = "2" anyhow = "1.0.93" chrono = { version = "0.4", features = ["serde"] } -rand = "0.9" -jwt = "0.16.0" -argon2 = "0.5.3" -aes-gcm = "0.10.3" sha2 = "0.10.8" -hmac = "0.12.1" hex = "0.4" -config = "0.15" tracing = "0.1" sqlx = { version = "0.8", features = [ "sqlite", @@ -40,6 +34,4 @@ uuid = { version = "1.11.0", features = [ "macro-diagnostics", "serde", ] } -lettre = "0.11" -dashmap = "6.1.0" url = "2.5.4" diff --git a/src-tauri/crates/business/migrations/20250101000001_create_users.sql b/src-tauri/crates/business/migrations/20250101000001_create_users.sql index 9e019ec..997d6a6 100644 --- a/src-tauri/crates/business/migrations/20250101000001_create_users.sql +++ b/src-tauri/crates/business/migrations/20250101000001_create_users.sql @@ -1,29 +1,15 @@ --- 创建 users 表 --- 用户基础信息表,存储用户的基础标识信息 - -CREATE TABLE IF NOT EXISTS users ( - uuid UUID PRIMARY KEY DEFAULT gen_random_uuid(), - id VARCHAR(255) NOT NULL UNIQUE, - created_at TIMESTAMP WITH TIME ZONE NOT NULL DEFAULT CURRENT_TIMESTAMP, - updated_at TIMESTAMP WITH TIME ZONE NOT NULL DEFAULT CURRENT_TIMESTAMP, - deleted_at TIMESTAMP WITH TIME ZONE -); - --- 创建索引 -CREATE INDEX IF NOT EXISTS idx_users_deleted_at ON users(deleted_at); - --- 创建更新时间触发器 -CREATE OR REPLACE FUNCTION update_updated_at_column() -RETURNS TRIGGER AS $$ -BEGIN - NEW.updated_at = CURRENT_TIMESTAMP; - RETURN NEW; -END; -$$ language 'plpgsql'; - -CREATE TRIGGER update_users_updated_at BEFORE UPDATE ON users - FOR EACH ROW EXECUTE FUNCTION update_updated_at_column(); - - - - +-- 创建 users 表 +-- 用户基础信息表,存储用户的基础标识信息 + +CREATE TABLE IF NOT EXISTS users ( + uuid TEXT PRIMARY KEY DEFAULT (randomblob(16)), + id VARCHAR(255) NOT NULL UNIQUE, + created_at TEXT NOT NULL DEFAULT CURRENT_TIMESTAMP, + updated_at TEXT NOT NULL DEFAULT CURRENT_TIMESTAMP, + deleted_at TEXT +); + +-- 创建索引 +CREATE INDEX IF NOT EXISTS idx_users_deleted_at ON users(deleted_at); + +-- 创建更新时间触发器 diff --git a/src-tauri/crates/business/migrations/20250101000002_create_user_infos.sql b/src-tauri/crates/business/migrations/20250101000002_create_user_infos.sql index 774c504..66d1692 100644 --- a/src-tauri/crates/business/migrations/20250101000002_create_user_infos.sql +++ b/src-tauri/crates/business/migrations/20250101000002_create_user_infos.sql @@ -1,31 +1,25 @@ --- 创建 user_infos 表 --- 用户详细信息表,存储用户的详细业务信息 - -CREATE TABLE IF NOT EXISTS user_infos ( - id SERIAL PRIMARY KEY, - user_uuid UUID NOT NULL UNIQUE, - nickname VARCHAR(255), - email VARCHAR(255) NOT NULL UNIQUE, - phone VARCHAR(50), - password VARCHAR(255) NOT NULL, - avatar_hash VARCHAR(255), - status VARCHAR(50) NOT NULL DEFAULT 'active', - created_at TIMESTAMP WITH TIME ZONE NOT NULL DEFAULT CURRENT_TIMESTAMP, - updated_at TIMESTAMP WITH TIME ZONE NOT NULL DEFAULT CURRENT_TIMESTAMP, - deleted_at TIMESTAMP WITH TIME ZONE, - CONSTRAINT fk_user_infos_user_uuid FOREIGN KEY (user_uuid) REFERENCES users(uuid) ON DELETE CASCADE -); - --- 创建索引 -CREATE UNIQUE INDEX IF NOT EXISTS idx_user_infos_user_uuid ON user_infos(user_uuid); -CREATE UNIQUE INDEX IF NOT EXISTS idx_user_infos_email ON user_infos(email); -CREATE INDEX IF NOT EXISTS idx_user_infos_deleted_at ON user_infos(deleted_at); -CREATE INDEX IF NOT EXISTS idx_user_infos_status ON user_infos(status); - --- 创建更新时间触发器 -CREATE TRIGGER update_user_infos_updated_at BEFORE UPDATE ON user_infos - FOR EACH ROW EXECUTE FUNCTION update_updated_at_column(); - - - - +-- 创建 user_infos 表 +-- 用户详细信息表,存储用户的详细业务信息 + +CREATE TABLE IF NOT EXISTS user_infos ( + id INTEGER PRIMARY KEY AUTOINCREMENT, + user_uuid TEXT NOT NULL UNIQUE, + nickname VARCHAR(255), + email VARCHAR(255) NOT NULL UNIQUE, + phone VARCHAR(50), + password VARCHAR(255) NOT NULL, + avatar_hash VARCHAR(255), + status VARCHAR(50) NOT NULL DEFAULT 'active', + created_at TEXT NOT NULL DEFAULT CURRENT_TIMESTAMP, + updated_at TEXT NOT NULL DEFAULT CURRENT_TIMESTAMP, + deleted_at TEXT, + CONSTRAINT fk_user_infos_user_uuid FOREIGN KEY (user_uuid) REFERENCES users(uuid) ON DELETE CASCADE +); + +-- 创建索引 +CREATE UNIQUE INDEX IF NOT EXISTS idx_user_infos_user_uuid ON user_infos(user_uuid); +CREATE UNIQUE INDEX IF NOT EXISTS idx_user_infos_email ON user_infos(email); +CREATE INDEX IF NOT EXISTS idx_user_infos_deleted_at ON user_infos(deleted_at); +CREATE INDEX IF NOT EXISTS idx_user_infos_status ON user_infos(status); + +-- 创建更新时间触发器 diff --git a/src-tauri/crates/business/migrations/20250101000003_create_machine_users_table.sql b/src-tauri/crates/business/migrations/20250101000003_create_machine_users_table.sql deleted file mode 100644 index a5ebc5c..0000000 --- a/src-tauri/crates/business/migrations/20250101000003_create_machine_users_table.sql +++ /dev/null @@ -1,41 +0,0 @@ --- Add migration script here --- public.machine_users definition - --- Drop table --- DROP TABLE public.machine_users; - -CREATE TABLE public.machine_users ( - id int4 GENERATED BY DEFAULT AS IDENTITY( INCREMENT BY 1 MINVALUE 1 MAXVALUE 2147483647 START 1 CACHE 1 NO CYCLE) NOT NULL, - machine_code varchar(255) NOT NULL, -- 机器码 - user_uuid uuid NULL, -- 用户UUID - platform varchar(50) NULL, -- 平台 - bind_time timestamptz DEFAULT CURRENT_TIMESTAMP NOT NULL, -- 绑定时间 - status varchar(20) DEFAULT 'active'::character varying NOT NULL, -- 状态 - version_info jsonb NULL, -- 版本信息(JSON格式) - hardware_hash varchar(255) NULL, -- 硬件哈希 - hardware_raw text NULL, -- 硬件原始信息 - allow boolean DEFAULT true NOT NULL, -- 是否允许使用 - created_at timestamptz DEFAULT CURRENT_TIMESTAMP NOT NULL, -- 创建时间 - updated_at timestamptz NULL, -- 更新时间 - CONSTRAINT machine_users_pk PRIMARY KEY (id) -); - -CREATE INDEX machine_users_machine_code_idx ON public.machine_users USING btree (machine_code); -CREATE INDEX machine_users_user_uuid_idx ON public.machine_users USING btree (user_uuid); -CREATE INDEX machine_users_status_idx ON public.machine_users USING btree (status); -CREATE INDEX machine_users_platform_idx ON public.machine_users USING btree (platform); - --- Column comments -COMMENT ON COLUMN public.machine_users.id IS '主键ID'; -COMMENT ON COLUMN public.machine_users.machine_code IS '机器码'; -COMMENT ON COLUMN public.machine_users.user_uuid IS '用户UUID'; -COMMENT ON COLUMN public.machine_users.platform IS '平台'; -COMMENT ON COLUMN public.machine_users.bind_time IS '绑定时间'; -COMMENT ON COLUMN public.machine_users.status IS '状态'; -COMMENT ON COLUMN public.machine_users.version_info IS '版本信息(JSON格式,存储versions表的信息)'; -COMMENT ON COLUMN public.machine_users.hardware_hash IS '硬件哈希'; -COMMENT ON COLUMN public.machine_users.hardware_raw IS '硬件原始信息'; -COMMENT ON COLUMN public.machine_users.allow IS '是否允许使用'; -COMMENT ON COLUMN public.machine_users.created_at IS '创建时间'; -COMMENT ON COLUMN public.machine_users.updated_at IS '更新时间'; - diff --git a/src-tauri/crates/business/migrations/20250101000004_create_version_types_table.sql b/src-tauri/crates/business/migrations/20250101000004_create_version_types_table.sql deleted file mode 100644 index 3ebc8d9..0000000 --- a/src-tauri/crates/business/migrations/20250101000004_create_version_types_table.sql +++ /dev/null @@ -1,26 +0,0 @@ --- Add migration script here --- public.version_types definition - --- Drop table --- DROP TABLE public.version_types; - -CREATE TABLE public.version_types ( - id int4 GENERATED BY DEFAULT AS IDENTITY( INCREMENT BY 1 MINVALUE 1 MAXVALUE 2147483647 START 1 CACHE 1 NO CYCLE) NOT NULL, - type_code varchar(50) NOT NULL, -- 类型代码 - type_name varchar(100) NOT NULL, -- 类型名称 - description text NULL, -- 描述 - sort_order int4 DEFAULT 0 NULL, -- 排序顺序 - is_active bool DEFAULT true NOT NULL, -- 是否激活 - CONSTRAINT version_types_pk PRIMARY KEY (id), - CONSTRAINT version_types_type_code_unique UNIQUE (type_code) -); -CREATE UNIQUE INDEX version_types_type_code_idx ON public.version_types USING btree (type_code); - --- Column comments -COMMENT ON COLUMN public.version_types.id IS '主键ID'; -COMMENT ON COLUMN public.version_types.type_code IS '类型代码'; -COMMENT ON COLUMN public.version_types.type_name IS '类型名称'; -COMMENT ON COLUMN public.version_types.description IS '描述'; -COMMENT ON COLUMN public.version_types.sort_order IS '排序顺序'; -COMMENT ON COLUMN public.version_types.is_active IS '是否激活'; - diff --git a/src-tauri/crates/business/migrations/20250101000005_create_versions_table.sql b/src-tauri/crates/business/migrations/20250101000005_create_versions_table.sql deleted file mode 100644 index 1033107..0000000 --- a/src-tauri/crates/business/migrations/20250101000005_create_versions_table.sql +++ /dev/null @@ -1,53 +0,0 @@ --- Add migration script here --- public.versions definition - --- Drop table --- DROP TABLE public.versions; - -CREATE TABLE public.versions ( - id int4 GENERATED BY DEFAULT AS IDENTITY( INCREMENT BY 1 MINVALUE 1 MAXVALUE 2147483647 START 1 CACHE 1 NO CYCLE) NOT NULL, - type_id int4 NOT NULL, -- 版本类型ID - resource_name varchar(100) NOT NULL, -- 资源名称 - version varchar(50) NOT NULL, -- 版本号 - name varchar(200) NULL, -- 版本名称 - notes text NULL, -- 更新说明 - platform varchar(50) NULL, -- 平台 - url text NULL, -- 下载链接 - hash varchar(255) NULL, -- 文件哈希 - signature varchar(255) NULL, -- 签名 - install_path text NULL, -- 安装路径 - file_size int4 NULL, -- 文件大小 - is_latest bool DEFAULT false NOT NULL, -- 是否最新版本 - status varchar(20) DEFAULT 'active'::character varying NOT NULL, -- 状态 - pub_date timestamptz NULL, -- 发布时间 - created_at timestamptz DEFAULT CURRENT_TIMESTAMP NOT NULL, -- 创建时间 - updated_at timestamptz NULL, -- 更新时间 - deleted_at timestamptz NULL, -- 删除时间 - CONSTRAINT versions_pk PRIMARY KEY (id), - CONSTRAINT versions_type_id_fk FOREIGN KEY (type_id) REFERENCES public.version_types(id) -); -CREATE INDEX versions_type_id_idx ON public.versions USING btree (type_id); -CREATE INDEX versions_resource_name_idx ON public.versions USING btree (resource_name); -CREATE INDEX versions_status_idx ON public.versions USING btree (status); -CREATE INDEX versions_is_latest_idx ON public.versions USING btree (is_latest); - --- Column comments -COMMENT ON COLUMN public.versions.id IS '主键ID'; -COMMENT ON COLUMN public.versions.type_id IS '版本类型ID'; -COMMENT ON COLUMN public.versions.resource_name IS '资源名称'; -COMMENT ON COLUMN public.versions.version IS '版本号'; -COMMENT ON COLUMN public.versions.name IS '版本名称'; -COMMENT ON COLUMN public.versions.notes IS '更新说明'; -COMMENT ON COLUMN public.versions.platform IS '平台'; -COMMENT ON COLUMN public.versions.url IS '下载链接'; -COMMENT ON COLUMN public.versions.hash IS '文件哈希'; -COMMENT ON COLUMN public.versions.signature IS '签名'; -COMMENT ON COLUMN public.versions.install_path IS '安装路径'; -COMMENT ON COLUMN public.versions.file_size IS '文件大小'; -COMMENT ON COLUMN public.versions.is_latest IS '是否最新版本'; -COMMENT ON COLUMN public.versions.status IS '状态'; -COMMENT ON COLUMN public.versions.pub_date IS '发布时间'; -COMMENT ON COLUMN public.versions.created_at IS '创建时间'; -COMMENT ON COLUMN public.versions.updated_at IS '更新时间'; -COMMENT ON COLUMN public.versions.deleted_at IS '删除时间'; - diff --git a/src-tauri/crates/business/migrations/20250101000006_create_gray_releases_table.sql b/src-tauri/crates/business/migrations/20250101000006_create_gray_releases_table.sql deleted file mode 100644 index 7b73ed2..0000000 --- a/src-tauri/crates/business/migrations/20250101000006_create_gray_releases_table.sql +++ /dev/null @@ -1,44 +0,0 @@ --- Add migration script here --- public.gray_releases definition - --- Drop table --- DROP TABLE public.gray_releases; - -CREATE TABLE public.gray_releases ( - id int4 GENERATED BY DEFAULT AS IDENTITY( INCREMENT BY 1 MINVALUE 1 MAXVALUE 2147483647 START 1 CACHE 1 NO CYCLE) NOT NULL, - name varchar(100) NOT NULL, -- 灰度名称 - description text NULL, -- 描述 - platform varchar(50) NOT NULL, -- 平台 - status varchar(20) DEFAULT 'pending'::character varying NOT NULL, -- 状态 - start_time timestamptz NOT NULL, -- 开始时间 - end_time timestamptz NULL, -- 结束时间 - max_machines int4 NULL, -- 最大配额(NULL=不限制) - allocated_count int4 DEFAULT 0 NOT NULL, -- 已分配数量 - priority int4 DEFAULT 0 NOT NULL, -- 优先级 - strategy_type varchar(50) NULL, -- 策略类型 - strategy_config jsonb NULL, -- 策略配置(JSON格式) - created_at timestamptz DEFAULT CURRENT_TIMESTAMP NOT NULL, -- 创建时间 - updated_at timestamptz NULL, -- 更新时间 - CONSTRAINT gray_releases_pk PRIMARY KEY (id) -); -CREATE INDEX gray_releases_status_idx ON public.gray_releases USING btree (status); -CREATE INDEX gray_releases_platform_idx ON public.gray_releases USING btree (platform); -CREATE INDEX gray_releases_time_idx ON public.gray_releases USING btree (start_time, end_time); -CREATE INDEX gray_releases_priority_idx ON public.gray_releases USING btree (priority); - --- Column comments -COMMENT ON COLUMN public.gray_releases.id IS '主键ID'; -COMMENT ON COLUMN public.gray_releases.name IS '灰度名称'; -COMMENT ON COLUMN public.gray_releases.description IS '描述'; -COMMENT ON COLUMN public.gray_releases.platform IS '平台'; -COMMENT ON COLUMN public.gray_releases.status IS '状态'; -COMMENT ON COLUMN public.gray_releases.start_time IS '开始时间'; -COMMENT ON COLUMN public.gray_releases.end_time IS '结束时间'; -COMMENT ON COLUMN public.gray_releases.max_machines IS '最大配额(NULL=不限制)'; -COMMENT ON COLUMN public.gray_releases.allocated_count IS '已分配数量'; -COMMENT ON COLUMN public.gray_releases.priority IS '优先级'; -COMMENT ON COLUMN public.gray_releases.strategy_type IS '策略类型'; -COMMENT ON COLUMN public.gray_releases.strategy_config IS '策略配置(JSON格式)'; -COMMENT ON COLUMN public.gray_releases.created_at IS '创建时间'; -COMMENT ON COLUMN public.gray_releases.updated_at IS '更新时间'; - diff --git a/src-tauri/crates/business/migrations/20250101000007_create_gray_resources_table.sql b/src-tauri/crates/business/migrations/20250101000007_create_gray_resources_table.sql deleted file mode 100644 index 22e9d1c..0000000 --- a/src-tauri/crates/business/migrations/20250101000007_create_gray_resources_table.sql +++ /dev/null @@ -1,24 +0,0 @@ --- Add migration script here --- public.gray_resources definition - --- Drop table --- DROP TABLE public.gray_resources; - -CREATE TABLE public.gray_resources ( - id int4 GENERATED BY DEFAULT AS IDENTITY( INCREMENT BY 1 MINVALUE 1 MAXVALUE 2147483647 START 1 CACHE 1 NO CYCLE) NOT NULL, - gray_release_id int4 NOT NULL, -- 灰度发布ID - version_id int4 NOT NULL, -- 版本ID - sort_order int4 DEFAULT 0 NOT NULL, -- 排序顺序 - CONSTRAINT gray_resources_pk PRIMARY KEY (id), - CONSTRAINT gray_resources_gray_release_id_fk FOREIGN KEY (gray_release_id) REFERENCES public.gray_releases(id) ON DELETE CASCADE, - CONSTRAINT gray_resources_version_id_fk FOREIGN KEY (version_id) REFERENCES public.versions(id) -); -CREATE INDEX gray_resources_gray_release_id_idx ON public.gray_resources USING btree (gray_release_id); -CREATE INDEX gray_resources_version_id_idx ON public.gray_resources USING btree (version_id); - --- Column comments -COMMENT ON COLUMN public.gray_resources.id IS '主键ID'; -COMMENT ON COLUMN public.gray_resources.gray_release_id IS '灰度发布ID'; -COMMENT ON COLUMN public.gray_resources.version_id IS '版本ID'; -COMMENT ON COLUMN public.gray_resources.sort_order IS '排序顺序'; - diff --git a/src-tauri/crates/business/migrations/20250101000008_create_machine_gray_allocations_table.sql b/src-tauri/crates/business/migrations/20250101000008_create_machine_gray_allocations_table.sql deleted file mode 100644 index feac000..0000000 --- a/src-tauri/crates/business/migrations/20250101000008_create_machine_gray_allocations_table.sql +++ /dev/null @@ -1,33 +0,0 @@ --- Add migration script here --- public.machine_gray_allocations definition - --- Drop table --- DROP TABLE public.machine_gray_allocations; - -CREATE TABLE public.machine_gray_allocations ( - id int4 GENERATED BY DEFAULT AS IDENTITY( INCREMENT BY 1 MINVALUE 1 MAXVALUE 2147483647 START 1 CACHE 1 NO CYCLE) NOT NULL, - machine_code varchar(255) NOT NULL, -- 机器码 - gray_release_id int4 NOT NULL, -- 灰度发布ID - allocated_at timestamptz DEFAULT CURRENT_TIMESTAMP NOT NULL, -- 分配时间 - effective_time timestamptz NULL, -- 生效时间 - status varchar(20) DEFAULT 'active'::character varying NOT NULL, -- 状态 - notes text NULL, -- 备注 - created_at timestamptz DEFAULT CURRENT_TIMESTAMP NOT NULL, -- 创建时间 - CONSTRAINT machine_gray_allocations_pk PRIMARY KEY (id), - CONSTRAINT machine_gray_allocations_machine_gray_unique UNIQUE (machine_code, gray_release_id), - CONSTRAINT machine_gray_allocations_gray_release_id_fk FOREIGN KEY (gray_release_id) REFERENCES public.gray_releases(id) -); -CREATE INDEX machine_gray_allocations_machine_code_idx ON public.machine_gray_allocations USING btree (machine_code); -CREATE INDEX machine_gray_allocations_gray_release_id_idx ON public.machine_gray_allocations USING btree (gray_release_id); -CREATE INDEX machine_gray_allocations_status_idx ON public.machine_gray_allocations USING btree (status); - --- Column comments -COMMENT ON COLUMN public.machine_gray_allocations.id IS '主键ID'; -COMMENT ON COLUMN public.machine_gray_allocations.machine_code IS '机器码'; -COMMENT ON COLUMN public.machine_gray_allocations.gray_release_id IS '灰度发布ID'; -COMMENT ON COLUMN public.machine_gray_allocations.allocated_at IS '分配时间'; -COMMENT ON COLUMN public.machine_gray_allocations.effective_time IS '生效时间'; -COMMENT ON COLUMN public.machine_gray_allocations.status IS '状态'; -COMMENT ON COLUMN public.machine_gray_allocations.notes IS '备注'; -COMMENT ON COLUMN public.machine_gray_allocations.created_at IS '创建时间'; - diff --git a/src-tauri/crates/business/migrations/20250101000009_create_maintenances_table.sql b/src-tauri/crates/business/migrations/20250101000009_create_maintenances_table.sql deleted file mode 100644 index b7038ba..0000000 --- a/src-tauri/crates/business/migrations/20250101000009_create_maintenances_table.sql +++ /dev/null @@ -1,33 +0,0 @@ --- Add migration script here --- public.maintenances definition - --- Drop table --- DROP TABLE public.maintenances; - -CREATE TABLE public.maintenances ( - id int8 GENERATED BY DEFAULT AS IDENTITY( INCREMENT BY 1 MINVALUE 1 MAXVALUE 9223372036854775807 START 1 CACHE 1 NO CYCLE) NOT NULL, - name varchar(100) NOT NULL, -- 维护名称 - description text NULL, -- 维护描述 - status varchar(20) NOT NULL, -- 状态 - start_time timestamptz NOT NULL, -- 开始时间 - end_time timestamptz NOT NULL, -- 结束时间 - maintenance_type varchar(20) NOT NULL, -- 维护类型 - created_at timestamptz DEFAULT CURRENT_TIMESTAMP NOT NULL, -- 创建时间 - updated_at timestamptz DEFAULT CURRENT_TIMESTAMP NOT NULL, -- 更新时间 - CONSTRAINT maintenances_pk PRIMARY KEY (id) -); -CREATE INDEX maintenances_status_idx ON public.maintenances USING btree (status); -CREATE INDEX maintenances_time_idx ON public.maintenances USING btree (start_time, end_time); -CREATE INDEX maintenances_type_idx ON public.maintenances USING btree (maintenance_type); - --- Column comments -COMMENT ON COLUMN public.maintenances.id IS '主键ID'; -COMMENT ON COLUMN public.maintenances.name IS '维护名称'; -COMMENT ON COLUMN public.maintenances.description IS '维护描述'; -COMMENT ON COLUMN public.maintenances.status IS '状态'; -COMMENT ON COLUMN public.maintenances.start_time IS '开始时间'; -COMMENT ON COLUMN public.maintenances.end_time IS '结束时间'; -COMMENT ON COLUMN public.maintenances.maintenance_type IS '维护类型'; -COMMENT ON COLUMN public.maintenances.created_at IS '创建时间'; -COMMENT ON COLUMN public.maintenances.updated_at IS '更新时间'; - diff --git a/src-tauri/crates/business/migrations/20250101000010_create_strategy_types_table.sql b/src-tauri/crates/business/migrations/20250101000010_create_strategy_types_table.sql deleted file mode 100644 index 4ff64f5..0000000 --- a/src-tauri/crates/business/migrations/20250101000010_create_strategy_types_table.sql +++ /dev/null @@ -1,31 +0,0 @@ --- Add migration script here --- public.strategy_types definition - --- Drop table --- DROP TABLE public.strategy_types; - -CREATE TABLE public.strategy_types ( - id int4 GENERATED BY DEFAULT AS IDENTITY( INCREMENT BY 1 MINVALUE 1 MAXVALUE 2147483647 START 1 CACHE 1 NO CYCLE) NOT NULL, - code varchar(50) NOT NULL, -- 策略代码(对应 Rust 枚举值) - name varchar(100) NOT NULL, -- 策略名称 - category varchar(50) NULL, -- 分类 - description text NULL, -- 描述 - processor_type varchar(50) NOT NULL, -- 处理器类型(对应 ProcessorType 枚举) - config_schema text NULL, -- 配置JSON Schema - is_active bool DEFAULT true NOT NULL, -- 是否激活 - CONSTRAINT strategy_types_pk PRIMARY KEY (id), - CONSTRAINT strategy_types_code_unique UNIQUE (code) -); -CREATE UNIQUE INDEX strategy_types_code_idx ON public.strategy_types USING btree (code); -CREATE INDEX strategy_types_category_idx ON public.strategy_types USING btree (category); - --- Column comments -COMMENT ON COLUMN public.strategy_types.id IS '主键ID'; -COMMENT ON COLUMN public.strategy_types.code IS '策略代码(对应 Rust 枚举值)'; -COMMENT ON COLUMN public.strategy_types.name IS '策略名称'; -COMMENT ON COLUMN public.strategy_types.category IS '分类'; -COMMENT ON COLUMN public.strategy_types.description IS '描述'; -COMMENT ON COLUMN public.strategy_types.processor_type IS '处理器类型(对应 ProcessorType 枚举)'; -COMMENT ON COLUMN public.strategy_types.config_schema IS '配置JSON Schema'; -COMMENT ON COLUMN public.strategy_types.is_active IS '是否激活'; - diff --git a/src-tauri/crates/business/migrations/20250101000011_insert_strategy_types_data.sql b/src-tauri/crates/business/migrations/20250101000011_insert_strategy_types_data.sql deleted file mode 100644 index da96553..0000000 --- a/src-tauri/crates/business/migrations/20250101000011_insert_strategy_types_data.sql +++ /dev/null @@ -1,63 +0,0 @@ --- Add migration script here --- 初始化灰度策略类型数据(仅过滤类策略,不包含标签相关策略) - --- 插入过滤类策略 -INSERT INTO public.strategy_types (code, name, category, description, processor_type, config_schema, is_active) VALUES - ( - 'filter_whitelist', - '白名单过滤', - 'filter', - '基于机器码白名单的灰度策略,只对白名单中的机器生效', - 'filter_whitelist', - '{ - "type": "object", - "properties": { - "machines": { - "type": "array", - "items": {"type": "string"}, - "description": "机器码列表" - } - }, - "required": ["machines"] - }', - true - ), - ( - 'filter_percentage', - '百分比过滤', - 'filter', - '基于哈希百分比的一致性分配,按百分比随机分配机器到灰度', - 'filter_percentage', - '{ - "type": "object", - "properties": { - "percent": { - "type": "number", - "minimum": 0, - "maximum": 100, - "description": "百分比 (0-100)" - } - }, - "required": ["percent"] - }', - true - ), - ( - 'filter_random', - '随机过滤', - 'filter', - '基于随机种子的随机分配策略', - 'filter_random', - '{ - "type": "object", - "properties": { - "seed": { - "type": "integer", - "description": "随机种子" - } - }, - "required": ["seed"] - }', - true - ); - diff --git a/src-tauri/crates/business/migrations/20250120000001_create_teams.sql b/src-tauri/crates/business/migrations/20250120000001_create_teams.sql index 187d277..1436534 100644 --- a/src-tauri/crates/business/migrations/20250120000001_create_teams.sql +++ b/src-tauri/crates/business/migrations/20250120000001_create_teams.sql @@ -1,36 +1,33 @@ --- 创建 teams 表 --- 团队/工作空间表 - -CREATE TABLE IF NOT EXISTS teams ( - id SERIAL PRIMARY KEY, - uuid UUID NOT NULL DEFAULT gen_random_uuid() UNIQUE, - name VARCHAR(255) NOT NULL, - description TEXT, - -- 所有者 - owner_uuid UUID NOT NULL, - avatar_hash VARCHAR(255), - -- 配额限制 - max_members INT NOT NULL DEFAULT 10, - max_environments INT NOT NULL DEFAULT 100, - max_proxies INT NOT NULL DEFAULT 100, - -- 【关联】团队默认代理(外键在 proxies 表创建后添加) - default_proxy_uuid UUID, - -- 状态 - status VARCHAR(50) NOT NULL DEFAULT 'active', - -- 时间 - created_at TIMESTAMP WITH TIME ZONE NOT NULL DEFAULT CURRENT_TIMESTAMP, - updated_at TIMESTAMP WITH TIME ZONE NOT NULL DEFAULT CURRENT_TIMESTAMP, - deleted_at TIMESTAMP WITH TIME ZONE, - -- 约束 - CONSTRAINT fk_teams_owner FOREIGN KEY (owner_uuid) REFERENCES users(uuid) -); - --- 创建索引 -CREATE INDEX idx_teams_owner_uuid ON teams(owner_uuid); -CREATE INDEX idx_teams_status ON teams(status); -CREATE INDEX idx_teams_deleted_at ON teams(deleted_at); - --- 创建更新时间触发器 -CREATE TRIGGER update_teams_updated_at BEFORE UPDATE ON teams - FOR EACH ROW EXECUTE FUNCTION update_updated_at_column(); - +-- 创建 teams 表 +-- 团队/工作空间表 + +CREATE TABLE IF NOT EXISTS teams ( + id INTEGER PRIMARY KEY AUTOINCREMENT, + uuid TEXT NOT NULL DEFAULT (randomblob(16)) UNIQUE, + name VARCHAR(255) NOT NULL, + description TEXT, + -- 所有者 + owner_uuid TEXT NOT NULL, + avatar_hash VARCHAR(255), + -- 配额限制 + max_members INT NOT NULL DEFAULT 10, + max_environments INT NOT NULL DEFAULT 100, + max_proxies INT NOT NULL DEFAULT 100, + -- 【关联】团队默认代理(外键在 proxies 表创建后添加) + default_proxy_uuid TEXT, + -- 状态 + status VARCHAR(50) NOT NULL DEFAULT 'active', + -- 时间 + created_at TEXT NOT NULL DEFAULT CURRENT_TIMESTAMP, + updated_at TEXT NOT NULL DEFAULT CURRENT_TIMESTAMP, + deleted_at TEXT, + -- 约束 + CONSTRAINT fk_teams_owner FOREIGN KEY (owner_uuid) REFERENCES users(uuid) +); + +-- 创建索引 +CREATE INDEX idx_teams_owner_uuid ON teams(owner_uuid); +CREATE INDEX idx_teams_status ON teams(status); +CREATE INDEX idx_teams_deleted_at ON teams(deleted_at); + +-- 创建更新时间触发器 diff --git a/src-tauri/crates/business/migrations/20250120000002_create_team_members.sql b/src-tauri/crates/business/migrations/20250120000002_create_team_members.sql index 55732ef..5c91099 100644 --- a/src-tauri/crates/business/migrations/20250120000002_create_team_members.sql +++ b/src-tauri/crates/business/migrations/20250120000002_create_team_members.sql @@ -1,39 +1,36 @@ --- 创建 team_members 表 --- 团队成员关联表(用户 ↔ 团队,多对多) - -CREATE TABLE IF NOT EXISTS team_members ( - id SERIAL PRIMARY KEY, - team_uuid UUID NOT NULL, - user_uuid UUID NOT NULL, - -- 角色: owner, admin, editor, viewer - role VARCHAR(50) NOT NULL DEFAULT 'viewer', - -- 加入信息 - joined_at TIMESTAMP WITH TIME ZONE NOT NULL DEFAULT CURRENT_TIMESTAMP, - invited_by UUID, - -- 统计字段(冗余,提高查询性能) - environment_count INT NOT NULL DEFAULT 0, - group_count INT NOT NULL DEFAULT 0, - -- 状态 - status VARCHAR(50) NOT NULL DEFAULT 'active', - -- 时间 - created_at TIMESTAMP WITH TIME ZONE NOT NULL DEFAULT CURRENT_TIMESTAMP, - updated_at TIMESTAMP WITH TIME ZONE NOT NULL DEFAULT CURRENT_TIMESTAMP, - deleted_at TIMESTAMP WITH TIME ZONE, - -- 约束 - CONSTRAINT fk_team_members_team FOREIGN KEY (team_uuid) REFERENCES teams(uuid) ON DELETE CASCADE, - CONSTRAINT fk_team_members_user FOREIGN KEY (user_uuid) REFERENCES users(uuid) ON DELETE CASCADE, - CONSTRAINT fk_team_members_invited_by FOREIGN KEY (invited_by) REFERENCES users(uuid), - -- 唯一约束:用户在同一团队只能有一条记录 - CONSTRAINT uk_team_members UNIQUE (team_uuid, user_uuid) -); - --- 创建索引 -CREATE INDEX idx_team_members_team_uuid ON team_members(team_uuid); -CREATE INDEX idx_team_members_user_uuid ON team_members(user_uuid); -CREATE INDEX idx_team_members_role ON team_members(role); -CREATE INDEX idx_team_members_status ON team_members(status); - --- 创建更新时间触发器 -CREATE TRIGGER update_team_members_updated_at BEFORE UPDATE ON team_members - FOR EACH ROW EXECUTE FUNCTION update_updated_at_column(); - +-- 创建 team_members 表 +-- 团队成员关联表(用户 ↔ 团队,多对多) + +CREATE TABLE IF NOT EXISTS team_members ( + id INTEGER PRIMARY KEY AUTOINCREMENT, + team_uuid TEXT NOT NULL, + user_uuid TEXT NOT NULL, + -- 角色: owner, admin, editor, viewer + role VARCHAR(50) NOT NULL DEFAULT 'viewer', + -- 加入信息 + joined_at TEXT NOT NULL DEFAULT CURRENT_TIMESTAMP, + invited_by TEXT, + -- 统计字段(冗余,提高查询性能) + environment_count INT NOT NULL DEFAULT 0, + group_count INT NOT NULL DEFAULT 0, + -- 状态 + status VARCHAR(50) NOT NULL DEFAULT 'active', + -- 时间 + created_at TEXT NOT NULL DEFAULT CURRENT_TIMESTAMP, + updated_at TEXT NOT NULL DEFAULT CURRENT_TIMESTAMP, + deleted_at TEXT, + -- 约束 + CONSTRAINT fk_team_members_team FOREIGN KEY (team_uuid) REFERENCES teams(uuid) ON DELETE CASCADE, + CONSTRAINT fk_team_members_user FOREIGN KEY (user_uuid) REFERENCES users(uuid) ON DELETE CASCADE, + CONSTRAINT fk_team_members_invited_by FOREIGN KEY (invited_by) REFERENCES users(uuid), + -- 唯一约束:用户在同一团队只能有一条记录 + CONSTRAINT uk_team_members UNIQUE (team_uuid, user_uuid) +); + +-- 创建索引 +CREATE INDEX idx_team_members_team_uuid ON team_members(team_uuid); +CREATE INDEX idx_team_members_user_uuid ON team_members(user_uuid); +CREATE INDEX idx_team_members_role ON team_members(role); +CREATE INDEX idx_team_members_status ON team_members(status); + +-- 创建更新时间触发器 diff --git a/src-tauri/crates/business/migrations/20250120000003_create_team_invitations.sql b/src-tauri/crates/business/migrations/20250120000003_create_team_invitations.sql index 66fd558..40bd23d 100644 --- a/src-tauri/crates/business/migrations/20250120000003_create_team_invitations.sql +++ b/src-tauri/crates/business/migrations/20250120000003_create_team_invitations.sql @@ -1,37 +1,34 @@ --- 创建 team_invitations 表 --- 团队邀请表 - -CREATE TABLE IF NOT EXISTS team_invitations ( - id SERIAL PRIMARY KEY, - uuid UUID NOT NULL DEFAULT gen_random_uuid() UNIQUE, - team_uuid UUID NOT NULL, - email VARCHAR(255) NOT NULL, - -- 角色 - role VARCHAR(50) NOT NULL DEFAULT 'viewer', - -- 邀请者 - invited_by UUID NOT NULL, - -- 邀请链接 - token VARCHAR(255) NOT NULL UNIQUE, - expires_at TIMESTAMP WITH TIME ZONE NOT NULL, - -- 状态: pending, accepted, rejected, expired, cancelled - status VARCHAR(50) NOT NULL DEFAULT 'pending', - accepted_at TIMESTAMP WITH TIME ZONE, - -- 时间 - created_at TIMESTAMP WITH TIME ZONE NOT NULL DEFAULT CURRENT_TIMESTAMP, - updated_at TIMESTAMP WITH TIME ZONE NOT NULL DEFAULT CURRENT_TIMESTAMP, - -- 约束 - CONSTRAINT fk_invitations_team FOREIGN KEY (team_uuid) REFERENCES teams(uuid) ON DELETE CASCADE, - CONSTRAINT fk_invitations_invited_by FOREIGN KEY (invited_by) REFERENCES users(uuid) -); - --- 创建索引 -CREATE INDEX idx_invitations_team_uuid ON team_invitations(team_uuid); -CREATE INDEX idx_invitations_email ON team_invitations(email); -CREATE INDEX idx_invitations_token ON team_invitations(token); -CREATE INDEX idx_invitations_status ON team_invitations(status); -CREATE INDEX idx_invitations_expires_at ON team_invitations(expires_at); - --- 创建更新时间触发器 -CREATE TRIGGER update_team_invitations_updated_at BEFORE UPDATE ON team_invitations - FOR EACH ROW EXECUTE FUNCTION update_updated_at_column(); - +-- 创建 team_invitations 表 +-- 团队邀请表 + +CREATE TABLE IF NOT EXISTS team_invitations ( + id INTEGER PRIMARY KEY AUTOINCREMENT, + uuid TEXT NOT NULL DEFAULT (randomblob(16)) UNIQUE, + team_uuid TEXT NOT NULL, + email VARCHAR(255) NOT NULL, + -- 角色 + role VARCHAR(50) NOT NULL DEFAULT 'viewer', + -- 邀请者 + invited_by TEXT NOT NULL, + -- 邀请链接 + token VARCHAR(255) NOT NULL UNIQUE, + expires_at TEXT NOT NULL, + -- 状态: pending, accepted, rejected, expired, cancelled + status VARCHAR(50) NOT NULL DEFAULT 'pending', + accepted_at TEXT, + -- 时间 + created_at TEXT NOT NULL DEFAULT CURRENT_TIMESTAMP, + updated_at TEXT NOT NULL DEFAULT CURRENT_TIMESTAMP, + -- 约束 + CONSTRAINT fk_invitations_team FOREIGN KEY (team_uuid) REFERENCES teams(uuid) ON DELETE CASCADE, + CONSTRAINT fk_invitations_invited_by FOREIGN KEY (invited_by) REFERENCES users(uuid) +); + +-- 创建索引 +CREATE INDEX idx_invitations_team_uuid ON team_invitations(team_uuid); +CREATE INDEX idx_invitations_email ON team_invitations(email); +CREATE INDEX idx_invitations_token ON team_invitations(token); +CREATE INDEX idx_invitations_status ON team_invitations(status); +CREATE INDEX idx_invitations_expires_at ON team_invitations(expires_at); + +-- 创建更新时间触发器 diff --git a/src-tauri/crates/business/migrations/20250120000004_create_login_history.sql b/src-tauri/crates/business/migrations/20250120000004_create_login_history.sql deleted file mode 100644 index c3f17f5..0000000 --- a/src-tauri/crates/business/migrations/20250120000004_create_login_history.sql +++ /dev/null @@ -1,29 +0,0 @@ --- 创建 login_history 表 --- 登录历史表(安全审计) - -CREATE TABLE IF NOT EXISTS login_history ( - id BIGSERIAL PRIMARY KEY, - user_uuid UUID NOT NULL, - -- 登录信息 - ip_address VARCHAR(45) NOT NULL, - device_info VARCHAR(255), - user_agent TEXT, - -- 位置信息(通过 IP 解析) - location VARCHAR(255), - country VARCHAR(100), - city VARCHAR(100), - -- 结果 - success BOOLEAN NOT NULL DEFAULT TRUE, - failure_reason VARCHAR(255), - -- 时间 - created_at TIMESTAMP WITH TIME ZONE NOT NULL DEFAULT CURRENT_TIMESTAMP, - -- 约束 - CONSTRAINT fk_login_history_user FOREIGN KEY (user_uuid) REFERENCES users(uuid) ON DELETE CASCADE -); - --- 创建索引 -CREATE INDEX idx_login_history_user_uuid ON login_history(user_uuid); -CREATE INDEX idx_login_history_created_at ON login_history(created_at); -CREATE INDEX idx_login_history_ip ON login_history(ip_address); -CREATE INDEX idx_login_history_success ON login_history(success); - diff --git a/src-tauri/crates/business/migrations/20250120000005_alter_user_infos_add_current_team.sql b/src-tauri/crates/business/migrations/20250120000005_alter_user_infos_add_current_team.sql index d29b077..2db2e1d 100644 --- a/src-tauri/crates/business/migrations/20250120000005_alter_user_infos_add_current_team.sql +++ b/src-tauri/crates/business/migrations/20250120000005_alter_user_infos_add_current_team.sql @@ -1,22 +1,10 @@ --- 修改 user_infos 表,添加当前团队字段 --- 用于团队切换功能 - -ALTER TABLE user_infos -ADD COLUMN IF NOT EXISTS current_team_uuid UUID; - --- 添加外键约束(如果不存在) -DO $$ -BEGIN - IF NOT EXISTS ( - SELECT 1 FROM information_schema.table_constraints - WHERE constraint_name = 'fk_user_infos_current_team' - ) THEN - ALTER TABLE user_infos - ADD CONSTRAINT fk_user_infos_current_team - FOREIGN KEY (current_team_uuid) REFERENCES teams(uuid) ON DELETE SET NULL; - END IF; -END $$; - --- 创建索引 -CREATE INDEX IF NOT EXISTS idx_user_infos_current_team ON user_infos(current_team_uuid); - +-- 修改 user_infos 表,添加当前团队字段 +-- 用于团队切换功能 + +ALTER TABLE user_infos +ADD COLUMN current_team_uuid TEXT REFERENCES teams(uuid) ON DELETE SET NULL; + +-- 添加外键约束(如果不存在) + +-- 创建索引 +CREATE INDEX IF NOT EXISTS idx_user_infos_current_team ON user_infos(current_team_uuid); diff --git a/src-tauri/crates/business/migrations/20250120000006_create_groups.sql b/src-tauri/crates/business/migrations/20250120000006_create_groups.sql index fc868b2..79ef318 100644 --- a/src-tauri/crates/business/migrations/20250120000006_create_groups.sql +++ b/src-tauri/crates/business/migrations/20250120000006_create_groups.sql @@ -1,39 +1,35 @@ --- 创建 groups 表 --- 环境分组表 - -CREATE TABLE IF NOT EXISTS groups ( - id SERIAL PRIMARY KEY, - uuid UUID NOT NULL DEFAULT gen_random_uuid() UNIQUE, - user_uuid UUID NOT NULL, - team_uuid UUID, - -- 基础信息 - name VARCHAR(255) NOT NULL, - description TEXT, - color VARCHAR(50) DEFAULT 'gray', - sort_order INT DEFAULT 0, - -- 【关联】分组默认代理(外键在 proxies 表创建后添加) - default_proxy_uuid UUID, - -- 创建者 - created_by UUID, - -- 统计字段(计算字段) - environments_count INT DEFAULT 0, - -- 时间 - created_at TIMESTAMP WITH TIME ZONE NOT NULL DEFAULT CURRENT_TIMESTAMP, - updated_at TIMESTAMP WITH TIME ZONE NOT NULL DEFAULT CURRENT_TIMESTAMP, - deleted_at TIMESTAMP WITH TIME ZONE, - -- 约束 - CONSTRAINT fk_groups_user FOREIGN KEY (user_uuid) REFERENCES users(uuid), - CONSTRAINT fk_groups_team FOREIGN KEY (team_uuid) REFERENCES teams(uuid), - CONSTRAINT fk_groups_created_by FOREIGN KEY (created_by) REFERENCES users(uuid) - -- 注意: default_proxy_uuid 的外键需要在 proxies 表创建后添加 -); - --- 创建索引 -CREATE INDEX idx_groups_user_uuid ON groups(user_uuid); -CREATE INDEX idx_groups_team_uuid ON groups(team_uuid); -CREATE INDEX idx_groups_deleted_at ON groups(deleted_at); - --- 创建更新时间触发器 -CREATE TRIGGER update_groups_updated_at BEFORE UPDATE ON groups - FOR EACH ROW EXECUTE FUNCTION update_updated_at_column(); - +-- 创建 groups 表 +-- 环境分组表 + +CREATE TABLE IF NOT EXISTS groups ( + id INTEGER PRIMARY KEY AUTOINCREMENT, + uuid TEXT NOT NULL DEFAULT (randomblob(16)) UNIQUE, + user_uuid TEXT NOT NULL, + team_uuid TEXT, + -- 基础信息 + name VARCHAR(255) NOT NULL, + description TEXT, + color VARCHAR(50) DEFAULT 'gray', + sort_order INT DEFAULT 0, + -- 【关联】分组默认代理(外键在 proxies 表创建后添加) + default_proxy_uuid TEXT, + -- 创建者 + created_by TEXT, + -- 统计字段(计算字段) + environments_count INT DEFAULT 0, + -- 时间 + created_at TEXT NOT NULL DEFAULT CURRENT_TIMESTAMP, + updated_at TEXT NOT NULL DEFAULT CURRENT_TIMESTAMP, + deleted_at TEXT, + -- 约束 + CONSTRAINT fk_groups_team FOREIGN KEY (team_uuid) REFERENCES teams(uuid), + CONSTRAINT fk_groups_created_by FOREIGN KEY (created_by) REFERENCES users(uuid) + -- 注意: default_proxy_uuid 的外键需要在 proxies 表创建后添加 +); + +-- 创建索引 +CREATE INDEX idx_groups_user_uuid ON groups(user_uuid); +CREATE INDEX idx_groups_team_uuid ON groups(team_uuid); +CREATE INDEX idx_groups_deleted_at ON groups(deleted_at); + +-- 创建更新时间触发器 diff --git a/src-tauri/crates/business/migrations/20250120000007_create_tags.sql b/src-tauri/crates/business/migrations/20250120000007_create_tags.sql index c7d9c63..ced29f2 100644 --- a/src-tauri/crates/business/migrations/20250120000007_create_tags.sql +++ b/src-tauri/crates/business/migrations/20250120000007_create_tags.sql @@ -1,31 +1,28 @@ --- 创建 tags 表 --- 标签表 - -CREATE TABLE IF NOT EXISTS tags ( - id SERIAL PRIMARY KEY, - uuid UUID NOT NULL DEFAULT gen_random_uuid() UNIQUE, - user_uuid UUID NOT NULL, - team_uuid UUID, - name VARCHAR(100) NOT NULL, - color VARCHAR(50) DEFAULT 'gray', - sort_order INT DEFAULT 0, - -- 统计字段(计算字段,定期更新或触发器维护) - environments_count INT DEFAULT 0, - -- 时间 - created_at TIMESTAMP WITH TIME ZONE NOT NULL DEFAULT CURRENT_TIMESTAMP, - updated_at TIMESTAMP WITH TIME ZONE NOT NULL DEFAULT CURRENT_TIMESTAMP, - deleted_at TIMESTAMP WITH TIME ZONE, - -- 约束 - CONSTRAINT fk_tags_user FOREIGN KEY (user_uuid) REFERENCES users(uuid), - CONSTRAINT fk_tags_team FOREIGN KEY (team_uuid) REFERENCES teams(uuid) -); - --- 创建索引 -CREATE INDEX idx_tags_user_uuid ON tags(user_uuid); -CREATE INDEX idx_tags_team_uuid ON tags(team_uuid); -CREATE INDEX idx_tags_deleted_at ON tags(deleted_at); - --- 创建更新时间触发器 -CREATE TRIGGER update_tags_updated_at BEFORE UPDATE ON tags - FOR EACH ROW EXECUTE FUNCTION update_updated_at_column(); - +-- 创建 tags 表 +-- 标签表 + +CREATE TABLE IF NOT EXISTS tags ( + id INTEGER PRIMARY KEY AUTOINCREMENT, + uuid TEXT NOT NULL DEFAULT (randomblob(16)) UNIQUE, + user_uuid TEXT NOT NULL, + team_uuid TEXT, + name VARCHAR(100) NOT NULL, + color VARCHAR(50) DEFAULT 'gray', + sort_order INT DEFAULT 0, + -- 统计字段(计算字段,定期更新或触发器维护) + environments_count INT DEFAULT 0, + -- 时间 + created_at TEXT NOT NULL DEFAULT CURRENT_TIMESTAMP, + updated_at TEXT NOT NULL DEFAULT CURRENT_TIMESTAMP, + deleted_at TEXT, + -- 约束 + CONSTRAINT fk_tags_user FOREIGN KEY (user_uuid) REFERENCES users(uuid), + CONSTRAINT fk_tags_team FOREIGN KEY (team_uuid) REFERENCES teams(uuid) +); + +-- 创建索引 +CREATE INDEX idx_tags_user_uuid ON tags(user_uuid); +CREATE INDEX idx_tags_team_uuid ON tags(team_uuid); +CREATE INDEX idx_tags_deleted_at ON tags(deleted_at); + +-- 创建更新时间触发器 diff --git a/src-tauri/crates/business/migrations/20250120000008_create_proxies.sql b/src-tauri/crates/business/migrations/20250120000008_create_proxies.sql index eac8e32..a37dc5f 100644 --- a/src-tauri/crates/business/migrations/20250120000008_create_proxies.sql +++ b/src-tauri/crates/business/migrations/20250120000008_create_proxies.sql @@ -1,49 +1,45 @@ --- 创建 proxies 表 --- 代理服务器表 - -CREATE TABLE IF NOT EXISTS proxies ( - id SERIAL PRIMARY KEY, - uuid UUID NOT NULL DEFAULT gen_random_uuid() UNIQUE, - user_uuid UUID NOT NULL, - team_uuid UUID, - -- 基础信息 - name VARCHAR(255) NOT NULL, - host VARCHAR(255) NOT NULL, - port INT NOT NULL, - proxy_type VARCHAR(50) NOT NULL DEFAULT 'http', - -- 认证信息 - username VARCHAR(255), - password_encrypted TEXT, - -- SSH 类型额外字段 - ssh_key_encrypted TEXT, - ssh_passphrase_encrypted TEXT, - -- 地理位置信息 - country VARCHAR(100), - city VARCHAR(100), - -- 状态 - status VARCHAR(50) NOT NULL DEFAULT 'unknown', - latency INT, - last_check_ip VARCHAR(45), - last_checked_at TIMESTAMP WITH TIME ZONE, - -- 统计 - usage_count INT DEFAULT 0, - -- 时间 - created_at TIMESTAMP WITH TIME ZONE NOT NULL DEFAULT CURRENT_TIMESTAMP, - updated_at TIMESTAMP WITH TIME ZONE NOT NULL DEFAULT CURRENT_TIMESTAMP, - deleted_at TIMESTAMP WITH TIME ZONE, - -- 约束 - CONSTRAINT fk_proxies_user FOREIGN KEY (user_uuid) REFERENCES users(uuid), - CONSTRAINT fk_proxies_team FOREIGN KEY (team_uuid) REFERENCES teams(uuid) -); - --- 创建索引 -CREATE INDEX idx_proxies_user_uuid ON proxies(user_uuid); -CREATE INDEX idx_proxies_team_uuid ON proxies(team_uuid); -CREATE INDEX idx_proxies_proxy_type ON proxies(proxy_type); -CREATE INDEX idx_proxies_status ON proxies(status); -CREATE INDEX idx_proxies_deleted_at ON proxies(deleted_at); - --- 创建更新时间触发器 -CREATE TRIGGER update_proxies_updated_at BEFORE UPDATE ON proxies - FOR EACH ROW EXECUTE FUNCTION update_updated_at_column(); - +-- 创建 proxies 表 +-- 代理服务器表 + +CREATE TABLE IF NOT EXISTS proxies ( + id INTEGER PRIMARY KEY AUTOINCREMENT, + uuid TEXT NOT NULL DEFAULT (randomblob(16)) UNIQUE, + user_uuid TEXT, + team_uuid TEXT, + -- 基础信息 + name VARCHAR(255) NOT NULL, + host VARCHAR(255) NOT NULL, + port INT NOT NULL, + proxy_type VARCHAR(50) NOT NULL DEFAULT 'http', + -- 认证信息 + username VARCHAR(255), + password TEXT, + -- SSH 类型额外字段 + ssh_key_encrypted TEXT, + ssh_passphrase_encrypted TEXT, + -- 地理位置信息 + country VARCHAR(100), + city VARCHAR(100), + -- 状态 + status VARCHAR(50) NOT NULL DEFAULT 'unknown', + latency INT, + last_check_ip VARCHAR(45), + last_checked_at TEXT, + -- 统计 + usage_count INT DEFAULT 0, + -- 时间 + created_at TEXT NOT NULL DEFAULT CURRENT_TIMESTAMP, + updated_at TEXT NOT NULL DEFAULT CURRENT_TIMESTAMP, + deleted_at TEXT, + -- 约束 + CONSTRAINT fk_proxies_user FOREIGN KEY (user_uuid) REFERENCES users(uuid) +); + +-- 创建索引 +CREATE INDEX idx_proxies_user_uuid ON proxies(user_uuid); +CREATE INDEX idx_proxies_team_uuid ON proxies(team_uuid); +CREATE INDEX idx_proxies_proxy_type ON proxies(proxy_type); +CREATE INDEX idx_proxies_status ON proxies(status); +CREATE INDEX idx_proxies_deleted_at ON proxies(deleted_at); + +-- 创建更新时间触发器 diff --git a/src-tauri/crates/business/migrations/20250120000009_create_proxy_health_checks.sql b/src-tauri/crates/business/migrations/20250120000009_create_proxy_health_checks.sql index 8484ec2..9258f69 100644 --- a/src-tauri/crates/business/migrations/20250120000009_create_proxy_health_checks.sql +++ b/src-tauri/crates/business/migrations/20250120000009_create_proxy_health_checks.sql @@ -1,23 +1,22 @@ --- 创建 proxy_health_checks 表 --- 代理健康检查记录表 - -CREATE TABLE IF NOT EXISTS proxy_health_checks ( - id BIGSERIAL PRIMARY KEY, - proxy_uuid UUID NOT NULL, - -- 检查结果 - status VARCHAR(50) NOT NULL, - latency INT, - ip_address VARCHAR(45), - error_message TEXT, - -- 时间 - checked_at TIMESTAMP WITH TIME ZONE NOT NULL DEFAULT CURRENT_TIMESTAMP, - -- 约束 - CONSTRAINT fk_proxy_health_checks_proxy FOREIGN KEY (proxy_uuid) - REFERENCES proxies(uuid) ON DELETE CASCADE -); - --- 创建索引 -CREATE INDEX idx_proxy_health_checks_proxy_uuid ON proxy_health_checks(proxy_uuid); -CREATE INDEX idx_proxy_health_checks_checked_at ON proxy_health_checks(checked_at); -CREATE INDEX idx_proxy_health_checks_status ON proxy_health_checks(status); - +-- 创建 proxy_health_checks 表 +-- 代理健康检查记录表 + +CREATE TABLE IF NOT EXISTS proxy_health_checks ( + id INTEGER PRIMARY KEY AUTOINCREMENT, + proxy_uuid TEXT NOT NULL, + -- 检查结果 + status VARCHAR(50) NOT NULL, + latency INT, + ip_address VARCHAR(45), + error_message TEXT, + -- 时间 + checked_at TEXT NOT NULL DEFAULT CURRENT_TIMESTAMP, + -- 约束 + CONSTRAINT fk_proxy_health_checks_proxy FOREIGN KEY (proxy_uuid) + REFERENCES proxies(uuid) ON DELETE CASCADE +); + +-- 创建索引 +CREATE INDEX idx_proxy_health_checks_proxy_uuid ON proxy_health_checks(proxy_uuid); +CREATE INDEX idx_proxy_health_checks_checked_at ON proxy_health_checks(checked_at); +CREATE INDEX idx_proxy_health_checks_status ON proxy_health_checks(status); diff --git a/src-tauri/crates/business/migrations/20250120000010_create_environments.sql b/src-tauri/crates/business/migrations/20250120000010_create_environments.sql index c8e45eb..f9dae0e 100644 --- a/src-tauri/crates/business/migrations/20250120000010_create_environments.sql +++ b/src-tauri/crates/business/migrations/20250120000010_create_environments.sql @@ -1,48 +1,45 @@ --- 创建 environments 表 --- 环境基础信息表 - -CREATE TABLE IF NOT EXISTS environments ( - id SERIAL PRIMARY KEY, - uuid UUID NOT NULL DEFAULT gen_random_uuid() UNIQUE, - user_uuid UUID NOT NULL, - team_uuid UUID, - -- 基础信息 - name VARCHAR(255) NOT NULL, - description TEXT, - icon VARCHAR(50) DEFAULT 'chrome', - icon_color VARCHAR(50) DEFAULT 'text-gray-500', - -- 状态: ready, running, error - status VARCHAR(50) NOT NULL DEFAULT 'ready', - -- 【关联】分组 - group_uuid UUID, - -- 【关联】代理(环境直接使用的代理,优先级高于分组默认代理) - proxy_uuid UUID, - -- 摘要信息(用于列表显示) - system_info VARCHAR(100), - kernel_info VARCHAR(100), - fingerprint_summary VARCHAR(255), - -- 时间 - last_opened_at TIMESTAMP WITH TIME ZONE, - created_at TIMESTAMP WITH TIME ZONE NOT NULL DEFAULT CURRENT_TIMESTAMP, - updated_at TIMESTAMP WITH TIME ZONE NOT NULL DEFAULT CURRENT_TIMESTAMP, - deleted_at TIMESTAMP WITH TIME ZONE, - -- 约束 - CONSTRAINT fk_environments_user FOREIGN KEY (user_uuid) REFERENCES users(uuid), - CONSTRAINT fk_environments_team FOREIGN KEY (team_uuid) REFERENCES teams(uuid), - CONSTRAINT fk_environments_group FOREIGN KEY (group_uuid) REFERENCES groups(uuid) ON DELETE SET NULL, - CONSTRAINT fk_environments_proxy FOREIGN KEY (proxy_uuid) REFERENCES proxies(uuid) ON DELETE SET NULL -); - --- 创建索引 -CREATE INDEX idx_environments_user_uuid ON environments(user_uuid); -CREATE INDEX idx_environments_team_uuid ON environments(team_uuid); -CREATE INDEX idx_environments_group_uuid ON environments(group_uuid); -CREATE INDEX idx_environments_proxy_uuid ON environments(proxy_uuid); -CREATE INDEX idx_environments_status ON environments(status); -CREATE INDEX idx_environments_deleted_at ON environments(deleted_at); -CREATE INDEX idx_environments_name ON environments(name); - --- 创建更新时间触发器 -CREATE TRIGGER update_environments_updated_at BEFORE UPDATE ON environments - FOR EACH ROW EXECUTE FUNCTION update_updated_at_column(); - +-- 创建 environments 表 +-- 环境基础信息表 + +CREATE TABLE IF NOT EXISTS environments ( + id INTEGER PRIMARY KEY AUTOINCREMENT, + uuid TEXT NOT NULL DEFAULT (randomblob(16)) UNIQUE, + user_uuid TEXT NOT NULL, + team_uuid TEXT, + -- 基础信息 + name VARCHAR(255) NOT NULL, + description TEXT, + icon VARCHAR(50) DEFAULT 'chrome', + icon_color VARCHAR(50) DEFAULT 'text-gray-500', + -- 状态: ready, running, error + status VARCHAR(50) NOT NULL DEFAULT 'ready', + -- 【关联】分组 + group_uuid TEXT, + -- 【关联】代理(环境直接使用的代理,优先级高于分组默认代理) + proxy_uuid TEXT, + -- 摘要信息(用于列表显示) + system_info VARCHAR(100), + kernel_info VARCHAR(100), + fingerprint_summary VARCHAR(255), + -- 时间 + last_opened_at TEXT, + created_at TEXT NOT NULL DEFAULT CURRENT_TIMESTAMP, + updated_at TEXT NOT NULL DEFAULT CURRENT_TIMESTAMP, + deleted_at TEXT, + -- 约束 + CONSTRAINT fk_environments_user FOREIGN KEY (user_uuid) REFERENCES users(uuid), + CONSTRAINT fk_environments_team FOREIGN KEY (team_uuid) REFERENCES teams(uuid), + CONSTRAINT fk_environments_group FOREIGN KEY (group_uuid) REFERENCES groups(uuid) ON DELETE SET NULL, + CONSTRAINT fk_environments_proxy FOREIGN KEY (proxy_uuid) REFERENCES proxies(uuid) ON DELETE SET NULL +); + +-- 创建索引 +CREATE INDEX idx_environments_user_uuid ON environments(user_uuid); +CREATE INDEX idx_environments_team_uuid ON environments(team_uuid); +CREATE INDEX idx_environments_group_uuid ON environments(group_uuid); +CREATE INDEX idx_environments_proxy_uuid ON environments(proxy_uuid); +CREATE INDEX idx_environments_status ON environments(status); +CREATE INDEX idx_environments_deleted_at ON environments(deleted_at); +CREATE INDEX idx_environments_name ON environments(name); + +-- 创建更新时间触发器 diff --git a/src-tauri/crates/business/migrations/20250120000011_create_environment_configs.sql b/src-tauri/crates/business/migrations/20250120000011_create_environment_configs.sql index a2aca75..e14d678 100644 --- a/src-tauri/crates/business/migrations/20250120000011_create_environment_configs.sql +++ b/src-tauri/crates/business/migrations/20250120000011_create_environment_configs.sql @@ -1,33 +1,30 @@ --- 创建 environment_configs 表 --- 环境完整配置表(存储 WindowConfig,与 environments 1:1) - -CREATE TABLE IF NOT EXISTS environment_configs ( - id SERIAL PRIMARY KEY, - environment_uuid UUID NOT NULL UNIQUE, - -- WindowInfo - window_info JSONB NOT NULL DEFAULT '{}', - -- BasicSettings - basic_settings JSONB NOT NULL DEFAULT '{}', - -- AdvancedFingerprintSettings - fingerprint_settings JSONB NOT NULL DEFAULT '{}', - -- DeviceSettings - device_settings JSONB NOT NULL DEFAULT '{}', - -- PreferenceSettings - preference_settings JSONB NOT NULL DEFAULT '{}', - -- ProjectMetadata - project_metadata JSONB NOT NULL DEFAULT '{}', - -- 时间 - created_at TIMESTAMP WITH TIME ZONE NOT NULL DEFAULT CURRENT_TIMESTAMP, - updated_at TIMESTAMP WITH TIME ZONE NOT NULL DEFAULT CURRENT_TIMESTAMP, - -- 约束 - CONSTRAINT fk_env_configs_env FOREIGN KEY (environment_uuid) - REFERENCES environments(uuid) ON DELETE CASCADE -); - --- 创建索引 -CREATE INDEX idx_env_configs_env_uuid ON environment_configs(environment_uuid); - --- 创建更新时间触发器 -CREATE TRIGGER update_environment_configs_updated_at BEFORE UPDATE ON environment_configs - FOR EACH ROW EXECUTE FUNCTION update_updated_at_column(); - +-- 创建 environment_configs 表 +-- 环境完整配置表(存储 WindowConfig,与 environments 1:1) + +CREATE TABLE IF NOT EXISTS environment_configs ( + id INTEGER PRIMARY KEY AUTOINCREMENT, + environment_uuid TEXT NOT NULL UNIQUE, + -- WindowInfo + window_info TEXT NOT NULL DEFAULT '{}', + -- BasicSettings + basic_settings TEXT NOT NULL DEFAULT '{}', + -- AdvancedFingerprintSettings + fingerprint_settings TEXT NOT NULL DEFAULT '{}', + -- DeviceSettings + device_settings TEXT NOT NULL DEFAULT '{}', + -- PreferenceSettings + preference_settings TEXT NOT NULL DEFAULT '{}', + -- ProjectMetadata + project_metadata TEXT NOT NULL DEFAULT '{}', + -- 时间 + created_at TEXT NOT NULL DEFAULT CURRENT_TIMESTAMP, + updated_at TEXT NOT NULL DEFAULT CURRENT_TIMESTAMP, + -- 约束 + CONSTRAINT fk_env_configs_env FOREIGN KEY (environment_uuid) + REFERENCES environments(uuid) ON DELETE CASCADE +); + +-- 创建索引 +CREATE INDEX idx_env_configs_env_uuid ON environment_configs(environment_uuid); + +-- 创建更新时间触发器 diff --git a/src-tauri/crates/business/migrations/20250120000012_create_environment_tags.sql b/src-tauri/crates/business/migrations/20250120000012_create_environment_tags.sql index e92b671..25d465d 100644 --- a/src-tauri/crates/business/migrations/20250120000012_create_environment_tags.sql +++ b/src-tauri/crates/business/migrations/20250120000012_create_environment_tags.sql @@ -1,21 +1,20 @@ --- 创建 environment_tags 表 --- 环境-标签关联表(多对多) - -CREATE TABLE IF NOT EXISTS environment_tags ( - id SERIAL PRIMARY KEY, - environment_uuid UUID NOT NULL, - tag_uuid UUID NOT NULL, - created_at TIMESTAMP WITH TIME ZONE NOT NULL DEFAULT CURRENT_TIMESTAMP, - -- 约束 - CONSTRAINT fk_env_tags_env FOREIGN KEY (environment_uuid) - REFERENCES environments(uuid) ON DELETE CASCADE, - CONSTRAINT fk_env_tags_tag FOREIGN KEY (tag_uuid) - REFERENCES tags(uuid) ON DELETE CASCADE, - -- 唯一约束:同一环境不能重复添加同一标签 - CONSTRAINT uk_env_tags UNIQUE (environment_uuid, tag_uuid) -); - --- 创建索引 -CREATE INDEX idx_env_tags_env_uuid ON environment_tags(environment_uuid); -CREATE INDEX idx_env_tags_tag_uuid ON environment_tags(tag_uuid); - +-- 创建 environment_tags 表 +-- 环境-标签关联表(多对多) + +CREATE TABLE IF NOT EXISTS environment_tags ( + id INTEGER PRIMARY KEY AUTOINCREMENT, + environment_uuid TEXT NOT NULL, + tag_uuid TEXT NOT NULL, + created_at TEXT NOT NULL DEFAULT CURRENT_TIMESTAMP, + -- 约束 + CONSTRAINT fk_env_tags_env FOREIGN KEY (environment_uuid) + REFERENCES environments(uuid) ON DELETE CASCADE, + CONSTRAINT fk_env_tags_tag FOREIGN KEY (tag_uuid) + REFERENCES tags(uuid) ON DELETE CASCADE, + -- 唯一约束:同一环境不能重复添加同一标签 + CONSTRAINT uk_env_tags UNIQUE (environment_uuid, tag_uuid) +); + +-- 创建索引 +CREATE INDEX idx_env_tags_env_uuid ON environment_tags(environment_uuid); +CREATE INDEX idx_env_tags_tag_uuid ON environment_tags(tag_uuid); diff --git a/src-tauri/crates/business/migrations/20250120000013_create_environment_urls.sql b/src-tauri/crates/business/migrations/20250120000013_create_environment_urls.sql index 0000fd6..ee3648c 100644 --- a/src-tauri/crates/business/migrations/20250120000013_create_environment_urls.sql +++ b/src-tauri/crates/business/migrations/20250120000013_create_environment_urls.sql @@ -1,18 +1,17 @@ --- 创建 environment_urls 表 --- 环境预设 URL 表(环境可有多个预设 URL) - -CREATE TABLE IF NOT EXISTS environment_urls ( - id SERIAL PRIMARY KEY, - environment_uuid UUID NOT NULL, - url VARCHAR(2048) NOT NULL, - title VARCHAR(255), - sort_order INT DEFAULT 0, - created_at TIMESTAMP WITH TIME ZONE NOT NULL DEFAULT CURRENT_TIMESTAMP, - -- 约束 - CONSTRAINT fk_env_urls_env FOREIGN KEY (environment_uuid) - REFERENCES environments(uuid) ON DELETE CASCADE -); - --- 创建索引 -CREATE INDEX idx_env_urls_env_uuid ON environment_urls(environment_uuid); - +-- 创建 environment_urls 表 +-- 环境预设 URL 表(环境可有多个预设 URL) + +CREATE TABLE IF NOT EXISTS environment_urls ( + id INTEGER PRIMARY KEY AUTOINCREMENT, + environment_uuid TEXT NOT NULL, + url VARCHAR(2048) NOT NULL, + title VARCHAR(255), + sort_order INT DEFAULT 0, + created_at TEXT NOT NULL DEFAULT CURRENT_TIMESTAMP, + -- 约束 + CONSTRAINT fk_env_urls_env FOREIGN KEY (environment_uuid) + REFERENCES environments(uuid) ON DELETE CASCADE +); + +-- 创建索引 +CREATE INDEX idx_env_urls_env_uuid ON environment_urls(environment_uuid); diff --git a/src-tauri/crates/business/migrations/20250120000014_create_environment_cookies.sql b/src-tauri/crates/business/migrations/20250120000014_create_environment_cookies.sql index 54754ac..2e24de3 100644 --- a/src-tauri/crates/business/migrations/20250120000014_create_environment_cookies.sql +++ b/src-tauri/crates/business/migrations/20250120000014_create_environment_cookies.sql @@ -1,24 +1,23 @@ --- 创建 environment_cookies 表 --- 环境 Cookie 表(环境可导入多个 Cookie) - -CREATE TABLE IF NOT EXISTS environment_cookies ( - id SERIAL PRIMARY KEY, - environment_uuid UUID NOT NULL, - domain VARCHAR(255) NOT NULL, - name VARCHAR(255) NOT NULL, - value TEXT NOT NULL, - path VARCHAR(255) DEFAULT '/', - expires_at TIMESTAMP WITH TIME ZONE, - http_only BOOLEAN DEFAULT FALSE, - secure BOOLEAN DEFAULT FALSE, - same_site VARCHAR(20) DEFAULT 'Lax', - created_at TIMESTAMP WITH TIME ZONE NOT NULL DEFAULT CURRENT_TIMESTAMP, - -- 约束 - CONSTRAINT fk_env_cookies_env FOREIGN KEY (environment_uuid) - REFERENCES environments(uuid) ON DELETE CASCADE -); - --- 创建索引 -CREATE INDEX idx_env_cookies_env_uuid ON environment_cookies(environment_uuid); -CREATE INDEX idx_env_cookies_domain ON environment_cookies(domain); - +-- 创建 environment_cookies 表 +-- 环境 Cookie 表(环境可导入多个 Cookie) + +CREATE TABLE IF NOT EXISTS environment_cookies ( + id INTEGER PRIMARY KEY AUTOINCREMENT, + environment_uuid TEXT NOT NULL, + domain VARCHAR(255) NOT NULL, + name VARCHAR(255) NOT NULL, + value TEXT NOT NULL, + path VARCHAR(255) DEFAULT '/', + expires_at TEXT, + http_only INTEGER DEFAULT FALSE, + secure INTEGER DEFAULT FALSE, + same_site VARCHAR(20) DEFAULT 'Lax', + created_at TEXT NOT NULL DEFAULT CURRENT_TIMESTAMP, + -- 约束 + CONSTRAINT fk_env_cookies_env FOREIGN KEY (environment_uuid) + REFERENCES environments(uuid) ON DELETE CASCADE +); + +-- 创建索引 +CREATE INDEX idx_env_cookies_env_uuid ON environment_cookies(environment_uuid); +CREATE INDEX idx_env_cookies_domain ON environment_cookies(domain); diff --git a/src-tauri/crates/business/migrations/20250120000015_create_templates.sql b/src-tauri/crates/business/migrations/20250120000015_create_templates.sql index b052a46..a33855c 100644 --- a/src-tauri/crates/business/migrations/20250120000015_create_templates.sql +++ b/src-tauri/crates/business/migrations/20250120000015_create_templates.sql @@ -1,38 +1,35 @@ --- 创建 templates 表 --- 环境配置模板表 - -CREATE TABLE IF NOT EXISTS templates ( - id SERIAL PRIMARY KEY, - uuid UUID NOT NULL DEFAULT gen_random_uuid() UNIQUE, - user_uuid UUID NOT NULL, - team_uuid UUID, - name VARCHAR(255) NOT NULL, - description TEXT, - -- 是否公开(团队内所有人可用) - is_public BOOLEAN DEFAULT FALSE, - -- 摘要 - system_info VARCHAR(100), - kernel_info VARCHAR(100), - -- 完整配置(JSON 存储 WindowConfig 结构) - config_json JSONB NOT NULL DEFAULT '{}', - -- 使用统计 - usage_count INT DEFAULT 0, - -- 时间 - created_at TIMESTAMP WITH TIME ZONE NOT NULL DEFAULT CURRENT_TIMESTAMP, - updated_at TIMESTAMP WITH TIME ZONE NOT NULL DEFAULT CURRENT_TIMESTAMP, - deleted_at TIMESTAMP WITH TIME ZONE, - -- 约束 - CONSTRAINT fk_templates_user FOREIGN KEY (user_uuid) REFERENCES users(uuid), - CONSTRAINT fk_templates_team FOREIGN KEY (team_uuid) REFERENCES teams(uuid) -); - --- 创建索引 -CREATE INDEX idx_templates_user_uuid ON templates(user_uuid); -CREATE INDEX idx_templates_team_uuid ON templates(team_uuid); -CREATE INDEX idx_templates_is_public ON templates(is_public); -CREATE INDEX idx_templates_deleted_at ON templates(deleted_at); - --- 创建更新时间触发器 -CREATE TRIGGER update_templates_updated_at BEFORE UPDATE ON templates - FOR EACH ROW EXECUTE FUNCTION update_updated_at_column(); - +-- 创建 templates 表 +-- 环境配置模板表 + +CREATE TABLE IF NOT EXISTS templates ( + id INTEGER PRIMARY KEY AUTOINCREMENT, + uuid TEXT NOT NULL DEFAULT (randomblob(16)) UNIQUE, + user_uuid TEXT NOT NULL, + team_uuid TEXT, + name VARCHAR(255) NOT NULL, + description TEXT, + -- 是否公开(团队内所有人可用) + is_public INTEGER DEFAULT FALSE, + -- 摘要 + system_info VARCHAR(100), + kernel_info VARCHAR(100), + -- 完整配置(JSON 存储 WindowConfig 结构) + config_json TEXT NOT NULL DEFAULT '{}', + -- 使用统计 + usage_count INT DEFAULT 0, + -- 时间 + created_at TEXT NOT NULL DEFAULT CURRENT_TIMESTAMP, + updated_at TEXT NOT NULL DEFAULT CURRENT_TIMESTAMP, + deleted_at TEXT, + -- 约束 + CONSTRAINT fk_templates_user FOREIGN KEY (user_uuid) REFERENCES users(uuid), + CONSTRAINT fk_templates_team FOREIGN KEY (team_uuid) REFERENCES teams(uuid) +); + +-- 创建索引 +CREATE INDEX idx_templates_user_uuid ON templates(user_uuid); +CREATE INDEX idx_templates_team_uuid ON templates(team_uuid); +CREATE INDEX idx_templates_is_public ON templates(is_public); +CREATE INDEX idx_templates_deleted_at ON templates(deleted_at); + +-- 创建更新时间触发器 diff --git a/src-tauri/crates/business/migrations/20250120000016_create_platform_accounts.sql b/src-tauri/crates/business/migrations/20250120000016_create_platform_accounts.sql index f98ea80..4295dfc 100644 --- a/src-tauri/crates/business/migrations/20250120000016_create_platform_accounts.sql +++ b/src-tauri/crates/business/migrations/20250120000016_create_platform_accounts.sql @@ -1,40 +1,37 @@ --- 创建 platform_accounts 表 --- 平台账号表 - -CREATE TABLE IF NOT EXISTS platform_accounts ( - id SERIAL PRIMARY KEY, - uuid UUID NOT NULL DEFAULT gen_random_uuid() UNIQUE, - user_uuid UUID NOT NULL, - team_uuid UUID, - -- 平台信息 - platform_url VARCHAR(512) NOT NULL, - platform_name VARCHAR(100), - -- 账号信息 - account VARCHAR(255) NOT NULL, - password_encrypted TEXT, - -- 状态: active, inactive, expired - status VARCHAR(50) NOT NULL DEFAULT 'active', - remark TEXT, - -- 统计 - usage_count INT DEFAULT 0, - last_used_at TIMESTAMP WITH TIME ZONE, - -- 时间 - created_at TIMESTAMP WITH TIME ZONE NOT NULL DEFAULT CURRENT_TIMESTAMP, - updated_at TIMESTAMP WITH TIME ZONE NOT NULL DEFAULT CURRENT_TIMESTAMP, - deleted_at TIMESTAMP WITH TIME ZONE, - -- 约束 - CONSTRAINT fk_platform_accounts_user FOREIGN KEY (user_uuid) REFERENCES users(uuid), - CONSTRAINT fk_platform_accounts_team FOREIGN KEY (team_uuid) REFERENCES teams(uuid) -); - --- 创建索引 -CREATE INDEX idx_platform_accounts_user_uuid ON platform_accounts(user_uuid); -CREATE INDEX idx_platform_accounts_team_uuid ON platform_accounts(team_uuid); -CREATE INDEX idx_platform_accounts_platform_name ON platform_accounts(platform_name); -CREATE INDEX idx_platform_accounts_status ON platform_accounts(status); -CREATE INDEX idx_platform_accounts_deleted_at ON platform_accounts(deleted_at); - --- 创建更新时间触发器 -CREATE TRIGGER update_platform_accounts_updated_at BEFORE UPDATE ON platform_accounts - FOR EACH ROW EXECUTE FUNCTION update_updated_at_column(); - +-- 创建 platform_accounts 表 +-- 平台账号表 + +CREATE TABLE IF NOT EXISTS platform_accounts ( + id INTEGER PRIMARY KEY AUTOINCREMENT, + uuid TEXT NOT NULL DEFAULT (randomblob(16)) UNIQUE, + user_uuid TEXT NOT NULL, + team_uuid TEXT, + -- 平台信息 + platform_url VARCHAR(512) NOT NULL, + platform_name VARCHAR(100), + -- 账号信息 + account VARCHAR(255) NOT NULL, + password TEXT, + -- 状态: active, inactive, expired + status VARCHAR(50) NOT NULL DEFAULT 'active', + remark TEXT, + -- 统计 + usage_count INT DEFAULT 0, + last_used_at TEXT, + -- 时间 + created_at TEXT NOT NULL DEFAULT CURRENT_TIMESTAMP, + updated_at TEXT NOT NULL DEFAULT CURRENT_TIMESTAMP, + deleted_at TEXT, + -- 约束 + CONSTRAINT fk_platform_accounts_user FOREIGN KEY (user_uuid) REFERENCES users(uuid), + CONSTRAINT fk_platform_accounts_team FOREIGN KEY (team_uuid) REFERENCES teams(uuid) +); + +-- 创建索引 +CREATE INDEX idx_platform_accounts_user_uuid ON platform_accounts(user_uuid); +CREATE INDEX idx_platform_accounts_team_uuid ON platform_accounts(team_uuid); +CREATE INDEX idx_platform_accounts_platform_name ON platform_accounts(platform_name); +CREATE INDEX idx_platform_accounts_status ON platform_accounts(status); +CREATE INDEX idx_platform_accounts_deleted_at ON platform_accounts(deleted_at); + +-- 创建更新时间触发器 diff --git a/src-tauri/crates/business/migrations/20250120000017_create_environment_accounts.sql b/src-tauri/crates/business/migrations/20250120000017_create_environment_accounts.sql index d22007f..7423bb5 100644 --- a/src-tauri/crates/business/migrations/20250120000017_create_environment_accounts.sql +++ b/src-tauri/crates/business/migrations/20250120000017_create_environment_accounts.sql @@ -1,22 +1,21 @@ --- 创建 environment_accounts 表 --- 环境-账号关联表(多对多) - -CREATE TABLE IF NOT EXISTS environment_accounts ( - id SERIAL PRIMARY KEY, - environment_uuid UUID NOT NULL, - account_uuid UUID NOT NULL, - sort_order INT DEFAULT 0, - created_at TIMESTAMP WITH TIME ZONE NOT NULL DEFAULT CURRENT_TIMESTAMP, - -- 约束 - CONSTRAINT fk_env_accounts_env FOREIGN KEY (environment_uuid) - REFERENCES environments(uuid) ON DELETE CASCADE, - CONSTRAINT fk_env_accounts_account FOREIGN KEY (account_uuid) - REFERENCES platform_accounts(uuid) ON DELETE CASCADE, - -- 唯一约束:同一环境不能重复关联同一账号 - CONSTRAINT uk_env_accounts UNIQUE (environment_uuid, account_uuid) -); - --- 创建索引 -CREATE INDEX idx_env_accounts_env_uuid ON environment_accounts(environment_uuid); -CREATE INDEX idx_env_accounts_account_uuid ON environment_accounts(account_uuid); - +-- 创建 environment_accounts 表 +-- 环境-账号关联表(多对多) + +CREATE TABLE IF NOT EXISTS environment_accounts ( + id INTEGER PRIMARY KEY AUTOINCREMENT, + environment_uuid TEXT NOT NULL, + account_uuid TEXT NOT NULL, + sort_order INT DEFAULT 0, + created_at TEXT NOT NULL DEFAULT CURRENT_TIMESTAMP, + -- 约束 + CONSTRAINT fk_env_accounts_env FOREIGN KEY (environment_uuid) + REFERENCES environments(uuid) ON DELETE CASCADE, + CONSTRAINT fk_env_accounts_account FOREIGN KEY (account_uuid) + REFERENCES platform_accounts(uuid) ON DELETE CASCADE, + -- 唯一约束:同一环境不能重复关联同一账号 + CONSTRAINT uk_env_accounts UNIQUE (environment_uuid, account_uuid) +); + +-- 创建索引 +CREATE INDEX idx_env_accounts_env_uuid ON environment_accounts(environment_uuid); +CREATE INDEX idx_env_accounts_account_uuid ON environment_accounts(account_uuid); diff --git a/src-tauri/crates/business/migrations/20250120000018_create_extensions.sql b/src-tauri/crates/business/migrations/20250120000018_create_extensions.sql deleted file mode 100644 index b2d47f9..0000000 --- a/src-tauri/crates/business/migrations/20250120000018_create_extensions.sql +++ /dev/null @@ -1,48 +0,0 @@ --- 创建 extensions 表 --- 浏览器扩展表 - -CREATE TABLE IF NOT EXISTS extensions ( - id SERIAL PRIMARY KEY, - uuid UUID NOT NULL DEFAULT gen_random_uuid() UNIQUE, - -- 扩展 ID(Chrome/Firefox 商店 ID) - extension_id VARCHAR(255) NOT NULL UNIQUE, - name VARCHAR(255) NOT NULL, - description TEXT, - -- 版本信息 - version VARCHAR(50) NOT NULL, - -- 分类: productivity, privacy, automation, social, development, other - category VARCHAR(50) NOT NULL DEFAULT 'other', - -- 浏览器: chrome, firefox, edge, all - browser VARCHAR(50) NOT NULL DEFAULT 'all', - -- 开发者信息 - developer VARCHAR(255), - homepage VARCHAR(512), - icon_url VARCHAR(512), - -- 下载信息 - download_url VARCHAR(512), - file_size BIGINT, - -- 统计 - downloads_count BIGINT DEFAULT 0, - rating DECIMAL(3, 2), - -- 权限 - permissions JSONB DEFAULT '[]', - -- 状态: active, deprecated, removed - status VARCHAR(50) NOT NULL DEFAULT 'active', - -- 更新日志 - changelog JSONB DEFAULT '[]', - -- 时间 - published_at TIMESTAMP WITH TIME ZONE, - created_at TIMESTAMP WITH TIME ZONE NOT NULL DEFAULT CURRENT_TIMESTAMP, - updated_at TIMESTAMP WITH TIME ZONE NOT NULL DEFAULT CURRENT_TIMESTAMP -); - --- 创建索引 -CREATE INDEX idx_extensions_extension_id ON extensions(extension_id); -CREATE INDEX idx_extensions_category ON extensions(category); -CREATE INDEX idx_extensions_browser ON extensions(browser); -CREATE INDEX idx_extensions_status ON extensions(status); - --- 创建更新时间触发器 -CREATE TRIGGER update_extensions_updated_at BEFORE UPDATE ON extensions - FOR EACH ROW EXECUTE FUNCTION update_updated_at_column(); - diff --git a/src-tauri/crates/business/migrations/20250120000019_create_user_extensions.sql b/src-tauri/crates/business/migrations/20250120000019_create_user_extensions.sql deleted file mode 100644 index d351519..0000000 --- a/src-tauri/crates/business/migrations/20250120000019_create_user_extensions.sql +++ /dev/null @@ -1,28 +0,0 @@ --- 创建 user_extensions 表 --- 用户安装的扩展(用户级别) - -CREATE TABLE IF NOT EXISTS user_extensions ( - id SERIAL PRIMARY KEY, - user_uuid UUID NOT NULL, - extension_id VARCHAR(255) NOT NULL, - installed_version VARCHAR(50) NOT NULL, - -- 状态: installed, disabled - status VARCHAR(50) NOT NULL DEFAULT 'installed', - -- 时间 - installed_at TIMESTAMP WITH TIME ZONE NOT NULL DEFAULT CURRENT_TIMESTAMP, - updated_at TIMESTAMP WITH TIME ZONE NOT NULL DEFAULT CURRENT_TIMESTAMP, - -- 约束 - CONSTRAINT fk_user_extensions_user FOREIGN KEY (user_uuid) REFERENCES users(uuid) ON DELETE CASCADE, - CONSTRAINT fk_user_extensions_ext FOREIGN KEY (extension_id) REFERENCES extensions(extension_id) ON DELETE CASCADE, - -- 唯一约束 - CONSTRAINT uk_user_extensions UNIQUE (user_uuid, extension_id) -); - --- 创建索引 -CREATE INDEX idx_user_extensions_user_uuid ON user_extensions(user_uuid); -CREATE INDEX idx_user_extensions_extension_id ON user_extensions(extension_id); - --- 创建更新时间触发器 -CREATE TRIGGER update_user_extensions_updated_at BEFORE UPDATE ON user_extensions - FOR EACH ROW EXECUTE FUNCTION update_updated_at_column(); - diff --git a/src-tauri/crates/business/migrations/20250120000020_create_team_extensions.sql b/src-tauri/crates/business/migrations/20250120000020_create_team_extensions.sql deleted file mode 100644 index 80ff16f..0000000 --- a/src-tauri/crates/business/migrations/20250120000020_create_team_extensions.sql +++ /dev/null @@ -1,31 +0,0 @@ --- 创建 team_extensions 表 --- 团队安装的扩展(团队级别,所有团队成员可用) - -CREATE TABLE IF NOT EXISTS team_extensions ( - id SERIAL PRIMARY KEY, - team_uuid UUID NOT NULL, - extension_id VARCHAR(255) NOT NULL, - installed_version VARCHAR(50) NOT NULL, - -- 安装者 - installed_by UUID NOT NULL, - -- 状态: installed, disabled - status VARCHAR(50) NOT NULL DEFAULT 'installed', - -- 时间 - installed_at TIMESTAMP WITH TIME ZONE NOT NULL DEFAULT CURRENT_TIMESTAMP, - updated_at TIMESTAMP WITH TIME ZONE NOT NULL DEFAULT CURRENT_TIMESTAMP, - -- 约束 - CONSTRAINT fk_team_extensions_team FOREIGN KEY (team_uuid) REFERENCES teams(uuid) ON DELETE CASCADE, - CONSTRAINT fk_team_extensions_ext FOREIGN KEY (extension_id) REFERENCES extensions(extension_id) ON DELETE CASCADE, - CONSTRAINT fk_team_extensions_user FOREIGN KEY (installed_by) REFERENCES users(uuid), - -- 唯一约束 - CONSTRAINT uk_team_extensions UNIQUE (team_uuid, extension_id) -); - --- 创建索引 -CREATE INDEX idx_team_extensions_team_uuid ON team_extensions(team_uuid); -CREATE INDEX idx_team_extensions_extension_id ON team_extensions(extension_id); - --- 创建更新时间触发器 -CREATE TRIGGER update_team_extensions_updated_at BEFORE UPDATE ON team_extensions - FOR EACH ROW EXECUTE FUNCTION update_updated_at_column(); - diff --git a/src-tauri/crates/business/migrations/20250120000021_create_group_extensions.sql b/src-tauri/crates/business/migrations/20250120000021_create_group_extensions.sql deleted file mode 100644 index 73f9a7c..0000000 --- a/src-tauri/crates/business/migrations/20250120000021_create_group_extensions.sql +++ /dev/null @@ -1,31 +0,0 @@ --- 创建 group_extensions 表 --- 分组安装的扩展(分组级别,该分组下所有环境可用) - -CREATE TABLE IF NOT EXISTS group_extensions ( - id SERIAL PRIMARY KEY, - group_uuid UUID NOT NULL, - extension_id VARCHAR(255) NOT NULL, - installed_version VARCHAR(50) NOT NULL, - -- 安装者 - installed_by UUID NOT NULL, - -- 状态: installed, disabled - status VARCHAR(50) NOT NULL DEFAULT 'installed', - -- 时间 - installed_at TIMESTAMP WITH TIME ZONE NOT NULL DEFAULT CURRENT_TIMESTAMP, - updated_at TIMESTAMP WITH TIME ZONE NOT NULL DEFAULT CURRENT_TIMESTAMP, - -- 约束 - CONSTRAINT fk_group_extensions_group FOREIGN KEY (group_uuid) REFERENCES groups(uuid) ON DELETE CASCADE, - CONSTRAINT fk_group_extensions_ext FOREIGN KEY (extension_id) REFERENCES extensions(extension_id) ON DELETE CASCADE, - CONSTRAINT fk_group_extensions_user FOREIGN KEY (installed_by) REFERENCES users(uuid), - -- 唯一约束 - CONSTRAINT uk_group_extensions UNIQUE (group_uuid, extension_id) -); - --- 创建索引 -CREATE INDEX idx_group_extensions_group_uuid ON group_extensions(group_uuid); -CREATE INDEX idx_group_extensions_extension_id ON group_extensions(extension_id); - --- 创建更新时间触发器 -CREATE TRIGGER update_group_extensions_updated_at BEFORE UPDATE ON group_extensions - FOR EACH ROW EXECUTE FUNCTION update_updated_at_column(); - diff --git a/src-tauri/crates/business/migrations/20250120000022_create_environment_extensions.sql b/src-tauri/crates/business/migrations/20250120000022_create_environment_extensions.sql deleted file mode 100644 index d1bcdc0..0000000 --- a/src-tauri/crates/business/migrations/20250120000022_create_environment_extensions.sql +++ /dev/null @@ -1,28 +0,0 @@ --- 创建 environment_extensions 表 --- 环境安装的扩展(环境级别) - -CREATE TABLE IF NOT EXISTS environment_extensions ( - id SERIAL PRIMARY KEY, - environment_uuid UUID NOT NULL, - extension_id VARCHAR(255) NOT NULL, - installed_version VARCHAR(50) NOT NULL, - -- 状态: installed, disabled - status VARCHAR(50) NOT NULL DEFAULT 'installed', - -- 时间 - installed_at TIMESTAMP WITH TIME ZONE NOT NULL DEFAULT CURRENT_TIMESTAMP, - updated_at TIMESTAMP WITH TIME ZONE NOT NULL DEFAULT CURRENT_TIMESTAMP, - -- 约束 - CONSTRAINT fk_env_extensions_env FOREIGN KEY (environment_uuid) REFERENCES environments(uuid) ON DELETE CASCADE, - CONSTRAINT fk_env_extensions_ext FOREIGN KEY (extension_id) REFERENCES extensions(extension_id) ON DELETE CASCADE, - -- 唯一约束 - CONSTRAINT uk_env_extensions UNIQUE (environment_uuid, extension_id) -); - --- 创建索引 -CREATE INDEX idx_env_extensions_env_uuid ON environment_extensions(environment_uuid); -CREATE INDEX idx_env_extensions_extension_id ON environment_extensions(extension_id); - --- 创建更新时间触发器 -CREATE TRIGGER update_environment_extensions_updated_at BEFORE UPDATE ON environment_extensions - FOR EACH ROW EXECUTE FUNCTION update_updated_at_column(); - diff --git a/src-tauri/crates/business/migrations/20250120000023_create_plans.sql b/src-tauri/crates/business/migrations/20250120000023_create_plans.sql deleted file mode 100644 index 1848d36..0000000 --- a/src-tauri/crates/business/migrations/20250120000023_create_plans.sql +++ /dev/null @@ -1,40 +0,0 @@ --- 创建 plans 表 --- 订阅套餐表 - -CREATE TABLE IF NOT EXISTS plans ( - id SERIAL PRIMARY KEY, - uuid UUID NOT NULL DEFAULT gen_random_uuid() UNIQUE, - -- 基础信息 - name VARCHAR(100) NOT NULL, - description TEXT, - -- 价格 - price_per_month DECIMAL(12, 2) NOT NULL DEFAULT 0, - price_per_year DECIMAL(12, 2) NOT NULL DEFAULT 0, - currency VARCHAR(10) NOT NULL DEFAULT 'USD', - -- 折扣 - discount_monthly DECIMAL(5, 2) DEFAULT 0, - discount_yearly DECIMAL(5, 2) DEFAULT 0, - -- 配额限制 - max_environments INT NOT NULL DEFAULT 10, - max_team_members INT NOT NULL DEFAULT 5, - max_proxies INT NOT NULL DEFAULT 10, - max_rpa_tasks INT NOT NULL DEFAULT 5, - -- 是否推荐 - is_recommended BOOLEAN DEFAULT FALSE, - -- 排序 - sort_order INT DEFAULT 0, - -- 状态: active, deprecated - status VARCHAR(50) NOT NULL DEFAULT 'active', - -- 时间 - created_at TIMESTAMP WITH TIME ZONE NOT NULL DEFAULT CURRENT_TIMESTAMP, - updated_at TIMESTAMP WITH TIME ZONE NOT NULL DEFAULT CURRENT_TIMESTAMP -); - --- 创建索引 -CREATE INDEX idx_plans_status ON plans(status); -CREATE INDEX idx_plans_sort_order ON plans(sort_order); - --- 创建更新时间触发器 -CREATE TRIGGER update_plans_updated_at BEFORE UPDATE ON plans - FOR EACH ROW EXECUTE FUNCTION update_updated_at_column(); - diff --git a/src-tauri/crates/business/migrations/20250120000024_create_plan_features.sql b/src-tauri/crates/business/migrations/20250120000024_create_plan_features.sql deleted file mode 100644 index 531cdd8..0000000 --- a/src-tauri/crates/business/migrations/20250120000024_create_plan_features.sql +++ /dev/null @@ -1,25 +0,0 @@ --- 创建 plan_features 表 --- 套餐功能特性表 - -CREATE TABLE IF NOT EXISTS plan_features ( - id SERIAL PRIMARY KEY, - plan_uuid UUID NOT NULL, - -- 特性 - feature_key VARCHAR(100) NOT NULL, - feature_name VARCHAR(255) NOT NULL, - feature_value VARCHAR(255), - -- 是否包含 - is_included BOOLEAN DEFAULT TRUE, - -- 排序 - sort_order INT DEFAULT 0, - -- 时间 - created_at TIMESTAMP WITH TIME ZONE NOT NULL DEFAULT CURRENT_TIMESTAMP, - -- 约束 - CONSTRAINT fk_plan_features_plan FOREIGN KEY (plan_uuid) REFERENCES plans(uuid) ON DELETE CASCADE, - -- 唯一约束 - CONSTRAINT uk_plan_features UNIQUE (plan_uuid, feature_key) -); - --- 创建索引 -CREATE INDEX idx_plan_features_plan_uuid ON plan_features(plan_uuid); - diff --git a/src-tauri/crates/business/migrations/20250120000025_create_subscriptions.sql b/src-tauri/crates/business/migrations/20250120000025_create_subscriptions.sql deleted file mode 100644 index b8a7c8c..0000000 --- a/src-tauri/crates/business/migrations/20250120000025_create_subscriptions.sql +++ /dev/null @@ -1,40 +0,0 @@ --- 创建 subscriptions 表 --- 用户订阅表 - -CREATE TABLE IF NOT EXISTS subscriptions ( - id SERIAL PRIMARY KEY, - uuid UUID NOT NULL DEFAULT gen_random_uuid() UNIQUE, - user_uuid UUID NOT NULL, - plan_uuid UUID NOT NULL, - -- 订阅周期: monthly, yearly - billing_period VARCHAR(20) NOT NULL DEFAULT 'monthly', - -- 价格快照 - price DECIMAL(12, 2) NOT NULL, - currency VARCHAR(10) NOT NULL DEFAULT 'USD', - -- 时间 - started_at TIMESTAMP WITH TIME ZONE NOT NULL DEFAULT CURRENT_TIMESTAMP, - expires_at TIMESTAMP WITH TIME ZONE NOT NULL, - next_billing_date DATE, - -- 自动续费 - auto_renew BOOLEAN DEFAULT TRUE, - -- 状态: active, cancelled, expired, suspended - status VARCHAR(50) NOT NULL DEFAULT 'active', - cancelled_at TIMESTAMP WITH TIME ZONE, - -- 时间 - created_at TIMESTAMP WITH TIME ZONE NOT NULL DEFAULT CURRENT_TIMESTAMP, - updated_at TIMESTAMP WITH TIME ZONE NOT NULL DEFAULT CURRENT_TIMESTAMP, - -- 约束 - CONSTRAINT fk_subscriptions_user FOREIGN KEY (user_uuid) REFERENCES users(uuid), - CONSTRAINT fk_subscriptions_plan FOREIGN KEY (plan_uuid) REFERENCES plans(uuid) -); - --- 创建索引 -CREATE INDEX idx_subscriptions_user_uuid ON subscriptions(user_uuid); -CREATE INDEX idx_subscriptions_plan_uuid ON subscriptions(plan_uuid); -CREATE INDEX idx_subscriptions_status ON subscriptions(status); -CREATE INDEX idx_subscriptions_expires_at ON subscriptions(expires_at); - --- 创建更新时间触发器 -CREATE TRIGGER update_subscriptions_updated_at BEFORE UPDATE ON subscriptions - FOR EACH ROW EXECUTE FUNCTION update_updated_at_column(); - diff --git a/src-tauri/crates/business/migrations/20250120000026_create_user_wallets.sql b/src-tauri/crates/business/migrations/20250120000026_create_user_wallets.sql deleted file mode 100644 index 36c4f86..0000000 --- a/src-tauri/crates/business/migrations/20250120000026_create_user_wallets.sql +++ /dev/null @@ -1,27 +0,0 @@ --- 创建 user_wallets 表 --- 用户钱包表 - -CREATE TABLE IF NOT EXISTS user_wallets ( - id SERIAL PRIMARY KEY, - user_uuid UUID NOT NULL UNIQUE, - -- 余额 - balance DECIMAL(12, 2) NOT NULL DEFAULT 0, - currency VARCHAR(10) NOT NULL DEFAULT 'USD', - -- 冻结金额 - frozen_amount DECIMAL(12, 2) NOT NULL DEFAULT 0, - -- 自动续费组合金额 - auto_renewal_combined DECIMAL(12, 2) NOT NULL DEFAULT 0, - -- 时间 - created_at TIMESTAMP WITH TIME ZONE NOT NULL DEFAULT CURRENT_TIMESTAMP, - updated_at TIMESTAMP WITH TIME ZONE NOT NULL DEFAULT CURRENT_TIMESTAMP, - -- 约束 - CONSTRAINT fk_user_wallets_user FOREIGN KEY (user_uuid) REFERENCES users(uuid) ON DELETE CASCADE -); - --- 创建索引 -CREATE INDEX idx_user_wallets_user_uuid ON user_wallets(user_uuid); - --- 创建更新时间触发器 -CREATE TRIGGER update_user_wallets_updated_at BEFORE UPDATE ON user_wallets - FOR EACH ROW EXECUTE FUNCTION update_updated_at_column(); - diff --git a/src-tauri/crates/business/migrations/20250120000027_create_wallet_transactions.sql b/src-tauri/crates/business/migrations/20250120000027_create_wallet_transactions.sql deleted file mode 100644 index b556ffc..0000000 --- a/src-tauri/crates/business/migrations/20250120000027_create_wallet_transactions.sql +++ /dev/null @@ -1,33 +0,0 @@ --- 创建 wallet_transactions 表 --- 钱包交易记录表 - -CREATE TABLE IF NOT EXISTS wallet_transactions ( - id BIGSERIAL PRIMARY KEY, - uuid UUID NOT NULL DEFAULT gen_random_uuid() UNIQUE, - user_uuid UUID NOT NULL, - -- 交易类型: recharge, consume, refund, reward - transaction_type VARCHAR(50) NOT NULL, - -- 金额(正数为收入,负数为支出) - amount DECIMAL(12, 2) NOT NULL, - currency VARCHAR(10) NOT NULL DEFAULT 'USD', - -- 余额快照 - balance_before DECIMAL(12, 2) NOT NULL, - balance_after DECIMAL(12, 2) NOT NULL, - -- 描述 - description TEXT, - -- 关联订单 - order_uuid UUID, - -- 状态: pending, completed, failed - status VARCHAR(50) NOT NULL DEFAULT 'completed', - -- 时间 - created_at TIMESTAMP WITH TIME ZONE NOT NULL DEFAULT CURRENT_TIMESTAMP, - -- 约束 - CONSTRAINT fk_wallet_transactions_user FOREIGN KEY (user_uuid) REFERENCES users(uuid) -); - --- 创建索引 -CREATE INDEX idx_wallet_transactions_user_uuid ON wallet_transactions(user_uuid); -CREATE INDEX idx_wallet_transactions_type ON wallet_transactions(transaction_type); -CREATE INDEX idx_wallet_transactions_created_at ON wallet_transactions(created_at); -CREATE INDEX idx_wallet_transactions_order_uuid ON wallet_transactions(order_uuid); - diff --git a/src-tauri/crates/business/migrations/20250120000028_create_invoices.sql b/src-tauri/crates/business/migrations/20250120000028_create_invoices.sql deleted file mode 100644 index 84e7c70..0000000 --- a/src-tauri/crates/business/migrations/20250120000028_create_invoices.sql +++ /dev/null @@ -1,44 +0,0 @@ --- 创建 invoices 表 --- 发票表 - -CREATE TABLE IF NOT EXISTS invoices ( - id SERIAL PRIMARY KEY, - uuid UUID NOT NULL DEFAULT gen_random_uuid() UNIQUE, - user_uuid UUID NOT NULL, - -- 发票号 - invoice_number VARCHAR(100) NOT NULL UNIQUE, - -- 金额 - amount DECIMAL(12, 2) NOT NULL, - currency VARCHAR(10) NOT NULL DEFAULT 'USD', - -- 关联订阅/订单 - subscription_uuid UUID, - order_uuid UUID, - -- 发票类型: subscription, addon, recharge - invoice_type VARCHAR(50) NOT NULL, - -- 状态: draft, issued, paid, cancelled - status VARCHAR(50) NOT NULL DEFAULT 'draft', - -- 时间 - issued_at TIMESTAMP WITH TIME ZONE, - due_at TIMESTAMP WITH TIME ZONE, - paid_at TIMESTAMP WITH TIME ZONE, - -- 发票信息(PDF 链接等) - invoice_url VARCHAR(512), - -- 时间 - created_at TIMESTAMP WITH TIME ZONE NOT NULL DEFAULT CURRENT_TIMESTAMP, - updated_at TIMESTAMP WITH TIME ZONE NOT NULL DEFAULT CURRENT_TIMESTAMP, - -- 约束 - CONSTRAINT fk_invoices_user FOREIGN KEY (user_uuid) REFERENCES users(uuid), - CONSTRAINT fk_invoices_subscription FOREIGN KEY (subscription_uuid) REFERENCES subscriptions(uuid) -); - --- 创建索引 -CREATE INDEX idx_invoices_user_uuid ON invoices(user_uuid); -CREATE INDEX idx_invoices_invoice_number ON invoices(invoice_number); -CREATE INDEX idx_invoices_status ON invoices(status); -CREATE INDEX idx_invoices_subscription_uuid ON invoices(subscription_uuid); -CREATE INDEX idx_invoices_created_at ON invoices(created_at); - --- 创建更新时间触发器 -CREATE TRIGGER update_invoices_updated_at BEFORE UPDATE ON invoices - FOR EACH ROW EXECUTE FUNCTION update_updated_at_column(); - diff --git a/src-tauri/crates/business/migrations/20250120000029_create_user_quotas.sql b/src-tauri/crates/business/migrations/20250120000029_create_user_quotas.sql deleted file mode 100644 index 283f6ea..0000000 --- a/src-tauri/crates/business/migrations/20250120000029_create_user_quotas.sql +++ /dev/null @@ -1,31 +0,0 @@ --- 创建 user_quotas 表 --- 用户配额表 - -CREATE TABLE IF NOT EXISTS user_quotas ( - id SERIAL PRIMARY KEY, - user_uuid UUID NOT NULL UNIQUE, - -- 环境配额 - max_environments INT NOT NULL DEFAULT 10, - used_environments INT NOT NULL DEFAULT 0, - -- 团队成员配额 - max_team_members INT NOT NULL DEFAULT 5, - -- 代理配额 - max_proxies INT NOT NULL DEFAULT 10, - used_proxies INT NOT NULL DEFAULT 0, - -- RPA 任务配额 - max_rpa_tasks INT NOT NULL DEFAULT 5, - used_rpa_tasks INT NOT NULL DEFAULT 0, - -- 时间 - created_at TIMESTAMP WITH TIME ZONE NOT NULL DEFAULT CURRENT_TIMESTAMP, - updated_at TIMESTAMP WITH TIME ZONE NOT NULL DEFAULT CURRENT_TIMESTAMP, - -- 约束 - CONSTRAINT fk_user_quotas_user FOREIGN KEY (user_uuid) REFERENCES users(uuid) ON DELETE CASCADE -); - --- 创建索引 -CREATE INDEX idx_user_quotas_user_uuid ON user_quotas(user_uuid); - --- 创建更新时间触发器 -CREATE TRIGGER update_user_quotas_updated_at BEFORE UPDATE ON user_quotas - FOR EACH ROW EXECUTE FUNCTION update_updated_at_column(); - diff --git a/src-tauri/crates/business/migrations/20250120000030_create_coupons.sql b/src-tauri/crates/business/migrations/20250120000030_create_coupons.sql deleted file mode 100644 index 6713f09..0000000 --- a/src-tauri/crates/business/migrations/20250120000030_create_coupons.sql +++ /dev/null @@ -1,42 +0,0 @@ --- 创建 coupons 表 --- 优惠券表 - -CREATE TABLE IF NOT EXISTS coupons ( - id SERIAL PRIMARY KEY, - uuid UUID NOT NULL DEFAULT gen_random_uuid() UNIQUE, - -- 优惠券码 - code VARCHAR(50) NOT NULL UNIQUE, - -- 优惠类型: percent, fixed - discount_type VARCHAR(20) NOT NULL, - -- 优惠值(百分比或固定金额) - discount_value DECIMAL(12, 2) NOT NULL, - -- 最低消费 - min_amount DECIMAL(12, 2) DEFAULT 0, - -- 最大折扣(百分比类型时有效) - max_discount DECIMAL(12, 2), - -- 使用限制 - max_uses INT, - used_count INT NOT NULL DEFAULT 0, - -- 每用户限制 - max_uses_per_user INT DEFAULT 1, - -- 有效期 - valid_from TIMESTAMP WITH TIME ZONE NOT NULL DEFAULT CURRENT_TIMESTAMP, - valid_until TIMESTAMP WITH TIME ZONE, - -- 适用范围: all, subscription, addon - applicable_to VARCHAR(50) NOT NULL DEFAULT 'all', - -- 状态: active, inactive, expired - status VARCHAR(50) NOT NULL DEFAULT 'active', - -- 时间 - created_at TIMESTAMP WITH TIME ZONE NOT NULL DEFAULT CURRENT_TIMESTAMP, - updated_at TIMESTAMP WITH TIME ZONE NOT NULL DEFAULT CURRENT_TIMESTAMP -); - --- 创建索引 -CREATE INDEX idx_coupons_code ON coupons(code); -CREATE INDEX idx_coupons_status ON coupons(status); -CREATE INDEX idx_coupons_valid_until ON coupons(valid_until); - --- 创建更新时间触发器 -CREATE TRIGGER update_coupons_updated_at BEFORE UPDATE ON coupons - FOR EACH ROW EXECUTE FUNCTION update_updated_at_column(); - diff --git a/src-tauri/crates/business/migrations/20250120000031_create_coupon_usages.sql b/src-tauri/crates/business/migrations/20250120000031_create_coupon_usages.sql deleted file mode 100644 index 1242fab..0000000 --- a/src-tauri/crates/business/migrations/20250120000031_create_coupon_usages.sql +++ /dev/null @@ -1,21 +0,0 @@ --- 创建 coupon_usages 表 --- 优惠券使用记录表 - -CREATE TABLE IF NOT EXISTS coupon_usages ( - id SERIAL PRIMARY KEY, - coupon_uuid UUID NOT NULL, - user_uuid UUID NOT NULL, - order_uuid UUID, - -- 折扣金额 - discount_amount DECIMAL(12, 2) NOT NULL, - -- 使用时间 - used_at TIMESTAMP WITH TIME ZONE NOT NULL DEFAULT CURRENT_TIMESTAMP, - -- 约束 - CONSTRAINT fk_coupon_usages_coupon FOREIGN KEY (coupon_uuid) REFERENCES coupons(uuid), - CONSTRAINT fk_coupon_usages_user FOREIGN KEY (user_uuid) REFERENCES users(uuid) -); - --- 创建索引 -CREATE INDEX idx_coupon_usages_coupon_uuid ON coupon_usages(coupon_uuid); -CREATE INDEX idx_coupon_usages_user_uuid ON coupon_usages(user_uuid); - diff --git a/src-tauri/crates/business/migrations/20250120000032_create_payment_orders.sql b/src-tauri/crates/business/migrations/20250120000032_create_payment_orders.sql deleted file mode 100644 index 70f6635..0000000 --- a/src-tauri/crates/business/migrations/20250120000032_create_payment_orders.sql +++ /dev/null @@ -1,50 +0,0 @@ --- 创建 payment_orders 表 --- 支付订单表 - -CREATE TABLE IF NOT EXISTS payment_orders ( - id BIGSERIAL PRIMARY KEY, - uuid UUID NOT NULL DEFAULT gen_random_uuid() UNIQUE, - -- 订单号 - order_no VARCHAR(100) NOT NULL UNIQUE, - user_uuid UUID NOT NULL, - -- 订单类型: recharge, subscription, addon, refund - order_type VARCHAR(50) NOT NULL, - -- 金额 - amount DECIMAL(12, 2) NOT NULL, - currency VARCHAR(10) NOT NULL DEFAULT 'USD', - -- 状态: pending, paid, failed, refunded, cancelled - status VARCHAR(50) NOT NULL DEFAULT 'pending', - -- 支付渠道: alipay, wechatpay, stripe, paypal 等 - payment_channel VARCHAR(50), - -- 第三方订单号 - external_order_id VARCHAR(255), - -- 描述 - description TEXT, - -- 关联 - subscription_uuid UUID, - coupon_uuid UUID, - -- 折扣信息 - original_amount DECIMAL(12, 2), - discount_amount DECIMAL(12, 2) DEFAULT 0, - -- 时间 - paid_at TIMESTAMP WITH TIME ZONE, - refunded_at TIMESTAMP WITH TIME ZONE, - created_at TIMESTAMP WITH TIME ZONE NOT NULL DEFAULT CURRENT_TIMESTAMP, - updated_at TIMESTAMP WITH TIME ZONE NOT NULL DEFAULT CURRENT_TIMESTAMP, - -- 约束 - CONSTRAINT fk_payment_orders_user FOREIGN KEY (user_uuid) REFERENCES users(uuid), - CONSTRAINT fk_payment_orders_subscription FOREIGN KEY (subscription_uuid) REFERENCES subscriptions(uuid), - CONSTRAINT fk_payment_orders_coupon FOREIGN KEY (coupon_uuid) REFERENCES coupons(uuid) -); - --- 创建索引 -CREATE INDEX idx_payment_orders_user_uuid ON payment_orders(user_uuid); -CREATE INDEX idx_payment_orders_order_no ON payment_orders(order_no); -CREATE INDEX idx_payment_orders_status ON payment_orders(status); -CREATE INDEX idx_payment_orders_order_type ON payment_orders(order_type); -CREATE INDEX idx_payment_orders_created_at ON payment_orders(created_at); - --- 创建更新时间触发器 -CREATE TRIGGER update_payment_orders_updated_at BEFORE UPDATE ON payment_orders - FOR EACH ROW EXECUTE FUNCTION update_updated_at_column(); - diff --git a/src-tauri/crates/business/migrations/20250120000033_create_auto_renewal_services.sql b/src-tauri/crates/business/migrations/20250120000033_create_auto_renewal_services.sql deleted file mode 100644 index 1f045ac..0000000 --- a/src-tauri/crates/business/migrations/20250120000033_create_auto_renewal_services.sql +++ /dev/null @@ -1,36 +0,0 @@ --- 创建 auto_renewal_services 表 --- 自动续费服务表 - -CREATE TABLE IF NOT EXISTS auto_renewal_services ( - id SERIAL PRIMARY KEY, - uuid UUID NOT NULL DEFAULT gen_random_uuid() UNIQUE, - user_uuid UUID NOT NULL, - -- 服务类型: subscription, addon - service_type VARCHAR(50) NOT NULL, - -- 关联服务 ID - service_uuid UUID, - -- 服务名称 - service_name VARCHAR(255) NOT NULL, - -- 续费价格 - renewal_price DECIMAL(12, 2) NOT NULL, - currency VARCHAR(10) NOT NULL DEFAULT 'USD', - -- 下次扣费日期 - next_bill_date DATE NOT NULL, - -- 状态: active, paused, cancelled - status VARCHAR(50) NOT NULL DEFAULT 'active', - -- 时间 - created_at TIMESTAMP WITH TIME ZONE NOT NULL DEFAULT CURRENT_TIMESTAMP, - updated_at TIMESTAMP WITH TIME ZONE NOT NULL DEFAULT CURRENT_TIMESTAMP, - -- 约束 - CONSTRAINT fk_auto_renewal_user FOREIGN KEY (user_uuid) REFERENCES users(uuid) ON DELETE CASCADE -); - --- 创建索引 -CREATE INDEX idx_auto_renewal_services_user_uuid ON auto_renewal_services(user_uuid); -CREATE INDEX idx_auto_renewal_services_status ON auto_renewal_services(status); -CREATE INDEX idx_auto_renewal_services_next_bill_date ON auto_renewal_services(next_bill_date); - --- 创建更新时间触发器 -CREATE TRIGGER update_auto_renewal_services_updated_at BEFORE UPDATE ON auto_renewal_services - FOR EACH ROW EXECUTE FUNCTION update_updated_at_column(); - diff --git a/src-tauri/crates/business/migrations/20250120000034_create_rpa_tasks.sql b/src-tauri/crates/business/migrations/20250120000034_create_rpa_tasks.sql index bb37bc2..5c99ec4 100644 --- a/src-tauri/crates/business/migrations/20250120000034_create_rpa_tasks.sql +++ b/src-tauri/crates/business/migrations/20250120000034_create_rpa_tasks.sql @@ -1,59 +1,56 @@ --- 创建 rpa_tasks 表 --- RPA 任务表 - -CREATE TABLE IF NOT EXISTS rpa_tasks ( - id SERIAL PRIMARY KEY, - uuid UUID NOT NULL DEFAULT gen_random_uuid() UNIQUE, - user_uuid UUID NOT NULL, - team_uuid UUID, - -- 基础信息 - name VARCHAR(255) NOT NULL, - description TEXT, - tags JSONB DEFAULT '[]', - -- 触发器: manual, scheduled, event - trigger_type VARCHAR(50) NOT NULL DEFAULT 'manual', - -- 调度: hourly, daily, weekly, custom - schedule VARCHAR(50), - cron_expression VARCHAR(100), - -- 运行模式: sequential, parallel - run_mode VARCHAR(50) NOT NULL DEFAULT 'sequential', - -- 重试设置 - retry_count INT DEFAULT 0, - retry_interval INT DEFAULT 5, - -- 超时(秒) - timeout INT DEFAULT 300, - -- 并发数 - concurrency INT DEFAULT 1, - -- 错误时停止 - stop_on_error BOOLEAN DEFAULT TRUE, - -- 通知设置 - notify_on_complete BOOLEAN DEFAULT FALSE, - notify_on_error BOOLEAN DEFAULT TRUE, - -- 状态: idle, running, completed, failed, cancelled - status VARCHAR(50) NOT NULL DEFAULT 'idle', - -- 统计 - run_count INT DEFAULT 0, - success_count INT DEFAULT 0, - last_run_at TIMESTAMP WITH TIME ZONE, - next_run_at TIMESTAMP WITH TIME ZONE, - -- 时间 - created_at TIMESTAMP WITH TIME ZONE NOT NULL DEFAULT CURRENT_TIMESTAMP, - updated_at TIMESTAMP WITH TIME ZONE NOT NULL DEFAULT CURRENT_TIMESTAMP, - deleted_at TIMESTAMP WITH TIME ZONE, - -- 约束 - CONSTRAINT fk_rpa_tasks_user FOREIGN KEY (user_uuid) REFERENCES users(uuid), - CONSTRAINT fk_rpa_tasks_team FOREIGN KEY (team_uuid) REFERENCES teams(uuid) -); - --- 创建索引 -CREATE INDEX idx_rpa_tasks_user_uuid ON rpa_tasks(user_uuid); -CREATE INDEX idx_rpa_tasks_team_uuid ON rpa_tasks(team_uuid); -CREATE INDEX idx_rpa_tasks_trigger_type ON rpa_tasks(trigger_type); -CREATE INDEX idx_rpa_tasks_status ON rpa_tasks(status); -CREATE INDEX idx_rpa_tasks_next_run_at ON rpa_tasks(next_run_at); -CREATE INDEX idx_rpa_tasks_deleted_at ON rpa_tasks(deleted_at); - --- 创建更新时间触发器 -CREATE TRIGGER update_rpa_tasks_updated_at BEFORE UPDATE ON rpa_tasks - FOR EACH ROW EXECUTE FUNCTION update_updated_at_column(); - +-- 创建 rpa_tasks 表 +-- RPA 任务表 + +CREATE TABLE IF NOT EXISTS rpa_tasks ( + id INTEGER PRIMARY KEY AUTOINCREMENT, + uuid TEXT NOT NULL DEFAULT (randomblob(16)) UNIQUE, + user_uuid TEXT NOT NULL, + team_uuid TEXT, + -- 基础信息 + name VARCHAR(255) NOT NULL, + description TEXT, + tags TEXT DEFAULT '[]', + -- 触发器: manual, scheduled, event + trigger_type VARCHAR(50) NOT NULL DEFAULT 'manual', + -- 调度: hourly, daily, weekly, custom + schedule VARCHAR(50), + cron_expression VARCHAR(100), + -- 运行模式: sequential, parallel + run_mode VARCHAR(50) NOT NULL DEFAULT 'sequential', + -- 重试设置 + retry_count INT DEFAULT 0, + retry_interval INT DEFAULT 5, + -- 超时(秒) + timeout INT DEFAULT 300, + -- 并发数 + concurrency INT DEFAULT 1, + -- 错误时停止 + stop_on_error INTEGER DEFAULT TRUE, + -- 通知设置 + notify_on_complete INTEGER DEFAULT FALSE, + notify_on_error INTEGER DEFAULT TRUE, + -- 状态: idle, running, completed, failed, cancelled + status VARCHAR(50) NOT NULL DEFAULT 'idle', + -- 统计 + run_count INT DEFAULT 0, + success_count INT DEFAULT 0, + last_run_at TEXT, + next_run_at TEXT, + -- 时间 + created_at TEXT NOT NULL DEFAULT CURRENT_TIMESTAMP, + updated_at TEXT NOT NULL DEFAULT CURRENT_TIMESTAMP, + deleted_at TEXT, + -- 约束 + CONSTRAINT fk_rpa_tasks_user FOREIGN KEY (user_uuid) REFERENCES users(uuid), + CONSTRAINT fk_rpa_tasks_team FOREIGN KEY (team_uuid) REFERENCES teams(uuid) +); + +-- 创建索引 +CREATE INDEX idx_rpa_tasks_user_uuid ON rpa_tasks(user_uuid); +CREATE INDEX idx_rpa_tasks_team_uuid ON rpa_tasks(team_uuid); +CREATE INDEX idx_rpa_tasks_trigger_type ON rpa_tasks(trigger_type); +CREATE INDEX idx_rpa_tasks_status ON rpa_tasks(status); +CREATE INDEX idx_rpa_tasks_next_run_at ON rpa_tasks(next_run_at); +CREATE INDEX idx_rpa_tasks_deleted_at ON rpa_tasks(deleted_at); + +-- 创建更新时间触发器 diff --git a/src-tauri/crates/business/migrations/20250120000035_create_rpa_task_steps.sql b/src-tauri/crates/business/migrations/20250120000035_create_rpa_task_steps.sql index c0deaaf..06b46f5 100644 --- a/src-tauri/crates/business/migrations/20250120000035_create_rpa_task_steps.sql +++ b/src-tauri/crates/business/migrations/20250120000035_create_rpa_task_steps.sql @@ -1,39 +1,36 @@ --- 创建 rpa_task_steps 表 --- RPA 任务步骤表 - -CREATE TABLE IF NOT EXISTS rpa_task_steps ( - id SERIAL PRIMARY KEY, - uuid UUID NOT NULL DEFAULT gen_random_uuid() UNIQUE, - task_uuid UUID NOT NULL, - -- 步骤类型: navigate, click, input, wait, screenshot, script, condition, loop, scroll, keyboard, download, upload - step_type VARCHAR(50) NOT NULL, - -- 步骤名称 - name VARCHAR(255) NOT NULL, - -- 步骤配置(JSON) - config JSONB NOT NULL DEFAULT '{}', - -- 是否启用 - enabled BOOLEAN DEFAULT TRUE, - -- 画布位置 - position_x INT DEFAULT 0, - position_y INT DEFAULT 0, - -- 排序 - sort_order INT DEFAULT 0, - -- 连接到的下一个步骤 - next_step_uuid UUID, - -- 条件分支(条件类型步骤) - branch_config JSONB, - -- 时间 - created_at TIMESTAMP WITH TIME ZONE NOT NULL DEFAULT CURRENT_TIMESTAMP, - updated_at TIMESTAMP WITH TIME ZONE NOT NULL DEFAULT CURRENT_TIMESTAMP, - -- 约束 - CONSTRAINT fk_rpa_task_steps_task FOREIGN KEY (task_uuid) REFERENCES rpa_tasks(uuid) ON DELETE CASCADE -); - --- 创建索引 -CREATE INDEX idx_rpa_task_steps_task_uuid ON rpa_task_steps(task_uuid); -CREATE INDEX idx_rpa_task_steps_sort_order ON rpa_task_steps(sort_order); - --- 创建更新时间触发器 -CREATE TRIGGER update_rpa_task_steps_updated_at BEFORE UPDATE ON rpa_task_steps - FOR EACH ROW EXECUTE FUNCTION update_updated_at_column(); - +-- 创建 rpa_task_steps 表 +-- RPA 任务步骤表 + +CREATE TABLE IF NOT EXISTS rpa_task_steps ( + id INTEGER PRIMARY KEY AUTOINCREMENT, + uuid TEXT NOT NULL DEFAULT (randomblob(16)) UNIQUE, + task_uuid TEXT NOT NULL, + -- 步骤类型: navigate, click, input, wait, screenshot, script, condition, loop, scroll, keyboard, download, upload + step_type VARCHAR(50) NOT NULL, + -- 步骤名称 + name VARCHAR(255) NOT NULL, + -- 步骤配置(JSON) + config TEXT NOT NULL DEFAULT '{}', + -- 是否启用 + enabled INTEGER DEFAULT TRUE, + -- 画布位置 + position_x INT DEFAULT 0, + position_y INT DEFAULT 0, + -- 排序 + sort_order INT DEFAULT 0, + -- 连接到的下一个步骤 + next_step_uuid TEXT, + -- 条件分支(条件类型步骤) + branch_config TEXT, + -- 时间 + created_at TEXT NOT NULL DEFAULT CURRENT_TIMESTAMP, + updated_at TEXT NOT NULL DEFAULT CURRENT_TIMESTAMP, + -- 约束 + CONSTRAINT fk_rpa_task_steps_task FOREIGN KEY (task_uuid) REFERENCES rpa_tasks(uuid) ON DELETE CASCADE +); + +-- 创建索引 +CREATE INDEX idx_rpa_task_steps_task_uuid ON rpa_task_steps(task_uuid); +CREATE INDEX idx_rpa_task_steps_sort_order ON rpa_task_steps(sort_order); + +-- 创建更新时间触发器 diff --git a/src-tauri/crates/business/migrations/20250120000036_create_rpa_task_environments.sql b/src-tauri/crates/business/migrations/20250120000036_create_rpa_task_environments.sql index e68d5a6..bdd4c00 100644 --- a/src-tauri/crates/business/migrations/20250120000036_create_rpa_task_environments.sql +++ b/src-tauri/crates/business/migrations/20250120000036_create_rpa_task_environments.sql @@ -1,20 +1,19 @@ --- 创建 rpa_task_environments 表 --- RPA 任务-环境关联表(多对多) - -CREATE TABLE IF NOT EXISTS rpa_task_environments ( - id SERIAL PRIMARY KEY, - task_uuid UUID NOT NULL, - environment_uuid UUID NOT NULL, - sort_order INT DEFAULT 0, - created_at TIMESTAMP WITH TIME ZONE NOT NULL DEFAULT CURRENT_TIMESTAMP, - -- 约束 - CONSTRAINT fk_rpa_task_env_task FOREIGN KEY (task_uuid) REFERENCES rpa_tasks(uuid) ON DELETE CASCADE, - CONSTRAINT fk_rpa_task_env_env FOREIGN KEY (environment_uuid) REFERENCES environments(uuid) ON DELETE CASCADE, - -- 唯一约束 - CONSTRAINT uk_rpa_task_environments UNIQUE (task_uuid, environment_uuid) -); - --- 创建索引 -CREATE INDEX idx_rpa_task_env_task_uuid ON rpa_task_environments(task_uuid); -CREATE INDEX idx_rpa_task_env_env_uuid ON rpa_task_environments(environment_uuid); - +-- 创建 rpa_task_environments 表 +-- RPA 任务-环境关联表(多对多) + +CREATE TABLE IF NOT EXISTS rpa_task_environments ( + id INTEGER PRIMARY KEY AUTOINCREMENT, + task_uuid TEXT NOT NULL, + environment_uuid TEXT NOT NULL, + sort_order INT DEFAULT 0, + created_at TEXT NOT NULL DEFAULT CURRENT_TIMESTAMP, + -- 约束 + CONSTRAINT fk_rpa_task_env_task FOREIGN KEY (task_uuid) REFERENCES rpa_tasks(uuid) ON DELETE CASCADE, + CONSTRAINT fk_rpa_task_env_env FOREIGN KEY (environment_uuid) REFERENCES environments(uuid) ON DELETE CASCADE, + -- 唯一约束 + CONSTRAINT uk_rpa_task_environments UNIQUE (task_uuid, environment_uuid) +); + +-- 创建索引 +CREATE INDEX idx_rpa_task_env_task_uuid ON rpa_task_environments(task_uuid); +CREATE INDEX idx_rpa_task_env_env_uuid ON rpa_task_environments(environment_uuid); diff --git a/src-tauri/crates/business/migrations/20250120000037_create_rpa_task_runs.sql b/src-tauri/crates/business/migrations/20250120000037_create_rpa_task_runs.sql index 664da2e..26f67ad 100644 --- a/src-tauri/crates/business/migrations/20250120000037_create_rpa_task_runs.sql +++ b/src-tauri/crates/business/migrations/20250120000037_create_rpa_task_runs.sql @@ -1,32 +1,31 @@ --- 创建 rpa_task_runs 表 --- RPA 任务执行记录表 - -CREATE TABLE IF NOT EXISTS rpa_task_runs ( - id BIGSERIAL PRIMARY KEY, - uuid UUID NOT NULL DEFAULT gen_random_uuid() UNIQUE, - task_uuid UUID NOT NULL, - -- 状态: running, completed, failed, cancelled - status VARCHAR(50) NOT NULL DEFAULT 'running', - -- 步骤统计 - total_steps INT NOT NULL DEFAULT 0, - completed_steps INT NOT NULL DEFAULT 0, - failed_steps INT NOT NULL DEFAULT 0, - -- 执行时间 - started_at TIMESTAMP WITH TIME ZONE NOT NULL DEFAULT CURRENT_TIMESTAMP, - finished_at TIMESTAMP WITH TIME ZONE, - duration_ms BIGINT, - -- 结果摘要 - result_summary TEXT, - -- 错误信息 - error_message TEXT, - -- 执行日志(JSON 数组) - logs JSONB DEFAULT '[]', - -- 约束 - CONSTRAINT fk_rpa_task_runs_task FOREIGN KEY (task_uuid) REFERENCES rpa_tasks(uuid) ON DELETE CASCADE -); - --- 创建索引 -CREATE INDEX idx_rpa_task_runs_task_uuid ON rpa_task_runs(task_uuid); -CREATE INDEX idx_rpa_task_runs_status ON rpa_task_runs(status); -CREATE INDEX idx_rpa_task_runs_started_at ON rpa_task_runs(started_at); - +-- 创建 rpa_task_runs 表 +-- RPA 任务执行记录表 + +CREATE TABLE IF NOT EXISTS rpa_task_runs ( + id INTEGER PRIMARY KEY AUTOINCREMENT, + uuid TEXT NOT NULL DEFAULT (randomblob(16)) UNIQUE, + task_uuid TEXT NOT NULL, + -- 状态: running, completed, failed, cancelled + status VARCHAR(50) NOT NULL DEFAULT 'running', + -- 步骤统计 + total_steps INT NOT NULL DEFAULT 0, + completed_steps INT NOT NULL DEFAULT 0, + failed_steps INT NOT NULL DEFAULT 0, + -- 执行时间 + started_at TEXT NOT NULL DEFAULT CURRENT_TIMESTAMP, + finished_at TEXT, + duration_ms BIGINT, + -- 结果摘要 + result_summary TEXT, + -- 错误信息 + error_message TEXT, + -- 执行日志(JSON 数组) + logs TEXT DEFAULT '[]', + -- 约束 + CONSTRAINT fk_rpa_task_runs_task FOREIGN KEY (task_uuid) REFERENCES rpa_tasks(uuid) ON DELETE CASCADE +); + +-- 创建索引 +CREATE INDEX idx_rpa_task_runs_task_uuid ON rpa_task_runs(task_uuid); +CREATE INDEX idx_rpa_task_runs_status ON rpa_task_runs(status); +CREATE INDEX idx_rpa_task_runs_started_at ON rpa_task_runs(started_at); diff --git a/src-tauri/crates/business/migrations/20250120000038_create_referral_link_tiers.sql b/src-tauri/crates/business/migrations/20250120000038_create_referral_link_tiers.sql deleted file mode 100644 index 7da2d83..0000000 --- a/src-tauri/crates/business/migrations/20250120000038_create_referral_link_tiers.sql +++ /dev/null @@ -1,31 +0,0 @@ --- 创建 referral_link_tiers 表 --- 推广链接层级配置表 - -CREATE TABLE IF NOT EXISTS referral_link_tiers ( - id SERIAL PRIMARY KEY, - uuid UUID NOT NULL DEFAULT gen_random_uuid() UNIQUE, - -- 层级名称 - name VARCHAR(100) NOT NULL, - -- 解锁条件(邀请人数) - unlock_threshold INT NOT NULL DEFAULT 0, - -- 奖励比例(百分比) - reward_rate DECIMAL(5, 2) NOT NULL DEFAULT 0, - -- 被邀请人折扣比例(百分比) - discount_rate DECIMAL(5, 2) NOT NULL DEFAULT 0, - -- 描述 - description TEXT, - -- 排序 - sort_order INT DEFAULT 0, - -- 时间 - created_at TIMESTAMP WITH TIME ZONE NOT NULL DEFAULT CURRENT_TIMESTAMP, - updated_at TIMESTAMP WITH TIME ZONE NOT NULL DEFAULT CURRENT_TIMESTAMP -); - --- 创建索引 -CREATE INDEX idx_referral_link_tiers_unlock ON referral_link_tiers(unlock_threshold); -CREATE INDEX idx_referral_link_tiers_sort ON referral_link_tiers(sort_order); - --- 创建更新时间触发器 -CREATE TRIGGER update_referral_link_tiers_updated_at BEFORE UPDATE ON referral_link_tiers - FOR EACH ROW EXECUTE FUNCTION update_updated_at_column(); - diff --git a/src-tauri/crates/business/migrations/20250120000039_create_referral_links.sql b/src-tauri/crates/business/migrations/20250120000039_create_referral_links.sql deleted file mode 100644 index 0366672..0000000 --- a/src-tauri/crates/business/migrations/20250120000039_create_referral_links.sql +++ /dev/null @@ -1,42 +0,0 @@ --- 创建 referral_links 表 --- 用户推广链接表 - -CREATE TABLE IF NOT EXISTS referral_links ( - id SERIAL PRIMARY KEY, - uuid UUID NOT NULL DEFAULT gen_random_uuid() UNIQUE, - user_uuid UUID NOT NULL, - -- 链接码 - code VARCHAR(50) NOT NULL UNIQUE, - -- 完整 URL - url VARCHAR(512), - -- 当前层级 - tier_uuid UUID, - -- 是否解锁 - unlocked BOOLEAN DEFAULT TRUE, - -- 是否当前使用 - is_current BOOLEAN DEFAULT FALSE, - -- 奖励比例(冗余,便于查询) - reward_rate DECIMAL(5, 2) NOT NULL DEFAULT 0, - discount_rate DECIMAL(5, 2) NOT NULL DEFAULT 0, - -- 统计 - registered_users INT DEFAULT 0, - paid_users INT DEFAULT 0, - total_consumption DECIMAL(12, 2) DEFAULT 0, - last_30_days_consumption DECIMAL(12, 2) DEFAULT 0, - -- 时间 - created_at TIMESTAMP WITH TIME ZONE NOT NULL DEFAULT CURRENT_TIMESTAMP, - updated_at TIMESTAMP WITH TIME ZONE NOT NULL DEFAULT CURRENT_TIMESTAMP, - -- 约束 - CONSTRAINT fk_referral_links_user FOREIGN KEY (user_uuid) REFERENCES users(uuid) ON DELETE CASCADE, - CONSTRAINT fk_referral_links_tier FOREIGN KEY (tier_uuid) REFERENCES referral_link_tiers(uuid) -); - --- 创建索引 -CREATE INDEX idx_referral_links_user_uuid ON referral_links(user_uuid); -CREATE INDEX idx_referral_links_code ON referral_links(code); -CREATE INDEX idx_referral_links_is_current ON referral_links(is_current); - --- 创建更新时间触发器 -CREATE TRIGGER update_referral_links_updated_at BEFORE UPDATE ON referral_links - FOR EACH ROW EXECUTE FUNCTION update_updated_at_column(); - diff --git a/src-tauri/crates/business/migrations/20250120000040_create_user_referrals.sql b/src-tauri/crates/business/migrations/20250120000040_create_user_referrals.sql deleted file mode 100644 index a52cdfb..0000000 --- a/src-tauri/crates/business/migrations/20250120000040_create_user_referrals.sql +++ /dev/null @@ -1,32 +0,0 @@ --- 创建 user_referrals 表 --- 用户邀请关系表 - -CREATE TABLE IF NOT EXISTS user_referrals ( - id SERIAL PRIMARY KEY, - -- 邀请者 - inviter_uuid UUID NOT NULL, - -- 被邀请者 - invitee_uuid UUID NOT NULL UNIQUE, - -- 通过哪个推广链接 - link_uuid UUID, - -- 状态: registered, activated, paid - status VARCHAR(50) NOT NULL DEFAULT 'registered', - -- 消费统计 - total_consumption DECIMAL(12, 2) DEFAULT 0, - last_30_days_consumption DECIMAL(12, 2) DEFAULT 0, - -- 时间 - registered_at TIMESTAMP WITH TIME ZONE NOT NULL DEFAULT CURRENT_TIMESTAMP, - activated_at TIMESTAMP WITH TIME ZONE, - first_paid_at TIMESTAMP WITH TIME ZONE, - -- 约束 - CONSTRAINT fk_user_referrals_inviter FOREIGN KEY (inviter_uuid) REFERENCES users(uuid), - CONSTRAINT fk_user_referrals_invitee FOREIGN KEY (invitee_uuid) REFERENCES users(uuid), - CONSTRAINT fk_user_referrals_link FOREIGN KEY (link_uuid) REFERENCES referral_links(uuid) -); - --- 创建索引 -CREATE INDEX idx_user_referrals_inviter_uuid ON user_referrals(inviter_uuid); -CREATE INDEX idx_user_referrals_invitee_uuid ON user_referrals(invitee_uuid); -CREATE INDEX idx_user_referrals_status ON user_referrals(status); -CREATE INDEX idx_user_referrals_link_uuid ON user_referrals(link_uuid); - diff --git a/src-tauri/crates/business/migrations/20250120000041_create_referral_rewards.sql b/src-tauri/crates/business/migrations/20250120000041_create_referral_rewards.sql deleted file mode 100644 index 6d7f05d..0000000 --- a/src-tauri/crates/business/migrations/20250120000041_create_referral_rewards.sql +++ /dev/null @@ -1,33 +0,0 @@ --- 创建 referral_rewards 表 --- 推荐奖励记录表 - -CREATE TABLE IF NOT EXISTS referral_rewards ( - id BIGSERIAL PRIMARY KEY, - uuid UUID NOT NULL DEFAULT gen_random_uuid() UNIQUE, - user_uuid UUID NOT NULL, - -- 奖励类型: registration, first_pay, consumption - reward_type VARCHAR(50) NOT NULL, - -- 积分 - points INT NOT NULL, - -- 描述 - description TEXT, - -- 关联被邀请用户 - referred_user_uuid UUID, - -- 关联推广链接 - link_uuid UUID, - -- 状态: pending, approved, rejected - status VARCHAR(50) NOT NULL DEFAULT 'pending', - -- 时间 - created_at TIMESTAMP WITH TIME ZONE NOT NULL DEFAULT CURRENT_TIMESTAMP, - -- 约束 - CONSTRAINT fk_referral_rewards_user FOREIGN KEY (user_uuid) REFERENCES users(uuid), - CONSTRAINT fk_referral_rewards_referred FOREIGN KEY (referred_user_uuid) REFERENCES users(uuid), - CONSTRAINT fk_referral_rewards_link FOREIGN KEY (link_uuid) REFERENCES referral_links(uuid) -); - --- 创建索引 -CREATE INDEX idx_referral_rewards_user_uuid ON referral_rewards(user_uuid); -CREATE INDEX idx_referral_rewards_reward_type ON referral_rewards(reward_type); -CREATE INDEX idx_referral_rewards_status ON referral_rewards(status); -CREATE INDEX idx_referral_rewards_created_at ON referral_rewards(created_at); - diff --git a/src-tauri/crates/business/migrations/20250120000042_create_user_referral_points.sql b/src-tauri/crates/business/migrations/20250120000042_create_user_referral_points.sql deleted file mode 100644 index 7147616..0000000 --- a/src-tauri/crates/business/migrations/20250120000042_create_user_referral_points.sql +++ /dev/null @@ -1,28 +0,0 @@ --- 创建 user_referral_points 表 --- 用户推荐积分表 - -CREATE TABLE IF NOT EXISTS user_referral_points ( - id SERIAL PRIMARY KEY, - user_uuid UUID NOT NULL UNIQUE, - -- 总积分 - total_points INT NOT NULL DEFAULT 0, - -- 可用积分 - available_points INT NOT NULL DEFAULT 0, - -- 已使用积分 - used_points INT NOT NULL DEFAULT 0, - -- 待审核积分 - pending_points INT NOT NULL DEFAULT 0, - -- 时间 - created_at TIMESTAMP WITH TIME ZONE NOT NULL DEFAULT CURRENT_TIMESTAMP, - updated_at TIMESTAMP WITH TIME ZONE NOT NULL DEFAULT CURRENT_TIMESTAMP, - -- 约束 - CONSTRAINT fk_user_referral_points_user FOREIGN KEY (user_uuid) REFERENCES users(uuid) ON DELETE CASCADE -); - --- 创建索引 -CREATE INDEX idx_user_referral_points_user_uuid ON user_referral_points(user_uuid); - --- 创建更新时间触发器 -CREATE TRIGGER update_user_referral_points_updated_at BEFORE UPDATE ON user_referral_points - FOR EACH ROW EXECUTE FUNCTION update_updated_at_column(); - diff --git a/src-tauri/crates/business/migrations/20250120000043_create_redeem_options.sql b/src-tauri/crates/business/migrations/20250120000043_create_redeem_options.sql deleted file mode 100644 index e1293e4..0000000 --- a/src-tauri/crates/business/migrations/20250120000043_create_redeem_options.sql +++ /dev/null @@ -1,36 +0,0 @@ --- 创建 redeem_options 表 --- 积分兑换选项表 - -CREATE TABLE IF NOT EXISTS redeem_options ( - id SERIAL PRIMARY KEY, - uuid UUID NOT NULL DEFAULT gen_random_uuid() UNIQUE, - -- 兑换类型: wallet, coupon, gift - redeem_type VARCHAR(50) NOT NULL, - -- 名称 - name VARCHAR(255) NOT NULL, - -- 描述 - description TEXT, - -- 所需积分 - points_required INT NOT NULL, - -- 兑换价值 - value DECIMAL(12, 2) NOT NULL, - currency VARCHAR(10) DEFAULT 'USD', - -- 兑换比例(每 N 积分 = 1 单位价值) - exchange_rate INT NOT NULL DEFAULT 100, - -- 状态: active, inactive - status VARCHAR(50) NOT NULL DEFAULT 'active', - -- 排序 - sort_order INT DEFAULT 0, - -- 时间 - created_at TIMESTAMP WITH TIME ZONE NOT NULL DEFAULT CURRENT_TIMESTAMP, - updated_at TIMESTAMP WITH TIME ZONE NOT NULL DEFAULT CURRENT_TIMESTAMP -); - --- 创建索引 -CREATE INDEX idx_redeem_options_redeem_type ON redeem_options(redeem_type); -CREATE INDEX idx_redeem_options_status ON redeem_options(status); - --- 创建更新时间触发器 -CREATE TRIGGER update_redeem_options_updated_at BEFORE UPDATE ON redeem_options - FOR EACH ROW EXECUTE FUNCTION update_updated_at_column(); - diff --git a/src-tauri/crates/business/migrations/20250120000044_create_redeem_records.sql b/src-tauri/crates/business/migrations/20250120000044_create_redeem_records.sql deleted file mode 100644 index 72eb103..0000000 --- a/src-tauri/crates/business/migrations/20250120000044_create_redeem_records.sql +++ /dev/null @@ -1,29 +0,0 @@ --- 创建 redeem_records 表 --- 积分兑换记录表 - -CREATE TABLE IF NOT EXISTS redeem_records ( - id BIGSERIAL PRIMARY KEY, - uuid UUID NOT NULL DEFAULT gen_random_uuid() UNIQUE, - user_uuid UUID NOT NULL, - option_uuid UUID NOT NULL, - -- 兑换积分 - points_used INT NOT NULL, - -- 兑换价值 - value DECIMAL(12, 2) NOT NULL, - currency VARCHAR(10) DEFAULT 'USD', - -- 状态: pending, completed, failed - status VARCHAR(50) NOT NULL DEFAULT 'pending', - -- 时间 - created_at TIMESTAMP WITH TIME ZONE NOT NULL DEFAULT CURRENT_TIMESTAMP, - completed_at TIMESTAMP WITH TIME ZONE, - -- 约束 - CONSTRAINT fk_redeem_records_user FOREIGN KEY (user_uuid) REFERENCES users(uuid), - CONSTRAINT fk_redeem_records_option FOREIGN KEY (option_uuid) REFERENCES redeem_options(uuid) -); - --- 创建索引 -CREATE INDEX idx_redeem_records_user_uuid ON redeem_records(user_uuid); -CREATE INDEX idx_redeem_records_option_uuid ON redeem_records(option_uuid); -CREATE INDEX idx_redeem_records_status ON redeem_records(status); -CREATE INDEX idx_redeem_records_created_at ON redeem_records(created_at); - diff --git a/src-tauri/crates/business/migrations/20250120000045_create_audit_logs.sql b/src-tauri/crates/business/migrations/20250120000045_create_audit_logs.sql index ecb8a4d..417d841 100644 --- a/src-tauri/crates/business/migrations/20250120000045_create_audit_logs.sql +++ b/src-tauri/crates/business/migrations/20250120000045_create_audit_logs.sql @@ -1,37 +1,36 @@ --- 创建 audit_logs 表 --- 审计日志表 - -CREATE TABLE IF NOT EXISTS audit_logs ( - id BIGSERIAL PRIMARY KEY, - uuid UUID NOT NULL DEFAULT gen_random_uuid() UNIQUE, - user_uuid UUID NOT NULL, - team_uuid UUID, - -- 操作类型: login, logout, password_change, create, update, delete, batch_delete, start, stop, import, export, invite, role_change, member_remove, settings_update - action VARCHAR(50) NOT NULL, - -- 目标类型: environment, group, tag, proxy, account, team, settings, system - target_type VARCHAR(50) NOT NULL, - -- 目标 ID - target_uuid UUID, - target_name VARCHAR(255), - -- 详情 - details TEXT, - -- 变更内容(JSON) - changes JSONB, - -- 请求信息 - ip_address VARCHAR(45), - user_agent TEXT, - request_id VARCHAR(100), - -- 时间 - created_at TIMESTAMP WITH TIME ZONE NOT NULL DEFAULT CURRENT_TIMESTAMP, - -- 约束 - CONSTRAINT fk_audit_logs_user FOREIGN KEY (user_uuid) REFERENCES users(uuid) -); - --- 创建索引 -CREATE INDEX idx_audit_logs_user_uuid ON audit_logs(user_uuid); -CREATE INDEX idx_audit_logs_team_uuid ON audit_logs(team_uuid); -CREATE INDEX idx_audit_logs_action ON audit_logs(action); -CREATE INDEX idx_audit_logs_target_type ON audit_logs(target_type); -CREATE INDEX idx_audit_logs_target_uuid ON audit_logs(target_uuid); -CREATE INDEX idx_audit_logs_created_at ON audit_logs(created_at); - +-- 创建 audit_logs 表 +-- 审计日志表 + +CREATE TABLE IF NOT EXISTS audit_logs ( + id INTEGER PRIMARY KEY AUTOINCREMENT, + uuid TEXT NOT NULL DEFAULT (randomblob(16)) UNIQUE, + user_uuid TEXT NOT NULL, + team_uuid TEXT, + -- 操作类型: login, logout, password_change, create, update, delete, batch_delete, start, stop, import, export, invite, role_change, member_remove, settings_update + action VARCHAR(50) NOT NULL, + -- 目标类型: environment, group, tag, proxy, account, team, settings, system + target_type VARCHAR(50) NOT NULL, + -- 目标 ID + target_uuid TEXT, + target_name VARCHAR(255), + -- 详情 + details TEXT, + -- 变更内容(JSON) + changes TEXT, + -- 请求信息 + ip_address VARCHAR(45), + user_agent TEXT, + request_id VARCHAR(100), + -- 时间 + created_at TEXT NOT NULL DEFAULT CURRENT_TIMESTAMP, + -- 约束 + CONSTRAINT fk_audit_logs_user FOREIGN KEY (user_uuid) REFERENCES users(uuid) +); + +-- 创建索引 +CREATE INDEX idx_audit_logs_user_uuid ON audit_logs(user_uuid); +CREATE INDEX idx_audit_logs_team_uuid ON audit_logs(team_uuid); +CREATE INDEX idx_audit_logs_action ON audit_logs(action); +CREATE INDEX idx_audit_logs_target_type ON audit_logs(target_type); +CREATE INDEX idx_audit_logs_target_uuid ON audit_logs(target_uuid); +CREATE INDEX idx_audit_logs_created_at ON audit_logs(created_at); diff --git a/src-tauri/crates/business/migrations/20250120000046_create_api_keys.sql b/src-tauri/crates/business/migrations/20250120000046_create_api_keys.sql deleted file mode 100644 index 7e7c840..0000000 --- a/src-tauri/crates/business/migrations/20250120000046_create_api_keys.sql +++ /dev/null @@ -1,43 +0,0 @@ --- 创建 api_keys 表 --- API 密钥表 - -CREATE TABLE IF NOT EXISTS api_keys ( - id SERIAL PRIMARY KEY, - uuid UUID NOT NULL DEFAULT gen_random_uuid() UNIQUE, - user_uuid UUID NOT NULL, - -- 密钥信息 - name VARCHAR(255) NOT NULL, - key_hash VARCHAR(255) NOT NULL, - key_prefix VARCHAR(20) NOT NULL, - -- 权限: read, write, delete, admin - permissions JSONB NOT NULL DEFAULT '["read"]', - -- 限流 - rate_limit INT DEFAULT 1000, - daily_limit INT DEFAULT 10000, - -- IP 白名单(JSON 数组) - ip_whitelist JSONB DEFAULT '[]', - -- 过期时间 - expires_at TIMESTAMP WITH TIME ZONE, - -- 使用统计 - usage_count BIGINT DEFAULT 0, - daily_usage INT DEFAULT 0, - last_used_at TIMESTAMP WITH TIME ZONE, - -- 状态: active, revoked - status VARCHAR(50) NOT NULL DEFAULT 'active', - -- 时间 - created_at TIMESTAMP WITH TIME ZONE NOT NULL DEFAULT CURRENT_TIMESTAMP, - updated_at TIMESTAMP WITH TIME ZONE NOT NULL DEFAULT CURRENT_TIMESTAMP, - -- 约束 - CONSTRAINT fk_api_keys_user FOREIGN KEY (user_uuid) REFERENCES users(uuid) ON DELETE CASCADE -); - --- 创建索引 -CREATE INDEX idx_api_keys_user_uuid ON api_keys(user_uuid); -CREATE INDEX idx_api_keys_key_hash ON api_keys(key_hash); -CREATE INDEX idx_api_keys_key_prefix ON api_keys(key_prefix); -CREATE INDEX idx_api_keys_status ON api_keys(status); - --- 创建更新时间触发器 -CREATE TRIGGER update_api_keys_updated_at BEFORE UPDATE ON api_keys - FOR EACH ROW EXECUTE FUNCTION update_updated_at_column(); - diff --git a/src-tauri/crates/business/migrations/20250120000047_create_user_preferences.sql b/src-tauri/crates/business/migrations/20250120000047_create_user_preferences.sql index 97294b7..f2039a0 100644 --- a/src-tauri/crates/business/migrations/20250120000047_create_user_preferences.sql +++ b/src-tauri/crates/business/migrations/20250120000047_create_user_preferences.sql @@ -1,26 +1,23 @@ --- 创建 user_preferences 表 --- 用户偏好设置表(仅云同步设置) - -CREATE TABLE IF NOT EXISTS user_preferences ( - id SERIAL PRIMARY KEY, - user_uuid UUID NOT NULL UNIQUE, - -- 主题: light, dark, system - theme VARCHAR(50) NOT NULL DEFAULT 'system', - -- 语言 - language VARCHAR(20) NOT NULL DEFAULT 'zh-CN', - -- 通知开关 - notifications_enabled BOOLEAN NOT NULL DEFAULT TRUE, - -- 时间 - created_at TIMESTAMP WITH TIME ZONE NOT NULL DEFAULT CURRENT_TIMESTAMP, - updated_at TIMESTAMP WITH TIME ZONE NOT NULL DEFAULT CURRENT_TIMESTAMP, - -- 约束 - CONSTRAINT fk_user_preferences_user FOREIGN KEY (user_uuid) REFERENCES users(uuid) ON DELETE CASCADE -); - --- 创建索引 -CREATE INDEX idx_user_preferences_user_uuid ON user_preferences(user_uuid); - --- 创建更新时间触发器 -CREATE TRIGGER update_user_preferences_updated_at BEFORE UPDATE ON user_preferences - FOR EACH ROW EXECUTE FUNCTION update_updated_at_column(); - +-- 创建 user_preferences 表 +-- 用户偏好设置表(仅云同步设置) + +CREATE TABLE IF NOT EXISTS user_preferences ( + id INTEGER PRIMARY KEY AUTOINCREMENT, + user_uuid TEXT NOT NULL UNIQUE, + -- 主题: light, dark, system + theme VARCHAR(50) NOT NULL DEFAULT 'system', + -- 语言 + language VARCHAR(20) NOT NULL DEFAULT 'zh-CN', + -- 通知开关 + notifications_enabled INTEGER NOT NULL DEFAULT TRUE, + -- 时间 + created_at TEXT NOT NULL DEFAULT CURRENT_TIMESTAMP, + updated_at TEXT NOT NULL DEFAULT CURRENT_TIMESTAMP, + -- 约束 + CONSTRAINT fk_user_preferences_user FOREIGN KEY (user_uuid) REFERENCES users(uuid) ON DELETE CASCADE +); + +-- 创建索引 +CREATE INDEX idx_user_preferences_user_uuid ON user_preferences(user_uuid); + +-- 创建更新时间触发器 diff --git a/src-tauri/crates/business/migrations/20250120000048_create_system_configs.sql b/src-tauri/crates/business/migrations/20250120000048_create_system_configs.sql deleted file mode 100644 index 0e5078a..0000000 --- a/src-tauri/crates/business/migrations/20250120000048_create_system_configs.sql +++ /dev/null @@ -1,23 +0,0 @@ --- 创建 system_configs 表 --- 系统配置表(全局配置) - -CREATE TABLE IF NOT EXISTS system_configs ( - id SERIAL PRIMARY KEY, - -- 配置键 - config_key VARCHAR(255) NOT NULL UNIQUE, - -- 配置值(JSON) - config_value JSONB NOT NULL DEFAULT '{}', - -- 描述 - description TEXT, - -- 时间 - created_at TIMESTAMP WITH TIME ZONE NOT NULL DEFAULT CURRENT_TIMESTAMP, - updated_at TIMESTAMP WITH TIME ZONE NOT NULL DEFAULT CURRENT_TIMESTAMP -); - --- 创建索引 -CREATE INDEX idx_system_configs_key ON system_configs(config_key); - --- 创建更新时间触发器 -CREATE TRIGGER update_system_configs_updated_at BEFORE UPDATE ON system_configs - FOR EACH ROW EXECUTE FUNCTION update_updated_at_column(); - diff --git a/src-tauri/crates/business/migrations/20250120000049_add_foreign_keys.sql b/src-tauri/crates/business/migrations/20250120000049_add_foreign_keys.sql index 828dcb8..a770670 100644 --- a/src-tauri/crates/business/migrations/20250120000049_add_foreign_keys.sql +++ b/src-tauri/crates/business/migrations/20250120000049_add_foreign_keys.sql @@ -1,32 +1,8 @@ --- 添加延迟创建的外键约束 --- 某些外键需要在相关表都创建后才能添加 - --- teams.default_proxy_uuid -> proxies.uuid -DO $$ -BEGIN - IF NOT EXISTS ( - SELECT 1 FROM information_schema.table_constraints - WHERE constraint_name = 'fk_teams_default_proxy' - ) THEN - ALTER TABLE teams - ADD CONSTRAINT fk_teams_default_proxy - FOREIGN KEY (default_proxy_uuid) REFERENCES proxies(uuid) ON DELETE SET NULL; - END IF; -END $$; - --- groups.default_proxy_uuid -> proxies.uuid -DO $$ -BEGIN - IF NOT EXISTS ( - SELECT 1 FROM information_schema.table_constraints - WHERE constraint_name = 'fk_groups_default_proxy' - ) THEN - ALTER TABLE groups - ADD CONSTRAINT fk_groups_default_proxy - FOREIGN KEY (default_proxy_uuid) REFERENCES proxies(uuid) ON DELETE SET NULL; - END IF; -END $$; - --- 创建 groups.default_proxy_uuid 索引 -CREATE INDEX IF NOT EXISTS idx_groups_default_proxy ON groups(default_proxy_uuid); - +-- 添加延迟创建的外键约束 +-- 某些外键需要在相关表都创建后才能添加 + +-- teams.default_proxy_uuid -> proxies.uuid + +-- groups.default_proxy_uuid -> proxies.uuid + +-- 创建 groups.default_proxy_uuid 索引 diff --git a/src-tauri/crates/business/migrations/20250120000050_insert_default_data.sql b/src-tauri/crates/business/migrations/20250120000050_insert_default_data.sql deleted file mode 100644 index c058ba4..0000000 --- a/src-tauri/crates/business/migrations/20250120000050_insert_default_data.sql +++ /dev/null @@ -1,37 +0,0 @@ --- 插入默认数据 - --- 1. 默认套餐 -INSERT INTO plans (uuid, name, description, price_per_month, price_per_year, max_environments, max_team_members, max_proxies, max_rpa_tasks, is_recommended, sort_order, status) -VALUES - (gen_random_uuid(), 'Free', '免费套餐,适合个人试用', 0, 0, 5, 1, 5, 2, FALSE, 1, 'active'), - (gen_random_uuid(), 'Basic', '基础套餐,适合个人用户', 9.99, 99.99, 20, 3, 20, 5, FALSE, 2, 'active'), - (gen_random_uuid(), 'Pro', '专业套餐,适合小型团队', 29.99, 299.99, 100, 10, 100, 20, TRUE, 3, 'active'), - (gen_random_uuid(), 'Enterprise', '企业套餐,无限制', 99.99, 999.99, 1000, 100, 1000, 100, FALSE, 4, 'active') -ON CONFLICT DO NOTHING; - --- 2. 默认推广链接层级 -INSERT INTO referral_link_tiers (uuid, name, unlock_threshold, reward_rate, discount_rate, description, sort_order) -VALUES - (gen_random_uuid(), '青铜', 0, 10.00, 5.00, '默认层级,邀请即享', 1), - (gen_random_uuid(), '白银', 10, 15.00, 8.00, '邀请满10人解锁', 2), - (gen_random_uuid(), '黄金', 50, 20.00, 10.00, '邀请满50人解锁', 3), - (gen_random_uuid(), '钻石', 100, 25.00, 15.00, '邀请满100人解锁', 4) -ON CONFLICT DO NOTHING; - --- 3. 默认兑换选项 -INSERT INTO redeem_options (uuid, redeem_type, name, description, points_required, value, exchange_rate, status, sort_order) -VALUES - (gen_random_uuid(), 'wallet', '兑换余额', '100 积分 = $1', 100, 1.00, 100, 'active', 1), - (gen_random_uuid(), 'coupon', '兑换优惠券', '500 积分 = $10 优惠券', 500, 10.00, 50, 'active', 2), - (gen_random_uuid(), 'coupon', '兑换优惠券', '1000 积分 = $25 优惠券', 1000, 25.00, 40, 'active', 3) -ON CONFLICT DO NOTHING; - --- 4. 默认系统配置 -INSERT INTO system_configs (config_key, config_value, description) -VALUES - ('app_version', '"1.0.0"', '应用版本'), - ('maintenance_mode', 'false', '维护模式'), - ('registration_enabled', 'true', '是否允许注册'), - ('referral_enabled', 'true', '是否启用推广计划') -ON CONFLICT (config_key) DO NOTHING; - diff --git a/src-tauri/crates/business/migrations/20250120000051_remove_icon_and_color_fields.sql b/src-tauri/crates/business/migrations/20250120000051_remove_icon_and_color_fields.sql index 8b1a178..39cb995 100644 --- a/src-tauri/crates/business/migrations/20250120000051_remove_icon_and_color_fields.sql +++ b/src-tauri/crates/business/migrations/20250120000051_remove_icon_and_color_fields.sql @@ -1,10 +1,9 @@ --- 移除环境的 icon 和 icon_color 字段 --- 移除分组的 color 字段 - --- 移除 environments 表的 icon 和 icon_color 字段 -ALTER TABLE environments DROP COLUMN IF EXISTS icon; -ALTER TABLE environments DROP COLUMN IF EXISTS icon_color; - --- 移除 groups 表的 color 字段 -ALTER TABLE groups DROP COLUMN IF EXISTS color; - +-- 移除环境的 icon 和 icon_color 字段 +-- 移除分组的 color 字段 + +-- 移除 environments 表的 icon 和 icon_color 字段 +ALTER TABLE environments DROP COLUMN icon; +ALTER TABLE environments DROP COLUMN icon_color; + +-- 移除 groups 表的 color 字段 +ALTER TABLE groups DROP COLUMN color; diff --git a/src-tauri/crates/business/migrations/20250123000001_create_console_admins.sql b/src-tauri/crates/business/migrations/20250123000001_create_console_admins.sql deleted file mode 100644 index bc530e8..0000000 --- a/src-tauri/crates/business/migrations/20250123000001_create_console_admins.sql +++ /dev/null @@ -1,28 +0,0 @@ --- Console Gateway 管理员表 --- 用于存储 console-gateway 的管理员信息 - -CREATE TABLE IF NOT EXISTS console_admins ( - id SERIAL PRIMARY KEY, - uuid UUID NOT NULL DEFAULT gen_random_uuid() UNIQUE, - user_uuid UUID NOT NULL UNIQUE, - is_active BOOLEAN NOT NULL DEFAULT TRUE, - created_at TIMESTAMP WITH TIME ZONE NOT NULL DEFAULT CURRENT_TIMESTAMP, - updated_at TIMESTAMP WITH TIME ZONE NOT NULL DEFAULT CURRENT_TIMESTAMP, - deleted_at TIMESTAMP WITH TIME ZONE -); - --- 索引 -CREATE INDEX IF NOT EXISTS idx_console_admins_user_uuid ON console_admins(user_uuid); -CREATE INDEX IF NOT EXISTS idx_console_admins_is_active ON console_admins(is_active); -CREATE INDEX IF NOT EXISTS idx_console_admins_deleted_at ON console_admins(deleted_at); - --- 注释 -COMMENT ON TABLE console_admins IS 'Console Gateway 管理员表'; -COMMENT ON COLUMN console_admins.id IS '主键 ID'; -COMMENT ON COLUMN console_admins.uuid IS '唯一标识'; -COMMENT ON COLUMN console_admins.user_uuid IS '关联的用户 UUID'; -COMMENT ON COLUMN console_admins.is_active IS '是否激活'; -COMMENT ON COLUMN console_admins.created_at IS '创建时间'; -COMMENT ON COLUMN console_admins.updated_at IS '更新时间'; -COMMENT ON COLUMN console_admins.deleted_at IS '删除时间 (软删除)'; - diff --git a/src-tauri/crates/business/migrations/20250123000002_create_console_permissions.sql b/src-tauri/crates/business/migrations/20250123000002_create_console_permissions.sql deleted file mode 100644 index 4434cea..0000000 --- a/src-tauri/crates/business/migrations/20250123000002_create_console_permissions.sql +++ /dev/null @@ -1,51 +0,0 @@ --- Console Gateway 权限表 --- 用于存储 console-gateway 的路由权限 - -CREATE TABLE IF NOT EXISTS console_permissions ( - id SERIAL PRIMARY KEY, - uuid UUID NOT NULL DEFAULT gen_random_uuid() UNIQUE, - route_path VARCHAR(512) NOT NULL, - method VARCHAR(10) NOT NULL, - description TEXT, - created_at TIMESTAMP WITH TIME ZONE NOT NULL DEFAULT CURRENT_TIMESTAMP, - updated_at TIMESTAMP WITH TIME ZONE NOT NULL DEFAULT CURRENT_TIMESTAMP, - deleted_at TIMESTAMP WITH TIME ZONE, - UNIQUE(route_path, method) -); - --- Console Gateway 管理员权限关联表 -CREATE TABLE IF NOT EXISTS console_admin_permissions ( - id SERIAL PRIMARY KEY, - uuid UUID NOT NULL DEFAULT gen_random_uuid() UNIQUE, - admin_id INTEGER NOT NULL REFERENCES console_admins(id) ON DELETE CASCADE, - permission_id INTEGER NOT NULL REFERENCES console_permissions(id) ON DELETE CASCADE, - granted_at TIMESTAMP WITH TIME ZONE NOT NULL DEFAULT CURRENT_TIMESTAMP, - granted_by UUID, - UNIQUE(admin_id, permission_id) -); - --- 索引 -CREATE INDEX IF NOT EXISTS idx_console_permissions_route ON console_permissions(route_path, method); -CREATE INDEX IF NOT EXISTS idx_console_permissions_deleted_at ON console_permissions(deleted_at); -CREATE INDEX IF NOT EXISTS idx_console_admin_permissions_admin ON console_admin_permissions(admin_id); -CREATE INDEX IF NOT EXISTS idx_console_admin_permissions_permission ON console_admin_permissions(permission_id); - --- 注释 -COMMENT ON TABLE console_permissions IS 'Console Gateway 路由权限表'; -COMMENT ON COLUMN console_permissions.id IS '主键 ID'; -COMMENT ON COLUMN console_permissions.uuid IS '唯一标识'; -COMMENT ON COLUMN console_permissions.route_path IS '路由路径'; -COMMENT ON COLUMN console_permissions.method IS 'HTTP 方法 (GET, POST, PUT, DELETE, PATCH)'; -COMMENT ON COLUMN console_permissions.description IS '权限描述'; -COMMENT ON COLUMN console_permissions.created_at IS '创建时间'; -COMMENT ON COLUMN console_permissions.updated_at IS '更新时间'; -COMMENT ON COLUMN console_permissions.deleted_at IS '删除时间 (软删除)'; - -COMMENT ON TABLE console_admin_permissions IS 'Console Gateway 管理员权限关联表'; -COMMENT ON COLUMN console_admin_permissions.id IS '主键 ID'; -COMMENT ON COLUMN console_admin_permissions.uuid IS '唯一标识'; -COMMENT ON COLUMN console_admin_permissions.admin_id IS '管理员 ID'; -COMMENT ON COLUMN console_admin_permissions.permission_id IS '权限 ID'; -COMMENT ON COLUMN console_admin_permissions.granted_at IS '授权时间'; -COMMENT ON COLUMN console_admin_permissions.granted_by IS '授权人 UUID'; - diff --git a/src-tauri/crates/business/migrations/20250123000003_create_console_api_keys.sql b/src-tauri/crates/business/migrations/20250123000003_create_console_api_keys.sql deleted file mode 100644 index a923c1c..0000000 --- a/src-tauri/crates/business/migrations/20250123000003_create_console_api_keys.sql +++ /dev/null @@ -1,41 +0,0 @@ --- Console Gateway API 密钥表 --- 用于存储 console-gateway 的 API 密钥 - -CREATE TABLE IF NOT EXISTS console_api_keys ( - id SERIAL PRIMARY KEY, - uuid UUID NOT NULL DEFAULT gen_random_uuid() UNIQUE, - key_id VARCHAR(64) NOT NULL UNIQUE, - key_secret VARCHAR(128) NOT NULL, - name VARCHAR(255) NOT NULL, - description TEXT, - is_active BOOLEAN NOT NULL DEFAULT TRUE, - created_at TIMESTAMP WITH TIME ZONE NOT NULL DEFAULT CURRENT_TIMESTAMP, - updated_at TIMESTAMP WITH TIME ZONE NOT NULL DEFAULT CURRENT_TIMESTAMP, - expires_at TIMESTAMP WITH TIME ZONE, - last_used_at TIMESTAMP WITH TIME ZONE, - created_by UUID, - deleted_at TIMESTAMP WITH TIME ZONE -); - --- 索引 -CREATE INDEX IF NOT EXISTS idx_console_api_keys_key_id ON console_api_keys(key_id); -CREATE INDEX IF NOT EXISTS idx_console_api_keys_is_active ON console_api_keys(is_active); -CREATE INDEX IF NOT EXISTS idx_console_api_keys_expires_at ON console_api_keys(expires_at); -CREATE INDEX IF NOT EXISTS idx_console_api_keys_deleted_at ON console_api_keys(deleted_at); - --- 注释 -COMMENT ON TABLE console_api_keys IS 'Console Gateway API 密钥表'; -COMMENT ON COLUMN console_api_keys.id IS '主键 ID'; -COMMENT ON COLUMN console_api_keys.uuid IS '唯一标识'; -COMMENT ON COLUMN console_api_keys.key_id IS 'API 密钥 ID (公开部分)'; -COMMENT ON COLUMN console_api_keys.key_secret IS 'API 密钥密文 (私密部分)'; -COMMENT ON COLUMN console_api_keys.name IS '密钥名称'; -COMMENT ON COLUMN console_api_keys.description IS '密钥描述'; -COMMENT ON COLUMN console_api_keys.is_active IS '是否激活'; -COMMENT ON COLUMN console_api_keys.created_at IS '创建时间'; -COMMENT ON COLUMN console_api_keys.updated_at IS '更新时间'; -COMMENT ON COLUMN console_api_keys.expires_at IS '过期时间'; -COMMENT ON COLUMN console_api_keys.last_used_at IS '最后使用时间'; -COMMENT ON COLUMN console_api_keys.created_by IS '创建人 UUID'; -COMMENT ON COLUMN console_api_keys.deleted_at IS '删除时间 (软删除)'; - diff --git a/src-tauri/crates/business/migrations/20250124000001_create_messages.sql b/src-tauri/crates/business/migrations/20250124000001_create_messages.sql index e904ff0..0dd8ed7 100644 --- a/src-tauri/crates/business/migrations/20250124000001_create_messages.sql +++ b/src-tauri/crates/business/migrations/20250124000001_create_messages.sql @@ -1,148 +1,138 @@ --- 创建 messages 表 --- 消息表,用于存储系统消息、团队邀请通知等 - -CREATE TABLE IF NOT EXISTS messages ( - id SERIAL PRIMARY KEY, - uuid UUID NOT NULL DEFAULT gen_random_uuid() UNIQUE, - - -- 消息类型 - message_type VARCHAR(50) NOT NULL, - -- 可选值: - -- 'private_chat' - 用户私信 - -- 'team_announcement' - 团队公告 - -- 'team_invitation' - 团队邀请 - -- 'team_removal' - 团队移除成员 - -- 'system_notification' - 系统通知 - - -- 消息内容 - title VARCHAR(255) NOT NULL, -- 消息标题 - content TEXT, -- 消息内容(支持 JSON 格式存储扩展数据) - - -- 发送者 - sender_uuid UUID, -- 发送者 UUID(系统消息可为 NULL) - - -- 接收者模式 - recipient_type VARCHAR(20) NOT NULL DEFAULT 'single', - -- 'single' - 单个用户 - -- 'multiple' - 多个指定用户 - -- 'team' - 团队内所有成员 - -- 'all' - 所有用户(系统广播) - - -- 关联资源(根据消息类型关联不同的资源) - related_type VARCHAR(50), -- 关联类型:team, invitation, etc. - related_uuid UUID, -- 关联资源的 UUID(如 team_uuid, invitation_uuid) - - -- 消息元数据(JSON 格式,存储扩展信息) - metadata JSONB, - -- 例如:{ - -- "team_name": "研发团队", - -- "inviter_name": "张三", - -- "role": "editor" - -- } - - -- 消息状态 - status VARCHAR(20) NOT NULL DEFAULT 'active', -- active, deleted - priority VARCHAR(20) NOT NULL DEFAULT 'normal', -- low, normal, high, urgent - - -- 时间戳 - created_at TIMESTAMP WITH TIME ZONE NOT NULL DEFAULT CURRENT_TIMESTAMP, - updated_at TIMESTAMP WITH TIME ZONE NOT NULL DEFAULT CURRENT_TIMESTAMP, - deleted_at TIMESTAMP WITH TIME ZONE, - - -- 外键约束 - CONSTRAINT fk_messages_sender FOREIGN KEY (sender_uuid) - REFERENCES users(uuid) ON DELETE SET NULL -); - --- 创建 user_messages 表(用户消息关联表,支持多接收者) -CREATE TABLE IF NOT EXISTS user_messages ( - id SERIAL PRIMARY KEY, - message_uuid UUID NOT NULL, - user_uuid UUID NOT NULL, - - -- 阅读状态 - is_read BOOLEAN NOT NULL DEFAULT FALSE, - read_at TIMESTAMP WITH TIME ZONE, - - -- 操作状态(用于邀请、移除等需要操作的消息) - action_status VARCHAR(20), - -- 'pending' - 待处理(邀请类消息) - -- 'accepted' - 已接受 - -- 'rejected' - 已拒绝 - -- 'expired' - 已过期 - -- NULL - 无需操作的消息 - - action_at TIMESTAMP WITH TIME ZONE, - - -- 时间戳 - created_at TIMESTAMP WITH TIME ZONE NOT NULL DEFAULT CURRENT_TIMESTAMP, - updated_at TIMESTAMP WITH TIME ZONE NOT NULL DEFAULT CURRENT_TIMESTAMP, - - -- 外键约束 - CONSTRAINT fk_user_messages_message FOREIGN KEY (message_uuid) - REFERENCES messages(uuid) ON DELETE CASCADE, - CONSTRAINT fk_user_messages_user FOREIGN KEY (user_uuid) - REFERENCES users(uuid) ON DELETE CASCADE, - - -- 唯一约束:一个用户对一条消息只能有一条记录 - CONSTRAINT uk_user_messages_message_user UNIQUE (message_uuid, user_uuid) -); - --- 创建索引 -CREATE INDEX IF NOT EXISTS idx_messages_type ON messages(message_type); -CREATE INDEX IF NOT EXISTS idx_messages_sender ON messages(sender_uuid); -CREATE INDEX IF NOT EXISTS idx_messages_related ON messages(related_type, related_uuid); -CREATE INDEX IF NOT EXISTS idx_messages_recipient_type ON messages(recipient_type); -CREATE INDEX IF NOT EXISTS idx_messages_status ON messages(status); -CREATE INDEX IF NOT EXISTS idx_messages_priority ON messages(priority); -CREATE INDEX IF NOT EXISTS idx_messages_created_at ON messages(created_at DESC); -CREATE INDEX IF NOT EXISTS idx_messages_deleted_at ON messages(deleted_at); - -CREATE INDEX IF NOT EXISTS idx_user_messages_user ON user_messages(user_uuid); -CREATE INDEX IF NOT EXISTS idx_user_messages_message ON user_messages(message_uuid); -CREATE INDEX IF NOT EXISTS idx_user_messages_is_read ON user_messages(user_uuid, is_read); -CREATE INDEX IF NOT EXISTS idx_user_messages_action_status ON user_messages(user_uuid, action_status); -CREATE INDEX IF NOT EXISTS idx_user_messages_user_unread ON user_messages(user_uuid, is_read) - WHERE is_read = FALSE; - --- 复合索引:用于查询用户未读消息 -CREATE INDEX IF NOT EXISTS idx_user_messages_user_type_unread - ON user_messages(user_uuid, is_read, created_at DESC) - WHERE is_read = FALSE; - --- 创建更新时间触发器 -CREATE TRIGGER update_messages_updated_at BEFORE UPDATE ON messages - FOR EACH ROW EXECUTE FUNCTION update_updated_at_column(); - -CREATE TRIGGER update_user_messages_updated_at BEFORE UPDATE ON user_messages - FOR EACH ROW EXECUTE FUNCTION update_updated_at_column(); - --- 团队消息自动分发触发器 --- 当创建 recipient_type='team' 的消息时,自动为团队成员创建 user_messages 记录 -CREATE OR REPLACE FUNCTION auto_create_team_message_recipients() -RETURNS TRIGGER AS $$ -BEGIN - -- 如果接收者类型是 team,且有关联的团队 UUID - IF NEW.recipient_type = 'team' AND NEW.related_type = 'team' AND NEW.related_uuid IS NOT NULL THEN - -- 为团队所有活跃成员创建消息关联记录 - INSERT INTO user_messages (message_uuid, user_uuid, is_read, action_status) - SELECT NEW.uuid, tm.user_uuid, FALSE, - CASE - WHEN NEW.message_type = 'team_invitation' THEN 'pending' - ELSE NULL - END - FROM team_members tm - WHERE tm.team_uuid = NEW.related_uuid - AND tm.status = 'active' - AND tm.deleted_at IS NULL - ON CONFLICT (message_uuid, user_uuid) DO NOTHING; - END IF; - RETURN NEW; -END; -$$ LANGUAGE plpgsql; - -CREATE TRIGGER trigger_auto_create_team_message_recipients - AFTER INSERT ON messages - FOR EACH ROW - EXECUTE FUNCTION auto_create_team_message_recipients(); - +-- 创建 messages 表 +-- 消息表,用于存储系统消息、团队邀请通知等 + +CREATE TABLE IF NOT EXISTS messages ( + id INTEGER PRIMARY KEY AUTOINCREMENT, + uuid TEXT NOT NULL DEFAULT (randomblob(16)) UNIQUE, + + -- 消息类型 + message_type VARCHAR(50) NOT NULL, + -- 可选值: + -- 'private_chat' - 用户私信 + -- 'team_announcement' - 团队公告 + -- 'team_invitation' - 团队邀请 + -- 'team_removal' - 团队移除成员 + -- 'system_notification' - 系统通知 + + -- 消息内容 + title VARCHAR(255) NOT NULL, -- 消息标题 + content TEXT, -- 消息内容(支持 JSON 格式存储扩展数据) + + -- 发送者 + sender_uuid TEXT, -- 发送者 TEXT(系统消息可为 NULL) + + -- 接收者模式 + recipient_type VARCHAR(20) NOT NULL DEFAULT 'single', + -- 'single' - 单个用户 + -- 'multiple' - 多个指定用户 + -- 'team' - 团队内所有成员 + -- 'all' - 所有用户(系统广播) + + -- 关联资源(根据消息类型关联不同的资源) + related_type VARCHAR(50), -- 关联类型:team, invitation, etc. + related_uuid TEXT, -- 关联资源的 TEXT(如 team_uuid, invitation_uuid) + + -- 消息元数据(JSON 格式,存储扩展信息) + metadata TEXT, + -- 例如:{ + -- "team_name": "研发团队", + -- "inviter_name": "张三", + -- "role": "editor" + -- } + + -- 消息状态 + status VARCHAR(20) NOT NULL DEFAULT 'active', -- active, deleted + priority VARCHAR(20) NOT NULL DEFAULT 'normal', -- low, normal, high, urgent + + -- 时间戳 + created_at TEXT NOT NULL DEFAULT CURRENT_TIMESTAMP, + updated_at TEXT NOT NULL DEFAULT CURRENT_TIMESTAMP, + deleted_at TEXT, + + -- 外键约束 + CONSTRAINT fk_messages_sender FOREIGN KEY (sender_uuid) + REFERENCES users(uuid) ON DELETE SET NULL +); + +-- 创建 user_messages 表(用户消息关联表,支持多接收者) +CREATE TABLE IF NOT EXISTS user_messages ( + id INTEGER PRIMARY KEY AUTOINCREMENT, + message_uuid TEXT NOT NULL, + user_uuid TEXT NOT NULL, + + -- 阅读状态 + is_read INTEGER NOT NULL DEFAULT FALSE, + read_at TEXT, + + -- 操作状态(用于邀请、移除等需要操作的消息) + action_status VARCHAR(20), + -- 'pending' - 待处理(邀请类消息) + -- 'accepted' - 已接受 + -- 'rejected' - 已拒绝 + -- 'expired' - 已过期 + -- NULL - 无需操作的消息 + + action_at TEXT, + + -- 时间戳 + created_at TEXT NOT NULL DEFAULT CURRENT_TIMESTAMP, + updated_at TEXT NOT NULL DEFAULT CURRENT_TIMESTAMP, + + -- 外键约束 + CONSTRAINT fk_user_messages_message FOREIGN KEY (message_uuid) + REFERENCES messages(uuid) ON DELETE CASCADE, + CONSTRAINT fk_user_messages_user FOREIGN KEY (user_uuid) + REFERENCES users(uuid) ON DELETE CASCADE, + + -- 唯一约束:一个用户对一条消息只能有一条记录 + CONSTRAINT uk_user_messages_message_user UNIQUE (message_uuid, user_uuid) +); + +-- 创建索引 +CREATE INDEX IF NOT EXISTS idx_messages_type ON messages(message_type); +CREATE INDEX IF NOT EXISTS idx_messages_sender ON messages(sender_uuid); +CREATE INDEX IF NOT EXISTS idx_messages_related ON messages(related_type, related_uuid); +CREATE INDEX IF NOT EXISTS idx_messages_recipient_type ON messages(recipient_type); +CREATE INDEX IF NOT EXISTS idx_messages_status ON messages(status); +CREATE INDEX IF NOT EXISTS idx_messages_priority ON messages(priority); +CREATE INDEX IF NOT EXISTS idx_messages_created_at ON messages(created_at DESC); +CREATE INDEX IF NOT EXISTS idx_messages_deleted_at ON messages(deleted_at); + +CREATE INDEX IF NOT EXISTS idx_user_messages_user ON user_messages(user_uuid); +CREATE INDEX IF NOT EXISTS idx_user_messages_message ON user_messages(message_uuid); +CREATE INDEX IF NOT EXISTS idx_user_messages_is_read ON user_messages(user_uuid, is_read); +CREATE INDEX IF NOT EXISTS idx_user_messages_action_status ON user_messages(user_uuid, action_status); +CREATE INDEX IF NOT EXISTS idx_user_messages_user_unread ON user_messages(user_uuid, is_read) + WHERE is_read = FALSE; + +-- 复合索引:用于查询用户未读消息 +CREATE INDEX IF NOT EXISTS idx_user_messages_user_type_unread + ON user_messages(user_uuid, is_read, created_at DESC) + WHERE is_read = FALSE; + +-- 创建更新时间触发器 + + +-- 团队消息自动分发触发器 +-- 当创建 recipient_type='team' 的消息时,自动为团队成员创建 user_messages 记录 +CREATE TRIGGER trigger_auto_create_team_message_recipients + AFTER INSERT ON messages + FOR EACH ROW + WHEN NEW.recipient_type = 'team' + AND NEW.related_type = 'team' + AND NEW.related_uuid IS NOT NULL +BEGIN + -- 如果接收者类型是 team,且有关联的团队 TEXT + -- 为团队所有活跃成员创建消息关联记录 + INSERT INTO user_messages (message_uuid, user_uuid, is_read, action_status) + SELECT NEW.uuid, tm.user_uuid, FALSE, + CASE + WHEN NEW.message_type = 'team_invitation' THEN 'pending' + ELSE NULL + END + FROM team_members tm + WHERE tm.team_uuid = NEW.related_uuid + AND tm.status = 'active' + AND tm.deleted_at IS NULL + ON CONFLICT (message_uuid, user_uuid) DO NOTHING; +END; diff --git a/src-tauri/crates/business/migrations/20250124000002_add_deleted_at_to_team_invitations.sql b/src-tauri/crates/business/migrations/20250124000002_add_deleted_at_to_team_invitations.sql index f96eab0..b5a05dd 100644 --- a/src-tauri/crates/business/migrations/20250124000002_add_deleted_at_to_team_invitations.sql +++ b/src-tauri/crates/business/migrations/20250124000002_add_deleted_at_to_team_invitations.sql @@ -1,9 +1,8 @@ --- 为 team_invitations 表添加 deleted_at 字段 --- 用于软删除功能 - -ALTER TABLE team_invitations -ADD COLUMN IF NOT EXISTS deleted_at TIMESTAMP WITH TIME ZONE; - --- 创建索引以优化查询性能 -CREATE INDEX IF NOT EXISTS idx_invitations_deleted_at ON team_invitations(deleted_at) WHERE deleted_at IS NULL; - +-- 为 team_invitations 表添加 deleted_at 字段 +-- 用于软删除功能 + +ALTER TABLE team_invitations +ADD COLUMN deleted_at TEXT; + +-- 创建索引以优化查询性能 +CREATE INDEX IF NOT EXISTS idx_invitations_deleted_at ON team_invitations(deleted_at) WHERE deleted_at IS NULL; diff --git a/src-tauri/crates/business/migrations/20250125000001_create_workspaces.sql b/src-tauri/crates/business/migrations/20250125000001_create_workspaces.sql index 1bf948e..d3b923f 100644 --- a/src-tauri/crates/business/migrations/20250125000001_create_workspaces.sql +++ b/src-tauri/crates/business/migrations/20250125000001_create_workspaces.sql @@ -1,34 +1,23 @@ --- 创建 workspaces 表 --- 工作空间表,资源隔离的顶层容器 - -CREATE TABLE IF NOT EXISTS workspaces ( - uuid UUID PRIMARY KEY DEFAULT gen_random_uuid(), - name VARCHAR(255) NOT NULL, - owner_uuid UUID NOT NULL, - workspace_type VARCHAR(50) NOT NULL DEFAULT 'personal', - created_at TIMESTAMP WITH TIME ZONE NOT NULL DEFAULT CURRENT_TIMESTAMP, - updated_at TIMESTAMP WITH TIME ZONE NOT NULL DEFAULT CURRENT_TIMESTAMP, - deleted_at TIMESTAMP WITH TIME ZONE, - -- 约束 - CONSTRAINT fk_workspaces_owner FOREIGN KEY (owner_uuid) REFERENCES users(uuid) -); - --- 创建索引 -CREATE INDEX idx_workspaces_owner_uuid ON workspaces(owner_uuid); -CREATE INDEX idx_workspaces_deleted_at ON workspaces(deleted_at); -CREATE INDEX idx_workspaces_workspace_type ON workspaces(workspace_type); - --- 创建更新时间触发器 -CREATE TRIGGER update_workspaces_updated_at BEFORE UPDATE ON workspaces - FOR EACH ROW EXECUTE FUNCTION update_updated_at_column(); - --- 列注释 -COMMENT ON TABLE workspaces IS '工作空间表,资源隔离的顶层容器'; -COMMENT ON COLUMN workspaces.uuid IS '工作空间唯一标识'; -COMMENT ON COLUMN workspaces.name IS '工作空间名称'; -COMMENT ON COLUMN workspaces.owner_uuid IS '所有者用户 UUID'; -COMMENT ON COLUMN workspaces.workspace_type IS '工作空间类型:personal/team/enterprise'; -COMMENT ON COLUMN workspaces.created_at IS '创建时间'; -COMMENT ON COLUMN workspaces.updated_at IS '更新时间'; -COMMENT ON COLUMN workspaces.deleted_at IS '删除时间(软删除)'; - +-- 创建 workspaces 表 +-- 工作空间表,资源隔离的顶层容器 + +CREATE TABLE IF NOT EXISTS workspaces ( + uuid TEXT PRIMARY KEY DEFAULT (randomblob(16)), + name VARCHAR(255) NOT NULL, + owner_uuid TEXT NOT NULL, + workspace_type VARCHAR(50) NOT NULL DEFAULT 'personal', + created_at TEXT NOT NULL DEFAULT CURRENT_TIMESTAMP, + updated_at TEXT NOT NULL DEFAULT CURRENT_TIMESTAMP, + deleted_at TEXT, + -- 约束 + CONSTRAINT fk_workspaces_owner FOREIGN KEY (owner_uuid) REFERENCES users(uuid) +); + +-- 创建索引 +CREATE INDEX idx_workspaces_owner_uuid ON workspaces(owner_uuid); +CREATE INDEX idx_workspaces_deleted_at ON workspaces(deleted_at); +CREATE INDEX idx_workspaces_workspace_type ON workspaces(workspace_type); + +-- 创建更新时间触发器 + +-- 列注释 diff --git a/src-tauri/crates/business/migrations/20250125000002_create_workspace_quotas.sql b/src-tauri/crates/business/migrations/20250125000002_create_workspace_quotas.sql index e195227..6861aa4 100644 --- a/src-tauri/crates/business/migrations/20250125000002_create_workspace_quotas.sql +++ b/src-tauri/crates/business/migrations/20250125000002_create_workspace_quotas.sql @@ -1,43 +1,30 @@ --- 创建 workspace_quotas 表 --- 工作空间配额表,定义工作空间的资源配额限制 - -CREATE TABLE IF NOT EXISTS workspace_quotas ( - workspace_uuid UUID PRIMARY KEY, - -- 环境配额 - max_environments INT NOT NULL DEFAULT 10, - used_environments INT NOT NULL DEFAULT 0, - -- 团队成员配额(所有团队总和) - max_team_members INT NOT NULL DEFAULT 5, - used_team_members INT NOT NULL DEFAULT 0, - -- 代理配额 - max_proxies INT NOT NULL DEFAULT 10, - used_proxies INT NOT NULL DEFAULT 0, - -- RPA 任务配额 - max_rpa_tasks INT NOT NULL DEFAULT 5, - used_rpa_tasks INT NOT NULL DEFAULT 0, - -- 时间 - created_at TIMESTAMP WITH TIME ZONE NOT NULL DEFAULT CURRENT_TIMESTAMP, - updated_at TIMESTAMP WITH TIME ZONE NOT NULL DEFAULT CURRENT_TIMESTAMP, - -- 约束 - CONSTRAINT fk_workspace_quotas_workspace FOREIGN KEY (workspace_uuid) REFERENCES workspaces(uuid) ON DELETE CASCADE -); - --- 创建索引 -CREATE INDEX idx_workspace_quotas_workspace_uuid ON workspace_quotas(workspace_uuid); - --- 创建更新时间触发器 -CREATE TRIGGER update_workspace_quotas_updated_at BEFORE UPDATE ON workspace_quotas - FOR EACH ROW EXECUTE FUNCTION update_updated_at_column(); - --- 列注释 -COMMENT ON TABLE workspace_quotas IS '工作空间配额表,定义工作空间的资源配额限制'; -COMMENT ON COLUMN workspace_quotas.workspace_uuid IS '工作空间 UUID'; -COMMENT ON COLUMN workspace_quotas.max_environments IS '最大环境数'; -COMMENT ON COLUMN workspace_quotas.used_environments IS '已使用环境数'; -COMMENT ON COLUMN workspace_quotas.max_team_members IS '最大成员数(所有团队总和)'; -COMMENT ON COLUMN workspace_quotas.used_team_members IS '已使用成员数'; -COMMENT ON COLUMN workspace_quotas.max_proxies IS '最大代理数'; -COMMENT ON COLUMN workspace_quotas.used_proxies IS '已使用代理数'; -COMMENT ON COLUMN workspace_quotas.max_rpa_tasks IS '最大 RPA 任务数'; -COMMENT ON COLUMN workspace_quotas.used_rpa_tasks IS '已使用 RPA 任务数'; - +-- 创建 workspace_quotas 表 +-- 工作空间配额表,定义工作空间的资源配额限制 + +CREATE TABLE IF NOT EXISTS workspace_quotas ( + workspace_uuid TEXT PRIMARY KEY, + -- 环境配额 + max_environments INT NOT NULL DEFAULT 10, + used_environments INT NOT NULL DEFAULT 0, + -- 团队成员配额(所有团队总和) + max_team_members INT NOT NULL DEFAULT 5, + used_team_members INT NOT NULL DEFAULT 0, + -- 代理配额 + max_proxies INT NOT NULL DEFAULT 10, + used_proxies INT NOT NULL DEFAULT 0, + -- RPA 任务配额 + max_rpa_tasks INT NOT NULL DEFAULT 5, + used_rpa_tasks INT NOT NULL DEFAULT 0, + -- 时间 + created_at TEXT NOT NULL DEFAULT CURRENT_TIMESTAMP, + updated_at TEXT NOT NULL DEFAULT CURRENT_TIMESTAMP, + -- 约束 + CONSTRAINT fk_workspace_quotas_workspace FOREIGN KEY (workspace_uuid) REFERENCES workspaces(uuid) ON DELETE CASCADE +); + +-- 创建索引 +CREATE INDEX idx_workspace_quotas_workspace_uuid ON workspace_quotas(workspace_uuid); + +-- 创建更新时间触发器 + +-- 列注释 diff --git a/src-tauri/crates/business/migrations/20250125000003_create_proxy_visible_teams.sql b/src-tauri/crates/business/migrations/20250125000003_create_proxy_visible_teams.sql index 353955d..a449548 100644 --- a/src-tauri/crates/business/migrations/20250125000003_create_proxy_visible_teams.sql +++ b/src-tauri/crates/business/migrations/20250125000003_create_proxy_visible_teams.sql @@ -1,28 +1,22 @@ --- 创建 proxy_visible_teams 表 --- 代理可见团队关联表,控制代理对哪些团队可见 - -CREATE TABLE IF NOT EXISTS proxy_visible_teams ( - proxy_uuid UUID NOT NULL, - workspace_uuid UUID NOT NULL, - team_uuid UUID NOT NULL, - created_at TIMESTAMP WITH TIME ZONE NOT NULL DEFAULT CURRENT_TIMESTAMP, - -- 约束 - CONSTRAINT fk_proxy_visible_teams_proxy FOREIGN KEY (proxy_uuid) REFERENCES proxies(uuid) ON DELETE CASCADE, - CONSTRAINT fk_proxy_visible_teams_workspace FOREIGN KEY (workspace_uuid) REFERENCES workspaces(uuid) ON DELETE CASCADE, - CONSTRAINT fk_proxy_visible_teams_team FOREIGN KEY (team_uuid) REFERENCES teams(uuid) ON DELETE CASCADE, - -- 唯一约束:一个代理对一个团队只能有一条可见性记录 - CONSTRAINT uk_proxy_visible_teams UNIQUE (proxy_uuid, team_uuid) -); - --- 创建索引 -CREATE INDEX idx_proxy_visible_teams_proxy_uuid ON proxy_visible_teams(proxy_uuid); -CREATE INDEX idx_proxy_visible_teams_team_uuid ON proxy_visible_teams(team_uuid); -CREATE INDEX idx_proxy_visible_teams_workspace_uuid ON proxy_visible_teams(workspace_uuid); - --- 列注释 -COMMENT ON TABLE proxy_visible_teams IS '代理可见团队关联表,控制代理对哪些团队可见'; -COMMENT ON COLUMN proxy_visible_teams.proxy_uuid IS '代理 UUID'; -COMMENT ON COLUMN proxy_visible_teams.workspace_uuid IS '工作空间 UUID(冗余,便于查询)'; -COMMENT ON COLUMN proxy_visible_teams.team_uuid IS '团队 UUID'; -COMMENT ON COLUMN proxy_visible_teams.created_at IS '创建时间'; - +-- 创建 proxy_visible_teams 表 +-- 代理可见团队关联表,控制代理对哪些团队可见 + +CREATE TABLE IF NOT EXISTS proxy_visible_teams ( + proxy_uuid TEXT NOT NULL, + workspace_uuid TEXT NOT NULL, + team_uuid TEXT NOT NULL, + created_at TEXT NOT NULL DEFAULT CURRENT_TIMESTAMP, + -- 约束 + CONSTRAINT fk_proxy_visible_teams_proxy FOREIGN KEY (proxy_uuid) REFERENCES proxies(uuid) ON DELETE CASCADE, + CONSTRAINT fk_proxy_visible_teams_workspace FOREIGN KEY (workspace_uuid) REFERENCES workspaces(uuid) ON DELETE CASCADE, + CONSTRAINT fk_proxy_visible_teams_team FOREIGN KEY (team_uuid) REFERENCES teams(uuid) ON DELETE CASCADE, + -- 唯一约束:一个代理对一个团队只能有一条可见性记录 + CONSTRAINT uk_proxy_visible_teams UNIQUE (proxy_uuid, team_uuid) +); + +-- 创建索引 +CREATE INDEX idx_proxy_visible_teams_proxy_uuid ON proxy_visible_teams(proxy_uuid); +CREATE INDEX idx_proxy_visible_teams_team_uuid ON proxy_visible_teams(team_uuid); +CREATE INDEX idx_proxy_visible_teams_workspace_uuid ON proxy_visible_teams(workspace_uuid); + +-- 列注释 diff --git a/src-tauri/crates/business/migrations/20250125000004_create_group_member_permissions.sql b/src-tauri/crates/business/migrations/20250125000004_create_group_member_permissions.sql index 7d65419..43b5681 100644 --- a/src-tauri/crates/business/migrations/20250125000004_create_group_member_permissions.sql +++ b/src-tauri/crates/business/migrations/20250125000004_create_group_member_permissions.sql @@ -1,45 +1,33 @@ --- 创建 group_member_permissions 表 --- 分组权限表,控制团队成员对分组的访问权限 - -CREATE TABLE IF NOT EXISTS group_member_permissions ( - group_uuid UUID NOT NULL, - workspace_uuid UUID NOT NULL, - team_uuid UUID NOT NULL, - user_uuid UUID NOT NULL, - permission_type VARCHAR(50) NOT NULL DEFAULT 'read', - granted_by UUID NOT NULL, - created_at TIMESTAMP WITH TIME ZONE NOT NULL DEFAULT CURRENT_TIMESTAMP, - updated_at TIMESTAMP WITH TIME ZONE NOT NULL DEFAULT CURRENT_TIMESTAMP, - -- 约束 - CONSTRAINT fk_group_member_permissions_group FOREIGN KEY (group_uuid) REFERENCES groups(uuid) ON DELETE CASCADE, - CONSTRAINT fk_group_member_permissions_workspace FOREIGN KEY (workspace_uuid) REFERENCES workspaces(uuid) ON DELETE CASCADE, - CONSTRAINT fk_group_member_permissions_team FOREIGN KEY (team_uuid) REFERENCES teams(uuid) ON DELETE CASCADE, - CONSTRAINT fk_group_member_permissions_user FOREIGN KEY (user_uuid) REFERENCES users(uuid) ON DELETE CASCADE, - CONSTRAINT fk_group_member_permissions_granted_by FOREIGN KEY (granted_by) REFERENCES users(uuid), - -- 唯一约束:一个用户对一个分组只能有一条权限记录 - CONSTRAINT uk_group_member_permissions UNIQUE (group_uuid, user_uuid), - -- 检查约束:权限类型必须是 read/write/manage 之一 - CONSTRAINT ck_group_member_permissions_type CHECK (permission_type IN ('read', 'write', 'manage')) -); - --- 创建索引 -CREATE INDEX idx_group_member_permissions_group_uuid ON group_member_permissions(group_uuid); -CREATE INDEX idx_group_member_permissions_user_uuid ON group_member_permissions(user_uuid); -CREATE INDEX idx_group_member_permissions_workspace_uuid ON group_member_permissions(workspace_uuid); -CREATE INDEX idx_group_member_permissions_team_uuid ON group_member_permissions(team_uuid); - --- 创建更新时间触发器 -CREATE TRIGGER update_group_member_permissions_updated_at BEFORE UPDATE ON group_member_permissions - FOR EACH ROW EXECUTE FUNCTION update_updated_at_column(); - --- 列注释 -COMMENT ON TABLE group_member_permissions IS '分组权限表,控制团队成员对分组的访问权限'; -COMMENT ON COLUMN group_member_permissions.group_uuid IS '分组 UUID'; -COMMENT ON COLUMN group_member_permissions.workspace_uuid IS '工作空间 UUID(冗余,便于查询)'; -COMMENT ON COLUMN group_member_permissions.team_uuid IS '团队 UUID(冗余,便于查询)'; -COMMENT ON COLUMN group_member_permissions.user_uuid IS '用户 UUID'; -COMMENT ON COLUMN group_member_permissions.permission_type IS '权限类型:read/write/manage'; -COMMENT ON COLUMN group_member_permissions.granted_by IS '授权者 UUID'; -COMMENT ON COLUMN group_member_permissions.created_at IS '创建时间'; -COMMENT ON COLUMN group_member_permissions.updated_at IS '更新时间'; - +-- 创建 group_member_permissions 表 +-- 分组权限表,控制团队成员对分组的访问权限 + +CREATE TABLE IF NOT EXISTS group_member_permissions ( + group_uuid TEXT NOT NULL, + workspace_uuid TEXT NOT NULL, + team_uuid TEXT NOT NULL, + user_uuid TEXT NOT NULL, + permission_type VARCHAR(50) NOT NULL DEFAULT 'read', + granted_by TEXT NOT NULL, + created_at TEXT NOT NULL DEFAULT CURRENT_TIMESTAMP, + updated_at TEXT NOT NULL DEFAULT CURRENT_TIMESTAMP, + -- 约束 + CONSTRAINT fk_group_member_permissions_group FOREIGN KEY (group_uuid) REFERENCES groups(uuid) ON DELETE CASCADE, + CONSTRAINT fk_group_member_permissions_workspace FOREIGN KEY (workspace_uuid) REFERENCES workspaces(uuid) ON DELETE CASCADE, + CONSTRAINT fk_group_member_permissions_team FOREIGN KEY (team_uuid) REFERENCES teams(uuid) ON DELETE CASCADE, + CONSTRAINT fk_group_member_permissions_user FOREIGN KEY (user_uuid) REFERENCES users(uuid) ON DELETE CASCADE, + CONSTRAINT fk_group_member_permissions_granted_by FOREIGN KEY (granted_by) REFERENCES users(uuid), + -- 唯一约束:一个用户对一个分组只能有一条权限记录 + CONSTRAINT uk_group_member_permissions UNIQUE (group_uuid, user_uuid), + -- 检查约束:权限类型必须是 read/write/manage 之一 + CONSTRAINT ck_group_member_permissions_type CHECK (permission_type IN ('read', 'write', 'manage')) +); + +-- 创建索引 +CREATE INDEX idx_group_member_permissions_group_uuid ON group_member_permissions(group_uuid); +CREATE INDEX idx_group_member_permissions_user_uuid ON group_member_permissions(user_uuid); +CREATE INDEX idx_group_member_permissions_workspace_uuid ON group_member_permissions(workspace_uuid); +CREATE INDEX idx_group_member_permissions_team_uuid ON group_member_permissions(team_uuid); + +-- 创建更新时间触发器 + +-- 列注释 diff --git a/src-tauri/crates/business/migrations/20250125000005_alter_teams_add_workspace.sql b/src-tauri/crates/business/migrations/20250125000005_alter_teams_add_workspace.sql index 7c06755..018985b 100644 --- a/src-tauri/crates/business/migrations/20250125000005_alter_teams_add_workspace.sql +++ b/src-tauri/crates/business/migrations/20250125000005_alter_teams_add_workspace.sql @@ -1,19 +1,18 @@ --- 修改 teams 表,添加工作空间支持 --- 添加 workspace_uuid,移除配额相关字段和默认代理 - --- 添加 workspace_uuid 列(先允许 NULL,数据迁移后再设置为 NOT NULL) -ALTER TABLE teams ADD COLUMN IF NOT EXISTS workspace_uuid UUID; - --- 创建索引 -CREATE INDEX IF NOT EXISTS idx_teams_workspace_uuid ON teams(workspace_uuid); - --- 移除配额相关字段(配额移至 workspace_quotas) -ALTER TABLE teams DROP COLUMN IF EXISTS max_members; -ALTER TABLE teams DROP COLUMN IF EXISTS max_environments; -ALTER TABLE teams DROP COLUMN IF EXISTS max_proxies; - --- 移除默认代理字段(不再需要默认代理) -ALTER TABLE teams DROP COLUMN IF EXISTS default_proxy_uuid; - --- 注意:workspace_uuid 的外键约束和数据填充将在数据迁移脚本中完成 - +-- 修改 teams 表,添加工作空间支持 +-- 添加 workspace_uuid,移除配额相关字段和默认代理 + +-- 添加 workspace_uuid 列(先允许 NULL,数据迁移后再设置为 NOT NULL) +ALTER TABLE teams ADD COLUMN workspace_uuid TEXT REFERENCES workspaces(uuid) ON DELETE CASCADE; + +-- 创建索引 +CREATE INDEX IF NOT EXISTS idx_teams_workspace_uuid ON teams(workspace_uuid); + +-- 移除配额相关字段(配额移至 workspace_quotas) +ALTER TABLE teams DROP COLUMN max_members; +ALTER TABLE teams DROP COLUMN max_environments; +ALTER TABLE teams DROP COLUMN max_proxies; + +-- 移除默认代理字段(不再需要默认代理) +ALTER TABLE teams DROP COLUMN default_proxy_uuid; + +-- 注意:workspace_uuid 的外键约束和数据填充将在数据迁移脚本中完成 diff --git a/src-tauri/crates/business/migrations/20250125000006_alter_team_members_add_workspace.sql b/src-tauri/crates/business/migrations/20250125000006_alter_team_members_add_workspace.sql index ea5c086..42eaef9 100644 --- a/src-tauri/crates/business/migrations/20250125000006_alter_team_members_add_workspace.sql +++ b/src-tauri/crates/business/migrations/20250125000006_alter_team_members_add_workspace.sql @@ -1,21 +1,20 @@ --- 修改 team_members 表,添加工作空间支持 --- 添加 workspace_uuid(冗余字段),移除统计字段 - --- 添加 workspace_uuid 列(先允许 NULL,数据迁移后再设置为 NOT NULL) -ALTER TABLE team_members ADD COLUMN IF NOT EXISTS workspace_uuid UUID; - --- 创建索引 -CREATE INDEX IF NOT EXISTS idx_team_members_workspace_uuid ON team_members(workspace_uuid); - --- 移除统计字段(可通过查询计算) -ALTER TABLE team_members DROP COLUMN IF EXISTS environment_count; -ALTER TABLE team_members DROP COLUMN IF EXISTS group_count; - --- 删除旧的唯一约束 -ALTER TABLE team_members DROP CONSTRAINT IF EXISTS uk_team_members; - --- 添加新的唯一约束(包含 workspace_uuid) -ALTER TABLE team_members ADD CONSTRAINT uk_team_members UNIQUE (team_uuid, user_uuid, workspace_uuid); - --- 注意:workspace_uuid 的外键约束和数据填充将在数据迁移脚本中完成 - +-- 修改 team_members 表,添加工作空间支持 +-- 添加 workspace_uuid(冗余字段),移除统计字段 + +-- 添加 workspace_uuid 列(先允许 NULL,数据迁移后再设置为 NOT NULL) +ALTER TABLE team_members ADD COLUMN workspace_uuid TEXT REFERENCES workspaces(uuid) ON DELETE CASCADE; + +-- 创建索引 +CREATE INDEX IF NOT EXISTS idx_team_members_workspace_uuid ON team_members(workspace_uuid); + +-- 移除统计字段(可通过查询计算) +ALTER TABLE team_members DROP COLUMN environment_count; +ALTER TABLE team_members DROP COLUMN group_count; + +-- 删除旧的唯一约束 + +-- 添加新的唯一约束(包含 workspace_uuid) +CREATE UNIQUE INDEX IF NOT EXISTS uk_team_members_workspace +ON team_members(team_uuid, user_uuid, workspace_uuid); + +-- 注意:workspace_uuid 的外键约束和数据填充将在数据迁移脚本中完成 diff --git a/src-tauri/crates/business/migrations/20250125000007_alter_groups_add_workspace.sql b/src-tauri/crates/business/migrations/20250125000007_alter_groups_add_workspace.sql index 96e83bd..4c4e398 100644 --- a/src-tauri/crates/business/migrations/20250125000007_alter_groups_add_workspace.sql +++ b/src-tauri/crates/business/migrations/20250125000007_alter_groups_add_workspace.sql @@ -1,25 +1,23 @@ --- 修改 groups 表,添加工作空间支持 --- 添加 workspace_uuid,移除 user_uuid, default_proxy_uuid, color,确保 team_uuid NOT NULL - --- 添加 workspace_uuid 列(先允许 NULL,数据迁移后再设置为 NOT NULL) -ALTER TABLE groups ADD COLUMN IF NOT EXISTS workspace_uuid UUID; - --- 创建索引 -CREATE INDEX IF NOT EXISTS idx_groups_workspace_uuid ON groups(workspace_uuid); - --- 移除 user_uuid(分组属于团队,不再直接属于用户) -ALTER TABLE groups DROP COLUMN IF EXISTS user_uuid; - --- 移除默认代理字段(不再需要默认代理) -ALTER TABLE groups DROP COLUMN IF EXISTS default_proxy_uuid; - --- 移除 color 字段(简化设计) -ALTER TABLE groups DROP COLUMN IF EXISTS color; - --- 删除旧的索引 -DROP INDEX IF EXISTS idx_groups_user_uuid; - --- 注意: --- 1. workspace_uuid 的外键约束和数据填充将在数据迁移脚本中完成 --- 2. team_uuid 的 NOT NULL 约束将在数据迁移后添加(确保所有数据都有 team_uuid) - +-- 修改 groups 表,添加工作空间支持 +-- 添加 workspace_uuid,移除 user_uuid, default_proxy_uuid, color,确保 team_uuid NOT NULL + +-- 添加 workspace_uuid 列(先允许 NULL,数据迁移后再设置为 NOT NULL) +ALTER TABLE groups ADD COLUMN workspace_uuid TEXT REFERENCES workspaces(uuid) ON DELETE CASCADE; + +-- 创建索引 +CREATE INDEX IF NOT EXISTS idx_groups_workspace_uuid ON groups(workspace_uuid); + +-- 移除 user_uuid(分组属于团队,不再直接属于用户) +DROP INDEX IF EXISTS idx_groups_user_uuid; +ALTER TABLE groups DROP COLUMN user_uuid; + +-- 移除默认代理字段(不再需要默认代理) +ALTER TABLE groups DROP COLUMN default_proxy_uuid; + +-- 移除 color 字段(简化设计) + +-- 删除旧的索引 + +-- 注意: +-- 1. workspace_uuid 的外键约束和数据填充将在数据迁移脚本中完成 +-- 2. team_uuid 的 NOT NULL 约束将在数据迁移后添加(确保所有数据都有 team_uuid) diff --git a/src-tauri/crates/business/migrations/20250125000008_alter_environments_add_workspace.sql b/src-tauri/crates/business/migrations/20250125000008_alter_environments_add_workspace.sql index 92819d7..7e58b8d 100644 --- a/src-tauri/crates/business/migrations/20250125000008_alter_environments_add_workspace.sql +++ b/src-tauri/crates/business/migrations/20250125000008_alter_environments_add_workspace.sql @@ -1,13 +1,12 @@ --- 修改 environments 表,添加工作空间支持 --- 添加 workspace_uuid,确保 team_uuid NOT NULL - --- 添加 workspace_uuid 列(先允许 NULL,数据迁移后再设置为 NOT NULL) -ALTER TABLE environments ADD COLUMN IF NOT EXISTS workspace_uuid UUID; - --- 创建索引 -CREATE INDEX IF NOT EXISTS idx_environments_workspace_uuid ON environments(workspace_uuid); - --- 注意: --- 1. workspace_uuid 的外键约束和数据填充将在数据迁移脚本中完成 --- 2. team_uuid 的 NOT NULL 约束将在数据迁移后添加(确保所有数据都有 team_uuid) - +-- 修改 environments 表,添加工作空间支持 +-- 添加 workspace_uuid,确保 team_uuid NOT NULL + +-- 添加 workspace_uuid 列(先允许 NULL,数据迁移后再设置为 NOT NULL) +ALTER TABLE environments ADD COLUMN workspace_uuid TEXT REFERENCES workspaces(uuid) ON DELETE CASCADE; + +-- 创建索引 +CREATE INDEX IF NOT EXISTS idx_environments_workspace_uuid ON environments(workspace_uuid); + +-- 注意: +-- 1. workspace_uuid 的外键约束和数据填充将在数据迁移脚本中完成 +-- 2. team_uuid 的 NOT NULL 约束将在数据迁移后添加(确保所有数据都有 team_uuid) diff --git a/src-tauri/crates/business/migrations/20250125000009_alter_proxies_add_workspace.sql b/src-tauri/crates/business/migrations/20250125000009_alter_proxies_add_workspace.sql index 4fcedf0..60d5075 100644 --- a/src-tauri/crates/business/migrations/20250125000009_alter_proxies_add_workspace.sql +++ b/src-tauri/crates/business/migrations/20250125000009_alter_proxies_add_workspace.sql @@ -1,29 +1,28 @@ --- 修改 proxies 表,添加工作空间支持 --- 添加 workspace_uuid 和 owner_uuid(重命名自 user_uuid),移除 team_uuid, usage_count - --- 添加 workspace_uuid 列(先允许 NULL,数据迁移后再设置为 NOT NULL) -ALTER TABLE proxies ADD COLUMN IF NOT EXISTS workspace_uuid UUID; - --- 添加 owner_uuid 列(先允许 NULL,数据迁移后再设置为 NOT NULL) -ALTER TABLE proxies ADD COLUMN IF NOT EXISTS owner_uuid UUID; - --- 创建索引 -CREATE INDEX IF NOT EXISTS idx_proxies_workspace_uuid ON proxies(workspace_uuid); -CREATE INDEX IF NOT EXISTS idx_proxies_owner_uuid ON proxies(owner_uuid); - --- 将 user_uuid 的数据复制到 owner_uuid(如果 owner_uuid 为空) -UPDATE proxies SET owner_uuid = user_uuid WHERE owner_uuid IS NULL; - --- 移除 team_uuid(代理属于工作空间,不属于团队) -ALTER TABLE proxies DROP COLUMN IF EXISTS team_uuid; - --- 移除 usage_count(可通过查询计算) -ALTER TABLE proxies DROP COLUMN IF EXISTS usage_count; - --- 删除旧的索引 -DROP INDEX IF EXISTS idx_proxies_team_uuid; - --- 注意: --- 1. workspace_uuid 和 owner_uuid 的外键约束将在数据迁移脚本中完成 --- 2. user_uuid 列将在数据迁移后删除(迁移到 owner_uuid) - +-- 修改 proxies 表,添加工作空间支持 +-- 添加 workspace_uuid 和 owner_uuid(重命名自 user_uuid),移除 team_uuid, usage_count + +-- 添加 workspace_uuid 列(先允许 NULL,数据迁移后再设置为 NOT NULL) +ALTER TABLE proxies ADD COLUMN workspace_uuid TEXT REFERENCES workspaces(uuid) ON DELETE CASCADE; + +-- 添加 owner_uuid 列(先允许 NULL,数据迁移后再设置为 NOT NULL) +ALTER TABLE proxies ADD COLUMN owner_uuid TEXT REFERENCES users(uuid) ON DELETE CASCADE; + +-- 创建索引 +CREATE INDEX IF NOT EXISTS idx_proxies_workspace_uuid ON proxies(workspace_uuid); +CREATE INDEX IF NOT EXISTS idx_proxies_owner_uuid ON proxies(owner_uuid); + +-- 将 user_uuid 的数据复制到 owner_uuid(如果 owner_uuid 为空) +UPDATE proxies SET owner_uuid = user_uuid WHERE owner_uuid IS NULL; + +-- 移除 team_uuid(代理属于工作空间,不属于团队) +DROP INDEX IF EXISTS idx_proxies_team_uuid; +ALTER TABLE proxies DROP COLUMN team_uuid; + +-- 移除 usage_count(可通过查询计算) +ALTER TABLE proxies DROP COLUMN usage_count; + +-- 删除旧的索引 + +-- 注意: +-- 1. workspace_uuid 和 owner_uuid 的外键约束将在数据迁移脚本中完成 +-- 2. user_uuid 列将在数据迁移后删除(迁移到 owner_uuid) diff --git a/src-tauri/crates/business/migrations/20250125000010_alter_subscriptions_add_workspace.sql b/src-tauri/crates/business/migrations/20250125000010_alter_subscriptions_add_workspace.sql deleted file mode 100644 index eb6a47d..0000000 --- a/src-tauri/crates/business/migrations/20250125000010_alter_subscriptions_add_workspace.sql +++ /dev/null @@ -1,13 +0,0 @@ --- 修改 subscriptions 表,添加工作空间支持 --- 添加 workspace_uuid,保留 user_uuid(用于记录订阅者) - --- 添加 workspace_uuid 列(先允许 NULL,数据迁移后再设置为 NOT NULL) -ALTER TABLE subscriptions ADD COLUMN IF NOT EXISTS workspace_uuid UUID; - --- 创建索引 -CREATE INDEX IF NOT EXISTS idx_subscriptions_workspace_uuid ON subscriptions(workspace_uuid); - --- 注意: --- 1. workspace_uuid 的外键约束和数据填充将在数据迁移脚本中完成 --- 2. 唯一约束(确保一个工作空间只有一个活跃订阅)将在数据迁移后添加 - diff --git a/src-tauri/crates/business/migrations/20250125000011_deprecate_user_quotas.sql b/src-tauri/crates/business/migrations/20250125000011_deprecate_user_quotas.sql deleted file mode 100644 index 294fc7a..0000000 --- a/src-tauri/crates/business/migrations/20250125000011_deprecate_user_quotas.sql +++ /dev/null @@ -1,8 +0,0 @@ --- 废弃 user_quotas 表 --- 保留表结构,但标记为废弃,数据将迁移到 workspace_quotas - --- 添加注释标记为废弃 -COMMENT ON TABLE user_quotas IS '已废弃:配额已迁移到 workspace_quotas 表,此表保留仅用于历史数据查询'; - --- 注意:表结构保持不变,数据迁移将在数据迁移脚本中完成 - diff --git a/src-tauri/crates/business/migrations/20250125000012_migrate_to_workspaces.sql b/src-tauri/crates/business/migrations/20250125000012_migrate_to_workspaces.sql deleted file mode 100644 index 3140a0c..0000000 --- a/src-tauri/crates/business/migrations/20250125000012_migrate_to_workspaces.sql +++ /dev/null @@ -1,416 +0,0 @@ --- 数据迁移脚本:将现有数据迁移到工作空间架构 --- 此脚本将: --- 1. 为每个用户创建默认工作空间(personal 类型) --- 2. 为每个现有团队创建对应的工作空间(team 类型) --- 3. 迁移配额数据从 user_quotas 到 workspace_quotas --- 4. 更新所有相关表的外键关系 - -BEGIN; - --- ============================================ --- 步骤 1: 为每个用户创建默认工作空间 --- ============================================ -INSERT INTO workspaces (uuid, name, owner_uuid, workspace_type, created_at, updated_at) -SELECT - gen_random_uuid(), - COALESCE(ui.nickname, ui.email, '我的工作空间') || ' 的工作空间', - u.uuid, - 'personal', - u.created_at, - u.updated_at -FROM users u -LEFT JOIN user_infos ui ON u.uuid = ui.user_uuid -WHERE u.deleted_at IS NULL - AND NOT EXISTS ( - SELECT 1 FROM workspaces w - WHERE w.owner_uuid = u.uuid - AND w.workspace_type = 'personal' - AND w.deleted_at IS NULL - ); - --- ============================================ --- 步骤 2: 为每个现有团队创建对应的工作空间 --- ============================================ -INSERT INTO workspaces (uuid, name, owner_uuid, workspace_type, created_at, updated_at) -SELECT - gen_random_uuid(), - t.name || ' 的工作空间', - t.owner_uuid, - 'team', - t.created_at, - t.updated_at -FROM teams t -WHERE t.deleted_at IS NULL - AND NOT EXISTS ( - SELECT 1 FROM workspaces w - WHERE w.owner_uuid = t.owner_uuid - AND w.name = t.name || ' 的工作空间' - AND w.deleted_at IS NULL - ); - --- ============================================ --- 步骤 3: 更新 teams 表的 workspace_uuid --- ============================================ --- 为每个团队分配工作空间(优先使用团队对应的工作空间,如果没有则使用团队所有者的个人工作空间) -UPDATE teams t -SET workspace_uuid = COALESCE( - (SELECT w.uuid FROM workspaces w - WHERE w.owner_uuid = t.owner_uuid - AND w.workspace_type = 'team' - AND w.name = t.name || ' 的工作空间' - AND w.deleted_at IS NULL - LIMIT 1), - (SELECT w.uuid FROM workspaces w - WHERE w.owner_uuid = t.owner_uuid - AND w.workspace_type = 'personal' - AND w.deleted_at IS NULL - LIMIT 1) -) -WHERE t.workspace_uuid IS NULL - AND t.deleted_at IS NULL; - --- 设置 workspace_uuid 为 NOT NULL -ALTER TABLE teams ALTER COLUMN workspace_uuid SET NOT NULL; - --- 添加外键约束 -ALTER TABLE teams -ADD CONSTRAINT fk_teams_workspace -FOREIGN KEY (workspace_uuid) REFERENCES workspaces(uuid) ON DELETE CASCADE; - --- ============================================ --- 步骤 4: 更新 team_members 表的 workspace_uuid --- ============================================ -UPDATE team_members tm -SET workspace_uuid = t.workspace_uuid -FROM teams t -WHERE tm.team_uuid = t.uuid - AND tm.workspace_uuid IS NULL - AND tm.deleted_at IS NULL; - --- 设置 workspace_uuid 为 NOT NULL -ALTER TABLE team_members ALTER COLUMN workspace_uuid SET NOT NULL; - --- 添加外键约束 -ALTER TABLE team_members -ADD CONSTRAINT fk_team_members_workspace -FOREIGN KEY (workspace_uuid) REFERENCES workspaces(uuid) ON DELETE CASCADE; - --- ============================================ --- 步骤 5: 更新 groups 表的 workspace_uuid --- ============================================ --- 首先更新有 team_uuid 的分组 -UPDATE groups g -SET workspace_uuid = t.workspace_uuid -FROM teams t -WHERE g.team_uuid = t.uuid - AND g.workspace_uuid IS NULL - AND g.deleted_at IS NULL; - --- 对于没有 team_uuid 的分组,先分配一个默认团队 -UPDATE groups g -SET team_uuid = ( - SELECT t.uuid FROM teams t - INNER JOIN workspaces w ON t.workspace_uuid = w.uuid - WHERE w.owner_uuid = ( - -- 尝试从 environments 表找到关联的用户 - SELECT e.user_uuid FROM environments e - WHERE e.group_uuid = g.uuid - AND e.deleted_at IS NULL - LIMIT 1 - ) - AND t.deleted_at IS NULL - LIMIT 1 -) -WHERE g.team_uuid IS NULL - AND g.deleted_at IS NULL; - --- 再次更新 workspace_uuid(包括刚刚分配了 team_uuid 的分组) -UPDATE groups g -SET workspace_uuid = t.workspace_uuid -FROM teams t -WHERE g.team_uuid = t.uuid - AND g.workspace_uuid IS NULL - AND g.deleted_at IS NULL; - --- 对于仍然没有 workspace_uuid 的分组,使用用户的个人工作空间 -UPDATE groups g -SET workspace_uuid = ( - SELECT w.uuid FROM workspaces w - WHERE w.owner_uuid = ( - SELECT e.user_uuid FROM environments e - WHERE e.group_uuid = g.uuid - AND e.deleted_at IS NULL - LIMIT 1 - ) - AND w.workspace_type = 'personal' - AND w.deleted_at IS NULL - LIMIT 1 -) -WHERE g.workspace_uuid IS NULL - AND g.deleted_at IS NULL; - --- 如果还有 NULL 值,使用第一个可用的工作空间(兜底方案) -UPDATE groups g -SET workspace_uuid = ( - SELECT w.uuid FROM workspaces w - WHERE w.deleted_at IS NULL - ORDER BY w.created_at - LIMIT 1 -) -WHERE g.workspace_uuid IS NULL - AND g.deleted_at IS NULL; - --- 验证:确保所有分组都有 workspace_uuid -DO $$ -DECLARE - null_count INTEGER; -BEGIN - SELECT COUNT(*) INTO null_count - FROM groups - WHERE workspace_uuid IS NULL - AND deleted_at IS NULL; - - IF null_count > 0 THEN - RAISE EXCEPTION '仍有 % 个分组的 workspace_uuid 为 NULL,请手动处理', null_count; - END IF; -END $$; - --- 设置 workspace_uuid 为 NOT NULL -ALTER TABLE groups ALTER COLUMN workspace_uuid SET NOT NULL; - --- 确保 team_uuid 为 NOT NULL(删除没有 team_uuid 的分组或分配默认团队) --- 注意:这里假设所有分组都应该有 team_uuid,如果没有则可能需要手动处理 -UPDATE groups g -SET team_uuid = ( - SELECT t.uuid FROM teams t - INNER JOIN workspaces w ON t.workspace_uuid = w.uuid - WHERE w.owner_uuid = ( - SELECT owner_uuid FROM workspaces WHERE uuid = g.workspace_uuid - ) - AND t.deleted_at IS NULL - LIMIT 1 -) -WHERE g.team_uuid IS NULL -AND g.deleted_at IS NULL; - --- 设置 team_uuid 为 NOT NULL(如果还有 NULL 值,可能需要手动处理) --- ALTER TABLE groups ALTER COLUMN team_uuid SET NOT NULL; - --- 添加外键约束 -ALTER TABLE groups -ADD CONSTRAINT fk_groups_workspace -FOREIGN KEY (workspace_uuid) REFERENCES workspaces(uuid) ON DELETE CASCADE; - --- ============================================ --- 步骤 6: 更新 environments 表的 workspace_uuid --- ============================================ -UPDATE environments e -SET workspace_uuid = t.workspace_uuid -FROM teams t -WHERE e.team_uuid = t.uuid - AND e.workspace_uuid IS NULL - AND e.deleted_at IS NULL; - --- 对于没有 team_uuid 的环境,使用用户的个人工作空间 -UPDATE environments e -SET workspace_uuid = ( - SELECT w.uuid FROM workspaces w - WHERE w.owner_uuid = e.user_uuid - AND w.workspace_type = 'personal' - AND w.deleted_at IS NULL - LIMIT 1 -) -WHERE e.workspace_uuid IS NULL - AND e.deleted_at IS NULL; - --- 设置 workspace_uuid 为 NOT NULL -ALTER TABLE environments ALTER COLUMN workspace_uuid SET NOT NULL; - --- 确保 team_uuid 为 NOT NULL(为没有 team_uuid 的环境分配默认团队) -UPDATE environments e -SET team_uuid = ( - SELECT t.uuid FROM teams t - INNER JOIN workspaces w ON t.workspace_uuid = w.uuid - WHERE w.uuid = e.workspace_uuid - AND t.deleted_at IS NULL - LIMIT 1 -) -WHERE e.team_uuid IS NULL - AND e.deleted_at IS NULL; - --- 设置 team_uuid 为 NOT NULL(如果还有 NULL 值,可能需要手动处理) --- ALTER TABLE environments ALTER COLUMN team_uuid SET NOT NULL; - --- 添加外键约束 -ALTER TABLE environments -ADD CONSTRAINT fk_environments_workspace -FOREIGN KEY (workspace_uuid) REFERENCES workspaces(uuid) ON DELETE CASCADE; - --- ============================================ --- 步骤 7: 更新 proxies 表的 workspace_uuid 和 owner_uuid --- ============================================ --- 确保 owner_uuid 已填充(从 user_uuid 复制) -UPDATE proxies p -SET owner_uuid = p.user_uuid -WHERE p.owner_uuid IS NULL - AND p.deleted_at IS NULL; - --- 为代理分配工作空间(优先使用团队的工作空间,如果没有则使用用户的个人工作空间) -UPDATE proxies p -SET workspace_uuid = COALESCE( - (SELECT t.workspace_uuid FROM teams t - WHERE t.uuid = ( - SELECT e.team_uuid FROM environments e - WHERE e.proxy_uuid = p.uuid - AND e.deleted_at IS NULL - LIMIT 1 - ) - AND t.deleted_at IS NULL - LIMIT 1), - (SELECT w.uuid FROM workspaces w - WHERE w.owner_uuid = p.owner_uuid - AND w.workspace_type = 'personal' - AND w.deleted_at IS NULL - LIMIT 1) -) -WHERE p.workspace_uuid IS NULL - AND p.deleted_at IS NULL; - --- 设置 workspace_uuid 和 owner_uuid 为 NOT NULL -ALTER TABLE proxies ALTER COLUMN workspace_uuid SET NOT NULL; -ALTER TABLE proxies ALTER COLUMN owner_uuid SET NOT NULL; - --- 添加外键约束 -ALTER TABLE proxies -ADD CONSTRAINT fk_proxies_workspace -FOREIGN KEY (workspace_uuid) REFERENCES workspaces(uuid) ON DELETE CASCADE; - -ALTER TABLE proxies -ADD CONSTRAINT fk_proxies_owner -FOREIGN KEY (owner_uuid) REFERENCES users(uuid) ON DELETE CASCADE; - --- 删除旧的 user_uuid 列(如果存在) -ALTER TABLE proxies DROP COLUMN IF EXISTS user_uuid; - --- ============================================ --- 步骤 8: 更新 subscriptions 表的 workspace_uuid --- ============================================ --- 为订阅分配工作空间(使用订阅者的个人工作空间) -UPDATE subscriptions s -SET workspace_uuid = ( - SELECT w.uuid FROM workspaces w - WHERE w.owner_uuid = s.user_uuid - AND w.workspace_type = 'personal' - AND w.deleted_at IS NULL - LIMIT 1 -) -WHERE s.workspace_uuid IS NULL; - --- 设置 workspace_uuid 为 NOT NULL -ALTER TABLE subscriptions ALTER COLUMN workspace_uuid SET NOT NULL; - --- 添加外键约束 -ALTER TABLE subscriptions -ADD CONSTRAINT fk_subscriptions_workspace -FOREIGN KEY (workspace_uuid) REFERENCES workspaces(uuid) ON DELETE CASCADE; - --- ============================================ --- 步骤 9: 迁移配额数据从 user_quotas 到 workspace_quotas --- ============================================ -INSERT INTO workspace_quotas ( - workspace_uuid, - max_environments, - used_environments, - max_team_members, - used_team_members, - max_proxies, - used_proxies, - max_rpa_tasks, - used_rpa_tasks, - created_at, - updated_at -) -SELECT - w.uuid, - COALESCE(uq.max_environments, 10), - COALESCE(uq.used_environments, 0), - COALESCE(uq.max_team_members, 5), - 0, -- used_team_members: user_quotas 表中没有此字段,使用默认值 0 - COALESCE(uq.max_proxies, 10), - COALESCE(uq.used_proxies, 0), - COALESCE(uq.max_rpa_tasks, 5), - COALESCE(uq.used_rpa_tasks, 0), - COALESCE(uq.created_at, CURRENT_TIMESTAMP), - COALESCE(uq.updated_at, CURRENT_TIMESTAMP) -FROM workspaces w -LEFT JOIN user_quotas uq ON w.owner_uuid = uq.user_uuid -WHERE w.deleted_at IS NULL - AND NOT EXISTS ( - SELECT 1 FROM workspace_quotas wq - WHERE wq.workspace_uuid = w.uuid - ); - --- ============================================ --- 步骤 10: 更新配额使用情况统计 --- ============================================ --- 更新环境使用数 -UPDATE workspace_quotas wq -SET used_environments = ( - SELECT COUNT(*) FROM environments e - WHERE e.workspace_uuid = wq.workspace_uuid - AND e.deleted_at IS NULL -); - --- 更新代理使用数 -UPDATE workspace_quotas wq -SET used_proxies = ( - SELECT COUNT(*) FROM proxies p - WHERE p.workspace_uuid = wq.workspace_uuid - AND p.deleted_at IS NULL -); - --- 更新团队成员使用数(所有团队总和) -UPDATE workspace_quotas wq -SET used_team_members = ( - SELECT COUNT(DISTINCT tm.user_uuid) FROM team_members tm - INNER JOIN teams t ON tm.team_uuid = t.uuid - WHERE t.workspace_uuid = wq.workspace_uuid - AND tm.deleted_at IS NULL - AND t.deleted_at IS NULL -); - --- 更新 RPA 任务使用数(通过 team_uuid 关联到工作空间) -UPDATE workspace_quotas wq -SET used_rpa_tasks = ( - SELECT COUNT(*) FROM rpa_tasks rt - INNER JOIN teams t ON rt.team_uuid = t.uuid - WHERE t.workspace_uuid = wq.workspace_uuid - AND rt.deleted_at IS NULL - AND t.deleted_at IS NULL -) -WHERE EXISTS ( - SELECT 1 FROM information_schema.tables - WHERE table_name = 'rpa_tasks' -); - -COMMIT; - --- 迁移完成后的验证查询(可选,用于检查迁移结果) --- SELECT --- 'workspaces' as table_name, --- COUNT(*) as total_count, --- COUNT(*) FILTER (WHERE deleted_at IS NULL) as active_count --- FROM workspaces --- UNION ALL --- SELECT --- 'teams', --- COUNT(*), --- COUNT(*) FILTER (WHERE deleted_at IS NULL) --- FROM teams --- UNION ALL --- SELECT --- 'workspace_quotas', --- COUNT(*), --- COUNT(*) --- FROM workspace_quotas; - diff --git a/src-tauri/crates/business/migrations/20250125000013_alter_user_infos_add_current_workspace.sql b/src-tauri/crates/business/migrations/20250125000013_alter_user_infos_add_current_workspace.sql index 9ccc763..2cbd933 100644 --- a/src-tauri/crates/business/migrations/20250125000013_alter_user_infos_add_current_workspace.sql +++ b/src-tauri/crates/business/migrations/20250125000013_alter_user_infos_add_current_workspace.sql @@ -1,44 +1,32 @@ --- 修改 user_infos 表,添加当前工作空间字段 --- 用于工作空间切换功能 - -ALTER TABLE user_infos -ADD COLUMN IF NOT EXISTS current_workspace_uuid UUID; - --- 添加外键约束(如果不存在) -DO $$ -BEGIN - IF NOT EXISTS ( - SELECT 1 FROM information_schema.table_constraints - WHERE constraint_name = 'fk_user_infos_current_workspace' - ) THEN - ALTER TABLE user_infos - ADD CONSTRAINT fk_user_infos_current_workspace - FOREIGN KEY (current_workspace_uuid) REFERENCES workspaces(uuid) ON DELETE SET NULL; - END IF; -END $$; - --- 创建索引 -CREATE INDEX IF NOT EXISTS idx_user_infos_current_workspace ON user_infos(current_workspace_uuid); - --- 从当前团队的工作空间初始化 current_workspace_uuid -UPDATE user_infos ui -SET current_workspace_uuid = ( - SELECT t.workspace_uuid FROM teams t - WHERE t.uuid = ui.current_team_uuid - AND t.deleted_at IS NULL - LIMIT 1 -) -WHERE ui.current_team_uuid IS NOT NULL - AND ui.current_workspace_uuid IS NULL; - --- 对于没有当前团队的用户,使用其个人工作空间 -UPDATE user_infos ui -SET current_workspace_uuid = ( - SELECT w.uuid FROM workspaces w - WHERE w.owner_uuid = ui.user_uuid - AND w.workspace_type = 'personal' - AND w.deleted_at IS NULL - LIMIT 1 -) -WHERE ui.current_workspace_uuid IS NULL; - +-- 修改 user_infos 表,添加当前工作空间字段 +-- 用于工作空间切换功能 + +ALTER TABLE user_infos +ADD COLUMN current_workspace_uuid TEXT REFERENCES workspaces(uuid) ON DELETE SET NULL; + +-- 添加外键约束(如果不存在) + +-- 创建索引 +CREATE INDEX IF NOT EXISTS idx_user_infos_current_workspace ON user_infos(current_workspace_uuid); + +-- 从当前团队的工作空间初始化 current_workspace_uuid +UPDATE user_infos +SET current_workspace_uuid = ( + SELECT t.workspace_uuid FROM teams t + WHERE t.uuid = user_infos.current_team_uuid + AND t.deleted_at IS NULL + LIMIT 1 +) +WHERE current_team_uuid IS NOT NULL + AND current_workspace_uuid IS NULL; + +-- 对于没有当前团队的用户,使用其个人工作空间 +UPDATE user_infos +SET current_workspace_uuid = ( + SELECT w.uuid FROM workspaces w + WHERE w.owner_uuid = user_infos.user_uuid + AND w.workspace_type = 'personal' + AND w.deleted_at IS NULL + LIMIT 1 +) +WHERE current_workspace_uuid IS NULL; diff --git a/src-tauri/crates/business/migrations/20250126000001_insert_default_plans_with_features.sql b/src-tauri/crates/business/migrations/20250126000001_insert_default_plans_with_features.sql deleted file mode 100644 index e2366bb..0000000 --- a/src-tauri/crates/business/migrations/20250126000001_insert_default_plans_with_features.sql +++ /dev/null @@ -1,432 +0,0 @@ --- 插入默认套餐数据和特性 --- 删除现有套餐数据(包括关联的特性数据,因为有外键约束会自动删除) - --- 1. 先删除 invoices 表中引用 subscriptions 的数据 --- 由于 invoices 表有外键约束引用 subscriptions,需要先删除发票数据 --- 注意:这会删除所有现有发票记录,请确保这是预期的行为 -DELETE FROM invoices; - --- 2. 删除 subscriptions 表中引用 plans 的数据 --- 由于 subscriptions 表有外键约束(没有 ON DELETE CASCADE),需要先删除订阅数据 --- 注意:这会删除所有现有订阅记录,请确保这是预期的行为 -DELETE FROM subscriptions; - --- 3. 删除现有套餐数据(会级联删除 plan_features 数据) -DELETE FROM plans; - --- 4. 插入新的套餐数据 --- 套餐:免费套餐、基础套餐、专业套餐、商业套餐、企业套餐 --- 月付价格:0, 21.4, 11.4, 758.4, 3558.4 --- 年付价格:按月付*10计算(相当于2个月免费) --- 最大环境:8, 100, 1000, 10000, 100000 --- 最大团队:1, 5, 10, 18, 30 --- 代理数量:所有都是99999 --- max_rpa_tasks:所有都是99999 - -INSERT INTO plans (uuid, name, description, price_per_month, price_per_year, currency, max_environments, max_team_members, max_proxies, max_rpa_tasks, is_recommended, sort_order, status) -VALUES - -- 免费套餐:月付 0,年付 0,环境 8,团队 1 - (gen_random_uuid(), '免费套餐', '适合个人试用,体验基础功能', 0.00, 0.00, 'USD', 8, 1, 99999, 99999, FALSE, 1, 'active'), - -- 基础套餐:月付 21.4,年付 214.00,环境 100,团队 5 - (gen_random_uuid(), '基础套餐', '适合个人用户和小型项目', 21.40, 214.00, 'USD', 100, 5, 99999, 99999, FALSE, 2, 'active'), - -- 专业套餐:月付 11.4,年付 114.00,环境 1000,团队 10 - (gen_random_uuid(), '专业套餐', '适合小型团队和成长型企业', 108.40, 1084.00, 'USD', 1000, 10, 99999, 99999, TRUE, 3, 'active'), - -- 商业套餐:月付 758.4,年付 7584.00,环境 10000,团队 18 - (gen_random_uuid(), '商业套餐', '适合中大型企业和专业团队', 758.40, 7584.00, 'USD', 10000, 18, 99999, 99999, FALSE, 4, 'active'), - -- 企业套餐:月付 3558.4,年付 35584.00,环境 100000,团队 30 - (gen_random_uuid(), '企业套餐', '适合大型企业,提供最高级别的服务和支持', 3558.40, 35584.00, 'USD', 100000, 30, 99999, 99999, FALSE, 5, 'active') -ON CONFLICT DO NOTHING; - --- 5. 为每个套餐插入特性数据 --- 使用 CTE 来获取套餐 UUID,然后插入特性 - --- 免费套餐特性 -INSERT INTO plan_features (plan_uuid, feature_key, feature_name, feature_value, is_included, sort_order) -SELECT - p.uuid, - 'env_limit', - '环境数量', - '8 个环境', - TRUE, - 1 -FROM plans p WHERE p.name = '免费套餐' -ON CONFLICT (plan_uuid, feature_key) DO NOTHING; - -INSERT INTO plan_features (plan_uuid, feature_key, feature_name, feature_value, is_included, sort_order) -SELECT - p.uuid, - 'team_members', - '团队成员', - '1 人', - TRUE, - 2 -FROM plans p WHERE p.name = '免费套餐' -ON CONFLICT (plan_uuid, feature_key) DO NOTHING; - -INSERT INTO plan_features (plan_uuid, feature_key, feature_name, feature_value, is_included, sort_order) -SELECT - p.uuid, - 'basic_support', - '基础支持', - '社区支持', - TRUE, - 3 -FROM plans p WHERE p.name = '免费套餐' -ON CONFLICT (plan_uuid, feature_key) DO NOTHING; - --- 基础套餐特性 -INSERT INTO plan_features (plan_uuid, feature_key, feature_name, feature_value, is_included, sort_order) -SELECT - p.uuid, - 'env_limit', - '环境数量', - '100 个环境', - TRUE, - 1 -FROM plans p WHERE p.name = '基础套餐' -ON CONFLICT (plan_uuid, feature_key) DO NOTHING; - -INSERT INTO plan_features (plan_uuid, feature_key, feature_name, feature_value, is_included, sort_order) -SELECT - p.uuid, - 'team_members', - '团队成员', - '5 人', - TRUE, - 2 -FROM plans p WHERE p.name = '基础套餐' -ON CONFLICT (plan_uuid, feature_key) DO NOTHING; - -INSERT INTO plan_features (plan_uuid, feature_key, feature_name, feature_value, is_included, sort_order) -SELECT - p.uuid, - 'proxy_access', - '代理访问', - '99999 个代理', - TRUE, - 3 -FROM plans p WHERE p.name = '基础套餐' -ON CONFLICT (plan_uuid, feature_key) DO NOTHING; - -INSERT INTO plan_features (plan_uuid, feature_key, feature_name, feature_value, is_included, sort_order) -SELECT - p.uuid, - 'rpa_tasks', - 'RPA 任务', - '99999 个任务', - TRUE, - 4 -FROM plans p WHERE p.name = '基础套餐' -ON CONFLICT (plan_uuid, feature_key) DO NOTHING; - -INSERT INTO plan_features (plan_uuid, feature_key, feature_name, feature_value, is_included, sort_order) -SELECT - p.uuid, - 'email_support', - '邮件支持', - '工作日支持', - TRUE, - 5 -FROM plans p WHERE p.name = '基础套餐' -ON CONFLICT (plan_uuid, feature_key) DO NOTHING; - --- 专业套餐特性(包含基础套餐的所有特性,并增加更多) -INSERT INTO plan_features (plan_uuid, feature_key, feature_name, feature_value, is_included, sort_order) -SELECT - p.uuid, - 'env_limit', - '环境数量', - '1000 个环境', - TRUE, - 1 -FROM plans p WHERE p.name = '专业套餐' -ON CONFLICT (plan_uuid, feature_key) DO NOTHING; - -INSERT INTO plan_features (plan_uuid, feature_key, feature_name, feature_value, is_included, sort_order) -SELECT - p.uuid, - 'team_members', - '团队成员', - '10 人', - TRUE, - 2 -FROM plans p WHERE p.name = '专业套餐' -ON CONFLICT (plan_uuid, feature_key) DO NOTHING; - -INSERT INTO plan_features (plan_uuid, feature_key, feature_name, feature_value, is_included, sort_order) -SELECT - p.uuid, - 'proxy_access', - '代理访问', - '99999 个代理', - TRUE, - 3 -FROM plans p WHERE p.name = '专业套餐' -ON CONFLICT (plan_uuid, feature_key) DO NOTHING; - -INSERT INTO plan_features (plan_uuid, feature_key, feature_name, feature_value, is_included, sort_order) -SELECT - p.uuid, - 'rpa_tasks', - 'RPA 任务', - '99999 个任务', - TRUE, - 4 -FROM plans p WHERE p.name = '专业套餐' -ON CONFLICT (plan_uuid, feature_key) DO NOTHING; - -INSERT INTO plan_features (plan_uuid, feature_key, feature_name, feature_value, is_included, sort_order) -SELECT - p.uuid, - 'priority_support', - '优先支持', - '工作日优先响应', - TRUE, - 5 -FROM plans p WHERE p.name = '专业套餐' -ON CONFLICT (plan_uuid, feature_key) DO NOTHING; - -INSERT INTO plan_features (plan_uuid, feature_key, feature_name, feature_value, is_included, sort_order) -SELECT - p.uuid, - 'api_access', - 'API 访问', - '完整 API 权限', - TRUE, - 6 -FROM plans p WHERE p.name = '专业套餐' -ON CONFLICT (plan_uuid, feature_key) DO NOTHING; - -INSERT INTO plan_features (plan_uuid, feature_key, feature_name, feature_value, is_included, sort_order) -SELECT - p.uuid, - 'advanced_features', - '高级功能', - '指纹管理、Cookie 同步等', - TRUE, - 7 -FROM plans p WHERE p.name = '专业套餐' -ON CONFLICT (plan_uuid, feature_key) DO NOTHING; - --- 商业套餐特性(包含专业套餐的所有特性,并增加更多) -INSERT INTO plan_features (plan_uuid, feature_key, feature_name, feature_value, is_included, sort_order) -SELECT - p.uuid, - 'env_limit', - '环境数量', - '10000 个环境', - TRUE, - 1 -FROM plans p WHERE p.name = '商业套餐' -ON CONFLICT (plan_uuid, feature_key) DO NOTHING; - -INSERT INTO plan_features (plan_uuid, feature_key, feature_name, feature_value, is_included, sort_order) -SELECT - p.uuid, - 'team_members', - '团队成员', - '18 人', - TRUE, - 2 -FROM plans p WHERE p.name = '商业套餐' -ON CONFLICT (plan_uuid, feature_key) DO NOTHING; - -INSERT INTO plan_features (plan_uuid, feature_key, feature_name, feature_value, is_included, sort_order) -SELECT - p.uuid, - 'proxy_access', - '代理访问', - '99999 个代理', - TRUE, - 3 -FROM plans p WHERE p.name = '商业套餐' -ON CONFLICT (plan_uuid, feature_key) DO NOTHING; - -INSERT INTO plan_features (plan_uuid, feature_key, feature_name, feature_value, is_included, sort_order) -SELECT - p.uuid, - 'rpa_tasks', - 'RPA 任务', - '99999 个任务', - TRUE, - 4 -FROM plans p WHERE p.name = '商业套餐' -ON CONFLICT (plan_uuid, feature_key) DO NOTHING; - -INSERT INTO plan_features (plan_uuid, feature_key, feature_name, feature_value, is_included, sort_order) -SELECT - p.uuid, - 'dedicated_support', - '专属支持', - '7x24 小时专属支持', - TRUE, - 5 -FROM plans p WHERE p.name = '商业套餐' -ON CONFLICT (plan_uuid, feature_key) DO NOTHING; - -INSERT INTO plan_features (plan_uuid, feature_key, feature_name, feature_value, is_included, sort_order) -SELECT - p.uuid, - 'api_access', - 'API 访问', - '完整 API 权限 + 高级 API', - TRUE, - 6 -FROM plans p WHERE p.name = '商业套餐' -ON CONFLICT (plan_uuid, feature_key) DO NOTHING; - -INSERT INTO plan_features (plan_uuid, feature_key, feature_name, feature_value, is_included, sort_order) -SELECT - p.uuid, - 'advanced_features', - '高级功能', - '所有高级功能', - TRUE, - 7 -FROM plans p WHERE p.name = '商业套餐' -ON CONFLICT (plan_uuid, feature_key) DO NOTHING; - -INSERT INTO plan_features (plan_uuid, feature_key, feature_name, feature_value, is_included, sort_order) -SELECT - p.uuid, - 'custom_integration', - '定制集成', - '支持定制化集成', - TRUE, - 8 -FROM plans p WHERE p.name = '商业套餐' -ON CONFLICT (plan_uuid, feature_key) DO NOTHING; - -INSERT INTO plan_features (plan_uuid, feature_key, feature_name, feature_value, is_included, sort_order) -SELECT - p.uuid, - 'sla_guarantee', - 'SLA 保障', - '99.9% 可用性保障', - TRUE, - 9 -FROM plans p WHERE p.name = '商业套餐' -ON CONFLICT (plan_uuid, feature_key) DO NOTHING; - --- 企业套餐特性(包含商业套餐的所有特性,并增加更多) -INSERT INTO plan_features (plan_uuid, feature_key, feature_name, feature_value, is_included, sort_order) -SELECT - p.uuid, - 'env_limit', - '环境数量', - '100000 个环境', - TRUE, - 1 -FROM plans p WHERE p.name = '企业套餐' -ON CONFLICT (plan_uuid, feature_key) DO NOTHING; - -INSERT INTO plan_features (plan_uuid, feature_key, feature_name, feature_value, is_included, sort_order) -SELECT - p.uuid, - 'team_members', - '团队成员', - '30 人', - TRUE, - 2 -FROM plans p WHERE p.name = '企业套餐' -ON CONFLICT (plan_uuid, feature_key) DO NOTHING; - -INSERT INTO plan_features (plan_uuid, feature_key, feature_name, feature_value, is_included, sort_order) -SELECT - p.uuid, - 'proxy_access', - '代理访问', - '99999 个代理', - TRUE, - 3 -FROM plans p WHERE p.name = '企业套餐' -ON CONFLICT (plan_uuid, feature_key) DO NOTHING; - -INSERT INTO plan_features (plan_uuid, feature_key, feature_name, feature_value, is_included, sort_order) -SELECT - p.uuid, - 'rpa_tasks', - 'RPA 任务', - '99999 个任务', - TRUE, - 4 -FROM plans p WHERE p.name = '企业套餐' -ON CONFLICT (plan_uuid, feature_key) DO NOTHING; - -INSERT INTO plan_features (plan_uuid, feature_key, feature_name, feature_value, is_included, sort_order) -SELECT - p.uuid, - 'premium_support', - '高级支持', - '7x24 小时专属支持 + 专属客户经理', - TRUE, - 5 -FROM plans p WHERE p.name = '企业套餐' -ON CONFLICT (plan_uuid, feature_key) DO NOTHING; - -INSERT INTO plan_features (plan_uuid, feature_key, feature_name, feature_value, is_included, sort_order) -SELECT - p.uuid, - 'api_access', - 'API 访问', - '完整 API 权限 + 高级 API + 定制 API', - TRUE, - 6 -FROM plans p WHERE p.name = '企业套餐' -ON CONFLICT (plan_uuid, feature_key) DO NOTHING; - -INSERT INTO plan_features (plan_uuid, feature_key, feature_name, feature_value, is_included, sort_order) -SELECT - p.uuid, - 'advanced_features', - '高级功能', - '所有高级功能 + 企业级功能', - TRUE, - 7 -FROM plans p WHERE p.name = '企业套餐' -ON CONFLICT (plan_uuid, feature_key) DO NOTHING; - -INSERT INTO plan_features (plan_uuid, feature_key, feature_name, feature_value, is_included, sort_order) -SELECT - p.uuid, - 'custom_integration', - '定制集成', - '完全定制化集成 + 专属技术支持', - TRUE, - 8 -FROM plans p WHERE p.name = '企业套餐' -ON CONFLICT (plan_uuid, feature_key) DO NOTHING; - -INSERT INTO plan_features (plan_uuid, feature_key, feature_name, feature_value, is_included, sort_order) -SELECT - p.uuid, - 'sla_guarantee', - 'SLA 保障', - '99.99% 可用性保障', - TRUE, - 9 -FROM plans p WHERE p.name = '企业套餐' -ON CONFLICT (plan_uuid, feature_key) DO NOTHING; - -INSERT INTO plan_features (plan_uuid, feature_key, feature_name, feature_value, is_included, sort_order) -SELECT - p.uuid, - 'dedicated_infrastructure', - '专属基础设施', - '专属服务器和资源', - TRUE, - 10 -FROM plans p WHERE p.name = '企业套餐' -ON CONFLICT (plan_uuid, feature_key) DO NOTHING; - -INSERT INTO plan_features (plan_uuid, feature_key, feature_name, feature_value, is_included, sort_order) -SELECT - p.uuid, - 'training_sessions', - '培训服务', - '定期培训和最佳实践指导', - TRUE, - 11 -FROM plans p WHERE p.name = '企业套餐' -ON CONFLICT (plan_uuid, feature_key) DO NOTHING; - diff --git a/src-tauri/crates/business/migrations/20250126000002_create_user_coupons.sql b/src-tauri/crates/business/migrations/20250126000002_create_user_coupons.sql deleted file mode 100644 index 24d65e5..0000000 --- a/src-tauri/crates/business/migrations/20250126000002_create_user_coupons.sql +++ /dev/null @@ -1,27 +0,0 @@ --- 创建 user_coupons 表 --- 用户优惠券表 - -CREATE TABLE IF NOT EXISTS user_coupons ( - id SERIAL PRIMARY KEY, - user_uuid UUID NOT NULL, - coupon_uuid UUID NOT NULL, - -- 状态: unused, used, expired - status VARCHAR(50) NOT NULL DEFAULT 'unused', - -- 发放时间 - issued_at TIMESTAMP WITH TIME ZONE NOT NULL DEFAULT CURRENT_TIMESTAMP, - -- 使用时间 - used_at TIMESTAMP WITH TIME ZONE, - -- 过期时间(可选,继承自优惠券或自定义) - expires_at TIMESTAMP WITH TIME ZONE, - -- 约束 - CONSTRAINT fk_user_coupons_user FOREIGN KEY (user_uuid) REFERENCES users(uuid), - CONSTRAINT fk_user_coupons_coupon FOREIGN KEY (coupon_uuid) REFERENCES coupons(uuid), - -- 唯一约束:同一用户不能重复拥有同一优惠券 - UNIQUE(user_uuid, coupon_uuid) -); - --- 创建索引 -CREATE INDEX idx_user_coupons_user_uuid ON user_coupons(user_uuid); -CREATE INDEX idx_user_coupons_status ON user_coupons(status); -CREATE INDEX idx_user_coupons_coupon_uuid ON user_coupons(coupon_uuid); -CREATE INDEX idx_user_coupons_expires_at ON user_coupons(expires_at); diff --git a/src-tauri/crates/business/migrations/20250126000003_insert_default_coupons.sql b/src-tauri/crates/business/migrations/20250126000003_insert_default_coupons.sql deleted file mode 100644 index 1b35f65..0000000 --- a/src-tauri/crates/business/migrations/20250126000003_insert_default_coupons.sql +++ /dev/null @@ -1,37 +0,0 @@ --- 插入默认优惠券数据 - --- 1. 新用户专享优惠券(10%折扣,无使用次数限制,适用于所有订单) -INSERT INTO coupons (uuid, code, discount_type, discount_value, min_amount, max_discount, max_uses, max_uses_per_user, valid_from, valid_until, applicable_to, status) -VALUES - (gen_random_uuid(), 'WELCOME10', 'percentage', 10.00, 0.00, NULL, NULL, 1, CURRENT_TIMESTAMP, NULL, 'all', 'active') -ON CONFLICT (code) DO NOTHING; - --- 2. 年付优惠券(固定金额 $20,最低消费 $100,适用于订阅) -INSERT INTO coupons (uuid, code, discount_type, discount_value, min_amount, max_discount, max_uses, max_uses_per_user, valid_from, valid_until, applicable_to, status) -VALUES - (gen_random_uuid(), 'YEARLY20', 'fixed', 20.00, 100.00, NULL, NULL, 1, CURRENT_TIMESTAMP, NULL, 'subscription', 'active') -ON CONFLICT (code) DO NOTHING; - --- 3. 限时促销优惠券(15%折扣,最大折扣 $50,使用次数限制 100 次) -INSERT INTO coupons (uuid, code, discount_type, discount_value, min_amount, max_discount, max_uses, max_uses_per_user, valid_from, valid_until, applicable_to, status) -VALUES - (gen_random_uuid(), 'PROMO15', 'percentage', 15.00, 50.00, 50.00, 100, 1, CURRENT_TIMESTAMP, (CURRENT_TIMESTAMP + INTERVAL '30 days'), 'all', 'active') -ON CONFLICT (code) DO NOTHING; - --- 4. 大额订单优惠券(固定金额 $50,最低消费 $200) -INSERT INTO coupons (uuid, code, discount_type, discount_value, min_amount, max_discount, max_uses, max_uses_per_user, valid_from, valid_until, applicable_to, status) -VALUES - (gen_random_uuid(), 'BIG50', 'fixed', 50.00, 200.00, NULL, NULL, 1, CURRENT_TIMESTAMP, NULL, 'all', 'active') -ON CONFLICT (code) DO NOTHING; - --- 5. 年付专属优惠券(20%折扣,仅适用于年付订阅,最大折扣 $200) -INSERT INTO coupons (uuid, code, discount_type, discount_value, min_amount, max_discount, max_uses, max_uses_per_user, valid_from, valid_until, applicable_to, status) -VALUES - (gen_random_uuid(), 'YEARLY20PCT', 'percentage', 20.00, 100.00, 200.00, NULL, 1, CURRENT_TIMESTAMP, NULL, 'subscription', 'active') -ON CONFLICT (code) DO NOTHING; - --- 6. 首次购买优惠券(固定金额 $10,无最低消费,每人限用1次) -INSERT INTO coupons (uuid, code, discount_type, discount_value, min_amount, max_discount, max_uses, max_uses_per_user, valid_from, valid_until, applicable_to, status) -VALUES - (gen_random_uuid(), 'FIRST10', 'fixed', 10.00, 0.00, NULL, NULL, 1, CURRENT_TIMESTAMP, NULL, 'all', 'active') -ON CONFLICT (code) DO NOTHING; diff --git a/src-tauri/crates/business/migrations/20250127000001_add_coupon_name_description.sql b/src-tauri/crates/business/migrations/20250127000001_add_coupon_name_description.sql deleted file mode 100644 index f8f7b98..0000000 --- a/src-tauri/crates/business/migrations/20250127000001_add_coupon_name_description.sql +++ /dev/null @@ -1,13 +0,0 @@ --- 为优惠券表添加名称和描述字段 - -ALTER TABLE coupons -ADD COLUMN IF NOT EXISTS name VARCHAR(100), -ADD COLUMN IF NOT EXISTS description TEXT; - --- 为现有优惠券更新名称和描述 -UPDATE coupons SET name = '新用户专享', description = '新用户注册专享优惠,享受10%折扣' WHERE code = 'WELCOME10'; -UPDATE coupons SET name = '年付优惠', description = '年付订阅专享,立减$20,最低消费$100' WHERE code = 'YEARLY20'; -UPDATE coupons SET name = '限时促销', description = '限时促销活动,享受15%折扣,最大折扣$50' WHERE code = 'PROMO15'; -UPDATE coupons SET name = '大额订单优惠', description = '大额订单专享,立减$50,最低消费$200' WHERE code = 'BIG50'; -UPDATE coupons SET name = '年付专属', description = '年付订阅专属优惠,享受20%折扣,最大折扣$200' WHERE code = 'YEARLY20PCT'; -UPDATE coupons SET name = '首次购买', description = '首次购买专享,立减$10,无最低消费限制' WHERE code = 'FIRST10'; diff --git a/src-tauri/crates/business/migrations/20250127000002_reset_referral_default_tiers.sql b/src-tauri/crates/business/migrations/20250127000002_reset_referral_default_tiers.sql deleted file mode 100644 index eeb7fcc..0000000 --- a/src-tauri/crates/business/migrations/20250127000002_reset_referral_default_tiers.sql +++ /dev/null @@ -1,25 +0,0 @@ --- 重置推广链接层级默认数据 --- 说明: --- - 该迁移用于在开发 / 测试环境中统一推广层级配置 --- - 按产品设计保留 4 个层级,按照邀请人数逐级解锁 --- - 为避免脏数据干扰,这里会清空依赖 referral_link_tiers 的相关表 - --- 1. 清空依赖表(注意:这会删除所有历史推广数据) -TRUNCATE TABLE redeem_records RESTART IDENTITY CASCADE; -TRUNCATE TABLE referral_rewards RESTART IDENTITY CASCADE; -TRUNCATE TABLE user_referrals RESTART IDENTITY CASCADE; -TRUNCATE TABLE referral_links RESTART IDENTITY CASCADE; -TRUNCATE TABLE referral_link_tiers RESTART IDENTITY CASCADE; - --- 2. 重新插入 4 个默认推广层级 -INSERT INTO referral_link_tiers (uuid, name, unlock_threshold, reward_rate, discount_rate, description, sort_order) -VALUES - -- 第一档:默认开放 - (gen_random_uuid(), '青铜', 0, 10.00, 5.00, '默认层级,绑定第一个推广链接', 1), - -- 第二档:达到一定推广人数后解锁 - (gen_random_uuid(), '白银', 10, 15.00, 8.00, '推广满 10 人后解锁第二个推广链接', 2), - -- 第三档 - (gen_random_uuid(), '黄金', 50, 20.00, 10.00, '推广满 50 人后解锁第三个推广链接', 3), - -- 第四档 - (gen_random_uuid(), '钻石', 100, 25.00, 15.00, '推广满 100 人后解锁第四个推广链接', 4); - diff --git a/src-tauri/crates/business/migrations/20250127000003_create_machine_tags_tables.sql b/src-tauri/crates/business/migrations/20250127000003_create_machine_tags_tables.sql deleted file mode 100644 index 4aa6378..0000000 --- a/src-tauri/crates/business/migrations/20250127000003_create_machine_tags_tables.sql +++ /dev/null @@ -1,53 +0,0 @@ --- Add migration script here --- Create machine_tags and machine_user_tags tables for tag management - --- Drop tables (for development) --- DROP TABLE public.machine_user_tags CASCADE; --- DROP TABLE public.machine_tags CASCADE; - --- Create machine_tags table -CREATE TABLE public.machine_tags ( - id int4 GENERATED BY DEFAULT AS IDENTITY( INCREMENT BY 1 MINVALUE 1 MAXVALUE 2147483647 START 1 CACHE 1 NO CYCLE) NOT NULL, - tag_name varchar(100) NOT NULL, -- 标签名称 - description text NULL, -- 标签描述 - category varchar(50) NULL, -- 标签分类 - is_active boolean DEFAULT true NOT NULL, -- 是否激活 - created_at timestamptz DEFAULT CURRENT_TIMESTAMP NOT NULL, -- 创建时间 - CONSTRAINT machine_tags_pk PRIMARY KEY (id), - CONSTRAINT machine_tags_tag_name_unique UNIQUE (tag_name) -); - --- Create machine_user_tags table (association table) -CREATE TABLE public.machine_user_tags ( - id int4 GENERATED BY DEFAULT AS IDENTITY( INCREMENT BY 1 MINVALUE 1 MAXVALUE 2147483647 START 1 CACHE 1 NO CYCLE) NOT NULL, - machine_user_id int4 NOT NULL, -- 机器用户ID - tag_id int4 NOT NULL, -- 标签ID - created_at timestamptz DEFAULT CURRENT_TIMESTAMP NOT NULL, -- 创建时间 - CONSTRAINT machine_user_tags_pk PRIMARY KEY (id), - CONSTRAINT machine_user_tags_machine_user_id_fk FOREIGN KEY (machine_user_id) REFERENCES public.machine_users(id) ON DELETE CASCADE, - CONSTRAINT machine_user_tags_tag_id_fk FOREIGN KEY (tag_id) REFERENCES public.machine_tags(id) ON DELETE CASCADE, - CONSTRAINT machine_user_tags_unique UNIQUE (machine_user_id, tag_id) -); - --- Create indexes for performance -CREATE INDEX machine_tags_tag_name_idx ON public.machine_tags USING btree (tag_name); -CREATE INDEX machine_tags_is_active_idx ON public.machine_tags USING btree (is_active); -CREATE INDEX machine_user_tags_machine_user_id_idx ON public.machine_user_tags USING btree (machine_user_id); -CREATE INDEX machine_user_tags_tag_id_idx ON public.machine_user_tags USING btree (tag_id); - --- Column comments -COMMENT ON TABLE public.machine_tags IS '机器标签主表'; -COMMENT ON TABLE public.machine_user_tags IS '机器用户标签关联表'; - -COMMENT ON COLUMN public.machine_tags.id IS '主键ID'; -COMMENT ON COLUMN public.machine_tags.tag_name IS '标签名称(唯一)'; -COMMENT ON COLUMN public.machine_tags.description IS '标签描述'; -COMMENT ON COLUMN public.machine_tags.category IS '标签分类'; -COMMENT ON COLUMN public.machine_tags.is_active IS '是否激活'; -COMMENT ON COLUMN public.machine_tags.created_at IS '创建时间'; - -COMMENT ON COLUMN public.machine_user_tags.id IS '主键ID'; -COMMENT ON COLUMN public.machine_user_tags.machine_user_id IS '机器用户ID'; -COMMENT ON COLUMN public.machine_user_tags.tag_id IS '标签ID'; -COMMENT ON COLUMN public.machine_user_tags.created_at IS '创建时间'; - diff --git a/src-tauri/crates/business/migrations/20250204000001_alter_version_types_add_is_auto_download.sql b/src-tauri/crates/business/migrations/20250204000001_alter_version_types_add_is_auto_download.sql deleted file mode 100644 index e91548f..0000000 --- a/src-tauri/crates/business/migrations/20250204000001_alter_version_types_add_is_auto_download.sql +++ /dev/null @@ -1,7 +0,0 @@ --- Add is_auto_download flag to version_types to control participation in auto-update flows - -ALTER TABLE public.version_types -ADD COLUMN IF NOT EXISTS is_auto_download bool DEFAULT true NOT NULL; - -COMMENT ON COLUMN public.version_types.is_auto_download IS '是否参与自动检查更新与自动下载'; - diff --git a/src-tauri/crates/business/migrations/20250204000002_alter_versions_add_kernel_fields.sql b/src-tauri/crates/business/migrations/20250204000002_alter_versions_add_kernel_fields.sql deleted file mode 100644 index a1059ef..0000000 --- a/src-tauri/crates/business/migrations/20250204000002_alter_versions_add_kernel_fields.sql +++ /dev/null @@ -1,16 +0,0 @@ --- Extend versions table with kernel-related metadata fields. --- These fields are optional for普通资源,但对浏览器内核版本会被要求填充。 - -ALTER TABLE public.versions -ADD COLUMN IF NOT EXISTS arch text NULL, -ADD COLUMN IF NOT EXISTS package_format text NULL, -ADD COLUMN IF NOT EXISTS requires_extract bool DEFAULT false NOT NULL, -ADD COLUMN IF NOT EXISTS entrypoint_template text NULL, -ADD COLUMN IF NOT EXISTS extract_root text NULL; - -COMMENT ON COLUMN public.versions.arch IS '架构(如 x86_64、arm64)'; -COMMENT ON COLUMN public.versions.package_format IS '包格式(如 zip、exe)'; -COMMENT ON COLUMN public.versions.requires_extract IS '是否需要解压(zip 等资源为 true)'; -COMMENT ON COLUMN public.versions.entrypoint_template IS '入口相对路径模板(如 Simprint-Browser/chrome-{version}/chrome.exe)'; -COMMENT ON COLUMN public.versions.extract_root IS '可选的解压根目录描述'; - diff --git a/src-tauri/crates/business/migrations/20250204000003_insert_default_version_types.sql b/src-tauri/crates/business/migrations/20250204000003_insert_default_version_types.sql deleted file mode 100644 index d999c95..0000000 --- a/src-tauri/crates/business/migrations/20250204000003_insert_default_version_types.sql +++ /dev/null @@ -1,8 +0,0 @@ --- 初始化版本类型默认数据 --- 注意:使用 ON CONFLICT 以保证迁移可重复执行 - -INSERT INTO public.version_types (type_code, type_name, description, sort_order, is_active, is_auto_download) -VALUES - ('SIMPRINT_CLIENT_INSTALLER', 'Simprint 客户端安装包', 'Simprint 浏览器主程序安装包', 10, true, true), - ('SIMPRINT_KERNEL_CHROMIUM', 'Chromium 内核', 'Chromium 浏览器内核 zip 包', 30, true, false) -ON CONFLICT (type_code) DO NOTHING; \ No newline at end of file diff --git a/src-tauri/crates/business/migrations/20260213000001_add_updated_at_to_user_coupons.sql b/src-tauri/crates/business/migrations/20260213000001_add_updated_at_to_user_coupons.sql deleted file mode 100644 index a97141c..0000000 --- a/src-tauri/crates/business/migrations/20260213000001_add_updated_at_to_user_coupons.sql +++ /dev/null @@ -1,9 +0,0 @@ --- 为 user_coupons 表添加 updated_at 字段 --- 修复订阅功能中缺少 updated_at 字段的问题 - -ALTER TABLE user_coupons -ADD COLUMN updated_at TIMESTAMP WITH TIME ZONE NOT NULL DEFAULT CURRENT_TIMESTAMP; - --- 创建更新时间触发器 -CREATE TRIGGER update_user_coupons_updated_at BEFORE UPDATE ON user_coupons - FOR EACH ROW EXECUTE FUNCTION update_updated_at_column(); diff --git a/src-tauri/crates/business/migrations/20260306000001_fix_extensions_status_comments.sql b/src-tauri/crates/business/migrations/20260306000001_fix_extensions_status_comments.sql deleted file mode 100644 index c46f477..0000000 --- a/src-tauri/crates/business/migrations/20260306000001_fix_extensions_status_comments.sql +++ /dev/null @@ -1,18 +0,0 @@ --- 修正扩展表的状态默认值和注释 --- 将状态从 "installed, disabled" 改为 "active, inactive" 以对齐代码实现 - --- user_extensions 表 -ALTER TABLE user_extensions ALTER COLUMN status SET DEFAULT 'active'; -COMMENT ON COLUMN user_extensions.status IS '状态: active, inactive'; - --- team_extensions 表 -ALTER TABLE team_extensions ALTER COLUMN status SET DEFAULT 'active'; -COMMENT ON COLUMN team_extensions.status IS '状态: active, inactive'; - --- group_extensions 表 -ALTER TABLE group_extensions ALTER COLUMN status SET DEFAULT 'active'; -COMMENT ON COLUMN group_extensions.status IS '状态: active, inactive'; - --- environment_extensions 表 -ALTER TABLE environment_extensions ALTER COLUMN status SET DEFAULT 'active'; -COMMENT ON COLUMN environment_extensions.status IS '状态: active, inactive'; diff --git a/src-tauri/crates/business/migrations/20260306000002_drop_environment_extensions.sql b/src-tauri/crates/business/migrations/20260306000002_drop_environment_extensions.sql deleted file mode 100644 index af9dfb1..0000000 --- a/src-tauri/crates/business/migrations/20260306000002_drop_environment_extensions.sql +++ /dev/null @@ -1,12 +0,0 @@ --- 删除 environment_extensions 表 --- 环境不直接绑定插件,插件通过用户/团队/分组间接作用于环境 - --- 删除触发器 -DROP TRIGGER IF EXISTS update_environment_extensions_updated_at ON environment_extensions; - --- 删除索引 -DROP INDEX IF EXISTS idx_env_extensions_extension_id; -DROP INDEX IF EXISTS idx_env_extensions_env_uuid; - --- 删除表 -DROP TABLE IF EXISTS environment_extensions; diff --git a/src-tauri/crates/business/migrations/20260306000003_add_is_team_shared_to_group_extensions.sql b/src-tauri/crates/business/migrations/20260306000003_add_is_team_shared_to_group_extensions.sql deleted file mode 100644 index 286a7b5..0000000 --- a/src-tauri/crates/business/migrations/20260306000003_add_is_team_shared_to_group_extensions.sql +++ /dev/null @@ -1,11 +0,0 @@ --- 为 group_extensions 表添加 is_team_shared 字段 --- 用于区分"分组团队插件"和"分组个人插件" - --- 添加 is_team_shared 字段 -ALTER TABLE group_extensions ADD COLUMN is_team_shared BOOLEAN NOT NULL DEFAULT false; - --- 添加索引 -CREATE INDEX idx_group_extensions_is_team_shared ON group_extensions(is_team_shared); - --- 更新注释 -COMMENT ON COLUMN group_extensions.is_team_shared IS '是否为团队共享: true=团队所有成员可用, false=仅创建者可用'; diff --git a/src-tauri/crates/business/migrations/20260306000004_update_extension_status_support_disabled.sql b/src-tauri/crates/business/migrations/20260306000004_update_extension_status_support_disabled.sql deleted file mode 100644 index 299781e..0000000 --- a/src-tauri/crates/business/migrations/20260306000004_update_extension_status_support_disabled.sql +++ /dev/null @@ -1,7 +0,0 @@ --- 更新扩展状态支持 disabled --- 允许用户禁用团队/分组插件 - --- 更新状态注释 -COMMENT ON COLUMN user_extensions.status IS '状态: active=已启用, disabled=已禁用, inactive=已卸载'; -COMMENT ON COLUMN team_extensions.status IS '状态: active=已安装, inactive=已卸载'; -COMMENT ON COLUMN group_extensions.status IS '状态: active=已安装, inactive=已卸载'; diff --git a/src-tauri/crates/business/migrations/20260306000005_create_user_team_extension_preferences.sql b/src-tauri/crates/business/migrations/20260306000005_create_user_team_extension_preferences.sql deleted file mode 100644 index badb26c..0000000 --- a/src-tauri/crates/business/migrations/20260306000005_create_user_team_extension_preferences.sql +++ /dev/null @@ -1,23 +0,0 @@ --- 创建用户团队插件偏好设置表 -CREATE TABLE IF NOT EXISTS user_team_extension_preferences ( - id SERIAL PRIMARY KEY, - user_uuid UUID NOT NULL, - team_uuid UUID NOT NULL, - extension_id VARCHAR(255) NOT NULL, - is_disabled BOOLEAN DEFAULT false, - created_at TIMESTAMP DEFAULT CURRENT_TIMESTAMP, - updated_at TIMESTAMP DEFAULT CURRENT_TIMESTAMP, - UNIQUE(user_uuid, team_uuid, extension_id) -); - --- 添加索引 -CREATE INDEX idx_user_team_extension_preferences_user_uuid ON user_team_extension_preferences(user_uuid); -CREATE INDEX idx_user_team_extension_preferences_team_uuid ON user_team_extension_preferences(team_uuid); -CREATE INDEX idx_user_team_extension_preferences_extension_id ON user_team_extension_preferences(extension_id); - --- 添加注释 -COMMENT ON TABLE user_team_extension_preferences IS '用户对团队插件的偏好设置(如禁用)'; -COMMENT ON COLUMN user_team_extension_preferences.user_uuid IS '用户 UUID'; -COMMENT ON COLUMN user_team_extension_preferences.team_uuid IS '团队 UUID'; -COMMENT ON COLUMN user_team_extension_preferences.extension_id IS '插件 ID'; -COMMENT ON COLUMN user_team_extension_preferences.is_disabled IS '是否禁用(true 表示用户禁用了该团队插件)'; diff --git a/src-tauri/crates/business/migrations/20260308000001_add_hash_to_extensions.sql b/src-tauri/crates/business/migrations/20260308000001_add_hash_to_extensions.sql deleted file mode 100644 index bd2db84..0000000 --- a/src-tauri/crates/business/migrations/20260308000001_add_hash_to_extensions.sql +++ /dev/null @@ -1,8 +0,0 @@ --- 为 extensions 表添加 hash 字段 --- 用于存储扩展文件的哈希值 - -ALTER TABLE extensions -ADD COLUMN hash VARCHAR(255) DEFAULT NULL; - --- 为 hash 字段创建索引(可选,如果需要通过 hash 查询) -CREATE INDEX idx_extensions_hash ON extensions(hash); diff --git a/src-tauri/crates/business/migrations/20260314000001_alter_versions_require_publish_fields.sql b/src-tauri/crates/business/migrations/20260314000001_alter_versions_require_publish_fields.sql deleted file mode 100644 index 07b4a60..0000000 --- a/src-tauri/crates/business/migrations/20260314000001_alter_versions_require_publish_fields.sql +++ /dev/null @@ -1,15 +0,0 @@ --- Enforce required publish metadata for versions exposed to the updater. --- Existing dirty rows are removed first so the NOT NULL constraints can be applied safely. - -DELETE FROM public.versions -WHERE platform IS NULL - OR url IS NULL - OR hash IS NULL - OR file_size IS NULL - OR pub_date IS NULL; - -ALTER TABLE public.versions -ALTER COLUMN platform SET NOT NULL, -ALTER COLUMN url SET NOT NULL, -ALTER COLUMN hash SET NOT NULL, -ALTER COLUMN file_size SET NOT NULL; \ No newline at end of file diff --git a/src-tauri/crates/business/migrations/20260316000001_create_local_api_service_tables.sql b/src-tauri/crates/business/migrations/20260316000001_create_local_api_service_tables.sql index 8461bcf..bd821bf 100644 --- a/src-tauri/crates/business/migrations/20260316000001_create_local_api_service_tables.sql +++ b/src-tauri/crates/business/migrations/20260316000001_create_local_api_service_tables.sql @@ -1,109 +1,100 @@ --- 创建本地 API 服务相关表 - -CREATE TABLE IF NOT EXISTS user_local_api_settings ( - id SERIAL PRIMARY KEY, - uuid UUID NOT NULL DEFAULT gen_random_uuid() UNIQUE, - user_uuid UUID NOT NULL UNIQUE, - enabled BOOLEAN NOT NULL DEFAULT FALSE, - port INTEGER NOT NULL DEFAULT 8080, - remote_access BOOLEAN NOT NULL DEFAULT FALSE, - cors_origins JSONB NOT NULL DEFAULT '[]'::jsonb, - created_at TIMESTAMP WITH TIME ZONE NOT NULL DEFAULT CURRENT_TIMESTAMP, - updated_at TIMESTAMP WITH TIME ZONE NOT NULL DEFAULT CURRENT_TIMESTAMP, - deleted_at TIMESTAMP WITH TIME ZONE, - CONSTRAINT fk_user_local_api_settings_user FOREIGN KEY (user_uuid) REFERENCES users(uuid) ON DELETE CASCADE, - CONSTRAINT chk_user_local_api_settings_port CHECK (port BETWEEN 1 AND 65535), - CONSTRAINT chk_user_local_api_settings_cors_origins CHECK (jsonb_typeof(cors_origins) = 'array') -); - -CREATE INDEX idx_user_local_api_settings_user_uuid - ON user_local_api_settings(user_uuid); - -CREATE TRIGGER update_user_local_api_settings_updated_at - BEFORE UPDATE ON user_local_api_settings - FOR EACH ROW EXECUTE FUNCTION update_updated_at_column(); - -CREATE TABLE IF NOT EXISTS user_local_api_keys ( - id SERIAL PRIMARY KEY, - uuid UUID NOT NULL DEFAULT gen_random_uuid() UNIQUE, - user_uuid UUID NOT NULL, - key_prefix VARCHAR(32) NOT NULL, - key_hash VARCHAR(128) NOT NULL, - masked_key VARCHAR(64) NOT NULL, - is_active BOOLEAN NOT NULL DEFAULT TRUE, - requests_today INTEGER NOT NULL DEFAULT 0, - daily_limit INTEGER NOT NULL DEFAULT 1000, - last_reset_date DATE NOT NULL DEFAULT CURRENT_DATE, - last_used_at TIMESTAMP WITH TIME ZONE, - expires_at TIMESTAMP WITH TIME ZONE, - created_at TIMESTAMP WITH TIME ZONE NOT NULL DEFAULT CURRENT_TIMESTAMP, - updated_at TIMESTAMP WITH TIME ZONE NOT NULL DEFAULT CURRENT_TIMESTAMP, - deleted_at TIMESTAMP WITH TIME ZONE, - CONSTRAINT fk_user_local_api_keys_user FOREIGN KEY (user_uuid) REFERENCES users(uuid) ON DELETE CASCADE, - CONSTRAINT chk_user_local_api_keys_requests_today CHECK (requests_today >= 0), - CONSTRAINT chk_user_local_api_keys_daily_limit CHECK (daily_limit >= 0) -); - -CREATE INDEX idx_user_local_api_keys_user_uuid - ON user_local_api_keys(user_uuid); - -CREATE INDEX idx_user_local_api_keys_key_prefix - ON user_local_api_keys(key_prefix); - -CREATE INDEX idx_user_local_api_keys_key_hash - ON user_local_api_keys(key_hash); - -CREATE UNIQUE INDEX idx_user_local_api_keys_active_user - ON user_local_api_keys(user_uuid) - WHERE is_active = TRUE AND deleted_at IS NULL; - -CREATE TRIGGER update_user_local_api_keys_updated_at - BEFORE UPDATE ON user_local_api_keys - FOR EACH ROW EXECUTE FUNCTION update_updated_at_column(); - -CREATE TABLE IF NOT EXISTS user_local_api_key_permissions ( - id SERIAL PRIMARY KEY, - uuid UUID NOT NULL DEFAULT gen_random_uuid() UNIQUE, - api_key_id INTEGER NOT NULL, - permission_code VARCHAR(128) NOT NULL, - is_enabled BOOLEAN NOT NULL DEFAULT TRUE, - rate_limit_per_minute INTEGER NOT NULL DEFAULT 60, - rate_limit_per_hour INTEGER NOT NULL DEFAULT 1000, - created_at TIMESTAMP WITH TIME ZONE NOT NULL DEFAULT CURRENT_TIMESTAMP, - updated_at TIMESTAMP WITH TIME ZONE NOT NULL DEFAULT CURRENT_TIMESTAMP, - deleted_at TIMESTAMP WITH TIME ZONE, - CONSTRAINT fk_user_local_api_key_permissions_key FOREIGN KEY (api_key_id) REFERENCES user_local_api_keys(id) ON DELETE CASCADE, - CONSTRAINT uq_user_local_api_key_permissions UNIQUE (api_key_id, permission_code), - CONSTRAINT chk_user_local_api_key_permissions_minute CHECK (rate_limit_per_minute >= 0), - CONSTRAINT chk_user_local_api_key_permissions_hour CHECK (rate_limit_per_hour >= 0) -); - -CREATE INDEX idx_user_local_api_key_permissions_key - ON user_local_api_key_permissions(api_key_id); - -CREATE INDEX idx_user_local_api_key_permissions_code - ON user_local_api_key_permissions(permission_code); - -CREATE TRIGGER update_user_local_api_key_permissions_updated_at - BEFORE UPDATE ON user_local_api_key_permissions - FOR EACH ROW EXECUTE FUNCTION update_updated_at_column(); - -CREATE TABLE IF NOT EXISTS user_local_api_request_counters ( - id SERIAL PRIMARY KEY, - api_key_id INTEGER NOT NULL, - permission_code VARCHAR(128) NOT NULL, - window_type VARCHAR(16) NOT NULL, - window_start TIMESTAMP WITH TIME ZONE NOT NULL, - request_count INTEGER NOT NULL DEFAULT 0, - updated_at TIMESTAMP WITH TIME ZONE NOT NULL DEFAULT CURRENT_TIMESTAMP, - CONSTRAINT fk_user_local_api_request_counters_key FOREIGN KEY (api_key_id) REFERENCES user_local_api_keys(id) ON DELETE CASCADE, - CONSTRAINT uq_user_local_api_request_counters UNIQUE (api_key_id, permission_code, window_type, window_start), - CONSTRAINT chk_user_local_api_request_counters_window_type CHECK (window_type IN ('minute', 'hour', 'day')), - CONSTRAINT chk_user_local_api_request_counters_request_count CHECK (request_count >= 0) -); - -CREATE INDEX idx_user_local_api_request_counters_key - ON user_local_api_request_counters(api_key_id); - -CREATE INDEX idx_user_local_api_request_counters_lookup - ON user_local_api_request_counters(api_key_id, permission_code, window_type, window_start); +-- 创建本地 API 服务相关表 + +CREATE TABLE IF NOT EXISTS user_local_api_settings ( + id INTEGER PRIMARY KEY AUTOINCREMENT, + uuid TEXT NOT NULL DEFAULT (randomblob(16)) UNIQUE, + user_uuid TEXT NOT NULL UNIQUE, + enabled INTEGER NOT NULL DEFAULT FALSE, + port INTEGER NOT NULL DEFAULT 8080, + remote_access INTEGER NOT NULL DEFAULT FALSE, + cors_origins TEXT NOT NULL DEFAULT '[]', + created_at TEXT NOT NULL DEFAULT CURRENT_TIMESTAMP, + updated_at TEXT NOT NULL DEFAULT CURRENT_TIMESTAMP, + deleted_at TEXT, + CONSTRAINT fk_user_local_api_settings_user FOREIGN KEY (user_uuid) REFERENCES users(uuid) ON DELETE CASCADE, + CONSTRAINT chk_user_local_api_settings_port CHECK (port BETWEEN 1 AND 65535), + CONSTRAINT chk_user_local_api_settings_cors_origins CHECK (json_type(cors_origins) = 'array') +); + +CREATE INDEX idx_user_local_api_settings_user_uuid + ON user_local_api_settings(user_uuid); + + +CREATE TABLE IF NOT EXISTS user_local_api_keys ( + id INTEGER PRIMARY KEY AUTOINCREMENT, + uuid TEXT NOT NULL DEFAULT (randomblob(16)) UNIQUE, + user_uuid TEXT NOT NULL, + key_prefix VARCHAR(32) NOT NULL, + key_hash VARCHAR(128) NOT NULL, + masked_key VARCHAR(64) NOT NULL, + is_active INTEGER NOT NULL DEFAULT TRUE, + requests_today INTEGER NOT NULL DEFAULT 0, + daily_limit INTEGER NOT NULL DEFAULT 1000, + last_reset_date DATE NOT NULL DEFAULT CURRENT_DATE, + last_used_at TEXT, + expires_at TEXT, + created_at TEXT NOT NULL DEFAULT CURRENT_TIMESTAMP, + updated_at TEXT NOT NULL DEFAULT CURRENT_TIMESTAMP, + deleted_at TEXT, + CONSTRAINT fk_user_local_api_keys_user FOREIGN KEY (user_uuid) REFERENCES users(uuid) ON DELETE CASCADE, + CONSTRAINT chk_user_local_api_keys_requests_today CHECK (requests_today >= 0), + CONSTRAINT chk_user_local_api_keys_daily_limit CHECK (daily_limit >= 0) +); + +CREATE INDEX idx_user_local_api_keys_user_uuid + ON user_local_api_keys(user_uuid); + +CREATE INDEX idx_user_local_api_keys_key_prefix + ON user_local_api_keys(key_prefix); + +CREATE INDEX idx_user_local_api_keys_key_hash + ON user_local_api_keys(key_hash); + +CREATE UNIQUE INDEX idx_user_local_api_keys_active_user + ON user_local_api_keys(user_uuid) + WHERE is_active = TRUE AND deleted_at IS NULL; + + +CREATE TABLE IF NOT EXISTS user_local_api_key_permissions ( + id INTEGER PRIMARY KEY AUTOINCREMENT, + uuid TEXT NOT NULL DEFAULT (randomblob(16)) UNIQUE, + api_key_id INTEGER NOT NULL, + permission_code VARCHAR(128) NOT NULL, + is_enabled INTEGER NOT NULL DEFAULT TRUE, + rate_limit_per_minute INTEGER NOT NULL DEFAULT 60, + rate_limit_per_hour INTEGER NOT NULL DEFAULT 1000, + created_at TEXT NOT NULL DEFAULT CURRENT_TIMESTAMP, + updated_at TEXT NOT NULL DEFAULT CURRENT_TIMESTAMP, + deleted_at TEXT, + CONSTRAINT fk_user_local_api_key_permissions_key FOREIGN KEY (api_key_id) REFERENCES user_local_api_keys(id) ON DELETE CASCADE, + CONSTRAINT uq_user_local_api_key_permissions UNIQUE (api_key_id, permission_code), + CONSTRAINT chk_user_local_api_key_permissions_minute CHECK (rate_limit_per_minute >= 0), + CONSTRAINT chk_user_local_api_key_permissions_hour CHECK (rate_limit_per_hour >= 0) +); + +CREATE INDEX idx_user_local_api_key_permissions_key + ON user_local_api_key_permissions(api_key_id); + +CREATE INDEX idx_user_local_api_key_permissions_code + ON user_local_api_key_permissions(permission_code); + + +CREATE TABLE IF NOT EXISTS user_local_api_request_counters ( + id INTEGER PRIMARY KEY AUTOINCREMENT, + api_key_id INTEGER NOT NULL, + permission_code VARCHAR(128) NOT NULL, + window_type VARCHAR(16) NOT NULL, + window_start TEXT NOT NULL, + request_count INTEGER NOT NULL DEFAULT 0, + updated_at TEXT NOT NULL DEFAULT CURRENT_TIMESTAMP, + CONSTRAINT fk_user_local_api_request_counters_key FOREIGN KEY (api_key_id) REFERENCES user_local_api_keys(id) ON DELETE CASCADE, + CONSTRAINT uq_user_local_api_request_counters UNIQUE (api_key_id, permission_code, window_type, window_start), + CONSTRAINT chk_user_local_api_request_counters_window_type CHECK (window_type IN ('minute', 'hour', 'day')), + CONSTRAINT chk_user_local_api_request_counters_request_count CHECK (request_count >= 0) +); + +CREATE INDEX idx_user_local_api_request_counters_key + ON user_local_api_request_counters(api_key_id); + +CREATE INDEX idx_user_local_api_request_counters_lookup + ON user_local_api_request_counters(api_key_id, permission_code, window_type, window_start); diff --git a/src-tauri/crates/business/migrations/20260316000002_initialize_local_api_default_configs.sql b/src-tauri/crates/business/migrations/20260316000002_initialize_local_api_default_configs.sql deleted file mode 100644 index ebfb8a4..0000000 --- a/src-tauri/crates/business/migrations/20260316000002_initialize_local_api_default_configs.sql +++ /dev/null @@ -1,13 +0,0 @@ --- 初始化本地 API 功能默认配置 --- 这些配置作为系统级默认值存在,具体用户数据仍在首次使用时按需创建。 - -INSERT INTO system_configs (config_key, config_value, description) -VALUES - ('local_api_default_enabled', 'false', '本地 API 默认是否启用'), - ('local_api_default_port', '8080', '本地 API 默认监听端口'), - ('local_api_default_remote_access', 'false', '本地 API 默认是否允许局域网访问'), - ('local_api_default_cors_origins', '[]', '本地 API 默认允许的 CORS 来源'), - ('local_api_default_daily_limit', '1000', '本地 API 默认每日总调用上限'), - ('local_api_default_rate_limit_per_minute', '60', '本地 API 默认每分钟限流'), - ('local_api_default_rate_limit_per_hour', '1000', '本地 API 默认每小时限流') -ON CONFLICT (config_key) DO NOTHING; diff --git a/src-tauri/crates/business/migrations/20260316000003_create_local_api_permission_definitions.sql b/src-tauri/crates/business/migrations/20260316000003_create_local_api_permission_definitions.sql index 44da062..798a492 100644 --- a/src-tauri/crates/business/migrations/20260316000003_create_local_api_permission_definitions.sql +++ b/src-tauri/crates/business/migrations/20260316000003_create_local_api_permission_definitions.sql @@ -1,25 +1,21 @@ --- 创建本地 API 权限定义表 - -CREATE TABLE IF NOT EXISTS local_api_permission_definitions ( - id SERIAL PRIMARY KEY, - uuid UUID NOT NULL DEFAULT gen_random_uuid() UNIQUE, - permission_code VARCHAR(128) NOT NULL UNIQUE, - name VARCHAR(128) NOT NULL, - description TEXT, - default_enabled BOOLEAN NOT NULL DEFAULT TRUE, - default_rate_limit_per_minute INTEGER NOT NULL DEFAULT 60, - default_rate_limit_per_hour INTEGER NOT NULL DEFAULT 1000, - sort_order INTEGER NOT NULL DEFAULT 0, - created_at TIMESTAMP WITH TIME ZONE NOT NULL DEFAULT CURRENT_TIMESTAMP, - updated_at TIMESTAMP WITH TIME ZONE NOT NULL DEFAULT CURRENT_TIMESTAMP, - deleted_at TIMESTAMP WITH TIME ZONE, - CONSTRAINT chk_local_api_permission_definitions_minute CHECK (default_rate_limit_per_minute >= 0), - CONSTRAINT chk_local_api_permission_definitions_hour CHECK (default_rate_limit_per_hour >= 0) -); - -CREATE INDEX idx_local_api_permission_definitions_sort_order - ON local_api_permission_definitions(sort_order); - -CREATE TRIGGER update_local_api_permission_definitions_updated_at - BEFORE UPDATE ON local_api_permission_definitions - FOR EACH ROW EXECUTE FUNCTION update_updated_at_column(); +-- 创建本地 API 权限定义表 + +CREATE TABLE IF NOT EXISTS local_api_permission_definitions ( + id INTEGER PRIMARY KEY AUTOINCREMENT, + uuid TEXT NOT NULL DEFAULT (randomblob(16)) UNIQUE, + permission_code VARCHAR(128) NOT NULL UNIQUE, + name VARCHAR(128) NOT NULL, + description TEXT, + default_enabled INTEGER NOT NULL DEFAULT TRUE, + default_rate_limit_per_minute INTEGER NOT NULL DEFAULT 60, + default_rate_limit_per_hour INTEGER NOT NULL DEFAULT 1000, + sort_order INTEGER NOT NULL DEFAULT 0, + created_at TEXT NOT NULL DEFAULT CURRENT_TIMESTAMP, + updated_at TEXT NOT NULL DEFAULT CURRENT_TIMESTAMP, + deleted_at TEXT, + CONSTRAINT chk_local_api_permission_definitions_minute CHECK (default_rate_limit_per_minute >= 0), + CONSTRAINT chk_local_api_permission_definitions_hour CHECK (default_rate_limit_per_hour >= 0) +); + +CREATE INDEX idx_local_api_permission_definitions_sort_order + ON local_api_permission_definitions(sort_order); diff --git a/src-tauri/crates/business/migrations/20260316000004_initialize_local_api_permission_definitions.sql b/src-tauri/crates/business/migrations/20260316000004_initialize_local_api_permission_definitions.sql index 0bfaf1f..0694f03 100644 --- a/src-tauri/crates/business/migrations/20260316000004_initialize_local_api_permission_definitions.sql +++ b/src-tauri/crates/business/migrations/20260316000004_initialize_local_api_permission_definitions.sql @@ -1,55 +1,55 @@ --- 初始化本地 API 权限定义 - -INSERT INTO local_api_permission_definitions ( - permission_code, name, description, default_enabled, default_rate_limit_per_minute, default_rate_limit_per_hour, sort_order -) -VALUES - ('workspaces.list', '工作空间列表', '读取工作空间列表', TRUE, 120, 3000, 10), - ('workspaces.get', '工作空间详情', '读取工作空间详情', TRUE, 120, 3000, 20), - ('workspaces.switch', '切换工作空间', '切换当前工作空间', TRUE, 60, 1000, 30), - ('browser-kernels.list', '浏览器内核列表', '读取浏览器内核列表', TRUE, 120, 3000, 40), - ('groups.list', '分组列表', '读取分组列表', TRUE, 120, 3000, 50), - ('groups.create', '创建分组', '创建新的分组', TRUE, 30, 500, 60), - ('groups.update', '更新分组', '更新分组信息', TRUE, 30, 500, 70), - ('groups.delete', '删除分组', '删除分组', TRUE, 20, 300, 80), - ('tags.list', '标签列表', '读取标签列表', TRUE, 120, 3000, 90), - ('tags.create', '创建标签', '创建新的标签', TRUE, 30, 500, 100), - ('tags.update', '更新标签', '更新标签信息', TRUE, 30, 500, 110), - ('tags.delete', '删除标签', '删除标签', TRUE, 20, 300, 120), - ('environments.list', '环境列表', '读取环境列表', TRUE, 120, 3000, 130), - ('environments.detail', '环境详情', '读取环境详情', TRUE, 120, 3000, 140), - ('environments.batch-detail', '批量环境详情', '批量读取环境详情', TRUE, 60, 1200, 150), - ('environments.create', '创建环境', '创建新的环境', TRUE, 20, 300, 160), - ('environments.batch-create', '批量创建环境', '批量创建环境', TRUE, 10, 120, 170), - ('environments.update', '更新环境', '更新环境配置', TRUE, 30, 500, 180), - ('environments.delete', '删除环境', '删除环境', TRUE, 20, 300, 190), - ('environments.batch-delete', '批量删除环境', '批量删除环境', TRUE, 10, 120, 200), - ('environments.set-proxy', '设置环境代理', '为环境设置代理', TRUE, 30, 500, 210), - ('environments.assign-tags', '分配标签', '为环境分配标签', TRUE, 30, 500, 220), - ('environments.remove-tag', '移除标签', '从环境移除标签', TRUE, 30, 500, 230), - ('environments.move-to-group', '移动到分组', '将环境移动到指定分组', TRUE, 30, 500, 240), - ('environments.batch-move-to-group', '批量移动到分组', '批量移动环境到指定分组', TRUE, 15, 180, 250), - ('environments.set-accounts', '设置环境账号', '为环境关联账号', TRUE, 20, 300, 260), - ('environments.batch-assign-tags', '批量分配标签', '批量为环境分配标签', TRUE, 15, 180, 270), - ('environments.batch-remove-tags', '批量移除标签', '批量从环境移除标签', TRUE, 15, 180, 280), - ('environments.urls.list', '环境 URL 列表', '读取环境 URL 列表', TRUE, 120, 3000, 290), - ('environments.urls.add', '添加环境 URL', '向环境添加 URL', TRUE, 30, 500, 300), - ('environments.urls.delete', '删除环境 URL', '删除环境 URL', TRUE, 30, 500, 310), - ('environments.urls.clear', '清空环境 URL', '清空环境 URL', TRUE, 20, 300, 320), - ('environments.cookies.list', '环境 Cookie 列表', '读取环境 Cookie 列表', TRUE, 120, 3000, 330), - ('environments.cookies.add', '添加环境 Cookie', '向环境添加 Cookie', TRUE, 30, 500, 340), - ('environments.cookies.delete', '删除环境 Cookie', '删除环境 Cookie', TRUE, 30, 500, 350), - ('environments.cookies.clear', '清空环境 Cookie', '清空环境 Cookie', TRUE, 20, 300, 360), - ('environments.recycle-bin.list', '回收站环境列表', '读取回收站中的环境', TRUE, 120, 3000, 370), - ('environments.recycle-bin.restore', '恢复环境', '从回收站恢复环境', TRUE, 20, 300, 380), - ('environments.recycle-bin.batch-restore', '批量恢复环境', '批量从回收站恢复环境', TRUE, 10, 120, 390), - ('environments.recycle-bin.permanent-delete', '永久删除环境', '永久删除环境', TRUE, 10, 120, 400), - ('environments.recycle-bin.batch-permanent-delete', '批量永久删除环境', '批量永久删除环境', TRUE, 5, 60, 410), - ('proxies.list', '代理列表', '读取代理列表', TRUE, 120, 3000, 420), - ('proxies.detail', '代理详情', '读取代理详情', TRUE, 120, 3000, 430), - ('proxies.create', '创建代理', '创建新的代理', TRUE, 30, 500, 440), - ('proxies.update', '更新代理', '更新代理信息', TRUE, 30, 500, 450), - ('proxies.delete', '删除代理', '删除代理', TRUE, 20, 300, 460), - ('proxies.batch-delete', '批量删除代理', '批量删除代理', TRUE, 10, 120, 470), - ('proxies.batch-import', '批量导入代理', '批量导入代理', TRUE, 10, 120, 480) -ON CONFLICT (permission_code) DO NOTHING; +-- 初始化本地 API 权限定义 + +INSERT INTO local_api_permission_definitions ( + permission_code, name, description, default_enabled, default_rate_limit_per_minute, default_rate_limit_per_hour, sort_order +) +VALUES + ('workspaces.list', '工作空间列表', '读取工作空间列表', TRUE, 120, 3000, 10), + ('workspaces.get', '工作空间详情', '读取工作空间详情', TRUE, 120, 3000, 20), + ('workspaces.switch', '切换工作空间', '切换当前工作空间', TRUE, 60, 1000, 30), + ('browser-kernels.list', '浏览器内核列表', '读取浏览器内核列表', TRUE, 120, 3000, 40), + ('groups.list', '分组列表', '读取分组列表', TRUE, 120, 3000, 50), + ('groups.create', '创建分组', '创建新的分组', TRUE, 30, 500, 60), + ('groups.update', '更新分组', '更新分组信息', TRUE, 30, 500, 70), + ('groups.delete', '删除分组', '删除分组', TRUE, 20, 300, 80), + ('tags.list', '标签列表', '读取标签列表', TRUE, 120, 3000, 90), + ('tags.create', '创建标签', '创建新的标签', TRUE, 30, 500, 100), + ('tags.update', '更新标签', '更新标签信息', TRUE, 30, 500, 110), + ('tags.delete', '删除标签', '删除标签', TRUE, 20, 300, 120), + ('environments.list', '环境列表', '读取环境列表', TRUE, 120, 3000, 130), + ('environments.detail', '环境详情', '读取环境详情', TRUE, 120, 3000, 140), + ('environments.batch-detail', '批量环境详情', '批量读取环境详情', TRUE, 60, 1200, 150), + ('environments.create', '创建环境', '创建新的环境', TRUE, 20, 300, 160), + ('environments.batch-create', '批量创建环境', '批量创建环境', TRUE, 10, 120, 170), + ('environments.update', '更新环境', '更新环境配置', TRUE, 30, 500, 180), + ('environments.delete', '删除环境', '删除环境', TRUE, 20, 300, 190), + ('environments.batch-delete', '批量删除环境', '批量删除环境', TRUE, 10, 120, 200), + ('environments.set-proxy', '设置环境代理', '为环境设置代理', TRUE, 30, 500, 210), + ('environments.assign-tags', '分配标签', '为环境分配标签', TRUE, 30, 500, 220), + ('environments.remove-tag', '移除标签', '从环境移除标签', TRUE, 30, 500, 230), + ('environments.move-to-group', '移动到分组', '将环境移动到指定分组', TRUE, 30, 500, 240), + ('environments.batch-move-to-group', '批量移动到分组', '批量移动环境到指定分组', TRUE, 15, 180, 250), + ('environments.set-accounts', '设置环境账号', '为环境关联账号', TRUE, 20, 300, 260), + ('environments.batch-assign-tags', '批量分配标签', '批量为环境分配标签', TRUE, 15, 180, 270), + ('environments.batch-remove-tags', '批量移除标签', '批量从环境移除标签', TRUE, 15, 180, 280), + ('environments.urls.list', '环境 URL 列表', '读取环境 URL 列表', TRUE, 120, 3000, 290), + ('environments.urls.add', '添加环境 URL', '向环境添加 URL', TRUE, 30, 500, 300), + ('environments.urls.delete', '删除环境 URL', '删除环境 URL', TRUE, 30, 500, 310), + ('environments.urls.clear', '清空环境 URL', '清空环境 URL', TRUE, 20, 300, 320), + ('environments.cookies.list', '环境 Cookie 列表', '读取环境 Cookie 列表', TRUE, 120, 3000, 330), + ('environments.cookies.add', '添加环境 Cookie', '向环境添加 Cookie', TRUE, 30, 500, 340), + ('environments.cookies.delete', '删除环境 Cookie', '删除环境 Cookie', TRUE, 30, 500, 350), + ('environments.cookies.clear', '清空环境 Cookie', '清空环境 Cookie', TRUE, 20, 300, 360), + ('environments.recycle-bin.list', '回收站环境列表', '读取回收站中的环境', TRUE, 120, 3000, 370), + ('environments.recycle-bin.restore', '恢复环境', '从回收站恢复环境', TRUE, 20, 300, 380), + ('environments.recycle-bin.batch-restore', '批量恢复环境', '批量从回收站恢复环境', TRUE, 10, 120, 390), + ('environments.recycle-bin.permanent-delete', '永久删除环境', '永久删除环境', TRUE, 10, 120, 400), + ('environments.recycle-bin.batch-permanent-delete', '批量永久删除环境', '批量永久删除环境', TRUE, 5, 60, 410), + ('proxies.list', '代理列表', '读取代理列表', TRUE, 120, 3000, 420), + ('proxies.detail', '代理详情', '读取代理详情', TRUE, 120, 3000, 430), + ('proxies.create', '创建代理', '创建新的代理', TRUE, 30, 500, 440), + ('proxies.update', '更新代理', '更新代理信息', TRUE, 30, 500, 450), + ('proxies.delete', '删除代理', '删除代理', TRUE, 20, 300, 460), + ('proxies.batch-delete', '批量删除代理', '批量删除代理', TRUE, 10, 120, 470), + ('proxies.batch-import', '批量导入代理', '批量导入代理', TRUE, 10, 120, 480) +ON CONFLICT (permission_code) DO NOTHING; diff --git a/src-tauri/crates/business/migrations/20260316000005_replace_masked_key_with_api_key.sql b/src-tauri/crates/business/migrations/20260316000005_replace_masked_key_with_api_key.sql index ff513ad..ad16612 100644 --- a/src-tauri/crates/business/migrations/20260316000005_replace_masked_key_with_api_key.sql +++ b/src-tauri/crates/business/migrations/20260316000005_replace_masked_key_with_api_key.sql @@ -1,9 +1,9 @@ --- 使用完整 api_key 替代 masked_key --- 旧数据无法从 masked_key 还原明文,因此 api_key 先允许为空; --- 服务端在发现历史记录缺少 api_key 时,会自动轮换生成新 key。 - -ALTER TABLE user_local_api_keys - ADD COLUMN IF NOT EXISTS api_key TEXT; - -ALTER TABLE user_local_api_keys - DROP COLUMN IF EXISTS masked_key; +-- 使用完整 api_key 替代 masked_key +-- 旧数据无法从 masked_key 还原明文,因此 api_key 先允许为空; +-- 服务端在发现历史记录缺少 api_key 时,会自动轮换生成新 key。 + +ALTER TABLE user_local_api_keys + ADD COLUMN api_key TEXT; + +ALTER TABLE user_local_api_keys + DROP COLUMN masked_key; diff --git a/src-tauri/crates/business/migrations/20260506000001_replace_encrypted_password_columns.sql b/src-tauri/crates/business/migrations/20260506000001_replace_encrypted_password_columns.sql deleted file mode 100644 index dc0029b..0000000 --- a/src-tauri/crates/business/migrations/20260506000001_replace_encrypted_password_columns.sql +++ /dev/null @@ -1,11 +0,0 @@ -ALTER TABLE public.proxies - DROP COLUMN IF EXISTS password_encrypted, - ADD COLUMN password TEXT; - -COMMENT ON COLUMN public.proxies.password IS '代理密码(明文存储)'; - -ALTER TABLE public.platform_accounts - DROP COLUMN IF EXISTS password_encrypted, - ADD COLUMN password TEXT; - -COMMENT ON COLUMN public.platform_accounts.password IS '平台账号密码(明文存储)'; diff --git a/src-tauri/crates/business/migrations/20260506000002_drop_machine_and_gray_tables.sql b/src-tauri/crates/business/migrations/20260506000002_drop_machine_and_gray_tables.sql deleted file mode 100644 index eea4800..0000000 --- a/src-tauri/crates/business/migrations/20260506000002_drop_machine_and_gray_tables.sql +++ /dev/null @@ -1,6 +0,0 @@ -DROP TABLE IF EXISTS public.machine_gray_allocations; -DROP TABLE IF EXISTS public.gray_resources; -DROP TABLE IF EXISTS public.gray_releases; -DROP TABLE IF EXISTS public.machine_user_tags; -DROP TABLE IF EXISTS public.machine_tags; -DROP TABLE IF EXISTS public.machine_users; diff --git a/src-tauri/crates/business/migrations/20260508000001_insert_default_browser_kernel_version.sql b/src-tauri/crates/business/migrations/20260508000001_insert_default_browser_kernel_version.sql deleted file mode 100644 index 92bf5a5..0000000 --- a/src-tauri/crates/business/migrations/20260508000001_insert_default_browser_kernel_version.sql +++ /dev/null @@ -1,67 +0,0 @@ --- 初始化默认浏览器内核版本数据 --- 使用 version_types.type_code 定位类型,避免写死 type_id。 --- 使用 NOT EXISTS 保证迁移可重复执行。 - -WITH kernel_type AS ( - SELECT id - FROM public.version_types - WHERE type_code = 'SIMPRINT_KERNEL_CHROMIUM' -) -INSERT INTO public.versions ( - type_id, - resource_name, - version, - name, - notes, - platform, - url, - hash, - signature, - install_path, - file_size, - is_latest, - status, - pub_date, - created_at, - updated_at, - deleted_at, - arch, - package_format, - requires_extract, - entrypoint_template, - extract_root -) -SELECT - kernel_type.id, - 'Chrome 144', - '144.0.7559.118.1', - 'simprint-browser-144.0.7559.118.zip', - '上传浏览器内核版本。', - 'windows', - 'versions/144.0.7559.118.1/simprint-browser-144.0.7559.118.zip', - 'c74ce58537c93e99e4099c94667a21a8e150ac4052c383bc2a095ffdb3b0e075', - 'a864f950c2e77d18ef932f2ad3dbde63039c3b467994daab83081ce6b28c4f82', - NULL, - 184526645, - false, - 'active', - '2026-05-05 17:01:26.746804+08'::timestamptz, - '2026-05-05 17:01:26.746804+08'::timestamptz, - NULL, - NULL, - 'x86_64', - 'zip', - true, - NULL, - NULL -FROM kernel_type -WHERE NOT EXISTS ( - SELECT 1 - FROM public.versions v - WHERE v.type_id = kernel_type.id - AND v.resource_name = 'Chrome 144' - AND v.version = '144.0.7559.118.1' - AND v.platform = 'windows' - AND v.arch = 'x86_64' - AND v.deleted_at IS NULL -); diff --git a/src-tauri/crates/business/migrations/20260511000015_add_site_input_to_environment_cookies.sql b/src-tauri/crates/business/migrations/20260511000015_add_site_input_to_environment_cookies.sql index a91132e..abdac24 100644 --- a/src-tauri/crates/business/migrations/20260511000015_add_site_input_to_environment_cookies.sql +++ b/src-tauri/crates/business/migrations/20260511000015_add_site_input_to_environment_cookies.sql @@ -1,5 +1,5 @@ -ALTER TABLE environment_cookies -ADD COLUMN IF NOT EXISTS site_input TEXT NOT NULL DEFAULT ''; - -CREATE INDEX IF NOT EXISTS idx_env_cookies_env_uuid_site_input -ON environment_cookies(environment_uuid, site_input); +ALTER TABLE environment_cookies +ADD COLUMN site_input TEXT NOT NULL DEFAULT ''; + +CREATE INDEX IF NOT EXISTS idx_env_cookies_env_uuid_site_input +ON environment_cookies(environment_uuid, site_input); diff --git a/src-tauri/crates/business/src/caches/local_api.rs b/src-tauri/crates/business/src/caches/local_api.rs deleted file mode 100644 index 1f71b35..0000000 --- a/src-tauri/crates/business/src/caches/local_api.rs +++ /dev/null @@ -1,204 +0,0 @@ -use chrono::Utc; -use serde::{Deserialize, Serialize}; -use std::time::Duration; -use uuid::Uuid; - -use crate::dto::LocalApiPermissionDefinitionDto; -use crate::svc_ctx::SvcCtx; - -const LOCAL_API_KEY_CACHE_PREFIX: &str = "local_api:key"; -const LOCAL_API_PERMISSION_CACHE_PREFIX: &str = "local_api:permission"; -const LOCAL_API_PERMISSION_DEFINITION_CACHE_PREFIX: &str = "local_api:permission_definition"; -const LOCAL_API_RATE_CACHE_PREFIX: &str = "local_api:rate"; -const LOCAL_API_KEY_CACHE_TTL: u64 = 60 * 10; -const LOCAL_API_PERMISSION_CACHE_TTL: u64 = 60 * 10; -const MINUTE_WINDOW_SECONDS: i64 = 60; -const HOUR_WINDOW_SECONDS: i64 = 60 * 60; -const DAY_WINDOW_SECONDS: i64 = 60 * 60 * 24; - -#[derive(Debug, Clone, Serialize, Deserialize)] -pub struct LocalApiKeyCache { - pub id: i32, - pub user_uuid: Uuid, - pub is_active: bool, - pub expires_at: Option>, - pub daily_limit: i32, -} - -#[derive(Debug, Clone, Serialize, Deserialize)] -pub struct LocalApiPermissionCache { - pub is_enabled: bool, - pub rate_limit_per_minute: i32, - pub rate_limit_per_hour: i32, -} - -fn local_api_key_cache_key(key_hash: &str) -> String { - format!("{}:{}", LOCAL_API_KEY_CACHE_PREFIX, key_hash) -} - -fn local_api_permission_cache_key(api_key_id: i32, permission_code: &str) -> String { - format!( - "{}:{}:{}", - LOCAL_API_PERMISSION_CACHE_PREFIX, api_key_id, permission_code - ) -} - -fn local_api_permission_definition_cache_key(permission_code: &str) -> String { - format!( - "{}:{}", - LOCAL_API_PERMISSION_DEFINITION_CACHE_PREFIX, permission_code - ) -} - -fn local_api_rate_cache_key( - api_key_id: i32, - permission_code: Option<&str>, - window_type: &str, - window_key: &str, -) -> String { - if let Some(permission_code) = permission_code { - format!( - "{}:{}:{}:{}:{}", - LOCAL_API_RATE_CACHE_PREFIX, api_key_id, permission_code, window_type, window_key - ) - } else { - format!( - "{}:{}:{}:{}", - LOCAL_API_RATE_CACHE_PREFIX, api_key_id, window_type, window_key - ) - } -} - -pub async fn get_local_api_key_cache( - svc_ctx: &SvcCtx, - key_hash: &str, -) -> Result, anyhow::Error> { - let key = local_api_key_cache_key(key_hash); - svc_ctx.cache.get_json(&key).await -} - -pub async fn set_local_api_key_cache( - svc_ctx: &SvcCtx, - key_hash: &str, - cache: &LocalApiKeyCache, -) -> Result<(), anyhow::Error> { - let key = local_api_key_cache_key(key_hash); - svc_ctx - .cache - .set_json(key, cache, Duration::from_secs(LOCAL_API_KEY_CACHE_TTL)) - .await -} - -pub async fn delete_local_api_key_cache( - svc_ctx: &SvcCtx, - key_hash: &str, -) -> Result<(), anyhow::Error> { - let key = local_api_key_cache_key(key_hash); - svc_ctx.cache.delete(&key).await -} - -pub async fn get_local_api_permission_cache( - svc_ctx: &SvcCtx, - api_key_id: i32, - permission_code: &str, -) -> Result, anyhow::Error> { - let key = local_api_permission_cache_key(api_key_id, permission_code); - svc_ctx.cache.get_json(&key).await -} - -pub async fn set_local_api_permission_cache( - svc_ctx: &SvcCtx, - api_key_id: i32, - permission_code: &str, - cache: &LocalApiPermissionCache, -) -> Result<(), anyhow::Error> { - let key = local_api_permission_cache_key(api_key_id, permission_code); - svc_ctx - .cache - .set_json( - key, - cache, - Duration::from_secs(LOCAL_API_PERMISSION_CACHE_TTL), - ) - .await -} - -pub async fn delete_local_api_permission_cache( - svc_ctx: &SvcCtx, - api_key_id: i32, - permission_code: &str, -) -> Result<(), anyhow::Error> { - let key = local_api_permission_cache_key(api_key_id, permission_code); - svc_ctx.cache.delete(&key).await -} - -pub async fn delete_local_api_permission_caches_for_key( - svc_ctx: &SvcCtx, - api_key_id: i32, -) -> Result<(), anyhow::Error> { - let prefix = format!("{}:{}:", LOCAL_API_PERMISSION_CACHE_PREFIX, api_key_id); - svc_ctx.cache.delete_prefix(&prefix).await -} - -pub async fn get_local_api_permission_definition_cache( - svc_ctx: &SvcCtx, - permission_code: &str, -) -> Result, anyhow::Error> { - let key = local_api_permission_definition_cache_key(permission_code); - svc_ctx.cache.get_json(&key).await -} - -pub async fn set_local_api_permission_definition_cache( - svc_ctx: &SvcCtx, - permission_code: &str, - cache: &LocalApiPermissionDefinitionDto, -) -> Result<(), anyhow::Error> { - let key = local_api_permission_definition_cache_key(permission_code); - svc_ctx - .cache - .set_json( - key, - cache, - Duration::from_secs(LOCAL_API_PERMISSION_CACHE_TTL), - ) - .await -} - -pub async fn delete_local_api_permission_definition_cache( - svc_ctx: &SvcCtx, - permission_code: &str, -) -> Result<(), anyhow::Error> { - let key = local_api_permission_definition_cache_key(permission_code); - svc_ctx.cache.delete(&key).await -} - -pub async fn get_local_api_rate_count( - svc_ctx: &SvcCtx, - api_key_id: i32, - permission_code: Option<&str>, - window_type: &str, - window_key: &str, -) -> Result { - let key = local_api_rate_cache_key(api_key_id, permission_code, window_type, window_key); - Ok(svc_ctx.cache.get_i64(&key).await?.unwrap_or(0)) -} - -pub async fn increment_local_api_rate_count( - svc_ctx: &SvcCtx, - api_key_id: i32, - permission_code: Option<&str>, - window_type: &str, - window_key: &str, -) -> Result { - let key = local_api_rate_cache_key(api_key_id, permission_code, window_type, window_key); - let ttl = match window_type { - "minute" => MINUTE_WINDOW_SECONDS, - "hour" => HOUR_WINDOW_SECONDS, - "day" => DAY_WINDOW_SECONDS, - _ => DAY_WINDOW_SECONDS, - }; - svc_ctx - .cache - .increment(&key, Duration::from_secs(ttl as u64)) - .await -} diff --git a/src-tauri/crates/business/src/caches/mod.rs b/src-tauri/crates/business/src/caches/mod.rs deleted file mode 100644 index f0165b0..0000000 --- a/src-tauri/crates/business/src/caches/mod.rs +++ /dev/null @@ -1,7 +0,0 @@ -pub mod local_api; -pub mod store; -pub mod user; - -pub use local_api::*; -pub use store::*; -pub use user::*; diff --git a/src-tauri/crates/business/src/caches/store.rs b/src-tauri/crates/business/src/caches/store.rs deleted file mode 100644 index 8bd4b14..0000000 --- a/src-tauri/crates/business/src/caches/store.rs +++ /dev/null @@ -1,221 +0,0 @@ -use std::{ - sync::{ - Arc, - atomic::{AtomicUsize, Ordering}, - }, - time::{Duration, Instant}, -}; - -use dashmap::{DashMap, mapref::entry::Entry}; -use serde::{Serialize, de::DeserializeOwned}; - -#[derive(Clone)] -pub struct CacheStore(Arc); - -#[derive(Default)] -pub struct MemoryCache { - entries: DashMap, - operations: AtomicUsize, -} - -struct CacheEntry { - value: String, - expires_at: Option, -} - -impl CacheEntry { - fn is_expired(&self, now: Instant) -> bool { - self.expires_at.is_some_and(|expires_at| expires_at <= now) - } -} - -impl CacheStore { - pub fn memory() -> Self { - Self(Arc::new(MemoryCache::default())) - } - - pub async fn get_string(&self, key: &str) -> Result, anyhow::Error> { - Ok(self.0.get(key)) - } - - pub async fn set_string( - &self, - key: impl Into, - value: impl Into, - ttl: Duration, - ) -> Result<(), anyhow::Error> { - let key = key.into(); - let value = value.into(); - self.0.set(key, value, ttl); - Ok(()) - } - - pub async fn get_json(&self, key: &str) -> Result, anyhow::Error> - where - T: DeserializeOwned, - { - self.get_string(key) - .await? - .map(|value| serde_json::from_str(&value)) - .transpose() - .map_err(Into::into) - } - - pub async fn set_json( - &self, - key: impl Into, - value: &T, - ttl: Duration, - ) -> Result<(), anyhow::Error> - where - T: Serialize, - { - self.set_string(key, serde_json::to_string(value)?, ttl).await - } - - pub async fn delete(&self, key: &str) -> Result<(), anyhow::Error> { - self.0.entries.remove(key); - Ok(()) - } - - pub async fn delete_prefix(&self, prefix: &str) -> Result<(), anyhow::Error> { - self.0.delete_prefix(prefix); - Ok(()) - } - - pub async fn get_i64(&self, key: &str) -> Result, anyhow::Error> { - self.get_string(key) - .await? - .map(|value| value.parse::()) - .transpose() - .map_err(Into::into) - } - - pub async fn increment(&self, key: &str, ttl: Duration) -> Result { - Ok(self.0.increment(key, ttl)?) - } -} - -impl MemoryCache { - fn get(&self, key: &str) -> Option { - self.maybe_prune(); - let now = Instant::now(); - let value = self.entries.get(key).and_then(|entry| { - if entry.is_expired(now) { - None - } else { - Some(entry.value.clone()) - } - }); - - if value.is_none() { - self.entries.remove_if(key, |_, entry| entry.is_expired(now)); - } - value - } - - fn set(&self, key: String, value: String, ttl: Duration) { - self.maybe_prune(); - self.entries.insert( - key, - CacheEntry { - value, - expires_at: Some(Instant::now() + ttl), - }, - ); - } - - fn delete_prefix(&self, prefix: &str) { - let keys = self - .entries - .iter() - .filter(|entry| entry.key().starts_with(prefix)) - .map(|entry| entry.key().clone()) - .collect::>(); - for key in keys { - self.entries.remove(&key); - } - } - - fn increment(&self, key: &str, ttl: Duration) -> Result { - self.maybe_prune(); - let now = Instant::now(); - match self.entries.entry(key.to_string()) { - Entry::Occupied(mut entry) if !entry.get().is_expired(now) => { - let count = entry.get().value.parse::()? + 1; - entry.get_mut().value = count.to_string(); - Ok(count) - } - Entry::Occupied(mut entry) => { - entry.insert(CacheEntry { - value: "1".to_string(), - expires_at: Some(now + ttl), - }); - Ok(1) - } - Entry::Vacant(entry) => { - entry.insert(CacheEntry { - value: "1".to_string(), - expires_at: Some(now + ttl), - }); - Ok(1) - } - } - } - - fn maybe_prune(&self) { - const PRUNE_INTERVAL: usize = 256; - if self.operations.fetch_add(1, Ordering::Relaxed) % PRUNE_INTERVAL != 0 { - return; - } - - let now = Instant::now(); - self.entries.retain(|_, entry| !entry.is_expired(now)); - } -} - -#[cfg(test)] -mod tests { - use super::CacheStore; - use serde::{Deserialize, Serialize}; - use std::time::Duration; - - #[derive(Debug, PartialEq, Serialize, Deserialize)] - struct Value { - name: String, - } - - #[tokio::test] - async fn memory_cache_supports_values_prefixes_and_counters() { - let cache = CacheStore::memory(); - cache - .set_json( - "group:one", - &Value { name: "one".into() }, - Duration::from_secs(30), - ) - .await - .unwrap(); - cache.set_string("group:two", "two", Duration::from_secs(30)).await.unwrap(); - - assert_eq!( - cache.get_json::("group:one").await.unwrap(), - Some(Value { name: "one".into() }) - ); - assert_eq!( - cache.increment("counter", Duration::from_secs(30)).await.unwrap(), - 1 - ); - assert_eq!( - cache.increment("counter", Duration::from_secs(30)).await.unwrap(), - 2 - ); - - cache.delete_prefix("group:").await.unwrap(); - assert_eq!(cache.get_string("group:one").await.unwrap(), None); - assert_eq!(cache.get_string("group:two").await.unwrap(), None); - - cache.set_string("expired", "value", Duration::ZERO).await.unwrap(); - assert_eq!(cache.get_string("expired").await.unwrap(), None); - } -} diff --git a/src-tauri/crates/business/src/caches/user.rs b/src-tauri/crates/business/src/caches/user.rs deleted file mode 100644 index 81ac708..0000000 --- a/src-tauri/crates/business/src/caches/user.rs +++ /dev/null @@ -1,75 +0,0 @@ -use crate::svc_ctx::SvcCtx; -use std::time::Duration; -use uuid::Uuid; - -pub(crate) const REGISTER_CODE_CACHE_KEY: &str = "verification:code:register:send"; -pub(crate) const RESET_PASSWORD_CODE_CACHE_KEY: &str = "verification:code:reset_password:send"; -pub(crate) const CODE_EXPIRATION: u64 = 60 * 5; // 5 分钟过期 -pub(crate) const USER_PUBLIC_KEY_CACHE_KEY: &str = "user:public_key"; -pub(crate) const PUBLIC_KEY_EXPIRATION: u64 = 60 * 60 * 24 * 7; // 7 天过期(与 refresh token 一致) - -/// 设置用户注册的验证码 -pub async fn set_register_code( - svc_ctx: &SvcCtx, - email: &str, - code: &str, -) -> Result<(), anyhow::Error> { - let key = format!("{}:{}", REGISTER_CODE_CACHE_KEY, email); - svc_ctx - .cache - .set_string(key, code, Duration::from_secs(CODE_EXPIRATION)) - .await -} - -/// 获取用户注册的验证码 -pub async fn get_register_code( - svc_ctx: &SvcCtx, - email: &str, -) -> Result, anyhow::Error> { - let key = format!("{}:{}", REGISTER_CODE_CACHE_KEY, email); - svc_ctx.cache.get_string(&key).await -} - -/// 设置重置密码的验证码 -pub async fn set_reset_password_code( - svc_ctx: &SvcCtx, - email: &str, - code: &str, -) -> Result<(), anyhow::Error> { - let key = format!("{}:{}", RESET_PASSWORD_CODE_CACHE_KEY, email); - svc_ctx - .cache - .set_string(key, code, Duration::from_secs(CODE_EXPIRATION)) - .await -} - -/// 获取重置密码的验证码 -pub async fn get_reset_password_code( - svc_ctx: &SvcCtx, - email: &str, -) -> Result, anyhow::Error> { - let key = format!("{}:{}", RESET_PASSWORD_CODE_CACHE_KEY, email); - svc_ctx.cache.get_string(&key).await -} - -/// 设置用户公钥 -pub async fn set_user_public_key( - svc_ctx: &SvcCtx, - user_uuid: &Uuid, - public_key: &str, -) -> Result<(), anyhow::Error> { - let key = format!("{}:{}", USER_PUBLIC_KEY_CACHE_KEY, user_uuid); - svc_ctx - .cache - .set_string(key, public_key, Duration::from_secs(PUBLIC_KEY_EXPIRATION)) - .await -} - -/// 获取用户公钥 -pub async fn get_user_public_key( - svc_ctx: &SvcCtx, - user_uuid: &Uuid, -) -> Result, anyhow::Error> { - let key = format!("{}:{}", USER_PUBLIC_KEY_CACHE_KEY, user_uuid); - svc_ctx.cache.get_string(&key).await -} diff --git a/src-tauri/crates/business/src/database.rs b/src-tauri/crates/business/src/database.rs index 40be28c..e6545a6 100644 --- a/src-tauri/crates/business/src/database.rs +++ b/src-tauri/crates/business/src/database.rs @@ -2,6 +2,7 @@ use std::str::FromStr; use sqlx::{ Sqlite, + migrate::Migrator, sqlite::{SqliteConnectOptions, SqliteJournalMode, SqlitePoolOptions, SqliteSynchronous}, }; @@ -12,6 +13,8 @@ pub type Db = Sqlite; pub type Pool = sqlx::Pool; pub type DbPool = Pool; +static MIGRATOR: Migrator = sqlx::migrate!("./migrations"); + /// Build a numbered placeholder list for a variable-length SQLite `IN` clause. pub fn placeholders(start: usize, len: usize) -> String { (start..start + len) @@ -39,6 +42,12 @@ pub async fn connect(config: &DatabaseConfig) -> anyhow::Result { Ok(pool) } +/// Bring a newly-created or existing embedded database up to the current schema. +pub async fn migrate(pool: &DbPool) -> anyhow::Result<()> { + MIGRATOR.run(pool).await?; + Ok(()) +} + #[cfg(test)] mod tests { use super::*; @@ -72,4 +81,154 @@ mod tests { assert_eq!(value, "ok"); assert_eq!(placeholders(3, 3), "$3, $4, $5"); } + + #[tokio::test] + async fn applies_all_migrations_to_a_fresh_database() { + let config = DatabaseConfig { + url: "sqlite::memory:".to_string(), + max_connections: 1, + min_connections: 1, + max_lifetime: 30, + acquire_timeout: 30, + idle_timeout: 30, + }; + + let pool = connect(&config).await.expect("SQLite should open"); + migrate(&pool).await.expect("all embedded migrations should apply"); + + let table_count: i64 = sqlx::query_scalar( + "SELECT COUNT(*) FROM sqlite_master WHERE type = 'table' AND name NOT LIKE 'sqlite_%'", + ) + .fetch_one(&pool) + .await + .expect("schema metadata should be readable"); + assert!(table_count > 20, "business tables should be created"); + } + + #[tokio::test] + async fn core_models_round_trip_on_sqlite() { + use crate::entitys::{CreateTeamRequest, CreateWorkspaceRequest}; + use crate::models::{environments, proxies, teams, workspaces}; + + let mut config = DatabaseConfig::embedded("sqlite::memory:"); + config.max_connections = 1; + config.min_connections = 1; + let context = crate::svc_ctx::SvcCtx::new(&config) + .await + .expect("business context should initialize"); + let pool = &context.db; + let user_uuid = uuid::Uuid::new_v4(); + + sqlx::query("INSERT INTO users (uuid, id) VALUES ($1, $2)") + .bind(user_uuid) + .bind("LOCAL_USER") + .execute(pool) + .await + .expect("local user should be inserted"); + sqlx::query( + "INSERT INTO user_infos (user_uuid, nickname, email, password) VALUES ($1, $2, $3, $4)", + ) + .bind(user_uuid) + .bind("Local User") + .bind("local@simprint.invalid") + .bind("") + .execute(pool) + .await + .expect("local user profile should be inserted"); + + let workspace_uuid = workspaces::insert_workspace( + pool, + user_uuid, + &CreateWorkspaceRequest { + name: "Local Workspace".to_string(), + workspace_type: Some("personal".to_string()), + }, + ) + .await + .expect("workspace should be created"); + let workspace = workspaces::fetch_workspace_by_uuid(pool, workspace_uuid) + .await + .expect("workspace query should succeed") + .expect("workspace should exist"); + assert_eq!(workspace.name, "Local Workspace"); + + let team_uuid = teams::insert_team( + pool, + user_uuid, + &CreateTeamRequest { + workspace_uuid, + name: "Local Team".to_string(), + description: None, + }, + ) + .await + .expect("team should be created"); + assert!(teams::fetch_team_by_uuid(pool, team_uuid).await.unwrap().is_some()); + + let group_uuid = + environments::insert_group(pool, workspace_uuid, team_uuid, "Default", None, user_uuid) + .await + .expect("group should be created"); + let tag_uuid = + environments::insert_tag(pool, user_uuid, Some(team_uuid), "QA", Some("blue")) + .await + .expect("tag should be created"); + assert!(environments::fetch_group_by_uuid(pool, group_uuid).await.unwrap().is_some()); + assert!(environments::fetch_tag_by_uuid(pool, tag_uuid).await.unwrap().is_some()); + + let proxy_uuid = proxies::insert_proxy( + pool, + workspace_uuid, + user_uuid, + "Local Proxy", + "127.0.0.1", + 8080, + "http", + None, + Some("secret"), + None, + None, + ) + .await + .expect("proxy should be created"); + let proxy = proxies::fetch_proxy_by_uuid(pool, proxy_uuid) + .await + .expect("proxy query should succeed") + .expect("proxy should exist"); + assert_eq!(proxy.password.as_deref(), Some("secret")); + + let environment_uuid = environments::insert_environment( + pool, + workspace_uuid, + user_uuid, + team_uuid, + "Browser A", + None, + Some(group_uuid), + Some(proxy_uuid), + Some("Windows"), + Some("Chromium"), + ) + .await + .expect("environment should be created"); + let environment = + environments::fetch_environment_by_uuid(pool, workspace_uuid, environment_uuid) + .await + .expect("environment query should succeed") + .expect("environment should exist"); + assert_eq!(environment.name, "Browser A"); + assert_eq!(environment.proxy_uuid, Some(proxy_uuid)); + + workspaces::update_workspace(pool, workspace_uuid, Some("Renamed Workspace")) + .await + .expect("workspace should update"); + assert_eq!( + workspaces::fetch_workspace_by_uuid(pool, workspace_uuid) + .await + .unwrap() + .unwrap() + .name, + "Renamed Workspace" + ); + } } diff --git a/src-tauri/crates/business/src/dispatcher.rs b/src-tauri/crates/business/src/dispatcher.rs new file mode 100644 index 0000000..083d1b3 --- /dev/null +++ b/src-tauri/crates/business/src/dispatcher.rs @@ -0,0 +1,1589 @@ +use serde::{Serialize, de::DeserializeOwned}; +use serde_json::{Value, json}; +use uuid::Uuid; + +use crate::{entitys::*, models, services, svc_ctx::SvcCtx}; + +fn payload(data: &Value) -> Result { + serde_json::from_value(data.clone()) + .map_err(|error| format!("Invalid request payload: {error}")) +} + +fn value(data: T) -> Result { + serde_json::to_value(data).map_err(|error| format!("Failed to serialize response: {error}")) +} + +async fn current_workspace(context: &SvcCtx) -> Result { + models::user::fetch_user_current_workspace(&context.db, context.local_user_uuid) + .await + .map_err(|error| error.to_string())? + .ok_or_else(|| "No local workspace is selected".to_string()) +} + +async fn current_team(context: &SvcCtx) -> Result { + services::teams::get_current_team_service(context, context.local_user_uuid) + .await? + .ok_or_else(|| "No local team is selected".to_string()) +} + +/// Dispatch a migrated business POST route locally. +/// +/// `None` means the route belongs to a non-migrated concern (authentication, +/// updates or optional cloud services) and may be handled by the remote client. +pub async fn dispatch_post( + context: &SvcCtx, + route: &str, + data: &Value, +) -> Option> { + let result = match route.trim_start_matches('/') { + "local-api/get" => { + async { + value( + services::local_api::get_local_api_config_service( + context, + context.local_user_uuid, + ) + .await?, + ) + } + .await + } + "local-api/update" => { + async { + let request: UpdateLocalApiConfigRequest = payload(data)?; + value( + services::local_api::update_local_api_config_service( + context, + context.local_user_uuid, + &request, + ) + .await?, + ) + } + .await + } + "local-api/reset-api-key" => { + async { + value( + services::local_api::reset_local_api_key_service( + context, + context.local_user_uuid, + ) + .await?, + ) + } + .await + } + "workspaces/list" => { + let workspaces = + services::workspaces::get_user_workspaces_service(context, context.local_user_uuid) + .await; + match workspaces { + Ok(workspaces) => { + let current = current_workspace(context).await.ok(); + value(WorkspaceListResponse { + current_workspace_uuid: current, + workspaces: workspaces + .into_iter() + .map(|workspace| WorkspaceItem { + uuid: workspace.uuid, + name: workspace.name, + workspace_type: workspace.workspace_type, + is_current: current == Some(workspace.uuid), + }) + .collect(), + }) + } + Err(error) => Err(error), + } + } + "workspaces/get" => { + async { + let request: UuidRequest = payload(data)?; + value(services::workspaces::get_workspace_service(context, request.uuid).await?) + } + .await + } + "workspaces/create" => { + async { + let request: CreateWorkspaceRequest = payload(data)?; + let uuid = services::workspaces::create_workspace_service( + context, + context.local_user_uuid, + &request, + ) + .await?; + Ok(json!({ "uuid": uuid })) + } + .await + } + "workspaces/update" => { + async { + let request: UpdateWorkspaceRequest = payload(data)?; + services::workspaces::update_workspace_service( + context, + context.local_user_uuid, + &request, + ) + .await?; + Ok(Value::Null) + } + .await + } + "workspaces/delete" => { + async { + let request: UuidRequest = payload(data)?; + services::workspaces::delete_workspace_service( + context, + context.local_user_uuid, + request.uuid, + ) + .await?; + Ok(Value::Null) + } + .await + } + "workspaces/switch" => { + async { + let request: SwitchWorkspaceRequest = payload(data)?; + services::workspaces::switch_workspace_service( + context, + request.workspace_uuid, + context.local_user_uuid, + ) + .await?; + Ok(Value::Null) + } + .await + } + + "teams/my-teams" => { + async { + let workspace_uuid = current_workspace(context).await?; + let teams = services::teams::get_user_teams_service( + context, + workspace_uuid, + context.local_user_uuid, + ) + .await?; + let current = + services::teams::get_current_team_service(context, context.local_user_uuid) + .await?; + let mut items = Vec::with_capacity(teams.len()); + for team in teams { + let role = if team.owner_uuid == context.local_user_uuid { + "owner".to_string() + } else { + models::teams::fetch_team_member( + &context.db, + workspace_uuid, + team.uuid, + context.local_user_uuid, + ) + .await + .ok() + .flatten() + .map(|member| member.role) + .unwrap_or_else(|| "member".to_string()) + }; + let members_count = models::teams::fetch_team_member_count( + &context.db, + team.uuid, + None, + None, + None, + ) + .await + .unwrap_or(0); + items.push(TeamItem { + uuid: team.uuid, + name: team.name, + description: team.description, + role, + members_count, + is_current: current == Some(team.uuid), + }); + } + value(TeamListResponse { + current_team_uuid: current, + teams: items, + }) + } + .await + } + "teams/create" => { + async { + let request: CreateTeamRequest = payload(data)?; + let uuid = services::teams::create_team_service( + context, + context.local_user_uuid, + &request, + ) + .await?; + Ok(json!({ "uuid": uuid })) + } + .await + } + "teams/get" | "teams/detail" => { + async { + let request: UuidRequest = payload(data)?; + value(services::teams::get_team_service(context, request.uuid).await?) + } + .await + } + "teams/switch" => { + async { + let request: SwitchTeamRequest = payload(data)?; + services::teams::switch_team_service( + context, + current_workspace(context).await?, + context.local_user_uuid, + &request, + ) + .await?; + Ok(Value::Null) + } + .await + } + "teams/update" => { + async { + let request: UpdateTeamRequest = payload(data)?; + services::teams::update_team_service( + context, + current_workspace(context).await?, + context.local_user_uuid, + &request, + ) + .await?; + Ok(Value::Null) + } + .await + } + "teams/members" => { + async { + let request: ListTeamMembersRequest = payload(data)?; + let (items, total) = services::teams::get_team_members_service( + context, + current_team(context).await?, + &request, + ) + .await?; + value(MemberListResponse { + items, + total, + page: request.pagination.page, + page_size: request.pagination.page_size, + }) + } + .await + } + "teams/invite" => { + async { + let request: InviteMemberRequest = payload(data)?; + let invitation_uuid = services::teams::invite_member_service( + context, + current_workspace(context).await?, + current_team(context).await?, + context.local_user_uuid, + &request, + ) + .await?; + Ok(json!({ "invitation_uuid": invitation_uuid })) + } + .await + } + "teams/member/role" => { + async { + let request: UpdateMemberRoleRequest = payload(data)?; + value( + services::teams::update_member_role_service( + context, + current_workspace(context).await?, + current_team(context).await?, + context.local_user_uuid, + &request, + ) + .await?, + ) + } + .await + } + "teams/member/remove" => { + async { + let request: RemoveMemberRequest = payload(data)?; + services::teams::remove_member_service( + context, + current_workspace(context).await?, + current_team(context).await?, + context.local_user_uuid, + request.member_uuid, + ) + .await?; + Ok(Value::Null) + } + .await + } + "teams/invitations" => { + async { + value( + services::teams::get_pending_invitations_service( + context, + current_team(context).await?, + ) + .await?, + ) + } + .await + } + "teams/invitation/cancel" => { + async { + let request: CancelInviteRequest = payload(data)?; + services::teams::cancel_invitation_service(context, request.invitation_uuid) + .await?; + Ok(Value::Null) + } + .await + } + "teams/invitation/accept" => { + async { + let request: AcceptInvitationRequest = payload(data)?; + let team_uuid = services::teams::accept_invitation_service( + context, + context.local_user_uuid, + &request.token, + ) + .await?; + Ok(json!({ "team_uuid": team_uuid })) + } + .await + } + "teams/invitation/reject" => { + async { + let request: RejectInvitationRequest = payload(data)?; + services::teams::reject_invitation_service( + context, + context.local_user_uuid, + &request.token, + ) + .await?; + Ok(Value::Null) + } + .await + } + "teams/leave" => { + async { + services::teams::leave_team_service( + context, + current_workspace(context).await?, + current_team(context).await?, + context.local_user_uuid, + ) + .await?; + Ok(Value::Null) + } + .await + } + + "groups/list" => { + async { + value( + services::groups::get_groups_service( + context, + current_workspace(context).await?, + current_team(context).await?, + 1, + 10_000, + ) + .await?, + ) + } + .await + } + "groups/create" => { + async { + let request: CreateGroupRequest = payload(data)?; + let uuid = services::groups::create_group_service( + context, + context.local_user_uuid, + current_workspace(context).await?, + current_team(context).await?, + &request, + ) + .await?; + Ok(json!({ "uuid": uuid })) + } + .await + } + "groups/update" => { + async { + let request: UpdateGroupRequest = payload(data)?; + services::groups::update_group_service( + context, + current_workspace(context).await?, + current_team(context).await?, + context.local_user_uuid, + &request, + ) + .await?; + Ok(Value::Null) + } + .await + } + "groups/delete" => { + async { + let request: UuidRequest = payload(data)?; + services::groups::delete_group_service( + context, + current_workspace(context).await?, + current_team(context).await?, + context.local_user_uuid, + request.uuid, + ) + .await?; + Ok(Value::Null) + } + .await + } + "groups/batch-delete" => { + async { + let request: BatchUuidRequest = payload(data)?; + for uuid in request.uuids { + services::groups::delete_group_service( + context, + current_workspace(context).await?, + current_team(context).await?, + context.local_user_uuid, + uuid, + ) + .await?; + } + Ok(Value::Null) + } + .await + } + + "group-permissions/grant" => { + async { + let request: GrantGroupPermissionRequest = payload(data)?; + services::group_permissions::grant_group_permission_service( + context, + context.local_user_uuid, + &request, + ) + .await?; + Ok(Value::Null) + } + .await + } + "group-permissions/revoke" => { + async { + let request: RevokeGroupPermissionRequest = payload(data)?; + services::group_permissions::revoke_group_permission_service( + context, + context.local_user_uuid, + &request, + ) + .await?; + Ok(Value::Null) + } + .await + } + "group-permissions/check" => { + async { + let request: CheckGroupPermissionRequest = payload(data)?; + let has_permission = services::group_permissions::check_group_permission_service( + context, + current_workspace(context).await?, + &request, + ) + .await?; + value(CheckPermissionResponse { + has_permission, + permission_type: has_permission.then_some(request.permission_type), + }) + } + .await + } + "group-permissions/list" => { + async { + let request: ListUserGroupPermissionsRequest = payload(data)?; + let items = services::group_permissions::list_user_group_permissions_service( + context, &request, + ) + .await?; + value(GroupPermissionListResponse { + total: items.len() as i64, + items, + page: request.pagination.page, + page_size: request.pagination.page_size, + }) + } + .await + } + + "tags/list" => { + async { + value( + services::tags::get_tags_service( + context, + context.local_user_uuid, + services::teams::get_current_team_service(context, context.local_user_uuid) + .await?, + ) + .await?, + ) + } + .await + } + "tags/create" => { + async { + let request: CreateTagRequest = payload(data)?; + let uuid = services::tags::create_tag_service( + context, + context.local_user_uuid, + Some(current_team(context).await?), + &request, + ) + .await?; + Ok(json!({ "uuid": uuid })) + } + .await + } + "tags/update" => { + async { + let request: UpdateTagRequest = payload(data)?; + services::tags::update_tag_service(context, &request).await?; + Ok(Value::Null) + } + .await + } + "tags/delete" => { + async { + let request: UuidRequest = payload(data)?; + services::tags::delete_tag_service(context, request.uuid).await?; + Ok(Value::Null) + } + .await + } + + "proxies/list" => { + async { + let request: ListProxiesRequest = payload(data)?; + let (items, total) = services::proxies::get_proxies_service( + context, + context.local_user_uuid, + current_workspace(context).await?, + Some(current_team(context).await?), + &request, + ) + .await?; + value(ProxyListResponse { + items, + total, + page: request.pagination.page, + page_size: request.pagination.page_size, + }) + } + .await + } + "proxies/detail" => { + async { + let request: UuidRequest = payload(data)?; + value(services::proxies::get_proxy_service(context, request.uuid).await?) + } + .await + } + "proxies/create" => { + async { + let request: CreateProxyRequest = payload(data)?; + let uuid = services::proxies::create_proxy_service( + context, + context.local_user_uuid, + current_workspace(context).await?, + &request, + ) + .await?; + Ok(json!({ "uuid": uuid })) + } + .await + } + "proxies/update" => { + async { + let request: UpdateProxyRequest = payload(data)?; + services::proxies::update_proxy_service(context, &request).await?; + Ok(Value::Null) + } + .await + } + "proxies/delete" => { + async { + let request: UuidRequest = payload(data)?; + services::proxies::delete_proxy_service(context, request.uuid).await?; + Ok(Value::Null) + } + .await + } + "proxies/batch-delete" => { + async { + let request: BatchUuidRequest = payload(data)?; + value( + services::proxies::batch_delete_proxies_service(context, &request.uuids) + .await?, + ) + } + .await + } + "proxies/batch-import" => { + async { + let request: BatchImportProxiesRequest = payload(data)?; + value( + services::proxies::batch_import_proxies_service( + context, + context.local_user_uuid, + current_workspace(context).await?, + &request, + ) + .await?, + ) + } + .await + } + + "proxy-visibility/set" => { + async { + let request: SetProxyVisibleRequest = payload(data)?; + services::proxy_visibility::set_proxy_visible_to_team_service( + context, + context.local_user_uuid, + &request, + ) + .await?; + Ok(Value::Null) + } + .await + } + "proxy-visibility/remove" => { + async { + let request: RemoveProxyVisibleRequest = payload(data)?; + services::proxy_visibility::remove_proxy_visible_from_team_service( + context, + context.local_user_uuid, + &request, + ) + .await?; + Ok(Value::Null) + } + .await + } + "proxy-visibility/batch-set" => { + async { + let request: BatchSetProxyVisibleRequest = payload(data)?; + services::proxy_visibility::batch_set_proxy_visible_service( + context, + context.local_user_uuid, + &request, + ) + .await?; + Ok(Value::Null) + } + .await + } + "proxy-visibility/list-visible" => { + async { + let mut request: ListVisibleProxiesRequest = payload(data)?; + if request.workspace_uuid.is_nil() { + request.workspace_uuid = current_workspace(context).await?; + } + value(VisibleProxyListResponse { + items: services::proxy_visibility::get_visible_proxies_service( + context, + context.local_user_uuid, + &request, + ) + .await?, + }) + } + .await + } + "proxy-visibility/list-teams" => { + async { + let request: ListProxyVisibleTeamsRequest = payload(data)?; + value( + services::proxy_visibility::get_proxy_visible_teams_service( + context, + request.proxy_uuid, + ) + .await?, + ) + } + .await + } + + "accounts/list" => { + async { + let request: ListAccountsRequest = payload(data)?; + let (items, total) = services::accounts::get_accounts_service( + context, + context.local_user_uuid, + Some(current_team(context).await?), + &request, + ) + .await?; + value(AccountListResponse { + items, + total, + page: request.pagination.page, + page_size: request.pagination.page_size, + }) + } + .await + } + "accounts/detail" => { + async { + let request: UuidRequest = payload(data)?; + value(services::accounts::get_account_service(context, request.uuid).await?) + } + .await + } + "accounts/create" => { + async { + let request: CreateAccountRequest = payload(data)?; + let uuid = services::accounts::create_account_service( + context, + context.local_user_uuid, + Some(current_team(context).await?), + &request, + ) + .await?; + Ok(json!({ "uuid": uuid })) + } + .await + } + "accounts/update" => { + async { + let request: UpdateAccountRequest = payload(data)?; + services::accounts::update_account_service(context, &request).await?; + Ok(Value::Null) + } + .await + } + "accounts/delete" => { + async { + let request: UuidRequest = payload(data)?; + services::accounts::delete_account_service(context, request.uuid).await?; + Ok(Value::Null) + } + .await + } + "accounts/batch-delete" => { + async { + let request: BatchUuidRequest = payload(data)?; + value( + services::accounts::batch_delete_accounts_service(context, &request.uuids) + .await?, + ) + } + .await + } + "accounts/batch-import" => { + async { + let request: BatchImportAccountsRequest = payload(data)?; + value( + services::accounts::batch_import_accounts_service( + context, + context.local_user_uuid, + Some(current_team(context).await?), + &request, + ) + .await?, + ) + } + .await + } + + "environments/list" => { + async { + let request: ListEnvironmentsRequest = payload(data)?; + let (items, total) = services::environments::get_environments_service( + context, + context.local_user_uuid, + current_workspace(context).await?, + current_team(context).await?, + &request, + ) + .await?; + value(EnvironmentListResponse { + items, + total, + page: request.pagination.page, + page_size: request.pagination.page_size, + }) + } + .await + } + "environments/detail" => { + async { + let request: UuidRequest = payload(data)?; + value( + services::environments::get_environment_detail_service( + context, + current_workspace(context).await?, + current_team(context).await?, + context.local_user_uuid, + request.uuid, + ) + .await?, + ) + } + .await + } + "environments/batch-detail" => { + async { + let request: BatchUuidRequest = payload(data)?; + let workspace_uuid = current_workspace(context).await?; + let team_uuid = current_team(context).await?; + let mut items = std::collections::HashMap::new(); + for uuid in request.uuids { + if let Ok(detail) = services::environments::get_environment_detail_service( + context, + workspace_uuid, + team_uuid, + context.local_user_uuid, + uuid, + ) + .await + { + items.insert(uuid.to_string(), detail); + } + } + value(items) + } + .await + } + "environments/create" => { + async { + let request: CreateEnvironmentRequest = payload(data)?; + let uuid = services::environments::create_environment_service( + context, + context.local_user_uuid, + current_workspace(context).await?, + current_team(context).await?, + &request, + ) + .await?; + Ok(json!({ "uuid": uuid })) + } + .await + } + "environments/batch-create" => { + async { + let request: BatchCreateEnvironmentRequest = payload(data)?; + value( + services::environments::batch_create_environments_service( + context, + context.local_user_uuid, + current_workspace(context).await?, + current_team(context).await?, + &request, + ) + .await?, + ) + } + .await + } + "environments/update" => { + async { + let request: UpdateEnvironmentRequest = payload(data)?; + services::environments::update_environment_service( + context, + current_workspace(context).await?, + current_team(context).await?, + context.local_user_uuid, + &request, + ) + .await?; + Ok(Value::Null) + } + .await + } + "environments/delete" => { + async { + let request: UuidRequest = payload(data)?; + services::environments::delete_environment_service( + context, + current_workspace(context).await?, + current_team(context).await?, + context.local_user_uuid, + request.uuid, + ) + .await?; + Ok(Value::Null) + } + .await + } + "environments/batch-delete" => { + async { + let request: BatchUuidRequest = payload(data)?; + value( + services::environments::batch_delete_environments_service( + context, + &request.uuids, + ) + .await?, + ) + } + .await + } + "environments/set-proxy" => { + async { + let request: SetEnvironmentProxyRequest = payload(data)?; + services::environments::set_environment_proxy_service(context, &request).await?; + Ok(Value::Null) + } + .await + } + "environments/set-accounts" => { + async { + let request: SetEnvironmentAccountsRequest = payload(data)?; + services::accounts::set_environment_accounts_service( + context, + request.uuid, + &request.account_uuids, + ) + .await?; + Ok(Value::Null) + } + .await + } + "environments/assign-tags" => { + async { + let request: AssignTagsRequest = payload(data)?; + services::environments::assign_tags_service(context, &request).await?; + Ok(Value::Null) + } + .await + } + "environments/remove-tag" => { + async { + let request: RemoveTagRequest = payload(data)?; + services::environments::remove_tag_service(context, request.uuid, request.tag_uuid) + .await?; + Ok(Value::Null) + } + .await + } + "environments/batch-assign-tags" => { + async { + let request: BatchAssignTagRequest = payload(data)?; + services::environments::batch_assign_tags_service(context, &request).await?; + Ok(Value::Null) + } + .await + } + "environments/batch-remove-tags" => { + async { + let request: BatchRemoveTagsRequest = payload(data)?; + services::environments::batch_remove_tags_service(context, &request).await?; + Ok(Value::Null) + } + .await + } + "environments/move-to-group" => { + async { + let request: MoveToGroupRequest = payload(data)?; + services::environments::move_to_group_service(context, &request).await?; + Ok(Value::Null) + } + .await + } + "environments/batch-move-to-group" => { + async { + let request: BatchMoveToGroupRequest = payload(data)?; + services::environments::batch_move_to_group_service(context, &request).await?; + Ok(Value::Null) + } + .await + } + "environments/recycle-bin/list" => { + async { + let request: ListEnvironmentsRequest = payload(data)?; + let (items, total) = services::environments::get_recycle_bin_environments_service( + context, + current_workspace(context).await?, + current_team(context).await?, + context.local_user_uuid, + &request, + ) + .await?; + value(EnvironmentListResponse { + items, + total, + page: request.pagination.page, + page_size: request.pagination.page_size, + }) + } + .await + } + "environments/recycle-bin/restore" => { + async { + let request: UuidRequest = payload(data)?; + services::environments::restore_environment_service(context, request.uuid).await?; + Ok(Value::Null) + } + .await + } + "environments/recycle-bin/batch-restore" => { + async { + let request: BatchUuidRequest = payload(data)?; + services::environments::batch_restore_environments_service(context, &request.uuids) + .await?; + Ok(Value::Null) + } + .await + } + "environments/recycle-bin/permanent-delete" => { + async { + let request: UuidRequest = payload(data)?; + services::environments::permanent_delete_environment_service( + context, + request.uuid, + current_workspace(context).await?, + ) + .await?; + Ok(Value::Null) + } + .await + } + "environments/recycle-bin/batch-permanent-delete" => { + async { + let request: BatchUuidRequest = payload(data)?; + services::environments::batch_permanent_delete_environments_service( + context, + &request.uuids, + current_workspace(context).await?, + ) + .await?; + Ok(Value::Null) + } + .await + } + "environments/urls/list" => { + async { + let request: UuidRequest = payload(data)?; + value( + services::environments::get_environment_urls_service(context, request.uuid) + .await?, + ) + } + .await + } + "environments/urls/add" => { + async { + let request: AddEnvironmentUrlRequest = payload(data)?; + let id = + services::environments::add_environment_url_service(context, &request).await?; + Ok(json!({ "id": id })) + } + .await + } + "environments/urls/delete" => { + async { + let request: DeleteEnvironmentUrlRequest = payload(data)?; + services::environments::delete_environment_url_service(context, request.id).await?; + Ok(Value::Null) + } + .await + } + "environments/urls/clear" => { + async { + let request: ClearEnvironmentUrlsRequest = payload(data)?; + value( + services::environments::clear_environment_urls_service( + context, + request.environment_uuid, + ) + .await?, + ) + } + .await + } + "environments/cookies/list" => { + async { + let request: UuidRequest = payload(data)?; + value( + services::environments::get_environment_cookies_service(context, request.uuid) + .await?, + ) + } + .await + } + "environments/cookies/add" => { + async { + let request: AddEnvironmentCookieRequest = payload(data)?; + let id = services::environments::add_environment_cookie_service(context, &request) + .await?; + Ok(json!({ "id": id })) + } + .await + } + "environments/cookies/delete" => { + async { + let request: DeleteEnvironmentCookieRequest = payload(data)?; + services::environments::delete_environment_cookie_service(context, request.id) + .await?; + Ok(Value::Null) + } + .await + } + "environments/cookies/clear" => { + async { + let request: ClearEnvironmentCookiesRequest = payload(data)?; + value( + services::environments::clear_environment_cookies_service( + context, + request.environment_uuid, + ) + .await?, + ) + } + .await + } + + "templates/list" => { + async { + let request: ListTemplatesRequest = payload(data)?; + let (items, total) = services::templates::get_templates_service( + context, + context.local_user_uuid, + Some(current_team(context).await?), + request.is_public, + request.pagination.page, + request.pagination.page_size, + ) + .await?; + value(TemplateListResponse { + items, + total, + page: request.pagination.page, + page_size: request.pagination.page_size, + }) + } + .await + } + "templates/detail" => { + async { + let request: GetTemplateRequest = payload(data)?; + value( + services::templates::get_template_service( + context, + request.uuid, + request.for_create.unwrap_or(false), + ) + .await?, + ) + } + .await + } + "templates/create" => { + async { + let request: CreateTemplateRequest = payload(data)?; + let uuid = services::templates::create_template_service( + context, + context.local_user_uuid, + Some(current_team(context).await?), + &request, + ) + .await?; + Ok(json!({ "uuid": uuid })) + } + .await + } + "templates/update" => { + async { + let request: UpdateTemplateRequest = payload(data)?; + services::templates::update_template_service(context, &request).await?; + Ok(Value::Null) + } + .await + } + "templates/delete" => { + async { + let request: UuidRequest = payload(data)?; + services::templates::delete_template_service(context, request.uuid).await?; + Ok(Value::Null) + } + .await + } + "templates/apply" => { + async { + let request: ApplyTemplateRequest = payload(data)?; + services::templates::apply_template_service( + context, + current_workspace(context).await?, + current_team(context).await?, + context.local_user_uuid, + &request, + ) + .await?; + Ok(Value::Null) + } + .await + } + "templates/create-from" => { + async { + let request: CreateFromTemplateRequest = payload(data)?; + let uuid = services::templates::create_from_template_service( + context, + context.local_user_uuid, + current_workspace(context).await?, + current_team(context).await?, + &request, + ) + .await?; + Ok(json!({ "uuid": uuid })) + } + .await + } + + "rpa/tasks" => { + async { + let request: ListRpaTasksRequest = payload(data)?; + let (items, total) = services::rpa::get_rpa_tasks_service( + context, + context.local_user_uuid, + Some(current_team(context).await?), + &request, + ) + .await?; + value(RpaTaskListResponse { + items, + total, + page: request.pagination.page, + page_size: request.pagination.page_size, + }) + } + .await + } + "rpa/tasks/detail" => { + async { + let request: UuidRequest = payload(data)?; + let (task, steps, environment_uuids) = + services::rpa::get_rpa_task_service(context, request.uuid).await?; + value(RpaTaskDetailResponse { + task, + steps, + environment_uuids, + }) + } + .await + } + "rpa/tasks/create" => { + async { + let request: CreateRpaTaskRequest = payload(data)?; + let uuid = services::rpa::create_rpa_task_service( + context, + context.local_user_uuid, + Some(current_team(context).await?), + &request, + ) + .await?; + Ok(json!({ "uuid": uuid })) + } + .await + } + "rpa/tasks/update" => { + async { + let request: UpdateRpaTaskRequest = payload(data)?; + services::rpa::update_rpa_task_service(context, &request).await?; + Ok(Value::Null) + } + .await + } + "rpa/tasks/delete" => { + async { + let request: UuidRequest = payload(data)?; + services::rpa::delete_rpa_task_service(context, request.uuid).await?; + Ok(Value::Null) + } + .await + } + "rpa/tasks/batch-delete" => { + async { + let request: BatchUuidRequest = payload(data)?; + services::rpa::batch_delete_rpa_tasks_service(context, &request.uuids).await?; + Ok(Value::Null) + } + .await + } + "rpa/tasks/duplicate" => { + async { + let request: DuplicateRpaTaskRequest = payload(data)?; + let uuid = services::rpa::duplicate_rpa_task_service( + context, + context.local_user_uuid, + Some(current_team(context).await?), + &request, + ) + .await?; + Ok(json!({ "uuid": uuid })) + } + .await + } + + "messages/list" => { + async { + let request: ListMessagesRequest = payload(data)?; + value( + services::messages::get_user_messages_service( + context, + context.local_user_uuid, + &request, + ) + .await?, + ) + } + .await + } + "messages/read" => { + async { + let request: MarkMessageReadRequest = payload(data)?; + services::messages::mark_message_read_service( + context, + context.local_user_uuid, + &request, + ) + .await?; + Ok(Value::Null) + } + .await + } + "messages/batch-read" => { + async { + let request: BatchMarkReadRequest = payload(data)?; + services::messages::batch_mark_messages_read_service( + context, + context.local_user_uuid, + &request, + ) + .await?; + Ok(Value::Null) + } + .await + } + "messages/handle" => { + async { + let request: HandleMessageRequest = payload(data)?; + services::messages::handle_message_service( + context, + context.local_user_uuid, + &request, + ) + .await?; + Ok(Value::Null) + } + .await + } + "messages/stats" => { + async { + value( + services::messages::get_user_message_stats_service( + context, + context.local_user_uuid, + ) + .await?, + ) + } + .await + } + + "audit/logs" => { + async { + let request: ListAuditLogsRequest = payload(data)?; + let (items, total) = services::audit::get_audit_logs_service( + context, + context.local_user_uuid, + Some(current_team(context).await?), + &request, + ) + .await?; + value(AuditLogsListResponse { + items, + total, + page: request.pagination.page, + page_size: request.pagination.page_size, + }) + } + .await + } + "audit/logs/detail" => { + async { + let request: UuidRequest = payload(data)?; + value(services::audit::get_audit_log_service(context, request.uuid).await?) + } + .await + } + "audit/stats" => { + async { + value( + services::audit::get_audit_stats_service( + context, + context.local_user_uuid, + Some(current_team(context).await?), + ) + .await?, + ) + } + .await + } + "audit/logs/export" => { + async { + let request: ExportAuditLogsRequest = payload(data)?; + let (content, filename, mime_type) = services::audit::export_audit_logs_service( + context, + context.local_user_uuid, + Some(current_team(context).await?), + &request, + ) + .await?; + value(ExportResponse { + content, + filename, + mime_type, + }) + } + .await + } + + "workspace-quotas/get" => { + async { + let mut request: GetWorkspaceQuotaRequest = payload(data)?; + if request.workspace_uuid.is_none() { + request.workspace_uuid = Some(current_workspace(context).await?); + } + value( + services::workspace_quotas::get_workspace_quota_service(context, &request) + .await?, + ) + } + .await + } + "workspace-quotas/update" => { + async { + let request: UpdateQuotaUsageRequest = payload(data)?; + services::workspace_quotas::update_quota_usage_service(context, &request).await?; + Ok(Value::Null) + } + .await + } + + "preferences/get" => { + async { + value( + services::preferences::get_preferences_service( + context, + context.local_user_uuid, + ) + .await?, + ) + } + .await + } + "preferences/update" => { + async { + let request: UpdatePreferencesRequest = payload(data)?; + value( + services::preferences::update_preferences_service( + context, + context.local_user_uuid, + &request, + ) + .await?, + ) + } + .await + } + _ => return None, + }; + + Some(result) +} + +#[cfg(test)] +mod tests { + use super::*; + use crate::utils::DatabaseConfig; + + #[tokio::test] + async fn migrated_routes_use_the_embedded_database() { + let mut config = DatabaseConfig::embedded("sqlite::memory:"); + config.max_connections = 1; + config.min_connections = 1; + let context = SvcCtx::new(&config).await.expect("context should initialize"); + + let workspace_list = dispatch_post(&context, "workspaces/list", &json!({})) + .await + .expect("route should be local") + .expect("workspace list should succeed"); + assert_eq!(workspace_list["workspaces"].as_array().unwrap().len(), 1); + + let group = dispatch_post( + &context, + "groups/create", + &json!({ "name": "Local Group", "description": null }), + ) + .await + .unwrap() + .expect("group creation should succeed"); + assert!(group["uuid"].as_str().is_some()); + + dispatch_post( + &context, + "proxies/create", + &json!({ + "name": "Local Proxy", + "host": "127.0.0.1", + "port": 8080, + "proxy_type": "http", + "password": "secret" + }), + ) + .await + .unwrap() + .expect("proxy creation should succeed"); + let proxies = dispatch_post( + &context, + "proxies/list", + &json!({ "page": 1, "page_size": 20 }), + ) + .await + .unwrap() + .expect("proxy list should succeed"); + assert_eq!(proxies["total"], 1); + + let local_api = dispatch_post(&context, "local-api/get", &json!({})) + .await + .unwrap() + .expect("local API config should be stored locally"); + let api_key = local_api["apiKey"].as_str().unwrap().to_string(); + assert!(api_key.starts_with("sk_local_")); + dispatch_post( + &context, + "local-api/update", + &json!({ "enabled": true, "port": 18080, "remoteAccess": false }), + ) + .await + .unwrap() + .expect("local API config update should succeed"); + services::local_api::validate_local_api_key_service( + &context, + &ValidateLocalApiKeyRequest { + api_key, + permission_code: "workspaces.list".to_string(), + }, + ) + .await + .expect("local API key should validate without a remote cache"); + + assert!(dispatch_post(&context, "auth/login", &json!({})).await.is_none()); + } +} diff --git a/src-tauri/crates/business/src/dto/maintenance.rs b/src-tauri/crates/business/src/dto/maintenance.rs deleted file mode 100644 index 735555a..0000000 --- a/src-tauri/crates/business/src/dto/maintenance.rs +++ /dev/null @@ -1,49 +0,0 @@ -use chrono::{DateTime, Utc}; -use serde::{Deserialize, Serialize}; -use std::str::FromStr; - -#[derive(Debug, Clone, Serialize, Deserialize, PartialEq)] -pub enum MaintenanceType { - #[serde(rename = "scheduled")] - Scheduled, - #[serde(rename = "emergency")] - Emergency, - #[serde(rename = "upgrade")] - Upgrade, -} - -impl FromStr for MaintenanceType { - type Err = String; - - fn from_str(s: &str) -> Result { - match s { - "scheduled" => Ok(MaintenanceType::Scheduled), - "emergency" => Ok(MaintenanceType::Emergency), - "upgrade" => Ok(MaintenanceType::Upgrade), - _ => Err(format!("无效的维护类型: {}", s)), - } - } -} - -impl From for String { - fn from(maintenance_type: MaintenanceType) -> Self { - match maintenance_type { - MaintenanceType::Scheduled => "scheduled".to_string(), - MaintenanceType::Emergency => "emergency".to_string(), - MaintenanceType::Upgrade => "upgrade".to_string(), - } - } -} - -#[derive(Debug, Clone, Serialize, Deserialize, sqlx::FromRow)] -pub struct Maintenance { - pub id: i64, - pub name: String, - pub description: Option, - pub status: String, - pub start_time: DateTime, - pub end_time: DateTime, - pub maintenance_type: String, - pub created_at: DateTime, - pub updated_at: DateTime, -} diff --git a/src-tauri/crates/business/src/dto/mod.rs b/src-tauri/crates/business/src/dto/mod.rs index 96da24f..7cbd14a 100644 --- a/src-tauri/crates/business/src/dto/mod.rs +++ b/src-tauri/crates/business/src/dto/mod.rs @@ -1,8 +1,4 @@ -pub mod maintenance; -pub mod strategy_types; -pub mod user; -pub mod version_types; -pub mod versions; +pub mod user; // 新增模块 pub mod accounts; @@ -19,11 +15,7 @@ pub mod teams; pub mod workspace_quotas; pub mod workspaces; -pub use maintenance::*; -pub use strategy_types::*; -pub use user::*; -pub use version_types::*; -pub use versions::*; +pub use user::*; // 新增导出 pub use accounts::*; diff --git a/src-tauri/crates/business/src/dto/strategy_types.rs b/src-tauri/crates/business/src/dto/strategy_types.rs deleted file mode 100644 index 0618594..0000000 --- a/src-tauri/crates/business/src/dto/strategy_types.rs +++ /dev/null @@ -1,92 +0,0 @@ -use serde::{Deserialize, Serialize}; -use sqlx::FromRow; - -pub mod processor_config { - use super::*; - - /// 白名单过滤配置 - #[derive(Debug, Deserialize, Serialize, Clone, PartialEq)] - pub struct FilterWhitelistConfig { - pub machines: Vec, // 机器码列表 - } - - /// 百分比过滤配置 - #[derive(Debug, Deserialize, Serialize, Clone, PartialEq)] - pub struct FilterPercentageConfig { - pub percent: f64, // 百分比 (0-100) - } - - /// 随机过滤配置 - #[derive(Debug, Deserialize, Serialize, Clone, PartialEq)] - pub struct FilterRandomConfig { - pub count: usize, // 随机选择的数量 - } - - /// 用户标签过滤配置 - #[derive(Debug, Deserialize, Serialize, Clone, PartialEq)] - pub struct FilterUserTagConfig { - pub tags: Vec, // 标签列表 - pub match_all: bool, // true: 必须包含所有标签, false: 包含任意一个标签 - } - - /// 时间段转换配置 - #[derive(Debug, Deserialize, Serialize, Clone, PartialEq)] - pub struct TransformTimeRangeConfig { - pub start_hour: u8, // 开始小时 (0-23) - pub end_hour: u8, // 结束小时 (0-23) - pub days: Vec, // 生效星期 (0-6, 0=周日) - } - - /// 延迟分配配置 - #[derive(Debug, Deserialize, Serialize, Clone, PartialEq)] - pub struct TransformDelayConfig { - pub delay_seconds: u64, // 延迟秒数 - } - - /// 分步分配配置 - #[derive(Debug, Deserialize, Serialize, Clone, PartialEq)] - pub struct TransformStepConfig { - pub step_percentage: f64, // 每一步的百分比 - pub step_interval_hours: u32, // 每步间隔(小时) - } -} - -/// 处理器类型枚举 -#[derive(Debug, Clone, PartialEq)] -pub enum ProcessorType { - FilterWhitelist(processor_config::FilterWhitelistConfig), - FilterPercentage(processor_config::FilterPercentageConfig), - FilterRandom(processor_config::FilterRandomConfig), - FilterUserTag(processor_config::FilterUserTagConfig), - TransformTimeRange(processor_config::TransformTimeRangeConfig), - TransformDelay(processor_config::TransformDelayConfig), - TransformStep(processor_config::TransformStepConfig), -} - -impl ProcessorType { - /// 获取处理器类型名称 - pub fn type_name(&self) -> &'static str { - match self { - ProcessorType::FilterWhitelist(_) => "filter_whitelist", - ProcessorType::FilterPercentage(_) => "filter_percentage", - ProcessorType::FilterRandom(_) => "filter_random", - ProcessorType::FilterUserTag(_) => "filter_user_tag", - ProcessorType::TransformTimeRange(_) => "transform_time_range", - ProcessorType::TransformDelay(_) => "transform_delay", - ProcessorType::TransformStep(_) => "transform_step", - } - } -} - -/// 策略类型 -#[derive(Debug, Deserialize, Serialize, FromRow, Clone)] -pub struct StrategyType { - pub id: i32, - pub code: String, - pub name: String, - pub category: Option, - pub description: Option, - pub processor_type: String, - pub config_schema: Option, - pub is_active: bool, -} diff --git a/src-tauri/crates/business/src/dto/version_types.rs b/src-tauri/crates/business/src/dto/version_types.rs deleted file mode 100644 index 048cd3f..0000000 --- a/src-tauri/crates/business/src/dto/version_types.rs +++ /dev/null @@ -1,14 +0,0 @@ -use serde::{Deserialize, Serialize}; -use sqlx::FromRow; - -/// 版本类型 -#[derive(Debug, Deserialize, Serialize, FromRow, Clone, Default)] -pub struct VersionType { - pub id: i32, - pub type_code: String, - pub type_name: String, - pub description: Option, - pub sort_order: i32, - pub is_active: bool, - pub is_auto_download: bool, -} diff --git a/src-tauri/crates/business/src/dto/versions.rs b/src-tauri/crates/business/src/dto/versions.rs deleted file mode 100644 index 50ad520..0000000 --- a/src-tauri/crates/business/src/dto/versions.rs +++ /dev/null @@ -1,31 +0,0 @@ -use chrono::DateTime; -use serde::{Deserialize, Serialize}; -use sqlx::FromRow; - -/// 统一版本表 -#[derive(Debug, Deserialize, Serialize, FromRow, Clone, Default)] -pub struct Version { - pub id: i32, - pub type_id: i32, - pub resource_name: String, - pub version: String, - pub name: Option, - pub notes: Option, - pub platform: Option, - pub url: Option, - pub hash: Option, - pub signature: Option, - pub install_path: Option, - pub file_size: Option, - pub is_latest: bool, - pub status: String, - pub pub_date: Option>, - pub created_at: DateTime, - pub updated_at: Option>, - pub deleted_at: Option>, - pub arch: Option, - pub package_format: Option, - pub requires_extract: bool, - pub entrypoint_template: Option, - pub extract_root: Option, -} diff --git a/src-tauri/crates/business/src/entitys/browser_kernel.rs b/src-tauri/crates/business/src/entitys/browser_kernel.rs deleted file mode 100644 index edc1495..0000000 --- a/src-tauri/crates/business/src/entitys/browser_kernel.rs +++ /dev/null @@ -1,10 +0,0 @@ -use serde::{Deserialize, Serialize}; - -/// 查询浏览器内核列表请求 -#[derive(Debug, Default, Deserialize, Serialize)] -pub struct ListBrowserKernelsRequest { - /// 平台过滤,如 windows/darwin/linux,为空表示不过滤 - pub platform: Option, - /// 版本类型过滤,如 SIMPRINT_KERNEL_CHROMIUM,为空表示查询所有 SIMPRINT_KERNEL_* 类型 - pub type_code: Option, -} diff --git a/src-tauri/crates/business/src/entitys/maintenance.rs b/src-tauri/crates/business/src/entitys/maintenance.rs deleted file mode 100644 index dcb98ce..0000000 --- a/src-tauri/crates/business/src/entitys/maintenance.rs +++ /dev/null @@ -1,42 +0,0 @@ -use chrono::{DateTime, Utc}; -use serde::{Deserialize, Serialize}; - -use crate::dto::maintenance::MaintenanceType; - -#[derive(Debug, Clone, Deserialize, Serialize)] -pub struct CreateMaintenanceRequest { - pub name: String, - pub description: Option, - pub status: String, - pub start_time: DateTime, - pub end_time: DateTime, - pub maintenance_type: MaintenanceType, -} - -/// 获取维护详情请求 -#[derive(Debug, Deserialize, Serialize)] -pub struct GetMaintenanceRequest { - pub id: i64, -} - -/// 查询维护列表请求 -#[derive(Debug, Deserialize, Serialize)] -pub struct ListMaintenancesRequest { - pub limit: Option, - pub offset: Option, -} - -/// 更新维护状态请求 -#[derive(Debug, Deserialize, Serialize)] -pub struct UpdateMaintenanceStatusRequest { - pub id: i64, - pub status: String, -} - -/// 结束维护请求(可为空 body) -#[derive(Debug, Default, Deserialize, Serialize)] -pub struct EndMaintenanceRequest {} - -/// 获取当前活跃维护请求(可为空 body) -#[derive(Debug, Default, Deserialize, Serialize)] -pub struct GetActiveMaintenanceRequest {} diff --git a/src-tauri/crates/business/src/entitys/mod.rs b/src-tauri/crates/business/src/entitys/mod.rs index a86aea2..a328d8d 100644 --- a/src-tauri/crates/business/src/entitys/mod.rs +++ b/src-tauri/crates/business/src/entitys/mod.rs @@ -1,13 +1,8 @@ -pub mod maintenance; -pub mod strategy_types; -pub mod user; -pub mod version_types; -pub mod versions; +pub mod user; // 新增模块 pub mod accounts; pub mod audit; -pub mod browser_kernel; pub mod common; pub mod environments; pub mod group_member_permissions; @@ -24,16 +19,11 @@ pub mod templates; pub mod workspace_quotas; pub mod workspaces; -pub use maintenance::*; -pub use strategy_types::*; -pub use user::*; -pub use version_types::*; -pub use versions::*; +pub use user::*; // 新增导出 pub use accounts::*; pub use audit::*; -pub use browser_kernel::*; pub use common::*; pub use environments::*; pub use group_member_permissions::*; diff --git a/src-tauri/crates/business/src/entitys/strategy_types.rs b/src-tauri/crates/business/src/entitys/strategy_types.rs deleted file mode 100644 index 94d1122..0000000 --- a/src-tauri/crates/business/src/entitys/strategy_types.rs +++ /dev/null @@ -1,32 +0,0 @@ -use serde::{Deserialize, Serialize}; - -/// 根据ID获取策略类型请求 -#[derive(Debug, Deserialize, Serialize)] -pub struct GetStrategyTypeByIdRequest { - pub id: i32, -} - -/// 根据代码获取策略类型请求 -#[derive(Debug, Deserialize, Serialize)] -pub struct GetStrategyTypeByCodeRequest { - pub code: String, -} - -/// 根据分类查询策略类型请求 -#[derive(Debug, Deserialize, Serialize)] -pub struct ListStrategyTypesByCategoryRequest { - pub category: String, -} - -/// 策略类型列表响应 -#[derive(Debug, Deserialize, Serialize)] -pub struct StrategyTypeListResponse { - pub list: Vec, -} - -/// 策略类型详细信息响应(包含配置示例) -#[derive(Debug, Deserialize, Serialize)] -pub struct StrategyTypeDetailResponse { - pub strategy_type: crate::dto::strategy_types::StrategyType, - pub config_example: String, // JSON 配置示例 -} diff --git a/src-tauri/crates/business/src/entitys/version_types.rs b/src-tauri/crates/business/src/entitys/version_types.rs deleted file mode 100644 index d796392..0000000 --- a/src-tauri/crates/business/src/entitys/version_types.rs +++ /dev/null @@ -1,69 +0,0 @@ -use serde::{Deserialize, Serialize}; - -/// 创建版本类型请求 -#[derive(Debug, Deserialize, Serialize)] -pub struct CreateVersionTypeRequest { - pub type_code: String, - pub type_name: String, - pub description: Option, - pub sort_order: Option, -} - -/// 更新版本类型请求 -#[derive(Debug, Deserialize, Serialize)] -pub struct UpdateVersionTypeRequest { - pub type_name: Option, - pub description: Option, - pub sort_order: Option, - pub is_active: Option, - pub is_auto_download: Option, -} - -/// 根据ID获取版本类型请求 -#[derive(Debug, Deserialize, Serialize)] -pub struct GetVersionTypeByIdRequest { - pub id: i32, -} - -/// 根据代码获取版本类型请求 -#[derive(Debug, Deserialize, Serialize)] -pub struct GetVersionTypeByCodeRequest { - pub type_code: String, -} - -/// 查询所有版本类型请求(可为空 body) -#[derive(Debug, Default, Deserialize, Serialize)] -pub struct ListAllVersionTypesRequest {} - -/// 查询激活的版本类型请求(可为空 body) -#[derive(Debug, Default, Deserialize, Serialize)] -pub struct ListActiveVersionTypesRequest {} - -/// 更新版本类型请求(含 id) -#[derive(Debug, Deserialize, Serialize)] -pub struct UpdateVersionTypeHandleRequest { - pub id: i32, - pub type_name: Option, - pub description: Option, - pub sort_order: Option, - pub is_active: Option, -} - -/// 删除版本类型请求 -#[derive(Debug, Deserialize, Serialize)] -pub struct DeleteVersionTypeRequest { - pub id: i32, -} - -/// 切换版本类型状态请求 -#[derive(Debug, Deserialize, Serialize)] -pub struct ToggleVersionTypeStatusRequest { - pub id: i32, - pub is_active: bool, -} - -/// 版本类型列表响应 -#[derive(Debug, Deserialize, Serialize)] -pub struct VersionTypeListResponse { - pub list: Vec, -} diff --git a/src-tauri/crates/business/src/entitys/versions.rs b/src-tauri/crates/business/src/entitys/versions.rs deleted file mode 100644 index 52b4a31..0000000 --- a/src-tauri/crates/business/src/entitys/versions.rs +++ /dev/null @@ -1,132 +0,0 @@ -use chrono::DateTime; -use serde::{Deserialize, Serialize}; - -/// 创建版本请求 -#[derive(Debug, Default, Deserialize, Serialize)] -pub struct CreateVersionRequest { - pub type_id: i32, - pub resource_name: String, - pub version: String, - pub name: Option, - pub notes: Option, - pub platform: Option, - pub url: Option, - pub hash: Option, - pub signature: Option, - pub install_path: Option, - pub file_size: Option, - pub pub_date: Option>, - pub arch: Option, - pub package_format: Option, - pub requires_extract: Option, - pub entrypoint_template: Option, - pub extract_root: Option, -} - -/// 更新版本请求 -#[derive(Debug, Deserialize, Serialize)] -pub struct UpdateVersionRequest { - pub name: Option, - pub notes: Option, - pub platform: Option, - pub url: Option, - pub hash: Option, - pub signature: Option, - pub install_path: Option, - pub file_size: Option, - pub status: Option, - pub is_latest: Option, - pub arch: Option, - pub package_format: Option, - pub requires_extract: Option, - pub entrypoint_template: Option, - pub extract_root: Option, -} - -/// 版本列表响应 -#[derive(Debug, Deserialize, Serialize)] -pub struct VersionListResponse { - pub total: i64, - pub list: Vec, -} - -/// 查询版本参数 -#[derive(Debug, Deserialize, Serialize)] -pub struct QueryVersionParams { - pub resource_name: Option, - pub platform: Option, - pub status: Option, -} - -/// 根据ID获取版本请求 -#[derive(Debug, Deserialize, Serialize)] -pub struct GetVersionByIdRequest { - pub id: i32, -} - -/// 根据资源名称和版本号获取版本请求 -#[derive(Debug, Deserialize, Serialize)] -pub struct GetVersionByNameAndVersionRequest { - pub resource_name: String, - pub version: String, -} - -/// 获取最新版本请求 -#[derive(Debug, Deserialize, Serialize)] -pub struct GetLatestVersionRequest { - pub resource_name: String, - pub platform: String, -} - -/// 查询版本列表请求 -#[derive(Debug, Deserialize, Serialize)] -pub struct ListVersionsRequest { - pub params: QueryVersionParams, - pub page_num: Option, - pub page_size: Option, -} - -/// 更新版本请求(含 id) -#[derive(Debug, Deserialize, Serialize)] -pub struct UpdateVersionHandleRequest { - pub id: i32, - pub name: Option, - pub notes: Option, - pub platform: Option, - pub url: Option, - pub hash: Option, - pub signature: Option, - pub install_path: Option, - pub file_size: Option, - pub status: Option, - pub is_latest: Option, -} - -/// 删除版本请求 -#[derive(Debug, Deserialize, Serialize)] -pub struct DeleteVersionRequest { - pub id: i32, -} - -/// 设置最新版本请求 -#[derive(Debug, Deserialize, Serialize)] -pub struct SetLatestVersionRequest { - pub type_id: i32, - pub resource_name: String, - pub version_id: i32, -} - -/// 版本回退请求 -#[derive(Debug, Deserialize, Serialize)] -pub struct RollbackVersionRequest { - pub type_id: i32, - pub resource_name: String, - pub target_version_id: i32, -} - -/// 版本对比请求 -#[derive(Debug, Deserialize, Serialize)] -pub struct CompareVersionsRequest { - pub version_id_1: i32, - pub version_id_2: i32, -} diff --git a/src-tauri/crates/business/src/lib.rs b/src-tauri/crates/business/src/lib.rs index 3957594..d8ff38e 100644 --- a/src-tauri/crates/business/src/lib.rs +++ b/src-tauri/crates/business/src/lib.rs @@ -1,5 +1,5 @@ -pub mod caches; pub mod database; +pub mod dispatcher; pub mod dto; pub mod entitys; pub mod errors; diff --git a/src-tauri/crates/business/src/models/environments.rs b/src-tauri/crates/business/src/models/environments.rs index 55c8433..5830faa 100644 --- a/src-tauri/crates/business/src/models/environments.rs +++ b/src-tauri/crates/business/src/models/environments.rs @@ -808,7 +808,6 @@ pub async fn fetch_deleted_environments_count( "(LOWER(e.name) LIKE LOWER(${}) OR CAST(e.uuid AS TEXT) LIKE LOWER(${}))", param_index, param_index )); - param_index += 1; } if !conditions.is_empty() { diff --git a/src-tauri/crates/business/src/models/maintenance.rs b/src-tauri/crates/business/src/models/maintenance.rs deleted file mode 100644 index 2ee4ac7..0000000 --- a/src-tauri/crates/business/src/models/maintenance.rs +++ /dev/null @@ -1,180 +0,0 @@ -use chrono::Utc; -use crate::database::{Db as Postgres, Pool}; - -use crate::{dto::maintenance::Maintenance, entitys::maintenance::CreateMaintenanceRequest}; - -pub async fn create_maintenance( - pool: &Pool, - request: CreateMaintenanceRequest, -) -> Result { - let now = Utc::now(); - - // 开始事务 - let mut tx = pool.begin().await?; - - // 关闭所有其他活跃的维护 - sqlx::query( - r#" - UPDATE maintenances - SET status = 'inactive', updated_at = $1 - WHERE status = 'active' - "#, - ) - .bind(now) - .execute(&mut *tx) - .await?; - - // 创建新的维护记录 - let maintenance = sqlx::query_as::<_, Maintenance>( - r#" - INSERT INTO maintenances (name, description, status, start_time, end_time, maintenance_type, created_at, updated_at) - VALUES ($1, $2, $3, $4, $5, $6, $7, $8) - RETURNING id, name, description, status, start_time, end_time, maintenance_type, created_at, updated_at - "# - ) - .bind(request.name) - .bind(request.description) - .bind(request.status) - .bind(request.start_time) - .bind(request.end_time) - .bind(String::from(request.maintenance_type)) - .bind(now) - .bind(now) - .fetch_one(&mut *tx) - .await?; - - // 提交事务 - tx.commit().await?; - - Ok(maintenance) -} - -pub async fn list_maintenances( - pool: &Pool, - limit: Option, - offset: Option, -) -> Result, sqlx::Error> { - let limit = limit.unwrap_or(50); - let offset = offset.unwrap_or(0); - - let maintenances = sqlx::query_as::<_, Maintenance>( - r#" - SELECT id, name, description, status, start_time, end_time, maintenance_type, - created_at, updated_at - FROM maintenances - ORDER BY created_at DESC - LIMIT $1 OFFSET $2 - "#, - ) - .bind(limit) - .bind(offset) - .fetch_all(pool) - .await?; - - Ok(maintenances) -} - -/// 结束维护 -pub async fn end_maintenance(pool: &Pool) -> Result { - let now = Utc::now(); - - // 关闭所有其他活跃的维护 - sqlx::query( - r#" - UPDATE maintenances - SET status = 'inactive', updated_at = $1 - WHERE status = 'active' - "#, - ) - .bind(now) - .execute(pool) - .await?; - - Ok(true) -} - -pub async fn update_maintenance_status( - pool: &Pool, - id: i64, - status: &str, -) -> Result { - let now = Utc::now(); - let result = sqlx::query( - r#" - UPDATE maintenances - SET status = $1, updated_at = $2 - WHERE id = $3 - "#, - ) - .bind(status) - .bind(now) - .bind(id) - .execute(pool) - .await?; - - Ok(result.rows_affected() > 0) -} - -pub async fn get_maintenance_by_id( - pool: &Pool, - id: i64, -) -> Result, anyhow::Error> { - let maintenance = sqlx::query_as::<_, Maintenance>( - r#" - SELECT id, name, description, status, start_time, end_time, maintenance_type, - created_at, updated_at - FROM maintenances - WHERE id = $1 - "#, - ) - .bind(id) - .fetch_optional(pool) - .await?; - - Ok(maintenance) -} - -pub async fn get_active_maintenances( - pool: &Pool, -) -> Result, anyhow::Error> { - let maintenance = sqlx::query_as::<_, Maintenance>( - r#" - SELECT id, name, description, status, start_time, end_time, maintenance_type, - created_at, updated_at - FROM maintenances - WHERE status = 'active' AND start_time <= CURRENT_TIMESTAMP AND end_time >= CURRENT_TIMESTAMP - ORDER BY created_at DESC - LIMIT 1 - "#, - ) - .fetch_optional(pool) - .await?; - - Ok(maintenance) -} - -pub async fn create_maintenances_table(pool: &Pool) -> Result<(), sqlx::Error> { - sqlx::query( - r#" - CREATE TABLE IF NOT EXISTS maintenances ( - id BIGSERIAL PRIMARY KEY, - name VARCHAR(100) NOT NULL, - description TEXT, - status VARCHAR(20) NOT NULL, - start_time TIMESTAMPTZ NOT NULL, - end_time TIMESTAMPTZ NOT NULL, - maintenance_type VARCHAR(20) NOT NULL, - created_at TEXT DEFAULT CURRENT_TIMESTAMP NOT NULL, - updated_at TEXT DEFAULT CURRENT_TIMESTAMP NOT NULL - ); - - CREATE INDEX IF NOT EXISTS idx_maintenances_status ON maintenances(status); - CREATE INDEX IF NOT EXISTS idx_maintenances_time ON maintenances(start_time, end_time); - CREATE INDEX IF NOT EXISTS idx_maintenances_type ON maintenances(maintenance_type); - "#, - ) - .execute(pool) - .await?; - - Ok(()) -} diff --git a/src-tauri/crates/business/src/models/mod.rs b/src-tauri/crates/business/src/models/mod.rs index 050a982..3c66051 100644 --- a/src-tauri/crates/business/src/models/mod.rs +++ b/src-tauri/crates/business/src/models/mod.rs @@ -1,8 +1,4 @@ -pub mod maintenance; -pub mod strategy_types; -pub mod user; -pub mod version_types; -pub mod versions; +pub mod user; // 新增模块 pub mod accounts; @@ -19,11 +15,7 @@ pub mod teams; pub mod workspace_quotas; pub mod workspaces; -pub use maintenance::*; -pub use strategy_types::*; -pub use user::*; -pub use version_types::*; -pub use versions::*; +pub use user::*; // 新增导出 pub use accounts::*; diff --git a/src-tauri/crates/business/src/models/proxies.rs b/src-tauri/crates/business/src/models/proxies.rs index a30e78d..13b31f2 100644 --- a/src-tauri/crates/business/src/models/proxies.rs +++ b/src-tauri/crates/business/src/models/proxies.rs @@ -65,7 +65,7 @@ pub async fn fetch_proxies( AND ($3 IS NULL OR p.status = $3) AND p.deleted_at IS NULL ORDER BY p.created_at DESC - LIMIT $5 OFFSET $6 + LIMIT $4 OFFSET $5 "#, ) .bind(workspace_uuid) diff --git a/src-tauri/crates/business/src/models/proxy_visible_teams.rs b/src-tauri/crates/business/src/models/proxy_visible_teams.rs index 92f2e9d..6034762 100644 --- a/src-tauri/crates/business/src/models/proxy_visible_teams.rs +++ b/src-tauri/crates/business/src/models/proxy_visible_teams.rs @@ -215,7 +215,7 @@ pub async fn fetch_visible_proxies_for_user_paginated( ) ) ORDER BY p.created_at DESC - OFFSET $8 LIMIT $9 + LIMIT $8 OFFSET $9 "#, ) .bind(workspace_uuid) @@ -225,8 +225,8 @@ pub async fn fetch_visible_proxies_for_user_paginated( .bind(proxy_type) .bind(status) .bind(country) - .bind(offset) .bind(limit) + .bind(offset) .fetch_all(pool) .await?; diff --git a/src-tauri/crates/business/src/models/strategy_types.rs b/src-tauri/crates/business/src/models/strategy_types.rs deleted file mode 100644 index c9c4cbc..0000000 --- a/src-tauri/crates/business/src/models/strategy_types.rs +++ /dev/null @@ -1,58 +0,0 @@ -use crate::dto::strategy_types::StrategyType; -use sqlx::Error; - -use crate::database::{Db as Postgres, Pool}; - -/// 根据ID查询策略类型 -pub async fn query_strategy_type_by_id( - pool: &Pool, - id: i32, -) -> Result { - let strategy_type: StrategyType = sqlx::query_as("SELECT * FROM strategy_types WHERE id = $1") - .bind(id) - .fetch_one(pool) - .await?; - - Ok(strategy_type) -} - -/// 根据code查询策略类型 -pub async fn query_strategy_type_by_code( - pool: &Pool, - code: &str, -) -> Result { - let strategy_type: StrategyType = - sqlx::query_as("SELECT * FROM strategy_types WHERE code = $1") - .bind(code) - .fetch_one(pool) - .await?; - - Ok(strategy_type) -} - -/// 查询所有可用的策略类型(只查询激活的) -pub async fn query_available_strategy_types( - pool: &Pool, -) -> Result, Error> { - let strategy_types: Vec = - sqlx::query_as("SELECT * FROM strategy_types WHERE is_active = true ORDER BY code") - .fetch_all(pool) - .await?; - - Ok(strategy_types) -} - -/// 根据分类查询策略类型 -pub async fn query_strategy_types_by_category( - pool: &Pool, - category: &str, -) -> Result, Error> { - let strategy_types: Vec = sqlx::query_as( - "SELECT * FROM strategy_types WHERE category = $1 AND is_active = true ORDER BY code", - ) - .bind(category) - .fetch_all(pool) - .await?; - - Ok(strategy_types) -} diff --git a/src-tauri/crates/business/src/models/version_types.rs b/src-tauri/crates/business/src/models/version_types.rs deleted file mode 100644 index 418819b..0000000 --- a/src-tauri/crates/business/src/models/version_types.rs +++ /dev/null @@ -1,126 +0,0 @@ -use crate::dto::version_types::VersionType; -use crate::entitys::version_types::{CreateVersionTypeRequest, UpdateVersionTypeRequest}; -use sqlx::Error; - -use crate::database::{Db as Postgres, Pool}; - -/// 插入新版本类型 -pub async fn insert_version_type( - pool: &Pool, - request: &CreateVersionTypeRequest, -) -> Result { - let sql = " - INSERT INTO version_types ( - type_code, type_name, description, sort_order, is_active - ) VALUES ( - $1, $2, $3, $4, true - ) RETURNING id - "; - - let result: (i32,) = sqlx::query_as(sql) - .bind(&request.type_code) - .bind(&request.type_name) - .bind(&request.description) - .bind(request.sort_order.unwrap_or(0)) - .fetch_one(pool) - .await?; - - Ok(result.0) -} - -/// 根据ID查询版本类型 -pub async fn query_version_type_by_id( - pool: &Pool, - id: i32, -) -> Result { - let version_type: VersionType = sqlx::query_as("SELECT * FROM version_types WHERE id = $1") - .bind(id) - .fetch_one(pool) - .await?; - - Ok(version_type) -} - -/// 根据代码查询版本类型 -pub async fn query_version_type_by_code( - pool: &Pool, - type_code: &str, -) -> Result { - let version_type: VersionType = - sqlx::query_as("SELECT * FROM version_types WHERE type_code = $1") - .bind(type_code) - .fetch_one(pool) - .await?; - - Ok(version_type) -} - -/// 查询所有版本类型 -pub async fn query_all_version_types(pool: &Pool) -> Result, Error> { - let version_types: Vec = - sqlx::query_as("SELECT * FROM version_types ORDER BY sort_order ASC, id ASC") - .fetch_all(pool) - .await?; - - Ok(version_types) -} - -/// 查询激活的版本类型 -pub async fn query_active_version_types(pool: &Pool) -> Result, Error> { - let version_types: Vec = sqlx::query_as( - "SELECT * FROM version_types WHERE is_active = true ORDER BY sort_order ASC, id ASC", - ) - .fetch_all(pool) - .await?; - - Ok(version_types) -} - -/// 更新版本类型 -pub async fn update_version_type( - pool: &Pool, - id: i32, - request: &UpdateVersionTypeRequest, -) -> Result { - let sql = " - UPDATE version_types SET - type_name = COALESCE($1, type_name), - description = COALESCE($2, description), - sort_order = COALESCE($3, sort_order), - is_active = COALESCE($4, is_active) - WHERE id = $5 - "; - - let row = sqlx::query(sql) - .bind(&request.type_name) - .bind(&request.description) - .bind(request.sort_order) - .bind(request.is_active) - .bind(id) - .execute(pool) - .await?; - - Ok(row.rows_affected() == 1) -} - -/// 删除版本类型 -pub async fn delete_version_type(pool: &Pool, id: i32) -> Result { - let sql = "DELETE FROM version_types WHERE id = $1"; - let row = sqlx::query(sql).bind(id).execute(pool).await?; - Ok(row.rows_affected() == 1) -} - -/// 激活/停用版本类型 -pub async fn toggle_version_type_status( - pool: &Pool, - id: i32, - is_active: bool, -) -> Result { - let sql = "UPDATE version_types SET is_active = $1 WHERE id = $2"; - let row = sqlx::query(sql) - .bind(is_active) - .bind(id) - .execute(pool) - .await?; - Ok(row.rows_affected() == 1) -} diff --git a/src-tauri/crates/business/src/models/versions.rs b/src-tauri/crates/business/src/models/versions.rs deleted file mode 100644 index 2646196..0000000 --- a/src-tauri/crates/business/src/models/versions.rs +++ /dev/null @@ -1,551 +0,0 @@ -use crate::dto::versions::Version; -use crate::entitys::versions::{CreateVersionRequest, UpdateVersionRequest}; -use chrono::{DateTime, Utc}; -use sqlx::Error; - -use crate::database::{Db as Postgres, Pool}; - -/// 插入新版本 -pub async fn insert_version( - pool: &Pool, - request: &CreateVersionRequest, -) -> Result { - let sql = " - INSERT INTO versions ( - type_id, resource_name, version, name, notes, platform, url, hash, - signature, install_path, file_size, pub_date, - arch, package_format, requires_extract, created_at - ) VALUES ( - $1, $2, $3, $4, $5, $6, $7, $8, $9, $10, $11, $12, - $13, $14, $15, CURRENT_TIMESTAMP - ) RETURNING id - "; - - let result: (i32,) = sqlx::query_as(sql) - .bind(request.type_id) - .bind(&request.resource_name) - .bind(&request.version) - .bind(&request.name) - .bind(&request.notes) - .bind(&request.platform) - .bind(&request.url) - .bind(&request.hash) - .bind(&request.signature) - .bind(&request.install_path) - .bind(request.file_size) - .bind(request.pub_date) - .bind(&request.arch) - .bind(&request.package_format) - .bind(request.requires_extract.unwrap_or(true)) - .fetch_one(pool) - .await?; - - Ok(result.0) -} - -/// 根据ID查询版本 -pub async fn query_version_by_id(pool: &Pool, id: i32) -> Result { - let version: Version = sqlx::query_as("SELECT * FROM versions WHERE id = $1") - .bind(id) - .fetch_one(pool) - .await?; - - Ok(version) -} - -/// 根据资源名称和版本号查询 -pub async fn query_version_by_name_and_version( - pool: &Pool, - resource_name: &str, - version: &str, -) -> Result { - let version_data: Version = - sqlx::query_as("SELECT * FROM versions WHERE resource_name = $1 AND version = $2") - .bind(resource_name) - .bind(version) - .fetch_one(pool) - .await?; - - Ok(version_data) -} - -/// 查询最新版本 -pub async fn query_latest_version( - pool: &Pool, - resource_name: &str, - platform: &str, -) -> Result, Error> { - let version: Option = sqlx::query_as( - "SELECT * FROM versions - WHERE resource_name = $1 AND platform = $2 AND status = 'active' - ORDER BY pub_date DESC LIMIT 1", - ) - .bind(resource_name) - .bind(platform) - .fetch_optional(pool) - .await?; - - Ok(version) -} - -/// 查询版本列表(分页) -pub async fn query_versions( - pool: &Pool, - resource_name: Option<&str>, - platform: Option<&str>, - status: Option<&str>, - page_num: i32, - page_size: i32, -) -> Result<(i64, Vec), Error> { - // 构建查询条件 - let mut where_clauses = vec!["deleted_at IS NULL".to_string()]; - let mut param_index = 1; - - if let Some(_name) = resource_name { - where_clauses.push(format!("resource_name = ${}", param_index)); - param_index += 1; - } - if let Some(_plat) = platform { - where_clauses.push(format!("platform = ${}", param_index)); - param_index += 1; - } - if let Some(_st) = status { - where_clauses.push(format!("status = ${}", param_index)); - param_index += 1; - } - - let where_sql = where_clauses.join(" AND "); - - // 获取总数 - let count_sql = format!("SELECT COUNT(*) FROM versions WHERE {}", where_sql); - let count_result: (i64,) = match (resource_name, platform, status) { - (Some(n), Some(p), Some(s)) => { - sqlx::query_as(&count_sql) - .bind(n) - .bind(p) - .bind(s) - .fetch_one(pool) - .await? - } - (Some(n), Some(p), None) => { - sqlx::query_as(&count_sql) - .bind(n) - .bind(p) - .fetch_one(pool) - .await? - } - (Some(n), None, Some(s)) => { - sqlx::query_as(&count_sql) - .bind(n) - .bind(s) - .fetch_one(pool) - .await? - } - (Some(n), None, None) => sqlx::query_as(&count_sql).bind(n).fetch_one(pool).await?, - (None, Some(p), Some(s)) => { - sqlx::query_as(&count_sql) - .bind(p) - .bind(s) - .fetch_one(pool) - .await? - } - (None, Some(p), None) => sqlx::query_as(&count_sql).bind(p).fetch_one(pool).await?, - (None, None, Some(s)) => sqlx::query_as(&count_sql).bind(s).fetch_one(pool).await?, - (None, None, None) => sqlx::query_as(&count_sql).fetch_one(pool).await?, - }; - - // 获取分页列表 - let list_sql = format!( - "SELECT * FROM versions WHERE {} ORDER BY created_at DESC LIMIT ${} OFFSET ${}", - where_sql, - param_index, - param_index + 1 - ); - let versions: Vec = match (resource_name, platform, status) { - (Some(n), Some(p), Some(s)) => { - sqlx::query_as(&list_sql) - .bind(n) - .bind(p) - .bind(s) - .bind(page_size) - .bind((page_num - 1) * page_size) - .fetch_all(pool) - .await? - } - (Some(n), Some(p), None) => { - sqlx::query_as(&list_sql) - .bind(n) - .bind(p) - .bind(page_size) - .bind((page_num - 1) * page_size) - .fetch_all(pool) - .await? - } - (Some(n), None, Some(s)) => { - sqlx::query_as(&list_sql) - .bind(n) - .bind(s) - .bind(page_size) - .bind((page_num - 1) * page_size) - .fetch_all(pool) - .await? - } - (Some(n), None, None) => { - sqlx::query_as(&list_sql) - .bind(n) - .bind(page_size) - .bind((page_num - 1) * page_size) - .fetch_all(pool) - .await? - } - (None, Some(p), Some(s)) => { - sqlx::query_as(&list_sql) - .bind(p) - .bind(s) - .bind(page_size) - .bind((page_num - 1) * page_size) - .fetch_all(pool) - .await? - } - (None, Some(p), None) => { - sqlx::query_as(&list_sql) - .bind(p) - .bind(page_size) - .bind((page_num - 1) * page_size) - .fetch_all(pool) - .await? - } - (None, None, Some(s)) => { - sqlx::query_as(&list_sql) - .bind(s) - .bind(page_size) - .bind((page_num - 1) * page_size) - .fetch_all(pool) - .await? - } - (None, None, None) => { - sqlx::query_as(&list_sql) - .bind(page_size) - .bind((page_num - 1) * page_size) - .fetch_all(pool) - .await? - } - }; - - Ok((count_result.0, versions)) -} - -/// 更新版本 -pub async fn update_version( - pool: &Pool, - id: i32, - request: &UpdateVersionRequest, -) -> Result { - let sql = " - UPDATE versions SET - name = COALESCE($1, name), - notes = COALESCE($2, notes), - platform = COALESCE($3, platform), - url = COALESCE($4, url), - hash = COALESCE($5, hash), - signature = COALESCE($6, signature), - install_path = COALESCE($7, install_path), - file_size = COALESCE($8, file_size), - status = COALESCE($9, status), - is_latest = COALESCE($10, is_latest), - updated_at = CURRENT_TIMESTAMP - WHERE id = $11 - "; - - let row = sqlx::query(sql) - .bind(&request.name) - .bind(&request.notes) - .bind(&request.platform) - .bind(&request.url) - .bind(&request.hash) - .bind(&request.signature) - .bind(&request.install_path) - .bind(request.file_size) - .bind(&request.status) - .bind(request.is_latest) - .bind(id) - .execute(pool) - .await?; - - Ok(row.rows_affected() == 1) -} - -/// 软删除版本 -pub async fn delete_version(pool: &Pool, id: i32) -> Result { - // 非逻辑删除 - let sql = "DELETE FROM versions WHERE id = $1"; - let row = sqlx::query(sql).bind(id).execute(pool).await?; - Ok(row.rows_affected() == 1) -} - -/// 设置某个资源为最新版本 -pub async fn set_as_latest_version( - pool: &Pool, - type_id: i32, - resource_name: &str, - version_id: i32, -) -> Result { - let mut tx = pool.begin().await?; - - // 先取消其他版本的最新状态 - sqlx::query( - "UPDATE versions SET is_latest = false, updated_at = CURRENT_TIMESTAMP - WHERE type_id = $1 AND resource_name = $2 AND id != $3", - ) - .bind(type_id) - .bind(resource_name) - .bind(version_id) - .execute(&mut *tx) - .await?; - - // 设置当前版本为最新 - let row = sqlx::query("UPDATE versions SET is_latest = true, updated_at = CURRENT_TIMESTAMP WHERE id = $1") - .bind(version_id) - .execute(&mut *tx) - .await?; - - tx.commit().await?; - Ok(row.rows_affected() == 1) -} - -/// 查询所有激活版本类型对应平台的最新版本 -/// 使用 is_latest 字段优先,按 (type_code, resource_name) 分组 -/// 返回 (type_code, resource_name, Version) 元组列表 -pub async fn query_all_active_latest_versions( - pool: &Pool, - platform: &str, -) -> Result, Error> { - // 自定义结构体用于接收查询结果 - #[derive(sqlx::FromRow)] - struct VersionWithTypeCode { - type_code: String, - // Version 的所有字段 - id: i32, - type_id: i32, - resource_name: String, - version: String, - name: Option, - notes: Option, - platform: Option, - url: Option, - hash: Option, - signature: Option, - install_path: Option, - file_size: Option, - is_latest: bool, - status: String, - pub_date: Option>, - created_at: DateTime, - updated_at: Option>, - deleted_at: Option>, - arch: Option, - package_format: Option, - requires_extract: bool, - entrypoint_template: Option, - extract_root: Option, - } - - let results: Vec = sqlx::query_as( - r#" - SELECT DISTINCT ON (vt.type_code, v.resource_name) - vt.type_code, - v.id, v.type_id, v.resource_name, v.version, v.name, v.notes, - v.platform, v.url, v.hash, v.signature, v.install_path, - v.file_size, v.is_latest, v.status, v.pub_date, - v.created_at, v.updated_at, v.deleted_at, - v.arch, v.package_format, v.requires_extract, v.entrypoint_template, v.extract_root - FROM versions v - INNER JOIN version_types vt ON v.type_id = vt.id - WHERE vt.is_active = true - AND v.platform = $1 - AND v.status = 'active' - AND v.deleted_at IS NULL - ORDER BY vt.type_code, v.resource_name, v.is_latest DESC, v.id DESC - "#, - ) - .bind(platform) - .fetch_all(pool) - .await?; - - // 转换为 (String, String, Version) 元组 - let converted: Vec<(String, String, Version)> = results - .into_iter() - .map(|r| { - ( - r.type_code.clone(), - r.resource_name.clone(), - Version { - id: r.id, - type_id: r.type_id, - resource_name: r.resource_name, - version: r.version, - name: r.name.clone(), - notes: r.notes, - platform: r.platform, - url: r.url, - hash: r.hash, - signature: r.signature, - install_path: r.install_path, - file_size: r.file_size, - is_latest: r.is_latest, - status: r.status, - pub_date: r.pub_date, - created_at: r.created_at, - updated_at: r.updated_at, - deleted_at: r.deleted_at, - arch: r.arch, - package_format: r.package_format, - requires_extract: r.requires_extract, - entrypoint_template: r.entrypoint_template, - extract_root: r.extract_root, - }, - ) - }) - .collect(); - - Ok(converted) -} - -/// 查询浏览器内核类型的最新版本列表 -/// type_code_filter 为 None 时筛选 type_code LIKE 'SIMPRINT_KERNEL_%',为 Some 时精确匹配 -/// 按 (type_code, resource_name) 分组,每组取最新版本 -/// 最新判定:is_latest 优先,其次 COALESCE(pub_date, created_at),最后 id -/// platform 为可选,None 表示不按平台过滤 -pub async fn query_browser_kernel_latest_versions( - pool: &Pool, - platform: Option<&str>, - type_code_filter: Option<&str>, -) -> Result, Error> { - #[derive(sqlx::FromRow)] - struct VersionWithTypeCode { - type_code: String, - id: i32, - type_id: i32, - resource_name: String, - version: String, - name: Option, - notes: Option, - platform: Option, - url: Option, - hash: Option, - signature: Option, - install_path: Option, - file_size: Option, - is_latest: bool, - status: String, - pub_date: Option>, - created_at: DateTime, - updated_at: Option>, - deleted_at: Option>, - arch: Option, - package_format: Option, - requires_extract: bool, - entrypoint_template: Option, - extract_root: Option, - } - - const SELECT_AND_ORDER: &str = r#" - SELECT DISTINCT ON (vt.type_code, v.resource_name) - vt.type_code, - v.id, v.type_id, v.resource_name, v.version, v.name, v.notes, - v.platform, v.url, v.hash, v.signature, v.install_path, - v.file_size, v.is_latest, v.status, v.pub_date, - v.created_at, v.updated_at, v.deleted_at, - v.arch, v.package_format, v.requires_extract, v.entrypoint_template, v.extract_root - FROM versions v - INNER JOIN version_types vt ON v.type_id = vt.id - "#; - - let results: Vec = match (platform, type_code_filter) { - (Some(plat), Some(tc)) => { - sqlx::query_as(&format!( - "{} WHERE vt.type_code = $1 AND vt.is_active = true AND v.platform = $2 \ - AND v.status = 'active' AND v.deleted_at IS NULL \ - ORDER BY vt.type_code, v.resource_name, v.is_latest DESC, \ - COALESCE(v.pub_date, v.created_at) DESC NULLS LAST, v.id DESC", - SELECT_AND_ORDER - )) - .bind(tc) - .bind(plat) - .fetch_all(pool) - .await? - } - (Some(plat), None) => { - sqlx::query_as(&format!( - "{} WHERE vt.type_code LIKE 'SIMPRINT_KERNEL_%' AND vt.is_active = true \ - AND v.platform = $1 AND v.status = 'active' AND v.deleted_at IS NULL \ - ORDER BY vt.type_code, v.resource_name, v.is_latest DESC, \ - COALESCE(v.pub_date, v.created_at) DESC NULLS LAST, v.id DESC", - SELECT_AND_ORDER - )) - .bind(plat) - .fetch_all(pool) - .await? - } - (None, Some(tc)) => { - sqlx::query_as(&format!( - "{} WHERE vt.type_code = $1 AND vt.is_active = true \ - AND v.status = 'active' AND v.deleted_at IS NULL \ - ORDER BY vt.type_code, v.resource_name, v.is_latest DESC, \ - COALESCE(v.pub_date, v.created_at) DESC NULLS LAST, v.id DESC", - SELECT_AND_ORDER - )) - .bind(tc) - .fetch_all(pool) - .await? - } - (None, None) => { - sqlx::query_as(&format!( - "{} WHERE vt.type_code LIKE 'SIMPRINT_KERNEL_%' AND vt.is_active = true \ - AND v.status = 'active' AND v.deleted_at IS NULL \ - ORDER BY vt.type_code, v.resource_name, v.is_latest DESC, \ - COALESCE(v.pub_date, v.created_at) DESC NULLS LAST, v.id DESC", - SELECT_AND_ORDER - )) - .fetch_all(pool) - .await? - } - }; - - let converted: Vec<(String, String, Version)> = results - .into_iter() - .map(|r| { - ( - r.type_code.clone(), - r.resource_name.clone(), - Version { - id: r.id, - type_id: r.type_id, - resource_name: r.resource_name, - version: r.version, - name: r.name.clone(), - notes: r.notes, - platform: r.platform, - url: r.url, - hash: r.hash, - signature: r.signature, - install_path: r.install_path, - file_size: r.file_size, - is_latest: r.is_latest, - status: r.status, - pub_date: r.pub_date, - created_at: r.created_at, - updated_at: r.updated_at, - deleted_at: r.deleted_at, - arch: r.arch, - package_format: r.package_format, - requires_extract: r.requires_extract, - entrypoint_template: r.entrypoint_template, - extract_root: r.extract_root, - }, - ) - }) - .collect(); - - Ok(converted) -} diff --git a/src-tauri/crates/business/src/services.rs b/src-tauri/crates/business/src/services.rs index 8909958..8dfe064 100644 --- a/src-tauri/crates/business/src/services.rs +++ b/src-tauri/crates/business/src/services.rs @@ -1,14 +1,8 @@ -mod maintenance; -mod strategy_types; -mod time; -pub mod users; -mod version_types; -mod versions; +mod time; // 新增模块 pub mod accounts; pub mod audit; -pub mod browser_kernel; pub mod environments; pub mod group_permissions; pub mod groups; @@ -24,17 +18,11 @@ pub mod templates; pub mod workspace_quotas; pub mod workspaces; -pub use maintenance::*; -pub use strategy_types::*; -pub use time::*; -pub use users::*; -pub use version_types::*; -pub use versions::*; +pub use time::*; // 新增导出 pub use accounts::*; pub use audit::*; -pub use browser_kernel::*; pub use environments::*; pub use group_permissions::*; pub use groups::*; diff --git a/src-tauri/crates/business/src/services/audit.rs b/src-tauri/crates/business/src/services/audit.rs index 4284829..7c960fb 100644 --- a/src-tauri/crates/business/src/services/audit.rs +++ b/src-tauri/crates/business/src/services/audit.rs @@ -1,183 +1,183 @@ -use chrono::Datelike; -use uuid::Uuid; - -use crate::dto::AuditLogDto; -use crate::entitys::{ - ActionCount, AuditStatsResponse, ExportAuditLogsRequest, ListAuditLogsRequest, TargetTypeCount, -}; -use crate::models; -use crate::state::RequestContext; -use crate::svc_ctx::SvcCtx; - -/// 审计日志宏 - 简化审计日志记录 -/// -/// # 用法 -/// ```rust -/// // 基础用法: action, target_type, detail -/// audit_log!(svc_ctx, ctx, "login", "user", "用户登录"); -/// -/// // 带目标 UUID -/// audit_log!(svc_ctx, ctx, "delete", "environment", env_uuid, "删除环境"); -/// -/// // 带目标 UUID 和名称 -/// audit_log!(svc_ctx, ctx, "delete", "environment", env_uuid, "生产环境", "删除环境"); -/// ``` -#[macro_export] -macro_rules! audit_log { - // 基础: action, target_type, detail - ($svc_ctx:expr, $ctx:expr, $action:expr, $target_type:expr, $detail:expr) => { - $crate::services::audit::log_audit( - $svc_ctx, - $ctx, - $action, - $target_type, - None, - None, - $detail, - ) - }; - // 带目标 UUID: action, target_type, target_uuid, detail - ($svc_ctx:expr, $ctx:expr, $action:expr, $target_type:expr, $target_uuid:expr, $detail:expr) => { - $crate::services::audit::log_audit( - $svc_ctx, - $ctx, - $action, - $target_type, - Some($target_uuid), - None, - $detail, - ) - }; - // 完整: action, target_type, target_uuid, target_name, detail - ($svc_ctx:expr, $ctx:expr, $action:expr, $target_type:expr, $target_uuid:expr, $target_name:expr, $detail:expr) => { - $crate::services::audit::log_audit( - $svc_ctx, - $ctx, - $action, - $target_type, - Some($target_uuid), - Some($target_name), - $detail, - ) - }; -} - -/// 记录审计日志(宏的内部实现) -/// -/// 自动从 RequestContext 中提取 user_uuid、team_uuid、ip_address -pub async fn log_audit( - svc_ctx: &SvcCtx, - ctx: &RequestContext, - action: &str, - target_type: &str, - target_uuid: Option, - target_name: Option<&str>, - detail: &str, -) { - // 从 ctx 中提取数据 - let user_uuid = match ctx.user_uuid() { - Some(uuid) => uuid, - None => { - tracing::warn!("audit_log: user_uuid is None, skipping audit log"); - return; - } - }; - - let team_uuid = ctx.current_team_uuid; - let ip_address = ctx.ip(); - - // 异步记录,忽略错误(审计失败不应影响业务) - if let Err(e) = log_action_service( - svc_ctx, - user_uuid, - team_uuid, - action, - target_type, - target_uuid, - target_name, - Some(detail), - None, // changes - ip_address, - None, // user_agent - None, // request_id - ) - .await - { - tracing::error!("audit_log failed: {}", e); - } -} - -/// 记录审计日志(允许无用户,用于登录/注册等场景) -pub async fn log_audit_anonymous( - svc_ctx: &SvcCtx, - ctx: &RequestContext, - user_uuid: Uuid, - action: &str, - target_type: &str, - detail: &str, -) { - let ip_address = ctx.ip(); - - if let Err(e) = log_action_service( - svc_ctx, - user_uuid, - None, - action, - target_type, - Some(user_uuid), - None, - Some(detail), - None, - ip_address, - None, - None, - ) - .await - { - tracing::error!("audit_log_anonymous failed: {}", e); - } -} - -/// 记录审计日志 -pub async fn log_action_service( - svc_ctx: &SvcCtx, - user_uuid: Uuid, - team_uuid: Option, - action: &str, - target_type: &str, - target_uuid: Option, - target_name: Option<&str>, - details: Option<&str>, - changes: Option<&serde_json::Value>, - ip_address: Option<&str>, - user_agent: Option<&str>, - request_id: Option<&str>, -) -> Result { - models::insert_audit_log( - &svc_ctx.db, - user_uuid, - team_uuid, - action, - target_type, - target_uuid, - target_name, - details, - changes, - ip_address, - user_agent, - request_id, - ) - .await - .map_err(|e| e.to_string()) -} - -/// 获取审计日志列表 -pub async fn get_audit_logs_service( - svc_ctx: &SvcCtx, - current_user_uuid: Uuid, - team_uuid: Option, - payload: &ListAuditLogsRequest, -) -> Result<(Vec, i64), String> { +use chrono::Datelike; +use uuid::Uuid; + +use crate::dto::AuditLogDto; +use crate::entitys::{ + ActionCount, AuditStatsResponse, ExportAuditLogsRequest, ListAuditLogsRequest, TargetTypeCount, +}; +use crate::models; +use crate::state::RequestContext; +use crate::svc_ctx::SvcCtx; + +/// 审计日志宏 - 简化审计日志记录 +/// +/// # 用法 +/// ```ignore +/// // 基础用法: action, target_type, detail +/// audit_log!(svc_ctx, ctx, "login", "user", "用户登录"); +/// +/// // 带目标 UUID +/// audit_log!(svc_ctx, ctx, "delete", "environment", env_uuid, "删除环境"); +/// +/// // 带目标 UUID 和名称 +/// audit_log!(svc_ctx, ctx, "delete", "environment", env_uuid, "生产环境", "删除环境"); +/// ``` +#[macro_export] +macro_rules! audit_log { + // 基础: action, target_type, detail + ($svc_ctx:expr, $ctx:expr, $action:expr, $target_type:expr, $detail:expr) => { + $crate::services::audit::log_audit( + $svc_ctx, + $ctx, + $action, + $target_type, + None, + None, + $detail, + ) + }; + // 带目标 UUID: action, target_type, target_uuid, detail + ($svc_ctx:expr, $ctx:expr, $action:expr, $target_type:expr, $target_uuid:expr, $detail:expr) => { + $crate::services::audit::log_audit( + $svc_ctx, + $ctx, + $action, + $target_type, + Some($target_uuid), + None, + $detail, + ) + }; + // 完整: action, target_type, target_uuid, target_name, detail + ($svc_ctx:expr, $ctx:expr, $action:expr, $target_type:expr, $target_uuid:expr, $target_name:expr, $detail:expr) => { + $crate::services::audit::log_audit( + $svc_ctx, + $ctx, + $action, + $target_type, + Some($target_uuid), + Some($target_name), + $detail, + ) + }; +} + +/// 记录审计日志(宏的内部实现) +/// +/// 自动从 RequestContext 中提取 user_uuid、team_uuid、ip_address +pub async fn log_audit( + svc_ctx: &SvcCtx, + ctx: &RequestContext, + action: &str, + target_type: &str, + target_uuid: Option, + target_name: Option<&str>, + detail: &str, +) { + // 从 ctx 中提取数据 + let user_uuid = match ctx.user_uuid() { + Some(uuid) => uuid, + None => { + tracing::warn!("audit_log: user_uuid is None, skipping audit log"); + return; + } + }; + + let team_uuid = ctx.current_team_uuid; + let ip_address = ctx.ip(); + + // 异步记录,忽略错误(审计失败不应影响业务) + if let Err(e) = log_action_service( + svc_ctx, + user_uuid, + team_uuid, + action, + target_type, + target_uuid, + target_name, + Some(detail), + None, // changes + ip_address, + None, // user_agent + None, // request_id + ) + .await + { + tracing::error!("audit_log failed: {}", e); + } +} + +/// 记录审计日志(允许无用户,用于登录/注册等场景) +pub async fn log_audit_anonymous( + svc_ctx: &SvcCtx, + ctx: &RequestContext, + user_uuid: Uuid, + action: &str, + target_type: &str, + detail: &str, +) { + let ip_address = ctx.ip(); + + if let Err(e) = log_action_service( + svc_ctx, + user_uuid, + None, + action, + target_type, + Some(user_uuid), + None, + Some(detail), + None, + ip_address, + None, + None, + ) + .await + { + tracing::error!("audit_log_anonymous failed: {}", e); + } +} + +/// 记录审计日志 +pub async fn log_action_service( + svc_ctx: &SvcCtx, + user_uuid: Uuid, + team_uuid: Option, + action: &str, + target_type: &str, + target_uuid: Option, + target_name: Option<&str>, + details: Option<&str>, + changes: Option<&serde_json::Value>, + ip_address: Option<&str>, + user_agent: Option<&str>, + request_id: Option<&str>, +) -> Result { + models::insert_audit_log( + &svc_ctx.db, + user_uuid, + team_uuid, + action, + target_type, + target_uuid, + target_name, + details, + changes, + ip_address, + user_agent, + request_id, + ) + .await + .map_err(|e| e.to_string()) +} + +/// 获取审计日志列表 +pub async fn get_audit_logs_service( + svc_ctx: &SvcCtx, + current_user_uuid: Uuid, + team_uuid: Option, + payload: &ListAuditLogsRequest, +) -> Result<(Vec, i64), String> { let offset = (payload.pagination.page - 1) * payload.pagination.page_size; let user_uuid_filter = payload.filters.as_ref().and_then(|f| f.user_uuid); @@ -195,11 +195,11 @@ pub async fn get_audit_logs_service( target_type, offset, payload.pagination.page_size, - ) - .await - .map_err(|e| e.to_string())?; - - let total = models::fetch_audit_logs_count( + ) + .await + .map_err(|e| e.to_string())?; + + let total = models::fetch_audit_logs_count( &svc_ctx.db, current_user_uuid, team_uuid, @@ -208,145 +208,144 @@ pub async fn get_audit_logs_service( action, target_type, ) - .await - .map_err(|e| e.to_string())?; - - Ok((logs, total)) -} - -/// 获取审计日志详情 -pub async fn get_audit_log_service( - svc_ctx: &SvcCtx, - log_uuid: Uuid, -) -> Result { - models::fetch_audit_log_by_uuid(&svc_ctx.db, log_uuid) - .await - .map_err(|e| e.to_string())? - .ok_or_else(|| "审计日志不存在".to_string()) -} - -/// 获取审计统计 -pub async fn get_audit_stats_service( - svc_ctx: &SvcCtx, - current_user_uuid: Uuid, - team_uuid: Option, -) -> Result { - // 总数 - let total_logs = - models::fetch_audit_logs_count( - &svc_ctx.db, - current_user_uuid, - team_uuid, - None, - None, - None, - None, - ) + .await + .map_err(|e| e.to_string())?; + + Ok((logs, total)) +} + +/// 获取审计日志详情 +pub async fn get_audit_log_service( + svc_ctx: &SvcCtx, + log_uuid: Uuid, +) -> Result { + models::fetch_audit_log_by_uuid(&svc_ctx.db, log_uuid) + .await + .map_err(|e| e.to_string())? + .ok_or_else(|| "审计日志不存在".to_string()) +} + +/// 获取审计统计 +pub async fn get_audit_stats_service( + svc_ctx: &SvcCtx, + current_user_uuid: Uuid, + team_uuid: Option, +) -> Result { + // 总数 + let total_logs = models::fetch_audit_logs_count( + &svc_ctx.db, + current_user_uuid, + team_uuid, + None, + None, + None, + None, + ) + .await + .map_err(|e| e.to_string())?; + + // 今日数量 + let today = chrono::Utc::now().date_naive(); + let logs_today = models::fetch_audit_logs_count_by_date(&svc_ctx.db, team_uuid, today) + .await + .map_err(|e| e.to_string())?; + + // 本周数量 + let week_start = today - chrono::Duration::days(today.weekday().num_days_from_monday() as i64); + let logs_this_week = + models::fetch_audit_logs_count_since_date(&svc_ctx.db, team_uuid, week_start) + .await + .map_err(|e| e.to_string())?; + + // 本月数量 + let month_start = chrono::NaiveDate::from_ymd_opt(today.year(), today.month(), 1).unwrap(); + let logs_this_month = + models::fetch_audit_logs_count_since_date(&svc_ctx.db, team_uuid, month_start) .await .map_err(|e| e.to_string())?; - - // 今日数量 - let today = chrono::Utc::now().date_naive(); - let logs_today = models::fetch_audit_logs_count_by_date(&svc_ctx.db, team_uuid, today) - .await - .map_err(|e| e.to_string())?; - - // 本周数量 - let week_start = today - chrono::Duration::days(today.weekday().num_days_from_monday() as i64); - let logs_this_week = - models::fetch_audit_logs_count_since_date(&svc_ctx.db, team_uuid, week_start) - .await - .map_err(|e| e.to_string())?; - - // 本月数量 - let month_start = chrono::NaiveDate::from_ymd_opt(today.year(), today.month(), 1).unwrap(); - let logs_this_month = - models::fetch_audit_logs_count_since_date(&svc_ctx.db, team_uuid, month_start) - .await - .map_err(|e| e.to_string())?; - - // 热门操作(Top 5) - let top_actions_raw = models::fetch_top_actions(&svc_ctx.db, team_uuid, 5) - .await - .map_err(|e| e.to_string())?; - - // 热门目标类型(Top 5) - let top_target_types_raw = models::fetch_top_target_types(&svc_ctx.db, team_uuid, 5) - .await - .map_err(|e| e.to_string())?; - - Ok(AuditStatsResponse { - total_logs, - logs_today, - logs_this_week, - logs_this_month, - top_actions: top_actions_raw - .into_iter() - .map(|(action, count)| ActionCount { action, count }) - .collect(), - top_target_types: top_target_types_raw - .into_iter() - .map(|(target_type, count)| TargetTypeCount { target_type, count }) - .collect(), - }) -} - -/// 导出审计日志 -pub async fn export_audit_logs_service( - svc_ctx: &SvcCtx, - current_user_uuid: Uuid, - team_uuid: Option, - payload: &ExportAuditLogsRequest, -) -> Result<(String, String, String), String> { - // 构建查询请求 - let list_request = ListAuditLogsRequest { - pagination: crate::entitys::Pagination { - page: 1, - page_size: payload.max_records.unwrap_or(1000) as i64, - sort_by: None, - sort_order: None, - }, - filters: payload.filters.clone(), - }; - - let (logs, _total) = - get_audit_logs_service(svc_ctx, current_user_uuid, team_uuid, &list_request).await?; - - // 根据格式生成导出内容 - let content = match payload.format.as_str() { - "csv" => export_to_csv(&logs), - "json" => serde_json::to_string_pretty(&logs).unwrap_or_default(), - _ => return Err("不支持的导出格式".to_string()), - }; - - let filename = format!( - "audit_logs_{}.{}", - chrono::Utc::now().format("%Y%m%d%H%M%S"), - payload.format - ); - - let mime_type = match payload.format.as_str() { - "csv" => "text/csv".to_string(), - "json" => "application/json".to_string(), - _ => "text/plain".to_string(), - }; - - Ok((content, filename, mime_type)) -} - -fn export_to_csv(logs: &[AuditLogDto]) -> String { - let mut csv = String::from("时间,用户UUID,操作,目标类型,目标名称,详情,IP地址\n"); - for log in logs { - csv.push_str(&format!( - "{},{},{},{},{},{},{}\n", - log.created_at.format("%Y-%m-%d %H:%M:%S"), - log.user_uuid, - log.action, - log.target_type, - log.target_name.as_deref().unwrap_or(""), - log.details.as_deref().unwrap_or("").replace(',', ";"), - log.ip_address.as_deref().unwrap_or(""), - )); - } - csv -} + + // 热门操作(Top 5) + let top_actions_raw = models::fetch_top_actions(&svc_ctx.db, team_uuid, 5) + .await + .map_err(|e| e.to_string())?; + + // 热门目标类型(Top 5) + let top_target_types_raw = models::fetch_top_target_types(&svc_ctx.db, team_uuid, 5) + .await + .map_err(|e| e.to_string())?; + + Ok(AuditStatsResponse { + total_logs, + logs_today, + logs_this_week, + logs_this_month, + top_actions: top_actions_raw + .into_iter() + .map(|(action, count)| ActionCount { action, count }) + .collect(), + top_target_types: top_target_types_raw + .into_iter() + .map(|(target_type, count)| TargetTypeCount { target_type, count }) + .collect(), + }) +} + +/// 导出审计日志 +pub async fn export_audit_logs_service( + svc_ctx: &SvcCtx, + current_user_uuid: Uuid, + team_uuid: Option, + payload: &ExportAuditLogsRequest, +) -> Result<(String, String, String), String> { + // 构建查询请求 + let list_request = ListAuditLogsRequest { + pagination: crate::entitys::Pagination { + page: 1, + page_size: payload.max_records.unwrap_or(1000) as i64, + sort_by: None, + sort_order: None, + }, + filters: payload.filters.clone(), + }; + + let (logs, _total) = + get_audit_logs_service(svc_ctx, current_user_uuid, team_uuid, &list_request).await?; + + // 根据格式生成导出内容 + let content = match payload.format.as_str() { + "csv" => export_to_csv(&logs), + "json" => serde_json::to_string_pretty(&logs).unwrap_or_default(), + _ => return Err("不支持的导出格式".to_string()), + }; + + let filename = format!( + "audit_logs_{}.{}", + chrono::Utc::now().format("%Y%m%d%H%M%S"), + payload.format + ); + + let mime_type = match payload.format.as_str() { + "csv" => "text/csv".to_string(), + "json" => "application/json".to_string(), + _ => "text/plain".to_string(), + }; + + Ok((content, filename, mime_type)) +} + +fn export_to_csv(logs: &[AuditLogDto]) -> String { + let mut csv = String::from("时间,用户UUID,操作,目标类型,目标名称,详情,IP地址\n"); + for log in logs { + csv.push_str(&format!( + "{},{},{},{},{},{},{}\n", + log.created_at.format("%Y-%m-%d %H:%M:%S"), + log.user_uuid, + log.action, + log.target_type, + log.target_name.as_deref().unwrap_or(""), + log.details.as_deref().unwrap_or("").replace(',', ";"), + log.ip_address.as_deref().unwrap_or(""), + )); + } + csv +} diff --git a/src-tauri/crates/business/src/services/browser_kernel.rs b/src-tauri/crates/business/src/services/browser_kernel.rs deleted file mode 100644 index 316947d..0000000 --- a/src-tauri/crates/business/src/services/browser_kernel.rs +++ /dev/null @@ -1,41 +0,0 @@ -use crate::{ - dto::versions::Version, - errors::SimprintError, - svc_ctx::SvcCtx, - utils::get_objects::get_version_resource_url, -}; -use std::collections::HashMap; - -/// 查询浏览器内核最新版本列表 -/// 筛选 SIMPRINT_KERNEL_* 类型(或指定 type_code),按 resource_name 分组取最新 -/// 返回 HashMap>,已填充可下载 URL -pub async fn get_browser_kernel_list_service( - svc_ctx: &SvcCtx, - platform: Option, - type_code: Option, -) -> Result>, SimprintError> { - let results = crate::models::versions::query_browser_kernel_latest_versions( - &svc_ctx.db, - platform.as_deref(), - type_code.as_deref(), - ) - .await - .map_err(|_| SimprintError::Other("查询浏览器内核列表失败".to_string()))?; - - let mut map: HashMap> = HashMap::new(); - - let storage_config = &svc_ctx.config.storage; - - for (type_code, _resource_name, version) in results { - map.entry(type_code).or_insert_with(Vec::new).push(Version { - url: Some(get_version_resource_url( - &storage_config.public_base_url, - &storage_config.version_root, - &version.url.unwrap_or_default(), - )), - ..version - }); - } - - Ok(map) -} diff --git a/src-tauri/crates/business/src/services/environments.rs b/src-tauri/crates/business/src/services/environments.rs index d710ca6..d73ee5d 100644 --- a/src-tauri/crates/business/src/services/environments.rs +++ b/src-tauri/crates/business/src/services/environments.rs @@ -1,1596 +1,1577 @@ -use std::collections::HashMap; -use url::Url; -use uuid::Uuid; - -use crate::dto::{ - EnvironmentConfigDto, EnvironmentCookieDto, EnvironmentCookieGroupDto, EnvironmentDto, - EnvironmentUrlDto, GroupSummaryDto, PlatformAccountDto, ProxySummaryDto, TagDto, -}; -use crate::entitys::{ - AddEnvironmentCookieRequest, AddEnvironmentUrlRequest, AssignTagsRequest, - BatchAssignTagRequest, BatchCreateEnvironmentRequest, BatchMoveToGroupRequest, - BatchRemoveTagsRequest, CookieGroupInput, CookieInput, CreateEnvironmentRequest, - ListEnvironmentsRequest, MoveToGroupRequest, SetEnvironmentProxyRequest, - UpdateEnvironmentRequest, UrlInput, -}; -use crate::models; -use crate::services::accounts; -use crate::svc_ctx::SvcCtx; - -// ============ Environments ============ - -/// 创建环境 -pub async fn create_environment_service( - svc_ctx: &SvcCtx, - user_uuid: Uuid, - workspace_uuid: Uuid, - team_uuid: Uuid, - payload: &CreateEnvironmentRequest, -) -> Result { - // 1. 检查用户是否在当前工作空间的团队中(工作空间级别) - let team_member = - models::teams::fetch_team_member(&svc_ctx.db, workspace_uuid, team_uuid, user_uuid) - .await - .map_err(|e| e.to_string())? - .ok_or_else(|| "您不是该团队的成员".to_string())?; - - // 2. 权限检查 - if let Some(group_uuid) = payload.group_uuid { - // 如果指定了分组,检查用户是否有目标分组的 write 或 manage 权限 - let has_write = models::check_group_permission( - &svc_ctx.db, - workspace_uuid, - group_uuid, - user_uuid, - "write", - ) - .await - .map_err(|e| e.to_string())?; - - let has_manage = models::check_group_permission( - &svc_ctx.db, - workspace_uuid, - group_uuid, - user_uuid, - "manage", - ) - .await - .map_err(|e| e.to_string())?; - - if !has_write && !has_manage { - return Err("您没有在该分组中创建环境的权限".to_string()); - } - } else { - // 如果未指定分组,检查用户是否有团队级别的环境创建权限(Editor/Admin/Owner) - let can_create = matches!(team_member.role.as_str(), "owner" | "admin" | "editor"); - if !can_create { - return Err("您没有创建环境的权限,需要 Editor 及以上角色".to_string()); - } - } - - // 3. 检查工作空间配额是否充足 - let quota_available = models::check_quota(&svc_ctx.db, workspace_uuid, "environments") - .await - .map_err(|e| e.to_string())?; - if !quota_available { - return Err("工作空间环境配额不足,无法创建新环境".to_string()); - } - - // 提取系统信息 - let system_info = payload - .config - .window_info - .get("system") - .and_then(|v| v.as_str()) - .map(|s| s.to_string()); - let kernel_info = payload - .config - .window_info - .get("kernel") - .and_then(|v| v.as_str()) - .map(|s| s.to_string()); - - // 创建环境 - let env_uuid = models::insert_environment( - &svc_ctx.db, - workspace_uuid, - user_uuid, - team_uuid, - &payload.name, - payload.description.as_deref(), - payload.group_uuid, - payload.proxy_uuid, - system_info.as_deref(), - kernel_info.as_deref(), - ) - .await - .map_err(|e| e.to_string())?; - - // 创建环境配置 - models::upsert_environment_config( - &svc_ctx.db, - env_uuid, - &payload.config.window_info, - &payload.config.basic_settings, - &payload.config.fingerprint_settings, - &payload.config.device_settings, - &payload.config.preference_settings, - &payload.config.project_metadata, - ) - .await - .map_err(|e| e.to_string())?; - - replace_environment_urls(svc_ctx, env_uuid, payload.urls.as_deref()).await?; - replace_environment_cookies(svc_ctx, env_uuid, payload.cookies.as_deref()).await?; - - // 分配标签 - if let Some(tag_uuids) = &payload.tag_uuids { - for tag_uuid in tag_uuids { - let _ = models::insert_environment_tag(&svc_ctx.db, env_uuid, *tag_uuid).await; - } - } - - // 关联账号 - if let Some(account_uuids) = &payload.account_uuids { - for (idx, account_uuid) in account_uuids.iter().enumerate() { - let _ = models::accounts::insert_environment_account( - &svc_ctx.db, - env_uuid, - *account_uuid, - idx as i32, - ) - .await; - } - } - - // 4. 更新工作空间配额(创建后增加使用数) - models::increment_used_environments(&svc_ctx.db, workspace_uuid, 1) - .await - .map_err(|e| format!("更新配额失败: {}", e))?; - - Ok(env_uuid) -} - -#[derive(Debug, Clone)] -struct CookieSiteTarget { - site_input: String, - domain: String, - path: String, - secure: bool, -} - -fn normalize_cookie_path(path: &str) -> String { - if path.trim().is_empty() || path == "/" { - "/".to_string() - } else if path.starts_with('/') { - path.to_string() - } else { - format!("/{}", path) - } -} - -fn parse_cookie_site(site: &str) -> Result { - let site = site.trim(); - if site.is_empty() { - return Err("Cookie 目标网页/域名不能为空".to_string()); - } - - if let Ok(parsed) = Url::parse(site) { - let host = parsed - .host_str() - .ok_or_else(|| format!("无效的 Cookie 目标网页: {}", site))?; - - return Ok(CookieSiteTarget { - site_input: site.to_string(), - domain: host.to_string(), - path: normalize_cookie_path(parsed.path()), - secure: parsed.scheme().eq_ignore_ascii_case("https"), - }); - } - - if !site.contains("://") { - if site.starts_with('.') && !site.contains('/') && !site.chars().any(|ch| ch.is_whitespace()) { - return Ok(CookieSiteTarget { - site_input: site.to_string(), - domain: site.to_string(), - path: "/".to_string(), - secure: false, - }); - } - - let candidate = format!("https://{}", site); - if let Ok(parsed) = Url::parse(&candidate) { - if let Some(host) = parsed.host_str() { - return Ok(CookieSiteTarget { - site_input: site.to_string(), - domain: host.to_string(), - path: normalize_cookie_path(parsed.path()), - secure: false, - }); - } - } - } - - Err(format!("无效的 Cookie 目标网页/域名: {}", site)) -} - -fn is_cookie_attribute(part: &str) -> bool { - let lower = part.trim().to_lowercase(); - matches!( - lower.as_str(), - "secure" | "httponly" | "http-only" | "partitioned" - ) || lower.starts_with("domain=") - || lower.starts_with("path=") - || lower.starts_with("expires=") - || lower.starts_with("max-age=") - || lower.starts_with("samesite=") -} - -fn parse_cookie_name_value(part: &str) -> Result<(String, String), String> { - let Some(eq_pos) = part.find('=') else { - return Err(format!("无效的 Cookie 格式: {}", part)); - }; - - let name = part[..eq_pos].trim(); - let value = part[eq_pos + 1..].trim(); - if name.is_empty() { - return Err(format!("Cookie 名称不能为空: {}", part)); - } - - Ok((name.to_string(), value.to_string())) -} - -fn parse_cookie_line_with_attrs( - line: &str, - site_target: &CookieSiteTarget, -) -> Result { - let parts: Vec<&str> = line.split(';').map(|part| part.trim()).filter(|part| !part.is_empty()).collect(); - let (name, value) = parse_cookie_name_value( - parts - .first() - .copied() - .ok_or_else(|| "Cookie 内容不能为空".to_string())?, - )?; - - let mut domain = site_target.domain.clone(); - let mut path = site_target.path.clone(); - let mut http_only = false; - let mut secure = site_target.secure; - let mut same_site = Some("Lax".to_string()); - - for part in parts.iter().skip(1) { - let lower = part.to_lowercase(); - if lower.starts_with("domain=") { - domain = part[7..].trim().to_string(); - } else if lower.starts_with("path=") { - path = normalize_cookie_path(part[5..].trim()); - } else if lower == "secure" { - secure = true; - } else if lower == "httponly" || lower == "http-only" { - http_only = true; - } else if lower.starts_with("samesite=") { - same_site = Some(part[9..].trim().to_string()); - } - } - - Ok(CookieInput { - site_input: site_target.site_input.clone(), - domain, - name, - value, - path: Some(path), - http_only: Some(http_only), - secure: Some(secure), - same_site, - }) -} - -fn parse_cookie_group(group: &CookieGroupInput) -> Result, String> { - let site_target = parse_cookie_site(&group.site)?; - let cookie_text = group.cookie_text.trim(); - if cookie_text.is_empty() { - return Err("Cookie 内容不能为空".to_string()); - } - - let mut cookies = Vec::new(); - - for line in cookie_text - .lines() - .map(|line| line.trim()) - .filter(|line| !line.is_empty()) - { - let parts: Vec<&str> = line - .split(';') - .map(|part| part.trim()) - .filter(|part| !part.is_empty()) - .collect(); - - if parts.is_empty() { - continue; - } - - let has_attr_style = parts.len() > 1 && parts.iter().skip(1).all(|part| is_cookie_attribute(part)); - if has_attr_style { - cookies.push(parse_cookie_line_with_attrs(line, &site_target)?); - continue; - } - - for part in parts { - let (name, value) = parse_cookie_name_value(part)?; - cookies.push(CookieInput { - site_input: site_target.site_input.clone(), - domain: site_target.domain.clone(), - name, - value, - path: Some(site_target.path.clone()), - http_only: Some(false), - secure: Some(site_target.secure), - same_site: Some("Lax".to_string()), - }); - } - } - - if cookies.is_empty() { - return Err("Cookie 内容不能为空".to_string()); - } - - Ok(cookies) -} - -fn format_cookie_row(cookie: &EnvironmentCookieDto, site_target: Option<&CookieSiteTarget>) -> String { - let mut parts = vec![format!("{}={}", cookie.name, cookie.value)]; - - let default_domain = site_target.map(|target| target.domain.as_str()).unwrap_or(""); - let default_path = site_target.map(|target| target.path.as_str()).unwrap_or("/"); - let default_secure = site_target.map(|target| target.secure).unwrap_or(false); - - if !cookie.domain.trim().is_empty() && cookie.domain != default_domain { - parts.push(format!("domain={}", cookie.domain)); - } - - let cookie_path = cookie.path.as_deref().unwrap_or("/"); - if cookie_path != default_path { - parts.push(format!("path={}", cookie_path)); - } - - if cookie.secure.unwrap_or(false) != default_secure && cookie.secure.unwrap_or(false) { - parts.push("secure".to_string()); - } - - if cookie.http_only.unwrap_or(false) { - parts.push("httpOnly".to_string()); - } - - if let Some(same_site) = cookie - .same_site - .as_deref() - .filter(|same_site| !same_site.trim().is_empty() && *same_site != "Lax") - { - parts.push(format!("sameSite={}", same_site)); - } - - parts.join("; ") -} - -fn group_cookie_rows(cookie_rows: Vec) -> Vec { - let mut grouped: HashMap> = HashMap::new(); - - for cookie in cookie_rows { - grouped - .entry(cookie.site_input.clone()) - .or_default() - .push(cookie); - } - - let mut items: Vec = grouped - .into_iter() - .map(|(site, cookies)| { - let site_target = parse_cookie_site(&site).ok(); - let simple_only = cookies.iter().all(|cookie| { - let default_domain = site_target - .as_ref() - .map(|target| target.domain.as_str()) - .unwrap_or(""); - let default_path = site_target - .as_ref() - .map(|target| target.path.as_str()) - .unwrap_or("/"); - let default_secure = site_target.as_ref().map(|target| target.secure).unwrap_or(false); - - cookie.domain == default_domain - && cookie.path.as_deref().unwrap_or("/") == default_path - && cookie.secure.unwrap_or(false) == default_secure - && !cookie.http_only.unwrap_or(false) - && cookie - .same_site - .as_deref() - .map(|same_site| same_site.eq_ignore_ascii_case("lax")) - .unwrap_or(true) - && cookie.expires_at.is_none() - }); - - let parts: Vec = cookies - .iter() - .map(|cookie| format_cookie_row(cookie, site_target.as_ref())) - .collect(); - - EnvironmentCookieGroupDto { - site, - cookie_text: if simple_only { - parts.join("; ") - } else { - parts.join("\n") - }, - } - }) - .collect(); - - items.sort_by(|left, right| left.site.cmp(&right.site)); - items -} - -async fn replace_environment_urls( - svc_ctx: &SvcCtx, - env_uuid: Uuid, - urls: Option<&[UrlInput]>, -) -> Result<(), String> { - let Some(urls) = urls else { - return Ok(()); - }; - - models::clear_environment_urls(&svc_ctx.db, env_uuid) - .await - .map_err(|e| format!("清空 URLs 失败: {}", e))?; - - for (idx, item) in urls.iter().enumerate() { - let url = item.url.trim(); - if url.is_empty() { - continue; - } - - models::insert_environment_url( - &svc_ctx.db, - env_uuid, - url, - item.title.as_deref(), - item.sort_order.or(Some(idx as i32)), - ) - .await - .map_err(|e| format!("保存 URL 失败: {}", e))?; - } - - Ok(()) -} - -async fn replace_environment_cookies( - svc_ctx: &SvcCtx, - env_uuid: Uuid, - cookie_groups: Option<&[CookieGroupInput]>, -) -> Result<(), String> { - let Some(cookie_groups) = cookie_groups else { - return Ok(()); - }; - - models::clear_environment_cookies(&svc_ctx.db, env_uuid) - .await - .map_err(|e| format!("清空 Cookies 失败: {}", e))?; - - let mut parsed_cookies = Vec::new(); - for group in cookie_groups { - let mut items = parse_cookie_group(group)?; - parsed_cookies.append(&mut items); - } - - if parsed_cookies.is_empty() { - return Ok(()); - } - - models::batch_insert_environment_cookies(&svc_ctx.db, env_uuid, &parsed_cookies) - .await - .map_err(|e| format!("保存 Cookies 失败: {}", e))?; - - Ok(()) -} - -/// 获取环境列表(包含完整关联数据) -pub async fn get_environments_service( - svc_ctx: &SvcCtx, - user_uuid: Uuid, - workspace_uuid: Uuid, - team_uuid: Uuid, - payload: &ListEnvironmentsRequest, -) -> Result<(Vec, i64), String> { - // 1. 检查用户是否在当前工作空间的团队中 - let team_member = - models::teams::fetch_team_member(&svc_ctx.db, workspace_uuid, team_uuid, user_uuid) - .await - .map_err(|e| e.to_string())? - .ok_or_else(|| "您不是该团队的成员".to_string())?; - - let offset = (payload.pagination.page - 1) * payload.pagination.page_size; - - let group_uuid = payload.filters.as_ref().and_then(|f| f.group_uuid); - let status = payload.filters.as_ref().and_then(|f| f.status.as_deref()); - let keyword = payload.filters.as_ref().and_then(|f| f.keyword.as_deref()); - let tag_uuids = payload.filters.as_ref().and_then(|f| f.tag_uuids.as_deref()); - - // 2. 查询环境总数(用于分页) - let total_count = models::fetch_environments_count( - &svc_ctx.db, - workspace_uuid, - team_uuid, - group_uuid, - status, - keyword, - tag_uuids, - ) - .await - .map_err(|e| e.to_string())?; - - // 3. 查询环境基础列表 - let env_rows = models::fetch_environments_base( - &svc_ctx.db, - workspace_uuid, - team_uuid, - group_uuid, - status, - keyword, - tag_uuids, - offset, - payload.pagination.page_size, - ) - .await - .map_err(|e| e.to_string())?; - - // 2. 权限过滤:根据分组权限过滤环境 - let is_owner_or_admin = matches!(team_member.role.as_str(), "owner" | "admin"); - - // 收集所有需要检查权限的分组 UUID(去重) - let unique_group_uuids: Vec = env_rows - .iter() - .filter_map(|row| row.group_uuid) - .collect::>() - .into_iter() - .collect(); - - // 批量检查分组权限(如果不是 Owner/Admin) - let mut group_permissions_cache: std::collections::HashMap = - std::collections::HashMap::new(); - if !is_owner_or_admin && !unique_group_uuids.is_empty() { - for group_uuid in unique_group_uuids { - let has_permission = models::check_group_permission( - &svc_ctx.db, - workspace_uuid, - group_uuid, - user_uuid, - "read", - ) - .await - .map_err(|e| e.to_string())?; - group_permissions_cache.insert(group_uuid, has_permission); - } - } - - // 8. 过滤无权限的环境 - let filtered_env_rows: Vec<_> = env_rows - .into_iter() - .filter(|row| { - if let Some(group_uuid) = row.group_uuid { - // 如果环境有分组,检查用户是否有分组的 read 权限 - if is_owner_or_admin { - true // Owner/Admin 自动拥有所有分组权限 - } else { - *group_permissions_cache.get(&group_uuid).unwrap_or(&false) - } - } else { - // 如果环境无分组,所有团队成员都可以查看(已在团队成员检查中验证) - true - } - }) - .collect(); - - // 9. 重新收集关联 UUID(基于过滤后的环境) - let env_uuids: Vec = filtered_env_rows.iter().map(|e| e.uuid).collect(); - let group_uuids: Vec = filtered_env_rows.iter().filter_map(|e| e.group_uuid).collect(); - let proxy_uuids: Vec = filtered_env_rows.iter().filter_map(|e| e.proxy_uuid).collect(); - - // 10. 重新查询关联数据(基于过滤后的环境) - // 批量查询环境配置 - let mut configs_map: HashMap = HashMap::new(); - if !env_uuids.is_empty() { - let config_rows = models::fetch_environment_configs_by_uuids(&svc_ctx.db, &env_uuids) - .await - .map_err(|e| e.to_string())?; - for config in config_rows { - configs_map.insert(config.environment_uuid, config); - } - } - - // 批量查询分组 - let group_rows = if !group_uuids.is_empty() { - models::fetch_groups_by_uuids(&svc_ctx.db, &group_uuids) - .await - .map_err(|e| e.to_string())? - } else { - vec![] - }; - let groups_map: HashMap = group_rows.into_iter().map(|g| (g.uuid, g)).collect(); - - // 批量查询代理 - let proxy_rows = if !proxy_uuids.is_empty() { - models::fetch_proxies_by_uuids(&svc_ctx.db, &proxy_uuids) - .await - .map_err(|e| e.to_string())? - } else { - vec![] - }; - let proxies_map: HashMap = proxy_rows.into_iter().map(|p| (p.uuid, p)).collect(); - - // 批量查询标签 - let tag_rows = if !env_uuids.is_empty() { - models::fetch_tags_for_environments(&svc_ctx.db, &env_uuids) - .await - .map_err(|e| e.to_string())? - } else { - vec![] - }; - - // 按环境分组标签 - let mut tags_map: HashMap> = HashMap::new(); - for tag_row in tag_rows { - tags_map.entry(tag_row.environment_uuid).or_default().push(TagDto { - id: tag_row.tag_id, - uuid: tag_row.tag_uuid, - user_uuid: tag_row.tag_user_uuid, - team_uuid: tag_row.tag_team_uuid, - name: tag_row.tag_name, - color: tag_row.tag_color, - sort_order: tag_row.tag_sort_order, - environments_count: tag_row.tag_environments_count, - created_at: tag_row.tag_created_at, - updated_at: tag_row.tag_updated_at, - deleted_at: tag_row.tag_deleted_at, - }); - } - - let mut urls_map: HashMap> = HashMap::new(); - if !env_uuids.is_empty() { - let url_rows = models::fetch_environment_urls_by_uuids(&svc_ctx.db, &env_uuids) - .await - .map_err(|e| e.to_string())?; - for url in url_rows { - urls_map.entry(url.environment_uuid).or_default().push(url); - } - } - - let mut cookies_map: HashMap> = HashMap::new(); - if !env_uuids.is_empty() { - let cookie_rows = models::fetch_environment_cookies_by_uuids(&svc_ctx.db, &env_uuids) - .await - .map_err(|e| e.to_string())?; - let mut grouped_rows: HashMap> = HashMap::new(); - for cookie in cookie_rows { - grouped_rows - .entry(cookie.environment_uuid) - .or_default() - .push(cookie); - } - for (environment_uuid, rows) in grouped_rows { - cookies_map - .insert(environment_uuid, group_cookie_rows(rows)); - } - } - - // 批量查询账号 - let mut accounts_map: HashMap> = HashMap::new(); - for env_uuid in &env_uuids { - let accounts = accounts::get_environment_accounts_service(&svc_ctx, *env_uuid) - .await - .unwrap_or_default(); - accounts_map.insert(*env_uuid, accounts); - } - - // 11. 组装完整数据(使用与环境详情一致的数据结构) - let environments: Vec = filtered_env_rows - .into_iter() - .map(|row| { - // 构建 EnvironmentDto - let environment = EnvironmentDto { - id: row.id, - uuid: row.uuid, - workspace_uuid: row.workspace_uuid, - user_uuid: row.user_uuid, - team_uuid: row.team_uuid, - name: row.name, - description: row.description, - status: row.status, - group_uuid: row.group_uuid, - proxy_uuid: row.proxy_uuid, - system_info: row.system_info, - kernel_info: row.kernel_info, - fingerprint_summary: row.fingerprint_summary, - last_opened_at: row.last_opened_at, - created_at: row.created_at, - updated_at: row.updated_at, - deleted_at: None, - }; - - // 分组详情 - let group = row.group_uuid.and_then(|uuid| { - groups_map.get(&uuid).map(|g| GroupSummaryDto { - id: g.id, - uuid: g.uuid, - name: g.name.clone(), - description: g.description.clone(), - sort_order: g.sort_order, - }) - }); - - // 代理详情 - let proxy = row.proxy_uuid.and_then(|uuid| { - proxies_map.get(&uuid).map(|p| ProxySummaryDto { - id: p.id, - uuid: p.uuid, - name: p.name.clone(), - host: p.host.clone(), - port: p.port, - proxy_type: p.proxy_type.clone(), - username: p.username.clone(), - password: p.password.clone(), - country: p.country.clone(), - city: p.city.clone(), - status: p.status.clone(), - latency: p.latency, - last_check_ip: p.last_check_ip.clone(), - }) - }); - - crate::entitys::EnvironmentDetailResponse { - environment, - config: configs_map.remove(&row.uuid), // 返回配置信息,用于传递给指纹浏览器内核 - cookies: cookies_map.remove(&row.uuid).unwrap_or_default(), - urls: urls_map.remove(&row.uuid).unwrap_or_default(), - tags: tags_map.remove(&row.uuid).unwrap_or_default(), - accounts: accounts_map.remove(&row.uuid).unwrap_or_default(), - group, - proxy, +use std::collections::HashMap; +use url::Url; +use uuid::Uuid; + +use crate::dto::{ + EnvironmentConfigDto, EnvironmentCookieDto, EnvironmentCookieGroupDto, EnvironmentDto, + EnvironmentUrlDto, GroupSummaryDto, PlatformAccountDto, ProxySummaryDto, TagDto, +}; +use crate::entitys::{ + AddEnvironmentCookieRequest, AddEnvironmentUrlRequest, AssignTagsRequest, + BatchAssignTagRequest, BatchCreateEnvironmentRequest, BatchMoveToGroupRequest, + BatchRemoveTagsRequest, CookieGroupInput, CookieInput, CreateEnvironmentRequest, + ListEnvironmentsRequest, MoveToGroupRequest, SetEnvironmentProxyRequest, + UpdateEnvironmentRequest, UrlInput, +}; +use crate::models; +use crate::services::accounts; +use crate::svc_ctx::SvcCtx; + +// ============ Environments ============ + +/// 创建环境 +pub async fn create_environment_service( + svc_ctx: &SvcCtx, + user_uuid: Uuid, + workspace_uuid: Uuid, + team_uuid: Uuid, + payload: &CreateEnvironmentRequest, +) -> Result { + // 1. 检查用户是否在当前工作空间的团队中(工作空间级别) + let team_member = + models::teams::fetch_team_member(&svc_ctx.db, workspace_uuid, team_uuid, user_uuid) + .await + .map_err(|e| e.to_string())? + .ok_or_else(|| "您不是该团队的成员".to_string())?; + + // 2. 权限检查 + if let Some(group_uuid) = payload.group_uuid { + // 如果指定了分组,检查用户是否有目标分组的 write 或 manage 权限 + let has_write = models::check_group_permission( + &svc_ctx.db, + workspace_uuid, + group_uuid, + user_uuid, + "write", + ) + .await + .map_err(|e| e.to_string())?; + + let has_manage = models::check_group_permission( + &svc_ctx.db, + workspace_uuid, + group_uuid, + user_uuid, + "manage", + ) + .await + .map_err(|e| e.to_string())?; + + if !has_write && !has_manage { + return Err("您没有在该分组中创建环境的权限".to_string()); + } + } else { + // 如果未指定分组,检查用户是否有团队级别的环境创建权限(Editor/Admin/Owner) + let can_create = matches!(team_member.role.as_str(), "owner" | "admin" | "editor"); + if !can_create { + return Err("您没有创建环境的权限,需要 Editor 及以上角色".to_string()); + } + } + + // 3. 检查工作空间配额是否充足 + let quota_available = models::check_quota(&svc_ctx.db, workspace_uuid, "environments") + .await + .map_err(|e| e.to_string())?; + if !quota_available { + return Err("工作空间环境配额不足,无法创建新环境".to_string()); + } + + // 提取系统信息 + let system_info = payload + .config + .window_info + .get("system") + .and_then(|v| v.as_str()) + .map(|s| s.to_string()); + let kernel_info = payload + .config + .window_info + .get("kernel") + .and_then(|v| v.as_str()) + .map(|s| s.to_string()); + + // 创建环境 + let env_uuid = models::insert_environment( + &svc_ctx.db, + workspace_uuid, + user_uuid, + team_uuid, + &payload.name, + payload.description.as_deref(), + payload.group_uuid, + payload.proxy_uuid, + system_info.as_deref(), + kernel_info.as_deref(), + ) + .await + .map_err(|e| e.to_string())?; + + // 创建环境配置 + models::upsert_environment_config( + &svc_ctx.db, + env_uuid, + &payload.config.window_info, + &payload.config.basic_settings, + &payload.config.fingerprint_settings, + &payload.config.device_settings, + &payload.config.preference_settings, + &payload.config.project_metadata, + ) + .await + .map_err(|e| e.to_string())?; + + replace_environment_urls(svc_ctx, env_uuid, payload.urls.as_deref()).await?; + replace_environment_cookies(svc_ctx, env_uuid, payload.cookies.as_deref()).await?; + + // 分配标签 + if let Some(tag_uuids) = &payload.tag_uuids { + for tag_uuid in tag_uuids { + let _ = models::insert_environment_tag(&svc_ctx.db, env_uuid, *tag_uuid).await; + } + } + + // 关联账号 + if let Some(account_uuids) = &payload.account_uuids { + for (idx, account_uuid) in account_uuids.iter().enumerate() { + let _ = models::accounts::insert_environment_account( + &svc_ctx.db, + env_uuid, + *account_uuid, + idx as i32, + ) + .await; + } + } + + // 4. 更新工作空间配额(创建后增加使用数) + models::increment_used_environments(&svc_ctx.db, workspace_uuid, 1) + .await + .map_err(|e| format!("更新配额失败: {}", e))?; + + Ok(env_uuid) +} + +#[derive(Debug, Clone)] +struct CookieSiteTarget { + site_input: String, + domain: String, + path: String, + secure: bool, +} + +fn normalize_cookie_path(path: &str) -> String { + if path.trim().is_empty() || path == "/" { + "/".to_string() + } else if path.starts_with('/') { + path.to_string() + } else { + format!("/{}", path) + } +} + +fn parse_cookie_site(site: &str) -> Result { + let site = site.trim(); + if site.is_empty() { + return Err("Cookie 目标网页/域名不能为空".to_string()); + } + + if let Ok(parsed) = Url::parse(site) { + let host = parsed.host_str().ok_or_else(|| format!("无效的 Cookie 目标网页: {}", site))?; + + return Ok(CookieSiteTarget { + site_input: site.to_string(), + domain: host.to_string(), + path: normalize_cookie_path(parsed.path()), + secure: parsed.scheme().eq_ignore_ascii_case("https"), + }); + } + + if !site.contains("://") { + if site.starts_with('.') + && !site.contains('/') + && !site.chars().any(|ch| ch.is_whitespace()) + { + return Ok(CookieSiteTarget { + site_input: site.to_string(), + domain: site.to_string(), + path: "/".to_string(), + secure: false, + }); + } + + let candidate = format!("https://{}", site); + if let Ok(parsed) = Url::parse(&candidate) { + if let Some(host) = parsed.host_str() { + return Ok(CookieSiteTarget { + site_input: site.to_string(), + domain: host.to_string(), + path: normalize_cookie_path(parsed.path()), + secure: false, + }); + } + } + } + + Err(format!("无效的 Cookie 目标网页/域名: {}", site)) +} + +fn is_cookie_attribute(part: &str) -> bool { + let lower = part.trim().to_lowercase(); + matches!( + lower.as_str(), + "secure" | "httponly" | "http-only" | "partitioned" + ) || lower.starts_with("domain=") + || lower.starts_with("path=") + || lower.starts_with("expires=") + || lower.starts_with("max-age=") + || lower.starts_with("samesite=") +} + +fn parse_cookie_name_value(part: &str) -> Result<(String, String), String> { + let Some(eq_pos) = part.find('=') else { + return Err(format!("无效的 Cookie 格式: {}", part)); + }; + + let name = part[..eq_pos].trim(); + let value = part[eq_pos + 1..].trim(); + if name.is_empty() { + return Err(format!("Cookie 名称不能为空: {}", part)); + } + + Ok((name.to_string(), value.to_string())) +} + +fn parse_cookie_line_with_attrs( + line: &str, + site_target: &CookieSiteTarget, +) -> Result { + let parts: Vec<&str> = line + .split(';') + .map(|part| part.trim()) + .filter(|part| !part.is_empty()) + .collect(); + let (name, value) = parse_cookie_name_value( + parts.first().copied().ok_or_else(|| "Cookie 内容不能为空".to_string())?, + )?; + + let mut domain = site_target.domain.clone(); + let mut path = site_target.path.clone(); + let mut http_only = false; + let mut secure = site_target.secure; + let mut same_site = Some("Lax".to_string()); + + for part in parts.iter().skip(1) { + let lower = part.to_lowercase(); + if lower.starts_with("domain=") { + domain = part[7..].trim().to_string(); + } else if lower.starts_with("path=") { + path = normalize_cookie_path(part[5..].trim()); + } else if lower == "secure" { + secure = true; + } else if lower == "httponly" || lower == "http-only" { + http_only = true; + } else if lower.starts_with("samesite=") { + same_site = Some(part[9..].trim().to_string()); + } + } + + Ok(CookieInput { + site_input: site_target.site_input.clone(), + domain, + name, + value, + path: Some(path), + http_only: Some(http_only), + secure: Some(secure), + same_site, + }) +} + +fn parse_cookie_group(group: &CookieGroupInput) -> Result, String> { + let site_target = parse_cookie_site(&group.site)?; + let cookie_text = group.cookie_text.trim(); + if cookie_text.is_empty() { + return Err("Cookie 内容不能为空".to_string()); + } + + let mut cookies = Vec::new(); + + for line in cookie_text.lines().map(|line| line.trim()).filter(|line| !line.is_empty()) { + let parts: Vec<&str> = line + .split(';') + .map(|part| part.trim()) + .filter(|part| !part.is_empty()) + .collect(); + + if parts.is_empty() { + continue; + } + + let has_attr_style = + parts.len() > 1 && parts.iter().skip(1).all(|part| is_cookie_attribute(part)); + if has_attr_style { + cookies.push(parse_cookie_line_with_attrs(line, &site_target)?); + continue; + } + + for part in parts { + let (name, value) = parse_cookie_name_value(part)?; + cookies.push(CookieInput { + site_input: site_target.site_input.clone(), + domain: site_target.domain.clone(), + name, + value, + path: Some(site_target.path.clone()), + http_only: Some(false), + secure: Some(site_target.secure), + same_site: Some("Lax".to_string()), + }); + } + } + + if cookies.is_empty() { + return Err("Cookie 内容不能为空".to_string()); + } + + Ok(cookies) +} + +fn format_cookie_row( + cookie: &EnvironmentCookieDto, + site_target: Option<&CookieSiteTarget>, +) -> String { + let mut parts = vec![format!("{}={}", cookie.name, cookie.value)]; + + let default_domain = site_target.map(|target| target.domain.as_str()).unwrap_or(""); + let default_path = site_target.map(|target| target.path.as_str()).unwrap_or("/"); + let default_secure = site_target.map(|target| target.secure).unwrap_or(false); + + if !cookie.domain.trim().is_empty() && cookie.domain != default_domain { + parts.push(format!("domain={}", cookie.domain)); + } + + let cookie_path = cookie.path.as_deref().unwrap_or("/"); + if cookie_path != default_path { + parts.push(format!("path={}", cookie_path)); + } + + if cookie.secure.unwrap_or(false) != default_secure && cookie.secure.unwrap_or(false) { + parts.push("secure".to_string()); + } + + if cookie.http_only.unwrap_or(false) { + parts.push("httpOnly".to_string()); + } + + if let Some(same_site) = cookie + .same_site + .as_deref() + .filter(|same_site| !same_site.trim().is_empty() && *same_site != "Lax") + { + parts.push(format!("sameSite={}", same_site)); + } + + parts.join("; ") +} + +fn group_cookie_rows(cookie_rows: Vec) -> Vec { + let mut grouped: HashMap> = HashMap::new(); + + for cookie in cookie_rows { + grouped.entry(cookie.site_input.clone()).or_default().push(cookie); + } + + let mut items: Vec = grouped + .into_iter() + .map(|(site, cookies)| { + let site_target = parse_cookie_site(&site).ok(); + let simple_only = cookies.iter().all(|cookie| { + let default_domain = + site_target.as_ref().map(|target| target.domain.as_str()).unwrap_or(""); + let default_path = + site_target.as_ref().map(|target| target.path.as_str()).unwrap_or("/"); + let default_secure = + site_target.as_ref().map(|target| target.secure).unwrap_or(false); + + cookie.domain == default_domain + && cookie.path.as_deref().unwrap_or("/") == default_path + && cookie.secure.unwrap_or(false) == default_secure + && !cookie.http_only.unwrap_or(false) + && cookie + .same_site + .as_deref() + .map(|same_site| same_site.eq_ignore_ascii_case("lax")) + .unwrap_or(true) + && cookie.expires_at.is_none() + }); + + let parts: Vec = cookies + .iter() + .map(|cookie| format_cookie_row(cookie, site_target.as_ref())) + .collect(); + + EnvironmentCookieGroupDto { + site, + cookie_text: if simple_only { + parts.join("; ") + } else { + parts.join("\n") + }, + } + }) + .collect(); + + items.sort_by(|left, right| left.site.cmp(&right.site)); + items +} + +async fn replace_environment_urls( + svc_ctx: &SvcCtx, + env_uuid: Uuid, + urls: Option<&[UrlInput]>, +) -> Result<(), String> { + let Some(urls) = urls else { + return Ok(()); + }; + + models::clear_environment_urls(&svc_ctx.db, env_uuid) + .await + .map_err(|e| format!("清空 URLs 失败: {}", e))?; + + for (idx, item) in urls.iter().enumerate() { + let url = item.url.trim(); + if url.is_empty() { + continue; + } + + models::insert_environment_url( + &svc_ctx.db, + env_uuid, + url, + item.title.as_deref(), + item.sort_order.or(Some(idx as i32)), + ) + .await + .map_err(|e| format!("保存 URL 失败: {}", e))?; + } + + Ok(()) +} + +async fn replace_environment_cookies( + svc_ctx: &SvcCtx, + env_uuid: Uuid, + cookie_groups: Option<&[CookieGroupInput]>, +) -> Result<(), String> { + let Some(cookie_groups) = cookie_groups else { + return Ok(()); + }; + + models::clear_environment_cookies(&svc_ctx.db, env_uuid) + .await + .map_err(|e| format!("清空 Cookies 失败: {}", e))?; + + let mut parsed_cookies = Vec::new(); + for group in cookie_groups { + let mut items = parse_cookie_group(group)?; + parsed_cookies.append(&mut items); + } + + if parsed_cookies.is_empty() { + return Ok(()); + } + + models::batch_insert_environment_cookies(&svc_ctx.db, env_uuid, &parsed_cookies) + .await + .map_err(|e| format!("保存 Cookies 失败: {}", e))?; + + Ok(()) +} + +/// 获取环境列表(包含完整关联数据) +pub async fn get_environments_service( + svc_ctx: &SvcCtx, + user_uuid: Uuid, + workspace_uuid: Uuid, + team_uuid: Uuid, + payload: &ListEnvironmentsRequest, +) -> Result<(Vec, i64), String> { + // 1. 检查用户是否在当前工作空间的团队中 + let team_member = + models::teams::fetch_team_member(&svc_ctx.db, workspace_uuid, team_uuid, user_uuid) + .await + .map_err(|e| e.to_string())? + .ok_or_else(|| "您不是该团队的成员".to_string())?; + + let offset = (payload.pagination.page - 1) * payload.pagination.page_size; + + let group_uuid = payload.filters.as_ref().and_then(|f| f.group_uuid); + let status = payload.filters.as_ref().and_then(|f| f.status.as_deref()); + let keyword = payload.filters.as_ref().and_then(|f| f.keyword.as_deref()); + let tag_uuids = payload.filters.as_ref().and_then(|f| f.tag_uuids.as_deref()); + + // 2. 查询环境总数(用于分页) + let total_count = models::fetch_environments_count( + &svc_ctx.db, + workspace_uuid, + team_uuid, + group_uuid, + status, + keyword, + tag_uuids, + ) + .await + .map_err(|e| e.to_string())?; + + // 3. 查询环境基础列表 + let env_rows = models::fetch_environments_base( + &svc_ctx.db, + workspace_uuid, + team_uuid, + group_uuid, + status, + keyword, + tag_uuids, + offset, + payload.pagination.page_size, + ) + .await + .map_err(|e| e.to_string())?; + + // 2. 权限过滤:根据分组权限过滤环境 + let is_owner_or_admin = matches!(team_member.role.as_str(), "owner" | "admin"); + + // 收集所有需要检查权限的分组 UUID(去重) + let unique_group_uuids: Vec = env_rows + .iter() + .filter_map(|row| row.group_uuid) + .collect::>() + .into_iter() + .collect(); + + // 批量检查分组权限(如果不是 Owner/Admin) + let mut group_permissions_cache: std::collections::HashMap = + std::collections::HashMap::new(); + if !is_owner_or_admin && !unique_group_uuids.is_empty() { + for group_uuid in unique_group_uuids { + let has_permission = models::check_group_permission( + &svc_ctx.db, + workspace_uuid, + group_uuid, + user_uuid, + "read", + ) + .await + .map_err(|e| e.to_string())?; + group_permissions_cache.insert(group_uuid, has_permission); + } + } + + // 8. 过滤无权限的环境 + let filtered_env_rows: Vec<_> = env_rows + .into_iter() + .filter(|row| { + if let Some(group_uuid) = row.group_uuid { + // 如果环境有分组,检查用户是否有分组的 read 权限 + if is_owner_or_admin { + true // Owner/Admin 自动拥有所有分组权限 + } else { + *group_permissions_cache.get(&group_uuid).unwrap_or(&false) + } + } else { + // 如果环境无分组,所有团队成员都可以查看(已在团队成员检查中验证) + true + } + }) + .collect(); + + // 9. 重新收集关联 UUID(基于过滤后的环境) + let env_uuids: Vec = filtered_env_rows.iter().map(|e| e.uuid).collect(); + let group_uuids: Vec = filtered_env_rows.iter().filter_map(|e| e.group_uuid).collect(); + let proxy_uuids: Vec = filtered_env_rows.iter().filter_map(|e| e.proxy_uuid).collect(); + + // 10. 重新查询关联数据(基于过滤后的环境) + // 批量查询环境配置 + let mut configs_map: HashMap = HashMap::new(); + if !env_uuids.is_empty() { + let config_rows = models::fetch_environment_configs_by_uuids(&svc_ctx.db, &env_uuids) + .await + .map_err(|e| e.to_string())?; + for config in config_rows { + configs_map.insert(config.environment_uuid, config); + } + } + + // 批量查询分组 + let group_rows = if !group_uuids.is_empty() { + models::fetch_groups_by_uuids(&svc_ctx.db, &group_uuids) + .await + .map_err(|e| e.to_string())? + } else { + vec![] + }; + let groups_map: HashMap = group_rows.into_iter().map(|g| (g.uuid, g)).collect(); + + // 批量查询代理 + let proxy_rows = if !proxy_uuids.is_empty() { + models::fetch_proxies_by_uuids(&svc_ctx.db, &proxy_uuids) + .await + .map_err(|e| e.to_string())? + } else { + vec![] + }; + let proxies_map: HashMap = proxy_rows.into_iter().map(|p| (p.uuid, p)).collect(); + + // 批量查询标签 + let tag_rows = if !env_uuids.is_empty() { + models::fetch_tags_for_environments(&svc_ctx.db, &env_uuids) + .await + .map_err(|e| e.to_string())? + } else { + vec![] + }; + + // 按环境分组标签 + let mut tags_map: HashMap> = HashMap::new(); + for tag_row in tag_rows { + tags_map.entry(tag_row.environment_uuid).or_default().push(TagDto { + id: tag_row.tag_id, + uuid: tag_row.tag_uuid, + user_uuid: tag_row.tag_user_uuid, + team_uuid: tag_row.tag_team_uuid, + name: tag_row.tag_name, + color: tag_row.tag_color, + sort_order: tag_row.tag_sort_order, + environments_count: tag_row.tag_environments_count, + created_at: tag_row.tag_created_at, + updated_at: tag_row.tag_updated_at, + deleted_at: tag_row.tag_deleted_at, + }); + } + + let mut urls_map: HashMap> = HashMap::new(); + if !env_uuids.is_empty() { + let url_rows = models::fetch_environment_urls_by_uuids(&svc_ctx.db, &env_uuids) + .await + .map_err(|e| e.to_string())?; + for url in url_rows { + urls_map.entry(url.environment_uuid).or_default().push(url); + } + } + + let mut cookies_map: HashMap> = HashMap::new(); + if !env_uuids.is_empty() { + let cookie_rows = models::fetch_environment_cookies_by_uuids(&svc_ctx.db, &env_uuids) + .await + .map_err(|e| e.to_string())?; + let mut grouped_rows: HashMap> = HashMap::new(); + for cookie in cookie_rows { + grouped_rows.entry(cookie.environment_uuid).or_default().push(cookie); + } + for (environment_uuid, rows) in grouped_rows { + cookies_map.insert(environment_uuid, group_cookie_rows(rows)); + } + } + + // 批量查询账号 + let mut accounts_map: HashMap> = HashMap::new(); + for env_uuid in &env_uuids { + let accounts = accounts::get_environment_accounts_service(&svc_ctx, *env_uuid) + .await + .unwrap_or_default(); + accounts_map.insert(*env_uuid, accounts); + } + + // 11. 组装完整数据(使用与环境详情一致的数据结构) + let environments: Vec = filtered_env_rows + .into_iter() + .map(|row| { + // 构建 EnvironmentDto + let environment = EnvironmentDto { + id: row.id, + uuid: row.uuid, + workspace_uuid: row.workspace_uuid, + user_uuid: row.user_uuid, + team_uuid: row.team_uuid, + name: row.name, + description: row.description, + status: row.status, + group_uuid: row.group_uuid, + proxy_uuid: row.proxy_uuid, + system_info: row.system_info, + kernel_info: row.kernel_info, + fingerprint_summary: row.fingerprint_summary, + last_opened_at: row.last_opened_at, + created_at: row.created_at, + updated_at: row.updated_at, + deleted_at: None, + }; + + // 分组详情 + let group = row.group_uuid.and_then(|uuid| { + groups_map.get(&uuid).map(|g| GroupSummaryDto { + id: g.id, + uuid: g.uuid, + name: g.name.clone(), + description: g.description.clone(), + sort_order: g.sort_order, + }) + }); + + // 代理详情 + let proxy = row.proxy_uuid.and_then(|uuid| { + proxies_map.get(&uuid).map(|p| ProxySummaryDto { + id: p.id, + uuid: p.uuid, + name: p.name.clone(), + host: p.host.clone(), + port: p.port, + proxy_type: p.proxy_type.clone(), + username: p.username.clone(), + password: p.password.clone(), + country: p.country.clone(), + city: p.city.clone(), + status: p.status.clone(), + latency: p.latency, + last_check_ip: p.last_check_ip.clone(), + }) + }); + + crate::entitys::EnvironmentDetailResponse { + environment, + config: configs_map.remove(&row.uuid), // 返回配置信息,用于传递给指纹浏览器内核 + cookies: cookies_map.remove(&row.uuid).unwrap_or_default(), + urls: urls_map.remove(&row.uuid).unwrap_or_default(), + tags: tags_map.remove(&row.uuid).unwrap_or_default(), + accounts: accounts_map.remove(&row.uuid).unwrap_or_default(), + group, + proxy, extensions: Vec::new(), - } - }) - .collect(); - - // 返回过滤后的环境列表和数据库总数(用于分页显示) - // 注意:total 是数据库中符合条件的总数,不考虑权限过滤 - // 权限过滤只影响当前页返回的数据,不影响总数统计 - Ok((environments, total_count)) -} - -/// 获取环境详情 -pub async fn get_environment_service( - svc_ctx: &SvcCtx, - workspace_uuid: Uuid, - team_uuid: Uuid, - user_uuid: Uuid, - env_uuid: Uuid, -) -> Result { - // 1. 检查用户是否在当前工作空间的团队中 - let _team_member = - models::teams::fetch_team_member(&svc_ctx.db, workspace_uuid, team_uuid, user_uuid) - .await - .map_err(|e| e.to_string())? - .ok_or_else(|| "您不是该团队的成员".to_string())?; - - // 2. 查询环境(带工作空间过滤) - let environment = models::fetch_environment_by_uuid(&svc_ctx.db, workspace_uuid, env_uuid) - .await - .map_err(|e| e.to_string())? - .ok_or_else(|| "环境不存在或不属于当前工作空间".to_string())?; - - // 3. 验证环境属于指定团队 - if environment.team_uuid != team_uuid { - return Err("环境不属于指定团队".to_string()); - } - - // 4. 权限检查 - if let Some(group_uuid) = environment.group_uuid { - // 如果环境有分组,检查用户是否有分组的 read/write/manage 权限 - // Owner/Admin 自动拥有所有分组权限(已在 check_group_permission 中处理) - let has_permission = models::check_group_permission( - &svc_ctx.db, - workspace_uuid, - group_uuid, - user_uuid, - "read", - ) - .await - .map_err(|e| e.to_string())?; - - if !has_permission { - return Err("您没有查看该环境的权限".to_string()); - } - } - // 如果环境无分组,所有团队成员都可以查看(已在团队成员检查中验证) - - Ok(environment) -} - -/// 获取环境配置 -pub async fn get_environment_config_service( - svc_ctx: &SvcCtx, - env_uuid: Uuid, -) -> Result { - models::fetch_environment_config(&svc_ctx.db, env_uuid) - .await - .map_err(|e| e.to_string())? - .ok_or_else(|| "环境配置不存在".to_string()) -} - -/// 获取环境的标签 -pub async fn get_environment_tags_service( - svc_ctx: &SvcCtx, - env_uuid: Uuid, -) -> Result, String> { - models::fetch_environment_tags(&svc_ctx.db, env_uuid) - .await - .map_err(|e| e.to_string()) -} - -/// 获取环境详情(包含完整关联数据) -pub async fn get_environment_detail_service( - svc_ctx: &SvcCtx, - workspace_uuid: Uuid, - team_uuid: Uuid, - user_uuid: Uuid, - env_uuid: Uuid, -) -> Result { - let environment = - get_environment_service(svc_ctx, workspace_uuid, team_uuid, user_uuid, env_uuid).await?; - - let config = get_environment_config_service(svc_ctx, env_uuid).await.ok(); - let cookies = get_environment_cookies_service(svc_ctx, env_uuid) - .await - .unwrap_or_default(); - let urls = get_environment_urls_service(svc_ctx, env_uuid) - .await - .unwrap_or_default(); - - let tags = get_environment_tags_service(svc_ctx, env_uuid).await?; - - let accounts = accounts::get_environment_accounts_service(svc_ctx, env_uuid) - .await - .unwrap_or_default(); - - // 获取分组信息 - let group = if let Some(group_uuid) = environment.group_uuid { - get_group_summary_service(svc_ctx, group_uuid).await.ok() - } else { - None - }; - - // 获取代理信息 - let proxy = if let Some(proxy_uuid) = environment.proxy_uuid { - get_proxy_summary_service(svc_ctx, proxy_uuid).await.ok() - } else { - None - }; - - Ok(crate::entitys::EnvironmentDetailResponse { - environment, - config, - cookies, - urls, - tags, - accounts, - group, - proxy, + } + }) + .collect(); + + // 返回过滤后的环境列表和数据库总数(用于分页显示) + // 注意:total 是数据库中符合条件的总数,不考虑权限过滤 + // 权限过滤只影响当前页返回的数据,不影响总数统计 + Ok((environments, total_count)) +} + +/// 获取环境详情 +pub async fn get_environment_service( + svc_ctx: &SvcCtx, + workspace_uuid: Uuid, + team_uuid: Uuid, + user_uuid: Uuid, + env_uuid: Uuid, +) -> Result { + // 1. 检查用户是否在当前工作空间的团队中 + let _team_member = + models::teams::fetch_team_member(&svc_ctx.db, workspace_uuid, team_uuid, user_uuid) + .await + .map_err(|e| e.to_string())? + .ok_or_else(|| "您不是该团队的成员".to_string())?; + + // 2. 查询环境(带工作空间过滤) + let environment = models::fetch_environment_by_uuid(&svc_ctx.db, workspace_uuid, env_uuid) + .await + .map_err(|e| e.to_string())? + .ok_or_else(|| "环境不存在或不属于当前工作空间".to_string())?; + + // 3. 验证环境属于指定团队 + if environment.team_uuid != team_uuid { + return Err("环境不属于指定团队".to_string()); + } + + // 4. 权限检查 + if let Some(group_uuid) = environment.group_uuid { + // 如果环境有分组,检查用户是否有分组的 read/write/manage 权限 + // Owner/Admin 自动拥有所有分组权限(已在 check_group_permission 中处理) + let has_permission = models::check_group_permission( + &svc_ctx.db, + workspace_uuid, + group_uuid, + user_uuid, + "read", + ) + .await + .map_err(|e| e.to_string())?; + + if !has_permission { + return Err("您没有查看该环境的权限".to_string()); + } + } + // 如果环境无分组,所有团队成员都可以查看(已在团队成员检查中验证) + + Ok(environment) +} + +/// 获取环境配置 +pub async fn get_environment_config_service( + svc_ctx: &SvcCtx, + env_uuid: Uuid, +) -> Result { + models::fetch_environment_config(&svc_ctx.db, env_uuid) + .await + .map_err(|e| e.to_string())? + .ok_or_else(|| "环境配置不存在".to_string()) +} + +/// 获取环境的标签 +pub async fn get_environment_tags_service( + svc_ctx: &SvcCtx, + env_uuid: Uuid, +) -> Result, String> { + models::fetch_environment_tags(&svc_ctx.db, env_uuid) + .await + .map_err(|e| e.to_string()) +} + +/// 获取环境详情(包含完整关联数据) +pub async fn get_environment_detail_service( + svc_ctx: &SvcCtx, + workspace_uuid: Uuid, + team_uuid: Uuid, + user_uuid: Uuid, + env_uuid: Uuid, +) -> Result { + let environment = + get_environment_service(svc_ctx, workspace_uuid, team_uuid, user_uuid, env_uuid).await?; + + let config = get_environment_config_service(svc_ctx, env_uuid).await.ok(); + let cookies = get_environment_cookies_service(svc_ctx, env_uuid).await.unwrap_or_default(); + let urls = get_environment_urls_service(svc_ctx, env_uuid).await.unwrap_or_default(); + + let tags = get_environment_tags_service(svc_ctx, env_uuid).await?; + + let accounts = accounts::get_environment_accounts_service(svc_ctx, env_uuid) + .await + .unwrap_or_default(); + + // 获取分组信息 + let group = if let Some(group_uuid) = environment.group_uuid { + get_group_summary_service(svc_ctx, group_uuid).await.ok() + } else { + None + }; + + // 获取代理信息 + let proxy = if let Some(proxy_uuid) = environment.proxy_uuid { + get_proxy_summary_service(svc_ctx, proxy_uuid).await.ok() + } else { + None + }; + + Ok(crate::entitys::EnvironmentDetailResponse { + environment, + config, + cookies, + urls, + tags, + accounts, + group, + proxy, extensions: Vec::new(), - }) -} - -/// 获取分组摘要信息 -pub async fn get_group_summary_service( - svc_ctx: &SvcCtx, - group_uuid: Uuid, -) -> Result { - let group = models::fetch_group_by_uuid(&svc_ctx.db, group_uuid) - .await - .map_err(|e| e.to_string())? - .ok_or_else(|| "分组不存在".to_string())?; - - Ok(crate::dto::GroupSummaryDto { - id: group.id, - uuid: group.uuid, - name: group.name, - description: group.description, - sort_order: group.sort_order, - }) -} - -/// 获取代理摘要信息 -pub async fn get_proxy_summary_service( - svc_ctx: &SvcCtx, - proxy_uuid: Uuid, -) -> Result { - let proxy = crate::models::proxies::fetch_proxy_by_uuid(&svc_ctx.db, proxy_uuid) - .await - .map_err(|e| e.to_string())? - .ok_or_else(|| "代理不存在".to_string())?; - - Ok(crate::dto::ProxySummaryDto { - id: proxy.id, - uuid: proxy.uuid, - name: proxy.name, - host: proxy.host, - port: proxy.port, - proxy_type: proxy.proxy_type, - username: proxy.username, - password: proxy.password, - country: proxy.country, - city: proxy.city, - status: proxy.status, - latency: proxy.latency, - last_check_ip: proxy.last_check_ip, - }) -} - -/// 更新环境 -pub async fn update_environment_service( - svc_ctx: &SvcCtx, - workspace_uuid: Uuid, - team_uuid: Uuid, - user_uuid: Uuid, - payload: &UpdateEnvironmentRequest, -) -> Result<(), String> { - // 1. 检查用户是否在当前工作空间的团队中 - let team_member = - models::teams::fetch_team_member(&svc_ctx.db, workspace_uuid, team_uuid, user_uuid) - .await - .map_err(|e| e.to_string())? - .ok_or_else(|| "您不是该团队的成员".to_string())?; - - // 2. 查询环境(带工作空间过滤) - let environment = models::fetch_environment_by_uuid(&svc_ctx.db, workspace_uuid, payload.uuid) - .await - .map_err(|e| e.to_string())? - .ok_or_else(|| "环境不存在或不属于当前工作空间".to_string())?; - - // 3. 验证环境属于指定团队 - if environment.team_uuid != team_uuid { - return Err("环境不属于指定团队".to_string()); - } - - // 4. 权限检查 - if let Some(group_uuid) = environment.group_uuid { - // 如果环境有分组,检查用户是否有分组的 write 或 manage 权限 - let has_write = models::check_group_permission( - &svc_ctx.db, - workspace_uuid, - group_uuid, - user_uuid, - "write", - ) - .await - .map_err(|e| e.to_string())?; - - let has_manage = models::check_group_permission( - &svc_ctx.db, - workspace_uuid, - group_uuid, - user_uuid, - "manage", - ) - .await - .map_err(|e| e.to_string())?; - - if !has_write && !has_manage { - return Err("您没有编辑该环境的权限".to_string()); - } - } else { - // 如果环境无分组,检查用户是否有团队级别的编辑权限(Editor/Admin/Owner) - let can_edit = matches!(team_member.role.as_str(), "owner" | "admin" | "editor"); - if !can_edit { - return Err("您没有编辑环境的权限,需要 Editor 及以上角色".to_string()); - } - } - - models::update_environment( - &svc_ctx.db, - payload.uuid, - payload.name.as_deref(), - payload.description.as_deref(), - payload.group_uuid, - ) - .await - .map_err(|e| e.to_string())?; - - // 更新配置 - if let Some(config) = &payload.config { - models::upsert_environment_config( - &svc_ctx.db, - payload.uuid, - &config.window_info, - &config.basic_settings, - &config.fingerprint_settings, - &config.device_settings, - &config.preference_settings, - &config.project_metadata, - ) - .await - .map_err(|e| e.to_string())?; - } - - replace_environment_urls(svc_ctx, payload.uuid, payload.urls.as_deref()).await?; - replace_environment_cookies(svc_ctx, payload.uuid, payload.cookies.as_deref()).await?; - - Ok(()) -} - -/// 设置环境代理 -pub async fn set_environment_proxy_service( - svc_ctx: &SvcCtx, - payload: &SetEnvironmentProxyRequest, -) -> Result<(), String> { - models::update_environment_proxy(&svc_ctx.db, payload.uuid, payload.proxy_uuid) - .await - .map_err(|e| e.to_string()) -} - -/// 分配标签 -pub async fn assign_tags_service( - svc_ctx: &SvcCtx, - payload: &AssignTagsRequest, -) -> Result<(), String> { - for tag_uuid in &payload.tag_uuids { - models::insert_environment_tag(&svc_ctx.db, payload.uuid, *tag_uuid) - .await - .map_err(|e| e.to_string())?; - } - Ok(()) -} - -/// 移除标签 -pub async fn remove_tag_service( - svc_ctx: &SvcCtx, - env_uuid: Uuid, - tag_uuid: Uuid, -) -> Result<(), String> { - models::remove_environment_tag(&svc_ctx.db, env_uuid, tag_uuid) - .await - .map_err(|e| e.to_string()) -} - -/// 移动到分组 -pub async fn move_to_group_service( - svc_ctx: &SvcCtx, - payload: &MoveToGroupRequest, -) -> Result<(), String> { - models::update_environment(&svc_ctx.db, payload.uuid, None, None, payload.group_uuid) - .await - .map_err(|e| e.to_string()) -} - -/// 批量移动到分组 -pub async fn batch_move_to_group_service( - svc_ctx: &SvcCtx, - payload: &BatchMoveToGroupRequest, -) -> Result<(), String> { - for env_uuid in &payload.env_uuids { - models::update_environment(&svc_ctx.db, *env_uuid, None, None, Some(payload.group_uuid)) - .await - .map_err(|e| e.to_string())?; - } - Ok(()) -} - -/// 批量分配标签 -pub async fn batch_assign_tags_service( - svc_ctx: &SvcCtx, - payload: &BatchAssignTagRequest, -) -> Result<(), String> { - for env_uuid in &payload.env_uuids { - models::insert_environment_tag(&svc_ctx.db, *env_uuid, payload.tag_uuid) - .await - .map_err(|e| e.to_string())?; - } - Ok(()) -} - -/// 批量移除标签 -pub async fn batch_remove_tags_service( - svc_ctx: &SvcCtx, - payload: &BatchRemoveTagsRequest, -) -> Result<(), String> { - if let Some(tag_uuid) = payload.tag_uuid { - // 移除指定的标签 - for env_uuid in &payload.env_uuids { - models::remove_environment_tag(&svc_ctx.db, *env_uuid, tag_uuid) - .await - .map_err(|e| e.to_string())?; - } - } else { - // 移除所有标签 - for env_uuid in &payload.env_uuids { - models::clear_environment_tags(&svc_ctx.db, *env_uuid) - .await - .map_err(|e| e.to_string())?; - } - } - Ok(()) -} - -/// 删除环境 -pub async fn delete_environment_service( - svc_ctx: &SvcCtx, - workspace_uuid: Uuid, - team_uuid: Uuid, - user_uuid: Uuid, - env_uuid: Uuid, -) -> Result<(), String> { - // 1. 检查用户是否在当前工作空间的团队中 - let team_member = - models::teams::fetch_team_member(&svc_ctx.db, workspace_uuid, team_uuid, user_uuid) - .await - .map_err(|e| e.to_string())? - .ok_or_else(|| "您不是该团队的成员".to_string())?; - - // 2. 查询环境(带工作空间过滤) - let environment = models::fetch_environment_by_uuid(&svc_ctx.db, workspace_uuid, env_uuid) - .await - .map_err(|e| e.to_string())? - .ok_or_else(|| "环境不存在或不属于当前工作空间".to_string())?; - - // 3. 验证环境属于指定团队 - if environment.team_uuid != team_uuid { - return Err("环境不属于指定团队".to_string()); - } - - // 4. 权限检查 - if let Some(group_uuid) = environment.group_uuid { - // 如果环境有分组,检查用户是否有分组的 manage 权限 - let has_manage = models::check_group_permission( - &svc_ctx.db, - workspace_uuid, - group_uuid, - user_uuid, - "manage", - ) - .await - .map_err(|e| e.to_string())?; - - if !has_manage { - return Err("您没有删除该环境的权限".to_string()); - } - } else { - // 如果环境无分组,检查用户是否有团队级别的删除权限(Owner/Admin) - let can_delete = matches!(team_member.role.as_str(), "owner" | "admin"); - if !can_delete { - return Err("您没有删除环境的权限,需要 Owner 或 Admin 角色".to_string()); - } - } - - // 5. 删除环境 - models::delete_environment(&svc_ctx.db, env_uuid) - .await - .map_err(|e| e.to_string())?; - - // 6. 更新工作空间配额(删除后减少使用数) - models::decrement_used_environments(&svc_ctx.db, workspace_uuid, 1) - .await - .map_err(|e| format!("更新配额失败: {}", e))?; - - Ok(()) -} - -/// 批量删除环境 -pub async fn batch_delete_environments_service( - svc_ctx: &SvcCtx, - env_uuids: &[Uuid], -) -> Result { - models::batch_delete_environments(&svc_ctx.db, env_uuids) - .await - .map_err(|e| e.to_string()) -} - -// ============ Recycle Bin ============ - -/// 查询回收站环境列表 -pub async fn get_recycle_bin_environments_service( - svc_ctx: &SvcCtx, - workspace_uuid: Uuid, - team_uuid: Uuid, - user_uuid: Uuid, - payload: &ListEnvironmentsRequest, -) -> Result<(Vec, i64), String> { - // 1. 提取过滤参数 - let group_uuid = payload.filters.as_ref().and_then(|f| f.group_uuid); - let keyword = payload.filters.as_ref().and_then(|f| f.keyword.as_deref()); - - // 2. 计算分页参数 - let page = payload.pagination.page; - let page_size = payload.pagination.page_size; - let offset = (page - 1) * page_size; - let limit = page_size; - - // 3. 查询回收站环境总数 - let total_count = models::fetch_deleted_environments_count( - &svc_ctx.db, - workspace_uuid, - team_uuid, - group_uuid, - keyword, - ) - .await - .map_err(|e| e.to_string())?; - - // 4. 查询回收站环境基础列表 - let env_rows = models::fetch_deleted_environments_base( - &svc_ctx.db, - workspace_uuid, - team_uuid, - group_uuid, - keyword, - offset, - limit, - ) - .await - .map_err(|e| e.to_string())?; - - if env_rows.is_empty() { - return Ok((vec![], total_count)); - } - - // 5. 获取所有环境的 UUID - let env_uuids: Vec = env_rows.iter().map(|e| e.uuid).collect(); - let group_uuids: Vec = env_rows.iter().filter_map(|e| e.group_uuid).collect(); - let proxy_uuids: Vec = env_rows.iter().filter_map(|e| e.proxy_uuid).collect(); - - // 6. 批量查询分组信息 - let group_rows = if !group_uuids.is_empty() { - models::fetch_groups_by_uuids(&svc_ctx.db, &group_uuids) - .await - .map_err(|e| e.to_string())? - } else { - vec![] - }; - let group_map: HashMap = group_rows - .into_iter() - .map(|g| { - ( - g.uuid, - GroupSummaryDto { - id: g.id, - uuid: g.uuid, - name: g.name, - description: g.description, - sort_order: g.sort_order, - }, - ) - }) - .collect(); - - // 7. 批量查询代理信息 - let proxy_rows = if !proxy_uuids.is_empty() { - models::fetch_proxies_by_uuids(&svc_ctx.db, &proxy_uuids) - .await - .map_err(|e| e.to_string())? - } else { - vec![] - }; - let proxy_map: HashMap = proxy_rows - .into_iter() - .map(|p| { - ( - p.uuid, - ProxySummaryDto { - id: p.id, - uuid: p.uuid, - name: p.name, - host: p.host, - port: p.port, - proxy_type: p.proxy_type, - username: p.username, - password: p.password, - country: p.country, - city: p.city, - status: p.status, - latency: p.latency, - last_check_ip: p.last_check_ip, - }, - ) - }) - .collect(); - - // 8. 批量查询标签信息 - let tag_rows = if !env_uuids.is_empty() { - models::fetch_tags_for_environments(&svc_ctx.db, &env_uuids) - .await - .map_err(|e| e.to_string())? - } else { - vec![] - }; - let mut env_tags_map: HashMap> = HashMap::new(); - for tag_row in tag_rows { - env_tags_map.entry(tag_row.environment_uuid).or_default().push(TagDto { - id: tag_row.tag_id, - uuid: tag_row.tag_uuid, - user_uuid: tag_row.tag_user_uuid, - team_uuid: tag_row.tag_team_uuid, - name: tag_row.tag_name, - color: tag_row.tag_color, - sort_order: tag_row.tag_sort_order, - environments_count: tag_row.tag_environments_count, - created_at: tag_row.tag_created_at, - updated_at: tag_row.tag_updated_at, - deleted_at: tag_row.tag_deleted_at, - }); - } - - let mut env_urls_map: HashMap> = HashMap::new(); - let url_rows = models::fetch_environment_urls_by_uuids(&svc_ctx.db, &env_uuids) - .await - .map_err(|e| e.to_string())?; - for url in url_rows { - env_urls_map - .entry(url.environment_uuid) - .or_default() - .push(url); - } - - let mut env_cookies_map: HashMap> = HashMap::new(); - let cookie_rows = models::fetch_environment_cookies_by_uuids(&svc_ctx.db, &env_uuids) - .await - .map_err(|e| e.to_string())?; - let mut grouped_cookie_rows: HashMap> = HashMap::new(); - for cookie in cookie_rows { - grouped_cookie_rows - .entry(cookie.environment_uuid) - .or_default() - .push(cookie); - } - for (environment_uuid, rows) in grouped_cookie_rows { - env_cookies_map - .insert(environment_uuid, group_cookie_rows(rows)); - } - - // 9. 批量查询账号信息 - let mut env_accounts_map: HashMap> = HashMap::new(); - for env_uuid in &env_uuids { - let accounts = accounts::get_environment_accounts_service(svc_ctx, *env_uuid) - .await - .unwrap_or_default(); - if !accounts.is_empty() { - env_accounts_map.insert(*env_uuid, accounts); - } - } - - // 10. 组装完整的环境信息 - let environments: Vec = env_rows - .into_iter() - .map(|row| { - let group = row.group_uuid.and_then(|gid| group_map.get(&gid).cloned()); - let proxy = row.proxy_uuid.and_then(|pid| proxy_map.get(&pid).cloned()); - let tags = env_tags_map.get(&row.uuid).cloned().unwrap_or_default(); - let accounts = env_accounts_map.get(&row.uuid).cloned().unwrap_or_default(); - - crate::entitys::EnvironmentDetailResponse { - environment: EnvironmentDto { - id: row.id, - uuid: row.uuid, - workspace_uuid: row.workspace_uuid, - user_uuid: row.user_uuid, - team_uuid: row.team_uuid, - name: row.name, - description: row.description, - status: row.status, - group_uuid: row.group_uuid, - proxy_uuid: row.proxy_uuid, - system_info: row.system_info, - kernel_info: row.kernel_info, - fingerprint_summary: row.fingerprint_summary, - last_opened_at: row.last_opened_at, - created_at: row.created_at, - updated_at: row.updated_at, - deleted_at: None, - }, - config: None, - cookies: env_cookies_map.remove(&row.uuid).unwrap_or_default(), - urls: env_urls_map.remove(&row.uuid).unwrap_or_default(), - tags, - accounts, - group, - proxy, - extensions: vec![], // 回收站不需要返回扩展数据 - } - }) - .collect(); - - Ok((environments, total_count)) -} - -/// 恢复环境 -pub async fn restore_environment_service(svc_ctx: &SvcCtx, env_uuid: Uuid) -> Result<(), String> { - models::restore_environment(&svc_ctx.db, env_uuid) - .await - .map_err(|e| e.to_string()) -} - -/// 批量恢复环境 -pub async fn batch_restore_environments_service( - svc_ctx: &SvcCtx, - env_uuids: &[Uuid], -) -> Result { - models::batch_restore_environments(&svc_ctx.db, env_uuids) - .await - .map_err(|e| e.to_string()) -} - -/// 永久删除环境 -pub async fn permanent_delete_environment_service( - svc_ctx: &SvcCtx, - env_uuid: Uuid, - workspace_uuid: Uuid, -) -> Result<(), String> { - // 永久删除环境 - models::permanent_delete_environment(&svc_ctx.db, env_uuid) - .await - .map_err(|e| e.to_string())?; - - // 更新工作空间配额(永久删除后减少使用数) - models::decrement_used_environments(&svc_ctx.db, workspace_uuid, 1) - .await - .map_err(|e| format!("更新配额失败: {}", e))?; - - Ok(()) -} - -/// 批量永久删除环境 -pub async fn batch_permanent_delete_environments_service( - svc_ctx: &SvcCtx, - env_uuids: &[Uuid], - workspace_uuid: Uuid, -) -> Result { - let count = models::batch_permanent_delete_environments(&svc_ctx.db, env_uuids) - .await - .map_err(|e| e.to_string())?; - - // 更新工作空间配额 - if count > 0 { - models::decrement_used_environments(&svc_ctx.db, workspace_uuid, count as i32) - .await - .map_err(|e| format!("更新配额失败: {}", e))?; - } - - Ok(count) -} - -// ============ Environment URLs ============ - -/// 添加环境 URL -pub async fn add_environment_url_service( - svc_ctx: &SvcCtx, - payload: &AddEnvironmentUrlRequest, -) -> Result { - models::insert_environment_url( - &svc_ctx.db, - payload.environment_uuid, - &payload.url, - payload.title.as_deref(), - payload.sort_order, - ) - .await - .map_err(|e| e.to_string()) -} - -/// 获取环境的所有 URL -pub async fn get_environment_urls_service( - svc_ctx: &SvcCtx, - env_uuid: Uuid, -) -> Result, String> { - models::fetch_environment_urls(&svc_ctx.db, env_uuid) - .await - .map_err(|e| e.to_string()) -} - -/// 删除环境 URL -pub async fn delete_environment_url_service(svc_ctx: &SvcCtx, url_id: i32) -> Result<(), String> { - models::delete_environment_url(&svc_ctx.db, url_id) - .await - .map_err(|e| e.to_string()) -} - -/// 清空环境的所有 URL -pub async fn clear_environment_urls_service( - svc_ctx: &SvcCtx, - env_uuid: Uuid, -) -> Result { - models::clear_environment_urls(&svc_ctx.db, env_uuid) - .await - .map_err(|e| e.to_string()) -} - -// ============ Environment Cookies ============ - -/// 添加环境 Cookie -pub async fn add_environment_cookie_service( - svc_ctx: &SvcCtx, - payload: &AddEnvironmentCookieRequest, -) -> Result { - let cookies = parse_cookie_group(&CookieGroupInput { - site: payload.site.clone(), - cookie_text: payload.cookie_text.clone(), - })?; - - let mut last_id = 0; - for cookie in cookies { - last_id = models::insert_environment_cookie( - &svc_ctx.db, - payload.environment_uuid, - &cookie.site_input, - &cookie.domain, - &cookie.name, - &cookie.value, - cookie.path.as_deref(), - cookie.http_only, - cookie.secure, - cookie.same_site.as_deref(), - ) - .await - .map_err(|e| e.to_string())?; - } - - Ok(last_id) -} - -/// 获取环境的所有 Cookies -pub async fn get_environment_cookies_service( - svc_ctx: &SvcCtx, - env_uuid: Uuid, -) -> Result, String> { - let rows = models::fetch_environment_cookies(&svc_ctx.db, env_uuid) - .await - .map_err(|e| e.to_string())?; - - Ok(group_cookie_rows(rows)) -} - -/// 删除环境 Cookie -pub async fn delete_environment_cookie_service( - svc_ctx: &SvcCtx, - cookie_id: i32, -) -> Result<(), String> { - models::delete_environment_cookie(&svc_ctx.db, cookie_id) - .await - .map_err(|e| e.to_string()) -} - -/// 清空环境的所有 Cookies -pub async fn clear_environment_cookies_service( - svc_ctx: &SvcCtx, - env_uuid: Uuid, -) -> Result { - models::clear_environment_cookies(&svc_ctx.db, env_uuid) - .await - .map_err(|e| e.to_string()) -} - -/// 批量创建环境 -pub async fn batch_create_environments_service( - svc_ctx: &SvcCtx, - user_uuid: Uuid, - workspace_uuid: Uuid, - team_uuid: Uuid, - payload: &BatchCreateEnvironmentRequest, -) -> Result, String> { - let mut created_uuids = Vec::new(); - - for env_request in &payload.environments { - let env_uuid = - create_environment_service(svc_ctx, user_uuid, workspace_uuid, team_uuid, env_request) - .await - .map_err(|e| format!("创建环境 '{}' 失败: {}", env_request.name, e))?; - - created_uuids.push(env_uuid); - } - - Ok(created_uuids) -} + }) +} + +/// 获取分组摘要信息 +pub async fn get_group_summary_service( + svc_ctx: &SvcCtx, + group_uuid: Uuid, +) -> Result { + let group = models::fetch_group_by_uuid(&svc_ctx.db, group_uuid) + .await + .map_err(|e| e.to_string())? + .ok_or_else(|| "分组不存在".to_string())?; + + Ok(crate::dto::GroupSummaryDto { + id: group.id, + uuid: group.uuid, + name: group.name, + description: group.description, + sort_order: group.sort_order, + }) +} + +/// 获取代理摘要信息 +pub async fn get_proxy_summary_service( + svc_ctx: &SvcCtx, + proxy_uuid: Uuid, +) -> Result { + let proxy = crate::models::proxies::fetch_proxy_by_uuid(&svc_ctx.db, proxy_uuid) + .await + .map_err(|e| e.to_string())? + .ok_or_else(|| "代理不存在".to_string())?; + + Ok(crate::dto::ProxySummaryDto { + id: proxy.id, + uuid: proxy.uuid, + name: proxy.name, + host: proxy.host, + port: proxy.port, + proxy_type: proxy.proxy_type, + username: proxy.username, + password: proxy.password, + country: proxy.country, + city: proxy.city, + status: proxy.status, + latency: proxy.latency, + last_check_ip: proxy.last_check_ip, + }) +} + +/// 更新环境 +pub async fn update_environment_service( + svc_ctx: &SvcCtx, + workspace_uuid: Uuid, + team_uuid: Uuid, + user_uuid: Uuid, + payload: &UpdateEnvironmentRequest, +) -> Result<(), String> { + // 1. 检查用户是否在当前工作空间的团队中 + let team_member = + models::teams::fetch_team_member(&svc_ctx.db, workspace_uuid, team_uuid, user_uuid) + .await + .map_err(|e| e.to_string())? + .ok_or_else(|| "您不是该团队的成员".to_string())?; + + // 2. 查询环境(带工作空间过滤) + let environment = models::fetch_environment_by_uuid(&svc_ctx.db, workspace_uuid, payload.uuid) + .await + .map_err(|e| e.to_string())? + .ok_or_else(|| "环境不存在或不属于当前工作空间".to_string())?; + + // 3. 验证环境属于指定团队 + if environment.team_uuid != team_uuid { + return Err("环境不属于指定团队".to_string()); + } + + // 4. 权限检查 + if let Some(group_uuid) = environment.group_uuid { + // 如果环境有分组,检查用户是否有分组的 write 或 manage 权限 + let has_write = models::check_group_permission( + &svc_ctx.db, + workspace_uuid, + group_uuid, + user_uuid, + "write", + ) + .await + .map_err(|e| e.to_string())?; + + let has_manage = models::check_group_permission( + &svc_ctx.db, + workspace_uuid, + group_uuid, + user_uuid, + "manage", + ) + .await + .map_err(|e| e.to_string())?; + + if !has_write && !has_manage { + return Err("您没有编辑该环境的权限".to_string()); + } + } else { + // 如果环境无分组,检查用户是否有团队级别的编辑权限(Editor/Admin/Owner) + let can_edit = matches!(team_member.role.as_str(), "owner" | "admin" | "editor"); + if !can_edit { + return Err("您没有编辑环境的权限,需要 Editor 及以上角色".to_string()); + } + } + + models::update_environment( + &svc_ctx.db, + payload.uuid, + payload.name.as_deref(), + payload.description.as_deref(), + payload.group_uuid, + ) + .await + .map_err(|e| e.to_string())?; + + // 更新配置 + if let Some(config) = &payload.config { + models::upsert_environment_config( + &svc_ctx.db, + payload.uuid, + &config.window_info, + &config.basic_settings, + &config.fingerprint_settings, + &config.device_settings, + &config.preference_settings, + &config.project_metadata, + ) + .await + .map_err(|e| e.to_string())?; + } + + replace_environment_urls(svc_ctx, payload.uuid, payload.urls.as_deref()).await?; + replace_environment_cookies(svc_ctx, payload.uuid, payload.cookies.as_deref()).await?; + + Ok(()) +} + +/// 设置环境代理 +pub async fn set_environment_proxy_service( + svc_ctx: &SvcCtx, + payload: &SetEnvironmentProxyRequest, +) -> Result<(), String> { + models::update_environment_proxy(&svc_ctx.db, payload.uuid, payload.proxy_uuid) + .await + .map_err(|e| e.to_string()) +} + +/// 分配标签 +pub async fn assign_tags_service( + svc_ctx: &SvcCtx, + payload: &AssignTagsRequest, +) -> Result<(), String> { + for tag_uuid in &payload.tag_uuids { + models::insert_environment_tag(&svc_ctx.db, payload.uuid, *tag_uuid) + .await + .map_err(|e| e.to_string())?; + } + Ok(()) +} + +/// 移除标签 +pub async fn remove_tag_service( + svc_ctx: &SvcCtx, + env_uuid: Uuid, + tag_uuid: Uuid, +) -> Result<(), String> { + models::remove_environment_tag(&svc_ctx.db, env_uuid, tag_uuid) + .await + .map_err(|e| e.to_string()) +} + +/// 移动到分组 +pub async fn move_to_group_service( + svc_ctx: &SvcCtx, + payload: &MoveToGroupRequest, +) -> Result<(), String> { + models::update_environment(&svc_ctx.db, payload.uuid, None, None, payload.group_uuid) + .await + .map_err(|e| e.to_string()) +} + +/// 批量移动到分组 +pub async fn batch_move_to_group_service( + svc_ctx: &SvcCtx, + payload: &BatchMoveToGroupRequest, +) -> Result<(), String> { + for env_uuid in &payload.env_uuids { + models::update_environment(&svc_ctx.db, *env_uuid, None, None, Some(payload.group_uuid)) + .await + .map_err(|e| e.to_string())?; + } + Ok(()) +} + +/// 批量分配标签 +pub async fn batch_assign_tags_service( + svc_ctx: &SvcCtx, + payload: &BatchAssignTagRequest, +) -> Result<(), String> { + for env_uuid in &payload.env_uuids { + models::insert_environment_tag(&svc_ctx.db, *env_uuid, payload.tag_uuid) + .await + .map_err(|e| e.to_string())?; + } + Ok(()) +} + +/// 批量移除标签 +pub async fn batch_remove_tags_service( + svc_ctx: &SvcCtx, + payload: &BatchRemoveTagsRequest, +) -> Result<(), String> { + if let Some(tag_uuid) = payload.tag_uuid { + // 移除指定的标签 + for env_uuid in &payload.env_uuids { + models::remove_environment_tag(&svc_ctx.db, *env_uuid, tag_uuid) + .await + .map_err(|e| e.to_string())?; + } + } else { + // 移除所有标签 + for env_uuid in &payload.env_uuids { + models::clear_environment_tags(&svc_ctx.db, *env_uuid) + .await + .map_err(|e| e.to_string())?; + } + } + Ok(()) +} + +/// 删除环境 +pub async fn delete_environment_service( + svc_ctx: &SvcCtx, + workspace_uuid: Uuid, + team_uuid: Uuid, + user_uuid: Uuid, + env_uuid: Uuid, +) -> Result<(), String> { + // 1. 检查用户是否在当前工作空间的团队中 + let team_member = + models::teams::fetch_team_member(&svc_ctx.db, workspace_uuid, team_uuid, user_uuid) + .await + .map_err(|e| e.to_string())? + .ok_or_else(|| "您不是该团队的成员".to_string())?; + + // 2. 查询环境(带工作空间过滤) + let environment = models::fetch_environment_by_uuid(&svc_ctx.db, workspace_uuid, env_uuid) + .await + .map_err(|e| e.to_string())? + .ok_or_else(|| "环境不存在或不属于当前工作空间".to_string())?; + + // 3. 验证环境属于指定团队 + if environment.team_uuid != team_uuid { + return Err("环境不属于指定团队".to_string()); + } + + // 4. 权限检查 + if let Some(group_uuid) = environment.group_uuid { + // 如果环境有分组,检查用户是否有分组的 manage 权限 + let has_manage = models::check_group_permission( + &svc_ctx.db, + workspace_uuid, + group_uuid, + user_uuid, + "manage", + ) + .await + .map_err(|e| e.to_string())?; + + if !has_manage { + return Err("您没有删除该环境的权限".to_string()); + } + } else { + // 如果环境无分组,检查用户是否有团队级别的删除权限(Owner/Admin) + let can_delete = matches!(team_member.role.as_str(), "owner" | "admin"); + if !can_delete { + return Err("您没有删除环境的权限,需要 Owner 或 Admin 角色".to_string()); + } + } + + // 5. 删除环境 + models::delete_environment(&svc_ctx.db, env_uuid) + .await + .map_err(|e| e.to_string())?; + + // 6. 更新工作空间配额(删除后减少使用数) + models::decrement_used_environments(&svc_ctx.db, workspace_uuid, 1) + .await + .map_err(|e| format!("更新配额失败: {}", e))?; + + Ok(()) +} + +/// 批量删除环境 +pub async fn batch_delete_environments_service( + svc_ctx: &SvcCtx, + env_uuids: &[Uuid], +) -> Result { + models::batch_delete_environments(&svc_ctx.db, env_uuids) + .await + .map_err(|e| e.to_string()) +} + +// ============ Recycle Bin ============ + +/// 查询回收站环境列表 +pub async fn get_recycle_bin_environments_service( + svc_ctx: &SvcCtx, + workspace_uuid: Uuid, + team_uuid: Uuid, + _user_uuid: Uuid, + payload: &ListEnvironmentsRequest, +) -> Result<(Vec, i64), String> { + // 1. 提取过滤参数 + let group_uuid = payload.filters.as_ref().and_then(|f| f.group_uuid); + let keyword = payload.filters.as_ref().and_then(|f| f.keyword.as_deref()); + + // 2. 计算分页参数 + let page = payload.pagination.page; + let page_size = payload.pagination.page_size; + let offset = (page - 1) * page_size; + let limit = page_size; + + // 3. 查询回收站环境总数 + let total_count = models::fetch_deleted_environments_count( + &svc_ctx.db, + workspace_uuid, + team_uuid, + group_uuid, + keyword, + ) + .await + .map_err(|e| e.to_string())?; + + // 4. 查询回收站环境基础列表 + let env_rows = models::fetch_deleted_environments_base( + &svc_ctx.db, + workspace_uuid, + team_uuid, + group_uuid, + keyword, + offset, + limit, + ) + .await + .map_err(|e| e.to_string())?; + + if env_rows.is_empty() { + return Ok((vec![], total_count)); + } + + // 5. 获取所有环境的 UUID + let env_uuids: Vec = env_rows.iter().map(|e| e.uuid).collect(); + let group_uuids: Vec = env_rows.iter().filter_map(|e| e.group_uuid).collect(); + let proxy_uuids: Vec = env_rows.iter().filter_map(|e| e.proxy_uuid).collect(); + + // 6. 批量查询分组信息 + let group_rows = if !group_uuids.is_empty() { + models::fetch_groups_by_uuids(&svc_ctx.db, &group_uuids) + .await + .map_err(|e| e.to_string())? + } else { + vec![] + }; + let group_map: HashMap = group_rows + .into_iter() + .map(|g| { + ( + g.uuid, + GroupSummaryDto { + id: g.id, + uuid: g.uuid, + name: g.name, + description: g.description, + sort_order: g.sort_order, + }, + ) + }) + .collect(); + + // 7. 批量查询代理信息 + let proxy_rows = if !proxy_uuids.is_empty() { + models::fetch_proxies_by_uuids(&svc_ctx.db, &proxy_uuids) + .await + .map_err(|e| e.to_string())? + } else { + vec![] + }; + let proxy_map: HashMap = proxy_rows + .into_iter() + .map(|p| { + ( + p.uuid, + ProxySummaryDto { + id: p.id, + uuid: p.uuid, + name: p.name, + host: p.host, + port: p.port, + proxy_type: p.proxy_type, + username: p.username, + password: p.password, + country: p.country, + city: p.city, + status: p.status, + latency: p.latency, + last_check_ip: p.last_check_ip, + }, + ) + }) + .collect(); + + // 8. 批量查询标签信息 + let tag_rows = if !env_uuids.is_empty() { + models::fetch_tags_for_environments(&svc_ctx.db, &env_uuids) + .await + .map_err(|e| e.to_string())? + } else { + vec![] + }; + let mut env_tags_map: HashMap> = HashMap::new(); + for tag_row in tag_rows { + env_tags_map.entry(tag_row.environment_uuid).or_default().push(TagDto { + id: tag_row.tag_id, + uuid: tag_row.tag_uuid, + user_uuid: tag_row.tag_user_uuid, + team_uuid: tag_row.tag_team_uuid, + name: tag_row.tag_name, + color: tag_row.tag_color, + sort_order: tag_row.tag_sort_order, + environments_count: tag_row.tag_environments_count, + created_at: tag_row.tag_created_at, + updated_at: tag_row.tag_updated_at, + deleted_at: tag_row.tag_deleted_at, + }); + } + + let mut env_urls_map: HashMap> = HashMap::new(); + let url_rows = models::fetch_environment_urls_by_uuids(&svc_ctx.db, &env_uuids) + .await + .map_err(|e| e.to_string())?; + for url in url_rows { + env_urls_map.entry(url.environment_uuid).or_default().push(url); + } + + let mut env_cookies_map: HashMap> = HashMap::new(); + let cookie_rows = models::fetch_environment_cookies_by_uuids(&svc_ctx.db, &env_uuids) + .await + .map_err(|e| e.to_string())?; + let mut grouped_cookie_rows: HashMap> = HashMap::new(); + for cookie in cookie_rows { + grouped_cookie_rows.entry(cookie.environment_uuid).or_default().push(cookie); + } + for (environment_uuid, rows) in grouped_cookie_rows { + env_cookies_map.insert(environment_uuid, group_cookie_rows(rows)); + } + + // 9. 批量查询账号信息 + let mut env_accounts_map: HashMap> = HashMap::new(); + for env_uuid in &env_uuids { + let accounts = accounts::get_environment_accounts_service(svc_ctx, *env_uuid) + .await + .unwrap_or_default(); + if !accounts.is_empty() { + env_accounts_map.insert(*env_uuid, accounts); + } + } + + // 10. 组装完整的环境信息 + let environments: Vec = env_rows + .into_iter() + .map(|row| { + let group = row.group_uuid.and_then(|gid| group_map.get(&gid).cloned()); + let proxy = row.proxy_uuid.and_then(|pid| proxy_map.get(&pid).cloned()); + let tags = env_tags_map.get(&row.uuid).cloned().unwrap_or_default(); + let accounts = env_accounts_map.get(&row.uuid).cloned().unwrap_or_default(); + + crate::entitys::EnvironmentDetailResponse { + environment: EnvironmentDto { + id: row.id, + uuid: row.uuid, + workspace_uuid: row.workspace_uuid, + user_uuid: row.user_uuid, + team_uuid: row.team_uuid, + name: row.name, + description: row.description, + status: row.status, + group_uuid: row.group_uuid, + proxy_uuid: row.proxy_uuid, + system_info: row.system_info, + kernel_info: row.kernel_info, + fingerprint_summary: row.fingerprint_summary, + last_opened_at: row.last_opened_at, + created_at: row.created_at, + updated_at: row.updated_at, + deleted_at: None, + }, + config: None, + cookies: env_cookies_map.remove(&row.uuid).unwrap_or_default(), + urls: env_urls_map.remove(&row.uuid).unwrap_or_default(), + tags, + accounts, + group, + proxy, + extensions: vec![], // 回收站不需要返回扩展数据 + } + }) + .collect(); + + Ok((environments, total_count)) +} + +/// 恢复环境 +pub async fn restore_environment_service(svc_ctx: &SvcCtx, env_uuid: Uuid) -> Result<(), String> { + models::restore_environment(&svc_ctx.db, env_uuid) + .await + .map_err(|e| e.to_string()) +} + +/// 批量恢复环境 +pub async fn batch_restore_environments_service( + svc_ctx: &SvcCtx, + env_uuids: &[Uuid], +) -> Result { + models::batch_restore_environments(&svc_ctx.db, env_uuids) + .await + .map_err(|e| e.to_string()) +} + +/// 永久删除环境 +pub async fn permanent_delete_environment_service( + svc_ctx: &SvcCtx, + env_uuid: Uuid, + workspace_uuid: Uuid, +) -> Result<(), String> { + // 永久删除环境 + models::permanent_delete_environment(&svc_ctx.db, env_uuid) + .await + .map_err(|e| e.to_string())?; + + // 更新工作空间配额(永久删除后减少使用数) + models::decrement_used_environments(&svc_ctx.db, workspace_uuid, 1) + .await + .map_err(|e| format!("更新配额失败: {}", e))?; + + Ok(()) +} + +/// 批量永久删除环境 +pub async fn batch_permanent_delete_environments_service( + svc_ctx: &SvcCtx, + env_uuids: &[Uuid], + workspace_uuid: Uuid, +) -> Result { + let count = models::batch_permanent_delete_environments(&svc_ctx.db, env_uuids) + .await + .map_err(|e| e.to_string())?; + + // 更新工作空间配额 + if count > 0 { + models::decrement_used_environments(&svc_ctx.db, workspace_uuid, count as i32) + .await + .map_err(|e| format!("更新配额失败: {}", e))?; + } + + Ok(count) +} + +// ============ Environment URLs ============ + +/// 添加环境 URL +pub async fn add_environment_url_service( + svc_ctx: &SvcCtx, + payload: &AddEnvironmentUrlRequest, +) -> Result { + models::insert_environment_url( + &svc_ctx.db, + payload.environment_uuid, + &payload.url, + payload.title.as_deref(), + payload.sort_order, + ) + .await + .map_err(|e| e.to_string()) +} + +/// 获取环境的所有 URL +pub async fn get_environment_urls_service( + svc_ctx: &SvcCtx, + env_uuid: Uuid, +) -> Result, String> { + models::fetch_environment_urls(&svc_ctx.db, env_uuid) + .await + .map_err(|e| e.to_string()) +} + +/// 删除环境 URL +pub async fn delete_environment_url_service(svc_ctx: &SvcCtx, url_id: i32) -> Result<(), String> { + models::delete_environment_url(&svc_ctx.db, url_id) + .await + .map_err(|e| e.to_string()) +} + +/// 清空环境的所有 URL +pub async fn clear_environment_urls_service( + svc_ctx: &SvcCtx, + env_uuid: Uuid, +) -> Result { + models::clear_environment_urls(&svc_ctx.db, env_uuid) + .await + .map_err(|e| e.to_string()) +} + +// ============ Environment Cookies ============ + +/// 添加环境 Cookie +pub async fn add_environment_cookie_service( + svc_ctx: &SvcCtx, + payload: &AddEnvironmentCookieRequest, +) -> Result { + let cookies = parse_cookie_group(&CookieGroupInput { + site: payload.site.clone(), + cookie_text: payload.cookie_text.clone(), + })?; + + let mut last_id = 0; + for cookie in cookies { + last_id = models::insert_environment_cookie( + &svc_ctx.db, + payload.environment_uuid, + &cookie.site_input, + &cookie.domain, + &cookie.name, + &cookie.value, + cookie.path.as_deref(), + cookie.http_only, + cookie.secure, + cookie.same_site.as_deref(), + ) + .await + .map_err(|e| e.to_string())?; + } + + Ok(last_id) +} + +/// 获取环境的所有 Cookies +pub async fn get_environment_cookies_service( + svc_ctx: &SvcCtx, + env_uuid: Uuid, +) -> Result, String> { + let rows = models::fetch_environment_cookies(&svc_ctx.db, env_uuid) + .await + .map_err(|e| e.to_string())?; + + Ok(group_cookie_rows(rows)) +} + +/// 删除环境 Cookie +pub async fn delete_environment_cookie_service( + svc_ctx: &SvcCtx, + cookie_id: i32, +) -> Result<(), String> { + models::delete_environment_cookie(&svc_ctx.db, cookie_id) + .await + .map_err(|e| e.to_string()) +} + +/// 清空环境的所有 Cookies +pub async fn clear_environment_cookies_service( + svc_ctx: &SvcCtx, + env_uuid: Uuid, +) -> Result { + models::clear_environment_cookies(&svc_ctx.db, env_uuid) + .await + .map_err(|e| e.to_string()) +} + +/// 批量创建环境 +pub async fn batch_create_environments_service( + svc_ctx: &SvcCtx, + user_uuid: Uuid, + workspace_uuid: Uuid, + team_uuid: Uuid, + payload: &BatchCreateEnvironmentRequest, +) -> Result, String> { + let mut created_uuids = Vec::new(); + + for env_request in &payload.environments { + let env_uuid = + create_environment_service(svc_ctx, user_uuid, workspace_uuid, team_uuid, env_request) + .await + .map_err(|e| format!("创建环境 '{}' 失败: {}", env_request.name, e))?; + + created_uuids.push(env_uuid); + } + + Ok(created_uuids) +} diff --git a/src-tauri/crates/business/src/services/group_permissions.rs b/src-tauri/crates/business/src/services/group_permissions.rs index 697af9a..42d8464 100644 --- a/src-tauri/crates/business/src/services/group_permissions.rs +++ b/src-tauri/crates/business/src/services/group_permissions.rs @@ -1,139 +1,160 @@ -use uuid::Uuid; - -use crate::dto::GroupMemberPermissionDto; -use crate::entitys::{ - CheckGroupPermissionRequest, GrantGroupPermissionRequest, ListUserGroupPermissionsRequest, - RevokeGroupPermissionRequest, -}; -use crate::models; -use crate::svc_ctx::SvcCtx; - -/// 授予分组权限 -pub async fn grant_group_permission_service( - svc_ctx: &SvcCtx, - user_uuid: Uuid, - payload: &GrantGroupPermissionRequest, -) -> Result<(), String> { - // 检查权限:只有团队 Owner/Admin 或拥有分组 manage 权限的用户可以授权 - let group = models::fetch_group_by_uuid(&svc_ctx.db, payload.group_uuid) - .await - .map_err(|e| e.to_string())? - .ok_or_else(|| "分组不存在".to_string())?; - - // 检查用户是否是团队成员(工作空间级别) - let team_member = models::fetch_team_member( - &svc_ctx.db, - group.workspace_uuid, - group.team_uuid, - user_uuid, - ) - .await - .map_err(|e| e.to_string())? - .ok_or_else(|| "您不是该团队的成员".to_string())?; - - // Owner/Admin 自动拥有所有权限 - let can_grant = team_member.role == "owner" - || team_member.role == "admin" - || models::check_group_permission( - &svc_ctx.db, - group.workspace_uuid, - payload.group_uuid, - user_uuid, - "manage", - ) - .await - .map_err(|e| e.to_string())?; - - if !can_grant { - return Err("您没有权限授予分组权限".to_string()); - } - - models::grant_group_permission( - &svc_ctx.db, - payload.group_uuid, - group.workspace_uuid, - group.team_uuid, - payload.user_uuid, - &payload.permission_type, - user_uuid, - ) - .await - .map_err(|e| e.to_string()) -} - -/// 撤销分组权限 -pub async fn revoke_group_permission_service( - svc_ctx: &SvcCtx, - user_uuid: Uuid, - payload: &RevokeGroupPermissionRequest, -) -> Result<(), String> { - // 检查权限:只有团队 Owner/Admin 或拥有分组 manage 权限的用户可以撤销权限 - let group = models::fetch_group_by_uuid(&svc_ctx.db, payload.group_uuid) - .await - .map_err(|e| e.to_string())? - .ok_or_else(|| "分组不存在".to_string())?; - - // 检查用户是否是团队成员(工作空间级别) - let team_member = models::fetch_team_member( - &svc_ctx.db, - group.workspace_uuid, - group.team_uuid, - user_uuid, - ) - .await - .map_err(|e| e.to_string())? - .ok_or_else(|| "您不是该团队的成员".to_string())?; - - // Owner/Admin 自动拥有所有权限 - let can_revoke = team_member.role == "owner" - || team_member.role == "admin" - || models::check_group_permission( - &svc_ctx.db, - group.workspace_uuid, - payload.group_uuid, - user_uuid, - "manage", - ) - .await - .map_err(|e| e.to_string())?; - - if !can_revoke { - return Err("您没有权限撤销分组权限".to_string()); - } - - models::revoke_group_permission(&svc_ctx.db, payload.group_uuid, payload.user_uuid) - .await - .map_err(|e| e.to_string()) -} - -/// 检查分组权限 -pub async fn check_group_permission_service( - svc_ctx: &SvcCtx, - workspace_uuid: Uuid, - payload: &CheckGroupPermissionRequest, -) -> Result { - models::check_group_permission( - &svc_ctx.db, - workspace_uuid, - payload.group_uuid, - payload.user_uuid, - &payload.permission_type, - ) - .await - .map_err(|e| e.to_string()) -} - -/// 查询用户的分组权限列表 -pub async fn list_user_group_permissions_service( - svc_ctx: &SvcCtx, - payload: &ListUserGroupPermissionsRequest, -) -> Result, String> { - models::fetch_user_group_permissions( - &svc_ctx.db, - payload.user_uuid, - None, // workspace_uuid 可以从 payload 中获取,如果需要的话 - payload.group_uuid, - ) - .await - .map_err(|e| e.to_string()) -} +use uuid::Uuid; + +use crate::dto::GroupMemberPermissionDetailDto; +use crate::entitys::{ + CheckGroupPermissionRequest, GrantGroupPermissionRequest, ListUserGroupPermissionsRequest, + RevokeGroupPermissionRequest, +}; +use crate::models; +use crate::svc_ctx::SvcCtx; + +/// 授予分组权限 +pub async fn grant_group_permission_service( + svc_ctx: &SvcCtx, + user_uuid: Uuid, + payload: &GrantGroupPermissionRequest, +) -> Result<(), String> { + // 检查权限:只有团队 Owner/Admin 或拥有分组 manage 权限的用户可以授权 + let group = models::fetch_group_by_uuid(&svc_ctx.db, payload.group_uuid) + .await + .map_err(|e| e.to_string())? + .ok_or_else(|| "分组不存在".to_string())?; + + // 检查用户是否是团队成员(工作空间级别) + let team_member = models::fetch_team_member( + &svc_ctx.db, + group.workspace_uuid, + group.team_uuid, + user_uuid, + ) + .await + .map_err(|e| e.to_string())? + .ok_or_else(|| "您不是该团队的成员".to_string())?; + + // Owner/Admin 自动拥有所有权限 + let can_grant = team_member.role == "owner" + || team_member.role == "admin" + || models::check_group_permission( + &svc_ctx.db, + group.workspace_uuid, + payload.group_uuid, + user_uuid, + "manage", + ) + .await + .map_err(|e| e.to_string())?; + + if !can_grant { + return Err("您没有权限授予分组权限".to_string()); + } + + models::grant_group_permission( + &svc_ctx.db, + payload.group_uuid, + group.workspace_uuid, + group.team_uuid, + payload.user_uuid, + &payload.permission_type, + user_uuid, + ) + .await + .map_err(|e| e.to_string()) +} + +/// 撤销分组权限 +pub async fn revoke_group_permission_service( + svc_ctx: &SvcCtx, + user_uuid: Uuid, + payload: &RevokeGroupPermissionRequest, +) -> Result<(), String> { + // 检查权限:只有团队 Owner/Admin 或拥有分组 manage 权限的用户可以撤销权限 + let group = models::fetch_group_by_uuid(&svc_ctx.db, payload.group_uuid) + .await + .map_err(|e| e.to_string())? + .ok_or_else(|| "分组不存在".to_string())?; + + // 检查用户是否是团队成员(工作空间级别) + let team_member = models::fetch_team_member( + &svc_ctx.db, + group.workspace_uuid, + group.team_uuid, + user_uuid, + ) + .await + .map_err(|e| e.to_string())? + .ok_or_else(|| "您不是该团队的成员".to_string())?; + + // Owner/Admin 自动拥有所有权限 + let can_revoke = team_member.role == "owner" + || team_member.role == "admin" + || models::check_group_permission( + &svc_ctx.db, + group.workspace_uuid, + payload.group_uuid, + user_uuid, + "manage", + ) + .await + .map_err(|e| e.to_string())?; + + if !can_revoke { + return Err("您没有权限撤销分组权限".to_string()); + } + + models::revoke_group_permission(&svc_ctx.db, payload.group_uuid, payload.user_uuid) + .await + .map_err(|e| e.to_string()) +} + +/// 检查分组权限 +pub async fn check_group_permission_service( + svc_ctx: &SvcCtx, + workspace_uuid: Uuid, + payload: &CheckGroupPermissionRequest, +) -> Result { + models::check_group_permission( + &svc_ctx.db, + workspace_uuid, + payload.group_uuid, + payload.user_uuid, + &payload.permission_type, + ) + .await + .map_err(|e| e.to_string()) +} + +/// 查询用户的分组权限列表 +pub async fn list_user_group_permissions_service( + svc_ctx: &SvcCtx, + payload: &ListUserGroupPermissionsRequest, +) -> Result, String> { + let permissions = models::fetch_user_group_permissions( + &svc_ctx.db, + payload.user_uuid, + None, // workspace_uuid 可以从 payload 中获取,如果需要的话 + payload.group_uuid, + ) + .await + .map_err(|e| e.to_string())?; + let offset = ((payload.pagination.page - 1) * payload.pagination.page_size).max(0) as usize; + let limit = payload.pagination.page_size.max(0) as usize; + let mut details = Vec::new(); + for permission in permissions.into_iter().skip(offset).take(limit) { + let user_info = models::user::fetch_user_info_by_uuid(&svc_ctx.db, permission.user_uuid) + .await + .map_err(|error| error.to_string())?; + details.push(GroupMemberPermissionDetailDto { + group_uuid: permission.group_uuid, + workspace_uuid: permission.workspace_uuid, + team_uuid: permission.team_uuid, + user_uuid: permission.user_uuid, + permission_type: permission.permission_type, + granted_by: permission.granted_by, + user_name: user_info.as_ref().and_then(|user| user.nickname.clone()), + user_email: user_info.map(|user| user.email), + created_at: permission.created_at, + updated_at: permission.updated_at, + }); + } + Ok(details) +} diff --git a/src-tauri/crates/business/src/services/local_api.rs b/src-tauri/crates/business/src/services/local_api.rs index a16023a..3836a3c 100644 --- a/src-tauri/crates/business/src/services/local_api.rs +++ b/src-tauri/crates/business/src/services/local_api.rs @@ -1,459 +1,232 @@ -use chrono::Utc; -use uuid::Uuid; - -use crate::caches::{ - LocalApiKeyCache, LocalApiPermissionCache, delete_local_api_key_cache, - delete_local_api_permission_caches_for_key, - get_local_api_key_cache, get_local_api_permission_cache, get_local_api_permission_definition_cache, - get_local_api_rate_count, increment_local_api_rate_count, set_local_api_key_cache, - set_local_api_permission_cache, set_local_api_permission_definition_cache, -}; -use crate::dto::{LocalApiConfigDto, ResetLocalApiKeyDto, ValidateLocalApiKeyDto}; -use crate::entitys::{UpdateLocalApiConfigRequest, ValidateLocalApiKeyRequest}; -use crate::models; -use crate::svc_ctx::SvcCtx; - -const DEFAULT_DAILY_LIMIT: i32 = 1000; - -pub async fn get_local_api_config_service( - svc_ctx: &SvcCtx, - user_uuid: Uuid, -) -> Result { - init_local_api_for_user_service(svc_ctx, user_uuid).await?; - - let settings = models::local_api::fetch_local_api_settings(&svc_ctx.db, user_uuid) - .await - .map_err(|e| e.to_string())? - .ok_or_else(|| "API 服务配置不存在".to_string())?; - - let mut api_key = models::local_api::fetch_active_api_key(&svc_ctx.db, user_uuid) - .await - .map_err(|e| e.to_string())? - .ok_or_else(|| "API 密钥不存在".to_string())?; - - if api_key.api_key.is_none() { - rotate_local_api_key_for_user(svc_ctx, user_uuid).await?; - api_key = models::local_api::fetch_active_api_key(&svc_ctx.db, user_uuid) - .await - .map_err(|e| e.to_string())? - .ok_or_else(|| "API 密钥不存在".to_string())?; - } - - Ok(LocalApiConfigDto { - enabled: settings.enabled, - api_key: api_key - .api_key - .clone() - .ok_or_else(|| "API 密钥不存在".to_string())?, - port: settings.port, - remote_access: settings.remote_access, - cors_origins: models::local_api::parse_cors_origins(&settings.cors_origins), - requests_today: get_local_api_rate_count( - svc_ctx, - api_key.id, - None, - "day", - &Utc::now().format("%Y%m%d").to_string(), - ) - .await - .ok() - .map(|count| count as i32) - .unwrap_or(api_key.requests_today), - daily_limit: api_key.daily_limit, - }) -} - -pub async fn update_local_api_config_service( - svc_ctx: &SvcCtx, - user_uuid: Uuid, - payload: &UpdateLocalApiConfigRequest, -) -> Result { - if let Some(port) = payload.port { - if !(1..=65535).contains(&port) { - return Err("端口范围无效".to_string()); - } - } - - init_local_api_for_user_service(svc_ctx, user_uuid).await?; - - let cors_origins = payload - .cors_origins - .as_ref() - .map(|origins| models::local_api::build_cors_origins_value(origins)); - - models::local_api::upsert_local_api_settings( - &svc_ctx.db, - user_uuid, - payload.enabled, - payload.port, - payload.remote_access, - cors_origins.as_ref(), - ) - .await - .map_err(|e| e.to_string())?; - - get_local_api_config_service(svc_ctx, user_uuid).await -} - -pub async fn reset_local_api_key_service( - svc_ctx: &SvcCtx, - user_uuid: Uuid, -) -> Result { - init_local_api_for_user_service(svc_ctx, user_uuid).await?; - - if let Some(current_key) = models::local_api::fetch_active_api_key(&svc_ctx.db, user_uuid) - .await - .map_err(|e| e.to_string())? - { - delete_local_api_key_cache(svc_ctx, ¤t_key.key_hash) - .await - .map_err(|e| e.to_string())?; - delete_local_api_permission_caches_for_key(svc_ctx, current_key.id) - .await - .map_err(|e| e.to_string())?; - } - - models::local_api::deactivate_api_keys_for_user(&svc_ctx.db, user_uuid) - .await - .map_err(|e| e.to_string())?; - - let api_key = generate_api_key(); - let key_hash = models::local_api::hash_api_key(&api_key); - let key_prefix = api_key.chars().take(16).collect::(); - let created_key = models::local_api::insert_api_key( - &svc_ctx.db, - user_uuid, - &key_prefix, - &key_hash, - &api_key, - DEFAULT_DAILY_LIMIT, - ) - .await - .map_err(|e| e.to_string())?; - set_local_api_key_cache( - svc_ctx, - &key_hash, - &LocalApiKeyCache { - id: created_key.id, - user_uuid: created_key.user_uuid, - is_active: created_key.is_active, - expires_at: created_key.expires_at, - daily_limit: created_key.daily_limit, - }, - ) - .await - .map_err(|e| e.to_string())?; - - ensure_default_permissions(svc_ctx, created_key.id).await?; - - Ok(ResetLocalApiKeyDto { - api_key, - }) -} - -pub async fn validate_local_api_key_service( - svc_ctx: &SvcCtx, - payload: &ValidateLocalApiKeyRequest, -) -> Result { - let key_hash = models::local_api::hash_api_key(payload.api_key.as_str()); - let api_key = if let Some(cache) = get_local_api_key_cache(svc_ctx, &key_hash) - .await - .map_err(|e| e.to_string())? - { - cache - } else { - let db_api_key = models::local_api::fetch_api_key_by_hash(&svc_ctx.db, &key_hash) - .await - .map_err(|e| e.to_string())? - .ok_or_else(|| "API 密钥无效".to_string())?; - let cache = LocalApiKeyCache { - id: db_api_key.id, - user_uuid: db_api_key.user_uuid, - is_active: db_api_key.is_active, - expires_at: db_api_key.expires_at, - daily_limit: db_api_key.daily_limit, - }; - set_local_api_key_cache(svc_ctx, &key_hash, &cache) - .await - .map_err(|e| e.to_string())?; - cache - }; - - if !api_key.is_active { - return Err("API 密钥已停用".to_string()); - } - - if let Some(expires_at) = api_key.expires_at { - if expires_at < Utc::now() { - return Err("API 密钥已过期".to_string()); - } - } - - let day_key = Utc::now().format("%Y%m%d").to_string(); - let day_count = get_local_api_rate_count(svc_ctx, api_key.id, None, "day", &day_key) - .await - .map_err(|e| e.to_string())?; - if day_count >= i64::from(api_key.daily_limit) { - return Err("API 密钥已达到今日调用上限".to_string()); - } - - let definition = if let Some(cache) = - get_local_api_permission_definition_cache(svc_ctx, &payload.permission_code) - .await - .map_err(|e| e.to_string())? - { - cache - } else { - let definition = models::local_api::fetch_permission_definition( - &svc_ctx.db, - &payload.permission_code, - ) - .await - .map_err(|e| e.to_string())? - .ok_or_else(|| "未知的 permissionCode".to_string())?; - set_local_api_permission_definition_cache(svc_ctx, &payload.permission_code, &definition) - .await - .map_err(|e| e.to_string())?; - definition - }; - - let permission = if let Some(cache) = - get_local_api_permission_cache(svc_ctx, api_key.id, &payload.permission_code) - .await - .map_err(|e| e.to_string())? - { - cache - } else { - let db_permission = fetch_effective_permission(svc_ctx, api_key.id, &payload.permission_code) - .await? - .ok_or_else(|| "当前密钥无权访问该接口".to_string())?; - let cache = LocalApiPermissionCache { - is_enabled: db_permission.is_enabled, - rate_limit_per_minute: db_permission.rate_limit_per_minute, - rate_limit_per_hour: db_permission.rate_limit_per_hour, - }; - set_local_api_permission_cache(svc_ctx, api_key.id, &payload.permission_code, &cache) - .await - .map_err(|e| e.to_string())?; - cache - }; - - if !permission.is_enabled { - return Err("当前密钥已被禁止访问该接口".to_string()); - } - - let now = Utc::now(); - let minute_key = now.format("%Y%m%d%H%M").to_string(); - let hour_key = now.format("%Y%m%d%H").to_string(); - - let minute_count = get_local_api_rate_count( - svc_ctx, - api_key.id, - Some(payload.permission_code.as_str()), - "minute", - &minute_key, - ) - .await - .map_err(|e| e.to_string())?; - if minute_count >= i64::from(permission.rate_limit_per_minute) { - return Err("接口每分钟调用次数已达上限".to_string()); - } - - let hour_count = get_local_api_rate_count( - svc_ctx, - api_key.id, - Some(payload.permission_code.as_str()), - "hour", - &hour_key, - ) - .await - .map_err(|e| e.to_string())?; - if hour_count >= i64::from(permission.rate_limit_per_hour) { - return Err("接口每小时调用次数已达上限".to_string()); - } - - increment_local_api_rate_count( - svc_ctx, - api_key.id, - Some(payload.permission_code.as_str()), - "minute", - &minute_key, - ) - .await - .map_err(|e| e.to_string())?; - increment_local_api_rate_count( - svc_ctx, - api_key.id, - Some(payload.permission_code.as_str()), - "hour", - &hour_key, - ) - .await - .map_err(|e| e.to_string())?; - increment_local_api_rate_count(svc_ctx, api_key.id, None, "day", &day_key) - .await - .map_err(|e| e.to_string())?; - - Ok(ValidateLocalApiKeyDto { - valid: true, - permission_code: definition.permission_code, - requests_today: (day_count + 1) as i32, - daily_limit: api_key.daily_limit, - rate_limit_per_minute: permission.rate_limit_per_minute, - rate_limit_per_hour: permission.rate_limit_per_hour, - }) -} - -pub async fn init_local_api_for_user_service( - svc_ctx: &SvcCtx, - user_uuid: Uuid, -) -> Result<(), String> { - models::local_api::upsert_local_api_settings( - &svc_ctx.db, - user_uuid, - None, - None, - None, - None, - ) - .await - .map_err(|e| e.to_string())?; - - let current_key = models::local_api::fetch_active_api_key(&svc_ctx.db, user_uuid) - .await - .map_err(|e| e.to_string())?; - - if let Some(api_key) = current_key { - if api_key.api_key.is_none() { - rotate_local_api_key_for_user(svc_ctx, user_uuid).await?; - return Ok(()); - } - ensure_default_permissions(svc_ctx, api_key.id).await?; - return Ok(()); - } - - let api_key = generate_api_key(); - let key_hash = models::local_api::hash_api_key(&api_key); - let key_prefix = api_key.chars().take(16).collect::(); - - let created_key = models::local_api::insert_api_key( - &svc_ctx.db, - user_uuid, - &key_prefix, - &key_hash, - &api_key, - DEFAULT_DAILY_LIMIT, - ) - .await - .map_err(|e| e.to_string())?; - set_local_api_key_cache( - svc_ctx, - &key_hash, - &LocalApiKeyCache { - id: created_key.id, - user_uuid: created_key.user_uuid, - is_active: created_key.is_active, - expires_at: created_key.expires_at, - daily_limit: created_key.daily_limit, - }, - ) - .await - .map_err(|e| e.to_string())?; - - ensure_default_permissions(svc_ctx, created_key.id).await -} - -async fn ensure_default_permissions(svc_ctx: &SvcCtx, api_key_id: i32) -> Result<(), String> { - let definitions = models::local_api::fetch_permission_definitions(&svc_ctx.db) - .await - .map_err(|e| e.to_string())?; - - for definition in definitions { - models::local_api::insert_api_key_permission( - &svc_ctx.db, - api_key_id, - definition.permission_code.as_str(), - definition.default_rate_limit_per_minute, - definition.default_rate_limit_per_hour, - ) - .await - .map_err(|e| e.to_string())?; - set_local_api_permission_definition_cache( - svc_ctx, - definition.permission_code.as_str(), - &definition, - ) - .await - .map_err(|e| e.to_string())?; - set_local_api_permission_cache( - svc_ctx, - api_key_id, - definition.permission_code.as_str(), - &LocalApiPermissionCache { - is_enabled: true, - rate_limit_per_minute: definition.default_rate_limit_per_minute, - rate_limit_per_hour: definition.default_rate_limit_per_hour, - }, - ) - .await - .map_err(|e| e.to_string())?; - } - - Ok(()) -} - -async fn fetch_effective_permission( - svc_ctx: &SvcCtx, - api_key_id: i32, - permission_code: &str, -) -> Result, String> { - models::local_api::fetch_api_key_permission(&svc_ctx.db, api_key_id, permission_code) - .await - .map_err(|e| e.to_string()) -} - -fn generate_api_key() -> String { - let raw = Uuid::new_v4().simple().to_string(); - format!("sk_local_{}", raw) -} - -async fn rotate_local_api_key_for_user( - svc_ctx: &SvcCtx, - user_uuid: Uuid, -) -> Result { - models::local_api::deactivate_api_keys_for_user(&svc_ctx.db, user_uuid) - .await - .map_err(|e| e.to_string())?; - - let api_key = generate_api_key(); - let key_hash = models::local_api::hash_api_key(&api_key); - let key_prefix = api_key.chars().take(16).collect::(); - - let created_key = models::local_api::insert_api_key( - &svc_ctx.db, - user_uuid, - &key_prefix, - &key_hash, - &api_key, - DEFAULT_DAILY_LIMIT, - ) - .await - .map_err(|e| e.to_string())?; - set_local_api_key_cache( - svc_ctx, - &key_hash, - &LocalApiKeyCache { - id: created_key.id, - user_uuid: created_key.user_uuid, - is_active: created_key.is_active, - expires_at: created_key.expires_at, - daily_limit: created_key.daily_limit, - }, - ) - .await - .map_err(|e| e.to_string())?; - - ensure_default_permissions(svc_ctx, created_key.id).await?; - - Ok(ResetLocalApiKeyDto { api_key }) -} +use chrono::{Timelike, Utc}; +use uuid::Uuid; + +use crate::dto::{LocalApiConfigDto, ResetLocalApiKeyDto, ValidateLocalApiKeyDto}; +use crate::entitys::{UpdateLocalApiConfigRequest, ValidateLocalApiKeyRequest}; +use crate::{models, svc_ctx::SvcCtx}; + +const DEFAULT_DAILY_LIMIT: i32 = 1000; + +pub async fn get_local_api_config_service( + context: &SvcCtx, + user_uuid: Uuid, +) -> Result { + init_local_api_for_user_service(context, user_uuid).await?; + let settings = models::local_api::fetch_local_api_settings(&context.db, user_uuid) + .await + .map_err(|error| error.to_string())? + .ok_or_else(|| "Local API settings do not exist".to_string())?; + let api_key = models::local_api::fetch_active_api_key(&context.db, user_uuid) + .await + .map_err(|error| error.to_string())? + .ok_or_else(|| "Local API key does not exist".to_string())?; + + Ok(LocalApiConfigDto { + enabled: settings.enabled, + api_key: api_key.api_key.ok_or_else(|| "Local API key is unavailable".to_string())?, + port: settings.port, + remote_access: settings.remote_access, + cors_origins: models::local_api::parse_cors_origins(&settings.cors_origins), + requests_today: api_key.requests_today, + daily_limit: api_key.daily_limit, + }) +} + +pub async fn update_local_api_config_service( + context: &SvcCtx, + user_uuid: Uuid, + request: &UpdateLocalApiConfigRequest, +) -> Result { + if let Some(port) = request.port { + if !(1..=65535).contains(&port) { + return Err("Local API port must be between 1 and 65535".to_string()); + } + } + + init_local_api_for_user_service(context, user_uuid).await?; + let cors_origins = request + .cors_origins + .as_ref() + .map(|origins| models::local_api::build_cors_origins_value(origins)); + models::local_api::upsert_local_api_settings( + &context.db, + user_uuid, + request.enabled, + request.port, + request.remote_access, + cors_origins.as_ref(), + ) + .await + .map_err(|error| error.to_string())?; + + get_local_api_config_service(context, user_uuid).await +} + +pub async fn reset_local_api_key_service( + context: &SvcCtx, + user_uuid: Uuid, +) -> Result { + init_local_api_for_user_service(context, user_uuid).await?; + models::local_api::deactivate_api_keys_for_user(&context.db, user_uuid) + .await + .map_err(|error| error.to_string())?; + let (api_key, key_id) = create_api_key(context, user_uuid).await?; + ensure_default_permissions(context, key_id).await?; + Ok(ResetLocalApiKeyDto { api_key }) +} + +pub async fn validate_local_api_key_service( + context: &SvcCtx, + request: &ValidateLocalApiKeyRequest, +) -> Result { + let key_hash = models::local_api::hash_api_key(&request.api_key); + let mut api_key = models::local_api::fetch_api_key_by_hash(&context.db, &key_hash) + .await + .map_err(|error| error.to_string())? + .ok_or_else(|| "Invalid Local API key".to_string())?; + let now = Utc::now(); + if api_key.expires_at.is_some_and(|expires_at| expires_at < now) { + return Err("Local API key has expired".to_string()); + } + if api_key.last_reset_date != now.date_naive() { + models::local_api::reset_api_key_daily_usage(&context.db, api_key.id, now.date_naive()) + .await + .map_err(|error| error.to_string())?; + api_key.requests_today = 0; + } + if api_key.requests_today >= api_key.daily_limit { + return Err("Local API daily limit reached".to_string()); + } + + let definition = + models::local_api::fetch_permission_definition(&context.db, &request.permission_code) + .await + .map_err(|error| error.to_string())? + .ok_or_else(|| "Unknown Local API permission".to_string())?; + let permission = models::local_api::fetch_api_key_permission( + &context.db, + api_key.id, + &request.permission_code, + ) + .await + .map_err(|error| error.to_string())? + .ok_or_else(|| "Local API key cannot access this route".to_string())?; + if !permission.is_enabled { + return Err("Local API permission is disabled".to_string()); + } + + let minute_start = now.with_second(0).and_then(|value| value.with_nanosecond(0)).unwrap_or(now); + let hour_start = minute_start.with_minute(0).unwrap_or(minute_start); + let minute_count = models::local_api::fetch_request_count( + &context.db, + api_key.id, + &request.permission_code, + "minute", + minute_start, + ) + .await + .map_err(|error| error.to_string())?; + let hour_count = models::local_api::fetch_request_count( + &context.db, + api_key.id, + &request.permission_code, + "hour", + hour_start, + ) + .await + .map_err(|error| error.to_string())?; + if minute_count >= permission.rate_limit_per_minute { + return Err("Local API minute rate limit reached".to_string()); + } + if hour_count >= permission.rate_limit_per_hour { + return Err("Local API hourly rate limit reached".to_string()); + } + + models::local_api::increment_request_counter( + &context.db, + api_key.id, + &request.permission_code, + "minute", + minute_start, + ) + .await + .map_err(|error| error.to_string())?; + models::local_api::increment_request_counter( + &context.db, + api_key.id, + &request.permission_code, + "hour", + hour_start, + ) + .await + .map_err(|error| error.to_string())?; + models::local_api::increment_api_key_usage(&context.db, api_key.id, now) + .await + .map_err(|error| error.to_string())?; + + Ok(ValidateLocalApiKeyDto { + valid: true, + permission_code: definition.permission_code, + requests_today: api_key.requests_today + 1, + daily_limit: api_key.daily_limit, + rate_limit_per_minute: permission.rate_limit_per_minute, + rate_limit_per_hour: permission.rate_limit_per_hour, + }) +} + +pub async fn init_local_api_for_user_service( + context: &SvcCtx, + user_uuid: Uuid, +) -> Result<(), String> { + models::local_api::upsert_local_api_settings(&context.db, user_uuid, None, None, None, None) + .await + .map_err(|error| error.to_string())?; + if let Some(key) = models::local_api::fetch_active_api_key(&context.db, user_uuid) + .await + .map_err(|error| error.to_string())? + { + if key.api_key.is_some() { + return ensure_default_permissions(context, key.id).await; + } + models::local_api::deactivate_api_keys_for_user(&context.db, user_uuid) + .await + .map_err(|error| error.to_string())?; + } + let (_, key_id) = create_api_key(context, user_uuid).await?; + ensure_default_permissions(context, key_id).await +} + +async fn create_api_key(context: &SvcCtx, user_uuid: Uuid) -> Result<(String, i32), String> { + let api_key = format!("sk_local_{}", Uuid::new_v4().simple()); + let key_hash = models::local_api::hash_api_key(&api_key); + let key_prefix = api_key.chars().take(16).collect::(); + let created = models::local_api::insert_api_key( + &context.db, + user_uuid, + &key_prefix, + &key_hash, + &api_key, + DEFAULT_DAILY_LIMIT, + ) + .await + .map_err(|error| error.to_string())?; + Ok((api_key, created.id)) +} + +async fn ensure_default_permissions(context: &SvcCtx, api_key_id: i32) -> Result<(), String> { + for definition in models::local_api::fetch_permission_definitions(&context.db) + .await + .map_err(|error| error.to_string())? + { + models::local_api::insert_api_key_permission( + &context.db, + api_key_id, + &definition.permission_code, + definition.default_rate_limit_per_minute, + definition.default_rate_limit_per_hour, + ) + .await + .map_err(|error| error.to_string())?; + } + Ok(()) +} diff --git a/src-tauri/crates/business/src/services/maintenance.rs b/src-tauri/crates/business/src/services/maintenance.rs deleted file mode 100644 index 0721d3e..0000000 --- a/src-tauri/crates/business/src/services/maintenance.rs +++ /dev/null @@ -1,78 +0,0 @@ -use crate::{ - dto::maintenance::Maintenance, entitys::maintenance::CreateMaintenanceRequest, - errors::SimprintError, svc_ctx::SvcCtx, -}; - -/// 创建维护 -pub async fn create_maintenance_service( - svc_ctx: &SvcCtx, - request: CreateMaintenanceRequest, -) -> Result { - let maintenance = crate::models::maintenance::create_maintenance(&svc_ctx.db, request) - .await - .map_err(|e| SimprintError::InvalidRequest(format!("创建维护失败: {}", e)))?; - - Ok(maintenance.id) -} - -/// 根据ID查询维护 -pub async fn get_maintenance_by_id_service( - svc_ctx: &SvcCtx, - id: i64, -) -> Result, SimprintError> { - let maintenance = crate::models::maintenance::get_maintenance_by_id(&svc_ctx.db, id) - .await - .map_err(|e| SimprintError::InvalidRequest(format!("查询维护失败: {}", e)))?; - - Ok(maintenance) -} - -/// 查询维护列表 -pub async fn list_maintenances_service( - svc_ctx: &SvcCtx, - limit: Option, - offset: Option, -) -> Result, SimprintError> { - let maintenances = crate::models::maintenance::list_maintenances( - &svc_ctx.db, - limit.map(|l| l as i64), - offset.map(|o| o as i64), - ) - .await - .map_err(|e| SimprintError::InvalidRequest(format!("查询维护列表失败: {}", e)))?; - - Ok(maintenances) -} - -/// 更新维护状态 -pub async fn update_maintenance_status_service( - svc_ctx: &SvcCtx, - id: i64, - status: String, -) -> Result { - let success = crate::models::maintenance::update_maintenance_status(&svc_ctx.db, id, &status) - .await - .map_err(|e| SimprintError::InvalidRequest(format!("更新维护状态失败: {}", e)))?; - - Ok(success) -} - -/// 结束维护 -pub async fn end_maintenance_service(svc_ctx: &SvcCtx) -> Result { - let success = crate::models::maintenance::end_maintenance(&svc_ctx.db) - .await - .map_err(|e| SimprintError::InvalidRequest(format!("结束维护失败: {}", e)))?; - - Ok(success) -} - -/// 获取当前活跃维护 -pub async fn get_active_maintenance_service( - svc_ctx: &SvcCtx, -) -> Result, SimprintError> { - let maintenance = crate::models::maintenance::get_active_maintenances(&svc_ctx.db) - .await - .map_err(|e| SimprintError::InvalidRequest(format!("查询活跃维护失败: {}", e)))?; - - Ok(maintenance) -} diff --git a/src-tauri/crates/business/src/services/proxy_visibility.rs b/src-tauri/crates/business/src/services/proxy_visibility.rs index 7c126fd..b918033 100644 --- a/src-tauri/crates/business/src/services/proxy_visibility.rs +++ b/src-tauri/crates/business/src/services/proxy_visibility.rs @@ -1,143 +1,167 @@ -use uuid::Uuid; - -use crate::dto::ProxyDto; -use crate::entitys::{ - BatchSetProxyVisibleRequest, ListVisibleProxiesRequest, - RemoveProxyVisibleRequest, SetProxyVisibleRequest, -}; -use crate::models; -use crate::svc_ctx::SvcCtx; - -/// 设置代理对团队可见 -pub async fn set_proxy_visible_to_team_service( - svc_ctx: &SvcCtx, - user_uuid: Uuid, - payload: &SetProxyVisibleRequest, -) -> Result<(), String> { - // 检查权限:只有代理所有者或工作空间所有者可以设置可见性 - let proxy = models::fetch_proxy_by_uuid(&svc_ctx.db, payload.proxy_uuid) - .await - .map_err(|e| e.to_string())? - .ok_or_else(|| "代理不存在".to_string())?; - - let is_owner = proxy.owner_uuid == user_uuid; - let is_workspace_owner = - models::check_workspace_owner(&svc_ctx.db, proxy.workspace_uuid, user_uuid) - .await - .map_err(|e| e.to_string())?; - - if !is_owner && !is_workspace_owner { - return Err("只有代理所有者或工作空间所有者可以设置可见性".to_string()); - } - - // 获取团队的工作空间 - let team = models::fetch_team_by_uuid(&svc_ctx.db, payload.team_uuid) - .await - .map_err(|e| e.to_string())? - .ok_or_else(|| "团队不存在".to_string())?; - - if team.workspace_uuid != proxy.workspace_uuid { - return Err("团队和工作空间不匹配".to_string()); - } - - models::insert_proxy_visible_team( - &svc_ctx.db, - payload.proxy_uuid, - proxy.workspace_uuid, - payload.team_uuid, - ) - .await - .map_err(|e| e.to_string()) -} - -/// 移除代理对团队的可见性 -pub async fn remove_proxy_visible_from_team_service( - svc_ctx: &SvcCtx, - user_uuid: Uuid, - payload: &RemoveProxyVisibleRequest, -) -> Result<(), String> { - // 检查权限:只有代理所有者或工作空间所有者可以移除可见性 - let proxy = models::fetch_proxy_by_uuid(&svc_ctx.db, payload.proxy_uuid) - .await - .map_err(|e| e.to_string())? - .ok_or_else(|| "代理不存在".to_string())?; - - let is_owner = proxy.owner_uuid == user_uuid; - let is_workspace_owner = - models::check_workspace_owner(&svc_ctx.db, proxy.workspace_uuid, user_uuid) - .await - .map_err(|e| e.to_string())?; - - if !is_owner && !is_workspace_owner { - return Err("只有代理所有者或工作空间所有者可以移除可见性".to_string()); - } - - models::remove_proxy_visible_team(&svc_ctx.db, payload.proxy_uuid, payload.team_uuid) - .await - .map_err(|e| e.to_string()) -} - -/// 批量设置代理可见性 -pub async fn batch_set_proxy_visible_service( - svc_ctx: &SvcCtx, - user_uuid: Uuid, - payload: &BatchSetProxyVisibleRequest, -) -> Result<(), String> { - // 检查权限 - let proxy = models::fetch_proxy_by_uuid(&svc_ctx.db, payload.proxy_uuid) - .await - .map_err(|e| e.to_string())? - .ok_or_else(|| "代理不存在".to_string())?; - - let is_owner = proxy.owner_uuid == user_uuid; - let is_workspace_owner = - models::check_workspace_owner(&svc_ctx.db, proxy.workspace_uuid, user_uuid) - .await - .map_err(|e| e.to_string())?; - - if !is_owner && !is_workspace_owner { - return Err("只有代理所有者或工作空间所有者可以设置可见性".to_string()); - } - - // 批量设置可见性 - for team_uuid in &payload.team_uuids { - models::insert_proxy_visible_team( - &svc_ctx.db, - payload.proxy_uuid, - proxy.workspace_uuid, - *team_uuid, - ) - .await - .map_err(|e| e.to_string())?; - } - - Ok(()) -} - -/// 获取可见的代理列表 -pub async fn get_visible_proxies_service( - svc_ctx: &SvcCtx, - user_uuid: Uuid, - payload: &ListVisibleProxiesRequest, -) -> Result, String> { - models::fetch_visible_proxies_for_user( - &svc_ctx.db, - payload.workspace_uuid, - user_uuid, - payload.team_uuid, - ) - .await - .map_err(|e| e.to_string()) -} - -/// 检查代理可见性 -pub async fn check_proxy_visibility_service( - svc_ctx: &SvcCtx, - proxy_uuid: Uuid, - workspace_uuid: Uuid, - team_uuid: Uuid, -) -> Result { - models::check_proxy_visibility(&svc_ctx.db, proxy_uuid, workspace_uuid, team_uuid) - .await - .map_err(|e| e.to_string()) -} +use uuid::Uuid; + +use crate::dto::ProxyDto; +use crate::entitys::{ + BatchSetProxyVisibleRequest, ListVisibleProxiesRequest, RemoveProxyVisibleRequest, + SetProxyVisibleRequest, +}; +use crate::models; +use crate::svc_ctx::SvcCtx; + +/// 设置代理对团队可见 +pub async fn set_proxy_visible_to_team_service( + svc_ctx: &SvcCtx, + user_uuid: Uuid, + payload: &SetProxyVisibleRequest, +) -> Result<(), String> { + // 检查权限:只有代理所有者或工作空间所有者可以设置可见性 + let proxy = models::fetch_proxy_by_uuid(&svc_ctx.db, payload.proxy_uuid) + .await + .map_err(|e| e.to_string())? + .ok_or_else(|| "代理不存在".to_string())?; + + let is_owner = proxy.owner_uuid == user_uuid; + let is_workspace_owner = + models::check_workspace_owner(&svc_ctx.db, proxy.workspace_uuid, user_uuid) + .await + .map_err(|e| e.to_string())?; + + if !is_owner && !is_workspace_owner { + return Err("只有代理所有者或工作空间所有者可以设置可见性".to_string()); + } + + // 获取团队的工作空间 + let team = models::fetch_team_by_uuid(&svc_ctx.db, payload.team_uuid) + .await + .map_err(|e| e.to_string())? + .ok_or_else(|| "团队不存在".to_string())?; + + if team.workspace_uuid != proxy.workspace_uuid { + return Err("团队和工作空间不匹配".to_string()); + } + + models::insert_proxy_visible_team( + &svc_ctx.db, + payload.proxy_uuid, + proxy.workspace_uuid, + payload.team_uuid, + ) + .await + .map_err(|e| e.to_string()) +} + +/// 移除代理对团队的可见性 +pub async fn remove_proxy_visible_from_team_service( + svc_ctx: &SvcCtx, + user_uuid: Uuid, + payload: &RemoveProxyVisibleRequest, +) -> Result<(), String> { + // 检查权限:只有代理所有者或工作空间所有者可以移除可见性 + let proxy = models::fetch_proxy_by_uuid(&svc_ctx.db, payload.proxy_uuid) + .await + .map_err(|e| e.to_string())? + .ok_or_else(|| "代理不存在".to_string())?; + + let is_owner = proxy.owner_uuid == user_uuid; + let is_workspace_owner = + models::check_workspace_owner(&svc_ctx.db, proxy.workspace_uuid, user_uuid) + .await + .map_err(|e| e.to_string())?; + + if !is_owner && !is_workspace_owner { + return Err("只有代理所有者或工作空间所有者可以移除可见性".to_string()); + } + + models::remove_proxy_visible_team(&svc_ctx.db, payload.proxy_uuid, payload.team_uuid) + .await + .map_err(|e| e.to_string()) +} + +/// 批量设置代理可见性 +pub async fn batch_set_proxy_visible_service( + svc_ctx: &SvcCtx, + user_uuid: Uuid, + payload: &BatchSetProxyVisibleRequest, +) -> Result<(), String> { + // 检查权限 + let proxy = models::fetch_proxy_by_uuid(&svc_ctx.db, payload.proxy_uuid) + .await + .map_err(|e| e.to_string())? + .ok_or_else(|| "代理不存在".to_string())?; + + let is_owner = proxy.owner_uuid == user_uuid; + let is_workspace_owner = + models::check_workspace_owner(&svc_ctx.db, proxy.workspace_uuid, user_uuid) + .await + .map_err(|e| e.to_string())?; + + if !is_owner && !is_workspace_owner { + return Err("只有代理所有者或工作空间所有者可以设置可见性".to_string()); + } + + // 批量设置可见性 + for team_uuid in &payload.team_uuids { + models::insert_proxy_visible_team( + &svc_ctx.db, + payload.proxy_uuid, + proxy.workspace_uuid, + *team_uuid, + ) + .await + .map_err(|e| e.to_string())?; + } + + Ok(()) +} + +/// 获取可见的代理列表 +pub async fn get_visible_proxies_service( + svc_ctx: &SvcCtx, + user_uuid: Uuid, + payload: &ListVisibleProxiesRequest, +) -> Result, String> { + models::fetch_visible_proxies_for_user( + &svc_ctx.db, + payload.workspace_uuid, + user_uuid, + payload.team_uuid, + ) + .await + .map_err(|e| e.to_string()) +} + +/// 检查代理可见性 +pub async fn check_proxy_visibility_service( + svc_ctx: &SvcCtx, + proxy_uuid: Uuid, + workspace_uuid: Uuid, + team_uuid: Uuid, +) -> Result { + models::check_proxy_visibility(&svc_ctx.db, proxy_uuid, workspace_uuid, team_uuid) + .await + .map_err(|e| e.to_string()) +} + +pub async fn get_proxy_visible_teams_service( + svc_ctx: &SvcCtx, + proxy_uuid: Uuid, +) -> Result, String> { + let visible_teams = models::fetch_visible_teams_by_proxy(&svc_ctx.db, proxy_uuid) + .await + .map_err(|error| error.to_string())?; + let mut details = Vec::with_capacity(visible_teams.len()); + for visible in visible_teams { + let team_name = models::fetch_team_by_uuid(&svc_ctx.db, visible.team_uuid) + .await + .map_err(|error| error.to_string())? + .map(|team| team.name); + details.push(crate::dto::ProxyVisibleTeamDetailDto { + proxy_uuid: visible.proxy_uuid, + workspace_uuid: visible.workspace_uuid, + team_uuid: visible.team_uuid, + team_name, + created_at: visible.created_at, + }); + } + Ok(details) +} diff --git a/src-tauri/crates/business/src/services/strategy_types.rs b/src-tauri/crates/business/src/services/strategy_types.rs deleted file mode 100644 index 841dd14..0000000 --- a/src-tauri/crates/business/src/services/strategy_types.rs +++ /dev/null @@ -1,50 +0,0 @@ -use crate::{ - dto::strategy_types::StrategyType, entitys::strategy_types::*, errors::SimprintError, - svc_ctx::SvcCtx, -}; - -/// 根据ID查询策略类型 -pub async fn get_strategy_type_by_id( - svc_ctx: &SvcCtx, - id: i32, -) -> Result { - let strategy_type = crate::models::strategy_types::query_strategy_type_by_id(&svc_ctx.db, id) - .await - .map_err(|_| SimprintError::StrategyTypeNotFound)?; - - Ok(strategy_type) -} - -/// 根据code查询策略类型 -pub async fn get_strategy_type_by_code( - svc_ctx: &SvcCtx, - code: &str, -) -> Result { - let strategy_type = - crate::models::strategy_types::query_strategy_type_by_code(&svc_ctx.db, &code) - .await - .map_err(|_| SimprintError::StrategyTypeNotFound)?; - - Ok(strategy_type) -} - -/// 查询所有可用策略类型 -pub async fn query_available_strategy_types( - svc_ctx: &SvcCtx, -) -> Result { - let list = crate::models::strategy_types::query_available_strategy_types(&svc_ctx.db).await?; - - Ok(StrategyTypeListResponse { list }) -} - -/// 根据分类查询策略类型 -pub async fn query_strategy_types_by_category( - svc_ctx: &SvcCtx, - category: &str, -) -> Result { - let list = - crate::models::strategy_types::query_strategy_types_by_category(&svc_ctx.db, &category) - .await?; - - Ok(StrategyTypeListResponse { list }) -} diff --git a/src-tauri/crates/business/src/services/teams.rs b/src-tauri/crates/business/src/services/teams.rs index 0859ef1..f52f0ba 100644 --- a/src-tauri/crates/business/src/services/teams.rs +++ b/src-tauri/crates/business/src/services/teams.rs @@ -203,27 +203,11 @@ pub async fn invite_member_service( .map_err(|e| e.to_string())?; // 1. 发送邮件通知 - let smtp_config = svc_ctx.config.clone().smtp.ok_or_else(|| "邮箱配置不存在".to_string())?; let inviter_name: &str = inviter_info.nickname.as_deref().unwrap_or_else(|| inviter_info.email.as_str()); let inviter_email: &str = inviter_info.email.as_str(); - let email_title = format!("团队邀请:{} 邀请您加入团队", inviter_name); - let email_body = format!( - "

团队邀请

您好,

{}({})邀请您加入团队 {}

邀请角色:{}

邀请有效期:7 天

接受邀请

如果您不想接受此邀请,可以忽略此邮件。

", - inviter_name, inviter_email, team.name, payload.role - ); - - // 发送邮件(失败不影响邀请流程) - let _ = crate::utils::send_email( - &smtp_config.smtp_username, - &smtp_config.smtp_password, - &smtp_config.smtp_server, - &payload.email, - &email_title, - &email_body, - ); // 2. 如果用户已注册,发送团队邀请消息通知 let _ = crate::services::messages::create_message_service( diff --git a/src-tauri/crates/business/src/services/users.rs b/src-tauri/crates/business/src/services/users.rs deleted file mode 100644 index b596ce5..0000000 --- a/src-tauri/crates/business/src/services/users.rs +++ /dev/null @@ -1,469 +0,0 @@ -use uuid::Uuid; - -use crate::caches::{ - get_register_code, get_reset_password_code, get_user_public_key, set_register_code, - set_reset_password_code, set_user_public_key, -}; -use crate::entitys::{ - LoginRequest, LoginResponse, RegisterRequest, RegisterResponse, ResetPasswordRequest, - UpdatePasswordRequest, UpdateUserRequest, UserResponse, VerifyPasswordRequest, - VerifyPasswordResponse, -}; -use crate::models::{ - user::{ - create_user_with_info, fetch_user_by_uuid, fetch_user_info_by_email, - fetch_user_info_by_uuid, update_password, update_user_info, - }, -}; -use crate::services::messages; -use crate::svc_ctx::SvcCtx; -use crate::utils::{ - encryption_password, generate_token, random_six_number_code, send_email, verify_password, -}; - -/// 通用登录逻辑(内部辅助函数) -/// -/// 处理登录、注册、记住密码登录的共同逻辑: -/// - 生成 Token -/// - 保存公钥到缓存 -/// - 获取用户信息 -async fn common_login_logic( - svc_ctx: &SvcCtx, - user_uuid: Uuid, - public_key: Option<&String>, -) -> Result<(String, String, Option), anyhow::Error> { - let secret = svc_ctx.config.app.secret.as_bytes(); - - // 1. 生成 Token - let access_token = generate_token(&user_uuid.to_string(), 60 * 60 * 2, secret)?; // 2 小时 - let refresh_token = generate_token(&user_uuid.to_string(), 60 * 60 * 24 * 7, secret)?; // 7 天 - - // 2. 如果提供了公钥,保存到缓存 - if let Some(ref public_key) = public_key { - if !public_key.is_empty() { - let _ = set_user_public_key(svc_ctx, &user_uuid, public_key).await; - // 保存失败不影响登录流程,只记录错误 - } - } - - // 3. 获取用户信息 - let user_info = get_current_user_service(svc_ctx, user_uuid).await.ok(); - - Ok((access_token, refresh_token, user_info)) -} - -/// 用户注册服务 -/// -/// # Arguments -/// * `svc_ctx` - 服务上下文 -/// * `payload` - 注册请求数据 -/// -/// # Returns -/// 返回注册响应 -pub async fn register_service( - svc_ctx: &SvcCtx, - payload: &RegisterRequest, -) -> Result { - let pool = &svc_ctx.db; - // 1. 验证验证码 - let cached_code = get_register_code(svc_ctx, &payload.email).await?; - if cached_code != Some(payload.code.clone()) { - return Err(anyhow::anyhow!("验证码错误或已过期")); - } - - // 2. 检查邮箱是否已存在 - let existing_user = fetch_user_info_by_email(pool, &payload.email).await?; - if existing_user.is_some() { - return Err(anyhow::anyhow!("邮箱已被注册")); - } - - // 3. 加密密码 - let password_hash = encryption_password(&payload.password)?; - - // 4. 生成用户 ID(使用 UUID 的前8位作为用户 ID) - let user_id = format!("USER{}", Uuid::new_v4().to_string()[..8].to_uppercase()); - - // 5. 创建用户(传入默认配额配置) - let quota = &svc_ctx.config.workspace_quota.default; - let user_uuid = create_user_with_info(pool, user_id, payload, &password_hash, quota).await?; - - // 6. 执行通用登录逻辑 - let (access_token, refresh_token, user_info) = - common_login_logic(svc_ctx, user_uuid, payload.public_secret_key.as_ref()).await?; - - // 7. 发送欢迎系统通知 - let _ = messages::create_message_service( - svc_ctx, - None, // 系统消息,无发送者 - &crate::entitys::CreateMessageRequest { - message_type: "system_notification".to_string(), - title: "欢迎加入".to_string(), - content: Some("感谢您的注册!我们很高兴您能加入我们。祝您使用愉快!".to_string()), - recipient_uuids: vec![user_uuid], - recipient_type: "single".to_string(), - related_type: None, - related_uuid: None, - priority: Some("normal".to_string()), - metadata: None, - }, - ) - .await; - // 注意:消息创建失败不影响注册流程,使用 _ 忽略错误 - - Ok(RegisterResponse { - access_token, - refresh_token, - user_info, - }) -} - -/// 用户登录服务(统一处理两种登录方式) -/// -/// # Arguments -/// * `svc_ctx` - 服务上下文 -/// * `payload` - 登录请求数据 -/// -/// # Returns -/// 返回登录响应 -pub async fn login_service( - svc_ctx: &SvcCtx, - payload: LoginRequest, -) -> Result { - let pool = &svc_ctx.db; - let (user_uuid, public_secret_key) = match payload { - LoginRequest::Basic(data) => { - // 基本登录:验证邮箱和密码 - // 1. 查询用户 - let user_info = fetch_user_info_by_email(pool, &data.email) - .await? - .ok_or_else(|| anyhow::anyhow!("账号不存在"))?; - - // 2. 验证密码 - if !verify_password(&data.password, &user_info.password) { - return Err(anyhow::anyhow!("密码错误")); - } - - // 3. 检查用户状态 - if user_info.status != "active" { - return Err(anyhow::anyhow!("用户已被禁用")); - } - - (user_info.user_uuid, data.public_secret_key) - } - LoginRequest::Remember(data) => { - // 记住密码登录:验证 refresh_token - // 1. 验证 refresh_token - let secret = svc_ctx.config.app.secret.as_bytes(); - let user_uuid_str = verify_token_service(&data.refresh_token, secret)?; - let user_uuid = Uuid::parse_str(&user_uuid_str)?; - - // 2. 查询用户信息 - let user_info = fetch_user_info_by_uuid(pool, user_uuid) - .await? - .ok_or_else(|| anyhow::anyhow!("账号不存在"))?; - - // 3. 验证邮箱是否匹配 - if user_info.email != data.email { - return Err(anyhow::anyhow!("邮箱不匹配")); - } - - // 4. 检查用户状态 - if user_info.status != "active" { - return Err(anyhow::anyhow!("用户已被禁用")); - } - - (user_uuid, data.public_secret_key) - } - }; - - // 执行通用登录逻辑 - let (access_token, refresh_token, user_response) = - common_login_logic(svc_ctx, user_uuid, public_secret_key.as_ref()).await?; - - Ok(LoginResponse { - access_token, - refresh_token, - user_info: user_response, - }) -} - -/// 验证 Token 服务 -/// -/// # Arguments -/// * `token` - 要验证的 token -/// * `secret` - JWT 密钥 -/// -/// # Returns -/// 返回用户 UUID -pub fn verify_token_service(token: &str, secret: &[u8]) -> Result { - crate::utils::verify_token(token, secret) -} - -/// 刷新 Token 服务 -/// -/// # Arguments -/// * `pool` - 数据库连接池 -/// * `svc_ctx` - 服务上下文 -/// * `refresh_token` - 刷新令牌 -/// -/// # Returns -/// 返回登录响应 -pub async fn refresh_token_service( - svc_ctx: &SvcCtx, - refresh_token: &str, -) -> Result { - let pool = &svc_ctx.db; - // 1. 验证 refresh_token - let secret = svc_ctx.config.app.secret.as_bytes(); - let user_uuid_str = verify_token_service(refresh_token, secret)?; - - // 2. 验证用户是否存在 - let user_uuid = Uuid::parse_str(&user_uuid_str)?; - let user = fetch_user_by_uuid(pool, user_uuid).await?; - if user.is_none() { - return Err(anyhow::anyhow!("用户不存在")); - } - - // 3. 生成新的 Token - let access_token = generate_token(&user_uuid_str, 60 * 60 * 2, secret)?; // 2 小时 - let new_refresh_token = generate_token(&user_uuid_str, 60 * 60 * 24 * 7, secret)?; // 7 天 - - Ok(LoginResponse { - access_token, - refresh_token: new_refresh_token, - user_info: None, - }) -} - -/// 获取当前用户信息服务 -/// -/// # Arguments -/// * `svc_ctx` - 服务上下文 -/// * `user_uuid` - 用户 UUID -/// -/// # Returns -/// 返回用户信息响应(包含当前团队信息) -pub async fn get_current_user_service( - svc_ctx: &SvcCtx, - user_uuid: Uuid, -) -> Result { - let pool = &svc_ctx.db; - let user = fetch_user_by_uuid(pool, user_uuid) - .await? - .ok_or_else(|| anyhow::anyhow!("用户不存在"))?; - - let user_info = fetch_user_info_by_uuid(pool, user_uuid) - .await? - .ok_or_else(|| anyhow::anyhow!("用户信息不存在"))?; - - // 获取当前团队详细信息 - let current_team = if let Some(team_uuid) = user_info.current_team_uuid { - // 查询团队详细信息 - crate::models::teams::fetch_team_by_uuid(pool, team_uuid).await.ok().flatten() - } else { - None - }; - - // 获取当前工作空间详细信息 - let current_workspace = if let Some(workspace_uuid) = user_info.current_workspace_uuid { - // 查询工作空间详细信息 - crate::models::workspaces::fetch_workspace_by_uuid(pool, workspace_uuid) - .await - .ok() - .flatten() - } else { - None - }; - - let avatar_url = user_info.avatar_hash.as_ref().map(|hash| { - crate::utils::storage::get_objects::get_avatar_url( - &svc_ctx.config.storage.public_base_url, - &svc_ctx.config.storage.avatar_root, - hash, - ) - }); - - Ok(UserResponse { - uuid: user.uuid, - id: user.id, - nickname: user_info.nickname, - email: user_info.email, - phone: user_info.phone, - avatar_hash: user_info.avatar_hash, - avatar_url, - status: user_info.status, - created_at: user.created_at, - updated_at: user.updated_at, - current_team, - current_workspace, - }) -} - -/// 更新用户信息服务 -/// -/// # Arguments -/// * `pool` - 数据库连接池 -/// * `user_uuid` - 用户 UUID -/// * `payload` - 更新请求数据 -pub async fn update_user_service( - svc_ctx: &SvcCtx, - user_uuid: Uuid, - payload: &UpdateUserRequest, -) -> Result<(), anyhow::Error> { - let pool = &svc_ctx.db; - update_user_info(pool, user_uuid, payload).await?; - Ok(()) -} - -/// 修改密码服务 -/// -/// # Arguments -/// * `pool` - 数据库连接池 -/// * `user_uuid` - 用户 UUID -/// * `payload` - 修改密码请求数据 -pub async fn update_password_service( - svc_ctx: &SvcCtx, - user_uuid: Uuid, - payload: &UpdatePasswordRequest, -) -> Result<(), anyhow::Error> { - let pool = &svc_ctx.db; - // 1. 查询用户 - let user_info = fetch_user_info_by_uuid(pool, user_uuid) - .await? - .ok_or_else(|| anyhow::anyhow!("用户不存在"))?; - - // 2. 验证旧密码 - if !verify_password(&payload.old_password, &user_info.password) { - return Err(anyhow::anyhow!("原密码错误")); - } - - // 3. 加密新密码 - let password_hash = encryption_password(&payload.new_password)?; - - // 4. 更新密码 - update_password(pool, user_uuid, &password_hash).await?; - - Ok(()) -} - -/// 校验当前用户密码 -pub async fn verify_password_service( - svc_ctx: &SvcCtx, - user_uuid: Uuid, - payload: &VerifyPasswordRequest, -) -> Result { - let user_info = fetch_user_info_by_uuid(&svc_ctx.db, user_uuid) - .await? - .ok_or_else(|| anyhow::anyhow!("用户不存在"))?; - - if user_info.status != "active" { - return Err(anyhow::anyhow!("用户已被禁用")); - } - - Ok(VerifyPasswordResponse { - valid: verify_password(&payload.password, &user_info.password), - }) -} - -/// 重置密码服务 -/// -/// # Arguments -/// * `pool` - 数据库连接池 -/// * `svc_ctx` - 服务上下文 -/// * `payload` - 重置密码请求数据 -pub async fn reset_password_service( - svc_ctx: &SvcCtx, - payload: &ResetPasswordRequest, -) -> Result<(), anyhow::Error> { - let pool = &svc_ctx.db; - // 1. 验证验证码 - let cached_code = get_reset_password_code(svc_ctx, &payload.email).await?; - if cached_code != Some(payload.code.clone()) { - return Err(anyhow::anyhow!("验证码错误或已过期")); - } - - // 2. 查询用户 - let user_info = fetch_user_info_by_email(pool, &payload.email) - .await? - .ok_or_else(|| anyhow::anyhow!("用户不存在"))?; - - // 3. 加密新密码 - let password_hash = encryption_password(&payload.new_password)?; - - // 4. 更新密码 - update_password(pool, user_info.user_uuid, &password_hash).await?; - - Ok(()) -} - -/// 发送验证码服务 -/// -/// # Arguments -/// * `svc_ctx` - 服务上下文 -/// * `email` - 邮箱地址 -/// * `code_type` - 验证码类型(register 或 reset_password) -pub async fn send_verification_code_service( - svc_ctx: &SvcCtx, - email: &str, - code_type: &str, -) -> Result<(), anyhow::Error> { - if code_type == "reset_password" { - let user_exists = fetch_user_info_by_email(&svc_ctx.db, email).await?.is_some(); - if !user_exists { - return Err(anyhow::anyhow!("用户不存在")); - } - } - - let smtp_config = svc_ctx - .config - .clone() - .smtp - .ok_or_else(|| anyhow::anyhow!("邮箱配置不存在"))?; - - // 1. 生成验证码 - let code = random_six_number_code(); - - // 2. 存储验证码到 Redis - match code_type { - "register" => set_register_code(svc_ctx, email, &code).await?, - "reset_password" => set_reset_password_code(svc_ctx, email, &code).await?, - _ => return Err(anyhow::anyhow!("无效的验证码类型")), - } - - // 3. 发送邮件 - let title = match code_type { - "register" => "注册验证码", - "reset_password" => "重置密码验证码", - _ => "验证码", - }; - let body = format!("您的验证码是:{},有效期 5 分钟。", code); - - send_email( - &smtp_config.smtp_username, - &smtp_config.smtp_password, - &smtp_config.smtp_server, - email, - title, - &body, - )?; - - Ok(()) -} - -/// 获取用户公钥 -/// -/// # Arguments -/// * `svc_ctx` - 服务上下文 -/// * `user_uuid` - 用户对应的UUID -/// -/// # Returns -/// 返回用户公钥,如果获取成功则返回公钥,否则返回错误 -pub async fn get_user_public_key_service( - svc_ctx: &SvcCtx, - user_uuid: &Uuid, -) -> Result { - let public_key = get_user_public_key(svc_ctx, user_uuid) - .await? - .ok_or_else(|| anyhow::anyhow!("用户公钥不存在"))?; - Ok(public_key) -} diff --git a/src-tauri/crates/business/src/services/version_types.rs b/src-tauri/crates/business/src/services/version_types.rs deleted file mode 100644 index 125829f..0000000 --- a/src-tauri/crates/business/src/services/version_types.rs +++ /dev/null @@ -1,123 +0,0 @@ -use crate::{ - dto::version_types::VersionType, entitys::version_types::*, errors::SimprintError, - svc_ctx::SvcCtx, -}; - -/// 创建版本类型 -pub async fn create_version_type( - svc_ctx: &SvcCtx, - request: CreateVersionTypeRequest, -) -> Result { - // 验证类型代码 - if request.type_code.is_empty() { - return Err(SimprintError::Other("版本类型代码不能为空".to_string())); - } - - // 检查版本类型是否已存在 - let exists = - crate::models::version_types::query_version_type_by_code(&svc_ctx.db, &request.type_code) - .await - .ok(); - - if exists.is_some() { - return Err(SimprintError::Other("版本类型已存在".to_string())); - } - - // 创建版本类型 - let id = crate::models::version_types::insert_version_type(&svc_ctx.db, &request).await?; - - Ok(id) -} - -/// 根据ID查询版本类型 -pub async fn get_version_type_by_id( - svc_ctx: &SvcCtx, - id: i32, -) -> Result { - let version_type = crate::models::version_types::query_version_type_by_id(&svc_ctx.db, id) - .await - .map_err(|_| SimprintError::Other("版本类型不存在".to_string()))?; - - Ok(version_type) -} - -/// 根据代码查询版本类型 -pub async fn get_version_type_by_code( - svc_ctx: &SvcCtx, - type_code: String, -) -> Result { - let version_type = - crate::models::version_types::query_version_type_by_code(&svc_ctx.db, &type_code) - .await - .map_err(|_| SimprintError::Other("版本类型不存在".to_string()))?; - - Ok(version_type) -} - -/// 查询所有版本类型 -pub async fn query_all_version_types_service( - svc_ctx: &SvcCtx, -) -> Result { - let list = crate::models::version_types::query_all_version_types(&svc_ctx.db).await?; - - Ok(VersionTypeListResponse { list }) -} - -/// 查询激活的版本类型 -pub async fn query_active_version_types_service( - svc_ctx: &SvcCtx, -) -> Result { - let list = crate::models::version_types::query_active_version_types(&svc_ctx.db).await?; - - Ok(VersionTypeListResponse { list }) -} - -/// 更新版本类型 -pub async fn update_version_type_service( - svc_ctx: &SvcCtx, - id: i32, - request: UpdateVersionTypeRequest, -) -> Result { - // 检查版本类型是否存在 - crate::models::version_types::query_version_type_by_id(&svc_ctx.db, id) - .await - .map_err(|_| SimprintError::Other("版本类型不存在".to_string()))?; - - // 更新版本类型 - let success = - crate::models::version_types::update_version_type(&svc_ctx.db, id, &request).await?; - - if !success { - return Err(SimprintError::Other("更新版本类型失败".to_string())); - } - - Ok(true) -} - -/// 删除版本类型 -pub async fn delete_version_type_service(svc_ctx: &SvcCtx, id: i32) -> Result { - let success = crate::models::version_types::delete_version_type(&svc_ctx.db, id).await?; - - if !success { - return Err(SimprintError::Other("删除版本类型失败".to_string())); - } - - Ok(true) -} - -/// 激活/停用版本类型 -pub async fn toggle_version_type_status_service( - svc_ctx: &SvcCtx, - id: i32, - is_active: bool, -) -> Result { - let success = - crate::models::version_types::toggle_version_type_status(&svc_ctx.db, id, is_active) - .await?; - - if !success { - return Err(SimprintError::Other("激活/停用版本类型失败".to_string())); - } - - Ok(true) -} diff --git a/src-tauri/crates/business/src/services/versions.rs b/src-tauri/crates/business/src/services/versions.rs deleted file mode 100644 index 7d6fd6e..0000000 --- a/src-tauri/crates/business/src/services/versions.rs +++ /dev/null @@ -1,240 +0,0 @@ -use crate::{ - dto::versions::Version, entitys::versions::*, errors::SimprintError, svc_ctx::SvcCtx, - utils::get_objects::get_version_resource_url, -}; -use std::collections::HashMap; - -/// 创建版本 -pub async fn create_version( - svc_ctx: &SvcCtx, - request: CreateVersionRequest, -) -> Result { - // 验证版本号 - if request.version.is_empty() { - return Err(SimprintError::VersionEmpty); - } - - // 验证资源名称 - if request.resource_name.is_empty() { - return Err(SimprintError::ResourceNameEmpty); - } - - // 检查版本是否已存在 - let exists = crate::models::versions::query_version_by_name_and_version( - &svc_ctx.db, - &request.resource_name, - &request.version, - ) - .await - .ok(); - - if exists.is_some() { - return Err(SimprintError::VersionAlreadyExists); - } - - // 创建版本 - let id = crate::models::versions::insert_version(&svc_ctx.db, &request).await?; - - Ok(id) -} - -/// 根据ID查询版本 -pub async fn get_version_by_id(svc_ctx: &SvcCtx, id: i32) -> Result { - let mut version = crate::models::versions::query_version_by_id(&svc_ctx.db, id) - .await - .map_err(|_| SimprintError::VersionNotFound)?; - - let storage_config = &svc_ctx.config.storage; - let object_path = version.url.clone().unwrap_or_default(); - version.url = Some(get_version_resource_url( - &storage_config.public_base_url, - &storage_config.version_root, - &object_path, - )); - - Ok(version) -} - -/// 根据资源名称和版本号查询版本 -pub async fn get_version_by_name_and_version( - svc_ctx: &SvcCtx, - resource_name: String, - version: String, -) -> Result { - let version_data = crate::models::versions::query_version_by_name_and_version( - &svc_ctx.db, - &resource_name, - &version, - ) - .await - .map_err(|_| SimprintError::VersionNotFound)?; - - Ok(version_data) -} - -/// 查询最新版本 -pub async fn get_latest_version( - svc_ctx: &SvcCtx, - resource_name: String, - platform: String, -) -> Result { - let version = - crate::models::versions::query_latest_version(&svc_ctx.db, &resource_name, &platform) - .await - .map_err(|_| SimprintError::VersionNotFound)?; - - version.ok_or(SimprintError::VersionNotFound) -} - -/// 查询版本列表 -pub async fn query_versions_service( - svc_ctx: &SvcCtx, - params: QueryVersionParams, - page_num: i32, - page_size: i32, -) -> Result { - let (total, list) = crate::models::versions::query_versions( - &svc_ctx.db, - params.resource_name.as_deref(), - params.platform.as_deref(), - params.status.as_deref(), - page_num, - page_size, - ) - .await?; - - Ok(VersionListResponse { total, list }) -} - -/// 更新版本 -pub async fn update_version_service( - svc_ctx: &SvcCtx, - id: i32, - request: UpdateVersionRequest, -) -> Result { - // 检查版本是否存在 - crate::models::versions::query_version_by_id(&svc_ctx.db, id) - .await - .map_err(|_| SimprintError::VersionNotFound)?; - - // 更新版本 - let success = crate::models::versions::update_version(&svc_ctx.db, id, &request).await?; - - if !success { - return Err(SimprintError::VersionNotFound); - } - - Ok(true) -} - -/// 删除版本 -pub async fn delete_version_service(svc_ctx: &SvcCtx, id: i32) -> Result { - let success = crate::models::versions::delete_version(&svc_ctx.db, id).await?; - - if !success { - return Err(SimprintError::VersionNotFound); - } - - Ok(true) -} - -/// 设置某版本为最新版本 -pub async fn set_latest_version_service( - svc_ctx: &SvcCtx, - type_id: i32, - resource_name: String, - version_id: i32, -) -> Result { - // 检查版本是否存在 - crate::models::versions::query_version_by_id(&svc_ctx.db, version_id) - .await - .map_err(|_| SimprintError::VersionNotFound)?; - - // 设置为最新版本 - let success = crate::models::versions::set_as_latest_version( - &svc_ctx.db, - type_id, - &resource_name, - version_id, - ) - .await?; - - if !success { - return Err(SimprintError::Other("设置最新版本失败".to_string())); - } - - Ok(true) -} - -/// 版本回退到指定版本 -pub async fn rollback_version_service( - svc_ctx: &SvcCtx, - type_id: i32, - resource_name: String, - target_version_id: i32, -) -> Result { - // 检查目标版本是否存在 - let target_version = - crate::models::versions::query_version_by_id(&svc_ctx.db, target_version_id) - .await - .map_err(|_| SimprintError::VersionNotFound)?; - - // 检查版本是否属于同一资源 - if target_version.type_id != type_id || target_version.resource_name != resource_name { - return Err(SimprintError::VersionNotFound); - } - - // 设置目标版本为最新 - let success = - set_latest_version_service(svc_ctx, type_id, resource_name, target_version_id).await?; - - if !success { - return Err(SimprintError::Other("版本回退失败".to_string())); - } - - Ok(true) -} - -/// 版本差异对比 -pub async fn compare_versions_service( - svc_ctx: &SvcCtx, - version_id_1: i32, - version_id_2: i32, -) -> Result<(Version, Version), SimprintError> { - let version_1 = crate::models::versions::query_version_by_id(&svc_ctx.db, version_id_1) - .await - .map_err(|_| SimprintError::VersionNotFound)?; - - let version_2 = crate::models::versions::query_version_by_id(&svc_ctx.db, version_id_2) - .await - .map_err(|_| SimprintError::VersionNotFound)?; - - Ok((version_1, version_2)) -} - -/// 查询所有激活版本类型的最新版本 -pub async fn get_all_active_latest_versions_service( - svc_ctx: &SvcCtx, - platform: String, -) -> Result>, SimprintError> { - let results = - crate::models::versions::query_all_active_latest_versions(&svc_ctx.db, &platform).await?; - - // 转换为 HashMap> - let mut map: HashMap> = HashMap::new(); - - let storage_config = &svc_ctx.config.storage; - - for (type_code, _resource_name, version) in results { - map.entry(type_code).or_insert_with(Vec::new).push(Version { - url: Some(get_version_resource_url( - &storage_config.public_base_url, - &storage_config.version_root, - &version.url.unwrap_or_default(), - )), - ..version - }); - } - - Ok(map) -} diff --git a/src-tauri/crates/business/src/services/workspaces.rs b/src-tauri/crates/business/src/services/workspaces.rs index 8603371..3e65cd4 100644 --- a/src-tauri/crates/business/src/services/workspaces.rs +++ b/src-tauri/crates/business/src/services/workspaces.rs @@ -39,7 +39,7 @@ pub async fn create_workspace_service( .map_err(|e| e.to_string())?; // 创建默认配额(从配置读取) - let quota = &svc_ctx.config.workspace_quota.default; + let quota = &svc_ctx.workspace_quota.default; models::insert_or_update_workspace_quota( &svc_ctx.db, workspace_uuid, diff --git a/src-tauri/crates/business/src/svc_ctx.rs b/src-tauri/crates/business/src/svc_ctx.rs index bd84a75..4f71960 100644 --- a/src-tauri/crates/business/src/svc_ctx.rs +++ b/src-tauri/crates/business/src/svc_ctx.rs @@ -1,30 +1,81 @@ use crate::{ - caches::CacheStore, database::{self, DbPool}, - utils::{DatabaseConfig, IConfig}, + entitys::CreateWorkspaceRequest, + utils::{DatabaseConfig, WorkspaceQuotaConfig}, }; - -/// Shared resources used by handlers and services. -#[derive(Clone)] -pub struct SvcCtx { - pub config: IConfig, +use uuid::Uuid; + +/// Shared resources used by handlers and services. +#[derive(Clone)] +pub struct SvcCtx { pub db: DbPool, - pub cache: CacheStore, -} - -impl SvcCtx { - pub async fn new(config: &IConfig) -> Result { - let db = Self::create_db(&config.database).await?; - let cache = CacheStore::memory(); - - Ok(Self { - config: config.clone(), - db, - cache, - }) - } - + pub workspace_quota: WorkspaceQuotaConfig, + pub local_user_uuid: Uuid, +} + +impl SvcCtx { + pub async fn new(config: &DatabaseConfig) -> Result { + let db = Self::create_db(config).await?; + database::migrate(&db).await?; + + let local_user_uuid = Self::ensure_local_user(&db).await?; + let context = Self { + db, + workspace_quota: WorkspaceQuotaConfig::default(), + local_user_uuid, + }; + context.ensure_local_workspace().await?; + Ok(context) + } + pub async fn create_db(config: &DatabaseConfig) -> Result { database::connect(config).await } + + async fn ensure_local_user(db: &DbPool) -> Result { + if let Some(uuid) = sqlx::query_scalar::<_, Uuid>( + "SELECT uuid FROM users WHERE id = 'LOCAL' AND deleted_at IS NULL LIMIT 1", + ) + .fetch_optional(db) + .await? + { + return Ok(uuid); + } + + let uuid = Uuid::new_v4(); + let mut tx = db.begin().await?; + sqlx::query("INSERT INTO users (uuid, id) VALUES ($1, 'LOCAL')") + .bind(uuid) + .execute(&mut *tx) + .await?; + sqlx::query( + "INSERT INTO user_infos (user_uuid, nickname, email, password) VALUES ($1, $2, $3, '')", + ) + .bind(uuid) + .bind("Local User") + .bind(format!("local-{uuid}@simprint.invalid")) + .execute(&mut *tx) + .await?; + tx.commit().await?; + Ok(uuid) + } + + async fn ensure_local_workspace(&self) -> Result<(), anyhow::Error> { + if crate::models::workspaces::fetch_user_workspaces(&self.db, self.local_user_uuid) + .await? + .is_empty() + { + crate::services::workspaces::create_workspace_service( + self, + self.local_user_uuid, + &CreateWorkspaceRequest { + name: "Local Workspace".to_string(), + workspace_type: Some("personal".to_string()), + }, + ) + .await + .map_err(anyhow::Error::msg)?; + } + Ok(()) + } } diff --git a/src-tauri/crates/business/src/utils.rs b/src-tauri/crates/business/src/utils.rs index c51c2f1..aa4926f 100644 --- a/src-tauri/crates/business/src/utils.rs +++ b/src-tauri/crates/business/src/utils.rs @@ -1,44 +1,3 @@ mod config; -mod jwt; -mod password; -pub mod storage; pub use config::*; -pub use jwt::*; -pub use password::*; -pub use storage::*; - -/// 向指定邮箱发送验证码 -pub fn send_email( - smtp_username: &str, - smtp_password: &str, - smtp_server: &str, - to: &str, - title: &str, - body: &str, -) -> std::result::Result { - use lettre::message::header::ContentType; - use lettre::transport::smtp::authentication::Credentials; - use lettre::{Message, SmtpTransport, Transport}; - - let email = Message::builder() - .from(smtp_username.parse().map_err(|_| anyhow::anyhow!("from email is failed."))?) - .to(to.parse().map_err(|_| anyhow::anyhow!("receive email is failed."))?) - .subject(title) - .header(ContentType::TEXT_HTML) - .body(String::from(body))?; - let creds = Credentials::new(smtp_username.to_string(), smtp_password.to_string()); - let mailer = SmtpTransport::relay(smtp_server).unwrap().credentials(creds).build(); - - match mailer.send(&email) { - Ok(_) => Ok(true), - Err(_e) => Err(anyhow::anyhow!("email send failed.")), - } -} - -/// 随机生成6位的数字编码 -pub fn random_six_number_code() -> String { - let mut rng = rand::rng(); - let random_number: i32 = rand::Rng::random_range(&mut rng, 100000..1000000); - format!("{}", random_number) -} diff --git a/src-tauri/crates/business/src/utils/config.rs b/src-tauri/crates/business/src/utils/config.rs index a964ca4..bc0c378 100644 --- a/src-tauri/crates/business/src/utils/config.rs +++ b/src-tauri/crates/business/src/utils/config.rs @@ -1,7 +1,7 @@ -use config::{Config, ConfigError}; use serde::Deserialize; +use std::path::Path; -/// 数据库配置 +/// Connection settings for the embedded SQLite database. #[derive(Debug, Clone, Deserialize)] pub struct DatabaseConfig { pub url: String, @@ -12,48 +12,38 @@ pub struct DatabaseConfig { pub idle_timeout: u64, } -/// Download-resource URL configuration retained by the transitional HTTP API. -#[derive(Debug, Clone, Deserialize)] -pub struct StorageConfig { - #[serde(default)] - pub public_base_url: String, - #[serde(default = "default_avatar_root")] - pub avatar_root: String, - #[serde(default = "default_extension_root")] - pub extension_root: String, - #[serde(default = "default_version_root")] - pub version_root: String, -} - -fn default_avatar_root() -> String { - "avatars".to_string() -} - -fn default_extension_root() -> String { - "extensions".to_string() -} - -fn default_version_root() -> String { - "versions".to_string() -} +impl DatabaseConfig { + pub fn embedded(url: impl Into) -> Self { + Self { + url: url.into(), + max_connections: 4, + min_connections: 1, + max_lifetime: 30 * 60, + acquire_timeout: 30, + idle_timeout: 10 * 60, + } + } -/// SMTP 配置 -#[derive(Debug, Clone, Deserialize)] -pub struct SmtpConfig { - pub smtp_server: String, - pub smtp_username: String, - pub smtp_password: String, + pub fn from_path(path: &Path) -> Self { + let normalized = path.to_string_lossy().replace('\\', "/"); + Self::embedded(format!("sqlite://{normalized}")) + } } -/// 工作空间默认配额配置 +/// Default limits used when a local workspace is created. #[derive(Debug, Clone, Deserialize)] pub struct WorkspaceQuotaConfig { - /// 默认配额(新用户注册和手动创建工作空间都使用此配置) - #[serde(default = "default_workspace_quota")] pub default: WorkspaceQuotaValues, } -/// 工作空间配额值 +impl Default for WorkspaceQuotaConfig { + fn default() -> Self { + Self { + default: WorkspaceQuotaValues::default(), + } + } +} + #[derive(Debug, Clone, Deserialize)] pub struct WorkspaceQuotaValues { pub max_environments: i32, @@ -62,53 +52,13 @@ pub struct WorkspaceQuotaValues { pub max_rpa_tasks: i32, } -fn default_workspace_quota() -> WorkspaceQuotaValues { - WorkspaceQuotaValues { - max_environments: 8, - max_team_members: 1, - max_proxies: 99999, - max_rpa_tasks: 99999, - } -} - -/// 应用配置 -#[derive(Debug, Clone, Deserialize)] -pub struct AppConfig { - pub name: String, - pub port: u16, - pub secret: String, - pub prefix: String, - pub encrypt_secret_location: String, - pub route_whitelists: Vec, - /// 推广链接前缀,例如: https://www.example.com/register - /// 实际推广链接将拼接为: {referral_link_prefix}?referral_code={code} - pub referral_link_prefix: Option, -} - -#[derive(Debug, Clone, Deserialize)] -pub struct IConfig { - pub app: AppConfig, - pub database: DatabaseConfig, - pub storage: StorageConfig, - pub smtp: Option, - #[serde(default = "default_workspace_quota_config")] - pub workspace_quota: WorkspaceQuotaConfig, -} - -fn default_workspace_quota_config() -> WorkspaceQuotaConfig { - WorkspaceQuotaConfig { - default: default_workspace_quota(), - } -} - -impl IConfig { - pub fn build_by_filepath(config_path: &str) -> Result { - let config = Config::builder() - .add_source(config::File::with_name(config_path)) - .add_source(config::File::with_name(".").required(false)) - .add_source(config::Environment::with_prefix("APP")) - .build()?; - - config.try_deserialize() +impl Default for WorkspaceQuotaValues { + fn default() -> Self { + Self { + max_environments: 8, + max_team_members: 1, + max_proxies: 99_999, + max_rpa_tasks: 99_999, + } } } diff --git a/src-tauri/crates/business/src/utils/jwt.rs b/src-tauri/crates/business/src/utils/jwt.rs deleted file mode 100644 index 9bea4b8..0000000 --- a/src-tauri/crates/business/src/utils/jwt.rs +++ /dev/null @@ -1,59 +0,0 @@ -use jwt::Token; - -// 生成token -pub fn generate_token( - user_uuid: &str, - expiration: u64, - secret: &[u8], -) -> Result { - use aes_gcm::KeyInit; - use hmac::Hmac; - use jwt::SignWithKey; - use std::collections::BTreeMap; - - let key: Hmac = Hmac::new_from_slice(secret).unwrap(); - - let mut claims = BTreeMap::new(); - claims.insert("uuid", format!("{}", user_uuid)); - claims.insert( - "exp", - format!("{}", chrono::Utc::now().timestamp() + expiration as i64), - ); - claims.insert("iat", format!("{}", chrono::Utc::now().timestamp())); - - let header = jwt::Header { - algorithm: jwt::AlgorithmType::Hs256, - ..Default::default() - }; - - Token::new(header, claims).sign_with_key(&key).map(|v| v.as_str().to_string()) -} - -// 校验token -pub fn verify_token(token_str: &str, secret: &[u8]) -> Result { - use aes_gcm::KeyInit; - use hmac::Hmac; - use jwt::VerifyWithKey; - use sha2::Sha256; - use std::collections::BTreeMap; - - let key: Hmac = Hmac::new_from_slice(secret)?; - let claims: BTreeMap = token_str.verify_with_key(&key)?; - let exp_str = claims.get("exp"); - - // 检查过期时间 - let exp = exp_str - .ok_or_else(|| anyhow::anyhow!("token expired."))? - .parse::() - .map_err(|_| anyhow::anyhow!("token expired."))?; - let current_time = chrono::Utc::now().timestamp() as u64; - if current_time > exp { - return Err(anyhow::anyhow!("token expired.")); - } - - let uuid = claims.get("uuid"); - match uuid { - Some(uuid) => return Ok(uuid.to_string()), - None => Err(anyhow::anyhow!("token parse failed.")), - } -} diff --git a/src-tauri/crates/business/src/utils/password.rs b/src-tauri/crates/business/src/utils/password.rs deleted file mode 100644 index 89fd423..0000000 --- a/src-tauri/crates/business/src/utils/password.rs +++ /dev/null @@ -1,26 +0,0 @@ -use argon2::{ - Argon2, - password_hash::{PasswordHash, PasswordHasher, PasswordVerifier, SaltString, rand_core::OsRng}, -}; - -/// 对密码进行加密 -pub fn encryption_password(password: &str) -> Result { - let salt = SaltString::generate(&mut OsRng); - - let argon2 = Argon2::default(); - - match argon2.hash_password(password.as_bytes(), &salt) { - Ok(password_hash) => Ok(password_hash.to_string()), - Err(_) => Err(anyhow::anyhow!("密码异常")), - } -} - -pub fn verify_password(password: &str, password_hash: &str) -> bool { - let parsed_hash = if let Ok(parsed_hash) = PasswordHash::new(&password_hash) { - parsed_hash - } else { - return false; - }; - - Argon2::default().verify_password(password.as_bytes(), &parsed_hash).is_ok() -} diff --git a/src-tauri/crates/business/src/utils/storage.rs b/src-tauri/crates/business/src/utils/storage.rs deleted file mode 100644 index 1f67535..0000000 --- a/src-tauri/crates/business/src/utils/storage.rs +++ /dev/null @@ -1,6 +0,0 @@ -//! Helpers for turning stored resource paths into download URLs. -//! -//! The imported service never exposed an upload API, so object-storage -//! clients do not belong in the local-first application. - -pub mod get_objects; diff --git a/src-tauri/crates/business/src/utils/storage/get_objects.rs b/src-tauri/crates/business/src/utils/storage/get_objects.rs deleted file mode 100644 index caaf5b5..0000000 --- a/src-tauri/crates/business/src/utils/storage/get_objects.rs +++ /dev/null @@ -1,84 +0,0 @@ -//! 对象存储 URL 获取模块 -//! -//! 提供获取对象存储资源访问 URL 的功能 - -fn ensure_rooted_path(root: &str, object_path: &str) -> String { - let root = root.trim_matches('/'); - let object_path = object_path.trim_start_matches('/'); - if object_path.is_empty() { - return root.to_string(); - } - if object_path == root || object_path.starts_with(&format!("{}/", root)) { - object_path.to_string() - } else { - format!("{}/{}", root, object_path) - } -} - -/// 获取对象的完整 URL -/// -/// # Arguments -/// - `public_base_url`: 对象存储资源访问基础 URL -/// - `object_path`: 对象路径 -/// -/// # Returns -/// 返回完整的访问 URL -pub fn get_object_url(public_base_url: &str, object_path: &str) -> String { - format!( - "{}/{}", - public_base_url.trim_end_matches('/'), - object_path.trim_start_matches('/') - ) -} - -/// 获取扩展 CRX 文件的完整 URL -/// -/// # Arguments -/// - `public_base_url`: 对象存储资源访问基础 URL -/// - `extension_root`: 扩展对象根路径 -/// - `object_path`: CRX 对象路径(数据库中存储的路径) -pub fn get_extension_crx_url( - public_base_url: &str, - extension_root: &str, - object_path: &str, -) -> String { - get_object_url(public_base_url, &ensure_rooted_path(extension_root, object_path)) -} - -/// 获取扩展图标的完整 URL -/// -/// # Arguments -/// - `public_base_url`: 对象存储资源访问基础 URL -/// - `extension_root`: 扩展对象根路径 -/// - `object_path`: 图标对象路径(数据库中存储的路径) -pub fn get_extension_icon_url( - public_base_url: &str, - extension_root: &str, - object_path: &str, -) -> String { - get_object_url(public_base_url, &ensure_rooted_path(extension_root, object_path)) -} - -/// 获取头像的完整 URL -/// -/// # Arguments -/// - `public_base_url`: 对象存储资源访问基础 URL -/// - `avatar_root`: 头像对象根路径 -/// - `resource_hash`: 头像文件哈希 -pub fn get_avatar_url(public_base_url: &str, avatar_root: &str, resource_hash: &str) -> String { - get_object_url(public_base_url, &ensure_rooted_path(avatar_root, resource_hash)) -} - -/// 获取版本资源的完整 URL -/// -/// # Arguments -/// - `public_base_url`: 对象存储资源访问基础 URL -/// - `version_root`: 版本资源对象根路径 -/// - `object_path`: 版本资源对象路径(数据库中存储的路径) -pub fn get_version_resource_url( - public_base_url: &str, - version_root: &str, - object_path: &str, -) -> String { - get_object_url(public_base_url, &ensure_rooted_path(version_root, object_path)) -} diff --git a/src-tauri/src/app/mod.rs b/src-tauri/src/app/mod.rs index 00aaa4d..61c0375 100644 --- a/src-tauri/src/app/mod.rs +++ b/src-tauri/src/app/mod.rs @@ -13,6 +13,7 @@ pub mod startup; use crate::commands; use components::tray; +use tauri::Manager; pub fn run() { let ctx = tauri::generate_context!(); @@ -29,6 +30,17 @@ pub fn run() { crate::infrastructure::persistence::tauri_store::get_logs_path(app.handle()) .map_err(|e| anyhow::anyhow!("{}", e))?; crate::core::logger::init_logging(&log_dir); + + let database_file = crate::core::paths::PathManager::get_business_database_file()?; + let database_config = business::utils::DatabaseConfig::from_path(&database_file); + let business_context = + tauri::async_runtime::block_on(business::svc_ctx::SvcCtx::new(&database_config))?; + app.manage(business_context); + log::info!( + "Local business database initialized: {}", + database_file.display() + ); + setup::register_deep_link(app.handle().clone())?; crate::commands::window::create_splashscreen_window(app.handle().clone())?; diff --git a/src-tauri/src/commands/local_api.rs b/src-tauri/src/commands/local_api.rs index d1b3a91..d561a91 100644 --- a/src-tauri/src/commands/local_api.rs +++ b/src-tauri/src/commands/local_api.rs @@ -9,14 +9,20 @@ pub fn get_local_api_runtime_running() -> bool { #[tauri::command] pub async fn start_local_api_runtime() -> Result<()> { let ctx = AppContext::get(); - ctx.local_api_manager.refresh_from_server().await?; + let app = crate::app::handle::get_app_handle()?; + use tauri::Manager; + let business_context = app.state::(); + ctx.local_api_manager.refresh(&business_context).await?; Ok(()) } #[tauri::command] pub async fn reload_local_api_runtime() -> Result<()> { let ctx = AppContext::get(); - ctx.local_api_manager.refresh_from_server().await?; + let app = crate::app::handle::get_app_handle()?; + use tauri::Manager; + let business_context = app.state::(); + ctx.local_api_manager.refresh(&business_context).await?; Ok(()) } diff --git a/src-tauri/src/commands/network.rs b/src-tauri/src/commands/network.rs index cb9a91d..bddde64 100644 --- a/src-tauri/src/commands/network.rs +++ b/src-tauri/src/commands/network.rs @@ -40,9 +40,31 @@ pub async fn http_get(url: String) -> std::result::Result { pub async fn http_post( url: String, data: Option, + business_context: tauri::State<'_, business::svc_ctx::SvcCtx>, ) -> std::result::Result { + let payload = data.unwrap_or_else(|| Value::Object(Default::default())); + if let Some(result) = + business::dispatcher::dispatch_post(&business_context, &url, &payload).await + { + return Ok(match result { + Ok(data) => JsonRespnse { + code: Some(1), + message: Some("OK".to_string()), + data: (!data.is_null()).then_some(data), + }, + Err(message) => JsonRespnse { + code: Some(-1), + message: Some(message), + data: None, + }, + }); + } + let ctx = AppContext::get(); - ctx.main_server_client.post(&url, &data).await.map_err(|e| e.to_string()) + ctx.main_server_client + .post(&url, &Some(payload)) + .await + .map_err(|e| e.to_string()) } /// HTTP POST 表单请求(用于文件上传) diff --git a/src-tauri/src/core/paths.rs b/src-tauri/src/core/paths.rs index bf646b5..f26ced9 100644 --- a/src-tauri/src/core/paths.rs +++ b/src-tauri/src/core/paths.rs @@ -129,6 +129,13 @@ impl PathManager { Ok(dir) } + /// SQLite database containing the local-first business data. + pub fn get_business_database_file() -> Result { + let path = Self::get_data_dir()?.join("simprint.db"); + Self::ensure_parent_dir(&path)?; + Ok(path) + } + pub fn get_local_dir(app: &tauri::AppHandle) -> Result { let dir = Self::get_app_data_dir(app)?.join(".local"); Self::ensure_dir(&dir)?; diff --git a/src-tauri/src/local_api/client/main_server.rs b/src-tauri/src/local_api/client/main_server.rs index 98c9bde..f056685 100644 --- a/src-tauri/src/local_api/client/main_server.rs +++ b/src-tauri/src/local_api/client/main_server.rs @@ -1,8 +1,12 @@ use axum::http::StatusCode; use serde::{Serialize, de::DeserializeOwned}; use serde_json::{Value, json}; +use tauri::Manager; -use crate::{app::context::AppContext, infrastructure::main_server::error::ResponseError}; +use crate::{ + app::{context::AppContext, handle::get_app_handle}, + infrastructure::main_server::error::ResponseError, +}; use super::headers::build_local_api_auth_headers; @@ -12,6 +16,28 @@ pub async fn proxy_request( api_key: &str, payload: Value, ) -> Result { + let app = + get_app_handle().map_err(|error| (StatusCode::INTERNAL_SERVER_ERROR, error.to_string()))?; + let business_context = app.state::(); + business::services::local_api::validate_local_api_key_service( + &business_context, + &business::entitys::ValidateLocalApiKeyRequest { + api_key: api_key.to_string(), + permission_code: permission_code.to_string(), + }, + ) + .await + .map_err(|message| (StatusCode::UNAUTHORIZED, message))?; + + if let Some(result) = + business::dispatcher::dispatch_post(&business_context, server_path, &payload).await + { + return match result { + Ok(data) => Ok(json!({ "code": 1, "message": "OK", "data": data })), + Err(message) => Err((StatusCode::BAD_REQUEST, message)), + }; + } + let ctx = AppContext::get(); let headers = build_local_api_auth_headers(api_key, permission_code) .map_err(|error| (StatusCode::BAD_REQUEST, error))?; diff --git a/src-tauri/src/local_api/manager/runtime.rs b/src-tauri/src/local_api/manager/runtime.rs index 3c30a00..34f0b2b 100644 --- a/src-tauri/src/local_api/manager/runtime.rs +++ b/src-tauri/src/local_api/manager/runtime.rs @@ -4,10 +4,9 @@ use std::sync::{ }; use anyhow::Result; -use serde_json::json; use tokio::sync::Mutex; -use crate::{app::context::AppContext, local_api::types::LocalApiRuntimeConfig}; +use crate::local_api::types::LocalApiRuntimeConfig; use super::super::server::bootstrap::{LocalApiServerHandle, spawn_local_api_server}; @@ -24,22 +23,17 @@ impl LocalApiManager { } } - pub async fn refresh_from_server(self: &Arc) -> Result<()> { - let ctx = AppContext::get(); - let response = match ctx.main_server_client.post("local-api/get", &json!({})).await { - Ok(response) => response, - Err(error) => { - log::warn!("failed to fetch local api config: {}", error); - return Ok(()); - } - }; - - let config = response - .data - .ok_or_else(|| anyhow::anyhow!("missing local api config data")) - .and_then(|data| { - serde_json::from_value::(data).map_err(Into::into) - })?; + pub async fn refresh(self: &Arc, context: &business::svc_ctx::SvcCtx) -> Result<()> { + let config = business::services::local_api::get_local_api_config_service( + context, + context.local_user_uuid, + ) + .await + .map_err(anyhow::Error::msg) + .and_then(|config| serde_json::to_value(config).map_err(Into::into)) + .and_then(|value| { + serde_json::from_value::(value).map_err(Into::into) + })?; if !config.enabled { self.stop().await; diff --git a/src-tauri/src/local_api/middleware/auth.rs b/src-tauri/src/local_api/middleware/auth.rs index 8b4ab5c..bb0313e 100644 --- a/src-tauri/src/local_api/middleware/auth.rs +++ b/src-tauri/src/local_api/middleware/auth.rs @@ -14,7 +14,7 @@ use crate::local_api::{ use super::super::client::headers::extract_api_key; pub async fn auth_middleware( - State(_state): State, + State(state): State, mut request: Request, next: Next, ) -> Response { @@ -30,6 +30,14 @@ pub async fn auth_middleware( .into_response(); }; + if api_key != state.config.api_key { + return LocalApiResponse::<()>::fail( + Some("invalid api key"), + axum::http::StatusCode::UNAUTHORIZED, + ) + .into_response(); + } + request.extensions_mut().insert(LocalApiRequestContext { api_key }); next.run(request).await } diff --git a/src-tauri/src/local_api/services/groups.rs b/src-tauri/src/local_api/services/groups.rs index e32b677..9782a50 100644 --- a/src-tauri/src/local_api/services/groups.rs +++ b/src-tauri/src/local_api/services/groups.rs @@ -2,25 +2,11 @@ use axum::http::StatusCode; use serde_json::Value; use crate::local_api::{ - client::main_server::proxy_data_value_request, context::LocalApiRequestContext, + client::main_server::proxy_data_request, context::LocalApiRequestContext, services::forward_service, types::LocalApiRoute, }; macro_rules! group_service { - ($fn_name:ident, $path:literal, $permission:literal, $response_ty:ty) => { - pub async fn $fn_name( - ctx: &LocalApiRequestContext, - payload: Value, - ) -> Result { - proxy_data_value_request::<$response_ty>( - concat!("groups", $path), - $permission, - &ctx.api_key, - payload, - ) - .await - } - }; ($fn_name:ident, $path:literal, $permission:literal) => { pub async fn $fn_name( ctx: &LocalApiRequestContext, @@ -41,12 +27,15 @@ macro_rules! group_service { }; } -group_service!( - list_groups_service, - "/list", - "groups.list", - crate::local_api::entitys::LocalApiGroupListResponse -); +pub async fn list_groups_service( + ctx: &LocalApiRequestContext, + payload: Value, +) -> Result { + let items = + proxy_data_request::>("groups/list", "groups.list", &ctx.api_key, payload) + .await?; + Ok(serde_json::json!({ "items": items })) +} group_service!(create_group_service, "/create", "groups.create"); group_service!(update_group_service, "/update", "groups.update"); group_service!(delete_group_service, "/delete", "groups.delete"); diff --git a/src-tauri/src/local_api/services/mod.rs b/src-tauri/src/local_api/services/mod.rs index 3825c0d..103ccfe 100644 --- a/src-tauri/src/local_api/services/mod.rs +++ b/src-tauri/src/local_api/services/mod.rs @@ -17,11 +17,19 @@ pub async fn forward_service( payload: Value, route: LocalApiRoute, ) -> Result { - proxy_request( + let response = proxy_request( route.server_path, route.permission_code, &ctx.api_key, payload, ) - .await + .await?; + let response: crate::infrastructure::http::client::JsonRespnse = + serde_json::from_value(response).map_err(|error| { + ( + StatusCode::BAD_GATEWAY, + format!("failed to parse business response: {error}"), + ) + })?; + Ok(response.data.unwrap_or(Value::Null)) } diff --git a/src-tauri/src/mcp/manager.rs b/src-tauri/src/mcp/manager.rs index 26200d6..7a3bc72 100644 --- a/src-tauri/src/mcp/manager.rs +++ b/src-tauri/src/mcp/manager.rs @@ -1,12 +1,10 @@ use std::sync::Arc; use std::time::Duration; -use serde_json::json; +use tauri::Manager; use tokio::{sync::Mutex, time::sleep}; use crate::{ - app::context::AppContext, - infrastructure::http::client::JsonRespnse, local_api::types::LocalApiRuntimeConfig, mcp::{ config::{McpConfig, McpManagerStatus, McpServerRuntimeConfig, load_config}, @@ -99,16 +97,17 @@ impl McpManager { } async fn fetch_local_api_runtime_config() -> Result { - let ctx = AppContext::get(); - let response: JsonRespnse = ctx - .main_server_client - .post("local-api/get", &json!({})) - .await - .map_err(|error| error.to_string())?; - - let data = response.data.ok_or_else(|| "missing local api config data".to_string())?; - - serde_json::from_value::(data).map_err(|error| error.to_string()) + let app = crate::app::handle::get_app_handle().map_err(|error| error.to_string())?; + let context = app.state::(); + let config = business::services::local_api::get_local_api_config_service( + &context, + context.local_user_uuid, + ) + .await + .map_err(|error| error.to_string())?; + serde_json::to_value(config) + .and_then(serde_json::from_value::) + .map_err(|error| error.to_string()) } async fn wait_for_mcp_server_ready(health_url: &str) -> Result<(), String> { From cbf7ccb4a54d052cae35e8fdbebf0e630876c98a Mon Sep 17 00:00:00 2001 From: lius-new Date: Wed, 12 Aug 2026 12:46:24 +0800 Subject: [PATCH 56/60] refactor(business): remove PostgreSQL type aliases --- .../crates/business/src/models/accounts.rs | 26 +- src-tauri/crates/business/src/models/audit.rs | 360 ++-- .../business/src/models/environments.rs | 132 +- .../src/models/group_member_permissions.rs | 394 ++-- .../crates/business/src/models/local_api.rs | 30 +- .../crates/business/src/models/messages.rs | 818 ++++---- src-tauri/crates/business/src/models/mod.rs | 30 +- .../crates/business/src/models/preferences.rs | 108 +- .../crates/business/src/models/proxies.rs | 24 +- .../src/models/proxy_visible_teams.rs | 18 +- src-tauri/crates/business/src/models/rpa.rs | 918 +++++---- src-tauri/crates/business/src/models/teams.rs | 1648 ++++++++--------- src-tauri/crates/business/src/models/user.rs | 26 +- .../business/src/models/workspace_quotas.rs | 470 ++--- .../crates/business/src/models/workspaces.rs | 246 +-- 15 files changed, 2623 insertions(+), 2625 deletions(-) diff --git a/src-tauri/crates/business/src/models/accounts.rs b/src-tauri/crates/business/src/models/accounts.rs index 4164ddb..641c4a4 100644 --- a/src-tauri/crates/business/src/models/accounts.rs +++ b/src-tauri/crates/business/src/models/accounts.rs @@ -1,13 +1,13 @@ use sqlx::Error; use uuid::Uuid; -use crate::database::{Db as Postgres, Pool, placeholders}; +use crate::database::{Db, Pool, placeholders}; use crate::dto::PlatformAccountDto; /// 创建平台账号 pub async fn insert_platform_account( - pool: &Pool, + pool: &Pool, user_uuid: Uuid, team_uuid: Option, platform_url: &str, @@ -39,7 +39,7 @@ pub async fn insert_platform_account( /// 查询平台账号列表 pub async fn fetch_platform_accounts( - pool: &Pool, + pool: &Pool, team_uuid: Option, user_uuid: Uuid, keyword: Option<&str>, @@ -87,7 +87,7 @@ pub async fn fetch_platform_accounts( /// 查询平台账号总数 pub async fn fetch_platform_accounts_count( - pool: &Pool, + pool: &Pool, team_uuid: Option, user_uuid: Uuid, keyword: Option<&str>, @@ -125,7 +125,7 @@ pub async fn fetch_platform_accounts_count( /// 根据 UUID 查询平台账号 pub async fn fetch_platform_account_by_uuid( - pool: &Pool, + pool: &Pool, account_uuid: Uuid, ) -> Result, Error> { let rec = sqlx::query_as::<_, PlatformAccountDto>( @@ -147,7 +147,7 @@ pub async fn fetch_platform_account_by_uuid( /// 更新平台账号 pub async fn update_platform_account( - pool: &Pool, + pool: &Pool, account_uuid: Uuid, platform_url: Option<&str>, platform_name: Option<&str>, @@ -183,7 +183,7 @@ pub async fn update_platform_account( /// 增加账号使用次数 pub async fn increment_account_usage( - pool: &Pool, + pool: &Pool, account_uuid: Uuid, ) -> Result<(), Error> { sqlx::query( @@ -202,7 +202,7 @@ pub async fn increment_account_usage( /// 软删除平台账号 pub async fn delete_platform_account( - pool: &Pool, + pool: &Pool, account_uuid: Uuid, ) -> Result<(), Error> { sqlx::query( @@ -220,7 +220,7 @@ pub async fn delete_platform_account( /// 批量软删除平台账号 pub async fn batch_delete_platform_accounts( - pool: &Pool, + pool: &Pool, account_uuids: &[Uuid], ) -> Result { if account_uuids.is_empty() { @@ -247,7 +247,7 @@ pub async fn batch_delete_platform_accounts( /// 关联环境和账号 pub async fn insert_environment_account( - pool: &Pool, + pool: &Pool, env_uuid: Uuid, account_uuid: Uuid, sort_order: i32, @@ -270,7 +270,7 @@ pub async fn insert_environment_account( /// 移除环境和账号的关联 pub async fn remove_environment_account( - pool: &Pool, + pool: &Pool, env_uuid: Uuid, account_uuid: Uuid, ) -> Result<(), Error> { @@ -290,7 +290,7 @@ pub async fn remove_environment_account( /// 清空环境的所有账号关联 pub async fn clear_environment_accounts( - pool: &Pool, + pool: &Pool, env_uuid: Uuid, ) -> Result<(), Error> { sqlx::query( @@ -307,7 +307,7 @@ pub async fn clear_environment_accounts( /// 查询环境关联的所有账号 pub async fn fetch_environment_accounts( - pool: &Pool, + pool: &Pool, env_uuid: Uuid, ) -> Result, Error> { let recs = sqlx::query_as::<_, PlatformAccountDto>( diff --git a/src-tauri/crates/business/src/models/audit.rs b/src-tauri/crates/business/src/models/audit.rs index 0fd74b9..5bf8b53 100644 --- a/src-tauri/crates/business/src/models/audit.rs +++ b/src-tauri/crates/business/src/models/audit.rs @@ -1,57 +1,57 @@ -use sqlx::Error; -use uuid::Uuid; - -use crate::database::{Db as Postgres, Pool}; - -use crate::dto::AuditLogDto; - -/// 记录审计日志 -pub async fn insert_audit_log( - pool: &Pool, - user_uuid: Uuid, - team_uuid: Option, - action: &str, - target_type: &str, - target_uuid: Option, - target_name: Option<&str>, - details: Option<&str>, - changes: Option<&serde_json::Value>, - ip_address: Option<&str>, - user_agent: Option<&str>, - request_id: Option<&str>, -) -> Result { - let id: i64 = sqlx::query_scalar( - r#" - INSERT INTO audit_logs (user_uuid, team_uuid, action, target_type, target_uuid, - target_name, details, changes, ip_address, user_agent, request_id) - VALUES ($1, $2, $3, $4, $5, $6, $7, $8, $9, $10, $11) - RETURNING id; - "#, - ) - .bind(user_uuid) - .bind(team_uuid) - .bind(action) - .bind(target_type) - .bind(target_uuid) - .bind(target_name) - .bind(details) - .bind(changes) - .bind(ip_address) - .bind(user_agent) - .bind(request_id) - .fetch_one(pool) - .await?; - - Ok(id) -} - -/// 查询审计日志列表 -/// -/// 查询逻辑: -/// - 当前团队的所有审计日志 -/// - 加上当前用户的个人操作日志(team_uuid 为空的,如登录、注册等) +use sqlx::Error; +use uuid::Uuid; + +use crate::database::{Db, Pool}; + +use crate::dto::AuditLogDto; + +/// 记录审计日志 +pub async fn insert_audit_log( + pool: &Pool, + user_uuid: Uuid, + team_uuid: Option, + action: &str, + target_type: &str, + target_uuid: Option, + target_name: Option<&str>, + details: Option<&str>, + changes: Option<&serde_json::Value>, + ip_address: Option<&str>, + user_agent: Option<&str>, + request_id: Option<&str>, +) -> Result { + let id: i64 = sqlx::query_scalar( + r#" + INSERT INTO audit_logs (user_uuid, team_uuid, action, target_type, target_uuid, + target_name, details, changes, ip_address, user_agent, request_id) + VALUES ($1, $2, $3, $4, $5, $6, $7, $8, $9, $10, $11) + RETURNING id; + "#, + ) + .bind(user_uuid) + .bind(team_uuid) + .bind(action) + .bind(target_type) + .bind(target_uuid) + .bind(target_name) + .bind(details) + .bind(changes) + .bind(ip_address) + .bind(user_agent) + .bind(request_id) + .fetch_one(pool) + .await?; + + Ok(id) +} + +/// 查询审计日志列表 +/// +/// 查询逻辑: +/// - 当前团队的所有审计日志 +/// - 加上当前用户的个人操作日志(team_uuid 为空的,如登录、注册等) pub async fn fetch_audit_logs( - pool: &Pool, + pool: &Pool, current_user_uuid: Uuid, team_uuid: Option, user_uuid_filter: Option, @@ -61,14 +61,14 @@ pub async fn fetch_audit_logs( offset: i64, limit: i64, ) -> Result, Error> { - let recs = sqlx::query_as::<_, AuditLogDto>( - r#" - SELECT - a.id, a.uuid, a.user_uuid, a.team_uuid, a.action, a.target_type, a.target_uuid, - a.target_name, a.details, a.changes, a.ip_address, a.user_agent, a.request_id, a.created_at, - ui.nickname AS user_name, ui.email AS user_email - FROM audit_logs a - LEFT JOIN user_infos ui ON a.user_uuid = ui.user_uuid + let recs = sqlx::query_as::<_, AuditLogDto>( + r#" + SELECT + a.id, a.uuid, a.user_uuid, a.team_uuid, a.action, a.target_type, a.target_uuid, + a.target_name, a.details, a.changes, a.ip_address, a.user_agent, a.request_id, a.created_at, + ui.nickname AS user_name, ui.email AS user_email + FROM audit_logs a + LEFT JOIN user_infos ui ON a.user_uuid = ui.user_uuid WHERE ( ($2 IS NULL OR a.team_uuid = $2) OR (a.team_uuid IS NULL AND a.user_uuid = $1) @@ -89,15 +89,15 @@ pub async fn fetch_audit_logs( .bind(target_type) .bind(limit) .bind(offset) - .fetch_all(pool) - .await?; - - Ok(recs) -} - -/// 查询审计日志总数 + .fetch_all(pool) + .await?; + + Ok(recs) +} + +/// 查询审计日志总数 pub async fn fetch_audit_logs_count( - pool: &Pool, + pool: &Pool, current_user_uuid: Uuid, team_uuid: Option, user_uuid_filter: Option, @@ -107,7 +107,7 @@ pub async fn fetch_audit_logs_count( ) -> Result { let count: i64 = sqlx::query_scalar( r#" - SELECT COUNT(*) FROM audit_logs + SELECT COUNT(*) FROM audit_logs WHERE ( ($2 IS NULL OR team_uuid = $2) OR (team_uuid IS NULL AND user_uuid = $1) @@ -126,117 +126,117 @@ pub async fn fetch_audit_logs_count( .bind(target_type) .fetch_one(pool) .await?; - - Ok(count) -} - -/// 根据 UUID 查询审计日志 -pub async fn fetch_audit_log_by_uuid( - pool: &Pool, - log_uuid: Uuid, -) -> Result, Error> { - let rec = sqlx::query_as::<_, AuditLogDto>( - r#" - SELECT - a.id, a.uuid, a.user_uuid, a.team_uuid, a.action, a.target_type, a.target_uuid, - a.target_name, a.details, a.changes, a.ip_address, a.user_agent, a.request_id, a.created_at, - ui.nickname AS user_name, ui.email AS user_email - FROM audit_logs a - LEFT JOIN user_infos ui ON a.user_uuid = ui.user_uuid - WHERE a.uuid = $1 - "#, - ) - .bind(log_uuid) - .fetch_optional(pool) - .await?; - - Ok(rec) -} - -/// 查询指定日期的审计日志数量 -pub async fn fetch_audit_logs_count_by_date( - pool: &Pool, - team_uuid: Option, - date: chrono::NaiveDate, -) -> Result { - let count: i64 = sqlx::query_scalar( - r#" - SELECT COUNT(*) FROM audit_logs + + Ok(count) +} + +/// 根据 UUID 查询审计日志 +pub async fn fetch_audit_log_by_uuid( + pool: &Pool, + log_uuid: Uuid, +) -> Result, Error> { + let rec = sqlx::query_as::<_, AuditLogDto>( + r#" + SELECT + a.id, a.uuid, a.user_uuid, a.team_uuid, a.action, a.target_type, a.target_uuid, + a.target_name, a.details, a.changes, a.ip_address, a.user_agent, a.request_id, a.created_at, + ui.nickname AS user_name, ui.email AS user_email + FROM audit_logs a + LEFT JOIN user_infos ui ON a.user_uuid = ui.user_uuid + WHERE a.uuid = $1 + "#, + ) + .bind(log_uuid) + .fetch_optional(pool) + .await?; + + Ok(rec) +} + +/// 查询指定日期的审计日志数量 +pub async fn fetch_audit_logs_count_by_date( + pool: &Pool, + team_uuid: Option, + date: chrono::NaiveDate, +) -> Result { + let count: i64 = sqlx::query_scalar( + r#" + SELECT COUNT(*) FROM audit_logs WHERE ($1 IS NULL OR team_uuid = $1) - AND created_at::date = $2 - "#, - ) - .bind(team_uuid) - .bind(date) - .fetch_one(pool) - .await?; - - Ok(count) -} - -/// 查询指定日期之后的审计日志数量 -pub async fn fetch_audit_logs_count_since_date( - pool: &Pool, - team_uuid: Option, - since_date: chrono::NaiveDate, -) -> Result { - let count: i64 = sqlx::query_scalar( - r#" - SELECT COUNT(*) FROM audit_logs + AND created_at::date = $2 + "#, + ) + .bind(team_uuid) + .bind(date) + .fetch_one(pool) + .await?; + + Ok(count) +} + +/// 查询指定日期之后的审计日志数量 +pub async fn fetch_audit_logs_count_since_date( + pool: &Pool, + team_uuid: Option, + since_date: chrono::NaiveDate, +) -> Result { + let count: i64 = sqlx::query_scalar( + r#" + SELECT COUNT(*) FROM audit_logs WHERE ($1 IS NULL OR team_uuid = $1) - AND created_at::date >= $2 - "#, - ) - .bind(team_uuid) - .bind(since_date) - .fetch_one(pool) - .await?; - - Ok(count) -} - -/// 查询热门操作类型 -pub async fn fetch_top_actions( - pool: &Pool, - team_uuid: Option, - limit: i64, -) -> Result, Error> { - let rows: Vec<(String, i64)> = sqlx::query_as( - r#" - SELECT action, COUNT(*) as count FROM audit_logs + AND created_at::date >= $2 + "#, + ) + .bind(team_uuid) + .bind(since_date) + .fetch_one(pool) + .await?; + + Ok(count) +} + +/// 查询热门操作类型 +pub async fn fetch_top_actions( + pool: &Pool, + team_uuid: Option, + limit: i64, +) -> Result, Error> { + let rows: Vec<(String, i64)> = sqlx::query_as( + r#" + SELECT action, COUNT(*) as count FROM audit_logs WHERE ($1 IS NULL OR team_uuid = $1) - GROUP BY action - ORDER BY count DESC - LIMIT $2 - "#, - ) - .bind(team_uuid) - .bind(limit) - .fetch_all(pool) - .await?; - - Ok(rows) -} - -/// 查询热门目标类型 -pub async fn fetch_top_target_types( - pool: &Pool, - team_uuid: Option, - limit: i64, -) -> Result, Error> { - let rows: Vec<(String, i64)> = sqlx::query_as( - r#" - SELECT target_type, COUNT(*) as count FROM audit_logs + GROUP BY action + ORDER BY count DESC + LIMIT $2 + "#, + ) + .bind(team_uuid) + .bind(limit) + .fetch_all(pool) + .await?; + + Ok(rows) +} + +/// 查询热门目标类型 +pub async fn fetch_top_target_types( + pool: &Pool, + team_uuid: Option, + limit: i64, +) -> Result, Error> { + let rows: Vec<(String, i64)> = sqlx::query_as( + r#" + SELECT target_type, COUNT(*) as count FROM audit_logs WHERE ($1 IS NULL OR team_uuid = $1) - GROUP BY target_type - ORDER BY count DESC - LIMIT $2 - "#, - ) - .bind(team_uuid) - .bind(limit) - .fetch_all(pool) - .await?; - - Ok(rows) -} + GROUP BY target_type + ORDER BY count DESC + LIMIT $2 + "#, + ) + .bind(team_uuid) + .bind(limit) + .fetch_all(pool) + .await?; + + Ok(rows) +} diff --git a/src-tauri/crates/business/src/models/environments.rs b/src-tauri/crates/business/src/models/environments.rs index 5830faa..9827f91 100644 --- a/src-tauri/crates/business/src/models/environments.rs +++ b/src-tauri/crates/business/src/models/environments.rs @@ -1,7 +1,7 @@ use sqlx::Error; use uuid::Uuid; -use crate::database::{Db as Postgres, Pool, placeholders}; +use crate::database::{Db, Pool, placeholders}; use crate::dto::{ EnvironmentAccountRowDto, EnvironmentConfigDto, EnvironmentCookieDto, EnvironmentDto, @@ -14,7 +14,7 @@ use crate::entitys::CookieInput; /// 创建分组 pub async fn insert_group( - pool: &Pool, + pool: &Pool, workspace_uuid: Uuid, team_uuid: Uuid, name: &str, @@ -41,7 +41,7 @@ pub async fn insert_group( /// 查询分组列表(工作空间级别) pub async fn fetch_groups( - pool: &Pool, + pool: &Pool, workspace_uuid: Uuid, team_uuid: Uuid, offset: i64, @@ -74,7 +74,7 @@ pub async fn fetch_groups( /// 根据 UUID 查询分组 pub async fn fetch_group_by_uuid( - pool: &Pool, + pool: &Pool, group_uuid: Uuid, ) -> Result, Error> { let rec = sqlx::query_as::<_, GroupDto>( @@ -99,7 +99,7 @@ pub async fn fetch_group_by_uuid( /// 更新分组 pub async fn update_group( - pool: &Pool, + pool: &Pool, group_uuid: Uuid, name: Option<&str>, description: Option<&str>, @@ -125,7 +125,7 @@ pub async fn update_group( } /// 软删除分组 -pub async fn delete_group(pool: &Pool, group_uuid: Uuid) -> Result<(), Error> { +pub async fn delete_group(pool: &Pool, group_uuid: Uuid) -> Result<(), Error> { sqlx::query( r#" UPDATE groups SET deleted_at = CURRENT_TIMESTAMP @@ -143,7 +143,7 @@ pub async fn delete_group(pool: &Pool, group_uuid: Uuid) -> Result<(), /// 创建标签 pub async fn insert_tag( - pool: &Pool, + pool: &Pool, user_uuid: Uuid, team_uuid: Option, name: &str, @@ -168,7 +168,7 @@ pub async fn insert_tag( /// 查询标签列表 pub async fn fetch_tags( - pool: &Pool, + pool: &Pool, team_uuid: Option, user_uuid: Uuid, ) -> Result, Error> { @@ -192,7 +192,7 @@ pub async fn fetch_tags( /// 根据 UUID 查询标签 pub async fn fetch_tag_by_uuid( - pool: &Pool, + pool: &Pool, tag_uuid: Uuid, ) -> Result, Error> { let rec = sqlx::query_as::<_, TagDto>( @@ -212,7 +212,7 @@ pub async fn fetch_tag_by_uuid( /// 更新标签 pub async fn update_tag( - pool: &Pool, + pool: &Pool, tag_uuid: Uuid, name: Option<&str>, color: Option<&str>, @@ -238,7 +238,7 @@ pub async fn update_tag( } /// 软删除标签 -pub async fn delete_tag(pool: &Pool, tag_uuid: Uuid) -> Result<(), Error> { +pub async fn delete_tag(pool: &Pool, tag_uuid: Uuid) -> Result<(), Error> { sqlx::query( r#" UPDATE tags SET deleted_at = CURRENT_TIMESTAMP @@ -256,7 +256,7 @@ pub async fn delete_tag(pool: &Pool, tag_uuid: Uuid) -> Result<(), Err /// 创建环境 pub async fn insert_environment( - pool: &Pool, + pool: &Pool, workspace_uuid: Uuid, user_uuid: Uuid, team_uuid: Uuid, @@ -292,7 +292,7 @@ pub async fn insert_environment( /// 查询环境列表(基础信息) pub async fn fetch_environments_base( - pool: &Pool, + pool: &Pool, workspace_uuid: Uuid, team_uuid: Uuid, group_uuid: Option, @@ -405,7 +405,7 @@ pub async fn fetch_environments_base( /// 查询环境列表(基础) pub async fn fetch_environments( - pool: &Pool, + pool: &Pool, workspace_uuid: Uuid, team_uuid: Uuid, group_uuid: Option, @@ -441,7 +441,7 @@ pub async fn fetch_environments( /// 查询环境总数 pub async fn fetch_environments_count( - pool: &Pool, + pool: &Pool, workspace_uuid: Uuid, team_uuid: Uuid, group_uuid: Option, @@ -536,7 +536,7 @@ pub async fn fetch_environments_count( /// 根据 UUID 查询环境(不带工作空间过滤,用于内部查询) pub async fn fetch_environment_by_uuid_unfiltered( - pool: &Pool, + pool: &Pool, env_uuid: Uuid, ) -> Result, Error> { let rec = sqlx::query_as::<_, EnvironmentDto>( @@ -557,7 +557,7 @@ pub async fn fetch_environment_by_uuid_unfiltered( /// 根据 UUID 查询环境(带工作空间过滤) pub async fn fetch_environment_by_uuid( - pool: &Pool, + pool: &Pool, workspace_uuid: Uuid, env_uuid: Uuid, ) -> Result, Error> { @@ -580,7 +580,7 @@ pub async fn fetch_environment_by_uuid( /// 更新环境基础信息 pub async fn update_environment( - pool: &Pool, + pool: &Pool, env_uuid: Uuid, name: Option<&str>, description: Option<&str>, @@ -607,7 +607,7 @@ pub async fn update_environment( /// 更新环境状态 pub async fn update_environment_status( - pool: &Pool, + pool: &Pool, env_uuid: Uuid, status: &str, ) -> Result<(), Error> { @@ -627,7 +627,7 @@ pub async fn update_environment_status( /// 更新环境代理 pub async fn update_environment_proxy( - pool: &Pool, + pool: &Pool, env_uuid: Uuid, proxy_uuid: Option, ) -> Result<(), Error> { @@ -647,7 +647,7 @@ pub async fn update_environment_proxy( /// 更新环境最后打开时间 pub async fn update_environment_last_opened( - pool: &Pool, + pool: &Pool, env_uuid: Uuid, ) -> Result<(), Error> { sqlx::query( @@ -664,7 +664,7 @@ pub async fn update_environment_last_opened( } /// 软删除环境 -pub async fn delete_environment(pool: &Pool, env_uuid: Uuid) -> Result<(), Error> { +pub async fn delete_environment(pool: &Pool, env_uuid: Uuid) -> Result<(), Error> { sqlx::query( r#" UPDATE environments SET deleted_at = CURRENT_TIMESTAMP @@ -680,7 +680,7 @@ pub async fn delete_environment(pool: &Pool, env_uuid: Uuid) -> Result /// 批量软删除环境 pub async fn batch_delete_environments( - pool: &Pool, + pool: &Pool, env_uuids: &[Uuid], ) -> Result { if env_uuids.is_empty() { @@ -707,7 +707,7 @@ pub async fn batch_delete_environments( /// 查询回收站环境列表(已删除但未永久删除) pub async fn fetch_deleted_environments_base( - pool: &Pool, + pool: &Pool, workspace_uuid: Uuid, team_uuid: Uuid, group_uuid: Option, @@ -779,7 +779,7 @@ pub async fn fetch_deleted_environments_base( /// 统计回收站环境总数 pub async fn fetch_deleted_environments_count( - pool: &Pool, + pool: &Pool, workspace_uuid: Uuid, team_uuid: Uuid, group_uuid: Option, @@ -831,7 +831,7 @@ pub async fn fetch_deleted_environments_count( } /// 恢复环境(将 deleted_at 设为 NULL) -pub async fn restore_environment(pool: &Pool, env_uuid: Uuid) -> Result<(), Error> { +pub async fn restore_environment(pool: &Pool, env_uuid: Uuid) -> Result<(), Error> { sqlx::query( r#" UPDATE environments SET deleted_at = NULL @@ -847,7 +847,7 @@ pub async fn restore_environment(pool: &Pool, env_uuid: Uuid) -> Resul /// 批量恢复环境 pub async fn batch_restore_environments( - pool: &Pool, + pool: &Pool, env_uuids: &[Uuid], ) -> Result { if env_uuids.is_empty() { @@ -872,7 +872,7 @@ pub async fn batch_restore_environments( /// 永久删除环境(真正的 DELETE) pub async fn permanent_delete_environment( - pool: &Pool, + pool: &Pool, env_uuid: Uuid, ) -> Result<(), Error> { // 先删除关联数据 @@ -915,7 +915,7 @@ pub async fn permanent_delete_environment( /// 批量永久删除环境 pub async fn batch_permanent_delete_environments( - pool: &Pool, + pool: &Pool, env_uuids: &[Uuid], ) -> Result { if env_uuids.is_empty() { @@ -951,7 +951,7 @@ pub async fn batch_permanent_delete_environments( } async fn delete_environment_relations( - pool: &Pool, + pool: &Pool, table: &str, env_uuids: &[Uuid], ) -> Result<(), Error> { @@ -971,7 +971,7 @@ async fn delete_environment_relations( /// 创建或更新环境配置 pub async fn upsert_environment_config( - pool: &Pool, + pool: &Pool, env_uuid: Uuid, window_info: &serde_json::Value, basic_settings: &serde_json::Value, @@ -1011,7 +1011,7 @@ pub async fn upsert_environment_config( /// 查询环境配置 pub async fn fetch_environment_config( - pool: &Pool, + pool: &Pool, env_uuid: Uuid, ) -> Result, Error> { let rec = sqlx::query_as::<_, EnvironmentConfigDto>( @@ -1031,7 +1031,7 @@ pub async fn fetch_environment_config( /// 批量查询环境配置 pub async fn fetch_environment_configs_by_uuids( - pool: &Pool, + pool: &Pool, env_uuids: &[Uuid], ) -> Result, Error> { if env_uuids.is_empty() { @@ -1060,7 +1060,7 @@ pub async fn fetch_environment_configs_by_uuids( /// 为环境添加标签 pub async fn insert_environment_tag( - pool: &Pool, + pool: &Pool, env_uuid: Uuid, tag_uuid: Uuid, ) -> Result<(), Error> { @@ -1081,7 +1081,7 @@ pub async fn insert_environment_tag( /// 移除环境的标签 pub async fn remove_environment_tag( - pool: &Pool, + pool: &Pool, env_uuid: Uuid, tag_uuid: Uuid, ) -> Result<(), Error> { @@ -1100,7 +1100,7 @@ pub async fn remove_environment_tag( } /// 清除环境的所有标签 -pub async fn clear_environment_tags(pool: &Pool, env_uuid: Uuid) -> Result<(), Error> { +pub async fn clear_environment_tags(pool: &Pool, env_uuid: Uuid) -> Result<(), Error> { sqlx::query( r#" DELETE FROM environment_tags @@ -1116,7 +1116,7 @@ pub async fn clear_environment_tags(pool: &Pool, env_uuid: Uuid) -> Re /// 查询环境的所有标签 pub async fn fetch_environment_tags( - pool: &Pool, + pool: &Pool, env_uuid: Uuid, ) -> Result, Error> { let recs = sqlx::query_as::<_, TagDto>( @@ -1138,7 +1138,7 @@ pub async fn fetch_environment_tags( /// 批量查询环境标签(完整标签信息) pub async fn fetch_tags_for_environments( - pool: &Pool, + pool: &Pool, env_uuids: &[Uuid], ) -> Result, Error> { if env_uuids.is_empty() { @@ -1147,11 +1147,11 @@ pub async fn fetch_tags_for_environments( let statement = format!( r#" - SELECT + SELECT et.environment_uuid, t.id as tag_id, - t.uuid as tag_uuid, - t.name as tag_name, + t.uuid as tag_uuid, + t.name as tag_name, t.color as tag_color, t.sort_order as tag_sort_order, t.user_uuid as tag_user_uuid, @@ -1178,7 +1178,7 @@ pub async fn fetch_tags_for_environments( /// 批量查询环境账号(完整账号信息,排除敏感数据) pub async fn fetch_accounts_for_environments( - pool: &Pool, + pool: &Pool, env_uuids: &[Uuid], ) -> Result, Error> { if env_uuids.is_empty() { @@ -1187,12 +1187,12 @@ pub async fn fetch_accounts_for_environments( let statement = format!( r#" - SELECT + SELECT ea.environment_uuid, pa.id as account_id, - pa.uuid as account_uuid, + pa.uuid as account_uuid, pa.platform_url, - pa.platform_name, + pa.platform_name, pa.account, pa.status as account_status, pa.remark @@ -1214,7 +1214,7 @@ pub async fn fetch_accounts_for_environments( /// 批量查询分组 pub async fn fetch_groups_by_uuids( - pool: &Pool, + pool: &Pool, group_uuids: &[Uuid], ) -> Result, Error> { if group_uuids.is_empty() { @@ -1240,7 +1240,7 @@ pub async fn fetch_groups_by_uuids( /// 批量查询代理 pub async fn fetch_proxies_by_uuids( - pool: &Pool, + pool: &Pool, proxy_uuids: &[Uuid], ) -> Result, Error> { if proxy_uuids.is_empty() { @@ -1270,7 +1270,7 @@ pub async fn fetch_proxies_by_uuids( /// 创建模板 pub async fn insert_template( - pool: &Pool, + pool: &Pool, user_uuid: Uuid, team_uuid: Option, name: &str, @@ -1304,7 +1304,7 @@ pub async fn insert_template( /// 查询模板列表 pub async fn fetch_templates( - pool: &Pool, + pool: &Pool, team_uuid: Option, user_uuid: Uuid, is_public: Option, @@ -1336,7 +1336,7 @@ pub async fn fetch_templates( /// 查询模板总数 pub async fn fetch_templates_count( - pool: &Pool, + pool: &Pool, team_uuid: Option, user_uuid: Uuid, is_public: Option, @@ -1361,7 +1361,7 @@ pub async fn fetch_templates_count( /// 根据 UUID 查询模板 pub async fn fetch_template_by_uuid( - pool: &Pool, + pool: &Pool, template_uuid: Uuid, ) -> Result, Error> { let rec = sqlx::query_as::<_, TemplateDto>( @@ -1381,7 +1381,7 @@ pub async fn fetch_template_by_uuid( /// 更新模板 pub async fn update_template( - pool: &Pool, + pool: &Pool, template_uuid: Uuid, name: Option<&str>, description: Option<&str>, @@ -1411,7 +1411,7 @@ pub async fn update_template( /// 增加模板使用次数 pub async fn increment_template_usage( - pool: &Pool, + pool: &Pool, template_uuid: Uuid, ) -> Result<(), Error> { sqlx::query( @@ -1428,7 +1428,7 @@ pub async fn increment_template_usage( } /// 软删除模板 -pub async fn delete_template(pool: &Pool, template_uuid: Uuid) -> Result<(), Error> { +pub async fn delete_template(pool: &Pool, template_uuid: Uuid) -> Result<(), Error> { sqlx::query( r#" UPDATE templates SET deleted_at = CURRENT_TIMESTAMP @@ -1446,7 +1446,7 @@ pub async fn delete_template(pool: &Pool, template_uuid: Uuid) -> Resu /// 添加环境 URL pub async fn insert_environment_url( - pool: &Pool, + pool: &Pool, env_uuid: Uuid, url: &str, title: Option<&str>, @@ -1471,7 +1471,7 @@ pub async fn insert_environment_url( /// 批量添加环境 URL pub async fn batch_insert_environment_urls( - pool: &Pool, + pool: &Pool, env_uuid: Uuid, urls: &[(String, Option)], ) -> Result { @@ -1497,7 +1497,7 @@ pub async fn batch_insert_environment_urls( /// 查询环境的所有 URL pub async fn fetch_environment_urls( - pool: &Pool, + pool: &Pool, env_uuid: Uuid, ) -> Result, Error> { let recs = sqlx::query_as::<_, EnvironmentUrlDto>( @@ -1517,7 +1517,7 @@ pub async fn fetch_environment_urls( /// 批量查询环境的所有 URL pub async fn fetch_environment_urls_by_uuids( - pool: &Pool, + pool: &Pool, env_uuids: &[Uuid], ) -> Result, Error> { if env_uuids.is_empty() { @@ -1543,7 +1543,7 @@ pub async fn fetch_environment_urls_by_uuids( } /// 删除环境的 URL -pub async fn delete_environment_url(pool: &Pool, url_id: i32) -> Result<(), Error> { +pub async fn delete_environment_url(pool: &Pool, url_id: i32) -> Result<(), Error> { sqlx::query( r#" DELETE FROM environment_urls WHERE id = $1 @@ -1557,7 +1557,7 @@ pub async fn delete_environment_url(pool: &Pool, url_id: i32) -> Resul } /// 清空环境的所有 URL -pub async fn clear_environment_urls(pool: &Pool, env_uuid: Uuid) -> Result { +pub async fn clear_environment_urls(pool: &Pool, env_uuid: Uuid) -> Result { let result = sqlx::query( r#" DELETE FROM environment_urls WHERE environment_uuid = $1 @@ -1574,7 +1574,7 @@ pub async fn clear_environment_urls(pool: &Pool, env_uuid: Uuid) -> Re /// 添加环境 Cookie pub async fn insert_environment_cookie( - pool: &Pool, + pool: &Pool, env_uuid: Uuid, site_input: &str, domain: &str, @@ -1609,7 +1609,7 @@ pub async fn insert_environment_cookie( /// 批量添加环境 Cookies pub async fn batch_insert_environment_cookies( - pool: &Pool, + pool: &Pool, env_uuid: Uuid, cookies: &[CookieInput], ) -> Result { @@ -1640,7 +1640,7 @@ pub async fn batch_insert_environment_cookies( /// 查询环境的所有 Cookies pub async fn fetch_environment_cookies( - pool: &Pool, + pool: &Pool, env_uuid: Uuid, ) -> Result, Error> { let recs = sqlx::query_as::<_, EnvironmentCookieDto>( @@ -1660,7 +1660,7 @@ pub async fn fetch_environment_cookies( /// 批量查询环境的所有 Cookies pub async fn fetch_environment_cookies_by_uuids( - pool: &Pool, + pool: &Pool, env_uuids: &[Uuid], ) -> Result, Error> { if env_uuids.is_empty() { @@ -1686,7 +1686,7 @@ pub async fn fetch_environment_cookies_by_uuids( } /// 删除环境的 Cookie -pub async fn delete_environment_cookie(pool: &Pool, cookie_id: i32) -> Result<(), Error> { +pub async fn delete_environment_cookie(pool: &Pool, cookie_id: i32) -> Result<(), Error> { sqlx::query( r#" DELETE FROM environment_cookies WHERE id = $1 @@ -1701,7 +1701,7 @@ pub async fn delete_environment_cookie(pool: &Pool, cookie_id: i32) -> /// 清空环境的所有 Cookies pub async fn clear_environment_cookies( - pool: &Pool, + pool: &Pool, env_uuid: Uuid, ) -> Result { let result = sqlx::query( diff --git a/src-tauri/crates/business/src/models/group_member_permissions.rs b/src-tauri/crates/business/src/models/group_member_permissions.rs index c0520aa..55ae9cc 100644 --- a/src-tauri/crates/business/src/models/group_member_permissions.rs +++ b/src-tauri/crates/business/src/models/group_member_permissions.rs @@ -1,199 +1,199 @@ -use sqlx::Error; -use uuid::Uuid; - -use crate::database::{Db as Postgres, Pool}; - -use crate::dto::GroupMemberPermissionDto; - -/// 授予分组权限 -pub async fn grant_group_permission( - pool: &Pool, - group_uuid: Uuid, - workspace_uuid: Uuid, - team_uuid: Uuid, - user_uuid: Uuid, - permission_type: &str, - granted_by: Uuid, -) -> Result<(), Error> { - sqlx::query( - r#" - INSERT INTO group_member_permissions ( - group_uuid, workspace_uuid, team_uuid, user_uuid, permission_type, granted_by - ) - VALUES ($1, $2, $3, $4, $5, $6) - ON CONFLICT (group_uuid, user_uuid) DO UPDATE SET - permission_type = EXCLUDED.permission_type, - granted_by = EXCLUDED.granted_by, - updated_at = CURRENT_TIMESTAMP - "#, - ) - .bind(group_uuid) - .bind(workspace_uuid) - .bind(team_uuid) - .bind(user_uuid) - .bind(permission_type) - .bind(granted_by) - .execute(pool) - .await?; - - Ok(()) -} - -/// 撤销分组权限 -pub async fn revoke_group_permission( - pool: &Pool, - group_uuid: Uuid, - user_uuid: Uuid, -) -> Result<(), Error> { - sqlx::query( - r#" - DELETE FROM group_member_permissions - WHERE group_uuid = $1 AND user_uuid = $2 - "#, - ) - .bind(group_uuid) - .bind(user_uuid) - .execute(pool) - .await?; - - Ok(()) -} - -/// 查询用户的分组权限列表 -pub async fn fetch_user_group_permissions( - pool: &Pool, - user_uuid: Uuid, - workspace_uuid: Option, - group_uuid: Option, -) -> Result, Error> { - let recs = sqlx::query_as::<_, GroupMemberPermissionDto>( - r#" - SELECT group_uuid, workspace_uuid, team_uuid, user_uuid, permission_type, granted_by, - created_at, updated_at - FROM group_member_permissions - WHERE user_uuid = $1 +use sqlx::Error; +use uuid::Uuid; + +use crate::database::{Db, Pool}; + +use crate::dto::GroupMemberPermissionDto; + +/// 授予分组权限 +pub async fn grant_group_permission( + pool: &Pool, + group_uuid: Uuid, + workspace_uuid: Uuid, + team_uuid: Uuid, + user_uuid: Uuid, + permission_type: &str, + granted_by: Uuid, +) -> Result<(), Error> { + sqlx::query( + r#" + INSERT INTO group_member_permissions ( + group_uuid, workspace_uuid, team_uuid, user_uuid, permission_type, granted_by + ) + VALUES ($1, $2, $3, $4, $5, $6) + ON CONFLICT (group_uuid, user_uuid) DO UPDATE SET + permission_type = EXCLUDED.permission_type, + granted_by = EXCLUDED.granted_by, + updated_at = CURRENT_TIMESTAMP + "#, + ) + .bind(group_uuid) + .bind(workspace_uuid) + .bind(team_uuid) + .bind(user_uuid) + .bind(permission_type) + .bind(granted_by) + .execute(pool) + .await?; + + Ok(()) +} + +/// 撤销分组权限 +pub async fn revoke_group_permission( + pool: &Pool, + group_uuid: Uuid, + user_uuid: Uuid, +) -> Result<(), Error> { + sqlx::query( + r#" + DELETE FROM group_member_permissions + WHERE group_uuid = $1 AND user_uuid = $2 + "#, + ) + .bind(group_uuid) + .bind(user_uuid) + .execute(pool) + .await?; + + Ok(()) +} + +/// 查询用户的分组权限列表 +pub async fn fetch_user_group_permissions( + pool: &Pool, + user_uuid: Uuid, + workspace_uuid: Option, + group_uuid: Option, +) -> Result, Error> { + let recs = sqlx::query_as::<_, GroupMemberPermissionDto>( + r#" + SELECT group_uuid, workspace_uuid, team_uuid, user_uuid, permission_type, granted_by, + created_at, updated_at + FROM group_member_permissions + WHERE user_uuid = $1 AND ($2 IS NULL OR workspace_uuid = $2) AND ($3 IS NULL OR group_uuid = $3) - ORDER BY created_at DESC - "#, - ) - .bind(user_uuid) - .bind(workspace_uuid) - .bind(group_uuid) - .fetch_all(pool) - .await?; - - Ok(recs) -} - -/// 检查用户是否有分组权限(工作空间级别) -pub async fn check_group_permission( - pool: &Pool, - workspace_uuid: Uuid, - group_uuid: Uuid, - user_uuid: Uuid, - permission_type: &str, -) -> Result { - // 首先检查用户是否是团队成员,以及是否是 Owner/Admin(自动拥有所有权限) - let is_owner_or_admin: bool = sqlx::query_scalar( - r#" - SELECT EXISTS ( - SELECT 1 FROM team_members tm - INNER JOIN groups g ON tm.team_uuid = g.team_uuid AND tm.workspace_uuid = g.workspace_uuid - WHERE g.uuid = $1 - AND g.workspace_uuid = $2 - AND tm.workspace_uuid = $2 - AND tm.user_uuid = $3 - AND tm.role IN ('owner', 'admin') - AND tm.deleted_at IS NULL - ) - "#, - ) - .bind(group_uuid) - .bind(workspace_uuid) - .bind(user_uuid) - .fetch_one(pool) - .await?; - - if is_owner_or_admin { - return Ok(true); - } - - // 检查显式权限(工作空间级别) - let has_permission = match permission_type { - "read" => { - // read 权限:检查是否有 read/write/manage 任一权限 - sqlx::query_scalar::<_, bool>( - r#" - SELECT EXISTS ( - SELECT 1 FROM group_member_permissions - WHERE group_uuid = $1 AND workspace_uuid = $2 AND user_uuid = $3 - ) - "#, - ) - .bind(group_uuid) - .bind(workspace_uuid) - .bind(user_uuid) - .fetch_one(pool) - .await? - } - "write" => { - // write 权限:检查是否有 write/manage 权限 - sqlx::query_scalar::<_, bool>( - r#" - SELECT EXISTS ( - SELECT 1 FROM group_member_permissions - WHERE group_uuid = $1 AND workspace_uuid = $2 AND user_uuid = $3 - AND permission_type IN ('write', 'manage') - ) - "#, - ) - .bind(group_uuid) - .bind(workspace_uuid) - .bind(user_uuid) - .fetch_one(pool) - .await? - } - "manage" => { - // manage 权限:检查是否有 manage 权限 - sqlx::query_scalar::<_, bool>( - r#" - SELECT EXISTS ( - SELECT 1 FROM group_member_permissions - WHERE group_uuid = $1 AND workspace_uuid = $2 AND user_uuid = $3 - AND permission_type = 'manage' - ) - "#, - ) - .bind(group_uuid) - .bind(workspace_uuid) - .bind(user_uuid) - .fetch_one(pool) - .await? - } - _ => false, - }; - - Ok(has_permission) -} - -/// 查询分组的所有权限 -pub async fn fetch_group_permissions( - pool: &Pool, - group_uuid: Uuid, -) -> Result, Error> { - let recs = sqlx::query_as::<_, GroupMemberPermissionDto>( - r#" - SELECT group_uuid, workspace_uuid, team_uuid, user_uuid, permission_type, granted_by, - created_at, updated_at - FROM group_member_permissions - WHERE group_uuid = $1 - ORDER BY created_at DESC - "#, - ) - .bind(group_uuid) - .fetch_all(pool) - .await?; - - Ok(recs) -} + ORDER BY created_at DESC + "#, + ) + .bind(user_uuid) + .bind(workspace_uuid) + .bind(group_uuid) + .fetch_all(pool) + .await?; + + Ok(recs) +} + +/// 检查用户是否有分组权限(工作空间级别) +pub async fn check_group_permission( + pool: &Pool, + workspace_uuid: Uuid, + group_uuid: Uuid, + user_uuid: Uuid, + permission_type: &str, +) -> Result { + // 首先检查用户是否是团队成员,以及是否是 Owner/Admin(自动拥有所有权限) + let is_owner_or_admin: bool = sqlx::query_scalar( + r#" + SELECT EXISTS ( + SELECT 1 FROM team_members tm + INNER JOIN groups g ON tm.team_uuid = g.team_uuid AND tm.workspace_uuid = g.workspace_uuid + WHERE g.uuid = $1 + AND g.workspace_uuid = $2 + AND tm.workspace_uuid = $2 + AND tm.user_uuid = $3 + AND tm.role IN ('owner', 'admin') + AND tm.deleted_at IS NULL + ) + "#, + ) + .bind(group_uuid) + .bind(workspace_uuid) + .bind(user_uuid) + .fetch_one(pool) + .await?; + + if is_owner_or_admin { + return Ok(true); + } + + // 检查显式权限(工作空间级别) + let has_permission = match permission_type { + "read" => { + // read 权限:检查是否有 read/write/manage 任一权限 + sqlx::query_scalar::<_, bool>( + r#" + SELECT EXISTS ( + SELECT 1 FROM group_member_permissions + WHERE group_uuid = $1 AND workspace_uuid = $2 AND user_uuid = $3 + ) + "#, + ) + .bind(group_uuid) + .bind(workspace_uuid) + .bind(user_uuid) + .fetch_one(pool) + .await? + } + "write" => { + // write 权限:检查是否有 write/manage 权限 + sqlx::query_scalar::<_, bool>( + r#" + SELECT EXISTS ( + SELECT 1 FROM group_member_permissions + WHERE group_uuid = $1 AND workspace_uuid = $2 AND user_uuid = $3 + AND permission_type IN ('write', 'manage') + ) + "#, + ) + .bind(group_uuid) + .bind(workspace_uuid) + .bind(user_uuid) + .fetch_one(pool) + .await? + } + "manage" => { + // manage 权限:检查是否有 manage 权限 + sqlx::query_scalar::<_, bool>( + r#" + SELECT EXISTS ( + SELECT 1 FROM group_member_permissions + WHERE group_uuid = $1 AND workspace_uuid = $2 AND user_uuid = $3 + AND permission_type = 'manage' + ) + "#, + ) + .bind(group_uuid) + .bind(workspace_uuid) + .bind(user_uuid) + .fetch_one(pool) + .await? + } + _ => false, + }; + + Ok(has_permission) +} + +/// 查询分组的所有权限 +pub async fn fetch_group_permissions( + pool: &Pool, + group_uuid: Uuid, +) -> Result, Error> { + let recs = sqlx::query_as::<_, GroupMemberPermissionDto>( + r#" + SELECT group_uuid, workspace_uuid, team_uuid, user_uuid, permission_type, granted_by, + created_at, updated_at + FROM group_member_permissions + WHERE group_uuid = $1 + ORDER BY created_at DESC + "#, + ) + .bind(group_uuid) + .fetch_all(pool) + .await?; + + Ok(recs) +} diff --git a/src-tauri/crates/business/src/models/local_api.rs b/src-tauri/crates/business/src/models/local_api.rs index 6515a73..2de2cc9 100644 --- a/src-tauri/crates/business/src/models/local_api.rs +++ b/src-tauri/crates/business/src/models/local_api.rs @@ -4,14 +4,14 @@ use sha2::{Digest, Sha256}; use sqlx::Error; use uuid::Uuid; -use crate::database::{Db as Postgres, Pool}; +use crate::database::{Db, Pool}; use crate::dto::{ LocalApiKeyDto, LocalApiKeyPermissionDto, LocalApiPermissionDefinitionDto, LocalApiSettingsDto, }; pub async fn fetch_local_api_settings( - pool: &Pool, + pool: &Pool, user_uuid: Uuid, ) -> Result, Error> { sqlx::query_as::<_, LocalApiSettingsDto>( @@ -27,7 +27,7 @@ pub async fn fetch_local_api_settings( } pub async fn upsert_local_api_settings( - pool: &Pool, + pool: &Pool, user_uuid: Uuid, enabled: Option, port: Option, @@ -58,7 +58,7 @@ pub async fn upsert_local_api_settings( } pub async fn fetch_active_api_key( - pool: &Pool, + pool: &Pool, user_uuid: Uuid, ) -> Result, Error> { sqlx::query_as::<_, LocalApiKeyDto>( @@ -78,7 +78,7 @@ pub async fn fetch_active_api_key( } pub async fn fetch_api_key_by_hash( - pool: &Pool, + pool: &Pool, key_hash: &str, ) -> Result, Error> { sqlx::query_as::<_, LocalApiKeyDto>( @@ -96,7 +96,7 @@ pub async fn fetch_api_key_by_hash( .await } -pub async fn deactivate_api_keys_for_user(pool: &Pool, user_uuid: Uuid) -> Result<(), Error> { +pub async fn deactivate_api_keys_for_user(pool: &Pool, user_uuid: Uuid) -> Result<(), Error> { sqlx::query( r#" UPDATE user_local_api_keys @@ -112,7 +112,7 @@ pub async fn deactivate_api_keys_for_user(pool: &Pool, user_uuid: Uuid } pub async fn insert_api_key( - pool: &Pool, + pool: &Pool, user_uuid: Uuid, key_prefix: &str, key_hash: &str, @@ -138,7 +138,7 @@ pub async fn insert_api_key( } pub async fn fetch_api_key_permission( - pool: &Pool, + pool: &Pool, api_key_id: i32, permission_code: &str, ) -> Result, Error> { @@ -161,7 +161,7 @@ pub async fn fetch_api_key_permission( } pub async fn insert_api_key_permission( - pool: &Pool, + pool: &Pool, api_key_id: i32, permission_code: &str, rate_limit_per_minute: i32, @@ -187,7 +187,7 @@ pub async fn insert_api_key_permission( } pub async fn fetch_permission_definition( - pool: &Pool, + pool: &Pool, permission_code: &str, ) -> Result, Error> { sqlx::query_as::<_, LocalApiPermissionDefinitionDto>( @@ -207,7 +207,7 @@ pub async fn fetch_permission_definition( } pub async fn fetch_permission_definitions( - pool: &Pool, + pool: &Pool, ) -> Result, Error> { sqlx::query_as::<_, LocalApiPermissionDefinitionDto>( r#" @@ -225,7 +225,7 @@ pub async fn fetch_permission_definitions( } pub async fn reset_api_key_daily_usage( - pool: &Pool, + pool: &Pool, api_key_id: i32, today: chrono::NaiveDate, ) -> Result<(), Error> { @@ -245,7 +245,7 @@ pub async fn reset_api_key_daily_usage( } pub async fn increment_api_key_usage( - pool: &Pool, + pool: &Pool, api_key_id: i32, used_at: DateTime, ) -> Result<(), Error> { @@ -265,7 +265,7 @@ pub async fn increment_api_key_usage( } pub async fn fetch_request_count( - pool: &Pool, + pool: &Pool, api_key_id: i32, permission_code: &str, window_type: &str, @@ -293,7 +293,7 @@ pub async fn fetch_request_count( } pub async fn increment_request_counter( - pool: &Pool, + pool: &Pool, api_key_id: i32, permission_code: &str, window_type: &str, diff --git a/src-tauri/crates/business/src/models/messages.rs b/src-tauri/crates/business/src/models/messages.rs index fc9a68b..8330a9f 100644 --- a/src-tauri/crates/business/src/models/messages.rs +++ b/src-tauri/crates/business/src/models/messages.rs @@ -1,413 +1,413 @@ -use sqlx::Error; -use uuid::Uuid; - -use crate::database::{Db as Postgres, Pool}; - -use crate::dto::{MessageDto, UserMessageDto}; - -// ============ Messages ============ - -/// 创建消息 -pub async fn create_message( - pool: &Pool, - sender_uuid: Option, - message_type: &str, - title: &str, - content: Option<&str>, - recipient_type: &str, - related_type: Option<&str>, - related_uuid: Option, - priority: &str, - metadata: Option, -) -> Result { - let message_uuid: Uuid = sqlx::query_scalar( - r#" - INSERT INTO messages (message_type, title, content, sender_uuid, recipient_type, related_type, related_uuid, priority, metadata, status) - VALUES ($1, $2, $3, $4, $5, $6, $7, $8, $9, 'active') - RETURNING uuid; - "#, - ) - .bind(message_type) - .bind(title) - .bind(content) - .bind(sender_uuid) - .bind(recipient_type) - .bind(related_type) - .bind(related_uuid) - .bind(priority) - .bind(metadata) - .fetch_one(pool) - .await?; - - Ok(message_uuid) -} - -/// 添加消息接收者 -pub async fn add_message_recipient( - pool: &Pool, - message_uuid: Uuid, - user_uuid: Uuid, - action_status: Option<&str>, -) -> Result<(), Error> { - sqlx::query( - r#" - INSERT INTO user_messages (message_uuid, user_uuid, is_read, action_status) - VALUES ($1, $2, FALSE, $3) - ON CONFLICT (message_uuid, user_uuid) DO NOTHING; - "#, - ) - .bind(message_uuid) - .bind(user_uuid) - .bind(action_status) - .execute(pool) - .await?; - - Ok(()) -} - -/// 批量添加消息接收者 -pub async fn add_message_recipients( - pool: &Pool, - message_uuid: Uuid, - user_uuids: &[Uuid], - action_status: Option<&str>, -) -> Result<(), Error> { - let mut tx = pool.begin().await?; - - for user_uuid in user_uuids { - sqlx::query( - r#" - INSERT INTO user_messages (message_uuid, user_uuid, is_read, action_status) - VALUES ($1, $2, FALSE, $3) - ON CONFLICT (message_uuid, user_uuid) DO NOTHING; - "#, - ) - .bind(message_uuid) - .bind(user_uuid) - .bind(action_status) - .execute(&mut *tx) - .await?; - } - - tx.commit().await?; - - Ok(()) -} - -/// 查询用户消息列表 -pub async fn fetch_user_messages( - pool: &Pool, - user_uuid: Uuid, - offset: i64, - limit: i64, - message_type: Option<&str>, - is_read: Option, - action_status: Option<&str>, - priority: Option<&str>, -) -> Result, Error> { - let mut query = String::from( - r#" - SELECT - m.uuid AS message_uuid, - m.message_type, - m.title, - m.content, - m.sender_uuid, - m.related_type, - m.related_uuid, - m.metadata, - m.priority, - m.created_at AS message_created_at, - um.is_read, - um.read_at, - um.action_status, - um.action_at, - ui.nickname AS sender_name, - ui.email AS sender_email - FROM user_messages um - INNER JOIN messages m ON um.message_uuid = m.uuid - LEFT JOIN user_infos ui ON m.sender_uuid = ui.user_uuid - WHERE um.user_uuid = $1 AND m.deleted_at IS NULL - "#, - ); - - let mut param_count = 1; - - if message_type.is_some() { - param_count += 1; - query.push_str(&format!(" AND m.message_type = ${}", param_count)); - } - - if is_read.is_some() { - param_count += 1; - query.push_str(&format!(" AND um.is_read = ${}", param_count)); - } - - if action_status.is_some() { - param_count += 1; - query.push_str(&format!(" AND um.action_status = ${}", param_count)); - } - - if priority.is_some() { - param_count += 1; - query.push_str(&format!(" AND m.priority = ${}", param_count)); - } - - query.push_str(" ORDER BY m.created_at DESC"); - param_count += 1; - query.push_str(&format!(" LIMIT ${}", param_count)); - param_count += 1; - query.push_str(&format!(" OFFSET ${}", param_count)); - - // 构建查询参数 - let mut query_builder = sqlx::query_as::<_, UserMessageDto>(&query); - query_builder = query_builder.bind(user_uuid); - - if let Some(mt) = message_type { - query_builder = query_builder.bind(mt); - } - if let Some(ir) = is_read { - query_builder = query_builder.bind(ir); - } - if let Some(as_) = action_status { - query_builder = query_builder.bind(as_); - } - if let Some(p) = priority { - query_builder = query_builder.bind(p); - } - - query_builder = query_builder.bind(limit); - query_builder = query_builder.bind(offset); - - let recs = query_builder.fetch_all(pool).await?; - - Ok(recs) -} - -/// 查询用户消息总数 -pub async fn fetch_user_messages_count( - pool: &Pool, - user_uuid: Uuid, - message_type: Option<&str>, - is_read: Option, - action_status: Option<&str>, - priority: Option<&str>, -) -> Result { - let mut query = String::from( - r#" - SELECT COUNT(*) - FROM user_messages um - INNER JOIN messages m ON um.message_uuid = m.uuid - WHERE um.user_uuid = $1 AND m.deleted_at IS NULL - "#, - ); - - let mut param_count = 1; - - if message_type.is_some() { - param_count += 1; - query.push_str(&format!(" AND m.message_type = ${}", param_count)); - } - - if is_read.is_some() { - param_count += 1; - query.push_str(&format!(" AND um.is_read = ${}", param_count)); - } - - if action_status.is_some() { - param_count += 1; - query.push_str(&format!(" AND um.action_status = ${}", param_count)); - } - - if priority.is_some() { - param_count += 1; - query.push_str(&format!(" AND m.priority = ${}", param_count)); - } - - let mut query_builder = sqlx::query_scalar::<_, i64>(&query); - query_builder = query_builder.bind(user_uuid); - - if let Some(mt) = message_type { - query_builder = query_builder.bind(mt); - } - if let Some(ir) = is_read { - query_builder = query_builder.bind(ir); - } - if let Some(as_) = action_status { - query_builder = query_builder.bind(as_); - } - if let Some(p) = priority { - query_builder = query_builder.bind(p); - } - - let count = query_builder.fetch_one(pool).await?; - - Ok(count) -} - -/// 标记消息为已读 -pub async fn mark_message_read( - pool: &Pool, - message_uuid: Uuid, - user_uuid: Uuid, -) -> Result<(), Error> { - sqlx::query( - r#" - UPDATE user_messages +use sqlx::Error; +use uuid::Uuid; + +use crate::database::{Db, Pool}; + +use crate::dto::{MessageDto, UserMessageDto}; + +// ============ Messages ============ + +/// 创建消息 +pub async fn create_message( + pool: &Pool, + sender_uuid: Option, + message_type: &str, + title: &str, + content: Option<&str>, + recipient_type: &str, + related_type: Option<&str>, + related_uuid: Option, + priority: &str, + metadata: Option, +) -> Result { + let message_uuid: Uuid = sqlx::query_scalar( + r#" + INSERT INTO messages (message_type, title, content, sender_uuid, recipient_type, related_type, related_uuid, priority, metadata, status) + VALUES ($1, $2, $3, $4, $5, $6, $7, $8, $9, 'active') + RETURNING uuid; + "#, + ) + .bind(message_type) + .bind(title) + .bind(content) + .bind(sender_uuid) + .bind(recipient_type) + .bind(related_type) + .bind(related_uuid) + .bind(priority) + .bind(metadata) + .fetch_one(pool) + .await?; + + Ok(message_uuid) +} + +/// 添加消息接收者 +pub async fn add_message_recipient( + pool: &Pool, + message_uuid: Uuid, + user_uuid: Uuid, + action_status: Option<&str>, +) -> Result<(), Error> { + sqlx::query( + r#" + INSERT INTO user_messages (message_uuid, user_uuid, is_read, action_status) + VALUES ($1, $2, FALSE, $3) + ON CONFLICT (message_uuid, user_uuid) DO NOTHING; + "#, + ) + .bind(message_uuid) + .bind(user_uuid) + .bind(action_status) + .execute(pool) + .await?; + + Ok(()) +} + +/// 批量添加消息接收者 +pub async fn add_message_recipients( + pool: &Pool, + message_uuid: Uuid, + user_uuids: &[Uuid], + action_status: Option<&str>, +) -> Result<(), Error> { + let mut tx = pool.begin().await?; + + for user_uuid in user_uuids { + sqlx::query( + r#" + INSERT INTO user_messages (message_uuid, user_uuid, is_read, action_status) + VALUES ($1, $2, FALSE, $3) + ON CONFLICT (message_uuid, user_uuid) DO NOTHING; + "#, + ) + .bind(message_uuid) + .bind(user_uuid) + .bind(action_status) + .execute(&mut *tx) + .await?; + } + + tx.commit().await?; + + Ok(()) +} + +/// 查询用户消息列表 +pub async fn fetch_user_messages( + pool: &Pool, + user_uuid: Uuid, + offset: i64, + limit: i64, + message_type: Option<&str>, + is_read: Option, + action_status: Option<&str>, + priority: Option<&str>, +) -> Result, Error> { + let mut query = String::from( + r#" + SELECT + m.uuid AS message_uuid, + m.message_type, + m.title, + m.content, + m.sender_uuid, + m.related_type, + m.related_uuid, + m.metadata, + m.priority, + m.created_at AS message_created_at, + um.is_read, + um.read_at, + um.action_status, + um.action_at, + ui.nickname AS sender_name, + ui.email AS sender_email + FROM user_messages um + INNER JOIN messages m ON um.message_uuid = m.uuid + LEFT JOIN user_infos ui ON m.sender_uuid = ui.user_uuid + WHERE um.user_uuid = $1 AND m.deleted_at IS NULL + "#, + ); + + let mut param_count = 1; + + if message_type.is_some() { + param_count += 1; + query.push_str(&format!(" AND m.message_type = ${}", param_count)); + } + + if is_read.is_some() { + param_count += 1; + query.push_str(&format!(" AND um.is_read = ${}", param_count)); + } + + if action_status.is_some() { + param_count += 1; + query.push_str(&format!(" AND um.action_status = ${}", param_count)); + } + + if priority.is_some() { + param_count += 1; + query.push_str(&format!(" AND m.priority = ${}", param_count)); + } + + query.push_str(" ORDER BY m.created_at DESC"); + param_count += 1; + query.push_str(&format!(" LIMIT ${}", param_count)); + param_count += 1; + query.push_str(&format!(" OFFSET ${}", param_count)); + + // 构建查询参数 + let mut query_builder = sqlx::query_as::<_, UserMessageDto>(&query); + query_builder = query_builder.bind(user_uuid); + + if let Some(mt) = message_type { + query_builder = query_builder.bind(mt); + } + if let Some(ir) = is_read { + query_builder = query_builder.bind(ir); + } + if let Some(as_) = action_status { + query_builder = query_builder.bind(as_); + } + if let Some(p) = priority { + query_builder = query_builder.bind(p); + } + + query_builder = query_builder.bind(limit); + query_builder = query_builder.bind(offset); + + let recs = query_builder.fetch_all(pool).await?; + + Ok(recs) +} + +/// 查询用户消息总数 +pub async fn fetch_user_messages_count( + pool: &Pool, + user_uuid: Uuid, + message_type: Option<&str>, + is_read: Option, + action_status: Option<&str>, + priority: Option<&str>, +) -> Result { + let mut query = String::from( + r#" + SELECT COUNT(*) + FROM user_messages um + INNER JOIN messages m ON um.message_uuid = m.uuid + WHERE um.user_uuid = $1 AND m.deleted_at IS NULL + "#, + ); + + let mut param_count = 1; + + if message_type.is_some() { + param_count += 1; + query.push_str(&format!(" AND m.message_type = ${}", param_count)); + } + + if is_read.is_some() { + param_count += 1; + query.push_str(&format!(" AND um.is_read = ${}", param_count)); + } + + if action_status.is_some() { + param_count += 1; + query.push_str(&format!(" AND um.action_status = ${}", param_count)); + } + + if priority.is_some() { + param_count += 1; + query.push_str(&format!(" AND m.priority = ${}", param_count)); + } + + let mut query_builder = sqlx::query_scalar::<_, i64>(&query); + query_builder = query_builder.bind(user_uuid); + + if let Some(mt) = message_type { + query_builder = query_builder.bind(mt); + } + if let Some(ir) = is_read { + query_builder = query_builder.bind(ir); + } + if let Some(as_) = action_status { + query_builder = query_builder.bind(as_); + } + if let Some(p) = priority { + query_builder = query_builder.bind(p); + } + + let count = query_builder.fetch_one(pool).await?; + + Ok(count) +} + +/// 标记消息为已读 +pub async fn mark_message_read( + pool: &Pool, + message_uuid: Uuid, + user_uuid: Uuid, +) -> Result<(), Error> { + sqlx::query( + r#" + UPDATE user_messages SET is_read = TRUE, read_at = CURRENT_TIMESTAMP, updated_at = CURRENT_TIMESTAMP - WHERE message_uuid = $1 AND user_uuid = $2 AND is_read = FALSE - "#, - ) - .bind(message_uuid) - .bind(user_uuid) - .execute(pool) - .await?; - - Ok(()) -} - -/// 批量标记消息为已读 -pub async fn batch_mark_messages_read( - pool: &Pool, - message_uuids: &[Uuid], - user_uuid: Uuid, -) -> Result<(), Error> { - let mut tx = pool.begin().await?; - - for message_uuid in message_uuids { - sqlx::query( - r#" - UPDATE user_messages + WHERE message_uuid = $1 AND user_uuid = $2 AND is_read = FALSE + "#, + ) + .bind(message_uuid) + .bind(user_uuid) + .execute(pool) + .await?; + + Ok(()) +} + +/// 批量标记消息为已读 +pub async fn batch_mark_messages_read( + pool: &Pool, + message_uuids: &[Uuid], + user_uuid: Uuid, +) -> Result<(), Error> { + let mut tx = pool.begin().await?; + + for message_uuid in message_uuids { + sqlx::query( + r#" + UPDATE user_messages SET is_read = TRUE, read_at = CURRENT_TIMESTAMP, updated_at = CURRENT_TIMESTAMP - WHERE message_uuid = $1 AND user_uuid = $2 AND is_read = FALSE - "#, - ) - .bind(message_uuid) - .bind(user_uuid) - .execute(&mut *tx) - .await?; - } - - tx.commit().await?; - - Ok(()) -} - -/// 处理消息(接受/拒绝) -pub async fn handle_message( - pool: &Pool, - message_uuid: Uuid, - user_uuid: Uuid, - action: &str, -) -> Result<(), Error> { - let action_status = match action { - "accept" => "accepted", - "reject" => "rejected", - _ => return Err(Error::RowNotFound), - }; - - sqlx::query( - r#" - UPDATE user_messages + WHERE message_uuid = $1 AND user_uuid = $2 AND is_read = FALSE + "#, + ) + .bind(message_uuid) + .bind(user_uuid) + .execute(&mut *tx) + .await?; + } + + tx.commit().await?; + + Ok(()) +} + +/// 处理消息(接受/拒绝) +pub async fn handle_message( + pool: &Pool, + message_uuid: Uuid, + user_uuid: Uuid, + action: &str, +) -> Result<(), Error> { + let action_status = match action { + "accept" => "accepted", + "reject" => "rejected", + _ => return Err(Error::RowNotFound), + }; + + sqlx::query( + r#" + UPDATE user_messages SET action_status = $1, action_at = CURRENT_TIMESTAMP, updated_at = CURRENT_TIMESTAMP - WHERE message_uuid = $2 AND user_uuid = $3 - "#, - ) - .bind(action_status) - .bind(message_uuid) - .bind(user_uuid) - .execute(pool) - .await?; - - Ok(()) -} - -/// 获取用户消息统计 -pub async fn fetch_user_message_stats( - pool: &Pool, - user_uuid: Uuid, -) -> Result<(i64, i64, std::collections::HashMap), Error> { - // 总消息数 - let total: i64 = sqlx::query_scalar( - r#" - SELECT COUNT(*) - FROM user_messages um - INNER JOIN messages m ON um.message_uuid = m.uuid - WHERE um.user_uuid = $1 AND m.deleted_at IS NULL - "#, - ) - .bind(user_uuid) - .fetch_one(pool) - .await?; - - // 未读消息数 - let unread: i64 = sqlx::query_scalar( - r#" - SELECT COUNT(*) - FROM user_messages um - INNER JOIN messages m ON um.message_uuid = m.uuid - WHERE um.user_uuid = $1 AND um.is_read = FALSE AND m.deleted_at IS NULL - "#, - ) - .bind(user_uuid) - .fetch_one(pool) - .await?; - - // 按类型统计 - let type_stats: Vec<(String, i64)> = sqlx::query_as( - r#" - SELECT m.message_type, COUNT(*) as count - FROM user_messages um - INNER JOIN messages m ON um.message_uuid = m.uuid - WHERE um.user_uuid = $1 AND m.deleted_at IS NULL - GROUP BY m.message_type - "#, - ) - .bind(user_uuid) - .fetch_all(pool) - .await?; - - let mut by_type = std::collections::HashMap::new(); - for (msg_type, count) in type_stats { - by_type.insert(msg_type, count); - } - - Ok((total, unread, by_type)) -} - -/// 删除消息(软删除) -pub async fn delete_message(pool: &Pool, message_uuid: Uuid) -> Result<(), Error> { - sqlx::query( - r#" - UPDATE messages + WHERE message_uuid = $2 AND user_uuid = $3 + "#, + ) + .bind(action_status) + .bind(message_uuid) + .bind(user_uuid) + .execute(pool) + .await?; + + Ok(()) +} + +/// 获取用户消息统计 +pub async fn fetch_user_message_stats( + pool: &Pool, + user_uuid: Uuid, +) -> Result<(i64, i64, std::collections::HashMap), Error> { + // 总消息数 + let total: i64 = sqlx::query_scalar( + r#" + SELECT COUNT(*) + FROM user_messages um + INNER JOIN messages m ON um.message_uuid = m.uuid + WHERE um.user_uuid = $1 AND m.deleted_at IS NULL + "#, + ) + .bind(user_uuid) + .fetch_one(pool) + .await?; + + // 未读消息数 + let unread: i64 = sqlx::query_scalar( + r#" + SELECT COUNT(*) + FROM user_messages um + INNER JOIN messages m ON um.message_uuid = m.uuid + WHERE um.user_uuid = $1 AND um.is_read = FALSE AND m.deleted_at IS NULL + "#, + ) + .bind(user_uuid) + .fetch_one(pool) + .await?; + + // 按类型统计 + let type_stats: Vec<(String, i64)> = sqlx::query_as( + r#" + SELECT m.message_type, COUNT(*) as count + FROM user_messages um + INNER JOIN messages m ON um.message_uuid = m.uuid + WHERE um.user_uuid = $1 AND m.deleted_at IS NULL + GROUP BY m.message_type + "#, + ) + .bind(user_uuid) + .fetch_all(pool) + .await?; + + let mut by_type = std::collections::HashMap::new(); + for (msg_type, count) in type_stats { + by_type.insert(msg_type, count); + } + + Ok((total, unread, by_type)) +} + +/// 删除消息(软删除) +pub async fn delete_message(pool: &Pool, message_uuid: Uuid) -> Result<(), Error> { + sqlx::query( + r#" + UPDATE messages SET deleted_at = CURRENT_TIMESTAMP, status = 'deleted', updated_at = CURRENT_TIMESTAMP - WHERE uuid = $1 AND deleted_at IS NULL - "#, - ) - .bind(message_uuid) - .execute(pool) - .await?; - - Ok(()) -} - -/// 根据 UUID 查询消息 -pub async fn fetch_message_by_uuid( - pool: &Pool, - message_uuid: Uuid, -) -> Result, Error> { - let rec = sqlx::query_as::<_, MessageDto>( - r#" - SELECT id, uuid, message_type, title, content, sender_uuid, recipient_type, - related_type, related_uuid, metadata, status, priority, - created_at, updated_at, deleted_at - FROM messages - WHERE uuid = $1 AND deleted_at IS NULL - "#, - ) - .bind(message_uuid) - .fetch_optional(pool) - .await?; - - Ok(rec) -} + WHERE uuid = $1 AND deleted_at IS NULL + "#, + ) + .bind(message_uuid) + .execute(pool) + .await?; + + Ok(()) +} + +/// 根据 UUID 查询消息 +pub async fn fetch_message_by_uuid( + pool: &Pool, + message_uuid: Uuid, +) -> Result, Error> { + let rec = sqlx::query_as::<_, MessageDto>( + r#" + SELECT id, uuid, message_type, title, content, sender_uuid, recipient_type, + related_type, related_uuid, metadata, status, priority, + created_at, updated_at, deleted_at + FROM messages + WHERE uuid = $1 AND deleted_at IS NULL + "#, + ) + .bind(message_uuid) + .fetch_optional(pool) + .await?; + + Ok(rec) +} diff --git a/src-tauri/crates/business/src/models/mod.rs b/src-tauri/crates/business/src/models/mod.rs index 3c66051..3216160 100644 --- a/src-tauri/crates/business/src/models/mod.rs +++ b/src-tauri/crates/business/src/models/mod.rs @@ -1,6 +1,6 @@ pub mod user; - -// 新增模块 + +// 新增模块 pub mod accounts; pub mod audit; pub mod environments; @@ -11,21 +11,21 @@ pub mod preferences; pub mod proxies; pub mod proxy_visible_teams; pub mod rpa; -pub mod teams; -pub mod workspace_quotas; -pub mod workspaces; - +pub mod teams; +pub mod workspace_quotas; +pub mod workspaces; + pub use user::*; - -// 新增导出 + +// 新增导出 pub use accounts::*; pub use audit::*; pub use environments::*; pub use group_member_permissions::*; -pub use local_api::*; -pub use messages::*; -pub use proxies::*; -pub use proxy_visible_teams::*; -pub use teams::*; -pub use workspace_quotas::*; -pub use workspaces::*; +pub use local_api::*; +pub use messages::*; +pub use proxies::*; +pub use proxy_visible_teams::*; +pub use teams::*; +pub use workspace_quotas::*; +pub use workspaces::*; diff --git a/src-tauri/crates/business/src/models/preferences.rs b/src-tauri/crates/business/src/models/preferences.rs index e1e9541..ae530f5 100644 --- a/src-tauri/crates/business/src/models/preferences.rs +++ b/src-tauri/crates/business/src/models/preferences.rs @@ -1,54 +1,54 @@ -use sqlx::Error; -use uuid::Uuid; - -use crate::database::{Db as Postgres, Pool}; - -use crate::dto::UserPreferenceDto; - -/// 获取用户偏好设置 -pub async fn fetch_user_preferences( - pool: &Pool, - user_uuid: Uuid, -) -> Result, Error> { - let rec = sqlx::query_as::<_, UserPreferenceDto>( - r#" - SELECT id, user_uuid, theme, language, notifications_enabled, created_at, updated_at - FROM user_preferences - WHERE user_uuid = $1 - "#, - ) - .bind(user_uuid) - .fetch_optional(pool) - .await?; - - Ok(rec) -} - -/// 创建或更新用户偏好设置 -pub async fn upsert_user_preferences( - pool: &Pool, - user_uuid: Uuid, - theme: Option<&str>, - language: Option<&str>, - notifications_enabled: Option, -) -> Result<(), Error> { - sqlx::query( - r#" - INSERT INTO user_preferences (user_uuid, theme, language, notifications_enabled) - VALUES ($1, COALESCE($2, 'system'), COALESCE($3, 'zh-CN'), COALESCE($4, true)) - ON CONFLICT (user_uuid) DO UPDATE SET - theme = COALESCE($2, user_preferences.theme), - language = COALESCE($3, user_preferences.language), - notifications_enabled = COALESCE($4, user_preferences.notifications_enabled), - updated_at = CURRENT_TIMESTAMP - "#, - ) - .bind(user_uuid) - .bind(theme) - .bind(language) - .bind(notifications_enabled) - .execute(pool) - .await?; - - Ok(()) -} +use sqlx::Error; +use uuid::Uuid; + +use crate::database::{Db, Pool}; + +use crate::dto::UserPreferenceDto; + +/// 获取用户偏好设置 +pub async fn fetch_user_preferences( + pool: &Pool, + user_uuid: Uuid, +) -> Result, Error> { + let rec = sqlx::query_as::<_, UserPreferenceDto>( + r#" + SELECT id, user_uuid, theme, language, notifications_enabled, created_at, updated_at + FROM user_preferences + WHERE user_uuid = $1 + "#, + ) + .bind(user_uuid) + .fetch_optional(pool) + .await?; + + Ok(rec) +} + +/// 创建或更新用户偏好设置 +pub async fn upsert_user_preferences( + pool: &Pool, + user_uuid: Uuid, + theme: Option<&str>, + language: Option<&str>, + notifications_enabled: Option, +) -> Result<(), Error> { + sqlx::query( + r#" + INSERT INTO user_preferences (user_uuid, theme, language, notifications_enabled) + VALUES ($1, COALESCE($2, 'system'), COALESCE($3, 'zh-CN'), COALESCE($4, true)) + ON CONFLICT (user_uuid) DO UPDATE SET + theme = COALESCE($2, user_preferences.theme), + language = COALESCE($3, user_preferences.language), + notifications_enabled = COALESCE($4, user_preferences.notifications_enabled), + updated_at = CURRENT_TIMESTAMP + "#, + ) + .bind(user_uuid) + .bind(theme) + .bind(language) + .bind(notifications_enabled) + .execute(pool) + .await?; + + Ok(()) +} diff --git a/src-tauri/crates/business/src/models/proxies.rs b/src-tauri/crates/business/src/models/proxies.rs index 13b31f2..6f8f107 100644 --- a/src-tauri/crates/business/src/models/proxies.rs +++ b/src-tauri/crates/business/src/models/proxies.rs @@ -1,13 +1,13 @@ use sqlx::Error; use uuid::Uuid; -use crate::database::{Db as Postgres, Pool, placeholders}; +use crate::database::{Db, Pool, placeholders}; use crate::dto::{ProxyDto, ProxyHealthCheckDto}; /// 创建代理 pub async fn insert_proxy( - pool: &Pool, + pool: &Pool, workspace_uuid: Uuid, owner_uuid: Uuid, name: &str, @@ -45,7 +45,7 @@ pub async fn insert_proxy( /// 查询代理列表 pub async fn fetch_proxies( - pool: &Pool, + pool: &Pool, workspace_uuid: Uuid, proxy_type: Option<&str>, status: Option<&str>, @@ -81,7 +81,7 @@ pub async fn fetch_proxies( /// 查询代理总数 pub async fn fetch_proxies_count( - pool: &Pool, + pool: &Pool, workspace_uuid: Uuid, proxy_type: Option<&str>, status: Option<&str>, @@ -106,7 +106,7 @@ pub async fn fetch_proxies_count( /// 根据 UUID 查询代理 pub async fn fetch_proxy_by_uuid( - pool: &Pool, + pool: &Pool, proxy_uuid: Uuid, ) -> Result, Error> { let rec = sqlx::query_as::<_, ProxyDto>( @@ -129,7 +129,7 @@ pub async fn fetch_proxy_by_uuid( /// 更新代理 pub async fn update_proxy( - pool: &Pool, + pool: &Pool, proxy_uuid: Uuid, name: Option<&str>, host: Option<&str>, @@ -171,7 +171,7 @@ pub async fn update_proxy( /// 更新代理检测结果 pub async fn update_proxy_check_result( - pool: &Pool, + pool: &Pool, proxy_uuid: Uuid, status: &str, latency: Option, @@ -204,7 +204,7 @@ pub async fn update_proxy_check_result( } /// 增加代理使用次数 -pub async fn increment_proxy_usage(pool: &Pool, proxy_uuid: Uuid) -> Result<(), Error> { +pub async fn increment_proxy_usage(pool: &Pool, proxy_uuid: Uuid) -> Result<(), Error> { sqlx::query( r#" UPDATE proxies SET usage_count = usage_count + 1 @@ -219,7 +219,7 @@ pub async fn increment_proxy_usage(pool: &Pool, proxy_uuid: Uuid) -> R } /// 软删除代理 -pub async fn delete_proxy(pool: &Pool, proxy_uuid: Uuid) -> Result<(), Error> { +pub async fn delete_proxy(pool: &Pool, proxy_uuid: Uuid) -> Result<(), Error> { sqlx::query( r#" UPDATE proxies SET deleted_at = CURRENT_TIMESTAMP @@ -235,7 +235,7 @@ pub async fn delete_proxy(pool: &Pool, proxy_uuid: Uuid) -> Result<(), /// 批量软删除代理 pub async fn batch_delete_proxies( - pool: &Pool, + pool: &Pool, proxy_uuids: &[Uuid], ) -> Result { if proxy_uuids.is_empty() { @@ -262,7 +262,7 @@ pub async fn batch_delete_proxies( /// 记录代理健康检查 pub async fn insert_proxy_health_check( - pool: &Pool, + pool: &Pool, proxy_uuid: Uuid, status: &str, latency: Option, @@ -289,7 +289,7 @@ pub async fn insert_proxy_health_check( /// 查询代理健康检查历史 pub async fn fetch_proxy_health_checks( - pool: &Pool, + pool: &Pool, proxy_uuid: Uuid, limit: i64, ) -> Result, Error> { diff --git a/src-tauri/crates/business/src/models/proxy_visible_teams.rs b/src-tauri/crates/business/src/models/proxy_visible_teams.rs index 6034762..4709bdf 100644 --- a/src-tauri/crates/business/src/models/proxy_visible_teams.rs +++ b/src-tauri/crates/business/src/models/proxy_visible_teams.rs @@ -1,13 +1,13 @@ use sqlx::Error; use uuid::Uuid; -use crate::database::{Db as Postgres, Pool}; +use crate::database::{Db, Pool}; use crate::dto::{ProxyDto, ProxyVisibleTeamDto}; /// 添加代理可见团队 pub async fn insert_proxy_visible_team( - pool: &Pool, + pool: &Pool, proxy_uuid: Uuid, workspace_uuid: Uuid, team_uuid: Uuid, @@ -30,7 +30,7 @@ pub async fn insert_proxy_visible_team( /// 移除代理可见团队 pub async fn remove_proxy_visible_team( - pool: &Pool, + pool: &Pool, proxy_uuid: Uuid, team_uuid: Uuid, ) -> Result<(), Error> { @@ -50,7 +50,7 @@ pub async fn remove_proxy_visible_team( /// 查询代理的可见团队列表 pub async fn fetch_visible_teams_by_proxy( - pool: &Pool, + pool: &Pool, proxy_uuid: Uuid, ) -> Result, Error> { let recs = sqlx::query_as::<_, ProxyVisibleTeamDto>( @@ -70,7 +70,7 @@ pub async fn fetch_visible_teams_by_proxy( /// 查询团队可见的代理列表 pub async fn fetch_visible_proxies_by_team( - pool: &Pool, + pool: &Pool, workspace_uuid: Uuid, team_uuid: Uuid, ) -> Result, Error> { @@ -98,7 +98,7 @@ pub async fn fetch_visible_proxies_by_team( /// 检查代理对团队是否可见 pub async fn check_proxy_visibility( - pool: &Pool, + pool: &Pool, proxy_uuid: Uuid, workspace_uuid: Uuid, team_uuid: Uuid, @@ -120,7 +120,7 @@ pub async fn check_proxy_visibility( /// 查询工作空间所有可见的代理(包括工作空间 Owner 和代理所有者的代理) pub async fn fetch_visible_proxies_for_user( - pool: &Pool, + pool: &Pool, workspace_uuid: Uuid, user_uuid: Uuid, team_uuid: Option, @@ -171,7 +171,7 @@ pub async fn fetch_visible_proxies_for_user( /// 分页查询用户可见的代理列表,并支持名称搜索和筛选 pub async fn fetch_visible_proxies_for_user_paginated( - pool: &Pool, + pool: &Pool, workspace_uuid: Uuid, user_uuid: Uuid, team_uuid: Option, @@ -235,7 +235,7 @@ pub async fn fetch_visible_proxies_for_user_paginated( /// 查询用户可见代理总数,并支持名称搜索和筛选 pub async fn fetch_visible_proxies_for_user_count( - pool: &Pool, + pool: &Pool, workspace_uuid: Uuid, user_uuid: Uuid, team_uuid: Option, diff --git a/src-tauri/crates/business/src/models/rpa.rs b/src-tauri/crates/business/src/models/rpa.rs index 5aebb03..efd227d 100644 --- a/src-tauri/crates/business/src/models/rpa.rs +++ b/src-tauri/crates/business/src/models/rpa.rs @@ -1,66 +1,66 @@ -use sqlx::Error; -use uuid::Uuid; - -use crate::database::{Db as Postgres, Pool, placeholders}; - -use crate::dto::{RpaTaskDto, RpaTaskEnvironmentDto, RpaTaskRunDto, RpaTaskStepDto}; - -// ============ RPA Tasks ============ - -/// 创建 RPA 任务 -pub async fn insert_rpa_task( - pool: &Pool, - user_uuid: Uuid, - team_uuid: Option, - name: &str, - description: Option<&str>, - tags: Option<&serde_json::Value>, - trigger_type: &str, - schedule: Option<&str>, - cron_expression: Option<&str>, - run_mode: &str, - retry_count: Option, - retry_interval: Option, - timeout: Option, - concurrency: Option, - stop_on_error: Option, - notify_on_complete: Option, - notify_on_error: Option, -) -> Result { - let uuid: Uuid = sqlx::query_scalar( - r#" - INSERT INTO rpa_tasks (user_uuid, team_uuid, name, description, tags, trigger_type, - schedule, cron_expression, run_mode, retry_count, retry_interval, - timeout, concurrency, stop_on_error, notify_on_complete, notify_on_error) - VALUES ($1, $2, $3, $4, $5, $6, $7, $8, $9, $10, $11, $12, $13, $14, $15, $16) - RETURNING uuid; - "#, - ) - .bind(user_uuid) - .bind(team_uuid) - .bind(name) - .bind(description) - .bind(tags) - .bind(trigger_type) - .bind(schedule) - .bind(cron_expression) - .bind(run_mode) - .bind(retry_count) - .bind(retry_interval) - .bind(timeout) - .bind(concurrency) - .bind(stop_on_error) - .bind(notify_on_complete) - .bind(notify_on_error) - .fetch_one(pool) - .await?; - - Ok(uuid) -} - -/// 查询 RPA 任务列表 +use sqlx::Error; +use uuid::Uuid; + +use crate::database::{Db, Pool, placeholders}; + +use crate::dto::{RpaTaskDto, RpaTaskEnvironmentDto, RpaTaskRunDto, RpaTaskStepDto}; + +// ============ RPA Tasks ============ + +/// 创建 RPA 任务 +pub async fn insert_rpa_task( + pool: &Pool, + user_uuid: Uuid, + team_uuid: Option, + name: &str, + description: Option<&str>, + tags: Option<&serde_json::Value>, + trigger_type: &str, + schedule: Option<&str>, + cron_expression: Option<&str>, + run_mode: &str, + retry_count: Option, + retry_interval: Option, + timeout: Option, + concurrency: Option, + stop_on_error: Option, + notify_on_complete: Option, + notify_on_error: Option, +) -> Result { + let uuid: Uuid = sqlx::query_scalar( + r#" + INSERT INTO rpa_tasks (user_uuid, team_uuid, name, description, tags, trigger_type, + schedule, cron_expression, run_mode, retry_count, retry_interval, + timeout, concurrency, stop_on_error, notify_on_complete, notify_on_error) + VALUES ($1, $2, $3, $4, $5, $6, $7, $8, $9, $10, $11, $12, $13, $14, $15, $16) + RETURNING uuid; + "#, + ) + .bind(user_uuid) + .bind(team_uuid) + .bind(name) + .bind(description) + .bind(tags) + .bind(trigger_type) + .bind(schedule) + .bind(cron_expression) + .bind(run_mode) + .bind(retry_count) + .bind(retry_interval) + .bind(timeout) + .bind(concurrency) + .bind(stop_on_error) + .bind(notify_on_complete) + .bind(notify_on_error) + .fetch_one(pool) + .await?; + + Ok(uuid) +} + +/// 查询 RPA 任务列表 pub async fn fetch_rpa_tasks( - pool: &Pool, + pool: &Pool, team_uuid: Option, user_uuid: Uuid, keyword: Option<&str>, @@ -69,8 +69,8 @@ pub async fn fetch_rpa_tasks( offset: i64, limit: i64, ) -> Result, Error> { - let recs = sqlx::query_as::<_, RpaTaskDto>( - r#" + let recs = sqlx::query_as::<_, RpaTaskDto>( + r#" SELECT id, uuid, user_uuid, team_uuid, name, description, tags, trigger_type, schedule, cron_expression, run_mode, retry_count, retry_interval, timeout, concurrency, stop_on_error, notify_on_complete, notify_on_error, status, @@ -101,7 +101,7 @@ pub async fn fetch_rpa_tasks( /// 查询 RPA 任务总数 pub async fn fetch_rpa_tasks_count( - pool: &Pool, + pool: &Pool, team_uuid: Option, user_uuid: Uuid, keyword: Option<&str>, @@ -125,133 +125,133 @@ pub async fn fetch_rpa_tasks_count( .bind(keyword.map(|k| format!("%{}%", k))) .fetch_one(pool) .await?; - - Ok(count) -} - -/// 根据 UUID 查询 RPA 任务 -pub async fn fetch_rpa_task_by_uuid( - pool: &Pool, - task_uuid: Uuid, -) -> Result, Error> { - let rec = sqlx::query_as::<_, RpaTaskDto>( - r#" + + Ok(count) +} + +/// 根据 UUID 查询 RPA 任务 +pub async fn fetch_rpa_task_by_uuid( + pool: &Pool, + task_uuid: Uuid, +) -> Result, Error> { + let rec = sqlx::query_as::<_, RpaTaskDto>( + r#" SELECT id, uuid, user_uuid, team_uuid, name, description, tags, trigger_type, schedule, cron_expression, run_mode, retry_count, retry_interval, timeout, concurrency, stop_on_error, notify_on_complete, notify_on_error, status, run_count, success_count, last_run_at, next_run_at, created_at, updated_at, deleted_at , (SELECT COUNT(*) FROM rpa_task_environments rte WHERE rte.task_uuid = rpa_tasks.uuid) AS environment_count FROM rpa_tasks - WHERE uuid = $1 AND deleted_at IS NULL - "#, - ) - .bind(task_uuid) - .fetch_optional(pool) - .await?; - - Ok(rec) -} - -/// 更新 RPA 任务 -pub async fn update_rpa_task( - pool: &Pool, - task_uuid: Uuid, - name: Option<&str>, - description: Option<&str>, - tags: Option<&serde_json::Value>, - trigger_type: Option<&str>, - schedule: Option<&str>, - cron_expression: Option<&str>, - run_mode: Option<&str>, - retry_count: Option, - retry_interval: Option, - timeout: Option, - concurrency: Option, - stop_on_error: Option, - notify_on_complete: Option, - notify_on_error: Option, -) -> Result<(), Error> { - sqlx::query( - r#" - UPDATE rpa_tasks - SET name = COALESCE($1, name), - description = COALESCE($2, description), - tags = COALESCE($3, tags), - trigger_type = COALESCE($4, trigger_type), - schedule = COALESCE($5, schedule), - cron_expression = COALESCE($6, cron_expression), - run_mode = COALESCE($7, run_mode), - retry_count = COALESCE($8, retry_count), - retry_interval = COALESCE($9, retry_interval), - timeout = COALESCE($10, timeout), - concurrency = COALESCE($11, concurrency), - stop_on_error = COALESCE($12, stop_on_error), - notify_on_complete = COALESCE($13, notify_on_complete), - notify_on_error = COALESCE($14, notify_on_error), - updated_at = CURRENT_TIMESTAMP - WHERE uuid = $15 AND deleted_at IS NULL - "#, - ) - .bind(name) - .bind(description) - .bind(tags) - .bind(trigger_type) - .bind(schedule) - .bind(cron_expression) - .bind(run_mode) - .bind(retry_count) - .bind(retry_interval) - .bind(timeout) - .bind(concurrency) - .bind(stop_on_error) - .bind(notify_on_complete) - .bind(notify_on_error) - .bind(task_uuid) - .execute(pool) - .await?; - - Ok(()) -} - -/// 更新任务状态 -pub async fn update_rpa_task_status( - pool: &Pool, - task_uuid: Uuid, - status: &str, -) -> Result<(), Error> { - sqlx::query( - r#" - UPDATE rpa_tasks - SET status = $1, updated_at = CURRENT_TIMESTAMP - WHERE uuid = $2 AND deleted_at IS NULL - "#, - ) - .bind(status) - .bind(task_uuid) - .execute(pool) - .await?; - - Ok(()) -} - -/// 软删除 RPA 任务 -pub async fn delete_rpa_task(pool: &Pool, task_uuid: Uuid) -> Result<(), Error> { - sqlx::query( - r#" - UPDATE rpa_tasks SET deleted_at = CURRENT_TIMESTAMP - WHERE uuid = $1 AND deleted_at IS NULL - "#, - ) - .bind(task_uuid) - .execute(pool) - .await?; - - Ok(()) -} - -/// 批量软删除 RPA 任务 + WHERE uuid = $1 AND deleted_at IS NULL + "#, + ) + .bind(task_uuid) + .fetch_optional(pool) + .await?; + + Ok(rec) +} + +/// 更新 RPA 任务 +pub async fn update_rpa_task( + pool: &Pool, + task_uuid: Uuid, + name: Option<&str>, + description: Option<&str>, + tags: Option<&serde_json::Value>, + trigger_type: Option<&str>, + schedule: Option<&str>, + cron_expression: Option<&str>, + run_mode: Option<&str>, + retry_count: Option, + retry_interval: Option, + timeout: Option, + concurrency: Option, + stop_on_error: Option, + notify_on_complete: Option, + notify_on_error: Option, +) -> Result<(), Error> { + sqlx::query( + r#" + UPDATE rpa_tasks + SET name = COALESCE($1, name), + description = COALESCE($2, description), + tags = COALESCE($3, tags), + trigger_type = COALESCE($4, trigger_type), + schedule = COALESCE($5, schedule), + cron_expression = COALESCE($6, cron_expression), + run_mode = COALESCE($7, run_mode), + retry_count = COALESCE($8, retry_count), + retry_interval = COALESCE($9, retry_interval), + timeout = COALESCE($10, timeout), + concurrency = COALESCE($11, concurrency), + stop_on_error = COALESCE($12, stop_on_error), + notify_on_complete = COALESCE($13, notify_on_complete), + notify_on_error = COALESCE($14, notify_on_error), + updated_at = CURRENT_TIMESTAMP + WHERE uuid = $15 AND deleted_at IS NULL + "#, + ) + .bind(name) + .bind(description) + .bind(tags) + .bind(trigger_type) + .bind(schedule) + .bind(cron_expression) + .bind(run_mode) + .bind(retry_count) + .bind(retry_interval) + .bind(timeout) + .bind(concurrency) + .bind(stop_on_error) + .bind(notify_on_complete) + .bind(notify_on_error) + .bind(task_uuid) + .execute(pool) + .await?; + + Ok(()) +} + +/// 更新任务状态 +pub async fn update_rpa_task_status( + pool: &Pool, + task_uuid: Uuid, + status: &str, +) -> Result<(), Error> { + sqlx::query( + r#" + UPDATE rpa_tasks + SET status = $1, updated_at = CURRENT_TIMESTAMP + WHERE uuid = $2 AND deleted_at IS NULL + "#, + ) + .bind(status) + .bind(task_uuid) + .execute(pool) + .await?; + + Ok(()) +} + +/// 软删除 RPA 任务 +pub async fn delete_rpa_task(pool: &Pool, task_uuid: Uuid) -> Result<(), Error> { + sqlx::query( + r#" + UPDATE rpa_tasks SET deleted_at = CURRENT_TIMESTAMP + WHERE uuid = $1 AND deleted_at IS NULL + "#, + ) + .bind(task_uuid) + .execute(pool) + .await?; + + Ok(()) +} + +/// 批量软删除 RPA 任务 pub async fn batch_delete_rpa_tasks( - pool: &Pool, + pool: &Pool, task_uuids: &[Uuid], ) -> Result { if task_uuids.is_empty() { @@ -270,281 +270,279 @@ pub async fn batch_delete_rpa_tasks( query = query.bind(uuid); } let result = query.execute(pool).await?; - - Ok(result.rows_affected()) -} - -// ============ RPA Task Steps ============ - -/// 插入任务步骤 -pub async fn insert_rpa_task_step( - pool: &Pool, - task_uuid: Uuid, - step_type: &str, - name: &str, - config: &serde_json::Value, - enabled: Option, - position_x: Option, - position_y: Option, - sort_order: Option, - next_step_uuid: Option, - branch_config: Option<&serde_json::Value>, -) -> Result { - let uuid: Uuid = sqlx::query_scalar( - r#" - INSERT INTO rpa_task_steps (task_uuid, step_type, name, config, enabled, - position_x, position_y, sort_order) - VALUES ($1, $2, $3, $4, $5, $6, $7, $8) - RETURNING uuid; - "#, - ) - .bind(task_uuid) - .bind(step_type) - .bind(name) - .bind(config) - .bind(enabled.unwrap_or(true)) - .bind(position_x) - .bind(position_y) - .bind(sort_order) - .bind(next_step_uuid) - .bind(branch_config) - .fetch_one(pool) - .await?; - - Ok(uuid) -} - -/// 查询任务步骤列表 -pub async fn fetch_rpa_task_steps( - pool: &Pool, - task_uuid: Uuid, -) -> Result, Error> { - let recs = sqlx::query_as::<_, RpaTaskStepDto>( - r#" - SELECT id, uuid, task_uuid, step_type, name, config, enabled, - position_x, position_y, sort_order, next_step_uuid, branch_config, - created_at, updated_at - FROM rpa_task_steps - WHERE task_uuid = $1 - ORDER BY sort_order ASC, id ASC - "#, - ) - .bind(task_uuid) - .fetch_all(pool) - .await?; - - Ok(recs) -} - -/// 删除任务所有步骤 -pub async fn delete_rpa_task_steps(pool: &Pool, task_uuid: Uuid) -> Result<(), Error> { - sqlx::query("DELETE FROM rpa_task_steps WHERE task_uuid = $1") - .bind(task_uuid) - .execute(pool) - .await?; - - Ok(()) -} - -// ============ RPA Task Environments ============ - -/// 添加任务环境关联 -pub async fn insert_rpa_task_environment( - pool: &Pool, - task_uuid: Uuid, - environment_uuid: Uuid, - sort_order: Option, -) -> Result { - let id: i32 = sqlx::query_scalar( - r#" - INSERT INTO rpa_task_environments (task_uuid, environment_uuid, sort_order) - VALUES ($1, $2, $3) - RETURNING id; - "#, - ) - .bind(task_uuid) - .bind(environment_uuid) - .bind(sort_order) - .fetch_one(pool) - .await?; - - Ok(id) -} - -/// 查询任务环境关联 -pub async fn fetch_rpa_task_environments( - pool: &Pool, - task_uuid: Uuid, -) -> Result, Error> { - let recs = sqlx::query_as::<_, RpaTaskEnvironmentDto>( - r#" - SELECT id, task_uuid, environment_uuid, sort_order, created_at - FROM rpa_task_environments - WHERE task_uuid = $1 - ORDER BY sort_order ASC, id ASC - "#, - ) - .bind(task_uuid) - .fetch_all(pool) - .await?; - - Ok(recs) -} - -/// 删除任务所有环境关联 -pub async fn delete_rpa_task_environments( - pool: &Pool, - task_uuid: Uuid, -) -> Result<(), Error> { - sqlx::query("DELETE FROM rpa_task_environments WHERE task_uuid = $1") - .bind(task_uuid) - .execute(pool) - .await?; - - Ok(()) -} - -// ============ RPA Task Runs ============ - -/// 创建任务执行记录 -pub async fn insert_rpa_task_run( - pool: &Pool, - task_uuid: Uuid, - total_steps: i32, -) -> Result { - let uuid: Uuid = sqlx::query_scalar( - r#" - INSERT INTO rpa_task_runs (task_uuid, status, total_steps, completed_steps, failed_steps) - VALUES ($1, 'running', $2, 0, 0) - RETURNING uuid; - "#, - ) - .bind(task_uuid) - .bind(total_steps) - .fetch_one(pool) - .await?; - - // 更新任务最后运行时间和运行次数 - sqlx::query( - r#" - UPDATE rpa_tasks - SET last_run_at = CURRENT_TIMESTAMP, - run_count = COALESCE(run_count, 0) + 1 - WHERE uuid = $1 - "#, - ) - .bind(task_uuid) - .execute(pool) - .await?; - - Ok(uuid) -} - -/// 查询任务执行记录列表 -pub async fn fetch_rpa_task_runs( - pool: &Pool, - task_uuid: Uuid, - status: Option<&str>, - offset: i64, - limit: i64, -) -> Result, Error> { - let recs = sqlx::query_as::<_, RpaTaskRunDto>( - r#" - SELECT id, uuid, task_uuid, status, total_steps, completed_steps, failed_steps, - started_at, finished_at, duration_ms, result_summary, error_message, logs - FROM rpa_task_runs - WHERE task_uuid = $1 + + Ok(result.rows_affected()) +} + +// ============ RPA Task Steps ============ + +/// 插入任务步骤 +pub async fn insert_rpa_task_step( + pool: &Pool, + task_uuid: Uuid, + step_type: &str, + name: &str, + config: &serde_json::Value, + enabled: Option, + position_x: Option, + position_y: Option, + sort_order: Option, + next_step_uuid: Option, + branch_config: Option<&serde_json::Value>, +) -> Result { + let uuid: Uuid = sqlx::query_scalar( + r#" + INSERT INTO rpa_task_steps (task_uuid, step_type, name, config, enabled, + position_x, position_y, sort_order) + VALUES ($1, $2, $3, $4, $5, $6, $7, $8) + RETURNING uuid; + "#, + ) + .bind(task_uuid) + .bind(step_type) + .bind(name) + .bind(config) + .bind(enabled.unwrap_or(true)) + .bind(position_x) + .bind(position_y) + .bind(sort_order) + .bind(next_step_uuid) + .bind(branch_config) + .fetch_one(pool) + .await?; + + Ok(uuid) +} + +/// 查询任务步骤列表 +pub async fn fetch_rpa_task_steps( + pool: &Pool, + task_uuid: Uuid, +) -> Result, Error> { + let recs = sqlx::query_as::<_, RpaTaskStepDto>( + r#" + SELECT id, uuid, task_uuid, step_type, name, config, enabled, + position_x, position_y, sort_order, next_step_uuid, branch_config, + created_at, updated_at + FROM rpa_task_steps + WHERE task_uuid = $1 + ORDER BY sort_order ASC, id ASC + "#, + ) + .bind(task_uuid) + .fetch_all(pool) + .await?; + + Ok(recs) +} + +/// 删除任务所有步骤 +pub async fn delete_rpa_task_steps(pool: &Pool, task_uuid: Uuid) -> Result<(), Error> { + sqlx::query("DELETE FROM rpa_task_steps WHERE task_uuid = $1") + .bind(task_uuid) + .execute(pool) + .await?; + + Ok(()) +} + +// ============ RPA Task Environments ============ + +/// 添加任务环境关联 +pub async fn insert_rpa_task_environment( + pool: &Pool, + task_uuid: Uuid, + environment_uuid: Uuid, + sort_order: Option, +) -> Result { + let id: i32 = sqlx::query_scalar( + r#" + INSERT INTO rpa_task_environments (task_uuid, environment_uuid, sort_order) + VALUES ($1, $2, $3) + RETURNING id; + "#, + ) + .bind(task_uuid) + .bind(environment_uuid) + .bind(sort_order) + .fetch_one(pool) + .await?; + + Ok(id) +} + +/// 查询任务环境关联 +pub async fn fetch_rpa_task_environments( + pool: &Pool, + task_uuid: Uuid, +) -> Result, Error> { + let recs = sqlx::query_as::<_, RpaTaskEnvironmentDto>( + r#" + SELECT id, task_uuid, environment_uuid, sort_order, created_at + FROM rpa_task_environments + WHERE task_uuid = $1 + ORDER BY sort_order ASC, id ASC + "#, + ) + .bind(task_uuid) + .fetch_all(pool) + .await?; + + Ok(recs) +} + +/// 删除任务所有环境关联 +pub async fn delete_rpa_task_environments( + pool: &Pool, + task_uuid: Uuid, +) -> Result<(), Error> { + sqlx::query("DELETE FROM rpa_task_environments WHERE task_uuid = $1") + .bind(task_uuid) + .execute(pool) + .await?; + + Ok(()) +} + +// ============ RPA Task Runs ============ + +/// 创建任务执行记录 +pub async fn insert_rpa_task_run( + pool: &Pool, + task_uuid: Uuid, + total_steps: i32, +) -> Result { + let uuid: Uuid = sqlx::query_scalar( + r#" + INSERT INTO rpa_task_runs (task_uuid, status, total_steps, completed_steps, failed_steps) + VALUES ($1, 'running', $2, 0, 0) + RETURNING uuid; + "#, + ) + .bind(task_uuid) + .bind(total_steps) + .fetch_one(pool) + .await?; + + // 更新任务最后运行时间和运行次数 + sqlx::query( + r#" + UPDATE rpa_tasks + SET last_run_at = CURRENT_TIMESTAMP, + run_count = COALESCE(run_count, 0) + 1 + WHERE uuid = $1 + "#, + ) + .bind(task_uuid) + .execute(pool) + .await?; + + Ok(uuid) +} + +/// 查询任务执行记录列表 +pub async fn fetch_rpa_task_runs( + pool: &Pool, + task_uuid: Uuid, + status: Option<&str>, + offset: i64, + limit: i64, +) -> Result, Error> { + let recs = sqlx::query_as::<_, RpaTaskRunDto>( + r#" + SELECT id, uuid, task_uuid, status, total_steps, completed_steps, failed_steps, + started_at, finished_at, duration_ms, result_summary, error_message, logs + FROM rpa_task_runs + WHERE task_uuid = $1 AND ($2 IS NULL OR status = $2) - ORDER BY started_at DESC - LIMIT $3 OFFSET $4 - "#, - ) - .bind(task_uuid) - .bind(status) - .bind(limit) - .bind(offset) - .fetch_all(pool) - .await?; - - Ok(recs) -} - -/// 查询任务执行记录总数 -pub async fn fetch_rpa_task_runs_count( - pool: &Pool, - task_uuid: Uuid, - status: Option<&str>, -) -> Result { - let count: i64 = sqlx::query_scalar( - r#" - SELECT COUNT(*) FROM rpa_task_runs - WHERE task_uuid = $1 + ORDER BY started_at DESC + LIMIT $3 OFFSET $4 + "#, + ) + .bind(task_uuid) + .bind(status) + .bind(limit) + .bind(offset) + .fetch_all(pool) + .await?; + + Ok(recs) +} + +/// 查询任务执行记录总数 +pub async fn fetch_rpa_task_runs_count( + pool: &Pool, + task_uuid: Uuid, + status: Option<&str>, +) -> Result { + let count: i64 = sqlx::query_scalar( + r#" + SELECT COUNT(*) FROM rpa_task_runs + WHERE task_uuid = $1 AND ($2 IS NULL OR status = $2) - "#, - ) - .bind(task_uuid) - .bind(status) - .fetch_one(pool) - .await?; - - Ok(count) -} - -/// 根据 UUID 查询执行记录 -pub async fn fetch_rpa_task_run_by_uuid( - pool: &Pool, - run_uuid: Uuid, -) -> Result, Error> { - let rec = sqlx::query_as::<_, RpaTaskRunDto>( - r#" - SELECT id, uuid, task_uuid, status, total_steps, completed_steps, failed_steps, - started_at, finished_at, duration_ms, result_summary, error_message, logs - FROM rpa_task_runs - WHERE uuid = $1 - "#, - ) - .bind(run_uuid) - .fetch_optional(pool) - .await?; - - Ok(rec) -} - -/// 更新执行记录状态 -pub async fn update_rpa_task_run_status( - pool: &Pool, - run_uuid: Uuid, - status: &str, - completed_steps: i32, - failed_steps: i32, - result_summary: Option<&str>, - error_message: Option<&str>, -) -> Result<(), Error> { - sqlx::query( - r#" - UPDATE rpa_task_runs - SET status = $1, - completed_steps = $2, - failed_steps = $3, - result_summary = $4, - error_message = $5, - finished_at = CASE WHEN $1 IN ('completed', 'failed', 'stopped') THEN CURRENT_TIMESTAMP ELSE finished_at END, - duration_ms = CASE WHEN $1 IN ('completed', 'failed', 'stopped') + "#, + ) + .bind(task_uuid) + .bind(status) + .fetch_one(pool) + .await?; + + Ok(count) +} + +/// 根据 UUID 查询执行记录 +pub async fn fetch_rpa_task_run_by_uuid( + pool: &Pool, + run_uuid: Uuid, +) -> Result, Error> { + let rec = sqlx::query_as::<_, RpaTaskRunDto>( + r#" + SELECT id, uuid, task_uuid, status, total_steps, completed_steps, failed_steps, + started_at, finished_at, duration_ms, result_summary, error_message, logs + FROM rpa_task_runs + WHERE uuid = $1 + "#, + ) + .bind(run_uuid) + .fetch_optional(pool) + .await?; + + Ok(rec) +} + +/// 更新执行记录状态 +pub async fn update_rpa_task_run_status( + pool: &Pool, + run_uuid: Uuid, + status: &str, + completed_steps: i32, + failed_steps: i32, + result_summary: Option<&str>, + error_message: Option<&str>, +) -> Result<(), Error> { + sqlx::query( + r#" + UPDATE rpa_task_runs + SET status = $1, + completed_steps = $2, + failed_steps = $3, + result_summary = $4, + error_message = $5, + finished_at = CASE WHEN $1 IN ('completed', 'failed', 'stopped') THEN CURRENT_TIMESTAMP ELSE finished_at END, + duration_ms = CASE WHEN $1 IN ('completed', 'failed', 'stopped') THEN (julianday(CURRENT_TIMESTAMP) - julianday(started_at)) * 86400000 - ELSE duration_ms END - WHERE uuid = $6 - "#, - ) - .bind(status) - .bind(completed_steps) - .bind(failed_steps) - .bind(result_summary) - .bind(error_message) - .bind(run_uuid) - .execute(pool) - .await?; - - Ok(()) -} - - + ELSE duration_ms END + WHERE uuid = $6 + "#, + ) + .bind(status) + .bind(completed_steps) + .bind(failed_steps) + .bind(result_summary) + .bind(error_message) + .bind(run_uuid) + .execute(pool) + .await?; + + Ok(()) +} diff --git a/src-tauri/crates/business/src/models/teams.rs b/src-tauri/crates/business/src/models/teams.rs index 7cf2cca..d21d161 100644 --- a/src-tauri/crates/business/src/models/teams.rs +++ b/src-tauri/crates/business/src/models/teams.rs @@ -1,837 +1,837 @@ -use sqlx::Error; -use uuid::Uuid; - -use crate::database::{Db as Postgres, Pool}; - -use crate::dto::{LoginHistoryDto, TeamDto, TeamInvitationDto, TeamMemberDto}; -use crate::entitys::CreateTeamRequest; - -// ============ Teams ============ - -/// 创建团队 -pub async fn insert_team( - pool: &Pool, - owner_uuid: Uuid, - payload: &CreateTeamRequest, -) -> Result { - let mut tx = pool.begin().await?; - - // 1. 创建团队 - let team_uuid: Uuid = sqlx::query_scalar( - r#" - INSERT INTO teams (workspace_uuid, name, description, owner_uuid) - VALUES ($1, $2, $3, $4) - RETURNING uuid; - "#, - ) - .bind(payload.workspace_uuid) - .bind(&payload.name) - .bind(&payload.description) - .bind(owner_uuid) - .fetch_one(&mut *tx) - .await?; - - // 2. 添加所有者为成员 - sqlx::query( - r#" - INSERT INTO team_members (team_uuid, workspace_uuid, user_uuid, role, status) - VALUES ($1, $2, $3, 'owner', 'active'); - "#, - ) - .bind(team_uuid) - .bind(payload.workspace_uuid) - .bind(owner_uuid) - .execute(&mut *tx) - .await?; - - // 3. 设置为用户当前团队 - sqlx::query( - r#" - UPDATE user_infos SET current_team_uuid = $1 WHERE user_uuid = $2; - "#, - ) - .bind(team_uuid) - .bind(owner_uuid) - .execute(&mut *tx) - .await?; - - tx.commit().await?; - - Ok(team_uuid) -} - -/// 根据 UUID 查询团队 -pub async fn fetch_team_by_uuid( - pool: &Pool, - team_uuid: Uuid, -) -> Result, Error> { - let rec = sqlx::query_as::<_, TeamDto>( - r#" - SELECT id, uuid, workspace_uuid, name, description, owner_uuid, avatar_hash, - status, created_at, updated_at, deleted_at - FROM teams - WHERE uuid = $1 AND deleted_at IS NULL - "#, - ) - .bind(team_uuid) - .fetch_optional(pool) - .await?; - - Ok(rec) -} - -/// 查询用户在工作空间中所属的所有团队(工作空间级别) -pub async fn fetch_user_teams( - pool: &Pool, - workspace_uuid: Uuid, - user_uuid: Uuid, -) -> Result, Error> { - let recs = sqlx::query_as::<_, TeamDto>( - r#" - SELECT t.id, t.uuid, t.workspace_uuid, t.name, t.description, t.owner_uuid, t.avatar_hash, - t.status, t.created_at, t.updated_at, t.deleted_at - FROM teams t - INNER JOIN team_members tm ON t.uuid = tm.team_uuid - WHERE tm.workspace_uuid = $1 AND tm.user_uuid = $2 AND t.deleted_at IS NULL AND tm.deleted_at IS NULL - ORDER BY t.created_at - "#, - ) - .bind(workspace_uuid) - .bind(user_uuid) - .fetch_all(pool) - .await?; - - Ok(recs) -} - -/// 查询用户当前团队 -pub async fn fetch_user_current_team( - pool: &Pool, - user_uuid: Uuid, -) -> Result, Error> { - let rec: Option = sqlx::query_scalar( - r#" - SELECT current_team_uuid FROM user_infos - WHERE user_uuid = $1 - "#, - ) - .bind(user_uuid) - .fetch_optional(pool) - .await?; - - Ok(rec) -} - -/// 设置用户当前团队 -pub async fn set_user_current_team( - pool: &Pool, - user_uuid: Uuid, - team_uuid: Uuid, -) -> Result<(), Error> { - sqlx::query( - r#" - UPDATE user_infos SET current_team_uuid = $1 WHERE user_uuid = $2 - "#, - ) - .bind(team_uuid) - .bind(user_uuid) - .execute(pool) - .await?; - - Ok(()) -} - -/// 清除用户当前团队 -pub async fn clear_user_current_team(pool: &Pool, user_uuid: Uuid) -> Result<(), Error> { - sqlx::query( - r#" - UPDATE user_infos SET current_team_uuid = NULL WHERE user_uuid = $1 - "#, - ) - .bind(user_uuid) - .execute(pool) - .await?; - - Ok(()) -} - -/// 更新团队信息 -pub async fn update_team( - pool: &Pool, - team_uuid: Uuid, - name: Option<&str>, - description: Option<&str>, - avatar_hash: Option<&str>, -) -> Result<(), Error> { +use sqlx::Error; +use uuid::Uuid; + +use crate::database::{Db, Pool}; + +use crate::dto::{LoginHistoryDto, TeamDto, TeamInvitationDto, TeamMemberDto}; +use crate::entitys::CreateTeamRequest; + +// ============ Teams ============ + +/// 创建团队 +pub async fn insert_team( + pool: &Pool, + owner_uuid: Uuid, + payload: &CreateTeamRequest, +) -> Result { + let mut tx = pool.begin().await?; + + // 1. 创建团队 + let team_uuid: Uuid = sqlx::query_scalar( + r#" + INSERT INTO teams (workspace_uuid, name, description, owner_uuid) + VALUES ($1, $2, $3, $4) + RETURNING uuid; + "#, + ) + .bind(payload.workspace_uuid) + .bind(&payload.name) + .bind(&payload.description) + .bind(owner_uuid) + .fetch_one(&mut *tx) + .await?; + + // 2. 添加所有者为成员 + sqlx::query( + r#" + INSERT INTO team_members (team_uuid, workspace_uuid, user_uuid, role, status) + VALUES ($1, $2, $3, 'owner', 'active'); + "#, + ) + .bind(team_uuid) + .bind(payload.workspace_uuid) + .bind(owner_uuid) + .execute(&mut *tx) + .await?; + + // 3. 设置为用户当前团队 + sqlx::query( + r#" + UPDATE user_infos SET current_team_uuid = $1 WHERE user_uuid = $2; + "#, + ) + .bind(team_uuid) + .bind(owner_uuid) + .execute(&mut *tx) + .await?; + + tx.commit().await?; + + Ok(team_uuid) +} + +/// 根据 UUID 查询团队 +pub async fn fetch_team_by_uuid( + pool: &Pool, + team_uuid: Uuid, +) -> Result, Error> { + let rec = sqlx::query_as::<_, TeamDto>( + r#" + SELECT id, uuid, workspace_uuid, name, description, owner_uuid, avatar_hash, + status, created_at, updated_at, deleted_at + FROM teams + WHERE uuid = $1 AND deleted_at IS NULL + "#, + ) + .bind(team_uuid) + .fetch_optional(pool) + .await?; + + Ok(rec) +} + +/// 查询用户在工作空间中所属的所有团队(工作空间级别) +pub async fn fetch_user_teams( + pool: &Pool, + workspace_uuid: Uuid, + user_uuid: Uuid, +) -> Result, Error> { + let recs = sqlx::query_as::<_, TeamDto>( + r#" + SELECT t.id, t.uuid, t.workspace_uuid, t.name, t.description, t.owner_uuid, t.avatar_hash, + t.status, t.created_at, t.updated_at, t.deleted_at + FROM teams t + INNER JOIN team_members tm ON t.uuid = tm.team_uuid + WHERE tm.workspace_uuid = $1 AND tm.user_uuid = $2 AND t.deleted_at IS NULL AND tm.deleted_at IS NULL + ORDER BY t.created_at + "#, + ) + .bind(workspace_uuid) + .bind(user_uuid) + .fetch_all(pool) + .await?; + + Ok(recs) +} + +/// 查询用户当前团队 +pub async fn fetch_user_current_team( + pool: &Pool, + user_uuid: Uuid, +) -> Result, Error> { + let rec: Option = sqlx::query_scalar( + r#" + SELECT current_team_uuid FROM user_infos + WHERE user_uuid = $1 + "#, + ) + .bind(user_uuid) + .fetch_optional(pool) + .await?; + + Ok(rec) +} + +/// 设置用户当前团队 +pub async fn set_user_current_team( + pool: &Pool, + user_uuid: Uuid, + team_uuid: Uuid, +) -> Result<(), Error> { + sqlx::query( + r#" + UPDATE user_infos SET current_team_uuid = $1 WHERE user_uuid = $2 + "#, + ) + .bind(team_uuid) + .bind(user_uuid) + .execute(pool) + .await?; + + Ok(()) +} + +/// 清除用户当前团队 +pub async fn clear_user_current_team(pool: &Pool, user_uuid: Uuid) -> Result<(), Error> { + sqlx::query( + r#" + UPDATE user_infos SET current_team_uuid = NULL WHERE user_uuid = $1 + "#, + ) + .bind(user_uuid) + .execute(pool) + .await?; + + Ok(()) +} + +/// 更新团队信息 +pub async fn update_team( + pool: &Pool, + team_uuid: Uuid, + name: Option<&str>, + description: Option<&str>, + avatar_hash: Option<&str>, +) -> Result<(), Error> { let mut query = String::from("UPDATE teams SET updated_at = CURRENT_TIMESTAMP"); - let mut params: Vec + Send + Sync>> = vec![]; - - if let Some(n) = name { - query.push_str(", name = $"); - params.push(Box::new(n)); - query.push_str(&format!("{}", params.len())); - } - - if let Some(d) = description { - query.push_str(", description = $"); - params.push(Box::new(d)); - query.push_str(&format!("{}", params.len())); - } - - if let Some(a) = avatar_hash { - query.push_str(", avatar_hash = $"); - params.push(Box::new(a)); - query.push_str(&format!("{}", params.len())); - } - - query.push_str(" WHERE uuid = $"); - params.push(Box::new(team_uuid)); - query.push_str(&format!("{}", params.len())); - - // 这里需要动态构建查询,但 sqlx 不支持动态查询,所以使用条件分支 - if name.is_some() - && description.is_some() - && avatar_hash.is_some() - { - sqlx::query( - r#" - UPDATE teams + let mut params: Vec + Send + Sync>> = vec![]; + + if let Some(n) = name { + query.push_str(", name = $"); + params.push(Box::new(n)); + query.push_str(&format!("{}", params.len())); + } + + if let Some(d) = description { + query.push_str(", description = $"); + params.push(Box::new(d)); + query.push_str(&format!("{}", params.len())); + } + + if let Some(a) = avatar_hash { + query.push_str(", avatar_hash = $"); + params.push(Box::new(a)); + query.push_str(&format!("{}", params.len())); + } + + query.push_str(" WHERE uuid = $"); + params.push(Box::new(team_uuid)); + query.push_str(&format!("{}", params.len())); + + // 这里需要动态构建查询,但 sqlx 不支持动态查询,所以使用条件分支 + if name.is_some() + && description.is_some() + && avatar_hash.is_some() + { + sqlx::query( + r#" + UPDATE teams SET name = $1, description = $2, avatar_hash = $3, updated_at = CURRENT_TIMESTAMP - WHERE uuid = $4 - "#, - ) - .bind(name.unwrap()) - .bind(description.unwrap()) - .bind(avatar_hash.unwrap()) - .bind(team_uuid) - .execute(pool) - .await?; - } else if name.is_some() && description.is_some() { - sqlx::query( - r#" - UPDATE teams + WHERE uuid = $4 + "#, + ) + .bind(name.unwrap()) + .bind(description.unwrap()) + .bind(avatar_hash.unwrap()) + .bind(team_uuid) + .execute(pool) + .await?; + } else if name.is_some() && description.is_some() { + sqlx::query( + r#" + UPDATE teams SET name = $1, description = $2, updated_at = CURRENT_TIMESTAMP - WHERE uuid = $3 - "#, - ) - .bind(name.unwrap()) - .bind(description.unwrap()) - .bind(team_uuid) - .execute(pool) - .await?; - } else if name.is_some() { - sqlx::query( - r#" - UPDATE teams + WHERE uuid = $3 + "#, + ) + .bind(name.unwrap()) + .bind(description.unwrap()) + .bind(team_uuid) + .execute(pool) + .await?; + } else if name.is_some() { + sqlx::query( + r#" + UPDATE teams SET name = $1, updated_at = CURRENT_TIMESTAMP - WHERE uuid = $2 - "#, - ) - .bind(name.unwrap()) - .bind(team_uuid) - .execute(pool) - .await?; - } - - Ok(()) -} - -// ============ Team Members ============ - -/// 获取团队成员数量 -pub async fn fetch_team_member_count( - pool: &Pool, - team_uuid: Uuid, - keyword: Option<&str>, - role: Option<&str>, - status: Option<&str>, -) -> Result { - let mut query = String::from( - r#" - SELECT COUNT(*) FROM team_members tm - LEFT JOIN user_infos ui ON tm.user_uuid = ui.user_uuid - WHERE tm.team_uuid = $1 AND tm.deleted_at IS NULL - "#, - ); - - let mut param_index = 2; - - // 默认只查询 active 状态 - if status.is_some() { - query.push_str(&format!(" AND tm.status = ${}", param_index)); - param_index += 1; - } else { - query.push_str(" AND tm.status = 'active'"); - } - - if role.is_some() { - query.push_str(&format!(" AND tm.role = ${}", param_index)); - param_index += 1; - } - - if keyword.is_some() { - query.push_str(&format!( + WHERE uuid = $2 + "#, + ) + .bind(name.unwrap()) + .bind(team_uuid) + .execute(pool) + .await?; + } + + Ok(()) +} + +// ============ Team Members ============ + +/// 获取团队成员数量 +pub async fn fetch_team_member_count( + pool: &Pool, + team_uuid: Uuid, + keyword: Option<&str>, + role: Option<&str>, + status: Option<&str>, +) -> Result { + let mut query = String::from( + r#" + SELECT COUNT(*) FROM team_members tm + LEFT JOIN user_infos ui ON tm.user_uuid = ui.user_uuid + WHERE tm.team_uuid = $1 AND tm.deleted_at IS NULL + "#, + ); + + let mut param_index = 2; + + // 默认只查询 active 状态 + if status.is_some() { + query.push_str(&format!(" AND tm.status = ${}", param_index)); + param_index += 1; + } else { + query.push_str(" AND tm.status = 'active'"); + } + + if role.is_some() { + query.push_str(&format!(" AND tm.role = ${}", param_index)); + param_index += 1; + } + + if keyword.is_some() { + query.push_str(&format!( " AND (ui.nickname LIKE ${} OR ui.email LIKE ${})", - param_index, param_index - )); - } - - let mut query_builder = sqlx::query_scalar::<_, i64>(&query).bind(team_uuid); - - if let Some(s) = status { - query_builder = query_builder.bind(s); - } - - if let Some(r) = role { - query_builder = query_builder.bind(r); - } - - if let Some(k) = keyword { - let keyword_pattern = format!("%{}%", k); - query_builder = query_builder.bind(keyword_pattern); - } - - let count = query_builder.fetch_one(pool).await?; - - Ok(count) -} - -/// 查询团队成员列表(关联用户信息,支持筛选) -pub async fn fetch_team_members( - pool: &Pool, - team_uuid: Uuid, - offset: i64, - limit: i64, - keyword: Option<&str>, - role: Option<&str>, - status: Option<&str>, -) -> Result, Error> { - let mut query = String::from( - r#" - SELECT - tm.id, - tm.team_uuid, - tm.workspace_uuid, - tm.user_uuid, - tm.role, - tm.joined_at, - tm.invited_by, - tm.status, - tm.created_at, - tm.updated_at, - tm.deleted_at, - ui.nickname AS name, - ui.email AS email, - ui.avatar_hash AS avatar - FROM team_members tm - LEFT JOIN user_infos ui ON tm.user_uuid = ui.user_uuid - WHERE tm.team_uuid = $1 AND tm.deleted_at IS NULL - "#, - ); - - let mut param_index = 2; - - // 默认只查询 active 状态 - if status.is_some() { - query.push_str(&format!(" AND tm.status = ${}", param_index)); - param_index += 1; - } else { - query.push_str(" AND tm.status = 'active'"); - } - - if role.is_some() { - query.push_str(&format!(" AND tm.role = ${}", param_index)); - param_index += 1; - } - - if keyword.is_some() { - query.push_str(&format!( + param_index, param_index + )); + } + + let mut query_builder = sqlx::query_scalar::<_, i64>(&query).bind(team_uuid); + + if let Some(s) = status { + query_builder = query_builder.bind(s); + } + + if let Some(r) = role { + query_builder = query_builder.bind(r); + } + + if let Some(k) = keyword { + let keyword_pattern = format!("%{}%", k); + query_builder = query_builder.bind(keyword_pattern); + } + + let count = query_builder.fetch_one(pool).await?; + + Ok(count) +} + +/// 查询团队成员列表(关联用户信息,支持筛选) +pub async fn fetch_team_members( + pool: &Pool, + team_uuid: Uuid, + offset: i64, + limit: i64, + keyword: Option<&str>, + role: Option<&str>, + status: Option<&str>, +) -> Result, Error> { + let mut query = String::from( + r#" + SELECT + tm.id, + tm.team_uuid, + tm.workspace_uuid, + tm.user_uuid, + tm.role, + tm.joined_at, + tm.invited_by, + tm.status, + tm.created_at, + tm.updated_at, + tm.deleted_at, + ui.nickname AS name, + ui.email AS email, + ui.avatar_hash AS avatar + FROM team_members tm + LEFT JOIN user_infos ui ON tm.user_uuid = ui.user_uuid + WHERE tm.team_uuid = $1 AND tm.deleted_at IS NULL + "#, + ); + + let mut param_index = 2; + + // 默认只查询 active 状态 + if status.is_some() { + query.push_str(&format!(" AND tm.status = ${}", param_index)); + param_index += 1; + } else { + query.push_str(" AND tm.status = 'active'"); + } + + if role.is_some() { + query.push_str(&format!(" AND tm.role = ${}", param_index)); + param_index += 1; + } + + if keyword.is_some() { + query.push_str(&format!( " AND (ui.nickname LIKE ${} OR ui.email LIKE ${})", - param_index, param_index - )); - param_index += 1; - } - - query.push_str(&format!( - r#" - ORDER BY - CASE tm.role - WHEN 'owner' THEN 1 - WHEN 'admin' THEN 2 - WHEN 'editor' THEN 3 - ELSE 4 - END, - tm.joined_at - LIMIT ${} OFFSET ${} - "#, - param_index, - param_index + 1 - )); - - let mut query_builder = sqlx::query_as::<_, TeamMemberDto>(&query).bind(team_uuid); - - if let Some(s) = status { - query_builder = query_builder.bind(s); - } - - if let Some(r) = role { - query_builder = query_builder.bind(r); - } - - if let Some(k) = keyword { - let keyword_pattern = format!("%{}%", k); - query_builder = query_builder.bind(keyword_pattern); - } - - query_builder = query_builder.bind(limit).bind(offset); - - let recs = query_builder.fetch_all(pool).await?; - - Ok(recs) -} - -/// 查询用户在团队中的成员信息(工作空间级别) -pub async fn fetch_team_member( - pool: &Pool, - workspace_uuid: Uuid, - team_uuid: Uuid, - user_uuid: Uuid, -) -> Result, Error> { - let rec = sqlx::query_as::<_, TeamMemberDto>( - r#" - SELECT - tm.id, - tm.team_uuid, - tm.workspace_uuid, - tm.user_uuid, - tm.role, - tm.joined_at, - tm.invited_by, - tm.status, - tm.created_at, - tm.updated_at, - tm.deleted_at, - ui.nickname AS name, - ui.email AS email, - ui.avatar_hash AS avatar - FROM team_members tm - LEFT JOIN user_infos ui ON tm.user_uuid = ui.user_uuid - WHERE tm.workspace_uuid = $1 AND tm.team_uuid = $2 AND tm.user_uuid = $3 AND tm.deleted_at IS NULL - "#, - ) - .bind(workspace_uuid) - .bind(team_uuid) - .bind(user_uuid) - .fetch_optional(pool) - .await?; - - Ok(rec) -} - -/// 添加团队成员 -pub async fn insert_team_member( - pool: &Pool, - team_uuid: Uuid, - user_uuid: Uuid, - role: &str, - invited_by: Option, -) -> Result { - let id: i32 = sqlx::query_scalar( - r#" - INSERT INTO team_members (team_uuid, workspace_uuid, user_uuid, role, invited_by, status) - VALUES ($1, (SELECT workspace_uuid FROM teams WHERE uuid = $1), $2, $3, $4, 'active') - RETURNING id; - "#, - ) - .bind(team_uuid) - .bind(user_uuid) - .bind(role) - .bind(invited_by) - .fetch_one(pool) - .await?; - - Ok(id) -} - -/// 更新成员角色 -pub async fn update_member_role( - pool: &Pool, - team_uuid: Uuid, - user_uuid: Uuid, - role: &str, -) -> Result<(), Error> { - sqlx::query( - r#" - UPDATE team_members + param_index, param_index + )); + param_index += 1; + } + + query.push_str(&format!( + r#" + ORDER BY + CASE tm.role + WHEN 'owner' THEN 1 + WHEN 'admin' THEN 2 + WHEN 'editor' THEN 3 + ELSE 4 + END, + tm.joined_at + LIMIT ${} OFFSET ${} + "#, + param_index, + param_index + 1 + )); + + let mut query_builder = sqlx::query_as::<_, TeamMemberDto>(&query).bind(team_uuid); + + if let Some(s) = status { + query_builder = query_builder.bind(s); + } + + if let Some(r) = role { + query_builder = query_builder.bind(r); + } + + if let Some(k) = keyword { + let keyword_pattern = format!("%{}%", k); + query_builder = query_builder.bind(keyword_pattern); + } + + query_builder = query_builder.bind(limit).bind(offset); + + let recs = query_builder.fetch_all(pool).await?; + + Ok(recs) +} + +/// 查询用户在团队中的成员信息(工作空间级别) +pub async fn fetch_team_member( + pool: &Pool, + workspace_uuid: Uuid, + team_uuid: Uuid, + user_uuid: Uuid, +) -> Result, Error> { + let rec = sqlx::query_as::<_, TeamMemberDto>( + r#" + SELECT + tm.id, + tm.team_uuid, + tm.workspace_uuid, + tm.user_uuid, + tm.role, + tm.joined_at, + tm.invited_by, + tm.status, + tm.created_at, + tm.updated_at, + tm.deleted_at, + ui.nickname AS name, + ui.email AS email, + ui.avatar_hash AS avatar + FROM team_members tm + LEFT JOIN user_infos ui ON tm.user_uuid = ui.user_uuid + WHERE tm.workspace_uuid = $1 AND tm.team_uuid = $2 AND tm.user_uuid = $3 AND tm.deleted_at IS NULL + "#, + ) + .bind(workspace_uuid) + .bind(team_uuid) + .bind(user_uuid) + .fetch_optional(pool) + .await?; + + Ok(rec) +} + +/// 添加团队成员 +pub async fn insert_team_member( + pool: &Pool, + team_uuid: Uuid, + user_uuid: Uuid, + role: &str, + invited_by: Option, +) -> Result { + let id: i32 = sqlx::query_scalar( + r#" + INSERT INTO team_members (team_uuid, workspace_uuid, user_uuid, role, invited_by, status) + VALUES ($1, (SELECT workspace_uuid FROM teams WHERE uuid = $1), $2, $3, $4, 'active') + RETURNING id; + "#, + ) + .bind(team_uuid) + .bind(user_uuid) + .bind(role) + .bind(invited_by) + .fetch_one(pool) + .await?; + + Ok(id) +} + +/// 更新成员角色 +pub async fn update_member_role( + pool: &Pool, + team_uuid: Uuid, + user_uuid: Uuid, + role: &str, +) -> Result<(), Error> { + sqlx::query( + r#" + UPDATE team_members SET role = $1, updated_at = CURRENT_TIMESTAMP - WHERE team_uuid = $2 AND user_uuid = $3 AND deleted_at IS NULL - "#, - ) - .bind(role) - .bind(team_uuid) - .bind(user_uuid) - .execute(pool) - .await?; - - Ok(()) -} - -/// 更新成员状态 -pub async fn update_member_status( - pool: &Pool, - team_uuid: Uuid, - user_uuid: Uuid, - status: &str, -) -> Result<(), Error> { - sqlx::query( - r#" - UPDATE team_members - SET status = $1 - WHERE team_uuid = $2 AND user_uuid = $3 AND deleted_at IS NULL - "#, - ) - .bind(status) - .bind(team_uuid) - .bind(user_uuid) - .execute(pool) - .await?; - - Ok(()) -} - -/// 移除成员(软删除) -pub async fn remove_team_member( - pool: &Pool, - team_uuid: Uuid, - user_uuid: Uuid, -) -> Result<(), Error> { - sqlx::query( - r#" - UPDATE team_members + WHERE team_uuid = $2 AND user_uuid = $3 AND deleted_at IS NULL + "#, + ) + .bind(role) + .bind(team_uuid) + .bind(user_uuid) + .execute(pool) + .await?; + + Ok(()) +} + +/// 更新成员状态 +pub async fn update_member_status( + pool: &Pool, + team_uuid: Uuid, + user_uuid: Uuid, + status: &str, +) -> Result<(), Error> { + sqlx::query( + r#" + UPDATE team_members + SET status = $1 + WHERE team_uuid = $2 AND user_uuid = $3 AND deleted_at IS NULL + "#, + ) + .bind(status) + .bind(team_uuid) + .bind(user_uuid) + .execute(pool) + .await?; + + Ok(()) +} + +/// 移除成员(软删除) +pub async fn remove_team_member( + pool: &Pool, + team_uuid: Uuid, + user_uuid: Uuid, +) -> Result<(), Error> { + sqlx::query( + r#" + UPDATE team_members SET deleted_at = CURRENT_TIMESTAMP, status = 'inactive' - WHERE team_uuid = $1 AND user_uuid = $2 AND deleted_at IS NULL - "#, - ) - .bind(team_uuid) - .bind(user_uuid) - .execute(pool) - .await?; - - Ok(()) -} - -// ============ Team Invitations ============ - -/// 创建团队邀请 -pub async fn insert_team_invitation( - pool: &Pool, - team_uuid: Uuid, - email: &str, - role: &str, - invited_by: Uuid, - token: &str, - expires_at: chrono::DateTime, -) -> Result { - let uuid: Uuid = sqlx::query_scalar( - r#" - INSERT INTO team_invitations (team_uuid, email, role, invited_by, token, expires_at, status) - VALUES ($1, $2, $3, $4, $5, $6, 'pending') - RETURNING uuid; - "#, - ) - .bind(team_uuid) - .bind(email) - .bind(role) - .bind(invited_by) - .bind(token) - .bind(expires_at) - .fetch_one(pool) - .await?; - - Ok(uuid) -} - -/// 查询团队邀请 -pub async fn fetch_team_invitation_by_token( - pool: &Pool, - token: &str, -) -> Result, Error> { - let rec = sqlx::query_as::<_, TeamInvitationDto>( - r#" - SELECT id, uuid, team_uuid, email, role, invited_by, token, expires_at, status, accepted_at, created_at, updated_at - FROM team_invitations - WHERE token = $1 AND deleted_at IS NULL - "#, - ) - .bind(token) - .fetch_optional(pool) - .await?; - - Ok(rec) -} - -/// 查询团队的待处理邀请 -pub async fn fetch_pending_invitations( - pool: &Pool, - team_uuid: Uuid, -) -> Result, Error> { - let recs = sqlx::query_as::<_, TeamInvitationDto>( - r#" - SELECT id, uuid, team_uuid, email, role, invited_by, token, expires_at, status, accepted_at, created_at, updated_at - FROM team_invitations - WHERE team_uuid = $1 AND status = 'pending' AND deleted_at IS NULL - ORDER BY created_at DESC - "#, - ) - .bind(team_uuid) - .fetch_all(pool) - .await?; - - Ok(recs) -} - -/// 检查是否有待处理的邀请 -pub async fn has_pending_invitation( - pool: &Pool, - team_uuid: Uuid, - email: &str, -) -> Result { - let count: i64 = sqlx::query_scalar( - r#" - SELECT COUNT(*) FROM team_invitations - WHERE team_uuid = $1 AND email = $2 AND status = 'pending' AND deleted_at IS NULL - "#, - ) - .bind(team_uuid) - .bind(email) - .fetch_one(pool) - .await?; - - Ok(count > 0) -} - -/// 更新邀请状态 -pub async fn update_invitation_status( - pool: &Pool, - invitation_uuid: Uuid, - status: &str, -) -> Result<(), Error> { - sqlx::query( - r#" - UPDATE team_invitations + WHERE team_uuid = $1 AND user_uuid = $2 AND deleted_at IS NULL + "#, + ) + .bind(team_uuid) + .bind(user_uuid) + .execute(pool) + .await?; + + Ok(()) +} + +// ============ Team Invitations ============ + +/// 创建团队邀请 +pub async fn insert_team_invitation( + pool: &Pool, + team_uuid: Uuid, + email: &str, + role: &str, + invited_by: Uuid, + token: &str, + expires_at: chrono::DateTime, +) -> Result { + let uuid: Uuid = sqlx::query_scalar( + r#" + INSERT INTO team_invitations (team_uuid, email, role, invited_by, token, expires_at, status) + VALUES ($1, $2, $3, $4, $5, $6, 'pending') + RETURNING uuid; + "#, + ) + .bind(team_uuid) + .bind(email) + .bind(role) + .bind(invited_by) + .bind(token) + .bind(expires_at) + .fetch_one(pool) + .await?; + + Ok(uuid) +} + +/// 查询团队邀请 +pub async fn fetch_team_invitation_by_token( + pool: &Pool, + token: &str, +) -> Result, Error> { + let rec = sqlx::query_as::<_, TeamInvitationDto>( + r#" + SELECT id, uuid, team_uuid, email, role, invited_by, token, expires_at, status, accepted_at, created_at, updated_at + FROM team_invitations + WHERE token = $1 AND deleted_at IS NULL + "#, + ) + .bind(token) + .fetch_optional(pool) + .await?; + + Ok(rec) +} + +/// 查询团队的待处理邀请 +pub async fn fetch_pending_invitations( + pool: &Pool, + team_uuid: Uuid, +) -> Result, Error> { + let recs = sqlx::query_as::<_, TeamInvitationDto>( + r#" + SELECT id, uuid, team_uuid, email, role, invited_by, token, expires_at, status, accepted_at, created_at, updated_at + FROM team_invitations + WHERE team_uuid = $1 AND status = 'pending' AND deleted_at IS NULL + ORDER BY created_at DESC + "#, + ) + .bind(team_uuid) + .fetch_all(pool) + .await?; + + Ok(recs) +} + +/// 检查是否有待处理的邀请 +pub async fn has_pending_invitation( + pool: &Pool, + team_uuid: Uuid, + email: &str, +) -> Result { + let count: i64 = sqlx::query_scalar( + r#" + SELECT COUNT(*) FROM team_invitations + WHERE team_uuid = $1 AND email = $2 AND status = 'pending' AND deleted_at IS NULL + "#, + ) + .bind(team_uuid) + .bind(email) + .fetch_one(pool) + .await?; + + Ok(count > 0) +} + +/// 更新邀请状态 +pub async fn update_invitation_status( + pool: &Pool, + invitation_uuid: Uuid, + status: &str, +) -> Result<(), Error> { + sqlx::query( + r#" + UPDATE team_invitations SET status = $1, updated_at = CURRENT_TIMESTAMP - WHERE uuid = $2 AND deleted_at IS NULL - "#, - ) - .bind(status) - .bind(invitation_uuid) - .execute(pool) - .await?; - - Ok(()) -} - -/// 查询邀请(通过 token,别名函数) -pub async fn fetch_invitation_by_token( - pool: &Pool, - token: &str, -) -> Result, Error> { - fetch_team_invitation_by_token(pool, token).await -} - -/// 取消邀请 -pub async fn cancel_team_invitation( - pool: &Pool, - invitation_uuid: Uuid, -) -> Result<(), Error> { - sqlx::query( - r#" - UPDATE team_invitations + WHERE uuid = $2 AND deleted_at IS NULL + "#, + ) + .bind(status) + .bind(invitation_uuid) + .execute(pool) + .await?; + + Ok(()) +} + +/// 查询邀请(通过 token,别名函数) +pub async fn fetch_invitation_by_token( + pool: &Pool, + token: &str, +) -> Result, Error> { + fetch_team_invitation_by_token(pool, token).await +} + +/// 取消邀请 +pub async fn cancel_team_invitation( + pool: &Pool, + invitation_uuid: Uuid, +) -> Result<(), Error> { + sqlx::query( + r#" + UPDATE team_invitations SET status = 'cancelled', deleted_at = CURRENT_TIMESTAMP - WHERE uuid = $1 AND deleted_at IS NULL - "#, - ) - .bind(invitation_uuid) - .execute(pool) - .await?; - - Ok(()) -} - -/// 接受邀请 -pub async fn accept_team_invitation( - pool: &Pool, - invitation_uuid: Uuid, - user_uuid: Uuid, - workspace_uuid: Uuid, -) -> Result { - let mut tx = pool.begin().await?; - - // 1. 获取邀请信息 - let invitation = sqlx::query_as::<_, TeamInvitationDto>( - r#" - SELECT id, uuid, team_uuid, email, role, invited_by, token, expires_at, status, accepted_at, created_at, updated_at - FROM team_invitations - WHERE uuid = $1 AND deleted_at IS NULL - "#, - ) - .bind(invitation_uuid) - .fetch_optional(&mut *tx) - .await?; - - let invitation = invitation.ok_or_else(|| Error::RowNotFound)?; - - // 2. 检查邀请状态 - if invitation.status != "pending" { - return Err(Error::RowNotFound); - } - - // 3. 检查是否过期 - if invitation.expires_at < chrono::Utc::now() { - return Err(Error::RowNotFound); - } - - // 4. 添加成员(如果已存在则更新状态为 active) - sqlx::query( - r#" - INSERT INTO team_members (team_uuid, workspace_uuid, user_uuid, role, invited_by, status) - VALUES ($1, $2, $3, $4, $5, 'active') - ON CONFLICT (team_uuid, user_uuid, workspace_uuid) DO UPDATE SET - role = EXCLUDED.role, - invited_by = EXCLUDED.invited_by, - status = 'active', - deleted_at = NULL, + WHERE uuid = $1 AND deleted_at IS NULL + "#, + ) + .bind(invitation_uuid) + .execute(pool) + .await?; + + Ok(()) +} + +/// 接受邀请 +pub async fn accept_team_invitation( + pool: &Pool, + invitation_uuid: Uuid, + user_uuid: Uuid, + workspace_uuid: Uuid, +) -> Result { + let mut tx = pool.begin().await?; + + // 1. 获取邀请信息 + let invitation = sqlx::query_as::<_, TeamInvitationDto>( + r#" + SELECT id, uuid, team_uuid, email, role, invited_by, token, expires_at, status, accepted_at, created_at, updated_at + FROM team_invitations + WHERE uuid = $1 AND deleted_at IS NULL + "#, + ) + .bind(invitation_uuid) + .fetch_optional(&mut *tx) + .await?; + + let invitation = invitation.ok_or_else(|| Error::RowNotFound)?; + + // 2. 检查邀请状态 + if invitation.status != "pending" { + return Err(Error::RowNotFound); + } + + // 3. 检查是否过期 + if invitation.expires_at < chrono::Utc::now() { + return Err(Error::RowNotFound); + } + + // 4. 添加成员(如果已存在则更新状态为 active) + sqlx::query( + r#" + INSERT INTO team_members (team_uuid, workspace_uuid, user_uuid, role, invited_by, status) + VALUES ($1, $2, $3, $4, $5, 'active') + ON CONFLICT (team_uuid, user_uuid, workspace_uuid) DO UPDATE SET + role = EXCLUDED.role, + invited_by = EXCLUDED.invited_by, + status = 'active', + deleted_at = NULL, updated_at = CURRENT_TIMESTAMP - "#, - ) - .bind(invitation.team_uuid) - .bind(workspace_uuid) - .bind(user_uuid) - .bind(&invitation.role) - .bind(invitation.invited_by) - .execute(&mut *tx) - .await?; - - // 5. 更新邀请状态 - sqlx::query( - r#" - UPDATE team_invitations + "#, + ) + .bind(invitation.team_uuid) + .bind(workspace_uuid) + .bind(user_uuid) + .bind(&invitation.role) + .bind(invitation.invited_by) + .execute(&mut *tx) + .await?; + + // 5. 更新邀请状态 + sqlx::query( + r#" + UPDATE team_invitations SET status = 'accepted', accepted_at = CURRENT_TIMESTAMP - WHERE uuid = $1 - "#, - ) - .bind(invitation_uuid) - .execute(&mut *tx) - .await?; - - tx.commit().await?; - - Ok(invitation.team_uuid) -} - -/// 拒绝邀请 -pub async fn reject_team_invitation( - pool: &Pool, - invitation_uuid: Uuid, - _user_uuid: Uuid, -) -> Result<(), Error> { - let mut tx = pool.begin().await?; - - // 1. 获取邀请信息 - let invitation = sqlx::query_as::<_, TeamInvitationDto>( - r#" - SELECT id, uuid, team_uuid, email, role, invited_by, token, expires_at, status, accepted_at, created_at, updated_at - FROM team_invitations - WHERE uuid = $1 AND deleted_at IS NULL - "#, - ) - .bind(invitation_uuid) - .fetch_optional(&mut *tx) - .await?; - - let invitation = invitation.ok_or_else(|| Error::RowNotFound)?; - - // 2. 检查邀请状态(只有 pending 状态的邀请才能被拒绝) - if invitation.status != "pending" { - return Err(Error::RowNotFound); - } - - // 3. 检查是否过期(过期的邀请也可以标记为拒绝) - // 这里不检查过期时间,允许拒绝已过期的邀请 - - // 4. 更新邀请状态为 rejected - sqlx::query( - r#" - UPDATE team_invitations + WHERE uuid = $1 + "#, + ) + .bind(invitation_uuid) + .execute(&mut *tx) + .await?; + + tx.commit().await?; + + Ok(invitation.team_uuid) +} + +/// 拒绝邀请 +pub async fn reject_team_invitation( + pool: &Pool, + invitation_uuid: Uuid, + _user_uuid: Uuid, +) -> Result<(), Error> { + let mut tx = pool.begin().await?; + + // 1. 获取邀请信息 + let invitation = sqlx::query_as::<_, TeamInvitationDto>( + r#" + SELECT id, uuid, team_uuid, email, role, invited_by, token, expires_at, status, accepted_at, created_at, updated_at + FROM team_invitations + WHERE uuid = $1 AND deleted_at IS NULL + "#, + ) + .bind(invitation_uuid) + .fetch_optional(&mut *tx) + .await?; + + let invitation = invitation.ok_or_else(|| Error::RowNotFound)?; + + // 2. 检查邀请状态(只有 pending 状态的邀请才能被拒绝) + if invitation.status != "pending" { + return Err(Error::RowNotFound); + } + + // 3. 检查是否过期(过期的邀请也可以标记为拒绝) + // 这里不检查过期时间,允许拒绝已过期的邀请 + + // 4. 更新邀请状态为 rejected + sqlx::query( + r#" + UPDATE team_invitations SET status = 'rejected', updated_at = CURRENT_TIMESTAMP - WHERE uuid = $1 - "#, - ) - .bind(invitation_uuid) - .execute(&mut *tx) - .await?; - - tx.commit().await?; - - Ok(()) -} - -// ============ Login History ============ - -/// 记录登录历史 -pub async fn insert_login_history( - pool: &Pool, - user_uuid: Uuid, - ip_address: &str, - device_info: Option<&str>, - user_agent: Option<&str>, - location: Option<&str>, - country: Option<&str>, - city: Option<&str>, - success: bool, - failure_reason: Option<&str>, -) -> Result { - let id: i64 = sqlx::query_scalar( - r#" - INSERT INTO login_history (user_uuid, ip_address, device_info, user_agent, location, country, city, success, failure_reason) - VALUES ($1, $2, $3, $4, $5, $6, $7, $8, $9) - RETURNING id; - "#, - ) - .bind(user_uuid) - .bind(ip_address) - .bind(device_info) - .bind(user_agent) - .bind(location) - .bind(country) - .bind(city) - .bind(success) - .bind(failure_reason) - .fetch_one(pool) - .await?; - - Ok(id) -} - -/// 查询用户登录历史 -pub async fn fetch_user_login_history( - pool: &Pool, - user_uuid: Uuid, - limit: i64, -) -> Result, Error> { - let recs = sqlx::query_as::<_, LoginHistoryDto>( - r#" - SELECT id, user_uuid, ip_address, device_info, user_agent, location, country, city, success, failure_reason, created_at - FROM login_history - WHERE user_uuid = $1 - ORDER BY created_at DESC - LIMIT $2 - "#, - ) - .bind(user_uuid) - .bind(limit) - .fetch_all(pool) - .await?; - - Ok(recs) -} + WHERE uuid = $1 + "#, + ) + .bind(invitation_uuid) + .execute(&mut *tx) + .await?; + + tx.commit().await?; + + Ok(()) +} + +// ============ Login History ============ + +/// 记录登录历史 +pub async fn insert_login_history( + pool: &Pool, + user_uuid: Uuid, + ip_address: &str, + device_info: Option<&str>, + user_agent: Option<&str>, + location: Option<&str>, + country: Option<&str>, + city: Option<&str>, + success: bool, + failure_reason: Option<&str>, +) -> Result { + let id: i64 = sqlx::query_scalar( + r#" + INSERT INTO login_history (user_uuid, ip_address, device_info, user_agent, location, country, city, success, failure_reason) + VALUES ($1, $2, $3, $4, $5, $6, $7, $8, $9) + RETURNING id; + "#, + ) + .bind(user_uuid) + .bind(ip_address) + .bind(device_info) + .bind(user_agent) + .bind(location) + .bind(country) + .bind(city) + .bind(success) + .bind(failure_reason) + .fetch_one(pool) + .await?; + + Ok(id) +} + +/// 查询用户登录历史 +pub async fn fetch_user_login_history( + pool: &Pool, + user_uuid: Uuid, + limit: i64, +) -> Result, Error> { + let recs = sqlx::query_as::<_, LoginHistoryDto>( + r#" + SELECT id, user_uuid, ip_address, device_info, user_agent, location, country, city, success, failure_reason, created_at + FROM login_history + WHERE user_uuid = $1 + ORDER BY created_at DESC + LIMIT $2 + "#, + ) + .bind(user_uuid) + .bind(limit) + .fetch_all(pool) + .await?; + + Ok(recs) +} diff --git a/src-tauri/crates/business/src/models/user.rs b/src-tauri/crates/business/src/models/user.rs index 02fe30d..943e219 100644 --- a/src-tauri/crates/business/src/models/user.rs +++ b/src-tauri/crates/business/src/models/user.rs @@ -1,13 +1,13 @@ use sqlx::Error; use uuid::Uuid; -use crate::database::{Db as Postgres, Pool}; +use crate::database::{Db, Pool}; use crate::dto::{LocalApiPermissionDefinitionDto, UserDto, UserInfoDto}; use crate::entitys::{RegisterRequest, UpdateUserRequest}; /// 插入用户基础信息 -pub async fn insert_user(pool: &Pool, user_id: String) -> Result { +pub async fn insert_user(pool: &Pool, user_id: String) -> Result { let uuid: Uuid = sqlx::query_scalar("INSERT INTO users (id) VALUES ($1) RETURNING uuid;") .bind(user_id) .fetch_one(pool) @@ -18,7 +18,7 @@ pub async fn insert_user(pool: &Pool, user_id: String) -> Result, + pool: &Pool, user_uuid: Uuid, payload: &RegisterRequest, password_hash: &str, @@ -46,7 +46,7 @@ pub async fn insert_user_info( /// - user_preferences: 用户偏好设置 /// - teams: 个人团队(每个用户自动创建一个团队) pub async fn create_user_with_info( - pool: &Pool, + pool: &Pool, user_id: String, payload: &RegisterRequest, password_hash: &str, @@ -213,7 +213,7 @@ pub async fn create_user_with_info( } async fn insert_local_api_permissions( - tx: &mut sqlx::Transaction<'_, Postgres>, + tx: &mut sqlx::Transaction<'_, Db>, api_key_id: i32, definitions: &[LocalApiPermissionDefinitionDto], ) -> Result<(), Error> { @@ -247,7 +247,7 @@ fn generate_local_api_key() -> String { /// 根据 UUID 查询用户 pub async fn fetch_user_by_uuid( - pool: &Pool, + pool: &Pool, user_uuid: Uuid, ) -> Result, Error> { let rec = sqlx::query_as::<_, UserDto>( @@ -266,7 +266,7 @@ pub async fn fetch_user_by_uuid( /// 查询用户详细信息 pub async fn fetch_user_info_by_uuid( - pool: &Pool, + pool: &Pool, user_uuid: Uuid, ) -> Result, Error> { let rec = sqlx::query_as::<_, UserInfoDto>( @@ -286,7 +286,7 @@ pub async fn fetch_user_info_by_uuid( /// 根据邮箱查询用户详细信息 pub async fn fetch_user_info_by_email( - pool: &Pool, + pool: &Pool, email: &str, ) -> Result, Error> { let rec = sqlx::query_as::<_, UserInfoDto>( @@ -306,7 +306,7 @@ pub async fn fetch_user_info_by_email( /// 更新用户信息 pub async fn update_user_info( - pool: &Pool, + pool: &Pool, user_uuid: Uuid, payload: &UpdateUserRequest, ) -> Result<(), Error> { @@ -332,7 +332,7 @@ pub async fn update_user_info( /// 更新密码 pub async fn update_password( - pool: &Pool, + pool: &Pool, user_uuid: Uuid, password_hash: &str, ) -> Result<(), Error> { @@ -353,7 +353,7 @@ pub async fn update_password( /// 设置用户当前工作空间 pub async fn fetch_user_current_workspace( - pool: &Pool, + pool: &Pool, user_uuid: Uuid, ) -> Result, Error> { let rec: Option = sqlx::query_scalar( @@ -370,7 +370,7 @@ pub async fn fetch_user_current_workspace( } pub async fn set_user_current_workspace( - pool: &Pool, + pool: &Pool, user_uuid: Uuid, workspace_uuid: Uuid, ) -> Result<(), Error> { @@ -388,7 +388,7 @@ pub async fn set_user_current_workspace( } pub async fn set_user_current_workspace_and_team( - pool: &Pool, + pool: &Pool, user_uuid: Uuid, workspace_uuid: Uuid, team_uuid: Uuid, diff --git a/src-tauri/crates/business/src/models/workspace_quotas.rs b/src-tauri/crates/business/src/models/workspace_quotas.rs index 863f705..6bc2752 100644 --- a/src-tauri/crates/business/src/models/workspace_quotas.rs +++ b/src-tauri/crates/business/src/models/workspace_quotas.rs @@ -1,235 +1,235 @@ -use sqlx::Error; -use uuid::Uuid; - -use crate::database::{Db as Postgres, Pool}; - -use crate::dto::WorkspaceQuotaDto; - -/// 创建或更新工作空间配额 -pub async fn insert_or_update_workspace_quota( - pool: &Pool, - workspace_uuid: Uuid, - max_environments: i32, - max_team_members: i32, - max_proxies: i32, - max_rpa_tasks: i32, -) -> Result<(), Error> { - sqlx::query( - r#" - INSERT INTO workspace_quotas ( - workspace_uuid, max_environments, max_team_members, max_proxies, max_rpa_tasks - ) - VALUES ($1, $2, $3, $4, $5) - ON CONFLICT (workspace_uuid) DO UPDATE SET - max_environments = EXCLUDED.max_environments, - max_team_members = EXCLUDED.max_team_members, - max_proxies = EXCLUDED.max_proxies, - max_rpa_tasks = EXCLUDED.max_rpa_tasks, - updated_at = CURRENT_TIMESTAMP - "#, - ) - .bind(workspace_uuid) - .bind(max_environments) - .bind(max_team_members) - .bind(max_proxies) - .bind(max_rpa_tasks) - .execute(pool) - .await?; - - Ok(()) -} - -/// 查询工作空间配额 -pub async fn fetch_workspace_quota( - pool: &Pool, - workspace_uuid: Uuid, -) -> Result, Error> { - let rec = sqlx::query_as::<_, WorkspaceQuotaDto>( - r#" - SELECT workspace_uuid, max_environments, used_environments, - max_team_members, used_team_members, - max_proxies, used_proxies, - max_rpa_tasks, used_rpa_tasks, - created_at, updated_at - FROM workspace_quotas - WHERE workspace_uuid = $1 - "#, - ) - .bind(workspace_uuid) - .fetch_optional(pool) - .await?; - - Ok(rec) -} - -/// 增加环境使用数 -pub async fn increment_used_environments( - pool: &Pool, - workspace_uuid: Uuid, - amount: i32, -) -> Result<(), Error> { - sqlx::query( - r#" - UPDATE workspace_quotas - SET used_environments = used_environments + $1, - updated_at = CURRENT_TIMESTAMP - WHERE workspace_uuid = $2 - "#, - ) - .bind(amount) - .bind(workspace_uuid) - .execute(pool) - .await?; - - Ok(()) -} - -/// 减少环境使用数 -pub async fn decrement_used_environments( - pool: &Pool, - workspace_uuid: Uuid, - amount: i32, -) -> Result<(), Error> { - sqlx::query( - r#" - UPDATE workspace_quotas - SET used_environments = GREATEST(0, used_environments - $1), - updated_at = CURRENT_TIMESTAMP - WHERE workspace_uuid = $2 - "#, - ) - .bind(amount) - .bind(workspace_uuid) - .execute(pool) - .await?; - - Ok(()) -} - -/// 增加代理使用数 -pub async fn increment_used_proxies( - pool: &Pool, - workspace_uuid: Uuid, - amount: i32, -) -> Result<(), Error> { - sqlx::query( - r#" - UPDATE workspace_quotas - SET used_proxies = used_proxies + $1, - updated_at = CURRENT_TIMESTAMP - WHERE workspace_uuid = $2 - "#, - ) - .bind(amount) - .bind(workspace_uuid) - .execute(pool) - .await?; - - Ok(()) -} - -/// 减少代理使用数 -pub async fn decrement_used_proxies( - pool: &Pool, - workspace_uuid: Uuid, - amount: i32, -) -> Result<(), Error> { - sqlx::query( - r#" - UPDATE workspace_quotas - SET used_proxies = GREATEST(0, used_proxies - $1), - updated_at = CURRENT_TIMESTAMP - WHERE workspace_uuid = $2 - "#, - ) - .bind(amount) - .bind(workspace_uuid) - .execute(pool) - .await?; - - Ok(()) -} - -/// 更新团队成员使用数(统计所有团队的活跃成员) -pub async fn update_used_team_members( - pool: &Pool, - workspace_uuid: Uuid, -) -> Result<(), Error> { - sqlx::query( - r#" - UPDATE workspace_quotas wq - SET used_team_members = ( - SELECT COUNT(DISTINCT tm.user_uuid) - FROM team_members tm - INNER JOIN teams t ON tm.team_uuid = t.uuid - WHERE t.workspace_uuid = wq.workspace_uuid - AND tm.deleted_at IS NULL - AND t.deleted_at IS NULL - ), - updated_at = CURRENT_TIMESTAMP - WHERE wq.workspace_uuid = $1 - "#, - ) - .bind(workspace_uuid) - .execute(pool) - .await?; - - Ok(()) -} - -/// 检查配额是否充足 -pub async fn check_quota( - pool: &Pool, - workspace_uuid: Uuid, - quota_type: &str, -) -> Result { - let result = match quota_type { - "environments" => sqlx::query_scalar::<_, bool>( - r#" - SELECT used_environments < max_environments - FROM workspace_quotas - WHERE workspace_uuid = $1 - "#, - ) - .bind(workspace_uuid) - .fetch_optional(pool) - .await? - .unwrap_or(false), - "proxies" => sqlx::query_scalar::<_, bool>( - r#" - SELECT used_proxies < max_proxies - FROM workspace_quotas - WHERE workspace_uuid = $1 - "#, - ) - .bind(workspace_uuid) - .fetch_optional(pool) - .await? - .unwrap_or(false), - "team_members" => sqlx::query_scalar::<_, bool>( - r#" - SELECT used_team_members < max_team_members - FROM workspace_quotas - WHERE workspace_uuid = $1 - "#, - ) - .bind(workspace_uuid) - .fetch_optional(pool) - .await? - .unwrap_or(false), - "rpa_tasks" => sqlx::query_scalar::<_, bool>( - r#" - SELECT used_rpa_tasks < max_rpa_tasks - FROM workspace_quotas - WHERE workspace_uuid = $1 - "#, - ) - .bind(workspace_uuid) - .fetch_optional(pool) - .await? - .unwrap_or(false), - _ => false, - }; - - Ok(result) -} +use sqlx::Error; +use uuid::Uuid; + +use crate::database::{Db, Pool}; + +use crate::dto::WorkspaceQuotaDto; + +/// 创建或更新工作空间配额 +pub async fn insert_or_update_workspace_quota( + pool: &Pool, + workspace_uuid: Uuid, + max_environments: i32, + max_team_members: i32, + max_proxies: i32, + max_rpa_tasks: i32, +) -> Result<(), Error> { + sqlx::query( + r#" + INSERT INTO workspace_quotas ( + workspace_uuid, max_environments, max_team_members, max_proxies, max_rpa_tasks + ) + VALUES ($1, $2, $3, $4, $5) + ON CONFLICT (workspace_uuid) DO UPDATE SET + max_environments = EXCLUDED.max_environments, + max_team_members = EXCLUDED.max_team_members, + max_proxies = EXCLUDED.max_proxies, + max_rpa_tasks = EXCLUDED.max_rpa_tasks, + updated_at = CURRENT_TIMESTAMP + "#, + ) + .bind(workspace_uuid) + .bind(max_environments) + .bind(max_team_members) + .bind(max_proxies) + .bind(max_rpa_tasks) + .execute(pool) + .await?; + + Ok(()) +} + +/// 查询工作空间配额 +pub async fn fetch_workspace_quota( + pool: &Pool, + workspace_uuid: Uuid, +) -> Result, Error> { + let rec = sqlx::query_as::<_, WorkspaceQuotaDto>( + r#" + SELECT workspace_uuid, max_environments, used_environments, + max_team_members, used_team_members, + max_proxies, used_proxies, + max_rpa_tasks, used_rpa_tasks, + created_at, updated_at + FROM workspace_quotas + WHERE workspace_uuid = $1 + "#, + ) + .bind(workspace_uuid) + .fetch_optional(pool) + .await?; + + Ok(rec) +} + +/// 增加环境使用数 +pub async fn increment_used_environments( + pool: &Pool, + workspace_uuid: Uuid, + amount: i32, +) -> Result<(), Error> { + sqlx::query( + r#" + UPDATE workspace_quotas + SET used_environments = used_environments + $1, + updated_at = CURRENT_TIMESTAMP + WHERE workspace_uuid = $2 + "#, + ) + .bind(amount) + .bind(workspace_uuid) + .execute(pool) + .await?; + + Ok(()) +} + +/// 减少环境使用数 +pub async fn decrement_used_environments( + pool: &Pool, + workspace_uuid: Uuid, + amount: i32, +) -> Result<(), Error> { + sqlx::query( + r#" + UPDATE workspace_quotas + SET used_environments = GREATEST(0, used_environments - $1), + updated_at = CURRENT_TIMESTAMP + WHERE workspace_uuid = $2 + "#, + ) + .bind(amount) + .bind(workspace_uuid) + .execute(pool) + .await?; + + Ok(()) +} + +/// 增加代理使用数 +pub async fn increment_used_proxies( + pool: &Pool, + workspace_uuid: Uuid, + amount: i32, +) -> Result<(), Error> { + sqlx::query( + r#" + UPDATE workspace_quotas + SET used_proxies = used_proxies + $1, + updated_at = CURRENT_TIMESTAMP + WHERE workspace_uuid = $2 + "#, + ) + .bind(amount) + .bind(workspace_uuid) + .execute(pool) + .await?; + + Ok(()) +} + +/// 减少代理使用数 +pub async fn decrement_used_proxies( + pool: &Pool, + workspace_uuid: Uuid, + amount: i32, +) -> Result<(), Error> { + sqlx::query( + r#" + UPDATE workspace_quotas + SET used_proxies = GREATEST(0, used_proxies - $1), + updated_at = CURRENT_TIMESTAMP + WHERE workspace_uuid = $2 + "#, + ) + .bind(amount) + .bind(workspace_uuid) + .execute(pool) + .await?; + + Ok(()) +} + +/// 更新团队成员使用数(统计所有团队的活跃成员) +pub async fn update_used_team_members( + pool: &Pool, + workspace_uuid: Uuid, +) -> Result<(), Error> { + sqlx::query( + r#" + UPDATE workspace_quotas wq + SET used_team_members = ( + SELECT COUNT(DISTINCT tm.user_uuid) + FROM team_members tm + INNER JOIN teams t ON tm.team_uuid = t.uuid + WHERE t.workspace_uuid = wq.workspace_uuid + AND tm.deleted_at IS NULL + AND t.deleted_at IS NULL + ), + updated_at = CURRENT_TIMESTAMP + WHERE wq.workspace_uuid = $1 + "#, + ) + .bind(workspace_uuid) + .execute(pool) + .await?; + + Ok(()) +} + +/// 检查配额是否充足 +pub async fn check_quota( + pool: &Pool, + workspace_uuid: Uuid, + quota_type: &str, +) -> Result { + let result = match quota_type { + "environments" => sqlx::query_scalar::<_, bool>( + r#" + SELECT used_environments < max_environments + FROM workspace_quotas + WHERE workspace_uuid = $1 + "#, + ) + .bind(workspace_uuid) + .fetch_optional(pool) + .await? + .unwrap_or(false), + "proxies" => sqlx::query_scalar::<_, bool>( + r#" + SELECT used_proxies < max_proxies + FROM workspace_quotas + WHERE workspace_uuid = $1 + "#, + ) + .bind(workspace_uuid) + .fetch_optional(pool) + .await? + .unwrap_or(false), + "team_members" => sqlx::query_scalar::<_, bool>( + r#" + SELECT used_team_members < max_team_members + FROM workspace_quotas + WHERE workspace_uuid = $1 + "#, + ) + .bind(workspace_uuid) + .fetch_optional(pool) + .await? + .unwrap_or(false), + "rpa_tasks" => sqlx::query_scalar::<_, bool>( + r#" + SELECT used_rpa_tasks < max_rpa_tasks + FROM workspace_quotas + WHERE workspace_uuid = $1 + "#, + ) + .bind(workspace_uuid) + .fetch_optional(pool) + .await? + .unwrap_or(false), + _ => false, + }; + + Ok(result) +} diff --git a/src-tauri/crates/business/src/models/workspaces.rs b/src-tauri/crates/business/src/models/workspaces.rs index 3f07957..608fe07 100644 --- a/src-tauri/crates/business/src/models/workspaces.rs +++ b/src-tauri/crates/business/src/models/workspaces.rs @@ -1,123 +1,123 @@ -use sqlx::Error; -use uuid::Uuid; - -use crate::database::{Db as Postgres, Pool}; - -use crate::dto::WorkspaceDto; -use crate::entitys::CreateWorkspaceRequest; - -/// 创建工作空间 -pub async fn insert_workspace( - pool: &Pool, - owner_uuid: Uuid, - payload: &CreateWorkspaceRequest, -) -> Result { - let workspace_uuid: Uuid = sqlx::query_scalar( - r#" - INSERT INTO workspaces (name, owner_uuid, workspace_type) - VALUES ($1, $2, $3) - RETURNING uuid; - "#, - ) - .bind(&payload.name) - .bind(owner_uuid) - .bind(payload.workspace_type.as_deref().unwrap_or("personal")) - .fetch_one(pool) - .await?; - - Ok(workspace_uuid) -} - -/// 根据 UUID 查询工作空间 -pub async fn fetch_workspace_by_uuid( - pool: &Pool, - workspace_uuid: Uuid, -) -> Result, Error> { - let rec = sqlx::query_as::<_, WorkspaceDto>( - r#" - SELECT uuid, name, owner_uuid, workspace_type, created_at, updated_at, deleted_at - FROM workspaces - WHERE uuid = $1 AND deleted_at IS NULL - "#, - ) - .bind(workspace_uuid) - .fetch_optional(pool) - .await?; - - Ok(rec) -} - -/// 查询用户所属的所有工作空间 -pub async fn fetch_user_workspaces( - pool: &Pool, - user_uuid: Uuid, -) -> Result, Error> { - let recs = sqlx::query_as::<_, WorkspaceDto>( - r#" - SELECT uuid, name, owner_uuid, workspace_type, created_at, updated_at, deleted_at - FROM workspaces - WHERE owner_uuid = $1 AND deleted_at IS NULL - ORDER BY created_at - "#, - ) - .bind(user_uuid) - .fetch_all(pool) - .await?; - - Ok(recs) -} - -/// 更新工作空间 -pub async fn update_workspace( - pool: &Pool, - workspace_uuid: Uuid, - name: Option<&str>, -) -> Result<(), Error> { - if let Some(name) = name { - sqlx::query( - r#" - UPDATE workspaces SET name = $1 WHERE uuid = $2 AND deleted_at IS NULL - "#, - ) - .bind(name) - .bind(workspace_uuid) - .execute(pool) - .await?; - } - - Ok(()) -} - -/// 删除工作空间(软删除) -pub async fn delete_workspace(pool: &Pool, workspace_uuid: Uuid) -> Result<(), Error> { - sqlx::query( - r#" - UPDATE workspaces SET deleted_at = CURRENT_TIMESTAMP WHERE uuid = $1 - "#, - ) - .bind(workspace_uuid) - .execute(pool) - .await?; - - Ok(()) -} - -/// 检查用户是否是工作空间所有者 -pub async fn check_workspace_owner( - pool: &Pool, - workspace_uuid: Uuid, - user_uuid: Uuid, -) -> Result { - let count: i64 = sqlx::query_scalar( - r#" - SELECT COUNT(*) FROM workspaces - WHERE uuid = $1 AND owner_uuid = $2 AND deleted_at IS NULL - "#, - ) - .bind(workspace_uuid) - .bind(user_uuid) - .fetch_one(pool) - .await?; - - Ok(count > 0) -} +use sqlx::Error; +use uuid::Uuid; + +use crate::database::{Db, Pool}; + +use crate::dto::WorkspaceDto; +use crate::entitys::CreateWorkspaceRequest; + +/// 创建工作空间 +pub async fn insert_workspace( + pool: &Pool, + owner_uuid: Uuid, + payload: &CreateWorkspaceRequest, +) -> Result { + let workspace_uuid: Uuid = sqlx::query_scalar( + r#" + INSERT INTO workspaces (name, owner_uuid, workspace_type) + VALUES ($1, $2, $3) + RETURNING uuid; + "#, + ) + .bind(&payload.name) + .bind(owner_uuid) + .bind(payload.workspace_type.as_deref().unwrap_or("personal")) + .fetch_one(pool) + .await?; + + Ok(workspace_uuid) +} + +/// 根据 UUID 查询工作空间 +pub async fn fetch_workspace_by_uuid( + pool: &Pool, + workspace_uuid: Uuid, +) -> Result, Error> { + let rec = sqlx::query_as::<_, WorkspaceDto>( + r#" + SELECT uuid, name, owner_uuid, workspace_type, created_at, updated_at, deleted_at + FROM workspaces + WHERE uuid = $1 AND deleted_at IS NULL + "#, + ) + .bind(workspace_uuid) + .fetch_optional(pool) + .await?; + + Ok(rec) +} + +/// 查询用户所属的所有工作空间 +pub async fn fetch_user_workspaces( + pool: &Pool, + user_uuid: Uuid, +) -> Result, Error> { + let recs = sqlx::query_as::<_, WorkspaceDto>( + r#" + SELECT uuid, name, owner_uuid, workspace_type, created_at, updated_at, deleted_at + FROM workspaces + WHERE owner_uuid = $1 AND deleted_at IS NULL + ORDER BY created_at + "#, + ) + .bind(user_uuid) + .fetch_all(pool) + .await?; + + Ok(recs) +} + +/// 更新工作空间 +pub async fn update_workspace( + pool: &Pool, + workspace_uuid: Uuid, + name: Option<&str>, +) -> Result<(), Error> { + if let Some(name) = name { + sqlx::query( + r#" + UPDATE workspaces SET name = $1 WHERE uuid = $2 AND deleted_at IS NULL + "#, + ) + .bind(name) + .bind(workspace_uuid) + .execute(pool) + .await?; + } + + Ok(()) +} + +/// 删除工作空间(软删除) +pub async fn delete_workspace(pool: &Pool, workspace_uuid: Uuid) -> Result<(), Error> { + sqlx::query( + r#" + UPDATE workspaces SET deleted_at = CURRENT_TIMESTAMP WHERE uuid = $1 + "#, + ) + .bind(workspace_uuid) + .execute(pool) + .await?; + + Ok(()) +} + +/// 检查用户是否是工作空间所有者 +pub async fn check_workspace_owner( + pool: &Pool, + workspace_uuid: Uuid, + user_uuid: Uuid, +) -> Result { + let count: i64 = sqlx::query_scalar( + r#" + SELECT COUNT(*) FROM workspaces + WHERE uuid = $1 AND owner_uuid = $2 AND deleted_at IS NULL + "#, + ) + .bind(workspace_uuid) + .bind(user_uuid) + .fetch_one(pool) + .await?; + + Ok(count > 0) +} From fdde89c6681bf6d3d49e70a747f1a7b18b941e0b Mon Sep 17 00:00:00 2001 From: lius-new Date: Wed, 12 Aug 2026 17:35:57 +0800 Subject: [PATCH 57/60] refactor: migrate business services to local-first architecture Move business APIs and authentication to the embedded local database, remove remote-only product flows, add the local browser-kernel registry, and make startup readiness deterministic. --- .../app-layout/src/components/app-sidebar.tsx | 11 +- .../src/components/free-quota-usage.tsx | 131 --- .../titlebar/default-navigation-slot.tsx | 7 - .../components/titlebar/hooks/use-messages.ts | 298 ------- .../components/titlebar/messages-dialog.tsx | 48 - .../components/titlebar/notification-menu.tsx | 55 -- .../src/components/titlebar/user-menu.tsx | 29 +- .../layouts/app-layout/src/i18n/resources.ts | 24 +- .../app-layout/src/stores/messages-store.ts | 101 --- plugins/layouts/authorization/README.md | 12 +- plugins/layouts/authorization/src/index.tsx | 8 +- .../api-ai/src/components/ApiInfoCards.tsx | 7 - plugins/pages/billing-center/manifest.json | 10 - plugins/pages/billing-center/package.json | 6 - plugins/pages/billing-center/src/api/index.ts | 138 --- .../billing-center/src/api/index.types.ts | 163 ---- .../src/components/auto-renewal-services.tsx | 60 -- .../src/components/billing-page-skeleton.tsx | 51 -- .../src/components/billing-pagination.tsx | 116 --- .../src/components/billing-table-skeleton.tsx | 46 - .../src/components/billing-tabs.tsx | 42 - .../src/components/coupons-dialog.tsx | 213 ----- .../src/components/coupons-list.tsx | 191 ---- .../src/components/current-plan-card.tsx | 81 -- .../src/components/invoice-date-filter.tsx | 98 --- .../src/components/invoices-skeleton.tsx | 68 -- .../src/components/invoices-tab.tsx | 278 ------ .../src/components/promotion-card.tsx | 40 - .../src/components/resource-usage-card.tsx | 107 --- .../src/components/wallet-card.tsx | 168 ---- .../src/components/wallet-skeleton.tsx | 155 ---- .../src/components/wallet-tab.tsx | 98 --- plugins/pages/billing-center/src/constants.ts | 4 - .../src/hooks/use-billing-data.ts | 101 --- .../src/hooks/use-billing-tabs.ts | 25 - .../src/hooks/use-invoice-filters-state.ts | 126 --- .../src/hooks/use-invoice-filters.ts | 58 -- .../src/hooks/use-invoice-pagination.ts | 39 - .../src/hooks/use-invoice-selection.ts | 59 -- .../src/hooks/use-invoice-stats.ts | 32 - .../src/hooks/use-invoices-data.ts | 54 -- .../billing-center/src/hooks/use-invoices.ts | 65 -- .../billing-center/src/i18n/resources.ts | 275 ------ plugins/pages/billing-center/src/index.tsx | 123 --- .../pages/billing-center/src/types/index.ts | 103 --- .../src/utils/resource-usage.ts | 61 -- plugins/pages/billing-center/tsconfig.json | 7 - .../pages/browser-extensions/src/api/index.ts | 495 +---------- .../components/extension-batch-actions.tsx | 60 -- .../extension-batch-uninstall-dialog.tsx | 68 -- .../components/extension-detail-dialog.tsx | 299 +------ .../src/components/extension-header.tsx | 76 -- .../components/extension-homepage-dialog.tsx | 185 ---- .../src/components/extension-icon.tsx | 15 +- .../components/extension-install-dialog.tsx | 267 ------ .../src/components/extension-pagination.tsx | 109 --- .../src/components/extension-stats.tsx | 72 -- .../components/extension-store-skeleton.tsx | 77 -- .../src/components/extension-store.tsx | 362 -------- .../src/components/extension-table-row.tsx | 272 ------ .../components/extension-table-skeleton.tsx | 62 -- .../src/components/extension-table.tsx | 151 ---- .../pages/browser-extensions/src/constants.ts | 14 - .../src/hooks/use-batch-uninstall-dialog.ts | 28 - .../src/hooks/use-detail-dialog.ts | 35 - .../src/hooks/use-extension-filters.ts | 50 -- .../src/hooks/use-extension-handlers.ts | 280 ------ .../src/hooks/use-extension-operations.ts | 239 ----- .../src/hooks/use-extension-pagination.ts | 39 - .../src/hooks/use-extension-selection.ts | 59 -- .../src/hooks/use-extension-stats.ts | 29 - .../src/hooks/use-extensions.ts | 109 --- .../src/hooks/use-homepage-dialog.ts | 34 - .../src/hooks/use-install-dialog.ts | 58 -- .../src/hooks/use-search-pagination.ts | 36 - .../src/hooks/use-toggle-dialog.ts | 27 - .../src/hooks/use-uninstall-dialog.ts | 47 - .../src/hooks/use-view-mode.ts | 19 - .../browser-extensions/src/i18n/resources.ts | 2 + .../pages/browser-extensions/src/index.tsx | 500 +++-------- .../browser-extensions/src/types/index.ts | 15 +- .../src/utils/chrome-store.ts | 21 - plugins/pages/create-window/src/api/index.ts | 6 +- .../src/components/import-dialog.tsx | 26 +- .../src/components/proxy-select-dialog.tsx | 491 ----------- .../src/components/window-info-form.tsx | 70 +- .../pages/create-window/src/types/index.ts | 3 +- .../create-window/src/utils/default-config.ts | 1 + .../src/utils/environment-to-config.ts | 1 + .../src/utils/template-to-config.ts | 1 + .../environment-manager/src/api/index.ts | 5 +- plugins/pages/login/src/api/index.ts | 106 +-- plugins/pages/login/src/api/index.types.ts | 99 +-- .../pages/login/src/components/login-form.tsx | 321 ++++--- plugins/pages/login/src/constants.ts | 7 - .../pages/login/src/hooks/use-login-form.ts | 49 -- .../login/src/hooks/use-login-validation.ts | 94 -- plugins/pages/login/src/hooks/use-login.ts | 85 -- plugins/pages/login/src/i18n/resources.ts | 136 +-- plugins/pages/login/src/types/index.ts | 8 - plugins/pages/plan-selection/manifest.json | 10 - plugins/pages/plan-selection/package.json | 6 - plugins/pages/plan-selection/src/api/index.ts | 105 --- .../plan-selection/src/api/index.types.ts | 117 --- .../src/components/coupon-selector-dialog.tsx | 213 ----- .../src/components/payment-dialog.tsx | 181 ---- .../src/components/plan-banner.tsx | 70 -- .../src/components/plan-card-skeleton.tsx | 24 - .../src/components/plan-card.tsx | 35 - .../src/components/plan-details-panel.tsx | 190 ---- .../src/components/plan-features-list.tsx | 36 - .../src/components/plan-quota-bar.tsx | 184 ---- .../src/components/plan-selection-content.tsx | 57 -- .../components/plan-selection-skeleton.tsx | 97 --- .../src/components/recharge-dialog.tsx | 143 --- plugins/pages/plan-selection/src/constants.ts | 4 - .../src/hooks/use-current-plan.ts | 91 -- .../src/hooks/use-plan-selection.ts | 38 - .../plan-selection/src/hooks/use-plans.ts | 55 -- .../plan-selection/src/hooks/use-subscribe.ts | 40 - .../plan-selection/src/i18n/resources.ts | 224 ----- plugins/pages/plan-selection/src/index.tsx | 293 ------- .../pages/plan-selection/src/types/index.ts | 53 -- .../plan-selection/src/utils/plan-price.ts | 88 -- .../src/utils/plan-transform.ts | 40 - plugins/pages/plan-selection/tsconfig.json | 7 - plugins/pages/referral-program/manifest.json | 10 - plugins/pages/referral-program/package.json | 6 - .../pages/referral-program/src/api/index.ts | 222 ----- .../referral-program/src/api/index.types.ts | 117 --- .../src/components/overview-tab.tsx | 287 ------ .../src/components/redeem-dialog.tsx | 103 --- .../src/components/redeemable-reward-card.tsx | 178 ---- .../src/components/referral-page-skeleton.tsx | 84 -- .../src/components/referral-pagination.tsx | 116 --- .../components/referral-table-skeleton.tsx | 44 - .../src/components/referral-tabs.tsx | 39 - .../src/components/reward-table-skeleton.tsx | 44 - .../src/components/rewards-tab.tsx | 301 ------- .../src/components/users-tab.tsx | 251 ------ .../pages/referral-program/src/constants.ts | 12 - .../src/hooks/use-banner-sizes.ts | 40 - .../src/hooks/use-referral-computed.ts | 36 - .../src/hooks/use-referral-dashboard.ts | 49 -- .../src/hooks/use-referral-filters.ts | 60 -- .../src/hooks/use-referral-handlers.ts | 122 --- .../src/hooks/use-referral-rewards.ts | 65 -- .../src/hooks/use-referral-stats.ts | 42 - .../src/hooks/use-referral-tab-handlers.ts | 95 -- .../src/hooks/use-referral-tabs.ts | 16 - .../src/hooks/use-referred-users.ts | 63 -- .../referral-program/src/i18n/resources.ts | 248 ------ plugins/pages/referral-program/src/index.tsx | 194 ----- .../pages/referral-program/src/types/index.ts | 56 -- plugins/pages/referral-program/tsconfig.json | 7 - plugins/pages/register/src/api/index.ts | 99 +-- plugins/pages/register/src/api/index.types.ts | 85 +- .../src/components/local-nickname-input.tsx | 81 ++ .../register/src/components/register-form.tsx | 404 ++++----- plugins/pages/register/src/constants.ts | 13 - .../register/src/hooks/use-register-code.ts | 60 -- .../register/src/hooks/use-register-form.ts | 57 -- .../src/hooks/use-register-validation.ts | 159 ---- .../pages/register/src/hooks/use-register.ts | 79 -- plugins/pages/register/src/types/index.ts | 9 - plugins/pages/reset-password/manifest.json | 10 - plugins/pages/reset-password/package.json | 6 - plugins/pages/reset-password/src/api/index.ts | 54 -- .../reset-password/src/api/index.types.ts | 25 - .../src/components/code-input.tsx | 72 -- .../src/components/reset-form.tsx | 68 -- .../src/components/reset-step1.tsx | 67 -- .../src/components/reset-step2.tsx | 108 --- plugins/pages/reset-password/src/constants.ts | 13 - .../src/hooks/use-reset-code.ts | 60 -- .../src/hooks/use-reset-form.ts | 63 -- .../src/hooks/use-reset-handlers.ts | 128 --- .../src/hooks/use-reset-password.ts | 49 -- .../src/hooks/use-reset-validation.ts | 163 ---- plugins/pages/reset-password/src/index.tsx | 35 - .../pages/reset-password/src/types/index.ts | 14 - plugins/pages/reset-password/tsconfig.json | 7 - plugins/pages/rpa-workflow/src/constants.ts | 11 - .../src/runtime/anonymous-environment.ts | 2 +- .../src/components/splashscreen-page.tsx | 43 + .../hooks/use-splashscreen-window-display.ts | 145 ++-- .../src/hooks/useSplashscreenInit.ts | 48 +- plugins/pages/splashscreen/src/index.tsx | 89 +- .../pages/system-settings/src/api/users.ts | 108 --- .../src/components/account-panel.tsx | 218 +---- .../src/components/change-password-dialog.tsx | 185 ---- .../src/components/edit-profile-dialog.tsx | 241 ----- .../system-settings/src/i18n/resources.ts | 10 + plugins/pages/team/src/api/index.ts | 71 +- plugins/pages/team/src/api/index.types.ts | 49 +- .../src/components/team-invite-dialog.tsx | 343 +++----- .../team/src/components/team-table-row.tsx | 11 +- .../pages/team/src/hooks/use-team-handlers.ts | 12 +- .../team/src/hooks/use-team-operations.ts | 17 +- plugins/pages/team/src/i18n/resources.ts | 28 +- plugins/pages/team/src/index.tsx | 4 +- .../team/src/stores/dialogs/dialog-store.ts | 12 +- plugins/services/environment/src/types.ts | 5 +- .../store/src/stores/auth/auth-store.ts | 201 ++--- .../store/src/stores/auth/auth-store.types.ts | 37 +- .../store/src/stores/auth/auth-store.utils.ts | 116 --- .../services/store/src/stores/auth/index.ts | 11 +- .../services/store/src/types/store.types.ts | 5 +- plugins/services/window-manager/src/index.tsx | 177 +--- .../src/window-manager-service.tsx | 76 ++ server/.claude/settings.local.json | 9 - server/AGENTS.md | 237 ----- src-tauri/Cargo.lock | 190 +--- src-tauri/Cargo.toml | 7 +- src-tauri/config.example.toml | 5 - .../20260812000001_create_local_user_auth.sql | 30 + ...2000002_create_browser_kernel_registry.sql | 69 ++ ...d_browser_kernel_compatible_signatures.sql | 9 + .../resources/default-browser-kernels.json | 30 + src-tauri/crates/business/src/dispatcher.rs | 113 +-- .../crates/business/src/dto/local_api.rs | 195 ++--- src-tauri/crates/business/src/entitys/rpa.rs | 4 +- .../crates/business/src/entitys/teams.rs | 260 +++--- .../crates/business/src/models/accounts.rs | 15 +- .../business/src/models/environments.rs | 56 +- .../crates/business/src/models/local_api.rs | 7 +- .../crates/business/src/models/proxies.rs | 5 +- src-tauri/crates/business/src/models/rpa.rs | 10 +- src-tauri/crates/business/src/models/teams.rs | 27 +- src-tauri/crates/business/src/models/user.rs | 9 +- .../business/src/models/workspace_quotas.rs | 5 +- src-tauri/crates/business/src/services.rs | 12 +- .../business/src/services/browser_kernels.rs | 705 +++++++++++++++ .../business/src/services/environments.rs | 83 +- .../crates/business/src/services/local_api.rs | 1 + .../business/src/services/local_users.rs | 422 +++++++++ src-tauri/crates/business/src/services/rpa.rs | 619 +++++++------ .../crates/business/src/services/teams.rs | 821 ++++++++---------- .../business/src/services/workspace_quotas.rs | 143 ++- .../business/src/services/workspaces.rs | 369 ++++---- src-tauri/crates/business/src/svc_ctx.rs | 51 ++ src-tauri/src/app/context.rs | 33 - src-tauri/src/app/mod.rs | 45 +- src-tauri/src/app/runtime.rs | 17 +- src-tauri/src/app/setup.rs | 33 +- src-tauri/src/app/splashscreen.rs | 87 +- src-tauri/src/app/startup.rs | 113 ++- src-tauri/src/commands.rs | 19 +- src-tauri/src/commands/app.rs | 6 +- src-tauri/src/commands/auth.rs | 117 +-- src-tauri/src/commands/mihomo.rs | 5 +- src-tauri/src/commands/network.rs | 104 +-- src-tauri/src/commands/rpa.rs | 10 +- src-tauri/src/commands/security.rs | 9 - src-tauri/src/commands/window.rs | 4 +- src-tauri/src/core/config/mod.rs | 2 +- src-tauri/src/core/config/types.rs | 13 - src-tauri/src/core/config/validator.rs | 32 - src-tauri/src/core/error/convert.rs | 19 - src-tauri/src/core/paths.rs | 17 - src-tauri/src/core/utils/process.rs | 5 +- src-tauri/src/domain/credential.rs | 63 -- src-tauri/src/domain/environment.rs | 4 + src-tauri/src/domain/mod.rs | 2 - src-tauri/src/domain/referral_code.rs | 48 - src-tauri/src/infrastructure/deeplink/mod.rs | 31 - .../src/infrastructure/deeplink/referral.rs | 33 - .../src/infrastructure/deeplink/storage.rs | 15 - .../src/infrastructure/http/client/client.rs | 156 ---- .../src/infrastructure/http/client/mod.rs | 5 - .../src/infrastructure/http/encryption/aes.rs | 203 ----- .../src/infrastructure/http/encryption/mod.rs | 9 - .../src/infrastructure/http/encryption/rsa.rs | 88 -- src-tauri/src/infrastructure/http/mod.rs | 9 +- .../src/infrastructure/main_server/client.rs | 214 ----- .../src/infrastructure/main_server/error.rs | 66 -- .../main_server/interceptors/crypto.rs | 49 -- .../main_server/interceptors/mod.rs | 13 - .../main_server/interceptors/request.rs | 123 --- .../src/infrastructure/main_server/mod.rs | 5 - .../infrastructure/main_server/response.rs | 142 --- .../src/infrastructure/main_server/types.rs | 27 - src-tauri/src/infrastructure/mihomo/models.rs | 5 +- src-tauri/src/infrastructure/mod.rs | 2 - .../persistence/credential/mod.rs | 201 ----- .../persistence/credential/remembered.rs | 66 -- .../src/infrastructure/persistence/mod.rs | 1 - src-tauri/src/infrastructure/runtime/mod.rs | 4 +- src-tauri/src/local_api/client/business.rs | 92 ++ src-tauri/src/local_api/client/main_server.rs | 128 --- src-tauri/src/local_api/client/mod.rs | 2 +- .../src/local_api/entitys/browser_kernels.rs | 8 + .../src/local_api/services/browser_kernels.rs | 4 +- .../src/local_api/services/environments.rs | 4 +- src-tauri/src/local_api/services/groups.rs | 4 +- src-tauri/src/local_api/services/mod.rs | 4 +- src-tauri/src/local_api/services/proxies.rs | 4 +- src-tauri/src/local_api/services/tags.rs | 4 +- .../src/local_api/services/workspaces.rs | 4 +- src-tauri/src/main.rs | 7 +- src-tauri/src/mcp/bridge.rs | 100 +-- src-tauri/src/mcp/tools/browser_kernels.rs | 2 + src-tauri/src/services/auth/credential.rs | 54 -- src-tauri/src/services/auth/login.rs | 144 --- src-tauri/src/services/auth/mod.rs | 12 - src-tauri/src/services/auth/register.rs | 169 ---- .../services/environment/kernel/downloader.rs | 52 +- .../src/services/environment/kernel/mod.rs | 131 ++- .../environment/kernel/runtime_bridge.rs | 8 +- .../src/services/environment/kernel/utils.rs | 29 + .../services/environment/kernel/verifier.rs | 45 +- .../environment/launch_runtime/detail.rs | 20 +- .../environment/launch_runtime/kernel.rs | 93 +- .../environment/launch_runtime/mod.rs | 12 +- .../environment/launch_runtime/types.rs | 12 - src-tauri/src/services/mod.rs | 1 - src-tauri/src/services/window/mod.rs | 2 +- src/App.tsx | 142 +-- src/app/components/AppLayout/index.tsx | 52 +- src/app/components/AppRoutes/index.tsx | 130 +-- src/app/components/session-lock-overlay.tsx | 11 +- src/app/hooks/use-session-lock.ts | 22 +- src/splashscreen-main.tsx | 11 +- 323 files changed, 4933 insertions(+), 22661 deletions(-) delete mode 100644 plugins/layouts/app-layout/src/components/free-quota-usage.tsx delete mode 100644 plugins/layouts/app-layout/src/components/titlebar/hooks/use-messages.ts delete mode 100644 plugins/pages/billing-center/manifest.json delete mode 100644 plugins/pages/billing-center/package.json delete mode 100644 plugins/pages/billing-center/src/api/index.ts delete mode 100644 plugins/pages/billing-center/src/api/index.types.ts delete mode 100644 plugins/pages/billing-center/src/components/auto-renewal-services.tsx delete mode 100644 plugins/pages/billing-center/src/components/billing-page-skeleton.tsx delete mode 100644 plugins/pages/billing-center/src/components/billing-pagination.tsx delete mode 100644 plugins/pages/billing-center/src/components/billing-table-skeleton.tsx delete mode 100644 plugins/pages/billing-center/src/components/billing-tabs.tsx delete mode 100644 plugins/pages/billing-center/src/components/coupons-dialog.tsx delete mode 100644 plugins/pages/billing-center/src/components/coupons-list.tsx delete mode 100644 plugins/pages/billing-center/src/components/current-plan-card.tsx delete mode 100644 plugins/pages/billing-center/src/components/invoice-date-filter.tsx delete mode 100644 plugins/pages/billing-center/src/components/invoices-skeleton.tsx delete mode 100644 plugins/pages/billing-center/src/components/invoices-tab.tsx delete mode 100644 plugins/pages/billing-center/src/components/promotion-card.tsx delete mode 100644 plugins/pages/billing-center/src/components/resource-usage-card.tsx delete mode 100644 plugins/pages/billing-center/src/components/wallet-card.tsx delete mode 100644 plugins/pages/billing-center/src/components/wallet-skeleton.tsx delete mode 100644 plugins/pages/billing-center/src/components/wallet-tab.tsx delete mode 100644 plugins/pages/billing-center/src/constants.ts delete mode 100644 plugins/pages/billing-center/src/hooks/use-billing-data.ts delete mode 100644 plugins/pages/billing-center/src/hooks/use-billing-tabs.ts delete mode 100644 plugins/pages/billing-center/src/hooks/use-invoice-filters-state.ts delete mode 100644 plugins/pages/billing-center/src/hooks/use-invoice-filters.ts delete mode 100644 plugins/pages/billing-center/src/hooks/use-invoice-pagination.ts delete mode 100644 plugins/pages/billing-center/src/hooks/use-invoice-selection.ts delete mode 100644 plugins/pages/billing-center/src/hooks/use-invoice-stats.ts delete mode 100644 plugins/pages/billing-center/src/hooks/use-invoices-data.ts delete mode 100644 plugins/pages/billing-center/src/hooks/use-invoices.ts delete mode 100644 plugins/pages/billing-center/src/i18n/resources.ts delete mode 100644 plugins/pages/billing-center/src/index.tsx delete mode 100644 plugins/pages/billing-center/src/types/index.ts delete mode 100644 plugins/pages/billing-center/src/utils/resource-usage.ts delete mode 100644 plugins/pages/billing-center/tsconfig.json delete mode 100644 plugins/pages/browser-extensions/src/components/extension-batch-actions.tsx delete mode 100644 plugins/pages/browser-extensions/src/components/extension-batch-uninstall-dialog.tsx delete mode 100644 plugins/pages/browser-extensions/src/components/extension-header.tsx delete mode 100644 plugins/pages/browser-extensions/src/components/extension-homepage-dialog.tsx delete mode 100644 plugins/pages/browser-extensions/src/components/extension-install-dialog.tsx delete mode 100644 plugins/pages/browser-extensions/src/components/extension-pagination.tsx delete mode 100644 plugins/pages/browser-extensions/src/components/extension-stats.tsx delete mode 100644 plugins/pages/browser-extensions/src/components/extension-store-skeleton.tsx delete mode 100644 plugins/pages/browser-extensions/src/components/extension-store.tsx delete mode 100644 plugins/pages/browser-extensions/src/components/extension-table-row.tsx delete mode 100644 plugins/pages/browser-extensions/src/components/extension-table-skeleton.tsx delete mode 100644 plugins/pages/browser-extensions/src/components/extension-table.tsx delete mode 100644 plugins/pages/browser-extensions/src/constants.ts delete mode 100644 plugins/pages/browser-extensions/src/hooks/use-batch-uninstall-dialog.ts delete mode 100644 plugins/pages/browser-extensions/src/hooks/use-detail-dialog.ts delete mode 100644 plugins/pages/browser-extensions/src/hooks/use-extension-filters.ts delete mode 100644 plugins/pages/browser-extensions/src/hooks/use-extension-handlers.ts delete mode 100644 plugins/pages/browser-extensions/src/hooks/use-extension-operations.ts delete mode 100644 plugins/pages/browser-extensions/src/hooks/use-extension-pagination.ts delete mode 100644 plugins/pages/browser-extensions/src/hooks/use-extension-selection.ts delete mode 100644 plugins/pages/browser-extensions/src/hooks/use-extension-stats.ts delete mode 100644 plugins/pages/browser-extensions/src/hooks/use-extensions.ts delete mode 100644 plugins/pages/browser-extensions/src/hooks/use-homepage-dialog.ts delete mode 100644 plugins/pages/browser-extensions/src/hooks/use-install-dialog.ts delete mode 100644 plugins/pages/browser-extensions/src/hooks/use-search-pagination.ts delete mode 100644 plugins/pages/browser-extensions/src/hooks/use-toggle-dialog.ts delete mode 100644 plugins/pages/browser-extensions/src/hooks/use-uninstall-dialog.ts delete mode 100644 plugins/pages/browser-extensions/src/hooks/use-view-mode.ts delete mode 100644 plugins/pages/browser-extensions/src/utils/chrome-store.ts delete mode 100644 plugins/pages/create-window/src/components/proxy-select-dialog.tsx delete mode 100644 plugins/pages/login/src/constants.ts delete mode 100644 plugins/pages/login/src/hooks/use-login-form.ts delete mode 100644 plugins/pages/login/src/hooks/use-login-validation.ts delete mode 100644 plugins/pages/login/src/hooks/use-login.ts delete mode 100644 plugins/pages/login/src/types/index.ts delete mode 100644 plugins/pages/plan-selection/manifest.json delete mode 100644 plugins/pages/plan-selection/package.json delete mode 100644 plugins/pages/plan-selection/src/api/index.ts delete mode 100644 plugins/pages/plan-selection/src/api/index.types.ts delete mode 100644 plugins/pages/plan-selection/src/components/coupon-selector-dialog.tsx delete mode 100644 plugins/pages/plan-selection/src/components/payment-dialog.tsx delete mode 100644 plugins/pages/plan-selection/src/components/plan-banner.tsx delete mode 100644 plugins/pages/plan-selection/src/components/plan-card-skeleton.tsx delete mode 100644 plugins/pages/plan-selection/src/components/plan-card.tsx delete mode 100644 plugins/pages/plan-selection/src/components/plan-details-panel.tsx delete mode 100644 plugins/pages/plan-selection/src/components/plan-features-list.tsx delete mode 100644 plugins/pages/plan-selection/src/components/plan-quota-bar.tsx delete mode 100644 plugins/pages/plan-selection/src/components/plan-selection-content.tsx delete mode 100644 plugins/pages/plan-selection/src/components/plan-selection-skeleton.tsx delete mode 100644 plugins/pages/plan-selection/src/components/recharge-dialog.tsx delete mode 100644 plugins/pages/plan-selection/src/constants.ts delete mode 100644 plugins/pages/plan-selection/src/hooks/use-current-plan.ts delete mode 100644 plugins/pages/plan-selection/src/hooks/use-plan-selection.ts delete mode 100644 plugins/pages/plan-selection/src/hooks/use-plans.ts delete mode 100644 plugins/pages/plan-selection/src/hooks/use-subscribe.ts delete mode 100644 plugins/pages/plan-selection/src/i18n/resources.ts delete mode 100644 plugins/pages/plan-selection/src/index.tsx delete mode 100644 plugins/pages/plan-selection/src/types/index.ts delete mode 100644 plugins/pages/plan-selection/src/utils/plan-price.ts delete mode 100644 plugins/pages/plan-selection/src/utils/plan-transform.ts delete mode 100644 plugins/pages/plan-selection/tsconfig.json delete mode 100644 plugins/pages/referral-program/manifest.json delete mode 100644 plugins/pages/referral-program/package.json delete mode 100644 plugins/pages/referral-program/src/api/index.ts delete mode 100644 plugins/pages/referral-program/src/api/index.types.ts delete mode 100644 plugins/pages/referral-program/src/components/overview-tab.tsx delete mode 100644 plugins/pages/referral-program/src/components/redeem-dialog.tsx delete mode 100644 plugins/pages/referral-program/src/components/redeemable-reward-card.tsx delete mode 100644 plugins/pages/referral-program/src/components/referral-page-skeleton.tsx delete mode 100644 plugins/pages/referral-program/src/components/referral-pagination.tsx delete mode 100644 plugins/pages/referral-program/src/components/referral-table-skeleton.tsx delete mode 100644 plugins/pages/referral-program/src/components/referral-tabs.tsx delete mode 100644 plugins/pages/referral-program/src/components/reward-table-skeleton.tsx delete mode 100644 plugins/pages/referral-program/src/components/rewards-tab.tsx delete mode 100644 plugins/pages/referral-program/src/components/users-tab.tsx delete mode 100644 plugins/pages/referral-program/src/constants.ts delete mode 100644 plugins/pages/referral-program/src/hooks/use-banner-sizes.ts delete mode 100644 plugins/pages/referral-program/src/hooks/use-referral-computed.ts delete mode 100644 plugins/pages/referral-program/src/hooks/use-referral-dashboard.ts delete mode 100644 plugins/pages/referral-program/src/hooks/use-referral-filters.ts delete mode 100644 plugins/pages/referral-program/src/hooks/use-referral-handlers.ts delete mode 100644 plugins/pages/referral-program/src/hooks/use-referral-rewards.ts delete mode 100644 plugins/pages/referral-program/src/hooks/use-referral-stats.ts delete mode 100644 plugins/pages/referral-program/src/hooks/use-referral-tab-handlers.ts delete mode 100644 plugins/pages/referral-program/src/hooks/use-referral-tabs.ts delete mode 100644 plugins/pages/referral-program/src/hooks/use-referred-users.ts delete mode 100644 plugins/pages/referral-program/src/i18n/resources.ts delete mode 100644 plugins/pages/referral-program/src/index.tsx delete mode 100644 plugins/pages/referral-program/src/types/index.ts delete mode 100644 plugins/pages/referral-program/tsconfig.json create mode 100644 plugins/pages/register/src/components/local-nickname-input.tsx delete mode 100644 plugins/pages/register/src/constants.ts delete mode 100644 plugins/pages/register/src/hooks/use-register-code.ts delete mode 100644 plugins/pages/register/src/hooks/use-register-form.ts delete mode 100644 plugins/pages/register/src/hooks/use-register-validation.ts delete mode 100644 plugins/pages/register/src/hooks/use-register.ts delete mode 100644 plugins/pages/register/src/types/index.ts delete mode 100644 plugins/pages/reset-password/manifest.json delete mode 100644 plugins/pages/reset-password/package.json delete mode 100644 plugins/pages/reset-password/src/api/index.ts delete mode 100644 plugins/pages/reset-password/src/api/index.types.ts delete mode 100644 plugins/pages/reset-password/src/components/code-input.tsx delete mode 100644 plugins/pages/reset-password/src/components/reset-form.tsx delete mode 100644 plugins/pages/reset-password/src/components/reset-step1.tsx delete mode 100644 plugins/pages/reset-password/src/components/reset-step2.tsx delete mode 100644 plugins/pages/reset-password/src/constants.ts delete mode 100644 plugins/pages/reset-password/src/hooks/use-reset-code.ts delete mode 100644 plugins/pages/reset-password/src/hooks/use-reset-form.ts delete mode 100644 plugins/pages/reset-password/src/hooks/use-reset-handlers.ts delete mode 100644 plugins/pages/reset-password/src/hooks/use-reset-password.ts delete mode 100644 plugins/pages/reset-password/src/hooks/use-reset-validation.ts delete mode 100644 plugins/pages/reset-password/src/index.tsx delete mode 100644 plugins/pages/reset-password/src/types/index.ts delete mode 100644 plugins/pages/reset-password/tsconfig.json delete mode 100644 plugins/pages/rpa-workflow/src/constants.ts create mode 100644 plugins/pages/splashscreen/src/components/splashscreen-page.tsx delete mode 100644 plugins/pages/system-settings/src/api/users.ts delete mode 100644 plugins/pages/system-settings/src/components/change-password-dialog.tsx delete mode 100644 plugins/pages/system-settings/src/components/edit-profile-dialog.tsx delete mode 100644 plugins/services/store/src/stores/auth/auth-store.utils.ts create mode 100644 plugins/services/window-manager/src/window-manager-service.tsx delete mode 100644 server/.claude/settings.local.json delete mode 100644 server/AGENTS.md create mode 100644 src-tauri/crates/business/migrations/20260812000001_create_local_user_auth.sql create mode 100644 src-tauri/crates/business/migrations/20260812000002_create_browser_kernel_registry.sql create mode 100644 src-tauri/crates/business/migrations/20260812000003_add_browser_kernel_compatible_signatures.sql create mode 100644 src-tauri/crates/business/resources/default-browser-kernels.json create mode 100644 src-tauri/crates/business/src/services/browser_kernels.rs create mode 100644 src-tauri/crates/business/src/services/local_users.rs delete mode 100644 src-tauri/src/domain/credential.rs delete mode 100644 src-tauri/src/domain/referral_code.rs delete mode 100644 src-tauri/src/infrastructure/deeplink/mod.rs delete mode 100644 src-tauri/src/infrastructure/deeplink/referral.rs delete mode 100644 src-tauri/src/infrastructure/deeplink/storage.rs delete mode 100644 src-tauri/src/infrastructure/http/client/client.rs delete mode 100644 src-tauri/src/infrastructure/http/encryption/aes.rs delete mode 100644 src-tauri/src/infrastructure/http/encryption/mod.rs delete mode 100644 src-tauri/src/infrastructure/http/encryption/rsa.rs delete mode 100644 src-tauri/src/infrastructure/main_server/client.rs delete mode 100644 src-tauri/src/infrastructure/main_server/error.rs delete mode 100644 src-tauri/src/infrastructure/main_server/interceptors/crypto.rs delete mode 100644 src-tauri/src/infrastructure/main_server/interceptors/mod.rs delete mode 100644 src-tauri/src/infrastructure/main_server/interceptors/request.rs delete mode 100644 src-tauri/src/infrastructure/main_server/mod.rs delete mode 100644 src-tauri/src/infrastructure/main_server/response.rs delete mode 100644 src-tauri/src/infrastructure/main_server/types.rs delete mode 100644 src-tauri/src/infrastructure/persistence/credential/mod.rs delete mode 100644 src-tauri/src/infrastructure/persistence/credential/remembered.rs create mode 100644 src-tauri/src/local_api/client/business.rs delete mode 100644 src-tauri/src/local_api/client/main_server.rs delete mode 100644 src-tauri/src/services/auth/credential.rs delete mode 100644 src-tauri/src/services/auth/login.rs delete mode 100644 src-tauri/src/services/auth/mod.rs delete mode 100644 src-tauri/src/services/auth/register.rs diff --git a/plugins/layouts/app-layout/src/components/app-sidebar.tsx b/plugins/layouts/app-layout/src/components/app-sidebar.tsx index c2740e7..4a1a38c 100644 --- a/plugins/layouts/app-layout/src/components/app-sidebar.tsx +++ b/plugins/layouts/app-layout/src/components/app-sidebar.tsx @@ -4,17 +4,12 @@ import { Workflow, TerminalSquare, ShieldHalf, - SquarePlus, - Layout, - Monitor, FolderTree, ChevronLeft, ChevronRight, Puzzle, Users, UserCircle, - CreditCard, - Gift, } from 'lucide-react'; import { TfiWorld } from "react-icons/tfi"; import { BsWindowSidebar } from "react-icons/bs"; @@ -26,7 +21,6 @@ import { Link, useLocation, useNavigate } from 'react-router'; import { Tooltip, TooltipContent, TooltipTrigger } from '@/components/ui/tooltip'; import { ScrollArea } from '@/components/ui/scroll-area'; import { useTranslation } from 'react-i18next'; -import { FreeQuotaUsage } from './free-quota-usage'; import { getGeneralSettings, useMihomoRuntimeStore } from '../../../../services/store/src'; import { ClashIcon } from '../../../../pages/proxy-center/src/mihomo/clash-icon'; import { MihomoConnectDialog } from '../../../../pages/proxy-center/src/mihomo/mihomo-connect-dialog'; @@ -488,14 +482,12 @@ interface BottomNavItemsProps { /** * 底部导航项组件 - * 包含指纹审计、费用中心、推广计划 + * 底部保留本地审计入口。 */ const BottomNavItems: React.FC = ({ collapsed, currentPath }) => { const { t } = useTranslation('appLayout'); const bottomNavItems: NavItemData[] = [ { label: t('nav.item.audit'), href: '/audit', icon: ShieldHalf }, - { label: t('quota.billingCenter'), href: '/billing', icon: CreditCard }, - { label: t('quota.referralProgram'), href: '/referral', icon: Gift }, ]; return ( @@ -696,7 +688,6 @@ export const AppSidebar: React.FC = () => { {/* 免费额度使用情况区域 */}
-
{/* 折叠按钮区域 */} diff --git a/plugins/layouts/app-layout/src/components/free-quota-usage.tsx b/plugins/layouts/app-layout/src/components/free-quota-usage.tsx deleted file mode 100644 index 37852f6..0000000 --- a/plugins/layouts/app-layout/src/components/free-quota-usage.tsx +++ /dev/null @@ -1,131 +0,0 @@ -import { useEffect, useState } from 'react'; -import { AlertCircle, Package } from 'lucide-react'; -import { Link } from 'react-router'; -import { useTranslation } from 'react-i18next'; - -import { Tooltip, TooltipContent, TooltipTrigger } from '@/components/ui/tooltip'; -import { useAuthStore, useRefreshStore } from '../../../../services/store/src'; -import { getWorkspaceQuota, type WorkspaceQuotaDto } from '../api/workspace-quotas'; - -interface FreeQuotaUsageProps { - collapsed: boolean; - currentPath: string; -} - -// 折叠状态下的套餐选择按钮数据 -const getCollapsedButtonsData = (t: (key: string) => string) => [ - { - label: t('quota.planSelection'), - href: '/plans', - icon: Package, - tooltip: t('quota.planSelection'), - }, -]; - -/** - * 免费额度使用情况组件 - */ -export const FreeQuotaUsage: React.FC = ({ collapsed, currentPath }) => { - const { t } = useTranslation('appLayout'); - const workspacesRefreshKey = useRefreshStore((state) => state.workspaces); - const [quota, setQuota] = useState(null); - const [loading, setLoading] = useState(false); - - // 当侧边栏展开时,从服务器加载配额信息 - useEffect(() => { - if (!collapsed) { - setLoading(true); - void getWorkspaceQuota({}) - .then((data) => { - setQuota(data); - }) - .catch((error) => { - // eslint-disable-next-line no-console - console.error('Failed to load workspace quota:', error); - }) - .finally(() => { - setLoading(false); - }); - } - }, [collapsed, workspacesRefreshKey]); - - const total = quota?.max_environments ?? 6; - const used = quota?.used_environments ?? 0; - const percentage = total > 0 ? (used / total) * 100 : 0; - - if (collapsed) { - // 折叠状态下显示三个图标按钮 - const collapsedButtonsData = getCollapsedButtonsData(t); - return ( -
- {collapsedButtonsData.map((item) => { - const isActive = currentPath === item.href; - return ( - - - - {isActive &&
 
} - - -
- {item.tooltip} -
- ); - })} -
- ); - } - - // 展开状态下显示完整内容 - return ( - - {/* 使用剩余环境的进度 */} -
- {/* 免费用量 */} -
-
- {t('quota.freeUsage')} -
-
- - {loading ? '--/--' : `${used}/${total}`} - -
- - - - - - {t('quota.freeUsageDesc', { total, used })} - - -
-
- - {/* 进度条 */} -
-
-
-
-
- - ); -} - diff --git a/plugins/layouts/app-layout/src/components/titlebar/default-navigation-slot.tsx b/plugins/layouts/app-layout/src/components/titlebar/default-navigation-slot.tsx index 9d87515..312e5a5 100644 --- a/plugins/layouts/app-layout/src/components/titlebar/default-navigation-slot.tsx +++ b/plugins/layouts/app-layout/src/components/titlebar/default-navigation-slot.tsx @@ -1,7 +1,6 @@ import { ChevronRight, Home, - List, FolderTree, Network, UserCircle, @@ -9,9 +8,6 @@ import { TerminalSquare, Puzzle, Users, - Package, - CreditCard, - Gift, ShieldHalf, Settings, SquarePlus, @@ -32,9 +28,6 @@ const navItems: Array<{ key: string; href: string; icon: LucideIcon | IconType } { key: 'api', href: '/api', icon: TerminalSquare }, { key: 'extensions', href: '/extensions', icon: Puzzle }, { key: 'team', href: '/team', icon: Users }, - { key: 'plans', href: '/plans', icon: Package }, - { key: 'billing', href: '/billing', icon: CreditCard }, - { key: 'referral', href: '/referral', icon: Gift }, { key: 'audit', href: '/audit', icon: ShieldHalf }, { key: 'settings', href: '/settings', icon: Settings }, { key: 'createWindow', href: '/create-window', icon: SquarePlus }, diff --git a/plugins/layouts/app-layout/src/components/titlebar/hooks/use-messages.ts b/plugins/layouts/app-layout/src/components/titlebar/hooks/use-messages.ts deleted file mode 100644 index 26efa7d..0000000 --- a/plugins/layouts/app-layout/src/components/titlebar/hooks/use-messages.ts +++ /dev/null @@ -1,298 +0,0 @@ -import { useState, useEffect, useCallback } from 'react'; -import { useTranslation } from 'react-i18next'; -import { - listMessages, - markMessageRead, - batchMarkMessagesRead, - getMessageStats, - handleMessage, - type Message, - type MessageListRequest, -} from '../../../api/messages'; -import { acceptInvitation, rejectInvitation } from '../../../../../../pages/team/src/api'; -import { useRefreshStore } from '../../../../../../services/store/src'; -import { toast } from 'sonner'; - -export interface UseMessagesReturn { - messages: Message[]; - stats: { - total: number; - unread: number; - by_type: Record; - } | null; - loading: boolean; - error: string | null; - currentPage: number; - totalPages: number; - messageTypeFilter: string | null; - isReadFilter: boolean | null; - refresh: () => Promise; - loadMore: () => Promise; - markAsRead: (messageUuid: string) => Promise; - markAllAsRead: () => Promise; - handleInvitation: (message: Message, action: 'accept' | 'reject') => Promise; - setMessageTypeFilter: (type: string | null) => void; - setIsReadFilter: (isRead: boolean | null) => void; -} - -const PAGE_SIZE = 20; - -/** - * 消息管理 Hook - */ -export function useMessages(): UseMessagesReturn { - const { t } = useTranslation('appLayout'); - const [messages, setMessages] = useState([]); - const [stats, setStats] = useState(null); - const [loading, setLoading] = useState(false); - const [error, setError] = useState(null); - const [currentPage, setCurrentPage] = useState(1); - const [totalPages, setTotalPages] = useState(1); - const [messageTypeFilter, setMessageTypeFilter] = useState(null); - const [isReadFilter, setIsReadFilter] = useState(null); - - // 加载消息列表 - const loadMessages = useCallback( - async (page: number = 1, append: boolean = false) => { - setLoading(true); - setError(null); - - try { - const request: MessageListRequest = { - page, - page_size: PAGE_SIZE, - filters: { - ...(messageTypeFilter && { message_type: messageTypeFilter }), - ...(isReadFilter !== null && { is_read: isReadFilter }), - }, - }; - - const response = await listMessages(request); - - if (append) { - setMessages((prev) => [...prev, ...response.items]); - } else { - setMessages(response.items); - } - - setCurrentPage(page); - setTotalPages(Math.ceil(response.total / PAGE_SIZE)); - } catch (e) { - const errorMessage = e instanceof Error ? e.message : '加载消息失败'; - setError(errorMessage); - console.error('Failed to load messages:', e); - } finally { - setLoading(false); - } - }, - [messageTypeFilter, isReadFilter] - ); - - // 加载消息统计 - const loadStats = useCallback(async () => { - try { - const statsData = await getMessageStats(); - setStats(statsData); - } catch (e) { - console.error('Failed to load message stats:', e); - } - }, []); - - // 刷新消息列表 - const refresh = useCallback(async () => { - await Promise.all([loadMessages(1, false), loadStats()]); - }, [loadMessages, loadStats]); - - // 加载更多 - const loadMore = useCallback(async () => { - if (currentPage < totalPages && !loading) { - await loadMessages(currentPage + 1, true); - } - }, [currentPage, totalPages, loading, loadMessages]); - - // 标记为已读 - const markAsRead = useCallback( - async (messageUuid: string) => { - try { - await markMessageRead(messageUuid); - setMessages((prev) => - prev.map((msg) => - msg.message_uuid === messageUuid - ? { ...msg, is_read: true, read_at: new Date().toISOString() } - : msg - ) - ); - // 更新统计 - if (stats) { - setStats({ - ...stats, - unread: Math.max(0, stats.unread - 1), - }); - } - } catch (e) { - toast.error(e instanceof Error ? e.message : '标记已读失败'); - } - }, - [stats] - ); - - // 标记所有为已读 - const markAllAsRead = useCallback(async () => { - const unreadMessages = messages.filter((msg) => !msg.is_read); - if (unreadMessages.length === 0) return; - - try { - const unreadUuids = unreadMessages.map((msg) => msg.message_uuid); - await batchMarkMessagesRead(unreadUuids); - - setMessages((prev) => - prev.map((msg) => ({ - ...msg, - is_read: true, - read_at: msg.read_at || new Date().toISOString(), - })) - ); - - // 更新统计 - if (stats) { - setStats({ - ...stats, - unread: 0, - }); - } - - toast.success(`已标记 ${unreadUuids.length} 条消息为已读`); - } catch (e) { - toast.error(e instanceof Error ? e.message : '批量标记已读失败'); - } - }, [messages, stats]); - - // 处理邀请(接受/拒绝) - const handleInvitation = useCallback( - async (message: Message, action: 'accept' | 'reject') => { - // 检查是否是团队邀请消息 - if (message.message_type !== 'team_invitation') { - toast.error(t('notification.acceptInvitationFailed')); - return; - } - - // 检查是否已经处理过 - if (message.action_status === 'accepted' || message.action_status === 'rejected') { - toast.error(t('notification.acceptInvitationFailed')); - return; - } - - // 检查 metadata 中是否有 token - if (!message.metadata || !message.metadata.token) { - toast.error(t('notification.acceptInvitationFailed')); - return; - } - - try { - if (action === 'accept') { - // 先接受邀请 - const teamUuid = await acceptInvitation({ token: message.metadata.token as string }); - - // 然后更新消息状态 - await handleMessage({ - message_uuid: message.message_uuid, - action: 'accept', - }); - - // 标记消息为已读(如果还未读) - if (!message.is_read) { - await markAsRead(message.message_uuid); - } - - // 更新本地状态 - setMessages((prev) => - prev.map((msg) => - msg.message_uuid === message.message_uuid - ? { - ...msg, - action_status: 'accepted', - action_at: new Date().toISOString(), - is_read: true, - read_at: message.read_at || new Date().toISOString(), - } - : msg - ) - ); - - // 触发团队数据刷新(让 user-menu 和 team-header 重新加载团队列表) - useRefreshStore.getState().refreshTeams(); - - toast.success(t('notification.acceptInvitationSuccess')); - } else { - // 先拒绝邀请(更新 team_invitations 表的状态) - await rejectInvitation({ token: message.metadata.token as string }); - - // 然后更新消息状态 - await handleMessage({ - message_uuid: message.message_uuid, - action: 'reject', - }); - - // 标记消息为已读(如果还未读) - if (!message.is_read) { - await markAsRead(message.message_uuid); - } - - // 更新本地状态 - setMessages((prev) => - prev.map((msg) => - msg.message_uuid === message.message_uuid - ? { - ...msg, - action_status: 'rejected', - action_at: new Date().toISOString(), - is_read: true, - read_at: message.read_at || new Date().toISOString(), - } - : msg - ) - ); - - toast.success(t('notification.rejectInvitationSuccess')); - } - - // 刷新统计 - await loadStats(); - } catch (e) { - const errorMessage = - e instanceof Error - ? e.message - : action === 'accept' - ? t('notification.acceptInvitationFailed') - : t('notification.rejectInvitationFailed'); - toast.error(errorMessage); - console.error('Failed to handle invitation:', e); - } - }, - [loadStats, t, markAsRead] - ); - - // 初始加载和筛选条件改变时重新加载 - useEffect(() => { - void refresh(); - // eslint-disable-next-line react-hooks/exhaustive-deps - }, [messageTypeFilter, isReadFilter]); - - return { - messages, - stats, - loading, - error, - currentPage, - totalPages, - messageTypeFilter, - isReadFilter, - refresh, - loadMore, - markAsRead, - markAllAsRead, - handleInvitation, - setMessageTypeFilter, - setIsReadFilter, - }; -} diff --git a/plugins/layouts/app-layout/src/components/titlebar/messages-dialog.tsx b/plugins/layouts/app-layout/src/components/titlebar/messages-dialog.tsx index 58f4fa4..bab5d45 100644 --- a/plugins/layouts/app-layout/src/components/titlebar/messages-dialog.tsx +++ b/plugins/layouts/app-layout/src/components/titlebar/messages-dialog.tsx @@ -1,13 +1,10 @@ import { useEffect, useMemo } from 'react'; import { BellOff, - Check, CheckCheck, - CheckCircle2, Inbox, Info, Loader2, - X, } from 'lucide-react'; import { useTranslation } from 'react-i18next'; import { format, isToday, isYesterday } from 'date-fns'; @@ -49,7 +46,6 @@ export function MessagesDialog({ open, onOpenChange }: MessagesDialogProps) { const loadMore = useMessagesStore((state) => state.loadMore); const markAsRead = useMessagesStore((state) => state.markAsRead); const markAllAsRead = useMessagesStore((state) => state.markAllAsRead); - const handleInvitation = useMessagesStore((state) => state.handleInvitation); const setMessageTypeFilter = useMessagesStore((state) => state.setMessageTypeFilter); const setIsReadFilter = useMessagesStore((state) => state.setIsReadFilter); @@ -116,15 +112,6 @@ export function MessagesDialog({ open, onOpenChange }: MessagesDialogProps) { } }; - const handleInvitationClick = async ( - e: React.MouseEvent, - message: (typeof messages)[0], - action: 'accept' | 'reject' - ) => { - e.stopPropagation(); - await handleInvitation(message, action); - }; - const handleTypeFilter = (type: string | null) => { if (type === 'team') { setMessageTypeFilter(TEAM_MESSAGE_TYPES[0]); @@ -337,41 +324,6 @@ export function MessagesDialog({ open, onOpenChange }: MessagesDialogProps) {

) : null} - {message.message_type === 'team_invitation' ? ( -
- {message.action_status === 'accepted' ? ( - - - {t('notification.invitationAccepted')} - - ) : message.action_status === 'rejected' ? ( - - - {t('notification.invitationRejected')} - - ) : ( - <> - - - - )} -
- ) : null}
diff --git a/plugins/layouts/app-layout/src/components/titlebar/notification-menu.tsx b/plugins/layouts/app-layout/src/components/titlebar/notification-menu.tsx index bbd9e6c..6208194 100644 --- a/plugins/layouts/app-layout/src/components/titlebar/notification-menu.tsx +++ b/plugins/layouts/app-layout/src/components/titlebar/notification-menu.tsx @@ -6,12 +6,9 @@ import { Loader2, Eye, Info, - UserPlus, UserMinus, MessageSquare, AlertCircle, - Check, - X, ChevronRight, } from 'lucide-react'; import { useTranslation } from 'react-i18next'; @@ -34,7 +31,6 @@ import { Tooltip, TooltipContent, TooltipTrigger } from '@/components/ui/tooltip // 消息类型映射 const MESSAGE_TYPE_MAP: Record = { system_notification: 'notification.systemNotification', - team_invitation: 'notification.teamInvitation', team_removal: 'notification.teamRemoval', team_announcement: 'notification.teamAnnouncement', private_chat: 'notification.privateChat', @@ -43,7 +39,6 @@ const MESSAGE_TYPE_MAP: Record = { // 消息类型图标映射 const MESSAGE_TYPE_ICONS: Record = { system_notification: Info, - team_invitation: UserPlus, team_removal: UserMinus, team_announcement: MessageSquare, private_chat: MessageSquare, @@ -70,7 +65,6 @@ export function NotificationMenu() { const loadMore = useMessagesStore((state) => state.loadMore); const markAsRead = useMessagesStore((state) => state.markAsRead); const markAllAsRead = useMessagesStore((state) => state.markAllAsRead); - const handleInvitation = useMessagesStore((state) => state.handleInvitation); const setMessageTypeFilter = useMessagesStore((state) => state.setMessageTypeFilter); const setIsReadFilter = useMessagesStore((state) => state.setIsReadFilter); @@ -112,7 +106,6 @@ export function NotificationMenu() { const getMessageTypeIconColor = (type: string) => { const colorMap: Record = { system_notification: 'text-blue-500', - team_invitation: 'text-green-500', team_removal: 'text-red-500', team_announcement: 'text-purple-500', private_chat: 'text-orange-500', @@ -127,16 +120,6 @@ export function NotificationMenu() { } }; - // 处理邀请按钮点击 - const handleInvitationClick = async ( - e: React.MouseEvent, - message: (typeof messages)[0], - action: 'accept' | 'reject' - ) => { - e.stopPropagation(); - await handleInvitation(message, action); - }; - // 获取已读状态显示文本 const getReadStatusText = () => { if (isReadFilter === null) return t('notification.filterAll'); @@ -331,44 +314,6 @@ export function NotificationMenu() {

)} - {/* 团队邀请操作按钮 */} - {message.message_type === 'team_invitation' && ( -
- {message.action_status === 'accepted' ? ( -
- - {t('notification.invitationAccepted')} -
- ) : message.action_status === 'rejected' ? ( -
- - {t('notification.invitationRejected')} -
- ) : ( -
- - -
- )} -
- )} - {/* 底部元信息 */}
diff --git a/plugins/layouts/app-layout/src/components/titlebar/user-menu.tsx b/plugins/layouts/app-layout/src/components/titlebar/user-menu.tsx index f12a6d0..700135c 100644 --- a/plugins/layouts/app-layout/src/components/titlebar/user-menu.tsx +++ b/plugins/layouts/app-layout/src/components/titlebar/user-menu.tsx @@ -22,7 +22,7 @@ import { DropdownMenuSubTrigger, DropdownMenuSubContent, } from '@/components/ui/dropdown-menu'; -import { Avatar, AvatarImage, AvatarFallback } from '@/components/ui/avatar'; +import { Avatar, AvatarFallback } from '@/components/ui/avatar'; import { useUserMenuConfig } from './hooks/use-user-menu-config'; import { getMyTeams, switchTeam, type TeamItem } from '../../api/teams'; import { useWorkspaceStore } from '../../../../../services/store/src/stores/workspace'; @@ -56,7 +56,7 @@ export function UserMenu() { const { setCurrentTeam } = useAuthStore(); const handleLogout = async () => { - // 先清理 Tauri 侧的 token / remembered credential,避免下次启动被自动登录拉起 + // 清理当前本地会话,返回用户选择界面。 try { const { invoke } = await import('@tauri-apps/api/core'); await invoke('logout'); @@ -125,11 +125,7 @@ export function UserMenu() { } }; - const displayName = - user && (user.nickname || user.email?.split('@')[0]) - ? user.nickname || user.email!.split('@')[0] - : 'User'; - const initial = displayName.charAt(0).toUpperCase(); + const displayName = user?.nickname || t('status.user'); // 获取当前工作空间 const currentWorkspace = workspaces.find((ws) => ws.is_current); @@ -143,11 +139,10 @@ export function UserMenu() { -
- ) : ( - - - - - - - - - - {services.map((service) => ( - - - - - - ))} - -
{t('autoRenewal.serviceDetails')}{t('autoRenewal.renewalPrice')}{t('autoRenewal.nextBillDate')}
{service.serviceName} - ${service.renewalPrice} {service.currency} - {service.nextBillDate}
- )} -
- - ); -} diff --git a/plugins/pages/billing-center/src/components/billing-page-skeleton.tsx b/plugins/pages/billing-center/src/components/billing-page-skeleton.tsx deleted file mode 100644 index 3857db2..0000000 --- a/plugins/pages/billing-center/src/components/billing-page-skeleton.tsx +++ /dev/null @@ -1,51 +0,0 @@ -import { Skeleton } from '@/components/ui/skeleton'; - -export function BillingPageSkeleton() { - return ( -
-
-
- {/* 头部区域 */} -
- - -
- - {/* 统计卡片区域 */} -
- {Array.from({ length: 4 }).map((_, index) => ( -
- - - -
- ))} -
- - {/* 表格区域 */} -
-
- -
-
- {Array.from({ length: 5 }).map((_, index) => ( -
- - - - - - - -
- ))} -
-
-
-
-
- ); -} diff --git a/plugins/pages/billing-center/src/components/billing-pagination.tsx b/plugins/pages/billing-center/src/components/billing-pagination.tsx deleted file mode 100644 index 3b7a208..0000000 --- a/plugins/pages/billing-center/src/components/billing-pagination.tsx +++ /dev/null @@ -1,116 +0,0 @@ -import { useTranslation } from 'react-i18next'; -import { - Pagination, - PaginationContent, - PaginationItem, - PaginationLink, - PaginationNext, - PaginationPrevious, - PaginationEllipsis, -} from '@/components/ui/pagination'; - -interface BillingPaginationProps { - currentPage: number; - totalPages: number; - onPageChange: (page: number) => void; -} - -/** - * 账单分页组件 - */ -export function BillingPagination({ - currentPage, - totalPages, - onPageChange, -}: BillingPaginationProps) { - const { t } = useTranslation('billing'); - - const getPageNumbers = () => { - const pages: (number | 'ellipsis')[] = []; - const maxVisible = 7; - - if (totalPages <= maxVisible) { - for (let i = 1; i <= totalPages; i++) { - pages.push(i); - } - } else if (currentPage <= 3) { - for (let i = 1; i <= 4; i++) pages.push(i); - pages.push('ellipsis'); - pages.push(totalPages); - } else if (currentPage >= totalPages - 2) { - pages.push(1); - pages.push('ellipsis'); - for (let i = totalPages - 3; i <= totalPages; i++) pages.push(i); - } else { - pages.push(1); - pages.push('ellipsis'); - for (let i = currentPage - 1; i <= currentPage + 1; i++) pages.push(i); - pages.push('ellipsis'); - pages.push(totalPages); - } - - return pages; - }; - - return ( -
-
- {t('invoices.pagination.pageInfo', { current: currentPage, total: totalPages })} -
- - - - { - e.preventDefault(); - if (currentPage > 1) onPageChange(currentPage - 1); - }} - className={ - currentPage === 1 || totalPages === 0 - ? 'pointer-events-none opacity-50' - : 'cursor-pointer' - } - > - {t('invoices.pagination.previous')} - - - - {getPageNumbers().map((page, index) => ( - - {page === 'ellipsis' ? ( - - ) : ( - { - e.preventDefault(); - onPageChange(page); - }} - isActive={page === currentPage} - className="cursor-pointer" - > - {page} - - )} - - ))} - - - { - e.preventDefault(); - if (currentPage < totalPages) onPageChange(currentPage + 1); - }} - className={ - currentPage === totalPages || totalPages === 0 - ? 'pointer-events-none opacity-50' - : 'cursor-pointer' - } - > - {t('invoices.pagination.next')} - - - - -
- ); -} diff --git a/plugins/pages/billing-center/src/components/billing-table-skeleton.tsx b/plugins/pages/billing-center/src/components/billing-table-skeleton.tsx deleted file mode 100644 index 603ac11..0000000 --- a/plugins/pages/billing-center/src/components/billing-table-skeleton.tsx +++ /dev/null @@ -1,46 +0,0 @@ -import { Skeleton } from '@/components/ui/skeleton'; - -interface BillingTableSkeletonProps { - rows?: number; -} - -export function BillingTableSkeleton({ rows = 8 }: BillingTableSkeletonProps) { - return ( - <> - {Array.from({ length: rows }).map((_, index) => ( - - {/* 复选框列 */} - -
- -
- - {/* 类型列 */} - - - - {/* 金额列 */} - - - - {/* 描述列 */} - - - - {/* 状态列 */} - - - - {/* 创建时间列 */} - - - - {/* 操作列 */} - - - - - ))} - - ); -} diff --git a/plugins/pages/billing-center/src/components/billing-tabs.tsx b/plugins/pages/billing-center/src/components/billing-tabs.tsx deleted file mode 100644 index 87c4a2c..0000000 --- a/plugins/pages/billing-center/src/components/billing-tabs.tsx +++ /dev/null @@ -1,42 +0,0 @@ -import { useTranslation } from 'react-i18next'; -import { cn } from '@/lib/utils'; - -type BillingTab = 'wallet' | 'invoices'; - -interface BillingTabsProps { - activeTab: BillingTab; - onTabChange: (tab: BillingTab) => void; -} - -/** - * 计费中心 Tab 导航组件 - */ -export function BillingTabs({ activeTab, onTabChange }: BillingTabsProps) { - const { t } = useTranslation('billing'); - - const tabs: { value: BillingTab; key: string }[] = [ - { value: 'wallet', key: 'tabs.wallet' }, - { value: 'invoices', key: 'tabs.invoices' }, - ]; - - return ( -
-
- {tabs.map((tab) => ( - - ))} -
-
- ); -} diff --git a/plugins/pages/billing-center/src/components/coupons-dialog.tsx b/plugins/pages/billing-center/src/components/coupons-dialog.tsx deleted file mode 100644 index 231d981..0000000 --- a/plugins/pages/billing-center/src/components/coupons-dialog.tsx +++ /dev/null @@ -1,213 +0,0 @@ -import { useTranslation } from 'react-i18next'; -import { useNavigate } from 'react-router'; -import { useState, useEffect } from 'react'; -import { Ticket } from 'lucide-react'; -import { FormattedDialog, FormattedDialogFooter } from '@/components/formatted-dialog'; -import { Button } from '@/components/ui/button'; -import { ScrollArea } from '@/components/ui/scroll-area'; -import { getMyCoupons } from '../api'; -import type { UserCoupon } from '../api/index.types'; - -interface CouponsDialogProps { - open: boolean; - onOpenChange: (open: boolean) => void; -} - -/** - * 优惠券列表弹窗组件 - */ -export function CouponsDialog({ open, onOpenChange }: CouponsDialogProps) { - const { t, i18n } = useTranslation('billing'); - const navigate = useNavigate(); - const [allCoupons, setAllCoupons] = useState([]); - const [loading, setLoading] = useState(true); - - useEffect(() => { - if (open) { - loadCoupons(); - } - }, [open]); - - const loadCoupons = async () => { - try { - setLoading(true); - // 获取所有优惠券(使用较大的 page_size) - const data = await getMyCoupons({ - pagination: { page: 1, page_size: 1000 }, - }); - setAllCoupons(data.items || []); - } catch (error) { - console.error('获取优惠券失败:', error); - setAllCoupons([]); - } finally { - setLoading(false); - } - }; - - const formatDiscount = (coupon: UserCoupon) => { - let value: number; - if (typeof coupon.discount_value === 'string') { - value = parseFloat(coupon.discount_value); - } else if (typeof coupon.discount_value === 'number') { - value = coupon.discount_value; - } else { - value = 0; - } - - if (isNaN(value) || value == null) { - return '-$0.00'; - } - - if (coupon.discount_type === 'percentage') { - return `-${value}%`; - } else { - return `-$${value.toFixed(2)}`; - } - }; - - const getStatusBadge = (status: string) => { - switch (status) { - case 'unused': - return ( - - {t('coupons.status.unused')} - - ); - case 'used': - return ( - - {t('coupons.status.used')} - - ); - case 'expired': - return ( - - {t('coupons.status.expired')} - - ); - default: - return null; - } - }; - - const formatDate = (dateStr: string) => { - const date = new Date(dateStr); - const locale = i18n.language === 'en-US' ? 'en-US' : 'zh-CN'; - return date.toLocaleDateString(locale); - }; - - return ( - - {loading ? ( -
- {[1, 2, 3, 4, 5].map((i) => ( -
- ))} -
- ) : allCoupons.length === 0 ? ( -
-
- -
-

{t('coupons.noCoupons')}

-

- {t('coupons.noCouponsDescription')} -

-
- ) : ( - -
- {allCoupons.map((coupon) => ( -
{ - onOpenChange(false); - navigate(`/plans?coupon=${coupon.code}`); - }} - > - {/* 背景装饰 */} -
- -
- - {/* 内容 */} -
-
-
-
- - - {coupon.name || coupon.code || t('coupons.unknown')} - -
- {coupon.description && ( -
- {coupon.description} -
- )} -
- {formatDiscount(coupon)} - {coupon.min_amount != null && (() => { - const minAmount = typeof coupon.min_amount === 'string' - ? parseFloat(coupon.min_amount) - : coupon.min_amount; - return minAmount != null && !isNaN(minAmount) && minAmount > 0 ? ( - - {t('coupons.minAmount', { - amount: minAmount.toFixed(2), - })} - - ) : null; - })()} -
-
- {getStatusBadge(coupon.status)} -
- {coupon.expires_at && ( -
- {t('coupons.expiresAt', { - date: formatDate(coupon.expires_at), - })} -
- )} -
-
- ))} -
-
- )} - - - - - - - ); -} diff --git a/plugins/pages/billing-center/src/components/coupons-list.tsx b/plugins/pages/billing-center/src/components/coupons-list.tsx deleted file mode 100644 index 23607d7..0000000 --- a/plugins/pages/billing-center/src/components/coupons-list.tsx +++ /dev/null @@ -1,191 +0,0 @@ -import { useTranslation } from 'react-i18next'; -import { useNavigate } from 'react-router'; -import { useState, useEffect } from 'react'; -import { Ticket, ChevronRight } from 'lucide-react'; -import { Button } from '@/components/ui/button'; -import { getAvailableCoupons } from '../api'; -import { CouponsDialog } from './coupons-dialog'; -import type { UserCoupon } from '../api/index.types'; - -/** - * 优惠券列表组件 - */ -export function CouponsList() { - const { t, i18n } = useTranslation('billing'); - const navigate = useNavigate(); - const [coupons, setCoupons] = useState([]); - const [loading, setLoading] = useState(true); - const [dialogOpen, setDialogOpen] = useState(false); - - useEffect(() => { - loadCoupons(); - }, []); - - const loadCoupons = async () => { - try { - setLoading(true); - const data = await getAvailableCoupons(); - setCoupons(data || []); - } catch (error) { - console.error('获取优惠券失败:', error); - setCoupons([]); - } finally { - setLoading(false); - } - }; - - const formatDiscount = (coupon: UserCoupon) => { - let value: number; - if (typeof coupon.discount_value === 'string') { - value = parseFloat(coupon.discount_value); - } else if (typeof coupon.discount_value === 'number') { - value = coupon.discount_value; - } else { - value = 0; - } - - if (isNaN(value) || value == null) { - return '-$0.00'; - } - - if (coupon.discount_type === 'percentage') { - return `-${value}%`; - } else { - return `-$${value.toFixed(2)}`; - } - }; - - const getStatusBadge = (status: string) => { - switch (status) { - case 'unused': - return ( - - {t('coupons.status.unused')} - - ); - case 'used': - return ( - - {t('coupons.status.used')} - - ); - case 'expired': - return ( - - {t('coupons.status.expired')} - - ); - default: - return null; - } - }; - - if (loading) { - return ( -
-
-

{t('coupons.title')}

-
-
- {[1, 2].map((i) => ( -
- ))} -
-
- ); - } - - if (coupons.length === 0) { - return ( -
-
-

{t('coupons.title')}

-
-
- -

{t('coupons.noCoupons')}

- -
-
- ); - } - - return ( -
-
-

{t('coupons.title')}

- -
-
- {coupons.slice(0, 5).map((coupon) => ( -
navigate(`/plans?coupon=${coupon.code}`)} - > - {/* 背景装饰 */} -
- -
- - {/* 内容 */} -
-
-
-
- - - {coupon.name || coupon.code || t('coupons.unknown')} - -
- {coupon.description && ( -
- {coupon.description} -
- )} -
- {formatDiscount(coupon)} - {coupon.min_amount != null && (() => { - const minAmount = typeof coupon.min_amount === 'string' - ? parseFloat(coupon.min_amount) - : coupon.min_amount; - return minAmount != null && !isNaN(minAmount) && minAmount > 0 ? ( - - {t('coupons.minAmount', { amount: minAmount.toFixed(2) })} - - ) : null; - })()} -
-
- {getStatusBadge(coupon.status)} -
- {coupon.expires_at && ( -
- {t('coupons.expiresAt', { - date: new Date(coupon.expires_at).toLocaleDateString( - i18n.language === 'en-US' ? 'en-US' : 'zh-CN' - ), - })} -
- )} -
-
- ))} -
- -
- ); -} diff --git a/plugins/pages/billing-center/src/components/current-plan-card.tsx b/plugins/pages/billing-center/src/components/current-plan-card.tsx deleted file mode 100644 index 2ae149f..0000000 --- a/plugins/pages/billing-center/src/components/current-plan-card.tsx +++ /dev/null @@ -1,81 +0,0 @@ -import { useTranslation } from 'react-i18next'; -import { useNavigate } from 'react-router'; -import { Package, Crown, Zap, Globe, Sparkles, TrendingUp } from 'lucide-react'; -import { Button } from '@/components/ui/button'; -import type { AccountInfo } from '../types'; - -interface CurrentPlanCardProps { - account: AccountInfo; -} - -/** - * 当前套餐信息卡片组件 - */ -export function CurrentPlanCard({ account }: CurrentPlanCardProps) { - const { t } = useTranslation('billing'); - const navigate = useNavigate(); - - return ( -
-
- {/* 背景装饰图标 */} -
- {account.planId === 'free' ? ( - <> - - - - ) : ( - <> - - - - - )} -
-
-
-
- {account.planId === 'free' ? ( - - ) : ( - - )} - {account.planName} -
-
- {account.environmentsLimit} {t('currentPlan.windows')} -
-
-
- - - {t('dailyLimits.create')}: {account.dailyCreateLimit} - -
-
- - - {t('dailyLimits.open')}: {account.dailyOpenLimit} - -
-
-
-
- {account.planId === 'free' && ( -
{t('currentPlan.premiumPrompt')}
- )} - -
-
-
-
- ); -} diff --git a/plugins/pages/billing-center/src/components/invoice-date-filter.tsx b/plugins/pages/billing-center/src/components/invoice-date-filter.tsx deleted file mode 100644 index 9b7a1e7..0000000 --- a/plugins/pages/billing-center/src/components/invoice-date-filter.tsx +++ /dev/null @@ -1,98 +0,0 @@ -import { useState } from 'react'; -import { X, CalendarIcon } from 'lucide-react'; -import { Popover, PopoverContent, PopoverTrigger } from '@/components/ui/popover'; -import { Calendar } from '@/components/ui/calendar'; -import { format } from 'date-fns'; -import { zhCN } from 'date-fns/locale'; - -interface InvoiceDateFilterProps { - startDate: string; - endDate: string; - onStartDateChange: (value: string) => void; - onEndDateChange: (value: string) => void; -} - -/** - * 账单日期筛选组件 - */ -export function InvoiceDateFilter({ - startDate, - endDate, - onStartDateChange, - onEndDateChange, -}: InvoiceDateFilterProps) { - const [startDateOpen, setStartDateOpen] = useState(false); - const [endDateOpen, setEndDateOpen] = useState(false); - - const formatDateDisplay = (dateStr: string) => { - if (!dateStr) return null; - try { - return format(new Date(dateStr), 'MM/dd', { locale: zhCN }); - } catch { - return dateStr; - } - }; - - return ( -
- - - - - - { - onStartDateChange(date ? format(date, 'yyyy-MM-dd') : ''); - setStartDateOpen(false); - }} - /> - - - - - - - - - - { - onEndDateChange(date ? format(date, 'yyyy-MM-dd') : ''); - setEndDateOpen(false); - }} - /> - - - {(startDate || endDate) && ( - - )} -
- ); -} diff --git a/plugins/pages/billing-center/src/components/invoices-skeleton.tsx b/plugins/pages/billing-center/src/components/invoices-skeleton.tsx deleted file mode 100644 index 04ccdd6..0000000 --- a/plugins/pages/billing-center/src/components/invoices-skeleton.tsx +++ /dev/null @@ -1,68 +0,0 @@ -import { Skeleton } from '@/components/ui/skeleton'; -import { BillingTableSkeleton } from './billing-table-skeleton'; - -export function InvoicesSkeleton() { - return ( -
- {/* 顶部搜索栏 */} -
-
- -
-
- -
-
- - {/* 统计信息 */} -
- {Array.from({ length: 5 }).map((_, index) => ( -
- - -
- ))} -
- - {/* 账单表格 */} -
-
-
- - - - {Array.from({ length: 7 }).map((_, index) => ( - - ))} - - - - - -
- -
-
-
-
- - {/* 分页 */} -
- -
- - - - - -
-
-
- ); -} diff --git a/plugins/pages/billing-center/src/components/invoices-tab.tsx b/plugins/pages/billing-center/src/components/invoices-tab.tsx deleted file mode 100644 index 706ee04..0000000 --- a/plugins/pages/billing-center/src/components/invoices-tab.tsx +++ /dev/null @@ -1,278 +0,0 @@ -import { useTranslation } from 'react-i18next'; -import { DollarSign, AlertCircle, CheckCircle2, ChevronDown, X } from 'lucide-react'; -import { - DropdownMenu, - DropdownMenuContent, - DropdownMenuItem, - DropdownMenuTrigger, -} from '@/components/ui/dropdown-menu'; -import { ScrollArea, ScrollBar } from '@/components/ui/scroll-area'; -import { BillingTableSkeleton } from './billing-table-skeleton'; -import { InvoiceDateFilter } from './invoice-date-filter'; -import { BillingPagination } from './billing-pagination'; -import type { Invoice, InvoiceStatusFilter } from '../types'; - -interface InvoicesTabProps { - invoices: Invoice[]; - loading: boolean; - error: string | null; - searchQuery: string; - statusFilter: InvoiceStatusFilter; - typeFilter: string; - startDate: string; - endDate: string; - currentPage: number; - totalPages: number; - paginatedInvoices: Invoice[]; - stats: { - total: number; - todayCount: number; - weekCount: number; - todayAmount: number; - weekAmount: number; - }; - invoiceTypes: string[]; - onSearchChange: (value: string) => void; - onStatusFilterChange: (value: InvoiceStatusFilter) => void; - onTypeFilterChange: (value: string) => void; - onStartDateChange: (value: string) => void; - onEndDateChange: (value: string) => void; - onPageChange: (page: number) => void; - onRefresh: () => void; -} - -/** - * 账单标签页组件 - */ -export function InvoicesTab({ - invoices: _invoices, - loading, - error, - searchQuery, - statusFilter, - typeFilter, - startDate, - endDate, - currentPage, - totalPages, - paginatedInvoices, - stats, - invoiceTypes, - onSearchChange, - onStatusFilterChange, - onTypeFilterChange, - onStartDateChange, - onEndDateChange, - onPageChange, - onRefresh, -}: InvoicesTabProps) { - const { t } = useTranslation('billing'); - - return ( -
- {error && ( -
{t('invoices.error', { error })}
- )} - - {/* 账单表格 */} -
- -
- - - - {/* 类型筛选 */} - - - - {/* 状态筛选 */} - - - {/* 日期筛选 */} - - - - - {loading ? ( - - ) : paginatedInvoices.length === 0 ? ( - - - - ) : ( - paginatedInvoices.map((inv) => ( - - - - - - - - - )) - )} - -
-
- {t('invoices.table.type')} - {!typeFilter ? ( - - - - - - {invoiceTypes.map((type) => ( - onTypeFilterChange(type)} - className="text-xs cursor-pointer" - > - {type} - - ))} - - - ) : ( - <> - - {typeFilter} - - - - )} -
-
- {t('invoices.table.amount')} - - {t('invoices.table.description')} - -
- {t('invoices.table.status')} - {statusFilter === 'all' ? ( - - - - - - onStatusFilterChange('paid')} - className="text-xs cursor-pointer" - > - {t('invoices.statusPaid')} - - onStatusFilterChange('pending')} - className="text-xs cursor-pointer" - > - {t('invoices.statusPending')} - - onStatusFilterChange('failed')} - className="text-xs cursor-pointer" - > - {t('invoices.statusFailed')} - - - - ) : ( - <> - - {t( - `invoices.status${statusFilter.charAt(0).toUpperCase() + statusFilter.slice(1)}` - )} - - - - )} -
-
- {t('invoices.table.operator')} - -
- {t('invoices.table.date')} - -
-
- -
{t('invoices.noData')}
-
-
{inv.type}
-
-
- {inv.amount} {inv.currency} -
-
-
{inv.description}
-
- {inv.status === 'paid' && ( - - - {t('invoices.statusPaid')} - - )} - {inv.status === 'pending' && ( - - - {t('invoices.statusPending')} - - )} - {inv.status === 'failed' && ( - - - {t('invoices.statusFailed')} - - )} - -
{inv.operator}
-
-
- {inv.createdAt} -
-
-
- -
-
- - {/* 分页 */} - { - if (page < 1 || page > totalPages) return; - onPageChange(page); - }} - /> -
- ); -} diff --git a/plugins/pages/billing-center/src/components/promotion-card.tsx b/plugins/pages/billing-center/src/components/promotion-card.tsx deleted file mode 100644 index 6c9e260..0000000 --- a/plugins/pages/billing-center/src/components/promotion-card.tsx +++ /dev/null @@ -1,40 +0,0 @@ -import { useTranslation } from 'react-i18next'; -import { useNavigate } from 'react-router'; -import { Sparkles, ArrowRight } from 'lucide-react'; -import { Button } from '@/components/ui/button'; - -/** - * 优惠活动卡片组件 - */ -export function PromotionCard() { - const { t } = useTranslation('billing'); - const navigate = useNavigate(); - - return ( -
- {/* 背景装饰 */} -
- -
- - {/* 内容 */} -
-
- -

{t('promotion.title')}

-
-

{t('promotion.description')}

- -
-
- ); -} - diff --git a/plugins/pages/billing-center/src/components/resource-usage-card.tsx b/plugins/pages/billing-center/src/components/resource-usage-card.tsx deleted file mode 100644 index 258a857..0000000 --- a/plugins/pages/billing-center/src/components/resource-usage-card.tsx +++ /dev/null @@ -1,107 +0,0 @@ -import { ArrowRight } from 'lucide-react'; -import { useTranslation } from 'react-i18next'; -import { useNavigate } from 'react-router'; -import type { ResourceUsage } from '../types'; -import { getUsagePercentage, getUsageStatus } from '../utils/resource-usage'; - -interface ResourceUsageCardProps { - resource: ResourceUsage; -} - -/** - * 资源使用情况卡片组件 - */ -export function ResourceUsageCard({ resource }: ResourceUsageCardProps) { - const { t } = useTranslation('billing'); - const navigate = useNavigate(); - const percentage = getUsagePercentage(resource.used, resource.limit); - getUsageStatus(percentage); // 计算状态(虽然未使用,但保留逻辑) - const Icon = resource.icon; - - return ( -
- {/* 管理链接 - 右上角 */} - {resource.link && ( - - )} -
- {/* 图标和名称 */} -
- - {resource.name} -
- - {/* 圆形进度条 */} -
- - {/* 外层轮廓圆 */} - - {/* 背景圆 */} - - {/* 进度圆 */} - = 90 - ? 'text-destructive' - : percentage >= 70 - ? 'text-warning' - : 'text-foreground' - }`} - /> - - {/* 中心文本 */} -
- {percentage.toFixed(0)}% -
-
- - {/* 使用量信息 */} -
- = 90 - ? 'text-destructive' - : percentage >= 70 - ? 'text-warning' - : 'text-foreground' - }`} - > - {resource.used}/{resource.limit} {resource.unit} - -
-
-
- ); -} diff --git a/plugins/pages/billing-center/src/components/wallet-card.tsx b/plugins/pages/billing-center/src/components/wallet-card.tsx deleted file mode 100644 index 43f90a6..0000000 --- a/plugins/pages/billing-center/src/components/wallet-card.tsx +++ /dev/null @@ -1,168 +0,0 @@ -import { useEffect, useRef } from 'react'; -import { useTranslation } from 'react-i18next'; -import { Wallet, Circle } from 'lucide-react'; - -interface WalletCardProps { - balance: number; - giftBalance: number; -} - -/** - * 钱包卡片组件 - 带 Canvas 装饰 - */ -export function WalletCard({ balance, giftBalance }: WalletCardProps) { - const { t } = useTranslation('billing'); - const canvasRef = useRef(null); - - useEffect(() => { - const canvas = canvasRef.current; - if (!canvas) return; - - const ctx = canvas.getContext('2d'); - if (!ctx) return; - - const drawPattern = () => { - const dpr = window.devicePixelRatio || 1; - const rect = canvas.getBoundingClientRect(); - const width = rect.width; - const height = rect.height; - - canvas.width = width * dpr; - canvas.height = height * dpr; - ctx.scale(dpr, dpr); - canvas.style.width = width + 'px'; - canvas.style.height = height + 'px'; - - ctx.clearRect(0, 0, width, height); - - // 绘制网格背景 - const gridSize = 16; - ctx.strokeStyle = 'rgba(0, 0, 0, 0.04)'; - ctx.lineWidth = 0.5; - - for (let x = 0; x <= width; x += gridSize) { - ctx.beginPath(); - ctx.moveTo(x + 0.5, 0); - ctx.lineTo(x + 0.5, height); - ctx.stroke(); - } - - for (let y = 0; y <= height; y += gridSize) { - ctx.beginPath(); - ctx.moveTo(0, y + 0.5); - ctx.lineTo(width, y + 0.5); - ctx.stroke(); - } - - // 绘制装饰性渐变圆圈 - const circles = [ - { x: width * 0.2, y: height * 0.15, radius: 50, opacity: 0.08 }, - { x: width * 0.8, y: height * 0.25, radius: 60, opacity: 0.06 }, - { x: width * 0.75, y: height * 0.75, radius: 40, opacity: 0.07 }, - ]; - - circles.forEach((circle) => { - const gradient = ctx.createRadialGradient( - circle.x, - circle.y, - 0, - circle.x, - circle.y, - circle.radius - ); - gradient.addColorStop(0, `rgba(0, 0, 0, ${circle.opacity})`); - gradient.addColorStop(0.5, `rgba(0, 0, 0, ${circle.opacity * 0.5})`); - gradient.addColorStop(1, 'rgba(0, 0, 0, 0)'); - - ctx.beginPath(); - ctx.arc(circle.x, circle.y, circle.radius, 0, Math.PI * 2); - ctx.fillStyle = gradient; - ctx.fill(); - }); - - // 绘制点阵装饰 - ctx.fillStyle = 'rgba(0, 0, 0, 0.02)'; - const dotSpacing = 24; - for (let x = dotSpacing; x < width; x += dotSpacing) { - for (let y = dotSpacing; y < height; y += dotSpacing) { - if (Math.random() > 0.85) { - ctx.beginPath(); - ctx.arc(x, y, 1, 0, Math.PI * 2); - ctx.fill(); - } - } - } - }; - - // 初始绘制 - drawPattern(); - - // 监听窗口大小变化 - const resizeObserver = new ResizeObserver(() => { - drawPattern(); - }); - resizeObserver.observe(canvas); - - return () => { - resizeObserver.disconnect(); - }; - }, []); - - return ( -
- {/* Canvas 背景 */} - - {/* 内容层 - 重新设计的银行卡样式 */} -
- {/* 顶部区域 */} -
-
- {/* 加密圆圈装饰 */} -
- {Array.from({ length: 20 }).map((_, i) => ( - - ))} -
- {/* 余额标签 */} -
- {t('wallet.balance')} -
- {/* 余额金额 */} -
- ${balance.toFixed(2)} -
-
- {/* 钱包图标 */} -
- -
-
- - {/* 中间区域 - 如果有赠送金 */} - {giftBalance > 0 && ( -
-
- {t('wallet.gift')}:{' '} - - ${giftBalance.toFixed(2)} - -
-
- )} - - {/* 底部区域 */} -
-
- {t('wallet.title')} -
- {/* 装饰性元素 - 模拟芯片 */} -
- {Array.from({ length: 6 }).map((_, i) => ( -
- ))} -
-
-
-
- ); -} diff --git a/plugins/pages/billing-center/src/components/wallet-skeleton.tsx b/plugins/pages/billing-center/src/components/wallet-skeleton.tsx deleted file mode 100644 index cd2504d..0000000 --- a/plugins/pages/billing-center/src/components/wallet-skeleton.tsx +++ /dev/null @@ -1,155 +0,0 @@ -import { Skeleton } from '@/components/ui/skeleton'; - -export function WalletSkeleton() { - return ( -
-
- {/* 左侧内容 - 2/3 宽度 */} -
- {/* 当前套餐信息 */} -
-
-
-
-
- - -
- -
-
- - -
-
- - -
-
-
-
- - -
-
-
-
- - {/* 资源使用情况 */} -
- -
- {Array.from({ length: 3 }).map((_, index) => ( -
- {/* 管理链接 - 右上角 */} -
- -
-
- {/* 图标和名称 */} -
- - -
- - {/* 圆形进度条 */} -
- -
- -
-
- - {/* 使用量信息 */} - -
-
- ))} -
-
- - {/* 自动续订服务 */} -
- -
- {/* 空状态 skeleton */} -
- - - -
-
-
-
- - {/* 右侧内容 - 1/3 宽度 */} -
- {/* 钱包信息 */} -
-
- - -
- {/* 银行卡样式 skeleton - 完全匹配 WalletCard 结构 */} -
- {/* Canvas 背景占位 */} -
- {/* 内容层 */} -
- {/* 顶部区域 */} -
-
- {/* 加密圆圈装饰 */} -
- {Array.from({ length: 20 }).map((_, i) => ( - - ))} -
- {/* 余额标签 */} - - {/* 余额金额 */} - -
- {/* 钱包图标 */} - -
- - {/* 底部区域 */} -
- - {/* 装饰性元素 - 模拟芯片 */} -
- {Array.from({ length: 6 }).map((_, i) => ( - - ))} -
-
-
-
- -
- - {/* 自动续订组合支付 */} -
-
- - -
- -
- - {/* 月结费用(条件渲染,可能不显示) */} -
- - - - -
-
-
-
- ); -} diff --git a/plugins/pages/billing-center/src/components/wallet-tab.tsx b/plugins/pages/billing-center/src/components/wallet-tab.tsx deleted file mode 100644 index f8bf0fd..0000000 --- a/plugins/pages/billing-center/src/components/wallet-tab.tsx +++ /dev/null @@ -1,98 +0,0 @@ -import { useTranslation } from 'react-i18next'; -import { useNavigate } from 'react-router'; -import { RefreshCw } from 'lucide-react'; -import { Button } from '@/components/ui/button'; -import { WalletCard } from './wallet-card'; -import { CurrentPlanCard } from './current-plan-card'; -import { ResourceUsageCard } from './resource-usage-card'; -import { AutoRenewalServices } from './auto-renewal-services'; -import { CouponsList } from './coupons-list'; -import { calculateResourceUsages } from '../utils/resource-usage'; -import type { AccountInfo, AutoRenewalService } from '../types'; - -interface WalletTabProps { - account: AccountInfo; - autoRenewalServices: AutoRenewalService[]; - onRefresh: () => void; -} - -/** - * 钱包标签页组件 - */ -export function WalletTab({ - account, - autoRenewalServices, - onRefresh, -}: WalletTabProps) { - const { t } = useTranslation('billing'); - const navigate = useNavigate(); - const resourceUsages = calculateResourceUsages(account, t); - - return ( -
-
- {/* 左侧内容 - 2/3 宽度 */} -
- {/* 当前套餐信息 */} - - - {/* 资源使用情况 */} -
-

{t('resources.title')}

-
- {resourceUsages.map((resource) => ( - - ))} -
-
- - {/* 自动续订服务 */} - -
- - {/* 右侧内容 - 1/3 宽度 */} -
- {/* 钱包信息 */} -
-
-

{t('wallet.title')}

- -
- {/* 银行卡样式 - 使用 WalletCard 组件 */} - - -
- - {/* 分割线 */} -
- - {/* 优惠券列表 */} - - - {/* 月结费用 */} - {account.monthlyBilling > 0 && ( -
-

{t('monthly.title')}

-
- ${account.monthlyBilling.toFixed(2)} -
-
- {t('monthly.basicPackage')}: ${account.monthlyBilling.toFixed(2)} -
- -
- )} -
-
-
- ); -} diff --git a/plugins/pages/billing-center/src/constants.ts b/plugins/pages/billing-center/src/constants.ts deleted file mode 100644 index 020b028..0000000 --- a/plugins/pages/billing-center/src/constants.ts +++ /dev/null @@ -1,4 +0,0 @@ -/** - * 每页显示的项目数 - */ -export const ITEMS_PER_PAGE = 10; diff --git a/plugins/pages/billing-center/src/hooks/use-billing-data.ts b/plugins/pages/billing-center/src/hooks/use-billing-data.ts deleted file mode 100644 index fb4065d..0000000 --- a/plugins/pages/billing-center/src/hooks/use-billing-data.ts +++ /dev/null @@ -1,101 +0,0 @@ -import { useEffect, useState } from 'react'; -import type { AccountInfo, AutoRenewalService } from '../types'; -import { - getAccountInfo, - getAutoRenewalServices, - type AccountInfoResponse, - type AutoRenewalServiceResponse, -} from '../api'; - -interface UseBillingDataReturn { - accountInfo: AccountInfo | null; - autoRenewalServices: AutoRenewalService[]; - loading: boolean; - error: string | null; - refresh: () => Promise; -} - -/** - * 转换后端账户信息为前端格式 - */ -function transformAccountInfo(data: AccountInfoResponse): AccountInfo { - const subscription = data.subscription; - const quota = data.quota; - - return { - planName: subscription ? '已订阅' : '免费', - planId: subscription?.plan_uuid || 'free', - email: data.email, - billingDate: subscription?.next_billing_date || null, - walletBalance: Number(data.wallet_balance), - giftBalance: Number(data.gift_balance), - monthlyBilling: Number(data.monthly_billing), - environmentsUsed: quota.used_environments, - environmentsLimit: quota.max_environments, - teamMembersUsed: quota.used_team_members, - teamMembersLimit: quota.max_team_members, - proxyCount: quota.used_proxies, - proxyLimit: quota.max_proxies, - dailyCreateLimit: 150, // TODO: 如果后端有这些字段,从配额中获取 - dailyOpenLimit: 500, - dailyCreateUsed: 0, - dailyOpenUsed: 0, - }; -} - -/** - * 转换自动续费服务 - */ -function transformAutoRenewalServices( - data: AutoRenewalServiceResponse[] -): AutoRenewalService[] { - return data.map((item) => ({ - id: item.uuid, - serviceName: item.service_name, - renewalPrice: Number(item.renewal_price), - currency: item.currency, - nextBillDate: item.next_bill_date, - })); -} - -/** - * 获取计费数据的 Hook - */ -export function useBillingData(): UseBillingDataReturn { - const [accountInfo, setAccountInfo] = useState(null); - const [autoRenewalServices, setAutoRenewalServices] = useState([]); - const [loading, setLoading] = useState(false); - const [error, setError] = useState(null); - - const fetchData = async () => { - setLoading(true); - setError(null); - try { - const [accountData, autoRenewalData] = await Promise.all([ - getAccountInfo(), - getAutoRenewalServices(), - ]); - - setAccountInfo(transformAccountInfo(accountData)); - setAutoRenewalServices(transformAutoRenewalServices(autoRenewalData)); - } catch (e) { - setError(e instanceof Error ? e.message : '未知错误'); - setAccountInfo(null); - setAutoRenewalServices([]); - } finally { - setLoading(false); - } - }; - - useEffect(() => { - void fetchData(); - }, []); - - return { - accountInfo, - autoRenewalServices, - loading, - error, - refresh: fetchData, - }; -} diff --git a/plugins/pages/billing-center/src/hooks/use-billing-tabs.ts b/plugins/pages/billing-center/src/hooks/use-billing-tabs.ts deleted file mode 100644 index d871bc8..0000000 --- a/plugins/pages/billing-center/src/hooks/use-billing-tabs.ts +++ /dev/null @@ -1,25 +0,0 @@ -import { useState } from 'react'; - -type BillingTab = 'wallet' | 'invoices'; - -interface UseBillingTabsReturn { - activeTab: BillingTab; - setActiveTab: (tab: BillingTab) => void; -} - -/** - * 计费中心 Tab 切换逻辑 Hook - */ -export function useBillingTabs(onTabChange?: (tab: BillingTab) => void): UseBillingTabsReturn { - const [activeTab, setActiveTabState] = useState('wallet'); - - const setActiveTab = (tab: BillingTab) => { - setActiveTabState(tab); - onTabChange?.(tab); - }; - - return { - activeTab, - setActiveTab, - }; -} diff --git a/plugins/pages/billing-center/src/hooks/use-invoice-filters-state.ts b/plugins/pages/billing-center/src/hooks/use-invoice-filters-state.ts deleted file mode 100644 index 90e8056..0000000 --- a/plugins/pages/billing-center/src/hooks/use-invoice-filters-state.ts +++ /dev/null @@ -1,126 +0,0 @@ -import { useState, useCallback } from 'react'; -import type { InvoiceStatusFilter } from '../types'; - -interface InvoiceFiltersState { - searchQuery: string; - statusFilter: InvoiceStatusFilter; - typeFilter: string; - startDate: string; - endDate: string; -} - -interface UseInvoiceFiltersStateReturn { - filters: InvoiceFiltersState; - setSearchQuery: (value: string) => void; - setStatusFilter: (value: InvoiceStatusFilter) => void; - setTypeFilter: (value: string) => void; - setStartDate: (value: string) => void; - setEndDate: (value: string) => void; - resetFilters: () => void; - updateFilter: (filterType: keyof InvoiceFiltersState, value: string) => void; -} - -/** - * 发票过滤状态管理 Hook - */ -export function useInvoiceFiltersState(onFilterChange?: () => void): UseInvoiceFiltersStateReturn { - const [searchQuery, setSearchQueryState] = useState(''); - const [statusFilter, setStatusFilterState] = useState('all'); - const [typeFilter, setTypeFilterState] = useState(''); - const [startDate, setStartDateState] = useState(''); - const [endDate, setEndDateState] = useState(''); - - const handleFilterChange = useCallback(() => { - onFilterChange?.(); - }, [onFilterChange]); - - const setSearchQuery = useCallback( - (value: string) => { - setSearchQueryState(value); - handleFilterChange(); - }, - [handleFilterChange] - ); - - const setStatusFilter = useCallback( - (value: InvoiceStatusFilter) => { - setStatusFilterState(value); - handleFilterChange(); - }, - [handleFilterChange] - ); - - const setTypeFilter = useCallback( - (value: string) => { - setTypeFilterState(value); - handleFilterChange(); - }, - [handleFilterChange] - ); - - const setStartDate = useCallback( - (value: string) => { - setStartDateState(value); - handleFilterChange(); - }, - [handleFilterChange] - ); - - const setEndDate = useCallback( - (value: string) => { - setEndDateState(value); - handleFilterChange(); - }, - [handleFilterChange] - ); - - const resetFilters = useCallback(() => { - setSearchQueryState(''); - setStatusFilterState('all'); - setTypeFilterState(''); - setStartDateState(''); - setEndDateState(''); - handleFilterChange(); - }, [handleFilterChange]); - - const updateFilter = useCallback( - (filterType: keyof InvoiceFiltersState, value: string) => { - switch (filterType) { - case 'searchQuery': - setSearchQueryState(value); - break; - case 'statusFilter': - setStatusFilterState(value as InvoiceStatusFilter); - break; - case 'typeFilter': - setTypeFilterState(value); - break; - case 'startDate': - setStartDateState(value); - break; - case 'endDate': - setEndDateState(value); - break; - } - handleFilterChange(); - }, - [handleFilterChange] - ); - - return { - filters: { - searchQuery, - statusFilter, - typeFilter, - startDate, - endDate, - }, - setSearchQuery, - setStatusFilter, - setTypeFilter, - setStartDate, - setEndDate, - resetFilters, - updateFilter, - }; -} diff --git a/plugins/pages/billing-center/src/hooks/use-invoice-filters.ts b/plugins/pages/billing-center/src/hooks/use-invoice-filters.ts deleted file mode 100644 index fb8f734..0000000 --- a/plugins/pages/billing-center/src/hooks/use-invoice-filters.ts +++ /dev/null @@ -1,58 +0,0 @@ -import { useMemo } from 'react'; -import type { Invoice, InvoiceStatusFilter } from '../types'; - -interface InvoiceFilters { - searchQuery: string; - statusFilter: InvoiceStatusFilter; - typeFilter: string; - startDate: string; - endDate: string; -} - -/** - * 账单过滤 Hook - */ -export function useInvoiceFilters(invoices: Invoice[], filters: InvoiceFilters): Invoice[] { - return useMemo(() => { - let result = [...invoices]; - - // 搜索筛选 - if (filters.searchQuery.trim()) { - const query = filters.searchQuery.toLowerCase(); - result = result.filter( - (inv) => - inv.type.toLowerCase().includes(query) || - inv.description.toLowerCase().includes(query) || - inv.operator.toLowerCase().includes(query) || - inv.id.toLowerCase().includes(query) - ); - } - - // 状态筛选 - if (filters.statusFilter !== 'all') { - result = result.filter((inv) => inv.status === filters.statusFilter); - } - - // 类型筛选 - if (filters.typeFilter) { - result = result.filter((inv) => inv.type === filters.typeFilter); - } - - // 日期筛选 - if (filters.startDate) { - result = result.filter((inv) => inv.createdAt >= filters.startDate); - } - if (filters.endDate) { - result = result.filter((inv) => inv.createdAt <= filters.endDate + ' 23:59:59'); - } - - return result; - }, [ - invoices, - filters.searchQuery, - filters.statusFilter, - filters.typeFilter, - filters.startDate, - filters.endDate, - ]); -} diff --git a/plugins/pages/billing-center/src/hooks/use-invoice-pagination.ts b/plugins/pages/billing-center/src/hooks/use-invoice-pagination.ts deleted file mode 100644 index a1f8e7e..0000000 --- a/plugins/pages/billing-center/src/hooks/use-invoice-pagination.ts +++ /dev/null @@ -1,39 +0,0 @@ -import { useMemo } from 'react'; -import type { Invoice } from '../types'; -import { ITEMS_PER_PAGE } from '../constants'; - -interface UseInvoicePaginationReturn { - paginatedInvoices: Invoice[]; - totalPages: number; - startIndex: number; - endIndex: number; -} - -/** - * 账单分页 Hook - */ -export function useInvoicePagination( - invoices: Invoice[], - currentPage: number -): UseInvoicePaginationReturn { - const { paginatedInvoices, totalPages, startIndex, endIndex } = useMemo(() => { - const totalPages = Math.max(1, Math.ceil(invoices.length / ITEMS_PER_PAGE)); - const startIndex = (currentPage - 1) * ITEMS_PER_PAGE; - const endIndex = startIndex + ITEMS_PER_PAGE; - const paginatedInvoices = invoices.slice(startIndex, endIndex); - - return { - paginatedInvoices, - totalPages, - startIndex, - endIndex, - }; - }, [invoices, currentPage]); - - return { - paginatedInvoices, - totalPages, - startIndex, - endIndex, - }; -} diff --git a/plugins/pages/billing-center/src/hooks/use-invoice-selection.ts b/plugins/pages/billing-center/src/hooks/use-invoice-selection.ts deleted file mode 100644 index d7d12b8..0000000 --- a/plugins/pages/billing-center/src/hooks/use-invoice-selection.ts +++ /dev/null @@ -1,59 +0,0 @@ -import { useState, useMemo } from 'react'; -import type { Invoice } from '../types'; - -interface UseInvoiceSelectionReturn { - selectedInvoiceIds: Set; - allInvoicesSelected: boolean; - someInvoicesSelected: boolean; - selectInvoice: (id: string, selected: boolean) => void; - selectAllInvoices: (invoices: Invoice[], selected: boolean) => void; - clearSelection: () => void; -} - -/** - * 发票选择逻辑 Hook - */ -export function useInvoiceSelection(invoices: Invoice[]): UseInvoiceSelectionReturn { - const [selectedInvoiceIds, setSelectedInvoiceIds] = useState>(new Set()); - - const selectInvoice = (id: string, selected: boolean) => { - setSelectedInvoiceIds((prev) => { - const newSelected = new Set(prev); - if (selected) { - newSelected.add(id); - } else { - newSelected.delete(id); - } - return newSelected; - }); - }; - - const selectAllInvoices = (invoicesToSelect: Invoice[], selected: boolean) => { - if (selected) { - setSelectedInvoiceIds(new Set(invoicesToSelect.map((inv) => inv.id))); - } else { - setSelectedInvoiceIds(new Set()); - } - }; - - const clearSelection = () => { - setSelectedInvoiceIds(new Set()); - }; - - const allInvoicesSelected = useMemo(() => { - return invoices.length > 0 && invoices.every((inv) => selectedInvoiceIds.has(inv.id)); - }, [invoices, selectedInvoiceIds]); - - const someInvoicesSelected = useMemo(() => { - return invoices.some((inv) => selectedInvoiceIds.has(inv.id)); - }, [invoices, selectedInvoiceIds]); - - return { - selectedInvoiceIds, - allInvoicesSelected, - someInvoicesSelected, - selectInvoice, - selectAllInvoices, - clearSelection, - }; -} diff --git a/plugins/pages/billing-center/src/hooks/use-invoice-stats.ts b/plugins/pages/billing-center/src/hooks/use-invoice-stats.ts deleted file mode 100644 index 4fe2272..0000000 --- a/plugins/pages/billing-center/src/hooks/use-invoice-stats.ts +++ /dev/null @@ -1,32 +0,0 @@ -import { useMemo } from 'react'; -import type { Invoice } from '../types'; - -interface InvoiceStats { - total: number; - todayCount: number; - weekCount: number; - todayAmount: number; - weekAmount: number; -} - -/** - * 账单统计数据 Hook - */ -export function useInvoiceStats(invoices: Invoice[]): InvoiceStats { - return useMemo(() => { - const today = new Date().toISOString().slice(0, 10); - const weekAgo = new Date(Date.now() - 7 * 24 * 60 * 60 * 1000).toISOString().slice(0, 10); - const todayInvoices = invoices.filter((inv) => inv.createdAt.startsWith(today)); - const weekInvoices = invoices.filter((inv) => inv.createdAt >= weekAgo); - const todayAmount = todayInvoices.reduce((sum, inv) => sum + inv.amount, 0); - const weekAmount = weekInvoices.reduce((sum, inv) => sum + inv.amount, 0); - - return { - total: invoices.length, - todayCount: todayInvoices.length, - weekCount: weekInvoices.length, - todayAmount, - weekAmount, - }; - }, [invoices]); -} diff --git a/plugins/pages/billing-center/src/hooks/use-invoices-data.ts b/plugins/pages/billing-center/src/hooks/use-invoices-data.ts deleted file mode 100644 index 5e4dcac..0000000 --- a/plugins/pages/billing-center/src/hooks/use-invoices-data.ts +++ /dev/null @@ -1,54 +0,0 @@ -import { useEffect, useMemo } from 'react'; -import { useInvoices } from './use-invoices'; -import { useInvoiceFilters } from './use-invoice-filters'; -import { useInvoicePagination } from './use-invoice-pagination'; -import { useInvoiceStats } from './use-invoice-stats'; -import { useInvoiceSelection } from './use-invoice-selection'; -import type { InvoiceStatusFilter } from '../types'; - -interface InvoiceFilters { - searchQuery: string; - statusFilter: InvoiceStatusFilter; - typeFilter: string; - startDate: string; - endDate: string; -} - -interface UseInvoicesDataReturn { - invoices: ReturnType; - filteredInvoices: ReturnType; - pagination: ReturnType; - stats: ReturnType; - selection: ReturnType; - invoiceTypes: string[]; -} - -/** - * 整合发票相关数据的 Hook - */ -export function useInvoicesData( - filters: InvoiceFilters, - currentPage: number, - activeTab: 'wallet' | 'invoices' -): UseInvoicesDataReturn { - const invoices = useInvoices(); - - const filteredInvoices = useInvoiceFilters(invoices.invoices, filters); - const pagination = useInvoicePagination(filteredInvoices, currentPage); - const stats = useInvoiceStats(filteredInvoices); - const selection = useInvoiceSelection(pagination.paginatedInvoices); - - const invoiceTypes = useMemo(() => { - const types = new Set(invoices.invoices.map((inv) => inv.type)); - return Array.from(types); - }, [invoices.invoices]); - - return { - invoices, - filteredInvoices, - pagination, - stats, - selection, - invoiceTypes, - }; -} diff --git a/plugins/pages/billing-center/src/hooks/use-invoices.ts b/plugins/pages/billing-center/src/hooks/use-invoices.ts deleted file mode 100644 index 14dc185..0000000 --- a/plugins/pages/billing-center/src/hooks/use-invoices.ts +++ /dev/null @@ -1,65 +0,0 @@ -import { useEffect, useState, useCallback } from 'react'; -import type { Invoice } from '../types'; -import { getInvoices, type InvoiceItem } from '../api'; - -interface UseInvoicesReturn { - invoices: Invoice[]; - loading: boolean; - error: string | null; - refresh: () => Promise; -} - -/** - * 转换后端发票格式为前端格式 - */ -function transformInvoice(item: InvoiceItem): Invoice { - return { - id: item.uuid, - createdAt: item.created_at, - amount: Number(item.amount), - currency: item.currency, - status: item.status as 'paid' | 'pending' | 'failed', - type: item.invoice_type, - description: item.invoice_number, - operator: '', // TODO: 如果后端有操作者字段,从后端获取 - }; -} - -/** - * 获取账单列表的 Hook - */ -export function useInvoices(): UseInvoicesReturn { - const [invoices, setInvoices] = useState([]); - const [loading, setLoading] = useState(false); - const [error, setError] = useState(null); - - const fetchInvoices = useCallback(async () => { - setLoading(true); - setError(null); - try { - const result = await getInvoices({ - pagination: { - page: 1, - page_size: 1000, // TODO: 如果需要分页,可以改为动态参数 - }, - }); - setInvoices(result.items.map(transformInvoice)); - } catch (e) { - setError(e instanceof Error ? e.message : '未知错误'); - setInvoices([]); - } finally { - setLoading(false); - } - }, []); - - useEffect(() => { - void fetchInvoices(); - }, [fetchInvoices]); - - return { - invoices, - loading, - error, - refresh: fetchInvoices, - }; -} diff --git a/plugins/pages/billing-center/src/i18n/resources.ts b/plugins/pages/billing-center/src/i18n/resources.ts deleted file mode 100644 index 1aa9650..0000000 --- a/plugins/pages/billing-center/src/i18n/resources.ts +++ /dev/null @@ -1,275 +0,0 @@ -export const billingResources = { - 'zh-CN': { - title: '费用中心', - subtitle: '管理您的账户和账单', - loading: '加载中...', - error: '加载失败:{{error}}', - banner: { - current: '当前:{{plan}}({{limit}}环境)', - usage: '用量:{{used}}/{{total}}', - }, - currentPlan: { - title: '当前套餐', - free: '免费版', - upgrade: '升级套餐', - change: '更换套餐', - purchase: '购买套餐', - nextBilling: '下次账单日期', - windows: '窗口', - premiumPrompt: '成为付费用户,显示高级功能', - }, - resources: { - title: '资源使用情况', - environments: '浏览器环境', - teamMembers: '团队成员', - proxies: '代理IP', - unit: '个', - manage: '管理', - warningHigh: '使用率过高,建议升级套餐', - warningMedium: '使用率较高,请注意', - }, - dailyLimits: { - title: '每日使用限制', - create: '创建次数', - open: '打开次数', - }, - autoRenewal: { - title: '自动续订服务', - serviceDetails: '服务详情', - renewalPrice: '续费价格', - nextBillDate: '下一次账单日期', - noServices: '您目前没有将服务设置为自动续订', - setup: '设置自动续订', - }, - tabs: { - wallet: '钱包', - invoices: '账单', - }, - invoices: { - title: '账单记录', - type: '类型', - amount: '金额', - description: '描述', - status: '状态', - date: '日期', - noData: '暂无账单记录', - statusPaid: '已支付', - statusPending: '待支付', - statusFailed: '支付失败', - searchPlaceholder: '搜索账单...', - refresh: '刷新', - loading: '加载中...', - error: '加载失败:{{error}}', - filters: { - allStatuses: '全部状态', - allTypes: '全部类型', - }, - stats: { - total: '总计', - today: '今日', - week: '本周', - todayAmount: '今日金额', - weekAmount: '本周金额', - }, - pagination: { - showing: '显示 {{start}}-{{end}} 条,共 {{total}} 条', - pageInfo: '第 {{current}} 页,共 {{total}} 页', - previous: '上一页', - next: '下一页', - }, - table: { - type: '类型', - amount: '金额', - description: '描述', - status: '状态', - operator: '操作者', - date: '日期', - }, - }, - wallet: { - title: '钱包', - balance: '余额', - gift: '赠送金', - recharge: '充值', - promotionText: '参与', - promotionLink: '推广计划', - promotionSuffix: '获得奖励', - autoRenewal: { - title: '启用自动续订组合支付', - description: '当钱包余额不足以自动续订时,自动用卡片组合支付。(仅默认卡片会被用于组合支付)', - }, - cards: { - title: '卡片信息', - addNew: '新增', - noCards: '暂无绑定卡片', - }, - }, - account: { - title: '账户信息', - email: '邮箱', - billingDate: '月结日期', - }, - monthly: { - title: '月结费用', - basicPackage: '基础套餐', - upgrade: '升级套餐', - }, - promotion: { - title: '限时优惠', - description: '首充送 20% 额外余额,立即充值享受优惠', - action: '立即充值', - }, - coupons: { - title: '我的优惠券', - noCoupons: '暂无可用优惠券', - noCouponsDescription: '您目前还没有优惠券,前往套餐页面查看可用优惠', - viewPlans: '查看套餐', - viewAll: '查看全部', - close: '关闭', - searchPlaceholder: '搜索优惠券代码...', - noMatch: '未找到匹配的优惠券', - dialogDescription: '查看和管理您的所有优惠券', - status: { - unused: '未使用', - used: '已使用', - expired: '已过期', - }, - minAmount: '满{{amount}}可用', - expiresAt: '有效期至 {{date}}', - unknown: '优惠券', - }, - }, - 'en-US': { - title: 'Billing Center', - subtitle: 'Manage your account and billing', - loading: 'Loading...', - error: 'Failed to load: {{error}}', - banner: { - current: 'Current:{{plan}}({{limit}} environments)', - usage: 'Usage:{{used}}/{{total}}', - }, - currentPlan: { - title: 'Current Plan', - free: 'Free', - upgrade: 'Upgrade Plan', - change: 'Change Plan', - purchase: 'Purchase Package', - nextBilling: 'Next Billing Date', - windows: 'Windows', - premiumPrompt: 'Become a paid user to unlock premium features', - }, - resources: { - title: 'Resource Usage', - environments: 'Browser Environments', - teamMembers: 'Team Members', - proxies: 'Proxy IPs', - unit: '', - manage: 'Manage', - warningHigh: 'Usage is too high, consider upgrading', - warningMedium: 'Usage is high, please note', - }, - dailyLimits: { - title: 'Daily Usage Limits', - create: 'Creation Count', - open: 'Open Count', - }, - autoRenewal: { - title: 'Automatic Renewal Services', - serviceDetails: 'Service Details', - renewalPrice: 'Renewal Price', - nextBillDate: 'Next Bill Date', - noServices: 'You currently have no services set for automatic renewal', - setup: 'Setup Auto-renewal', - }, - tabs: { - wallet: 'Wallet', - invoices: 'Invoices', - }, - invoices: { - title: 'Invoice Records', - type: 'Type', - amount: 'Amount', - description: 'Description', - status: 'Status', - date: 'Date', - noData: 'No invoice records', - statusPaid: 'Paid', - statusPending: 'Pending', - statusFailed: 'Failed', - stats: { - total: 'Total', - today: 'Today', - week: 'This Week', - todayAmount: 'Today Amount', - weekAmount: 'Week Amount', - }, - pagination: { - showing: 'Showing {{start}}-{{end}} of {{total}}', - pageInfo: 'Page {{current}} / {{total}}', - previous: 'Previous', - next: 'Next', - }, - table: { - type: 'Type', - amount: 'Amount', - description: 'Description', - status: 'Status', - operator: 'Operator', - date: 'Date', - }, - }, - wallet: { - title: 'Wallet', - balance: 'Balance', - gift: 'Gift Amount', - recharge: 'Recharge', - promotionText: 'Participate in', - promotionLink: 'Promotion Plan', - promotionSuffix: 'to earn rewards', - autoRenewal: { - title: 'Enable Automatic Renewal Combined Payment', - description: - 'When the wallet balance is insufficient for automatic renewal, automatically use card combination payment. (Only the default card will be used for combined payment)', - }, - cards: { - title: 'Card Information', - addNew: 'Add New', - noCards: 'No bound cards yet', - }, - }, - account: { - title: 'Account Information', - email: 'Email', - billingDate: 'Monthly Billing Date', - }, - monthly: { - title: 'Monthly Billing', - basicPackage: 'Basic Package', - upgrade: 'Upgrade Package', - }, - promotion: { - title: 'Limited Offer', - description: 'Get 20% extra balance on first recharge', - action: 'Recharge Now', - }, - coupons: { - title: 'My Coupons', - noCoupons: 'No available coupons', - noCouponsDescription: 'You currently have no coupons. Visit the plans page to see available offers', - viewPlans: 'View Plans', - viewAll: 'View All', - close: 'Close', - searchPlaceholder: 'Search coupon code...', - noMatch: 'No matching coupons found', - dialogDescription: 'View and manage all your coupons', - status: { - unused: 'Unused', - used: 'Used', - expired: 'Expired', - }, - minAmount: 'Min {{amount}}', - expiresAt: 'Expires {{date}}', - unknown: 'Coupon', - }, - }, -} as const; diff --git a/plugins/pages/billing-center/src/index.tsx b/plugins/pages/billing-center/src/index.tsx deleted file mode 100644 index d484e3d..0000000 --- a/plugins/pages/billing-center/src/index.tsx +++ /dev/null @@ -1,123 +0,0 @@ -import { extensionRegistry } from '@slotkitjs/core'; -import { useTranslation } from 'react-i18next'; -import { billingResources } from './i18n/resources'; -import { useState } from 'react'; -import { WalletSkeleton } from './components/wallet-skeleton'; -import { InvoicesSkeleton } from './components/invoices-skeleton'; -import { WalletTab } from './components/wallet-tab'; -import { InvoicesTab } from './components/invoices-tab'; -import { BillingTabs } from './components/billing-tabs'; -import { useBillingData } from './hooks/use-billing-data'; -import { useBillingTabs } from './hooks/use-billing-tabs'; -import { useInvoiceFiltersState } from './hooks/use-invoice-filters-state'; -import { useInvoicesData } from './hooks/use-invoices-data'; - -const BillingCenterPage: React.FC = () => { - const { t } = useTranslation('billing'); - const [currentPage, setCurrentPage] = useState(1); - - // Tab 管理 - const { activeTab, setActiveTab } = useBillingTabs(); - - // 数据获取 - const { accountInfo, autoRenewalServices, loading, error, refresh } = useBillingData(); - - // 过滤状态管理 - const filtersState = useInvoiceFiltersState(() => { - setCurrentPage(1); - }); - - // 发票数据(整合了过滤、分页、统计、选择等逻辑) - const invoicesData = useInvoicesData(filtersState.filters, currentPage, activeTab); - - return ( -
- {/* 顶部区域 - 包含 Tab 导航 */} - - -
- {error &&
{t('error', { error })}
} -
- {/* 钱包标签页 */} - {activeTab === 'wallet' && - (loading || !accountInfo ? ( - - ) : ( - - ))} - - {/* 账单标签页 */} - {activeTab === 'invoices' && - (invoicesData.invoices.loading ? ( - - ) : ( - - ))} -
-
-
- ); -}; - -// 在模块加载时贡献路由 -try { - extensionRegistry.contribute('routes', { - contributorId: 'billing-center', - value: { - path: '/billing', - Component: BillingCenterPage, - }, - priority: 10, - }); - console.log('[billing-center] Route contributed at module load: /billing'); -} catch (error) { - console.warn('[billing-center] Failed to contribute route at module load:', error); -} - -try { - extensionRegistry.contribute('i18n:resources', { - contributorId: 'billing-center', - value: { - namespace: 'billing', - resources: billingResources, - }, - priority: 10, - }); -} catch (error) { - console.warn('[billing-center] Failed to contribute i18n resources:', error); -} - -const billingCenterPlugin = { - id: 'billing-center', - name: 'Billing Center', - version: '1.0.0', - component: BillingCenterPage, - slots: [], -}; - -export default billingCenterPlugin; diff --git a/plugins/pages/billing-center/src/types/index.ts b/plugins/pages/billing-center/src/types/index.ts deleted file mode 100644 index 30221be..0000000 --- a/plugins/pages/billing-center/src/types/index.ts +++ /dev/null @@ -1,103 +0,0 @@ -export interface BillingPlan { - id: string; - name: string; - pricePerMonth: number; - currency: string; - environmentsLimit: number; - description: string; -} - -export interface Invoice { - id: string; - createdAt: string; - amount: number; - currency: string; - status: 'paid' | 'pending' | 'failed'; - type: string; - description: string; - operator: string; -} - -export interface AccountInfo { - planName: string; - planId: string; - email: string; - billingDate: string | null; - walletBalance: number; - giftBalance: number; - monthlyBilling: number; - environmentsUsed: number; - environmentsLimit: number; - teamMembersUsed: number; - teamMembersLimit: number; - proxyCount: number; - proxyLimit: number; - dailyCreateLimit: number; - dailyOpenLimit: number; - dailyCreateUsed: number; - dailyOpenUsed: number; -} - -// 后端返回的账户信息格式 -export interface AccountInfoResponse { - email: string; - wallet_balance: number; - gift_balance: number; - currency: string; - subscription: SubscriptionResponse | null; - quota: QuotaResponse; - monthly_billing: number; -} - -export interface SubscriptionResponse { - id: number; - uuid: string; - workspace_uuid: string; - user_uuid: string; - plan_uuid: string; - billing_period: string; - price: number; - currency: string; - started_at: string; - expires_at: string; - next_billing_date: string | null; - auto_renew: boolean | null; - status: string; - cancelled_at: string | null; - created_at: string; - updated_at: string; -} - -export interface QuotaResponse { - workspace_uuid: string; - max_environments: number; - used_environments: number; - max_team_members: number; - used_team_members: number; - max_proxies: number; - used_proxies: number; - max_rpa_tasks: number; - used_rpa_tasks: number; - created_at: string; - updated_at: string; -} - -export interface AutoRenewalService { - id: string; - serviceName: string; - renewalPrice: number; - currency: string; - nextBillDate: string; -} - -export interface ResourceUsage { - name: string; - used: number; - limit: number; - unit: string; - icon: React.ComponentType<{ className?: string }>; - color: string; - link?: string; -} - -export type InvoiceStatusFilter = 'all' | 'paid' | 'pending' | 'failed'; diff --git a/plugins/pages/billing-center/src/utils/resource-usage.ts b/plugins/pages/billing-center/src/utils/resource-usage.ts deleted file mode 100644 index 92ae085..0000000 --- a/plugins/pages/billing-center/src/utils/resource-usage.ts +++ /dev/null @@ -1,61 +0,0 @@ -import type { ResourceUsage } from '../types'; -import { Globe, Users } from 'lucide-react'; -import type { TFunction } from 'i18next'; -import type { AccountInfo } from '../types'; - -/** - * 计算资源使用情况 - */ -export function calculateResourceUsages( - account: AccountInfo | null, - t: TFunction<'billing'> -): ResourceUsage[] { - if (!account) return []; - - return [ - { - name: t('resources.environments'), - used: account.environmentsUsed, - limit: account.environmentsLimit, - unit: t('resources.unit'), - icon: Globe, - color: 'text-muted-foreground', - link: '/', - }, - { - name: t('resources.teamMembers'), - used: account.teamMembersUsed, - limit: account.teamMembersLimit, - unit: t('resources.unit'), - icon: Users, - color: 'text-muted-foreground', - link: '/team', - }, - { - name: t('resources.proxies'), - used: account.proxyCount, - limit: account.proxyLimit, - unit: t('resources.unit'), - icon: Globe, - color: 'text-muted-foreground', - link: '/proxy', - }, - ]; -} - -/** - * 计算使用百分比 - */ -export function getUsagePercentage(used: number, limit: number): number { - if (limit === 0) return 0; - return Math.min((used / limit) * 100, 100); -} - -/** - * 获取使用状态样式 - */ -export function getUsageStatus(percentage: number): { color: string; bg: string } { - if (percentage >= 90) return { color: 'text-destructive', bg: 'bg-destructive/10' }; - if (percentage >= 70) return { color: 'text-warning', bg: 'bg-warning/10' }; - return { color: 'text-primary', bg: 'bg-primary/10' }; -} diff --git a/plugins/pages/billing-center/tsconfig.json b/plugins/pages/billing-center/tsconfig.json deleted file mode 100644 index f5b6723..0000000 --- a/plugins/pages/billing-center/tsconfig.json +++ /dev/null @@ -1,7 +0,0 @@ -{ - "extends": "../../../tsconfig.json", - "compilerOptions": { - "jsx": "react-jsx" - }, - "include": ["src/**/*"] -} diff --git a/plugins/pages/browser-extensions/src/api/index.ts b/plugins/pages/browser-extensions/src/api/index.ts index 09e1231..5e7af73 100644 --- a/plugins/pages/browser-extensions/src/api/index.ts +++ b/plugins/pages/browser-extensions/src/api/index.ts @@ -1,52 +1,5 @@ -import { post, isSuccess } from '@/lib/request'; import { invoke } from '@/lib/tauri'; -// API 端点配置 -export const API_ENDPOINTS = { - // 扩展市场 - LIST_EXTENSIONS: 'extensions/list', - EXTENSION_DETAIL: 'extensions/detail', - CATEGORIES: 'extensions/categories', - // 已安装扩展 - INSTALLED: 'extensions/installed', - // 安装/卸载/更新 - INSTALL: 'extensions/install', - UNINSTALL: 'extensions/uninstall', - UPDATE: 'extensions/update', - BATCH_UPDATE: 'extensions/batch-update', - // 禁用/启用 - DISABLE: 'extensions/disable', - ENABLE: 'extensions/enable', -} as const; - -/** - * 扩展 DTO(后端返回格式) - */ -export interface ExtensionDto { - id: number; - uuid: string; - extension_id: string; - name: string; - description?: string; - version: string; - category?: string; - browser: string; - developer?: string; - homepage?: string; - icon_url?: string; - download_url?: string; - file_size?: number; - downloads_count?: number; - rating?: string | number; - permissions?: string[]; - status: string; - created_at: string; - updated_at: string; -} - -/** - * 扩展(前端格式) - */ export interface Extension { id: string; extensionId: string; @@ -56,57 +9,19 @@ export interface Extension { version: string; category?: string; browser: string; - source: 'remote' | 'local'; + source: 'local'; author?: string; homepage?: string; icon?: string; - downloadUrl?: string; - managedCrxPath?: string; fileSize?: number; downloads?: number; permissions?: string[]; - status: 'installed' | 'available' | 'update' | 'disabled' | 'active'; + status: 'available' | 'disabled' | 'active'; rating?: number; updatedAt?: string; createdAt?: string; hash?: string; - scope?: 'user' | 'team' | 'group-personal' | 'group-team' | 'local'; // 安装范围 - groups?: Array<{ - uuid: string; - name: string; - }>; // 关联的分组列表(包含 uuid 和 name) -} - -/** - * 转换 DTO 到前端格式 - */ -function transformExtensionDto(dto: ExtensionDto): Extension { - return { - id: dto.uuid, - extensionId: dto.extension_id, - source: 'remote', - name: dto.name, - description: dto.description || '', - version: dto.version, - category: dto.category as Extension['category'], - browser: dto.browser, - author: dto.developer, - homepage: dto.homepage, - icon: dto.icon_url, - downloadUrl: dto.download_url, - fileSize: dto.file_size, - downloads: dto.downloads_count, - permissions: dto.permissions, - status: (dto.status as Extension['status']) || 'available', - rating: dto.rating - ? typeof dto.rating === 'string' - ? parseFloat(dto.rating) - : dto.rating - : undefined, - updatedAt: dto.updated_at, - createdAt: dto.created_at, - hash: undefined, - }; + scope?: 'local'; } export interface LocalExtensionDto { @@ -153,367 +68,21 @@ function transformLocalExtensionDto(dto: LocalExtensionDto): Extension { author: dto.author, homepage: dto.homepage, icon: dto.iconUrl, - downloadUrl: undefined, fileSize: dto.fileSize, downloads: dto.downloadsCount, permissions: dto.permissions, - status: - dto.status === 'active' ? 'active' : dto.status === 'disabled' ? 'disabled' : 'available', + status: dto.status, rating: - dto.rating !== undefined - ? typeof dto.rating === 'string' - ? parseFloat(dto.rating) - : dto.rating - : undefined, + dto.rating === undefined + ? undefined + : typeof dto.rating === 'string' + ? Number(dto.rating) + : dto.rating, updatedAt: dto.updatedAt, createdAt: dto.importedAt, hash: dto.hash, scope: dto.status === 'available' ? undefined : 'local', - groups: undefined, - }; -} - -export interface ListExtensionsResponse { - items: Extension[]; - total: number; - page: number; - page_size: number; -} - -/** - * 获取扩展市场列表 - */ -export async function listExtensions(params?: { - category?: string; - search?: string; - sort_by?: 'downloads' | 'rating' | 'name' | 'newest'; - sort_order?: 'asc' | 'desc'; - page?: number; - page_size?: number; -}): Promise { - const result = await post<{ - items: ExtensionDto[]; - total: number; - page: number; - page_size: number; - }>(API_ENDPOINTS.LIST_EXTENSIONS, { - page: params?.page || 1, - page_size: params?.page_size || 12, - filters: { - category: params?.category, - keyword: params?.search, - sort_by: params?.sort_by, - sort_order: params?.sort_order, - }, - }); - if (!isSuccess(result)) { - throw new Error(result.message || '获取扩展列表失败'); - } - return { - items: (result.data?.items || []).map(transformExtensionDto), - total: result.data?.total || 0, - page: result.data?.page || params?.page || 1, - page_size: result.data?.page_size || params?.page_size || 12, - }; -} - -/** - * 获取扩展详情 - */ -export async function getExtensionDetail(extensionId: string): Promise { - const result = await post(API_ENDPOINTS.EXTENSION_DETAIL, { - extension_id: extensionId, - }); - if (!isSuccess(result)) { - throw new Error(result.message || '获取扩展详情失败'); - } - return transformExtensionDto(result.data!); -} - -/** - * 已安装扩展项(后端返回格式,包含完整扩展详情) - */ -export interface InstalledExtensionItemDto { - extension_id: string; - name: string; - version: string; - installed_version: string; - has_update: boolean; - status: string; - installed_at: string; - homepage?: string; - icon_url?: string; - team_uuid?: string; - scope: 'user' | 'team' | 'group-personal' | 'group-team'; - // 完整扩展详情字段 - description?: string; - category?: string; - browser?: string; - developer?: string; - downloads_count?: number; - rating?: string | number; - permissions?: string[]; - file_size?: number; - updated_at?: string; - groups?: Array<{ - uuid: string; - name: string; - }>; -} - -/** - * 已安装扩展响应(后端返回格式) - */ -export interface InstalledExtensionsResponseDto { - user_extensions: InstalledExtensionItemDto[]; - team_extensions: InstalledExtensionItemDto[]; -} - -/** - * 获取已安装扩展列表 - * - * @param scope - 范围过滤:'all' | 'user' | 'team' - */ -export async function listInstalledExtensions( - scope: 'all' | 'user' | 'team' = 'all' -): Promise { - const result = await post(API_ENDPOINTS.INSTALLED, { - scope: scope === 'all' ? 'all' : scope === 'user' ? 'user' : 'team', - }); - if (!isSuccess(result)) { - throw new Error(result.message || '获取已安装扩展失败'); - } - - const data = result.data; - if (!data) { - return []; - } - - // 合并用户和团队的已安装扩展 - const allInstalled: InstalledExtensionItemDto[] = [ - ...(data.user_extensions || []), - ...(data.team_extensions || []), - ]; - - // 去重(同一个扩展可能在用户和团队中都存在) - const uniqueExtensions = new Map(); - for (const item of allInstalled) { - if (!uniqueExtensions.has(item.extension_id)) { - uniqueExtensions.set(item.extension_id, item); - } - } - - // 直接使用后端返回的完整信息,无需再次调用详情接口 - const extensions: Extension[] = []; - for (const installedItem of Array.from(uniqueExtensions.values())) { - // 根据 status 和 has_update 设置状态 - let status: Extension['status']; - if (installedItem.status === 'disabled') { - status = 'disabled'; - } else if (installedItem.has_update) { - status = 'update'; - } else if (installedItem.status === 'active') { - status = 'active'; - } else { - status = 'installed'; - } - - // 转换评分 - const rating = installedItem.rating - ? typeof installedItem.rating === 'string' - ? parseFloat(installedItem.rating) - : installedItem.rating - : undefined; - - extensions.push({ - id: installedItem.extension_id, // 使用 extension_id 作为业务 ID - extensionId: installedItem.extension_id, - source: 'remote', - name: installedItem.name, - description: installedItem.description || '', - version: installedItem.installed_version, // 使用已安装的版本号 - category: installedItem.category as Extension['category'], - browser: installedItem.browser || 'chrome', - author: installedItem.developer, - homepage: installedItem.homepage, - icon: installedItem.icon_url, - downloadUrl: undefined, // 已安装扩展不需要下载 URL - fileSize: installedItem.file_size, - downloads: installedItem.downloads_count, - permissions: installedItem.permissions as string[] | undefined, - status, - rating, - updatedAt: installedItem.updated_at, - createdAt: undefined, // 已安装扩展不需要创建时间 - hash: undefined, - scope: installedItem.scope, // 保留 scope 信息 - groups: installedItem.groups, // 关联的分组列表(包含 uuid 和 name) - }); - } - - if (scope !== 'team') { - const localExtensions = await listLocalExtensions(); - extensions.push( - ...localExtensions - .filter((item) => item.status === 'active' || item.status === 'disabled') - .map((item) => ({ - ...item, - status: item.status, - scope: 'local' as const, - })) - ); - } - - return extensions; -} - -/** - * 安装扩展 - */ -export async function installExtension(data: { - extension_id: string; - group_ids?: string[]; - for_team?: boolean; - is_team_shared?: boolean; -}): Promise { - // 确定 target_type - let target_type: string; - let group_ids: string[] | undefined; - let is_team_shared: boolean | undefined; - - if (data.for_team) { - target_type = 'team'; - group_ids = undefined; - is_team_shared = undefined; - } else if (data.group_ids && data.group_ids.length > 0) { - target_type = 'group'; - group_ids = data.group_ids; // 即使只有一个分组也传入数组 - is_team_shared = data.is_team_shared; // 分组插件支持团队共享 - } else { - target_type = 'user'; - group_ids = undefined; - is_team_shared = undefined; - } - - const result = await post(API_ENDPOINTS.INSTALL, { - extension_id: data.extension_id, - target_type, - group_ids, - is_team_shared, - }); - if (!isSuccess(result)) { - throw new Error(result.message || '安装扩展失败'); - } -} - -/** - * 卸载扩展 - */ -export async function uninstallExtension( - extensionId: string, - targetType?: 'user' | 'team' | 'group', - targetUuid?: string -): Promise { - const payload: { - extension_id: string; - target_type?: string; - target_uuid?: string; - } = { - extension_id: extensionId, }; - - if (targetType) { - payload.target_type = targetType; - } - - if (targetUuid) { - payload.target_uuid = targetUuid; - } - - const result = await post(API_ENDPOINTS.UNINSTALL, payload); - if (!isSuccess(result)) { - throw new Error(result.message || '卸载扩展失败'); - } -} - -/** - * 更新扩展 - */ -export async function updateExtension(extensionId: string): Promise { - const result = await post(API_ENDPOINTS.UPDATE, { extension_id: extensionId }); - if (!isSuccess(result)) { - throw new Error(result.message || '更新扩展失败'); - } - return transformExtensionDto(result.data!); -} - -/** - * 批量更新扩展 - */ -export async function batchUpdateExtensions(extensionIds: string[]): Promise { - const result = await post(API_ENDPOINTS.BATCH_UPDATE, { extension_ids: extensionIds }); - if (!isSuccess(result)) { - throw new Error(result.message || '批量更新失败'); - } -} - -/** - * 获取扩展分类列表 - */ -export async function getCategories(): Promise { - const result = await post<{ categories: string[] }>(API_ENDPOINTS.CATEGORIES, {}); - if (!isSuccess(result)) { - throw new Error(result.message || '获取分类列表失败'); - } - return result.data?.categories || []; -} - -/** - * 分组项类型 - */ -export interface GroupItem { - uuid: string; - name: string; - description?: string; -} - -/** - * 获取分组列表 - */ -export async function listGroups(): Promise { - const result = await post('groups/list', {}); - if (!isSuccess(result)) { - throw new Error(result.message || '获取分组列表失败'); - } - return (result.data || []).map((dto: any) => ({ - uuid: dto.uuid || '', - name: dto.name || '', - description: dto.description, - })); -} - -/** - * 禁用扩展(用户级别) - */ -export async function disableExtension(extensionId: string): Promise { - const result = await post(API_ENDPOINTS.DISABLE, { - extension_id: extensionId, - }); - if (!isSuccess(result)) { - throw new Error(result.message || '禁用扩展失败'); - } -} - -/** - * 启用扩展(用户级别) - */ -export async function enableExtension(extensionId: string): Promise { - const result = await post(API_ENDPOINTS.ENABLE, { - extension_id: extensionId, - }); - if (!isSuccess(result)) { - throw new Error(result.message || '启用扩展失败'); - } } export async function listLocalExtensions(): Promise { @@ -522,18 +91,14 @@ export async function listLocalExtensions(): Promise { } export async function importLocalExtensionCrx(path: string): Promise { - const result = await invoke('import_local_extension_crx', { - path, - }); + const result = await invoke('import_local_extension_crx', { path }); return transformLocalImportResult(result); } export async function importLocalExtensionStoreUrl( storeUrl: string ): Promise { - const result = await invoke('import_local_extension_store_url', { - storeUrl, - }); + const result = await invoke('import_local_extension_store_url', { storeUrl }); return transformLocalImportResult(result); } @@ -549,36 +114,20 @@ function transformLocalImportResult(dto: LocalExtensionDto): LocalExtensionImpor }; } -export async function installLocalExtension(recordId: string): Promise { - const result = await invoke('install_local_extension', { - recordId, - }); +async function mutateLocalExtension(command: string, recordId: string): Promise { + const result = await invoke(command, { recordId }); return transformLocalExtensionDto(result); } -export async function uninstallLocalExtension(recordId: string): Promise { - const result = await invoke('uninstall_local_extension', { - recordId, - }); - return transformLocalExtensionDto(result); -} +export const installLocalExtension = (recordId: string) => + mutateLocalExtension('install_local_extension', recordId); +export const uninstallLocalExtension = (recordId: string) => + mutateLocalExtension('uninstall_local_extension', recordId); +export const disableLocalExtension = (recordId: string) => + mutateLocalExtension('disable_local_extension', recordId); +export const enableLocalExtension = (recordId: string) => + mutateLocalExtension('enable_local_extension', recordId); export async function removeLocalExtension(recordId: string): Promise { - await invoke('remove_local_extension', { - recordId, - }); -} - -export async function disableLocalExtension(recordId: string): Promise { - const result = await invoke('disable_local_extension', { - recordId, - }); - return transformLocalExtensionDto(result); -} - -export async function enableLocalExtension(recordId: string): Promise { - const result = await invoke('enable_local_extension', { - recordId, - }); - return transformLocalExtensionDto(result); + await invoke('remove_local_extension', { recordId }); } diff --git a/plugins/pages/browser-extensions/src/components/extension-batch-actions.tsx b/plugins/pages/browser-extensions/src/components/extension-batch-actions.tsx deleted file mode 100644 index 8e8034e..0000000 --- a/plugins/pages/browser-extensions/src/components/extension-batch-actions.tsx +++ /dev/null @@ -1,60 +0,0 @@ -import { useTranslation } from 'react-i18next'; - -interface ExtensionBatchActionsProps { - selectedCount: number; - onBatchInstall: () => void; - onBatchUpdate: () => void; - onBatchUninstall: () => void; - onClearSelection: () => void; -} - -export function ExtensionBatchActions({ - selectedCount, - onBatchInstall, - onBatchUpdate, - onBatchUninstall, - onClearSelection, -}: ExtensionBatchActionsProps) { - const { t } = useTranslation('extensions'); - - if (selectedCount === 0) return null; - - return ( -
-
- - {t('batch.selected')}: - - - {selectedCount.toString().padStart(2, '0')} - -
-
- - - -
- -
- ); -} diff --git a/plugins/pages/browser-extensions/src/components/extension-batch-uninstall-dialog.tsx b/plugins/pages/browser-extensions/src/components/extension-batch-uninstall-dialog.tsx deleted file mode 100644 index bdb2140..0000000 --- a/plugins/pages/browser-extensions/src/components/extension-batch-uninstall-dialog.tsx +++ /dev/null @@ -1,68 +0,0 @@ -import { AlertTriangle } from 'lucide-react'; -import { useTranslation } from 'react-i18next'; -import { - AlertDialog, - AlertDialogAction, - AlertDialogCancel, - AlertDialogContent, - AlertDialogDescription, - AlertDialogFooter, - AlertDialogHeader, - AlertDialogTitle, -} from '@/components/ui/alert-dialog'; -import { Alert, AlertDescription, AlertTitle } from '@/components/ui/alert'; - -interface ExtensionBatchUninstallDialogProps { - open: boolean; - count: number; - onOpenChange: (open: boolean) => void; - onConfirm: () => void; -} - -/** - * 批量卸载对话框组件 - */ -export function ExtensionBatchUninstallDialog({ - open, - count, - onOpenChange, - onConfirm, -}: ExtensionBatchUninstallDialogProps) { - const { t } = useTranslation('extensions'); - - return ( - - - - - - {t('dialog.batchUninstall.title')} - - - {t('dialog.batchUninstall.description', { count })} - - - - - - {t('dialog.batchUninstall.warningTitle')} - - - {t('dialog.batchUninstall.warningDescription')} - - - - - {t('dialog.batchUninstall.cancel')} - - - {t('dialog.batchUninstall.confirm')} - - - - - ); -} diff --git a/plugins/pages/browser-extensions/src/components/extension-detail-dialog.tsx b/plugins/pages/browser-extensions/src/components/extension-detail-dialog.tsx index 3c458e5..8520ba4 100644 --- a/plugins/pages/browser-extensions/src/components/extension-detail-dialog.tsx +++ b/plugins/pages/browser-extensions/src/components/extension-detail-dialog.tsx @@ -1,11 +1,7 @@ -import { useState, useEffect } from 'react'; import { useTranslation } from 'react-i18next'; -import { Loader2, Info, Package, Shield, Download, Star, Calendar } from 'lucide-react'; +import { Info } from 'lucide-react'; import { FormattedDialog, FormattedDialogFooter } from '@/components/formatted-dialog'; import { Button } from '@/components/ui/button'; -import { ScrollArea } from '@/components/ui/scroll-area'; -import { getExtensionDetail } from '../api'; -import type { Extension } from '../api'; import type { ExtensionItem } from '../types'; import { ExtensionIcon } from './extension-icon'; @@ -15,298 +11,41 @@ interface ExtensionDetailDialogProps { onOpenChange: (open: boolean) => void; } -/** - * 扩展详情对话框组件 - */ export function ExtensionDetailDialog({ open, extension, onOpenChange, }: ExtensionDetailDialogProps) { const { t } = useTranslation('extensions'); - const knownCategories = new Set([ - 'automation', - 'security', - 'productivity', - 'tools', - 'media', - 'social', - ]); - const [remoteState, setRemoteState] = useState<{ - key: string | null; - detail: Extension | null; - error: string | null; - }>({ - key: null, - detail: null, - error: null, - }); - - const remoteExtensionKey = - open && extension && extension.source !== 'local' - ? extension.extensionId || extension.id - : null; - - const localExtensionDetail = - open && extension?.source === 'local' - ? { - id: extension.id, - recordId: extension.recordId, - extensionId: extension.extensionId || extension.id, - source: 'local' as const, - name: extension.name, - description: extension.description, - version: extension.version, - category: extension.category, - browser: extension.browser, - author: extension.author, - homepage: extension.homepage, - icon: extension.icon, - downloadUrl: undefined, - fileSize: extension.fileSize, - downloads: extension.downloads, - permissions: extension.permissions, - status: extension.status, - rating: extension.rating, - updatedAt: extension.updatedAt, - createdAt: extension.createdAt, - hash: extension.hash, - scope: extension.scope, - groups: extension.groups, - } - : null; - - const hasMatchingRemoteDetail = - remoteExtensionKey !== null && - remoteState.detail !== null && - remoteState.key === remoteExtensionKey; - const hasMatchingRemoteError = - remoteExtensionKey !== null && remoteState.key === remoteExtensionKey; - const loading = - remoteExtensionKey !== null && !hasMatchingRemoteDetail && !hasMatchingRemoteError; - const error = hasMatchingRemoteError ? remoteState.error : null; - - useEffect(() => { - if (!remoteExtensionKey || hasMatchingRemoteDetail) { - return; - } - - getExtensionDetail(remoteExtensionKey) - .then((data) => { - setRemoteState({ - key: remoteExtensionKey, - detail: data, - error: null, - }); - }) - .catch((e) => { - setRemoteState({ - key: remoteExtensionKey, - detail: null, - error: e instanceof Error ? e.message : '获取扩展详情失败', - }); - }); - }, [hasMatchingRemoteDetail, remoteExtensionKey]); - - const extensionDetail = - localExtensionDetail || (hasMatchingRemoteDetail ? remoteState.detail : null); - - const formatDate = (dateString?: string) => { - if (!dateString) return '-'; - try { - const date = new Date(dateString); - return date.toLocaleDateString('zh-CN', { - year: 'numeric', - month: '2-digit', - day: '2-digit', - hour: '2-digit', - minute: '2-digit', - }); - } catch { - return dateString; - } - }; - - const formatFileSize = (bytes?: number) => { - if (!bytes) return '-'; - if (bytes < 1024) return `${bytes} B`; - if (bytes < 1024 * 1024) return `${(bytes / 1024).toFixed(2)} KB`; - return `${(bytes / (1024 * 1024)).toFixed(2)} MB`; - }; - - const formatCategory = (category?: string) => { - if (!category) return t('store.categories.all'); - return knownCategories.has(category) ? t(`store.categories.${category}`) : category; - }; + if (!extension) return null; return ( - {loading ? ( -
- +
+ +
+
{extension.name}
+
v{extension.version}
+

+ {extension.description || t('local.noDescription')} +

- ) : error ? ( -
{error}
- ) : extensionDetail ? ( - -
- {/* 基本信息 */} -
-
- -
-

- {extensionDetail.name} -

- {extensionDetail.author && ( -

{extensionDetail.author}

- )} -
- - v{extensionDetail.version} - - {extensionDetail.rating && ( -
- - - {extensionDetail.rating.toFixed(1)} - -
- )} -
-
-
- - {extensionDetail.description && ( -

- {extensionDetail.description} -

- )} -
- - {/* 详细信息 */} -
-
-
- - - {t('dialog.detail.category')} - -
-

- {formatCategory(extensionDetail.category)} -

-
- -
-
- - - {t('dialog.detail.browser')} - -
-

{t(`browser.${extensionDetail.browser}`)}

-
- -
-
- - - {t('dialog.detail.downloads')} - -
-

- {extensionDetail.downloads?.toLocaleString() || '-'} -

-
- -
-
- - - {t('dialog.detail.fileSize')} - -
-

- {formatFileSize(extensionDetail.fileSize)} -

-
-
- - {/* 权限 */} - {extensionDetail.permissions && extensionDetail.permissions.length > 0 && ( -
-
- - - {t('dialog.detail.permissions')} - -
- -
- {extensionDetail.permissions.map((permission, index) => ( - - {permission} - - ))} -
-
-
- )} - - {/* 时间信息 */} -
- {extensionDetail.updatedAt && ( -
-
- - - {t('dialog.detail.updatedAt')} - -
-

{formatDate(extensionDetail.updatedAt)}

-
- )} - - {extensionDetail.createdAt && ( -
-
- - - {t('dialog.detail.createdAt')} - -
-

{formatDate(extensionDetail.createdAt)}

-
- )} -
-
-
- ) : null} - +
- - - - ); -} diff --git a/plugins/pages/browser-extensions/src/components/extension-icon.tsx b/plugins/pages/browser-extensions/src/components/extension-icon.tsx index 6cf8fb8..6b380d6 100644 --- a/plugins/pages/browser-extensions/src/components/extension-icon.tsx +++ b/plugins/pages/browser-extensions/src/components/extension-icon.tsx @@ -1,11 +1,11 @@ import { useMemo, useState } from 'react'; -import { Package, Puzzle } from 'lucide-react'; +import { Puzzle } from 'lucide-react'; import { cn } from '@/lib/utils'; import { resolveExtensionIconSrc } from '../utils/icon'; interface ExtensionIconProps { icon?: string | null; - source?: 'remote' | 'local'; + source?: 'local'; containerClassName?: string; imageClassName?: string; textClassName?: string; @@ -24,7 +24,7 @@ function isLikelyIconText(icon: string): boolean { export function ExtensionIcon({ icon, - source = 'remote', + source = 'local', containerClassName, imageClassName, textClassName, @@ -41,8 +41,6 @@ export function ExtensionIcon({ return resolvedIconSrc; }, [failedIconSrc, resolvedIconSrc]); - const FallbackIcon = source === 'local' ? Puzzle : Package; - return (
{icon} ) : ( - + )}
); diff --git a/plugins/pages/browser-extensions/src/components/extension-install-dialog.tsx b/plugins/pages/browser-extensions/src/components/extension-install-dialog.tsx deleted file mode 100644 index 49cccd7..0000000 --- a/plugins/pages/browser-extensions/src/components/extension-install-dialog.tsx +++ /dev/null @@ -1,267 +0,0 @@ -import { useState, useMemo } from 'react'; -import { useTranslation } from 'react-i18next'; -import { Loader2, Check, Download, Users, Folder, Search } from 'lucide-react'; -import { FormattedDialog, FormattedDialogFooter } from '@/components/formatted-dialog'; -import { Button } from '@/components/ui/button'; -import { TextareaInput } from '@/components/textarea-input'; -import { ScrollArea } from '@/components/ui/scroll-area'; -import { Switch } from '@/components/ui/switch'; -import { Label } from '@/components/ui/label'; -import type { StoreExtension } from '../types'; -import { ExtensionIcon } from './extension-icon'; - -// 分组项类型 -interface GroupItem { - uuid: string; - name: string; - description?: string; -} - -interface ExtensionInstallDialogProps { - open: boolean; - extension: StoreExtension | null; - groups: GroupItem[]; - loadingGroups?: boolean; - installGroups: string[]; - installForTeam: boolean; - installing: boolean; - onOpenChange: (open: boolean) => void; - onGroupToggle: (group: string) => void; - onTeamChange: (forTeam: boolean) => void; - onConfirm: () => void; -} - -// 分组卡片组件 -const GroupCard: React.FC<{ - group: GroupItem; - isSelected: boolean; - onClick: () => void; -}> = ({ group, isSelected, onClick }) => { - return ( -
-
- -
-
-
- {group.name} -
- {group.description && ( -
{group.description}
- )} -
-
- {isSelected && } -
-
- ); -}; - -// 加载状态组件 -const LoadingState: React.FC = () => ( -
- -
-); - -// 空状态组件 -const EmptyState: React.FC<{ title: string; description: string }> = ({ title, description }) => ( -
-
- -
-

{title}

-

{description}

-
-); - -/** - * 扩展安装对话框组件 - */ -export function ExtensionInstallDialog({ - open, - extension, - groups, - loadingGroups = false, - installGroups, - installForTeam, - installing, - onOpenChange, - onGroupToggle, - onTeamChange, - onConfirm, -}: ExtensionInstallDialogProps) { - const { t } = useTranslation('extensions'); - const [searchQuery, setSearchQuery] = useState(''); - - // 过滤分组 - const filteredGroups = useMemo(() => { - if (!searchQuery.trim()) return groups; - const q = searchQuery.toLowerCase(); - return groups.filter( - (g) => g.name.toLowerCase().includes(q) || g.description?.toLowerCase().includes(q) - ); - }, [groups, searchQuery]); - - const handleOpenChange = (nextOpen: boolean) => { - if (!nextOpen) { - setSearchQuery(''); - } - onOpenChange(nextOpen); - }; - - return ( - - {extension && ( - <> - {/* 插件信息卡片 */} -
-
- -
-
-

{extension.name}

-
- - v{extension.version} - - {extension.author && ( - <> - · - - {extension.author} - - - )} -
-
-
- - {/* 分组选择区域 */} - {loadingGroups ? ( - - ) : groups.length > 0 ? ( - <> - {/* 搜索框 - 只在分组数量较多时显示 */} - {groups.length > 4 && ( -
- - setSearchQuery(e.target.value)} - className="pl-9 pr-2 text-sm min-h-9" - /> -
- )} - - {filteredGroups.length === 0 ? ( -
-

{t('dialog.install.noMatch')}

-
- ) : ( - -
- {filteredGroups.map((group) => ( - onGroupToggle(group.uuid)} - /> - ))} -
-
- )} - - ) : ( - - )} - - {/* 团队安装开关 */} -
-
-
- -
-
- -

- {installGroups.length > 0 - ? t('dialog.install.teamInstallDescriptionForGroup') - : t('dialog.install.teamInstallDescription')} -

-
-
- -
- - )} - - - - - -
- ); -} diff --git a/plugins/pages/browser-extensions/src/components/extension-pagination.tsx b/plugins/pages/browser-extensions/src/components/extension-pagination.tsx deleted file mode 100644 index 911b54a..0000000 --- a/plugins/pages/browser-extensions/src/components/extension-pagination.tsx +++ /dev/null @@ -1,109 +0,0 @@ -import { useTranslation } from 'react-i18next'; -import { - Pagination, - PaginationContent, - PaginationItem, - PaginationLink, - PaginationPrevious, - PaginationNext, - PaginationEllipsis, -} from '@/components/ui/pagination'; - -interface ExtensionPaginationProps { - currentPage: number; - totalPages: number; - onPageChange: (page: number) => void; -} - -export function ExtensionPagination({ - currentPage, - totalPages, - onPageChange, -}: ExtensionPaginationProps) { - const { t } = useTranslation('extensions'); - - const getPageNumbers = () => { - const pages: (number | 'ellipsis')[] = []; - const maxVisible = 7; - - if (totalPages <= maxVisible) { - for (let i = 1; i <= totalPages; i++) { - pages.push(i); - } - } else { - if (currentPage <= 3) { - for (let i = 1; i <= 4; i++) pages.push(i); - pages.push('ellipsis'); - pages.push(totalPages); - } else if (currentPage >= totalPages - 2) { - pages.push(1); - pages.push('ellipsis'); - for (let i = totalPages - 3; i <= totalPages; i++) pages.push(i); - } else { - pages.push(1); - pages.push('ellipsis'); - for (let i = currentPage - 1; i <= currentPage + 1; i++) pages.push(i); - pages.push('ellipsis'); - pages.push(totalPages); - } - } - - return pages; - }; - - return ( -
-
- {t('pagination.pageInfo', { currentPage, totalPages })} -
- - - - { - e.preventDefault(); - if (currentPage > 1) onPageChange(currentPage - 1); - }} - className={currentPage === 1 ? 'pointer-events-none opacity-50' : 'cursor-pointer'} - > - {t('pagination.previous')} - - - - {getPageNumbers().map((page, index) => ( - - {page === 'ellipsis' ? ( - - ) : ( - { - e.preventDefault(); - onPageChange(page); - }} - isActive={page === currentPage} - className="cursor-pointer" - > - {page} - - )} - - ))} - - - { - e.preventDefault(); - if (currentPage < totalPages) onPageChange(currentPage + 1); - }} - className={ - currentPage === totalPages ? 'pointer-events-none opacity-50' : 'cursor-pointer' - } - > - {t('pagination.next')} - - - - -
- ); -} diff --git a/plugins/pages/browser-extensions/src/components/extension-stats.tsx b/plugins/pages/browser-extensions/src/components/extension-stats.tsx deleted file mode 100644 index 2243490..0000000 --- a/plugins/pages/browser-extensions/src/components/extension-stats.tsx +++ /dev/null @@ -1,72 +0,0 @@ -import { useTranslation } from 'react-i18next'; -import { Tabs, TabsList, TabsTrigger } from '@/components/ui/tabs'; -import { Puzzle, CheckCircle2, AlertCircle, Download, RefreshCw } from 'lucide-react'; - -interface ExtensionStatsProps { - total: number; - installed: number; - updates: number; - available: number; - scopeFilter?: string; - onScopeFilterChange?: (value: string) => void; - onRefresh: () => void; -} - -export function ExtensionStats({ - total, - installed, - updates, - available, - scopeFilter = 'all', - onScopeFilterChange, - onRefresh, -}: ExtensionStatsProps) { - const { t } = useTranslation('extensions'); - - return ( -
-
- - {t('stats.total')} - {total} -
-
- - {t('stats.installed')} - {installed} -
-
- - {t('stats.updates')} - {updates} -
-
- - {t('stats.available')} - {available} -
-
- - - - {t('tabs.all')} - - - {t('tabs.team')} - - - {t('tabs.personal')} - - - - -
-
- ); -} diff --git a/plugins/pages/browser-extensions/src/components/extension-store-skeleton.tsx b/plugins/pages/browser-extensions/src/components/extension-store-skeleton.tsx deleted file mode 100644 index 500b4f2..0000000 --- a/plugins/pages/browser-extensions/src/components/extension-store-skeleton.tsx +++ /dev/null @@ -1,77 +0,0 @@ -import { Skeleton } from '@/components/ui/skeleton'; -import { ScrollArea } from '@/components/ui/scroll-area'; - -export function ExtensionStoreSkeleton() { - return ( -
- {/* 分类筛选 */} -
-
- {Array.from({ length: 7 }).map((_, index) => ( - - ))} -
-
- -
-
- - {/* 插件卡片列表 */} - -
-
- {Array.from({ length: 12 }).map((_, index) => ( -
- {/* 图标和名称 */} -
- -
- - -
-
- - {/* 描述 */} - - - - {/* 浏览器和评分 */} -
- -
- - -
-
- - {/* 下载量和作者 */} -
- - -
- - {/* 按钮 */} - -
- ))} -
-
-
- - {/* 分页 */} -
- -
- - - - - -
-
-
- ); -} diff --git a/plugins/pages/browser-extensions/src/components/extension-store.tsx b/plugins/pages/browser-extensions/src/components/extension-store.tsx deleted file mode 100644 index 8982ec8..0000000 --- a/plugins/pages/browser-extensions/src/components/extension-store.tsx +++ /dev/null @@ -1,362 +0,0 @@ -import { useTranslation } from 'react-i18next'; -import { Download, CheckCircle2, Star, Users, ArrowUpDown, SearchX } from 'lucide-react'; -import { ScrollArea } from '@/components/ui/scroll-area'; -import { Button } from '@/components/ui/button'; -import { - Pagination, - PaginationContent, - PaginationItem, - PaginationLink, - PaginationPrevious, - PaginationNext, - PaginationEllipsis, -} from '@/components/ui/pagination'; -import { - Select, - SelectContent, - SelectItem, - SelectTrigger, - SelectValue, -} from '@/components/ui/select'; - -import type { StoreExtension, SortOption } from '../types'; -import { STORE_PAGE_SIZE_OPTIONS } from '../constants'; -import { ExtensionIcon } from './extension-icon'; - -export type { StoreExtension }; - -interface ExtensionStoreProps { - extensions: StoreExtension[]; - searchQuery: string; - totalCount: number; - currentPage: number; - totalPages: number; - pageSize: number; - categoryFilter: string; - sortBy: SortOption; - onCategoryChange: (category: string) => void; - onSortChange: (sort: SortOption) => void; - onPageChange: (page: number) => void; - onPageSizeChange: (size: number) => void; - onInstall: (id: string) => void; -} - -// 分类颜色配置 -const categoryStyles: Record = { - automation: 'border-blue-500/40 bg-blue-500/10 text-blue-600 dark:text-blue-400', - security: 'border-emerald-500/40 bg-emerald-500/10 text-emerald-600 dark:text-emerald-400', - productivity: 'border-violet-500/40 bg-violet-500/10 text-violet-600 dark:text-violet-400', - tools: 'border-amber-500/40 bg-amber-500/10 text-amber-600 dark:text-amber-400', - media: 'border-rose-500/40 bg-rose-500/10 text-rose-600 dark:text-rose-400', - social: 'border-sky-500/40 bg-sky-500/10 text-sky-600 dark:text-sky-400', -}; - -// 格式化下载量 -function formatDownloads(downloads: number | undefined): string { - if (!downloads) return '0'; - if (downloads >= 1000000) return `${(downloads / 1000000).toFixed(1)}M`; - if (downloads >= 1000) return `${(downloads / 1000).toFixed(0)}K`; - return downloads.toString(); -} - -function ExtensionCard({ - extension, - onInstall, -}: { - extension: StoreExtension; - onInstall: (id: string) => void; -}) { - const { t } = useTranslation('extensions'); - const isInstalled = extension.status === 'installed' || extension.status === 'update'; - - const categoryBadge = extension.category ? ( - - {t(`store.categories.${extension.category}`)} - - ) : null; - - return ( -
- {/* 头部:图标 + 名称 + 分类 */} -
-
- -
-
-
-

{extension.name}

-
-
- - v{extension.version} - - {categoryBadge} -
-
-
- - {/* 描述 */} -

- {extension.description} -

- - {/* 统计信息 */} -
- - - {formatDownloads(extension.downloads)} - - {extension.rating && ( - - - {extension.rating.toFixed(1)} - - )} - {extension.author && ( - {extension.author} - )} -
- - {/* 操作按钮 */} -
- {isInstalled ? ( - - - {t('store.installed')} - - ) : ( - - )} -
-
- ); -} - -export function ExtensionStore({ - extensions, - searchQuery, - totalCount, - currentPage, - totalPages, - pageSize, - categoryFilter, - sortBy, - onCategoryChange, - onSortChange, - onPageChange, - onPageSizeChange, - onInstall, -}: ExtensionStoreProps) { - const { t } = useTranslation('extensions'); - - const categories = [ - { value: 'all', label: t('store.categories.all') }, - { value: 'automation', label: t('store.categories.automation') }, - { value: 'security', label: t('store.categories.security') }, - { value: 'productivity', label: t('store.categories.productivity') }, - { value: 'tools', label: t('store.categories.tools') }, - { value: 'media', label: t('store.categories.media') }, - { value: 'social', label: t('store.categories.social') }, - ]; - - const sortOptions: { value: SortOption; label: string }[] = [ - { value: 'downloads', label: t('store.sort.downloads') }, - { value: 'rating', label: t('store.sort.rating') }, - { value: 'name', label: t('store.sort.name') }, - { value: 'newest', label: t('store.sort.newest') }, - ]; - - const getPageNumbers = () => { - const pages: (number | 'ellipsis')[] = []; - const maxVisible = 7; - - if (totalPages <= maxVisible) { - for (let i = 1; i <= totalPages; i++) { - pages.push(i); - } - } else { - if (currentPage <= 3) { - for (let i = 1; i <= 4; i++) pages.push(i); - pages.push('ellipsis'); - pages.push(totalPages); - } else if (currentPage >= totalPages - 2) { - pages.push(1); - pages.push('ellipsis'); - for (let i = totalPages - 3; i <= totalPages; i++) pages.push(i); - } else { - pages.push(1); - pages.push('ellipsis'); - for (let i = currentPage - 1; i <= currentPage + 1; i++) pages.push(i); - pages.push('ellipsis'); - pages.push(totalPages); - } - } - - return pages; - }; - - return ( -
- {/* 筛选栏 */} -
- {/* 分类筛选 */} -
- {categories.map((cat) => ( - - ))} -
- - {/* 右侧:排序 + 统计 */} -
- - - {totalCount > 0 && ( - - {t('store.totalExtensions', { count: totalCount })} - - )} -
-
- - {/* 插件卡片列表 - 可滚动区域 */} - -
- {extensions.length === 0 ? ( -
-
- -
-

{t('store.noResults')}

-

- {searchQuery ? t('store.noResultsHint') : t('store.noResultsHint')} -

-
- ) : ( -
- {extensions.map((ext) => ( - - ))} -
- )} -
-
- - {/* 分页 - 固定在底部 */} -
-
-
- {t('store.pageInfo', { currentPage, totalPages })} -
-
- - {t('store.pageSize')} - - - - {t('store.totalItems', { total: totalCount })} - -
-
- - - - { - e.preventDefault(); - if (currentPage > 1) onPageChange(currentPage - 1); - }} - className={currentPage === 1 ? 'pointer-events-none opacity-50' : 'cursor-pointer'} - > - {t('pagination.previous')} - - - - {getPageNumbers().map((page, index) => ( - - {page === 'ellipsis' ? ( - - ) : ( - { - e.preventDefault(); - onPageChange(page); - }} - isActive={page === currentPage} - className="cursor-pointer" - > - {page} - - )} - - ))} - - - { - e.preventDefault(); - if (currentPage < totalPages) onPageChange(currentPage + 1); - }} - className={ - currentPage === totalPages ? 'pointer-events-none opacity-50' : 'cursor-pointer' - } - > - {t('pagination.next')} - - - - -
-
- ); -} diff --git a/plugins/pages/browser-extensions/src/components/extension-table-row.tsx b/plugins/pages/browser-extensions/src/components/extension-table-row.tsx deleted file mode 100644 index 78cf1a2..0000000 --- a/plugins/pages/browser-extensions/src/components/extension-table-row.tsx +++ /dev/null @@ -1,272 +0,0 @@ -import { useTranslation } from 'react-i18next'; -import { - CheckCircle2, - AlertCircle, - MoreVertical, - Trash2, - RefreshCw, - ExternalLink, - Info, - ShieldCheck, - Ban, - Play, -} from 'lucide-react'; -import { - DropdownMenu, - DropdownMenuContent, - DropdownMenuItem, - DropdownMenuSeparator, - DropdownMenuTrigger, -} from '@/components/ui/dropdown-menu'; -import { - DataTableRowContainer, - DataTableCell, - DataTableCheckboxCell, - DataTableActionsCell, -} from '@/components/data-table'; -import type { ExtensionItem } from '../types'; -import { ExtensionIcon } from './extension-icon'; - -interface ExtensionTableRowProps { - extension: ExtensionItem; - isSelected?: boolean; - onSelect?: (id: string, selected: boolean) => void; - onUpdate?: (id: string) => void; - onUninstall?: (id: string, name: string) => void; - onViewDetails?: (id: string) => void; - onHomepage?: (id: string) => void; - onSecurityCheck?: (id: string) => void; - onDisable?: (id: string, name: string) => void; - onEnable?: (id: string, name: string) => void; -} - -export function ExtensionTableRow({ - extension, - isSelected = false, - onSelect, - onUpdate, - onUninstall, - onViewDetails, - onHomepage, - onSecurityCheck, - onDisable, - onEnable, -}: ExtensionTableRowProps) { - const { t } = useTranslation('extensions'); - - const formatDate = (dateString?: string) => { - if (!dateString) return '-'; - try { - const date = new Date(dateString); - return date.toLocaleDateString('zh-CN', { - year: 'numeric', - month: '2-digit', - day: '2-digit', - }); - } catch { - return dateString; - } - }; - - const getStatusBadge = () => { - switch (extension.status) { - case 'active': - case 'installed': - return ( - - - {t('status.installed')} - - ); - case 'update': - return ( - - - {t('status.update')} - - ); - case 'disabled': - return ( - - - {t('status.disabled')} - - ); - default: - return null; - } - }; - - const getBrowserBadge = () => { - const colors: Record = { - chrome: 'border-yellow-500/30 bg-yellow-500/10 text-yellow-600', - firefox: 'border-orange-500/30 bg-orange-500/10 text-orange-500', - edge: 'border-blue-500/30 bg-blue-500/10 text-blue-500', - all: 'border-purple-500/30 bg-purple-500/10 text-purple-500', - }; - const labels: Record = { - chrome: 'Chrome', - firefox: 'Firefox', - edge: 'Edge', - all: t('browser.all'), - }; - return ( - - {labels[extension.browser]} - - ); - }; - - return ( - - {/* 选择框列 */} - onSelect?.(extension.id, selected)} - /> - - {/* 名称列 */} - -
- - {extension.name} - {extension.source === 'local' && ( - - {t('local.badge')} - - )} -
-
- {extension.description} -
-
- - {/* 状态列 */} - {getStatusBadge()} - - {/* 浏览器列 */} - {getBrowserBadge()} - - {/* 分组列 */} - - {extension.groups && extension.groups.length > 0 ? ( -
- {extension.groups.map((group) => ( - - {group.name} - - ))} -
- ) : ( - - - )} -
- - {/* 下载量列 */} - - - {extension.downloads?.toLocaleString() || '-'} - - - - {/* 更新时间列 */} - - {formatDate(extension.updatedAt)} - - - {/* 操作列 */} - - {/* 安全检查 */} - - {/* 检查更新 */} - - {/* 更多操作 */} - - - - - - onViewDetails?.(extension.id)} - className="cursor-pointer" - > - - {t('table.actions.details')} - - onHomepage?.(extension.id)} - className="cursor-pointer" - disabled={false} - > - - {t('table.actions.homepage')} - - - {/* 只有团队插件才显示禁用/启用选项 */} - {extension.scope === 'team' && ( - <> - {extension.status === 'disabled' ? ( - onEnable?.(extension.id, extension.name)} - className="cursor-pointer" - > - - {t('table.actions.enable')} - - ) : ( - onDisable?.(extension.id, extension.name)} - className="cursor-pointer" - > - - {t('table.actions.disable')} - - )} - - - )} - onUninstall?.(extension.id, extension.name)} - className="cursor-pointer text-destructive focus:text-destructive" - > - - {t('table.actions.uninstall')} - - - - -
- ); -} diff --git a/plugins/pages/browser-extensions/src/components/extension-table-skeleton.tsx b/plugins/pages/browser-extensions/src/components/extension-table-skeleton.tsx deleted file mode 100644 index 078e894..0000000 --- a/plugins/pages/browser-extensions/src/components/extension-table-skeleton.tsx +++ /dev/null @@ -1,62 +0,0 @@ -import { Skeleton } from '@/components/ui/skeleton'; - -interface ExtensionTableSkeletonProps { - rows?: number; -} - -export function ExtensionTableSkeleton({ rows = 8 }: ExtensionTableSkeletonProps) { - return ( - <> - {Array.from({ length: rows }).map((_, index) => ( - - {/* 复选框列 */} - -
- -
- - {/* 名称列 */} - -
- - -
- - - {/* 版本列 */} - - - - {/* 状态列 */} - - - - {/* 浏览器列 */} - - - - {/* 作者列 */} - - - - {/* 下载量列 */} - - - - {/* 更新时间列 */} - - - - {/* 操作列 */} - -
- - - -
- - - ))} - - ); -} diff --git a/plugins/pages/browser-extensions/src/components/extension-table.tsx b/plugins/pages/browser-extensions/src/components/extension-table.tsx deleted file mode 100644 index c5befeb..0000000 --- a/plugins/pages/browser-extensions/src/components/extension-table.tsx +++ /dev/null @@ -1,151 +0,0 @@ -import { useTranslation } from 'react-i18next'; -import { Puzzle } from 'lucide-react'; -import { DataTable, type ColumnDef } from '@/components/data-table'; -import { ExtensionTableRow } from './extension-table-row'; -import type { ExtensionItem } from '../types'; - -export type { ExtensionItem }; - -/** - * 空状态组件 - */ -const EmptyState: React.FC<{ - t: (key: string) => string; -}> = ({ t }) => ( -
-
- -
-

{t('table.empty')}

-

- {t('table.emptyDescription')} -

-
-); - -interface ExtensionTableProps { - extensions: ExtensionItem[]; - selectedIds?: Set; - onSelect?: (id: string, selected: boolean) => void; - onSelectAll?: (selected: boolean) => void; - onUpdate?: (id: string) => void; - onUninstall?: (id: string, name: string) => void; - onViewDetails?: (id: string) => void; - onHomepage?: (id: string) => void; - onSecurityCheck?: (id: string) => void; - onDisable?: (id: string, name: string) => void; - onEnable?: (id: string, name: string) => void; - loading?: boolean; -} - -export function ExtensionTable({ - extensions, - selectedIds = new Set(), - onSelect, - onSelectAll, - onUpdate, - onUninstall, - onViewDetails, - onHomepage, - onSecurityCheck, - onDisable, - onEnable, - loading = false, -}: ExtensionTableProps) { - const { t } = useTranslation('extensions'); - - // 定义列 - const columns: ColumnDef[] = [ - { - id: 'name', - header: t('table.headers.name'), - cell: () => null, - width: 256, - }, - { - id: 'status', - header: t('table.headers.status'), - cell: () => null, - width: 112, - }, - { - id: 'browser', - header: t('table.headers.browser'), - cell: () => null, - width: 96, - }, - { - id: 'groups', - header: t('table.headers.groups'), - cell: () => null, - width: 128, - }, - { - id: 'downloads', - header: t('table.headers.downloads'), - cell: () => null, - width: 96, - }, - { - id: 'updatedAt', - header: t('table.headers.updatedAt'), - cell: () => null, - width: 112, - }, - { - id: 'actions', - header: t('table.headers.actions'), - cell: () => null, - width: 128, - }, - ]; - - // 处理选择变化 - const handleSelectionChange = (newSelectedIds: Set) => { - const allSelected = extensions.length > 0 && extensions.every((e) => newSelectedIds.has(e.id)); - const noneSelected = extensions.every((e) => !newSelectedIds.has(e.id)); - - if (allSelected && onSelectAll) { - onSelectAll(true); - } else if (noneSelected && onSelectAll) { - onSelectAll(false); - } else if (onSelect) { - extensions.forEach((extension) => { - const wasSelected = selectedIds.has(extension.id); - const isNowSelected = newSelectedIds.has(extension.id); - if (wasSelected !== isNowSelected) { - onSelect(extension.id, isNowSelected); - } - }); - } - }; - - return ( - extension.id} - loading={loading} - skeletonRows={8} - emptyText={} - selectable - selectedIds={selectedIds} - onSelectionChange={handleSelectionChange} - renderRow={({ row, rowKey, isSelected, onSelect: handleSelect }) => ( - handleSelect(selected)} - onUpdate={onUpdate} - onUninstall={onUninstall} - onViewDetails={onViewDetails} - onHomepage={onHomepage} - onSecurityCheck={onSecurityCheck} - onDisable={onDisable} - onEnable={onEnable} - /> - )} - /> - ); -} diff --git a/plugins/pages/browser-extensions/src/constants.ts b/plugins/pages/browser-extensions/src/constants.ts deleted file mode 100644 index 47ccff7..0000000 --- a/plugins/pages/browser-extensions/src/constants.ts +++ /dev/null @@ -1,14 +0,0 @@ -/** - * 每页显示的项目数(已安装视图) - */ -export const ITEMS_PER_PAGE = 10; - -/** - * 每页显示的项目数(商店视图)- 默认值 - */ -export const STORE_ITEMS_PER_PAGE = 12; - -/** - * 商店分页大小选项 - */ -export const STORE_PAGE_SIZE_OPTIONS = [12, 24, 48, 96]; diff --git a/plugins/pages/browser-extensions/src/hooks/use-batch-uninstall-dialog.ts b/plugins/pages/browser-extensions/src/hooks/use-batch-uninstall-dialog.ts deleted file mode 100644 index 46f396d..0000000 --- a/plugins/pages/browser-extensions/src/hooks/use-batch-uninstall-dialog.ts +++ /dev/null @@ -1,28 +0,0 @@ -import { useState } from 'react'; - -interface UseBatchUninstallDialogReturn { - batchUninstallDialogOpen: boolean; - openBatchUninstallDialog: () => void; - closeBatchUninstallDialog: () => void; -} - -/** - * 批量卸载对话框状态管理 Hook - */ -export function useBatchUninstallDialog(): UseBatchUninstallDialogReturn { - const [batchUninstallDialogOpen, setBatchUninstallDialogOpen] = useState(false); - - const openBatchUninstallDialog = () => { - setBatchUninstallDialogOpen(true); - }; - - const closeBatchUninstallDialog = () => { - setBatchUninstallDialogOpen(false); - }; - - return { - batchUninstallDialogOpen, - openBatchUninstallDialog, - closeBatchUninstallDialog, - }; -} diff --git a/plugins/pages/browser-extensions/src/hooks/use-detail-dialog.ts b/plugins/pages/browser-extensions/src/hooks/use-detail-dialog.ts deleted file mode 100644 index d7afabc..0000000 --- a/plugins/pages/browser-extensions/src/hooks/use-detail-dialog.ts +++ /dev/null @@ -1,35 +0,0 @@ -import { useState } from 'react'; - -import type { ExtensionItem } from '../types'; - -interface UseDetailDialogReturn { - detailDialogOpen: boolean; - viewingExtension: ExtensionItem | null; - openDetailDialog: (extension: ExtensionItem) => void; - closeDetailDialog: () => void; -} - -/** - * 详情对话框状态管理 Hook - */ -export function useDetailDialog(): UseDetailDialogReturn { - const [detailDialogOpen, setDetailDialogOpen] = useState(false); - const [viewingExtension, setViewingExtension] = useState(null); - - const openDetailDialog = (extension: ExtensionItem) => { - setViewingExtension(extension); - setDetailDialogOpen(true); - }; - - const closeDetailDialog = () => { - setDetailDialogOpen(false); - setViewingExtension(null); - }; - - return { - detailDialogOpen, - viewingExtension, - openDetailDialog, - closeDetailDialog, - }; -} diff --git a/plugins/pages/browser-extensions/src/hooks/use-extension-filters.ts b/plugins/pages/browser-extensions/src/hooks/use-extension-filters.ts deleted file mode 100644 index 205db03..0000000 --- a/plugins/pages/browser-extensions/src/hooks/use-extension-filters.ts +++ /dev/null @@ -1,50 +0,0 @@ -import { useMemo } from 'react'; -import type { ExtensionItem, StoreExtension } from '../types'; - -interface ExtensionFilters { - searchQuery: string; - scopeFilter: string; -} - -/** - * 扩展过滤 Hook - */ -export function useExtensionFilters( - extensions: ExtensionItem[], - filters: ExtensionFilters -): ExtensionItem[] { - return useMemo(() => { - let result = extensions.filter((e) => e.status === 'installed' || e.status === 'update'); - - // 搜索过滤 - if (filters.searchQuery) { - const query = filters.searchQuery.toLowerCase(); - result = result.filter( - (ext) => - ext.name.toLowerCase().includes(query) || - ext.description.toLowerCase().includes(query) || - ext.author?.toLowerCase().includes(query) - ); - } - - // 作用域过滤(如果需要) - // if (filters.scopeFilter && filters.scopeFilter !== 'all') { - // result = result.filter((ext) => ext.scope === filters.scopeFilter); - // } - - return result; - }, [extensions, filters.searchQuery, filters.scopeFilter]); -} - -/** - * 转换为商店扩展格式 - */ -export function useStoreExtensions(extensions: ExtensionItem[]): StoreExtension[] { - return useMemo(() => { - return extensions.map((ext) => ({ - ...ext, - // 使用后端返回的 rating,如果没有则不显示 - rating: ext.rating, - })); - }, [extensions]); -} diff --git a/plugins/pages/browser-extensions/src/hooks/use-extension-handlers.ts b/plugins/pages/browser-extensions/src/hooks/use-extension-handlers.ts deleted file mode 100644 index a1835a0..0000000 --- a/plugins/pages/browser-extensions/src/hooks/use-extension-handlers.ts +++ /dev/null @@ -1,280 +0,0 @@ -import { useTranslation } from 'react-i18next'; -import { useCallback } from 'react'; -import { toast } from 'sonner'; -import type { ExtensionItem } from '../types'; -import { useExtensionOperations } from './use-extension-operations'; -import { useInstallDialog } from './use-install-dialog'; -import { useUninstallDialog } from './use-uninstall-dialog'; -import { useBatchUninstallDialog } from './use-batch-uninstall-dialog'; -import { useExtensionSelection } from './use-extension-selection'; -import { useDetailDialog } from './use-detail-dialog'; -import { useHomepageDialog } from './use-homepage-dialog'; -import { useToggleDialog } from './use-toggle-dialog'; - -interface UseExtensionHandlersProps { - extensions: ExtensionItem[]; - paginatedExtensions: ExtensionItem[]; - onRefresh: () => Promise; -} - -interface UseExtensionHandlersReturn { - // 安装相关 - handleInstall: (id: string) => void; - handleConfirmInstall: () => Promise; - installDialog: ReturnType; - installing: boolean; - - // 更新相关 - handleUpdate: (id: string) => Promise; - - // 卸载相关 - handleUninstall: (id: string, name: string) => void; - handleRemove: (id: string, name: string) => void; - handleConfirmUninstall: () => Promise; - uninstallDialog: ReturnType; - - // 批量操作相关 - handleBatchUpdate: () => Promise; - handleBatchUninstall: () => void; - handleConfirmBatchUninstall: () => Promise; - batchUninstallDialog: ReturnType; - - // 禁用/启用相关 - handleDisable: (id: string, name: string) => void; - handleEnable: (id: string, name: string) => void; - handleConfirmToggle: () => Promise; - toggleDialog: ReturnType; - - // 其他操作 - handleViewDetails: (id: string) => void; - handleHomepage: (id: string) => void; - handleSecurityCheck: (id: string) => void; - detailDialog: ReturnType; - homepageDialog: ReturnType; - - // 选择相关 - selection: ReturnType; -} - -/** - * 扩展操作事件处理 Hook - * 整合所有扩展相关的操作和事件处理逻辑 - */ -export function useExtensionHandlers({ - extensions, - paginatedExtensions, - onRefresh, -}: UseExtensionHandlersProps): UseExtensionHandlersReturn { - const { t } = useTranslation('extensions'); - - // 对话框状态管理 - const installDialog = useInstallDialog(); - const uninstallDialog = useUninstallDialog(); - const batchUninstallDialog = useBatchUninstallDialog(); - const detailDialog = useDetailDialog(); - const homepageDialog = useHomepageDialog(); - const toggleDialog = useToggleDialog(); - - // 选择管理 - const selection = useExtensionSelection(paginatedExtensions); - - // 操作逻辑 - const operations = useExtensionOperations(() => { - void onRefresh(); - }); - - // 安装处理 - const handleInstall = useCallback( - (id: string) => { - const ext = extensions.find((e) => e.id === id); - if (ext) { - if (ext.source === 'local') { - void operations - .installExtension( - { - ...ext, - isInstalled: ext.status === 'installed' || ext.status === 'update', - }, - [], - false - ) - .catch((e) => { - toast.error(e instanceof Error ? e.message : '安装失败'); - }); - return; - } - - installDialog.openInstallDialog({ - ...ext, - isInstalled: ext.status === 'installed' || ext.status === 'update', - }); - } - }, - [extensions, installDialog] - ); - - const handleConfirmInstall = useCallback(async () => { - if (!installDialog.installingExt) return; - try { - await operations.installExtension( - installDialog.installingExt, - installDialog.installGroups, - installDialog.installForTeam - ); - installDialog.closeInstallDialog(); - } catch (e) { - toast.error(e instanceof Error ? e.message : '安装失败'); - } - }, [installDialog, operations]); - - // 更新处理 - const handleUpdate = useCallback( - async (_id: string) => { - // 直接提示检查通过 - toast.success(t('update.checkPassed')); - }, - [t] - ); - - // 卸载处理 - const handleUninstall = useCallback( - (id: string, _name: string) => { - const ext = extensions.find((e) => e.id === id); - if (ext) { - uninstallDialog.openUninstallDialog(ext); - } - }, - [extensions, uninstallDialog] - ); - - const handleConfirmUninstall = useCallback(async () => { - if (!uninstallDialog.uninstallingExt) return; - if (uninstallDialog.action === 'remove') { - await operations.removeExtension(uninstallDialog.uninstallingExt); - } else { - await operations.uninstallExtension(uninstallDialog.uninstallingExt); - } - uninstallDialog.closeUninstallDialog(); - }, [uninstallDialog, operations]); - - const handleRemove = useCallback( - (id: string, _name: string) => { - const ext = extensions.find((e) => e.id === id); - if (ext) { - uninstallDialog.openRemoveDialog(ext); - } - }, - [extensions, uninstallDialog] - ); - - // 批量操作处理 - const handleBatchUpdate = useCallback(async () => { - const selectedExtensions = paginatedExtensions.filter((ext) => - selection.selectedIds.has(ext.id) - ); - await operations.batchUpdate(selectedExtensions); - selection.clearSelection(); - }, [paginatedExtensions, selection, operations]); - - const handleBatchUninstall = useCallback(() => { - batchUninstallDialog.openBatchUninstallDialog(); - }, [batchUninstallDialog]); - - const handleConfirmBatchUninstall = useCallback(async () => { - const selectedExtensions = paginatedExtensions.filter((ext) => - selection.selectedIds.has(ext.id) - ); - await operations.batchUninstall(selectedExtensions); - await onRefresh(); - selection.clearSelection(); - batchUninstallDialog.closeBatchUninstallDialog(); - }, [paginatedExtensions, selection, operations, onRefresh, batchUninstallDialog]); - - // 其他操作 - const handleViewDetails = useCallback( - (id: string) => { - const ext = extensions.find((item) => item.id === id); - if (ext) { - detailDialog.openDetailDialog(ext); - } - }, - [detailDialog, extensions] - ); - - const handleHomepage = useCallback( - (id: string) => { - const ext = extensions.find((e) => e.id === id); - if (ext) { - homepageDialog.openHomepageDialog(ext); - } else { - toast.error(t('dialog.homepage.noHomepage')); - } - }, - [extensions, homepageDialog, t] - ); - - const handleSecurityCheck = useCallback( - (_id: string) => { - // 直接提示检查通过 - toast.success(t('securityCheck.passed')); - }, - [t] - ); - - // 禁用/启用处理 - const handleDisable = useCallback( - (id: string, _name: string) => { - const ext = extensions.find((e) => e.id === id); - if (ext) { - toggleDialog.openToggleDialog(ext, 'disable'); - } - }, - [extensions, toggleDialog] - ); - - const handleEnable = useCallback( - (id: string, _name: string) => { - const ext = extensions.find((e) => e.id === id); - if (ext) { - toggleDialog.openToggleDialog(ext, 'enable'); - } - }, - [extensions, toggleDialog] - ); - - const handleConfirmToggle = useCallback(async () => { - if (!toggleDialog.togglingExt) return; - if (toggleDialog.toggleAction === 'disable') { - await operations.disableExtension(toggleDialog.togglingExt); - } else { - await operations.enableExtension(toggleDialog.togglingExt); - } - toggleDialog.closeToggleDialog(); - }, [toggleDialog, operations]); - - return { - handleInstall, - handleConfirmInstall, - installDialog, - installing: operations.installing, - handleUpdate, - handleUninstall, - handleRemove, - handleConfirmUninstall, - uninstallDialog, - handleBatchUpdate, - handleBatchUninstall, - handleConfirmBatchUninstall, - batchUninstallDialog, - handleDisable, - handleEnable, - handleConfirmToggle, - toggleDialog, - handleViewDetails, - handleHomepage, - handleSecurityCheck, - detailDialog, - homepageDialog, - selection, - }; -} diff --git a/plugins/pages/browser-extensions/src/hooks/use-extension-operations.ts b/plugins/pages/browser-extensions/src/hooks/use-extension-operations.ts deleted file mode 100644 index c7eab5e..0000000 --- a/plugins/pages/browser-extensions/src/hooks/use-extension-operations.ts +++ /dev/null @@ -1,239 +0,0 @@ -import { useState } from 'react'; -import type { ExtensionItem, StoreExtension } from '../types'; -import { - installExtension as apiInstallExtension, - uninstallExtension as apiUninstallExtension, - updateExtension as apiUpdateExtension, - batchUpdateExtensions as apiBatchUpdateExtensions, - disableExtension as apiDisableExtension, - enableExtension as apiEnableExtension, - installLocalExtension as apiInstallLocalExtension, - uninstallLocalExtension as apiUninstallLocalExtension, - removeLocalExtension as apiRemoveLocalExtension, - disableLocalExtension as apiDisableLocalExtension, - enableLocalExtension as apiEnableLocalExtension, -} from '../api'; - -interface UseExtensionOperationsReturn { - installing: boolean; - installExtension: ( - extension: StoreExtension, - groupIds: string[], - forTeam: boolean - ) => Promise; - updateExtension: (id: string) => Promise; - uninstallExtension: (extension: ExtensionItem) => Promise; - removeExtension: (extension: ExtensionItem) => Promise; - batchUpdate: (extensions: ExtensionItem[]) => Promise; - batchUninstall: (extensions: ExtensionItem[]) => Promise; - disableExtension: (extension: ExtensionItem) => Promise; - enableExtension: (extension: ExtensionItem) => Promise; -} - -/** - * 扩展操作逻辑 Hook - */ -export function useExtensionOperations(onComplete?: () => void): UseExtensionOperationsReturn { - const [installing, setInstalling] = useState(false); - - const installExtension = async ( - extension: StoreExtension, - groupIds: string[], - forTeam: boolean - ) => { - setInstalling(true); - try { - if (extension.source === 'local') { - if (!extension.recordId) { - throw new Error('本地插件缺少 recordId'); - } - await apiInstallLocalExtension(extension.recordId); - onComplete?.(); - return; - } - - // 如果选择了分组 - if (groupIds.length > 0) { - // 安装到分组,is_team_shared 由 forTeam 决定 - await apiInstallExtension({ - extension_id: extension.id, - group_ids: groupIds, - for_team: false, - is_team_shared: forTeam, // forTeam 决定是否团队共享 - }); - } else { - // 没有选择分组,根据 forTeam 决定安装到用户还是团队 - await apiInstallExtension({ - extension_id: extension.id, - group_ids: undefined, - for_team: forTeam, - is_team_shared: undefined, - }); - } - onComplete?.(); - } finally { - setInstalling(false); - } - }; - - const updateExtension = async (id: string): Promise => { - try { - const result = await apiUpdateExtension(id); - onComplete?.(); - return { - id: result.extensionId, - uuid: result.id, - recordId: result.recordId, - extensionId: result.extensionId, - name: result.name, - description: result.description, - version: result.version, - icon: result.icon || '', - browser: result.browser as ExtensionItem['browser'], - status: result.status, - source: result.source, - author: result.author, - homepage: result.homepage, - downloads: result.downloads, - rating: result.rating, - updatedAt: result.updatedAt, - createdAt: result.createdAt, - fileSize: result.fileSize, - permissions: result.permissions, - hash: result.hash, - scope: result.scope, - groups: result.groups, - category: result.category, - }; - } catch { - return null; - } - }; - - const uninstallExtension = async (extension: ExtensionItem) => { - try { - if (extension.source === 'local') { - if (!extension.recordId) { - throw new Error('本地插件缺少 recordId'); - } - await apiUninstallLocalExtension(extension.recordId); - onComplete?.(); - return; - } - - // 根据 scope 决定卸载类型和目标 - let targetType: 'user' | 'team' | 'group' = 'user'; - let targetUuid: string | undefined = undefined; - - // 从 extension 中获取 scope 信息 - const scope = (extension as any).scope as string | undefined; - - if (scope === 'team') { - targetType = 'team'; - } else if (scope === 'group-personal' || scope === 'group-team') { - targetType = 'group'; - // 如果是分组插件,需要提供 group_uuid - // 从 groups 数组中获取第一个分组的 uuid - const groups = (extension as any).groups as Array<{ uuid: string; name: string }> | undefined; - if (groups && groups.length > 0) { - targetUuid = groups[0].uuid; - } - } else { - // 默认为用户插件 - targetType = 'user'; - } - - await apiUninstallExtension(extension.id, targetType, targetUuid); - onComplete?.(); - } catch { - // 错误已处理 - } - }; - - const batchUpdate = async (extensions: ExtensionItem[]) => { - try { - const remoteIds = extensions - .filter((extension) => extension.source === 'remote') - .map((extension) => extension.id); - if (remoteIds.length > 0) { - await apiBatchUpdateExtensions(remoteIds); - onComplete?.(); - } - } catch { - // 忽略错误 - } - }; - - const removeExtension = async (extension: ExtensionItem) => { - try { - if (extension.source === 'local') { - if (!extension.recordId) { - throw new Error('本地插件缺少 recordId'); - } - await apiRemoveLocalExtension(extension.recordId); - } else { - await uninstallExtension(extension); - return; - } - onComplete?.(); - } catch { - // 错误已处理 - } - }; - - const batchUninstall = async (extensions: ExtensionItem[]) => { - // 后端没有批量卸载接口,逐个卸载 - for (const ext of extensions) { - try { - await uninstallExtension(ext); - } catch { - // 忽略单个错误 - } - } - onComplete?.(); - }; - - const disableExtension = async (extension: ExtensionItem) => { - try { - if (extension.source === 'local') { - if (!extension.recordId) { - throw new Error('本地插件缺少 recordId'); - } - await apiDisableLocalExtension(extension.recordId); - } else { - await apiDisableExtension(extension.id); - } - onComplete?.(); - } catch { - // 错误已处理 - } - }; - - const enableExtension = async (extension: ExtensionItem) => { - try { - if (extension.source === 'local') { - if (!extension.recordId) { - throw new Error('本地插件缺少 recordId'); - } - await apiEnableLocalExtension(extension.recordId); - } else { - await apiEnableExtension(extension.id); - } - onComplete?.(); - } catch { - // 错误已处理 - } - }; - - return { - installing, - installExtension, - updateExtension, - uninstallExtension, - removeExtension, - batchUpdate, - batchUninstall, - disableExtension, - enableExtension, - }; -} diff --git a/plugins/pages/browser-extensions/src/hooks/use-extension-pagination.ts b/plugins/pages/browser-extensions/src/hooks/use-extension-pagination.ts deleted file mode 100644 index 7fb9824..0000000 --- a/plugins/pages/browser-extensions/src/hooks/use-extension-pagination.ts +++ /dev/null @@ -1,39 +0,0 @@ -import { useMemo } from 'react'; -import type { ExtensionItem } from '../types'; -import { ITEMS_PER_PAGE } from '../constants'; - -interface UseExtensionPaginationReturn { - paginatedExtensions: ExtensionItem[]; - totalPages: number; - startIndex: number; - endIndex: number; -} - -/** - * 扩展分页 Hook - */ -export function useExtensionPagination( - extensions: ExtensionItem[], - currentPage: number -): UseExtensionPaginationReturn { - const { paginatedExtensions, totalPages, startIndex, endIndex } = useMemo(() => { - const totalPages = Math.max(1, Math.ceil(extensions.length / ITEMS_PER_PAGE)); - const startIndex = (currentPage - 1) * ITEMS_PER_PAGE; - const endIndex = startIndex + ITEMS_PER_PAGE; - const paginatedExtensions = extensions.slice(startIndex, endIndex); - - return { - paginatedExtensions, - totalPages, - startIndex, - endIndex, - }; - }, [extensions, currentPage]); - - return { - paginatedExtensions, - totalPages, - startIndex, - endIndex, - }; -} diff --git a/plugins/pages/browser-extensions/src/hooks/use-extension-selection.ts b/plugins/pages/browser-extensions/src/hooks/use-extension-selection.ts deleted file mode 100644 index 1b0e0e5..0000000 --- a/plugins/pages/browser-extensions/src/hooks/use-extension-selection.ts +++ /dev/null @@ -1,59 +0,0 @@ -import { useState, useMemo } from 'react'; -import type { ExtensionItem } from '../types'; - -interface UseExtensionSelectionReturn { - selectedIds: Set; - allSelected: boolean; - someSelected: boolean; - select: (id: string, selected: boolean) => void; - selectAll: (extensions: ExtensionItem[], selected: boolean) => void; - clearSelection: () => void; -} - -/** - * 扩展选择逻辑 Hook - */ -export function useExtensionSelection(extensions: ExtensionItem[]): UseExtensionSelectionReturn { - const [selectedIds, setSelectedIds] = useState>(new Set()); - - const select = (id: string, selected: boolean) => { - setSelectedIds((prev) => { - const next = new Set(prev); - if (selected) { - next.add(id); - } else { - next.delete(id); - } - return next; - }); - }; - - const selectAll = (extensionsToSelect: ExtensionItem[], selected: boolean) => { - if (selected) { - setSelectedIds(new Set(extensionsToSelect.map((e) => e.id))); - } else { - setSelectedIds(new Set()); - } - }; - - const clearSelection = () => { - setSelectedIds(new Set()); - }; - - const allSelected = useMemo(() => { - return extensions.length > 0 && extensions.every((ext) => selectedIds.has(ext.id)); - }, [extensions, selectedIds]); - - const someSelected = useMemo(() => { - return extensions.some((ext) => selectedIds.has(ext.id)); - }, [extensions, selectedIds]); - - return { - selectedIds, - allSelected, - someSelected, - select, - selectAll, - clearSelection, - }; -} diff --git a/plugins/pages/browser-extensions/src/hooks/use-extension-stats.ts b/plugins/pages/browser-extensions/src/hooks/use-extension-stats.ts deleted file mode 100644 index 184dbc9..0000000 --- a/plugins/pages/browser-extensions/src/hooks/use-extension-stats.ts +++ /dev/null @@ -1,29 +0,0 @@ -import { useMemo } from 'react'; -import type { ExtensionItem } from '../types'; - -interface ExtensionStats { - installedCount: number; - updateCount: number; - availableCount: number; - totalCount: number; -} - -/** - * 扩展统计数据 Hook - */ -export function useExtensionStats(extensions: ExtensionItem[]): ExtensionStats { - return useMemo(() => { - const installedCount = extensions.filter( - (e) => e.status === 'installed' || e.status === 'active' || e.status === 'disabled' - ).length; - const updateCount = extensions.filter((e) => e.status === 'update').length; - const availableCount = extensions.filter((e) => e.status === 'available').length; - - return { - installedCount, - updateCount, - availableCount, - totalCount: extensions.length, - }; - }, [extensions]); -} diff --git a/plugins/pages/browser-extensions/src/hooks/use-extensions.ts b/plugins/pages/browser-extensions/src/hooks/use-extensions.ts deleted file mode 100644 index d01bd74..0000000 --- a/plugins/pages/browser-extensions/src/hooks/use-extensions.ts +++ /dev/null @@ -1,109 +0,0 @@ -import { useState, useCallback } from 'react'; -import { listExtensions, listInstalledExtensions, listLocalExtensions, type Extension } from '../api'; -import type { ExtensionItem } from '../types'; - -interface UseExtensionsParams { - page?: number; - pageSize?: number; - search?: string; - category?: string; - sortBy?: 'downloads' | 'rating' | 'name' | 'newest'; - sortOrder?: 'asc' | 'desc'; -} - -interface UseExtensionsReturn { - extensions: ExtensionItem[]; - total: number; - loading: boolean; - error: string | null; - refresh: () => Promise; -} - -/** - * 将 API Extension 转换为 ExtensionItem - */ -function toExtensionItem(ext: Extension): ExtensionItem { - return { - // 业务 ID 使用 extensionId(后端的 extension_id) - id: ext.extensionId, - // 保留后端 uuid 以便需要时使用 - uuid: ext.id, - recordId: ext.recordId, - extensionId: ext.extensionId, - name: ext.name, - description: ext.description, - version: ext.version, - icon: ext.icon || '', - browser: ext.browser as ExtensionItem['browser'], - status: ext.status, - source: ext.source, - author: ext.author, - homepage: ext.homepage, - downloads: ext.downloads, - rating: ext.rating, - updatedAt: ext.updatedAt, - createdAt: ext.createdAt, - fileSize: ext.fileSize, - permissions: ext.permissions, - hash: ext.hash, - scope: ext.scope, // 保留 scope 信息 - groups: ext.groups, // 关联的分组列表 - category: ext.category, - }; -} - -/** - * 获取扩展列表的 Hook - * - * @param mode - 'all' 获取所有扩展(商店),'installed' 获取已安装扩展 - * @param scope - 范围过滤:'all' | 'user' | 'team'(仅用于 installed 模式) - */ -export function useExtensions( - mode: 'all' | 'installed' | 'local' = 'all', - scope: 'all' | 'user' | 'team' = 'all', - params?: UseExtensionsParams -): UseExtensionsReturn { - const [extensions, setExtensions] = useState([]); - const [total, setTotal] = useState(0); - const [loading, setLoading] = useState(false); - const [error, setError] = useState(null); - - const fetchExtensions = useCallback(async () => { - setLoading(true); - setError(null); - try { - if (mode === 'installed') { - const data = await listInstalledExtensions(scope); - setExtensions(data.map(toExtensionItem)); - setTotal(data.length); - } else if (mode === 'local') { - const data = await listLocalExtensions(); - setExtensions(data.map(toExtensionItem)); - setTotal(data.length); - } else { - const data = await listExtensions({ - page: params?.page, - page_size: params?.pageSize, - search: params?.search, - category: params?.category, - sort_by: params?.sortBy, - sort_order: params?.sortOrder, - }); - setExtensions(data.items.map(toExtensionItem)); - setTotal(data.total); - } - } catch (e) { - setError(e instanceof Error ? e.message : '获取扩展列表失败'); - } finally { - setLoading(false); - } - }, [mode, params?.category, params?.page, params?.pageSize, params?.search, params?.sortBy, params?.sortOrder, scope]); - - return { - extensions, - total, - loading, - error, - refresh: fetchExtensions, - }; -} diff --git a/plugins/pages/browser-extensions/src/hooks/use-homepage-dialog.ts b/plugins/pages/browser-extensions/src/hooks/use-homepage-dialog.ts deleted file mode 100644 index 97626eb..0000000 --- a/plugins/pages/browser-extensions/src/hooks/use-homepage-dialog.ts +++ /dev/null @@ -1,34 +0,0 @@ -import { useState } from 'react'; -import type { ExtensionItem } from '../types'; - -interface UseHomepageDialogReturn { - homepageDialogOpen: boolean; - homepageExtension: ExtensionItem | null; - openHomepageDialog: (extension: ExtensionItem) => void; - closeHomepageDialog: () => void; -} - -/** - * 访问主页对话框状态管理 Hook - */ -export function useHomepageDialog(): UseHomepageDialogReturn { - const [homepageDialogOpen, setHomepageDialogOpen] = useState(false); - const [homepageExtension, setHomepageExtension] = useState(null); - - const openHomepageDialog = (extension: ExtensionItem) => { - setHomepageExtension(extension); - setHomepageDialogOpen(true); - }; - - const closeHomepageDialog = () => { - setHomepageDialogOpen(false); - setHomepageExtension(null); - }; - - return { - homepageDialogOpen, - homepageExtension, - openHomepageDialog, - closeHomepageDialog, - }; -} diff --git a/plugins/pages/browser-extensions/src/hooks/use-install-dialog.ts b/plugins/pages/browser-extensions/src/hooks/use-install-dialog.ts deleted file mode 100644 index 9e8ecb9..0000000 --- a/plugins/pages/browser-extensions/src/hooks/use-install-dialog.ts +++ /dev/null @@ -1,58 +0,0 @@ -import { useState } from 'react'; -import type { StoreExtension } from '../types'; - -interface UseInstallDialogReturn { - installDialogOpen: boolean; - installingExt: StoreExtension | null; - installGroups: string[]; - installForTeam: boolean; - openInstallDialog: (extension: StoreExtension) => void; - closeInstallDialog: () => void; - toggleInstallGroup: (group: string) => void; - setInstallForTeam: (forTeam: boolean) => void; -} - -/** - * 安装对话框状态管理 Hook - */ -export function useInstallDialog(): UseInstallDialogReturn { - const [installDialogOpen, setInstallDialogOpen] = useState(false); - const [installingExt, setInstallingExt] = useState(null); - const [installGroups, setInstallGroups] = useState([]); - const [installForTeam, setInstallForTeam] = useState(false); - - const openInstallDialog = (extension: StoreExtension) => { - setInstallingExt(extension); - setInstallGroups([]); - setInstallForTeam(false); - setInstallDialogOpen(true); - }; - - const closeInstallDialog = () => { - setInstallDialogOpen(false); - setInstallingExt(null); - setInstallGroups([]); - setInstallForTeam(false); - }; - - const toggleInstallGroup = (group: string) => { - setInstallGroups((prev) => { - if (prev.includes(group)) { - return prev.filter((g) => g !== group); - } else { - return [...prev, group]; - } - }); - }; - - return { - installDialogOpen, - installingExt, - installGroups, - installForTeam, - openInstallDialog, - closeInstallDialog, - toggleInstallGroup, - setInstallForTeam, - }; -} diff --git a/plugins/pages/browser-extensions/src/hooks/use-search-pagination.ts b/plugins/pages/browser-extensions/src/hooks/use-search-pagination.ts deleted file mode 100644 index 48be2c7..0000000 --- a/plugins/pages/browser-extensions/src/hooks/use-search-pagination.ts +++ /dev/null @@ -1,36 +0,0 @@ -import { useState, useCallback } from 'react'; - -interface UseSearchPaginationReturn { - searchQuery: string; - currentPage: number; - setSearchQuery: (value: string) => void; - setCurrentPage: (page: number) => void; - handleSearchChange: (value: string) => void; - handlePageChange: (page: number) => void; -} - -/** - * 搜索和分页状态管理 Hook - */ -export function useSearchPagination(): UseSearchPaginationReturn { - const [searchQuery, setSearchQueryState] = useState(''); - const [currentPage, setCurrentPageState] = useState(1); - - const handleSearchChange = useCallback((value: string) => { - setSearchQueryState(value); - setCurrentPageState(1); - }, []); - - const handlePageChange = useCallback((page: number) => { - setCurrentPageState(page); - }, []); - - return { - searchQuery, - currentPage, - setSearchQuery: setSearchQueryState, - setCurrentPage: setCurrentPageState, - handleSearchChange, - handlePageChange, - }; -} diff --git a/plugins/pages/browser-extensions/src/hooks/use-toggle-dialog.ts b/plugins/pages/browser-extensions/src/hooks/use-toggle-dialog.ts deleted file mode 100644 index e936d41..0000000 --- a/plugins/pages/browser-extensions/src/hooks/use-toggle-dialog.ts +++ /dev/null @@ -1,27 +0,0 @@ -import { useState, useCallback } from 'react'; -import type { ExtensionItem } from '../types'; - -export function useToggleDialog() { - const [toggleDialogOpen, setToggleDialogOpen] = useState(false); - const [togglingExt, setTogglingExt] = useState(null); - const [toggleAction, setToggleAction] = useState<'disable' | 'enable'>('disable'); - - const openToggleDialog = useCallback((extension: ExtensionItem, action: 'disable' | 'enable') => { - setTogglingExt(extension); - setToggleAction(action); - setToggleDialogOpen(true); - }, []); - - const closeToggleDialog = useCallback(() => { - setToggleDialogOpen(false); - setTogglingExt(null); - }, []); - - return { - toggleDialogOpen, - togglingExt, - toggleAction, - openToggleDialog, - closeToggleDialog, - }; -} diff --git a/plugins/pages/browser-extensions/src/hooks/use-uninstall-dialog.ts b/plugins/pages/browser-extensions/src/hooks/use-uninstall-dialog.ts deleted file mode 100644 index 74d5b66..0000000 --- a/plugins/pages/browser-extensions/src/hooks/use-uninstall-dialog.ts +++ /dev/null @@ -1,47 +0,0 @@ -import { useState } from 'react'; -import type { ExtensionItem } from '../types'; - -interface UseUninstallDialogReturn { - uninstallDialogOpen: boolean; - uninstallingExt: ExtensionItem | null; - action: 'uninstall' | 'remove'; - openUninstallDialog: (extension: ExtensionItem) => void; - openRemoveDialog: (extension: ExtensionItem) => void; - closeUninstallDialog: () => void; -} - -/** - * 卸载对话框状态管理 Hook - */ -export function useUninstallDialog(): UseUninstallDialogReturn { - const [uninstallDialogOpen, setUninstallDialogOpen] = useState(false); - const [uninstallingExt, setUninstallingExt] = useState(null); - const [action, setAction] = useState<'uninstall' | 'remove'>('uninstall'); - - const openUninstallDialog = (extension: ExtensionItem) => { - setAction('uninstall'); - setUninstallingExt(extension); - setUninstallDialogOpen(true); - }; - - const openRemoveDialog = (extension: ExtensionItem) => { - setAction('remove'); - setUninstallingExt(extension); - setUninstallDialogOpen(true); - }; - - const closeUninstallDialog = () => { - setUninstallDialogOpen(false); - setUninstallingExt(null); - setAction('uninstall'); - }; - - return { - uninstallDialogOpen, - uninstallingExt, - action, - openUninstallDialog, - openRemoveDialog, - closeUninstallDialog, - }; -} diff --git a/plugins/pages/browser-extensions/src/hooks/use-view-mode.ts b/plugins/pages/browser-extensions/src/hooks/use-view-mode.ts deleted file mode 100644 index fefb75e..0000000 --- a/plugins/pages/browser-extensions/src/hooks/use-view-mode.ts +++ /dev/null @@ -1,19 +0,0 @@ -import { useState } from 'react'; -import type { ViewMode } from '../types'; - -interface UseViewModeReturn { - viewMode: ViewMode; - setViewMode: (mode: ViewMode) => void; -} - -/** - * 视图模式管理 Hook - */ -export function useViewMode(): UseViewModeReturn { - const [viewMode, setViewMode] = useState('installed'); - - return { - viewMode, - setViewMode, - }; -} diff --git a/plugins/pages/browser-extensions/src/i18n/resources.ts b/plugins/pages/browser-extensions/src/i18n/resources.ts index d6f113e..9901983 100644 --- a/plugins/pages/browser-extensions/src/i18n/resources.ts +++ b/plugins/pages/browser-extensions/src/i18n/resources.ts @@ -117,6 +117,7 @@ export const extensionsResources = { import: '导入插件', importing: '导入中...', install: '安装', + installSuccess: '插件已安装到本地', remove: '移除', empty: '暂无本地插件', emptyDescription: '点击“导入插件”将本地浏览器插件加入当前设备的插件库。', @@ -349,6 +350,7 @@ export const extensionsResources = { import: 'Import Plugin', importing: 'Importing...', install: 'Install', + installSuccess: 'Extension installed locally', remove: 'Remove', empty: 'No local extensions', emptyDescription: diff --git a/plugins/pages/browser-extensions/src/index.tsx b/plugins/pages/browser-extensions/src/index.tsx index 76343a9..233ec91 100644 --- a/plugins/pages/browser-extensions/src/index.tsx +++ b/plugins/pages/browser-extensions/src/index.tsx @@ -1,224 +1,88 @@ +import { useCallback, useEffect, useMemo, useState } from 'react'; import { extensionRegistry } from '@slotkitjs/core'; +import { Loader2 } from 'lucide-react'; import { useTranslation } from 'react-i18next'; +import { toast } from 'sonner'; import { extensionsResources } from './i18n/resources'; -import { useState, useEffect, useRef, useMemo } from 'react'; -import { ExtensionHeader } from './components/extension-header'; -import { ExtensionStats } from './components/extension-stats'; -import { ExtensionTable } from './components/extension-table'; -import { ExtensionBatchActions } from './components/extension-batch-actions'; -import { ExtensionPagination } from './components/extension-pagination'; -import { ExtensionStore } from './components/extension-store'; import { LocalExtensionLibrary } from './components/local-extension-library'; import { LocalExtensionImportDialog } from './components/local-extension-import-dialog'; -import { ExtensionInstallDialog } from './components/extension-install-dialog'; import { ExtensionUninstallDialog } from './components/extension-uninstall-dialog'; -import { ExtensionDetailDialog } from './components/extension-detail-dialog'; -import { ExtensionHomepageDialog } from './components/extension-homepage-dialog'; -import { ExtensionBatchUninstallDialog } from './components/extension-batch-uninstall-dialog'; import { ExtensionToggleDialog } from './components/extension-toggle-dialog'; -import { useExtensions } from './hooks/use-extensions'; -import { useExtensionFilters, useStoreExtensions } from './hooks/use-extension-filters'; -import { useExtensionPagination } from './hooks/use-extension-pagination'; -import { useExtensionStats } from './hooks/use-extension-stats'; -import { useViewMode } from './hooks/use-view-mode'; -import { useSearchPagination } from './hooks/use-search-pagination'; -import { useExtensionHandlers } from './hooks/use-extension-handlers'; +import { ExtensionDetailDialog } from './components/extension-detail-dialog'; import { + disableLocalExtension, + enableLocalExtension, importLocalExtensionCrx, importLocalExtensionStoreUrl, - listGroups, - type GroupItem, + installLocalExtension, + listLocalExtensions, + removeLocalExtension, + type Extension, } from './api'; -import { STORE_ITEMS_PER_PAGE } from './constants'; -import type { SortOption } from './types'; -import { toast } from 'sonner'; +import type { ExtensionItem } from './types'; + +function toExtensionItem(extension: Extension): ExtensionItem { + return { + id: extension.id, + uuid: extension.id, + recordId: extension.recordId, + extensionId: extension.extensionId, + name: extension.name, + description: extension.description, + version: extension.version, + icon: extension.icon || '', + browser: extension.browser as ExtensionItem['browser'], + status: extension.status, + source: 'local', + author: extension.author, + homepage: extension.homepage, + downloads: extension.downloads, + rating: extension.rating, + updatedAt: extension.updatedAt, + createdAt: extension.createdAt, + fileSize: extension.fileSize, + permissions: extension.permissions, + hash: extension.hash, + scope: extension.scope, + category: extension.category, + }; +} const BrowserExtensionsPage: React.FC = () => { const { t } = useTranslation('extensions'); - const [scopeFilter, setScopeFilter] = useState('all'); - const [importingLocal, setImportingLocal] = useState(false); - const [localImportDialogOpen, setLocalImportDialogOpen] = useState(false); - - // 分组数据 - const [groups, setGroups] = useState([]); - const [loadingGroups, setLoadingGroups] = useState(false); - - // 视图模式 - const { viewMode, setViewMode } = useViewMode(); - - // 搜索和分页 - const { searchQuery, currentPage, handleSearchChange, handlePageChange } = useSearchPagination(); - const [storeCategory, setStoreCategory] = useState('all'); - const [storePageSize, setStorePageSize] = useState(STORE_ITEMS_PER_PAGE); - const [storeSortBy, setStoreSortBy] = useState('downloads'); - - // 数据获取 - 同时获取所有扩展和已安装扩展的数据 - const allExtensionsHook = useExtensions('all', 'all', { - page: currentPage, - pageSize: storePageSize, - search: searchQuery, - category: storeCategory === 'all' ? undefined : storeCategory, - sortBy: storeSortBy, - sortOrder: storeSortBy === 'name' ? 'asc' : 'desc', - }); - // 将 scopeFilter 映射到 API 的 scope 参数:'all' -> 'all', 'team' -> 'team', 'personal' -> 'user' - const apiScope = scopeFilter === 'personal' ? 'user' : scopeFilter === 'team' ? 'team' : 'all'; - const installedExtensionsHook = useExtensions('installed', apiScope); - const localExtensionsHook = useExtensions('local'); - - // 根据视图模式选择对应的数据和刷新函数 - const { - extensions: viewExtensions, - loading, - error, - refresh, - } = viewMode === 'installed' - ? installedExtensionsHook - : viewMode === 'local' - ? localExtensionsHook - : allExtensionsHook; - - // 获取已安装扩展的数据(用于统计) - const { extensions: installedExtensions } = installedExtensionsHook; - - const refreshRef = useRef(refresh); - const skipRefreshEffectOnMountRef = useRef(true); - - // 保持 refresh 函数引用最新 - useEffect(() => { - refreshRef.current = refresh; - }, [refresh]); - - // 初始加载 - 同时加载两个数据源 - useEffect(() => { - if (viewMode === 'installed') { - void installedExtensionsHook.refresh(); - } else if (viewMode === 'local') { - void localExtensionsHook.refresh(); - } else { - void allExtensionsHook.refresh(); - } - }, []); - - // 当切换视图模式或 scopeFilter 时,重新加载对应数据 - useEffect(() => { - if (skipRefreshEffectOnMountRef.current) { - skipRefreshEffectOnMountRef.current = false; - return; - } - void refreshRef.current(); - }, [viewMode, scopeFilter]); - - useEffect(() => { - if (viewMode !== 'store') { - return; - } - void allExtensionsHook.refresh(); - }, [allExtensionsHook.refresh]); - - useEffect(() => { - if (viewMode !== 'local') { - return; - } - void localExtensionsHook.refresh(); - }, [localExtensionsHook.refresh, viewMode]); - - // 过滤 - // 注意:已安装视图模式下,API 返回的都是已安装的扩展,不需要再次过滤状态 - const allFilteredExtensions = useExtensionFilters(allExtensionsHook.extensions, { - searchQuery, - scopeFilter, - }); - - // 对于已安装视图,只进行搜索过滤(不过滤状态,因为 API 返回的都是已安装的) - const filteredExtensions = useMemo(() => { - if (viewMode === 'installed' || viewMode === 'local') { - if (!searchQuery) return viewExtensions; - const query = searchQuery.toLowerCase(); - return viewExtensions.filter( - (ext) => - ext.name.toLowerCase().includes(query) || - ext.description.toLowerCase().includes(query) || - ext.author?.toLowerCase().includes(query) - ); - } - return allFilteredExtensions; - }, [viewMode, viewExtensions, allFilteredExtensions, searchQuery]); - - // 商店扩展 - const storeExtensions = useStoreExtensions(allExtensionsHook.extensions); - - // 分页 - const { paginatedExtensions, totalPages } = useExtensionPagination( - filteredExtensions, - currentPage - ); - - // 统计 - 使用正确的数据源 - // 已安装数量:从已安装扩展数据源获取 - const installedStats = useExtensionStats(installedExtensions); - // 所有扩展数量:从所有扩展数据源获取 - const allStats = useExtensionStats(allExtensionsHook.extensions); - - // 合并统计信息 - const stats = { - installedCount: installedStats.installedCount, - updateCount: installedStats.updateCount, - availableCount: allStats.availableCount, - }; - - const refreshAfterOperation = async () => { - await Promise.all([ - refresh(), - installedExtensionsHook.refresh(), - localExtensionsHook.refresh(), - ]); - }; - - // 事件处理(整合所有操作) - const handlers = useExtensionHandlers({ - extensions: viewExtensions, - paginatedExtensions, - onRefresh: refreshAfterOperation, - }); - - // 加载分组数据 - const loadGroups = async () => { - setLoadingGroups(true); + const [extensions, setExtensions] = useState([]); + const [loading, setLoading] = useState(true); + const [error, setError] = useState(null); + const [importing, setImporting] = useState(false); + const [importOpen, setImportOpen] = useState(false); + const [removeTarget, setRemoveTarget] = useState(null); + const [toggleTarget, setToggleTarget] = useState(null); + const [toggleAction, setToggleAction] = useState<'disable' | 'enable'>('disable'); + const [detailTarget, setDetailTarget] = useState(null); + + const refresh = useCallback(async () => { + setError(null); try { - const data = await listGroups(); - setGroups(data); - } catch (e) { - console.error('Failed to load groups:', e); + setExtensions((await listLocalExtensions()).map(toExtensionItem)); + } catch (reason) { + setError(reason instanceof Error ? reason.message : String(reason)); } finally { - setLoadingGroups(false); + setLoading(false); } - }; - - // 当安装弹窗打开时加载分组数据 - useEffect(() => { - if (handlers.installDialog.installDialogOpen) { - void loadGroups(); - } - }, [handlers.installDialog.installDialogOpen]); + }, []); useEffect(() => { - handlePageChange(1); - }, [handlePageChange, searchQuery, storeCategory, storePageSize, storeSortBy, viewMode]); - - const handleStoreCategoryChange = (category: string) => { - setStoreCategory(category); - }; + void refresh(); + }, [refresh]); - const handleStorePageSizeChange = (size: number) => { - setStorePageSize(size); - }; + const byId = useMemo( + () => new Map(extensions.map((extension) => [extension.id, extension])), + [extensions] + ); - const handleStoreSortChange = (sort: SortOption) => { - setStoreSortBy(sort); - }; + const recordId = (extension: ExtensionItem) => extension.recordId || extension.id; - const handleImportLocal = async ({ + const handleImport = async ({ mode, crxPath, storeUrl, @@ -227,19 +91,13 @@ const BrowserExtensionsPage: React.FC = () => { crxPath?: string; storeUrl?: string; }) => { + setImporting(true); try { - setImportingLocal(true); const result = mode === 'file' ? await importLocalExtensionCrx(crxPath || '') : await importLocalExtensionStoreUrl(storeUrl || ''); - - if (result.importState === 'imported') { - await Promise.all([localExtensionsHook.refresh(), installedExtensionsHook.refresh()]); - } - - setLocalImportDialogOpen(false); - + setImportOpen(false); if (result.importState === 'alreadyInstalled') { toast.info(t('dialog.localImport.alreadyInstalled')); } else if (result.importState === 'alreadyExists') { @@ -247,218 +105,118 @@ const BrowserExtensionsPage: React.FC = () => { } else { toast.success(t('dialog.localImport.success')); } - - if (viewMode !== 'local') { - setViewMode('local'); - } - } catch (e) { - toast.error(e instanceof Error ? e.message : '导入本地插件失败'); + await refresh(); } finally { - setImportingLocal(false); + setImporting(false); } }; - return ( -
- - - {viewMode === 'installed' ? ( - <> - - - {error && ( -
- {t('error', { message: error })} -
- )} - - handlers.selection.selectAll(paginatedExtensions, selected)} - onUpdate={handlers.handleUpdate} - onUninstall={handlers.handleUninstall} - onViewDetails={handlers.handleViewDetails} - onHomepage={handlers.handleHomepage} - onSecurityCheck={handlers.handleSecurityCheck} - onDisable={handlers.handleDisable} - onEnable={handlers.handleEnable} - loading={loading} - /> + const handleInstall = async (extension: ExtensionItem) => { + await installLocalExtension(recordId(extension)); + toast.success(t('local.installSuccess')); + await refresh(); + }; - + const confirmRemove = async () => { + if (!removeTarget) return; + await removeLocalExtension(recordId(removeTarget)); + setRemoveTarget(null); + await refresh(); + }; - {}} - onBatchUpdate={handlers.handleBatchUpdate} - onBatchUninstall={handlers.handleBatchUninstall} - onClearSelection={handlers.selection.clearSelection} - /> - - ) : viewMode === 'store' ? ( - <> - {error && ( -
- {t('error', { message: error })} -
- )} + const confirmToggle = async () => { + if (!toggleTarget) return; + const command = toggleAction === 'disable' ? disableLocalExtension : enableLocalExtension; + await command(recordId(toggleTarget)); + setToggleTarget(null); + await refresh(); + }; - - + return ( +
+ {loading ? ( +
+ +
) : ( <> {error && ( -
+
{t('error', { message: error })}
)} - setLocalImportDialogOpen(true)} - onInstall={(extension) => handlers.handleInstall(extension.id)} - onDisable={(extension) => handlers.handleDisable(extension.id, extension.name)} - onEnable={(extension) => handlers.handleEnable(extension.id, extension.name)} - onRemove={(extension) => handlers.handleRemove(extension.id, extension.name)} - onViewDetails={(extension) => handlers.handleViewDetails(extension.id)} + extensions={extensions} + importing={importing} + onImport={() => setImportOpen(true)} + onInstall={(extension) => void handleInstall(byId.get(extension.id) || extension)} + onDisable={(extension) => { + setToggleTarget(extension); + setToggleAction('disable'); + }} + onEnable={(extension) => { + setToggleTarget(extension); + setToggleAction('enable'); + }} + onRemove={setRemoveTarget} + onViewDetails={setDetailTarget} /> )} - {/* 安装对话框 */} - - - - {/* 卸载确认对话框 */} - - {/* 详情对话框 */} - - - {/* 访问主页对话框 */} - !open && setRemoveTarget(null)} + onConfirm={confirmRemove} /> - - {/* 批量卸载确认对话框 */} - - - {/* 禁用/启用确认对话框 */} !open && setToggleTarget(null)} + onConfirm={confirmToggle} + /> + !open && setDetailTarget(null)} />
); }; -// 在模块加载时贡献路由 try { extensionRegistry.contribute('routes', { contributorId: 'browser-extensions', - value: { - path: '/extensions', - Component: BrowserExtensionsPage, - }, + value: { path: '/extensions', Component: BrowserExtensionsPage }, priority: 10, }); - console.log('[browser-extensions] Route contributed at module load: /extensions'); } catch (error) { - console.warn('[browser-extensions] Failed to contribute route at module load:', error); + console.warn('[browser-extensions] Failed to contribute route:', error); } try { extensionRegistry.contribute('i18n:resources', { contributorId: 'browser-extensions', - value: { - namespace: 'extensions', - resources: extensionsResources, - }, + value: { namespace: 'extensions', resources: extensionsResources }, priority: 10, }); } catch (error) { console.warn('[browser-extensions] Failed to contribute i18n resources:', error); } -const browserExtensionsPlugin = { +export default { id: 'browser-extensions', name: 'Browser Extensions', version: '1.0.0', component: BrowserExtensionsPage, slots: [], }; - -export default browserExtensionsPlugin; diff --git a/plugins/pages/browser-extensions/src/types/index.ts b/plugins/pages/browser-extensions/src/types/index.ts index ec173b8..ca97a5b 100644 --- a/plugins/pages/browser-extensions/src/types/index.ts +++ b/plugins/pages/browser-extensions/src/types/index.ts @@ -1,8 +1,5 @@ export interface ExtensionItem { - /** - * 用于业务操作的唯一 ID。 - * 远端插件使用 extension_id,本地插件使用 record_id。 - */ + /** 用于本地扩展业务操作的唯一 ID。 */ id: string; /** * 后端扩展记录的 UUID(仅用于展示/调试,不用于接口调用) @@ -16,7 +13,7 @@ export interface ExtensionItem { status: 'installed' | 'available' | 'update' | 'disabled' | 'active'; icon?: string; browser: 'chrome' | 'firefox' | 'edge' | 'all'; - source: 'remote' | 'local'; + source: 'local'; author?: string; homepage?: string; downloads?: number; @@ -34,12 +31,6 @@ export interface ExtensionItem { category?: string; } -export interface StoreExtension extends ExtensionItem { - category?: string; - rating?: number; - isInstalled?: boolean; -} - export type ExtensionStatus = 'installed' | 'available' | 'update' | 'disabled' | 'active'; export type ExtensionBrowser = 'chrome' | 'firefox' | 'edge' | 'all'; export type ExtensionCategory = @@ -49,5 +40,3 @@ export type ExtensionCategory = | 'tools' | 'media' | 'social'; -export type ViewMode = 'installed' | 'store' | 'local'; -export type SortOption = 'downloads' | 'rating' | 'name' | 'newest'; diff --git a/plugins/pages/browser-extensions/src/utils/chrome-store.ts b/plugins/pages/browser-extensions/src/utils/chrome-store.ts deleted file mode 100644 index 95fb33a..0000000 --- a/plugins/pages/browser-extensions/src/utils/chrome-store.ts +++ /dev/null @@ -1,21 +0,0 @@ -/** - * 根据扩展 ID 生成 Chrome Web Store URL - * - * @param extensionId - Chrome 扩展 ID - * @returns Chrome Web Store 页面 URL - */ -export function getChromeStoreUrl(extensionId: string): string { - return `https://chromewebstore.google.com/detail/${extensionId}`; -} - -/** - * 获取扩展的主页 URL - * 如果扩展有 homepage,则使用 homepage;否则使用 Chrome Web Store URL - * - * @param extensionId - Chrome 扩展 ID - * @param homepage - 扩展的主页 URL(可选) - * @returns 实际的主页 URL - */ -export function getExtensionHomepageUrl(extensionId: string, homepage?: string | null): string { - return homepage || getChromeStoreUrl(extensionId); -} diff --git a/plugins/pages/create-window/src/api/index.ts b/plugins/pages/create-window/src/api/index.ts index 2cf8aae..6d3349c 100644 --- a/plugins/pages/create-window/src/api/index.ts +++ b/plugins/pages/create-window/src/api/index.ts @@ -30,8 +30,8 @@ export const API_ENDPOINTS = { /** 浏览器内核版本 */ export interface BrowserKernelVersion { - id: number; - type_id: number; + kernel_id: string; + type_code: string; resource_name: string; version: string; name?: string; @@ -44,6 +44,7 @@ export interface BrowserKernelVersion { requires_extract?: boolean; entrypoint_template?: string; extract_root?: string; + installed?: boolean; } function buildEnvironmentUrls(urls: string[]) { @@ -70,6 +71,7 @@ function buildWindowInfoPayload(config: WindowConfig['windowInfo']) { name: config.name, system: config.system, kernel: config.kernel, + kernel_id: config.kernelId || undefined, userAgent: config.userAgent, searchEngine: config.searchEngine, description: config.description, diff --git a/plugins/pages/create-window/src/components/import-dialog.tsx b/plugins/pages/create-window/src/components/import-dialog.tsx index fe095ac..5fb781d 100644 --- a/plugins/pages/create-window/src/components/import-dialog.tsx +++ b/plugins/pages/create-window/src/components/import-dialog.tsx @@ -8,8 +8,9 @@ import { DialogHeader, DialogTitle, } from '@/components/ui/dialog'; -import { Button } from '@/components/ui/button'; -import { Label } from '@/components/ui/label'; +import { Button } from '@/components/ui/button'; +import { Label } from '@/components/ui/label'; +import { isSuccess, post } from '@/lib/request'; interface ImportDialogProps { open: boolean; @@ -31,18 +32,15 @@ export function ImportDialog({ open, onOpenChange }: ImportDialogProps) { const data = JSON.parse(importJson); setSubmitting(true); - const response = await fetch('/api/v1/environments/import', { - method: 'POST', - headers: { 'Content-Type': 'application/json' }, - body: JSON.stringify({ environments: Array.isArray(data) ? data : [data] }), - }); - - if (!response.ok) { - throw new Error('导入失败'); - } - - const json = await response.json(); - toast.success(`成功导入 ${json.data?.length || 0} 个窗口`); + const environments = Array.isArray(data) ? data : [data]; + const response = await post('environments/batch-create', { environments }); + + if (!isSuccess(response)) { + throw new Error(response.message || '导入失败'); + } + + const imported = Array.isArray(response.data) ? response.data.length : environments.length; + toast.success(`成功导入 ${imported} 个窗口`); setImportJson(''); onOpenChange(false); } catch (error) { diff --git a/plugins/pages/create-window/src/components/proxy-select-dialog.tsx b/plugins/pages/create-window/src/components/proxy-select-dialog.tsx deleted file mode 100644 index 959e1d3..0000000 --- a/plugins/pages/create-window/src/components/proxy-select-dialog.tsx +++ /dev/null @@ -1,491 +0,0 @@ -import { useState, useEffect, useCallback } from 'react'; -import { useTranslation } from 'react-i18next'; -import { ScrollArea, ScrollBar } from '@/components/ui/scroll-area'; -import { Checkbox } from '@/components/animate-ui/components/radix/checkbox'; -import { Button } from '@/components/ui/button'; -import { Dialog, DialogContent, DialogHeader, DialogTitle } from '@/components/ui/dialog'; -import { - Pagination, - PaginationContent, - PaginationItem, - PaginationLink, - PaginationNext, - PaginationPrevious, - PaginationEllipsis, -} from '@/components/ui/pagination'; -import { Network, Zap, Monitor } from 'lucide-react'; -// 代理类型定义(从代理中心复制) -interface Proxy { - id: string; - name: string; - host: string; - port: number; - type: 'http' | 'https' | 'socks5'; - username?: string; - password?: string; - country: string; - latency: number; - status: 'healthy' | 'unreachable' | 'testing'; - usageCount: number; - linkedEnvironments: number; - createdAt: string; - lastChecked: string; -} - -const API_ENDPOINTS = { - PROXIES: '/api/v1/proxies', -} as const; - -const ITEMS_PER_PAGE = 10; - -interface ProxySelectDialogProps { - open: boolean; - onOpenChange: (open: boolean) => void; - createCount: number; - onSelect: (proxies: Proxy[]) => void; -} - -/** - * 代理选择对话框组件 - */ -export function ProxySelectDialog({ - open, - onOpenChange, - createCount, - onSelect, -}: ProxySelectDialogProps) { - const { t } = useTranslation('proxy'); - const [proxies, setProxies] = useState([]); - const [loading, setLoading] = useState(false); - const [error, setError] = useState(null); - const [currentPage, setCurrentPage] = useState(1); - const [selectedProxyIds, setSelectedProxyIds] = useState>(new Set()); - - // 是否支持多选 - const allowMultiple = createCount > 1; - - // 获取代理列表 - const fetchProxies = useCallback(async () => { - setLoading(true); - setError(null); - try { - const res = await fetch(API_ENDPOINTS.PROXIES); - if (!res.ok) throw new Error(`请求失败: ${res.status}`); - const json = (await res.json()) as { data: Proxy[] }; - setProxies(json.data ?? []); - } catch (e) { - setError(e instanceof Error ? e.message : '未知错误'); - } finally { - setLoading(false); - } - }, []); - - useEffect(() => { - if (open) { - void fetchProxies(); - setCurrentPage(1); - setSelectedProxyIds(new Set()); - } - }, [open, fetchProxies]); - - // 分页计算 - const totalPages = Math.ceil(proxies.length / ITEMS_PER_PAGE); - const startIndex = (currentPage - 1) * ITEMS_PER_PAGE; - const endIndex = startIndex + ITEMS_PER_PAGE; - const paginatedProxies = proxies.slice(startIndex, endIndex); - - // 格式化日期 - const formatDate = (dateString: string) => { - if (!dateString || dateString === '-') return '-'; - try { - const date = new Date(dateString); - return date.toLocaleDateString('zh-CN', { - year: 'numeric', - month: '2-digit', - day: '2-digit', - }); - } catch { - return dateString; - } - }; - - // 获取状态徽章 - const getStatusBadge = (status: Proxy['status']) => { - switch (status) { - case 'healthy': - return ( - - - {t('status.healthy')} - - ); - case 'unreachable': - return ( - - - {t('status.unreachable')} - - ); - case 'testing': - return ( - - - {t('status.testing')} - - ); - default: - return null; - } - }; - - // 获取类型徽章 - const getTypeBadge = (type: Proxy['type']) => { - const colors: Record = { - http: 'bg-blue-500/10 text-blue-500', - https: 'bg-green-500/10 text-green-500', - socks5: 'bg-purple-500/10 text-purple-500', - }; - return ( - - {type} - - ); - }; - - // 获取页码数组 - const getPageNumbers = () => { - const pages: (number | 'ellipsis')[] = []; - const maxVisible = 7; - - if (totalPages <= maxVisible) { - for (let i = 1; i <= totalPages; i++) { - pages.push(i); - } - } else { - if (currentPage <= 3) { - for (let i = 1; i <= 4; i++) pages.push(i); - pages.push('ellipsis'); - pages.push(totalPages); - } else if (currentPage >= totalPages - 2) { - pages.push(1); - pages.push('ellipsis'); - for (let i = totalPages - 3; i <= totalPages; i++) pages.push(i); - } else { - pages.push(1); - pages.push('ellipsis'); - for (let i = currentPage - 1; i <= currentPage + 1; i++) pages.push(i); - pages.push('ellipsis'); - pages.push(totalPages); - } - } - - return pages; - }; - - // 处理选择代理 - const handleSelectProxy = (proxy: Proxy) => { - if (allowMultiple) { - // 多选模式:切换选择状态 - const newSelectedIds = new Set(selectedProxyIds); - if (newSelectedIds.has(proxy.id)) { - newSelectedIds.delete(proxy.id); - } else { - newSelectedIds.add(proxy.id); - } - setSelectedProxyIds(newSelectedIds); - } else { - // 单选模式:选择新代理时取消之前的选择,只保留当前选择的 - setSelectedProxyIds(new Set([proxy.id])); - } - }; - - // 处理全选/取消全选 - const handleSelectAll = (selected: boolean) => { - if (!allowMultiple) return; - - if (selected) { - const allIds = new Set(proxies.map((p) => p.id)); - setSelectedProxyIds(allIds); - } else { - setSelectedProxyIds(new Set()); - } - }; - - // 处理确认选择 - const handleConfirmSelection = () => { - if (selectedProxyIds.size === 0) return; - - const selectedProxies = proxies.filter((p) => selectedProxyIds.has(p.id)); - onSelect(selectedProxies); - onOpenChange(false); - }; - - // 计算全选状态 - const allSelected = - allowMultiple && proxies.length > 0 && proxies.every((proxy) => selectedProxyIds.has(proxy.id)); - const someSelected = allowMultiple && proxies.some((proxy) => selectedProxyIds.has(proxy.id)); - - return ( - - - - - {t('dialog.select.title', { defaultValue: '选择代理' })} - - - -
- -
- - - - - - - - - - - - - - - - - {loading ? ( - - - - ) : error ? ( - - - - ) : paginatedProxies.length === 0 ? ( - - - - ) : ( - paginatedProxies.map((proxy) => ( - handleSelectProxy(proxy)} - > - - - - - - - - - - - - )) - )} - -
-
- {allowMultiple ? ( - { - if (input && 'indeterminate' in input) { - (input as HTMLInputElement).indeterminate = - someSelected && !allSelected; - } - }} - onCheckedChange={handleSelectAll} - className="h-4 w-4" - /> - ) : ( - - )} -
-
- {t('table.headers.name')} - - {t('table.headers.address')} - - {t('table.headers.type')} - - {t('table.headers.country')} - - {t('table.headers.latency')} - - {t('table.headers.status')} - - {t('table.headers.linkedEnvironments')} - - {t('table.headers.usageCount')} - - {t('table.headers.createdAt')} -
- {t('table.loading', { defaultValue: '加载中...' })} -
- {error} -
- {t('table.empty')} -
-
- handleSelectProxy(proxy)} - className="h-4 w-4" - onClick={(e) => e.stopPropagation()} - /> -
-
-
- - {proxy.name} -
-
- ID: {proxy.id} -
-
-
- {proxy.host}:{proxy.port} -
- {proxy.username && ( -
- {t('table.auth')}: {proxy.username} -
- )} -
- {getTypeBadge(proxy.type)} - - {proxy.country} - - {proxy.status === 'healthy' ? ( -
- - {proxy.latency} ms -
- ) : ( - - - )} -
- {getStatusBadge(proxy.status)} - -
- - - {proxy.linkedEnvironments} - -
-
- - {proxy.usageCount} - - -
- {formatDate(proxy.createdAt)} -
-
-
- -
- - {/* 确认选择按钮 */} - {selectedProxyIds.size > 0 && ( -
-
- {allowMultiple ? `已选择 ${selectedProxyIds.size} 个代理` : '已选择 1 个代理'} -
-
- {allowMultiple && ( - - )} - -
-
- )} - - {/* 分页 */} - {!loading && !error && proxies.length > 0 && ( -
-
- {t('pagination.pageInfo', { currentPage, totalPages })} -
- - - - { - e.preventDefault(); - if (currentPage > 1) setCurrentPage(currentPage - 1); - }} - className={ - currentPage === 1 ? 'pointer-events-none opacity-50' : 'cursor-pointer' - } - > - {t('pagination.previous')} - - - - {getPageNumbers().map((page, index) => ( - - {page === 'ellipsis' ? ( - - ) : ( - { - e.preventDefault(); - setCurrentPage(page); - }} - isActive={page === currentPage} - className="cursor-pointer" - > - {page} - - )} - - ))} - - - { - e.preventDefault(); - if (currentPage < totalPages) setCurrentPage(currentPage + 1); - }} - className={ - currentPage === totalPages - ? 'pointer-events-none opacity-50' - : 'cursor-pointer' - } - > - {t('pagination.next')} - - - - -
- )} -
-
-
- ); -} diff --git a/plugins/pages/create-window/src/components/window-info-form.tsx b/plugins/pages/create-window/src/components/window-info-form.tsx index 1eaa409..1212506 100644 --- a/plugins/pages/create-window/src/components/window-info-form.tsx +++ b/plugins/pages/create-window/src/components/window-info-form.tsx @@ -63,11 +63,13 @@ interface WindowInfoFormProps { * 优先从 name 字段提取版本号(如 "simprint-browser-144.0.7559.118.zip"), * 如果提取失败则使用 version 字段 */ -function getCurrentKernelVersion( - kernelVersions: BrowserKernelVersion[], - currentKernel: string -): string | undefined { - const kernel = kernelVersions.find((v) => v.resource_name === currentKernel); +function getCurrentKernelVersion( + kernelVersions: BrowserKernelVersion[], + currentKernelIdOrName: string +): string | undefined { + const kernel = kernelVersions.find( + (v) => v.kernel_id === currentKernelIdOrName || v.resource_name === currentKernelIdOrName + ); if (!kernel) { return undefined; } @@ -132,16 +134,23 @@ export function WindowInfoForm({ value, onChange }: WindowInfoFormProps) { setKernelVersions(versions); // 首次加载且 kernel 不在列表中时,设为第一个版本 if (versions.length > 0) { - const currentInList = versions.some((v) => v.resource_name === value.kernel); + const currentInList = versions.some( + (v) => v.kernel_id === value.kernelId || (!value.kernelId && v.resource_name === value.kernel) + ); const needDefault = !hasSetDefaultKernel.current[platform] && (!currentInList || value.kernel === 'Chrome'); if (needDefault) { hasSetDefaultKernel.current[platform] = true; - const firstResourceName = versions[0].resource_name; - const kernelVersion = versions[0].version; - const newUA = generateUserAgentByKernel(value.system, 'Chrome', kernelVersion); - onChange({ ...value, kernel: firstResourceName, userAgent: newUA }); + const firstResourceName = versions[0].resource_name; + const kernelVersion = versions[0].version; + const newUA = generateUserAgentByKernel(value.system, 'Chrome', kernelVersion); + onChange({ + ...value, + kernel: firstResourceName, + kernelId: versions[0].kernel_id, + userAgent: newUA, + }); } } }) @@ -157,10 +166,17 @@ export function WindowInfoForm({ value, onChange }: WindowInfoFormProps) { toast.info(t('windowInfo.firefoxNotSupported')); }; - const handleKernelVersionChange = (resourceName: string) => { - const kernelVersion = getCurrentKernelVersion(kernelVersions, resourceName); - const newUA = generateUserAgentByKernel(value.system, 'Chrome', kernelVersion); - onChange({ ...value, kernel: resourceName, userAgent: newUA }); + const handleKernelVersionChange = (kernelId: string) => { + const selected = kernelVersions.find((kernel) => kernel.kernel_id === kernelId); + if (!selected) return; + const kernelVersion = getCurrentKernelVersion(kernelVersions, kernelId); + const newUA = generateUserAgentByKernel(value.system, 'Chrome', kernelVersion); + onChange({ + ...value, + kernel: selected.resource_name, + kernelId: selected.kernel_id, + userAgent: newUA, + }); }; // 加载账号列表(用于显示) @@ -247,7 +263,10 @@ export function WindowInfoForm({ value, onChange }: WindowInfoFormProps) { key={option.value} type="button" onClick={() => { - const kernelVersion = getCurrentKernelVersion(kernelVersions, value.kernel); + const kernelVersion = getCurrentKernelVersion( + kernelVersions, + value.kernelId || value.kernel + ); const newUA = generateUserAgentByKernel(option.value, 'Chrome', kernelVersion); onChange({ ...value, system: option.value, userAgent: newUA }); }} @@ -303,10 +322,12 @@ export function WindowInfoForm({ value, onChange }: WindowInfoFormProps) { {t('windowInfo.firefoxNotSupported')} { - setPassword(e.target.value); - clearErrors(); - }} - aria-invalid={!!errors.password} - className="pl-9" - required - /> -
- {errors.password &&

{errors.password}

} -
-
- setRememberPassword(checked === true)} - /> - -
- -
-
- -
- - -
- - - ); -}; +import { useEffect, useState, type FormEvent } from 'react'; +import { useNavigate } from 'react-router'; +import { ArrowLeft, LoaderCircle, LockKeyhole, Plus, UserRound } from 'lucide-react'; +import { Input } from '@/components/ui/input'; +import { Button } from '@/components/ui/button'; +import { Label } from '@/components/ui/label'; +import { useTranslation } from 'react-i18next'; +import { toast } from 'sonner'; +import { useAuth } from '../../../../services/store/src'; +import { listLocalUsers, loginLocalUser, type LocalUserProfile } from '../api'; + +function toStoreUser(user: LocalUserProfile) { + return { + uuid: user.uuid, + id: user.uuid, + nickname: user.nickname, + avatar: user.avatar, + has_password: user.hasPassword, + status: 'active', + current_workspace_uuid: user.currentWorkspaceUuid ?? null, + current_team_uuid: user.currentTeamUuid ?? null, + }; +} + +export const LoginForm: React.FC = () => { + const navigate = useNavigate(); + const { t } = useTranslation('auth'); + const { setUser } = useAuth(); + const [users, setUsers] = useState([]); + const [selectedUser, setSelectedUser] = useState(null); + const [password, setPassword] = useState(''); + const [loading, setLoading] = useState(true); + const [submitting, setSubmitting] = useState(false); + const [error, setError] = useState(''); + + useEffect(() => { + listLocalUsers() + .then(setUsers) + .catch((reason) => toast.error(String(reason))) + .finally(() => setLoading(false)); + }, []); + + const finishLogin = (user: LocalUserProfile) => { + setUser(toStoreUser(user)); + navigate('/'); + }; + + const chooseUser = async (user: LocalUserProfile) => { + setError(''); + if (user.hasPassword) { + setSelectedUser(user); + setPassword(''); + return; + } + setSubmitting(true); + try { + finishLogin(await loginLocalUser(user.uuid)); + } catch (reason) { + toast.error(reason instanceof Error ? reason.message : String(reason)); + } finally { + setSubmitting(false); + } + }; + + const submitPassword = async (event: FormEvent) => { + event.preventDefault(); + if (!selectedUser || !password) return; + setSubmitting(true); + setError(''); + try { + finishLogin(await loginLocalUser(selectedUser.uuid, password)); + } catch (reason) { + setError(reason instanceof Error ? reason.message : String(reason)); + } finally { + setSubmitting(false); + } + }; + + if (selectedUser) { + return ( + <> +
+
+ {selectedUser.avatar} +
+

{t('password.title')}

+

{t('password.subtitle')}

+
+
+
+ +
+ {selectedUser.avatar} + {selectedUser.nickname} +
+
+
+ +
+ + { + setPassword(event.target.value); + setError(''); + }} + placeholder={t('password.passwordPlaceholder')} + className="pl-9" + aria-invalid={!!error} + /> +
+ {error &&

{error}

} +
+
+ + +
+
+ + ); + } + + return ( + <> +
+

{t('login.title')}

+

{t('login.subtitle')}

+
+
+ {loading ? ( +
+ +
+ ) : users.length === 0 ? ( +
+ +

{t('login.empty')}

+
+ ) : ( +
+ {users.map((user) => ( + + ))} +
+ )} + +
+ + ); +}; diff --git a/plugins/pages/login/src/constants.ts b/plugins/pages/login/src/constants.ts deleted file mode 100644 index 8fcd421..0000000 --- a/plugins/pages/login/src/constants.ts +++ /dev/null @@ -1,7 +0,0 @@ -/** - * 邮箱验证正则 - * - 用户名部分:允许大小写字母、数字、点、连字符、下划线 - * - 域名部分:只允许小写字母、数字、连字符 - */ -export const EMAIL_REGEX = - /^[^\s@]+@[a-z0-9]([a-z0-9-]*[a-z0-9])?(\.[a-z0-9]([a-z0-9-]*[a-z0-9])?)+$/; diff --git a/plugins/pages/login/src/hooks/use-login-form.ts b/plugins/pages/login/src/hooks/use-login-form.ts deleted file mode 100644 index 9b16da7..0000000 --- a/plugins/pages/login/src/hooks/use-login-form.ts +++ /dev/null @@ -1,49 +0,0 @@ -import { useState, useCallback } from 'react'; -import type { LoginFormData } from '../types'; - -interface UseLoginFormReturn { - formData: LoginFormData; - setEmail: (email: string) => void; - setPassword: (password: string) => void; - setRememberPassword: (remember: boolean) => void; - resetForm: () => void; -} - -/** - * 登录表单状态管理 Hook - */ -export function useLoginForm(): UseLoginFormReturn { - const [formData, setFormData] = useState({ - email: '', - password: '', - rememberPassword: false, - }); - - const setEmail = useCallback((email: string) => { - setFormData((prev) => ({ ...prev, email })); - }, []); - - const setPassword = useCallback((password: string) => { - setFormData((prev) => ({ ...prev, password })); - }, []); - - const setRememberPassword = useCallback((remember: boolean) => { - setFormData((prev) => ({ ...prev, rememberPassword: remember })); - }, []); - - const resetForm = useCallback(() => { - setFormData({ - email: '', - password: '', - rememberPassword: false, - }); - }, []); - - return { - formData, - setEmail, - setPassword, - setRememberPassword, - resetForm, - }; -} diff --git a/plugins/pages/login/src/hooks/use-login-validation.ts b/plugins/pages/login/src/hooks/use-login-validation.ts deleted file mode 100644 index 93b279e..0000000 --- a/plugins/pages/login/src/hooks/use-login-validation.ts +++ /dev/null @@ -1,94 +0,0 @@ -import { useState, useCallback } from 'react'; -import { useTranslation } from 'react-i18next'; -import { EMAIL_REGEX } from '../constants'; - -interface ValidationErrors { - email: string; - password: string; -} - -interface UseLoginValidationReturn { - errors: ValidationErrors; - validateEmail: (email: string) => boolean; - validatePassword: (password: string) => boolean; - validateForm: (email: string, password: string) => boolean; - clearErrors: () => void; - setEmailError: (error: string) => void; - setPasswordError: (error: string) => void; -} - -/** - * 登录表单验证 Hook - */ -export function useLoginValidation(): UseLoginValidationReturn { - const { t } = useTranslation('auth'); - const [errors, setErrors] = useState({ - email: '', - password: '', - }); - - const validateEmail = useCallback( - (email: string): boolean => { - if (!email) { - setErrors((prev) => ({ ...prev, email: t('login.err.emailRequired') })); - return false; - } - - if (!EMAIL_REGEX.test(email)) { - setErrors((prev) => ({ ...prev, email: t('login.err.emailInvalid') })); - return false; - } - - setErrors((prev) => ({ ...prev, email: '' })); - return true; - }, - [t] - ); - - const validatePassword = useCallback( - (password: string): boolean => { - if (!password) { - setErrors((prev) => ({ ...prev, password: t('login.passwordPlaceholder') })); - return false; - } - - setErrors((prev) => ({ ...prev, password: '' })); - return true; - }, - [t] - ); - - const validateForm = useCallback( - (email: string, password: string): boolean => { - const isEmailValid = validateEmail(email); - const isPasswordValid = validatePassword(password); - return isEmailValid && isPasswordValid; - }, - [validateEmail, validatePassword] - ); - - const clearErrors = useCallback(() => { - setErrors({ - email: '', - password: '', - }); - }, []); - - const setEmailError = useCallback((error: string) => { - setErrors((prev) => ({ ...prev, email: error })); - }, []); - - const setPasswordError = useCallback((error: string) => { - setErrors((prev) => ({ ...prev, password: error })); - }, []); - - return { - errors, - validateEmail, - validatePassword, - validateForm, - clearErrors, - setEmailError, - setPasswordError, - }; -} diff --git a/plugins/pages/login/src/hooks/use-login.ts b/plugins/pages/login/src/hooks/use-login.ts deleted file mode 100644 index f1ec529..0000000 --- a/plugins/pages/login/src/hooks/use-login.ts +++ /dev/null @@ -1,85 +0,0 @@ -import { useCallback } from 'react'; -import { useNavigate } from 'react-router'; -import { toast } from 'sonner'; -import { useTranslation } from 'react-i18next'; -import { useAuth, useLoading } from '../../../../services/store/src'; -import type { LoginFormData } from '../types'; -import { login, saveRememberedCredential, clearRememberedCredential } from '../api'; - -interface UseLoginReturn { - handleLogin: (formData: LoginFormData) => Promise; -} - -/** - * 登录操作 Hook - */ -export function useLogin(): UseLoginReturn { - const navigate = useNavigate(); - const { setUser } = useAuth(); - const { setLoading } = useLoading(); - const { t } = useTranslation('auth'); - - const handleLogin = useCallback( - async (formData: LoginFormData) => { - setLoading(true); - - try { - const responseData = await login(formData.email, formData.password); - - // 根据"记住密码"选项处理凭证 - if (formData.rememberPassword && responseData.refresh_token) { - // 如果勾选了"记住密码",保存 refresh_token - try { - await saveRememberedCredential(formData.email, responseData.refresh_token); - } catch (error) { - console.warn('保存记住的凭证失败:', error); - // 不影响登录流程,只记录警告 - } - } else { - // 如果未勾选"记住密码",清除已保存的凭证 - await clearRememberedCredential(); - } - - // 更新 store 中的用户状态 - if (responseData.user_info) { - const userInfo = responseData.user_info; - setUser({ - uuid: userInfo.uuid || '', - id: userInfo.id || '', - nickname: userInfo.nickname, - email: userInfo.email || formData.email, - phone: userInfo.phone, - avatar: userInfo.avatar_hash, - status: userInfo.status || 'active', - current_workspace_uuid: userInfo.current_workspace?.uuid || null, - current_team_uuid: userInfo.current_team?.uuid || null, - }); - } else { - // 如果没有用户信息,使用邮箱作为基本信息 - setUser({ - uuid: '', - id: formData.email.split('@')[0], - email: formData.email, - status: 'active', - current_workspace_uuid: null, - current_team_uuid: null, - }); - } - - // 登录成功后跳转到首页 - navigate('/'); - } catch (error: unknown) { - const message = error instanceof Error ? error.message : t('login.err.failed'); - toast.error(message); - throw error; - } finally { - setLoading(false); - } - }, - [navigate, setUser, setLoading, t] - ); - - return { - handleLogin, - }; -} diff --git a/plugins/pages/login/src/i18n/resources.ts b/plugins/pages/login/src/i18n/resources.ts index 7810899..dcc2f33 100644 --- a/plugins/pages/login/src/i18n/resources.ts +++ b/plugins/pages/login/src/i18n/resources.ts @@ -1,68 +1,68 @@ -export const authResources = { - 'zh-CN': { - login: { - title: '欢迎回来', - subtitle: '登录您的账户以继续', - emailLabel: '邮箱地址', - emailPlaceholder: '请输入您的邮箱', - passwordLabel: '密码', - passwordPlaceholder: '请输入您的密码', - remember: '记住密码', - forgot: '忘记密码?', - submit: '登录', - register: '注册', - err: { - emailRequired: '请输入邮箱地址', - emailInvalid: '请输入有效的邮箱地址', - failed: '登录失败,请检查您的凭据', - failedGeneric: '登录失败', - }, - }, - register: { - title: '创建账户', - subtitle: '注册新账户以开始使用', - }, - reset: { - title: '重置密码', - subtitleStep1: '通过邮箱重置您的密码', - subtitleStep2: '请输入验证码和新密码', - backToLogin: '返回登录', - sendCode: '发送验证码', - emailPlaceholder: '请输入您的注册邮箱', - hint: '我们将向您的邮箱发送验证码,请查收邮件并输入验证码。', - }, - }, - 'en-US': { - login: { - title: 'Welcome back', - subtitle: 'Sign in to continue', - emailLabel: 'Email', - emailPlaceholder: 'Enter your email', - passwordLabel: 'Password', - passwordPlaceholder: 'Enter your password', - remember: 'Remember me', - forgot: 'Forgot password?', - submit: 'Sign in', - register: 'Sign up', - err: { - emailRequired: 'Email is required', - emailInvalid: 'Please enter a valid email', - failed: 'Sign-in failed. Please check your credentials.', - failedGeneric: 'Sign-in failed', - }, - }, - register: { - title: 'Create account', - subtitle: 'Sign up to get started', - }, - reset: { - title: 'Reset password', - subtitleStep1: 'Reset your password via email', - subtitleStep2: 'Enter the code and your new password', - backToLogin: 'Back to login', - sendCode: 'Send code', - emailPlaceholder: 'Enter your registered email', - hint: 'We will email you a verification code. Please enter it below.', - }, - }, -} as const; +export const authResources = { + 'zh-CN': { + login: { + title: '选择本地用户', + subtitle: '选择一个用户以继续使用 Simprint', + empty: '还没有本地用户', + passwordProtected: '需要密码', + directEntry: '点击直接进入', + create: '创建本地用户', + }, + password: { + title: '输入密码', + subtitle: '验证本地用户后继续', + userLabel: '本地用户', + passwordLabel: '密码', + passwordPlaceholder: '请输入密码', + back: '返回选择用户', + submit: '进入 Simprint', + }, + register: { + title: '创建本地用户', + subtitle: '数据将保存在此设备上', + nicknameLabel: '用户昵称', + nicknamePlaceholder: '请输入用户昵称', + nicknameRequired: '请输入用户昵称', + randomAvatar: '随机更换图标', + randomNickname: '生成随机昵称', + passwordLabel: '密码', + passwordPlaceholder: '密码(可选)', + passwordHint: '留空时,点击该用户即可直接进入。', + back: '返回选择用户', + submit: '创建并进入', + }, + }, + 'en-US': { + login: { + title: 'Choose a local user', + subtitle: 'Select a user to continue to Simprint', + empty: 'No local users yet', + passwordProtected: 'Password required', + directEntry: 'Click to enter', + create: 'Create local user', + }, + password: { + title: 'Enter password', + subtitle: 'Verify the local user to continue', + userLabel: 'Local user', + passwordLabel: 'Password', + passwordPlaceholder: 'Enter password', + back: 'Back to user selection', + submit: 'Enter Simprint', + }, + register: { + title: 'Create local user', + subtitle: 'Data will stay on this device', + nicknameLabel: 'Nickname', + nicknamePlaceholder: 'Enter a nickname', + nicknameRequired: 'Nickname is required', + randomAvatar: 'Choose another random icon', + randomNickname: 'Generate a random nickname', + passwordLabel: 'Password', + passwordPlaceholder: 'Password (optional)', + passwordHint: 'Leave empty to enter directly when this user is selected.', + back: 'Back to user selection', + submit: 'Create and enter', + }, + }, +} as const; diff --git a/plugins/pages/login/src/types/index.ts b/plugins/pages/login/src/types/index.ts deleted file mode 100644 index d29e5ea..0000000 --- a/plugins/pages/login/src/types/index.ts +++ /dev/null @@ -1,8 +0,0 @@ -/** - * 登录表单数据 - */ -export interface LoginFormData { - email: string; - password: string; - rememberPassword: boolean; -} diff --git a/plugins/pages/plan-selection/manifest.json b/plugins/pages/plan-selection/manifest.json deleted file mode 100644 index adacaba..0000000 --- a/plugins/pages/plan-selection/manifest.json +++ /dev/null @@ -1,10 +0,0 @@ -{ - "id": "plan-selection", - "name": "Plan Selection", - "version": "1.0.0", - "description": "套餐选择页面插件", - "author": "Simprint Team", - "entry": "./src/index.tsx", - "slots": [], - "enabled": true -} diff --git a/plugins/pages/plan-selection/package.json b/plugins/pages/plan-selection/package.json deleted file mode 100644 index 949b723..0000000 --- a/plugins/pages/plan-selection/package.json +++ /dev/null @@ -1,6 +0,0 @@ -{ - "name": "plugin-plan-selection", - "version": "1.0.0", - "main": "src/index.tsx", - "private": true -} diff --git a/plugins/pages/plan-selection/src/api/index.ts b/plugins/pages/plan-selection/src/api/index.ts deleted file mode 100644 index 2f91cf6..0000000 --- a/plugins/pages/plan-selection/src/api/index.ts +++ /dev/null @@ -1,105 +0,0 @@ -/** - * 套餐选择 API - */ -import { post, isSuccess } from '@/lib/request'; -import type { - PlanDto, - PlanFeatureDto, - PlanWithFeatures, - SubscriptionDto, - WalletResponse, - SubscribePlanRequest, - SubscribePlanResponse, - PlansResponse, - CurrentPlanResponse, - ReferralPlanSummary, -} from './index.types'; - -export * from './index.types'; - -// ============ API 端点 ============ - -export const API_ENDPOINTS = { - PLANS: 'billing/plans', - SUBSCRIPTION: 'billing/subscription', - SUBSCRIPTION_SUBSCRIBE: 'billing/subscription/subscribe', - WALLET: 'billing/wallet', - REFERRAL_PLAN_SUMMARY: 'referral/summary-for-plan', -} as const; - -// ============ API 函数 ============ - -/** - * 获取套餐列表(包含特性) - */ -export async function getPlans(couponCode?: string, billingPeriod: string = 'monthly'): Promise { - const result = await post(API_ENDPOINTS.PLANS, { - coupon_code: couponCode, - billing_period: billingPeriod, - }); - if (!isSuccess(result)) { - throw new Error(result.message || '获取套餐列表失败'); - } - return result.data!; -} - -/** - * 获取当前订阅 - */ -export async function getCurrentSubscription(): Promise { - const result = await post(API_ENDPOINTS.SUBSCRIPTION, {}); - if (!isSuccess(result)) { - throw new Error(result.message || '获取当前订阅失败'); - } - return result.data ?? null; -} - -/** - * 订阅套餐 - */ -export async function subscribePlan( - planUuid: string, - billingPeriod: string = 'monthly', - couponCode?: string, - paymentMethod?: string -): Promise { - const payload: SubscribePlanRequest = { - plan_uuid: planUuid, - billing_period: billingPeriod, - }; - if (couponCode) { - payload.coupon_code = couponCode; - } - if (paymentMethod) { - payload.payment_method = paymentMethod; - } - - const result = await post(API_ENDPOINTS.SUBSCRIPTION_SUBSCRIBE, payload); - if (!isSuccess(result)) { - throw new Error(result.message || '订阅失败'); - } - return result.data!; -} - -/** - * 获取钱包信息 - */ -export async function getWallet(): Promise { - const result = await post(API_ENDPOINTS.WALLET, {}); - if (!isSuccess(result)) { - throw new Error(result.message || '获取钱包信息失败'); - } - return result.data!; -} - -/** - * 获取套餐页推广摘要信息 - */ -export async function getReferralPlanSummary(): Promise { - const result = await post(API_ENDPOINTS.REFERRAL_PLAN_SUMMARY, {}); - if (!isSuccess(result)) { - throw new Error(result.message || '获取推广摘要失败'); - } - return result.data ?? null; -} - diff --git a/plugins/pages/plan-selection/src/api/index.types.ts b/plugins/pages/plan-selection/src/api/index.types.ts deleted file mode 100644 index 0dc4b1a..0000000 --- a/plugins/pages/plan-selection/src/api/index.types.ts +++ /dev/null @@ -1,117 +0,0 @@ -/** - * 套餐选择 API 类型定义 - */ - -// ============ 后端 DTO 类型 ============ - -export interface PlanDto { - id: number; - uuid: string; - name: string; - description: string | null; - price_per_month: number; - price_per_year: number; - currency: string; - discount_monthly: number | null; - discount_yearly: number | null; - max_environments: number; - max_team_members: number; - max_proxies: number; - max_rpa_tasks: number; - is_recommended: boolean | null; - sort_order: number | null; - status: string; - created_at: string; - updated_at: string; -} - -export interface PlanFeatureDto { - id: number; - plan_uuid: string; - feature_key: string; - feature_name: string; - feature_value: string | null; - is_included: boolean | null; - sort_order: number | null; - created_at: string; -} - -export interface PlanPriceInfo { - original_price: number | string; - plan_discount: number | string; - coupon_discount: number | string; - final_price: number | string; - total_saved: number | string; - billing_period: string; -} - -export interface PlanWithFeatures { - plan: PlanDto; - features: PlanFeatureDto[]; - calculated_price?: PlanPriceInfo | null; -} - -export interface SubscriptionDto { - id: number; - uuid: string; - workspace_uuid: string; - user_uuid: string; - plan_uuid: string; - billing_period: string; - price: number; - currency: string; - started_at: string; - expires_at: string; - next_billing_date: string | null; - auto_renew: boolean | null; - status: string; - cancelled_at: string | null; - created_at: string; - updated_at: string; -} - -export interface WalletResponse { - id: number; - user_uuid: string; - balance: number; - currency: string; - frozen_amount: number; - auto_renewal_combined: number; - created_at: string; - updated_at: string; -} - -// 注意:WalletResponse 与 billing-center 中的 WalletResponse 相同 -// 可以统一使用 billing-center 的类型定义,但为了保持模块独立性,这里也定义一份 - -// ============ 请求类型 ============ - -export interface SubscribePlanRequest { - plan_uuid: string; - billing_period: string; - coupon_code?: string; - payment_method?: string; // wallet | alipay | wechat -} - -export interface SubscribePlanResponse { - subscription_uuid: string; -} - -// ============ 响应类型 ============ - -export interface PlansResponse { - plans: PlanWithFeatures[]; -} - -export interface CurrentPlanResponse { - data: SubscriptionDto | null; -} - -// ============ 推广摘要类型 ============ - -export interface ReferralPlanSummary { - referral_value_last_30_days: string | number; - current_plan_monthly_price: string | number | null; - coverage_ratio: string | number | null; -} - diff --git a/plugins/pages/plan-selection/src/components/coupon-selector-dialog.tsx b/plugins/pages/plan-selection/src/components/coupon-selector-dialog.tsx deleted file mode 100644 index bb5f51e..0000000 --- a/plugins/pages/plan-selection/src/components/coupon-selector-dialog.tsx +++ /dev/null @@ -1,213 +0,0 @@ -import { useState, useEffect } from 'react'; -import { useTranslation } from 'react-i18next'; -import { Ticket, Check } from 'lucide-react'; -import { FormattedDialog, FormattedDialogFooter } from '@/components/formatted-dialog'; -import { Button } from '@/components/ui/button'; -import { ScrollArea } from '@/components/ui/scroll-area'; -// @ts-ignore - Cross-plugin import -import { getAvailableCoupons } from '../../../billing-center/src/api'; -// @ts-ignore - Cross-plugin import -import type { UserCoupon } from '../../../billing-center/src/api/index.types'; - -interface CouponSelectorDialogProps { - open: boolean; - onOpenChange: (open: boolean) => void; - selectedCouponCode?: string; - onSelect: (couponCode: string | null) => void; -} - -/** - * 优惠券选择弹窗组件 - */ -export function CouponSelectorDialog({ - open, - onOpenChange, - selectedCouponCode, - onSelect, -}: CouponSelectorDialogProps) { - const { t, i18n } = useTranslation('plans'); - const [allCoupons, setAllCoupons] = useState([]); - const [loading, setLoading] = useState(true); - - useEffect(() => { - if (open) { - loadCoupons(); - } - }, [open]); - - const loadCoupons = async () => { - try { - setLoading(true); - const data = await getAvailableCoupons(); - setAllCoupons(data || []); - } catch (error) { - console.error('获取优惠券失败:', error); - setAllCoupons([]); - } finally { - setLoading(false); - } - }; - - const formatDiscount = (coupon: UserCoupon) => { - let value: number; - if (typeof coupon.discount_value === 'string') { - value = parseFloat(coupon.discount_value); - } else if (typeof coupon.discount_value === 'number') { - value = coupon.discount_value; - } else { - value = 0; - } - - if (isNaN(value) || value == null) { - return '-$0.00'; - } - - if (coupon.discount_type === 'percentage') { - return `-${value}%`; - } else { - return `-$${value.toFixed(2)}`; - } - }; - - const formatDate = (dateStr: string) => { - const date = new Date(dateStr); - const locale = i18n.language === 'en-US' ? 'en-US' : 'zh-CN'; - return date.toLocaleDateString(locale); - }; - - const handleSelect = (coupon: UserCoupon) => { - if (selectedCouponCode === coupon.code) { - // 如果已选中,则取消选择 - onSelect(null); - } else { - onSelect(coupon.code); - } - }; - - return ( - - {loading ? ( -
- {[1, 2, 3, 4, 5].map((i) => ( -
- ))} -
- ) : allCoupons.length === 0 ? ( -
-
- -
-

{t('couponSelector.noCoupons')}

-

- {t('couponSelector.noCouponsDescription')} -

-
- ) : ( - -
- {allCoupons.map((coupon) => { - const isSelected = selectedCouponCode === coupon.code; - return ( -
handleSelect(coupon)} - > - {/* 选中标记 */} - {isSelected && ( -
-
- -
-
- )} - - {/* 背景装饰 */} -
- -
- - {/* 内容 */} -
-
-
-
- - - {coupon.name || coupon.code || t('couponSelector.unknown')} - -
- {coupon.description && ( -
- {coupon.description} -
- )} -
- {formatDiscount(coupon)} - {coupon.min_amount != null && (() => { - const minAmount = - typeof coupon.min_amount === 'string' - ? parseFloat(coupon.min_amount) - : coupon.min_amount; - return minAmount != null && !isNaN(minAmount) && minAmount > 0 ? ( - - {t('couponSelector.minAmount', { - amount: minAmount.toFixed(2), - })} - - ) : null; - })()} -
-
-
- {coupon.expires_at && ( -
- {t('couponSelector.expiresAt', { - date: formatDate(coupon.expires_at), - })} -
- )} -
-
- ); - })} -
-
- )} - - - - - - - ); -} diff --git a/plugins/pages/plan-selection/src/components/payment-dialog.tsx b/plugins/pages/plan-selection/src/components/payment-dialog.tsx deleted file mode 100644 index 8c2df73..0000000 --- a/plugins/pages/plan-selection/src/components/payment-dialog.tsx +++ /dev/null @@ -1,181 +0,0 @@ -import { useState, useEffect } from 'react'; -import { useTranslation } from 'react-i18next'; -import { Wallet, CreditCard, Smartphone, Loader2 } from 'lucide-react'; -import { FormattedDialog, FormattedDialogFooter } from '@/components/formatted-dialog'; -import { Button } from '@/components/ui/button'; -import { RadioGroup, RadioGroupItem } from '@/components/ui/radio-group'; -import { Label } from '@/components/ui/label'; -import type { BillingPlan, PlanPriceInfo } from '../types'; -import { getWallet } from '../api'; - -export type PaymentMethod = 'wallet' | 'alipay' | 'wechat'; - -interface PaymentDialogProps { - open: boolean; - onOpenChange: (open: boolean) => void; - plan: BillingPlan; - priceInfo: PlanPriceInfo; - couponCode?: string; - onPayment: (method: PaymentMethod) => void; - onRecharge: (amount: number) => void; -} - -/** - * 支付弹窗组件 - */ -export function PaymentDialog({ - open, - onOpenChange, - plan, - priceInfo, - couponCode, - onPayment, - onRecharge, -}: PaymentDialogProps) { - const { t } = useTranslation('plans'); - const [paymentMethod, setPaymentMethod] = useState('wallet'); - const [walletBalance, setWalletBalance] = useState(0); - const [loading, setLoading] = useState(false); - - // 获取钱包余额 - useEffect(() => { - if (open) { - setLoading(true); - getWallet() - .then((wallet) => { - setWalletBalance(Number(wallet.balance) || 0); - }) - .catch(() => { - setWalletBalance(0); - }) - .finally(() => { - setLoading(false); - }); - } - }, [open]); - - const amount = priceInfo.actualPrice; - const isBalanceSufficient = walletBalance >= amount; - const insufficientAmount = isBalanceSufficient ? 0 : amount - walletBalance; - - const handlePayment = () => { - if (paymentMethod === 'wallet' && !isBalanceSufficient) { - // 余额不足,显示充值弹窗 - onRecharge(insufficientAmount); - onOpenChange(false); - } else { - // 直接支付 - onPayment(paymentMethod); - onOpenChange(false); - } - }; - - return ( - -
- {/* 消费详情 */} -
-

{t('payment.orderDetails')}

-
-
- {t('payment.planName')} - {plan.name} -
-
- {t('payment.subscriptionDuration')} - {t('right.durationValue')} -
-
- {t('payment.amount')} - ${amount.toFixed(2)} -
- {couponCode && ( -
- {t('payment.coupon')} - {couponCode} -
- )} -
-
- - {/* 支付方式选择 */} -
-

{t('payment.paymentMethod')}

- setPaymentMethod(value as PaymentMethod)}> -
- - -
- -
- - -
- -
- - -
-
-
-
- - - - - -
- ); -} - diff --git a/plugins/pages/plan-selection/src/components/plan-banner.tsx b/plugins/pages/plan-selection/src/components/plan-banner.tsx deleted file mode 100644 index 6ee73e9..0000000 --- a/plugins/pages/plan-selection/src/components/plan-banner.tsx +++ /dev/null @@ -1,70 +0,0 @@ -import { Info } from 'lucide-react'; -import { useTranslation } from 'react-i18next'; -import type { CurrentPlan, BillingPlan, ReferralPlanSummaryView } from '../types'; - -interface PlanBannerProps { - currentPlan: CurrentPlan | null; - plans: BillingPlan[]; - selectedPlanId?: string | null; - referralSummary?: ReferralPlanSummaryView | null; -} - -/** - * 顶部状态横幅组件 - */ -export function PlanBanner({ - currentPlan, - plans, - selectedPlanId, - referralSummary, -}: PlanBannerProps) { - const { t } = useTranslation('plans'); - - if (!currentPlan) return null; - - // 获取选中套餐 - const selectedPlan = selectedPlanId ? plans.find((p) => p.id === selectedPlanId) : null; - - // 如果有选中套餐,使用选中套餐的信息;否则使用当前套餐的信息 - const displayPlanName = selectedPlan - ? selectedPlan.name - : plans.find((p) => p.id === currentPlan.id)?.name || '免费'; - const displayMaxEnvironments = selectedPlan - ? selectedPlan.environmentsLimit || 0 - : currentPlan.maxEnvironments || 0; - - // 实际使用的环境数量始终使用当前的实际使用量 - const usedEnvironments = currentPlan.environmentsUsed || 0; - - return ( -
-
- -
- {t('banner.current', { - plan: displayPlanName, - limit: displayMaxEnvironments, - })} - {'; '} - {t('banner.usage', { - used: usedEnvironments, - total: displayMaxEnvironments, - })} -
-
- - {referralSummary && referralSummary.referralValueLast30Days > 0 && ( -
- {referralSummary.currentPlanMonthlyPrice && referralSummary.coverageRatio != null - ? t('banner.referralCoverage', { - value: referralSummary.referralValueLast30Days.toFixed(2), - coverage: Math.min(referralSummary.coverageRatio, 1) * 100, - }) - : t('banner.referralValueOnly', { - value: referralSummary.referralValueLast30Days.toFixed(2), - })} -
- )} -
- ); -} diff --git a/plugins/pages/plan-selection/src/components/plan-card-skeleton.tsx b/plugins/pages/plan-selection/src/components/plan-card-skeleton.tsx deleted file mode 100644 index 56f03e3..0000000 --- a/plugins/pages/plan-selection/src/components/plan-card-skeleton.tsx +++ /dev/null @@ -1,24 +0,0 @@ -import { Skeleton } from '@/components/ui/skeleton'; - -export function PlanCardSkeleton() { - return ( -
- {/* 徽章位置 */} -
- -
-
- {/* 套餐名称 */} - - {/* 价格 */} -
- - -
- {/* 描述 */} - - -
-
- ); -} diff --git a/plugins/pages/plan-selection/src/components/plan-card.tsx b/plugins/pages/plan-selection/src/components/plan-card.tsx deleted file mode 100644 index 7cdeed4..0000000 --- a/plugins/pages/plan-selection/src/components/plan-card.tsx +++ /dev/null @@ -1,35 +0,0 @@ -import { useTranslation } from 'react-i18next'; -import type { BillingPlan } from '../types'; - -interface PlanCardProps { - plan: BillingPlan; - isSelected: boolean; - onSelect: (planId: string) => void; -} - -/** - * 套餐选择卡片组件 - */ -export function PlanCard({ plan, isSelected, onSelect }: PlanCardProps) { - const { t } = useTranslation('plans'); - - return ( -
onSelect(plan.id)} - className={`relative border-2 rounded-lg p-4 cursor-pointer transition-all ${isSelected ? 'border-primary shadow-md' : 'border-border hover:border-primary/50' - } ${plan.popular ? 'ring-1 ring-primary/20' : ''}`} - > - {plan.popular && ( -
- - {plan.badge || t('badge.popular')} - -
- )} -
-

{plan.name}

-

{plan.description}

-
-
- ); -} diff --git a/plugins/pages/plan-selection/src/components/plan-details-panel.tsx b/plugins/pages/plan-selection/src/components/plan-details-panel.tsx deleted file mode 100644 index d4ebb10..0000000 --- a/plugins/pages/plan-selection/src/components/plan-details-panel.tsx +++ /dev/null @@ -1,190 +0,0 @@ -import { CreditCard, Ticket, Edit2 } from 'lucide-react'; -import { useTranslation } from 'react-i18next'; -import { Button } from '@/components/ui/button'; -import { Checkbox } from '@/components/animate-ui/components/radix/checkbox'; -import type { BillingPlan, CurrentPlan, PlanPriceInfo } from '../types'; -import type { PaymentMethod } from './payment-dialog'; - -interface PlanDetailsPanelProps { - plan: BillingPlan; - priceInfo: PlanPriceInfo; - currentPlan: CurrentPlan | null; - agreed: boolean; - subscribing: boolean; - couponCode?: string; - onAgreedChange: (agreed: boolean) => void; - onPayment: () => void; - onOpenCouponSelector: () => void; -} - -/** - * 右侧套餐详情面板组件 - */ -export function PlanDetailsPanel({ - plan, - priceInfo, - currentPlan, - agreed, - subscribing, - couponCode, - onAgreedChange, - onPayment, - onOpenCouponSelector, -}: PlanDetailsPanelProps) { - const { t } = useTranslation('plans'); - - return ( -
- {/* 套餐详情部分 */} -
-

{t('right.details')}

-
- {/* 环境数量 */} -
-
- {t('right.environments')} -
-
{plan.environmentsLimit}
-
- - {/* 订阅时长 */} -
-
- {t('right.subscriptionDuration')} -
-
{t('right.durationValue')}
-
- - {/* 到期时间 */} - {currentPlan?.expiresAt && ( -
-
- {t('right.expiresAt')} -
-
- {new Date(currentPlan.expiresAt).toLocaleDateString('zh-CN', { - year: 'numeric', - month: '2-digit', - day: '2-digit', - })} -
-
- )} -
-
- - {/* 分割线 */} -
- - {/* 价格信息部分 */} -
- {/* 首购折扣 */} - {priceInfo.discountPercent && ( -
-
- {t('right.firstPurchaseDiscount')} -
-
- -{priceInfo.discountPercent}% -
-
- )} - - {/* 套餐原价 */} -
-
- {t('right.originalPrice')} -
-
- - ${priceInfo.originalPrice.toFixed(2)} - - /{t('price.unit')} -
-
- - {/* 实际价格(突出显示) */} -
-
{t('right.actualPrice')}
-
- - ${priceInfo.actualPrice.toFixed(2)} - - /{t('price.unit')} -
-
- - {/* 节省金额 */} - {priceInfo.savedAmount > 0 && ( -
- {t('right.savedAmount', { amount: priceInfo.savedAmount.toFixed(2) })} -
- )} - - {/* 优惠券信息 */} -
- {couponCode ? ( -
-
- -
- {t('right.couponApplied', { code: couponCode })} -
-
- -
- ) : ( - - )} -
-
- - {/* 支付按钮 */} -
- - -
-
- ); -} diff --git a/plugins/pages/plan-selection/src/components/plan-features-list.tsx b/plugins/pages/plan-selection/src/components/plan-features-list.tsx deleted file mode 100644 index 68cfdf3..0000000 --- a/plugins/pages/plan-selection/src/components/plan-features-list.tsx +++ /dev/null @@ -1,36 +0,0 @@ -import { useTranslation } from 'react-i18next'; -import type { BillingPlan } from '../types'; -import { getFeatureIcon } from '../utils/plan-price'; - -interface PlanFeaturesListProps { - plan: BillingPlan; -} - -/** - * 详细功能列表组件 - */ -export function PlanFeaturesList({ plan }: PlanFeaturesListProps) { - const { t } = useTranslation('plans'); - - return ( -
-

{t('left.features')}

-
- {plan.features.map((feature) => { - const Icon = getFeatureIcon(feature.id); - return ( -
- -
- {feature.name} - {feature.description && ( -

{feature.description}

- )} -
-
- ); - })} -
-
- ); -} diff --git a/plugins/pages/plan-selection/src/components/plan-quota-bar.tsx b/plugins/pages/plan-selection/src/components/plan-quota-bar.tsx deleted file mode 100644 index b6b6a18..0000000 --- a/plugins/pages/plan-selection/src/components/plan-quota-bar.tsx +++ /dev/null @@ -1,184 +0,0 @@ -import { useTranslation } from 'react-i18next'; -import { useMemo } from 'react'; -import type { BillingPlan } from '../types'; - -interface PlanQuotaBarProps { - plans: BillingPlan[]; - selectedPlan: BillingPlan | undefined; - selectedPlanId: string | null; - onSelect: (planId: string) => void; -} - -/** - * 环境配额显示条组件(参考创建环境的分段滑块设计) - */ -export function PlanQuotaBar({ plans, selectedPlan, selectedPlanId, onSelect }: PlanQuotaBarProps) { - const { t } = useTranslation('plans'); - - const maxQuota = plans.length > 0 ? Math.max(...plans.map((p) => p.environmentsLimit)) : 100; - const environmentsLimit = selectedPlan?.environmentsLimit || 0; - - // 将套餐按配额排序 - const sortedPlans = useMemo(() => { - return [...plans].sort((a, b) => a.environmentsLimit - b.environmentsLimit); - }, [plans]); - - // 计算每个套餐分段的位置和宽度(基于增量的智能缩放) - const planSegments = useMemo(() => { - if (sortedPlans.length === 0) return []; - - // 计算每个分段的增量(从上一个套餐到当前套餐的差值) - const increments = sortedPlans.map((plan, index) => { - const prevValue = index === 0 ? 0 : sortedPlans[index - 1].environmentsLimit; - return { - plan, - increment: plan.environmentsLimit - prevValue, - }; - }); - - // 使用幂函数缩放增量(value^0.4,比平方根更激进,比对数更温和) - // 这样可以让小增量占据更多空间,大增量占据更少空间 - const scaleIncrement = (increment: number, maxIncrement: number): number => { - if (increment <= 0) return 0; - if (maxIncrement <= 0) return 0; - // 使用幂函数:increment^0.4 / maxIncrement^0.4 - const power = 0.3; - return (Math.pow(increment, power) / Math.pow(maxIncrement, power)) * 100; - }; - - // 找到最大增量 - const maxIncrement = Math.max(...increments.map((inc) => inc.increment)); - - // 计算每个增量的缩放宽度 - const scaledIncrements = increments.map((inc) => ({ - plan: inc.plan, - scaledWidth: scaleIncrement(inc.increment, maxIncrement), - })); - - // 计算总宽度,用于归一化到100% - const totalScaledWidth = scaledIncrements.reduce((sum, inc) => sum + inc.scaledWidth, 0); - - // 计算每个分段的位置和宽度 - let currentPosition = 0; - return scaledIncrements.map((item) => { - const widthPercent = (item.scaledWidth / totalScaledWidth) * 100; - const startPercent = currentPosition; - const endPercent = currentPosition + widthPercent; - currentPosition = endPercent; - - return { - plan: item.plan, - startPercent, - endPercent, - widthPercent, - isSelected: item.plan.id === selectedPlanId, - }; - }); - }, [sortedPlans, selectedPlanId]); - - // 计算当前选中套餐在分段中的位置 - const currentSegmentIndex = useMemo(() => { - if (!selectedPlan) return -1; - return planSegments.findIndex((seg) => seg.plan.id === selectedPlanId); - }, [selectedPlan, selectedPlanId, planSegments]); - - // 计算分段渐变颜色(根据位置从浅到深) - const getSegmentGradient = (startPercent: number, endPercent: number): string => { - // 使用主题 primary 颜色,从 0.5 到 1.0 透明度 - const startOpacity = 0.5 + (startPercent / 100) * 0.5; - const endOpacity = 0.5 + (endPercent / 100) * 0.5; - const startColor = `rgba(37, 99, 235, ${startOpacity})`; // primary color #2563eb - const endColor = `rgba(37, 99, 235, ${endOpacity})`; - return `linear-gradient(to right, ${startColor}, ${endColor})`; - }; - - // 计算灰色渐变背景(根据位置从浅到深) - const getGrayGradient = (startPercent: number, endPercent: number): string => { - const startLightness = 245 - (startPercent / 100) * 20; // 245 -> 225 - const endLightness = 245 - (endPercent / 100) * 20; - const startColor = `rgb(${startLightness}, ${startLightness + 1}, ${startLightness + 3})`; - const endColor = `rgb(${endLightness}, ${endLightness + 1}, ${endLightness + 3})`; - return `linear-gradient(to right, ${startColor}, ${endColor})`; - }; - - return ( -
- {/* 标题和配额 */} -
- {t('left.environmentQuota')} - {selectedPlan && ( - - {environmentsLimit} {t('left.environmentsUnit')} - - )} -
- - {/* 分段滑块样式(水平,参考创建环境的分段滑块) */} -
- {planSegments.map((segment, index) => { - const isBeforeCurrent = index < currentSegmentIndex; - const isCurrentSegment = index === currentSegmentIndex; - const isAfterCurrent = index > currentSegmentIndex; - - // 计算当前段的高亮比例(0-1) - // 如果完全在选中套餐之前,高亮比例为 1 - // 如果是当前段,高亮比例为 1(因为选中套餐就是当前段) - // 如果在选中套餐之后,高亮比例为 0 - const highlightProgress = isBeforeCurrent ? 1 : isCurrentSegment ? 1 : 0; - - return ( - - ); - })} -
- -
- ); -} diff --git a/plugins/pages/plan-selection/src/components/plan-selection-content.tsx b/plugins/pages/plan-selection/src/components/plan-selection-content.tsx deleted file mode 100644 index 233888c..0000000 --- a/plugins/pages/plan-selection/src/components/plan-selection-content.tsx +++ /dev/null @@ -1,57 +0,0 @@ -import { useTranslation } from 'react-i18next'; -import type { BillingPlan } from '../types'; -import { PlanCard } from './plan-card'; -import { PlanQuotaBar } from './plan-quota-bar'; -import { PlanFeaturesList } from './plan-features-list'; - -interface PlanSelectionContentProps { - plans: BillingPlan[]; - selectedPlanId: string | null; - selectedPlan: BillingPlan | undefined; - onSelect: (planId: string) => void; -} - -/** - * 左侧套餐选择内容组件 - */ -export function PlanSelectionContent({ - plans, - selectedPlanId, - selectedPlan, - onSelect, -}: PlanSelectionContentProps) { - const { t } = useTranslation('plans'); - - return ( -
- {/* 套餐选择卡片 */} -
-

{t('left.selectPlan')}

-
- {plans.map((plan) => ( - - ))} -
-
- - {/* 环境配额显示 */} - - - {/* 分割线 */} -
- - {/* 详细功能列表 */} - {selectedPlan && } -
- ); -} diff --git a/plugins/pages/plan-selection/src/components/plan-selection-skeleton.tsx b/plugins/pages/plan-selection/src/components/plan-selection-skeleton.tsx deleted file mode 100644 index 7a95815..0000000 --- a/plugins/pages/plan-selection/src/components/plan-selection-skeleton.tsx +++ /dev/null @@ -1,97 +0,0 @@ -import { Skeleton } from '@/components/ui/skeleton'; -import { PlanCardSkeleton } from './plan-card-skeleton'; - -export function PlanSelectionSkeleton() { - return ( -
-
-
- {/* 顶部状态横幅 */} - - -
- {/* 左侧内容 - 2/3 宽度 */} -
- {/* 套餐选择卡片 */} -
- -
- {Array.from({ length: 3 }).map((_, index) => ( - - ))} -
-
- - {/* 环境配额显示 */} -
-
- - -
- - -
- - {/* 分割线 */} - - - {/* 详细功能列表 */} -
- -
- {Array.from({ length: 6 }).map((_, index) => ( -
- -
- - -
-
- ))} -
-
-
- - {/* 右侧内容 - 1/3 宽度 */} -
- {/* 套餐详情部分 */} -
- -
- {Array.from({ length: 3 }).map((_, index) => ( -
- - -
- ))} -
-
- - {/* 分割线 */} - - - {/* 价格信息部分 */} -
- {Array.from({ length: 4 }).map((_, index) => ( -
- - -
- ))} -
- - {/* 支付按钮 */} -
-
- - -
- -
-
-
-
-
-
- ); -} diff --git a/plugins/pages/plan-selection/src/components/recharge-dialog.tsx b/plugins/pages/plan-selection/src/components/recharge-dialog.tsx deleted file mode 100644 index 1210348..0000000 --- a/plugins/pages/plan-selection/src/components/recharge-dialog.tsx +++ /dev/null @@ -1,143 +0,0 @@ -import { useState } from 'react'; -import { useTranslation } from 'react-i18next'; -import { CreditCard, Smartphone, Wallet, Loader2 } from 'lucide-react'; -import { FormattedDialog, FormattedDialogFooter } from '@/components/formatted-dialog'; -import { Button } from '@/components/ui/button'; -import { RadioGroup, RadioGroupItem } from '@/components/ui/radio-group'; -import { Label } from '@/components/ui/label'; -import { toast } from 'sonner'; - -export type RechargeMethod = 'alipay' | 'wechat'; - -interface RechargeDialogProps { - open: boolean; - onOpenChange: (open: boolean) => void; - amount: number; - onSuccess: () => void; -} - -/** - * 充值弹窗组件 - */ -export function RechargeDialog({ open, onOpenChange, amount, onSuccess }: RechargeDialogProps) { - const { t } = useTranslation('plans'); - const [rechargeMethod, setRechargeMethod] = useState('alipay'); - const [processing, setProcessing] = useState(false); - - const handleRecharge = async () => { - setProcessing(true); - try { - // TODO: 对接支付接口 - // 当前为开发阶段,直接模拟充值成功 - // 实际实现时需要: - // 1. 调用后端创建充值订单接口 - // 2. 根据支付方式跳转到对应的支付页面(支付宝/微信) - // 3. 处理支付回调 - // 4. 更新钱包余额 - - // 模拟支付成功 - await new Promise((resolve) => setTimeout(resolve, 1000)); - - toast.success(t('recharge.success', { amount: amount.toFixed(2) })); - onSuccess(); - onOpenChange(false); - } catch (error) { - toast.error(t('recharge.error')); - } finally { - setProcessing(false); - } - }; - - return ( - -
- {/* 充值金额 */} -
-

{t('recharge.amount')}

-
-
-
${amount.toFixed(2)}
-
{t('recharge.amountDescription')}
-
-
-
- - {/* 支付方式选择 */} -
-

{t('recharge.paymentMethod')}

- setRechargeMethod(value as RechargeMethod)} - > -
- - -
- -
- - -
-
-
- - {/* 描述信息 */} -
-
- -
- {t('recharge.info')} -
-
-
-
- - - - - -
- ); -} - diff --git a/plugins/pages/plan-selection/src/constants.ts b/plugins/pages/plan-selection/src/constants.ts deleted file mode 100644 index ca83552..0000000 --- a/plugins/pages/plan-selection/src/constants.ts +++ /dev/null @@ -1,4 +0,0 @@ -/** - * 默认计费周期 - */ -export const DEFAULT_BILLING_PERIOD = 'monthly' as const; diff --git a/plugins/pages/plan-selection/src/hooks/use-current-plan.ts b/plugins/pages/plan-selection/src/hooks/use-current-plan.ts deleted file mode 100644 index 44a97c2..0000000 --- a/plugins/pages/plan-selection/src/hooks/use-current-plan.ts +++ /dev/null @@ -1,91 +0,0 @@ -import { useState, useEffect, useCallback } from 'react'; -import { post, isSuccess } from '@/lib/request'; -import type { CurrentPlan } from '../types'; -import { getCurrentSubscription } from '../api'; - -interface UseCurrentPlanReturn { - currentPlan: CurrentPlan | null; - loading: boolean; - refresh: () => Promise; -} - -interface QuotaResponse { - workspace_uuid: string; - max_environments: number; - used_environments: number; - max_team_members: number; - used_team_members: number; - max_proxies: number; - used_proxies: number; - max_rpa_tasks: number; - used_rpa_tasks: number; - created_at: string; - updated_at: string; -} - -/** - * 获取工作空间配额 - */ -async function getQuota(): Promise { - try { - const result = await post('billing/quota', {}); - if (!isSuccess(result)) { - return null; - } - return result.data ?? null; - } catch { - return null; - } -} - -/** - * 获取当前套餐的 Hook - */ -export function useCurrentPlan(): UseCurrentPlanReturn { - const [currentPlan, setCurrentPlan] = useState(null); - const [loading, setLoading] = useState(false); - - const fetchCurrentPlan = useCallback(async () => { - setLoading(true); - try { - const [subscription, quota] = await Promise.all([ - getCurrentSubscription(), - getQuota(), - ]); - - if (subscription) { - setCurrentPlan({ - id: subscription.plan_uuid, - expiresAt: subscription.expires_at, - environmentsUsed: quota?.used_environments ?? 0, - maxEnvironments: quota?.max_environments ?? 0, - }); - } else if (quota) { - // 如果没有订阅,使用配额中的默认值 - setCurrentPlan({ - id: 'free', - expiresAt: undefined, - environmentsUsed: quota.used_environments, - maxEnvironments: quota.max_environments, - }); - } else { - setCurrentPlan(null); - } - } catch { - // 忽略错误,设置为 null - setCurrentPlan(null); - } finally { - setLoading(false); - } - }, []); - - useEffect(() => { - void fetchCurrentPlan(); - }, [fetchCurrentPlan]); - - return { - currentPlan, - loading, - refresh: fetchCurrentPlan, - }; -} diff --git a/plugins/pages/plan-selection/src/hooks/use-plan-selection.ts b/plugins/pages/plan-selection/src/hooks/use-plan-selection.ts deleted file mode 100644 index 1f20bd4..0000000 --- a/plugins/pages/plan-selection/src/hooks/use-plan-selection.ts +++ /dev/null @@ -1,38 +0,0 @@ -import { useState, useCallback } from 'react'; -import type { BillingPlan } from '../types'; - -interface UsePlanSelectionReturn { - selectedPlanId: string | null; - setSelectedPlanId: (id: string | null) => void; - selectedPlan: BillingPlan | undefined; - initializeSelection: (plans: BillingPlan[], currentPlanId?: string) => void; -} - -/** - * 套餐选择状态管理 Hook - */ -export function usePlanSelection(plans: BillingPlan[]): UsePlanSelectionReturn { - const [selectedPlanId, setSelectedPlanId] = useState(null); - - const initializeSelection = useCallback( - (plansToSelect: BillingPlan[], currentPlanId?: string) => { - if (currentPlanId && plansToSelect.some((p) => p.id === currentPlanId)) { - // 如果 currentPlanId 存在且在套餐列表中,选择它 - setSelectedPlanId(currentPlanId); - } else if (plansToSelect.length > 0) { - // 否则默认选择第一个套餐 - setSelectedPlanId(plansToSelect[0].id); - } - }, - [] - ); - - const selectedPlan = plans.find((p) => p.id === selectedPlanId); - - return { - selectedPlanId, - setSelectedPlanId, - selectedPlan, - initializeSelection, - }; -} diff --git a/plugins/pages/plan-selection/src/hooks/use-plans.ts b/plugins/pages/plan-selection/src/hooks/use-plans.ts deleted file mode 100644 index 1a6b204..0000000 --- a/plugins/pages/plan-selection/src/hooks/use-plans.ts +++ /dev/null @@ -1,55 +0,0 @@ -import { useState, useEffect, useCallback } from 'react'; -import type { BillingPlan } from '../types'; -import { getPlans } from '../api'; -import { transformPlanWithFeatures } from '../utils/plan-transform'; - -interface UsePlansReturn { - plans: BillingPlan[]; - currentPlanId?: string; - loading: boolean; - error: string | null; - refresh: () => Promise; -} - -interface UsePlansParams { - couponCode?: string; - billingPeriod?: string; -} - -/** - * 获取套餐列表的 Hook - */ -export function usePlans(params?: UsePlansParams): UsePlansReturn { - const [plans, setPlans] = useState([]); - const [currentPlanId, setCurrentPlanId] = useState(); - const [loading, setLoading] = useState(false); - const [error, setError] = useState(null); - - const fetchPlans = useCallback(async () => { - setLoading(true); - setError(null); - try { - const result = await getPlans(params?.couponCode, params?.billingPeriod || 'monthly'); - const transformedPlans = result.plans.map(transformPlanWithFeatures); - setPlans(transformedPlans); - // TODO: 如果后端返回 currentPlanId,需要从响应中提取 - setCurrentPlanId(undefined); - } catch (e) { - setError(e instanceof Error ? e.message : '获取套餐列表失败'); - } finally { - setLoading(false); - } - }, [params?.couponCode, params?.billingPeriod]); - - useEffect(() => { - void fetchPlans(); - }, [fetchPlans]); - - return { - plans, - currentPlanId, - loading, - error, - refresh: fetchPlans, - }; -} diff --git a/plugins/pages/plan-selection/src/hooks/use-subscribe.ts b/plugins/pages/plan-selection/src/hooks/use-subscribe.ts deleted file mode 100644 index e721b0d..0000000 --- a/plugins/pages/plan-selection/src/hooks/use-subscribe.ts +++ /dev/null @@ -1,40 +0,0 @@ -import { useState, useCallback } from 'react'; -import { useTranslation } from 'react-i18next'; -import { toast } from 'sonner'; -import { subscribePlan } from '../api'; -import { DEFAULT_BILLING_PERIOD } from '../constants'; - -interface UseSubscribeReturn { - subscribing: boolean; - subscribe: (planId: string, paymentMethod?: string) => Promise; -} - -/** - * 订阅操作 Hook - */ -export function useSubscribe(couponCode?: string, onSuccess?: () => void): UseSubscribeReturn { - const { t } = useTranslation('plans'); - const [subscribing, setSubscribing] = useState(false); - - const subscribe = useCallback( - async (planId: string, paymentMethod?: string) => { - setSubscribing(true); - try { - await subscribePlan(planId, DEFAULT_BILLING_PERIOD, couponCode, paymentMethod); - toast.success(t('subscribe.success', { plan: planId })); - onSuccess?.(); - } catch (e) { - toast.error(e instanceof Error ? e.message : t('subscribe.error')); - throw e; - } finally { - setSubscribing(false); - } - }, - [t, couponCode, onSuccess] - ); - - return { - subscribing, - subscribe, - }; -} diff --git a/plugins/pages/plan-selection/src/i18n/resources.ts b/plugins/pages/plan-selection/src/i18n/resources.ts deleted file mode 100644 index e810cd5..0000000 --- a/plugins/pages/plan-selection/src/i18n/resources.ts +++ /dev/null @@ -1,224 +0,0 @@ -export const planResources = { - 'zh-CN': { - title: '套餐选择', - subtitle: '选择适合您的套餐计划', - loading: '加载中...', - error: '加载失败:{{error}}', - billingPeriod: { - monthly: '月付', - yearly: '年付', - save: '节省', - }, - price: { - unit: '月', - yearlyTotal: '年付 ${{amount}}', - }, - discount: { - save: '节省 {{percent}}%', - }, - badge: { - popular: '最受欢迎', - }, - left: { - selectPlan: '选择套餐', - environmentUsage: '环境使用情况', - environmentUsageDesc: '已使用 {{used}} 个环境,剩余 {{remaining}} 个可用(共 {{total}} 个)', - environmentQuota: '环境配额', - environmentsUnit: '个环境', - environmentQuotaDesc: '该套餐提供 {{total}} 个环境配额', - selectPlanToViewQuota: '选择套餐查看环境配额', - features: '套餐功能', - }, - banner: { - current: '当前:{{plan}}({{limit}}环境)', - usage: '用量:{{used}}/{{total}}', - selecting: '选择:{{plan}}({{limit}}环境)', - }, - right: { - details: '套餐详情', - environments: '环境配额', - environmentsUnit: '个环境', - subscriptionDuration: '订阅时长', - durationValue: '30天', - originalPrice: '套餐原价', - actualPrice: '实际价格', - firstPurchaseDiscount: '首购折扣', - savedAmount: '节省${{amount}}/月', - expiresAt: '到期时间', - couponApplied: '优惠券:{{code}}', - useCoupon: '使用优惠券', - changeCoupon: '更换优惠券', - agreement: { - text: '同意我们的', - link: '用户协议', - }, - }, - couponSelector: { - title: '选择优惠券', - description: '请选择要使用的优惠券', - searchPlaceholder: '搜索优惠券代码或名称', - noCoupons: '暂无可用优惠券', - noCouponsDescription: '您当前没有可用的优惠券', - noMatch: '未找到匹配的优惠券', - unknown: '未知优惠券', - minAmount: '最低消费 ${{amount}}', - expiresAt: '有效期至:{{date}}', - cancel: '取消', - confirm: '确认', - }, - subscribe: { - button: '立即支付', - current: '当前套餐', - processing: '处理中...', - success: '成功订阅 {{plan}} 套餐', - error: '订阅失败,请重试', - agreementRequired: '请先同意用户协议', - }, - payment: { - title: '支付订单', - description: '请选择支付方式完成订单', - orderDetails: '订单详情', - planName: '套餐名称', - subscriptionDuration: '订阅时长', - amount: '支付金额', - coupon: '优惠券', - paymentMethod: '支付方式', - wallet: '钱包支付', - alipay: '支付宝', - wechat: '微信支付', - balance: '余额', - loading: '加载中...', - insufficientBalance: '余额不足,缺少 ${{amount}}', - cancel: '取消', - confirm: '确认支付', - recharge: '立即充值', - success: '{{method}} 支付成功', - }, - recharge: { - title: '充值', - description: '请选择支付方式完成充值', - amount: '充值金额', - amountDescription: '充值后可用于订阅套餐和消费', - paymentMethod: '支付方式', - alipay: '支付宝', - wechat: '微信支付', - info: '充值成功后,余额将立即到账,可用于订阅套餐和消费。', - cancel: '取消', - confirm: '确认充值', - processing: '处理中...', - success: '充值成功 ${{amount}}', - error: '充值失败,请重试', - }, - }, - 'en-US': { - title: 'Plans', - subtitle: 'Choose a plan that fits you', - loading: 'Loading...', - error: 'Failed to load: {{error}}', - billingPeriod: { - monthly: 'Monthly', - yearly: 'Yearly', - save: 'Save', - }, - price: { - unit: 'month', - yearlyTotal: '${{amount}}/year', - }, - discount: { - save: 'Save {{percent}}%', - }, - badge: { - popular: 'Most Popular', - }, - left: { - selectPlan: 'Select Plan', - environmentUsage: 'Environment Usage', - environmentUsageDesc: '{{used}} environments used, {{remaining}} remaining ({{total}} total)', - environmentQuota: 'Environment Quota', - environmentsUnit: 'environments', - environmentQuotaDesc: 'This plan provides {{total}} environment quota', - selectPlanToViewQuota: 'Select a plan to view quota', - features: 'Plan Features', - }, - banner: { - current: 'Current:{{plan}}({{limit}} environments)', - usage: 'Usage:{{used}}/{{total}}', - selecting: 'Selecting:{{plan}}({{limit}} environments)', - }, - right: { - details: 'Plan Details', - environments: 'Environment Quota', - environmentsUnit: 'environments', - subscriptionDuration: 'Subscription Duration', - durationValue: '30 days', - originalPrice: 'Original Price', - actualPrice: 'Actual Price', - firstPurchaseDiscount: 'First Purchase Discount', - savedAmount: 'Save ${{amount}}/month', - expiresAt: 'Expires At', - couponApplied: 'Coupon: {{code}}', - useCoupon: 'Use Coupon', - changeCoupon: 'Change Coupon', - agreement: { - text: 'Agree to our', - link: 'User Agreement', - }, - }, - couponSelector: { - title: 'Select Coupon', - description: 'Please select a coupon to use', - searchPlaceholder: 'Search coupon code or name', - noCoupons: 'No Available Coupons', - noCouponsDescription: 'You currently have no available coupons', - noMatch: 'No matching coupons found', - unknown: 'Unknown Coupon', - minAmount: 'Minimum amount ${{amount}}', - expiresAt: 'Expires: {{date}}', - cancel: 'Cancel', - confirm: 'Confirm', - }, - subscribe: { - button: 'Pay Now', - current: 'Current Plan', - processing: 'Processing...', - success: 'Successfully subscribed to {{plan}} plan', - error: 'Subscription failed, please try again', - agreementRequired: 'Please agree to the User Agreement first', - }, - payment: { - title: 'Payment', - description: 'Please select a payment method to complete the order', - orderDetails: 'Order Details', - planName: 'Plan Name', - subscriptionDuration: 'Subscription Duration', - amount: 'Amount', - coupon: 'Coupon', - paymentMethod: 'Payment Method', - wallet: 'Wallet', - alipay: 'Alipay', - wechat: 'WeChat Pay', - balance: 'Balance', - loading: 'Loading...', - insufficientBalance: 'Insufficient balance, missing ${{amount}}', - cancel: 'Cancel', - confirm: 'Confirm Payment', - recharge: 'Recharge Now', - success: '{{method}} payment successful', - }, - recharge: { - title: 'Recharge', - description: 'Please select a payment method to complete the recharge', - amount: 'Recharge Amount', - amountDescription: 'After recharge, it can be used for subscription and consumption', - paymentMethod: 'Payment Method', - alipay: 'Alipay', - wechat: 'WeChat Pay', - info: 'After successful recharge, the balance will be credited immediately and can be used for subscription and consumption.', - cancel: 'Cancel', - confirm: 'Confirm Recharge', - processing: 'Processing...', - success: 'Recharge successful ${{amount}}', - error: 'Recharge failed, please try again', - }, - }, -} as const; diff --git a/plugins/pages/plan-selection/src/index.tsx b/plugins/pages/plan-selection/src/index.tsx deleted file mode 100644 index cd9de68..0000000 --- a/plugins/pages/plan-selection/src/index.tsx +++ /dev/null @@ -1,293 +0,0 @@ -import { extensionRegistry } from '@slotkitjs/core'; -import { useTranslation } from 'react-i18next'; -import { useSearchParams } from 'react-router'; -import { toast } from 'sonner'; -import { planResources } from './i18n/resources'; -import { useState, useEffect, useMemo } from 'react'; -import { PlanSelectionSkeleton } from './components/plan-selection-skeleton'; -import { PlanBanner } from './components/plan-banner'; -import { PlanSelectionContent } from './components/plan-selection-content'; -import { PlanDetailsPanel } from './components/plan-details-panel'; -import { PaymentDialog, type PaymentMethod } from './components/payment-dialog'; -import { RechargeDialog } from './components/recharge-dialog'; -import { CouponSelectorDialog } from './components/coupon-selector-dialog'; -import { usePlans } from './hooks/use-plans'; -import { useCurrentPlan } from './hooks/use-current-plan'; -import { usePlanSelection } from './hooks/use-plan-selection'; -import { useSubscribe } from './hooks/use-subscribe'; -import { getPlanPriceInfo } from './utils/plan-price'; -import { getReferralPlanSummary } from './api'; -import type { ReferralPlanSummaryView } from './types'; - -const PlanSelectionPage: React.FC = () => { - const { t } = useTranslation('plans'); - const [searchParams, setSearchParams] = useSearchParams(); - const [agreed, setAgreed] = useState(false); - const [paymentDialogOpen, setPaymentDialogOpen] = useState(false); - const [rechargeDialogOpen, setRechargeDialogOpen] = useState(false); - const [rechargeAmount, setRechargeAmount] = useState(0); - const [couponSelectorOpen, setCouponSelectorOpen] = useState(false); - const [referralSummary, setReferralSummary] = useState(null); - - // 从路由参数读取优惠券代码 - const couponCode = searchParams.get('coupon') || undefined; - - // 处理优惠券选择 - const handleCouponSelect = (code: string | null) => { - if (code) { - setSearchParams({ coupon: code }, { replace: true }); - } else { - // 移除优惠券参数 - const newParams = new URLSearchParams(searchParams); - newParams.delete('coupon'); - setSearchParams(newParams, { replace: true }); - } - }; - - // 数据获取(传入优惠券代码,优惠券变化时自动重新加载) - const { plans, currentPlanId: plansCurrentPlanId, loading, error } = usePlans({ - couponCode, - billingPeriod: 'monthly', // 默认使用月度计费 - }); - const { currentPlan, refresh: refreshCurrentPlan } = useCurrentPlan(); - - // 套餐选择 - const { selectedPlanId, setSelectedPlanId, selectedPlan, initializeSelection } = - usePlanSelection(plans); - - // 订阅操作(传入优惠券代码) - const { subscribing, subscribe } = useSubscribe(couponCode, async () => { - // 订阅成功后,延迟一下再刷新,确保后端数据已更新 - await new Promise((resolve) => setTimeout(resolve, 500)); - await refreshCurrentPlan(); - }); - - // 初始化选择 - useEffect(() => { - if (plans.length > 0 && !selectedPlanId) { - // 优先使用 plans API 返回的 currentPlanId,否则使用 currentPlan?.id - // 如果 currentPlanId 不在 plans 列表中或为空,则选择第一个套餐 - const currentPlanId = plansCurrentPlanId || currentPlan?.id; - const planExists = - currentPlanId && - typeof currentPlanId === 'string' && - currentPlanId.trim() !== '' && - plans.some((p) => p.id === currentPlanId); - initializeSelection(plans, planExists ? currentPlanId : undefined); - } - }, [plans, selectedPlanId, plansCurrentPlanId, currentPlan?.id, initializeSelection]); - - // 加载推广摘要(与当前订阅绑定) - useEffect(() => { - const loadReferralSummary = async () => { - try { - const summary = await getReferralPlanSummary(); - if (!summary) { - setReferralSummary(null); - return; - } - const referralValue = - typeof summary.referral_value_last_30_days === 'string' - ? parseFloat(summary.referral_value_last_30_days) - : summary.referral_value_last_30_days; - const planPriceRaw = summary.current_plan_monthly_price; - const planPrice = - planPriceRaw == null - ? null - : typeof planPriceRaw === 'string' - ? parseFloat(planPriceRaw) - : planPriceRaw; - const coverageRaw = summary.coverage_ratio; - const coverage = - coverageRaw == null - ? null - : typeof coverageRaw === 'string' - ? parseFloat(coverageRaw) - : coverageRaw; - - setReferralSummary({ - referralValueLast30Days: Number.isFinite(referralValue) ? referralValue : 0, - currentPlanMonthlyPrice: planPrice, - coverageRatio: coverage, - }); - } catch { - // 推广摘要失败不影响套餐页面主流程 - setReferralSummary(null); - } - }; - - void loadReferralSummary(); - }, []); - - // 价格计算 - const priceInfo = useMemo(() => { - if (!selectedPlan) return null; - return getPlanPriceInfo(selectedPlan); - }, [selectedPlan]); - - // 处理支付弹窗打开 - const handlePayment = () => { - if (!selectedPlanId) return; - if (!agreed) { - toast.warning(t('subscribe.agreementRequired')); - return; - } - setPaymentDialogOpen(true); - }; - - // 处理支付 - const handlePaymentConfirm = async (method: PaymentMethod) => { - if (!selectedPlanId) return; - - try { - if (method === 'wallet') { - // 钱包支付,直接订阅 - await subscribe(selectedPlanId, 'wallet'); - } else { - // TODO: 对接支付接口 - // 当前为开发阶段,直接模拟支付成功 - // 实际实现时需要: - // 1. 调用后端创建支付订单接口 - // 2. 根据支付方式跳转到对应的支付页面(支付宝/微信) - // 3. 处理支付回调 - // 4. 支付成功后调用订阅接口 - - // 模拟支付成功 - await new Promise((resolve) => setTimeout(resolve, 1000)); - toast.success(t('payment.success', { method: t(`payment.${method}`) })); - // 传递支付方式参数,后端将跳过钱包余额检查 - await subscribe(selectedPlanId, method); - } - } catch (error) { - // 错误已在 subscribe Hook 中处理 - } - }; - - // 处理充值 - const handleRecharge = (amount: number) => { - setRechargeAmount(amount); - setRechargeDialogOpen(true); - }; - - // 充值成功后刷新钱包余额(支付弹窗会重新获取) - const handleRechargeSuccess = () => { - // 充值成功后,重新打开支付弹窗 - setPaymentDialogOpen(true); - }; - - return ( -
-
- {loading && } - {error && ( -
- {t('error', { error })} -
- )} - {!loading && !error && ( -
- {/* 顶部状态横幅 */} - - -
- {/* 左侧内容 */} - - - {/* 右侧内容 */} - {selectedPlan && priceInfo && ( - setCouponSelectorOpen(true)} - /> - )} -
-
- )} -
- - {/* 支付弹窗 */} - {selectedPlan && priceInfo && ( - - )} - - {/* 充值弹窗 */} - - - {/* 优惠券选择弹窗 */} - -
- ); -}; - -// 在模块加载时贡献路由 -try { - extensionRegistry.contribute('routes', { - contributorId: 'plan-selection', - value: { - path: '/plans', - Component: PlanSelectionPage, - }, - priority: 10, - }); - console.log('[plan-selection] Route contributed at module load: /plans'); -} catch (error) { - console.warn('[plan-selection] Failed to contribute route at module load:', error); -} - -try { - extensionRegistry.contribute('i18n:resources', { - contributorId: 'plan-selection', - value: { - namespace: 'plans', - resources: planResources, - }, - priority: 10, - }); -} catch (error) { - console.warn('[plan-selection] Failed to contribute i18n resources:', error); -} - -const planSelectionPlugin = { - id: 'plan-selection', - name: 'Plan Selection', - version: '1.0.0', - component: PlanSelectionPage, - slots: [], -}; - -export default planSelectionPlugin; diff --git a/plugins/pages/plan-selection/src/types/index.ts b/plugins/pages/plan-selection/src/types/index.ts deleted file mode 100644 index bc7fb18..0000000 --- a/plugins/pages/plan-selection/src/types/index.ts +++ /dev/null @@ -1,53 +0,0 @@ -export interface PlanFeature { - id: string; - name: string; - description?: string; -} - -export interface CalculatedPriceInfo { - original_price: number | string; - plan_discount: number | string; - coupon_discount: number | string; - final_price: number | string; - total_saved: number | string; - billing_period: string; -} - -export interface BillingPlan { - id: string; - name: string; - pricePerMonth: number; - pricePerYear?: number; - currency: string; - environmentsLimit: number; - description: string; - features: PlanFeature[]; - discount?: { - monthly?: number; - yearly?: number; - }; - popular?: boolean; - badge?: string; - calculatedPrice?: CalculatedPriceInfo | null; -} - -export interface CurrentPlan { - id: string; - expiresAt?: string; - environmentsUsed: number; - maxEnvironments: number; -} - -export interface ReferralPlanSummaryView { - referralValueLast30Days: number; - currentPlanMonthlyPrice?: number | null; - coverageRatio?: number | null; -} - -export interface PlanPriceInfo { - monthlyPrice: number; - originalPrice: number; - discountPercent: number | null; - actualPrice: number; - savedAmount: number; -} diff --git a/plugins/pages/plan-selection/src/utils/plan-price.ts b/plugins/pages/plan-selection/src/utils/plan-price.ts deleted file mode 100644 index 9ce89aa..0000000 --- a/plugins/pages/plan-selection/src/utils/plan-price.ts +++ /dev/null @@ -1,88 +0,0 @@ -import { Check, Users, Shield, Zap } from 'lucide-react'; -import type { BillingPlan, PlanPriceInfo } from '../types'; - -/** - * 获取功能图标 - */ -export function getFeatureIcon(featureId: string) { - if (featureId.includes('env')) return Users; - if (featureId.includes('support')) return Shield; - if (featureId.includes('api') || featureId.includes('rpa')) return Zap; - return Check; -} - -/** - * 获取套餐的月度价格 - */ -export function getMonthlyPrice(plan: BillingPlan): number { - return plan.pricePerMonth; -} - -/** - * 获取套餐的原价 - */ -export function getOriginalPrice(plan: BillingPlan): number { - return plan.pricePerMonth; -} - -/** - * 获取折扣百分比 - */ -export function getDiscountPercent(plan: BillingPlan): number | null { - return plan.discount?.monthly || null; -} - -/** - * 获取实际价格(考虑折扣后) - */ -export function getActualPrice(plan: BillingPlan): number { - if (plan.discount?.monthly) { - return plan.pricePerMonth * (1 - plan.discount.monthly / 100); - } - return plan.pricePerMonth; -} - -/** - * 获取节省金额 - */ -export function getSavedAmount(plan: BillingPlan): number { - if (plan.discount?.monthly) { - return plan.pricePerMonth - getActualPrice(plan); - } - return 0; -} - -/** - * 获取套餐价格信息 - */ -export function getPlanPriceInfo(plan: BillingPlan): PlanPriceInfo { - // 如果套餐有计算后的价格(来自后端),优先使用 - if (plan.calculatedPrice) { - const cp = plan.calculatedPrice; - const originalPrice = typeof cp.original_price === 'string' ? parseFloat(cp.original_price) : cp.original_price; - const finalPrice = typeof cp.final_price === 'string' ? parseFloat(cp.final_price) : cp.final_price; - const planDiscount = typeof cp.plan_discount === 'string' ? parseFloat(cp.plan_discount) : cp.plan_discount; - const couponDiscount = typeof cp.coupon_discount === 'string' ? parseFloat(cp.coupon_discount) : cp.coupon_discount; - const totalSaved = typeof cp.total_saved === 'string' ? parseFloat(cp.total_saved) : cp.total_saved; - - // 计算折扣百分比(基于套餐级折扣) - const discountPercent = originalPrice > 0 ? (planDiscount / originalPrice) * 100 : null; - - return { - monthlyPrice: finalPrice, - originalPrice: originalPrice, - discountPercent: discountPercent, - actualPrice: finalPrice, - savedAmount: totalSaved, - }; - } - - // 否则使用前端计算逻辑 - return { - monthlyPrice: getMonthlyPrice(plan), - originalPrice: getOriginalPrice(plan), - discountPercent: getDiscountPercent(plan), - actualPrice: getActualPrice(plan), - savedAmount: getSavedAmount(plan), - }; -} diff --git a/plugins/pages/plan-selection/src/utils/plan-transform.ts b/plugins/pages/plan-selection/src/utils/plan-transform.ts deleted file mode 100644 index 8b4aa4f..0000000 --- a/plugins/pages/plan-selection/src/utils/plan-transform.ts +++ /dev/null @@ -1,40 +0,0 @@ -import type { PlanDto, PlanFeatureDto, PlanWithFeatures } from '../api/index.types'; -import type { BillingPlan, PlanFeature } from '../types'; - -/** - * 将后端 PlanFeatureDto 转换为前端 PlanFeature - */ -export function transformPlanFeatureDto(dto: PlanFeatureDto): PlanFeature { - return { - id: dto.feature_key, - name: dto.feature_name, - description: dto.feature_value || undefined, - }; -} - -/** - * 将后端 PlanWithFeatures 转换为前端 BillingPlan - */ -export function transformPlanWithFeatures(pwf: PlanWithFeatures): BillingPlan { - const dto = pwf.plan; - return { - id: dto.uuid, - name: dto.name, - pricePerMonth: Number(dto.price_per_month), - pricePerYear: Number(dto.price_per_year), - currency: dto.currency || 'USD', - environmentsLimit: dto.max_environments, - description: dto.description || '', - features: pwf.features.map(transformPlanFeatureDto), - popular: dto.is_recommended ?? false, - badge: dto.is_recommended ? 'popular' : undefined, - discount: dto.discount_monthly || dto.discount_yearly - ? { - monthly: dto.discount_monthly ? Number(dto.discount_monthly) : undefined, - yearly: dto.discount_yearly ? Number(dto.discount_yearly) : undefined, - } - : undefined, - calculatedPrice: pwf.calculated_price || undefined, - }; -} - diff --git a/plugins/pages/plan-selection/tsconfig.json b/plugins/pages/plan-selection/tsconfig.json deleted file mode 100644 index f5b6723..0000000 --- a/plugins/pages/plan-selection/tsconfig.json +++ /dev/null @@ -1,7 +0,0 @@ -{ - "extends": "../../../tsconfig.json", - "compilerOptions": { - "jsx": "react-jsx" - }, - "include": ["src/**/*"] -} diff --git a/plugins/pages/referral-program/manifest.json b/plugins/pages/referral-program/manifest.json deleted file mode 100644 index 9872f86..0000000 --- a/plugins/pages/referral-program/manifest.json +++ /dev/null @@ -1,10 +0,0 @@ -{ - "id": "referral-program", - "name": "Referral Program", - "version": "1.0.0", - "description": "推广计划页面插件", - "author": "Simprint Team", - "entry": "./src/index.tsx", - "slots": [], - "enabled": true -} diff --git a/plugins/pages/referral-program/package.json b/plugins/pages/referral-program/package.json deleted file mode 100644 index c5463ba..0000000 --- a/plugins/pages/referral-program/package.json +++ /dev/null @@ -1,6 +0,0 @@ -{ - "name": "plugin-referral-program", - "version": "1.0.0", - "main": "src/index.tsx", - "private": true -} diff --git a/plugins/pages/referral-program/src/api/index.ts b/plugins/pages/referral-program/src/api/index.ts deleted file mode 100644 index 51c0fce..0000000 --- a/plugins/pages/referral-program/src/api/index.ts +++ /dev/null @@ -1,222 +0,0 @@ -/** - * 推广计划 API 实现 - * - * - 完全参考 account-center 的分层结构 - * - 只暴露「业务模型」和调用函数,隐藏 DTO 细节 - */ - -import { post, isSuccess } from '@/lib/request'; -import { API_ENDPOINTS, ITEMS_PER_PAGE } from '../constants'; - -import type { - ReferralLink, - ReferralReward, - ReferredUser, - ReferralStats, -} from '../types'; - -import type { - ReferralDashboardDto, - ReferralDashboard, - ReferralPointsSummary, - ListReferralRewardsRequest, - ReferralRewardsListResponse, - ListReferredUsersRequest, - ReferredUsersListResponse, - SwitchReferralLinkRequest, - RedeemPointsRequest, -} from './index.types'; - -// ============ DTO → 前端模型转换 ============ - -function transformReferralLinkDto( - dto: { - uuid: string; - code: string; - url?: string | null; - unlocked?: boolean | null; - reward_rate?: string | number | null; - discount_rate?: string | number | null; - name?: string | null; - description?: string | null; - }, - index: number, -): ReferralLink { - // 如果后端未返回完整 URL,则在前端根据邀请码 code 构造一个注册页链接 - // 目前注册路由为 /auth/register,推荐码字段为 referral_code - const fallbackUrl = - (typeof window !== 'undefined' - ? `${window.location.origin}/auth/register?referral_code=${encodeURIComponent(dto.code)}` - : '') || ''; - - return { - id: dto.uuid, - name: dto.name?.trim() || '', - code: dto.code, - url: dto.url || fallbackUrl, - unlocked: Boolean(dto.unlocked), - rewardRate: Number(dto.reward_rate ?? 0), - discountRate: Number(dto.discount_rate ?? 0), - description: dto.description || undefined, - }; -} - -function transformDashboardDtoToView(dto: ReferralDashboardDto): ReferralDashboard { - const links: ReferralLink[] = dto.links.map((link, index) => - transformReferralLinkDto(link, index), - ); - - const currentLink: ReferralLink | null = dto.current_link - ? transformReferralLinkDto(dto.current_link, 0) - : null; - - const points: ReferralPointsSummary = { - availablePoints: dto.points.available_points, - pendingPoints: dto.points.pending_points, - totalRewards: dto.points.total_rewards, - }; - - // 这里的统计字段目前后端还未完全对齐前端的 mock 结构, - // 先做一个相对合理的映射,后续可以根据实际接口再调整。 - const stats: ReferralStats = { - code: currentLink?.code || '', - currentLinkId: currentLink?.id || '', - availablePoints: points.availablePoints, - pendingPoints: points.pendingPoints, - totalEarnedPoints: points.totalRewards, - // 下列字段暂时从 dto.stats 推导或兜底为 0 - linkClicks: dto.stats.total_referrals || 0, - registeredUsers: dto.stats.total_referrals || 0, - paidUsers: dto.stats.paid_referrals || 0, - last30DaysConsumption: Number(dto.stats.last_30_days_consumption || 0), - links, - }; - - return { - stats, - links, - currentLink, - points, - }; -} - -// ============ 实际接口调用 ============ - -/** - * 获取推广看板聚合数据 - */ -export async function getReferralDashboard(): Promise { - const result = await post(API_ENDPOINTS.DASHBOARD, {}); - - if (!isSuccess(result)) { - throw new Error(result.message || '获取推广看板失败'); - } - - if (!result.data) { - throw new Error('推广看板响应数据为空'); - } - - return transformDashboardDtoToView(result.data); -} - -/** - * 查询奖励记录列表 - */ -export async function listReferralRewards( - params: Omit, -): Promise { - const payload: ListReferralRewardsRequest = { - page: params.page, - page_size: ITEMS_PER_PAGE, - keyword: params.keyword ?? null, - reward_type: params.reward_type ?? null, - status: params.status ?? null, - }; - - const result = await post( - API_ENDPOINTS.REWARDS, - payload, - ); - - if (!isSuccess(result)) { - throw new Error(result.message || '获取推广奖励列表失败'); - } - - return ( - result.data || { - items: [], - total: 0, - page: payload.page, - page_size: payload.page_size, - } - ); -} - -/** - * 查询被邀请用户列表 - */ -export async function listReferredUsers( - params: Omit, -): Promise { - const payload: ListReferredUsersRequest = { - page: params.page, - page_size: ITEMS_PER_PAGE, - keyword: params.keyword ?? null, - status: params.status ?? null, - }; - - const result = await post( - API_ENDPOINTS.REFERRED_USERS, - payload, - ); - - if (!isSuccess(result)) { - throw new Error(result.message || '获取被邀请用户列表失败'); - } - - return ( - result.data || { - items: [], - total: 0, - page: payload.page, - page_size: payload.page_size, - } - ); -} - -/** - * 切换当前推广链接 - */ -export async function switchReferralLink( - payload: SwitchReferralLinkRequest, -): Promise { - const result = await post(API_ENDPOINTS.SWITCH_LINK, payload); - if (!isSuccess(result)) { - throw new Error(result.message || '切换推广链接失败'); - } -} - -/** - * 兑换推广积分 - */ -export async function redeemReferralPoints( - payload: RedeemPointsRequest, -): Promise { - const result = await post(API_ENDPOINTS.REDEEM, payload); - if (!isSuccess(result)) { - throw new Error(result.message || '兑换推广积分失败'); - } -} - -// 类型再导出,方便外部直接从 ../api 引入 -export type { - ReferralDashboard, - ReferralPointsSummary, - ListReferralRewardsRequest, - ReferralRewardsListResponse, - ListReferredUsersRequest, - ReferredUsersListResponse, - SwitchReferralLinkRequest, - RedeemPointsRequest, -} from './index.types'; - diff --git a/plugins/pages/referral-program/src/api/index.types.ts b/plugins/pages/referral-program/src/api/index.types.ts deleted file mode 100644 index de66382..0000000 --- a/plugins/pages/referral-program/src/api/index.types.ts +++ /dev/null @@ -1,117 +0,0 @@ -/** - * 推广计划 API 类型定义 - */ - -import type { - ReferralLink, - ReferralReward, - ReferredUser, - ReferralStats, -} from '../types'; - -// ============ 后端 DTO / 请求 / 响应类型 ============ - -/** - * 推广看板聚合响应(后端原始结构,尽量贴合 Rust `ReferralDashboardResponse`) - */ -export interface ReferralDashboardDto { - stats: { - total_referrals: number; - paid_referrals: number; - total_consumption: string; - last_30_days_consumption: string; - total_rewards: number; - available_points: number; - // 当前层级等字段前端暂不直接使用,保留以便后续扩展 - current_tier?: unknown; - next_tier?: unknown; - upgrade_progress?: number; - }; - links: Array<{ - uuid: string; - code: string; - url?: string | null; - unlocked?: boolean | null; - reward_rate?: string | number | null; - discount_rate?: string | number | null; - // 后端目前没有 name / description,预留字段 - name?: string | null; - description?: string | null; - }>; - current_link: { - uuid: string; - code: string; - url?: string | null; - unlocked?: boolean | null; - reward_rate?: string | number | null; - discount_rate?: string | number | null; - name?: string | null; - description?: string | null; - } | null; - tiers: unknown[]; - points: { - available_points: number; - pending_points: number; - total_rewards: number; - }; -} - -/** 看板积分摘要(前端展示用) */ -export interface ReferralPointsSummary { - availablePoints: number; - pendingPoints: number; - totalRewards: number; -} - -/** 看板聚合(前端展示用) */ -export interface ReferralDashboard { - stats: ReferralStats; - links: ReferralLink[]; - currentLink: ReferralLink | null; - points: ReferralPointsSummary; -} - -/** 查询奖励记录请求(对应 `ListReferralRewardsRequest`) */ -export interface ListReferralRewardsRequest { - page: number; - page_size: number; - keyword?: string | null; - reward_type?: string | null; - status?: string | null; -} - -/** 奖励记录列表响应(后端原始结构) */ -export interface ReferralRewardsListResponse { - items: ReferralReward[]; - total: number; - page: number; - page_size: number; -} - -/** 查询被邀请用户请求(对应 `ListReferredUsersRequest`) */ -export interface ListReferredUsersRequest { - page: number; - page_size: number; - keyword?: string | null; - status?: string | null; -} - -/** 被邀请用户列表响应(后端原始结构) */ -export interface ReferredUsersListResponse { - items: ReferredUser[]; - total: number; - page: number; - page_size: number; -} - -/** 切换链接请求(对应 `SwitchReferralLinkRequest`) */ -export interface SwitchReferralLinkRequest { - link_uuid: string; -} - -/** 兑换积分请求(当前前端约定:points + type) */ -export interface RedeemPointsRequest { - points: number; - type: 'quota' | 'feature' | 'duration'; -} - diff --git a/plugins/pages/referral-program/src/components/overview-tab.tsx b/plugins/pages/referral-program/src/components/overview-tab.tsx deleted file mode 100644 index 738a79c..0000000 --- a/plugins/pages/referral-program/src/components/overview-tab.tsx +++ /dev/null @@ -1,287 +0,0 @@ -import { useTranslation } from 'react-i18next'; -import { useMemo } from 'react'; -import { - Copy, - Users, - MousePointerClick, - DollarSign, - Clock, - CheckCircle2, - Lock, - Radio, -} from 'lucide-react'; -import { RedeemableRewardCard } from './redeemable-reward-card'; -import type { ReferralStats, ReferralLink } from '../types'; - -interface OverviewTabProps { - stats: ReferralStats; - currentLink: ReferralLink | null; - onRedeem: () => void; - onSwitchLink: (linkId: string) => Promise; - onCopy: (text: string, label: string) => Promise; -} - -export const OverviewTab: React.FC = ({ - stats, - currentLink, - onRedeem, - onSwitchLink, - onCopy, -}) => { - const { t } = useTranslation('referral'); - - // 根据链接ID确定解锁条件(由后端配置的 description 驱动) - const getUnlockCondition = (linkId: string): string => { - const link = stats.links.find((l) => l.id === linkId); - if (link?.description) { - return link.description; - } - // 默认回退为通用文案,由 i18n 自行决定内容 - return t('overview.defaultUnlockCondition'); - }; - - // 统计数据配置 - const statsData = useMemo(() => { - const maxValue = Math.max( - stats.linkClicks, - stats.registeredUsers, - stats.last30DaysConsumption, - stats.pendingPoints, - 1 // 避免除零 - ); - - return [ - { - label: t('overview.stats.linkClicks'), - value: stats.linkClicks, - icon: MousePointerClick, - color: 'bg-primary', - }, - { - label: t('overview.stats.registeredUsers'), - value: stats.registeredUsers, - icon: Users, - color: 'bg-primary', - }, - { - label: t('overview.stats.last30DaysConsumption'), - value: stats.last30DaysConsumption, - icon: DollarSign, - color: 'bg-primary', - format: (v: number) => `$${v.toFixed(2)}`, - }, - { - label: t('overview.stats.underReview'), - value: stats.pendingPoints, - icon: Clock, - color: 'bg-primary', - }, - ].map((item) => { - const heightPercent = Math.max((item.value / maxValue) * 100, 0); - const barHeight = Math.max(heightPercent, 2); - const valueBottom = barHeight; - return { ...item, barHeight, valueBottom }; - }); - }, [stats, t]); - - return ( -
-
-
- {/* 顶部:可兑换奖励 + 统计卡片 + 奖励规则 */} -
- {/* 可兑换奖励 - 占据两行,带Canvas背景和图标背景 */} - - - {/* 统计卡片 - 链接点击 */} -
-
- -
- {t('overview.stats.linkClicks')} -
-
-
{stats.linkClicks}
-
- - {/* 充值用户和名下用户(两个独立卡片) */} -
-
- {/* 充值用户 */} -
-
- -
- {t('overview.stats.paidUsers')} -
-
-
{stats.paidUsers}
-
- - {/* 名下用户 */} -
-
- -
- {t('overview.stats.registeredUsers')} -
-
-
{stats.registeredUsers}
-
-
-
- - {/* 总获得奖励 */} -
-
- {t('overview.stats.totalEarned')} -
-
{stats.totalEarnedPoints}
-
- - {/* 奖励规则卡片(末尾,上下布局) */} - {currentLink && ( -
-
- {t('overview.rewardRules')} -
-
-
-
- {t('overview.youWillGet')} -
-
{currentLink.rewardRate}%
-
-
-
- {t('overview.theyWillGet')} -
-
- {currentLink.discountRate}% -
-
-
-
- )} -
- - {/* 主要内容区域:左侧推广链接选择,右侧详情 */} -
- {/* 左侧:推广链接选择(田字布局) */} -
-

{t('overview.selectLink')}

-
- {stats.links.map((link, index) => { - const isSelected = link.id === stats.currentLinkId; - const linkDisplayName = - link.name || t('overview.linkFallback', { index: index + 1 }); - return ( -
{ - if (link.unlocked && !isSelected) { - void onSwitchLink(link.id); - } - }} - > - {/* 复制按钮(右上角) */} - {link.unlocked && ( - - )} - -
-
- {isSelected ? ( - - ) : ( -
- )} - - {linkDisplayName} - -
- {!link.unlocked && } -
- {!link.unlocked && ( -
- {getUnlockCondition(link.id)} -
- )} -
- ); - })} -
-
- - {/* 右侧:数据统计(垂直柱状图) */} -
-

{t('overview.dataStats')}

-
- {statsData.map((item, index) => { - const Icon = item.icon; - return ( -
- {/* 垂直柱子容器 */} -
- {/* 数值显示 - 在柱子顶部 */} -
- {item.format ? item.format(item.value) : item.value} -
- - {/* 垂直柱子 */} -
-
-
-
- - {/* 标签 */} -
- - - {item.label} - -
-
- ); - })} -
-
-
-
-
-
- ); -}; diff --git a/plugins/pages/referral-program/src/components/redeem-dialog.tsx b/plugins/pages/referral-program/src/components/redeem-dialog.tsx deleted file mode 100644 index a7282f6..0000000 --- a/plugins/pages/referral-program/src/components/redeem-dialog.tsx +++ /dev/null @@ -1,103 +0,0 @@ -import { useTranslation } from 'react-i18next'; -import { Button } from '@/components/ui/button'; -import { Input } from '@/components/ui/input'; -import { Label } from '@/components/ui/label'; -import { - Select, - SelectContent, - SelectItem, - SelectTrigger, - SelectValue, -} from '@/components/ui/select'; -import { - Dialog, - DialogContent, - DialogDescription, - DialogFooter, - DialogHeader, - DialogTitle, -} from '@/components/ui/dialog'; -import type { ReferralStats, RedeemType } from '../types'; - -interface RedeemDialogProps { - open: boolean; - onOpenChange: (open: boolean) => void; - stats: ReferralStats | null; - redeemPoints: string; - redeemType: RedeemType; - onRedeemPointsChange: (points: string) => void; - onRedeemTypeChange: (type: RedeemType) => void; - onConfirm: () => void; -} - -export const RedeemDialog: React.FC = ({ - open, - onOpenChange, - stats, - redeemPoints, - redeemType, - onRedeemPointsChange, - onRedeemTypeChange, - onConfirm, -}) => { - const { t } = useTranslation('referral'); - - return ( - - - - {t('redeem.title')} - - {t('redeem.description')} - - -
-
- - onRedeemPointsChange(e.target.value)} - placeholder="输入兑换点数" - className="text-xs" - /> -
- 可用点数: {stats?.availablePoints ?? 0} -
-
-
- - -
-
- - - - -
-
- ); -}; diff --git a/plugins/pages/referral-program/src/components/redeemable-reward-card.tsx b/plugins/pages/referral-program/src/components/redeemable-reward-card.tsx deleted file mode 100644 index 2ddd21c..0000000 --- a/plugins/pages/referral-program/src/components/redeemable-reward-card.tsx +++ /dev/null @@ -1,178 +0,0 @@ -import { useRef, useEffect } from 'react'; -import { useTranslation } from 'react-i18next'; -import { Gift, Clock, Sparkles, TrendingUp } from 'lucide-react'; -import { Button } from '@/components/ui/button'; - -interface RedeemableRewardCardProps { - availablePoints: number; - pendingPoints: number; - onRedeem: () => void; -} - -export const RedeemableRewardCard: React.FC = ({ - availablePoints, - pendingPoints, - onRedeem, -}) => { - const { t } = useTranslation('referral'); - const canvasRef = useRef(null); - - useEffect(() => { - const canvas = canvasRef.current; - if (!canvas) return; - - const ctx = canvas.getContext('2d'); - if (!ctx) return; - - const drawPattern = () => { - const dpr = window.devicePixelRatio || 1; - const rect = canvas.getBoundingClientRect(); - const width = rect.width; - const height = rect.height; - - canvas.width = width * dpr; - canvas.height = height * dpr; - ctx.scale(dpr, dpr); - canvas.style.width = width + 'px'; - canvas.style.height = height + 'px'; - - ctx.clearRect(0, 0, width, height); - - // 绘制网格背景 - const gridSize = 16; - ctx.strokeStyle = 'rgba(0, 0, 0, 0.04)'; - ctx.lineWidth = 0.5; - - for (let x = 0; x <= width; x += gridSize) { - ctx.beginPath(); - ctx.moveTo(x + 0.5, 0); - ctx.lineTo(x + 0.5, height); - ctx.stroke(); - } - - for (let y = 0; y <= height; y += gridSize) { - ctx.beginPath(); - ctx.moveTo(0, y + 0.5); - ctx.lineTo(width, y + 0.5); - ctx.stroke(); - } - - // 绘制装饰性渐变圆圈 - const circles = [ - { x: width * 0.2, y: height * 0.15, radius: 50, opacity: 0.08 }, - { x: width * 0.8, y: height * 0.25, radius: 60, opacity: 0.06 }, - { x: width * 0.75, y: height * 0.75, radius: 40, opacity: 0.07 }, - ]; - - circles.forEach((circle) => { - const gradient = ctx.createRadialGradient( - circle.x, - circle.y, - 0, - circle.x, - circle.y, - circle.radius - ); - gradient.addColorStop(0, `rgba(0, 0, 0, ${circle.opacity})`); - gradient.addColorStop(0.5, `rgba(0, 0, 0, ${circle.opacity * 0.5})`); - gradient.addColorStop(1, 'rgba(0, 0, 0, 0)'); - - ctx.beginPath(); - ctx.arc(circle.x, circle.y, circle.radius, 0, Math.PI * 2); - ctx.fillStyle = gradient; - ctx.fill(); - }); - - // 绘制点阵装饰 - ctx.fillStyle = 'rgba(0, 0, 0, 0.02)'; - const dotSpacing = 24; - for (let x = dotSpacing; x < width; x += dotSpacing) { - for (let y = dotSpacing; y < height; y += dotSpacing) { - if (Math.random() > 0.85) { - ctx.beginPath(); - ctx.arc(x, y, 1, 0, Math.PI * 2); - ctx.fill(); - } - } - } - }; - - // 初始绘制 - drawPattern(); - - // 监听窗口大小变化 - const resizeObserver = new ResizeObserver(() => { - drawPattern(); - }); - resizeObserver.observe(canvas); - - return () => { - resizeObserver.disconnect(); - }; - }, []); - - return ( -
- {/* Canvas 背景 */} - - - {/* 图标背景 */} -
- - - -
- - {/* 内容层 */} -
-
- {/* 顶部区域 */} -
-
- {/* 标题 */} -
- {t('overview.withdrawableBalance')} -
- {/* 奖励点数 */} -
{availablePoints}
-
- {/* 图标 */} -
- -
-
- - {/* 待审核奖励提示 */} - {pendingPoints > 0 && ( -
-
- - - {t('overview.pendingRewards')} - -
-
- {pendingPoints} {t('overview.stats.underReview')} -
-
- )} - - {/* 激励描述文本 */} -

- {t('overview.withdrawableBalanceMotivation')} -

-
- - {/* 底部按钮 */} - -
-
- ); -}; diff --git a/plugins/pages/referral-program/src/components/referral-page-skeleton.tsx b/plugins/pages/referral-program/src/components/referral-page-skeleton.tsx deleted file mode 100644 index 1ba09d2..0000000 --- a/plugins/pages/referral-program/src/components/referral-page-skeleton.tsx +++ /dev/null @@ -1,84 +0,0 @@ -import { Skeleton } from '@/components/ui/skeleton'; - -export function ReferralPageSkeleton() { - return ( -
-
-
- {/* 顶部:可兑换奖励 + 统计卡片 + 奖励规则 */} -
- {/* 可兑换奖励卡片 - 占据两行 */} -
- - - - -
- - {/* 统计卡片 - 链接点击 */} -
- - -
- - {/* 充值用户和名下用户(两个独立卡片) */} -
-
-
- - -
-
- - -
-
-
- - {/* 总获得奖励 */} -
- - -
-
- - {/* 中间:推广链接和图表 */} -
- {/* 左侧:推广链接选择 */} -
- -
- {Array.from({ length: 4 }).map((_, index) => ( -
- - - -
- ))} -
-
- - {/* 右侧:图表 */} -
- - -
-
- - {/* 底部:推广横幅 */} -
- -
- - -
- - -
-
-
-
-
-
- ); -} diff --git a/plugins/pages/referral-program/src/components/referral-pagination.tsx b/plugins/pages/referral-program/src/components/referral-pagination.tsx deleted file mode 100644 index a4cae77..0000000 --- a/plugins/pages/referral-program/src/components/referral-pagination.tsx +++ /dev/null @@ -1,116 +0,0 @@ -import { useTranslation } from 'react-i18next'; -import { - Pagination, - PaginationContent, - PaginationEllipsis, - PaginationItem, - PaginationLink, - PaginationNext, - PaginationPrevious, -} from '@/components/ui/pagination'; - -interface ReferralPaginationProps { - currentPage: number; - totalPages: number; - onPageChange: (page: number) => void; - namespace: 'rewards' | 'users'; -} - -export const ReferralPagination: React.FC = ({ - currentPage, - totalPages, - onPageChange, - namespace, -}) => { - const { t } = useTranslation('referral'); - const safeTotalPages = Math.max(totalPages, 1); - const safeCurrentPage = Math.min(Math.max(currentPage, 1), safeTotalPages); - - const getPageNumbers = () => { - const pages: (number | 'ellipsis')[] = []; - const maxVisible = 7; - - if (safeTotalPages <= maxVisible) { - for (let i = 1; i <= safeTotalPages; i++) { - pages.push(i); - } - } else { - if (safeCurrentPage <= 3) { - for (let i = 1; i <= 4; i++) pages.push(i); - pages.push('ellipsis'); - pages.push(safeTotalPages); - } else if (safeCurrentPage >= safeTotalPages - 2) { - pages.push(1); - pages.push('ellipsis'); - for (let i = safeTotalPages - 3; i <= safeTotalPages; i++) pages.push(i); - } else { - pages.push(1); - pages.push('ellipsis'); - for (let i = safeCurrentPage - 1; i <= safeCurrentPage + 1; i++) pages.push(i); - pages.push('ellipsis'); - pages.push(safeTotalPages); - } - } - - return pages; - }; - - return ( -
-
- {t(`${namespace}.pagination.pageInfo`, { - current: safeCurrentPage, - total: safeTotalPages, - })} -
- - - - { - e.preventDefault(); - if (safeCurrentPage > 1) onPageChange(safeCurrentPage - 1); - }} - className={safeCurrentPage === 1 ? 'pointer-events-none opacity-50' : 'cursor-pointer'} - > - {t(`${namespace}.pagination.previous`)} - - - - {getPageNumbers().map((page, index) => ( - - {page === 'ellipsis' ? ( - - ) : ( - { - e.preventDefault(); - onPageChange(page); - }} - isActive={page === safeCurrentPage} - className="cursor-pointer" - > - {page} - - )} - - ))} - - - { - e.preventDefault(); - if (safeCurrentPage < safeTotalPages) onPageChange(safeCurrentPage + 1); - }} - className={ - safeCurrentPage === safeTotalPages ? 'pointer-events-none opacity-50' : 'cursor-pointer' - } - > - {t(`${namespace}.pagination.next`)} - - - - -
- ); -}; diff --git a/plugins/pages/referral-program/src/components/referral-table-skeleton.tsx b/plugins/pages/referral-program/src/components/referral-table-skeleton.tsx deleted file mode 100644 index 8249761..0000000 --- a/plugins/pages/referral-program/src/components/referral-table-skeleton.tsx +++ /dev/null @@ -1,44 +0,0 @@ -import { Skeleton } from '@/components/ui/skeleton'; - -interface ReferralTableSkeletonProps { - rows?: number; -} - -export function ReferralTableSkeleton({ rows = 8 }: ReferralTableSkeletonProps) { - return ( - <> - {Array.from({ length: rows }).map((_, index) => ( - - {/* 序号列 */} - - - - {/* 邮箱列 */} - - - - {/* 注册时间列 */} - - - - {/* 状态列 */} - - - - {/* 奖励列 */} - - - - {/* 链接列 */} - - - - {/* 操作列 */} - - - - - ))} - - ); -} diff --git a/plugins/pages/referral-program/src/components/referral-tabs.tsx b/plugins/pages/referral-program/src/components/referral-tabs.tsx deleted file mode 100644 index 8d44609..0000000 --- a/plugins/pages/referral-program/src/components/referral-tabs.tsx +++ /dev/null @@ -1,39 +0,0 @@ -import { useTranslation } from 'react-i18next'; -import { cn } from '@/lib/utils'; -import type { ReferralTab } from '../types'; - -interface ReferralTabsProps { - activeTab: ReferralTab; - onTabChange: (tab: ReferralTab) => void; -} - -export const ReferralTabs: React.FC = ({ activeTab, onTabChange }) => { - const { t } = useTranslation('referral'); - - const tabs: { value: ReferralTab; key: string }[] = [ - { value: 'overview', key: 'tabs.overview' }, - { value: 'rewards', key: 'tabs.rewards' }, - { value: 'users', key: 'tabs.users' }, - ]; - - return ( -
-
- {tabs.map((tab) => ( - - ))} -
-
- ); -}; diff --git a/plugins/pages/referral-program/src/components/reward-table-skeleton.tsx b/plugins/pages/referral-program/src/components/reward-table-skeleton.tsx deleted file mode 100644 index a9fbc28..0000000 --- a/plugins/pages/referral-program/src/components/reward-table-skeleton.tsx +++ /dev/null @@ -1,44 +0,0 @@ -import { Skeleton } from '@/components/ui/skeleton'; - -interface RewardTableSkeletonProps { - rows?: number; -} - -export function RewardTableSkeleton({ rows = 8 }: RewardTableSkeletonProps) { - return ( - <> - {Array.from({ length: rows }).map((_, index) => ( - - {/* 序号列 */} - - - - {/* 日期列 */} - - - - {/* 类型列 */} - - - - {/* 描述列 */} - - - - {/* 积分列 */} - - - - {/* 状态列 */} - - - - {/* 用户列 */} - - - - - ))} - - ); -} diff --git a/plugins/pages/referral-program/src/components/rewards-tab.tsx b/plugins/pages/referral-program/src/components/rewards-tab.tsx deleted file mode 100644 index 5a5e7c6..0000000 --- a/plugins/pages/referral-program/src/components/rewards-tab.tsx +++ /dev/null @@ -1,301 +0,0 @@ -import { useEffect, useRef, useState } from 'react'; -import { useTranslation } from 'react-i18next'; -import { - Gift, - RefreshCw, - Clock, - CheckCircle2, - XCircle, - ChevronDown, - X, - Search, -} from 'lucide-react'; -import { Input } from '@/components/ui/input'; -import { - DropdownMenu, - DropdownMenuContent, - DropdownMenuItem, - DropdownMenuTrigger, -} from '@/components/ui/dropdown-menu'; -import { ScrollArea, ScrollBar } from '@/components/ui/scroll-area'; -import { ReferralPagination } from './referral-pagination'; -import { RewardTableSkeleton } from './reward-table-skeleton'; -import type { ReferralReward, RewardStatusFilter } from '../types'; -import { ITEMS_PER_PAGE } from '../constants'; - -interface RewardsTabProps { - rewards: ReferralReward[]; - loading: boolean; - totalPages: number; - currentPage: number; - statusFilter: RewardStatusFilter; - typeFilter: string; - searchQuery: string; - rewardTypes: string[]; - onRefresh: () => Promise; - onPageChange: (page: number) => void; - onStatusFilterChange: (filter: RewardStatusFilter) => void; - onTypeFilterChange: (filter: string) => void; - onSearchChange: (query: string) => void; -} - -export const RewardsTab: React.FC = ({ - rewards, - loading, - totalPages, - currentPage, - statusFilter, - typeFilter, - searchQuery, - rewardTypes, - onRefresh, - onPageChange, - onStatusFilterChange, - onTypeFilterChange, - onSearchChange, -}) => { - const { t } = useTranslation('referral'); - const [searchValue, setSearchValue] = useState(searchQuery); - const searchChangeRef = useRef(onSearchChange); - - useEffect(() => { - searchChangeRef.current = onSearchChange; - }, [onSearchChange]); - - useEffect(() => { - setSearchValue(searchQuery); - }, [searchQuery]); - - useEffect(() => { - const timer = window.setTimeout(() => { - searchChangeRef.current(searchValue); - }, 300); - - return () => window.clearTimeout(timer); - }, [searchValue]); - - return ( -
- {/* 顶部搜索和筛选栏 */} -
-
-
- - setSearchValue(e.target.value)} - className="h-9 w-72 pl-8 text-xs" - /> -
-
-
- -
-
- - {/* 表格 */} -
- -
- - - - - - - - - - - - - - {loading ? ( - - ) : rewards.length === 0 ? ( - - - - ) : ( - rewards.map((reward, index) => ( - - - - - - - - - - )) - )} - -
- {t('rewards.table.index')} - - {t('rewards.table.date')} - -
- {t('rewards.table.type')} - {!typeFilter ? ( - - - - - - {rewardTypes.map((type) => ( - onTypeFilterChange(type)} - className="text-xs cursor-pointer" - > - {t(`rewards.types.${type}`)} - - ))} - - - ) : ( - <> - - {t(`rewards.types.${typeFilter}`)} - - - - )} -
-
- {t('rewards.table.description')} - - {t('rewards.table.points')} - -
- {t('rewards.table.status')} - {statusFilter === 'all' ? ( - - - - - - onStatusFilterChange('pending')} - className="text-xs cursor-pointer" - > - {t('rewards.status.pending')} - - onStatusFilterChange('approved')} - className="text-xs cursor-pointer" - > - {t('rewards.status.approved')} - - onStatusFilterChange('rejected')} - className="text-xs cursor-pointer" - > - {t('rewards.status.rejected')} - - - - ) : ( - <> - - {t(`rewards.status.${statusFilter}`)} - - - - )} -
-
- {t('rewards.table.user')} -
- -
{t('rewards.noData')}
-
-
- {(currentPage - 1) * ITEMS_PER_PAGE + index + 1} -
-
-
- {new Date(reward.createdAt).toLocaleDateString('zh-CN')} -
-
-
- {t(`rewards.types.${reward.type}`)} -
-
-
{reward.description}
-
-
{reward.points}
-
- {reward.status === 'pending' && ( - - - {t('rewards.status.pending')} - - )} - {reward.status === 'approved' && ( - - - {t('rewards.status.approved')} - - )} - {reward.status === 'rejected' && ( - - - {t('rewards.status.rejected')} - - )} - -
- {reward.referredUser || '-'} -
-
-
- -
-
- - {/* 分页 */} - { - if (page < 1 || page > Math.max(totalPages, 1)) return; - onPageChange(page); - }} - namespace="rewards" - /> -
- ); -}; diff --git a/plugins/pages/referral-program/src/components/users-tab.tsx b/plugins/pages/referral-program/src/components/users-tab.tsx deleted file mode 100644 index ed184fd..0000000 --- a/plugins/pages/referral-program/src/components/users-tab.tsx +++ /dev/null @@ -1,251 +0,0 @@ -import { useEffect, useRef, useState } from 'react'; -import { useTranslation } from 'react-i18next'; -import { Users, RefreshCw, ChevronDown, X, Search } from 'lucide-react'; -import { Input } from '@/components/ui/input'; -import { - DropdownMenu, - DropdownMenuContent, - DropdownMenuItem, - DropdownMenuTrigger, -} from '@/components/ui/dropdown-menu'; -import { ScrollArea, ScrollBar } from '@/components/ui/scroll-area'; -import { ReferralPagination } from './referral-pagination'; -import { ReferralTableSkeleton } from './referral-table-skeleton'; -import type { ReferredUser, UserStatusFilter, ReferralLink } from '../types'; -import { ITEMS_PER_PAGE } from '../constants'; - -interface UsersTabProps { - users: ReferredUser[]; - loading: boolean; - totalPages: number; - currentPage: number; - statusFilter: UserStatusFilter; - searchQuery: string; - links: ReferralLink[]; - onRefresh: () => Promise; - onPageChange: (page: number) => void; - onStatusFilterChange: (filter: UserStatusFilter) => void; - onSearchChange: (query: string) => void; -} - -export const UsersTab: React.FC = ({ - users, - loading, - totalPages, - currentPage, - statusFilter, - searchQuery, - links, - onRefresh, - onPageChange, - onStatusFilterChange, - onSearchChange, -}) => { - const { t } = useTranslation('referral'); - const [searchValue, setSearchValue] = useState(searchQuery); - const searchChangeRef = useRef(onSearchChange); - - useEffect(() => { - searchChangeRef.current = onSearchChange; - }, [onSearchChange]); - - useEffect(() => { - setSearchValue(searchQuery); - }, [searchQuery]); - - useEffect(() => { - const timer = window.setTimeout(() => { - searchChangeRef.current(searchValue); - }, 300); - - return () => window.clearTimeout(timer); - }, [searchValue]); - - const formatMoney = (value: unknown) => { - const n = typeof value === 'number' ? value : Number(value); - return Number.isFinite(n) ? n.toFixed(2) : '--'; - }; - - const getLinkDisplayName = (linkId: string) => { - const linkIndex = links.findIndex((link) => link.id === linkId); - if (linkIndex < 0) return '-'; - - const link = links[linkIndex]; - return link.name || t('overview.linkFallback', { index: linkIndex + 1 }); - }; - - return ( -
- {/* 顶部搜索和筛选栏 */} -
-
-
- - setSearchValue(e.target.value)} - className="h-9 w-72 pl-8 text-xs" - /> -
-
-
- -
-
- - {/* 表格 */} -
- -
- - - - - - - - - - - - - - {loading ? ( - - ) : users.length === 0 ? ( - - - - ) : ( - users.map((user, index) => ( - - - - - - - - - - )) - )} - -
- {t('users.table.index')} - - {t('users.table.email')} - - {t('users.table.registeredAt')} - -
- {t('users.table.status')} - {statusFilter === 'all' ? ( - - - - - - onStatusFilterChange('registered')} - className="text-xs cursor-pointer" - > - {t('users.status.registered')} - - onStatusFilterChange('paid')} - className="text-xs cursor-pointer" - > - {t('users.status.paid')} - - onStatusFilterChange('active')} - className="text-xs cursor-pointer" - > - {t('users.status.active')} - - - - ) : ( - <> - - {t(`users.status.${statusFilter}`)} - - - - )} -
-
- {t('users.table.totalConsumption')} - - {t('users.table.last30DaysConsumption')} - - {t('users.table.link')} -
- -
{t('users.noData')}
-
-
- {(currentPage - 1) * ITEMS_PER_PAGE + index + 1} -
-
-
{user.email}
-
-
- {new Date(user.registeredAt).toLocaleDateString('zh-CN')} -
-
- - {t(`users.status.${user.status}`)} - - -
- ${formatMoney(user.totalConsumption)} -
-
-
- ${formatMoney(user.last30DaysConsumption)} -
-
-
- {getLinkDisplayName(user.linkId)} -
-
-
- -
-
- - {/* 分页 */} - { - if (page < 1 || page > Math.max(totalPages, 1)) return; - onPageChange(page); - }} - namespace="users" - /> -
- ); -}; diff --git a/plugins/pages/referral-program/src/constants.ts b/plugins/pages/referral-program/src/constants.ts deleted file mode 100644 index 9c779ff..0000000 --- a/plugins/pages/referral-program/src/constants.ts +++ /dev/null @@ -1,12 +0,0 @@ -export const ITEMS_PER_PAGE = 10; - -// 这里配置的是「业务路径」,由 Tauri 的 http_* 命令统一加上前缀等信息, -// 风格与 plan-selection / account-center 等页面保持一致。 -export const API_ENDPOINTS = { - STATS: 'referral/stats', - DASHBOARD: 'referral/dashboard', - REWARDS: 'referral/rewards', - REFERRED_USERS: 'referral/users', - SWITCH_LINK: 'referral/links/switch', - REDEEM: 'referral/redeem', -} as const; diff --git a/plugins/pages/referral-program/src/hooks/use-banner-sizes.ts b/plugins/pages/referral-program/src/hooks/use-banner-sizes.ts deleted file mode 100644 index 2b0afdd..0000000 --- a/plugins/pages/referral-program/src/hooks/use-banner-sizes.ts +++ /dev/null @@ -1,40 +0,0 @@ -import { useState, useEffect } from 'react'; -import type { PromoBannerSize } from '../types'; -import { get } from '@/lib/request'; -import { API_ENDPOINTS } from '../constants'; - -export interface UseBannerSizesReturn { - bannerSizes: PromoBannerSize[]; - selectedBannerSize: string; - setSelectedBannerSize: (size: string) => void; -} - -export function useBannerSizes(): UseBannerSizesReturn { - const [bannerSizes, setBannerSizes] = useState([]); - const [selectedBannerSize, setSelectedBannerSize] = useState('630x330'); - - useEffect(() => { - const fetchBannerSizes = async () => { - try { - const result = await get(API_ENDPOINTS.BANNER_SIZES); - if (!result || result.code !== 1) { - throw new Error(result?.message || '获取推广物料尺寸失败'); - } - const data = result.data ?? []; - setBannerSizes(data); - if (data.length > 0) { - setSelectedBannerSize(data[0].id); - } - } catch (e) { - console.error('Failed to fetch banner sizes:', e); - } - }; - void fetchBannerSizes(); - }, []); - - return { - bannerSizes, - selectedBannerSize, - setSelectedBannerSize, - }; -} diff --git a/plugins/pages/referral-program/src/hooks/use-referral-computed.ts b/plugins/pages/referral-program/src/hooks/use-referral-computed.ts deleted file mode 100644 index ec9539d..0000000 --- a/plugins/pages/referral-program/src/hooks/use-referral-computed.ts +++ /dev/null @@ -1,36 +0,0 @@ -import { useMemo } from 'react'; -import type { ReferralStats, ReferralLink, ReferralReward } from '../types'; - -export interface UseReferralComputedParams { - stats: ReferralStats | null; - rewards: ReferralReward[]; -} - -export interface UseReferralComputedReturn { - currentLink: ReferralLink | null; - rewardTypes: string[]; -} - -/** - * 计算衍生数据的 Hook - */ -export function useReferralComputed(params: UseReferralComputedParams): UseReferralComputedReturn { - const { stats, rewards } = params; - - // 获取当前选中的推广链接 - const currentLink = useMemo(() => { - if (!stats) return null; - return stats.links.find((l) => l.id === stats.currentLinkId) || stats.links[0]; - }, [stats]); - - // 奖励类型选项 - const rewardTypes = useMemo(() => { - const types = new Set(rewards.map((r) => r.type)); - return Array.from(types); - }, [rewards]); - - return { - currentLink, - rewardTypes, - }; -} diff --git a/plugins/pages/referral-program/src/hooks/use-referral-dashboard.ts b/plugins/pages/referral-program/src/hooks/use-referral-dashboard.ts deleted file mode 100644 index 74debcd..0000000 --- a/plugins/pages/referral-program/src/hooks/use-referral-dashboard.ts +++ /dev/null @@ -1,49 +0,0 @@ -import { useState, useEffect, useCallback } from 'react'; -import type { ReferralDashboard } from '../api'; -import { getReferralDashboard } from '../api'; - -export interface UseReferralDashboardReturn { - dashboard: ReferralDashboard | null; - loading: boolean; - error: string | null; - refresh: () => Promise; -} - -/** - * 使用后端聚合的推广看板接口。 - * - * 注意:当前后端的字段命名和前端 mock 类型可能不完全一致,这里做了最小映射: - * - ReferralStats 仍然作为前端展示的单一入口; - * - links / currentLink 从 dashboard.links / dashboard.current_link 映射; - * - points 从 dashboard.points 映射。 - */ -export function useReferralDashboard(): UseReferralDashboardReturn { - const [dashboard, setDashboard] = useState(null); - const [loading, setLoading] = useState(false); - const [error, setError] = useState(null); - - const fetchDashboard = useCallback(async () => { - setLoading(true); - setError(null); - try { - const data = await getReferralDashboard(); - setDashboard(data); - } catch (e) { - setError(e instanceof Error ? e.message : '未知错误'); - } finally { - setLoading(false); - } - }, []); - - useEffect(() => { - void fetchDashboard(); - }, [fetchDashboard]); - - return { - dashboard, - loading, - error, - refresh: fetchDashboard, - }; -} - diff --git a/plugins/pages/referral-program/src/hooks/use-referral-filters.ts b/plugins/pages/referral-program/src/hooks/use-referral-filters.ts deleted file mode 100644 index 63736bc..0000000 --- a/plugins/pages/referral-program/src/hooks/use-referral-filters.ts +++ /dev/null @@ -1,60 +0,0 @@ -import { useState, useCallback } from 'react'; -import type { RewardStatusFilter, UserStatusFilter } from '../types'; - -export interface UseReferralFiltersReturn { - // 奖励明细页过滤 - rewardPage: number; - setRewardPage: (page: number) => void; - rewardStatusFilter: RewardStatusFilter; - setRewardStatusFilter: (filter: RewardStatusFilter) => void; - rewardTypeFilter: string; - setRewardTypeFilter: (filter: string) => void; - rewardSearchQuery: string; - setRewardSearchQuery: (query: string) => void; - - // 被邀请用户页过滤 - userPage: number; - setUserPage: (page: number) => void; - userStatusFilter: UserStatusFilter; - setUserStatusFilter: (filter: UserStatusFilter) => void; - userSearchQuery: string; - setUserSearchQuery: (query: string) => void; -} - -export function useReferralFilters(): UseReferralFiltersReturn { - const [rewardPage, setRewardPage] = useState(1); - const [rewardStatusFilter, setRewardStatusFilter] = useState('all'); - const [rewardTypeFilter, setRewardTypeFilter] = useState(''); - const [rewardSearchQuery, setRewardSearchQuery] = useState(''); - - const [userPage, setUserPage] = useState(1); - const [userStatusFilter, setUserStatusFilter] = useState('all'); - const [userSearchQuery, setUserSearchQuery] = useState(''); - - const handleRewardSearchChange = useCallback((query: string) => { - setRewardSearchQuery(query); - setRewardPage(1); - }, []); - - const handleUserSearchChange = useCallback((query: string) => { - setUserSearchQuery(query); - setUserPage(1); - }, []); - - return { - rewardPage, - setRewardPage, - rewardStatusFilter, - setRewardStatusFilter, - rewardTypeFilter, - setRewardTypeFilter, - rewardSearchQuery: rewardSearchQuery, - setRewardSearchQuery: handleRewardSearchChange, - userPage, - setUserPage, - userStatusFilter, - setUserStatusFilter, - userSearchQuery: userSearchQuery, - setUserSearchQuery: handleUserSearchChange, - }; -} diff --git a/plugins/pages/referral-program/src/hooks/use-referral-handlers.ts b/plugins/pages/referral-program/src/hooks/use-referral-handlers.ts deleted file mode 100644 index a9f66d8..0000000 --- a/plugins/pages/referral-program/src/hooks/use-referral-handlers.ts +++ /dev/null @@ -1,122 +0,0 @@ -import { useState, useCallback } from 'react'; -import { useTranslation } from 'react-i18next'; -import { toast } from 'sonner'; -import type { ReferralStats, RedeemType } from '../types'; -import { switchReferralLink, redeemReferralPoints } from '../api'; - -export interface UseReferralHandlersParams { - stats: ReferralStats | null; - /** - * 统计数据更新回调。 - * 在新的实现中,由上层负责重新拉取 Dashboard,因此不再直接传递 stats。 - */ - onStatsUpdate: () => void; -} - -export interface UseReferralHandlersReturn { - // 兑换对话框 - redeemDialogOpen: boolean; - setRedeemDialogOpen: (open: boolean) => void; - redeemPoints: string; - setRedeemPoints: (points: string) => void; - redeemType: RedeemType; - setRedeemType: (type: RedeemType) => void; - - // 事件处理 - handleSwitchLink: (linkId: string) => Promise; - handleCopy: (text: string, label: string) => Promise; - handleRedeem: () => Promise; -} - -export function useReferralHandlers(params: UseReferralHandlersParams): UseReferralHandlersReturn { - const { stats, onStatsUpdate } = params; - const { t } = useTranslation('referral'); - - const [redeemDialogOpen, setRedeemDialogOpen] = useState(false); - const [redeemPoints, setRedeemPoints] = useState(''); - const [redeemType, setRedeemType] = useState('quota'); - - const handleSwitchLink = useCallback( - async (linkId: string) => { - try { - await switchReferralLink({ link_uuid: linkId }); - // 切换成功后由上层触发 Dashboard 刷新 - onStatsUpdate(); - } catch { - toast.error('切换推广链接失败'); - } - }, - [onStatsUpdate] - ); - - const handleCopy = useCallback(async (text: string, label: string) => { - if (!text) { - toast.error('复制失败:链接为空'); - return; - } - - try { - if (navigator.clipboard && navigator.clipboard.writeText) { - await navigator.clipboard.writeText(text); - } else { - // 兼容性回退:使用隐藏 textarea + execCommand - const textarea = document.createElement('textarea'); - textarea.value = text; - textarea.style.position = 'fixed'; - textarea.style.left = '-9999px'; - textarea.style.top = '0'; - textarea.style.opacity = '0'; - document.body.appendChild(textarea); - textarea.focus(); - textarea.select(); - const ok = document.execCommand('copy'); - document.body.removeChild(textarea); - - if (!ok) { - throw new Error('execCommand copy failed'); - } - } - - toast.success(`已复制${label}`); - } catch (e) { - // 控制台打印错误原因,方便排查 - // eslint-disable-next-line no-console - console.error('Copy referral link failed:', e); - toast.error('复制失败'); - } - }, []); - - const handleRedeem = useCallback(async () => { - const points = parseInt(redeemPoints, 10); - if (!points || points <= 0) { - toast.warning('请输入有效的点数'); - return; - } - if (!stats || stats.availablePoints < points) { - toast.warning(t('redeem.insufficientPoints')); - return; - } - try { - await redeemReferralPoints({ points, type: redeemType }); - toast.success(t('redeem.success')); - setRedeemDialogOpen(false); - setRedeemPoints(''); - // 成功后交给上层刷新 Dashboard / 统计 - onStatsUpdate(); - } catch { - toast.error(t('redeem.error')); - } - }, [redeemPoints, redeemType, stats, t, onStatsUpdate]); - - return { - redeemDialogOpen, - setRedeemDialogOpen, - redeemPoints, - setRedeemPoints, - redeemType, - setRedeemType, - handleSwitchLink, - handleCopy, - handleRedeem, - }; -} diff --git a/plugins/pages/referral-program/src/hooks/use-referral-rewards.ts b/plugins/pages/referral-program/src/hooks/use-referral-rewards.ts deleted file mode 100644 index 5979500..0000000 --- a/plugins/pages/referral-program/src/hooks/use-referral-rewards.ts +++ /dev/null @@ -1,65 +0,0 @@ -import { useState, useEffect, useCallback } from 'react'; -import type { ReferralReward, RewardStatusFilter } from '../types'; -import { listReferralRewards } from '../api'; - -export interface UseReferralRewardsParams { - page: number; - statusFilter: RewardStatusFilter; - typeFilter: string; - searchQuery: string; - enabled?: boolean; -} - -export interface UseReferralRewardsReturn { - rewards: ReferralReward[]; - totalPages: number; - loading: boolean; - error: string | null; - refresh: () => Promise; -} - -export function useReferralRewards(params: UseReferralRewardsParams): UseReferralRewardsReturn { - const { page, statusFilter, typeFilter, searchQuery, enabled = true } = params; - const [rewards, setRewards] = useState([]); - const [totalPages, setTotalPages] = useState(0); - const [loading, setLoading] = useState(false); - const [error, setError] = useState(null); - - const fetchRewards = useCallback(async () => { - if (!enabled) return; - setLoading(true); - setError(null); - try { - const data = await listReferralRewards({ - page, - keyword: searchQuery || null, - reward_type: typeFilter && typeFilter !== 'all' ? typeFilter : null, - status: statusFilter === 'all' ? null : statusFilter, - }); - - setRewards(data.items ?? []); - const totalPagesCalc = - data.page_size > 0 ? Math.max(1, Math.ceil(data.total / data.page_size)) : 1; - setTotalPages(totalPagesCalc); - } catch (e) { - console.error('Failed to fetch rewards:', e); - setRewards([]); - setTotalPages(1); - setError(e instanceof Error ? e.message : '未知错误'); - } finally { - setLoading(false); - } - }, [page, searchQuery, statusFilter, typeFilter, enabled]); - - useEffect(() => { - void fetchRewards(); - }, [fetchRewards]); - - return { - rewards, - totalPages, - loading, - error, - refresh: fetchRewards, - }; -} diff --git a/plugins/pages/referral-program/src/hooks/use-referral-stats.ts b/plugins/pages/referral-program/src/hooks/use-referral-stats.ts deleted file mode 100644 index f905404..0000000 --- a/plugins/pages/referral-program/src/hooks/use-referral-stats.ts +++ /dev/null @@ -1,42 +0,0 @@ -import { useState, useEffect, useCallback } from 'react'; -import type { ReferralStats } from '../types'; -import { API_ENDPOINTS } from '../constants'; - -export interface UseReferralStatsReturn { - stats: ReferralStats | null; - loading: boolean; - error: string | null; - refresh: () => Promise; -} - -export function useReferralStats(): UseReferralStatsReturn { - const [stats, setStats] = useState(null); - const [loading, setLoading] = useState(false); - const [error, setError] = useState(null); - - const fetchStats = useCallback(async () => { - setLoading(true); - setError(null); - try { - const res = await fetch(API_ENDPOINTS.STATS); - if (!res.ok) throw new Error(`请求失败: ${res.status}`); - const json = (await res.json()) as { data: ReferralStats }; - setStats(json.data); - } catch (e) { - setError(e instanceof Error ? e.message : '未知错误'); - } finally { - setLoading(false); - } - }, []); - - useEffect(() => { - void fetchStats(); - }, [fetchStats]); - - return { - stats, - loading, - error, - refresh: fetchStats, - }; -} diff --git a/plugins/pages/referral-program/src/hooks/use-referral-tab-handlers.ts b/plugins/pages/referral-program/src/hooks/use-referral-tab-handlers.ts deleted file mode 100644 index 1e3bf29..0000000 --- a/plugins/pages/referral-program/src/hooks/use-referral-tab-handlers.ts +++ /dev/null @@ -1,95 +0,0 @@ -import { useCallback } from 'react'; -import type { RewardStatusFilter, UserStatusFilter } from '../types'; -import type { UseReferralFiltersReturn } from './use-referral-filters'; - -export interface UseReferralTabHandlersParams { - filters: UseReferralFiltersReturn; - rewardTotalPages: number; - userTotalPages: number; -} - -export interface UseReferralTabHandlersReturn { - // 奖励相关处理函数 - handleRewardSearchChange: (query: string) => void; - handleRewardStatusFilterChange: (filter: RewardStatusFilter) => void; - handleRewardTypeFilterChange: (filter: string) => void; - handleRewardPageChange: (page: number) => void; - - // 用户相关处理函数 - handleUserSearchChange: (query: string) => void; - handleUserStatusFilterChange: (filter: UserStatusFilter) => void; - handleUserPageChange: (page: number) => void; -} - -/** - * Tab 相关事件处理 Hook - * 整合所有 Tab 相关的事件处理函数 - */ -export function useReferralTabHandlers( - params: UseReferralTabHandlersParams -): UseReferralTabHandlersReturn { - const { filters, rewardTotalPages, userTotalPages } = params; - - // 奖励相关处理函数 - const handleRewardSearchChange = useCallback( - (query: string) => { - filters.setRewardSearchQuery(query); - }, - [filters] - ); - - const handleRewardStatusFilterChange = useCallback( - (filter: RewardStatusFilter) => { - filters.setRewardStatusFilter(filter); - }, - [filters] - ); - - const handleRewardTypeFilterChange = useCallback( - (filter: string) => { - filters.setRewardTypeFilter(filter); - }, - [filters] - ); - - const handleRewardPageChange = useCallback( - (page: number) => { - if (page < 1 || page > Math.max(rewardTotalPages, 1)) return; - filters.setRewardPage(page); - }, - [filters, rewardTotalPages] - ); - - // 用户相关处理函数 - const handleUserSearchChange = useCallback( - (query: string) => { - filters.setUserSearchQuery(query); - }, - [filters] - ); - - const handleUserStatusFilterChange = useCallback( - (filter: UserStatusFilter) => { - filters.setUserStatusFilter(filter); - }, - [filters] - ); - - const handleUserPageChange = useCallback( - (page: number) => { - if (page < 1 || page > Math.max(userTotalPages, 1)) return; - filters.setUserPage(page); - }, - [filters, userTotalPages] - ); - - return { - handleRewardSearchChange, - handleRewardStatusFilterChange, - handleRewardTypeFilterChange, - handleRewardPageChange, - handleUserSearchChange, - handleUserStatusFilterChange, - handleUserPageChange, - }; -} diff --git a/plugins/pages/referral-program/src/hooks/use-referral-tabs.ts b/plugins/pages/referral-program/src/hooks/use-referral-tabs.ts deleted file mode 100644 index d8e8b48..0000000 --- a/plugins/pages/referral-program/src/hooks/use-referral-tabs.ts +++ /dev/null @@ -1,16 +0,0 @@ -import { useState } from 'react'; -import type { ReferralTab } from '../types'; - -export interface UseReferralTabsReturn { - activeTab: ReferralTab; - setActiveTab: (tab: ReferralTab) => void; -} - -export function useReferralTabs(): UseReferralTabsReturn { - const [activeTab, setActiveTab] = useState('overview'); - - return { - activeTab, - setActiveTab, - }; -} diff --git a/plugins/pages/referral-program/src/hooks/use-referred-users.ts b/plugins/pages/referral-program/src/hooks/use-referred-users.ts deleted file mode 100644 index 5eac2d9..0000000 --- a/plugins/pages/referral-program/src/hooks/use-referred-users.ts +++ /dev/null @@ -1,63 +0,0 @@ -import { useState, useEffect, useCallback } from 'react'; -import type { ReferredUser, UserStatusFilter } from '../types'; -import { listReferredUsers } from '../api'; - -export interface UseReferredUsersParams { - page: number; - statusFilter: UserStatusFilter; - searchQuery: string; - enabled?: boolean; -} - -export interface UseReferredUsersReturn { - users: ReferredUser[]; - totalPages: number; - loading: boolean; - error: string | null; - refresh: () => Promise; -} - -export function useReferredUsers(params: UseReferredUsersParams): UseReferredUsersReturn { - const { page, statusFilter, searchQuery, enabled = true } = params; - const [users, setUsers] = useState([]); - const [totalPages, setTotalPages] = useState(0); - const [loading, setLoading] = useState(false); - const [error, setError] = useState(null); - - const fetchReferredUsers = useCallback(async () => { - if (!enabled) return; - setLoading(true); - setError(null); - try { - const data = await listReferredUsers({ - page, - keyword: searchQuery || null, - status: statusFilter === 'all' ? null : statusFilter, - }); - - setUsers(data.items ?? []); - const totalPagesCalc = - data.page_size > 0 ? Math.max(1, Math.ceil(data.total / data.page_size)) : 1; - setTotalPages(totalPagesCalc); - } catch (e) { - console.error('Failed to fetch referred users:', e); - setUsers([]); - setTotalPages(1); - setError(e instanceof Error ? e.message : '未知错误'); - } finally { - setLoading(false); - } - }, [page, searchQuery, statusFilter, enabled]); - - useEffect(() => { - void fetchReferredUsers(); - }, [fetchReferredUsers]); - - return { - users, - totalPages, - loading, - error, - refresh: fetchReferredUsers, - }; -} diff --git a/plugins/pages/referral-program/src/i18n/resources.ts b/plugins/pages/referral-program/src/i18n/resources.ts deleted file mode 100644 index e7bbc96..0000000 --- a/plugins/pages/referral-program/src/i18n/resources.ts +++ /dev/null @@ -1,248 +0,0 @@ -export const referralResources = { - 'zh-CN': { - title: '推广计划', - subtitle: '邀请好友,获得更多奖励', - loading: '加载中...', - error: '加载失败:{{error}}', - tabs: { - overview: '概览', - rewards: '奖励明细', - users: '被邀请用户', - }, - overview: { - withdrawableBalance: '可兑换奖励', - withdrawableBalanceDesc: '可兑换为功能配额或使用时长', - withdrawableBalanceMotivation: - '邀请好友使用 Simprint,好友注册、订阅或消费时,您将获得相应奖励点数。奖励点数可用于兑换功能配额或延长使用时长。', - pendingRewards: '待审核奖励', - applyRedeem: '申请兑换', - stats: { - linkClicks: '链接点击次数', - registeredUsers: '名下用户', - paidUsers: '充值用户', - underReview: '审核中', - last30DaysConsumption: '近30天名下用户消费金额', - totalEarned: '已获推广奖励', - }, - selectLink: '选择推广链接', - linkFallback: '链接 {{index}}', - currentLink: '当前推广链接', - copyLink: '复制链接', - copyInvitationCode: '复制邀请码', - createCustom: '创建自定义链接/邀请码', - rewardRules: '受邀用户消费时', - youWillGet: '您将获得', - theyWillGet: '他们得到', - consumptionAmount: '消费金额', - consumptionDiscount: '消费优惠', - dataStats: '数据统计', - promoBanners: '推广宣传图', - promoBannersDesc: '建站用户专用', - selectSize: '选择尺寸', - htmlCode: 'HTML 代码', - copyCode: '复制代码', - linkLocked: '未解锁', - linkUnlockCondition: '需邀请{{count}}人解锁', - defaultUnlockCondition: '达到指定邀请人数后自动解锁此推广链接', - }, - rewards: { - title: '奖励明细', - noData: '暂无奖励记录', - table: { - index: '序号', - date: '日期', - type: '类型', - description: '描述', - points: '奖励点数', - status: '状态', - user: '被邀请用户', - }, - types: { - registration: '注册奖励', - subscription: '订阅奖励', - consumption: '消费奖励', - }, - status: { - pending: '审核中', - approved: '已通过', - rejected: '已拒绝', - }, - filters: { - allStatuses: '全部状态', - allTypes: '全部类型', - }, - searchPlaceholder: '搜索奖励描述...', - refresh: '刷新', - pagination: { - pageInfo: '第 {{current}} 页,共 {{total}} 页', - previous: '上一页', - next: '下一页', - }, - }, - users: { - title: '被邀请用户', - noData: '暂无被邀请用户', - table: { - index: '序号', - email: '邮箱', - registeredAt: '注册时间', - status: '状态', - totalConsumption: '总消费', - last30DaysConsumption: '近30天消费', - link: '推广链接', - }, - status: { - registered: '已注册', - paid: '已付费', - active: '活跃', - }, - filters: { - allStatuses: '全部状态', - }, - searchPlaceholder: '搜索被邀请用户邮箱...', - refresh: '刷新', - pagination: { - pageInfo: '第 {{current}} 页,共 {{total}} 页', - previous: '上一页', - next: '下一页', - }, - }, - redeem: { - title: '申请兑换', - description: '将奖励点数兑换为功能配额或使用时长', - points: '兑换点数', - type: '兑换类型', - types: { - quota: '环境配额', - feature: '高级功能', - duration: '使用时长', - }, - success: '兑换成功', - error: '兑换失败', - insufficientPoints: '点数不足', - }, - }, - 'en-US': { - title: 'Referral Program', - subtitle: 'Invite friends and earn rewards', - loading: 'Loading...', - error: 'Failed to load: {{error}}', - tabs: { - overview: 'Overview', - rewards: 'Reward Details', - users: 'Referred Users', - }, - overview: { - withdrawableBalance: 'Redeemable Rewards', - withdrawableBalanceDesc: 'Can be redeemed for feature quotas or usage duration', - withdrawableBalanceMotivation: - 'Invite friends to use Simprint. When they register, subscribe, or make purchases, you will earn reward points. Reward points can be redeemed for feature quotas or extended usage duration.', - pendingRewards: 'Pending Rewards', - applyRedeem: 'Apply for Redemption', - stats: { - linkClicks: 'Link Click Count', - registeredUsers: 'Referred Users', - paidUsers: 'Paid Users', - underReview: 'Under Review', - last30DaysConsumption: 'Referred User Consumption (Last 30 Days)', - totalEarned: 'Promotion Rewards Earned', - }, - selectLink: 'Select Promotion Link', - linkFallback: 'Link {{index}}', - currentLink: 'Current Promotion Link', - copyLink: 'Copy Link', - copyInvitationCode: 'Copy Invitation Code', - createCustom: 'Create Custom Link/Invitation Code', - rewardRules: 'When invited users consume', - youWillGet: 'You will get', - theyWillGet: 'They will get', - consumptionAmount: 'consumption amount', - consumptionDiscount: 'consumption discount', - dataStats: 'Data Statistics', - promoBanners: 'Promotional Images', - promoBannersDesc: 'For website users', - selectSize: 'Select Size', - htmlCode: 'HTML Code', - copyCode: 'Copy Code', - linkLocked: 'Locked', - linkUnlockCondition: 'Requires {{count}} referrals to unlock', - defaultUnlockCondition: 'This promotion link will be unlocked automatically after you reach the required number of referrals', - }, - rewards: { - title: 'Reward Details', - noData: 'No reward records', - table: { - index: 'Index', - date: 'Date', - type: 'Type', - description: 'Description', - points: 'Points', - status: 'Status', - user: 'Referred User', - }, - types: { - registration: 'Registration Reward', - subscription: 'Subscription Reward', - consumption: 'Consumption Reward', - }, - status: { - pending: 'Pending', - approved: 'Approved', - rejected: 'Rejected', - }, - filters: { - allStatuses: 'All Statuses', - allTypes: 'All Types', - }, - searchPlaceholder: 'Search reward descriptions...', - refresh: 'Refresh', - pagination: { - pageInfo: 'Page {{current}} of {{total}}', - previous: 'Previous', - next: 'Next', - }, - }, - users: { - title: 'Referred Users', - noData: 'No referred users', - table: { - index: 'Index', - email: 'Email', - registeredAt: 'Registered At', - status: 'Status', - totalConsumption: 'Total Consumption', - last30DaysConsumption: 'Last 30 Days Consumption', - link: 'Promotion Link', - }, - status: { - registered: 'Registered', - paid: 'Paid', - active: 'Active', - }, - filters: { - allStatuses: 'All Statuses', - }, - searchPlaceholder: 'Search referred users...', - refresh: 'Refresh', - pagination: { - pageInfo: 'Page {{current}} of {{total}}', - previous: 'Previous', - next: 'Next', - }, - }, - redeem: { - title: 'Apply for Redemption', - description: 'Redeem reward points for feature quotas or usage duration', - points: 'Redeem Points', - type: 'Redemption Type', - types: { - quota: 'Environment Quota', - feature: 'Premium Features', - duration: 'Usage Duration', - }, - success: 'Redemption successful', - error: 'Redemption failed', - insufficientPoints: 'Insufficient points', - }, - }, -} as const; diff --git a/plugins/pages/referral-program/src/index.tsx b/plugins/pages/referral-program/src/index.tsx deleted file mode 100644 index aa395f1..0000000 --- a/plugins/pages/referral-program/src/index.tsx +++ /dev/null @@ -1,194 +0,0 @@ -import { extensionRegistry } from '@slotkitjs/core'; -import { useTranslation } from 'react-i18next'; -import { referralResources } from './i18n/resources'; -import { ReferralTabs } from './components/referral-tabs'; -import { OverviewTab } from './components/overview-tab'; -import { RewardsTab } from './components/rewards-tab'; -import { UsersTab } from './components/users-tab'; -import { RedeemDialog } from './components/redeem-dialog'; -import { ReferralPageSkeleton } from './components/referral-page-skeleton'; -import { useReferralDashboard } from './hooks/use-referral-dashboard'; -import { useReferralRewards } from './hooks/use-referral-rewards'; -import { useReferredUsers } from './hooks/use-referred-users'; -import { useReferralTabs } from './hooks/use-referral-tabs'; -import { useReferralFilters } from './hooks/use-referral-filters'; -import { useReferralHandlers } from './hooks/use-referral-handlers'; -import { useReferralComputed } from './hooks/use-referral-computed'; -import { useReferralTabHandlers } from './hooks/use-referral-tab-handlers'; - -const ReferralProgramPage: React.FC = () => { - const { t } = useTranslation('referral'); - - // Tab 管理 - const { activeTab, setActiveTab } = useReferralTabs(); - - // 数据获取:使用后端聚合的看板接口 - const { - dashboard, - loading, - error, - refresh: refreshDashboard, - } = useReferralDashboard(); - - // 过滤状态管理 - const filters = useReferralFilters(); - - // 奖励数据(仅在 rewards tab 激活时获取) - const shouldFetchRewards = activeTab === 'rewards'; - const rewardsData = useReferralRewards({ - page: filters.rewardPage, - statusFilter: filters.rewardStatusFilter, - typeFilter: filters.rewardTypeFilter, - searchQuery: filters.rewardSearchQuery, - enabled: shouldFetchRewards, - }); - - // 被邀请用户数据(仅在 users tab 激活时获取) - const shouldFetchUsers = activeTab === 'users'; - const usersData = useReferredUsers({ - page: filters.userPage, - statusFilter: filters.userStatusFilter, - searchQuery: filters.userSearchQuery, - enabled: shouldFetchUsers, - }); - - // 事件处理 - const handlers = useReferralHandlers({ - stats: dashboard?.stats ?? null, - // 交给 Dashboard Hook 统一刷新 - onStatsUpdate: () => { - void refreshDashboard(); - }, - }); - - // 计算衍生数据 - const { currentLink, rewardTypes } = useReferralComputed({ - stats: dashboard?.stats ?? null, - rewards: rewardsData.rewards, - }); - - // Tab 相关事件处理 - const tabHandlers = useReferralTabHandlers({ - filters, - rewardTotalPages: rewardsData.totalPages, - userTotalPages: usersData.totalPages, - }); - - return ( -
- {/* 顶部区域 - 包含 Tab 导航 */} - - -
- {error && ( -
-
{t('error', { error })}
-
- )} - {!error && ( -
- {/* 概览标签页 */} - {activeTab === 'overview' && - (loading || !dashboard?.stats ? ( - - ) : ( - handlers.setRedeemDialogOpen(true)} - onSwitchLink={handlers.handleSwitchLink} - onCopy={handlers.handleCopy} - /> - ))} - - {/* 奖励明细标签页 */} - {activeTab === 'rewards' && ( - - )} - - {/* 被邀请用户标签页 */} - {activeTab === 'users' && ( - - )} -
- )} -
- - {/* 兑换对话框 */} - -
- ); -}; - -// 在模块加载时贡献路由 -try { - extensionRegistry.contribute('routes', { - contributorId: 'referral-program', - value: { - path: '/referral', - Component: ReferralProgramPage, - }, - priority: 10, - }); - console.log('[referral-program] Route contributed at module load: /referral'); -} catch (error) { - console.warn('[referral-program] Failed to contribute route at module load:', error); -} - -try { - extensionRegistry.contribute('i18n:resources', { - contributorId: 'referral-program', - value: { - namespace: 'referral', - resources: referralResources, - }, - priority: 10, - }); -} catch (error) { - console.warn('[referral-program] Failed to contribute i18n resources:', error); -} - -const referralProgramPlugin = { - id: 'referral-program', - name: 'Referral Program', - version: '1.0.0', - component: ReferralProgramPage, - slots: [], -}; - -export default referralProgramPlugin; diff --git a/plugins/pages/referral-program/src/types/index.ts b/plugins/pages/referral-program/src/types/index.ts deleted file mode 100644 index e30602c..0000000 --- a/plugins/pages/referral-program/src/types/index.ts +++ /dev/null @@ -1,56 +0,0 @@ -export interface ReferralLink { - id: string; - name: string; - code: string; - url: string; - unlocked: boolean; - rewardRate: number; - discountRate: number; - description?: string; -} - -export interface ReferralStats { - code: string; - currentLinkId: string; - availablePoints: number; - pendingPoints: number; - totalEarnedPoints: number; - linkClicks: number; - registeredUsers: number; - paidUsers: number; - last30DaysConsumption: number; - links: ReferralLink[]; -} - -export interface ReferralReward { - id: string; - createdAt: string; - type: 'registration' | 'subscription' | 'consumption'; - description: string; - points: number; - status: 'pending' | 'approved' | 'rejected'; - referredUser?: string; - linkId: string; -} - -export interface ReferredUser { - id: string; - email: string; - registeredAt: string; - status: 'registered' | 'paid' | 'active'; - totalConsumption: number; - last30DaysConsumption: number; - linkId: string; -} - -export interface PromoBannerSize { - id: string; - width: number; - height: number; - label: string; -} - -export type RewardStatusFilter = 'all' | 'pending' | 'approved' | 'rejected'; -export type UserStatusFilter = 'all' | 'registered' | 'paid' | 'active'; -export type RedeemType = 'quota' | 'feature' | 'duration'; -export type ReferralTab = 'overview' | 'rewards' | 'users'; diff --git a/plugins/pages/referral-program/tsconfig.json b/plugins/pages/referral-program/tsconfig.json deleted file mode 100644 index f5b6723..0000000 --- a/plugins/pages/referral-program/tsconfig.json +++ /dev/null @@ -1,7 +0,0 @@ -{ - "extends": "../../../tsconfig.json", - "compilerOptions": { - "jsx": "react-jsx" - }, - "include": ["src/**/*"] -} diff --git a/plugins/pages/register/src/api/index.ts b/plugins/pages/register/src/api/index.ts index 4ff8066..69319cb 100644 --- a/plugins/pages/register/src/api/index.ts +++ b/plugins/pages/register/src/api/index.ts @@ -1,89 +1,10 @@ -/** - * 注册 API 服务 - */ -import { invoke } from '@/lib/tauri'; -import { post, isSuccess } from '@/lib/request'; -import type { - SendCodeRequest, - RegisterPayload, - RegisterResponseData, - CodeType, -} from './index.types'; - -// 导出类型 -export * from './index.types'; - -// Tauri 命令配置 -export const TAURI_COMMANDS = { - REGISTER: 'register', -} as const; - -// HTTP API 端点配置 -export const API_ENDPOINTS = { - SEND_CODE: 'users/register-send-code', -} as const; - -/** - * API 响应格式 - */ -interface ApiResponse { - code: number; - message: string; - data?: T; -} - -/** - * 发送验证码(HTTP API) - */ -export async function sendVerificationCode( - email: string, - type: CodeType = 'register' -): Promise { - const requestData: SendCodeRequest = { - email, - type, - }; - - const result = await post(API_ENDPOINTS.SEND_CODE, requestData); - - if (!isSuccess(result)) { - throw new Error(result.message || '发送验证码失败'); - } -} - -/** - * 用户注册(Tauri 命令) - * - * 注意:注册必须通过 Tauri 命令进行,因为: - * 1. 需要生成 RSA 公钥 - * 2. 需要获取机器信息 - * 3. 需要保存 access_token 和 refresh_token - * - * @param email - 邮箱 - * @param password - 密码 - * @param code - 验证码 - * @param referralCode - 邀请码(可选) - */ -export async function register( - email: string, - password: string, - code: string, - referralCode?: string -): Promise { - const payload: RegisterPayload = { - email, - password, - code, - ...(referralCode && { referral_code: referralCode }), - }; - - const result = (await invoke(TAURI_COMMANDS.REGISTER, { - payload, - })) as ApiResponse; - - if (result.code !== 1) { - throw new Error(result.message || '注册失败'); - } - - return result.data!; -} +import { invoke } from '@/lib/tauri'; +import type { CreateLocalUserPayload, LocalUserProfile } from './index.types'; + +export * from './index.types'; + +export async function createLocalUser( + payload: CreateLocalUserPayload +): Promise { + return invoke('create_local_user', { payload }); +} diff --git a/plugins/pages/register/src/api/index.types.ts b/plugins/pages/register/src/api/index.types.ts index 09dffcb..bb5e716 100644 --- a/plugins/pages/register/src/api/index.types.ts +++ b/plugins/pages/register/src/api/index.types.ts @@ -1,71 +1,14 @@ -/** - * 注册 API 类型定义 - */ - -/** - * 验证码类型 - */ -export type CodeType = 'register' | 'reset_password'; - -/** - * 发送验证码请求 - */ -export interface SendCodeRequest { - email: string; - type: CodeType; -} - -/** - * 注册请求载荷(发送给 Tauri 命令) - */ -export interface RegisterPayload { - email: string; - password: string; - code: string; - referral_code?: string; // 邀请码(可选) -} - -/** - * 团队信息 - */ -export interface TeamInfoResponse { - id: number; - uuid: string; - name: string; - description?: string; - owner_uuid: string; - avatar_hash?: string; - max_members: number; - max_environments: number; - max_proxies: number; - default_proxy_uuid?: string; - status: string; - created_at: string; - updated_at: string; - deleted_at?: string; -} - -/** - * 用户信息响应 - */ -export interface UserInfoResponse { - uuid: string; - id: string; - nickname?: string; - email?: string; - phone?: string; - avatar_hash?: string; - status?: string; - created_at?: string; - updated_at?: string; - current_team?: TeamInfoResponse; -} - -/** - * 注册响应数据 - */ -export interface RegisterResponseData { - access_token: string; - refresh_token: string; - user_info?: UserInfoResponse; -} +export interface CreateLocalUserPayload { + nickname: string; + avatar: string; + password?: string | null; +} + +export interface LocalUserProfile { + uuid: string; + nickname: string; + avatar: string; + hasPassword: boolean; + currentWorkspaceUuid?: string | null; + currentTeamUuid?: string | null; +} diff --git a/plugins/pages/register/src/components/local-nickname-input.tsx b/plugins/pages/register/src/components/local-nickname-input.tsx new file mode 100644 index 0000000..f53242a --- /dev/null +++ b/plugins/pages/register/src/components/local-nickname-input.tsx @@ -0,0 +1,81 @@ +import type { MouseEvent } from 'react'; +import { Dices } from 'lucide-react'; + +import { TextareaInput } from '@/components/textarea-input'; + +interface LocalNicknameInputProps { + id: string; + value: string; + avatar: string; + placeholder: string; + randomAvatarLabel: string; + randomNicknameLabel: string; + autoFocus?: boolean; + invalid?: boolean; + onChange: (value: string) => void; + onRandomizeAvatar: () => void; + onRandomizeNickname: () => void; +} + +export function LocalNicknameInput({ + id, + value, + avatar, + placeholder, + randomAvatarLabel, + randomNicknameLabel, + autoFocus, + invalid, + onChange, + onRandomizeAvatar, + onRandomizeNickname, +}: LocalNicknameInputProps) { + const keepInputFocus = (event: MouseEvent) => { + event.preventDefault(); + }; + + return ( +
+ + + onChange(event.target.value)} + /> + + +
+ ); +} diff --git a/plugins/pages/register/src/components/register-form.tsx b/plugins/pages/register/src/components/register-form.tsx index a5b01d9..bd634fd 100644 --- a/plugins/pages/register/src/components/register-form.tsx +++ b/plugins/pages/register/src/components/register-form.tsx @@ -1,238 +1,166 @@ -import { type FormEvent } from 'react'; -import { useNavigate } from 'react-router'; -import { Mail, Lock, ArrowLeft, UserPlus, Send, Key } from 'lucide-react'; -import { Input } from '@/components/ui/input'; -import { TextareaInput } from '@/components/textarea-input'; -import { Button } from '@/components/ui/button'; -import { Label } from '@/components/ui/label'; -import { useTranslation } from 'react-i18next'; -import { useRegisterForm } from '../hooks/use-register-form'; -import { useRegisterValidation } from '../hooks/use-register-validation'; -import { useRegisterCode } from '../hooks/use-register-code'; -import { useRegister } from '../hooks/use-register'; -import { CODE_MAX_LENGTH } from '../constants'; - -export const RegisterForm: React.FC = () => { - const navigate = useNavigate(); - const { t } = useTranslation('auth'); - - // 表单状态管理 - const { formData, setEmail, setPassword, setConfirmPassword, setCode } = useRegisterForm(); - - // 表单验证 - const { errors, validateForm, validateEmail, clearErrors, setEmailError, setCodeError } = - useRegisterValidation(); - - // 验证码管理 - const { codeSent, countdown, sendCode, resetCode } = useRegisterCode(); - - // 注册操作 - const { handleRegister } = useRegister(); - - // 处理发送验证码 - const handleSendCode = async () => { - clearErrors(); - - // 验证邮箱 - if (!validateEmail(formData.email)) { - return; - } - - try { - await sendCode(formData.email); - } catch (error: any) { - setEmailError(error?.message || error?.toString() || '发送验证码失败'); - } - }; - - // 处理邮箱变化 - const handleEmailChange = (value: string) => { - setEmail(value); - clearErrors(); - resetCode(); - setCode(''); - }; - - // 处理表单提交 - const handleSubmit = async (e: FormEvent) => { - e.preventDefault(); - - // 清除之前的错误 - clearErrors(); - - // 验证表单 - if (!validateForm(formData, codeSent)) { - return; - } - - // 执行注册 - try { - await handleRegister(formData); - } catch (error: any) { - console.error('注册失败:', error); - const errorMessage = error?.message || error?.toString() || '注册失败,请重试'; - setCodeError(errorMessage); - } - }; - - return ( - <> -
-

{t('register.title')}

-

{t('register.subtitle')}

-
- -
-
- -
- - handleEmailChange(e.target.value)} - aria-invalid={!!errors.email} - className="pl-9" - required - /> -
- {errors.email &&

{errors.email}

} -
- -
- - {!codeSent ? ( - // 默认状态:只显示发送验证码按钮 - - ) : ( - // 发送后:显示输入框 -
- {countdown > 0 ? ( - // 倒计时中:只显示输入框 - <> -
- - { - setCode(e.target.value); - clearErrors(); - }} - aria-invalid={!!errors.code} - className="pl-9" - required - maxLength={CODE_MAX_LENGTH} - /> -
- {errors.code &&

{errors.code}

} -

- 验证码已发送,{countdown} 秒后可重新发送 -

- - ) : ( - // 过期后:输入框和发送按钮在同一行 -
-
- - { - setCode(e.target.value); - clearErrors(); - }} - aria-invalid={!!errors.code} - className="pl-9" - required - maxLength={CODE_MAX_LENGTH} - /> -
- -
- )} - {errors.code && countdown === 0 && ( -

{errors.code}

- )} -
- )} -
- -
- -
- - { - setPassword(e.target.value); - clearErrors(); - }} - aria-invalid={!!errors.password} - className="pl-9" - required - minLength={8} - /> -
- {errors.password &&

{errors.password}

} -
- -
- -
- - { - setConfirmPassword(e.target.value); - clearErrors(); - }} - aria-invalid={!!errors.confirmPassword} - className="pl-9" - required - /> -
- {errors.confirmPassword && ( -

{errors.confirmPassword}

- )} -
- -
- - -
-
- - ); -}; +import { useState, type FormEvent } from 'react'; +import { useNavigate } from 'react-router'; +import { ArrowLeft, LoaderCircle, LockKeyhole, UserPlus } from 'lucide-react'; +import { Input } from '@/components/ui/input'; +import { Button } from '@/components/ui/button'; +import { Label } from '@/components/ui/label'; +import { useTranslation } from 'react-i18next'; +import { toast } from 'sonner'; +import { useAuth } from '../../../../services/store/src'; +import { createLocalUser } from '../api'; +import { LocalNicknameInput } from './local-nickname-input'; + +const AVATARS = ['🙂', '😎', '🦊', '🐼', '🐯', '🐙', '🦉', '🐳', '🌙', '⭐', '🌿', '🚀']; +const CHINESE_NICKNAME_PARTS = { + adjectives: ['安静的', '自由的', '幸运的', '勇敢的', '好奇的', '闪亮的', '悠闲的', '快乐的'], + nouns: ['星河', '旅人', '狐狸', '鲸鱼', '猫头鹰', '月光', '青竹', '火箭'], +}; +const ENGLISH_NICKNAME_PARTS = { + adjectives: ['Quiet', 'Free', 'Lucky', 'Brave', 'Curious', 'Bright', 'Easygoing', 'Happy'], + nouns: ['Voyager', 'Fox', 'Whale', 'Owl', 'Moon', 'Bamboo', 'Rocket', 'Comet'], +}; + +function randomAvatar(current: string): string { + const alternatives = AVATARS.filter((avatar) => avatar !== current); + return alternatives[Math.floor(Math.random() * alternatives.length)] || AVATARS[0]; +} + +function randomNickname(language: string, current: string): string { + const parts = language.toLowerCase().startsWith('zh') + ? CHINESE_NICKNAME_PARTS + : ENGLISH_NICKNAME_PARTS; + const adjective = parts.adjectives[Math.floor(Math.random() * parts.adjectives.length)]; + const noun = parts.nouns[Math.floor(Math.random() * parts.nouns.length)]; + const candidate = `${adjective}${noun}`; + return candidate === current.trim() + ? `${candidate}${Math.floor(Math.random() * 90) + 10}` + : candidate; +} + +export const RegisterForm: React.FC = () => { + const navigate = useNavigate(); + const { t, i18n } = useTranslation('auth'); + const { setUser } = useAuth(); + const [nickname, setNickname] = useState(''); + const [avatar, setAvatar] = useState(() => randomAvatar('')); + const [password, setPassword] = useState(''); + const [submitting, setSubmitting] = useState(false); + const [error, setError] = useState(''); + + const submit = async (event: FormEvent) => { + event.preventDefault(); + if (!nickname.trim()) { + setError(t('register.nicknameRequired')); + return; + } + setSubmitting(true); + setError(''); + try { + const user = await createLocalUser({ + nickname: nickname.trim(), + avatar, + password: password || null, + }); + setUser({ + uuid: user.uuid, + id: user.uuid, + nickname: user.nickname, + avatar: user.avatar, + has_password: user.hasPassword, + status: 'active', + current_workspace_uuid: user.currentWorkspaceUuid ?? null, + current_team_uuid: user.currentTeamUuid ?? null, + }); + navigate('/'); + } catch (reason) { + const message = reason instanceof Error ? reason.message : String(reason); + setError(message); + toast.error(message); + } finally { + setSubmitting(false); + } + }; + + return ( + <> +
+
+ {avatar} +
+

{t('register.title')}

+

{t('register.subtitle')}

+
+ +
+
+ + setAvatar(randomAvatar(avatar))} + onRandomizeNickname={() => { + setNickname(randomNickname(i18n.resolvedLanguage ?? i18n.language, nickname)); + setAvatar(randomAvatar(avatar)); + setError(''); + }} + onChange={(value) => { + setNickname(value); + setError(''); + }} + /> +
+ +
+ +
+ + + + { + setPassword(event.target.value); + setError(''); + }} + placeholder={t('register.passwordPlaceholder')} + autoComplete="new-password" + className="pl-10" + /> +
+

{t('register.passwordHint')}

+
+ + {error &&

{error}

} + +
+ + +
+
+ + ); +}; diff --git a/plugins/pages/register/src/constants.ts b/plugins/pages/register/src/constants.ts deleted file mode 100644 index 9463263..0000000 --- a/plugins/pages/register/src/constants.ts +++ /dev/null @@ -1,13 +0,0 @@ -/** - * 邮箱验证正则 - * - 用户名部分:允许大小写字母、数字、点、连字符、下划线 - * - 域名部分:只允许小写字母、数字、连字符 - */ -export const EMAIL_REGEX = - /^[^\s@]+@[a-z0-9]([a-z0-9-]*[a-z0-9])?(\.[a-z0-9]([a-z0-9-]*[a-z0-9])?)+$/; - -export const MIN_PASSWORD_LENGTH = 8; - -export const CODE_COUNTDOWN_SECONDS = 60; - -export const CODE_MAX_LENGTH = 6; diff --git a/plugins/pages/register/src/hooks/use-register-code.ts b/plugins/pages/register/src/hooks/use-register-code.ts deleted file mode 100644 index 0eab5f5..0000000 --- a/plugins/pages/register/src/hooks/use-register-code.ts +++ /dev/null @@ -1,60 +0,0 @@ -import { useState, useEffect, useCallback } from 'react'; -import { toast } from 'sonner'; -import { useLoading } from '../../../../services/store/src'; -import { sendVerificationCode } from '../api'; -import { CODE_COUNTDOWN_SECONDS } from '../constants'; - -interface UseRegisterCodeReturn { - codeSent: boolean; - countdown: number; - sendCode: (email: string) => Promise; - resetCode: () => void; -} - -/** - * 注册验证码管理 Hook - */ -export function useRegisterCode(): UseRegisterCodeReturn { - const { setLoading } = useLoading(); - const [codeSent, setCodeSent] = useState(false); - const [countdown, setCountdown] = useState(0); - - // 倒计时效果 - useEffect(() => { - if (countdown > 0) { - const timer = setTimeout(() => setCountdown(countdown - 1), 1000); - return () => clearTimeout(timer); - } - }, [countdown]); - - const sendCode = useCallback( - async (email: string) => { - setLoading(true); - - try { - await sendVerificationCode(email, 'register'); - setCodeSent(true); - setCountdown(CODE_COUNTDOWN_SECONDS); - } catch (error: unknown) { - const message = error instanceof Error ? error.message : '发送验证码失败'; - toast.error(message); - throw error; - } finally { - setLoading(false); - } - }, - [setLoading] - ); - - const resetCode = useCallback(() => { - setCodeSent(false); - setCountdown(0); - }, []); - - return { - codeSent, - countdown, - sendCode, - resetCode, - }; -} diff --git a/plugins/pages/register/src/hooks/use-register-form.ts b/plugins/pages/register/src/hooks/use-register-form.ts deleted file mode 100644 index 25d5720..0000000 --- a/plugins/pages/register/src/hooks/use-register-form.ts +++ /dev/null @@ -1,57 +0,0 @@ -import { useState, useCallback } from 'react'; -import type { RegisterFormData } from '../types'; - -interface UseRegisterFormReturn { - formData: RegisterFormData; - setEmail: (email: string) => void; - setPassword: (password: string) => void; - setConfirmPassword: (confirmPassword: string) => void; - setCode: (code: string) => void; - resetForm: () => void; -} - -/** - * 注册表单状态管理 Hook - */ -export function useRegisterForm(): UseRegisterFormReturn { - const [formData, setFormData] = useState({ - email: '', - password: '', - confirmPassword: '', - code: '', - }); - - const setEmail = useCallback((email: string) => { - setFormData((prev) => ({ ...prev, email })); - }, []); - - const setPassword = useCallback((password: string) => { - setFormData((prev) => ({ ...prev, password })); - }, []); - - const setConfirmPassword = useCallback((confirmPassword: string) => { - setFormData((prev) => ({ ...prev, confirmPassword })); - }, []); - - const setCode = useCallback((code: string) => { - setFormData((prev) => ({ ...prev, code })); - }, []); - - const resetForm = useCallback(() => { - setFormData({ - email: '', - password: '', - confirmPassword: '', - code: '', - }); - }, []); - - return { - formData, - setEmail, - setPassword, - setConfirmPassword, - setCode, - resetForm, - }; -} diff --git a/plugins/pages/register/src/hooks/use-register-validation.ts b/plugins/pages/register/src/hooks/use-register-validation.ts deleted file mode 100644 index f0ddb07..0000000 --- a/plugins/pages/register/src/hooks/use-register-validation.ts +++ /dev/null @@ -1,159 +0,0 @@ -import { useState, useCallback } from 'react'; -import { useTranslation } from 'react-i18next'; -import { EMAIL_REGEX, MIN_PASSWORD_LENGTH } from '../constants'; - -interface ValidationErrors { - email: string; - password: string; - confirmPassword: string; - code: string; -} - -interface UseRegisterValidationReturn { - errors: ValidationErrors; - validateEmail: (email: string) => boolean; - validatePassword: (password: string) => boolean; - validateConfirmPassword: (password: string, confirmPassword: string) => boolean; - validateCode: (code: string, codeSent: boolean) => boolean; - validateForm: ( - formData: { email: string; password: string; confirmPassword: string; code: string }, - codeSent: boolean - ) => boolean; - clearErrors: () => void; - setEmailError: (error: string) => void; - setPasswordError: (error: string) => void; - setConfirmPasswordError: (error: string) => void; - setCodeError: (error: string) => void; -} - -/** - * 注册表单验证 Hook - */ -export function useRegisterValidation(): UseRegisterValidationReturn { - useTranslation('auth'); // 注册 i18n namespace - const [errors, setErrors] = useState({ - email: '', - password: '', - confirmPassword: '', - code: '', - }); - - const validateEmail = useCallback((email: string): boolean => { - if (!email) { - setErrors((prev) => ({ ...prev, email: '请输入邮箱地址' })); - return false; - } - - if (!EMAIL_REGEX.test(email)) { - setErrors((prev) => ({ ...prev, email: '请输入有效的邮箱地址' })); - return false; - } - - setErrors((prev) => ({ ...prev, email: '' })); - return true; - }, []); - - const validatePassword = useCallback((password: string): boolean => { - if (!password) { - setErrors((prev) => ({ ...prev, password: '请输入密码' })); - return false; - } - - if (password.length < MIN_PASSWORD_LENGTH) { - setErrors((prev) => ({ ...prev, password: `密码至少需要${MIN_PASSWORD_LENGTH}个字符` })); - return false; - } - - setErrors((prev) => ({ ...prev, password: '' })); - return true; - }, []); - - const validateConfirmPassword = useCallback( - (password: string, confirmPassword: string): boolean => { - if (!confirmPassword) { - setErrors((prev) => ({ ...prev, confirmPassword: '请再次输入密码' })); - return false; - } - - if (password !== confirmPassword) { - setErrors((prev) => ({ ...prev, confirmPassword: '两次输入的密码不一致' })); - return false; - } - - setErrors((prev) => ({ ...prev, confirmPassword: '' })); - return true; - }, - [] - ); - - const validateCode = useCallback((code: string, codeSent: boolean): boolean => { - if (!codeSent) { - setErrors((prev) => ({ ...prev, code: '请先发送验证码' })); - return false; - } - - if (!code) { - setErrors((prev) => ({ ...prev, code: '请输入验证码' })); - return false; - } - - setErrors((prev) => ({ ...prev, code: '' })); - return true; - }, []); - - const validateForm = useCallback( - ( - formData: { email: string; password: string; confirmPassword: string; code: string }, - codeSent: boolean - ): boolean => { - const isEmailValid = validateEmail(formData.email); - const isPasswordValid = validatePassword(formData.password); - const isConfirmPasswordValid = validateConfirmPassword( - formData.password, - formData.confirmPassword - ); - const isCodeValid = validateCode(formData.code, codeSent); - return isEmailValid && isPasswordValid && isConfirmPasswordValid && isCodeValid; - }, - [validateEmail, validatePassword, validateConfirmPassword, validateCode] - ); - - const clearErrors = useCallback(() => { - setErrors({ - email: '', - password: '', - confirmPassword: '', - code: '', - }); - }, []); - - const setEmailError = useCallback((error: string) => { - setErrors((prev) => ({ ...prev, email: error })); - }, []); - - const setPasswordError = useCallback((error: string) => { - setErrors((prev) => ({ ...prev, password: error })); - }, []); - - const setConfirmPasswordError = useCallback((error: string) => { - setErrors((prev) => ({ ...prev, confirmPassword: error })); - }, []); - - const setCodeError = useCallback((error: string) => { - setErrors((prev) => ({ ...prev, code: error })); - }, []); - - return { - errors, - validateEmail, - validatePassword, - validateConfirmPassword, - validateCode, - validateForm, - clearErrors, - setEmailError, - setPasswordError, - setConfirmPasswordError, - setCodeError, - }; -} diff --git a/plugins/pages/register/src/hooks/use-register.ts b/plugins/pages/register/src/hooks/use-register.ts deleted file mode 100644 index dd1e68f..0000000 --- a/plugins/pages/register/src/hooks/use-register.ts +++ /dev/null @@ -1,79 +0,0 @@ -import { useCallback } from 'react'; -import { useNavigate } from 'react-router'; -import { toast } from 'sonner'; -import { useAuth, useLoading } from '../../../../services/store/src'; -import { useTranslation } from 'react-i18next'; -import type { RegisterFormData } from '../types'; -import { register } from '../api'; - -interface UseRegisterReturn { - handleRegister: (formData: RegisterFormData) => Promise; -} - -/** - * 从邮箱提取用户名(@前面的部分) - */ -const getUsernameFromEmail = (email: string): string => { - if (!email || !email.includes('@')) return ''; - return email.split('@')[0]; -}; - -/** - * 注册操作 Hook - */ -export function useRegister(): UseRegisterReturn { - const navigate = useNavigate(); - const { setUser } = useAuth(); - const { setLoading } = useLoading(); - useTranslation('auth'); // 注册 i18n namespace - - const handleRegister = useCallback( - async (formData: RegisterFormData) => { - setLoading(true); - - try { - // 通过 Tauri 命令注册(会自动处理公钥、机器信息和 token 保存) - const responseData = await register(formData.email, formData.password, formData.code); - - // 解析用户信息 - if (responseData.user_info) { - const userInfo = responseData.user_info; - const username = getUsernameFromEmail(userInfo.email || formData.email); - setUser({ - uuid: userInfo.uuid || '', - id: userInfo.id || '', - nickname: userInfo.nickname || username, - email: userInfo.email || formData.email, - phone: userInfo.phone, - avatar: userInfo.avatar_hash, - status: userInfo.status || 'active', - }); - } else { - // 如果没有用户信息,使用邮箱提取的用户名 - const username = getUsernameFromEmail(formData.email); - setUser({ - uuid: '', - id: username, - nickname: username, - email: formData.email, - status: 'active', - }); - } - - // 注册成功后跳转到首页 - navigate('/'); - } catch (error: unknown) { - const message = error instanceof Error ? error.message : '注册失败'; - toast.error(message); - throw error; - } finally { - setLoading(false); - } - }, - [navigate, setUser, setLoading] - ); - - return { - handleRegister, - }; -} diff --git a/plugins/pages/register/src/types/index.ts b/plugins/pages/register/src/types/index.ts deleted file mode 100644 index 0101335..0000000 --- a/plugins/pages/register/src/types/index.ts +++ /dev/null @@ -1,9 +0,0 @@ -/** - * 注册表单数据 - */ -export interface RegisterFormData { - email: string; - password: string; - confirmPassword: string; - code: string; -} diff --git a/plugins/pages/reset-password/manifest.json b/plugins/pages/reset-password/manifest.json deleted file mode 100644 index d2b044c..0000000 --- a/plugins/pages/reset-password/manifest.json +++ /dev/null @@ -1,10 +0,0 @@ -{ - "id": "reset-password", - "name": "Reset Password", - "version": "1.0.0", - "description": "重置密码页面插件", - "author": "Simprint Team", - "entry": "./src/index.tsx", - "slots": [], - "enabled": true -} diff --git a/plugins/pages/reset-password/package.json b/plugins/pages/reset-password/package.json deleted file mode 100644 index 2a515fa..0000000 --- a/plugins/pages/reset-password/package.json +++ /dev/null @@ -1,6 +0,0 @@ -{ - "name": "plugin-reset-password", - "version": "1.0.0", - "main": "src/index.tsx", - "private": true -} diff --git a/plugins/pages/reset-password/src/api/index.ts b/plugins/pages/reset-password/src/api/index.ts deleted file mode 100644 index dfa1a46..0000000 --- a/plugins/pages/reset-password/src/api/index.ts +++ /dev/null @@ -1,54 +0,0 @@ -/** - * 重置密码 API 服务 - */ -import { post, isSuccess } from '@/lib/request'; -import type { SendCodeRequest, ResetPasswordRequest, CodeType } from './index.types'; - -// 导出类型 -export * from './index.types'; - -// API 端点配置 -export const API_ENDPOINTS = { - SEND_CODE: 'users/reset-password-send-code', - RESET_PASSWORD: 'users/reset-password', -} as const; - -/** - * 发送重置密码验证码 - */ -export async function sendResetCode( - email: string, - type: CodeType = 'reset_password' -): Promise { - const requestData: SendCodeRequest = { - email, - type, - }; - - const result = await post(API_ENDPOINTS.SEND_CODE, requestData); - - if (!isSuccess(result)) { - throw new Error(result.message || '发送验证码失败'); - } -} - -/** - * 重置密码 - */ -export async function resetPassword( - email: string, - code: string, - newPassword: string -): Promise { - const requestData: ResetPasswordRequest = { - email, - code, - new_password: newPassword, - }; - - const result = await post(API_ENDPOINTS.RESET_PASSWORD, requestData); - - if (!isSuccess(result)) { - throw new Error(result.message || '重置密码失败'); - } -} diff --git a/plugins/pages/reset-password/src/api/index.types.ts b/plugins/pages/reset-password/src/api/index.types.ts deleted file mode 100644 index 6ad08d9..0000000 --- a/plugins/pages/reset-password/src/api/index.types.ts +++ /dev/null @@ -1,25 +0,0 @@ -/** - * 重置密码 API 类型定义 - */ - -/** - * 验证码类型 - */ -export type CodeType = 'register' | 'reset_password'; - -/** - * 发送验证码请求 - */ -export interface SendCodeRequest { - email: string; - type: CodeType; -} - -/** - * 重置密码请求 - */ -export interface ResetPasswordRequest { - email: string; - code: string; - new_password: string; -} diff --git a/plugins/pages/reset-password/src/components/code-input.tsx b/plugins/pages/reset-password/src/components/code-input.tsx deleted file mode 100644 index 40e5dbb..0000000 --- a/plugins/pages/reset-password/src/components/code-input.tsx +++ /dev/null @@ -1,72 +0,0 @@ -import { Key, Send } from 'lucide-react'; -import { TextareaInput } from '@/components/textarea-input'; -import { Button } from '@/components/ui/button'; -import { CODE_MAX_LENGTH } from '../constants'; - -interface CodeInputProps { - code: string; - countdown: number; - error?: string; - onCodeChange: (code: string) => void; - onResend: () => void; -} - -/** - * 验证码输入组件 - */ -export const CodeInput: React.FC = ({ - code, - countdown, - error, - onCodeChange, - onResend, -}) => { - if (countdown > 0) { - // 倒计时中:只显示输入框 - return ( - <> -
- - onCodeChange(e.target.value)} - aria-invalid={!!error} - className="pl-9" - required - maxLength={CODE_MAX_LENGTH} - /> -
- {error &&

{error}

} -

验证码已发送,{countdown} 秒后可重新发送

- - ); - } - - // 过期后:输入框和发送按钮在同一行 - return ( - <> -
-
- - onCodeChange(e.target.value)} - aria-invalid={!!error} - className="pl-9" - required - maxLength={CODE_MAX_LENGTH} - /> -
- -
- {error &&

{error}

} - - ); -}; diff --git a/plugins/pages/reset-password/src/components/reset-form.tsx b/plugins/pages/reset-password/src/components/reset-form.tsx deleted file mode 100644 index a7dfeb2..0000000 --- a/plugins/pages/reset-password/src/components/reset-form.tsx +++ /dev/null @@ -1,68 +0,0 @@ -import { useTranslation } from 'react-i18next'; -import { useResetForm } from '../hooks/use-reset-form'; -import { useResetValidation } from '../hooks/use-reset-validation'; -import { useResetCode } from '../hooks/use-reset-code'; -import { useResetPassword } from '../hooks/use-reset-password'; -import { useResetHandlers } from '../hooks/use-reset-handlers'; -import { ResetStep1 } from './reset-step1'; -import { ResetStep2 } from './reset-step2'; - -export const ResetForm: React.FC = () => { - const { t } = useTranslation('auth'); - - // 表单状态管理 - const form = useResetForm(); - - // 表单验证 - const validation = useResetValidation(); - - // 验证码管理 - const code = useResetCode(); - - // 重置密码操作 - const resetPassword = useResetPassword(); - - // 事件处理 - const handlers = useResetHandlers({ - form, - validation, - code, - resetPassword, - }); - - return ( - <> -
-

{t('reset.title')}

-

- {form.step === 1 ? t('reset.subtitleStep1') : t('reset.subtitleStep2')} -

-
- -
- {form.step === 1 ? ( - - ) : ( - - )} - - - ); -}; diff --git a/plugins/pages/reset-password/src/components/reset-step1.tsx b/plugins/pages/reset-password/src/components/reset-step1.tsx deleted file mode 100644 index 66380ee..0000000 --- a/plugins/pages/reset-password/src/components/reset-step1.tsx +++ /dev/null @@ -1,67 +0,0 @@ -import { Mail, ArrowLeft, Send } from 'lucide-react'; -import { useNavigate } from 'react-router'; -import { TextareaInput } from '@/components/textarea-input'; -import { Button } from '@/components/ui/button'; -import { Label } from '@/components/ui/label'; -import { useTranslation } from 'react-i18next'; - -interface ResetStep1Props { - email: string; - emailError?: string; - onEmailChange: (email: string) => void; - onSubmit: () => void; -} - -/** - * 重置密码第一步:发送验证码 - */ -export const ResetStep1: React.FC = ({ - email, - emailError, - onEmailChange, - onSubmit, -}) => { - const navigate = useNavigate(); - const { t } = useTranslation('auth'); - - return ( - <> -
- -
- - onEmailChange(e.target.value)} - aria-invalid={!!emailError} - className="pl-9" - required - /> -
- {emailError &&

{emailError}

} -
- -

- {t('reset.hint')} -

- -
- - -
- - ); -}; diff --git a/plugins/pages/reset-password/src/components/reset-step2.tsx b/plugins/pages/reset-password/src/components/reset-step2.tsx deleted file mode 100644 index 2e31537..0000000 --- a/plugins/pages/reset-password/src/components/reset-step2.tsx +++ /dev/null @@ -1,108 +0,0 @@ -import { ArrowLeft, Lock } from 'lucide-react'; -import { useNavigate } from 'react-router'; -import { Input } from '@/components/ui/input'; -import { Button } from '@/components/ui/button'; -import { Label } from '@/components/ui/label'; -import { CodeInput } from './code-input'; - -interface ResetStep2Props { - code: string; - newPassword: string; - confirmPassword: string; - countdown: number; - codeError?: string; - passwordError?: string; - confirmPasswordError?: string; - onCodeChange: (code: string) => void; - onNewPasswordChange: (password: string) => void; - onConfirmPasswordChange: (password: string) => void; - onResendCode: () => void; -} - -/** - * 重置密码第二步:输入验证码和新密码 - */ -export const ResetStep2: React.FC = ({ - code, - newPassword, - confirmPassword, - countdown, - codeError, - passwordError, - confirmPasswordError, - onCodeChange, - onNewPasswordChange, - onConfirmPasswordChange, - onResendCode, -}) => { - const navigate = useNavigate(); - - return ( - <> -
- -
- -
-
- -
- -
- - onNewPasswordChange(e.target.value)} - aria-invalid={!!passwordError} - className="pl-9" - required - minLength={8} - /> -
- {passwordError &&

{passwordError}

} -
- -
- -
- - onConfirmPasswordChange(e.target.value)} - aria-invalid={!!confirmPasswordError} - className="pl-9" - required - /> -
- {confirmPasswordError &&

{confirmPasswordError}

} -
- -
- - -
- - ); -}; diff --git a/plugins/pages/reset-password/src/constants.ts b/plugins/pages/reset-password/src/constants.ts deleted file mode 100644 index 9463263..0000000 --- a/plugins/pages/reset-password/src/constants.ts +++ /dev/null @@ -1,13 +0,0 @@ -/** - * 邮箱验证正则 - * - 用户名部分:允许大小写字母、数字、点、连字符、下划线 - * - 域名部分:只允许小写字母、数字、连字符 - */ -export const EMAIL_REGEX = - /^[^\s@]+@[a-z0-9]([a-z0-9-]*[a-z0-9])?(\.[a-z0-9]([a-z0-9-]*[a-z0-9])?)+$/; - -export const MIN_PASSWORD_LENGTH = 8; - -export const CODE_COUNTDOWN_SECONDS = 60; - -export const CODE_MAX_LENGTH = 6; diff --git a/plugins/pages/reset-password/src/hooks/use-reset-code.ts b/plugins/pages/reset-password/src/hooks/use-reset-code.ts deleted file mode 100644 index 999e52c..0000000 --- a/plugins/pages/reset-password/src/hooks/use-reset-code.ts +++ /dev/null @@ -1,60 +0,0 @@ -import { useState, useEffect, useCallback } from 'react'; -import { toast } from 'sonner'; -import { useLoading } from '../../../../services/store/src'; -import { sendResetCode } from '../api'; -import { CODE_COUNTDOWN_SECONDS } from '../constants'; - -export interface UseResetCodeReturn { - codeSent: boolean; - countdown: number; - sendCode: (email: string) => Promise; - resetCode: () => void; -} - -/** - * 重置密码验证码管理 Hook - */ -export function useResetCode(): UseResetCodeReturn { - const { setLoading } = useLoading(); - const [codeSent, setCodeSent] = useState(false); - const [countdown, setCountdown] = useState(0); - - // 倒计时效果 - useEffect(() => { - if (countdown > 0) { - const timer = setTimeout(() => setCountdown(countdown - 1), 1000); - return () => clearTimeout(timer); - } - }, [countdown]); - - const sendCode = useCallback( - async (email: string) => { - setLoading(true); - - try { - await sendResetCode(email, 'reset_password'); - setCodeSent(true); - setCountdown(CODE_COUNTDOWN_SECONDS); - } catch (error: unknown) { - const message = error instanceof Error ? error.message : '发送验证码失败'; - toast.error(message); - throw error; - } finally { - setLoading(false); - } - }, - [setLoading] - ); - - const resetCode = useCallback(() => { - setCodeSent(false); - setCountdown(0); - }, []); - - return { - codeSent, - countdown, - sendCode, - resetCode, - }; -} diff --git a/plugins/pages/reset-password/src/hooks/use-reset-form.ts b/plugins/pages/reset-password/src/hooks/use-reset-form.ts deleted file mode 100644 index 5142f5a..0000000 --- a/plugins/pages/reset-password/src/hooks/use-reset-form.ts +++ /dev/null @@ -1,63 +0,0 @@ -import { useState, useCallback } from 'react'; -import type { ResetFormData, ResetStep } from '../types'; - -export interface UseResetFormReturn { - step: ResetStep; - formData: ResetFormData; - setStep: (step: ResetStep) => void; - setEmail: (email: string) => void; - setCode: (code: string) => void; - setNewPassword: (password: string) => void; - setConfirmPassword: (password: string) => void; - resetForm: () => void; -} - -/** - * 重置密码表单状态管理 Hook - */ -export function useResetForm(): UseResetFormReturn { - const [step, setStep] = useState(1); - const [formData, setFormData] = useState({ - email: '', - code: '', - newPassword: '', - confirmPassword: '', - }); - - const setEmail = useCallback((email: string) => { - setFormData((prev) => ({ ...prev, email })); - }, []); - - const setCode = useCallback((code: string) => { - setFormData((prev) => ({ ...prev, code })); - }, []); - - const setNewPassword = useCallback((password: string) => { - setFormData((prev) => ({ ...prev, newPassword: password })); - }, []); - - const setConfirmPassword = useCallback((password: string) => { - setFormData((prev) => ({ ...prev, confirmPassword: password })); - }, []); - - const resetForm = useCallback(() => { - setStep(1); - setFormData({ - email: '', - code: '', - newPassword: '', - confirmPassword: '', - }); - }, []); - - return { - step, - formData, - setStep, - setEmail, - setCode, - setNewPassword, - setConfirmPassword, - resetForm, - }; -} diff --git a/plugins/pages/reset-password/src/hooks/use-reset-handlers.ts b/plugins/pages/reset-password/src/hooks/use-reset-handlers.ts deleted file mode 100644 index 3b61d0c..0000000 --- a/plugins/pages/reset-password/src/hooks/use-reset-handlers.ts +++ /dev/null @@ -1,128 +0,0 @@ -import { useCallback } from 'react'; -import type { UseResetFormReturn } from './use-reset-form'; -import type { UseResetValidationReturn } from './use-reset-validation'; -import type { UseResetCodeReturn } from './use-reset-code'; -import type { UseResetPasswordReturn } from './use-reset-password'; - -interface UseResetHandlersParams { - form: UseResetFormReturn; - validation: UseResetValidationReturn; - code: UseResetCodeReturn; - resetPassword: UseResetPasswordReturn; -} - -interface UseResetHandlersReturn { - handleSendCode: () => Promise; - handleSubmit: (e: React.FormEvent) => Promise; - handleEmailChange: (value: string) => void; - handleCodeChange: (value: string) => void; - handleNewPasswordChange: (value: string) => void; - handleConfirmPasswordChange: (value: string) => void; -} - -/** - * 重置密码事件处理 Hook - * 整合所有事件处理逻辑 - */ -export function useResetHandlers(params: UseResetHandlersParams): UseResetHandlersReturn { - const { form, validation, code, resetPassword } = params; - - // 处理发送验证码 - const handleSendCode = useCallback(async () => { - validation.clearErrors(); - - // 验证邮箱 - if (!validation.validateStep1(form.formData.email)) { - return; - } - - try { - await code.sendCode(form.formData.email); - form.setStep(2); // 进入第二步 - } catch (error: any) { - validation.setEmailError(error?.message || error?.toString() || '发送验证码失败'); - } - }, [form, validation, code]); - - // 处理表单提交 - const handleSubmit = useCallback( - async (e: React.FormEvent) => { - e.preventDefault(); - - if (form.step === 1) { - // 第一步:发送验证码 - await handleSendCode(); - return; - } - - // 第二步:重置密码 - validation.clearErrors(); - - // 验证表单 - if ( - !validation.validateStep2({ - code: form.formData.code, - newPassword: form.formData.newPassword, - confirmPassword: form.formData.confirmPassword, - }) - ) { - return; - } - - // 执行重置密码 - try { - await resetPassword.handleResetPassword(form.formData); - } catch (error: any) { - console.error('重置密码失败:', error); - const errorMessage = error?.message || error?.toString() || '重置密码失败,请重试'; - validation.setCodeError(errorMessage); - } - }, - [form, validation, resetPassword, handleSendCode] - ); - - // 处理邮箱变化 - const handleEmailChange = useCallback( - (value: string) => { - form.setEmail(value); - validation.clearErrors(); - }, - [form, validation] - ); - - // 处理验证码变化 - const handleCodeChange = useCallback( - (value: string) => { - form.setCode(value); - validation.clearErrors(); - }, - [form, validation] - ); - - // 处理新密码变化 - const handleNewPasswordChange = useCallback( - (value: string) => { - form.setNewPassword(value); - validation.clearErrors(); - }, - [form, validation] - ); - - // 处理确认密码变化 - const handleConfirmPasswordChange = useCallback( - (value: string) => { - form.setConfirmPassword(value); - validation.clearErrors(); - }, - [form, validation] - ); - - return { - handleSendCode, - handleSubmit, - handleEmailChange, - handleCodeChange, - handleNewPasswordChange, - handleConfirmPasswordChange, - }; -} diff --git a/plugins/pages/reset-password/src/hooks/use-reset-password.ts b/plugins/pages/reset-password/src/hooks/use-reset-password.ts deleted file mode 100644 index a918e0a..0000000 --- a/plugins/pages/reset-password/src/hooks/use-reset-password.ts +++ /dev/null @@ -1,49 +0,0 @@ -import { useCallback } from 'react'; -import { useNavigate } from 'react-router'; -import { toast } from 'sonner'; -import { useLoading } from '../../../../services/store/src'; -import type { ResetFormData } from '../types'; -import { resetPassword } from '../api'; - -export interface UseResetPasswordReturn { - handleResetPassword: (formData: ResetFormData) => Promise; -} - -/** - * 重置密码操作 Hook - */ -export function useResetPassword(): UseResetPasswordReturn { - const navigate = useNavigate(); - const { setLoading } = useLoading(); - - const handleResetPassword = useCallback( - async (formData: ResetFormData) => { - setLoading(true); - - try { - await resetPassword(formData.email, formData.code, formData.newPassword); - - // 重置成功,显示成功提示并跳转到登录页 - toast.success('密码重置成功', { - description: '请使用新密码登录', - }); - - // 延迟跳转,让用户看到提示 - setTimeout(() => { - navigate('/auth/login'); - }, 1000); - } catch (error: unknown) { - const message = error instanceof Error ? error.message : '重置密码失败'; - toast.error(message); - throw error; - } finally { - setLoading(false); - } - }, - [navigate, setLoading] - ); - - return { - handleResetPassword, - }; -} diff --git a/plugins/pages/reset-password/src/hooks/use-reset-validation.ts b/plugins/pages/reset-password/src/hooks/use-reset-validation.ts deleted file mode 100644 index 955b893..0000000 --- a/plugins/pages/reset-password/src/hooks/use-reset-validation.ts +++ /dev/null @@ -1,163 +0,0 @@ -import { useState, useCallback } from 'react'; -import { useTranslation } from 'react-i18next'; -import { EMAIL_REGEX, MIN_PASSWORD_LENGTH } from '../constants'; - -interface ValidationErrors { - email: string; - code: string; - password: string; - confirmPassword: string; -} - -export interface UseResetValidationReturn { - errors: ValidationErrors; - validateEmail: (email: string) => boolean; - validatePassword: (password: string) => boolean; - validateConfirmPassword: (password: string, confirmPassword: string) => boolean; - validateCode: (code: string) => boolean; - validateStep1: (email: string) => boolean; - validateStep2: (formData: { - code: string; - newPassword: string; - confirmPassword: string; - }) => boolean; - clearErrors: () => void; - setEmailError: (error: string) => void; - setCodeError: (error: string) => void; - setPasswordError: (error: string) => void; - setConfirmPasswordError: (error: string) => void; -} - -/** - * 重置密码表单验证 Hook - */ -export function useResetValidation(): UseResetValidationReturn { - const { t } = useTranslation('auth'); - const [errors, setErrors] = useState({ - email: '', - code: '', - password: '', - confirmPassword: '', - }); - - const validateEmail = useCallback( - (email: string): boolean => { - if (!email) { - setErrors((prev) => ({ ...prev, email: t('login.err.emailRequired') })); - return false; - } - - if (!EMAIL_REGEX.test(email)) { - setErrors((prev) => ({ ...prev, email: t('login.err.emailInvalid') })); - return false; - } - - setErrors((prev) => ({ ...prev, email: '' })); - return true; - }, - [t] - ); - - const validatePassword = useCallback((password: string): boolean => { - if (!password) { - setErrors((prev) => ({ ...prev, password: '请输入新密码' })); - return false; - } - - if (password.length < MIN_PASSWORD_LENGTH) { - setErrors((prev) => ({ ...prev, password: `密码至少需要${MIN_PASSWORD_LENGTH}个字符` })); - return false; - } - - setErrors((prev) => ({ ...prev, password: '' })); - return true; - }, []); - - const validateConfirmPassword = useCallback( - (password: string, confirmPassword: string): boolean => { - if (!confirmPassword) { - setErrors((prev) => ({ ...prev, confirmPassword: '请再次输入密码' })); - return false; - } - - if (password !== confirmPassword) { - setErrors((prev) => ({ ...prev, confirmPassword: '两次输入的密码不一致' })); - return false; - } - - setErrors((prev) => ({ ...prev, confirmPassword: '' })); - return true; - }, - [] - ); - - const validateCode = useCallback((code: string): boolean => { - if (!code) { - setErrors((prev) => ({ ...prev, code: '请输入验证码' })); - return false; - } - - setErrors((prev) => ({ ...prev, code: '' })); - return true; - }, []); - - const validateStep1 = useCallback( - (email: string): boolean => { - return validateEmail(email); - }, - [validateEmail] - ); - - const validateStep2 = useCallback( - (formData: { code: string; newPassword: string; confirmPassword: string }): boolean => { - const isCodeValid = validateCode(formData.code); - const isPasswordValid = validatePassword(formData.newPassword); - const isConfirmPasswordValid = validateConfirmPassword( - formData.newPassword, - formData.confirmPassword - ); - return isCodeValid && isPasswordValid && isConfirmPasswordValid; - }, - [validateCode, validatePassword, validateConfirmPassword] - ); - - const clearErrors = useCallback(() => { - setErrors({ - email: '', - code: '', - password: '', - confirmPassword: '', - }); - }, []); - - const setEmailError = useCallback((error: string) => { - setErrors((prev) => ({ ...prev, email: error })); - }, []); - - const setCodeError = useCallback((error: string) => { - setErrors((prev) => ({ ...prev, code: error })); - }, []); - - const setPasswordError = useCallback((error: string) => { - setErrors((prev) => ({ ...prev, password: error })); - }, []); - - const setConfirmPasswordError = useCallback((error: string) => { - setErrors((prev) => ({ ...prev, confirmPassword: error })); - }, []); - - return { - errors, - validateEmail, - validatePassword, - validateConfirmPassword, - validateCode, - validateStep1, - validateStep2, - clearErrors, - setEmailError, - setCodeError, - setPasswordError, - setConfirmPasswordError, - }; -} diff --git a/plugins/pages/reset-password/src/index.tsx b/plugins/pages/reset-password/src/index.tsx deleted file mode 100644 index e64ede7..0000000 --- a/plugins/pages/reset-password/src/index.tsx +++ /dev/null @@ -1,35 +0,0 @@ -import { extensionRegistry } from '@slotkitjs/core'; -import { ResetForm } from './components/reset-form'; - -// 页面组件 -const ResetPasswordPage: React.FC = () => { - return ; -}; - -// 在模块加载时贡献路由 -try { - extensionRegistry.contribute('routes', { - contributorId: 'reset-password', - value: { - path: '/auth/reset-password', - Component: ResetPasswordPage, - }, - priority: 10, - }); - console.log('[reset-password] Route contributed at module load: /auth/reset-password'); -} catch (error) { - console.warn( - '[reset-password] Failed to contribute route at module load (extension point may not be registered yet):', - error - ); -} - -const resetPasswordPlugin = { - id: 'reset-password', - name: 'Reset Password', - version: '1.0.0', - component: ResetPasswordPage, - slots: [], -}; - -export default resetPasswordPlugin; diff --git a/plugins/pages/reset-password/src/types/index.ts b/plugins/pages/reset-password/src/types/index.ts deleted file mode 100644 index 9560780..0000000 --- a/plugins/pages/reset-password/src/types/index.ts +++ /dev/null @@ -1,14 +0,0 @@ -/** - * 重置密码步骤 - */ -export type ResetStep = 1 | 2; - -/** - * 重置密码表单数据 - */ -export interface ResetFormData { - email: string; - code: string; - newPassword: string; - confirmPassword: string; -} diff --git a/plugins/pages/reset-password/tsconfig.json b/plugins/pages/reset-password/tsconfig.json deleted file mode 100644 index f5b6723..0000000 --- a/plugins/pages/reset-password/tsconfig.json +++ /dev/null @@ -1,7 +0,0 @@ -{ - "extends": "../../../tsconfig.json", - "compilerOptions": { - "jsx": "react-jsx" - }, - "include": ["src/**/*"] -} diff --git a/plugins/pages/rpa-workflow/src/constants.ts b/plugins/pages/rpa-workflow/src/constants.ts deleted file mode 100644 index f4de497..0000000 --- a/plugins/pages/rpa-workflow/src/constants.ts +++ /dev/null @@ -1,11 +0,0 @@ -export const ITEMS_PER_PAGE = 10; - -export const API_ENDPOINTS = { - WORKFLOWS: '/api/v1/rpa/workflows', - RUN: (id: string) => `/api/v1/rpa/workflows/${id}/run`, - STOP: (id: string) => `/api/v1/rpa/workflows/${id}/stop`, - DELETE: (id: string) => `/api/v1/rpa/workflows/${id}`, - DUPLICATE: (id: string) => `/api/v1/rpa/workflows/${id}/duplicate`, - BATCH_RUN: '/api/v1/rpa/workflows/batch-run', - BATCH_DELETE: '/api/v1/rpa/workflows/batch-delete', -} as const; diff --git a/plugins/pages/rpa-workflow/src/runtime/anonymous-environment.ts b/plugins/pages/rpa-workflow/src/runtime/anonymous-environment.ts index 027d5b1..f528ef7 100644 --- a/plugins/pages/rpa-workflow/src/runtime/anonymous-environment.ts +++ b/plugins/pages/rpa-workflow/src/runtime/anonymous-environment.ts @@ -92,7 +92,7 @@ export async function startAnonymousRpaEnvironment( const exePath = await invoke('ensure_kernel_ready', { envUuid: anonymousEnvUuid, - kernelValue: kernelDetail.resource_name, + kernelValue: kernelDetail.kernel_id, profilesPath: effectiveProfiles, kernelDetail: { url: kernelDetail.url, diff --git a/plugins/pages/splashscreen/src/components/splashscreen-page.tsx b/plugins/pages/splashscreen/src/components/splashscreen-page.tsx new file mode 100644 index 0000000..0230768 --- /dev/null +++ b/plugins/pages/splashscreen/src/components/splashscreen-page.tsx @@ -0,0 +1,43 @@ +import { useSplashscreenInit } from '../hooks/useSplashscreenInit'; +import { useSplashscreenEvents } from '../hooks/useSplashscreenEvents'; +import { useSplashscreenWindowDisplay } from '../hooks/use-splashscreen-window-display'; +import { useSplashscreenClose } from '../hooks/use-splashscreen-close'; +import { + SplashscreenBackground, + SplashscreenLogo, + SplashscreenContent, + SplashscreenLoadingText, + SplashscreenErrorMessage, + SplashscreenDecoration, +} from '.'; +import { SplashscreenCloseButton } from './splashscreen-close-button'; + +/** Splashscreen 页面,只依赖启动窗口所需的最小组件集合。 */ +export const SplashscreenPage: React.FC = () => { + useSplashscreenInit(); + + const { loadingText, errorMessage, connectionFailed, showCloseButton, progress, isUpdating } = + useSplashscreenEvents(); + const { contentRef } = useSplashscreenWindowDisplay(); + const { handleClose } = useSplashscreenClose(); + + return ( +
+ + {showCloseButton && } + +
+ + + {errorMessage && !connectionFailed && } +
+ + + +
+ ); +}; diff --git a/plugins/pages/splashscreen/src/hooks/use-splashscreen-window-display.ts b/plugins/pages/splashscreen/src/hooks/use-splashscreen-window-display.ts index ff822d5..3a65440 100644 --- a/plugins/pages/splashscreen/src/hooks/use-splashscreen-window-display.ts +++ b/plugins/pages/splashscreen/src/hooks/use-splashscreen-window-display.ts @@ -1,74 +1,71 @@ -import { useEffect, useRef } from 'react'; -import { getCurrentWindow } from '@tauri-apps/api/window'; -import { invoke } from '@/lib/tauri'; - -interface UseSplashscreenWindowDisplayReturn { - contentRef: React.RefObject; -} - -/** - * Splashscreen 窗口显示控制 Hook - * 负责在内容准备好后显示窗口并通知后端 - */ -export function useSplashscreenWindowDisplay(): UseSplashscreenWindowDisplayReturn { - const contentRef = useRef(null); - const hasShownWindow = useRef(false); - - useEffect(() => { - let animationFrameId: number; - - const checkContentReady = () => { - // 检查 DOM 元素是否已存在且可见(有高度) - const isContentReady = - contentRef.current && - contentRef.current.offsetHeight > 0 && - contentRef.current.offsetWidth > 0; - - if (isContentReady && !hasShownWindow.current) { - // 内容已渲染,使用双重 RAF + setTimeout 确保浏览器完成渲染后再显示窗口 - hasShownWindow.current = true; - - requestAnimationFrame(() => { - requestAnimationFrame(async () => { - // 额外的延迟确保所有样式、布局和动画都已完成 - setTimeout(async () => { - try { - const splashWindow = getCurrentWindow(); - - // 检查窗口标签是否匹配 - if (splashWindow.label.includes('splashscreen')) { - await splashWindow.show(); - await splashWindow.setFocus(); - // 窗口显示后,通知后端开始加载 - try { - await invoke('splashscreen_ready'); - } catch (error) { - console.error('[SplashscreenWindowDisplay] 通知后端开始加载失败:', error); - } - } - } catch (error) { - console.error('[SplashscreenWindowDisplay] 显示窗口失败:', error); - } - }, 150); - }); - }); - } else if (!isContentReady) { - // 内容还未准备好,继续检查(使用 RAF 避免阻塞) - animationFrameId = requestAnimationFrame(checkContentReady); - } - }; - - // 立即开始检查,使用 RAF 循环直到内容准备好 - animationFrameId = requestAnimationFrame(checkContentReady); - - return () => { - if (animationFrameId) { - cancelAnimationFrame(animationFrameId); - } - }; - }, []); - - return { - contentRef, - }; -} +import { useEffect, useRef } from 'react'; +import { getCurrentWindow } from '@tauri-apps/api/window'; +import { invoke } from '@/lib/tauri'; + +interface UseSplashscreenWindowDisplayReturn { + contentRef: React.RefObject; +} + +/** + * Splashscreen 窗口显示控制 Hook + * 负责在内容准备好后显示窗口并通知后端 + */ +export function useSplashscreenWindowDisplay(): UseSplashscreenWindowDisplayReturn { + const contentRef = useRef(null); + const hasShownWindow = useRef(false); + + useEffect(() => { + let animationFrameId: number; + + const checkContentReady = () => { + // 检查 DOM 元素是否已存在且可见(有高度) + const isContentReady = + contentRef.current && + contentRef.current.offsetHeight > 0 && + contentRef.current.offsetWidth > 0; + + if (isContentReady && !hasShownWindow.current) { + // 内容已渲染,使用双重 RAF 确保浏览器完成首帧布局后再显示窗口。 + hasShownWindow.current = true; + + requestAnimationFrame(() => { + requestAnimationFrame(async () => { + try { + const splashWindow = getCurrentWindow(); + + // 检查窗口标签是否匹配 + if (splashWindow.label.includes('splashscreen')) { + await splashWindow.show(); + await splashWindow.setFocus(); + // 窗口显示后,通知后端开始加载 + try { + await invoke('splashscreen_ready'); + } catch (error) { + console.error('[SplashscreenWindowDisplay] 通知后端开始加载失败:', error); + } + } + } catch (error) { + console.error('[SplashscreenWindowDisplay] 显示窗口失败:', error); + } + }); + }); + } else if (!isContentReady) { + // 内容还未准备好,继续检查(使用 RAF 避免阻塞) + animationFrameId = requestAnimationFrame(checkContentReady); + } + }; + + // 立即开始检查,使用 RAF 循环直到内容准备好 + animationFrameId = requestAnimationFrame(checkContentReady); + + return () => { + if (animationFrameId) { + cancelAnimationFrame(animationFrameId); + } + }; + }, []); + + return { + contentRef, + }; +} diff --git a/plugins/pages/splashscreen/src/hooks/useSplashscreenInit.ts b/plugins/pages/splashscreen/src/hooks/useSplashscreenInit.ts index 28f16bc..206cc8b 100644 --- a/plugins/pages/splashscreen/src/hooks/useSplashscreenInit.ts +++ b/plugins/pages/splashscreen/src/hooks/useSplashscreenInit.ts @@ -1,24 +1,24 @@ -import { useEffect } from 'react'; -import { invoke } from '@/lib/tauri'; - -/** - * Splashscreen 初始化 Hook - * 负责检查应用状态 - */ -export function useSplashscreenInit() { - useEffect(() => { - const init = async () => { - try { - const appState = (await invoke('get_app_state')) as { is_initialized?: boolean }; - - if (appState?.is_initialized) { - await invoke('complete_and_show_main'); - } - } catch (error) { - console.error('[SplashscreenInit] Initialization failed:', error); - } - }; - - init(); - }, []); -} +import { useEffect } from 'react'; +import { invoke } from '@/lib/tauri'; + +/** + * Splashscreen 初始化 Hook + * 负责检查应用状态 + */ +export function useSplashscreenInit() { + useEffect(() => { + const init = async () => { + try { + const appState = (await invoke('get_app_state')) as { is_initialized?: boolean }; + + if (appState?.is_initialized) { + await invoke('show_main_window'); + } + } catch (error) { + console.error('[SplashscreenInit] Initialization failed:', error); + } + }; + + init(); + }, []); +} diff --git a/plugins/pages/splashscreen/src/index.tsx b/plugins/pages/splashscreen/src/index.tsx index 5bcf81e..bdc5d92 100644 --- a/plugins/pages/splashscreen/src/index.tsx +++ b/plugins/pages/splashscreen/src/index.tsx @@ -1,76 +1,13 @@ -import '../../../../src/index.css'; -import './styles/animations.css'; -import { useSplashscreenInit } from './hooks/useSplashscreenInit'; -import { useSplashscreenEvents } from './hooks/useSplashscreenEvents'; -import { useSplashscreenWindowDisplay } from './hooks/use-splashscreen-window-display'; -import { useSplashscreenClose } from './hooks/use-splashscreen-close'; -import { - SplashscreenBackground, - SplashscreenLogo, - SplashscreenContent, - SplashscreenLoadingText, - SplashscreenErrorMessage, - SplashscreenDecoration, -} from './components'; -import { SplashscreenCloseButton } from './components/splashscreen-close-button'; - -/** - * Splashscreen 页面主组件 - * 负责整合所有子组件和业务逻辑 - */ -const SplashscreenPage: React.FC = () => { - // 初始化应用状态检查 - useSplashscreenInit(); - - // 监听事件并获取状态 - const { loadingText, errorMessage, connectionFailed, showCloseButton, progress, isUpdating } = - useSplashscreenEvents(); - - // 窗口显示控制 - const { contentRef } = useSplashscreenWindowDisplay(); - - // 关闭窗口处理 - const { handleClose } = useSplashscreenClose(); - - return ( -
- {/* 背景(网格动画和光效) */} - - - {/* 右上角关闭按钮(仅在连接失败时显示) */} - {showCloseButton && } - - {/* 主要内容区域 */} -
- {/* Logo 区域 */} - - - {/* 内容区域(标题) */} - - - {/* 错误消息(仅在非连接失败错误时显示) */} - {errorMessage && !connectionFailed && } -
- - {/* 右下角加载文本 */} - - - {/* 装饰元素 */} - -
- ); -}; - -const splashscreenPlugin = { - id: 'splashscreen', - name: 'Splashscreen', - version: '1.0.0', - component: SplashscreenPage, - slots: [], -}; - -export default splashscreenPlugin; +import '../../../../src/index.css'; +import './styles/animations.css'; +import { SplashscreenPage } from './components/splashscreen-page'; + +const splashscreenPlugin = { + id: 'splashscreen', + name: 'Splashscreen', + version: '1.0.0', + component: SplashscreenPage, + slots: [], +}; + +export default splashscreenPlugin; diff --git a/plugins/pages/system-settings/src/api/users.ts b/plugins/pages/system-settings/src/api/users.ts deleted file mode 100644 index 6c72116..0000000 --- a/plugins/pages/system-settings/src/api/users.ts +++ /dev/null @@ -1,108 +0,0 @@ -/** - * 用户相关 API - */ -import { post, isSuccess } from '@/lib/request'; - -const API_ENDPOINTS = { - GET_CURRENT_USER: 'users/me', - UPDATE_USER: 'users/update', - UPDATE_PASSWORD: 'users/password', - VERIFY_PASSWORD: 'users/verify-password', -} as const; - -/** - * 当前用户信息响应(与后端 UserResponse 对应) - */ -export interface CurrentUserResponse { - uuid: string; - id: string; - nickname?: string; - email: string; - phone?: string; - avatar_hash?: string; - avatar_url?: string; - status: string; - created_at: string; - updated_at: string; - current_team?: { uuid: string; name: string; [key: string]: unknown }; - current_workspace?: { uuid: string; name: string; [key: string]: unknown }; -} - -/** - * 获取当前用户信息 - */ -export async function getCurrentUser(): Promise { - const result = await post(API_ENDPOINTS.GET_CURRENT_USER, {}); - - if (!isSuccess(result) || !result.data) { - return null; - } - - return result.data; -} - -/** - * 更新用户信息请求 - */ -export interface UpdateUserRequest { - nickname?: string; - phone?: string; - email?: string; -} - -/** - * 更新当前用户信息 - */ -export async function updateCurrentUser( - payload: UpdateUserRequest -): Promise<{ ok: boolean; message?: string }> { - const result = await post(API_ENDPOINTS.UPDATE_USER, payload); - - if (!isSuccess(result)) { - return { ok: false, message: result.message || '更新失败' }; - } - - return { ok: true }; -} - -/** - * 修改密码请求 - */ -export interface UpdatePasswordRequest { - old_password: string; - new_password: string; -} - -export interface VerifyPasswordRequest { - password: string; -} - -/** - * 修改密码 - */ -export async function updatePassword( - payload: UpdatePasswordRequest -): Promise<{ ok: boolean; message?: string }> { - const result = await post(API_ENDPOINTS.UPDATE_PASSWORD, payload); - - if (!isSuccess(result)) { - return { ok: false, message: result.message || '修改密码失败' }; - } - - return { ok: true }; -} - -/** - * 校验当前用户密码 - */ -export async function verifyCurrentUserPassword( - payload: VerifyPasswordRequest -): Promise<{ ok: boolean; valid: boolean; message?: string }> { - const result = await post<{ valid: boolean }>(API_ENDPOINTS.VERIFY_PASSWORD, payload); - - if (!isSuccess(result) || !result.data) { - return { ok: false, valid: false, message: result.message || '校验失败' }; - } - - return { ok: true, valid: result.data.valid }; -} diff --git a/plugins/pages/system-settings/src/components/account-panel.tsx b/plugins/pages/system-settings/src/components/account-panel.tsx index c712a25..fadbc6f 100644 --- a/plugins/pages/system-settings/src/components/account-panel.tsx +++ b/plugins/pages/system-settings/src/components/account-panel.tsx @@ -1,16 +1,6 @@ -import { useState, useEffect, useCallback } from 'react'; +import { useCallback, useEffect, useState } from 'react'; import { useTranslation } from 'react-i18next'; -import { - User, - Shield, - Phone, - Calendar, - MapPin, - Lock, - Timer, - KeyRound, - ChevronRight, -} from 'lucide-react'; +import { KeyRound, Lock, Shield, Timer, User } from 'lucide-react'; import { Switch } from '@/components/ui/switch'; import { Select, @@ -19,86 +9,31 @@ import { SelectTrigger, SelectValue, } from '@/components/ui/select'; -import { Skeleton } from '@/components/ui/skeleton'; import { SettingCard } from './setting-card'; import { SettingRow } from './setting-row'; -import { getCurrentUser, type CurrentUserResponse } from '../api/users'; -import { EditProfileDialog } from './edit-profile-dialog'; -import { ChangePasswordDialog } from './change-password-dialog'; import { getAccountSecuritySettings, setAccountSecuritySettings, LOCK_TIME_OPTIONS, + useAuthStore, } from '../../../../services/store/src'; -/** 格式化日期为 YYYY-MM-DD */ -function formatJoinDate(isoDate?: string): string { - if (!isoDate) return '—'; - try { - const d = new Date(isoDate); - return d.toISOString().slice(0, 10); - } catch { - return '—'; - } -} - -/** 手机号脱敏,如 138****8888 */ -function maskPhone(phone?: string): string { - if (!phone) return '—'; - if (phone.length <= 4) return '****'; - return phone.slice(0, 3) + '****' + phone.slice(-4); -} - -/** 从名称获取首字母,如 "张三" -> "张","John Doe" -> "JD" */ -function getInitials(name: string): string { - if (!name || name === '—') return '?'; - const trimmed = name.trim(); - if (!trimmed) return '?'; - const parts = trimmed.split(/\s+/); - if (parts.length >= 2) { - return (parts[0][0] + parts[parts.length - 1][0]).toUpperCase().slice(0, 2); - } - return trimmed[0].toUpperCase(); -} - -/** - * 账户与安全面板 - */ +/** 本地用户资料与设备安全设置。 */ export const AccountPanel: React.FC = () => { const { t } = useTranslation('settings'); + const user = useAuthStore((state) => state.user); const [autoLockEnabled, setAutoLockEnabled] = useState(false); const [autoLockTime, setAutoLockTime] = useState(5); const [cleanDataOnExit, setCleanDataOnExit] = useState(false); const [securitySettingsLoaded, setSecuritySettingsLoaded] = useState(false); - const [user, setUser] = useState(null); - const [loading, setLoading] = useState(true); - const [editDialogOpen, setEditDialogOpen] = useState(false); - const [changePasswordDialogOpen, setChangePasswordDialogOpen] = useState(false); - - const fetchUser = useCallback(async () => { - setLoading(true); - try { - const data = await getCurrentUser(); - setUser(data); - } catch { - setUser(null); - } finally { - setLoading(false); - } - }, []); - - useEffect(() => { - void fetchUser(); - }, [fetchUser]); - useEffect(() => { let cancelled = false; - void getAccountSecuritySettings().then((s) => { + void getAccountSecuritySettings().then((settings) => { if (!cancelled) { - setAutoLockEnabled(s.autoLockEnabled); - setAutoLockTime(s.autoLockTime); - setCleanDataOnExit(s.cleanDataOnExit); + setAutoLockEnabled(settings.autoLockEnabled); + setAutoLockTime(settings.autoLockTime); + setCleanDataOnExit(settings.cleanDataOnExit); setSecuritySettingsLoaded(true); } }); @@ -113,9 +48,9 @@ export const AccountPanel: React.FC = () => { }, []); const handleAutoLockTimeChange = useCallback((value: string) => { - const num = Number(value); - setAutoLockTime(num); - void setAccountSecuritySettings({ autoLockTime: num }); + const minutes = Number(value); + setAutoLockTime(minutes); + void setAccountSecuritySettings({ autoLockTime: minutes }); }, []); const handleCleanDataOnExitChange = useCallback((checked: boolean) => { @@ -123,111 +58,32 @@ export const AccountPanel: React.FC = () => { void setAccountSecuritySettings({ cleanDataOnExit: checked }); }, []); - const avatarUrl = user?.avatar_url ?? ''; - const displayName = user?.nickname || user?.email?.split('@')[0] || '—'; - const displayEmail = user?.email || '—'; - const displayPhone = maskPhone(user?.phone); - const joinDate = formatJoinDate(user?.created_at); - const location = '中国'; - return (
- {/* 个人资料 */} - {loading ? ( - <> -
- -
- - -
- -
-
- {[1, 2, 3, 4].map((i) => ( -
- - -
- ))} -
- - ) : ( - <> -
-
- {avatarUrl ? ( - {t('accountProfile')} - ) : ( - - {getInitials(displayName)} - - )} -
-
-

{displayName}

-

{displayEmail}

-
- -
- -
- - - - - - - - - -
- - )} +
+
+ {user?.avatar || '🙂'} +
+
+

+ {user?.nickname || t('localUser')} +

+

{t('localAccountDesc')}

+
+
+
+ + + +
- - - - - {/* 安全设置 */} - - - { /> {autoLockEnabled && ( -
+
{t('lockTime')}
)} - + void; - onSuccess?: () => void; -} - -export function ChangePasswordDialog({ - open, - onOpenChange, - onSuccess, -}: ChangePasswordDialogProps) { - const { t } = useTranslation('settings'); - const [oldPassword, setOldPassword] = useState(''); - const [newPassword, setNewPassword] = useState(''); - const [confirmPassword, setConfirmPassword] = useState(''); - const [submitting, setSubmitting] = useState(false); - - useEffect(() => { - if (open) { - setOldPassword(''); - setNewPassword(''); - setConfirmPassword(''); - } - }, [open]); - - const validate = (): boolean => { - if (!oldPassword.trim()) { - toast.error(t('changePasswordOldRequired') || '请输入原密码'); - return false; - } - if (!newPassword.trim()) { - toast.error(t('changePasswordNewRequired') || '请输入新密码'); - return false; - } - if (newPassword.length < MIN_PASSWORD_LENGTH) { - toast.error( - t('changePasswordMinLength', { count: MIN_PASSWORD_LENGTH }) || - `密码至少需要${MIN_PASSWORD_LENGTH}个字符` - ); - return false; - } - if (newPassword !== confirmPassword) { - toast.error(t('changePasswordMismatch') || '两次输入的新密码不一致'); - return false; - } - if (oldPassword === newPassword) { - toast.error(t('changePasswordSameAsOld') || '新密码不能与原密码相同'); - return false; - } - return true; - }; - - const handleSubmit = async (e: React.FormEvent) => { - e.preventDefault(); - if (!validate()) return; - - setSubmitting(true); - try { - const result = await updatePassword({ - old_password: oldPassword, - new_password: newPassword, - }); - - if (result.ok) { - toast.success(t('changePasswordSuccess') || '密码修改成功'); - onOpenChange(false); - onSuccess?.(); - } else { - toast.error(result.message || t('changePasswordFailed') || '修改密码失败'); - } - } catch { - toast.error(t('changePasswordFailed') || '修改密码失败'); - } finally { - setSubmitting(false); - } - }; - - return ( - -
-
- -
- - setOldPassword(e.target.value)} - placeholder={t('changePasswordOldPlaceholder')} - className="pl-9 h-9 text-sm" - disabled={submitting} - /> -
-
-
- -
- - setNewPassword(e.target.value)} - placeholder={t('changePasswordNewPlaceholder')} - className="pl-9 h-9 text-sm" - disabled={submitting} - /> -
-
-
- -
- - setConfirmPassword(e.target.value)} - placeholder={t('changePasswordConfirmPlaceholder')} - className="pl-9 h-9 text-sm" - disabled={submitting} - /> -
-
-
- - - - - -
- ); -} diff --git a/plugins/pages/system-settings/src/components/edit-profile-dialog.tsx b/plugins/pages/system-settings/src/components/edit-profile-dialog.tsx deleted file mode 100644 index 05249d1..0000000 --- a/plugins/pages/system-settings/src/components/edit-profile-dialog.tsx +++ /dev/null @@ -1,241 +0,0 @@ -import { useState, useEffect } from 'react'; -import { useTranslation } from 'react-i18next'; -import { Loader2, User, X, Save } from 'lucide-react'; -import { FormattedDialog, FormattedDialogFooter } from '@/components/formatted-dialog'; -import { Button } from '@/components/ui/button'; -import { Label } from '@/components/ui/label'; -import { TextareaInput } from '@/components/textarea-input'; -import { toast } from 'sonner'; -import { updateCurrentUser, type CurrentUserResponse } from '../api/users'; - -interface EditProfileDialogProps { - open: boolean; - onOpenChange: (open: boolean) => void; - user: CurrentUserResponse | null; - onSuccess: () => void; -} - -/** 严格邮箱格式:本地部分 + @ + 域名 + 至少2位顶级域名 */ -const EMAIL_REGEX = - /^[a-zA-Z0-9._%+-]+@[a-zA-Z0-9](?:[a-zA-Z0-9-]*[a-zA-Z0-9])?(?:\.[a-zA-Z0-9](?:[a-zA-Z0-9-]*[a-zA-Z0-9])?)*\.[a-zA-Z]{2,}$/; -/** 中国大陆手机号:1 开头,第二位 3-9,共 11 位数字 */ -const PHONE_REGEX = /^1[3-9]\d{9}$/; - -function validateEmail(value: string): { valid: boolean; message?: string } { - const trimmed = value.trim(); - if (!trimmed) return { valid: false, message: 'editProfileEmailRequired' }; - if (trimmed.length > 254) return { valid: false, message: 'editProfileInvalidEmail' }; - if (!EMAIL_REGEX.test(trimmed)) return { valid: false, message: 'editProfileInvalidEmail' }; - return { valid: true }; -} - -function validatePhone(value: string): { valid: boolean; message?: string } { - const trimmed = value.trim(); - if (!trimmed) return { valid: true }; // 可选字段,空则通过 - const digits = trimmed.replace(/\D/g, ''); - if (digits.length !== 11 || !PHONE_REGEX.test(digits)) { - return { valid: false, message: 'editProfileInvalidPhone' }; - } - return { valid: true }; -} - -export function EditProfileDialog({ - open, - onOpenChange, - user, - onSuccess, -}: EditProfileDialogProps) { - const { t } = useTranslation('settings'); - const [nickname, setNickname] = useState(''); - const [phone, setPhone] = useState(''); - const [email, setEmail] = useState(''); - const [submitting, setSubmitting] = useState(false); - const [confirmOpen, setConfirmOpen] = useState(false); - - useEffect(() => { - if (open && user) { - setNickname(user.nickname ?? ''); - setPhone(user.phone ?? ''); - setEmail(user.email ?? ''); - } - }, [open, user]); - - const handleFormSubmit = (e: React.FormEvent) => { - e.preventDefault(); - if (!user) return; - - const emailResult = validateEmail(email); - if (!emailResult.valid) { - toast.error(t(emailResult.message!) || '请输入有效的邮箱地址'); - return; - } - - const phoneResult = validatePhone(phone); - if (!phoneResult.valid) { - toast.error(t(phoneResult.message!) || '请输入有效的手机号'); - return; - } - - setConfirmOpen(true); - }; - - const handleConfirmSave = async () => { - if (!user) return; - - const emailResult = validateEmail(email); - if (!emailResult.valid) { - toast.error(t(emailResult.message!) || '请输入有效的邮箱地址'); - return; - } - const phoneResult = validatePhone(phone); - if (!phoneResult.valid) { - toast.error(t(phoneResult.message!) || '请输入有效的手机号'); - return; - } - - setSubmitting(true); - setConfirmOpen(false); - try { - const trimmedEmail = email.trim(); - const result = await updateCurrentUser({ - nickname: nickname.trim() || undefined, - phone: phone.trim() ? phone.trim().replace(/\D/g, '') : undefined, - email: trimmedEmail || undefined, - }); - - if (result.ok) { - toast.success(t('editProfileSuccess') || '资料更新成功'); - onOpenChange(false); - onSuccess(); - } else { - toast.error(result.message || t('editProfileFailed') || '更新失败'); - } - } catch { - toast.error(t('editProfileFailed') || '更新失败'); - } finally { - setSubmitting(false); - } - }; - - return ( - <> - -
-
- - setNickname(e.target.value)} - placeholder={t('nicknamePlaceholder')} - className="text-sm min-h-9" - disabled={submitting} - /> -
-
- - setPhone(e.target.value)} - placeholder={t('phonePlaceholder')} - className="text-sm min-h-9" - disabled={submitting} - /> -
-
- - setEmail(e.target.value)} - placeholder={t('emailPlaceholder')} - className="text-sm min-h-9" - disabled={submitting} - /> -
-
- - - - - -
- - - - - - - - - ); -} diff --git a/plugins/pages/system-settings/src/i18n/resources.ts b/plugins/pages/system-settings/src/i18n/resources.ts index 63383d9..55f07e7 100644 --- a/plugins/pages/system-settings/src/i18n/resources.ts +++ b/plugins/pages/system-settings/src/i18n/resources.ts @@ -19,6 +19,11 @@ export const settingsResources = { // Account Panel accountProfile: '个人资料', accountSecurity: '安全设置', + localUser: '本地用户', + localAccountDesc: '资料仅保存在当前设备', + localPassword: '本地密码', + passwordProtected: '已设置密码保护', + passwordlessAccount: '未设置密码,可直接进入', clickToChangeAvatar: '点击更换头像', editProfile: '编辑资料', editProfileDesc: '修改您的个人资料信息', @@ -226,6 +231,11 @@ export const settingsResources = { // Account Panel accountProfile: 'Profile', accountSecurity: 'Security', + localUser: 'Local User', + localAccountDesc: 'Profile data is stored only on this device', + localPassword: 'Local Password', + passwordProtected: 'Password protected', + passwordlessAccount: 'No password; direct entry enabled', clickToChangeAvatar: 'Click to change avatar', editProfile: 'Edit Profile', editProfileDesc: 'Update your profile information', diff --git a/plugins/pages/team/src/api/index.ts b/plugins/pages/team/src/api/index.ts index 6f01cc2..6d8a529 100644 --- a/plugins/pages/team/src/api/index.ts +++ b/plugins/pages/team/src/api/index.ts @@ -4,7 +4,7 @@ import { post, isSuccess } from '@/lib/request'; import type { ListTeamMembersRequest, - InviteMemberRequest, + AddMemberRequest, UpdateMemberRoleRequest, RemoveMemberRequest, BatchRemoveMembersRequest, @@ -12,17 +12,12 @@ import type { GetTeamRequest, CreateTeamRequest, UpdateTeamRequest, - CancelInviteRequest, - AcceptInvitationRequest, - RejectInvitationRequest, InviteResponse, TeamMemberListResponse, TeamMemberDto, TeamDto, TeamListResponse, - TeamInvitationDto, - CreateResponse, - AcceptInvitationResponse, + CreateResponse, TeamMember, } from './index.types'; @@ -33,7 +28,7 @@ export * from './index.types'; export const API_ENDPOINTS = { // 团队成员 LIST_TEAM_MEMBERS: 'teams/members', - INVITE_MEMBER: 'teams/invite', + ADD_MEMBER: 'teams/member/add', UPDATE_MEMBER_ROLE: 'teams/member/role', REMOVE_MEMBER: 'teams/member/remove', @@ -45,11 +40,6 @@ export const API_ENDPOINTS = { SWITCH_TEAM: 'teams/switch', LEAVE_TEAM: 'teams/leave', - // 邀请管理 - GET_PENDING_INVITATIONS: 'teams/invitations', - CANCEL_INVITATION: 'teams/invitation/cancel', - ACCEPT_INVITATION: 'teams/invitation/accept', - REJECT_INVITATION: 'teams/invitation/reject', } as const; // ============ 数据转换 ============ @@ -63,7 +53,6 @@ function transformTeamMemberDto(dto: TeamMemberDto): TeamMember { return { id: memberId, name: dto.name || '', - email: dto.email || '', avatar: dto.avatar, role: (dto.role as TeamMember['role']) || 'viewer', status: (dto.status as TeamMember['status']) || 'active', @@ -109,8 +98,8 @@ export async function listTeamMembers( /** * 邀请成员 */ -export async function inviteMember(request: InviteMemberRequest): Promise { - const result = await post(API_ENDPOINTS.INVITE_MEMBER, request); +export async function addMember(request: AddMemberRequest): Promise { + const result = await post(API_ENDPOINTS.ADD_MEMBER, request); if (!isSuccess(result)) { throw new Error(result.message || '邀请成员失败'); } @@ -207,53 +196,9 @@ export async function switchTeam(teamUuid: string): Promise { /** * 退出团队 */ -export async function leaveTeam(): Promise { +export async function leaveTeam(): Promise { const result = await post(API_ENDPOINTS.LEAVE_TEAM, {}); if (!isSuccess(result)) { throw new Error(result.message || '退出团队失败'); - } -} - -// ============ 邀请管理 API ============ - -/** - * 获取待处理的邀请列表 - */ -export async function getPendingInvitations(): Promise { - const result = await post(API_ENDPOINTS.GET_PENDING_INVITATIONS, {}); - if (!isSuccess(result)) { - throw new Error(result.message || '获取邀请列表失败'); - } - return result.data! || []; -} - -/** - * 取消邀请 - */ -export async function cancelInvitation(request: CancelInviteRequest): Promise { - const result = await post(API_ENDPOINTS.CANCEL_INVITATION, request); - if (!isSuccess(result)) { - throw new Error(result.message || '取消邀请失败'); - } -} - -/** - * 接受邀请 - */ -export async function acceptInvitation(request: AcceptInvitationRequest): Promise { - const result = await post(API_ENDPOINTS.ACCEPT_INVITATION, request); - if (!isSuccess(result)) { - throw new Error(result.message || '接受邀请失败'); - } - return result.data!.team_uuid; -} - -/** - * 拒绝邀请 - */ -export async function rejectInvitation(request: RejectInvitationRequest): Promise { - const result = await post(API_ENDPOINTS.REJECT_INVITATION, request); - if (!isSuccess(result)) { - throw new Error(result.message || '拒绝邀请失败'); - } -} + } +} diff --git a/plugins/pages/team/src/api/index.types.ts b/plugins/pages/team/src/api/index.types.ts index 9453107..4cde7d6 100644 --- a/plugins/pages/team/src/api/index.types.ts +++ b/plugins/pages/team/src/api/index.types.ts @@ -18,13 +18,13 @@ export interface ListTeamMembersRequest { }; } -export interface InviteMemberRequest { - email: string; +export interface AddMemberRequest { + user_uuid: string; role: string; } -export interface InviteResponse { - invitation_uuid: string; +export interface InviteResponse { + member_uuid: string; } export interface UpdateMemberRoleRequest { @@ -61,22 +61,10 @@ export interface UpdateTeamRequest { avatar_hash?: string; } -export interface CancelInviteRequest { - invitation_uuid: string; -} - -export interface AcceptInvitationRequest { - token: string; -} - -export interface RejectInvitationRequest { - token: string; -} - // ============ 响应类型 ============ -export interface TeamMemberListResponse { - items: TeamMemberDto[]; +export interface TeamMemberListResponse { + items: TeamMember[]; total: number; page: number; page_size: number; @@ -132,25 +120,6 @@ export interface TeamListResponse { teams: TeamItem[]; } -export interface TeamInvitationDto { - id: number; - uuid: string; - team_uuid: string; - email: string; - role: string; - invited_by: string; - token: string; - expires_at: string; - status: string; - accepted_at?: string; - created_at: string; - updated_at: string; -} - -export interface CreateResponse { - uuid: string; -} - -export interface AcceptInvitationResponse { - team_uuid: string; -} +export interface CreateResponse { + uuid: string; +} diff --git a/plugins/pages/team/src/components/team-invite-dialog.tsx b/plugins/pages/team/src/components/team-invite-dialog.tsx index 26a6ae8..553488f 100644 --- a/plugins/pages/team/src/components/team-invite-dialog.tsx +++ b/plugins/pages/team/src/components/team-invite-dialog.tsx @@ -1,214 +1,129 @@ -import { useState, useEffect, useRef } from 'react'; -import { useTranslation } from 'react-i18next'; -import { Loader2, UserPlus, Mail, X } from 'lucide-react'; -import { FormattedDialog, FormattedDialogFooter } from '@/components/formatted-dialog'; -import { Button } from '@/components/ui/button'; -import { Label } from '@/components/ui/label'; -import { TextareaInput } from '@/components/textarea-input'; -import { - Select, - SelectContent, - SelectItem, - SelectTrigger, - SelectValue, -} from '@/components/ui/select'; -import { useTeamDialogStore } from '../stores'; -import type { TeamMember } from '../types'; -import { cn } from '@/lib/utils'; - -interface TeamInviteDialogProps { - open: boolean; - email: string; - role: TeamMember['role']; - submitting: boolean; - onOpenChange: (open: boolean) => void; - onEmailChange: (email: string) => void; - onRoleChange: (role: TeamMember['role']) => void; - onSubmit: () => void; -} - -// 邮箱格式验证(要求后缀必须是小写,如 .com, .org, .net 等) -function isValidEmail(email: string): boolean { - // 正则表达式要求: - // - 用户名部分:一个或多个非空白、非@字符 - // - @ 符号 - // - 域名部分:一个或多个非空白、非@字符,至少包含一个点 - // - TLD(顶级域名):必须是小写字母,至少2个字符 - const emailRegex = /^[^\s@]+@[^\s@]+\.[a-z]{2,}$/; - return emailRegex.test(email); -} - -/** - * 邀请成员对话框组件 - */ -export const TeamInviteDialog: React.FC = ({ - open, - email, - role, - submitting, - onOpenChange, - onEmailChange, - onRoleChange, - onSubmit, -}) => { - const { t } = useTranslation('team'); - const dialogStore = useTeamDialogStore(); - const [emailError, setEmailError] = useState(null); - const [touched, setTouched] = useState(false); - const emailInputRef = useRef(null); - - // 当对话框打开时,聚焦邮箱输入框 - useEffect(() => { - if (open && emailInputRef.current) { - // 延迟聚焦,确保对话框动画完成 - setTimeout(() => { - emailInputRef.current?.focus(); - }, 100); - } - }, [open]); - - // 邮箱验证 - useEffect(() => { - if (touched && email) { - if (!isValidEmail(email)) { - setEmailError('请输入有效的邮箱地址'); - } else { - setEmailError(null); - } - } else { - setEmailError(null); - } - }, [email, touched]); - - const handleEmailChange = (value: string) => { - onEmailChange(value); - if (!touched) { - setTouched(true); - } - }; - - const handleSubmit = () => { - if (!email.trim()) { - setTouched(true); - setEmailError('请输入邮箱地址'); - emailInputRef.current?.focus(); - return; - } - if (!isValidEmail(email)) { - setTouched(true); - setEmailError('请输入有效的邮箱地址'); - emailInputRef.current?.focus(); - return; - } - onSubmit(); - }; - - const handleClose = (open: boolean) => { - onOpenChange(open); - if (!open) { - dialogStore.closeInviteDialog(); - // 重置状态 - setTouched(false); - setEmailError(null); - } - }; - - return ( - -
- {/* 邮箱输入 */} -
- -
- - handleEmailChange(e.target.value)} - placeholder={t('dialog.invite.emailPlaceholder') || 'user@example.com'} - aria-invalid={!!emailError} - className={cn( - 'py-[2px]', - emailError ? 'pl-9 border-destructive focus-visible:ring-destructive/50' : 'pl-9' - )} - disabled={submitting} - onKeyDown={(e) => { - if (e.key === 'Enter' && !emailError && email.trim() && !submitting) { - // TextareaInput 是 textarea,默认 Enter 会换行;这里阻止默认行为并提交 - e.preventDefault(); - handleSubmit(); - } - }} - /> -
- {emailError &&

{emailError}

} -
- - {/* 角色选择 */} -
- - -
-
- - - - - -
- ); -}; +import { useEffect, useState } from 'react'; +import { useTranslation } from 'react-i18next'; +import { Loader2, UserPlus, X } from 'lucide-react'; +import { FormattedDialog, FormattedDialogFooter } from '@/components/formatted-dialog'; +import { Button } from '@/components/ui/button'; +import { Label } from '@/components/ui/label'; +import { + Select, + SelectContent, + SelectItem, + SelectTrigger, + SelectValue, +} from '@/components/ui/select'; +import { invoke } from '@/lib/tauri'; +import { useAuthStore } from '../../../../services/store/src'; +import { useTeamDialogStore } from '../stores'; +import type { TeamMember } from '../types'; + +interface LocalUserOption { + uuid: string; + nickname: string; + avatar: string; +} + +interface TeamInviteDialogProps { + open: boolean; + userUuid: string; + role: TeamMember['role']; + submitting: boolean; + onOpenChange: (open: boolean) => void; + onUserChange: (userUuid: string) => void; + onRoleChange: (role: TeamMember['role']) => void; + onSubmit: () => void; +} + +export const TeamInviteDialog: React.FC = ({ + open, + userUuid, + role, + submitting, + onOpenChange, + onUserChange, + onRoleChange, + onSubmit, +}) => { + const { t } = useTranslation('team'); + const currentUserUuid = useAuthStore((state) => state.user?.uuid); + const dialogStore = useTeamDialogStore(); + const [users, setUsers] = useState([]); + const [loadingUsers, setLoadingUsers] = useState(false); + + useEffect(() => { + if (!open) return; + setLoadingUsers(true); + invoke('list_local_users') + .then((items) => setUsers(items.filter((user) => user.uuid !== currentUserUuid))) + .finally(() => setLoadingUsers(false)); + }, [currentUserUuid, open]); + + const handleClose = (nextOpen: boolean) => { + onOpenChange(nextOpen); + if (!nextOpen) dialogStore.closeInviteDialog(); + }; + + return ( + +
+
+ + +
+ +
+ + +
+
+ + + + + +
+ ); +}; diff --git a/plugins/pages/team/src/components/team-table-row.tsx b/plugins/pages/team/src/components/team-table-row.tsx index 75ecacb..3a1a54f 100644 --- a/plugins/pages/team/src/components/team-table-row.tsx +++ b/plugins/pages/team/src/components/team-table-row.tsx @@ -1,5 +1,5 @@ import { useTranslation } from 'react-i18next'; -import { Crown, Shield, Pencil, Eye, MoreVertical, Trash2, Mail } from 'lucide-react'; +import { Crown, Shield, Pencil, Eye, MoreVertical, Trash2 } from 'lucide-react'; import { DropdownMenu, DropdownMenuContent, @@ -20,7 +20,6 @@ import { export interface TeamMember { id: string; name: string; - email: string; avatar?: string; role: 'owner' | 'admin' | 'editor' | 'viewer'; status: 'active' | 'pending' | 'inactive'; @@ -120,7 +119,7 @@ export function TeamTableRow({ } }; - const initials = member.name.split('').slice(0, 2).join('').toUpperCase(); + const initials = member.name.split('').slice(0, 2).join('').toUpperCase(); const roleOptions: { value: TeamMember['role']; icon: React.ReactNode }[] = [ { value: 'admin', icon: }, @@ -141,14 +140,10 @@ export function TeamTableRow({
- {initials} + {member.avatar || initials}
{member.name}
-
- - {member.email} -
diff --git a/plugins/pages/team/src/hooks/use-team-handlers.ts b/plugins/pages/team/src/hooks/use-team-handlers.ts index edb0588..890058a 100644 --- a/plugins/pages/team/src/hooks/use-team-handlers.ts +++ b/plugins/pages/team/src/hooks/use-team-handlers.ts @@ -14,23 +14,23 @@ interface UseTeamHandlersParams { export function useTeamHandlers({ operations, onRefresh }: UseTeamHandlersParams) { const dialogStore = useTeamDialogStore(); - // 邀请成员 + // 添加本地成员 const handleInvite = () => { dialogStore.openInviteDialog(); }; const handleSubmitInvite = async () => { - if (!dialogStore.inviteEmail.trim()) { - toast.warning('请输入邮箱地址'); + if (!dialogStore.inviteUserUuid) { + toast.warning('请选择本地用户'); return; } try { - await operations.inviteMember(dialogStore.inviteEmail, dialogStore.inviteRole); + await operations.addMember(dialogStore.inviteUserUuid, dialogStore.inviteRole); dialogStore.closeInviteDialog(); - toast.success('邀请已发送'); + toast.success('本地用户已加入团队'); await onRefresh(); } catch (e) { - toast.error(e instanceof Error ? e.message : '邀请成员失败'); + toast.error(e instanceof Error ? e.message : '添加成员失败'); } }; diff --git a/plugins/pages/team/src/hooks/use-team-operations.ts b/plugins/pages/team/src/hooks/use-team-operations.ts index 79b632c..092f63f 100644 --- a/plugins/pages/team/src/hooks/use-team-operations.ts +++ b/plugins/pages/team/src/hooks/use-team-operations.ts @@ -1,10 +1,10 @@ import { useState } from 'react'; -import { inviteMember, updateMemberRole, removeMember, batchRemoveMembers } from '../api'; +import { addMember as addLocalMember, updateMemberRole, removeMember, batchRemoveMembers } from '../api'; import type { TeamMember } from '../types'; export interface UseTeamOperationsReturn { submitting: boolean; - inviteMember: (email: string, role: TeamMember['role']) => Promise<{ invitationUuid: string }>; + addMember: (userUuid: string, role: TeamMember['role']) => Promise<{ memberUuid: string }>; deleteMember: (memberUuid: string) => Promise; batchDeleteMembers: (memberUuids: string[]) => Promise; changeMemberRole: (memberUuid: string, newRole: TeamMember['role']) => Promise; @@ -16,15 +16,14 @@ export interface UseTeamOperationsReturn { export function useTeamOperations(): UseTeamOperationsReturn { const [submitting, setSubmitting] = useState(false); - // 邀请成员(单个) - const inviteMemberOp = async (email: string, role: TeamMember['role']) => { - if (!email.trim()) { - throw new Error('请输入邮箱地址'); + const addMember = async (userUuid: string, role: TeamMember['role']) => { + if (!userUuid) { + throw new Error('请选择本地用户'); } setSubmitting(true); try { - const res = await inviteMember({ email, role }); - return { invitationUuid: res.invitation_uuid }; + const res = await addLocalMember({ user_uuid: userUuid, role }); + return { memberUuid: res.member_uuid }; } finally { setSubmitting(false); } @@ -62,7 +61,7 @@ export function useTeamOperations(): UseTeamOperationsReturn { return { submitting, - inviteMember: inviteMemberOp, + addMember, deleteMember, batchDeleteMembers, changeMemberRole, diff --git a/plugins/pages/team/src/i18n/resources.ts b/plugins/pages/team/src/i18n/resources.ts index c27a52b..af8a5f6 100644 --- a/plugins/pages/team/src/i18n/resources.ts +++ b/plugins/pages/team/src/i18n/resources.ts @@ -62,15 +62,17 @@ export const teamResources = { }, dialog: { invite: { - title: '邀请团队成员', - description: '发送邀请邮件给新成员', - email: '邮箱地址', - emailPlaceholder: 'user@example.com', + title: '添加团队成员', + description: '从本机用户中选择一名成员', + localDescription: '选择已在本机创建的用户并设置其团队角色', + localUser: '本地用户', + localUserPlaceholder: '请选择本地用户', role: '角色', rolePlaceholder: '选择角色', cancel: '取消', - submit: '发送邀请', - submitting: '发送中...', + addLocalUser: '加入团队', + submit: '加入团队', + submitting: '添加中...', }, delete: { title: '确认移除成员', @@ -183,15 +185,17 @@ export const teamResources = { }, dialog: { invite: { - title: 'Invite Team Member', - description: 'Send an invitation email to new member', - email: 'Email Address', - emailPlaceholder: 'user@example.com', + title: 'Add Team Member', + description: 'Choose a member from local users', + localDescription: 'Select a user created on this device and assign a team role', + localUser: 'Local User', + localUserPlaceholder: 'Select a local user', role: 'Role', rolePlaceholder: 'Select role', cancel: 'Cancel', - submit: 'Send Invitation', - submitting: 'Sending...', + addLocalUser: 'Add to Team', + submit: 'Add to Team', + submitting: 'Adding...', }, delete: { title: 'Confirm Remove Member', diff --git a/plugins/pages/team/src/index.tsx b/plugins/pages/team/src/index.tsx index c19ca2e..969647f 100644 --- a/plugins/pages/team/src/index.tsx +++ b/plugins/pages/team/src/index.tsx @@ -89,11 +89,11 @@ const TeamPage: React.FC = () => { {/* 邀请成员对话框 */} diff --git a/plugins/pages/team/src/stores/dialogs/dialog-store.ts b/plugins/pages/team/src/stores/dialogs/dialog-store.ts index a9ebdac..f6f06e9 100644 --- a/plugins/pages/team/src/stores/dialogs/dialog-store.ts +++ b/plugins/pages/team/src/stores/dialogs/dialog-store.ts @@ -4,7 +4,7 @@ import type { TeamMember, Team } from '../../types'; interface DialogState { // 邀请成员对话框 inviteDialogOpen: boolean; - inviteEmail: string; + inviteUserUuid: string; inviteRole: TeamMember['role']; // 删除成员对话框 @@ -28,7 +28,7 @@ interface DialogState { interface DialogActions { // 邀请成员对话框 setInviteDialogOpen: (open: boolean) => void; - setInviteEmail: (email: string) => void; + setInviteUserUuid: (userUuid: string) => void; setInviteRole: (role: TeamMember['role']) => void; openInviteDialog: () => void; closeInviteDialog: () => void; @@ -66,7 +66,7 @@ interface DialogActions { export const useTeamDialogStore = create((set) => ({ // 初始状态 inviteDialogOpen: false, - inviteEmail: '', + inviteUserUuid: '', inviteRole: 'viewer', deleteDialogOpen: false, deletingMember: null, @@ -80,10 +80,10 @@ export const useTeamDialogStore = create((set) => ( // 邀请成员对话框 setInviteDialogOpen: (open) => set({ inviteDialogOpen: open }), - setInviteEmail: (email) => set({ inviteEmail: email }), + setInviteUserUuid: (userUuid) => set({ inviteUserUuid: userUuid }), setInviteRole: (role) => set({ inviteRole: role }), - openInviteDialog: () => set({ inviteDialogOpen: true, inviteEmail: '', inviteRole: 'viewer' }), - closeInviteDialog: () => set({ inviteDialogOpen: false, inviteEmail: '', inviteRole: 'viewer' }), + openInviteDialog: () => set({ inviteDialogOpen: true, inviteUserUuid: '', inviteRole: 'viewer' }), + closeInviteDialog: () => set({ inviteDialogOpen: false, inviteUserUuid: '', inviteRole: 'viewer' }), // 删除成员对话框 setDeleteDialogOpen: (open) => set({ deleteDialogOpen: open }), diff --git a/plugins/services/environment/src/types.ts b/plugins/services/environment/src/types.ts index 78da344..6d30873 100644 --- a/plugins/services/environment/src/types.ts +++ b/plugins/services/environment/src/types.ts @@ -1,6 +1,6 @@ export interface BrowserKernelVersion { - id: number; - type_id: number; + kernel_id: string; + type_code: string; resource_name: string; version: string; name?: string; @@ -16,6 +16,7 @@ extract_root?: string; status?: string; is_latest?: boolean; + installed?: boolean; } export interface ProxyPassword { diff --git a/plugins/services/store/src/stores/auth/auth-store.ts b/plugins/services/store/src/stores/auth/auth-store.ts index 2a8f85f..d3a73a5 100644 --- a/plugins/services/store/src/stores/auth/auth-store.ts +++ b/plugins/services/store/src/stores/auth/auth-store.ts @@ -1,115 +1,86 @@ -import { create } from 'zustand'; -import type { User } from '../../types/store.types'; -import type { AuthActions, AuthState } from './auth-store.types'; -import { - clearRememberedCredentialSafely, - ensureStateConsistency, - tryAutoLogin, -} from './auth-store.utils'; - -/** - * Auth Store - * 专门管理用户认证相关的状态 - */ -export const useAuthStore = create((set, get) => ({ - // 初始状态 - user: null, - isAuthenticated: false, - isInitializing: false, - currentWorkspaceUuid: null, - currentTeamUuid: null, - - // Actions - setUser: (user: User) => - set({ - user, - isAuthenticated: true, - currentWorkspaceUuid: user.current_workspace_uuid || null, - currentTeamUuid: user.current_team_uuid || null, - }), - - clearUser: () => - set({ - user: null, - isAuthenticated: false, - currentWorkspaceUuid: null, - currentTeamUuid: null, - }), - - setCurrentWorkspace: (workspaceUuid: string | null) => - set({ - currentWorkspaceUuid: workspaceUuid, - user: get().user - ? { - ...get().user!, - current_workspace_uuid: workspaceUuid, - } - : null, - }), - - setCurrentTeam: (teamUuid: string | null) => - set({ - currentTeamUuid: teamUuid, - user: get().user - ? { - ...get().user!, - current_team_uuid: teamUuid, - } - : null, - }), - - initAuth: async () => { - // 如果正在初始化,直接返回 - if (get().isInitializing) { - return; - } - - // 如果 Store 中已有用户信息,说明已经登录,不需要做任何事 - if (get().user) { - return; - } - - set({ isInitializing: true }); - - try { - // 动态导入 invoke,避免在非 Tauri 环境中报错 - const { invoke } = await import('@tauri-apps/api/core'); - - // 尝试使用记住的凭证自动登录 - const rememberedCredential = (await invoke('get_remembered_credential')) as - | [string, string] - | null; - - if (rememberedCredential) { - const [email, refreshToken] = rememberedCredential; - const autoLoginSuccess = await tryAutoLogin(invoke, email, refreshToken, set); - - if (autoLoginSuccess) { - return; // 自动登录成功,直接返回 - } - - // 自动登录失败(可能是 refresh_token 过期),清除保存的凭证 - console.warn('[AuthStore] 自动登录失败,清除保存的凭证'); - await clearRememberedCredentialSafely(invoke); - } - - // 检查并修复状态一致性 - await ensureStateConsistency(invoke); - - // 确保状态一致:无用户信息 = 未登录 - set({ - user: null, - isAuthenticated: false, - isInitializing: false, - }); - } catch (error) { - console.error('[AuthStore] 初始化认证状态失败:', error); - // 初始化失败,确保状态一致 - set({ - user: null, - isAuthenticated: false, - isInitializing: false, - }); - } - }, -})); +import { create } from 'zustand'; +import type { User } from '../../types/store.types'; +import type { AuthActions, AuthState } from './auth-store.types'; + +interface LocalUserResponse { + uuid: string; + nickname: string; + avatar: string; + hasPassword: boolean; + currentWorkspaceUuid?: string | null; + currentTeamUuid?: string | null; +} + +function mapLocalUser(user: LocalUserResponse): User { + return { + uuid: user.uuid, + id: user.uuid, + nickname: user.nickname, + avatar: user.avatar, + has_password: user.hasPassword, + status: 'active', + current_workspace_uuid: user.currentWorkspaceUuid ?? null, + current_team_uuid: user.currentTeamUuid ?? null, + }; +} + +export const useAuthStore = create((set, get) => ({ + user: null, + isAuthenticated: false, + isInitializing: false, + currentWorkspaceUuid: null, + currentTeamUuid: null, + + setUser: (user: User) => + set({ + user, + isAuthenticated: true, + currentWorkspaceUuid: user.current_workspace_uuid || null, + currentTeamUuid: user.current_team_uuid || null, + }), + + clearUser: () => + set({ + user: null, + isAuthenticated: false, + currentWorkspaceUuid: null, + currentTeamUuid: null, + }), + + setCurrentWorkspace: (workspaceUuid: string | null) => + set({ + currentWorkspaceUuid: workspaceUuid, + user: get().user + ? { ...get().user!, current_workspace_uuid: workspaceUuid } + : null, + }), + + setCurrentTeam: (teamUuid: string | null) => + set({ + currentTeamUuid: teamUuid, + user: get().user ? { ...get().user!, current_team_uuid: teamUuid } : null, + }), + + initAuth: async () => { + if (get().isInitializing || get().user) return; + set({ isInitializing: true }); + try { + const { invoke } = await import('@tauri-apps/api/core'); + const currentUser = await invoke('get_current_local_user'); + if (currentUser) { + const user = mapLocalUser(currentUser); + set({ + user, + isAuthenticated: true, + isInitializing: false, + currentWorkspaceUuid: user.current_workspace_uuid || null, + currentTeamUuid: user.current_team_uuid || null, + }); + return; + } + } catch (error) { + console.error('[AuthStore] 初始化本地用户会话失败:', error); + } + set({ user: null, isAuthenticated: false, isInitializing: false }); + }, +})); diff --git a/plugins/services/store/src/stores/auth/auth-store.types.ts b/plugins/services/store/src/stores/auth/auth-store.types.ts index e0e6f91..58cf355 100644 --- a/plugins/services/store/src/stores/auth/auth-store.types.ts +++ b/plugins/services/store/src/stores/auth/auth-store.types.ts @@ -29,38 +29,5 @@ export interface AuthActions { /** 设置当前工作空间 */ setCurrentWorkspace: (workspaceUuid: string | null) => void; /** 设置当前团队 */ - setCurrentTeam: (teamUuid: string | null) => void; -} - -/** - * 登录响应数据结构 - */ -export interface LoginResponse { - access_token: string; - refresh_token: string; - user_info?: { - uuid?: string; - id?: string; - nickname?: string; - email?: string; - phone?: string; - avatar_hash?: string; - status?: string; - }; -} - -/** - * Tauri invoke 函数类型 - */ - -export type TauriInvoke = ( - cmd: string, - // eslint-disable-next-line @typescript-eslint/no-explicit-any - args?: Record - // eslint-disable-next-line @typescript-eslint/no-explicit-any -) => Promise; - -/** - * Store setter 函数类型 - */ -export type AuthStateSetter = (state: Partial) => void; + setCurrentTeam: (teamUuid: string | null) => void; +} diff --git a/plugins/services/store/src/stores/auth/auth-store.utils.ts b/plugins/services/store/src/stores/auth/auth-store.utils.ts deleted file mode 100644 index 2be4fe1..0000000 --- a/plugins/services/store/src/stores/auth/auth-store.utils.ts +++ /dev/null @@ -1,116 +0,0 @@ -import type { User } from '../../types/store.types'; -import type { AuthStateSetter, LoginResponse, TauriInvoke } from './auth-store.types'; - -/** - * 将服务端用户信息转换为前端 User 类型 - */ -export function mapUserInfo(userInfo: LoginResponse['user_info'], fallbackEmail: string): User { - return { - uuid: userInfo?.uuid || '', - id: userInfo?.id || '', - nickname: userInfo?.nickname, - email: userInfo?.email || fallbackEmail, - phone: userInfo?.phone, - avatar: userInfo?.avatar_hash, - status: userInfo?.status || 'active', - }; -} - -/** - * 清除记住的凭证(静默处理错误) - */ -export async function clearRememberedCredentialSafely(invoke: TauriInvoke): Promise { - try { - await invoke('clear_remembered_credential'); - } catch (error) { - console.warn('[AuthStore] 清除记住的凭证失败:', error); - } -} - -/** - * 更新保存的 refresh_token(如果服务端返回了新的) - */ -export async function updateRememberedCredentialIfNeeded( - invoke: TauriInvoke, - email: string, - oldToken: string, - newToken: string -): Promise { - if (newToken && newToken !== oldToken) { - try { - await invoke('save_remembered_credential', { - email: email, - refreshToken: newToken, - }); - } catch (error) { - console.warn('[AuthStore] 更新记住的凭证失败:', error); - } - } -} - -/** - * 尝试使用记住的凭证自动登录 - */ -export async function tryAutoLogin( - invoke: TauriInvoke, - email: string, - refreshToken: string, - set: AuthStateSetter -): Promise { - try { - const result = (await invoke('login', { - payload: { - type: 'remember_password', - data: { - email: email, - refresh_token: refreshToken, - }, - }, - })) as { code: number; message: string; data?: LoginResponse }; - - if (result.code !== 1 || !result.data) { - return false; - } - - const loginResponse = result.data; - - // 更新保存的 refresh_token(如果服务端返回了新的) - await updateRememberedCredentialIfNeeded( - invoke, - email, - refreshToken, - loginResponse.refresh_token - ); - - // 更新 Store 中的用户状态 - if (loginResponse.user_info) { - set({ - user: mapUserInfo(loginResponse.user_info, email), - isAuthenticated: true, - isInitializing: false, - }); - return true; - } - - return false; - } catch (error) { - console.error('[AuthStore] 自动登录失败:', error); - return false; - } -} - -/** - * 检查并修复状态一致性 - */ -export async function ensureStateConsistency(invoke: TauriInvoke): Promise { - try { - const isLoggedIn = (await invoke('is_logged_in')) as boolean; - - if (isLoggedIn) { - console.warn('[AuthStore] 检测到状态不一致:Tauri 有凭证但 Store 无用户信息,清除凭证'); - await invoke('logout'); - } - } catch (error) { - console.warn('[AuthStore] 检查状态一致性失败:', error); - } -} diff --git a/plugins/services/store/src/stores/auth/index.ts b/plugins/services/store/src/stores/auth/index.ts index a87aa39..4ce263b 100644 --- a/plugins/services/store/src/stores/auth/index.ts +++ b/plugins/services/store/src/stores/auth/index.ts @@ -2,10 +2,7 @@ * Auth Store 统一导出 */ export { useAuthStore } from './auth-store'; -export type { - AuthActions, - AuthState, - LoginResponse, - TauriInvoke, - AuthStateSetter, -} from './auth-store.types'; +export type { + AuthActions, + AuthState, +} from './auth-store.types'; diff --git a/plugins/services/store/src/types/store.types.ts b/plugins/services/store/src/types/store.types.ts index 91e5cf1..cada0a7 100644 --- a/plugins/services/store/src/types/store.types.ts +++ b/plugins/services/store/src/types/store.types.ts @@ -5,9 +5,8 @@ export interface User { uuid: string; id: string; nickname?: string; - email: string; - phone?: string; - avatar?: string; // 从 avatar_hash 映射 + avatar?: string; + has_password?: boolean; status: string; current_workspace_uuid?: string | null; // 当前工作空间 UUID current_team_uuid?: string | null; // 当前团队 UUID diff --git a/plugins/services/window-manager/src/index.tsx b/plugins/services/window-manager/src/index.tsx index bd706a7..89995a5 100644 --- a/plugins/services/window-manager/src/index.tsx +++ b/plugins/services/window-manager/src/index.tsx @@ -1,161 +1,16 @@ -import { useEffect, useRef, useState } from 'react'; -import { getCurrentWindow } from '@tauri-apps/api/window'; -import { invoke } from '@/lib/tauri'; -import { listen } from '@tauri-apps/api/event'; - -/** - * 窗口管理服务插件 - * 负责在主窗口内容渲染完成后显示窗口 - */ -const WindowManagerService: React.FC = () => { - const hasShownWindow = useRef(false); - const [isLoadingComplete, setIsLoadingComplete] = useState(false); - - // 监听后端加载完成事件 - useEffect(() => { - let unsubscribeLoadingComplete: (() => void) | null = null; - - const setupListener = async () => { - try { - unsubscribeLoadingComplete = await listen('splashscreen-loading-complete', () => { - setIsLoadingComplete(true); - }); - } catch (error) { - console.error( - '[WindowManagerService] Failed to register loading complete listener:', - error - ); - } - }; - - setupListener(); - - return () => { - unsubscribeLoadingComplete?.(); - }; - }, []); - - useEffect(() => { - let animationFrameId: number; - let checkCount = 0; - const MAX_CHECKS = 500; // 最多检查 500 次(约 8 秒) - - const checkAndShowWindow = async () => { - checkCount++; - - // 如果已经显示过窗口,不再重复显示 - if (hasShownWindow.current) { - return; - } - - // 如果检查次数过多,直接显示窗口(避免无限等待) - if (checkCount > MAX_CHECKS) { - console.warn('[WindowManagerService] 检查超时,强制关闭加载窗口并显示主窗口'); - hasShownWindow.current = true; - try { - await invoke('complete_and_show_main'); - console.log('[WindowManagerService] 加载窗口已关闭,主窗口已强制显示'); - } catch (error) { - console.error('[WindowManagerService] 强制显示窗口失败:', error); - hasShownWindow.current = false; - } - return; - } - - const currentWindow = getCurrentWindow(); - - // 只处理主窗口 - if (!currentWindow.label.includes('main')) { - // 不是主窗口,停止检查 - return; - } - - // 检查主窗口的 DOM 内容是否已渲染 - const rootElement = document.getElementById('root'); - const appElement = rootElement?.querySelector('.app') as HTMLElement | null; - const isContentReady = - rootElement && - rootElement.children.length > 0 && - rootElement.offsetHeight > 0 && - rootElement.offsetWidth > 0 && - appElement && - appElement.children.length > 0 && - appElement.offsetHeight > 0; - - // 加载完成且内容已准备好,通知后端关闭加载窗口并显示主窗口 - if (isLoadingComplete && isContentReady) { - // 内容已渲染,使用双重 RAF + setTimeout 确保浏览器完成渲染后再显示窗口 - hasShownWindow.current = true; - - requestAnimationFrame(() => { - requestAnimationFrame(async () => { - // 额外的延迟确保所有样式、布局都已完成 - setTimeout(async () => { - try { - // 调用后端命令关闭加载窗口并显示主窗口 - await invoke('complete_and_show_main'); - console.log( - '[WindowManagerService] 关闭加载窗口并显示主窗口请求已发送 (检查次数:', - checkCount, - ')' - ); - } catch (error) { - console.error('[WindowManagerService] 显示窗口失败:', error); - // 如果失败,重置标志以便重试 - hasShownWindow.current = false; - } - }, 150); - }); - }); - } else { - // 每 50 次检查打印一次日志(避免日志过多) - if (checkCount % 50 === 0) { - console.log( - '[WindowManagerService] 等待主窗口就绪...', - '(检查次数:', - checkCount, - '加载完成:', - isLoadingComplete, - '内容就绪:', - isContentReady, - ')' - ); - } - // 还未准备好,继续检查(使用 RAF 避免阻塞) - animationFrameId = requestAnimationFrame(checkAndShowWindow); - } - }; - - // 延迟一点开始检查,确保 React 组件已挂载 - console.log('[WindowManagerService] 开始检查主窗口内容...'); - const timer = setTimeout(() => { - // 使用 RAF 循环直到加载完成且内容准备好 - animationFrameId = requestAnimationFrame(checkAndShowWindow); - }, 100); - - return () => { - clearTimeout(timer); - if (animationFrameId) { - cancelAnimationFrame(animationFrameId); - } - }; - }, [isLoadingComplete]); - - // 这个服务插件不渲染任何 UI - return null; -}; - -/** - * Window Manager 服务插件 - * 注意:这是一个特殊的服务插件,它不遵循标准的插件格式 - * 因为它需要在应用启动时就被使用,但不渲染任何 UI - */ -const windowManagerPlugin = { - id: 'window-manager', - name: 'Window Manager Service', - version: '1.0.0', - component: WindowManagerService, - slots: [], -}; - -export default windowManagerPlugin; +import { WindowManagerService } from './window-manager-service'; + +/** + * Window Manager 服务插件 + * 注意:这是一个特殊的服务插件,它不遵循标准的插件格式 + * 因为它需要在应用启动时就被使用,但不渲染任何 UI + */ +const windowManagerPlugin = { + id: 'window-manager', + name: 'Window Manager Service', + version: '1.0.0', + component: WindowManagerService, + slots: [], +}; + +export default windowManagerPlugin; diff --git a/plugins/services/window-manager/src/window-manager-service.tsx b/plugins/services/window-manager/src/window-manager-service.tsx new file mode 100644 index 0000000..8a72363 --- /dev/null +++ b/plugins/services/window-manager/src/window-manager-service.tsx @@ -0,0 +1,76 @@ +import { useEffect, useRef } from 'react'; +import { getCurrentWindow } from '@tauri-apps/api/window'; +import { invoke } from '@/lib/tauri'; + +/** 在关键应用状态和首帧布局全部就绪后,向后端报告隐藏主窗口已就绪。 */ +export const WindowManagerService: React.FC = () => { + const hasReportedReady = useRef(false); + + useEffect(() => { + let animationFrameId: number; + let checkCount = 0; + + const checkAndReportReady = async () => { + checkCount++; + if (hasReportedReady.current) return; + + const currentWindow = getCurrentWindow(); + if (currentWindow.label !== 'main') return; + + const rootElement = document.getElementById('root'); + const appElement = rootElement?.querySelector('.app') as HTMLElement | null; + const isContentReady = + document.readyState === 'complete' && + rootElement && + rootElement.children.length > 0 && + rootElement.offsetHeight > 0 && + rootElement.offsetWidth > 0 && + appElement && + appElement.children.length > 0 && + appElement.offsetHeight > 0; + + if (isContentReady) { + hasReportedReady.current = true; + + try { + await document.fonts.ready; + requestAnimationFrame(() => { + requestAnimationFrame(async () => { + try { + await invoke('main_window_ready'); + console.log( + '[WindowManagerService] 主窗口真正就绪,已报告后端(检查次数:', + checkCount, + ')' + ); + } catch (error) { + console.error('[WindowManagerService] 报告主窗口就绪失败:', error); + hasReportedReady.current = false; + animationFrameId = requestAnimationFrame(checkAndReportReady); + } + }); + }); + } catch (error) { + console.error('[WindowManagerService] 等待字体加载失败:', error); + hasReportedReady.current = false; + animationFrameId = requestAnimationFrame(checkAndReportReady); + } + return; + } + + if (checkCount % 50 === 0) { + console.log('[WindowManagerService] 等待主窗口就绪…(检查次数:', checkCount, ')'); + } + animationFrameId = requestAnimationFrame(checkAndReportReady); + }; + + console.log('[WindowManagerService] 开始检查主窗口内容…'); + animationFrameId = requestAnimationFrame(checkAndReportReady); + + return () => { + if (animationFrameId) cancelAnimationFrame(animationFrameId); + }; + }, []); + + return null; +}; diff --git a/server/.claude/settings.local.json b/server/.claude/settings.local.json deleted file mode 100644 index 15fde62..0000000 --- a/server/.claude/settings.local.json +++ /dev/null @@ -1,9 +0,0 @@ -{ - "permissions": { - "allow": [ - "Bash(grep:*)", - "Bash(ls:*)", - "Bash(cargo check:*)" - ] - } -} diff --git a/server/AGENTS.md b/server/AGENTS.md deleted file mode 100644 index e0b0bc7..0000000 --- a/server/AGENTS.md +++ /dev/null @@ -1,237 +0,0 @@ -# Simprint Server 开发约束与规范 - -本文档定义了 Simprint Server 项目的开发约束、代码风格和最佳实践。 - -## 1. 数据库迁移文件命名规范 - -### 1.1 迁移文件创建命令 - -使用 `sqlx migrate add` 命令创建数据库迁移文件,命名格式如下: - -```bash -# 创建表 -sqlx migrate add create_{table_name} - -# 删除表 -sqlx migrate add delete_{table_name} - -# 插入数据 -sqlx migrate add insert_{table_name} -``` - -### 1.2 命名示例 - -- 创建 users 表:`sqlx migrate add create_users` -- 删除 users 表:`sqlx migrate add delete_users` -- 插入初始数据:`sqlx migrate add insert_initial_data` - -### 1.3 迁移文件位置 - -所有迁移文件应位于 `migrations/` 目录下,文件名格式为: - -``` -{timestamp}_{migration_name}.sql -``` - -## 2. 代码风格与格式化 - -### 2.1 Rustfmt 配置 - -项目使用 `rustfmt` 进行代码格式化,配置文件为 `rustfmt.toml`。 - -**格式化命令**: - -```bash -cargo fmt -``` - -**检查格式**: - -```bash -cargo fmt -- --check -``` - -### 2.2 Clippy Linting - -项目使用 `clippy` 进行代码静态分析和 linting,配置文件为 `clippy.toml`。 - -**运行 Clippy**: - -```bash -cargo clippy -``` - -**自动修复**: - -```bash -cargo clippy --fix -``` - -### 2.3 代码风格约定 - -- **函数和变量**:使用 `snake_case` -- **类型和结构体**:使用 `PascalCase` -- **常量**:使用 `SCREAMING_SNAKE_CASE` -- **模块**:使用 `snake_case` - -## 3. 编译配置 - -### 3.1 编译选项 - -项目在 `Cargo.toml` 中配置了以下编译选项: - -- **Edition**: Rust 2021 -- **Max Width**: 100 字符 -- **Tab Spaces**: 4 个空格 -- **Newline Style**: Unix (LF) - -### 3.2 编译命令 - -```bash -# 检查代码(不编译) -cargo check - -# 编译项目 -cargo build - -# 编译发布版本 -cargo build --release - -# 运行项目 -cargo run -``` - -## 4. 错误处理规范 - -- 优先使用 `Result` 进行错误处理 -- 使用 `thiserror` 或 `anyhow` 库进行错误处理(如需要) -- 避免使用 `unwrap()` 和 `expect()`,除非在测试代码中或确定不会失败的情况 - -## 5. 文档规范 - -- 所有公共 API 应使用文档注释(`///`) -- 复杂函数应添加文档说明参数和返回值 -- 使用 `cargo doc` 生成项目文档 - -## 6. 测试规范 - -- 单元测试放在与被测试代码相同的文件中 -- 集成测试放在 `tests/` 目录下 -- 使用 `cargo test` 运行所有测试 - -## 7. 提交前检查清单 - -在提交代码前,确保: - -- [ ] 代码已通过 `cargo fmt` 格式化 -- [ ] 代码已通过 `cargo clippy` 检查,无警告 -- [ ] 所有测试通过(`cargo test`) -- [ ] 迁移文件命名符合规范 -- [ ] 公共 API 都有适当的文档注释 - -## 8. 其他说明 - -- 所有数据库对应类型的映射需要放到dto目录中 -- 所有数据库操作的功能需要放到models目录中 -- 具体功能实现和调用models层或者其他业务代码都应该放到services目录中 -- 在handlers中接收的json参数定义应该放到entitys中,参数通常可以直接传递到services,然后到models层,下面是参数的示例: - - ``` - // entitys/{file_name}.rs - #[derive(Deserialize, Serialize, Debug, Default)] - pub struct Pagination { - pub page: i64, - pub page_size: i64, - } - - // handlers/{file_name}.rs - use crate::utils::Json; - use crate::state::CurrentUser; - - // JSON参数: Json(payload): Json - // user_uuid参数: Extension(current_user): Extension, - pub async fn list(Json(payload): Json) /* TODO: */ { - // TODO: - } - ``` - -- 通常都是post请求,且都是json参数 -- models 的实现示例如下: - - ``` - use sqlx::{Error, Pool, Sqlite}; - - pub async fn insert_user( - pool: &Pool, - payload: InsertUserRequestPayload - ) -> Result { - let mut tx = pool.begin().await?; - - let user_uuid: Uuid = sqlx::query_scalar("insert into users default values returning uuid;") - .fetch_one(&mut *tx) - .await?; - - sqlx::query("insert into user_infos (phone, user_uuid) values($1, $2) returning id;") - .bind(payload.phone) - .bind(user_uuid) - .execute(&mut *tx) - .await?; - - sqlx::query("insert into user_passwords (password, user_uuid) values($1, $2) returning id;") - .bind(payload.password) - .bind(user_uuid) - .execute(&mut *tx) - .await?; - - sqlx::query("insert into user_avatars (resource_hash, user_uuid) values($1, $2) returning id;") - .bind(payload.default_avatar) - .bind(user_uuid) - .execute(&mut *tx) - .await?; - - tx.commit().await?; - - Ok(user_uuid) - } - - pub async fn fetch_user_balance( - pool: &Pool, - user_uuid: Uuid, - ) -> Result, Error> { - let rec = sqlx::query_scalar( - r#" - SELECT balance FROM user_balance - WHERE user_uuid = $1 AND deleted_at IS NULL - "#, - ) - .bind(user_uuid) - .fetch_optional(pool) - .await?; - - Ok(rec) - } - ``` - -- sqlx的相关数据库操作通常不使用宏。 -- 缓存相关示例: - - ``` - pub(crate) const REGISTER_CODE_CACHE_KEY: &str = "verification:code:register:send"; - pub(crate) const CODE_EXPIRATION: u64 = 60 * 3; - - /// 设置用户注册的验证码 - pub async fn set_register_code( - svc_ctx: &SvcCtx, - phone: &str, - code: &str, - ) -> Result<(), anyhow::Error> { - let key = format!("{}:{}", REGISTER_CODE_CACHE_KEY, phone); - let _: () = svc_ctx - .redis - .clone() - .set_ex(key, code, CODE_EXPIRATION) - .await?; - Ok(()) - } - - ``` diff --git a/src-tauri/Cargo.lock b/src-tauri/Cargo.lock index 6331bb0..efbb951 100644 --- a/src-tauri/Cargo.lock +++ b/src-tauri/Cargo.lock @@ -1182,27 +1182,6 @@ dependencies = [ "syn 2.0.114", ] -[[package]] -name = "dbus" -version = "0.9.11" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "b942602992bb7acfd1f51c49811c58a610ef9181b6e66f3e519d79b540a3bf73" -dependencies = [ - "libc", - "libdbus-sys", - "windows-sys 0.61.2", -] - -[[package]] -name = "dbus-secret-service" -version = "4.1.0" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "708b509edf7889e53d7efb0ffadd994cc6c2345ccb62f55cfd6b0682165e4fa6" -dependencies = [ - "dbus", - "zeroize", -] - [[package]] name = "deflate64" version = "0.1.10" @@ -2025,10 +2004,8 @@ source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "335ff9f135e4384c8150d6f27c6daed433577f86b4750418338c01a1a2528592" dependencies = [ "cfg-if", - "js-sys", "libc", "wasi 0.11.1+wasi-snapshot-preview1", - "wasm-bindgen", ] [[package]] @@ -2433,7 +2410,6 @@ dependencies = [ "tokio", "tokio-rustls", "tower-service", - "webpki-roots", ] [[package]] @@ -2891,21 +2867,6 @@ dependencies = [ "unicode-segmentation", ] -[[package]] -name = "keyring" -version = "3.6.3" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "eebcc3aff044e5944a8fbaf69eb277d11986064cba30c468730e8b9909fb551c" -dependencies = [ - "byteorder", - "dbus-secret-service", - "log", - "security-framework 2.11.1", - "security-framework 3.7.0", - "windows-sys 0.60.2", - "zeroize", -] - [[package]] name = "kuchikiki" version = "0.8.8-speedreader" @@ -2963,15 +2924,6 @@ version = "0.2.189" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "3eaf3ede3fee6db1a4c2ee091bf8a8b4dccdc6d17f656fb07896ee72867612f2" -[[package]] -name = "libdbus-sys" -version = "0.2.7" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "328c4789d42200f1eeec05bd86c9c13c7f091d2ba9a6ea35acdf51f31bc0f043" -dependencies = [ - "pkg-config", -] - [[package]] name = "libloading" version = "0.7.4" @@ -3046,12 +2998,6 @@ dependencies = [ "value-bag", ] -[[package]] -name = "lru-slab" -version = "0.1.2" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "112b39cec0b298b6c1999fee3e31427f74f676e4cb9879ed1a121b43661a4154" - [[package]] name = "lzma-rs" version = "0.3.0" @@ -3230,7 +3176,7 @@ dependencies = [ "openssl-probe", "openssl-sys", "schannel", - "security-framework 2.11.1", + "security-framework", "security-framework-sys", "tempfile", ] @@ -4283,61 +4229,6 @@ dependencies = [ "memchr", ] -[[package]] -name = "quinn" -version = "0.11.9" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "b9e20a958963c291dc322d98411f541009df2ced7b5a4f2bd52337638cfccf20" -dependencies = [ - "bytes", - "cfg_aliases", - "pin-project-lite", - "quinn-proto", - "quinn-udp", - "rustc-hash", - "rustls", - "socket2", - "thiserror 2.0.17", - "tokio", - "tracing", - "web-time", -] - -[[package]] -name = "quinn-proto" -version = "0.11.13" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "f1906b49b0c3bc04b5fe5d86a77925ae6524a19b816ae38ce1e426255f1d8a31" -dependencies = [ - "bytes", - "getrandom 0.3.4", - "lru-slab", - "rand 0.9.2", - "ring", - "rustc-hash", - "rustls", - "rustls-pki-types", - "slab", - "thiserror 2.0.17", - "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", - "tracing", - "windows-sys 0.60.2", -] - [[package]] name = "quote" version = "1.0.47" @@ -4498,17 +4389,6 @@ version = "0.6.2" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "20675572f6f24e9e76ef639bc5552774ed45f1c30e2951e1e99c59888861c539" -[[package]] -name = "read-progress-stream" -version = "1.0.0" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "6435842fc2fea44b528719eb8c32203bbc1bb2f5b619fbe0c0a3d8350fd8d2a8" -dependencies = [ - "bytes", - "futures", - "pin-project-lite", -] - [[package]] name = "redox_syscall" version = "0.5.18" @@ -4634,8 +4514,6 @@ dependencies = [ "native-tls", "percent-encoding", "pin-project-lite", - "quinn", - "rustls", "rustls-pki-types", "serde", "serde_json", @@ -4643,7 +4521,6 @@ dependencies = [ "sync_wrapper", "tokio", "tokio-native-tls", - "tokio-rustls", "tokio-util", "tower", "tower-http", @@ -4653,7 +4530,6 @@ dependencies = [ "wasm-bindgen-futures", "wasm-streams", "web-sys", - "webpki-roots", ] [[package]] @@ -4863,12 +4739,6 @@ dependencies = [ "serde_json", ] -[[package]] -name = "rustc-hash" -version = "2.1.1" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "357703d41365b4b27c590e3ed91eabb1b663f07c4c084095e60cbed4362dff0d" - [[package]] name = "rustc_version" version = "0.4.1" @@ -4911,7 +4781,6 @@ source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "c665f33d38cea657d9614f766881e4d510e0eda4239891eea56b4cadcf01801b" dependencies = [ "once_cell", - "ring", "rustls-pki-types", "rustls-webpki", "subtle", @@ -4924,7 +4793,6 @@ version = "1.13.2" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "21e6f2ab2928ca4291b86736a8bd920a277a399bba1589409d72154ff87c1282" dependencies = [ - "web-time", "zeroize", ] @@ -5059,19 +4927,6 @@ dependencies = [ "security-framework-sys", ] -[[package]] -name = "security-framework" -version = "3.7.0" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "b7f4bc775c73d9a02cde8bf7b2ec4c9d12743edf609006c7facc23998404cd1d" -dependencies = [ - "bitflags 2.11.1", - "core-foundation 0.10.1", - "core-foundation-sys", - "libc", - "security-framework-sys", -] - [[package]] name = "security-framework-sys" version = "2.17.0" @@ -5378,11 +5233,9 @@ checksum = "e3a9fe34e3e7a50316060351f37187a3f546bce95496156754b601a5fa71b76e" name = "simprint" version = "0.1.0" dependencies = [ - "aes", "aes-gcm", "anyhow", "axum", - "base64 0.22.1", "business", "bytes", "chrono", @@ -5395,7 +5248,6 @@ dependencies = [ "hkdf", "include_dir", "indexmap 2.13.0", - "keyring", "lazy_static", "log", "once_cell", @@ -5404,7 +5256,6 @@ dependencies = [ "reqwest", "rmcp", "rmp-serde", - "rsa", "runtime", "schemars 1.2.0", "serde", @@ -5425,7 +5276,6 @@ dependencies = [ "tauri-plugin-process", "tauri-plugin-single-instance", "tauri-plugin-store", - "tauri-plugin-upload", "thiserror 2.0.17", "tokio", "tokio-socks", @@ -6295,25 +6145,6 @@ dependencies = [ "tracing", ] -[[package]] -name = "tauri-plugin-upload" -version = "2.4.0" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "9e2844560c33b360506cea7289626267f43be253a01018b53bd556e86163b9fd" -dependencies = [ - "futures-util", - "log", - "read-progress-stream", - "reqwest", - "serde", - "serde_json", - "tauri", - "tauri-plugin", - "thiserror 2.0.17", - "tokio", - "tokio-util", -] - [[package]] name = "tauri-runtime" version = "2.9.2" @@ -7343,16 +7174,6 @@ dependencies = [ "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 = "webkit2gtk" version = "2.0.1" @@ -7397,15 +7218,6 @@ dependencies = [ "system-deps", ] -[[package]] -name = "webpki-roots" -version = "1.0.5" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "12bed680863276c63889429bfd6cab3b99943659923822de1c8a39c49e4d722c" -dependencies = [ - "rustls-pki-types", -] - [[package]] name = "webview2-com" version = "0.38.2" diff --git a/src-tauri/Cargo.toml b/src-tauri/Cargo.toml index 42794c9..ba73976 100644 --- a/src-tauri/Cargo.toml +++ b/src-tauri/Cargo.toml @@ -52,19 +52,16 @@ env_logger = "0.11" chrono = { version = "0.4", features = ["serde"] } tauri-plugin-log = "2.3.1" tauri-plugin-opener = "2" -tauri-plugin-upload = "2.2.1" tauri-plugin-process = "2.2.0" tauri-plugin-deep-link = "2.2.1" tauri-plugin-dialog = "2.6.0" sysinfo = "0.34.2" -base64 = "0.22.1" rand = "0.9.1" -rsa = "0.9.8" aes-gcm = "0.10.3" bytes = "1.10.0" include_dir = "0.7.4" lazy_static = "1.5.0" -reqwest = { version = "0.12.22", features = ["multipart", "socks"] } +reqwest = { version = "0.12.22", features = ["multipart", "socks", "stream"] } tokio-socks = "0.5" tokio-util = "0.7" futures = "0.3.31" @@ -76,8 +73,6 @@ hex = "0.4.3" zip = "2.2.2" axum = "0.8.4" regex = "1.12.2" -aes = "0.8" -keyring = { version = "3.6.3", features = ["windows-native", "apple-native", "sync-secret-service"] } rmcp = { version = "1.2.0", features = ["server", "macros", "schemars", "transport-streamable-http-server"] } schemars = "1.0" tauri-plugin-clipboard-manager = "2.3.2" diff --git a/src-tauri/config.example.toml b/src-tauri/config.example.toml index e64ddc9..ec34c55 100644 --- a/src-tauri/config.example.toml +++ b/src-tauri/config.example.toml @@ -1,8 +1,3 @@ -[server] -base_url = "https://api.simprint.app/api/" -version = "v1" -secret_key = "Nuexz9Y2hRc5Z6HK7Atb" - [updater] check_url = "https://update.simprint.app/api/v1/versions/check" latest_json_url = "https://pub-39307a5e69c74324855a762027cbf9bf.r2.dev/latest.json" diff --git a/src-tauri/crates/business/migrations/20260812000001_create_local_user_auth.sql b/src-tauri/crates/business/migrations/20260812000001_create_local_user_auth.sql new file mode 100644 index 0000000..8af6154 --- /dev/null +++ b/src-tauri/crates/business/migrations/20260812000001_create_local_user_auth.sql @@ -0,0 +1,30 @@ +-- Local authentication is intentionally separate from the former cloud-account fields. +-- `users` remains the owner boundary for all business data, while this table contains +-- only the information needed by the local user picker. +CREATE TABLE IF NOT EXISTS local_user_auth ( + user_uuid TEXT PRIMARY KEY, + avatar TEXT NOT NULL, + password_salt TEXT, + password_hash TEXT, + created_at TEXT NOT NULL DEFAULT CURRENT_TIMESTAMP, + updated_at TEXT NOT NULL DEFAULT CURRENT_TIMESTAMP, + CONSTRAINT fk_local_user_auth_user + FOREIGN KEY (user_uuid) REFERENCES users(uuid) ON DELETE CASCADE, + CONSTRAINT chk_local_user_auth_password + CHECK ( + (password_salt IS NULL AND password_hash IS NULL) + OR (password_salt IS NOT NULL AND password_hash IS NOT NULL) + ) +); + +-- Preserve users created by builds that predate the local user picker. Their legacy +-- account password is deliberately not imported because it used a different scheme. +INSERT INTO local_user_auth (user_uuid, avatar) +SELECT u.uuid, COALESCE(NULLIF(ui.avatar_hash, ''), '🙂') +FROM users u +JOIN user_infos ui ON ui.user_uuid = u.uuid +WHERE u.deleted_at IS NULL AND ui.deleted_at IS NULL +ON CONFLICT (user_uuid) DO NOTHING; + +CREATE INDEX IF NOT EXISTS idx_local_user_auth_created_at + ON local_user_auth(created_at); diff --git a/src-tauri/crates/business/migrations/20260812000002_create_browser_kernel_registry.sql b/src-tauri/crates/business/migrations/20260812000002_create_browser_kernel_registry.sql new file mode 100644 index 0000000..2fccd99 --- /dev/null +++ b/src-tauri/crates/business/migrations/20260812000002_create_browser_kernel_registry.sql @@ -0,0 +1,69 @@ +CREATE TABLE IF NOT EXISTS browser_kernel_artifacts ( + kernel_id TEXT PRIMARY KEY, + type_code TEXT NOT NULL, + resource_name TEXT NOT NULL, + version TEXT NOT NULL, + name TEXT, + notes TEXT, + platform TEXT NOT NULL, + package_hash TEXT NOT NULL, + executable_signature TEXT NOT NULL, + file_size INTEGER, + is_latest INTEGER NOT NULL DEFAULT 0, + status TEXT NOT NULL DEFAULT 'active', + arch TEXT NOT NULL, + package_format TEXT NOT NULL, + requires_extract INTEGER NOT NULL DEFAULT 0, + entrypoint_template TEXT, + extract_root TEXT, + created_at TEXT NOT NULL DEFAULT CURRENT_TIMESTAMP, + updated_at TEXT NOT NULL DEFAULT CURRENT_TIMESTAMP +); + +CREATE INDEX IF NOT EXISTS idx_browser_kernel_artifacts_lookup + ON browser_kernel_artifacts(type_code, platform, status, is_latest); +CREATE INDEX IF NOT EXISTS idx_browser_kernel_artifacts_resource_name + ON browser_kernel_artifacts(resource_name); + +CREATE TABLE IF NOT EXISTS browser_kernel_sources ( + id INTEGER PRIMARY KEY AUTOINCREMENT, + kernel_id TEXT NOT NULL, + source_id TEXT NOT NULL, + url TEXT NOT NULL, + priority INTEGER NOT NULL DEFAULT 100, + is_active INTEGER NOT NULL DEFAULT 1, + created_at TEXT NOT NULL DEFAULT CURRENT_TIMESTAMP, + last_seen_at TEXT NOT NULL DEFAULT CURRENT_TIMESTAMP, + CONSTRAINT fk_browser_kernel_sources_artifact + FOREIGN KEY (kernel_id) REFERENCES browser_kernel_artifacts(kernel_id) ON DELETE CASCADE, + CONSTRAINT uq_browser_kernel_sources UNIQUE (kernel_id, source_id, url) +); + +CREATE INDEX IF NOT EXISTS idx_browser_kernel_sources_resolve + ON browser_kernel_sources(kernel_id, is_active, priority); + +CREATE TABLE IF NOT EXISTS browser_kernel_installations ( + kernel_id TEXT PRIMARY KEY, + install_path TEXT NOT NULL, + verified_signature TEXT, + status TEXT NOT NULL DEFAULT 'ready', + installed_at TEXT NOT NULL DEFAULT CURRENT_TIMESTAMP, + verified_at TEXT, + updated_at TEXT NOT NULL DEFAULT CURRENT_TIMESTAMP, + CONSTRAINT fk_browser_kernel_installations_artifact + FOREIGN KEY (kernel_id) REFERENCES browser_kernel_artifacts(kernel_id) ON DELETE RESTRICT +); + +CREATE TABLE IF NOT EXISTS environment_kernel_bindings ( + environment_uuid TEXT PRIMARY KEY, + kernel_id TEXT NOT NULL, + bound_at TEXT NOT NULL DEFAULT CURRENT_TIMESTAMP, + updated_at TEXT NOT NULL DEFAULT CURRENT_TIMESTAMP, + CONSTRAINT fk_environment_kernel_bindings_environment + FOREIGN KEY (environment_uuid) REFERENCES environments(uuid) ON DELETE CASCADE, + CONSTRAINT fk_environment_kernel_bindings_artifact + FOREIGN KEY (kernel_id) REFERENCES browser_kernel_artifacts(kernel_id) ON DELETE RESTRICT +); + +CREATE INDEX IF NOT EXISTS idx_environment_kernel_bindings_kernel_id + ON environment_kernel_bindings(kernel_id); diff --git a/src-tauri/crates/business/migrations/20260812000003_add_browser_kernel_compatible_signatures.sql b/src-tauri/crates/business/migrations/20260812000003_add_browser_kernel_compatible_signatures.sql new file mode 100644 index 0000000..c98c44c --- /dev/null +++ b/src-tauri/crates/business/migrations/20260812000003_add_browser_kernel_compatible_signatures.sql @@ -0,0 +1,9 @@ +ALTER TABLE browser_kernel_artifacts + ADD COLUMN compatible_executable_signatures TEXT NOT NULL DEFAULT '[]'; + +ALTER TABLE browser_kernel_artifacts + ADD COLUMN install_dir_name TEXT; + +UPDATE browser_kernel_artifacts +SET install_dir_name = resource_name +WHERE install_dir_name IS NULL; diff --git a/src-tauri/crates/business/resources/default-browser-kernels.json b/src-tauri/crates/business/resources/default-browser-kernels.json new file mode 100644 index 0000000..b33c66d --- /dev/null +++ b/src-tauri/crates/business/resources/default-browser-kernels.json @@ -0,0 +1,30 @@ +{ + "schema_version": 1, + "source_id": "simprint-builtin", + "kernels": [ + { + "type_code": "SIMPRINT_KERNEL_CHROMIUM", + "resource_name": "Chrome 144", + "install_dir_name": "Chrome 144", + "version": "144.0.7559.118.1", + "name": "simprint-browser-144.0.7559.118.zip", + "notes": "Simprint Chromium kernel", + "platform": "windows", + "url": "https://pub-39307a5e69c74324855a762027cbf9bf.r2.dev/versions/144.0.7559.118.1/simprint-browser-144.0.7559.118.zip", + "priority": 100, + "hash": "c74ce58537c93e99e4099c94667a21a8e150ac4052c383bc2a095ffdb3b0e075", + "signature": "a864f950c2e77d18ef932f2ad3dbde63039c3b467994daab83081ce6b28c4f82", + "compatible_signatures": [ + "26afa023c6637f045b6825a8e14b720a2305504b313f063bc8410bb0aef19bbf" + ], + "file_size": 184526645, + "is_latest": true, + "status": "active", + "arch": "x86_64", + "package_format": "zip", + "requires_extract": true, + "entrypoint_template": null, + "extract_root": null + } + ] +} diff --git a/src-tauri/crates/business/src/dispatcher.rs b/src-tauri/crates/business/src/dispatcher.rs index 083d1b3..dd83a58 100644 --- a/src-tauri/crates/business/src/dispatcher.rs +++ b/src-tauri/crates/business/src/dispatcher.rs @@ -28,14 +28,31 @@ async fn current_team(context: &SvcCtx) -> Result { /// Dispatch a migrated business POST route locally. /// -/// `None` means the route belongs to a non-migrated concern (authentication, -/// updates or optional cloud services) and may be handled by the remote client. +/// `None` means the route does not belong to the embedded business service. pub async fn dispatch_post( context: &SvcCtx, route: &str, data: &Value, ) -> Option> { let result = match route.trim_start_matches('/') { + "browser-kernels/list" => { + async { + let platform = data.get("platform").and_then(Value::as_str).unwrap_or("windows"); + let type_code = data + .get("type_code") + .and_then(Value::as_str) + .unwrap_or("SIMPRINT_KERNEL_CHROMIUM"); + value( + services::browser_kernels::list_browser_kernels( + &context.db, + Some(platform), + Some(type_code), + ) + .await?, + ) + } + .await + } "local-api/get" => { async { value( @@ -159,13 +176,9 @@ pub async fn dispatch_post( "teams/my-teams" => { async { - let workspace_uuid = current_workspace(context).await?; - let teams = services::teams::get_user_teams_service( - context, - workspace_uuid, - context.local_user_uuid, - ) - .await?; + let teams = + services::teams::get_user_teams_service(context, context.local_user_uuid) + .await?; let current = services::teams::get_current_team_service(context, context.local_user_uuid) .await?; @@ -176,7 +189,7 @@ pub async fn dispatch_post( } else { models::teams::fetch_team_member( &context.db, - workspace_uuid, + team.workspace_uuid, team.uuid, context.local_user_uuid, ) @@ -234,13 +247,8 @@ pub async fn dispatch_post( "teams/switch" => { async { let request: SwitchTeamRequest = payload(data)?; - services::teams::switch_team_service( - context, - current_workspace(context).await?, - context.local_user_uuid, - &request, - ) - .await?; + services::teams::switch_team_service(context, context.local_user_uuid, &request) + .await?; Ok(Value::Null) } .await @@ -277,10 +285,10 @@ pub async fn dispatch_post( } .await } - "teams/invite" => { + "teams/member/add" => { async { - let request: InviteMemberRequest = payload(data)?; - let invitation_uuid = services::teams::invite_member_service( + let request: AddMemberRequest = payload(data)?; + let member_uuid = services::teams::add_member_service( context, current_workspace(context).await?, current_team(context).await?, @@ -288,7 +296,7 @@ pub async fn dispatch_post( &request, ) .await?; - Ok(json!({ "invitation_uuid": invitation_uuid })) + Ok(json!({ "member_uuid": member_uuid })) } .await } @@ -323,53 +331,6 @@ pub async fn dispatch_post( } .await } - "teams/invitations" => { - async { - value( - services::teams::get_pending_invitations_service( - context, - current_team(context).await?, - ) - .await?, - ) - } - .await - } - "teams/invitation/cancel" => { - async { - let request: CancelInviteRequest = payload(data)?; - services::teams::cancel_invitation_service(context, request.invitation_uuid) - .await?; - Ok(Value::Null) - } - .await - } - "teams/invitation/accept" => { - async { - let request: AcceptInvitationRequest = payload(data)?; - let team_uuid = services::teams::accept_invitation_service( - context, - context.local_user_uuid, - &request.token, - ) - .await?; - Ok(json!({ "team_uuid": team_uuid })) - } - .await - } - "teams/invitation/reject" => { - async { - let request: RejectInvitationRequest = payload(data)?; - services::teams::reject_invitation_service( - context, - context.local_user_uuid, - &request.token, - ) - .await?; - Ok(Value::Null) - } - .await - } "teams/leave" => { async { services::teams::leave_team_service( @@ -1526,6 +1487,22 @@ mod tests { .expect("workspace list should succeed"); assert_eq!(workspace_list["workspaces"].as_array().unwrap().len(), 1); + let kernels = dispatch_post( + &context, + "browser-kernels/list", + &json!({ + "platform": "windows", + "type_code": "SIMPRINT_KERNEL_CHROMIUM" + }), + ) + .await + .unwrap() + .expect("browser kernels should be queried from the local registry"); + assert_eq!( + kernels["SIMPRINT_KERNEL_CHROMIUM"][0]["kernel_id"].as_str().map(str::len), + Some(64) + ); + let group = dispatch_post( &context, "groups/create", diff --git a/src-tauri/crates/business/src/dto/local_api.rs b/src-tauri/crates/business/src/dto/local_api.rs index ed32129..8afeb27 100644 --- a/src-tauri/crates/business/src/dto/local_api.rs +++ b/src-tauri/crates/business/src/dto/local_api.rs @@ -1,97 +1,98 @@ -use chrono::{DateTime, NaiveDate, Utc}; -use serde::{Deserialize, Serialize}; -use serde_json::Value; -use sqlx::FromRow; -use uuid::Uuid; - -#[derive(Debug, Clone, FromRow)] -pub struct LocalApiSettingsDto { - pub id: i32, - pub uuid: Uuid, - pub user_uuid: Uuid, - pub enabled: bool, - pub port: i32, - pub remote_access: bool, - pub cors_origins: Value, - pub created_at: DateTime, - pub updated_at: DateTime, - pub deleted_at: Option>, -} - -#[derive(Debug, Clone, FromRow)] -pub struct LocalApiKeyDto { - pub id: i32, - pub uuid: Uuid, - pub user_uuid: Uuid, - pub key_prefix: String, - pub key_hash: String, - pub api_key: Option, - pub is_active: bool, - pub requests_today: i32, - pub daily_limit: i32, - pub last_reset_date: NaiveDate, - pub last_used_at: Option>, - pub expires_at: Option>, - pub created_at: DateTime, - pub updated_at: DateTime, - pub deleted_at: Option>, -} - -#[derive(Debug, Clone, FromRow)] -pub struct LocalApiKeyPermissionDto { - pub id: i32, - pub uuid: Uuid, - pub api_key_id: i32, - pub permission_code: String, - pub is_enabled: bool, - pub rate_limit_per_minute: i32, - pub rate_limit_per_hour: i32, - pub created_at: DateTime, - pub updated_at: DateTime, - pub deleted_at: Option>, -} - -#[derive(Debug, Clone, FromRow, Serialize, Deserialize)] -pub struct LocalApiPermissionDefinitionDto { - pub id: i32, - pub uuid: Uuid, - pub permission_code: String, - pub name: String, - pub description: Option, - pub default_enabled: bool, - pub default_rate_limit_per_minute: i32, - pub default_rate_limit_per_hour: i32, - pub sort_order: i32, - pub created_at: DateTime, - pub updated_at: DateTime, - pub deleted_at: Option>, -} - -#[derive(Debug, Clone, Serialize)] -#[serde(rename_all = "camelCase")] -pub struct LocalApiConfigDto { - pub enabled: bool, - pub api_key: String, - pub port: i32, - pub remote_access: bool, - pub cors_origins: Vec, - pub requests_today: i32, - pub daily_limit: i32, -} - -#[derive(Debug, Clone, Serialize)] -#[serde(rename_all = "camelCase")] -pub struct ResetLocalApiKeyDto { - pub api_key: String, -} - -#[derive(Debug, Clone, Serialize)] -#[serde(rename_all = "camelCase")] -pub struct ValidateLocalApiKeyDto { - pub valid: bool, - pub permission_code: String, - pub requests_today: i32, - pub daily_limit: i32, - pub rate_limit_per_minute: i32, - pub rate_limit_per_hour: i32, -} +use chrono::{DateTime, NaiveDate, Utc}; +use serde::{Deserialize, Serialize}; +use serde_json::Value; +use sqlx::FromRow; +use uuid::Uuid; + +#[derive(Debug, Clone, FromRow)] +pub struct LocalApiSettingsDto { + pub id: i32, + pub uuid: Uuid, + pub user_uuid: Uuid, + pub enabled: bool, + pub port: i32, + pub remote_access: bool, + pub cors_origins: Value, + pub created_at: DateTime, + pub updated_at: DateTime, + pub deleted_at: Option>, +} + +#[derive(Debug, Clone, FromRow)] +pub struct LocalApiKeyDto { + pub id: i32, + pub uuid: Uuid, + pub user_uuid: Uuid, + pub key_prefix: String, + pub key_hash: String, + pub api_key: Option, + pub is_active: bool, + pub requests_today: i32, + pub daily_limit: i32, + pub last_reset_date: NaiveDate, + pub last_used_at: Option>, + pub expires_at: Option>, + pub created_at: DateTime, + pub updated_at: DateTime, + pub deleted_at: Option>, +} + +#[derive(Debug, Clone, FromRow)] +pub struct LocalApiKeyPermissionDto { + pub id: i32, + pub uuid: Uuid, + pub api_key_id: i32, + pub permission_code: String, + pub is_enabled: bool, + pub rate_limit_per_minute: i32, + pub rate_limit_per_hour: i32, + pub created_at: DateTime, + pub updated_at: DateTime, + pub deleted_at: Option>, +} + +#[derive(Debug, Clone, FromRow, Serialize, Deserialize)] +pub struct LocalApiPermissionDefinitionDto { + pub id: i32, + pub uuid: Uuid, + pub permission_code: String, + pub name: String, + pub description: Option, + pub default_enabled: bool, + pub default_rate_limit_per_minute: i32, + pub default_rate_limit_per_hour: i32, + pub sort_order: i32, + pub created_at: DateTime, + pub updated_at: DateTime, + pub deleted_at: Option>, +} + +#[derive(Debug, Clone, Serialize)] +#[serde(rename_all = "camelCase")] +pub struct LocalApiConfigDto { + pub enabled: bool, + pub api_key: String, + pub port: i32, + pub remote_access: bool, + pub cors_origins: Vec, + pub requests_today: i32, + pub daily_limit: i32, +} + +#[derive(Debug, Clone, Serialize)] +#[serde(rename_all = "camelCase")] +pub struct ResetLocalApiKeyDto { + pub api_key: String, +} + +#[derive(Debug, Clone, Serialize)] +#[serde(rename_all = "camelCase")] +pub struct ValidateLocalApiKeyDto { + pub valid: bool, + pub user_uuid: uuid::Uuid, + pub permission_code: String, + pub requests_today: i32, + pub daily_limit: i32, + pub rate_limit_per_minute: i32, + pub rate_limit_per_hour: i32, +} diff --git a/src-tauri/crates/business/src/entitys/rpa.rs b/src-tauri/crates/business/src/entitys/rpa.rs index ad830b4..47c5481 100644 --- a/src-tauri/crates/business/src/entitys/rpa.rs +++ b/src-tauri/crates/business/src/entitys/rpa.rs @@ -1,4 +1,4 @@ -use serde::{Deserialize, Serialize}; +use serde::{Deserialize, Serialize}; use uuid::Uuid; use super::Pagination; @@ -132,4 +132,4 @@ pub struct RpaRunsListResponse { pub struct ExportRpaTaskResponse { pub content: String, pub filename: String, -} +} diff --git a/src-tauri/crates/business/src/entitys/teams.rs b/src-tauri/crates/business/src/entitys/teams.rs index d28ced2..e690ca3 100644 --- a/src-tauri/crates/business/src/entitys/teams.rs +++ b/src-tauri/crates/business/src/entitys/teams.rs @@ -1,130 +1,130 @@ -use serde::{Deserialize, Serialize}; -use uuid::Uuid; - -use super::Pagination; - -/// 创建团队请求 -#[derive(Debug, Clone, Deserialize, Serialize)] -pub struct CreateTeamRequest { - pub workspace_uuid: Uuid, - pub name: String, - pub description: Option, -} - -/// 更新团队请求 -#[derive(Debug, Clone, Deserialize, Serialize)] -pub struct UpdateTeamRequest { - pub uuid: Uuid, - pub name: Option, - pub description: Option, - pub avatar_hash: Option, -} - -/// 切换团队请求 -#[derive(Debug, Clone, Deserialize, Serialize)] -pub struct SwitchTeamRequest { - pub team_uuid: Uuid, -} - -/// 查询团队成员请求 -#[derive(Debug, Clone, Deserialize, Serialize)] -pub struct ListTeamMembersRequest { - pub workspace_uuid: Uuid, - #[serde(flatten)] - pub pagination: Pagination, - pub filters: Option, -} - -/// 团队成员筛选条件 -#[derive(Debug, Clone, Deserialize, Serialize)] -pub struct TeamMemberFilters { - pub keyword: Option, - pub role: Option, - pub status: Option, -} - -/// 邀请成员请求 -#[derive(Debug, Clone, Deserialize, Serialize)] -pub struct InviteMemberRequest { - pub email: String, - pub role: String, -} - -/// 取消邀请请求 -#[derive(Debug, Clone, Deserialize, Serialize)] -pub struct CancelInviteRequest { - pub invitation_uuid: Uuid, -} - -/// 更新成员角色请求 -#[derive(Debug, Clone, Deserialize, Serialize)] -pub struct UpdateMemberRoleRequest { - pub member_uuid: Uuid, - pub role: String, -} - -/// 更新成员状态请求 -#[derive(Debug, Clone, Deserialize, Serialize)] -pub struct UpdateMemberStatusRequest { - pub member_uuid: Uuid, - pub status: String, -} - -/// 移除成员请求 -#[derive(Debug, Clone, Deserialize, Serialize)] -pub struct RemoveMemberRequest { - pub member_uuid: Uuid, -} - -/// 批量移除成员请求 -#[derive(Debug, Clone, Deserialize, Serialize)] -pub struct BatchRemoveMembersRequest { - pub member_uuids: Vec, -} - -/// 接受邀请请求 -#[derive(Debug, Clone, Deserialize, Serialize)] -pub struct AcceptInvitationRequest { - pub token: String, -} - -/// 拒绝邀请请求 -#[derive(Debug, Clone, Deserialize, Serialize)] -pub struct RejectInvitationRequest { - pub token: String, -} - -// ========== 响应结构体 ========== - -/// 团队列表响应 -#[derive(Debug, Clone, Serialize)] -pub struct TeamListResponse { - pub current_team_uuid: Option, - pub teams: Vec, -} - -/// 团队列表项 -#[derive(Debug, Clone, Serialize)] -pub struct TeamItem { - pub uuid: Uuid, - pub name: String, - pub description: Option, - pub role: String, - pub members_count: i64, - pub is_current: bool, -} - -/// 团队成员列表响应 -#[derive(Debug, Clone, Serialize)] -pub struct MemberListResponse { - pub items: Vec, - pub total: i64, - pub page: i64, - pub page_size: i64, -} - -/// 接受邀请响应 -#[derive(Debug, Clone, Serialize)] -pub struct AcceptInvitationResponse { - pub team_uuid: Uuid, -} +use serde::{Deserialize, Serialize}; +use uuid::Uuid; + +use super::Pagination; + +/// 创建团队请求 +#[derive(Debug, Clone, Deserialize, Serialize)] +pub struct CreateTeamRequest { + pub workspace_uuid: Uuid, + pub name: String, + pub description: Option, +} + +/// 更新团队请求 +#[derive(Debug, Clone, Deserialize, Serialize)] +pub struct UpdateTeamRequest { + pub uuid: Uuid, + pub name: Option, + pub description: Option, + pub avatar_hash: Option, +} + +/// 切换团队请求 +#[derive(Debug, Clone, Deserialize, Serialize)] +pub struct SwitchTeamRequest { + pub team_uuid: Uuid, +} + +/// 查询团队成员请求 +#[derive(Debug, Clone, Deserialize, Serialize)] +pub struct ListTeamMembersRequest { + pub workspace_uuid: Uuid, + #[serde(flatten)] + pub pagination: Pagination, + pub filters: Option, +} + +/// 团队成员筛选条件 +#[derive(Debug, Clone, Deserialize, Serialize)] +pub struct TeamMemberFilters { + pub keyword: Option, + pub role: Option, + pub status: Option, +} + +/// 将另一个本地用户加入团队。 +#[derive(Debug, Clone, Deserialize, Serialize)] +pub struct AddMemberRequest { + pub user_uuid: Uuid, + pub role: String, +} + +/// 取消邀请请求 +#[derive(Debug, Clone, Deserialize, Serialize)] +pub struct CancelInviteRequest { + pub invitation_uuid: Uuid, +} + +/// 更新成员角色请求 +#[derive(Debug, Clone, Deserialize, Serialize)] +pub struct UpdateMemberRoleRequest { + pub member_uuid: Uuid, + pub role: String, +} + +/// 更新成员状态请求 +#[derive(Debug, Clone, Deserialize, Serialize)] +pub struct UpdateMemberStatusRequest { + pub member_uuid: Uuid, + pub status: String, +} + +/// 移除成员请求 +#[derive(Debug, Clone, Deserialize, Serialize)] +pub struct RemoveMemberRequest { + pub member_uuid: Uuid, +} + +/// 批量移除成员请求 +#[derive(Debug, Clone, Deserialize, Serialize)] +pub struct BatchRemoveMembersRequest { + pub member_uuids: Vec, +} + +/// 接受邀请请求 +#[derive(Debug, Clone, Deserialize, Serialize)] +pub struct AcceptInvitationRequest { + pub token: String, +} + +/// 拒绝邀请请求 +#[derive(Debug, Clone, Deserialize, Serialize)] +pub struct RejectInvitationRequest { + pub token: String, +} + +// ========== 响应结构体 ========== + +/// 团队列表响应 +#[derive(Debug, Clone, Serialize)] +pub struct TeamListResponse { + pub current_team_uuid: Option, + pub teams: Vec, +} + +/// 团队列表项 +#[derive(Debug, Clone, Serialize)] +pub struct TeamItem { + pub uuid: Uuid, + pub name: String, + pub description: Option, + pub role: String, + pub members_count: i64, + pub is_current: bool, +} + +/// 团队成员列表响应 +#[derive(Debug, Clone, Serialize)] +pub struct MemberListResponse { + pub items: Vec, + pub total: i64, + pub page: i64, + pub page_size: i64, +} + +/// 接受邀请响应 +#[derive(Debug, Clone, Serialize)] +pub struct AcceptInvitationResponse { + pub team_uuid: Uuid, +} diff --git a/src-tauri/crates/business/src/models/accounts.rs b/src-tauri/crates/business/src/models/accounts.rs index 641c4a4..a645845 100644 --- a/src-tauri/crates/business/src/models/accounts.rs +++ b/src-tauri/crates/business/src/models/accounts.rs @@ -182,10 +182,7 @@ pub async fn update_platform_account( } /// 增加账号使用次数 -pub async fn increment_account_usage( - pool: &Pool, - account_uuid: Uuid, -) -> Result<(), Error> { +pub async fn increment_account_usage(pool: &Pool, account_uuid: Uuid) -> Result<(), Error> { sqlx::query( r#" UPDATE platform_accounts @@ -201,10 +198,7 @@ pub async fn increment_account_usage( } /// 软删除平台账号 -pub async fn delete_platform_account( - pool: &Pool, - account_uuid: Uuid, -) -> Result<(), Error> { +pub async fn delete_platform_account(pool: &Pool, account_uuid: Uuid) -> Result<(), Error> { sqlx::query( r#" UPDATE platform_accounts SET deleted_at = CURRENT_TIMESTAMP @@ -289,10 +283,7 @@ pub async fn remove_environment_account( } /// 清空环境的所有账号关联 -pub async fn clear_environment_accounts( - pool: &Pool, - env_uuid: Uuid, -) -> Result<(), Error> { +pub async fn clear_environment_accounts(pool: &Pool, env_uuid: Uuid) -> Result<(), Error> { sqlx::query( r#" DELETE FROM environment_accounts WHERE environment_uuid = $1; diff --git a/src-tauri/crates/business/src/models/environments.rs b/src-tauri/crates/business/src/models/environments.rs index 9827f91..7ec9328 100644 --- a/src-tauri/crates/business/src/models/environments.rs +++ b/src-tauri/crates/business/src/models/environments.rs @@ -191,10 +191,7 @@ pub async fn fetch_tags( } /// 根据 UUID 查询标签 -pub async fn fetch_tag_by_uuid( - pool: &Pool, - tag_uuid: Uuid, -) -> Result, Error> { +pub async fn fetch_tag_by_uuid(pool: &Pool, tag_uuid: Uuid) -> Result, Error> { let rec = sqlx::query_as::<_, TagDto>( r#" SELECT id, uuid, user_uuid, team_uuid, name, color, sort_order, @@ -605,6 +602,22 @@ pub async fn update_environment( Ok(()) } +pub async fn update_environment_kernel_info( + pool: &Pool, + env_uuid: Uuid, + kernel_info: &str, +) -> Result<(), Error> { + sqlx::query( + "UPDATE environments SET kernel_info = $1, updated_at = CURRENT_TIMESTAMP \ + WHERE uuid = $2 AND deleted_at IS NULL", + ) + .bind(kernel_info) + .bind(env_uuid) + .execute(pool) + .await?; + Ok(()) +} + /// 更新环境状态 pub async fn update_environment_status( pool: &Pool, @@ -646,10 +659,7 @@ pub async fn update_environment_proxy( } /// 更新环境最后打开时间 -pub async fn update_environment_last_opened( - pool: &Pool, - env_uuid: Uuid, -) -> Result<(), Error> { +pub async fn update_environment_last_opened(pool: &Pool, env_uuid: Uuid) -> Result<(), Error> { sqlx::query( r#" UPDATE environments SET last_opened_at = CURRENT_TIMESTAMP @@ -679,10 +689,7 @@ pub async fn delete_environment(pool: &Pool, env_uuid: Uuid) -> Result<(), E } /// 批量软删除环境 -pub async fn batch_delete_environments( - pool: &Pool, - env_uuids: &[Uuid], -) -> Result { +pub async fn batch_delete_environments(pool: &Pool, env_uuids: &[Uuid]) -> Result { if env_uuids.is_empty() { return Ok(0); } @@ -846,10 +853,7 @@ pub async fn restore_environment(pool: &Pool, env_uuid: Uuid) -> Result<(), } /// 批量恢复环境 -pub async fn batch_restore_environments( - pool: &Pool, - env_uuids: &[Uuid], -) -> Result { +pub async fn batch_restore_environments(pool: &Pool, env_uuids: &[Uuid]) -> Result { if env_uuids.is_empty() { return Ok(0); } @@ -871,10 +875,7 @@ pub async fn batch_restore_environments( } /// 永久删除环境(真正的 DELETE) -pub async fn permanent_delete_environment( - pool: &Pool, - env_uuid: Uuid, -) -> Result<(), Error> { +pub async fn permanent_delete_environment(pool: &Pool, env_uuid: Uuid) -> Result<(), Error> { // 先删除关联数据 sqlx::query("DELETE FROM environment_tags WHERE environment_uuid = $1") .bind(env_uuid) @@ -1115,10 +1116,7 @@ pub async fn clear_environment_tags(pool: &Pool, env_uuid: Uuid) -> Result<( } /// 查询环境的所有标签 -pub async fn fetch_environment_tags( - pool: &Pool, - env_uuid: Uuid, -) -> Result, Error> { +pub async fn fetch_environment_tags(pool: &Pool, env_uuid: Uuid) -> Result, Error> { let recs = sqlx::query_as::<_, TagDto>( r#" SELECT t.id, t.uuid, t.user_uuid, t.team_uuid, t.name, t.color, t.sort_order, @@ -1410,10 +1408,7 @@ pub async fn update_template( } /// 增加模板使用次数 -pub async fn increment_template_usage( - pool: &Pool, - template_uuid: Uuid, -) -> Result<(), Error> { +pub async fn increment_template_usage(pool: &Pool, template_uuid: Uuid) -> Result<(), Error> { sqlx::query( r#" UPDATE templates SET usage_count = usage_count + 1 @@ -1700,10 +1695,7 @@ pub async fn delete_environment_cookie(pool: &Pool, cookie_id: i32) -> Resul } /// 清空环境的所有 Cookies -pub async fn clear_environment_cookies( - pool: &Pool, - env_uuid: Uuid, -) -> Result { +pub async fn clear_environment_cookies(pool: &Pool, env_uuid: Uuid) -> Result { let result = sqlx::query( r#" DELETE FROM environment_cookies WHERE environment_uuid = $1 diff --git a/src-tauri/crates/business/src/models/local_api.rs b/src-tauri/crates/business/src/models/local_api.rs index 2de2cc9..b66df59 100644 --- a/src-tauri/crates/business/src/models/local_api.rs +++ b/src-tauri/crates/business/src/models/local_api.rs @@ -327,12 +327,7 @@ pub fn build_cors_origins_value(origins: &[String]) -> Value { pub fn parse_cors_origins(value: &Value) -> Vec { value .as_array() - .map(|items| { - items - .iter() - .filter_map(|item| item.as_str().map(ToOwned::to_owned)) - .collect() - }) + .map(|items| items.iter().filter_map(|item| item.as_str().map(ToOwned::to_owned)).collect()) .unwrap_or_default() } diff --git a/src-tauri/crates/business/src/models/proxies.rs b/src-tauri/crates/business/src/models/proxies.rs index 6f8f107..02922ae 100644 --- a/src-tauri/crates/business/src/models/proxies.rs +++ b/src-tauri/crates/business/src/models/proxies.rs @@ -234,10 +234,7 @@ pub async fn delete_proxy(pool: &Pool, proxy_uuid: Uuid) -> Result<(), Error } /// 批量软删除代理 -pub async fn batch_delete_proxies( - pool: &Pool, - proxy_uuids: &[Uuid], -) -> Result { +pub async fn batch_delete_proxies(pool: &Pool, proxy_uuids: &[Uuid]) -> Result { if proxy_uuids.is_empty() { return Ok(0); } diff --git a/src-tauri/crates/business/src/models/rpa.rs b/src-tauri/crates/business/src/models/rpa.rs index efd227d..7f7fa74 100644 --- a/src-tauri/crates/business/src/models/rpa.rs +++ b/src-tauri/crates/business/src/models/rpa.rs @@ -250,10 +250,7 @@ pub async fn delete_rpa_task(pool: &Pool, task_uuid: Uuid) -> Result<(), Err } /// 批量软删除 RPA 任务 -pub async fn batch_delete_rpa_tasks( - pool: &Pool, - task_uuids: &[Uuid], -) -> Result { +pub async fn batch_delete_rpa_tasks(pool: &Pool, task_uuids: &[Uuid]) -> Result { if task_uuids.is_empty() { return Ok(0); } @@ -392,10 +389,7 @@ pub async fn fetch_rpa_task_environments( } /// 删除任务所有环境关联 -pub async fn delete_rpa_task_environments( - pool: &Pool, - task_uuid: Uuid, -) -> Result<(), Error> { +pub async fn delete_rpa_task_environments(pool: &Pool, task_uuid: Uuid) -> Result<(), Error> { sqlx::query("DELETE FROM rpa_task_environments WHERE task_uuid = $1") .bind(task_uuid) .execute(pool) diff --git a/src-tauri/crates/business/src/models/teams.rs b/src-tauri/crates/business/src/models/teams.rs index d21d161..967dc09 100644 --- a/src-tauri/crates/business/src/models/teams.rs +++ b/src-tauri/crates/business/src/models/teams.rs @@ -104,6 +104,23 @@ pub async fn fetch_user_teams( Ok(recs) } +/// 查询用户在本机加入的全部团队,不以当前工作空间为边界。 +pub async fn fetch_all_user_teams(pool: &Pool, user_uuid: Uuid) -> Result, Error> { + sqlx::query_as::<_, TeamDto>( + r#" + SELECT t.id, t.uuid, t.workspace_uuid, t.name, t.description, t.owner_uuid, t.avatar_hash, + t.status, t.created_at, t.updated_at, t.deleted_at + FROM teams t + INNER JOIN team_members tm ON t.uuid = tm.team_uuid + WHERE tm.user_uuid = $1 AND t.deleted_at IS NULL AND tm.deleted_at IS NULL + ORDER BY t.created_at + "#, + ) + .bind(user_uuid) + .fetch_all(pool) + .await +} + /// 查询用户当前团队 pub async fn fetch_user_current_team( pool: &Pool, @@ -189,10 +206,7 @@ pub async fn update_team( query.push_str(&format!("{}", params.len())); // 这里需要动态构建查询,但 sqlx 不支持动态查询,所以使用条件分支 - if name.is_some() - && description.is_some() - && avatar_hash.is_some() - { + if name.is_some() && description.is_some() && avatar_hash.is_some() { sqlx::query( r#" UPDATE teams @@ -640,10 +654,7 @@ pub async fn fetch_invitation_by_token( } /// 取消邀请 -pub async fn cancel_team_invitation( - pool: &Pool, - invitation_uuid: Uuid, -) -> Result<(), Error> { +pub async fn cancel_team_invitation(pool: &Pool, invitation_uuid: Uuid) -> Result<(), Error> { sqlx::query( r#" UPDATE team_invitations diff --git a/src-tauri/crates/business/src/models/user.rs b/src-tauri/crates/business/src/models/user.rs index 943e219..efef2ae 100644 --- a/src-tauri/crates/business/src/models/user.rs +++ b/src-tauri/crates/business/src/models/user.rs @@ -118,12 +118,8 @@ pub async fn create_user_with_info( .await?; // 6. 初始化本地 API 权限记录 - insert_local_api_permissions( - &mut tx, - local_api_key_id, - &local_api_permission_definitions, - ) - .await?; + insert_local_api_permissions(&mut tx, local_api_key_id, &local_api_permission_definitions) + .await?; // 7. 创建个人团队(每个用户都应该有一个团队) // 团队名称使用用户昵称,如果没有昵称则使用邮箱前缀 @@ -244,7 +240,6 @@ fn generate_local_api_key() -> String { format!("sk_local_{}", raw) } - /// 根据 UUID 查询用户 pub async fn fetch_user_by_uuid( pool: &Pool, diff --git a/src-tauri/crates/business/src/models/workspace_quotas.rs b/src-tauri/crates/business/src/models/workspace_quotas.rs index 6bc2752..1a1bbe6 100644 --- a/src-tauri/crates/business/src/models/workspace_quotas.rs +++ b/src-tauri/crates/business/src/models/workspace_quotas.rs @@ -151,10 +151,7 @@ pub async fn decrement_used_proxies( } /// 更新团队成员使用数(统计所有团队的活跃成员) -pub async fn update_used_team_members( - pool: &Pool, - workspace_uuid: Uuid, -) -> Result<(), Error> { +pub async fn update_used_team_members(pool: &Pool, workspace_uuid: Uuid) -> Result<(), Error> { sqlx::query( r#" UPDATE workspace_quotas wq diff --git a/src-tauri/crates/business/src/services.rs b/src-tauri/crates/business/src/services.rs index 8dfe064..0ecf6ac 100644 --- a/src-tauri/crates/business/src/services.rs +++ b/src-tauri/crates/business/src/services.rs @@ -1,12 +1,14 @@ mod time; - -// 新增模块 + +// 新增模块 pub mod accounts; pub mod audit; +pub mod browser_kernels; pub mod environments; pub mod group_permissions; pub mod groups; pub mod local_api; +pub mod local_users; pub mod messages; pub mod preferences; pub mod proxies; @@ -17,10 +19,10 @@ pub mod teams; pub mod templates; pub mod workspace_quotas; pub mod workspaces; - + pub use time::*; - -// 新增导出 + +// 新增导出 pub use accounts::*; pub use audit::*; pub use environments::*; diff --git a/src-tauri/crates/business/src/services/browser_kernels.rs b/src-tauri/crates/business/src/services/browser_kernels.rs new file mode 100644 index 0000000..57290a7 --- /dev/null +++ b/src-tauri/crates/business/src/services/browser_kernels.rs @@ -0,0 +1,705 @@ +use std::{collections::BTreeMap, path::Path, sync::OnceLock}; + +use serde::{Deserialize, Serialize}; +use sha2::{Digest, Sha256}; +use sqlx::FromRow; +use uuid::Uuid; + +use crate::database::DbPool; + +const DEFAULT_BROWSER_KERNELS_JSON: &str = + include_str!("../../resources/default-browser-kernels.json"); +const SUPPORTED_SCHEMA_VERSION: u32 = 1; +const KERNEL_TYPE_PREFIX: &str = "SIMPRINT_KERNEL_"; + +#[derive(Debug, Clone, Deserialize, Serialize, FromRow, PartialEq, Eq)] +pub struct BrowserKernelVersion { + pub kernel_id: String, + pub type_code: String, + pub resource_name: String, + pub install_dir_name: String, + pub version: String, + pub name: Option, + pub notes: Option, + pub platform: String, + pub url: Option, + pub hash: String, + pub signature: String, + pub compatible_signatures: sqlx::types::Json>, + pub file_size: Option, + pub is_latest: bool, + pub status: String, + pub arch: String, + pub package_format: String, + pub requires_extract: bool, + pub entrypoint_template: Option, + pub extract_root: Option, + pub installed: bool, +} + +#[derive(Debug, Clone, Deserialize, Serialize)] +struct BrowserKernelRecord { + type_code: String, + resource_name: String, + #[serde(default)] + install_dir_name: Option, + version: String, + name: Option, + notes: Option, + platform: String, + url: String, + #[serde(default = "default_source_priority")] + priority: i32, + hash: String, + signature: String, + #[serde(default)] + compatible_signatures: Vec, + file_size: Option, + #[serde(default)] + is_latest: bool, + #[serde(default = "active_status")] + status: String, + arch: String, + package_format: String, + #[serde(default)] + requires_extract: bool, + entrypoint_template: Option, + extract_root: Option, +} + +#[derive(Debug, Clone, Deserialize)] +struct BrowserKernelCatalog { + schema_version: u32, + source_id: String, + kernels: Vec, +} + +#[derive(Serialize)] +struct BrowserKernelIdentity<'a> { + type_code: &'a str, + platform: &'a str, + arch: &'a str, + package_hash: &'a str, + executable_signature: &'a str, + package_format: &'a str, + requires_extract: bool, + entrypoint_template: Option<&'a str>, + extract_root: Option<&'a str>, +} + +static DEFAULT_CATALOG: OnceLock> = OnceLock::new(); + +fn active_status() -> String { + "active".to_string() +} + +fn default_source_priority() -> i32 { + 100 +} + +fn normalize_required(value: &str, field: &str) -> Result { + let value = value.trim(); + if value.is_empty() { + Err(format!("Browser kernel field {field} cannot be empty")) + } else { + Ok(value.to_string()) + } +} + +fn kernel_id(record: &BrowserKernelRecord) -> Result { + let identity = BrowserKernelIdentity { + type_code: record.type_code.trim(), + platform: record.platform.trim(), + arch: record.arch.trim(), + package_hash: record.hash.trim(), + executable_signature: record.signature.trim(), + package_format: record.package_format.trim(), + requires_extract: record.requires_extract, + entrypoint_template: record.entrypoint_template.as_deref().map(str::trim), + extract_root: record.extract_root.as_deref().map(str::trim), + }; + let canonical = serde_json::to_vec(&identity) + .map_err(|error| format!("Failed to serialize browser kernel identity: {error}"))?; + Ok(hex::encode(Sha256::digest(canonical))) +} + +fn validate_catalog(catalog: BrowserKernelCatalog) -> Result { + if catalog.schema_version != SUPPORTED_SCHEMA_VERSION { + return Err(format!( + "Unsupported default browser kernel catalog schema version: {}", + catalog.schema_version + )); + } + normalize_required(&catalog.source_id, "source_id")?; + if catalog.kernels.is_empty() { + return Err("The default browser kernel catalog is empty".to_string()); + } + + for record in &catalog.kernels { + if !record.type_code.starts_with(KERNEL_TYPE_PREFIX) { + return Err(format!( + "Invalid browser kernel type code: {}", + record.type_code + )); + } + normalize_required(&record.resource_name, "resource_name")?; + if let Some(install_dir_name) = &record.install_dir_name { + normalize_required(install_dir_name, "install_dir_name")?; + } + normalize_required(&record.version, "version")?; + normalize_required(&record.platform, "platform")?; + normalize_required(&record.url, "url")?; + normalize_required(&record.hash, "hash")?; + normalize_required(&record.signature, "signature")?; + for signature in &record.compatible_signatures { + let signature = normalize_required(signature, "compatible_signatures")?; + if signature.len() != 64 || !signature.bytes().all(|byte| byte.is_ascii_hexdigit()) { + return Err(format!( + "Invalid compatible browser kernel signature: {signature}" + )); + } + } + normalize_required(&record.arch, "arch")?; + normalize_required(&record.package_format, "package_format")?; + kernel_id(record)?; + } + + Ok(catalog) +} + +fn parse_catalog(contents: &str, source_label: &str) -> Result { + let catalog: BrowserKernelCatalog = serde_json::from_str(contents).map_err(|error| { + format!("Failed to parse browser kernel catalog {source_label}: {error}") + })?; + validate_catalog(catalog) +} + +fn parse_default_catalog() -> Result { + parse_catalog(DEFAULT_BROWSER_KERNELS_JSON, "simprint-builtin") +} + +fn default_catalog() -> Result<&'static BrowserKernelCatalog, String> { + DEFAULT_CATALOG + .get_or_init(parse_default_catalog) + .as_ref() + .map_err(Clone::clone) +} + +async fn import_catalog(pool: &DbPool, catalog: &BrowserKernelCatalog) -> Result { + let mut tx = pool.begin().await.map_err(|error| error.to_string())?; + + for record in &catalog.kernels { + let kernel_id = kernel_id(record)?; + sqlx::query( + r#" + INSERT INTO browser_kernel_artifacts ( + kernel_id, type_code, resource_name, install_dir_name, version, name, notes, platform, + package_hash, executable_signature, compatible_executable_signatures, + file_size, is_latest, status, + arch, package_format, requires_extract, entrypoint_template, extract_root + ) VALUES ($1, $2, $3, $4, $5, $6, $7, $8, $9, $10, $11, $12, $13, $14, $15, $16, $17, $18, $19) + ON CONFLICT (kernel_id) DO UPDATE SET + resource_name = excluded.resource_name, + version = excluded.version, + name = excluded.name, + notes = excluded.notes, + compatible_executable_signatures = excluded.compatible_executable_signatures, + file_size = excluded.file_size, + is_latest = excluded.is_latest, + status = excluded.status, + updated_at = CURRENT_TIMESTAMP + "#, + ) + .bind(&kernel_id) + .bind(record.type_code.trim()) + .bind(record.resource_name.trim()) + .bind( + record + .install_dir_name + .as_deref() + .map(str::trim) + .unwrap_or_else(|| record.resource_name.trim()), + ) + .bind(record.version.trim()) + .bind(record.name.as_deref().map(str::trim)) + .bind(record.notes.as_deref().map(str::trim)) + .bind(record.platform.trim()) + .bind(record.hash.trim()) + .bind(record.signature.trim()) + .bind(sqlx::types::Json(&record.compatible_signatures)) + .bind(record.file_size) + .bind(record.is_latest) + .bind(record.status.trim()) + .bind(record.arch.trim()) + .bind(record.package_format.trim()) + .bind(record.requires_extract) + .bind(record.entrypoint_template.as_deref().map(str::trim)) + .bind(record.extract_root.as_deref().map(str::trim)) + .execute(&mut *tx) + .await + .map_err(|error| error.to_string())?; + + sqlx::query( + "UPDATE browser_kernel_sources SET is_active = 0 \ + WHERE kernel_id = $1 AND source_id = $2", + ) + .bind(&kernel_id) + .bind(catalog.source_id.trim()) + .execute(&mut *tx) + .await + .map_err(|error| error.to_string())?; + + sqlx::query( + r#" + INSERT INTO browser_kernel_sources (kernel_id, source_id, url, priority) + VALUES ($1, $2, $3, $4) + ON CONFLICT (kernel_id, source_id, url) DO UPDATE SET + is_active = 1, + priority = excluded.priority, + last_seen_at = CURRENT_TIMESTAMP + "#, + ) + .bind(&kernel_id) + .bind(catalog.source_id.trim()) + .bind(record.url.trim()) + .bind(record.priority) + .execute(&mut *tx) + .await + .map_err(|error| error.to_string())?; + } + + tx.commit().await.map_err(|error| error.to_string())?; + Ok(catalog.kernels.len()) +} + +/// Import the bundled manifest into the runtime registry. Import is additive: +/// the content-derived kernel identity and existing environment bindings are +/// never replaced when a manifest record changes. +pub async fn import_default_catalog(pool: &DbPool) -> Result { + import_catalog(pool, default_catalog()?).await +} + +/// Import an optional user-maintained catalog with exactly the same rules as +/// the bundled catalog. Missing files are intentionally treated as no input. +pub async fn import_catalog_file(pool: &DbPool, path: &Path) -> Result { + if !path.exists() { + return Ok(0); + } + let contents = std::fs::read_to_string(path).map_err(|error| { + format!( + "Failed to read browser kernel catalog {}: {error}", + path.display() + ) + })?; + let catalog = parse_catalog(&contents, &path.display().to_string())?; + import_catalog(pool, &catalog).await +} + +const KERNEL_SELECT: &str = r#" + SELECT + a.kernel_id, + a.type_code, + a.resource_name, + COALESCE(a.install_dir_name, a.resource_name) AS install_dir_name, + a.version, + a.name, + a.notes, + a.platform, + ( + SELECT source.url + FROM browser_kernel_sources source + WHERE source.kernel_id = a.kernel_id AND source.is_active = 1 + ORDER BY source.priority ASC, source.id ASC + LIMIT 1 + ) AS url, + a.package_hash AS hash, + a.executable_signature AS signature, + a.compatible_executable_signatures AS compatible_signatures, + a.file_size, + a.is_latest, + a.status, + a.arch, + a.package_format, + a.requires_extract, + a.entrypoint_template, + a.extract_root, + EXISTS ( + SELECT 1 FROM browser_kernel_installations installation + WHERE installation.kernel_id = a.kernel_id AND installation.status = 'ready' + ) AS installed + FROM browser_kernel_artifacts a +"#; + +pub async fn list_browser_kernels( + pool: &DbPool, + platform: Option<&str>, + type_code: Option<&str>, +) -> Result>, String> { + let platform = platform.map(str::trim).filter(|value| !value.is_empty()); + let type_code = type_code.map(str::trim).filter(|value| !value.is_empty()); + let rows = sqlx::query_as::<_, BrowserKernelVersion>(&format!( + "{KERNEL_SELECT} WHERE a.status = 'active' ORDER BY a.is_latest DESC, a.version DESC" + )) + .fetch_all(pool) + .await + .map_err(|error| error.to_string())?; + let mut groups = BTreeMap::>::new(); + + for row in rows { + if platform.is_some_and(|value| !row.platform.eq_ignore_ascii_case(value)) { + continue; + } + if type_code.is_some_and(|value| row.type_code != value) { + continue; + } + if type_code.is_none() && !row.type_code.starts_with(KERNEL_TYPE_PREFIX) { + continue; + } + groups.entry(row.type_code.clone()).or_default().push(row); + } + + Ok(groups) +} + +pub async fn get_browser_kernel( + pool: &DbPool, + kernel_id: &str, +) -> Result, String> { + sqlx::query_as::<_, BrowserKernelVersion>(&format!( + "{KERNEL_SELECT} WHERE a.kernel_id = $1 LIMIT 1" + )) + .bind(kernel_id.trim()) + .fetch_optional(pool) + .await + .map_err(|error| error.to_string()) +} + +pub async fn find_browser_kernel_by_name( + pool: &DbPool, + resource_name: &str, +) -> Result, String> { + sqlx::query_as::<_, BrowserKernelVersion>(&format!( + "{KERNEL_SELECT} WHERE a.resource_name = $1 AND a.status = 'active' \ + ORDER BY a.is_latest DESC, a.version DESC LIMIT 1" + )) + .bind(resource_name.trim()) + .fetch_optional(pool) + .await + .map_err(|error| error.to_string()) +} + +pub async fn default_browser_kernel(pool: &DbPool) -> Result, String> { + sqlx::query_as::<_, BrowserKernelVersion>(&format!( + "{KERNEL_SELECT} WHERE a.type_code = 'SIMPRINT_KERNEL_CHROMIUM' \ + AND a.status = 'active' ORDER BY a.is_latest DESC, a.version DESC LIMIT 1" + )) + .fetch_optional(pool) + .await + .map_err(|error| error.to_string()) +} + +pub async fn bind_environment_kernel( + pool: &DbPool, + environment_uuid: Uuid, + kernel_id: &str, +) -> Result<(), String> { + if get_browser_kernel(pool, kernel_id).await?.is_none() { + return Err(format!( + "Browser kernel does not exist: {}", + kernel_id.trim() + )); + } + sqlx::query( + r#" + INSERT INTO environment_kernel_bindings (environment_uuid, kernel_id) + VALUES ($1, $2) + ON CONFLICT (environment_uuid) DO UPDATE SET + kernel_id = excluded.kernel_id, + updated_at = CURRENT_TIMESTAMP + "#, + ) + .bind(environment_uuid) + .bind(kernel_id.trim()) + .execute(pool) + .await + .map_err(|error| error.to_string())?; + Ok(()) +} + +pub async fn get_environment_kernel( + pool: &DbPool, + environment_uuid: Uuid, +) -> Result, String> { + sqlx::query_as::<_, BrowserKernelVersion>(&format!( + "{KERNEL_SELECT} INNER JOIN environment_kernel_bindings binding \ + ON binding.kernel_id = a.kernel_id WHERE binding.environment_uuid = $1 LIMIT 1" + )) + .bind(environment_uuid) + .fetch_optional(pool) + .await + .map_err(|error| error.to_string()) +} + +pub async fn resolve_requested_kernel( + pool: &DbPool, + window_info: &serde_json::Value, + allow_default: bool, +) -> Result { + if let Some(kernel_id) = window_info + .get("kernel_id") + .and_then(serde_json::Value::as_str) + .map(str::trim) + .filter(|value| !value.is_empty()) + { + return get_browser_kernel(pool, kernel_id) + .await? + .ok_or_else(|| format!("Browser kernel does not exist: {kernel_id}")); + } + if let Some(resource_name) = window_info + .get("kernel") + .and_then(serde_json::Value::as_str) + .map(str::trim) + .filter(|value| !value.is_empty() && !value.eq_ignore_ascii_case("chrome")) + { + if let Some(kernel) = find_browser_kernel_by_name(pool, resource_name).await? { + return Ok(kernel); + } + } + if allow_default { + if let Some(kernel) = default_browser_kernel(pool).await? { + return Ok(kernel); + } + } + Err("This environment has no valid browser kernel binding".to_string()) +} + +pub async fn migrate_legacy_environment_bindings(pool: &DbPool) -> Result { + let rows = sqlx::query_as::<_, (Uuid, String)>( + r#" + SELECT config.environment_uuid, config.window_info + FROM environment_configs config + LEFT JOIN environment_kernel_bindings binding + ON binding.environment_uuid = config.environment_uuid + WHERE binding.environment_uuid IS NULL + "#, + ) + .fetch_all(pool) + .await + .map_err(|error| error.to_string())?; + let mut migrated = 0; + + for (environment_uuid, raw_window_info) in rows { + let mut window_info: serde_json::Value = match serde_json::from_str(&raw_window_info) { + Ok(value) => value, + Err(_) => continue, + }; + let kernel = match resolve_requested_kernel(pool, &window_info, false).await { + Ok(kernel) => kernel, + Err(_) => continue, + }; + bind_environment_kernel(pool, environment_uuid, &kernel.kernel_id).await?; + if let Some(object) = window_info.as_object_mut() { + object.insert( + "kernel_id".to_string(), + serde_json::Value::String(kernel.kernel_id), + ); + sqlx::query( + "UPDATE environment_configs SET window_info = $1, updated_at = CURRENT_TIMESTAMP \ + WHERE environment_uuid = $2", + ) + .bind(window_info) + .bind(environment_uuid) + .execute(pool) + .await + .map_err(|error| error.to_string())?; + } + migrated += 1; + } + + Ok(migrated) +} + +pub async fn record_kernel_installation( + pool: &DbPool, + kernel_id: &str, + install_path: &str, + verified_signature: &str, +) -> Result<(), String> { + if get_browser_kernel(pool, kernel_id).await?.is_none() { + return Ok(()); + } + sqlx::query( + r#" + INSERT INTO browser_kernel_installations ( + kernel_id, install_path, verified_signature, status, verified_at + ) VALUES ($1, $2, $3, 'ready', CURRENT_TIMESTAMP) + ON CONFLICT (kernel_id) DO UPDATE SET + install_path = excluded.install_path, + verified_signature = excluded.verified_signature, + status = 'ready', + verified_at = CURRENT_TIMESTAMP, + updated_at = CURRENT_TIMESTAMP + "#, + ) + .bind(kernel_id.trim()) + .bind(install_path) + .bind(verified_signature.trim()) + .execute(pool) + .await + .map_err(|error| error.to_string())?; + Ok(()) +} + +#[cfg(test)] +mod tests { + use super::*; + use crate::{database, utils::DatabaseConfig}; + + async fn test_pool() -> DbPool { + let mut config = DatabaseConfig::embedded("sqlite::memory:"); + config.max_connections = 1; + config.min_connections = 1; + let pool = database::connect(&config).await.unwrap(); + database::migrate(&pool).await.unwrap(); + pool + } + + #[test] + fn content_identity_ignores_display_and_download_location() { + let catalog = parse_default_catalog().unwrap(); + let mut changed = catalog.kernels[0].clone(); + let original_id = kernel_id(&changed).unwrap(); + changed.resource_name = "Renamed kernel".to_string(); + changed.url = "https://example.invalid/mirror.zip".to_string(); + assert_eq!(kernel_id(&changed).unwrap(), original_id); + + changed.hash = "different-package-hash".to_string(); + assert_ne!(kernel_id(&changed).unwrap(), original_id); + } + + #[tokio::test] + async fn imports_and_queries_the_bundled_catalog() { + let pool = test_pool().await; + import_default_catalog(&pool).await.unwrap(); + import_default_catalog(&pool).await.unwrap(); + + let groups = list_browser_kernels(&pool, Some("windows"), Some("SIMPRINT_KERNEL_CHROMIUM")) + .await + .unwrap(); + let kernels = &groups["SIMPRINT_KERNEL_CHROMIUM"]; + assert_eq!(kernels.len(), 1, "catalog import must be idempotent"); + assert_eq!(kernels[0].resource_name, "Chrome 144"); + assert_eq!(kernels[0].install_dir_name, "Chrome 144"); + assert_eq!(kernels[0].kernel_id.len(), 64); + assert_eq!(kernels[0].compatible_signatures.len(), 1); + assert_eq!( + kernels[0].compatible_signatures[0], + "26afa023c6637f045b6825a8e14b720a2305504b313f063bc8410bb0aef19bbf" + ); + assert!(kernels[0].url.as_deref().unwrap().starts_with("https://")); + } + + #[tokio::test] + async fn replaces_a_sources_url_without_replacing_the_artifact() { + let pool = test_pool().await; + let mut catalog = parse_default_catalog().unwrap(); + catalog.source_id = "test-user-source".to_string(); + catalog.kernels[0].priority = 10; + catalog.kernels[0].url = "https://example.invalid/first.zip".to_string(); + import_catalog(&pool, &catalog).await.unwrap(); + catalog.kernels[0].resource_name = "Renamed Chrome 144".to_string(); + catalog.kernels[0].install_dir_name = Some("Another directory".to_string()); + catalog.kernels[0].url = "https://example.invalid/second.zip".to_string(); + import_catalog(&pool, &catalog).await.unwrap(); + + let artifact_count: i64 = + sqlx::query_scalar("SELECT COUNT(*) FROM browser_kernel_artifacts") + .fetch_one(&pool) + .await + .unwrap(); + let active_source: String = sqlx::query_scalar( + "SELECT url FROM browser_kernel_sources \ + WHERE source_id = 'test-user-source' AND is_active = 1", + ) + .fetch_one(&pool) + .await + .unwrap(); + let active_source_count: i64 = sqlx::query_scalar( + "SELECT COUNT(*) FROM browser_kernel_sources \ + WHERE source_id = 'test-user-source' AND is_active = 1", + ) + .fetch_one(&pool) + .await + .unwrap(); + let install_dir_name: String = + sqlx::query_scalar("SELECT install_dir_name FROM browser_kernel_artifacts") + .fetch_one(&pool) + .await + .unwrap(); + + assert_eq!(artifact_count, 1); + assert_eq!(active_source_count, 1); + assert_eq!(active_source, "https://example.invalid/second.zip"); + assert_eq!(install_dir_name, "Chrome 144"); + } + + #[tokio::test] + async fn migrates_legacy_environment_name_to_an_immutable_binding() { + let mut config = DatabaseConfig::embedded("sqlite::memory:"); + config.max_connections = 1; + config.min_connections = 1; + let context = crate::svc_ctx::SvcCtx::new(&config).await.unwrap(); + let (workspace_uuid, team_uuid): (Uuid, Uuid) = sqlx::query_as( + "SELECT current_workspace_uuid, current_team_uuid FROM user_infos \ + WHERE user_uuid = $1", + ) + .bind(context.local_user_uuid) + .fetch_one(&context.db) + .await + .unwrap(); + let environment_uuid = crate::models::environments::insert_environment( + &context.db, + workspace_uuid, + context.local_user_uuid, + team_uuid, + "Legacy browser", + None, + None, + None, + Some("Windows"), + Some("Chrome 144"), + ) + .await + .unwrap(); + crate::models::environments::upsert_environment_config( + &context.db, + environment_uuid, + &serde_json::json!({ "kernel": "Chrome 144" }), + &serde_json::json!({}), + &serde_json::json!({}), + &serde_json::json!({}), + &serde_json::json!({}), + &serde_json::json!({}), + ) + .await + .unwrap(); + + assert_eq!( + migrate_legacy_environment_bindings(&context.db).await.unwrap(), + 1 + ); + let bound = get_environment_kernel(&context.db, environment_uuid).await.unwrap().unwrap(); + assert_eq!(bound.resource_name, "Chrome 144"); + assert_eq!(bound.kernel_id.len(), 64); + + let stored = + crate::models::environments::fetch_environment_config(&context.db, environment_uuid) + .await + .unwrap() + .unwrap(); + assert_eq!( + stored.window_info["kernel_id"].as_str(), + Some(bound.kernel_id.as_str()) + ); + } +} diff --git a/src-tauri/crates/business/src/services/environments.rs b/src-tauri/crates/business/src/services/environments.rs index d73ee5d..bb9a76d 100644 --- a/src-tauri/crates/business/src/services/environments.rs +++ b/src-tauri/crates/business/src/services/environments.rs @@ -19,6 +19,24 @@ use crate::svc_ctx::SvcCtx; // ============ Environments ============ +fn window_info_with_kernel_id( + window_info: &serde_json::Value, + kernel: &crate::services::browser_kernels::BrowserKernelVersion, +) -> serde_json::Value { + let mut window_info = window_info.clone(); + if let Some(object) = window_info.as_object_mut() { + object.insert( + "kernel_id".to_string(), + serde_json::Value::String(kernel.kernel_id.clone()), + ); + object.insert( + "kernel".to_string(), + serde_json::Value::String(kernel.resource_name.clone()), + ); + } + window_info +} + /// 创建环境 pub async fn create_environment_service( svc_ctx: &SvcCtx, @@ -27,6 +45,14 @@ pub async fn create_environment_service( team_uuid: Uuid, payload: &CreateEnvironmentRequest, ) -> Result { + let selected_kernel = crate::services::browser_kernels::resolve_requested_kernel( + &svc_ctx.db, + &payload.config.window_info, + true, + ) + .await?; + let window_info = window_info_with_kernel_id(&payload.config.window_info, &selected_kernel); + // 1. 检查用户是否在当前工作空间的团队中(工作空间级别) let team_member = models::teams::fetch_team_member(&svc_ctx.db, workspace_uuid, team_uuid, user_uuid) @@ -83,12 +109,7 @@ pub async fn create_environment_service( .get("system") .and_then(|v| v.as_str()) .map(|s| s.to_string()); - let kernel_info = payload - .config - .window_info - .get("kernel") - .and_then(|v| v.as_str()) - .map(|s| s.to_string()); + let kernel_info = Some(selected_kernel.resource_name.clone()); // 创建环境 let env_uuid = models::insert_environment( @@ -110,7 +131,7 @@ pub async fn create_environment_service( models::upsert_environment_config( &svc_ctx.db, env_uuid, - &payload.config.window_info, + &window_info, &payload.config.basic_settings, &payload.config.fingerprint_settings, &payload.config.device_settings, @@ -120,6 +141,13 @@ pub async fn create_environment_service( .await .map_err(|e| e.to_string())?; + crate::services::browser_kernels::bind_environment_kernel( + &svc_ctx.db, + env_uuid, + &selected_kernel.kernel_id, + ) + .await?; + replace_environment_urls(svc_ctx, env_uuid, payload.urls.as_deref()).await?; replace_environment_cookies(svc_ctx, env_uuid, payload.cookies.as_deref()).await?; @@ -993,10 +1021,36 @@ pub async fn update_environment_service( // 更新配置 if let Some(config) = &payload.config { + let selected_kernel = if config + .window_info + .get("kernel_id") + .and_then(serde_json::Value::as_str) + .is_some_and(|value| !value.trim().is_empty()) + { + crate::services::browser_kernels::resolve_requested_kernel( + &svc_ctx.db, + &config.window_info, + false, + ) + .await? + } else if let Some(bound) = + crate::services::browser_kernels::get_environment_kernel(&svc_ctx.db, payload.uuid) + .await? + { + bound + } else { + crate::services::browser_kernels::resolve_requested_kernel( + &svc_ctx.db, + &config.window_info, + false, + ) + .await? + }; + let window_info = window_info_with_kernel_id(&config.window_info, &selected_kernel); models::upsert_environment_config( &svc_ctx.db, payload.uuid, - &config.window_info, + &window_info, &config.basic_settings, &config.fingerprint_settings, &config.device_settings, @@ -1005,6 +1059,19 @@ pub async fn update_environment_service( ) .await .map_err(|e| e.to_string())?; + crate::services::browser_kernels::bind_environment_kernel( + &svc_ctx.db, + payload.uuid, + &selected_kernel.kernel_id, + ) + .await?; + models::update_environment_kernel_info( + &svc_ctx.db, + payload.uuid, + &selected_kernel.resource_name, + ) + .await + .map_err(|error| error.to_string())?; } replace_environment_urls(svc_ctx, payload.uuid, payload.urls.as_deref()).await?; diff --git a/src-tauri/crates/business/src/services/local_api.rs b/src-tauri/crates/business/src/services/local_api.rs index 3836a3c..dbfcbfa 100644 --- a/src-tauri/crates/business/src/services/local_api.rs +++ b/src-tauri/crates/business/src/services/local_api.rs @@ -166,6 +166,7 @@ pub async fn validate_local_api_key_service( Ok(ValidateLocalApiKeyDto { valid: true, + user_uuid: api_key.user_uuid, permission_code: definition.permission_code, requests_today: api_key.requests_today + 1, daily_limit: api_key.daily_limit, diff --git a/src-tauri/crates/business/src/services/local_users.rs b/src-tauri/crates/business/src/services/local_users.rs new file mode 100644 index 0000000..66befeb --- /dev/null +++ b/src-tauri/crates/business/src/services/local_users.rs @@ -0,0 +1,422 @@ +use serde::{Deserialize, Serialize}; +use sha2::{Digest, Sha256}; +use uuid::Uuid; + +use crate::{dto::LocalApiPermissionDefinitionDto, svc_ctx::SvcCtx}; + +const PASSWORD_HASH_ROUNDS: u32 = 100_000; +pub const LOCAL_USER_AVATARS: &[&str] = &[ + "🙂", "😎", "🦊", "🐼", "🐯", "🐙", "🦉", "🐳", "🌙", "⭐", "🌿", "🚀", +]; + +#[derive(Debug, Clone, Serialize, sqlx::FromRow)] +#[serde(rename_all = "camelCase")] +pub struct LocalUser { + pub uuid: Uuid, + pub nickname: String, + pub avatar: String, + pub has_password: bool, + pub current_workspace_uuid: Option, + pub current_team_uuid: Option, +} + +#[derive(Debug, Deserialize)] +#[serde(rename_all = "camelCase")] +pub struct CreateLocalUserRequest { + pub nickname: String, + pub avatar: String, + pub password: Option, +} + +#[derive(Debug, Deserialize)] +#[serde(rename_all = "camelCase")] +pub struct LoginLocalUserRequest { + pub user_uuid: Uuid, + pub password: Option, +} + +pub async fn list_local_users(context: &SvcCtx) -> Result, String> { + sqlx::query_as::<_, LocalUser>( + r#" + SELECT + u.uuid, + COALESCE(NULLIF(ui.nickname, ''), 'Local User') AS nickname, + lua.avatar, + (lua.password_hash IS NOT NULL) AS has_password, + ui.current_workspace_uuid, + ui.current_team_uuid + FROM users u + JOIN user_infos ui ON ui.user_uuid = u.uuid + JOIN local_user_auth lua ON lua.user_uuid = u.uuid + WHERE u.deleted_at IS NULL AND ui.deleted_at IS NULL + ORDER BY lua.created_at, ui.id + "#, + ) + .fetch_all(&context.db) + .await + .map_err(|error| error.to_string()) +} + +pub async fn current_local_user(context: &SvcCtx) -> Result, String> { + let Some(user_uuid) = context.current_user_uuid() else { + return Ok(None); + }; + fetch_local_user(context, user_uuid).await +} + +pub async fn create_local_user( + context: &SvcCtx, + request: &CreateLocalUserRequest, +) -> Result { + let nickname = validate_nickname(&request.nickname)?; + let avatar = validate_avatar(&request.avatar)?; + let password = normalize_password(request.password.as_deref()); + let password_credentials = password.map(create_password_credentials); + let permission_definitions = + crate::models::local_api::fetch_permission_definitions(&context.db) + .await + .map_err(|error| error.to_string())?; + let mut transaction = context.db.begin().await.map_err(|error| error.to_string())?; + let user_uuid = Uuid::new_v4(); + let user_id = format!("LOCAL-{}", user_uuid.simple()); + + sqlx::query("INSERT INTO users (uuid, id) VALUES ($1, $2)") + .bind(user_uuid) + .bind(user_id) + .execute(&mut *transaction) + .await + .map_err(|error| error.to_string())?; + + // `email` and `password` are legacy columns retained while the copied business schema is + // being reduced. They are not local-account attributes and are never exposed to the UI. + sqlx::query( + "INSERT INTO user_infos (user_uuid, nickname, email, password, avatar_hash, status) \ + VALUES ($1, $2, $3, '', $4, 'active')", + ) + .bind(user_uuid) + .bind(&nickname) + .bind(format!("local-{user_uuid}@simprint.invalid")) + .bind(&avatar) + .execute(&mut *transaction) + .await + .map_err(|error| error.to_string())?; + + let (password_salt, password_hash) = password_credentials + .map(|(salt, hash)| (Some(salt), Some(hash))) + .unwrap_or((None, None)); + sqlx::query( + "INSERT INTO local_user_auth (user_uuid, avatar, password_salt, password_hash) \ + VALUES ($1, $2, $3, $4)", + ) + .bind(user_uuid) + .bind(&avatar) + .bind(password_salt) + .bind(password_hash) + .execute(&mut *transaction) + .await + .map_err(|error| error.to_string())?; + + sqlx::query( + "INSERT INTO user_preferences (user_uuid, theme, language, notifications_enabled) \ + VALUES ($1, 'system', 'zh-CN', TRUE)", + ) + .bind(user_uuid) + .execute(&mut *transaction) + .await + .map_err(|error| error.to_string())?; + + sqlx::query( + "INSERT INTO user_local_api_settings \ + (user_uuid, enabled, port, remote_access, cors_origins) \ + VALUES ($1, FALSE, 8080, FALSE, '[]')", + ) + .bind(user_uuid) + .execute(&mut *transaction) + .await + .map_err(|error| error.to_string())?; + + initialize_local_api_key(&mut transaction, user_uuid, &permission_definitions).await?; + + let workspace_uuid: Uuid = sqlx::query_scalar( + "INSERT INTO workspaces (name, owner_uuid, workspace_type) \ + VALUES ($1, $2, 'personal') RETURNING uuid", + ) + .bind(format!("{nickname} 的工作空间")) + .bind(user_uuid) + .fetch_one(&mut *transaction) + .await + .map_err(|error| error.to_string())?; + + let quota = &context.workspace_quota.default; + sqlx::query( + "INSERT INTO workspace_quotas \ + (workspace_uuid, max_environments, max_team_members, max_proxies, max_rpa_tasks) \ + VALUES ($1, $2, $3, $4, $5)", + ) + .bind(workspace_uuid) + .bind(quota.max_environments) + .bind(quota.max_team_members) + .bind(quota.max_proxies) + .bind(quota.max_rpa_tasks) + .execute(&mut *transaction) + .await + .map_err(|error| error.to_string())?; + + let team_uuid: Uuid = sqlx::query_scalar( + "INSERT INTO teams (workspace_uuid, name, description, owner_uuid) \ + VALUES ($1, $2, '默认团队', $3) RETURNING uuid", + ) + .bind(workspace_uuid) + .bind(format!("{nickname} 的团队")) + .bind(user_uuid) + .fetch_one(&mut *transaction) + .await + .map_err(|error| error.to_string())?; + + sqlx::query( + "INSERT INTO team_members (team_uuid, workspace_uuid, user_uuid, role, status) \ + VALUES ($1, $2, $3, 'owner', 'active')", + ) + .bind(team_uuid) + .bind(workspace_uuid) + .bind(user_uuid) + .execute(&mut *transaction) + .await + .map_err(|error| error.to_string())?; + + sqlx::query( + "UPDATE user_infos SET current_workspace_uuid = $1, current_team_uuid = $2 \ + WHERE user_uuid = $3", + ) + .bind(workspace_uuid) + .bind(team_uuid) + .bind(user_uuid) + .execute(&mut *transaction) + .await + .map_err(|error| error.to_string())?; + + transaction.commit().await.map_err(|error| error.to_string())?; + fetch_local_user(context, user_uuid) + .await? + .ok_or_else(|| "本地用户创建后无法读取".to_string()) +} + +pub async fn authenticate_local_user( + context: &SvcCtx, + request: &LoginLocalUserRequest, +) -> Result { + verify_local_user_password(context, request.user_uuid, request.password.as_deref()).await?; + + let user = fetch_local_user(context, request.user_uuid) + .await? + .ok_or_else(|| "本地用户不存在".to_string())?; + context.authenticate_user(user.uuid); + Ok(user) +} + +pub async fn verify_local_user_password( + context: &SvcCtx, + user_uuid: Uuid, + password: Option<&str>, +) -> Result<(), String> { + let credentials = sqlx::query_as::<_, (Option, Option)>( + "SELECT password_salt, password_hash FROM local_user_auth WHERE user_uuid = $1", + ) + .bind(user_uuid) + .fetch_optional(&context.db) + .await + .map_err(|error| error.to_string())? + .ok_or_else(|| "本地用户不存在".to_string())?; + + match credentials { + (None, None) => {} + (Some(salt), Some(expected_hash)) => { + let password = password.unwrap_or_default(); + let actual_hash = derive_password_hash(password, &salt); + if !constant_time_equal(actual_hash.as_bytes(), expected_hash.as_bytes()) { + return Err("密码不正确".to_string()); + } + } + _ => return Err("本地用户密码状态无效".to_string()), + } + Ok(()) +} + +async fn fetch_local_user(context: &SvcCtx, user_uuid: Uuid) -> Result, String> { + sqlx::query_as::<_, LocalUser>( + r#" + SELECT + u.uuid, + COALESCE(NULLIF(ui.nickname, ''), 'Local User') AS nickname, + lua.avatar, + (lua.password_hash IS NOT NULL) AS has_password, + ui.current_workspace_uuid, + ui.current_team_uuid + FROM users u + JOIN user_infos ui ON ui.user_uuid = u.uuid + JOIN local_user_auth lua ON lua.user_uuid = u.uuid + WHERE u.uuid = $1 AND u.deleted_at IS NULL AND ui.deleted_at IS NULL + "#, + ) + .bind(user_uuid) + .fetch_optional(&context.db) + .await + .map_err(|error| error.to_string()) +} + +async fn initialize_local_api_key( + transaction: &mut sqlx::Transaction<'_, crate::database::Db>, + user_uuid: Uuid, + definitions: &[LocalApiPermissionDefinitionDto], +) -> Result<(), String> { + let api_key = format!("sk_local_{}", Uuid::new_v4().simple()); + let key_hash = crate::models::local_api::hash_api_key(&api_key); + let key_id: i32 = sqlx::query_scalar( + "INSERT INTO user_local_api_keys \ + (user_uuid, key_prefix, key_hash, api_key, daily_limit) \ + VALUES ($1, $2, $3, $4, 1000) RETURNING id", + ) + .bind(user_uuid) + .bind(api_key.chars().take(16).collect::()) + .bind(key_hash) + .bind(api_key) + .fetch_one(&mut **transaction) + .await + .map_err(|error| error.to_string())?; + + for definition in definitions { + sqlx::query( + "INSERT INTO user_local_api_key_permissions \ + (api_key_id, permission_code, is_enabled, rate_limit_per_minute, rate_limit_per_hour) \ + VALUES ($1, $2, $3, $4, $5)", + ) + .bind(key_id) + .bind(&definition.permission_code) + .bind(definition.default_enabled) + .bind(definition.default_rate_limit_per_minute) + .bind(definition.default_rate_limit_per_hour) + .execute(&mut **transaction) + .await + .map_err(|error| error.to_string())?; + } + Ok(()) +} + +fn validate_nickname(value: &str) -> Result { + let nickname = value.trim(); + if nickname.is_empty() { + return Err("请输入用户昵称".to_string()); + } + if nickname.chars().count() > 64 { + return Err("用户昵称不能超过 64 个字符".to_string()); + } + Ok(nickname.to_string()) +} + +fn validate_avatar(value: &str) -> Result { + if LOCAL_USER_AVATARS.contains(&value) { + Ok(value.to_string()) + } else { + Err("请选择有效的本地用户图标".to_string()) + } +} + +fn normalize_password(value: Option<&str>) -> Option<&str> { + value.filter(|password| !password.is_empty()) +} + +fn create_password_credentials(password: &str) -> (String, String) { + let salt = Uuid::new_v4().simple().to_string(); + let hash = derive_password_hash(password, &salt); + (salt, hash) +} + +fn derive_password_hash(password: &str, salt: &str) -> String { + let mut state = Sha256::digest([salt.as_bytes(), password.as_bytes()].concat()).to_vec(); + for round in 0..PASSWORD_HASH_ROUNDS { + let mut hasher = Sha256::new(); + hasher.update(&state); + hasher.update(salt.as_bytes()); + hasher.update(password.as_bytes()); + hasher.update(round.to_le_bytes()); + state = hasher.finalize().to_vec(); + } + hex::encode(state) +} + +fn constant_time_equal(left: &[u8], right: &[u8]) -> bool { + if left.len() != right.len() { + return false; + } + left.iter().zip(right).fold(0_u8, |difference, (left, right)| { + difference | (left ^ right) + }) == 0 +} + +#[cfg(test)] +mod tests { + use super::*; + use crate::utils::DatabaseConfig; + + #[tokio::test] + async fn creates_and_authenticates_password_and_passwordless_users() { + let mut config = DatabaseConfig::embedded("sqlite::memory:"); + config.max_connections = 1; + config.min_connections = 1; + let context = SvcCtx::new(&config).await.unwrap(); + + let passwordless = create_local_user( + &context, + &CreateLocalUserRequest { + nickname: "Alice".to_string(), + avatar: "🦊".to_string(), + password: None, + }, + ) + .await + .unwrap(); + assert!(!passwordless.has_password); + authenticate_local_user( + &context, + &LoginLocalUserRequest { + user_uuid: passwordless.uuid, + password: None, + }, + ) + .await + .unwrap(); + + let protected = create_local_user( + &context, + &CreateLocalUserRequest { + nickname: "Bob".to_string(), + avatar: "🚀".to_string(), + password: Some("secret".to_string()), + }, + ) + .await + .unwrap(); + assert!(protected.has_password); + assert!( + authenticate_local_user( + &context, + &LoginLocalUserRequest { + user_uuid: protected.uuid, + password: Some("wrong".to_string()), + }, + ) + .await + .is_err() + ); + authenticate_local_user( + &context, + &LoginLocalUserRequest { + user_uuid: protected.uuid, + password: Some("secret".to_string()), + }, + ) + .await + .unwrap(); + assert_eq!(context.current_user_uuid(), Some(protected.uuid)); + } +} diff --git a/src-tauri/crates/business/src/services/rpa.rs b/src-tauri/crates/business/src/services/rpa.rs index 4b58e8e..becfc22 100644 --- a/src-tauri/crates/business/src/services/rpa.rs +++ b/src-tauri/crates/business/src/services/rpa.rs @@ -1,18 +1,18 @@ -use uuid::Uuid; - +use uuid::Uuid; + use crate::dto::{RpaTaskDto, RpaTaskStepDto}; use crate::entitys::{ CreateRpaTaskRequest, DuplicateRpaTaskRequest, ListRpaTasksRequest, UpdateRpaTaskRequest, }; -use crate::models; -use crate::svc_ctx::SvcCtx; - +use crate::models; +use crate::svc_ctx::SvcCtx; + /// List RPA tasks. -pub async fn get_rpa_tasks_service( - svc_ctx: &SvcCtx, - user_uuid: Uuid, - team_uuid: Option, - payload: &ListRpaTasksRequest, +pub async fn get_rpa_tasks_service( + svc_ctx: &SvcCtx, + user_uuid: Uuid, + team_uuid: Option, + payload: &ListRpaTasksRequest, ) -> Result<(Vec, i64), String> { let offset = (payload.pagination.page - 1) * payload.pagination.page_size; @@ -29,326 +29,319 @@ pub async fn get_rpa_tasks_service( trigger_type, offset, payload.pagination.page_size, - ) - .await - .map_err(|e| e.to_string())?; - - let total = - models::rpa::fetch_rpa_tasks_count( - &svc_ctx.db, - team_uuid, - user_uuid, - keyword, - status, - trigger_type, - ) + ) + .await + .map_err(|e| e.to_string())?; + + let total = models::rpa::fetch_rpa_tasks_count( + &svc_ctx.db, + team_uuid, + user_uuid, + keyword, + status, + trigger_type, + ) + .await + .map_err(|e| e.to_string())?; + + Ok((tasks, total)) +} + +/// Get RPA task detail. +pub async fn get_rpa_task_service( + svc_ctx: &SvcCtx, + task_uuid: Uuid, +) -> Result<(RpaTaskDto, Vec, Vec), String> { + let task = models::rpa::fetch_rpa_task_by_uuid(&svc_ctx.db, task_uuid) + .await + .map_err(|e| e.to_string())? + .ok_or_else(|| "RPA task not found".to_string())?; + + let steps = models::rpa::fetch_rpa_task_steps(&svc_ctx.db, task_uuid) .await .map_err(|e| e.to_string())?; - - Ok((tasks, total)) -} - -/// Get RPA task detail. -pub async fn get_rpa_task_service( - svc_ctx: &SvcCtx, - task_uuid: Uuid, -) -> Result<(RpaTaskDto, Vec, Vec), String> { - let task = models::rpa::fetch_rpa_task_by_uuid(&svc_ctx.db, task_uuid) - .await - .map_err(|e| e.to_string())? - .ok_or_else(|| "RPA task not found".to_string())?; - - let steps = models::rpa::fetch_rpa_task_steps(&svc_ctx.db, task_uuid) - .await - .map_err(|e| e.to_string())?; - - let environments = models::rpa::fetch_rpa_task_environments(&svc_ctx.db, task_uuid) - .await - .map_err(|e| e.to_string())?; - - let environment_uuids: Vec = - environments.into_iter().map(|e| e.environment_uuid).collect(); - - Ok((task, steps, environment_uuids)) -} - + + let environments = models::rpa::fetch_rpa_task_environments(&svc_ctx.db, task_uuid) + .await + .map_err(|e| e.to_string())?; + + let environment_uuids: Vec = + environments.into_iter().map(|e| e.environment_uuid).collect(); + + Ok((task, steps, environment_uuids)) +} + /// Create an RPA task. -pub async fn create_rpa_task_service( - svc_ctx: &SvcCtx, - user_uuid: Uuid, - team_uuid: Option, - payload: &CreateRpaTaskRequest, -) -> Result { - let tags_json = payload.tags.as_ref().map(|t| serde_json::json!(t)); - - let task_uuid = models::rpa::insert_rpa_task( - &svc_ctx.db, - user_uuid, - team_uuid, - &payload.name, - payload.description.as_deref(), - tags_json.as_ref(), - &payload.trigger_type, - payload.schedule.as_deref(), - payload.cron_expression.as_deref(), - &payload.run_mode, - payload.retry_count, - payload.retry_interval, - payload.timeout, - payload.concurrency, - payload.stop_on_error, - payload.notify_on_complete, - payload.notify_on_error, - ) - .await - .map_err(|e| e.to_string())?; - +pub async fn create_rpa_task_service( + svc_ctx: &SvcCtx, + user_uuid: Uuid, + team_uuid: Option, + payload: &CreateRpaTaskRequest, +) -> Result { + let tags_json = payload.tags.as_ref().map(|t| serde_json::json!(t)); + + let task_uuid = models::rpa::insert_rpa_task( + &svc_ctx.db, + user_uuid, + team_uuid, + &payload.name, + payload.description.as_deref(), + tags_json.as_ref(), + &payload.trigger_type, + payload.schedule.as_deref(), + payload.cron_expression.as_deref(), + &payload.run_mode, + payload.retry_count, + payload.retry_interval, + payload.timeout, + payload.concurrency, + payload.stop_on_error, + payload.notify_on_complete, + payload.notify_on_error, + ) + .await + .map_err(|e| e.to_string())?; + // Persist ordered steps. - if let Some(steps) = &payload.steps { - for (i, step) in steps.iter().enumerate() { - models::rpa::insert_rpa_task_step( - &svc_ctx.db, - task_uuid, - &step.step_type, - &step.name, - &step.config, - step.enabled, - step.position_x, - step.position_y, - Some(step.sort_order.unwrap_or(i as i32)), - step.next_step_uuid, - step.branch_config.as_ref(), - ) - .await - .map_err(|e| e.to_string())?; - } - } - + if let Some(steps) = &payload.steps { + for (i, step) in steps.iter().enumerate() { + models::rpa::insert_rpa_task_step( + &svc_ctx.db, + task_uuid, + &step.step_type, + &step.name, + &step.config, + step.enabled, + step.position_x, + step.position_y, + Some(step.sort_order.unwrap_or(i as i32)), + step.next_step_uuid, + step.branch_config.as_ref(), + ) + .await + .map_err(|e| e.to_string())?; + } + } + // Persist environment bindings. - if let Some(env_uuids) = &payload.environment_uuids { - for (i, env_uuid) in env_uuids.iter().enumerate() { - models::rpa::insert_rpa_task_environment( - &svc_ctx.db, - task_uuid, - *env_uuid, - Some(i as i32), - ) - .await - .map_err(|e| e.to_string())?; - } - } - - Ok(task_uuid) -} - + if let Some(env_uuids) = &payload.environment_uuids { + for (i, env_uuid) in env_uuids.iter().enumerate() { + models::rpa::insert_rpa_task_environment( + &svc_ctx.db, + task_uuid, + *env_uuid, + Some(i as i32), + ) + .await + .map_err(|e| e.to_string())?; + } + } + + Ok(task_uuid) +} + /// Update an RPA task. -pub async fn update_rpa_task_service( - svc_ctx: &SvcCtx, - payload: &UpdateRpaTaskRequest, -) -> Result<(), String> { - let tags_json = payload.tags.as_ref().map(|t| serde_json::json!(t)); - - models::rpa::update_rpa_task( - &svc_ctx.db, - payload.uuid, - payload.name.as_deref(), - payload.description.as_deref(), - tags_json.as_ref(), - payload.trigger_type.as_deref(), - payload.schedule.as_deref(), - payload.cron_expression.as_deref(), - payload.run_mode.as_deref(), - payload.retry_count, - payload.retry_interval, - payload.timeout, - payload.concurrency, - payload.stop_on_error, - payload.notify_on_complete, - payload.notify_on_error, - ) - .await - .map_err(|e| e.to_string())?; - +pub async fn update_rpa_task_service( + svc_ctx: &SvcCtx, + payload: &UpdateRpaTaskRequest, +) -> Result<(), String> { + let tags_json = payload.tags.as_ref().map(|t| serde_json::json!(t)); + + models::rpa::update_rpa_task( + &svc_ctx.db, + payload.uuid, + payload.name.as_deref(), + payload.description.as_deref(), + tags_json.as_ref(), + payload.trigger_type.as_deref(), + payload.schedule.as_deref(), + payload.cron_expression.as_deref(), + payload.run_mode.as_deref(), + payload.retry_count, + payload.retry_interval, + payload.timeout, + payload.concurrency, + payload.stop_on_error, + payload.notify_on_complete, + payload.notify_on_error, + ) + .await + .map_err(|e| e.to_string())?; + // Replace stored steps when the caller sends a full step list. - if let Some(steps) = &payload.steps { + if let Some(steps) = &payload.steps { // Remove previous step rows first. - models::rpa::delete_rpa_task_steps(&svc_ctx.db, payload.uuid) - .await - .map_err(|e| e.to_string())?; - + models::rpa::delete_rpa_task_steps(&svc_ctx.db, payload.uuid) + .await + .map_err(|e| e.to_string())?; + // Insert the new step rows. - for (i, step) in steps.iter().enumerate() { - models::rpa::insert_rpa_task_step( - &svc_ctx.db, - payload.uuid, - &step.step_type, - &step.name, - &step.config, - step.enabled, - step.position_x, - step.position_y, - Some(step.sort_order.unwrap_or(i as i32)), - step.next_step_uuid, - step.branch_config.as_ref(), - ) - .await - .map_err(|e| e.to_string())?; - } - } - + for (i, step) in steps.iter().enumerate() { + models::rpa::insert_rpa_task_step( + &svc_ctx.db, + payload.uuid, + &step.step_type, + &step.name, + &step.config, + step.enabled, + step.position_x, + step.position_y, + Some(step.sort_order.unwrap_or(i as i32)), + step.next_step_uuid, + step.branch_config.as_ref(), + ) + .await + .map_err(|e| e.to_string())?; + } + } + // Replace environment bindings when provided. - if let Some(env_uuids) = &payload.environment_uuids { + if let Some(env_uuids) = &payload.environment_uuids { // Remove previous environment bindings. - models::rpa::delete_rpa_task_environments(&svc_ctx.db, payload.uuid) - .await - .map_err(|e| e.to_string())?; - + models::rpa::delete_rpa_task_environments(&svc_ctx.db, payload.uuid) + .await + .map_err(|e| e.to_string())?; + // Insert the new environment bindings. - for (i, env_uuid) in env_uuids.iter().enumerate() { - models::rpa::insert_rpa_task_environment( - &svc_ctx.db, - payload.uuid, - *env_uuid, - Some(i as i32), - ) - .await - .map_err(|e| e.to_string())?; - } - } - - Ok(()) -} - + for (i, env_uuid) in env_uuids.iter().enumerate() { + models::rpa::insert_rpa_task_environment( + &svc_ctx.db, + payload.uuid, + *env_uuid, + Some(i as i32), + ) + .await + .map_err(|e| e.to_string())?; + } + } + + Ok(()) +} + /// Soft-delete an RPA task. -pub async fn delete_rpa_task_service(svc_ctx: &SvcCtx, task_uuid: Uuid) -> Result<(), String> { - models::rpa::delete_rpa_task(&svc_ctx.db, task_uuid) - .await - .map_err(|e| e.to_string()) -} - +pub async fn delete_rpa_task_service(svc_ctx: &SvcCtx, task_uuid: Uuid) -> Result<(), String> { + models::rpa::delete_rpa_task(&svc_ctx.db, task_uuid) + .await + .map_err(|e| e.to_string()) +} + /// Soft-delete RPA tasks in batch. -pub async fn batch_delete_rpa_tasks_service( - svc_ctx: &SvcCtx, - task_uuids: &[Uuid], -) -> Result { - models::rpa::batch_delete_rpa_tasks(&svc_ctx.db, task_uuids) - .await - .map_err(|e| e.to_string()) -} - +pub async fn batch_delete_rpa_tasks_service( + svc_ctx: &SvcCtx, + task_uuids: &[Uuid], +) -> Result { + models::rpa::batch_delete_rpa_tasks(&svc_ctx.db, task_uuids) + .await + .map_err(|e| e.to_string()) +} + /// Duplicate an RPA task. -pub async fn duplicate_rpa_task_service( - svc_ctx: &SvcCtx, - user_uuid: Uuid, - team_uuid: Option, - payload: &DuplicateRpaTaskRequest, -) -> Result { +pub async fn duplicate_rpa_task_service( + svc_ctx: &SvcCtx, + user_uuid: Uuid, + team_uuid: Option, + payload: &DuplicateRpaTaskRequest, +) -> Result { // Load source task data. - let (task, steps, environment_uuids) = get_rpa_task_service(svc_ctx, payload.uuid).await?; - - let new_name = payload - .new_name - .clone() - .unwrap_or_else(|| format!("{} (copy)", task.name)); - + let (task, steps, environment_uuids) = get_rpa_task_service(svc_ctx, payload.uuid).await?; + + let new_name = payload.new_name.clone().unwrap_or_else(|| format!("{} (copy)", task.name)); + // Create duplicated task row. - let new_task_uuid = models::rpa::insert_rpa_task( - &svc_ctx.db, - user_uuid, - team_uuid, - &new_name, - task.description.as_deref(), - task.tags.as_ref(), - &task.trigger_type, - task.schedule.as_deref(), - task.cron_expression.as_deref(), - &task.run_mode, - task.retry_count, - task.retry_interval, - task.timeout, - task.concurrency, - task.stop_on_error, - task.notify_on_complete, - task.notify_on_error, - ) - .await - .map_err(|e| e.to_string())?; - + let new_task_uuid = models::rpa::insert_rpa_task( + &svc_ctx.db, + user_uuid, + team_uuid, + &new_name, + task.description.as_deref(), + task.tags.as_ref(), + &task.trigger_type, + task.schedule.as_deref(), + task.cron_expression.as_deref(), + &task.run_mode, + task.retry_count, + task.retry_interval, + task.timeout, + task.concurrency, + task.stop_on_error, + task.notify_on_complete, + task.notify_on_error, + ) + .await + .map_err(|e| e.to_string())?; + // Copy steps. - for step in steps { - models::rpa::insert_rpa_task_step( - &svc_ctx.db, - new_task_uuid, - &step.step_type, - &step.name, - &step.config, - step.enabled, - step.position_x, - step.position_y, - step.sort_order, - step.next_step_uuid, - step.branch_config.as_ref(), - ) - .await - .map_err(|e| e.to_string())?; - } - + for step in steps { + models::rpa::insert_rpa_task_step( + &svc_ctx.db, + new_task_uuid, + &step.step_type, + &step.name, + &step.config, + step.enabled, + step.position_x, + step.position_y, + step.sort_order, + step.next_step_uuid, + step.branch_config.as_ref(), + ) + .await + .map_err(|e| e.to_string())?; + } + // Copy environment bindings. - for (i, env_uuid) in environment_uuids.iter().enumerate() { - models::rpa::insert_rpa_task_environment( - &svc_ctx.db, - new_task_uuid, - *env_uuid, - Some(i as i32), - ) - .await - .map_err(|e| e.to_string())?; - } - - Ok(new_task_uuid) -} - + for (i, env_uuid) in environment_uuids.iter().enumerate() { + models::rpa::insert_rpa_task_environment( + &svc_ctx.db, + new_task_uuid, + *env_uuid, + Some(i as i32), + ) + .await + .map_err(|e| e.to_string())?; + } + + Ok(new_task_uuid) +} + /// Export an RPA task. -pub async fn export_rpa_task_service( - svc_ctx: &SvcCtx, - task_uuid: Uuid, -) -> Result<(String, String), String> { - let (task, steps, environment_uuids) = get_rpa_task_service(svc_ctx, task_uuid).await?; - - let export_data = serde_json::json!({ - "name": task.name, - "description": task.description, - "tags": task.tags, - "trigger_type": task.trigger_type, - "schedule": task.schedule, - "cron_expression": task.cron_expression, - "run_mode": task.run_mode, - "retry_count": task.retry_count, - "retry_interval": task.retry_interval, - "timeout": task.timeout, - "concurrency": task.concurrency, - "stop_on_error": task.stop_on_error, - "notify_on_complete": task.notify_on_complete, - "notify_on_error": task.notify_on_error, - "steps": steps.iter().map(|s| serde_json::json!({ - "step_type": s.step_type, - "name": s.name, - "config": s.config, - "enabled": s.enabled, - "position_x": s.position_x, - "position_y": s.position_y, - "sort_order": s.sort_order, - "next_step_uuid": s.next_step_uuid, - "branch_config": s.branch_config, - })).collect::>(), - "environment_uuids": environment_uuids, - }); - - let content = serde_json::to_string_pretty(&export_data).map_err(|e| e.to_string())?; - let filename = format!("rpa_task_{}.json", task_uuid); - - Ok((content, filename)) -} - - - +pub async fn export_rpa_task_service( + svc_ctx: &SvcCtx, + task_uuid: Uuid, +) -> Result<(String, String), String> { + let (task, steps, environment_uuids) = get_rpa_task_service(svc_ctx, task_uuid).await?; + + let export_data = serde_json::json!({ + "name": task.name, + "description": task.description, + "tags": task.tags, + "trigger_type": task.trigger_type, + "schedule": task.schedule, + "cron_expression": task.cron_expression, + "run_mode": task.run_mode, + "retry_count": task.retry_count, + "retry_interval": task.retry_interval, + "timeout": task.timeout, + "concurrency": task.concurrency, + "stop_on_error": task.stop_on_error, + "notify_on_complete": task.notify_on_complete, + "notify_on_error": task.notify_on_error, + "steps": steps.iter().map(|s| serde_json::json!({ + "step_type": s.step_type, + "name": s.name, + "config": s.config, + "enabled": s.enabled, + "position_x": s.position_x, + "position_y": s.position_y, + "sort_order": s.sort_order, + "next_step_uuid": s.next_step_uuid, + "branch_config": s.branch_config, + })).collect::>(), + "environment_uuids": environment_uuids, + }); + + let content = serde_json::to_string_pretty(&export_data).map_err(|e| e.to_string())?; + let filename = format!("rpa_task_{}.json", task_uuid); + + Ok((content, filename)) +} diff --git a/src-tauri/crates/business/src/services/teams.rs b/src-tauri/crates/business/src/services/teams.rs index f52f0ba..f42b566 100644 --- a/src-tauri/crates/business/src/services/teams.rs +++ b/src-tauri/crates/business/src/services/teams.rs @@ -1,479 +1,342 @@ -use uuid::Uuid; - -use crate::dto::{TeamDto, TeamInvitationDto, TeamMemberDto}; -use crate::entitys::{ - CreateTeamRequest, InviteMemberRequest, ListTeamMembersRequest, SwitchTeamRequest, - UpdateMemberRoleRequest, UpdateTeamRequest, -}; -use crate::models; -use crate::svc_ctx::SvcCtx; - -/// 创建团队 -pub async fn create_team_service( - svc_ctx: &SvcCtx, - user_uuid: Uuid, - payload: &CreateTeamRequest, -) -> Result { - models::insert_team(&svc_ctx.db, user_uuid, payload) - .await - .map_err(|e| e.to_string()) -} - -/// 获取团队详情 -pub async fn get_team_service(svc_ctx: &SvcCtx, team_uuid: Uuid) -> Result { - models::fetch_team_by_uuid(&svc_ctx.db, team_uuid) - .await - .map_err(|e| e.to_string())? - .ok_or_else(|| "团队不存在".to_string()) -} - -/// 获取用户所属的所有团队(工作空间级别) -pub async fn get_user_teams_service( - svc_ctx: &SvcCtx, - workspace_uuid: Uuid, - user_uuid: Uuid, -) -> Result, String> { - models::fetch_user_teams(&svc_ctx.db, workspace_uuid, user_uuid) - .await - .map_err(|e| e.to_string()) -} - -/// 获取用户当前团队 -pub async fn get_current_team_service( - svc_ctx: &SvcCtx, - user_uuid: Uuid, -) -> Result, String> { - models::fetch_user_current_team(&svc_ctx.db, user_uuid) - .await - .map_err(|e| e.to_string()) -} - -/// 切换团队 -pub async fn switch_team_service( - svc_ctx: &SvcCtx, - workspace_uuid: Uuid, - user_uuid: Uuid, - payload: &SwitchTeamRequest, -) -> Result<(), String> { - // 验证用户是否是团队成员(工作空间级别) - let member = models::fetch_team_member(&svc_ctx.db, workspace_uuid, payload.team_uuid, user_uuid) - .await - .map_err(|e| e.to_string())?; - - if member.is_none() { - return Err("您不是该团队的成员".to_string()); - } - - models::set_user_current_team(&svc_ctx.db, user_uuid, payload.team_uuid) - .await - .map_err(|e| e.to_string()) -} - -/// 更新团队信息 -pub async fn update_team_service( - svc_ctx: &SvcCtx, - workspace_uuid: Uuid, - user_uuid: Uuid, - payload: &UpdateTeamRequest, -) -> Result<(), String> { - // 检查权限(仅所有者和管理员可以更新) - let member = models::fetch_team_member(&svc_ctx.db, workspace_uuid, payload.uuid, user_uuid) - .await - .map_err(|e| e.to_string())? - .ok_or_else(|| "您不是该团队的成员".to_string())?; - - if member.role != "owner" && member.role != "admin" { - return Err("权限不足".to_string()); - } - - models::update_team( - &svc_ctx.db, - payload.uuid, - payload.name.as_deref(), - payload.description.as_deref(), - payload.avatar_hash.as_deref(), - ) - .await - .map_err(|e| e.to_string()) -} - -/// 获取团队成员列表 -pub async fn get_team_members_service( - svc_ctx: &SvcCtx, - team_uuid: Uuid, - payload: &ListTeamMembersRequest, -) -> Result<(Vec, i64), String> { - let offset = (payload.pagination.page - 1) * payload.pagination.page_size; - - // 提取筛选条件 - let keyword = payload.filters.as_ref().and_then(|f| f.keyword.as_deref()); - let role = payload.filters.as_ref().and_then(|f| f.role.as_deref()); - let status = payload.filters.as_ref().and_then(|f| f.status.as_deref()); - - let members = models::fetch_team_members( - &svc_ctx.db, - team_uuid, - offset, - payload.pagination.page_size, - keyword, - role, - status, - ) - .await - .map_err(|e| e.to_string())?; - - let total = models::fetch_team_member_count(&svc_ctx.db, team_uuid, keyword, role, status) - .await - .map_err(|e| e.to_string())?; - - Ok((members, total)) -} - -/// 邀请成员 -pub async fn invite_member_service( - svc_ctx: &SvcCtx, - workspace_uuid: Uuid, - team_uuid: Uuid, - inviter_uuid: Uuid, - payload: &InviteMemberRequest, -) -> Result { - // 检查邀请者权限(工作空间级别) - let member = models::fetch_team_member(&svc_ctx.db, workspace_uuid, team_uuid, inviter_uuid) - .await - .map_err(|e| e.to_string())? - .ok_or_else(|| "您不是该团队的成员".to_string())?; - - if member.role != "owner" && member.role != "admin" { - return Err("权限不足".to_string()); - } - - // 检查是否已有待处理的邀请 - if models::has_pending_invitation(&svc_ctx.db, team_uuid, &payload.email) - .await - .map_err(|e| e.to_string())? - { - return Err("该邮箱已有待处理的邀请".to_string()); - } - - // 检查用户是否已是成员 - let invited_user_info = - crate::models::user::fetch_user_info_by_email(&svc_ctx.db, &payload.email) - .await - .map_err(|e| e.to_string())?; - - // 检查用户是否存在 - let user_info = invited_user_info.ok_or_else(|| "该邮箱未注册,请先注册账号".to_string())?; - - // 检查用户是否已是团队成员 - if models::fetch_team_member(&svc_ctx.db, workspace_uuid, team_uuid, user_info.user_uuid) - .await - .map_err(|e| e.to_string())? - .is_some() - { - return Err("该用户已是团队成员".to_string()); - } - - // 获取团队信息 - let team = models::fetch_team_by_uuid(&svc_ctx.db, team_uuid) - .await - .map_err(|e| e.to_string())? - .ok_or_else(|| "团队不存在".to_string())?; - - // 获取邀请者信息 - let inviter_info = crate::models::user::fetch_user_info_by_uuid(&svc_ctx.db, inviter_uuid) - .await - .map_err(|e| e.to_string())? - .ok_or_else(|| "邀请者信息不存在".to_string())?; - - // 生成邀请 token - let token = uuid::Uuid::new_v4().to_string(); - let expires_at = chrono::Utc::now() + chrono::Duration::days(7); - - // 插入邀请记录 - let invitation_uuid = models::insert_team_invitation( - &svc_ctx.db, - team_uuid, - &payload.email, - &payload.role, - inviter_uuid, - &token, - expires_at, - ) - .await - .map_err(|e| e.to_string())?; - - // 1. 发送邮件通知 - - let inviter_name: &str = - inviter_info.nickname.as_deref().unwrap_or_else(|| inviter_info.email.as_str()); - let inviter_email: &str = inviter_info.email.as_str(); - - - // 2. 如果用户已注册,发送团队邀请消息通知 - let _ = crate::services::messages::create_message_service( - svc_ctx, - Some(inviter_uuid), - &crate::entitys::CreateMessageRequest { - message_type: "team_invitation".to_string(), - title: format!("{} 邀请您加入团队 {}", inviter_name, team.name), - content: Some(format!( - "{}({})邀请您加入团队 {},角色:{}", - inviter_name, inviter_email, team.name, payload.role - )), - recipient_uuids: vec![user_info.user_uuid], - recipient_type: "single".to_string(), - related_type: Some("team".to_string()), - related_uuid: Some(team_uuid), - priority: Some("normal".to_string()), - metadata: Some(serde_json::json!({ - "invitation_uuid": invitation_uuid.to_string(), - "team_name": team.name, - "role": payload.role, - "inviter_name": inviter_name, - "inviter_email": inviter_email, - "token": token, - })), - }, - ) - .await; - // 消息创建失败不影响邀请流程,使用 _ 忽略错误 - - Ok(invitation_uuid) -} - -/// 取消邀请 -pub async fn cancel_invitation_service( - svc_ctx: &SvcCtx, - invitation_uuid: Uuid, -) -> Result<(), String> { - models::cancel_team_invitation(&svc_ctx.db, invitation_uuid) - .await - .map_err(|e| e.to_string()) -} - -/// 接受邀请 -pub async fn accept_invitation_service( - svc_ctx: &SvcCtx, - user_uuid: Uuid, - token: &str, -) -> Result { - // 查找邀请 - let invitation = models::fetch_team_invitation_by_token(&svc_ctx.db, token) - .await - .map_err(|e| e.to_string())? - .ok_or_else(|| "邀请不存在或已过期".to_string())?; - - // 获取被邀请用户的当前工作空间 - let user_workspace_uuid = crate::models::user::fetch_user_current_workspace(&svc_ctx.db, user_uuid) - .await - .map_err(|e| e.to_string())? - .ok_or_else(|| "请先选择工作空间".to_string())?; - - // 检查用户是否已经是该工作空间的团队成员(如果是,则不需要检查配额) - let existing_member = models::fetch_team_member( - &svc_ctx.db, - user_workspace_uuid, - invitation.team_uuid, - user_uuid, - ) - .await - .map_err(|e| e.to_string())?; - - // 如果用户还不是该工作空间的任何团队的成员,需要检查配额 - if existing_member.is_none() { - // 检查工作空间成员配额是否充足 - let quota_available = models::check_quota(&svc_ctx.db, user_workspace_uuid, "team_members") - .await - .map_err(|e| e.to_string())?; - if !quota_available { - return Err("工作空间成员配额不足,无法接受邀请".to_string()); - } - } - - // 使用 accept_team_invitation 函数处理整个流程 - let team_uuid = models::accept_team_invitation(&svc_ctx.db, invitation.uuid, user_uuid, user_workspace_uuid) - .await - .map_err(|e| e.to_string())?; - - // 更新成员配额(重新计算所有团队的活跃成员总数) - // 注意:只有在用户之前不是该工作空间的成员时才需要更新配额 - if existing_member.is_none() { - models::update_used_team_members(&svc_ctx.db, user_workspace_uuid) - .await - .map_err(|e| format!("更新配额失败: {}", e))?; - } - - Ok(team_uuid) -} - -/// 拒绝邀请 -pub async fn reject_invitation_service( - svc_ctx: &SvcCtx, - user_uuid: Uuid, - token: &str, -) -> Result<(), String> { - // 查找邀请 - let invitation = models::fetch_team_invitation_by_token(&svc_ctx.db, token) - .await - .map_err(|e| e.to_string())? - .ok_or_else(|| "邀请不存在或已过期".to_string())?; - - // 使用 reject_team_invitation 函数处理整个流程 - models::reject_team_invitation(&svc_ctx.db, invitation.uuid, user_uuid) - .await - .map_err(|e| e.to_string()) -} - -/// 更新成员角色 -pub async fn update_member_role_service( - svc_ctx: &SvcCtx, - workspace_uuid: Uuid, - team_uuid: Uuid, - operator_uuid: Uuid, - payload: &UpdateMemberRoleRequest, -) -> Result { - // 检查操作者权限(工作空间级别) - let operator = models::fetch_team_member(&svc_ctx.db, workspace_uuid, team_uuid, operator_uuid) - .await - .map_err(|e| e.to_string())? - .ok_or_else(|| "您不是该团队的成员".to_string())?; - - if operator.role != "owner" && operator.role != "admin" { - return Err("权限不足".to_string()); - } - - // 不能修改所有者角色 - let target = models::fetch_team_member(&svc_ctx.db, workspace_uuid, team_uuid, payload.member_uuid) - .await - .map_err(|e| e.to_string())? - .ok_or_else(|| "成员不存在".to_string())?; - - if target.role == "owner" { - return Err("不能修改所有者角色".to_string()); - } - - // 管理员不能设置其他管理员 - if operator.role == "admin" && payload.role == "admin" { - return Err("管理员不能设置其他管理员".to_string()); - } - - models::update_member_role(&svc_ctx.db, team_uuid, payload.member_uuid, &payload.role) - .await - .map_err(|e| e.to_string())?; - - // 返回更新后的成员信息 - models::fetch_team_member(&svc_ctx.db, workspace_uuid, team_uuid, payload.member_uuid) - .await - .map_err(|e| e.to_string())? - .ok_or_else(|| "获取更新后的成员信息失败".to_string()) -} - -/// 移除成员 -pub async fn remove_member_service( - svc_ctx: &SvcCtx, - workspace_uuid: Uuid, - team_uuid: Uuid, - operator_uuid: Uuid, - member_uuid: Uuid, -) -> Result<(), String> { - // 检查操作者权限(工作空间级别) - let operator = models::fetch_team_member(&svc_ctx.db, workspace_uuid, team_uuid, operator_uuid) - .await - .map_err(|e| e.to_string())? - .ok_or_else(|| "您不是该团队的成员".to_string())?; - - if operator.role != "owner" && operator.role != "admin" { - return Err("权限不足".to_string()); - } - - // 不能移除所有者 - let target = models::fetch_team_member(&svc_ctx.db, workspace_uuid, team_uuid, member_uuid) - .await - .map_err(|e| e.to_string())? - .ok_or_else(|| "成员不存在".to_string())?; - - if target.role == "owner" { - return Err("不能移除所有者".to_string()); - } - - // 管理员不能移除其他管理员 - if operator.role == "admin" && target.role == "admin" { - return Err("管理员不能移除其他管理员".to_string()); - } - - models::remove_team_member(&svc_ctx.db, team_uuid, member_uuid) - .await - .map_err(|e| e.to_string())?; - - // 更新成员配额(重新计算所有团队的活跃成员总数) - models::update_used_team_members(&svc_ctx.db, workspace_uuid) - .await - .map_err(|e| format!("更新配额失败: {}", e))?; - - Ok(()) -} - -/// 退出团队 -pub async fn leave_team_service( - svc_ctx: &SvcCtx, - workspace_uuid: Uuid, - team_uuid: Uuid, - user_uuid: Uuid, -) -> Result<(), String> { - // 检查用户是否是团队成员(工作空间级别) - let member = models::fetch_team_member(&svc_ctx.db, workspace_uuid, team_uuid, user_uuid) - .await - .map_err(|e| e.to_string())? - .ok_or_else(|| "您不是该团队的成员".to_string())?; - - // 所有者不能退出团队 - if member.role == "owner" { - return Err("团队所有者不能退出团队,请先转移所有权或解散团队".to_string()); - } - - // 移除成员 - models::remove_team_member(&svc_ctx.db, team_uuid, user_uuid) - .await - .map_err(|e| e.to_string())?; - - // 更新成员配额(重新计算所有团队的活跃成员总数) - models::update_used_team_members(&svc_ctx.db, workspace_uuid) - .await - .map_err(|e| format!("更新配额失败: {}", e))?; - - // 如果当前团队是用户的活跃团队,则需要切换到其他团队 - let current_team = models::fetch_user_current_team(&svc_ctx.db, user_uuid) - .await - .map_err(|e| e.to_string())?; - - if current_team == Some(team_uuid) { - // 获取用户的其他团队(工作空间级别) - let teams = models::fetch_user_teams(&svc_ctx.db, workspace_uuid, user_uuid) - .await - .map_err(|e| e.to_string())?; - - if let Some(first_team) = teams.first() { - // 切换到第一个可用团队 - models::set_user_current_team(&svc_ctx.db, user_uuid, first_team.uuid) - .await - .map_err(|e| e.to_string())?; - } else { - // 没有其他团队,清除当前团队 - models::clear_user_current_team(&svc_ctx.db, user_uuid) - .await - .map_err(|e| e.to_string())?; - } - } - - Ok(()) -} - -/// 获取待处理邀请列表 -pub async fn get_pending_invitations_service( - svc_ctx: &SvcCtx, - team_uuid: Uuid, -) -> Result, String> { - models::fetch_pending_invitations(&svc_ctx.db, team_uuid) - .await - .map_err(|e| e.to_string()) -} +use uuid::Uuid; + +use crate::dto::{TeamDto, TeamMemberDto}; +use crate::entitys::{ + AddMemberRequest, CreateTeamRequest, ListTeamMembersRequest, SwitchTeamRequest, + UpdateMemberRoleRequest, UpdateTeamRequest, +}; +use crate::models; +use crate::svc_ctx::SvcCtx; + +/// 创建团队 +pub async fn create_team_service( + svc_ctx: &SvcCtx, + user_uuid: Uuid, + payload: &CreateTeamRequest, +) -> Result { + models::insert_team(&svc_ctx.db, user_uuid, payload) + .await + .map_err(|e| e.to_string()) +} + +/// 获取团队详情 +pub async fn get_team_service(svc_ctx: &SvcCtx, team_uuid: Uuid) -> Result { + models::fetch_team_by_uuid(&svc_ctx.db, team_uuid) + .await + .map_err(|e| e.to_string())? + .ok_or_else(|| "团队不存在".to_string()) +} + +/// 获取用户在本机加入的所有团队。 +pub async fn get_user_teams_service( + svc_ctx: &SvcCtx, + user_uuid: Uuid, +) -> Result, String> { + models::fetch_all_user_teams(&svc_ctx.db, user_uuid) + .await + .map_err(|e| e.to_string()) +} + +/// 获取用户当前团队 +pub async fn get_current_team_service( + svc_ctx: &SvcCtx, + user_uuid: Uuid, +) -> Result, String> { + models::fetch_user_current_team(&svc_ctx.db, user_uuid) + .await + .map_err(|e| e.to_string()) +} + +/// 切换团队 +pub async fn switch_team_service( + svc_ctx: &SvcCtx, + user_uuid: Uuid, + payload: &SwitchTeamRequest, +) -> Result<(), String> { + let team = models::fetch_team_by_uuid(&svc_ctx.db, payload.team_uuid) + .await + .map_err(|e| e.to_string())? + .ok_or_else(|| "团队不存在".to_string())?; + models::fetch_team_member( + &svc_ctx.db, + team.workspace_uuid, + payload.team_uuid, + user_uuid, + ) + .await + .map_err(|e| e.to_string())? + .ok_or_else(|| "您不是该团队的成员".to_string())?; + + models::user::set_user_current_workspace_and_team( + &svc_ctx.db, + user_uuid, + team.workspace_uuid, + payload.team_uuid, + ) + .await + .map_err(|e| e.to_string()) +} + +/// 更新团队信息 +pub async fn update_team_service( + svc_ctx: &SvcCtx, + workspace_uuid: Uuid, + user_uuid: Uuid, + payload: &UpdateTeamRequest, +) -> Result<(), String> { + // 检查权限(仅所有者和管理员可以更新) + let member = models::fetch_team_member(&svc_ctx.db, workspace_uuid, payload.uuid, user_uuid) + .await + .map_err(|e| e.to_string())? + .ok_or_else(|| "您不是该团队的成员".to_string())?; + + if member.role != "owner" && member.role != "admin" { + return Err("权限不足".to_string()); + } + + models::update_team( + &svc_ctx.db, + payload.uuid, + payload.name.as_deref(), + payload.description.as_deref(), + payload.avatar_hash.as_deref(), + ) + .await + .map_err(|e| e.to_string()) +} + +/// 获取团队成员列表 +pub async fn get_team_members_service( + svc_ctx: &SvcCtx, + team_uuid: Uuid, + payload: &ListTeamMembersRequest, +) -> Result<(Vec, i64), String> { + let offset = (payload.pagination.page - 1) * payload.pagination.page_size; + + // 提取筛选条件 + let keyword = payload.filters.as_ref().and_then(|f| f.keyword.as_deref()); + let role = payload.filters.as_ref().and_then(|f| f.role.as_deref()); + let status = payload.filters.as_ref().and_then(|f| f.status.as_deref()); + + let members = models::fetch_team_members( + &svc_ctx.db, + team_uuid, + offset, + payload.pagination.page_size, + keyword, + role, + status, + ) + .await + .map_err(|e| e.to_string())?; + + let total = models::fetch_team_member_count(&svc_ctx.db, team_uuid, keyword, role, status) + .await + .map_err(|e| e.to_string())?; + + Ok((members, total)) +} + +/// 将另一个本地用户直接加入团队。 +pub async fn add_member_service( + svc_ctx: &SvcCtx, + workspace_uuid: Uuid, + team_uuid: Uuid, + added_by_uuid: Uuid, + payload: &AddMemberRequest, +) -> Result { + // 检查操作者权限(工作空间级别) + let member = models::fetch_team_member(&svc_ctx.db, workspace_uuid, team_uuid, added_by_uuid) + .await + .map_err(|e| e.to_string())? + .ok_or_else(|| "您不是该团队的成员".to_string())?; + + if member.role != "owner" && member.role != "admin" { + return Err("权限不足".to_string()); + } + + if !matches!(payload.role.as_str(), "admin" | "editor" | "viewer") { + return Err("无效的团队角色".to_string()); + } + let user_info = crate::models::user::fetch_user_info_by_uuid(&svc_ctx.db, payload.user_uuid) + .await + .map_err(|e| e.to_string())? + .ok_or_else(|| "本地用户不存在".to_string())?; + sqlx::query_scalar::<_, i64>("SELECT COUNT(*) FROM local_user_auth WHERE user_uuid = $1") + .bind(payload.user_uuid) + .fetch_one(&svc_ctx.db) + .await + .map_err(|e| e.to_string())? + .gt(&0) + .then_some(()) + .ok_or_else(|| "目标不是本地用户".to_string())?; + + // 检查用户是否已是团队成员 + if models::fetch_team_member(&svc_ctx.db, workspace_uuid, team_uuid, user_info.user_uuid) + .await + .map_err(|e| e.to_string())? + .is_some() + { + return Err("该用户已是团队成员".to_string()); + } + + models::insert_team_member( + &svc_ctx.db, + team_uuid, + user_info.user_uuid, + &payload.role, + Some(added_by_uuid), + ) + .await + .map_err(|e| e.to_string())?; + models::update_used_team_members(&svc_ctx.db, workspace_uuid) + .await + .map_err(|e| format!("更新配额失败: {e}"))?; + + Ok(user_info.user_uuid) +} + +/// 更新成员角色 +pub async fn update_member_role_service( + svc_ctx: &SvcCtx, + workspace_uuid: Uuid, + team_uuid: Uuid, + operator_uuid: Uuid, + payload: &UpdateMemberRoleRequest, +) -> Result { + // 检查操作者权限(工作空间级别) + let operator = models::fetch_team_member(&svc_ctx.db, workspace_uuid, team_uuid, operator_uuid) + .await + .map_err(|e| e.to_string())? + .ok_or_else(|| "您不是该团队的成员".to_string())?; + + if operator.role != "owner" && operator.role != "admin" { + return Err("权限不足".to_string()); + } + + // 不能修改所有者角色 + let target = + models::fetch_team_member(&svc_ctx.db, workspace_uuid, team_uuid, payload.member_uuid) + .await + .map_err(|e| e.to_string())? + .ok_or_else(|| "成员不存在".to_string())?; + + if target.role == "owner" { + return Err("不能修改所有者角色".to_string()); + } + + // 管理员不能设置其他管理员 + if operator.role == "admin" && payload.role == "admin" { + return Err("管理员不能设置其他管理员".to_string()); + } + + models::update_member_role(&svc_ctx.db, team_uuid, payload.member_uuid, &payload.role) + .await + .map_err(|e| e.to_string())?; + + // 返回更新后的成员信息 + models::fetch_team_member(&svc_ctx.db, workspace_uuid, team_uuid, payload.member_uuid) + .await + .map_err(|e| e.to_string())? + .ok_or_else(|| "获取更新后的成员信息失败".to_string()) +} + +/// 移除成员 +pub async fn remove_member_service( + svc_ctx: &SvcCtx, + workspace_uuid: Uuid, + team_uuid: Uuid, + operator_uuid: Uuid, + member_uuid: Uuid, +) -> Result<(), String> { + // 检查操作者权限(工作空间级别) + let operator = models::fetch_team_member(&svc_ctx.db, workspace_uuid, team_uuid, operator_uuid) + .await + .map_err(|e| e.to_string())? + .ok_or_else(|| "您不是该团队的成员".to_string())?; + + if operator.role != "owner" && operator.role != "admin" { + return Err("权限不足".to_string()); + } + + // 不能移除所有者 + let target = models::fetch_team_member(&svc_ctx.db, workspace_uuid, team_uuid, member_uuid) + .await + .map_err(|e| e.to_string())? + .ok_or_else(|| "成员不存在".to_string())?; + + if target.role == "owner" { + return Err("不能移除所有者".to_string()); + } + + // 管理员不能移除其他管理员 + if operator.role == "admin" && target.role == "admin" { + return Err("管理员不能移除其他管理员".to_string()); + } + + models::remove_team_member(&svc_ctx.db, team_uuid, member_uuid) + .await + .map_err(|e| e.to_string())?; + + // 更新成员配额(重新计算所有团队的活跃成员总数) + models::update_used_team_members(&svc_ctx.db, workspace_uuid) + .await + .map_err(|e| format!("更新配额失败: {}", e))?; + + Ok(()) +} + +/// 退出团队 +pub async fn leave_team_service( + svc_ctx: &SvcCtx, + workspace_uuid: Uuid, + team_uuid: Uuid, + user_uuid: Uuid, +) -> Result<(), String> { + // 检查用户是否是团队成员(工作空间级别) + let member = models::fetch_team_member(&svc_ctx.db, workspace_uuid, team_uuid, user_uuid) + .await + .map_err(|e| e.to_string())? + .ok_or_else(|| "您不是该团队的成员".to_string())?; + + // 所有者不能退出团队 + if member.role == "owner" { + return Err("团队所有者不能退出团队,请先转移所有权或解散团队".to_string()); + } + + // 移除成员 + models::remove_team_member(&svc_ctx.db, team_uuid, user_uuid) + .await + .map_err(|e| e.to_string())?; + + // 更新成员配额(重新计算所有团队的活跃成员总数) + models::update_used_team_members(&svc_ctx.db, workspace_uuid) + .await + .map_err(|e| format!("更新配额失败: {}", e))?; + + // 如果当前团队是用户的活跃团队,则需要切换到其他团队 + let current_team = models::fetch_user_current_team(&svc_ctx.db, user_uuid) + .await + .map_err(|e| e.to_string())?; + + if current_team == Some(team_uuid) { + // 获取用户的其他团队(工作空间级别) + let teams = models::fetch_user_teams(&svc_ctx.db, workspace_uuid, user_uuid) + .await + .map_err(|e| e.to_string())?; + + if let Some(first_team) = teams.first() { + // 切换到第一个可用团队 + models::set_user_current_team(&svc_ctx.db, user_uuid, first_team.uuid) + .await + .map_err(|e| e.to_string())?; + } else { + // 没有其他团队,清除当前团队 + models::clear_user_current_team(&svc_ctx.db, user_uuid) + .await + .map_err(|e| e.to_string())?; + } + } + + Ok(()) +} diff --git a/src-tauri/crates/business/src/services/workspace_quotas.rs b/src-tauri/crates/business/src/services/workspace_quotas.rs index 7fb0b85..38692bd 100644 --- a/src-tauri/crates/business/src/services/workspace_quotas.rs +++ b/src-tauri/crates/business/src/services/workspace_quotas.rs @@ -1,72 +1,71 @@ -use uuid::Uuid; - -use crate::dto::WorkspaceQuotaDto; -use crate::entitys::{GetWorkspaceQuotaRequest, UpdateQuotaUsageRequest}; -use crate::models; -use crate::svc_ctx::SvcCtx; - -/// 获取工作空间配额 -pub async fn get_workspace_quota_service( - svc_ctx: &SvcCtx, - payload: &GetWorkspaceQuotaRequest, -) -> Result { - let workspace_uuid = payload - .workspace_uuid - .ok_or_else(|| "工作空间 UUID 不能为空".to_string())?; - - models::fetch_workspace_quota(&svc_ctx.db, workspace_uuid) - .await - .map_err(|e| e.to_string())? - .ok_or_else(|| "配额不存在".to_string()) -} - -/// 检查配额是否充足 -pub async fn check_quota_service( - svc_ctx: &SvcCtx, - workspace_uuid: Uuid, - quota_type: &str, -) -> Result { - models::check_quota(&svc_ctx.db, workspace_uuid, quota_type) - .await - .map_err(|e| e.to_string()) -} - -/// 更新配额使用情况 -pub async fn update_quota_usage_service( - svc_ctx: &SvcCtx, - payload: &UpdateQuotaUsageRequest, -) -> Result<(), String> { - match payload.quota_type.as_str() { - "environments" => { - if payload.increment { - models::increment_used_environments( - &svc_ctx.db, - payload.workspace_uuid, - payload.amount, - ) - .await - } else { - models::decrement_used_environments( - &svc_ctx.db, - payload.workspace_uuid, - payload.amount, - ) - .await - } - } - "proxies" => { - if payload.increment { - models::increment_used_proxies(&svc_ctx.db, payload.workspace_uuid, payload.amount) - .await - } else { - models::decrement_used_proxies(&svc_ctx.db, payload.workspace_uuid, payload.amount) - .await - } - } - "team_members" => { - models::update_used_team_members(&svc_ctx.db, payload.workspace_uuid).await - } - _ => return Err("不支持的配额类型".to_string()), - } - .map_err(|e| e.to_string()) -} +use uuid::Uuid; + +use crate::dto::WorkspaceQuotaDto; +use crate::entitys::{GetWorkspaceQuotaRequest, UpdateQuotaUsageRequest}; +use crate::models; +use crate::svc_ctx::SvcCtx; + +/// 获取工作空间配额 +pub async fn get_workspace_quota_service( + svc_ctx: &SvcCtx, + payload: &GetWorkspaceQuotaRequest, +) -> Result { + let workspace_uuid = + payload.workspace_uuid.ok_or_else(|| "工作空间 UUID 不能为空".to_string())?; + + models::fetch_workspace_quota(&svc_ctx.db, workspace_uuid) + .await + .map_err(|e| e.to_string())? + .ok_or_else(|| "配额不存在".to_string()) +} + +/// 检查配额是否充足 +pub async fn check_quota_service( + svc_ctx: &SvcCtx, + workspace_uuid: Uuid, + quota_type: &str, +) -> Result { + models::check_quota(&svc_ctx.db, workspace_uuid, quota_type) + .await + .map_err(|e| e.to_string()) +} + +/// 更新配额使用情况 +pub async fn update_quota_usage_service( + svc_ctx: &SvcCtx, + payload: &UpdateQuotaUsageRequest, +) -> Result<(), String> { + match payload.quota_type.as_str() { + "environments" => { + if payload.increment { + models::increment_used_environments( + &svc_ctx.db, + payload.workspace_uuid, + payload.amount, + ) + .await + } else { + models::decrement_used_environments( + &svc_ctx.db, + payload.workspace_uuid, + payload.amount, + ) + .await + } + } + "proxies" => { + if payload.increment { + models::increment_used_proxies(&svc_ctx.db, payload.workspace_uuid, payload.amount) + .await + } else { + models::decrement_used_proxies(&svc_ctx.db, payload.workspace_uuid, payload.amount) + .await + } + } + "team_members" => { + models::update_used_team_members(&svc_ctx.db, payload.workspace_uuid).await + } + _ => return Err("不支持的配额类型".to_string()), + } + .map_err(|e| e.to_string()) +} diff --git a/src-tauri/crates/business/src/services/workspaces.rs b/src-tauri/crates/business/src/services/workspaces.rs index 3e65cd4..a350152 100644 --- a/src-tauri/crates/business/src/services/workspaces.rs +++ b/src-tauri/crates/business/src/services/workspaces.rs @@ -1,184 +1,187 @@ -use uuid::Uuid; - -use crate::dto::WorkspaceDto; -use crate::entitys::{CreateTeamRequest, CreateWorkspaceRequest, UpdateWorkspaceRequest}; -use crate::models; -use crate::svc_ctx::SvcCtx; - -/// 创建工作空间 -pub async fn create_workspace_service( - svc_ctx: &SvcCtx, - user_uuid: Uuid, - payload: &CreateWorkspaceRequest, -) -> Result { - // 获取用户信息,用于生成团队名称 - let user_info = models::user::fetch_user_info_by_uuid(&svc_ctx.db, user_uuid) - .await - .map_err(|e| e.to_string())? - .ok_or_else(|| "用户不存在".to_string())?; - - // 生成团队名称 - let team_name = user_info - .nickname - .as_ref() - .map(|n| format!("{} 的团队", n)) - .unwrap_or_else(|| { - format!( - "{} 的团队", - user_info - .email - .split('@') - .next() - .unwrap_or("用户") - ) - }); - - // 创建工作空间 - let workspace_uuid = models::insert_workspace(&svc_ctx.db, user_uuid, payload) - .await - .map_err(|e| e.to_string())?; - - // 创建默认配额(从配置读取) +use uuid::Uuid; + +use crate::dto::WorkspaceDto; +use crate::entitys::{CreateTeamRequest, CreateWorkspaceRequest, UpdateWorkspaceRequest}; +use crate::models; +use crate::svc_ctx::SvcCtx; + +/// 创建工作空间 +pub async fn create_workspace_service( + svc_ctx: &SvcCtx, + user_uuid: Uuid, + payload: &CreateWorkspaceRequest, +) -> Result { + // 获取用户信息,用于生成团队名称 + let user_info = models::user::fetch_user_info_by_uuid(&svc_ctx.db, user_uuid) + .await + .map_err(|e| e.to_string())? + .ok_or_else(|| "用户不存在".to_string())?; + + // 生成团队名称 + let team_name = + user_info.nickname.as_ref().map(|n| format!("{} 的团队", n)).unwrap_or_else(|| { + format!( + "{} 的团队", + user_info.email.split('@').next().unwrap_or("用户") + ) + }); + + // 创建工作空间 + let workspace_uuid = models::insert_workspace(&svc_ctx.db, user_uuid, payload) + .await + .map_err(|e| e.to_string())?; + + // 创建默认配额(从配置读取) let quota = &svc_ctx.workspace_quota.default; - models::insert_or_update_workspace_quota( - &svc_ctx.db, - workspace_uuid, - quota.max_environments, - quota.max_team_members, - quota.max_proxies, - quota.max_rpa_tasks, - ) - .await - .map_err(|e| e.to_string())?; - - // 创建默认团队(每个工作空间自动创建一个团队) - let team_request = CreateTeamRequest { - workspace_uuid, - name: team_name, - description: Some("默认团队".to_string()), - }; - let team_uuid = models::insert_team(&svc_ctx.db, user_uuid, &team_request) - .await - .map_err(|e| e.to_string())?; - - // 设置用户当前工作空间和团队,确保上下文始终一致。 - models::user::set_user_current_workspace_and_team(&svc_ctx.db, user_uuid, workspace_uuid, team_uuid) - .await - .map_err(|e| e.to_string())?; - - Ok(workspace_uuid) -} - -/// 获取工作空间详情 -pub async fn get_workspace_service( - svc_ctx: &SvcCtx, - workspace_uuid: Uuid, -) -> Result { - models::fetch_workspace_by_uuid(&svc_ctx.db, workspace_uuid) - .await - .map_err(|e| e.to_string())? - .ok_or_else(|| "工作空间不存在".to_string()) -} - -/// 获取用户所属的所有工作空间 -pub async fn get_user_workspaces_service( - svc_ctx: &SvcCtx, - user_uuid: Uuid, -) -> Result, String> { - models::fetch_user_workspaces(&svc_ctx.db, user_uuid) - .await - .map_err(|e| e.to_string()) -} - -/// 更新工作空间 -pub async fn update_workspace_service( - svc_ctx: &SvcCtx, - user_uuid: Uuid, - payload: &UpdateWorkspaceRequest, -) -> Result<(), String> { - // 检查权限(只有所有者可以更新) - let workspace = models::fetch_workspace_by_uuid(&svc_ctx.db, payload.uuid) - .await - .map_err(|e| e.to_string())? - .ok_or_else(|| "工作空间不存在".to_string())?; - - if workspace.owner_uuid != user_uuid { - return Err("只有工作空间所有者可以更新".to_string()); - } - - models::update_workspace(&svc_ctx.db, payload.uuid, payload.name.as_deref()) - .await - .map_err(|e| e.to_string()) -} - -/// 删除工作空间(软删除) -pub async fn delete_workspace_service( - svc_ctx: &SvcCtx, - user_uuid: Uuid, - workspace_uuid: Uuid, -) -> Result<(), String> { - // 检查权限(只有所有者可以删除) - let workspace = models::fetch_workspace_by_uuid(&svc_ctx.db, workspace_uuid) - .await - .map_err(|e| e.to_string())? - .ok_or_else(|| "工作空间不存在".to_string())?; - - if workspace.owner_uuid != user_uuid { - return Err("只有工作空间所有者可以删除".to_string()); - } - - let current_workspace_uuid = models::user::fetch_user_current_workspace(&svc_ctx.db, user_uuid) - .await - .map_err(|e| e.to_string())?; - - if current_workspace_uuid == Some(workspace_uuid) { - return Err("不能删除当前正在使用的工作空间,请先切换到其他工作空间".to_string()); - } - - // 检查用户是否只有一个工作空间,如果是则不允许删除 - let user_workspaces = models::fetch_user_workspaces(&svc_ctx.db, user_uuid) - .await - .map_err(|e| e.to_string())?; - - if user_workspaces.len() <= 1 { - return Err("至少需要保留一个工作空间,无法删除最后一个工作空间".to_string()); - } - - models::delete_workspace(&svc_ctx.db, workspace_uuid) - .await - .map_err(|e| e.to_string()) -} - -/// 检查用户是否是工作空间所有者 -pub async fn check_workspace_owner_service( - svc_ctx: &SvcCtx, - workspace_uuid: Uuid, - user_uuid: Uuid, -) -> Result { - models::check_workspace_owner(&svc_ctx.db, workspace_uuid, user_uuid) - .await - .map_err(|e| e.to_string()) -} - -pub async fn switch_workspace_service( - svc_ctx: &SvcCtx, - workspace_uuid: Uuid, - user_uuid: Uuid, -) -> Result<(), String> { - let teams = models::fetch_user_teams(&svc_ctx.db, workspace_uuid, user_uuid) - .await - .map_err(|e| e.to_string())?; - - let current_team_uuid = models::fetch_user_current_team(&svc_ctx.db, user_uuid) - .await - .map_err(|e| e.to_string())?; - - let team_uuid = current_team_uuid - .filter(|current| teams.iter().any(|team| team.uuid == *current)) - .or_else(|| teams.first().map(|team| team.uuid)) - .ok_or_else(|| "该工作空间下没有可用团队".to_string())?; - - models::user::set_user_current_workspace_and_team(&svc_ctx.db, user_uuid, workspace_uuid, team_uuid) - .await - .map_err(|e| e.to_string()) -} + models::insert_or_update_workspace_quota( + &svc_ctx.db, + workspace_uuid, + quota.max_environments, + quota.max_team_members, + quota.max_proxies, + quota.max_rpa_tasks, + ) + .await + .map_err(|e| e.to_string())?; + + // 创建默认团队(每个工作空间自动创建一个团队) + let team_request = CreateTeamRequest { + workspace_uuid, + name: team_name, + description: Some("默认团队".to_string()), + }; + let team_uuid = models::insert_team(&svc_ctx.db, user_uuid, &team_request) + .await + .map_err(|e| e.to_string())?; + + // 设置用户当前工作空间和团队,确保上下文始终一致。 + models::user::set_user_current_workspace_and_team( + &svc_ctx.db, + user_uuid, + workspace_uuid, + team_uuid, + ) + .await + .map_err(|e| e.to_string())?; + + Ok(workspace_uuid) +} + +/// 获取工作空间详情 +pub async fn get_workspace_service( + svc_ctx: &SvcCtx, + workspace_uuid: Uuid, +) -> Result { + models::fetch_workspace_by_uuid(&svc_ctx.db, workspace_uuid) + .await + .map_err(|e| e.to_string())? + .ok_or_else(|| "工作空间不存在".to_string()) +} + +/// 获取用户所属的所有工作空间 +pub async fn get_user_workspaces_service( + svc_ctx: &SvcCtx, + user_uuid: Uuid, +) -> Result, String> { + models::fetch_user_workspaces(&svc_ctx.db, user_uuid) + .await + .map_err(|e| e.to_string()) +} + +/// 更新工作空间 +pub async fn update_workspace_service( + svc_ctx: &SvcCtx, + user_uuid: Uuid, + payload: &UpdateWorkspaceRequest, +) -> Result<(), String> { + // 检查权限(只有所有者可以更新) + let workspace = models::fetch_workspace_by_uuid(&svc_ctx.db, payload.uuid) + .await + .map_err(|e| e.to_string())? + .ok_or_else(|| "工作空间不存在".to_string())?; + + if workspace.owner_uuid != user_uuid { + return Err("只有工作空间所有者可以更新".to_string()); + } + + models::update_workspace(&svc_ctx.db, payload.uuid, payload.name.as_deref()) + .await + .map_err(|e| e.to_string()) +} + +/// 删除工作空间(软删除) +pub async fn delete_workspace_service( + svc_ctx: &SvcCtx, + user_uuid: Uuid, + workspace_uuid: Uuid, +) -> Result<(), String> { + // 检查权限(只有所有者可以删除) + let workspace = models::fetch_workspace_by_uuid(&svc_ctx.db, workspace_uuid) + .await + .map_err(|e| e.to_string())? + .ok_or_else(|| "工作空间不存在".to_string())?; + + if workspace.owner_uuid != user_uuid { + return Err("只有工作空间所有者可以删除".to_string()); + } + + let current_workspace_uuid = models::user::fetch_user_current_workspace(&svc_ctx.db, user_uuid) + .await + .map_err(|e| e.to_string())?; + + if current_workspace_uuid == Some(workspace_uuid) { + return Err("不能删除当前正在使用的工作空间,请先切换到其他工作空间".to_string()); + } + + // 检查用户是否只有一个工作空间,如果是则不允许删除 + let user_workspaces = models::fetch_user_workspaces(&svc_ctx.db, user_uuid) + .await + .map_err(|e| e.to_string())?; + + if user_workspaces.len() <= 1 { + return Err("至少需要保留一个工作空间,无法删除最后一个工作空间".to_string()); + } + + models::delete_workspace(&svc_ctx.db, workspace_uuid) + .await + .map_err(|e| e.to_string()) +} + +/// 检查用户是否是工作空间所有者 +pub async fn check_workspace_owner_service( + svc_ctx: &SvcCtx, + workspace_uuid: Uuid, + user_uuid: Uuid, +) -> Result { + models::check_workspace_owner(&svc_ctx.db, workspace_uuid, user_uuid) + .await + .map_err(|e| e.to_string()) +} + +pub async fn switch_workspace_service( + svc_ctx: &SvcCtx, + workspace_uuid: Uuid, + user_uuid: Uuid, +) -> Result<(), String> { + let teams = models::fetch_user_teams(&svc_ctx.db, workspace_uuid, user_uuid) + .await + .map_err(|e| e.to_string())?; + + let current_team_uuid = models::fetch_user_current_team(&svc_ctx.db, user_uuid) + .await + .map_err(|e| e.to_string())?; + + let team_uuid = current_team_uuid + .filter(|current| teams.iter().any(|team| team.uuid == *current)) + .or_else(|| teams.first().map(|team| team.uuid)) + .ok_or_else(|| "该工作空间下没有可用团队".to_string())?; + + models::user::set_user_current_workspace_and_team( + &svc_ctx.db, + user_uuid, + workspace_uuid, + team_uuid, + ) + .await + .map_err(|e| e.to_string()) +} diff --git a/src-tauri/crates/business/src/svc_ctx.rs b/src-tauri/crates/business/src/svc_ctx.rs index 4f71960..434aa9c 100644 --- a/src-tauri/crates/business/src/svc_ctx.rs +++ b/src-tauri/crates/business/src/svc_ctx.rs @@ -3,6 +3,7 @@ use crate::{ entitys::CreateWorkspaceRequest, utils::{DatabaseConfig, WorkspaceQuotaConfig}, }; +use std::sync::{Arc, RwLock}; use uuid::Uuid; /// Shared resources used by handlers and services. @@ -11,23 +12,62 @@ pub struct SvcCtx { pub db: DbPool, pub workspace_quota: WorkspaceQuotaConfig, pub local_user_uuid: Uuid, + session_user_uuid: Arc>>, } impl SvcCtx { pub async fn new(config: &DatabaseConfig) -> Result { let db = Self::create_db(config).await?; database::migrate(&db).await?; + crate::services::browser_kernels::import_default_catalog(&db) + .await + .map_err(anyhow::Error::msg)?; + crate::services::browser_kernels::migrate_legacy_environment_bindings(&db) + .await + .map_err(anyhow::Error::msg)?; let local_user_uuid = Self::ensure_local_user(&db).await?; let context = Self { db, workspace_quota: WorkspaceQuotaConfig::default(), local_user_uuid, + session_user_uuid: Arc::new(RwLock::new(None)), }; context.ensure_local_workspace().await?; Ok(context) } + /// Build a request-scoped context for the currently authenticated local user. + /// The database pool and quota configuration stay shared between all users. + pub fn for_current_user(&self) -> Result { + let user_uuid = self + .current_user_uuid() + .ok_or_else(|| anyhow::anyhow!("No local user is authenticated"))?; + Ok(self.for_user(user_uuid)) + } + + pub fn for_user(&self, user_uuid: Uuid) -> Self { + Self { + db: self.db.clone(), + workspace_quota: self.workspace_quota.clone(), + local_user_uuid: user_uuid, + session_user_uuid: self.session_user_uuid.clone(), + } + } + + pub fn current_user_uuid(&self) -> Option { + *self.session_user_uuid.read().expect("local user session lock poisoned") + } + + pub fn authenticate_user(&self, user_uuid: Uuid) { + *self.session_user_uuid.write().expect("local user session lock poisoned") = + Some(user_uuid); + } + + pub fn clear_authenticated_user(&self) { + *self.session_user_uuid.write().expect("local user session lock poisoned") = None; + } + pub async fn create_db(config: &DatabaseConfig) -> Result { database::connect(config).await } @@ -39,6 +79,13 @@ impl SvcCtx { .fetch_optional(db) .await? { + sqlx::query( + "INSERT INTO local_user_auth (user_uuid, avatar) VALUES ($1, '🙂') \ + ON CONFLICT (user_uuid) DO NOTHING", + ) + .bind(uuid) + .execute(db) + .await?; return Ok(uuid); } @@ -56,6 +103,10 @@ impl SvcCtx { .bind(format!("local-{uuid}@simprint.invalid")) .execute(&mut *tx) .await?; + sqlx::query("INSERT INTO local_user_auth (user_uuid, avatar) VALUES ($1, '🙂')") + .bind(uuid) + .execute(&mut *tx) + .await?; tx.commit().await?; Ok(uuid) } diff --git a/src-tauri/src/app/context.rs b/src-tauri/src/app/context.rs index 01eac4f..4f58658 100644 --- a/src-tauri/src/app/context.rs +++ b/src-tauri/src/app/context.rs @@ -10,8 +10,6 @@ use tokio::sync::OnceCell; use crate::app::runtime::SimprintRuntimeManager; use crate::core::config::AppConfig; -use crate::infrastructure::http::encryption::RsaSecret; -use crate::infrastructure::main_server::client::MainServerRequestClient; use crate::local_api::LocalApiManager; use crate::mcp::McpManager; use crate::services::environment::{EnvironmentPositionManager, EnvironmentStatusManager}; @@ -24,18 +22,12 @@ pub struct AppContext { /// 应用配置(不可变) pub config: AppConfig, - /// RSA 密钥对(用于 HTTP 加密) - pub rsa_keypair: Arc, - /// 环境状态管理器 pub env_status_manager: Arc, /// 环境位置管理器 pub env_position_manager: Arc, - /// 主服务器 HTTP 客户端 - pub main_server_client: Arc, - /// 本地 API 服务管理器 pub local_api_manager: Arc, @@ -55,29 +47,6 @@ static APP_CONTEXT: OnceCell> = OnceCell::const_new(); impl AppContext { /// 创建新的应用上下文(早期初始化,不依赖 AppHandle) pub fn new(config: AppConfig) -> anyhow::Result { - // 初始化 RSA 密钥对 - let rsa_keypair = Arc::new(RsaSecret::new()?); - - // 初始化主服务器 HTTP 客户端并设置拦截器 - let mut main_server_client = MainServerRequestClient::new(); - - // 请求拦截器 - main_server_client.before(|rb| { - Box::pin(crate::infrastructure::main_server::interceptors::request::encrypt(rb)) - }); - main_server_client.before(|rb| { - Box::pin(crate::infrastructure::main_server::interceptors::request::auth(rb)) - }); - - // 响应拦截器 - main_server_client.after(|response| { - Box::pin( - crate::infrastructure::main_server::interceptors::response_interceptor(response), - ) - }); - - let main_server_client = Arc::new(main_server_client); - // 初始化环境状态管理器 let env_status_manager = Arc::new(EnvironmentStatusManager::new()); @@ -98,10 +67,8 @@ impl AppContext { Ok(Self { config, - rsa_keypair, env_status_manager, env_position_manager, - main_server_client, local_api_manager, mcp_manager, mihomo_manager, diff --git a/src-tauri/src/app/mod.rs b/src-tauri/src/app/mod.rs index 61c0375..d9a2491 100644 --- a/src-tauri/src/app/mod.rs +++ b/src-tauri/src/app/mod.rs @@ -15,6 +15,43 @@ use crate::commands; use components::tray; use tauri::Manager; +fn initialize_business_context( + database_config: business::utils::DatabaseConfig, + user_kernel_catalog: std::path::PathBuf, +) -> anyhow::Result { + // Tauri invokes `setup` from its async runtime. Blocking that same thread with + // `tauri::async_runtime::block_on` would try to enter Tokio recursively and panic. + // Keep setup synchronous, but perform the one-time async database bootstrap from + // a plain OS thread using Tauri's runtime handle. + std::thread::Builder::new() + .name("business-database-init".to_string()) + .spawn(move || { + tauri::async_runtime::block_on(async move { + let context = business::svc_ctx::SvcCtx::new(&database_config).await?; + let imported = business::services::browser_kernels::import_catalog_file( + &context.db, + &user_kernel_catalog, + ) + .await + .map_err(anyhow::Error::msg)?; + let migrated = + business::services::browser_kernels::migrate_legacy_environment_bindings( + &context.db, + ) + .await + .map_err(anyhow::Error::msg)?; + if imported > 0 || migrated > 0 { + log::info!( + "Imported {imported} user browser kernel records and migrated {migrated} environment bindings" + ); + } + Ok(context) + }) + })? + .join() + .map_err(|_| anyhow::anyhow!("Local business database initialization thread panicked"))? +} + pub fn run() { let ctx = tauri::generate_context!(); let session_lock_manager = session_lock::SessionLockManager::new(); @@ -33,13 +70,19 @@ pub fn run() { let database_file = crate::core::paths::PathManager::get_business_database_file()?; let database_config = business::utils::DatabaseConfig::from_path(&database_file); + let user_kernel_catalog = + crate::core::paths::PathManager::get_config_dir()?.join("browser-kernels.json"); let business_context = - tauri::async_runtime::block_on(business::svc_ctx::SvcCtx::new(&database_config))?; + initialize_business_context(database_config, user_kernel_catalog.clone())?; app.manage(business_context); log::info!( "Local business database initialized: {}", database_file.display() ); + log::info!( + "Optional user browser kernel catalog: {}", + user_kernel_catalog.display() + ); setup::register_deep_link(app.handle().clone())?; diff --git a/src-tauri/src/app/runtime.rs b/src-tauri/src/app/runtime.rs index 8b22975..d27a573 100644 --- a/src-tauri/src/app/runtime.rs +++ b/src-tauri/src/app/runtime.rs @@ -291,26 +291,27 @@ fn runtime_err_to_string( } fn current_auth_info() -> AuthInfo { - use crate::infrastructure::persistence::credential::{get_credential, is_login}; - - if !is_login() { + let Some(user_uuid) = crate::commands::auth::authenticated_user_uuid() else { return AuthInfo { is_authenticated: false, access_token: None, user_info: None, }; - } + }; - let credential = get_credential(); AuthInfo { is_authenticated: true, - access_token: credential.get_access_token(), - user_info: None, + access_token: None, + user_info: Some(crate::infrastructure::runtime::UserInfo { + user_id: user_uuid.to_string(), + username: user_uuid.to_string(), + email: None, + }), } } fn is_runtime_authenticated() -> bool { - crate::infrastructure::persistence::credential::is_login() + crate::commands::auth::has_local_session() } impl Default for SimprintRuntimeManager { diff --git a/src-tauri/src/app/setup.rs b/src-tauri/src/app/setup.rs index 270c499..93db19a 100644 --- a/src-tauri/src/app/setup.rs +++ b/src-tauri/src/app/setup.rs @@ -11,12 +11,15 @@ pub fn register_plugins(app_handle: &AppHandle) { .plugin(tauri_plugin_single_instance::init(|app, argv, _cwd| { use tauri::Manager; - if let Some(main_window) = app.get_webview_window("main") { + let app_state = crate::app::init_state::read_app_init_state(); + if app_state.is_initialized && !app_state.is_updating { + let Some(main_window) = app.get_webview_window("main") else { + return; + }; + // 如果程序启动期间得到深度链接参数,则将事件传递并传递打开的链接给前端。 if let Some(arg_1) = argv.get(1) { if arg_1.contains("://") { - // 收到 deep link 时,先在本地持久化 referral_code(若有) - crate::infrastructure::deeplink::process_arg(arg_1); main_window.emit("deep-link-open", arg_1).unwrap(); } @@ -25,6 +28,10 @@ pub fn register_plugins(app_handle: &AppHandle) { let _ = main_window.show(); let _ = main_window.set_focus(); + } else if let Some(splash_window) = app.get_webview_window("splashscreen") { + // 主窗口仍在并行加载时,不能由第二个实例提前显示。 + let _ = splash_window.show(); + let _ = splash_window.set_focus(); } })) .unwrap(); @@ -32,8 +39,6 @@ pub fn register_plugins(app_handle: &AppHandle) { // Register process plugin app_handle.plugin(tauri_plugin_process::init()).unwrap(); - app_handle.plugin(tauri_plugin_upload::init()).unwrap(); - // deep-link 插件 app_handle.plugin(tauri_plugin_deep_link::init()).unwrap(); @@ -70,24 +75,6 @@ pub fn register_deep_link(app: AppHandle) -> Result<(), anyhow::Error> { Ok(()) } -/// 后台初始化服务器公钥 -pub fn init_server_public_key_background(app_handle: AppHandle) { - tauri::async_runtime::spawn(async move { - match crate::infrastructure::persistence::credential::init_server_public_key().await { - Ok(_) => { - log::info!("Server connection successful"); - // 通知前端:服务器连接成功 - let _ = app_handle.emit("server-connected", ()); - } - Err(e) => { - log::warn!("Server connection failed: {}", e); - // 通知前端:服务器连接失败 - let _ = app_handle.emit("server-connection-failed", e); - } - } - }); -} - pub fn init_simprint_runtime_background(app_handle: AppHandle) { tauri::async_runtime::spawn(async move { use crate::app::context::AppContext; diff --git a/src-tauri/src/app/splashscreen.rs b/src-tauri/src/app/splashscreen.rs index ffc2747..a28648c 100644 --- a/src-tauri/src/app/splashscreen.rs +++ b/src-tauri/src/app/splashscreen.rs @@ -77,25 +77,6 @@ fn emit_status_complete(app_handle: &AppHandle, status: &str) { } } -/// 发射加载完成事件 -fn emit_ready(app_handle: &AppHandle) { - // 向 splashscreen 窗口发送加载完成事件 - if let Some(splash_window) = app_handle.get_webview_window("splashscreen") { - let _ = splash_window.emit("splashscreen-ready", ()); - } - // 向主窗口发送加载完成事件,通知主窗口加载逻辑已完成 - if let Some(main_window) = app_handle.get_webview_window("main") { - let _ = main_window.emit("splashscreen-loading-complete", ()); - } -} - -/// 发射连接失败事件(公钥初始化失败时调用,停止后续流程) -fn emit_connection_failed(app_handle: &AppHandle) { - if let Some(splash_window) = app_handle.get_webview_window("splashscreen") { - let _ = splash_window.emit("splashscreen-connection-failed", ()); - } -} - /// 初始化应用启动流程(显示 splashscreen 并控制加载) pub fn init_startup(app_handle: AppHandle) { // 注意:splashscreen 窗口由前端控制显示,确保内容准备好后再显示 @@ -105,19 +86,18 @@ pub fn init_startup(app_handle: AppHandle) { tauri::async_runtime::spawn(async move { let app_handle_clone = app_handle.clone(); - // 等待前端通知准备就绪(无限期等待,直到前端调用 splashscreen_ready) + // 等待前端完成首帧渲染并明确通知后端,再开始本地启动流程。 while !is_frontend_ready() { tokio::time::sleep(tokio::time::Duration::from_millis(100)).await; } - // 步骤1: 初始化应用 + // 后端初始化在 Tauri setup 阶段已经完成。这里只同步真实状态, + // 不再用固定延迟模拟工作进度。 emit_progress(&app_handle_clone, 10, "正在初始化...", Some("init")); - tokio::time::sleep(tokio::time::Duration::from_millis(300)).await; emit_status_complete(&app_handle_clone, "init"); // 步骤2: 加载配置 emit_progress(&app_handle_clone, 30, "加载配置中...", Some("config")); - tokio::time::sleep(tokio::time::Duration::from_millis(400)).await; emit_status_complete(&app_handle_clone, "config"); // 步骤3: 初始化安全上下文 @@ -127,32 +107,13 @@ pub fn init_startup(app_handle: AppHandle) { "初始化安全上下文...", Some("security"), ); - tokio::time::sleep(tokio::time::Duration::from_millis(300)).await; emit_status_complete(&app_handle_clone, "security"); - // 步骤4: 连接服务器 - emit_progress(&app_handle_clone, 70, "连接服务器...", Some("server")); - - // 等待服务器连接(使用现有的后台初始化) - // 这里我们等待一段时间,实际连接由 init_server_public_key_background 处理 - tokio::time::sleep(tokio::time::Duration::from_millis(800)).await; - - // 检查服务器连接状态 - if let Err(e) = - crate::infrastructure::persistence::credential::init_server_public_key().await - { - log::warn!("Server connection failed: {}", e); - emit_status_complete(&app_handle_clone, "server"); - emit_progress(&app_handle_clone, 90, "服务器连接失败", None); - // 发送连接失败事件,停止后续流程 - emit_connection_failed(&app_handle_clone); - // 不再继续后续步骤(不创建主窗口,不发送 ready 事件) - return; - } - - log::info!("Server connection successful"); - emit_status_complete(&app_handle_clone, "server"); - emit_progress(&app_handle_clone, 90, "服务器连接成功", None); + // 步骤4: 本地业务数据库已经在 Tauri setup 阶段完成初始化。 + // 启动流程不再连接远程业务服务器,也不再依赖远程公钥。 + emit_progress(&app_handle_clone, 70, "加载本地数据...", Some("local-data")); + emit_status_complete(&app_handle_clone, "local-data"); + emit_progress(&app_handle_clone, 90, "本地数据已就绪", None); // 步骤4.1: 检查并处理更新(自动检查、下载、安装) // 跳过参数只在更新入口消费,不传入更新服务、下载器或安装器。 @@ -234,20 +195,30 @@ pub fn init_startup(app_handle: AppHandle) { } } - // 创建主窗口(在步骤4完成后) - if let Err(e) = crate::commands::window::create_main_window(app_handle_clone.clone()).await - { - log::error!("Failed to create main window: {}", e); - // 不阻止加载流程继续 + // 后端流程完成后再创建隐藏主窗口,避免开发模式下两个 WebView + // 同时冷启动并争用 Vite 转换资源。主窗口仍需通过前端真实就绪门闩。 + if let Err(error) = crate::commands::window::create_main_window(app_handle_clone.clone()) { + log::error!("Failed to create main window: {error}"); + emit_progress( + &app_handle_clone, + 100, + "主窗口创建失败", + Some("main-window"), + ); + return; } + log::info!("Hidden main window created after backend startup flow"); - // 步骤5: 准备就绪 - emit_progress(&app_handle_clone, 100, "准备就绪", Some("ready")); - tokio::time::sleep(tokio::time::Duration::from_millis(300)).await; - emit_status_complete(&app_handle_clone, "ready"); + emit_progress( + &app_handle_clone, + 100, + "正在准备主窗口...", + Some("main-window"), + ); - // 发射加载完成事件(前端会自动关闭 splashscreen 并显示主窗口) - emit_ready(&app_handle_clone); + if crate::app::startup::StartupService::backend_startup_ready(&app_handle_clone).is_err() { + log::error!("Failed to complete the backend startup gate"); + } }); } diff --git a/src-tauri/src/app/startup.rs b/src-tauri/src/app/startup.rs index 81a2d0c..376b0d3 100644 --- a/src-tauri/src/app/startup.rs +++ b/src-tauri/src/app/startup.rs @@ -1,11 +1,17 @@ use crate::app::init_state::AppInitState; use serde::Deserialize; +use std::sync::atomic::{AtomicBool, Ordering}; use tauri::{AppHandle, Manager}; pub struct StartupService; const GENERAL_SETTINGS_STORE_KEY: &str = "general"; +/// 启动完成采用双就绪门闩:后端流程与主窗口前端必须都真实就绪。 +static BACKEND_STARTUP_READY: AtomicBool = AtomicBool::new(false); +static MAIN_FRONTEND_READY: AtomicBool = AtomicBool::new(false); +static STARTUP_COMPLETED: AtomicBool = AtomicBool::new(false); + #[derive(Debug, Deserialize)] #[serde(rename_all = "camelCase")] struct GeneralSettingsSnapshot { @@ -19,8 +25,56 @@ fn should_start_minimized(app: &AppHandle) -> bool { .unwrap_or(false) } +fn try_complete_startup(app: &AppHandle) -> Result<(), ()> { + let backend_ready = BACKEND_STARTUP_READY.load(Ordering::Acquire); + let frontend_ready = MAIN_FRONTEND_READY.load(Ordering::Acquire); + + if !backend_ready || !frontend_ready { + log::info!( + "Startup gate waiting: backend_ready={backend_ready}, main_frontend_ready={frontend_ready}" + ); + return Ok(()); + } + + if app.get_webview_window("main").is_none() { + log::error!("Startup gates are ready, but the main window does not exist"); + return Err(()); + } + + if STARTUP_COMPLETED + .compare_exchange(false, true, Ordering::AcqRel, Ordering::Acquire) + .is_err() + { + return Ok(()); + } + + log::info!("Startup gates satisfied; completing startup"); + + let mut app_state = AppInitState::default(); + app_state.is_initialized = true; + app_state.is_updating = false; + let _ = crate::app::init_state::update_app_init_state(app_state); + + if let Some(main_window) = app.get_webview_window("main") { + if should_start_minimized(app) { + log::info!("Start minimized is enabled; keeping the ready main window hidden"); + } else { + let _ = main_window.show(); + let _ = main_window.set_focus(); + log::info!("Main window shown after both startup gates became ready"); + } + } + + // 先显示真正就绪的主窗口,再关闭启动页,避免桌面闪烁。 + if let Some(splash_window) = app.get_webview_window("splashscreen") { + let _ = splash_window.close(); + } + + Ok(()) +} + impl StartupService { - /// 设置应用为更新状态 + /// 设置应用为更新状态。 pub async fn set_updating_state() -> Result<(), ()> { let mut app_state = AppInitState::default(); app_state.is_updating = true; @@ -31,58 +85,49 @@ impl StartupService { Ok(()) } - /// 获取应用状态 + /// 获取应用状态。 pub async fn get_app_state() -> Result { - let app_state = crate::app::init_state::read_app_init_state(); - Ok(app_state) + Ok(crate::app::init_state::read_app_init_state()) } - /// 完成加载并显示主窗口(关闭加载窗口并显示主窗口) - pub async fn complete_and_show_main(app: AppHandle) -> Result<(), ()> { - // 设置为已初始化状态 - { - let mut app_state = AppInitState::default(); - app_state.is_initialized = true; - app_state.is_updating = false; - let _ = crate::app::init_state::update_app_init_state(app_state); - } - - // 关闭 splashscreen 窗口 - if let Some(splash_window) = app.get_webview_window("splashscreen") { - let _ = splash_window.close(); - } - - // 按配置决定是否显示主窗口 - if let Some(main_window) = app.get_webview_window("main") { - if should_start_minimized(&app) { - log::info!("启动时最小化已启用,主窗口保持隐藏"); - } else { - let _ = main_window.show(); - let _ = main_window.set_focus(); - log::info!("主窗口已显示"); - } - } + /// 记录隐藏主窗口的前端已经完成插件、认证、路由、字体与首帧布局。 + pub async fn main_window_ready(app: AppHandle) -> Result<(), ()> { + MAIN_FRONTEND_READY.store(true, Ordering::Release); + log::info!("Main window frontend reported ready"); + try_complete_startup(&app) + } - Ok(()) + /// 记录后端启动流程已经完成,并尝试与前端就绪状态汇合。 + pub fn backend_startup_ready(app: &AppHandle) -> Result<(), ()> { + BACKEND_STARTUP_READY.store(true, Ordering::Release); + log::info!("Backend startup flow reported ready"); + try_complete_startup(app) } - /// 显示主窗口(由前端在内容渲染完成后调用) + /// 显示已经完成初始化的主窗口。 pub async fn show_main_window(app: AppHandle) -> Result<(), ()> { + let app_state = crate::app::init_state::read_app_init_state(); + if !app_state.is_initialized || app_state.is_updating { + log::warn!("Ignored a request to show the main window before startup completed"); + return Ok(()); + } + if let Some(main_window) = app.get_webview_window("main") { if should_start_minimized(&app) { - log::info!("启动时最小化已启用,跳过主窗口显示"); + log::info!("Start minimized is enabled; skipping main window display"); } else { let _ = main_window.show(); let _ = main_window.set_focus(); - log::info!("主窗口已显示"); + log::info!("Main window shown"); } } Ok(()) } - /// 通知后端前端 splashscreen 已准备好接收事件 + /// 通知后端 splashscreen 已准备好接收进度事件。 pub async fn splashscreen_ready() -> Result<(), ()> { crate::app::splashscreen::set_frontend_ready(); + log::info!("Splashscreen frontend reported ready"); Ok(()) } } diff --git a/src-tauri/src/commands.rs b/src-tauri/src/commands.rs index 855a814..d6d4d6b 100644 --- a/src-tauri/src/commands.rs +++ b/src-tauri/src/commands.rs @@ -19,7 +19,7 @@ pub mod window; pub fn register_handles() -> impl Fn(Invoke) -> bool + Send + Sync + 'static { tauri::generate_handler![ // App commands - app::complete_and_show_main, + app::main_window_ready, app::show_main_window, app::get_auto_start_state, app::set_auto_start_enabled, @@ -62,31 +62,24 @@ pub fn register_handles() -> impl Fn(Invoke) -> bool + Send + Sync + file_system::get_storage_default_paths, file_system::get_directory_sizes, // Security commands - security::get_client_public_key, security::report_user_activity, security::get_session_lock_state, security::unlock_session, // Network commands - network::http_get, network::http_post, - network::http_post_form, - network::http_put, - network::http_delete, network::test_proxy, network::test_direct_ip, network::detect_proxy_ip, network::download_files, rpa::execute_local_rpa_script, // Auth commands - auth::login, - auth::register, + auth::list_local_users, + auth::create_local_user, + auth::login_local_user, + auth::get_current_local_user, + auth::verify_local_user_password, auth::logout, - auth::save_credential, - auth::get_access_token, auth::is_logged_in, - auth::save_remembered_credential, - auth::get_remembered_credential, - auth::clear_remembered_credential, // Updater commands updater::check_update_available, updater::check_updates, diff --git a/src-tauri/src/commands/app.rs b/src-tauri/src/commands/app.rs index 868bffc..2e378fc 100644 --- a/src-tauri/src/commands/app.rs +++ b/src-tauri/src/commands/app.rs @@ -24,10 +24,10 @@ pub async fn get_app_state() -> Result { } #[tauri::command] -pub async fn complete_and_show_main(app: AppHandle) -> Result<()> { - crate::app::startup::StartupService::complete_and_show_main(app) +pub async fn main_window_ready(app: AppHandle) -> Result<()> { + crate::app::startup::StartupService::main_window_ready(app) .await - .map_err(|_| "显示主窗口失败".into()) + .map_err(|_| "记录主窗口就绪状态失败".into()) } #[tauri::command] diff --git a/src-tauri/src/commands/auth.rs b/src-tauri/src/commands/auth.rs index 4477bd8..d5a2faa 100644 --- a/src-tauri/src/commands/auth.rs +++ b/src-tauri/src/commands/auth.rs @@ -1,80 +1,89 @@ -/// 认证模块命令 -/// -/// 命令层仅负责参数解析和响应,业务逻辑由服务层处理 -use crate::core::error::Result; -use crate::infrastructure::http::client::JsonRespnse; -use crate::services::auth::{CredentialService, LoginService, RegisterService}; +use business::services::local_users::{CreateLocalUserRequest, LocalUser, LoginLocalUserRequest}; -// 重导出类型供外部使用 -pub use crate::services::auth::{ - BasicLoginRequest, LoginResponse, LoginType, RegisterRequest, RememberPasswordLoginRequest, -}; +use crate::app::context::AppContext; -// ============================================================================ -// 登录相关命令 -// ============================================================================ - -/// 登录命令 #[tauri::command] -pub async fn login(payload: LoginType) -> Result { - LoginService::login(payload).await +pub async fn list_local_users( + context: tauri::State<'_, business::svc_ctx::SvcCtx>, +) -> Result, String> { + business::services::local_users::list_local_users(&context).await } -/// 保存凭证命令 #[tauri::command] -pub async fn save_credential( - access_token: Option, - refresh_token: Option, -) -> Result<()> { - LoginService::save_credential(access_token, refresh_token).await +pub async fn create_local_user( + payload: CreateLocalUserRequest, + context: tauri::State<'_, business::svc_ctx::SvcCtx>, +) -> Result { + let user = business::services::local_users::create_local_user(&context, &payload).await?; + context.authenticate_user(user.uuid); + sync_local_session().await?; + Ok(user) } -// ============================================================================ -// 注册相关命令 -// ============================================================================ - -/// 注册命令 #[tauri::command] -pub async fn register(payload: RegisterRequest) -> Result { - RegisterService::register(payload).await +pub async fn login_local_user( + payload: LoginLocalUserRequest, + context: tauri::State<'_, business::svc_ctx::SvcCtx>, +) -> Result { + let user = business::services::local_users::authenticate_local_user(&context, &payload).await?; + sync_local_session().await?; + Ok(user) } -// ============================================================================ -// 凭证管理命令 -// ============================================================================ - -/// 退出登录 #[tauri::command] -pub async fn logout() -> Result<()> { - CredentialService::logout().await +pub async fn get_current_local_user( + context: tauri::State<'_, business::svc_ctx::SvcCtx>, +) -> Result, String> { + business::services::local_users::current_local_user(&context).await } -/// 获取登录凭证(access_token) #[tauri::command] -pub async fn get_access_token() -> Result { - CredentialService::get_access_token() +pub async fn verify_local_user_password( + password: Option, + context: tauri::State<'_, business::svc_ctx::SvcCtx>, +) -> Result<(), String> { + let user_uuid = context.current_user_uuid().ok_or_else(|| "尚未选择本地用户".to_string())?; + business::services::local_users::verify_local_user_password( + &context, + user_uuid, + password.as_deref(), + ) + .await } -/// 检查是否已登录 #[tauri::command] -pub async fn is_logged_in() -> Result { - Ok(CredentialService::is_logged_in()) +pub async fn logout(context: tauri::State<'_, business::svc_ctx::SvcCtx>) -> Result<(), String> { + context.clear_authenticated_user(); + if let Some(app_context) = AppContext::try_get() { + app_context.mcp_manager.stop().await; + app_context.local_api_manager.stop().await; + app_context.simprint_runtime_manager.stop().await; + } + Ok(()) } -/// 保存记住的凭证(用于"记住密码"功能) #[tauri::command] -pub async fn save_remembered_credential(email: String, refresh_token: String) -> Result<()> { - CredentialService::save_remembered_credential(email, refresh_token) +pub fn is_logged_in(context: tauri::State<'_, business::svc_ctx::SvcCtx>) -> bool { + context.current_user_uuid().is_some() } -/// 获取记住的凭证(用于自动登录) -#[tauri::command] -pub async fn get_remembered_credential() -> Result> { - CredentialService::get_remembered_credential() +async fn sync_local_session() -> Result<(), String> { + if let Some(context) = AppContext::try_get() { + context + .simprint_runtime_manager + .sync_session_state() + .await + .map_err(|error| error.to_string())?; + } + Ok(()) } -/// 清除记住的凭证 -#[tauri::command] -pub async fn clear_remembered_credential() -> Result<()> { - CredentialService::clear_remembered_credential() +pub(crate) fn authenticated_user_uuid() -> Option { + use tauri::Manager; + let app = crate::app::handle::get_app_handle().ok()?; + app.state::().current_user_uuid() +} + +pub(crate) fn has_local_session() -> bool { + authenticated_user_uuid().is_some() } diff --git a/src-tauri/src/commands/mihomo.rs b/src-tauri/src/commands/mihomo.rs index 97eddbd..4252b4a 100644 --- a/src-tauri/src/commands/mihomo.rs +++ b/src-tauri/src/commands/mihomo.rs @@ -72,10 +72,7 @@ pub async fn get_local_mihomo_proxies( #[tauri::command] pub async fn ensure_mihomo_local_proxy_listeners(app: tauri::AppHandle) -> Result { - AppContext::get() - .mihomo_manager - .ensure_local_proxy_listeners(&app) - .await + AppContext::get().mihomo_manager.ensure_local_proxy_listeners(&app).await } #[tauri::command] diff --git a/src-tauri/src/commands/network.rs b/src-tauri/src/commands/network.rs index bddde64..a1d4b61 100644 --- a/src-tauri/src/commands/network.rs +++ b/src-tauri/src/commands/network.rs @@ -1,7 +1,6 @@ /// 网络模块命令 /// /// 命令层仅负责参数解析和响应,业务逻辑由服务层处理 -use crate::app::context::AppContext; use crate::core::error::Result; use crate::infrastructure::http::client::JsonRespnse; use crate::services::connectivity::{DownloadService, ProxyService}; @@ -15,27 +14,7 @@ pub use crate::infrastructure::proxy::{IpInfo, ProxyConfig, ProxyTestResult}; // HTTP 请求命令 // ============================================================================ -/// 文件上传信息 -#[derive(serde::Deserialize)] -pub struct FileInfo { - /// 文件路径 - pub path: String, - /// 文件名(可选,不指定则使用文件路径中的文件名) - #[serde(default)] - pub file_name: Option, - /// MIME 类型(可选,不指定则自动推断) - #[serde(default)] - pub mime_type: Option, -} - -/// HTTP GET 请求 -#[tauri::command] -pub async fn http_get(url: String) -> std::result::Result { - let ctx = AppContext::get(); - ctx.main_server_client.get(&url).await.map_err(|e| e.to_string()) -} - -/// HTTP POST 请求 +/// 将业务请求分发到内嵌数据库。未知路由直接在本地失败,不再回退到远程服务。 #[tauri::command] pub async fn http_post( url: String, @@ -43,8 +22,9 @@ pub async fn http_post( business_context: tauri::State<'_, business::svc_ctx::SvcCtx>, ) -> std::result::Result { let payload = data.unwrap_or_else(|| Value::Object(Default::default())); + let request_context = business_context.for_current_user().map_err(|error| error.to_string())?; if let Some(result) = - business::dispatcher::dispatch_post(&business_context, &url, &payload).await + business::dispatcher::dispatch_post(&request_context, &url, &payload).await { return Ok(match result { Ok(data) => JsonRespnse { @@ -60,83 +40,7 @@ pub async fn http_post( }); } - let ctx = AppContext::get(); - ctx.main_server_client - .post(&url, &Some(payload)) - .await - .map_err(|e| e.to_string()) -} - -/// HTTP POST 表单请求(用于文件上传) -#[tauri::command] -pub async fn http_post_form( - url: String, - files: Option>>, - fields: Option>, -) -> std::result::Result { - use reqwest::multipart::{Form, Part}; - use tokio::fs::File; - use tokio::io::AsyncReadExt; - - let mut form = Form::new(); - - // 添加文件字段 - if let Some(files_map) = files { - for (field_name, file_list) in files_map { - for file_info in file_list { - let mut file = File::open(&file_info.path).await.map_err(|e| e.to_string())?; - - let mut buffer = Vec::new(); - file.read_to_end(&mut buffer).await.map_err(|e| e.to_string())?; - - // 确定文件名 - let file_name = file_info.file_name.clone().unwrap_or_else(|| { - std::path::Path::new(&file_info.path) - .file_name() - .and_then(|n| n.to_str()) - .unwrap_or("file") - .to_string() - }); - - // 创建 Part - let mut part = Part::bytes(buffer).file_name(file_name); - - // 设置 MIME 类型 - if let Some(ref mime) = file_info.mime_type { - part = part.mime_str(mime).map_err(|e| e.to_string())?; - } - - form = form.part(field_name.clone(), part); - } - } - } - - // 添加文本字段 - if let Some(fields_map) = fields { - for (key, value) in fields_map { - form = form.text(key, value); - } - } - - let ctx = AppContext::get(); - ctx.main_server_client.post_form(&url, form).await.map_err(|e| e.to_string()) -} - -/// HTTP PUT 请求 -#[tauri::command] -pub async fn http_put( - url: String, - data: Option, -) -> std::result::Result { - let ctx = AppContext::get(); - ctx.main_server_client.put(&url, &data).await.map_err(|e| e.to_string()) -} - -/// HTTP DELETE 请求 -#[tauri::command] -pub async fn http_delete(url: String, data: Value) -> std::result::Result { - let ctx = AppContext::get(); - ctx.main_server_client.delete(&url, &data).await.map_err(|e| e.to_string()) + Err(format!("本地业务路由不存在: {url}")) } // ============================================================================ diff --git a/src-tauri/src/commands/rpa.rs b/src-tauri/src/commands/rpa.rs index 1ca9cfb..4dd56a3 100644 --- a/src-tauri/src/commands/rpa.rs +++ b/src-tauri/src/commands/rpa.rs @@ -64,12 +64,10 @@ pub async fn execute_local_rpa_script( }); } - return Err( - envelope - .error - .filter(|message| !message.trim().is_empty()) - .unwrap_or_else(|| "LOCAL_SCRIPT_EXECUTION_FAILED".to_string()), - ); + return Err(envelope + .error + .filter(|message| !message.trim().is_empty()) + .unwrap_or_else(|| "LOCAL_SCRIPT_EXECUTION_FAILED".to_string())); } if !stderr.is_empty() { diff --git a/src-tauri/src/commands/security.rs b/src-tauri/src/commands/security.rs index 2ab1324..9b5ed1e 100644 --- a/src-tauri/src/commands/security.rs +++ b/src-tauri/src/commands/security.rs @@ -1,17 +1,8 @@ -use crate::app::context::AppContext; /// 安全相关的 Tauri 命令 use crate::app::session_lock::{SessionLockManager, SessionLockStateResponse}; use crate::core::error::Result; use tauri::{AppHandle, State}; -/// 获取客户端公钥 -#[tauri::command] -pub fn get_client_public_key() -> Result { - let ctx = AppContext::get(); - let public_key = ctx.rsa_keypair.get_public_key()?; - Ok(public_key) -} - /// 上报用户活跃 #[tauri::command] pub async fn report_user_activity( diff --git a/src-tauri/src/commands/window.rs b/src-tauri/src/commands/window.rs index 8488702..3d45b57 100644 --- a/src-tauri/src/commands/window.rs +++ b/src-tauri/src/commands/window.rs @@ -53,6 +53,6 @@ pub fn create_splashscreen_window(app_handle: AppHandle) -> Result<()> { } /// 创建主窗口(内部使用,不暴露为 Tauri 命令) -pub async fn create_main_window(app_handle: AppHandle) -> Result<()> { - WindowService::create_main_window(&app_handle).await +pub fn create_main_window(app_handle: AppHandle) -> Result<()> { + WindowService::create_main_window(&app_handle) } diff --git a/src-tauri/src/core/config/mod.rs b/src-tauri/src/core/config/mod.rs index 386de43..83a2666 100644 --- a/src-tauri/src/core/config/mod.rs +++ b/src-tauri/src/core/config/mod.rs @@ -7,7 +7,7 @@ mod loader; mod types; mod validator; -pub use types::{AppConfig, ServerConfig, UpdaterConfig}; +pub use types::{AppConfig, UpdaterConfig}; use crate::core::error::{Error, Result}; use std::{fs, path::PathBuf, sync::OnceLock}; diff --git a/src-tauri/src/core/config/types.rs b/src-tauri/src/core/config/types.rs index ba838ff..3464fce 100644 --- a/src-tauri/src/core/config/types.rs +++ b/src-tauri/src/core/config/types.rs @@ -4,17 +4,6 @@ use serde::Deserialize; -/// 服务器配置 -#[derive(Deserialize, Debug, Clone)] -pub struct ServerConfig { - /// 服务器基础URL - pub base_url: String, - /// API版本 - pub version: String, - /// 密钥 - pub secret_key: String, -} - /// 更新器配置 #[derive(Deserialize, Debug, Clone)] pub struct UpdaterConfig { @@ -31,8 +20,6 @@ pub struct UpdaterConfig { /// 应用配置 #[derive(Deserialize, Debug, Clone)] pub struct AppConfig { - /// 服务器配置 - pub server: ServerConfig, /// 更新器配置 pub updater: UpdaterConfig, } diff --git a/src-tauri/src/core/config/validator.rs b/src-tauri/src/core/config/validator.rs index 3bcf05d..d71e408 100644 --- a/src-tauri/src/core/config/validator.rs +++ b/src-tauri/src/core/config/validator.rs @@ -7,42 +7,10 @@ use crate::core::error::{Error, Result}; /// 验证配置 pub fn validate(config: &AppConfig) -> Result<()> { - validate_server_config(config)?; validate_updater_config(config)?; Ok(()) } -/// 验证服务器配置 -fn validate_server_config(config: &AppConfig) -> Result<()> { - if config.server.base_url.is_empty() { - return Err(Error::ConfigValidationFailed( - "server.base_url cannot be empty".to_string(), - )); - } - - if !config.server.base_url.starts_with("http://") - && !config.server.base_url.starts_with("https://") - { - return Err(Error::ConfigValidationFailed( - "server.base_url must start with http:// or https://".to_string(), - )); - } - - if config.server.version.is_empty() { - return Err(Error::ConfigValidationFailed( - "server.version cannot be empty".to_string(), - )); - } - - if config.server.secret_key.is_empty() { - return Err(Error::ConfigValidationFailed( - "server.secret_key cannot be empty".to_string(), - )); - } - - Ok(()) -} - /// 验证更新器配置 fn validate_updater_config(config: &AppConfig) -> Result<()> { if config.updater.check_url.is_empty() { diff --git a/src-tauri/src/core/error/convert.rs b/src-tauri/src/core/error/convert.rs index ef52456..8a4d3a4 100644 --- a/src-tauri/src/core/error/convert.rs +++ b/src-tauri/src/core/error/convert.rs @@ -4,25 +4,6 @@ use super::Error; -/// 从基础设施层的网络错误转换 -impl From for Error { - fn from(err: crate::infrastructure::main_server::error::ResponseError) -> Self { - use crate::infrastructure::main_server::error::ResponseError; - match err { - ResponseError::Unauthorized { .. } => Self::AuthTokenInvalid, - ResponseError::PublicKeyExpired => Self::PublicKeyParseFailed, - ResponseError::BadRequest { .. } => Self::NetworkRequestFailed, - ResponseError::UnprocessableEntity { .. } => Self::NetworkRequestFailed, - ResponseError::EmptyResponse { .. } => Self::NetworkRequestFailed, - ResponseError::JsonParseError { .. } => Self::DeserializeFailed, - ResponseError::ReadResponseFailed(_) => Self::NetworkRequestFailed, - ResponseError::ParseEncryptedDataFailed(_) => Self::DecryptFailed, - ResponseError::DecryptFailed(_) => Self::DecryptFailed, - ResponseError::ParseResponseFailed(_) => Self::DeserializeFailed, - } - } -} - /// 从 anyhow::Error 转换(兜底转换) impl From for Error { fn from(_err: anyhow::Error) -> Self { diff --git a/src-tauri/src/core/paths.rs b/src-tauri/src/core/paths.rs index f26ced9..f751e0e 100644 --- a/src-tauri/src/core/paths.rs +++ b/src-tauri/src/core/paths.rs @@ -29,7 +29,6 @@ struct BootstrapConfig { cache_dir: Option, data_dir: Option, kernels_dir: Option, - referral_dir: Option, updater_dir: Option, update_tasks_file: Option, } @@ -178,16 +177,6 @@ impl PathManager { Ok(dir) } - pub fn get_referral_dir() -> Result { - let dir = Self::resolve_named_dir( - Self::load_bootstrap().referral_dir.as_deref(), - Self::get_root_dir()?, - "referral", - )?; - Self::ensure_dir(&dir)?; - Ok(dir) - } - pub fn get_updater_dir() -> Result { let dir = Self::resolve_named_dir( Self::load_bootstrap().updater_dir.as_deref(), @@ -342,11 +331,6 @@ impl PathManager { &Self::get_kernels_dir()?.to_string_lossy().to_string(), ) .context("写入注册表 KernelsDir 失败")?; - key.set_value( - "ReferralDir", - &Self::get_referral_dir()?.to_string_lossy().to_string(), - ) - .context("写入注册表 ReferralDir 失败")?; key.set_value( "UpdaterDir", &Self::get_updater_dir()?.to_string_lossy().to_string(), @@ -407,7 +391,6 @@ impl BootstrapConfig { && self.cache_dir.is_none() && self.data_dir.is_none() && self.kernels_dir.is_none() - && self.referral_dir.is_none() && self.updater_dir.is_none() && self.update_tasks_file.is_none() } diff --git a/src-tauri/src/core/utils/process.rs b/src-tauri/src/core/utils/process.rs index 043a083..c26a36f 100644 --- a/src-tauri/src/core/utils/process.rs +++ b/src-tauri/src/core/utils/process.rs @@ -17,10 +17,7 @@ pub struct FindProcessResult { fn normalize_process_identity(value: &str) -> String { let normalized = value.trim().to_ascii_lowercase(); - normalized - .strip_suffix(".exe") - .unwrap_or(&normalized) - .to_string() + normalized.strip_suffix(".exe").unwrap_or(&normalized).to_string() } fn process_matches_candidate( diff --git a/src-tauri/src/domain/credential.rs b/src-tauri/src/domain/credential.rs deleted file mode 100644 index acb59bd..0000000 --- a/src-tauri/src/domain/credential.rs +++ /dev/null @@ -1,63 +0,0 @@ -//! 凭证领域模型 -//! -//! 封装凭证相关的业务规则和验证逻辑 - -use serde::{Deserialize, Serialize}; - -/// 凭证领域对象 -#[derive(Debug, Clone, Serialize, Deserialize)] -pub struct Credential { - /// 访问令牌 - pub access_token: String, - /// 刷新令牌 - pub refresh_token: String, -} - -impl Credential { - /// 创建新凭证 - pub fn new(access_token: String, refresh_token: String) -> Self { - Self { - access_token, - refresh_token, - } - } - - /// 验证凭证是否有效 - pub fn is_valid(&self) -> bool { - !self.access_token.is_empty() && !self.refresh_token.is_empty() - } - - /// 获取访问令牌 - pub fn access_token(&self) -> &str { - &self.access_token - } - - /// 获取刷新令牌 - pub fn refresh_token(&self) -> &str { - &self.refresh_token - } -} - -/// 记住的凭证(用于自动登录) -#[derive(Debug, Clone, Serialize, Deserialize)] -pub struct RememberedCredential { - /// 邮箱 - pub email: String, - /// 刷新令牌 - pub refresh_token: String, -} - -impl RememberedCredential { - /// 创建记住的凭证 - pub fn new(email: String, refresh_token: String) -> Self { - Self { - email, - refresh_token, - } - } - - /// 验证是否有效 - pub fn is_valid(&self) -> bool { - !self.email.is_empty() && !self.refresh_token.is_empty() - } -} diff --git a/src-tauri/src/domain/environment.rs b/src-tauri/src/domain/environment.rs index 02e411e..b198099 100644 --- a/src-tauri/src/domain/environment.rs +++ b/src-tauri/src/domain/environment.rs @@ -72,6 +72,9 @@ pub struct KernelDetail { /// 签名哈希(用于核心文件验证) #[serde(default)] pub signature: Option, + /// 允许复用的历史安装签名;仅用于校验已经存在的内核 + #[serde(default)] + pub compatible_signatures: Vec, /// 是否需要解压 pub requires_extract: bool, } @@ -83,6 +86,7 @@ impl KernelDetail { url, hash, signature: None, + compatible_signatures: Vec::new(), requires_extract, } } diff --git a/src-tauri/src/domain/mod.rs b/src-tauri/src/domain/mod.rs index 53aab9f..e1f2729 100644 --- a/src-tauri/src/domain/mod.rs +++ b/src-tauri/src/domain/mod.rs @@ -2,7 +2,5 @@ //! //! 包含核心业务模型和业务规则,无外部依赖 -pub mod credential; pub mod environment; -pub mod referral_code; pub mod user; diff --git a/src-tauri/src/domain/referral_code.rs b/src-tauri/src/domain/referral_code.rs deleted file mode 100644 index 38ad6fa..0000000 --- a/src-tauri/src/domain/referral_code.rs +++ /dev/null @@ -1,48 +0,0 @@ -//! 邀请码值对象 -//! -//! 封装邀请码的解码和验证逻辑 - -use crate::core::error::Result; -use base64::Engine; -use base64::engine::general_purpose::URL_SAFE_NO_PAD; - -/// 邀请码值对象 -#[derive(Debug, Clone)] -pub struct ReferralCode { - code: String, -} - -impl ReferralCode { - /// 创建邀请码 - pub fn new(code: String) -> Self { - Self { code } - } - - /// 从 base64url 编码的字符串解码 - pub fn decode(encoded: &str) -> Result { - let s = encoded.trim(); - if s.is_empty() { - return Err("邀请码为空".into()); - } - - let decoded = URL_SAFE_NO_PAD.decode(s.as_bytes()).map_err(|_| "邀请码解码失败")?; - - let code = String::from_utf8(decoded).map_err(|_| "邀请码格式无效")?; - - if code.is_empty() { - return Err("邀请码为空".into()); - } - - Ok(Self { code }) - } - - /// 验证邀请码是否有效 - pub fn is_valid(&self) -> bool { - !self.code.is_empty() - } - - /// 获取邀请码 - pub fn code(&self) -> &str { - &self.code - } -} diff --git a/src-tauri/src/infrastructure/deeplink/mod.rs b/src-tauri/src/infrastructure/deeplink/mod.rs deleted file mode 100644 index f80aaff..0000000 --- a/src-tauri/src/infrastructure/deeplink/mod.rs +++ /dev/null @@ -1,31 +0,0 @@ -use reqwest::Url; - -pub mod referral; -pub mod storage; - -/// Deeplink 统一入口:传入 argv 中的某个参数,若是 URL 则分发给各 handler。 -pub fn process_arg(arg: &str) { - if !arg.contains("://") { - return; - } - - let Ok(url) = Url::parse(arg) else { - return; - }; - - // 目前只处理推广码,后续可继续追加更多 handler - referral::handle_referral_code(&url); -} - -/// 冷启动/单实例启动场景:从 argv 中找到第一个 URL 参数并处理。 -pub fn process_first_url_arg(args: I) -where - I: IntoIterator, -{ - for arg in args { - if arg.contains("://") { - process_arg(&arg); - break; - } - } -} diff --git a/src-tauri/src/infrastructure/deeplink/referral.rs b/src-tauri/src/infrastructure/deeplink/referral.rs deleted file mode 100644 index 48cad21..0000000 --- a/src-tauri/src/infrastructure/deeplink/referral.rs +++ /dev/null @@ -1,33 +0,0 @@ -use reqwest::Url; - -use base64::Engine; -use base64::engine::general_purpose::URL_SAFE_NO_PAD; - -use super::storage; - -/// 从 deep link URL 中提取 `referral_code` 并落盘。 -pub fn handle_referral_code(url: &Url) { - let referral_code = url - .query_pairs() - .find_map(|(k, v)| (k == "referral_code").then(|| v.to_string())); - - if let Some(code) = referral_code.and_then(sanitize_referral_code) { - // 与注册侧提取规则对齐:创建名为 R_{base64url(code)}_R 的空文件 - let encoded = URL_SAFE_NO_PAD.encode(code.as_bytes()); - let marker_name = format!("R_{}_R", encoded); - let _ = storage::store_referral_code(&marker_name); - } -} - -fn sanitize_referral_code(raw: String) -> Option { - let trimmed = raw.trim(); - if trimmed.is_empty() { - return None; - } - // 文件名安全:仅保留字母数字、_、- - let cleaned: String = trimmed - .chars() - .filter(|c| c.is_ascii_alphanumeric() || *c == '_' || *c == '-') - .collect(); - (!cleaned.is_empty()).then_some(cleaned) -} diff --git a/src-tauri/src/infrastructure/deeplink/storage.rs b/src-tauri/src/infrastructure/deeplink/storage.rs deleted file mode 100644 index b02313e..0000000 --- a/src-tauri/src/infrastructure/deeplink/storage.rs +++ /dev/null @@ -1,15 +0,0 @@ -use std::fs; - -/// 推广码存储:在统一根目录的 `referral/` 创建空文件。 -pub fn store_referral_code(code: &str) -> Result<(), String> { - let referral_dir = - crate::core::paths::PathManager::get_referral_dir().map_err(|e| e.to_string())?; - - // 先删除旧目录,再写入(确保单一来源,避免残留) - let _ = fs::remove_dir_all(&referral_dir); - fs::create_dir_all(&referral_dir).map_err(|e| e.to_string())?; - - let file_path = referral_dir.join(code); - fs::write(&file_path, "").map_err(|e| e.to_string())?; - Ok(()) -} diff --git a/src-tauri/src/infrastructure/http/client/client.rs b/src-tauri/src/infrastructure/http/client/client.rs deleted file mode 100644 index e1cc9ab..0000000 --- a/src-tauri/src/infrastructure/http/client/client.rs +++ /dev/null @@ -1,156 +0,0 @@ -use reqwest::{Url, header}; -use std::{fmt::Debug, future::Future, pin::Pin, time::Duration}; - -use crate::app::context::AppContext; - -pub type BeforeCallFunction = fn( - rb: reqwest::RequestBuilder, -) -> Pin< - Box> + Send>, ->; - -pub type AfterCallFunction = fn( - response: reqwest::Response, -) -> Pin< - Box> + Send>, ->; - -pub struct Client { - client: reqwest::Client, - before: Vec, - after: Vec, -} - -impl Client { - pub fn new(timeout: u64) -> Client { - Client { - client: reqwest::Client::builder() - .timeout(Duration::from_secs(timeout)) - .default_headers({ - let headers = header::HeaderMap::new(); - headers - }) - .build() - .unwrap(), - before: vec![], - after: vec![], - } - } - - pub fn build_url(resource: &str) -> Result { - let ctx = AppContext::get(); - let config = ctx.config(); - let server_url = config.server.base_url.as_str(); - let version = config.server.version.as_str(); - - let t = Url::parse(server_url)?.join(&format!("{}/", version))?.join(resource)?; - - Ok(t) - } - - async fn run_before( - &self, - mut request_builder: reqwest::RequestBuilder, - ) -> core::result::Result { - for call in &self.before { - request_builder = call(request_builder).await?; - } - Ok(request_builder) - } - - async fn run_after( - &self, - mut response: reqwest::Response, - ) -> core::result::Result { - for call in &self.after { - response = call(response).await?; - } - Ok(response) - } - - pub async fn post( - &self, - url: reqwest::Url, - json: &T, - ) -> core::result::Result - where - T: serde::Serialize + Debug, - { - let request_builder = self.client.post(url).json(json); - let request_builder = self.run_before(request_builder).await?; - let response = request_builder.send().await?; - self.run_after(response).await - } - - pub async fn post_with_headers( - &self, - url: reqwest::Url, - json: &T, - headers: reqwest::header::HeaderMap, - ) -> core::result::Result - where - T: serde::Serialize + Debug, - { - let request_builder = self.client.post(url).headers(headers).json(json); - let request_builder = self.run_before(request_builder).await?; - let response = request_builder.send().await?; - self.run_after(response).await - } - - pub async fn post_form( - &self, - url: reqwest::Url, - form: reqwest::multipart::Form, - ) -> core::result::Result { - let request_builder = self.client.post(url).multipart(form); - let request_builder = self.run_before(request_builder).await?; - let response = request_builder.send().await?; - self.run_after(response).await - } - - pub async fn get( - &self, - url: reqwest::Url, - ) -> core::result::Result { - let request_builder = self.client.get(url); - let request_builder = self.run_before(request_builder).await?; - let response = request_builder.send().await?; - self.run_after(response).await - } - - pub async fn put( - &self, - url: reqwest::Url, - json: &T, - ) -> core::result::Result - where - T: serde::Serialize, - { - let request_builder = self.client.put(url).json(json); - let request_builder = self.run_before(request_builder).await?; - let response = request_builder.send().await?; - self.run_after(response).await - } - - pub async fn delete( - &self, - url: reqwest::Url, - json: &T, - ) -> core::result::Result - where - T: serde::Serialize, - { - let request_builder = self.client.delete(url).json(json); - let request_builder = self.run_before(request_builder).await?; - let response = request_builder.send().await?; - self.run_after(response).await - } - - pub fn before(&mut self, call: BeforeCallFunction) { - self.before.push(call); - } - - pub fn after(&mut self, call: AfterCallFunction) { - self.after.push(call); - } -} diff --git a/src-tauri/src/infrastructure/http/client/mod.rs b/src-tauri/src/infrastructure/http/client/mod.rs index d997053..ca774ee 100644 --- a/src-tauri/src/infrastructure/http/client/mod.rs +++ b/src-tauri/src/infrastructure/http/client/mod.rs @@ -1,7 +1,2 @@ -// ============ 基础模块 ============ -mod client; mod types; - -// 重导出基础类型和客户端 -pub use client::{AfterCallFunction, BeforeCallFunction, Client}; pub use types::JsonRespnse; diff --git a/src-tauri/src/infrastructure/http/encryption/aes.rs b/src-tauri/src/infrastructure/http/encryption/aes.rs deleted file mode 100644 index 21adfa4..0000000 --- a/src-tauri/src/infrastructure/http/encryption/aes.rs +++ /dev/null @@ -1,203 +0,0 @@ -use aes_gcm::{ - Aes256Gcm, Key, Nonce, - aead::{Aead, AeadCore, KeyInit, OsRng}, -}; -use anyhow::Context; -use base64::{Engine as _, engine::general_purpose::STANDARD as BASE64_STANDARD}; -use std::fmt::Debug; - -const AES_KEY_SIZE: usize = 32; -const NONCE_SIZE: usize = 12; - -/// key本身是字节数组, 先将字节数组转换成base64编码得字符串,然后再通过非对称公钥加密再转换成base64编码的字符串 -#[derive(Debug, Clone, Default)] -pub struct AesSecret { - key: Key, // 直接存储 GenericArray 以避免重复转换 -} - -impl AesSecret { - /// 生成一个新的 AES 密钥实例 - pub fn new() -> Self { - let key_bytes = Aes256Gcm::generate_key(&mut OsRng); - AesSecret { key: key_bytes } - } - - /// 从已有的密钥字节创建 AesSecret 实例 - /// 输入的 key_bytes 必须是正确的长度 (例如 32 字节 for AES-256) - pub fn from_bytes(key_bytes: &[u8]) -> Result { - if key_bytes.len() != AES_KEY_SIZE { - return Err(anyhow::anyhow!( - "Invalid AES key size: expected {}, got {}", - AES_KEY_SIZE, - key_bytes.len() - )); - } - Ok(AesSecret { - key: Key::::clone_from_slice(key_bytes), - }) - } - - /// 获取 AES 密钥的 Base64 编码字符串 - pub fn get_key_as_base64(&self) -> String { - BASE64_STANDARD.encode(self.key.as_slice()) - } - - /// 使用 AES 密钥加密数据 - /// 返回: 加密后的数据 Vec与Nonce Vec拼接的 Vec - pub fn encrypt(&self, data: &[u8]) -> Result { - let cipher = Aes256Gcm::new(&self.key); - // 为每次加密生成新的、唯一的 Nonce - let nonce_bytes = Aes256Gcm::generate_nonce(&mut OsRng); // 生成随机 Nonce - let nonce_instance = Nonce::from_slice(nonce_bytes.as_slice()); - - let ciphertext = cipher.encrypt(nonce_instance, data).map_err(|e| { - log::error!("Failed to encrypt data: {:?}", e); - anyhow::anyhow!("Failed to encrypt data") - })?; - - let mut result = Vec::with_capacity(ciphertext.len() + NONCE_SIZE); - result.extend_from_slice(nonce_bytes.as_slice()); - result.extend_from_slice(&ciphertext); - - // 将结果转换为 Base64 编码字符串 - let result = BASE64_STANDARD.encode(&result); - - Ok(result) - } - - /// 使用 AES 密钥和提供的 Nonce 解密数据 - pub fn decrypt(&self, base64_data: &str) -> Result, anyhow::Error> { - // 先对Base64编码的字符串进行解码 - let data = BASE64_STANDARD.decode(base64_data).context("Failed to decode base64 data")?; - - if data.len() < NONCE_SIZE { - return Err(anyhow::anyhow!( - "Data too short: expected at least {} bytes", - NONCE_SIZE - )); - } - - let (nonce, ciphertext) = data.split_at(NONCE_SIZE); - let cipher = Aes256Gcm::new(&self.key); - let nonce_instance = Nonce::from_slice(nonce); - - cipher.decrypt(nonce_instance, ciphertext).map_err(|e| { - log::error!("Failed to decrypt data: {:?}", e); - anyhow::anyhow!("Failed to decrypt data") - }) - } -} - -/// 从 Base64 编码的密钥字符串创建 AesSecret -impl TryFrom<&str> for AesSecret { - type Error = anyhow::Error; - - fn try_from(key_base64: &str) -> Result { - let decoded_key_bytes = BASE64_STANDARD - .decode(key_base64) - .context("Failed to decode base64 key string")?; - - Self::from_bytes(&decoded_key_bytes) - } -} - -#[cfg(test)] -mod tests { - - use super::*; - - #[test] - fn test_encrypt_decrypt_workflow() { - let secret = AesSecret::new(); - let plaintext = b"Hello, secure world of AES-GCM!"; // 字节数组 - - // 加密 - let encrypt_str = secret.encrypt(plaintext).expect("Encryption failed"); - println!("encrypt_str: {}", encrypt_str); - - // 解密 - let decrypted_bytes = secret.decrypt(&encrypt_str).unwrap(); - - // 将解密后的字节数组转换回字符串 - let original_str = String::from_utf8(decrypted_bytes).unwrap(); - println!("解密后的数据: {}", original_str); - } - - #[test] - fn test_encrypt_decrypt_json_workflow() { - let secret = AesSecret::new(); - - // 创建一个要序列化的结构体 - #[derive(serde::Serialize, serde::Deserialize, Debug, PartialEq)] - struct TestData { - message: String, - value: i32, - } - - let test_data = TestData { - message: "Hello, secure world of AES-GCM!".to_string(), - value: 42, - }; - - // 序列化为JSON - let json_bytes = serde_json::to_vec(&test_data).expect("Serialization failed"); - - // 加密JSON数据 - let encrypted = secret.encrypt(&json_bytes).expect("Encryption failed"); - println!("Encrypted: {}", encrypted); - - // 解密 - let decrypted = secret.decrypt(&encrypted).expect("Decryption failed"); - - // 从JSON反序列化 - let restored_data: TestData = - serde_json::from_slice(&decrypted).expect("Deserialization failed"); - println!("Restored: {:?}", restored_data); - - // 此值一致 - assert_eq!(test_data, restored_data); - } - - /// 测试加密流程 - #[test] - fn test_login_payload_encrypt() { - use crate::infrastructure::http::encryption::RsaSecret; - - let secret = AesSecret::new(); - let key = secret.get_key_as_base64(); - - // 测试通过base64编码的字符串创建AesSecret实例 - // let _ = AesSecret::try_from(key.as_str()).expect("创建AES实例失败"); - - #[derive(serde::Serialize, serde::Deserialize, Debug)] - struct LoginRequest { - email: String, - password: String, - public_secret_key: String, - } - let payload = LoginRequest { - email: "liusnew@gmail.com".to_string(), - password: "liusNew57~".to_string(), - public_secret_key: "-----BEGIN RSA PUBLIC KEY-----\nMIIBCgKCAQEAseI9vA7iTxOMb5Y2xCL7BOGr1by9qEH4EfP9Bj90gxDmY8yRsVK/\no2g+i95oQxzdvdvpPAocKlQv2FEbZaqFr2Q4vy1cLrM0B1NTZl1/hGcmPSofLT9g\nmnjzP60ikY40Dxq+YXAxXZ4s2M+9thNnFr4OydacHEPEkTklcBQBglopSXc1yqHU\nARyCQ3/VxQrfh215vIPgMg2f6PH741zXFaIJjucXR8wJVySo7aZhlBTOVz5GzV0b\naWh31zA47ivXh84OXIEI+CKDUSnvsa8SCRMRs8LgaO1Xktv4yCfHHpo8Zoy+KdW0\nLJxP11G+3f9RsYRpjdmsleyHuYYS07suqwIDAQAB\n-----END RSA PUBLIC KEY-----\n".to_string(), - }; - - let json_bytes = serde_json::to_vec(&payload).expect("Serialization failed"); - let encrypted = secret.encrypt(&json_bytes).expect("Encryption failed"); - - // 使用测试内生成的密钥对,避免依赖仓库外部的公钥文件。 - let rsa_secret = RsaSecret::new().expect("生成 RSA 密钥对失败"); - let public_key = rsa_secret.get_public_key().expect("编码 RSA 公钥失败"); - let encrypted_key = RsaSecret::encrypt_with_public_key(key.as_bytes(), &public_key) - .expect("加密 AES 密钥失败"); - let decrypted_key = rsa_secret.decrypt(&encrypted_key).expect("解密 AES 密钥失败"); - assert_eq!(decrypted_key, key.as_bytes()); - - let result = serde_json::json!({ - "data": encrypted, - "encrypted": true, - "key": encrypted_key, - }); - let result = serde_json::to_string_pretty(&result).unwrap(); - log::trace!("{}", result); - } -} diff --git a/src-tauri/src/infrastructure/http/encryption/mod.rs b/src-tauri/src/infrastructure/http/encryption/mod.rs deleted file mode 100644 index b708913..0000000 --- a/src-tauri/src/infrastructure/http/encryption/mod.rs +++ /dev/null @@ -1,9 +0,0 @@ -/// 加密安全模块 -/// -/// 提供 AES 和 RSA 加密功能 -pub mod aes; -pub mod rsa; - -// 重导出常用类型和函数 -pub use aes::AesSecret; -pub use rsa::RsaSecret; diff --git a/src-tauri/src/infrastructure/http/encryption/rsa.rs b/src-tauri/src/infrastructure/http/encryption/rsa.rs deleted file mode 100644 index 8fe635d..0000000 --- a/src-tauri/src/infrastructure/http/encryption/rsa.rs +++ /dev/null @@ -1,88 +0,0 @@ -use std::fmt::Debug; - -use base64::Engine; -use rsa::{ - Pkcs1v15Encrypt, RsaPrivateKey, RsaPublicKey, - pkcs1::{DecodeRsaPublicKey, EncodeRsaPublicKey}, -}; - -#[derive(Debug, Clone)] -pub struct RsaSecret { - pub private_key: RsaPrivateKey, - pub public_key: RsaPublicKey, -} - -impl RsaSecret { - pub fn new() -> Result { - let mut rng = rsa::rand_core::OsRng::default(); - - let bits = 2048; - let private_key = RsaPrivateKey::new(&mut rng, bits)?; - let public_key = private_key.to_public_key(); - - Ok(RsaSecret { - private_key, - public_key, - }) - } - - /// 获取公钥对 - pub fn get_public_key(&self) -> Result { - self.public_key.to_pkcs1_pem(rsa::pkcs8::LineEnding::LF).map_err(|e| { - log::error!("Failed to encode public key: {:?}", e); - anyhow::anyhow!("Failed to encode public key") - }) - } - - /// 获取私钥对 - pub fn get_private_key(&self) -> RsaPrivateKey { - self.private_key.clone() - } - - /// 解密为默认的&[u8] - pub fn decrypt(&self, data: &str) -> Result, anyhow::Error> { - // base64 解码 - let data = base64::engine::general_purpose::STANDARD.decode(data).map_err(|e| { - log::error!("Failed to decode base64: {:?}", e); - anyhow::anyhow!("Failed to decode base64") - })?; - - let private_key = &self.private_key; - let decrypted_data = private_key.decrypt(Pkcs1v15Encrypt, &data)?; - - Ok(decrypted_data) - } - - /// 使用公钥加密 - pub fn encrypt(&self, data: &[u8]) -> Result { - let public_key = &self.private_key.to_public_key(); - - let mut rng = rsa::rand_core::OsRng::default(); - let encrypted_data = public_key.encrypt(&mut rng, Pkcs1v15Encrypt, data).map_err(|e| { - log::error!("Failed to encrypt data: {:?}", e); - anyhow::anyhow!("Failed to encrypt data") - })?; - - let encoded_data = base64::engine::general_purpose::STANDARD.encode(&encrypted_data); - - Ok(encoded_data) - } - - /// 使用公钥加密, 根据接收到的公钥 - pub fn encrypt_with_public_key(data: &[u8], public_key: &str) -> Result { - let public_key = rsa::RsaPublicKey::from_pkcs1_pem(public_key).map_err(|e| { - log::error!("Failed to parse public key: {:?}", e); - anyhow::anyhow!("Failed to parse public key") - })?; - - let mut rng = rsa::rand_core::OsRng::default(); - let encrypted_data = public_key.encrypt(&mut rng, Pkcs1v15Encrypt, data).map_err(|e| { - log::error!("Failed to encrypt data: {:?}", e); - anyhow::anyhow!("Failed to encrypt data") - })?; - - let encoded_data = base64::engine::general_purpose::STANDARD.encode(&encrypted_data); - - Ok(encoded_data) - } -} diff --git a/src-tauri/src/infrastructure/http/mod.rs b/src-tauri/src/infrastructure/http/mod.rs index ec17c2f..8927392 100644 --- a/src-tauri/src/infrastructure/http/mod.rs +++ b/src-tauri/src/infrastructure/http/mod.rs @@ -1,8 +1,3 @@ -/// HTTP 客户端基础模块 +/// HTTP response types shared by the Tauri command and local API layers. pub mod client; - -/// HTTP 加密模块 -pub mod encryption; - -// 重导出常用类型 -pub use client::{AfterCallFunction, BeforeCallFunction, Client, JsonRespnse}; +pub use client::JsonRespnse; diff --git a/src-tauri/src/infrastructure/main_server/client.rs b/src-tauri/src/infrastructure/main_server/client.rs deleted file mode 100644 index 66e5d68..0000000 --- a/src-tauri/src/infrastructure/main_server/client.rs +++ /dev/null @@ -1,214 +0,0 @@ -use sha2::Sha256; - -use crate::infrastructure::http::client::JsonRespnse; -use crate::infrastructure::http::client::{AfterCallFunction, BeforeCallFunction, Client}; - -use crate::infrastructure::main_server::error::{ - is_public_key_expired_error, is_unauthorized_error, -}; -use crate::infrastructure::main_server::interceptors; -use crate::infrastructure::main_server::response::{build_url, parse_and_decrypt_response}; - -const TIMEOUT_DURATION_SECS: u64 = 35; - -/// 缓存配置 -const CACHE_TTL_SECS: u64 = 300; // 5 分钟 - -/// 自动重试宏:处理 401 和 422 错误 -macro_rules! with_auto_retry { - // 统一处理:支持 0 个或多个额外参数 - ($self:expr, $method_without_retry:ident, $url:expr $(, $args:expr)*) => {{ - match $self.$method_without_retry($url $(, $args)*).await { - Ok(resp) => Ok(resp), - - // 处理 401:刷新凭证 - Err(e) if is_unauthorized_error(&e) => { - if let Ok(_) = crate::infrastructure::persistence::credential::refresh_credentials().await { - $self.$method_without_retry($url $(, $args)*).await - } else { - Err(e) - } - } - - // 处理 422 + LASDE:刷新公钥 - Err(e) if is_public_key_expired_error(&e) => { - if let Ok(_) = crate::infrastructure::persistence::credential::fetch_server_public_key().await { - // 公钥已刷新,用新公钥重新加密原始数据并重试 - $self.$method_without_retry($url $(, $args)*).await - } else { - Err(e) - } - } - - Err(e) => Err(e), - } - }}; -} - -/// 主服务器的请求客户端 -pub struct MainServerRequestClient { - client: Client, -} - -impl MainServerRequestClient { - pub fn new() -> MainServerRequestClient { - MainServerRequestClient { - client: Client::new(TIMEOUT_DURATION_SECS), - } - } - - /// 发起 GET 请求(带自动凭证刷新) - pub async fn get(&self, url: &str) -> core::result::Result { - with_auto_retry!(self, get_no_retry, url) - } - - // 内部方法:不带刷新的 GET(仅供宏使用) - async fn get_no_retry(&self, url: &str) -> core::result::Result { - // 发起实际请求 - let response = self.client.get(build_url(url)?).await?; - parse_and_decrypt_response(response).await - } - - /// 发起 POST 请求(带自动凭证刷新) - pub async fn post( - &self, - url: &str, - json: &T, - ) -> core::result::Result - where - T: serde::Serialize + std::fmt::Debug, - { - with_auto_retry!(self, post_no_retry, url, json) - } - - pub async fn post_no_retry( - &self, - url: &str, - json: &T, - ) -> core::result::Result - where - T: serde::Serialize + std::fmt::Debug, - { - // 发起实际请求 - let response = self.client.post(build_url(url)?, json).await?; - parse_and_decrypt_response(response).await - } - - pub async fn post_with_headers( - &self, - url: &str, - json: &T, - headers: reqwest::header::HeaderMap, - ) -> core::result::Result - where - T: serde::Serialize + std::fmt::Debug, - { - match self.post_with_headers_no_retry(url, json, headers.clone()).await { - Ok(resp) => Ok(resp), - Err(e) if is_unauthorized_error(&e) => { - if let Ok(_) = - crate::infrastructure::persistence::credential::refresh_credentials().await - { - self.post_with_headers_no_retry(url, json, headers).await - } else { - Err(e) - } - } - Err(e) if is_public_key_expired_error(&e) => { - if let Ok(_) = - crate::infrastructure::persistence::credential::fetch_server_public_key().await - { - self.post_with_headers_no_retry(url, json, headers).await - } else { - Err(e) - } - } - Err(e) => Err(e), - } - } - - async fn post_with_headers_no_retry( - &self, - url: &str, - json: &T, - headers: reqwest::header::HeaderMap, - ) -> core::result::Result - where - T: serde::Serialize + std::fmt::Debug, - { - let response = self.client.post_with_headers(build_url(url)?, json, headers).await?; - parse_and_decrypt_response(response).await - } - - /// 发起 POST 表单请求(用于文件上传) - /// - /// 注意:由于 Form 无法克隆,此方法不支持自动重试 - pub async fn post_form( - &self, - url: &str, - form: reqwest::multipart::Form, - ) -> core::result::Result { - let response = self.client.post_form(build_url(url)?, form).await?; - parse_and_decrypt_response(response).await - } - - /// 发起 PUT 请求(带自动凭证刷新) - pub async fn put( - &self, - url: &str, - json: &T, - ) -> core::result::Result - where - T: serde::Serialize, - { - with_auto_retry!(self, put_no_retry, url, json) - } - - // 内部方法:不带刷新的 PUT(仅供宏使用) - async fn put_no_retry( - &self, - url: &str, - json: &T, - ) -> core::result::Result - where - T: serde::Serialize, - { - let response = self.client.put(build_url(url)?, json).await?; - parse_and_decrypt_response(response).await - } - - /// 发起 DELETE 请求(带自动凭证刷新) - pub async fn delete( - &self, - url: &str, - json: &T, - ) -> core::result::Result - where - T: serde::Serialize, - { - with_auto_retry!(self, delete_no_retry, url, json) - } - - // 内部方法:不带刷新的 DELETE(仅供宏使用) - async fn delete_no_retry( - &self, - url: &str, - json: &T, - ) -> core::result::Result - where - T: serde::Serialize, - { - let response = self.client.delete(build_url(url)?, json).await?; - parse_and_decrypt_response(response).await - } - - // 请求拦截器 - pub fn before(&mut self, call: BeforeCallFunction) { - self.client.before(call); - } - - // 响应拦截器 - pub fn after(&mut self, call: AfterCallFunction) { - self.client.after(call); - } -} diff --git a/src-tauri/src/infrastructure/main_server/error.rs b/src-tauri/src/infrastructure/main_server/error.rs deleted file mode 100644 index 244740d..0000000 --- a/src-tauri/src/infrastructure/main_server/error.rs +++ /dev/null @@ -1,66 +0,0 @@ -/// 错误处理模块 -use thiserror::Error; - -/// HTTP 响应错误类型 -#[derive(Debug, Error)] -pub enum ResponseError { - /// 未授权错误 (401) - #[error("未授权访问 (401): {message}")] - Unauthorized { message: String }, - - /// 请求参数错误 (400) - #[error("请求参数错误 (400): {message}")] - BadRequest { message: String }, - - /// 公钥过期错误 (422 + LASDE) - #[error("公钥已过期 (422): 需要重新获取公钥")] - PublicKeyExpired, - - /// 无法处理的实体错误 (422) - #[error("无法处理的请求 (422): {message}")] - UnprocessableEntity { message: String }, - - /// 响应体为空 - #[error("服务器响应为空 ({status}): 服务器未返回任何数据")] - EmptyResponse { status: u16 }, - - /// JSON 解析失败 - #[error("JSON 解析失败 ({status}): {parse_error}. {detail}")] - JsonParseError { - status: u16, - parse_error: String, - detail: String, - }, - - /// 读取响应失败 - #[error("读取响应失败: {0}")] - ReadResponseFailed(String), - - /// 解析加密数据失败 - #[error("解析加密数据失败: {0}")] - ParseEncryptedDataFailed(String), - - /// 解密失败 - #[error("解密失败: {0}")] - DecryptFailed(String), - - /// 解析响应失败 - #[error("解析响应失败: {0}")] - ParseResponseFailed(String), -} - -/// 检查是否是 401 未授权错误 -pub fn is_unauthorized_error(err: &anyhow::Error) -> bool { - err.downcast_ref::() - .map(|e| matches!(e, ResponseError::Unauthorized { .. })) - .unwrap_or(false) - || err.to_string().contains("StatusCode(401)") -} - -/// 检查是否是公钥过期错误(422 + LASDE) -pub fn is_public_key_expired_error(err: &anyhow::Error) -> bool { - err.downcast_ref::() - .map(|e| matches!(e, ResponseError::PublicKeyExpired)) - .unwrap_or(false) - || err.to_string().contains("StatusCode(422):LASDE") -} diff --git a/src-tauri/src/infrastructure/main_server/interceptors/crypto.rs b/src-tauri/src/infrastructure/main_server/interceptors/crypto.rs deleted file mode 100644 index e2597f6..0000000 --- a/src-tauri/src/infrastructure/main_server/interceptors/crypto.rs +++ /dev/null @@ -1,49 +0,0 @@ -/// 加密解密辅助模块 -use crate::app::context::AppContext; -use crate::infrastructure::http::encryption::AesSecret; -use serde::{Deserialize, Serialize}; -use serde_json::Value; - -/// 加密的数据结构 -#[derive(Debug, Deserialize, Serialize)] -pub struct EncryptedData { - pub encrypted: bool, - #[serde(default)] - pub data: String, - #[serde(default)] - pub key: String, -} - -/// 解密加密的数据 -pub fn decrypt_if_encrypted(encrypted_data: &EncryptedData) -> Result { - // 如果没有加密,直接解析 data 字段 - if !encrypted_data.encrypted { - return serde_json::from_str(&encrypted_data.data) - .map_err(|_| "Failed to parse data".to_string()); - } - - let ctx = AppContext::get(); - - // 解密 AES 密钥 - let aes_key_bytes = ctx - .rsa_keypair - .decrypt(&encrypted_data.key) - .map_err(|_| "Failed to decrypt AES key")?; - - let aes_key_str = - std::str::from_utf8(&aes_key_bytes).map_err(|_| "Failed to convert AES key to string")?; - - // 创建 AES 实例 - let aes_secret = - AesSecret::try_from(aes_key_str).map_err(|_| "Failed to create AES instance")?; - - // 解密数据 - let decrypted_bytes = - aes_secret.decrypt(&encrypted_data.data).map_err(|_| "Failed to decrypt data")?; - - // 解析为 JSON - let decrypted_value: Value = - serde_json::from_slice(&decrypted_bytes).map_err(|_| "Failed to parse decrypted data")?; - - Ok(decrypted_value) -} diff --git a/src-tauri/src/infrastructure/main_server/interceptors/mod.rs b/src-tauri/src/infrastructure/main_server/interceptors/mod.rs deleted file mode 100644 index c066423..0000000 --- a/src-tauri/src/infrastructure/main_server/interceptors/mod.rs +++ /dev/null @@ -1,13 +0,0 @@ -/// 拦截器模块 -/// -/// 负责请求和响应的拦截处理,包括加密、认证等 -pub mod crypto; -pub mod request; - -/// 响应拦截器(保持接口,实际解密在 response 模块处理) -pub async fn response_interceptor( - response: reqwest::Response, -) -> core::result::Result { - // 直接返回,实际解密由 response 模块处理 - Ok(response) -} diff --git a/src-tauri/src/infrastructure/main_server/interceptors/request.rs b/src-tauri/src/infrastructure/main_server/interceptors/request.rs deleted file mode 100644 index c3ca5fa..0000000 --- a/src-tauri/src/infrastructure/main_server/interceptors/request.rs +++ /dev/null @@ -1,123 +0,0 @@ -/// 请求拦截器模块 -use reqwest::header::AUTHORIZATION; -use serde_json::Value; - -use crate::app::context::AppContext; -use crate::infrastructure::http::encryption::{AesSecret, RsaSecret}; - -use crate::infrastructure::persistence::credential::{CREDENTIAL, SERVER_PUBLIC_KEY}; - -/// 加密拦截器 -pub async fn encrypt( - rb: reqwest::RequestBuilder, -) -> core::result::Result { - // 获取请求的 URL,检查是否是获取公钥的请求 - let url = rb - .try_clone() - .and_then(|r| r.build().ok()) - .and_then(|req| Some(req.url().to_string())) - .unwrap_or_default(); - - // 如果是获取公钥的请求,直接返回,不加密 - if url.contains("/secret/public/key") { - return Ok(rb); - } - - // 尝试获取并提取请求体 - let cloned_rb = match rb.try_clone() { - Some(r) => r, - None => return Ok(rb), // 无法克隆,直接返回原请求 - }; - - // 检查是否有 JSON 请求体 - let built_request = match cloned_rb.build() { - Ok(req) => req, - Err(_) => return Ok(rb), - }; - - let body = match built_request.body() { - Some(b) => b.as_bytes().unwrap_or(&[]), - None => return Ok(rb), - }; - - if body.is_empty() { - return Ok(rb); - } - - // 解析请求体为 JSON - let mut json_body: Value = match serde_json::from_slice(body) { - Ok(v) => v, - Err(_) => return Ok(rb), // 不是 JSON,不处理 - }; - - // 添加 api_secret - if let Some(obj) = json_body.as_object_mut() { - let ctx = AppContext::get(); - obj.insert( - "api_secret".to_string(), - Value::String(ctx.config.server.secret_key.clone()), - ); - } - - // 获取服务器公钥(必须已初始化) - let public_key = match SERVER_PUBLIC_KEY.read().unwrap().clone() { - Some(key) => key, - None => { - return Ok(rb); // 公钥未初始化,不加密 - } - }; - - // 加密数据 - let encrypted_body = match encrypt_request_body(&json_body, &public_key) { - Ok(body) => body, - Err(_) => { - return Ok(rb); // 加密失败,返回原请求 - } - }; - - // 重新构建带加密数据的请求 - Ok(rb.json(&encrypted_body)) -} - -/// 认证拦截器 -pub async fn auth( - rb: reqwest::RequestBuilder, -) -> core::result::Result { - let mut headers = reqwest::header::HeaderMap::new(); - - if let Ok(cred) = CREDENTIAL.read() { - if let Some(token) = cred.get_access_token() { - if let Ok(token) = reqwest::header::HeaderValue::from_str(&format!("Bearer {}", token)) - { - headers.insert(AUTHORIZATION, token); - } - } - } - - Ok(rb.headers(headers)) -} - -/// 加密请求体 -fn encrypt_request_body(data: &Value, public_key: &str) -> Result { - // 创建 AES 密钥 - let aes_secret = AesSecret::new(); - - // 序列化数据 - let json_string = serde_json::to_string(data).map_err(|_| "Failed to serialize data")?; - - // AES 加密数据 - let encrypted_data = aes_secret - .encrypt(json_string.as_bytes()) - .map_err(|_| "AES encryption failed")?; - - // RSA 加密 AES 密钥 - let encrypted_key = - RsaSecret::encrypt_with_public_key(aes_secret.get_key_as_base64().as_bytes(), public_key) - .map_err(|_| "RSA encryption failed")?; - - Ok(serde_json::json!({ - "data": encrypted_data, - "encrypted": true, - "key": encrypted_key - })) -} diff --git a/src-tauri/src/infrastructure/main_server/mod.rs b/src-tauri/src/infrastructure/main_server/mod.rs deleted file mode 100644 index 177a62e..0000000 --- a/src-tauri/src/infrastructure/main_server/mod.rs +++ /dev/null @@ -1,5 +0,0 @@ -pub mod client; -pub mod error; -pub mod interceptors; -pub mod response; -pub mod types; diff --git a/src-tauri/src/infrastructure/main_server/response.rs b/src-tauri/src/infrastructure/main_server/response.rs deleted file mode 100644 index 83974e8..0000000 --- a/src-tauri/src/infrastructure/main_server/response.rs +++ /dev/null @@ -1,142 +0,0 @@ -/// 响应解析和解密模块 -use crate::infrastructure::http::client::Client; -use crate::infrastructure::http::client::JsonRespnse; - -use crate::infrastructure::main_server::error::ResponseError; -use crate::infrastructure::main_server::interceptors::crypto::{ - EncryptedData, decrypt_if_encrypted, -}; - -/// 构建完整的 API URL -pub fn build_url(resource: &str) -> Result { - Client::build_url(resource) -} - -/// 解析并解密响应(业务层逻辑) -pub async fn parse_and_decrypt_response( - response: reqwest::Response, -) -> core::result::Result { - let status = response.status(); - let status_code = status.as_u16(); - - // 检查 HTTP 401 - if status == reqwest::StatusCode::UNAUTHORIZED { - let body_text = response.text().await.unwrap_or_default(); - let message = - extract_error_message(&body_text).unwrap_or_else(|| "请检查登录状态".to_string()); - return Err(ResponseError::Unauthorized { message }.into()); - } - - // 检查 HTTP 400 - if status == reqwest::StatusCode::BAD_REQUEST { - let body_text = response.text().await.unwrap_or_default(); - let message = - extract_error_message(&body_text).unwrap_or_else(|| "请求参数错误".to_string()); - return Err(ResponseError::BadRequest { message }.into()); - } - - // 检查 HTTP 422(可能是公钥过期) - if status == reqwest::StatusCode::UNPROCESSABLE_ENTITY { - // 先读取响应体 - let body_text = response - .text() - .await - .map_err(|e| ResponseError::ReadResponseFailed(e.to_string()))?; - - // 尝试解析并检查是否是公钥过期错误 - if let Ok(body_json) = serde_json::from_str::(&body_text) { - if let Some(message) = body_json.get("message").and_then(|m| m.as_str()) { - if message.contains("LASDE") { - // 公钥过期错误 - return Err(ResponseError::PublicKeyExpired.into()); - } - } - } - - // 其他 422 错误,尝试正常解析 - let encrypted_data: EncryptedData = - serde_json::from_str(&body_text).map_err(|e| ResponseError::UnprocessableEntity { - message: format!("解析响应失败: {}", e), - })?; - - return decrypt_response_data(encrypted_data); - } - - // 正常响应,先解析为 JSON Value 判断是否加密 - let body_text = response - .text() - .await - .map_err(|e| ResponseError::ReadResponseFailed(e.to_string()))?; - - // 检查响应体是否为空 - if body_text.is_empty() { - return Err(ResponseError::EmptyResponse { - status: status_code, - } - .into()); - } - - // 先解析为通用 JSON - let body_value: serde_json::Value = serde_json::from_str(&body_text).map_err(|e| { - let detail = extract_error_message(&body_text).unwrap_or_else(|| { - format!( - "响应数据: {}", - body_text.chars().take(200).collect::() - ) - }); - ResponseError::JsonParseError { - status: status_code, - parse_error: e.to_string(), - detail, - } - })?; - - // 检查是否有 encrypted 字段且为 true - let is_encrypted = body_value.get("encrypted").and_then(|v| v.as_bool()).unwrap_or(false); - if is_encrypted { - // 是加密响应,解析为 EncryptedData 并解密 - let encrypted_data: EncryptedData = serde_json::from_value(body_value) - .map_err(|e| ResponseError::ParseEncryptedDataFailed(e.to_string()))?; - decrypt_response_data(encrypted_data) - } else { - // 未加密,直接解析为 JsonResponse - serde_json::from_value(body_value) - .map_err(|e| ResponseError::ParseResponseFailed(e.to_string()).into()) - } -} - -/// 解密响应数据 -fn decrypt_response_data( - encrypted_data: EncryptedData, -) -> core::result::Result { - // 检查是否加密 - if encrypted_data.encrypted { - // 解密数据 - let decrypted_value = decrypt_if_encrypted(&encrypted_data) - .map_err(|e| ResponseError::DecryptFailed(e.to_string()))?; - - // 解析为 JsonRespnse - serde_json::from_value(decrypted_value) - .map_err(|e| ResponseError::ParseResponseFailed(e.to_string()).into()) - } else { - // 未加密,直接解析 data 字段 - serde_json::from_str(&encrypted_data.data).map_err(|e| { - ResponseError::ParseResponseFailed(format!("{}, 数据: {}", e, encrypted_data.data)) - .into() - }) - } -} - -/// 从响应体中提取错误信息 -fn extract_error_message(body_text: &str) -> Option { - if body_text.is_empty() { - return None; - } - - serde_json::from_str::(body_text).ok().and_then(|json| { - json.get("message") - .and_then(|m| m.as_str()) - .map(|s| s.to_string()) - .or_else(|| json.get("error").and_then(|e| e.as_str()).map(|s| s.to_string())) - }) -} diff --git a/src-tauri/src/infrastructure/main_server/types.rs b/src-tauri/src/infrastructure/main_server/types.rs deleted file mode 100644 index 6109aeb..0000000 --- a/src-tauri/src/infrastructure/main_server/types.rs +++ /dev/null @@ -1,27 +0,0 @@ -use serde::{Deserialize, Serialize}; -use serde_json::Value; - -/// 标准 JSON 响应结构 -#[derive(Deserialize, Debug, Serialize)] -pub struct JsonRespnse { - pub code: Option, - pub message: Option, - pub data: Option, -} - -/// 可能加密的响应数据 -#[derive(Deserialize, Debug, Serialize)] -#[serde(untagged)] -#[allow(dead_code)] -pub enum MaybeEncryptedResponse { - /// 加密的响应 - Encrypted { - encrypted: bool, - #[serde(default)] - data: String, - #[serde(default)] - key: String, - }, - /// 普通的 JSON 响应 - Plain(JsonRespnse), -} diff --git a/src-tauri/src/infrastructure/mihomo/models.rs b/src-tauri/src/infrastructure/mihomo/models.rs index 207060b..a0fee35 100644 --- a/src-tauri/src/infrastructure/mihomo/models.rs +++ b/src-tauri/src/infrastructure/mihomo/models.rs @@ -235,8 +235,5 @@ fn detect_clash_verge_rev_config_dir() -> Option { } fn resolve_mihomo_config_path(config_dir: PathBuf) -> String { - config_dir - .join(MIHOMO_CONFIG_FILE_PRIMARY) - .to_string_lossy() - .to_string() + config_dir.join(MIHOMO_CONFIG_FILE_PRIMARY).to_string_lossy().to_string() } diff --git a/src-tauri/src/infrastructure/mod.rs b/src-tauri/src/infrastructure/mod.rs index 0e2457e..06d54e9 100644 --- a/src-tauri/src/infrastructure/mod.rs +++ b/src-tauri/src/infrastructure/mod.rs @@ -1,6 +1,4 @@ -pub mod deeplink; pub mod http; -pub mod main_server; pub mod mihomo; pub mod persistence; pub mod proxy; diff --git a/src-tauri/src/infrastructure/persistence/credential/mod.rs b/src-tauri/src/infrastructure/persistence/credential/mod.rs deleted file mode 100644 index 35adb8a..0000000 --- a/src-tauri/src/infrastructure/persistence/credential/mod.rs +++ /dev/null @@ -1,201 +0,0 @@ -pub mod remembered; - -use log::error; -use once_cell::sync::Lazy; -use std::sync::{Arc, RwLock}; -use tokio::sync::Mutex; - -/// 凭证信息 -#[derive(Debug, Clone, Default)] -pub struct Credential { - access_token: Option, - refresh_token: Option, -} - -impl Credential { - pub fn get_access_token(&self) -> Option { - self.access_token.clone() - } - - pub fn get_refresh_token(&self) -> Option { - self.refresh_token.clone() - } - - // 重置token - pub fn reset_token(&mut self) { - self.access_token = None; - self.refresh_token = None; - } - - // 是否处于登录状态 - pub fn is_login(&self) -> bool { - self.access_token.is_some() - } -} - -/// 全局凭证存储 -pub(crate) static CREDENTIAL: Lazy> = - Lazy::new(|| RwLock::new(Credential::default())); - -/// 服务器公钥存储 -pub(crate) static SERVER_PUBLIC_KEY: Lazy>> = Lazy::new(|| RwLock::new(None)); - -/// 刷新锁:确保同一时间只有一个请求在刷新凭证 -static REFRESH_LOCK: Lazy>> = Lazy::new(|| Arc::new(Mutex::new(()))); - -// ============ 凭证管理函数 ============ - -/// 获取凭证 -pub fn get_credential() -> Credential { - CREDENTIAL.read().unwrap().clone() -} - -/// 设置访问令牌 -pub fn set_access_token(token: String) { - CREDENTIAL.write().unwrap().access_token = Some(token); -} - -/// 设置刷新令牌 -pub fn set_refresh_token(token: String) { - CREDENTIAL.write().unwrap().refresh_token = Some(token); -} - -/// 设置完整凭证 -pub fn set_credential(access_token: String, refresh_token: String) { - let mut cred = CREDENTIAL.write().unwrap(); - cred.access_token = Some(access_token); - cred.refresh_token = Some(refresh_token); -} - -/// 清除凭证(登出) -pub fn clear_credential() { - CREDENTIAL.write().unwrap().reset_token(); -} - -/// 是否已登录 -pub fn is_login() -> bool { - CREDENTIAL.read().unwrap().is_login() -} - -// ============ 服务器公钥管理函数 ============ - -/// 获取/刷新服务器公钥(应用启动时或公钥过期时调用) -/// 支持自动重试,最多重试 3 次 -pub async fn fetch_server_public_key() -> Result<(), String> { - use crate::infrastructure::http::client::Client; - use tokio::time::{Duration, sleep}; - - let url = Client::build_url("secret/public/key").map_err(|_| { - error!("服务器请求路径错误"); - "初始化失败".to_string() - })?; - - let client = reqwest::Client::builder() - .timeout(Duration::from_secs(10)) - .build() - .map_err(|_| "初始化失败".to_string())?; - - let mut last_err = None; - for attempt in 0..3 { - if attempt > 0 { - sleep(Duration::from_millis(1000 * attempt as u64)).await; - } - - match client.get(url.as_str()).send().await { - Ok(response) => { - if !response.status().is_success() { - last_err = Some("初始化失败".to_string()); - continue; - } - - match response.text().await { - Ok(public_key) => { - if public_key.contains("-----BEGIN RSA PUBLIC KEY-----") { - *SERVER_PUBLIC_KEY.write().unwrap() = Some(public_key); - return Ok(()); - } else { - error!("服务器响应错误"); - return Err("初始化失败".to_string()); - } - } - Err(_) => { - last_err = Some("初始化失败".to_string()); - continue; - } - } - } - Err(_) => { - last_err = Some("初始化失败".to_string()); - continue; - } - } - } - - error!("获取服务器公钥失败,已达到最大重试次数"); - Err(last_err.unwrap_or_else(|| "初始化失败".to_string())) -} - -/// 初始化服务器公钥(应用启动时调用,是 fetch_server_public_key 的别名) -#[inline] -pub async fn init_server_public_key() -> Result<(), String> { - fetch_server_public_key().await -} - -/// 获取服务器公钥(必须在初始化后调用) -pub fn get_server_public_key() -> String { - SERVER_PUBLIC_KEY.read().unwrap().clone().expect("服务器配置未初始化") -} - -/// 设置服务器公钥 -pub fn set_server_public_key(public_key: String) { - *SERVER_PUBLIC_KEY.write().unwrap() = Some(public_key); -} - -/// 清除服务器公钥 -pub fn clear_server_public_key() { - *SERVER_PUBLIC_KEY.write().unwrap() = None; -} - -// ============ 凭证刷新函数 ============ - -/// 刷新用户凭证(带并发控制) -pub async fn refresh_credentials() -> Result<(), String> { - // 使用 Box::pin 避免递归检测 - Box::pin(async { - // 获取锁,确保同一时间只有一个请求在刷新 - let _lock = REFRESH_LOCK.lock().await; - - // 获取当前的 refresh_token - let refresh_token = get_credential().get_refresh_token().ok_or("无刷新令牌")?; - - // 使用 AppContext 中的 HTTP 客户端发送刷新请求(避免递归) - let ctx = crate::app::context::AppContext::get(); - let response = ctx - .main_server_client - .post_no_retry( - "users/refresh-credentials", - &serde_json::json!({ - "refresh_token": refresh_token - }), - ) - .await - .map_err(|_| "刷新凭证失败")?; - - // 检查响应状态 - if response.code != Some(1) { - return Err("凭证刷新失败".to_string()); - } - - // 提取新凭证 - let data = response.data.ok_or("响应无数据")?; - let access_token = data.get("access_token").and_then(|t| t.as_str()).ok_or("无访问令牌")?; - let new_refresh_token = - data.get("refresh_token").and_then(|t| t.as_str()).ok_or("无刷新令牌")?; - - // 更新全局凭证 - set_credential(access_token.to_string(), new_refresh_token.to_string()); - - Ok(()) - }) - .await -} diff --git a/src-tauri/src/infrastructure/persistence/credential/remembered.rs b/src-tauri/src/infrastructure/persistence/credential/remembered.rs deleted file mode 100644 index 64fe4a4..0000000 --- a/src-tauri/src/infrastructure/persistence/credential/remembered.rs +++ /dev/null @@ -1,66 +0,0 @@ -/// 记住的凭证持久化存储模块 -/// -/// 使用系统凭据存储保存 email 和 refresh_token -use anyhow::{Context, Result}; -use keyring::{Entry, Error as KeyringError}; -use serde::{Deserialize, Serialize}; - -const REMEMBERED_CREDENTIAL_SERVICE: &str = "simprint"; -const REMEMBERED_CREDENTIAL_ACCOUNT: &str = "remembered-session"; - -/// 记住的凭证数据结构 -#[derive(Debug, Clone, Serialize, Deserialize)] -struct RememberedCredential { - email: String, - refresh_token: String, - saved_at: i64, // Unix timestamp -} - -fn remembered_entry() -> Result { - Entry::new(REMEMBERED_CREDENTIAL_SERVICE, REMEMBERED_CREDENTIAL_ACCOUNT) - .context("创建系统凭据条目失败") -} - -/// 保存记住的凭证 -pub fn save_remembered_credential(email: String, refresh_token: String) -> Result<()> { - let credential = RememberedCredential { - email, - refresh_token, - saved_at: chrono::Utc::now().timestamp(), - }; - - let payload = serde_json::to_string(&credential) - .map_err(|_| anyhow::anyhow!("Failed to serialize credential data"))?; - let entry = remembered_entry()?; - entry.set_password(&payload).context("保存系统凭据失败")?; - - log::debug!("凭证存储操作完成"); - Ok(()) -} - -/// 读取记住的凭证 -pub fn get_remembered_credential() -> Result> { - let entry = remembered_entry()?; - let payload = match entry.get_password() { - Ok(payload) => payload, - Err(KeyringError::NoEntry) => return Ok(None), - Err(error) => return Err(anyhow::Error::new(error).context("读取系统凭据失败")), - }; - - let credential: RememberedCredential = serde_json::from_str(&payload) - .map_err(|_| anyhow::anyhow!("Failed to deserialize credential data"))?; - - Ok(Some((credential.email, credential.refresh_token))) -} - -/// 清除记住的凭证 -pub fn clear_remembered_credential() -> Result<()> { - let entry = remembered_entry()?; - match entry.delete_credential() { - Ok(()) | Err(KeyringError::NoEntry) => { - log::debug!("凭证清除操作完成"); - Ok(()) - } - Err(error) => Err(anyhow::Error::new(error).context("删除系统凭据失败")), - } -} diff --git a/src-tauri/src/infrastructure/persistence/mod.rs b/src-tauri/src/infrastructure/persistence/mod.rs index 5d6bfad..21dc2d5 100644 --- a/src-tauri/src/infrastructure/persistence/mod.rs +++ b/src-tauri/src/infrastructure/persistence/mod.rs @@ -1,2 +1 @@ -pub mod credential; pub mod tauri_store; diff --git a/src-tauri/src/infrastructure/runtime/mod.rs b/src-tauri/src/infrastructure/runtime/mod.rs index bb85d12..b057103 100644 --- a/src-tauri/src/infrastructure/runtime/mod.rs +++ b/src-tauri/src/infrastructure/runtime/mod.rs @@ -9,8 +9,8 @@ pub use api::{ BatchLaunchResult, CdpEndpointResponse, CookieGroup, DestroyContextRequest, EmptyPayload, EnvConnectionPayload, EnvironmentCommandRequest, EnvironmentCommandResponse, EnvironmentResponse, EnvironmentStartRequest, ErrorResponse, FingerprintConfig, - HandshakeRequest, HandshakeResponse, InitializeContextRequest, RpaTabCloseResult, - RpaTabInfo, RpaTabSelection, RpaTabsSnapshot, RunningEnvironment, RuntimeContextInput, + HandshakeRequest, HandshakeResponse, InitializeContextRequest, RpaTabCloseResult, RpaTabInfo, + RpaTabSelection, RpaTabsSnapshot, RunningEnvironment, RuntimeContextInput, RuntimeEventEnvelope, RuntimePhase, RuntimeStateSnapshot, StateResponse, SyncCommandRequest, SyncCommandResponse, SyncResponse, UserInfo, WindowBoundsRequest, }; diff --git a/src-tauri/src/local_api/client/business.rs b/src-tauri/src/local_api/client/business.rs new file mode 100644 index 0000000..096a787 --- /dev/null +++ b/src-tauri/src/local_api/client/business.rs @@ -0,0 +1,92 @@ +use axum::http::StatusCode; +use serde::{Serialize, de::DeserializeOwned}; +use serde_json::{Value, json}; +use tauri::Manager; + +use crate::app::handle::get_app_handle; + +pub async fn dispatch_request( + route: &str, + permission_code: &str, + api_key: &str, + payload: Value, +) -> Result { + let app = + get_app_handle().map_err(|error| (StatusCode::INTERNAL_SERVER_ERROR, error.to_string()))?; + let business_context = app.state::(); + let validation = business::services::local_api::validate_local_api_key_service( + &business_context, + &business::entitys::ValidateLocalApiKeyRequest { + api_key: api_key.to_string(), + permission_code: permission_code.to_string(), + }, + ) + .await + .map_err(|message| (StatusCode::UNAUTHORIZED, message))?; + let request_context = business_context.for_user(validation.user_uuid); + + if let Some(result) = + business::dispatcher::dispatch_post(&request_context, route, &payload).await + { + return match result { + Ok(data) => Ok(json!({ "code": 1, "message": "OK", "data": data })), + Err(message) => Err((StatusCode::BAD_REQUEST, message)), + }; + } + + Err(( + StatusCode::NOT_FOUND, + format!("Local business route is not available: {route}"), + )) +} + +pub async fn dispatch_data_request( + route: &str, + permission_code: &str, + api_key: &str, + payload: Value, +) -> Result +where + T: DeserializeOwned, +{ + let value = dispatch_request(route, permission_code, api_key, payload).await?; + let response: crate::infrastructure::http::client::JsonRespnse = serde_json::from_value(value) + .map_err(|error| { + ( + StatusCode::BAD_GATEWAY, + format!("failed to parse local response: {error}"), + ) + })?; + + let data = response.data.ok_or_else(|| { + ( + StatusCode::BAD_GATEWAY, + "missing local response data".to_string(), + ) + })?; + + serde_json::from_value(data).map_err(|error| { + ( + StatusCode::BAD_GATEWAY, + format!("failed to parse local response data: {error}"), + ) + }) +} + +pub async fn dispatch_data_value_request( + route: &str, + permission_code: &str, + api_key: &str, + payload: Value, +) -> Result +where + T: DeserializeOwned + Serialize, +{ + let data: T = dispatch_data_request(route, permission_code, api_key, payload).await?; + serde_json::to_value(data).map_err(|error| { + ( + StatusCode::BAD_GATEWAY, + format!("failed to serialize local response data: {error}"), + ) + }) +} diff --git a/src-tauri/src/local_api/client/main_server.rs b/src-tauri/src/local_api/client/main_server.rs deleted file mode 100644 index f056685..0000000 --- a/src-tauri/src/local_api/client/main_server.rs +++ /dev/null @@ -1,128 +0,0 @@ -use axum::http::StatusCode; -use serde::{Serialize, de::DeserializeOwned}; -use serde_json::{Value, json}; -use tauri::Manager; - -use crate::{ - app::{context::AppContext, handle::get_app_handle}, - infrastructure::main_server::error::ResponseError, -}; - -use super::headers::build_local_api_auth_headers; - -pub async fn proxy_request( - server_path: &str, - permission_code: &str, - api_key: &str, - payload: Value, -) -> Result { - let app = - get_app_handle().map_err(|error| (StatusCode::INTERNAL_SERVER_ERROR, error.to_string()))?; - let business_context = app.state::(); - business::services::local_api::validate_local_api_key_service( - &business_context, - &business::entitys::ValidateLocalApiKeyRequest { - api_key: api_key.to_string(), - permission_code: permission_code.to_string(), - }, - ) - .await - .map_err(|message| (StatusCode::UNAUTHORIZED, message))?; - - if let Some(result) = - business::dispatcher::dispatch_post(&business_context, server_path, &payload).await - { - return match result { - Ok(data) => Ok(json!({ "code": 1, "message": "OK", "data": data })), - Err(message) => Err((StatusCode::BAD_REQUEST, message)), - }; - } - - let ctx = AppContext::get(); - let headers = build_local_api_auth_headers(api_key, permission_code) - .map_err(|error| (StatusCode::BAD_REQUEST, error))?; - - let response = ctx - .main_server_client - .post_with_headers(server_path, &payload, headers) - .await - .map_err(map_main_server_error)?; - - serde_json::to_value(response).map_err(|_| { - ( - StatusCode::BAD_GATEWAY, - json!({ - "code": -1, - "message": "failed to serialize response" - }) - .to_string(), - ) - }) -} - -pub async fn proxy_data_request( - server_path: &str, - permission_code: &str, - api_key: &str, - payload: Value, -) -> Result -where - T: DeserializeOwned, -{ - let value = proxy_request(server_path, permission_code, api_key, payload).await?; - let response: crate::infrastructure::http::client::JsonRespnse = serde_json::from_value(value) - .map_err(|error| { - ( - StatusCode::BAD_GATEWAY, - format!("failed to parse server response: {error}"), - ) - })?; - - let data = response.data.ok_or_else(|| { - ( - StatusCode::BAD_GATEWAY, - "missing server response data".to_string(), - ) - })?; - - serde_json::from_value(data).map_err(|error| { - ( - StatusCode::BAD_GATEWAY, - format!("failed to parse response data: {error}"), - ) - }) -} - -pub async fn proxy_data_value_request( - server_path: &str, - permission_code: &str, - api_key: &str, - payload: Value, -) -> Result -where - T: DeserializeOwned + Serialize, -{ - let data: T = proxy_data_request(server_path, permission_code, api_key, payload).await?; - serde_json::to_value(data).map_err(|error| { - ( - StatusCode::BAD_GATEWAY, - format!("failed to serialize response data: {error}"), - ) - }) -} - -fn map_main_server_error(error: anyhow::Error) -> (StatusCode, String) { - if let Some(response_error) = error.downcast_ref::() { - return match response_error { - ResponseError::Unauthorized { message } => (StatusCode::UNAUTHORIZED, message.clone()), - ResponseError::BadRequest { message } => (StatusCode::BAD_REQUEST, message.clone()), - ResponseError::PublicKeyExpired => ( - StatusCode::BAD_GATEWAY, - "server public key expired".to_string(), - ), - other => (StatusCode::BAD_GATEWAY, other.to_string()), - }; - } - - (StatusCode::BAD_GATEWAY, error.to_string()) -} diff --git a/src-tauri/src/local_api/client/mod.rs b/src-tauri/src/local_api/client/mod.rs index 055f132..5da5bb5 100644 --- a/src-tauri/src/local_api/client/mod.rs +++ b/src-tauri/src/local_api/client/mod.rs @@ -1,2 +1,2 @@ +pub mod business; pub mod headers; -pub mod main_server; diff --git a/src-tauri/src/local_api/entitys/browser_kernels.rs b/src-tauri/src/local_api/entitys/browser_kernels.rs index 0fb5211..da79cb8 100644 --- a/src-tauri/src/local_api/entitys/browser_kernels.rs +++ b/src-tauri/src/local_api/entitys/browser_kernels.rs @@ -4,17 +4,25 @@ use serde::{Deserialize, Serialize}; #[derive(Debug, Clone, Deserialize, Serialize)] pub struct LocalApiBrowserKernelVersion { + pub kernel_id: String, + pub type_code: String, pub resource_name: String, pub version: String, pub name: Option, pub notes: Option, pub platform: Option, + pub url: Option, + pub hash: String, + pub signature: String, pub file_size: Option, pub is_latest: bool, pub status: String, pub arch: Option, pub package_format: Option, pub requires_extract: bool, + pub entrypoint_template: Option, + pub extract_root: Option, + pub installed: bool, } pub type LocalApiBrowserKernelListResponse = HashMap>; diff --git a/src-tauri/src/local_api/services/browser_kernels.rs b/src-tauri/src/local_api/services/browser_kernels.rs index a075ea8..ea820c9 100644 --- a/src-tauri/src/local_api/services/browser_kernels.rs +++ b/src-tauri/src/local_api/services/browser_kernels.rs @@ -2,14 +2,14 @@ use axum::http::StatusCode; use serde_json::Value; use crate::local_api::{ - client::main_server::proxy_data_value_request, context::LocalApiRequestContext, + client::business::dispatch_data_value_request, context::LocalApiRequestContext, }; pub async fn list_browser_kernels_service( ctx: &LocalApiRequestContext, payload: Value, ) -> Result { - proxy_data_value_request::( + dispatch_data_value_request::( "browser-kernels/list", "browser-kernels.list", &ctx.api_key, diff --git a/src-tauri/src/local_api/services/environments.rs b/src-tauri/src/local_api/services/environments.rs index ded6662..d13b9ea 100644 --- a/src-tauri/src/local_api/services/environments.rs +++ b/src-tauri/src/local_api/services/environments.rs @@ -4,7 +4,7 @@ use serde_json::Value; use crate::{ app::handle::get_app_handle, local_api::{ - client::main_server::proxy_data_value_request, + client::business::dispatch_data_value_request, context::LocalApiRequestContext, entitys::{ LocalApiBatchStartEnvironmentsRequest, LocalApiEnvironmentActionResponse, @@ -22,7 +22,7 @@ macro_rules! environment_service { ctx: &LocalApiRequestContext, payload: Value, ) -> Result { - proxy_data_value_request::<$response_ty>( + dispatch_data_value_request::<$response_ty>( concat!("environments", $path), $permission, &ctx.api_key, diff --git a/src-tauri/src/local_api/services/groups.rs b/src-tauri/src/local_api/services/groups.rs index 9782a50..c0955d8 100644 --- a/src-tauri/src/local_api/services/groups.rs +++ b/src-tauri/src/local_api/services/groups.rs @@ -2,7 +2,7 @@ use axum::http::StatusCode; use serde_json::Value; use crate::local_api::{ - client::main_server::proxy_data_request, context::LocalApiRequestContext, + client::business::dispatch_data_request, context::LocalApiRequestContext, services::forward_service, types::LocalApiRoute, }; @@ -32,7 +32,7 @@ pub async fn list_groups_service( payload: Value, ) -> Result { let items = - proxy_data_request::>("groups/list", "groups.list", &ctx.api_key, payload) + dispatch_data_request::>("groups/list", "groups.list", &ctx.api_key, payload) .await?; Ok(serde_json::json!({ "items": items })) } diff --git a/src-tauri/src/local_api/services/mod.rs b/src-tauri/src/local_api/services/mod.rs index 103ccfe..1f4685c 100644 --- a/src-tauri/src/local_api/services/mod.rs +++ b/src-tauri/src/local_api/services/mod.rs @@ -9,7 +9,7 @@ use axum::http::StatusCode; use serde_json::Value; use crate::local_api::{ - client::main_server::proxy_request, context::LocalApiRequestContext, types::LocalApiRoute, + client::business::dispatch_request, context::LocalApiRequestContext, types::LocalApiRoute, }; pub async fn forward_service( @@ -17,7 +17,7 @@ pub async fn forward_service( payload: Value, route: LocalApiRoute, ) -> Result { - let response = proxy_request( + let response = dispatch_request( route.server_path, route.permission_code, &ctx.api_key, diff --git a/src-tauri/src/local_api/services/proxies.rs b/src-tauri/src/local_api/services/proxies.rs index b92e563..fd1d98a 100644 --- a/src-tauri/src/local_api/services/proxies.rs +++ b/src-tauri/src/local_api/services/proxies.rs @@ -2,7 +2,7 @@ use axum::http::StatusCode; use serde_json::Value; use crate::local_api::{ - client::main_server::proxy_data_value_request, context::LocalApiRequestContext, + client::business::dispatch_data_value_request, context::LocalApiRequestContext, services::forward_service, types::LocalApiRoute, }; @@ -12,7 +12,7 @@ macro_rules! proxy_service { ctx: &LocalApiRequestContext, payload: Value, ) -> Result { - proxy_data_value_request::<$response_ty>( + dispatch_data_value_request::<$response_ty>( concat!("proxies", $path), $permission, &ctx.api_key, diff --git a/src-tauri/src/local_api/services/tags.rs b/src-tauri/src/local_api/services/tags.rs index c2866ff..bdff4ab 100644 --- a/src-tauri/src/local_api/services/tags.rs +++ b/src-tauri/src/local_api/services/tags.rs @@ -2,7 +2,7 @@ use axum::http::StatusCode; use serde_json::Value; use crate::local_api::{ - client::main_server::proxy_data_value_request, context::LocalApiRequestContext, + client::business::dispatch_data_value_request, context::LocalApiRequestContext, services::forward_service, types::LocalApiRoute, }; @@ -12,7 +12,7 @@ macro_rules! tag_service { ctx: &LocalApiRequestContext, payload: Value, ) -> Result { - proxy_data_value_request::<$response_ty>( + dispatch_data_value_request::<$response_ty>( concat!("tags", $path), $permission, &ctx.api_key, diff --git a/src-tauri/src/local_api/services/workspaces.rs b/src-tauri/src/local_api/services/workspaces.rs index cb921f5..62b4b91 100644 --- a/src-tauri/src/local_api/services/workspaces.rs +++ b/src-tauri/src/local_api/services/workspaces.rs @@ -2,7 +2,7 @@ use axum::http::StatusCode; use serde_json::Value; use crate::local_api::{ - client::main_server::proxy_data_value_request, context::LocalApiRequestContext, + client::business::dispatch_data_value_request, context::LocalApiRequestContext, services::forward_service, types::LocalApiRoute, }; @@ -12,7 +12,7 @@ macro_rules! workspace_service { ctx: &LocalApiRequestContext, payload: Value, ) -> Result { - proxy_data_value_request::<$response_ty>( + dispatch_data_value_request::<$response_ty>( concat!("workspaces", $path), $permission, &ctx.api_key, diff --git a/src-tauri/src/main.rs b/src-tauri/src/main.rs index 51af27d..87a9331 100644 --- a/src-tauri/src/main.rs +++ b/src-tauri/src/main.rs @@ -5,7 +5,6 @@ use log::error; use simprint_lib::core::config; use simprint_lib::core::logger; -use simprint_lib::infrastructure::deeplink; /// 屏蔽环境变量 pub fn disable_env_var() { @@ -29,17 +28,13 @@ async fn main() -> Result<(), Box> { std::process::exit(-1); } - // 1.1 处理"应用未启动时"的 deep link 冷启动参数(协议拉起会把 URL 作为 argv 传入) - // 例如:simprint://register?referral_code=XXXX - deeplink::process_first_url_arg(std::env::args()); - // 初始化日志系统(兜底目录,Tauri 启动后会在 setup 中按 store 设置重新初始化) logger::init_logging(logger::bootstrap_log_dir()); // 记录应用启动 logger::log_app_start(); - // 初始化应用核心组件(AppContext、RSA 密钥对等) + // 初始化应用核心组件 if let Err(e) = simprint_lib::app::lifecycle::init_early() { error!("Failed to initialize app context: {}", e); std::process::exit(-1); diff --git a/src-tauri/src/mcp/bridge.rs b/src-tauri/src/mcp/bridge.rs index 017a38c..334e338 100644 --- a/src-tauri/src/mcp/bridge.rs +++ b/src-tauri/src/mcp/bridge.rs @@ -6,7 +6,7 @@ use crate::{ app::handle::get_app_handle, domain::environment::EnvironmentStatus, local_api::{ - client::main_server::proxy_data_request, + client::business::dispatch_data_request, entitys::{ LocalApiBrowserKernelListResponse, LocalApiEnvironmentActionResponse, LocalApiEnvironmentDetailResponse, LocalApiEnvironmentListResponse, @@ -84,14 +84,14 @@ impl LocalApiBridge { &self, request: LocalApiListEnvironmentsRequest, ) -> Result { - self.proxy_main_server("environments/list", "environments.list", &request).await + self.dispatch_business("environments/list", "environments.list", &request).await } pub async fn get_environment( &self, env_uuid: &str, ) -> Result { - self.proxy_main_server( + self.dispatch_business( "environments/detail", "environments.detail", &json!({ "uuid": env_uuid }), @@ -182,14 +182,14 @@ impl LocalApiBridge { } pub async fn list_groups(&self) -> Result { - self.proxy_main_server("groups/list", "groups.list", &json!({})).await + self.dispatch_business("groups/list", "groups.list", &json!({})).await } pub async fn create_group( &self, payload: &T, ) -> Result { - self.proxy_main_server("groups/create", "groups.create", payload).await + self.dispatch_business("groups/create", "groups.create", payload).await } pub async fn update_group( @@ -197,13 +197,13 @@ impl LocalApiBridge { payload: &T, ) -> Result<(), McpToolError> { let _: serde_json::Value = - self.proxy_main_server("groups/update", "groups.update", payload).await?; + self.dispatch_business("groups/update", "groups.update", payload).await?; Ok(()) } pub async fn delete_group(&self, group_uuid: &str) -> Result<(), McpToolError> { let _: serde_json::Value = self - .proxy_main_server( + .dispatch_business( "groups/delete", "groups.delete", &json!({ "uuid": group_uuid }), @@ -213,38 +213,38 @@ impl LocalApiBridge { } pub async fn list_tags(&self) -> Result, McpToolError> { - self.proxy_main_server("tags/list", "tags.list", &json!({})).await + self.dispatch_business("tags/list", "tags.list", &json!({})).await } pub async fn create_tag( &self, payload: &T, ) -> Result { - self.proxy_main_server("tags/create", "tags.create", payload).await + self.dispatch_business("tags/create", "tags.create", payload).await } pub async fn update_tag(&self, payload: &T) -> Result<(), McpToolError> { let _: serde_json::Value = - self.proxy_main_server("tags/update", "tags.update", payload).await?; + self.dispatch_business("tags/update", "tags.update", payload).await?; Ok(()) } pub async fn delete_tag(&self, tag_uuid: &str) -> Result<(), McpToolError> { let _: serde_json::Value = self - .proxy_main_server("tags/delete", "tags.delete", &json!({ "uuid": tag_uuid })) + .dispatch_business("tags/delete", "tags.delete", &json!({ "uuid": tag_uuid })) .await?; Ok(()) } pub async fn list_workspaces(&self) -> Result { - self.proxy_main_server("workspaces/list", "workspaces.list", &json!({})).await + self.dispatch_business("workspaces/list", "workspaces.list", &json!({})).await } pub async fn get_workspace( &self, workspace_uuid: &str, ) -> Result { - self.proxy_main_server( + self.dispatch_business( "workspaces/get", "workspaces.get", &json!({ "uuid": workspace_uuid }), @@ -254,7 +254,7 @@ impl LocalApiBridge { pub async fn switch_workspace(&self, workspace_uuid: &str) -> Result<(), McpToolError> { let _: serde_json::Value = self - .proxy_main_server( + .dispatch_business( "workspaces/switch", "workspaces.switch", &json!({ "workspace_uuid": workspace_uuid }), @@ -267,11 +267,11 @@ impl LocalApiBridge { &self, request: LocalApiListProxiesRequest, ) -> Result { - self.proxy_main_server("proxies/list", "proxies.list", &request).await + self.dispatch_business("proxies/list", "proxies.list", &request).await } pub async fn get_proxy(&self, proxy_uuid: &str) -> Result { - self.proxy_main_server( + self.dispatch_business( "proxies/detail", "proxies.detail", &json!({ "uuid": proxy_uuid }), @@ -283,7 +283,7 @@ impl LocalApiBridge { &self, payload: &T, ) -> Result { - self.proxy_main_server("proxies/create", "proxies.create", payload).await + self.dispatch_business("proxies/create", "proxies.create", payload).await } pub async fn update_proxy( @@ -291,13 +291,13 @@ impl LocalApiBridge { payload: &T, ) -> Result<(), McpToolError> { let _: serde_json::Value = - self.proxy_main_server("proxies/update", "proxies.update", payload).await?; + self.dispatch_business("proxies/update", "proxies.update", payload).await?; Ok(()) } pub async fn delete_proxy(&self, proxy_uuid: &str) -> Result<(), McpToolError> { let _: serde_json::Value = self - .proxy_main_server( + .dispatch_business( "proxies/delete", "proxies.delete", &json!({ "uuid": proxy_uuid }), @@ -308,7 +308,7 @@ impl LocalApiBridge { pub async fn batch_delete_proxies(&self, proxy_uuids: Vec) -> Result<(), McpToolError> { let _: serde_json::Value = self - .proxy_main_server( + .dispatch_business( "proxies/batch-delete", "proxies.batch-delete", &json!({ "uuids": proxy_uuids }), @@ -321,7 +321,7 @@ impl LocalApiBridge { &self, request: LocalApiListBrowserKernelsRequest, ) -> Result { - self.proxy_main_server("browser-kernels/list", "browser-kernels.list", &request) + self.dispatch_business("browser-kernels/list", "browser-kernels.list", &request) .await } @@ -330,7 +330,7 @@ impl LocalApiBridge { env_uuids: Vec, ) -> Result { - self.proxy_main_server( + self.dispatch_business( "environments/batch-detail", "environments.batch-detail", &json!({ "uuids": env_uuids }), @@ -342,7 +342,7 @@ impl LocalApiBridge { &self, payload: &T, ) -> Result { - self.proxy_main_server("environments/create", "environments.create", payload) + self.dispatch_business("environments/create", "environments.create", payload) .await } @@ -351,14 +351,14 @@ impl LocalApiBridge { payload: &T, ) -> Result<(), McpToolError> { let _: serde_json::Value = self - .proxy_main_server("environments/update", "environments.update", payload) + .dispatch_business("environments/update", "environments.update", payload) .await?; Ok(()) } pub async fn delete_environment(&self, env_uuid: &str) -> Result<(), McpToolError> { let _: serde_json::Value = self - .proxy_main_server( + .dispatch_business( "environments/delete", "environments.delete", &json!({ "uuid": env_uuid }), @@ -372,7 +372,7 @@ impl LocalApiBridge { env_uuids: Vec, ) -> Result<(), McpToolError> { let _: serde_json::Value = self - .proxy_main_server( + .dispatch_business( "environments/batch-delete", "environments.batch-delete", &json!({ "uuids": env_uuids }), @@ -386,7 +386,7 @@ impl LocalApiBridge { request: LocalApiListEnvironmentsRequest, ) -> Result { - self.proxy_main_server( + self.dispatch_business( "environments/recycle-bin/list", "environments.recycle-bin.list", &request, @@ -396,7 +396,7 @@ impl LocalApiBridge { pub async fn restore_environment(&self, env_uuid: &str) -> Result<(), McpToolError> { let _: serde_json::Value = self - .proxy_main_server( + .dispatch_business( "environments/recycle-bin/restore", "environments.recycle-bin.restore", &json!({ "uuid": env_uuid }), @@ -410,7 +410,7 @@ impl LocalApiBridge { env_uuids: Vec, ) -> Result<(), McpToolError> { let _: serde_json::Value = self - .proxy_main_server( + .dispatch_business( "environments/recycle-bin/batch-restore", "environments.recycle-bin.batch-restore", &json!({ "uuids": env_uuids }), @@ -421,7 +421,7 @@ impl LocalApiBridge { pub async fn permanent_delete_environment(&self, env_uuid: &str) -> Result<(), McpToolError> { let _: serde_json::Value = self - .proxy_main_server( + .dispatch_business( "environments/recycle-bin/permanent-delete", "environments.recycle-bin.permanent-delete", &json!({ "uuid": env_uuid }), @@ -435,7 +435,7 @@ impl LocalApiBridge { env_uuids: Vec, ) -> Result<(), McpToolError> { let _: serde_json::Value = self - .proxy_main_server( + .dispatch_business( "environments/recycle-bin/batch-permanent-delete", "environments.recycle-bin.batch-permanent-delete", &json!({ "uuids": env_uuids }), @@ -449,7 +449,7 @@ impl LocalApiBridge { payload: &T, ) -> Result<(), McpToolError> { let _: serde_json::Value = self - .proxy_main_server("environments/set-proxy", "environments.set-proxy", payload) + .dispatch_business("environments/set-proxy", "environments.set-proxy", payload) .await?; Ok(()) } @@ -459,7 +459,7 @@ impl LocalApiBridge { payload: &T, ) -> Result<(), McpToolError> { let _: serde_json::Value = self - .proxy_main_server( + .dispatch_business( "environments/set-accounts", "environments.set-accounts", payload, @@ -473,7 +473,7 @@ impl LocalApiBridge { payload: &T, ) -> Result<(), McpToolError> { let _: serde_json::Value = self - .proxy_main_server( + .dispatch_business( "environments/assign-tags", "environments.assign-tags", payload, @@ -487,7 +487,7 @@ impl LocalApiBridge { payload: &T, ) -> Result<(), McpToolError> { let _: serde_json::Value = self - .proxy_main_server( + .dispatch_business( "environments/batch-assign-tags", "environments.batch-assign-tags", payload, @@ -498,7 +498,7 @@ impl LocalApiBridge { pub async fn remove_tag(&self, payload: &T) -> Result<(), McpToolError> { let _: serde_json::Value = self - .proxy_main_server( + .dispatch_business( "environments/remove-tag", "environments.remove-tag", payload, @@ -512,7 +512,7 @@ impl LocalApiBridge { payload: &T, ) -> Result<(), McpToolError> { let _: serde_json::Value = self - .proxy_main_server( + .dispatch_business( "environments/batch-remove-tags", "environments.batch-remove-tags", payload, @@ -526,7 +526,7 @@ impl LocalApiBridge { payload: &T, ) -> Result<(), McpToolError> { let _: serde_json::Value = self - .proxy_main_server( + .dispatch_business( "environments/move-to-group", "environments.move-to-group", payload, @@ -540,7 +540,7 @@ impl LocalApiBridge { payload: &T, ) -> Result<(), McpToolError> { let _: serde_json::Value = self - .proxy_main_server( + .dispatch_business( "environments/batch-move-to-group", "environments.batch-move-to-group", payload, @@ -553,7 +553,7 @@ impl LocalApiBridge { &self, env_uuid: &str, ) -> Result, McpToolError> { - self.proxy_main_server( + self.dispatch_business( "environments/urls/list", "environments.urls.list", &json!({ "uuid": env_uuid }), @@ -565,13 +565,13 @@ impl LocalApiBridge { &self, payload: &T, ) -> Result { - self.proxy_main_server("environments/urls/add", "environments.urls.add", payload) + self.dispatch_business("environments/urls/add", "environments.urls.add", payload) .await } pub async fn delete_environment_url(&self, id: i32) -> Result<(), McpToolError> { let _: serde_json::Value = self - .proxy_main_server( + .dispatch_business( "environments/urls/delete", "environments.urls.delete", &json!({ "id": id }), @@ -582,7 +582,7 @@ impl LocalApiBridge { pub async fn clear_environment_urls(&self, env_uuid: &str) -> Result<(), McpToolError> { let _: serde_json::Value = self - .proxy_main_server( + .dispatch_business( "environments/urls/clear", "environments.urls.clear", &json!({ "environment_uuid": env_uuid }), @@ -595,7 +595,7 @@ impl LocalApiBridge { &self, env_uuid: &str, ) -> Result, McpToolError> { - self.proxy_main_server( + self.dispatch_business( "environments/cookies/list", "environments.cookies.list", &json!({ "uuid": env_uuid }), @@ -607,7 +607,7 @@ impl LocalApiBridge { &self, payload: &T, ) -> Result { - self.proxy_main_server( + self.dispatch_business( "environments/cookies/add", "environments.cookies.add", payload, @@ -617,7 +617,7 @@ impl LocalApiBridge { pub async fn delete_environment_cookie(&self, id: i32) -> Result<(), McpToolError> { let _: serde_json::Value = self - .proxy_main_server( + .dispatch_business( "environments/cookies/delete", "environments.cookies.delete", &json!({ "id": id }), @@ -628,7 +628,7 @@ impl LocalApiBridge { pub async fn clear_environment_cookies(&self, env_uuid: &str) -> Result<(), McpToolError> { let _: serde_json::Value = self - .proxy_main_server( + .dispatch_business( "environments/cookies/clear", "environments.cookies.clear", &json!({ "environment_uuid": env_uuid }), @@ -637,9 +637,9 @@ impl LocalApiBridge { Ok(()) } - async fn proxy_main_server( + async fn dispatch_business( &self, - server_path: &str, + route: &str, permission_code: &str, payload: &P, ) -> Result @@ -650,7 +650,7 @@ impl LocalApiBridge { let payload = serde_json::to_value(payload) .map_err(|error| McpToolError::internal(error.to_string()))?; - proxy_data_request::(server_path, permission_code, &self.api_key, payload) + dispatch_data_request::(route, permission_code, &self.api_key, payload) .await .map_err(map_proxy_error) } diff --git a/src-tauri/src/mcp/tools/browser_kernels.rs b/src-tauri/src/mcp/tools/browser_kernels.rs index 3ae2925..5989fb9 100644 --- a/src-tauri/src/mcp/tools/browser_kernels.rs +++ b/src-tauri/src/mcp/tools/browser_kernels.rs @@ -37,6 +37,7 @@ pub struct BrowserKernelGroupSummary { #[derive(Debug, Clone, Serialize, JsonSchema)] pub struct BrowserKernelVersionSummary { + pub kernel_id: String, pub resource_name: String, pub version: String, pub name: Option, @@ -103,6 +104,7 @@ fn map_browser_kernel_version( version: LocalApiBrowserKernelVersion, ) -> BrowserKernelVersionSummary { BrowserKernelVersionSummary { + kernel_id: version.kernel_id, resource_name: version.resource_name, version: version.version, name: version.name, diff --git a/src-tauri/src/services/auth/credential.rs b/src-tauri/src/services/auth/credential.rs deleted file mode 100644 index 98e8753..0000000 --- a/src-tauri/src/services/auth/credential.rs +++ /dev/null @@ -1,54 +0,0 @@ -/// 凭证服务 -/// -/// 封装凭证管理相关的业务逻辑 -use crate::core::error::Result; -use crate::infrastructure::persistence::credential::remembered; -use crate::infrastructure::persistence::credential::{clear_credential, get_credential, is_login}; -use crate::local_api; - -/// 凭证服务 -pub struct CredentialService; - -impl CredentialService { - /// 退出登录 - pub async fn logout() -> Result<()> { - clear_credential(); - remembered::clear_remembered_credential()?; - log::trace!("用户已退出登录"); - - if let Some(ctx) = crate::app::context::AppContext::try_get() { - ctx.simprint_runtime_manager.stop().await; - } - local_api::stop_runtime(); - - Ok(()) - } - - /// 获取访问令牌 - pub fn get_access_token() -> Result { - get_credential().get_access_token().ok_or_else(|| "获取登录凭证失败".into()) - } - - /// 检查是否已登录 - pub fn is_logged_in() -> bool { - is_login() - } - - /// 保存记住的凭证 - pub fn save_remembered_credential(email: String, refresh_token: String) -> Result<()> { - remembered::save_remembered_credential(email, refresh_token)?; - Ok(()) - } - - /// 获取记住的凭证 - pub fn get_remembered_credential() -> Result> { - Ok(remembered::get_remembered_credential()?) - } - - /// 清除记住的凭证 - pub fn clear_remembered_credential() -> Result<()> { - remembered::clear_remembered_credential()?; - log::trace!("记住的凭证已清除"); - Ok(()) - } -} diff --git a/src-tauri/src/services/auth/login.rs b/src-tauri/src/services/auth/login.rs deleted file mode 100644 index d2a8927..0000000 --- a/src-tauri/src/services/auth/login.rs +++ /dev/null @@ -1,144 +0,0 @@ -/// 登录服务 -/// -/// 封装登录相关的业务逻辑 -use crate::app::context::AppContext; -use crate::core::error::Result; -use crate::domain::credential::Credential; -use crate::infrastructure::http::client::JsonRespnse; -use crate::infrastructure::persistence::credential::{set_access_token, set_refresh_token}; -use serde::{Deserialize, Serialize}; -use serde_json::{Value, json}; - -/// 基本登录请求(邮箱 + 密码) -#[derive(Serialize, Deserialize)] -pub struct BasicLoginRequest { - pub email: String, - pub password: String, -} - -/// 记住密码登录请求 -#[derive(Serialize, Deserialize)] -pub struct RememberPasswordLoginRequest { - pub email: String, - pub refresh_token: String, -} - -/// 登录方式 -#[derive(Serialize, Deserialize)] -#[serde(tag = "type", content = "data", rename_all = "snake_case")] -pub enum LoginType { - /// 基本登录(邮箱 + 密码) - Basic(BasicLoginRequest), - /// 记住密码登录(邮箱 + refresh_token) - RememberPassword(RememberPasswordLoginRequest), -} - -/// 登录响应 -#[derive(Debug, Serialize, Deserialize, Clone)] -pub struct LoginResponse { - /// 访问令牌 - pub access_token: String, - /// 刷新令牌 - pub refresh_token: String, - /// 是否首次登录 - pub is_first_login: Option, - /// 用户信息 - #[serde(default)] - pub user_info: Option, - /// 用户额外信息 - #[serde(default)] - pub user_extra: Option, -} - -/// 登录服务 -pub struct LoginService; - -impl LoginService { - /// 执行登录 - pub async fn login(payload: LoginType) -> Result { - let ctx = AppContext::get(); - - // 1. 获取 RSA 公钥 - let public_key = ctx.rsa_keypair.get_public_key()?; - - // 2. 构建登录请求 - let request_payload = Self::build_login_payload(payload, public_key); - - // 3. 发起登录请求 - let result = ctx.main_server_client.post("users/login", &request_payload).await?; - - // 4. 处理登录响应 - if let Some(data) = result.data.clone() { - Self::handle_login_response(data).await?; - } - - Ok(result) - } - - /// 保存凭证 - pub async fn save_credential( - access_token: Option, - refresh_token: Option, - ) -> Result<()> { - if let Some(access_token) = access_token { - set_access_token(access_token); - } - if let Some(refresh_token) = refresh_token { - set_refresh_token(refresh_token); - } - - sync_runtime_session_state().await?; - - Ok(()) - } - - /// 构建登录请求 - fn build_login_payload(payload: LoginType, public_key: String) -> Value { - match payload { - LoginType::Basic(request) => json!({ - "login_type": "basic", - "email": request.email, - "password": request.password, - "public_secret_key": public_key, - }), - LoginType::RememberPassword(request) => json!({ - "login_type": "remember", - "email": request.email, - "refresh_token": request.refresh_token, - "public_secret_key": public_key, - }), - } - } - - /// 处理登录响应 - async fn handle_login_response(data: Value) -> Result<()> { - // 解析登录响应 - let login_response: LoginResponse = serde_json::from_value(data)?; - - // 使用领域对象创建凭证 - let credential = Credential::new( - login_response.access_token.clone(), - login_response.refresh_token.clone(), - ); - - // 验证凭证 - if !credential.is_valid() { - return Err("登录响应中的凭证无效".into()); - } - - // 保存凭证到存储层 - set_access_token(credential.access_token().to_string()); - set_refresh_token(credential.refresh_token().to_string()); - sync_runtime_session_state().await?; - - Ok(()) - } -} - -pub async fn sync_runtime_session_state() -> Result<()> { - if let Some(ctx) = AppContext::try_get() { - ctx.simprint_runtime_manager.sync_session_state().await?; - } - - Ok(()) -} diff --git a/src-tauri/src/services/auth/mod.rs b/src-tauri/src/services/auth/mod.rs deleted file mode 100644 index 1aad354..0000000 --- a/src-tauri/src/services/auth/mod.rs +++ /dev/null @@ -1,12 +0,0 @@ -/// 认证服务模块 -/// -/// 提供登录、注册和凭证管理的业务逻辑 -pub mod credential; -pub mod login; -pub mod register; - -pub use credential::CredentialService; -pub use login::{ - BasicLoginRequest, LoginResponse, LoginService, LoginType, RememberPasswordLoginRequest, -}; -pub use register::{RegisterRequest, RegisterService}; diff --git a/src-tauri/src/services/auth/register.rs b/src-tauri/src/services/auth/register.rs deleted file mode 100644 index 9a05399..0000000 --- a/src-tauri/src/services/auth/register.rs +++ /dev/null @@ -1,169 +0,0 @@ -/// 注册服务 -/// -/// 封装注册相关的业务逻辑 -use crate::app::context::AppContext; -use crate::core::error::Result; -use crate::domain::credential::Credential; -use crate::domain::referral_code::ReferralCode; -use crate::infrastructure::http::client::JsonRespnse; -use crate::infrastructure::persistence::credential::{set_access_token, set_refresh_token}; -use crate::services::auth::login::LoginResponse; -use base64::Engine; -use base64::engine::general_purpose::URL_SAFE_NO_PAD; -use regex::Regex; -use serde::{Deserialize, Serialize}; -use serde_json::{Value, json}; -use std::fs; - -/// 注册请求 -#[derive(Serialize, Deserialize)] -pub struct RegisterRequest { - pub email: String, - pub password: String, - pub code: String, - #[serde(skip_serializing_if = "Option::is_none")] - pub referral_code: Option, -} - -/// 注册服务 -pub struct RegisterService; - -impl RegisterService { - /// 执行注册 - pub async fn register(payload: RegisterRequest) -> Result { - let ctx = AppContext::get(); - - // 1. 获取 RSA 公钥 - let public_key = ctx.rsa_keypair.get_public_key()?; - - // 2. 从邮箱提取 nickname - let nickname = payload.email.split('@').next().map(|s| s.to_string()).unwrap_or_default(); - - // 3. 处理邀请码 - let referral_code = Self::resolve_referral_code(payload.referral_code); - - // 4. 构建注册请求 - let request_payload = Self::build_register_payload( - payload.email, - payload.password, - payload.code, - nickname, - public_key, - referral_code, - ); - - // 5. 发起注册请求 - let result = ctx.main_server_client.post("users/register", &request_payload).await?; - - // 6. 处理注册响应 - if let Some(data) = result.data.clone() { - Self::handle_register_response(data).await?; - } - - Ok(result) - } - - /// 构建注册请求 - fn build_register_payload( - email: String, - password: String, - code: String, - nickname: String, - public_key: String, - referral_code: Option, - ) -> Value { - let mut payload = json!({ - "email": email, - "password": password, - "code": code, - "nickname": nickname, - "public_secret_key": public_key, - }); - - if let Some(code) = referral_code { - payload["referral_code"] = json!(code); - } - - payload - } - - /// 处理注册响应 - async fn handle_register_response(data: Value) -> Result<()> { - let login_response: LoginResponse = serde_json::from_value(data)?; - - // 使用领域对象创建凭证 - let credential = Credential::new( - login_response.access_token.clone(), - login_response.refresh_token.clone(), - ); - - // 验证凭证 - if !credential.is_valid() { - return Err("注册响应中的凭证无效".into()); - } - - // 保存凭证到存储层 - set_access_token(credential.access_token().to_string()); - set_refresh_token(credential.refresh_token().to_string()); - crate::services::auth::login::sync_runtime_session_state().await?; - - Ok(()) - } - - /// 解析邀请码 - fn resolve_referral_code(user_provided: Option) -> Option { - // 优先使用用户提供的邀请码 - let code = user_provided - .as_ref() - .map(|s| s.trim()) - .filter(|s| !s.is_empty()) - .map(|s| s.to_string()) - .or_else(|| { - // 从安装目录读取邀请码 - Self::read_installed_referral_code().and_then(Self::decode_referral_code) - }); - - code - } - - /// 解码邀请码(base64url) - fn decode_referral_code(encoded: String) -> Option { - // 使用领域对象解码邀请码 - ReferralCode::decode(&encoded) - .ok() - .filter(|code| code.is_valid()) - .map(|code| code.code().to_string()) - } - - /// 从统一路径中的 referral 目录读取邀请码 - fn read_installed_referral_code() -> Option { - let referral_dir = crate::core::paths::PathManager::get_referral_dir().ok()?; - - if !referral_dir.is_dir() { - return None; - } - - // 提取邀请码 - let code_opt: Option = (|| { - let mut entries: Vec<_> = - fs::read_dir(&referral_dir).ok()?.filter_map(|e| e.ok()).collect(); - entries.sort_by_key(|e| e.file_name()); - - let first_entry = entries.into_iter().next()?; - let path = first_entry.path(); - if !path.is_file() { - return None; - } - - let file_name = path.file_name()?.to_string_lossy(); - let re = Regex::new(r"R_(.+)_R").ok()?; - let cap = re.captures(file_name.as_ref())?; - cap.get(1).map(|m| m.as_str().to_string()) - })(); - - // 清理 referral 目录 - let _ = fs::remove_dir_all(&referral_dir); - - code_opt - } -} diff --git a/src-tauri/src/services/environment/kernel/downloader.rs b/src-tauri/src/services/environment/kernel/downloader.rs index 8e2bbac..37b49c0 100644 --- a/src-tauri/src/services/environment/kernel/downloader.rs +++ b/src-tauri/src/services/environment/kernel/downloader.rs @@ -122,15 +122,27 @@ pub async fn download_and_install_kernel( /// 下载文件 async fn download_file( - app: &tauri::AppHandle, + _app: &tauri::AppHandle, env_uuid: &Option, kernel_value: &str, url: &str, zip_path: &Path, status_emitter: Option, ) -> Result<()> { + let display_url = redact_download_url(url); + crate::log_info!( + crate::core::logger::modules::KERNEL, + "开始下载内核: {}", + display_url + ); let client = reqwest::Client::new(); let res = client.get(url).send().await.map_err(|e| { + crate::log_error!( + crate::core::logger::modules::KERNEL, + "内核下载请求失败: {} - {}", + display_url, + e + ); emit_status( status_emitter.as_ref(), env_uuid, @@ -145,6 +157,12 @@ async fn download_file( })?; if !res.status().is_success() { + crate::log_error!( + crate::core::logger::modules::KERNEL, + "内核下载失败: HTTP {} - {}", + res.status(), + display_url + ); emit_status( status_emitter.as_ref(), env_uuid, @@ -245,6 +263,19 @@ async fn download_file( Ok(()) } +fn redact_download_url(url: &str) -> String { + match reqwest::Url::parse(url) { + Ok(mut parsed) => { + let _ = parsed.set_username(""); + let _ = parsed.set_password(None); + parsed.set_query(None); + parsed.set_fragment(None); + parsed.to_string() + } + Err(_) => "".to_string(), + } +} + /// 校验下载文件哈希 fn verify_download_hash( env_uuid: &Option, @@ -387,3 +418,22 @@ fn verify_core_dll_signature( Ok(()) } + +#[cfg(test)] +mod tests { + use super::redact_download_url; + + #[test] + fn download_log_url_does_not_include_credentials() { + assert_eq!( + redact_download_url( + "https://user:password@example.test/kernel.zip?token=secret#fragment" + ), + "https://example.test/kernel.zip" + ); + assert_eq!( + redact_download_url("not a url"), + "" + ); + } +} diff --git a/src-tauri/src/services/environment/kernel/mod.rs b/src-tauri/src/services/environment/kernel/mod.rs index c4c9963..7641765 100644 --- a/src-tauri/src/services/environment/kernel/mod.rs +++ b/src-tauri/src/services/environment/kernel/mod.rs @@ -4,7 +4,7 @@ use crate::core::error::Result; use crate::domain::environment::{EnvironmentStatus, KernelDetail}; -use std::fs; +use tauri::Manager; pub mod downloader; pub mod extension; @@ -19,13 +19,32 @@ pub mod verifier; // 重新导出常用类型 pub use types::{ AccountInfo, BatchLaunchRequest, BatchLaunchResult, CdpEndpointResponse, CookieGroup, - ExtensionInfo, KernelPrepareStatusPayload, KernelStatusEmitter, ProxyConfig, RpaTabInfo, - RpaTabCloseResult, RpaTabSelection, RpaTabsSnapshot, + ExtensionInfo, KernelPrepareStatusPayload, KernelStatusEmitter, ProxyConfig, RpaTabCloseResult, + RpaTabInfo, RpaTabSelection, RpaTabsSnapshot, }; /// 内核服务 pub struct KernelService; +async fn record_ready_installation( + app: &tauri::AppHandle, + kernel_id: &str, + exe_path: &std::path::Path, + verified_signature: &str, +) { + let context = app.state::(); + if let Err(error) = business::services::browser_kernels::record_kernel_installation( + &context.db, + kernel_id, + &exe_path.to_string_lossy(), + verified_signature, + ) + .await + { + log::warn!("Failed to record browser kernel installation: {error}"); + } +} + impl KernelService { /// 确保内核已就绪:目录不存在则下载并解压,存在则校验 chrome.dll 哈希 pub async fn ensure_kernel_ready( @@ -36,119 +55,173 @@ impl KernelService { kernel_detail: KernelDetail, status_emitter: Option, ) -> Result { - let kernel_value = kernel_value.trim().to_string(); - if kernel_value.is_empty() { + Self::ensure_kernel_ready_for_artifact( + app, + env_uuid, + kernel_value.clone(), + kernel_value, + profiles_path, + kernel_detail, + status_emitter, + ) + .await + } + + /// Prepare a registry artifact while keeping its immutable identity + /// separate from the backwards-compatible on-disk directory name. + pub async fn ensure_kernel_ready_for_artifact( + app: tauri::AppHandle, + env_uuid: Option, + kernel_id: String, + install_dir_name: String, + profiles_path: String, + kernel_detail: KernelDetail, + status_emitter: Option, + ) -> Result { + let kernel_id = kernel_id.trim().to_string(); + if kernel_id.is_empty() { + return Err("内核标识不能为空".into()); + } + let install_dir_name = install_dir_name.trim().to_string(); + if install_dir_name.is_empty() { return Err("内核版本不能为空".into()); } - let _prepare_guard = state::acquire_kernel_prepare_lock(&kernel_value).await; + let _prepare_guard = state::acquire_kernel_prepare_lock(&kernel_id).await; let base = utils::resolve_profiles_base(&app, &profiles_path)?; - let kernel_dir = base.join(&kernel_value); + let kernel_dir = utils::resolve_kernel_install_dir(&base, &install_dir_name)?; let exe_path = kernel_dir.join(utils::exe_name()); // 目录已存在:校验内核 - if kernel_dir.exists() && kernel_dir.is_dir() { + if kernel_dir.exists() { + if !kernel_dir.is_dir() { + return Err(format!("内核安装路径不是目录: {}", kernel_dir.display()).into()); + } // 检查可执行文件是否存在 if !exe_path.exists() { crate::log_warn!( crate::core::logger::modules::KERNEL, - "内核目录存在但未找到可执行文件,删除目录并重新下载: {}", + "内核目录存在但未找到可执行文件,保留目录并停止启动: {}", kernel_dir.display() ); utils::emit_status( status_emitter.as_ref(), &env_uuid, - &kernel_value, + &install_dir_name, EnvironmentStatus::Error, - Some("未找到可执行文件,重新下载"), + Some("内核目录不完整"), None, None, None, ); - let _ = fs::remove_dir_all(&kernel_dir); + return Err( + format!("内核目录不完整,已保留原目录: {}", kernel_dir.display()).into(), + ); } else { // 校验 signature - let signature = kernel_detail + let primary_signature = kernel_detail .signature .as_deref() .filter(|s| !s.trim().is_empty()) .ok_or("该内核版本缺少 signature,无法校验核心 DLL")?; + let mut accepted_signatures = vec![primary_signature.to_string()]; + for signature in &kernel_detail.compatible_signatures { + if !signature.trim().is_empty() + && !accepted_signatures + .iter() + .any(|current| current.eq_ignore_ascii_case(signature.trim())) + { + accepted_signatures.push(signature.trim().to_string()); + } + } match verifier::verify_kernel( &app, &env_uuid, - &kernel_value, + &install_dir_name, &kernel_dir, - signature, + &accepted_signatures, status_emitter.clone(), ) { - Ok(true) => { + Ok(Some(verified_signature)) => { // 校验通过 utils::emit_status( status_emitter.as_ref(), &env_uuid, - &kernel_value, + &install_dir_name, EnvironmentStatus::Ready, Some("就绪"), None, None, None, ); + record_ready_installation(&app, &kernel_id, &exe_path, &verified_signature) + .await; return Ok(exe_path.to_string_lossy().to_string()); } - Ok(false) => { - // 校验失败,删除目录重新下载 + Ok(None) => { crate::log_warn!( crate::core::logger::modules::KERNEL, - "内核校验失败,删除目录并重新下载: {}", + "内核校验失败,保留目录并停止启动: {}", kernel_dir.display() ); utils::emit_status( status_emitter.as_ref(), &env_uuid, - &kernel_value, + &install_dir_name, EnvironmentStatus::Error, - Some("校验失败,重新下载"), + Some("内核校验失败"), None, None, None, ); - let _ = fs::remove_dir_all(&kernel_dir); + return Err(format!( + "内核校验失败,已保留原目录: {}", + kernel_dir.display() + ) + .into()); } Err(e) => { crate::log_warn!( crate::core::logger::modules::KERNEL, - "内核校验出错,删除目录并重新下载: {} - {}", + "内核校验出错,保留目录并停止启动: {} - {}", kernel_dir.display(), e ); utils::emit_status( status_emitter.as_ref(), &env_uuid, - &kernel_value, + &install_dir_name, EnvironmentStatus::Error, - Some("校验出错,重新下载"), + Some("内核校验出错"), None, None, None, ); - let _ = fs::remove_dir_all(&kernel_dir); + return Err(format!( + "内核校验出错,已保留原目录 {}: {e}", + kernel_dir.display() + ) + .into()); } } } } - // 目录不存在或校验失败:下载并安装 + // 只有目录不存在时才下载;现有安装绝不在替代包准备好之前删除。 let exe_path = downloader::download_and_install_kernel( &app, &env_uuid, - &kernel_value, + &install_dir_name, &kernel_dir, &kernel_detail, status_emitter, ) .await?; + let verified_signature = kernel_detail.signature.as_deref().unwrap_or_default(); + record_ready_installation(&app, &kernel_id, &exe_path, verified_signature).await; + Ok(exe_path.to_string_lossy().to_string()) } diff --git a/src-tauri/src/services/environment/kernel/runtime_bridge.rs b/src-tauri/src/services/environment/kernel/runtime_bridge.rs index 57a12d4..60a2c70 100644 --- a/src-tauri/src/services/environment/kernel/runtime_bridge.rs +++ b/src-tauri/src/services/environment/kernel/runtime_bridge.rs @@ -199,7 +199,7 @@ pub async fn refresh_proxy(env_uuid: String, proxy: Option) -> Resu } pub async fn get_connected_environments() -> Result> { - if !crate::infrastructure::persistence::credential::is_login() { + if !crate::commands::auth::has_local_session() { return Ok(vec![]); } @@ -215,7 +215,7 @@ pub async fn get_connected_environments() -> Result> { } pub async fn get_cdp_endpoint(env_uuid: String) -> Result> { - if !crate::infrastructure::persistence::credential::is_login() { + if !crate::commands::auth::has_local_session() { return Ok(None); } @@ -297,7 +297,7 @@ pub async fn close_rpa_tab(env_uuid: String, position: u32) -> Result Result> { - if !crate::infrastructure::persistence::credential::is_login() { + if !crate::commands::auth::has_local_session() { return Ok(None); } @@ -313,7 +313,7 @@ pub async fn get_environment_status(env_uuid: String) -> Result Result> { - if !crate::infrastructure::persistence::credential::is_login() { + if !crate::commands::auth::has_local_session() { return Ok(HashMap::new()); } diff --git a/src-tauri/src/services/environment/kernel/utils.rs b/src-tauri/src/services/environment/kernel/utils.rs index 0a9d9e1..ccf8ba9 100644 --- a/src-tauri/src/services/environment/kernel/utils.rs +++ b/src-tauri/src/services/environment/kernel/utils.rs @@ -27,6 +27,17 @@ pub fn resolve_profiles_base( crate::core::paths::PathManager::get_profiles_dir(app).map_err(Into::into) } +/// Resolve a catalog-provided installation directory without allowing it to +/// escape the profiles root. +pub fn resolve_kernel_install_dir(base: &Path, install_dir_name: &str) -> Result { + let install_dir_name = install_dir_name.trim(); + let mut components = Path::new(install_dir_name).components(); + match (components.next(), components.next()) { + (Some(std::path::Component::Normal(_)), None) => Ok(base.join(install_dir_name)), + _ => Err("内核安装目录名称无效".into()), + } +} + /// 将 zip 解压到目标目录 pub fn extract_zip_to_dir(zip_path: &Path, target_dir: &Path) -> Result<()> { let file = fs::File::open(zip_path)?; @@ -136,3 +147,21 @@ pub fn emit_status( }); } } + +#[cfg(test)] +mod tests { + use super::resolve_kernel_install_dir; + use std::path::Path; + + #[test] + fn kernel_install_dir_must_be_one_normal_component() { + let base = Path::new("profiles"); + assert_eq!( + resolve_kernel_install_dir(base, "Chrome 144").unwrap(), + base.join("Chrome 144") + ); + assert!(resolve_kernel_install_dir(base, "").is_err()); + assert!(resolve_kernel_install_dir(base, "..").is_err()); + assert!(resolve_kernel_install_dir(base, "nested/kernel").is_err()); + } +} diff --git a/src-tauri/src/services/environment/kernel/verifier.rs b/src-tauri/src/services/environment/kernel/verifier.rs index 06f2472..f206a6f 100644 --- a/src-tauri/src/services/environment/kernel/verifier.rs +++ b/src-tauri/src/services/environment/kernel/verifier.rs @@ -11,17 +11,17 @@ use super::utils::{calculate_file_hash_head, core_dll_name, emit_status, extract /// 校验内核完整性 /// /// # 返回 -/// - `Ok(true)`: 校验通过 -/// - `Ok(false)`: 校验失败,需要重新下载 +/// - `Ok(Some(signature))`: 校验通过,并返回实际匹配的签名 +/// - `Ok(None)`: 校验失败 /// - `Err`: 发生错误 pub fn verify_kernel( - app: &tauri::AppHandle, + _app: &tauri::AppHandle, env_uuid: &Option, kernel_value: &str, kernel_dir: &Path, - expected_signature: &str, + expected_signatures: &[String], status_emitter: Option, -) -> Result { +) -> Result> { emit_status( status_emitter.as_ref(), env_uuid, @@ -56,7 +56,7 @@ pub fn verify_kernel( kernel_dir.display(), e ); - return Ok(false); + return Ok(None); } }; @@ -73,15 +73,22 @@ pub fn verify_kernel( "未找到核心 DLL 文件: {}", dll_path.display() ); - return Ok(false); + return Ok(None); } // 校验 signature - let expected = expected_signature.trim().to_lowercase(); + let expected = expected_signatures + .iter() + .map(|signature| signature.trim().to_lowercase()) + .filter(|signature| !signature.is_empty()) + .collect::>(); + if expected.is_empty() { + return Err("该内核版本缺少 signature,无法校验核心 DLL".into()); + } crate::log_info!( crate::core::logger::modules::KERNEL, - "开始计算 DLL 哈希,期望值: {}", - expected + "开始计算 DLL 哈希,可接受值: {}", + expected.join(", ") ); let local_hash = calculate_file_hash_head(&dll_path, SIGNATURE_HASH_SIZE)?; @@ -93,14 +100,22 @@ pub fn verify_kernel( local ); - if local != expected { + let Some(matched_index) = expected.iter().position(|signature| signature == &local) else { + crate::log_warn!( + crate::core::logger::modules::KERNEL, + "核心 DLL 哈希不一致,可接受值: {}, 实际: {}", + expected.join(", "), + local + ); + return Ok(None); + }; + + if matched_index > 0 { crate::log_warn!( crate::core::logger::modules::KERNEL, - "核心 DLL 哈希不一致,期望: {}, 实际: {}", - expected, + "使用兼容签名接受已安装内核: {}", local ); - return Ok(false); } crate::log_info!( @@ -109,7 +124,7 @@ pub fn verify_kernel( kernel_dir.display() ); - Ok(true) + Ok(Some(local)) } /// 查找版本子目录 diff --git a/src-tauri/src/services/environment/launch_runtime/detail.rs b/src-tauri/src/services/environment/launch_runtime/detail.rs index 5f72dd3..2cbc37a 100644 --- a/src-tauri/src/services/environment/launch_runtime/detail.rs +++ b/src-tauri/src/services/environment/launch_runtime/detail.rs @@ -1,18 +1,24 @@ use serde_json::json; +use tauri::Manager; -use crate::{app::context::AppContext, core::error::Result}; +use crate::core::error::Result; use super::types::EnvironmentLaunchDetail; pub(super) async fn get_environment_launch_detail( env_uuid: &str, ) -> Result { - let ctx = AppContext::get(); - let response = ctx - .main_server_client - .post("environments/detail", &json!({ "uuid": env_uuid })) - .await?; + let app = crate::app::handle::get_app_handle()?; + let context = app.state::(); + let request_context = context.for_current_user()?; + let data = business::dispatcher::dispatch_post( + &request_context, + "environments/detail", + &json!({ "uuid": env_uuid }), + ) + .await + .ok_or("本地环境详情路由不存在")? + .map_err(crate::core::error::Error::from)?; - let data = response.data.ok_or("获取环境详情失败")?; serde_json::from_value(data).map_err(Into::into) } diff --git a/src-tauri/src/services/environment/launch_runtime/kernel.rs b/src-tauri/src/services/environment/launch_runtime/kernel.rs index 89a8a4b..bb3f5dd 100644 --- a/src-tauri/src/services/environment/launch_runtime/kernel.rs +++ b/src-tauri/src/services/environment/launch_runtime/kernel.rs @@ -1,16 +1,14 @@ -use std::collections::HashMap; - -use serde_json::{Value, json}; -use tauri::AppHandle; +use serde_json::Value; +use tauri::{AppHandle, Manager}; +use uuid::Uuid; use crate::{ - app::context::AppContext, core::error::Result, domain::environment::KernelDetail, services::environment::{KernelService, KernelStatusEmitter}, }; -use super::types::{BrowserKernelVersion, EnvironmentLaunchDetail, SIMPRINT_KERNEL_CHROMIUM}; +use super::types::EnvironmentLaunchDetail; pub(super) struct ResolvedKernelLaunch { pub exe_path: String, @@ -26,45 +24,35 @@ pub(super) async fn resolve_kernel_launch( .environment .as_ref() .ok_or("Environment detail is missing environment uuid.")?; - let window_info = get_window_info(detail.config.as_ref()); - - let kernel_value = window_info - .get("kernel") - .and_then(Value::as_str) - .map(str::trim) - .filter(|value| !value.is_empty()) - .ok_or("This environment has no browser kernel configured yet.")? - .to_string(); - - let kernels_map = list_browser_kernels(host_platform()).await?; - let kernel_detail = kernels_map - .get(SIMPRINT_KERNEL_CHROMIUM) - .and_then(|list| list.iter().find(|kernel| kernel.resource_name == kernel_value)) - .cloned() - .ok_or_else(|| format!("No browser kernel matched \"{}\".", kernel_value))?; + let environment_uuid = Uuid::parse_str(&env.uuid) + .map_err(|error| format!("Invalid environment uuid {}: {error}", env.uuid))?; + let context = app.state::(); + let kernel_detail = + business::services::browser_kernels::get_environment_kernel(&context.db, environment_uuid) + .await? + .ok_or("This environment has no browser kernel binding.")?; + let kernel_id = kernel_detail.kernel_id.clone(); + let install_dir_name = kernel_detail.install_dir_name.clone(); let url = kernel_detail .url .filter(|value| !value.trim().is_empty()) .ok_or("The selected browser kernel is missing download metadata.")?; - let hash = kernel_detail - .hash - .filter(|value| !value.trim().is_empty()) - .ok_or("The selected browser kernel is missing download metadata.")?; - let signature = kernel_detail - .signature - .filter(|value| !value.trim().is_empty()) - .ok_or("The selected browser kernel is missing signature metadata.")?; + let hash = kernel_detail.hash; + let signature = kernel_detail.signature; + let compatible_signatures = kernel_detail.compatible_signatures.0; - let exe_path = KernelService::ensure_kernel_ready( + let exe_path = KernelService::ensure_kernel_ready_for_artifact( app, Some(env.uuid.clone()), - kernel_value, + kernel_id, + install_dir_name, profiles_path.to_string(), KernelDetail { url, hash, signature: Some(signature), + compatible_signatures, requires_extract: kernel_detail.requires_extract, }, status_emitter, @@ -81,44 +69,3 @@ pub(super) fn get_window_info(config: Option<&Value>) -> serde_json::Map Result>> { - let ctx = AppContext::get(); - let response = ctx - .main_server_client - .post( - "browser-kernels/list", - &json!({ - "platform": platform, - "type_code": SIMPRINT_KERNEL_CHROMIUM, - }), - ) - .await?; - - let data = response.data.ok_or("获取浏览器内核失败")?; - serde_json::from_value(data).map_err(Into::into) -} - -fn host_platform() -> &'static str { - #[cfg(target_os = "windows")] - { - "windows" - } - - #[cfg(target_os = "macos")] - { - "darwin" - } - - #[cfg(target_os = "linux")] - { - "linux" - } - - #[cfg(not(any(target_os = "windows", target_os = "macos", target_os = "linux")))] - { - "windows" - } -} diff --git a/src-tauri/src/services/environment/launch_runtime/mod.rs b/src-tauri/src/services/environment/launch_runtime/mod.rs index 056ea06..55b9920 100644 --- a/src-tauri/src/services/environment/launch_runtime/mod.rs +++ b/src-tauri/src/services/environment/launch_runtime/mod.rs @@ -82,9 +82,8 @@ impl EnvironmentLaunchRuntimeService { ) -> Result> { let app = get_app_handle()?; let requests = try_join_all(env_uuids.into_iter().map(|env_uuid| { - let display_id = display_ids_by_env_uuid - .as_ref() - .and_then(|items| items.get(&env_uuid).cloned()); + let display_id = + display_ids_by_env_uuid.as_ref().and_then(|items| items.get(&env_uuid).cloned()); Self::build_launch_request( app.clone(), env_uuid, @@ -156,7 +155,12 @@ fn resolve_environment_proxy_config( fn resolve_local_proxy_config(app: &AppHandle, env_uuid: &str) -> LocalProxyResolution { let bindings = tauri_store::get_store_key(app, ENVIRONMENT_LOCAL_PROXY_BINDINGS_STORE_KEY) - .and_then(|value| serde_json::from_value::>(value).ok()) + .and_then(|value| { + serde_json::from_value::< + std::collections::HashMap, + >(value) + .ok() + }) .unwrap_or_default(); let Some(binding) = bindings.get(env_uuid) else { diff --git a/src-tauri/src/services/environment/launch_runtime/types.rs b/src-tauri/src/services/environment/launch_runtime/types.rs index e7d7255..7c9a547 100644 --- a/src-tauri/src/services/environment/launch_runtime/types.rs +++ b/src-tauri/src/services/environment/launch_runtime/types.rs @@ -3,16 +3,6 @@ use serde_json::Value; use crate::services::environment::{AccountInfo, ExtensionInfo}; -#[derive(Debug, Clone, Deserialize)] -pub(super) struct BrowserKernelVersion { - pub resource_name: String, - pub url: Option, - pub hash: Option, - pub signature: Option, - #[serde(default)] - pub requires_extract: bool, -} - #[derive(Debug, Clone, Deserialize)] pub(super) struct EnvironmentLaunchDetail { pub environment: Option, @@ -51,8 +41,6 @@ pub(super) struct EnvironmentCookieLike { pub cookie_text: String, } -pub(super) const SIMPRINT_KERNEL_CHROMIUM: &str = "SIMPRINT_KERNEL_CHROMIUM"; - #[derive(Debug, Clone)] pub struct LaunchPaths { pub profiles_path: String, diff --git a/src-tauri/src/services/mod.rs b/src-tauri/src/services/mod.rs index 72a6cdb..e2c8d6a 100644 --- a/src-tauri/src/services/mod.rs +++ b/src-tauri/src/services/mod.rs @@ -7,7 +7,6 @@ //! - 权限检查和验证 pub mod app; -pub mod auth; pub mod connectivity; pub mod environment; pub mod file_system; diff --git a/src-tauri/src/services/window/mod.rs b/src-tauri/src/services/window/mod.rs index af4d84c..7d1a18f 100644 --- a/src-tauri/src/services/window/mod.rs +++ b/src-tauri/src/services/window/mod.rs @@ -255,7 +255,7 @@ impl WindowService { } /// 创建主窗口 - pub async fn create_main_window(app_handle: &AppHandle) -> Result<()> { + pub fn create_main_window(app_handle: &AppHandle) -> Result<()> { if app_handle.get_webview_window("main").is_some() { log::debug!("主窗口已存在,跳过创建"); return Ok(()); diff --git a/src/App.tsx b/src/App.tsx index 3cb6e86..14114f4 100644 --- a/src/App.tsx +++ b/src/App.tsx @@ -1,66 +1,76 @@ -import React, { useEffect } from 'react'; -import { AppLayout } from './app/components/AppLayout'; -import { pluginRegistry, pluginLoader, setPluginImportFunctions } from '@slotkitjs/core'; - -// Import generated plugin import mappings -// This file is generated by 'slotkit generate-imports' -import * as pluginImports from './core/plugin/loader/plugin-imports.generated'; - -// Initialize plugin import mappings -if (pluginImports.getPluginImport && pluginImports.getAvailablePluginIds) { - setPluginImportFunctions({ - getPluginImport: (pluginId: string) => { - const importFn = pluginImports.getPluginImport(pluginId); - if (!importFn) return undefined; - return () => Promise.resolve(importFn()); - }, - getAvailablePluginIds: pluginImports.getAvailablePluginIds, - getAllPluginManifests: pluginImports.getAllPluginManifests, - getPluginManifest: pluginImports.getPluginManifest, - }); -} else { - console.warn('[WARN] Plugin import functions not found in generated file'); -} - -// Load all plugins -const loadPlugins = async () => { - try { - const plugins = await pluginLoader.loadAllPlugins(); - - plugins.forEach((plugin) => { - pluginRegistry.register(plugin); - }); - - return plugins; - } catch (error) { - console.error('[ERROR] Failed to load plugins:', error); - return []; - } -}; - -interface AppProps { - /** - * 应用模式:'main' 渲染主应用布局,'splashscreen' 渲染启动屏幕,'syncer' 渲染同步器窗口 - */ - mode?: 'main' | 'splashscreen' | 'syncer'; -} - -const App: React.FC = ({ mode = 'main' }) => { - useEffect(() => { - loadPlugins() - .then(() => { - console.log('[App] Plugins loaded for mode:', mode); - }) - .catch((error) => { - console.error('[ERROR] Failed to load plugins:', error); - }); - }, [mode]); - - return ( -
- -
- ); -}; - -export default App; +import React, { useEffect, useState } from 'react'; +import { AppLayout } from './app/components/AppLayout'; +import { pluginRegistry, pluginLoader, setPluginImportFunctions } from '@slotkitjs/core'; + +// Import generated plugin import mappings +// This file is generated by 'slotkit generate-imports' +import * as pluginImports from './core/plugin/loader/plugin-imports.generated'; + +// Initialize plugin import mappings +if (pluginImports.getPluginImport && pluginImports.getAvailablePluginIds) { + setPluginImportFunctions({ + getPluginImport: (pluginId: string) => { + const importFn = pluginImports.getPluginImport(pluginId); + if (!importFn) return undefined; + return () => Promise.resolve(importFn()); + }, + getAvailablePluginIds: pluginImports.getAvailablePluginIds, + getAllPluginManifests: pluginImports.getAllPluginManifests, + getPluginManifest: pluginImports.getPluginManifest, + }); +} else { + console.warn('[WARN] Plugin import functions not found in generated file'); +} + +// Load all plugins +const loadPlugins = async () => { + try { + const plugins = await pluginLoader.loadAllPlugins(); + + plugins.forEach((plugin) => { + pluginRegistry.register(plugin); + }); + + return plugins; + } catch (error) { + console.error('[ERROR] Failed to load plugins:', error); + return []; + } +}; + +interface AppProps { + /** + * 应用模式:'main' 渲染主应用布局,'splashscreen' 渲染启动屏幕,'syncer' 渲染同步器窗口 + */ + mode?: 'main' | 'splashscreen' | 'syncer'; +} + +const App: React.FC = ({ mode = 'main' }) => { + const [pluginsReady, setPluginsReady] = useState(false); + + useEffect(() => { + let cancelled = false; + + loadPlugins() + .then(() => { + console.log('[App] Plugins loaded for mode:', mode); + if (!cancelled) setPluginsReady(true); + }) + .catch((error) => { + console.error('[ERROR] Failed to load plugins:', error); + if (!cancelled) setPluginsReady(true); + }); + + return () => { + cancelled = true; + }; + }, [mode]); + + return ( +
+ +
+ ); +}; + +export default App; diff --git a/src/app/components/AppLayout/index.tsx b/src/app/components/AppLayout/index.tsx index d6cbffd..bf6e55b 100644 --- a/src/app/components/AppLayout/index.tsx +++ b/src/app/components/AppLayout/index.tsx @@ -1,7 +1,7 @@ // 确保扩展点在所有插件导入之前注册 import '../../extension-points'; -import React, { useEffect, useState } from 'react'; +import React, { useCallback, useEffect, useState } from 'react'; import { BrowserRouter } from 'react-router'; import { pluginRegistry } from '@slotkitjs/core'; import { useAuthStore } from '../../../../plugins/services/store/src'; @@ -23,25 +23,41 @@ interface AppLayoutProps { * 布局模式:'main' 渲染主应用布局,'splashscreen' 渲染启动屏幕,'syncer' 渲染同步器窗口 */ mode?: 'main' | 'splashscreen' | 'syncer'; + pluginsReady?: boolean; } -export const AppLayout: React.FC = ({ mode = 'main' }) => { +export const AppLayout: React.FC = ({ mode = 'main', pluginsReady = false }) => { const [windowManagerComponent, setWindowManagerComponent] = useState( null ); const { initAuth, isAuthenticated } = useAuthStore(); + const [authReady, setAuthReady] = useState(mode !== 'main'); + const [routesReady, setRoutesReady] = useState(false); useDisableDevTools(); const sessionLock = useSessionLock(mode === 'main'); // 初始化认证状态(仅在 main 模式下) useEffect(() => { if (mode === 'main') { - initAuth().catch((error) => { - console.error('[AppLayout] 初始化认证状态失败:', error); - }); + let cancelled = false; + initAuth() + .catch((error) => { + console.error('[AppLayout] 初始化认证状态失败:', error); + }) + .finally(() => { + if (!cancelled) setAuthReady(true); + }); + + return () => { + cancelled = true; + }; } }, [mode, initAuth]); + const handleRoutesReady = useCallback(() => { + setRoutesReady(true); + }, []); + // 监听插件注册,确保 window-manager 插件加载后被渲染 useEffect(() => { const checkWindowManager = () => { @@ -86,18 +102,20 @@ export const AppLayout: React.FC = ({ mode = 'main' }) => { - - {windowManagerComponent && React.createElement(windowManagerComponent)} - - - - - + + {pluginsReady && authReady && routesReady && windowManagerComponent + ? React.createElement(windowManagerComponent) + : null} + + + + + diff --git a/src/app/components/AppRoutes/index.tsx b/src/app/components/AppRoutes/index.tsx index bbc8ad2..ecfaae6 100644 --- a/src/app/components/AppRoutes/index.tsx +++ b/src/app/components/AppRoutes/index.tsx @@ -1,56 +1,74 @@ -import { useEffect, useState } from 'react'; -import { useRoutes } from 'react-router'; -import { extensionRegistry, pluginRegistry } from '@slotkitjs/core'; -import { useRouteConfig } from '../../hooks/useRouteConfig'; -import type { RouteConfig } from '../../types'; - -/** - * 应用路由组件 - * 负责动态加载和管理路由 - */ -export const AppRoutes: React.FC = () => { - const [routes, setRoutes] = useState([]); - - useEffect(() => { - const checkLayout = () => { - const layoutPlugin = pluginRegistry.getPlugin('app-layout'); - // 检查布局插件是否已加载(用于调试) - if (layoutPlugin?.component) { - console.log('[AppRoutes] Layout plugin loaded'); - } - }; - - const updateRoutes = () => { - const routeContributions = extensionRegistry.getContributions('routes'); - const newRoutes = routeContributions.map((c) => c.value); - console.log( - '[AppRoutes] Routes updated:', - newRoutes.map((r) => r.path) - ); - setRoutes(newRoutes); - }; - - checkLayout(); - updateRoutes(); - - // 订阅插件注册事件 - const unsubscribe = pluginRegistry.subscribe((event) => { - if (event.type === 'register') { - if (event.plugin?.id === 'app-layout') { - console.log('[AppRoutes] Layout plugin registered'); - } - // 当插件注册时,更新路由(插件可能在模块加载时已贡献路由) - updateRoutes(); - checkLayout(); - } - }); - - return () => { - unsubscribe(); - }; - }, []); - - const routeElements = useRouteConfig(routes); - - return useRoutes(routeElements); -}; +import { useEffect, useState } from 'react'; +import { useRoutes } from 'react-router'; +import { extensionRegistry, pluginRegistry } from '@slotkitjs/core'; +import { useRouteConfig } from '../../hooks/useRouteConfig'; +import type { RouteConfig } from '../../types'; + +interface AppRoutesProps { + onReady?: () => void; +} + +/** + * 应用路由组件 + * 负责动态加载和管理路由 + */ +export const AppRoutes: React.FC = ({ onReady }) => { + const [routes, setRoutes] = useState([]); + + useEffect(() => { + const checkLayout = () => { + const layoutPlugin = pluginRegistry.getPlugin('app-layout'); + // 检查布局插件是否已加载(用于调试) + if (layoutPlugin?.component) { + console.log('[AppRoutes] Layout plugin loaded'); + } + }; + + const updateRoutes = () => { + const routeContributions = extensionRegistry.getContributions('routes'); + const newRoutes = routeContributions.map((c) => c.value); + console.log( + '[AppRoutes] Routes updated:', + newRoutes.map((r) => r.path) + ); + setRoutes(newRoutes); + }; + + checkLayout(); + updateRoutes(); + + // 订阅插件注册事件 + const unsubscribe = pluginRegistry.subscribe((event) => { + if (event.type === 'register') { + if (event.plugin?.id === 'app-layout') { + console.log('[AppRoutes] Layout plugin registered'); + } + // 当插件注册时,更新路由(插件可能在模块加载时已贡献路由) + updateRoutes(); + checkLayout(); + } + }); + + return () => { + unsubscribe(); + }; + }, []); + + const routeElements = useRouteConfig(routes); + + useEffect(() => { + if (routes.length === 0 || !onReady) return; + + let secondFrame = 0; + const firstFrame = requestAnimationFrame(() => { + secondFrame = requestAnimationFrame(onReady); + }); + + return () => { + cancelAnimationFrame(firstFrame); + if (secondFrame) cancelAnimationFrame(secondFrame); + }; + }, [routes, routeElements, onReady]); + + return useRoutes(routeElements); +}; diff --git a/src/app/components/session-lock-overlay.tsx b/src/app/components/session-lock-overlay.tsx index e0f4ea2..b27cb14 100644 --- a/src/app/components/session-lock-overlay.tsx +++ b/src/app/components/session-lock-overlay.tsx @@ -35,7 +35,8 @@ export const SessionLockOverlay: React.FC = ({ return null; } - const displayName = user?.nickname || user?.email || t('sessionLock.defaultUser'); + const displayName = user?.nickname || t('sessionLock.defaultUser'); + const hasPassword = user?.has_password ?? false; const handleUnlock = async () => { await onUnlock(password); @@ -59,7 +60,13 @@ export const SessionLockOverlay: React.FC = ({
- ); -}; diff --git a/plugins/pages/splashscreen/src/components/splashscreen-page.tsx b/plugins/pages/splashscreen/src/components/splashscreen-page.tsx deleted file mode 100644 index 0230768..0000000 --- a/plugins/pages/splashscreen/src/components/splashscreen-page.tsx +++ /dev/null @@ -1,43 +0,0 @@ -import { useSplashscreenInit } from '../hooks/useSplashscreenInit'; -import { useSplashscreenEvents } from '../hooks/useSplashscreenEvents'; -import { useSplashscreenWindowDisplay } from '../hooks/use-splashscreen-window-display'; -import { useSplashscreenClose } from '../hooks/use-splashscreen-close'; -import { - SplashscreenBackground, - SplashscreenLogo, - SplashscreenContent, - SplashscreenLoadingText, - SplashscreenErrorMessage, - SplashscreenDecoration, -} from '.'; -import { SplashscreenCloseButton } from './splashscreen-close-button'; - -/** Splashscreen 页面,只依赖启动窗口所需的最小组件集合。 */ -export const SplashscreenPage: React.FC = () => { - useSplashscreenInit(); - - const { loadingText, errorMessage, connectionFailed, showCloseButton, progress, isUpdating } = - useSplashscreenEvents(); - const { contentRef } = useSplashscreenWindowDisplay(); - const { handleClose } = useSplashscreenClose(); - - return ( -
- - {showCloseButton && } - -
- - - {errorMessage && !connectionFailed && } -
- - - -
- ); -}; diff --git a/plugins/pages/splashscreen/src/hooks/index.ts b/plugins/pages/splashscreen/src/hooks/index.ts deleted file mode 100644 index d147063..0000000 --- a/plugins/pages/splashscreen/src/hooks/index.ts +++ /dev/null @@ -1,7 +0,0 @@ -/** - * Splashscreen Hooks 统一导出 - */ -export { useSplashscreenWindow } from './useSplashscreenWindow'; -export { useSplashscreenEvents } from './useSplashscreenEvents'; -export { useSplashscreenInit } from './useSplashscreenInit'; -export { useParticleAnimation } from './useParticleAnimation'; diff --git a/plugins/pages/splashscreen/src/hooks/use-splashscreen-close.ts b/plugins/pages/splashscreen/src/hooks/use-splashscreen-close.ts deleted file mode 100644 index bd784c6..0000000 --- a/plugins/pages/splashscreen/src/hooks/use-splashscreen-close.ts +++ /dev/null @@ -1,19 +0,0 @@ -import { useCallback } from 'react'; -import { invoke } from '@/lib/tauri'; - -/** - * Splashscreen 关闭处理 Hook - */ -export function useSplashscreenClose() { - const handleClose = useCallback(async () => { - try { - await invoke('close_program'); // 直接请求后端退出应用 - } catch (error) { - console.error('[SplashscreenClose] 退出应用失败:', error); - } - }, []); - - return { - handleClose, - }; -} diff --git a/plugins/pages/splashscreen/src/hooks/use-splashscreen-window-display.ts b/plugins/pages/splashscreen/src/hooks/use-splashscreen-window-display.ts deleted file mode 100644 index 3a65440..0000000 --- a/plugins/pages/splashscreen/src/hooks/use-splashscreen-window-display.ts +++ /dev/null @@ -1,71 +0,0 @@ -import { useEffect, useRef } from 'react'; -import { getCurrentWindow } from '@tauri-apps/api/window'; -import { invoke } from '@/lib/tauri'; - -interface UseSplashscreenWindowDisplayReturn { - contentRef: React.RefObject; -} - -/** - * Splashscreen 窗口显示控制 Hook - * 负责在内容准备好后显示窗口并通知后端 - */ -export function useSplashscreenWindowDisplay(): UseSplashscreenWindowDisplayReturn { - const contentRef = useRef(null); - const hasShownWindow = useRef(false); - - useEffect(() => { - let animationFrameId: number; - - const checkContentReady = () => { - // 检查 DOM 元素是否已存在且可见(有高度) - const isContentReady = - contentRef.current && - contentRef.current.offsetHeight > 0 && - contentRef.current.offsetWidth > 0; - - if (isContentReady && !hasShownWindow.current) { - // 内容已渲染,使用双重 RAF 确保浏览器完成首帧布局后再显示窗口。 - hasShownWindow.current = true; - - requestAnimationFrame(() => { - requestAnimationFrame(async () => { - try { - const splashWindow = getCurrentWindow(); - - // 检查窗口标签是否匹配 - if (splashWindow.label.includes('splashscreen')) { - await splashWindow.show(); - await splashWindow.setFocus(); - // 窗口显示后,通知后端开始加载 - try { - await invoke('splashscreen_ready'); - } catch (error) { - console.error('[SplashscreenWindowDisplay] 通知后端开始加载失败:', error); - } - } - } catch (error) { - console.error('[SplashscreenWindowDisplay] 显示窗口失败:', error); - } - }); - }); - } else if (!isContentReady) { - // 内容还未准备好,继续检查(使用 RAF 避免阻塞) - animationFrameId = requestAnimationFrame(checkContentReady); - } - }; - - // 立即开始检查,使用 RAF 循环直到内容准备好 - animationFrameId = requestAnimationFrame(checkContentReady); - - return () => { - if (animationFrameId) { - cancelAnimationFrame(animationFrameId); - } - }; - }, []); - - return { - contentRef, - }; -} diff --git a/plugins/pages/splashscreen/src/hooks/useGridAnimation.ts b/plugins/pages/splashscreen/src/hooks/useGridAnimation.ts deleted file mode 100644 index cd60014..0000000 --- a/plugins/pages/splashscreen/src/hooks/useGridAnimation.ts +++ /dev/null @@ -1,105 +0,0 @@ -import { useEffect, useRef } from 'react'; - -interface GridConfig { - cellSize?: number; - lineWidth?: number; - color?: string; - rotation?: number; - speed?: number; -} - -/** - * 高密度网格动画 Hook - * 负责管理 Canvas 网格动画效果 - */ -export function useGridAnimation(config: GridConfig = {}) { - const canvasRef = useRef(null); - - useEffect(() => { - const canvas = canvasRef.current; - if (!canvas) return; - - const ctx = canvas.getContext('2d'); - if (!ctx) return; - - const { - cellSize = 30, - lineWidth = 0.5, - color = 'rgba(37, 99, 235, 0.06)', // 使用与登录界面相同的蓝色 (blue-600) - rotation = 90, - speed = 0.3, - } = config; - - // 设置画布尺寸 - const resizeCanvas = () => { - canvas.width = window.innerWidth; - canvas.height = window.innerHeight; - }; - resizeCanvas(); - window.addEventListener('resize', resizeCanvas); - - let offset = 0; - let animationId: number; - - function drawGrid() { - if (!ctx || !canvas) return; - - // 清空画布 - ctx.clearRect(0, 0, canvas.width, canvas.height); - - // 保存当前状态 - ctx.save(); - - // 移动到画布中心 - ctx.translate(canvas.width / 2, canvas.height / 2); - - // 旋转网格(90度效果) - ctx.rotate((rotation * Math.PI) / 180); - - // 设置样式 - ctx.strokeStyle = color; - ctx.lineWidth = lineWidth; - - // 计算需要绘制的网格范围(覆盖整个屏幕) - const diagonal = Math.sqrt(canvas.width ** 2 + canvas.height ** 2); - const halfSize = diagonal / 2; - - // 绘制垂直线(旋转后会变成水平) - const verticalOffset = offset % cellSize; - for (let x = -halfSize; x <= halfSize; x += cellSize) { - ctx.beginPath(); - ctx.moveTo(x + verticalOffset, -halfSize); - ctx.lineTo(x + verticalOffset, halfSize); - ctx.stroke(); - } - - // 绘制水平线(旋转后会变成垂直) - const horizontalOffset = offset % cellSize; - for (let y = -halfSize; y <= halfSize; y += cellSize) { - ctx.beginPath(); - ctx.moveTo(-halfSize, y + horizontalOffset); - ctx.lineTo(halfSize, y + horizontalOffset); - ctx.stroke(); - } - - // 恢复状态 - ctx.restore(); - - // 更新偏移量 - offset += speed; - } - - function animate() { - drawGrid(); - animationId = requestAnimationFrame(animate); - } - animate(); - - return () => { - window.removeEventListener('resize', resizeCanvas); - cancelAnimationFrame(animationId); - }; - }, [config]); - - return canvasRef; -} diff --git a/plugins/pages/splashscreen/src/hooks/useParticleAnimation.ts b/plugins/pages/splashscreen/src/hooks/useParticleAnimation.ts deleted file mode 100644 index 0d1fb60..0000000 --- a/plugins/pages/splashscreen/src/hooks/useParticleAnimation.ts +++ /dev/null @@ -1,114 +0,0 @@ -import { useEffect, useRef } from 'react'; -import type { ParticleConfig } from '../types'; - -/** - * 粒子类 - */ -class Particle { - x: number; - y: number; - size: number; - speedX: number; - speedY: number; - opacity: number; - canvas: HTMLCanvasElement; - ctx: CanvasRenderingContext2D; - maxSize: number; - minSize: number; - speed: number; - color: string; - - constructor( - canvas: HTMLCanvasElement, - ctx: CanvasRenderingContext2D, - maxSize: number, - minSize: number, - speed: number, - color: string - ) { - this.canvas = canvas; - this.ctx = ctx; - this.maxSize = maxSize; - this.minSize = minSize; - this.speed = speed; - this.color = color; - this.x = Math.random() * canvas.width; - this.y = Math.random() * canvas.height; - this.size = Math.random() * (maxSize - minSize) + minSize; - this.speedX = Math.random() * speed * 2 - speed; - this.speedY = Math.random() * speed * 2 - speed; - this.opacity = Math.random() * 0.5 + 0.3; - } - - update() { - this.x += this.speedX; - this.y += this.speedY; - if (this.x > this.canvas.width) this.x = 0; - if (this.x < 0) this.x = this.canvas.width; - if (this.y > this.canvas.height) this.y = 0; - if (this.y < 0) this.y = this.canvas.height; - } - - draw() { - this.ctx.fillStyle = `${this.color}${this.opacity})`; - this.ctx.beginPath(); - this.ctx.arc(this.x, this.y, this.size, 0, Math.PI * 2); - this.ctx.fill(); - } -} - -/** - * 粒子动画 Hook - * 负责管理 Canvas 粒子动画效果 - */ -export function useParticleAnimation(config: ParticleConfig = {}) { - const canvasRef = useRef(null); - - useEffect(() => { - const canvas = canvasRef.current; - if (!canvas) return; - - const ctx = canvas.getContext('2d'); - if (!ctx) return; - - const { - count = 100, - color = 'rgba(59, 130, 246, ', - minSize = 0.5, - maxSize = 2.5, - speed = 0.25, - } = config; - - // 设置画布尺寸 - const resizeCanvas = () => { - canvas.width = window.innerWidth; - canvas.height = window.innerHeight; - }; - resizeCanvas(); - window.addEventListener('resize', resizeCanvas); - - const particles: Particle[] = []; - for (let i = 0; i < count; i++) { - particles.push(new Particle(canvas, ctx, maxSize, minSize, speed, color)); - } - - let animationId: number; - function animate() { - if (!ctx || !canvas) return; - ctx.clearRect(0, 0, canvas.width, canvas.height); - particles.forEach((particle) => { - particle.update(); - particle.draw(); - }); - animationId = requestAnimationFrame(animate); - } - animate(); - - return () => { - window.removeEventListener('resize', resizeCanvas); - cancelAnimationFrame(animationId); - }; - }, [config]); - - return canvasRef; -} diff --git a/plugins/pages/splashscreen/src/hooks/useSplashscreenEvents.ts b/plugins/pages/splashscreen/src/hooks/useSplashscreenEvents.ts deleted file mode 100644 index a5b2091..0000000 --- a/plugins/pages/splashscreen/src/hooks/useSplashscreenEvents.ts +++ /dev/null @@ -1,167 +0,0 @@ -import { useEffect, useState } from 'react'; -import { listen } from '@tauri-apps/api/event'; -import type { SplashscreenState } from '../types'; - -/** - * Tauri 事件负载类型 - */ -interface TauriEventPayload { - payload?: { - text?: string; - message?: string; - percentage?: number; - error?: string; - error_message?: string; - }; -} - -/** - * Splashscreen 事件监听 Hook - * 负责监听后端事件并更新状态 - */ -export function useSplashscreenEvents() { - const [state, setState] = useState({ - loadingText: '初始化中...', - errorMessage: null, - connectionFailed: false, - showCloseButton: false, - progress: undefined, - isUpdating: false, - }); - - useEffect(() => { - let unsubscribeProgress: (() => void) | null = null; - let unsubscribeReady: (() => void) | null = null; - let unsubscribeError: (() => void) | null = null; - let unsubscribeConnectionFailed: (() => void) | null = null; - let unsubscribeUpdateDownloading: (() => void) | null = null; - let unsubscribeUpdateProgress: (() => void) | null = null; - let unsubscribeUpdateComplete: (() => void) | null = null; - let unsubscribeUpdateFailed: (() => void) | null = null; - let unsubscribeUpdatePartial: (() => void) | null = null; - - const setupListeners = async () => { - try { - unsubscribeProgress = await listen('splashscreen-progress', (event: TauriEventPayload) => { - const { text } = event.payload || {}; - if (text) { - setState((prev) => ({ ...prev, loadingText: text, errorMessage: null })); - } - }); - - unsubscribeReady = await listen('splashscreen-ready', async () => { - setState((prev) => ({ - ...prev, - loadingText: '加载完成', - progress: 100, - isUpdating: false, - })); - }); - - unsubscribeError = await listen('splashscreen-error', (event: TauriEventPayload) => { - const { message } = event.payload || {}; - setState((prev) => ({ - ...prev, - errorMessage: message || '加载过程中发生错误', - })); - }); - - unsubscribeConnectionFailed = await listen('splashscreen-connection-failed', () => { - setState((prev) => ({ - ...prev, - connectionFailed: true, - showCloseButton: true, - loadingText: '服务器连接失败,请尝试重新启动或重新下载', - })); - }); - - // 更新:下载开始 - unsubscribeUpdateDownloading = await listen('update_downloading', () => { - setState((prev) => ({ - ...prev, - isUpdating: true, - loadingText: '正在下载更新...', - errorMessage: null, - })); - }); - - // 更新:下载进度(已聚合的百分比) - unsubscribeUpdateProgress = await listen( - 'update_download_progress', - (event: TauriEventPayload) => { - const { percentage } = event.payload || {}; - const progressNumber = - typeof percentage === 'number' ? Math.max(0, Math.min(100, percentage)) : undefined; - setState((prev) => ({ - ...prev, - isUpdating: true, - progress: progressNumber ?? prev.progress, - loadingText: '正在下载更新...', - errorMessage: null, - })); - } - ); - - // 更新:下载完成 - unsubscribeUpdateComplete = await listen('update_download_complete', () => { - setState((prev) => ({ - ...prev, - isUpdating: false, - progress: 100, - loadingText: '更新下载完成,准备安装...', - })); - }); - - // 更新:下载失败或部分失败 - unsubscribeUpdateFailed = await listen( - 'update_download_failed', - (event: TauriEventPayload) => { - // eslint-disable-next-line @typescript-eslint/no-explicit-any - const payloadData = (event.payload as any) || {}; - const message = - payloadData?.error_message || payloadData?.error || '下载更新失败,继续当前版本'; - setState((prev) => ({ - ...prev, - isUpdating: false, - loadingText: '下载更新失败,继续当前版本', - errorMessage: message, - })); - } - ); - unsubscribeUpdatePartial = await listen( - 'update_download_partial', - (event: TauriEventPayload) => { - // eslint-disable-next-line @typescript-eslint/no-explicit-any - const payloadData = (event.payload as any) || {}; - const message = - payloadData?.error_message || '部分更新下载失败,已保留成功部分,继续当前版本'; - setState((prev) => ({ - ...prev, - isUpdating: false, - loadingText: '部分更新下载失败,继续当前版本', - errorMessage: message, - })); - } - ); - } catch (error) { - console.error('[SplashscreenEvents] Failed to register event listeners:', error); - } - }; - - setupListeners(); - - return () => { - unsubscribeProgress?.(); - unsubscribeReady?.(); - unsubscribeError?.(); - unsubscribeConnectionFailed?.(); - unsubscribeUpdateDownloading?.(); - unsubscribeUpdateProgress?.(); - unsubscribeUpdateComplete?.(); - unsubscribeUpdateFailed?.(); - unsubscribeUpdatePartial?.(); - }; - }, []); - - return state; -} diff --git a/plugins/pages/splashscreen/src/hooks/useSplashscreenInit.ts b/plugins/pages/splashscreen/src/hooks/useSplashscreenInit.ts deleted file mode 100644 index 206cc8b..0000000 --- a/plugins/pages/splashscreen/src/hooks/useSplashscreenInit.ts +++ /dev/null @@ -1,24 +0,0 @@ -import { useEffect } from 'react'; -import { invoke } from '@/lib/tauri'; - -/** - * Splashscreen 初始化 Hook - * 负责检查应用状态 - */ -export function useSplashscreenInit() { - useEffect(() => { - const init = async () => { - try { - const appState = (await invoke('get_app_state')) as { is_initialized?: boolean }; - - if (appState?.is_initialized) { - await invoke('show_main_window'); - } - } catch (error) { - console.error('[SplashscreenInit] Initialization failed:', error); - } - }; - - init(); - }, []); -} diff --git a/plugins/pages/splashscreen/src/hooks/useSplashscreenWindow.ts b/plugins/pages/splashscreen/src/hooks/useSplashscreenWindow.ts deleted file mode 100644 index 8c7a429..0000000 --- a/plugins/pages/splashscreen/src/hooks/useSplashscreenWindow.ts +++ /dev/null @@ -1,30 +0,0 @@ -import { useCallback } from 'react'; -import { getCurrentWindow } from '@tauri-apps/api/window'; - -/** - * 窗口显示控制 Hook - * 负责在内容准备好后显示 splashscreen 窗口 - */ -export function useSplashscreenWindow() { - const showWindowWhenReady = useCallback(async () => { - try { - const splashWindow = getCurrentWindow(); - requestAnimationFrame(() => { - requestAnimationFrame(async () => { - setTimeout(async () => { - try { - await splashWindow.show(); - await splashWindow.setFocus(); - } catch (error) { - console.error('显示窗口失败:', error); - } - }, 100); - }); - }); - } catch (error) { - console.error('显示窗口失败:', error); - } - }, []); - - return { showWindowWhenReady }; -} diff --git a/plugins/pages/splashscreen/src/index.tsx b/plugins/pages/splashscreen/src/index.tsx deleted file mode 100644 index bdc5d92..0000000 --- a/plugins/pages/splashscreen/src/index.tsx +++ /dev/null @@ -1,13 +0,0 @@ -import '../../../../src/index.css'; -import './styles/animations.css'; -import { SplashscreenPage } from './components/splashscreen-page'; - -const splashscreenPlugin = { - id: 'splashscreen', - name: 'Splashscreen', - version: '1.0.0', - component: SplashscreenPage, - slots: [], -}; - -export default splashscreenPlugin; diff --git a/plugins/pages/splashscreen/src/styles/animations.css b/plugins/pages/splashscreen/src/styles/animations.css deleted file mode 100644 index 9431929..0000000 --- a/plugins/pages/splashscreen/src/styles/animations.css +++ /dev/null @@ -1,72 +0,0 @@ -/** - * Splashscreen 动画样式定义 - */ - -@keyframes spin { - from { - transform: rotate(0deg); - } - to { - transform: rotate(360deg); - } -} - -@keyframes float-1 { - 0%, - 100% { - transform: translateY(0px) translateX(0px); - } - 50% { - transform: translateY(-10px) translateX(5px); - } -} - -@keyframes float-2 { - 0%, - 100% { - transform: translateY(0px) translateX(0px); - } - 50% { - transform: translateY(-8px) translateX(-5px); - } -} - -@keyframes float-3 { - 0%, - 100% { - transform: translateY(0px) translateX(0px); - } - 50% { - transform: translateY(-12px) translateX(8px); - } -} - -@keyframes float-4 { - 0%, - 100% { - transform: translateY(0px) translateX(0px); - } - 50% { - transform: translateY(-10px) translateX(-8px); - } -} - -@keyframes fade-in-up { - from { - opacity: 0; - transform: translateY(20px); - } - to { - opacity: 1; - transform: translateY(0); - } -} - -@keyframes fade-in { - from { - opacity: 0; - } - to { - opacity: 1; - } -} diff --git a/plugins/pages/splashscreen/src/types/index.ts b/plugins/pages/splashscreen/src/types/index.ts deleted file mode 100644 index c21ae09..0000000 --- a/plugins/pages/splashscreen/src/types/index.ts +++ /dev/null @@ -1,26 +0,0 @@ -/** - * Splashscreen 插件类型定义 - */ - -export interface SplashscreenState { - loadingText: string; - progress?: number; // 0-100 的进度百分比 - errorMessage: string | null; - connectionFailed: boolean; - showCloseButton: boolean; - isUpdating?: boolean; // 是否正在执行更新 -} - -export interface SplashscreenEvents { - onProgress?: (text: string) => void; - onReady?: () => void; - onError?: (message: string) => void; -} - -export interface ParticleConfig { - count?: number; - color?: string; - minSize?: number; - maxSize?: number; - speed?: number; -} diff --git a/plugins/pages/splashscreen/tsconfig.json b/plugins/pages/splashscreen/tsconfig.json deleted file mode 100644 index f5b6723..0000000 --- a/plugins/pages/splashscreen/tsconfig.json +++ /dev/null @@ -1,7 +0,0 @@ -{ - "extends": "../../../tsconfig.json", - "compilerOptions": { - "jsx": "react-jsx" - }, - "include": ["src/**/*"] -} diff --git a/plugins/services/window-manager/src/window-manager-service.tsx b/plugins/services/window-manager/src/window-manager-service.tsx index 8a72363..0f2b7f1 100644 --- a/plugins/services/window-manager/src/window-manager-service.tsx +++ b/plugins/services/window-manager/src/window-manager-service.tsx @@ -1,6 +1,7 @@ import { useEffect, useRef } from 'react'; import { getCurrentWindow } from '@tauri-apps/api/window'; import { invoke } from '@/lib/tauri'; +import { elapsedSinceMainHtml, mainStartupTiming } from '@/main-startup-timing'; /** 在关键应用状态和首帧布局全部就绪后,向后端报告隐藏主窗口已就绪。 */ export const WindowManagerService: React.FC = () => { @@ -9,6 +10,7 @@ export const WindowManagerService: React.FC = () => { useEffect(() => { let animationFrameId: number; let checkCount = 0; + mainStartupTiming.windowManagerEffectStartedAt = performance.now(); const checkAndReportReady = async () => { checkCount++; @@ -31,13 +33,41 @@ export const WindowManagerService: React.FC = () => { if (isContentReady) { hasReportedReady.current = true; + mainStartupTiming.firstContentLayoutAt = performance.now(); try { await document.fonts.ready; + mainStartupTiming.fontsReadyAt = performance.now(); requestAnimationFrame(() => { requestAnimationFrame(async () => { try { + mainStartupTiming.readyInvokeAt = performance.now(); + const frontendTiming = { + navigationStartEpochMs: performance.timeOrigin, + navigationToHtmlMs: mainStartupTiming.htmlStartedAt, + htmlToModuleMs: elapsedSinceMainHtml(mainStartupTiming.moduleExecutedAt), + htmlToReactRenderMs: elapsedSinceMainHtml( + mainStartupTiming.reactRenderScheduledAt + ), + htmlToWindowManagerEffectMs: elapsedSinceMainHtml( + mainStartupTiming.windowManagerEffectStartedAt + ), + htmlToFirstContentLayoutMs: elapsedSinceMainHtml( + mainStartupTiming.firstContentLayoutAt + ), + htmlToFontsReadyMs: elapsedSinceMainHtml(mainStartupTiming.fontsReadyAt), + htmlToReadyInvokeMs: elapsedSinceMainHtml(mainStartupTiming.readyInvokeAt), + }; + + void invoke('log_info', { + module: 'simprint::frontend::main', + message: `Main window frontend timing: ${JSON.stringify(frontendTiming)}`, + }).catch((error) => { + console.warn('[WindowManagerService] failed to report startup timing:', error); + }); + await invoke('main_window_ready'); + window.dispatchEvent(new Event('simprint:main-window-ready')); console.log( '[WindowManagerService] 主窗口真正就绪,已报告后端(检查次数:', checkCount, diff --git a/splashscreen.html b/splashscreen.html deleted file mode 100644 index 42420ec..0000000 --- a/splashscreen.html +++ /dev/null @@ -1,15 +0,0 @@ - - - - - - - Simprint - 加载中 - - - -
- - - - \ No newline at end of file diff --git a/src-tauri/Cargo.toml b/src-tauri/Cargo.toml index ba73976..c0f9ea4 100644 --- a/src-tauri/Cargo.toml +++ b/src-tauri/Cargo.toml @@ -118,5 +118,5 @@ all = "warn" [features] development = [] -production = [] +production = ["tauri/custom-protocol"] test = [] diff --git a/src-tauri/capabilities/splashscreen-capability.json b/src-tauri/capabilities/splashscreen-capability.json deleted file mode 100644 index 807ee5e..0000000 --- a/src-tauri/capabilities/splashscreen-capability.json +++ /dev/null @@ -1,20 +0,0 @@ -{ - "$schema": "../gen/schemas/desktop-schema.json", - "identifier": "splashscreen-capability", - "description": "Capability for the splashscreen window", - "windows": ["splashscreen"], - "permissions": [ - "core:default", - "process:default", - "core:event:default", - "core:window:default", - "dialog:allow-ask", - "dialog:default", - "dialog:allow-message", - "opener:allow-open-url", - "core:window:allow-show", - "core:window:allow-set-focus", - "core:window:allow-destroy", - "core:window:allow-close" - ] -} diff --git a/src-tauri/src/app/components/tray.rs b/src-tauri/src/app/components/tray.rs index 6ad4e36..60b7e20 100644 --- a/src-tauri/src/app/components/tray.rs +++ b/src-tauri/src/app/components/tray.rs @@ -93,11 +93,6 @@ pub fn tray_handler(tray: &TrayIcon, event: TrayIconEvent) { let _ = window.set_focus(); } } else { - // 应用未准备好,可以选择显示splashscreen或忽略 - if let Some(splash_window) = app_handle.get_webview_window("splashscreen") { - let _ = splash_window.show(); - let _ = splash_window.set_focus(); - } println!("应用正在初始化中,请稍候..."); } }); diff --git a/src-tauri/src/app/mod.rs b/src-tauri/src/app/mod.rs index d9a2491..7be5fbe 100644 --- a/src-tauri/src/app/mod.rs +++ b/src-tauri/src/app/mod.rs @@ -8,8 +8,8 @@ pub mod runtime; pub mod runtime_info; pub mod session_lock; pub mod setup; -pub mod splashscreen; pub mod startup; +pub mod startup_update; use crate::commands; use components::tray; @@ -86,7 +86,8 @@ pub fn run() { setup::register_deep_link(app.handle().clone())?; - crate::commands::window::create_splashscreen_window(app.handle().clone())?; + crate::commands::window::create_main_window(app.handle().clone())?; + log::info!("Hidden main window created for single-window startup"); tray::menu(app)?; @@ -101,8 +102,9 @@ pub fn run() { session_lock_manager.clone(), ); - // 初始化应用启动流程(显示 splashscreen) - splashscreen::init_startup(app.handle().clone()); + if startup::StartupService::backend_startup_ready(app.handle()).is_err() { + return Err(anyhow::anyhow!("Failed to complete the backend startup gate").into()); + } Ok(()) }) diff --git a/src-tauri/src/app/setup.rs b/src-tauri/src/app/setup.rs index 93db19a..bdf9cf7 100644 --- a/src-tauri/src/app/setup.rs +++ b/src-tauri/src/app/setup.rs @@ -28,10 +28,6 @@ pub fn register_plugins(app_handle: &AppHandle) { let _ = main_window.show(); let _ = main_window.set_focus(); - } else if let Some(splash_window) = app.get_webview_window("splashscreen") { - // 主窗口仍在并行加载时,不能由第二个实例提前显示。 - let _ = splash_window.show(); - let _ = splash_window.set_focus(); } })) .unwrap(); diff --git a/src-tauri/src/app/splashscreen.rs b/src-tauri/src/app/splashscreen.rs deleted file mode 100644 index a28648c..0000000 --- a/src-tauri/src/app/splashscreen.rs +++ /dev/null @@ -1,242 +0,0 @@ -use crate::commands::updater; -use std::ffi::OsStr; -use std::sync::Arc; -use std::sync::atomic::{AtomicBool, Ordering}; -use tauri::{AppHandle, Emitter, Manager}; - -const SKIP_UPDATE_ARG: &str = "--skip-update"; - -fn has_skip_update_arg(args: I) -> bool -where - I: IntoIterator, - S: AsRef, -{ - args.into_iter().any(|arg| arg.as_ref() == OsStr::new(SKIP_UPDATE_ARG)) -} - -fn should_skip_update() -> bool { - has_skip_update_arg(std::env::args_os()) -} - -// 前端就绪标志 -static SPLASHSCREEN_FRONTEND_READY: std::sync::OnceLock> = - std::sync::OnceLock::new(); - -/// 获取前端就绪标志 -fn get_frontend_ready_flag() -> Arc { - SPLASHSCREEN_FRONTEND_READY - .get_or_init(|| Arc::new(AtomicBool::new(false))) - .clone() -} - -/// 检查前端是否已就绪 -fn is_frontend_ready() -> bool { - get_frontend_ready_flag().load(Ordering::Acquire) -} - -/// 设置前端已就绪 -pub fn set_frontend_ready() { - get_frontend_ready_flag().store(true, Ordering::Release); -} - -/// 发射加载进度事件到 splashscreen -fn emit_progress(app_handle: &AppHandle, progress: u8, text: &str, status: Option<&str>) { - if let Some(splash_window) = app_handle.get_webview_window("splashscreen") { - #[derive(serde::Serialize, Clone)] - struct ProgressPayload { - progress: u8, - text: String, - status: Option, - } - - let _ = splash_window.emit( - "splashscreen-progress", - ProgressPayload { - progress, - text: text.to_string(), - status: status.map(|s| s.to_string()), - }, - ); - } -} - -/// 发射状态完成事件 -fn emit_status_complete(app_handle: &AppHandle, status: &str) { - if let Some(splash_window) = app_handle.get_webview_window("splashscreen") { - #[derive(serde::Serialize, Clone)] - struct StatusCompletePayload { - status: String, - } - - let _ = splash_window.emit( - "splashscreen-status-complete", - StatusCompletePayload { - status: status.to_string(), - }, - ); - } -} - -/// 初始化应用启动流程(显示 splashscreen 并控制加载) -pub fn init_startup(app_handle: AppHandle) { - // 注意:splashscreen 窗口由前端控制显示,确保内容准备好后再显示 - // 窗口在 tauri.conf.json 中配置为 visible: false,前端会在内容准备好后调用 show() - - // 异步执行加载流程 - tauri::async_runtime::spawn(async move { - let app_handle_clone = app_handle.clone(); - - // 等待前端完成首帧渲染并明确通知后端,再开始本地启动流程。 - while !is_frontend_ready() { - tokio::time::sleep(tokio::time::Duration::from_millis(100)).await; - } - - // 后端初始化在 Tauri setup 阶段已经完成。这里只同步真实状态, - // 不再用固定延迟模拟工作进度。 - emit_progress(&app_handle_clone, 10, "正在初始化...", Some("init")); - emit_status_complete(&app_handle_clone, "init"); - - // 步骤2: 加载配置 - emit_progress(&app_handle_clone, 30, "加载配置中...", Some("config")); - emit_status_complete(&app_handle_clone, "config"); - - // 步骤3: 初始化安全上下文 - emit_progress( - &app_handle_clone, - 50, - "初始化安全上下文...", - Some("security"), - ); - emit_status_complete(&app_handle_clone, "security"); - - // 步骤4: 本地业务数据库已经在 Tauri setup 阶段完成初始化。 - // 启动流程不再连接远程业务服务器,也不再依赖远程公钥。 - emit_progress(&app_handle_clone, 70, "加载本地数据...", Some("local-data")); - emit_status_complete(&app_handle_clone, "local-data"); - emit_progress(&app_handle_clone, 90, "本地数据已就绪", None); - - // 步骤4.1: 检查并处理更新(自动检查、下载、安装) - // 跳过参数只在更新入口消费,不传入更新服务、下载器或安装器。 - if should_skip_update() { - log::info!("Skipping startup update flow because {SKIP_UPDATE_ARG} was provided"); - emit_progress( - &app_handle_clone, - 92, - "已跳过更新检查", - Some("update-check"), - ); - emit_status_complete(&app_handle_clone, "update-check"); - } else { - emit_progress(&app_handle_clone, 92, "检查更新...", Some("update-check")); - let updates_available = match updater::check_updates(app_handle_clone.clone()).await { - Ok(result) => result.has_updates, - Err(e) => { - log::error!("Update check failed: {}", e); - emit_progress( - &app_handle_clone, - 92, - "检查更新失败,继续启动", - Some("update-check"), - ); - false - } - }; - - if updates_available { - emit_progress( - &app_handle_clone, - 94, - "检测到更新,开始下载...", - Some("update-download"), - ); - match updater::download_updates(app_handle_clone.clone(), None).await { - Ok(download_result) => { - if download_result.success_count > 0 { - emit_progress( - &app_handle_clone, - 96, - "下载完成,准备安装", - Some("update-install"), - ); - // 触发安装并退出(updater.exe 负责后续重启) - if let Err(e) = - updater::start_update_install(app_handle_clone.clone()).await - { - log::error!("Update installation start failed: {}", e); - emit_progress( - &app_handle_clone, - 96, - "安装启动失败,继续当前版本", - Some("update-install"), - ); - } - // 无论安装启动是否成功,都不再继续创建主窗口,交由 updater.exe 或用户重启 - return; - } else { - log::warn!("Update download failed, continuing with current version"); - emit_progress( - &app_handle_clone, - 94, - "下载失败,继续启动当前版本", - Some("update-download"), - ); - } - } - Err(e) => { - log::error!("Update download error: {}", e); - emit_progress( - &app_handle_clone, - 94, - "下载更新失败,继续启动当前版本", - Some("update-download"), - ); - } - } - } - } - - // 后端流程完成后再创建隐藏主窗口,避免开发模式下两个 WebView - // 同时冷启动并争用 Vite 转换资源。主窗口仍需通过前端真实就绪门闩。 - if let Err(error) = crate::commands::window::create_main_window(app_handle_clone.clone()) { - log::error!("Failed to create main window: {error}"); - emit_progress( - &app_handle_clone, - 100, - "主窗口创建失败", - Some("main-window"), - ); - return; - } - log::info!("Hidden main window created after backend startup flow"); - - emit_progress( - &app_handle_clone, - 100, - "正在准备主窗口...", - Some("main-window"), - ); - - if crate::app::startup::StartupService::backend_startup_ready(&app_handle_clone).is_err() { - log::error!("Failed to complete the backend startup gate"); - } - }); -} - -#[cfg(test)] -mod tests { - use super::has_skip_update_arg; - - #[test] - fn detects_skip_update_argument() { - assert!(has_skip_update_arg(["simprint.exe", "--skip-update"])); - } - - #[test] - fn does_not_treat_other_arguments_as_skip_update() { - assert!(!has_skip_update_arg([ - "simprint.exe", - "--skip-update-check", - "simprint://open" - ])); - } -} diff --git a/src-tauri/src/app/startup.rs b/src-tauri/src/app/startup.rs index 376b0d3..90adb58 100644 --- a/src-tauri/src/app/startup.rs +++ b/src-tauri/src/app/startup.rs @@ -65,10 +65,7 @@ fn try_complete_startup(app: &AppHandle) -> Result<(), ()> { } } - // 先显示真正就绪的主窗口,再关闭启动页,避免桌面闪烁。 - if let Some(splash_window) = app.get_webview_window("splashscreen") { - let _ = splash_window.close(); - } + crate::app::startup_update::start_background(app.clone()); Ok(()) } @@ -123,11 +120,4 @@ impl StartupService { } Ok(()) } - - /// 通知后端 splashscreen 已准备好接收进度事件。 - pub async fn splashscreen_ready() -> Result<(), ()> { - crate::app::splashscreen::set_frontend_ready(); - log::info!("Splashscreen frontend reported ready"); - Ok(()) - } } diff --git a/src-tauri/src/app/startup_update.rs b/src-tauri/src/app/startup_update.rs new file mode 100644 index 0000000..ed20755 --- /dev/null +++ b/src-tauri/src/app/startup_update.rs @@ -0,0 +1,77 @@ +use crate::commands::updater; +use std::ffi::OsStr; +use tauri::AppHandle; + +const SKIP_UPDATE_ARG: &str = "--skip-update"; + +fn has_skip_update_arg(args: I) -> bool +where + I: IntoIterator, + S: AsRef, +{ + args.into_iter().any(|arg| arg.as_ref() == OsStr::new(SKIP_UPDATE_ARG)) +} + +fn should_skip_update() -> bool { + has_skip_update_arg(std::env::args_os()) +} + +/// Runs the automatic update flow only after the main window is fully ready. +/// Updating is intentionally not part of the startup readiness gate. +pub fn start_background(app_handle: AppHandle) { + tauri::async_runtime::spawn(async move { + if should_skip_update() { + log::info!("Skipping background update flow because {SKIP_UPDATE_ARG} was provided"); + return; + } + + log::info!("Starting background update check after main window became ready"); + let updates_available = match updater::check_updates(app_handle.clone()).await { + Ok(result) => result.has_updates, + Err(error) => { + log::error!("Background update check failed: {error}"); + return; + } + }; + + if !updates_available { + log::info!("Background update check completed; no update is available"); + return; + } + + match updater::download_updates(app_handle.clone(), None).await { + Ok(download_result) if download_result.success_count > 0 => { + if let Err(error) = updater::start_update_install(app_handle).await { + log::error!("Background update installation start failed: {error}"); + } + } + Ok(_) => { + log::warn!( + "Background update download failed; continuing with the current version" + ); + } + Err(error) => { + log::error!("Background update download failed: {error}"); + } + } + }); +} + +#[cfg(test)] +mod tests { + use super::has_skip_update_arg; + + #[test] + fn detects_skip_update_argument() { + assert!(has_skip_update_arg(["simprint.exe", "--skip-update"])); + } + + #[test] + fn does_not_treat_other_arguments_as_skip_update() { + assert!(!has_skip_update_arg([ + "simprint.exe", + "--skip-update-check", + "simprint://open" + ])); + } +} diff --git a/src-tauri/src/commands.rs b/src-tauri/src/commands.rs index d6d4d6b..d8d4f3d 100644 --- a/src-tauri/src/commands.rs +++ b/src-tauri/src/commands.rs @@ -25,7 +25,6 @@ pub fn register_handles() -> impl Fn(Invoke) -> bool + Send + Sync + app::set_auto_start_enabled, app::set_updating_state, app::get_app_state, - app::splashscreen_ready, app::close_program, app::get_executable_path, app::is_dev, diff --git a/src-tauri/src/commands/app.rs b/src-tauri/src/commands/app.rs index 2e378fc..078fdfc 100644 --- a/src-tauri/src/commands/app.rs +++ b/src-tauri/src/commands/app.rs @@ -47,13 +47,6 @@ pub fn set_auto_start_enabled(app: AppHandle, enabled: bool) -> Result Result<()> { - crate::app::startup::StartupService::splashscreen_ready() - .await - .map_err(|_| "设置 splashscreen 就绪失败".into()) -} - // ============================================================================ // 运行时信息命令 // ============================================================================ diff --git a/src-tauri/src/commands/window.rs b/src-tauri/src/commands/window.rs index 3d45b57..6c01beb 100644 --- a/src-tauri/src/commands/window.rs +++ b/src-tauri/src/commands/window.rs @@ -47,11 +47,6 @@ pub async fn create_syncer_window(app_handle: AppHandle) -> Result<()> { WindowService::create_syncer_window(&app_handle).await } -/// 创建启动窗口(内部使用,不暴露为 Tauri 命令) -pub fn create_splashscreen_window(app_handle: AppHandle) -> Result<()> { - WindowService::create_splashscreen_window(&app_handle) -} - /// 创建主窗口(内部使用,不暴露为 Tauri 命令) pub fn create_main_window(app_handle: AppHandle) -> Result<()> { WindowService::create_main_window(&app_handle) diff --git a/src-tauri/src/core/error/types.rs b/src-tauri/src/core/error/types.rs index 7228147..66d72d3 100644 --- a/src-tauri/src/core/error/types.rs +++ b/src-tauri/src/core/error/types.rs @@ -531,10 +531,6 @@ pub enum Error { #[error("[220401] UI component initialization failed")] UiComponentInitFailed, - /// 220501: Splashscreen operation failed - #[error("[220501] Splashscreen operation failed")] - SplashscreenOperationFailed, - // ==================== Path Management (23) ==================== /// 230101: Path resolution failed #[error("[230101] Path resolution failed")] diff --git a/src-tauri/src/services/window/mod.rs b/src-tauri/src/services/window/mod.rs index 7d1a18f..e0b9c3e 100644 --- a/src-tauri/src/services/window/mod.rs +++ b/src-tauri/src/services/window/mod.rs @@ -261,6 +261,9 @@ impl WindowService { return Ok(()); } + let build_started_at = std::time::Instant::now(); + log::info!("Main window build started"); + #[cfg(feature = "production")] let devtools_enabled = false; #[cfg(not(feature = "production"))] @@ -282,33 +285,12 @@ impl WindowService { .devtools(devtools_enabled) .build()?; - log::trace!("主窗口已创建"); - Ok(()) - } - - /// 创建启动加载窗口 - pub fn create_splashscreen_window(app_handle: &AppHandle) -> Result<()> { - if app_handle.get_webview_window("splashscreen").is_some() { - log::debug!("启动窗口已存在,跳过创建"); - return Ok(()); - } + log::info!( + "Main window built in {:.1} ms", + build_started_at.elapsed().as_secs_f64() * 1_000.0 + ); - let _window = WebviewWindowBuilder::new( - app_handle, - "splashscreen", - WebviewUrl::App("splashscreen.html".into()), - ) - .data_directory(Self::get_webview_data_dir()?) - .title("Simprint") - .decorations(false) - .inner_size(725.0, 475.0) - .resizable(false) - .center() - .visible(false) - .drag_and_drop(false) - .build()?; - - log::trace!("启动窗口已创建"); + log::trace!("主窗口已创建"); Ok(()) } diff --git a/src/App.tsx b/src/App.tsx index 14114f4..04a0d8c 100644 --- a/src/App.tsx +++ b/src/App.tsx @@ -6,13 +6,32 @@ import { pluginRegistry, pluginLoader, setPluginImportFunctions } from '@slotkit // This file is generated by 'slotkit generate-imports' import * as pluginImports from './core/plugin/loader/plugin-imports.generated'; +const MAIN_STARTUP_PLUGIN_IDS = [ + 'app-layout', + 'authorization-layout', + 'environment-manager', + 'login', + 'register', + 'i18n', + 'store', + 'window-manager', +] as const; + +const MODE_PLUGIN_IDS = { + main: MAIN_STARTUP_PLUGIN_IDS, + syncer: ['syncer'], +} as const; + +const MAIN_WINDOW_READY_EVENT = 'simprint:main-window-ready'; + // Initialize plugin import mappings if (pluginImports.getPluginImport && pluginImports.getAvailablePluginIds) { setPluginImportFunctions({ + // @deprecated Compatibility adapter for generated mappings from + // @slotkitjs/core < 0.3.5. Remove after all consumers generate Promise-based imports. getPluginImport: (pluginId: string) => { - const importFn = pluginImports.getPluginImport(pluginId); - if (!importFn) return undefined; - return () => Promise.resolve(importFn()); + const importPlugin = pluginImports.getPluginImport(pluginId); + return importPlugin ? () => Promise.resolve(importPlugin()) : undefined; }, getAvailablePluginIds: pluginImports.getAvailablePluginIds, getAllPluginManifests: pluginImports.getAllPluginManifests, @@ -22,13 +41,22 @@ if (pluginImports.getPluginImport && pluginImports.getAvailablePluginIds) { console.warn('[WARN] Plugin import functions not found in generated file'); } -// Load all plugins -const loadPlugins = async () => { +const loadPlugins = async (pluginIds: readonly string[]) => { try { - const plugins = await pluginLoader.loadAllPlugins(); + const selectivePluginLoader = pluginLoader as typeof pluginLoader & { + loadPlugins?: (ids: readonly string[]) => ReturnType; + }; + + // @deprecated Compatibility fallback for @slotkitjs/core < 0.3.5. + // Remove after Simprint's lockfile is upgraded to a published 0.3.5+ release. + const plugins = selectivePluginLoader.loadPlugins + ? await selectivePluginLoader.loadPlugins(pluginIds) + : await pluginLoader.loadAllPlugins(); plugins.forEach((plugin) => { - pluginRegistry.register(plugin); + if (!pluginRegistry.getPlugin(plugin.id)) { + pluginRegistry.register(plugin); + } }); return plugins; @@ -40,9 +68,9 @@ const loadPlugins = async () => { interface AppProps { /** - * 应用模式:'main' 渲染主应用布局,'splashscreen' 渲染启动屏幕,'syncer' 渲染同步器窗口 + * 应用模式:'main' 渲染主应用布局,'syncer' 渲染同步器窗口 */ - mode?: 'main' | 'splashscreen' | 'syncer'; + mode?: 'main' | 'syncer'; } const App: React.FC = ({ mode = 'main' }) => { @@ -50,8 +78,32 @@ const App: React.FC = ({ mode = 'main' }) => { useEffect(() => { let cancelled = false; + let idleCallbackId: number | undefined; + + const loadDeferredPlugins = () => { + const startupPlugins = new Set(MAIN_STARTUP_PLUGIN_IDS); + const deferredPluginIds = pluginImports + .getAvailablePluginIds() + .filter((pluginId) => !startupPlugins.has(pluginId) && pluginId !== 'syncer'); + + const startLoading = () => { + void loadPlugins(deferredPluginIds).then(() => { + console.log('[App] Deferred plugins loaded'); + }); + }; + + if ('requestIdleCallback' in window) { + idleCallbackId = window.requestIdleCallback(startLoading, { timeout: 2000 }); + } else { + idleCallbackId = window.setTimeout(startLoading, 0); + } + }; + + if (mode === 'main') { + window.addEventListener(MAIN_WINDOW_READY_EVENT, loadDeferredPlugins, { once: true }); + } - loadPlugins() + loadPlugins(MODE_PLUGIN_IDS[mode]) .then(() => { console.log('[App] Plugins loaded for mode:', mode); if (!cancelled) setPluginsReady(true); @@ -63,6 +115,14 @@ const App: React.FC = ({ mode = 'main' }) => { return () => { cancelled = true; + window.removeEventListener(MAIN_WINDOW_READY_EVENT, loadDeferredPlugins); + if (idleCallbackId !== undefined) { + if ('cancelIdleCallback' in window) { + window.cancelIdleCallback(idleCallbackId); + } else { + window.clearTimeout(idleCallbackId); + } + } }; }, [mode]); diff --git a/src/app/components/AppLayout/index.tsx b/src/app/components/AppLayout/index.tsx index bf6e55b..8e8bd6a 100644 --- a/src/app/components/AppLayout/index.tsx +++ b/src/app/components/AppLayout/index.tsx @@ -1,28 +1,32 @@ // 确保扩展点在所有插件导入之前注册 import '../../extension-points'; -import React, { useCallback, useEffect, useState } from 'react'; +import React, { Suspense, useCallback, useEffect, useState } from 'react'; import { BrowserRouter } from 'react-router'; import { pluginRegistry } from '@slotkitjs/core'; -import { useAuthStore } from '../../../../plugins/services/store/src'; -import { SettingsDialog } from '../../../../plugins/pages/system-settings/src'; +import { useAuthStore, useSettingsDialogStore } from '../../../../plugins/services/store/src'; import { SettingsBootstrap } from '../../../../plugins/services/store/src'; import { ThemeProvider } from '@/components/theme-provider'; import { I18nProvider } from '@/components/i18n-provider'; import { commonResources } from '@/i18n/resources/common'; import { Toaster } from '@/components/ui/sonner'; import { AppRoutes } from '../AppRoutes'; -import { SplashscreenRenderer } from '../SplashscreenRenderer'; import { SyncerRenderer } from '../SyncerRenderer'; import { useDisableDevTools } from '@/hooks/use-disable-dev-tools'; import { SessionLockOverlay } from '../session-lock-overlay'; import { useSessionLock } from '../../hooks/use-session-lock'; +const SettingsDialog = React.lazy(() => + import('../../../../plugins/pages/system-settings/src/components/settings-dialog').then( + (module) => ({ default: module.SettingsDialog }) + ) +); + interface AppLayoutProps { /** - * 布局模式:'main' 渲染主应用布局,'splashscreen' 渲染启动屏幕,'syncer' 渲染同步器窗口 + * 布局模式:'main' 渲染主应用布局,'syncer' 渲染同步器窗口 */ - mode?: 'main' | 'splashscreen' | 'syncer'; + mode?: 'main' | 'syncer'; pluginsReady?: boolean; } @@ -31,6 +35,7 @@ export const AppLayout: React.FC = ({ mode = 'main', pluginsRead null ); const { initAuth, isAuthenticated } = useAuthStore(); + const isSettingsDialogOpen = useSettingsDialogStore((state) => state.isOpen); const [authReady, setAuthReady] = useState(mode !== 'main'); const [routesReady, setRoutesReady] = useState(false); useDisableDevTools(); @@ -83,11 +88,6 @@ export const AppLayout: React.FC = ({ mode = 'main', pluginsRead }; }, []); - // 如果是 splashscreen 模式,渲染启动屏幕 - if (mode === 'splashscreen') { - return ; - } - // 如果是 syncer 模式,渲染同步器窗口 if (mode === 'syncer') { return ( @@ -107,7 +107,11 @@ export const AppLayout: React.FC = ({ mode = 'main', pluginsRead ? React.createElement(windowManagerComponent) : null} - + {isSettingsDialogOpen ? ( + + + + ) : null} { - return ( - - } - /> - ); -}; diff --git a/src/main-startup-timing.ts b/src/main-startup-timing.ts new file mode 100644 index 0000000..5a51046 --- /dev/null +++ b/src/main-startup-timing.ts @@ -0,0 +1,24 @@ +export interface MainStartupTiming { + htmlStartedAt: number; + moduleExecutedAt?: number; + reactRenderScheduledAt?: number; + windowManagerEffectStartedAt?: number; + firstContentLayoutAt?: number; + fontsReadyAt?: number; + readyInvokeAt?: number; +} + +type MainTimingWindow = Window & { + __SIMPRINT_MAIN_HTML_STARTED_AT__?: number; +}; + +const timingWindow = window as MainTimingWindow; + +export const mainStartupTiming: MainStartupTiming = { + // The inline HTML marker runs before the module graph is fetched and evaluated. + htmlStartedAt: timingWindow.__SIMPRINT_MAIN_HTML_STARTED_AT__ ?? performance.now(), +}; + +export function elapsedSinceMainHtml(timestamp: number | undefined): number | undefined { + return timestamp === undefined ? undefined : timestamp - mainStartupTiming.htmlStartedAt; +} diff --git a/src/main.tsx b/src/main.tsx index 3a5d777..8154460 100644 --- a/src/main.tsx +++ b/src/main.tsx @@ -1,10 +1,11 @@ import { createRoot } from 'react-dom/client'; import './index.css'; import App from './App'; - -createRoot(document.getElementById('root')!).render( - -); +import { mainStartupTiming } from './main-startup-timing'; + +mainStartupTiming.moduleExecutedAt = performance.now(); +createRoot(document.getElementById('root')!).render(); +mainStartupTiming.reactRenderScheduledAt = performance.now(); // createRoot(document.getElementById('root')!).render( // diff --git a/src/splashscreen-main.tsx b/src/splashscreen-main.tsx deleted file mode 100644 index 8dc0cb6..0000000 --- a/src/splashscreen-main.tsx +++ /dev/null @@ -1,6 +0,0 @@ -import { createRoot } from 'react-dom/client'; -import '../plugins/pages/splashscreen/src/styles/animations.css'; -import './index.css'; -import { SplashscreenPage } from '../plugins/pages/splashscreen/src/components/splashscreen-page'; - -createRoot(document.getElementById('root')!).render(); diff --git a/vite.config.ts b/vite.config.ts index 202b3aa..786fcf5 100644 --- a/vite.config.ts +++ b/vite.config.ts @@ -40,11 +40,10 @@ export default defineConfig({ minify: !process.env.TAURI_ENV_DEBUG ? 'esbuild' : false, // produce sourcemaps for debug builds sourcemap: !!process.env.TAURI_ENV_DEBUG, - // 多入口配置:主应用、启动画面、同步器 + // 多入口配置:主应用、同步器 rollupOptions: { input: { main: path.resolve(__dirname, 'index.html'), - splashscreen: path.resolve(__dirname, 'splashscreen.html'), syncer: path.resolve(__dirname, 'syncer.html'), }, }, From 5705eae2b12ed1a524625d480cc9a46c5d63b596 Mon Sep 17 00:00:00 2001 From: lius-new Date: Wed, 12 Aug 2026 21:10:45 +0800 Subject: [PATCH 59/60] feat(updater): adopt official multi-arch Tauri releases Replace the custom forced updater with the official Tauri v2 updater and an in-app update prompt. Publish embedBootstrapper and fixed-runtime installers for x64, ARM64, and x86 while keeping latest.json limited to embedBootstrapper artifacts. --- .../workflows/tauri-build-win-official.yml | 252 ------- .github/workflows/tauri-release-win.yml | 257 +++++-- deploy/build.mjs | 21 +- deploy/generate-latest-json.mjs | 105 --- deploy/prepare-tauri-config.mjs | 59 ++ deploy/publish-github-release.mjs | 231 ------ deploy/publish-version.mjs | 105 --- package.json | 4 +- .../components/titlebar/app-update-button.tsx | 159 ++++ .../titlebar/default-status-slot.tsx | 20 +- .../titlebar/prepared-update-button.tsx | 122 --- .../layouts/app-layout/src/i18n/resources.ts | 708 +++++++++--------- .../src/components/storage-panel.tsx | 576 +++++++------- pnpm-lock.yaml | 15 + src-tauri/Cargo.lock | 370 +++++---- src-tauri/Cargo.toml | 16 +- src-tauri/build.rs | 153 ++-- src-tauri/capabilities/default.json | 87 +-- src-tauri/config.example.toml | 9 +- src-tauri/src/app/context.rs | 16 +- src-tauri/src/app/lifecycle.rs | 7 +- src-tauri/src/app/mod.rs | 1 - src-tauri/src/app/setup.rs | 5 + src-tauri/src/app/startup.rs | 2 - src-tauri/src/app/startup_update.rs | 77 -- src-tauri/src/bin/updater.rs | 232 ------ src-tauri/src/commands.rs | 8 - src-tauri/src/commands/updater.rs | 70 -- .../src/core/config/encryption/crypto.rs | 273 ------- .../core/config/encryption/key_derivation.rs | 124 --- src-tauri/src/core/config/encryption/mod.rs | 9 - src-tauri/src/core/config/loader.rs | 48 -- src-tauri/src/core/config/mod.rs | 96 --- src-tauri/src/core/config/types.rs | 25 - src-tauri/src/core/config/validator.rs | 45 -- src-tauri/src/core/mod.rs | 1 - src-tauri/src/core/paths.rs | 36 - src-tauri/src/core/utils/hash.rs | 25 + src-tauri/src/core/utils/mod.rs | 1 + src-tauri/src/infrastructure/mod.rs | 1 - .../src/infrastructure/updater/checker.rs | 45 -- .../src/infrastructure/updater/downloader.rs | 136 ---- .../src/infrastructure/updater/installer.rs | 275 ------- .../src/infrastructure/updater/manifest.rs | 81 -- src-tauri/src/infrastructure/updater/mod.rs | 21 - .../src/infrastructure/updater/planner.rs | 131 ---- .../src/infrastructure/updater/service.rs | 194 ----- src-tauri/src/infrastructure/updater/state.rs | 59 -- src-tauri/src/infrastructure/updater/types.rs | 346 --------- .../src/infrastructure/updater/verifier.rs | 41 - src-tauri/src/main.rs | 8 - .../services/environment/kernel/downloader.rs | 2 +- .../services/environment/kernel/extension.rs | 2 +- .../src/services/local_extensions/mod.rs | 2 +- src-tauri/src/services/mod.rs | 1 - src-tauri/src/services/updater/mod.rs | 5 - src-tauri/src/services/updater/types.rs | 9 - .../src/services/updater/update_service.rs | 701 ----------------- ...son => tauri.conf.embed-bootstrapper.json} | 139 ++-- ...xed.json => tauri.conf.fixed-runtime.json} | 103 +-- src-tauri/tauri.conf.json | 108 +-- src-tauri/windows/nsis/installer-hooks.nsi | 4 - src-tauri/windows/updater.manifest | 22 - src-tauri/windows/updater.rc | 2 - src/lib/app-updater.ts | 62 ++ 65 files changed, 1714 insertions(+), 5156 deletions(-) delete mode 100644 .github/workflows/tauri-build-win-official.yml delete mode 100644 deploy/generate-latest-json.mjs create mode 100644 deploy/prepare-tauri-config.mjs delete mode 100644 deploy/publish-github-release.mjs delete mode 100644 deploy/publish-version.mjs create mode 100644 plugins/layouts/app-layout/src/components/titlebar/app-update-button.tsx delete mode 100644 plugins/layouts/app-layout/src/components/titlebar/prepared-update-button.tsx delete mode 100644 src-tauri/src/app/startup_update.rs delete mode 100644 src-tauri/src/bin/updater.rs delete mode 100644 src-tauri/src/commands/updater.rs delete mode 100644 src-tauri/src/core/config/encryption/crypto.rs delete mode 100644 src-tauri/src/core/config/encryption/key_derivation.rs delete mode 100644 src-tauri/src/core/config/encryption/mod.rs delete mode 100644 src-tauri/src/core/config/loader.rs delete mode 100644 src-tauri/src/core/config/mod.rs delete mode 100644 src-tauri/src/core/config/types.rs delete mode 100644 src-tauri/src/core/config/validator.rs create mode 100644 src-tauri/src/core/utils/hash.rs delete mode 100644 src-tauri/src/infrastructure/updater/checker.rs delete mode 100644 src-tauri/src/infrastructure/updater/downloader.rs delete mode 100644 src-tauri/src/infrastructure/updater/installer.rs delete mode 100644 src-tauri/src/infrastructure/updater/manifest.rs delete mode 100644 src-tauri/src/infrastructure/updater/mod.rs delete mode 100644 src-tauri/src/infrastructure/updater/planner.rs delete mode 100644 src-tauri/src/infrastructure/updater/service.rs delete mode 100644 src-tauri/src/infrastructure/updater/state.rs delete mode 100644 src-tauri/src/infrastructure/updater/types.rs delete mode 100644 src-tauri/src/infrastructure/updater/verifier.rs delete mode 100644 src-tauri/src/services/updater/mod.rs delete mode 100644 src-tauri/src/services/updater/types.rs delete mode 100644 src-tauri/src/services/updater/update_service.rs rename src-tauri/{tauri.conf.window.download.json => tauri.conf.embed-bootstrapper.json} (61%) rename src-tauri/{tauri.conf.fixed.json => tauri.conf.fixed-runtime.json} (82%) delete mode 100644 src-tauri/windows/updater.manifest delete mode 100644 src-tauri/windows/updater.rc create mode 100644 src/lib/app-updater.ts diff --git a/.github/workflows/tauri-build-win-official.yml b/.github/workflows/tauri-build-win-official.yml deleted file mode 100644 index bb60f81..0000000 --- a/.github/workflows/tauri-build-win-official.yml +++ /dev/null @@ -1,252 +0,0 @@ -name: tauri-build-windows-official - -on: - workflow_dispatch: - inputs: - env_name: - description: "Frontend build mode (development/production/test)" - required: false - default: "production" - -jobs: - build: - # Windows 专用构建 Job: - # - 固定运行在 GitHub-hosted Windows Server 2025 Runner 上 - # - 不适用于 macOS / Linux 等其他平台 - # 如需支持多平台,请为不同平台创建独立的 workflow 或 job - runs-on: windows-2025 - permissions: - contents: write - - env: - # 前端构建参数 - # build.cjs reads ENV_NAME; default production - ENV_NAME: ${{ inputs.env_name || 'production' }} - - # App config values - APP_SERVER_BASE_URL: ${{ secrets.APP_SERVER_BASE_URL }} - APP_SERVER_VERSION: ${{ secrets.APP_SERVER_VERSION }} - APP_SERVER_SECRET_KEY: ${{ secrets.APP_SERVER_SECRET_KEY }} - APP_UPDATER_CHECK_URL: ${{ secrets.APP_UPDATER_CHECK_URL }} - APP_UPDATER_LATEST_JSON_URL: ${{ secrets.APP_UPDATER_LATEST_JSON_URL }} - APP_UPDATER_TEMP_DIR: ${{ secrets.APP_UPDATER_TEMP_DIR }} - APP_WEBVIEW_DOWNLOAD_URL: ${{ secrets.APP_WEBVIEW_DOWNLOAD_URL }} - - # Release 相关上下文 - # - tag push 时 github.ref_name 即为 vX.Y.Z - # - workflow_dispatch 时不会创建 release,仅用于手动构建 - RELEASE_TAG: ${{ github.ref_name }} - # R2 相关配置(若未配置对应 Secret,则相关功能会被跳过或报错) - R2_ACCOUNT_ID: ${{ secrets.R2_ACCOUNT_ID }} - R2_ACCESS_KEY_ID: ${{ secrets.R2_ACCESS_KEY_ID }} - R2_SECRET_ACCESS_KEY: ${{ secrets.R2_SECRET_ACCESS_KEY }} - R2_BUCKET: ${{ secrets.R2_BUCKET }} - R2_PUBLIC_BASE_URL: ${{ secrets.R2_PUBLIC_BASE_URL }} - - steps: - - name: Checkout - uses: actions/checkout@v4 - with: - fetch-depth: 0 - fetch-tags: true - - - name: Setup pnpm - uses: pnpm/action-setup@v4 - with: - version: 9 - - - name: Setup Node.js - uses: actions/setup-node@v4 - with: - node-version: 20 - cache: pnpm - - - name: Setup Rust toolchain - uses: dtolnay/rust-toolchain@stable - - - name: Ensure AWS CLI - shell: powershell - run: | - $ErrorActionPreference = "Stop" - - if (Get-Command aws -ErrorAction SilentlyContinue) { - aws --version - exit 0 - } - - $msiPath = Join-Path $env:RUNNER_TEMP "AWSCLIV2.msi" - Invoke-WebRequest "https://awscli.amazonaws.com/AWSCLIV2.msi" -OutFile $msiPath - Start-Process msiexec.exe -Wait -ArgumentList "/i `"$msiPath`" /qn" - - @( - "C:\Program Files\Amazon\AWSCLIV2", - "C:\Program Files\Amazon\AWSCLIV2\bin" - ) | ForEach-Object { - if (Test-Path $_) { - Add-Content -Path $env:GITHUB_PATH -Value $_ - } - } - - $awsExe = Get-Command aws -ErrorAction SilentlyContinue - if (-not $awsExe) { - $awsInstalled = Get-ChildItem "C:\Program Files\Amazon" -Filter aws.exe -Recurse -ErrorAction SilentlyContinue | - Select-Object -First 1 - if (-not $awsInstalled) { - Write-Error "AWS CLI installation completed, but aws.exe was not found." - } - & $awsInstalled.FullName --version - exit 0 - } - - aws --version - - - name: Install dependencies - run: pnpm install --frozen-lockfile - - - name: Prepare app config - shell: powershell - run: | - Copy-Item "src-tauri/config.example.toml" "src-tauri/config.production.toml" -Force - - $configPath = "src-tauri/config.production.toml" - $content = Get-Content $configPath -Raw - - function Set-ConfigValue { - param( - [string]$Pattern, - [string]$Value - ) - - if ([string]::IsNullOrWhiteSpace($Value)) { - return - } - - $escapedValue = $Value.Replace('\', '\\').Replace('"', '\"') - $script:content = [regex]::Replace( - $script:content, - $Pattern, - ('$1"{0}"' -f $escapedValue), - [System.Text.RegularExpressions.RegexOptions]::Multiline - ) - } - - Set-ConfigValue '^(base_url\s*=\s*)".*"$' "${{ env.APP_SERVER_BASE_URL }}" - Set-ConfigValue '^(version\s*=\s*)".*"$' "${{ env.APP_SERVER_VERSION }}" - Set-ConfigValue '^(secret_key\s*=\s*)".*"$' "${{ env.APP_SERVER_SECRET_KEY }}" - Set-ConfigValue '^(check_url\s*=\s*)".*"$' "${{ env.APP_UPDATER_CHECK_URL }}" - Set-ConfigValue '^(latest_json_url\s*=\s*)".*"$' "${{ env.APP_UPDATER_LATEST_JSON_URL }}" - Set-ConfigValue '^(updater_temp_dir\s*=\s*)".*"$' "${{ env.APP_UPDATER_TEMP_DIR }}" - Set-ConfigValue '^(downlaod_url\s*=\s*)".*"$' "${{ env.APP_WEBVIEW_DOWNLOAD_URL }}" - - $utf8NoBom = New-Object System.Text.UTF8Encoding($false) - $resolvedConfigPath = (Resolve-Path $configPath).Path - [System.IO.File]::WriteAllText($resolvedConfigPath, $content, $utf8NoBom) - - # 在 CI 构建期间使用 fixed 版本的 Tauri 配置 - # 先将 src-tauri/tauri.conf.fixed.json 覆盖为 src-tauri/tauri.conf.json, - # 然后再由 prepare-version.mjs 统一写入最终版本号,确保参与打包的配置版本正确。 - - name: Use fixed Tauri config - shell: powershell - run: | - Copy-Item "src-tauri/tauri.conf.fixed.json" "src-tauri/tauri.conf.json" -Force - - - name: Prepare version from tag - # For release events, GITHUB_REF_NAME is usually "vX.Y.Z" as well. - run: node deploy/prepare-version.mjs - - # Build and bundle the Tauri app. - # It will run `beforeBuildCommand` from `src-tauri/tauri.conf.json` (node build.cjs -> pnpm build:) - - name: Build Tauri app - uses: tauri-apps/tauri-action@v0 - env: - # 优先使用 GT_TOKEN;未配置时回退到 GitHub 自动注入的 token - GITHUB_TOKEN: ${{ secrets.GT_TOKEN || github.token }} - # Optional: enable updater signing / release signing if you configure them later - TAURI_PRIVATE_KEY: ${{ secrets.TAURI_PRIVATE_KEY }} - TAURI_KEY_PASSWORD: ${{ secrets.TAURI_KEY_PASSWORD }} - with: - projectPath: . - # 与本地一致,使用 production feature 进行构建 - args: --features production - - - name: Generate latest.json - run: node deploy/generate-latest-json.mjs - - - name: Publish GitHub release assets - if: startsWith(github.ref, 'refs/tags/') - env: - GH_TOKEN: ${{ secrets.GT_TOKEN || github.token }} - GH_REPO: ${{ github.repository }} - run: node deploy/publish-github-release.mjs - - - name: Upload installer to R2 storage - # tag push 时上传到 R2 - if: startsWith(github.ref, 'refs/tags/') - shell: powershell - run: | - $ErrorActionPreference = "Stop" - - $TAG = "${{ env.RELEASE_TAG }}" - # 去掉前缀 v,得到纯版本号,如 0.1.0 - $VERSION = $TAG.TrimStart('v') - - # 找到 NSIS 安装包 - $installer = Get-ChildItem "src-tauri/target/release/bundle/nsis" -Filter *.exe | Select-Object -First 1 - if (-not $installer) { - Write-Error "NSIS installer not found under src-tauri/target/release/bundle/nsis" - } - - # 使用 AWS CLI 上传到 R2(唯一方式) - if (-not (Get-Command aws -ErrorAction SilentlyContinue)) { - Write-Error "aws CLI not found. Please install AWS CLI v2 and ensure 'aws' is in PATH." - } - - $accountId = "${{ env.R2_ACCOUNT_ID }}" - $bucket = "${{ env.R2_BUCKET }}" - $endpoint = "https://${accountId}.r2.cloudflarestorage.com" - $dest = "s3://${bucket}/${VERSION}/simprint_setup.exe" - - $env:AWS_ACCESS_KEY_ID = "${{ env.R2_ACCESS_KEY_ID }}" - $env:AWS_SECRET_ACCESS_KEY = "${{ env.R2_SECRET_ACCESS_KEY }}" - $env:AWS_EC2_METADATA_DISABLED = "true" - - Write-Host "Uploading $($installer.FullName) to R2: $dest" - aws s3 cp $installer.FullName $dest --endpoint-url $endpoint --region auto - - - name: Upload latest.json to R2 root - # tag push 时上传到 R2 - if: startsWith(github.ref, 'refs/tags/') - shell: powershell - run: | - $ErrorActionPreference = "Stop" - - if (-not (Get-Command aws -ErrorAction SilentlyContinue)) { - Write-Error "aws CLI not found. Please install AWS CLI v2 and ensure 'aws' is in PATH." - } - - $accountId = "${{ env.R2_ACCOUNT_ID }}" - $bucket = "${{ env.R2_BUCKET }}" - $endpoint = "https://${accountId}.r2.cloudflarestorage.com" - $dest = "s3://${bucket}/latest.json" - - $env:AWS_ACCESS_KEY_ID = "${{ env.R2_ACCESS_KEY_ID }}" - $env:AWS_SECRET_ACCESS_KEY = "${{ env.R2_SECRET_ACCESS_KEY }}" - $env:AWS_EC2_METADATA_DISABLED = "true" - - Write-Host "Uploading latest.json to R2 root: $dest" - aws s3 cp "latest.json" $dest --endpoint-url $endpoint --region auto - - - name: Upload latest.json artifact (manual runs) - if: github.event_name == 'workflow_dispatch' && hashFiles('latest.json') != '' - uses: actions/upload-artifact@v4 - with: - name: latest-json - path: latest.json - - - name: Publish version metadata - if: startsWith(github.ref, 'refs/tags/') - env: - VERSION_API_URL: ${{ secrets.VERSION_API_URL }} - VERSION_API_KEY: ${{ secrets.VERSION_API_KEY }} - RELEASE_TAG: ${{ env.RELEASE_TAG }} - run: node deploy/publish-version.mjs diff --git a/.github/workflows/tauri-release-win.yml b/.github/workflows/tauri-release-win.yml index dc001af..dbc0f28 100644 --- a/.github/workflows/tauri-release-win.yml +++ b/.github/workflows/tauri-release-win.yml @@ -3,18 +3,14 @@ name: tauri-release-windows on: push: tags: - - "v*" + - 'v*' jobs: - build: + publish: runs-on: windows-2025 permissions: contents: write - env: - ENV_NAME: production - RELEASE_TAG: ${{ github.ref_name }} - steps: - name: Checkout uses: actions/checkout@v4 @@ -35,82 +31,219 @@ jobs: - name: Setup Rust toolchain uses: dtolnay/rust-toolchain@stable + with: + targets: x86_64-pc-windows-msvc,aarch64-pc-windows-msvc,i686-pc-windows-msvc - name: Install dependencies run: pnpm install --frozen-lockfile - - name: Prepare app config + - name: Prepare production build shell: powershell env: - # Optional overrides. If these are not configured, the workflow - # will keep the defaults from src-tauri/config.example.toml. - APP_SERVER_BASE_URL: ${{ secrets.APP_SERVER_BASE_URL }} - APP_SERVER_VERSION: ${{ secrets.APP_SERVER_VERSION }} - APP_SERVER_SECRET_KEY: ${{ secrets.APP_SERVER_SECRET_KEY }} - APP_UPDATER_CHECK_URL: ${{ secrets.APP_UPDATER_CHECK_URL }} - APP_UPDATER_LATEST_JSON_URL: ${{ secrets.APP_UPDATER_LATEST_JSON_URL }} - APP_UPDATER_TEMP_DIR: ${{ secrets.APP_UPDATER_TEMP_DIR }} - APP_WEBVIEW_DOWNLOAD_URL: ${{ secrets.APP_WEBVIEW_DOWNLOAD_URL }} + TAURI_UPDATER_PUBLIC_KEY: ${{ secrets.TAURI_UPDATER_PUBLIC_KEY }} + TAURI_SIGNING_PRIVATE_KEY: ${{ secrets.TAURI_PRIVATE_KEY }} run: | - Copy-Item "src-tauri/config.example.toml" "src-tauri/config.production.toml" -Force - - $configPath = "src-tauri/config.production.toml" - $content = Get-Content $configPath -Raw - - function Set-ConfigValue { - param( - [string]$Pattern, - [string]$Value - ) + if ([string]::IsNullOrWhiteSpace($env:TAURI_UPDATER_PUBLIC_KEY)) { + throw "TAURI_UPDATER_PUBLIC_KEY is required" + } + if ([string]::IsNullOrWhiteSpace($env:TAURI_SIGNING_PRIVATE_KEY)) { + throw "TAURI_SIGNING_PRIVATE_KEY is required" + } - if ([string]::IsNullOrWhiteSpace($Value)) { - return - } + Copy-Item "src-tauri/config.example.toml" "src-tauri/config.production.toml" -Force - $escapedValue = $Value.Replace('\', '\\').Replace('"', '\"') - $script:content = [regex]::Replace( - $script:content, - $Pattern, - ('$1"{0}"' -f $escapedValue), - [System.Text.RegularExpressions.RegexOptions]::Multiline - ) + - name: Read annotated tag notes + id: release_notes + shell: powershell + run: | + $notes = git for-each-ref --format='%(contents)' "refs/tags/${{ github.ref_name }}" + if ([string]::IsNullOrWhiteSpace($notes)) { + $notes = "See the assets below to download this version." } + "body< p.toLowerCase().endsWith('.exe')); - -if (!installerPath) { - throw new Error( - `[generate-latest-json] Cannot find NSIS installer (.exe) under: ${bundleNsisDir}` - ); -} - -const installerName = path.basename(installerPath); -const signaturePath = findFirstFile(bundleNsisDir, (p) => p.toLowerCase().endsWith('.sig')); -const signature = signaturePath ? fs.readFileSync(signaturePath, 'utf8').trim() : null; - -// Prefer GitHub Releases download URL when available. -// Example: -// - RELEASE_REPO=Simprint/simprint-release -// - RELEASE_TAG=v1.2.3 -// -> https://github.com/Simprint/simprint-release/releases/download/v1.2.3/ -const releaseRepo = process.env.RELEASE_REPO || process.env.GITHUB_REPOSITORY; -const tag = process.env.RELEASE_TAG || process.env.GITHUB_REF_NAME || `v${version}`; -const baseUrl = - process.env.DOWNLOAD_BASE_URL || - (releaseRepo ? `https://github.com/${releaseRepo}/releases/download/${tag}` : null); - -if (!baseUrl) { - throw new Error( - '[generate-latest-json] Missing DOWNLOAD_BASE_URL and cannot infer from GITHUB_REPOSITORY.' - ); -} - -const url = `${baseUrl}/${encodeURIComponent(installerName)}`; - -// Optional: R2 public URL, e.g. https://r2.example.com/releases -// Final R2 URL will be: ${R2_PUBLIC_BASE_URL}/${version}/simprint_setup.exe -const r2Base = process.env.R2_PUBLIC_BASE_URL; -const r2Url = r2Base - ? `${r2Base.replace(/\/$/, '')}/${version}/simprint_setup.exe` - : null; -const pub_date = new Date().toISOString(); -const notes = process.env.RELEASE_NOTES || ''; - -// Tauri updater-style manifest (works for many clients). -// Platform key: use the Rust target triple for Windows MSVC. -const latest = { - version, - notes, - pub_date, - platforms: { - 'x86_64-pc-windows-msvc': { - url, - ...(signature ? { signature } : {}), - ...(r2Url ? { r2_url: r2Url } : {}), - }, - }, -}; - -const outPath = path.join(root, 'latest.json'); -fs.writeFileSync(outPath, JSON.stringify(latest, null, 2) + '\n', 'utf8'); - -console.log(`[generate-latest-json] Wrote ${path.relative(root, outPath)}`); -console.log(`[generate-latest-json] Installer: ${installerName}`); -console.log(`[generate-latest-json] URL: ${url}`); -console.log(`[generate-latest-json] Signature: ${signature ? 'present' : 'missing'}`); - diff --git a/deploy/prepare-tauri-config.mjs b/deploy/prepare-tauri-config.mjs new file mode 100644 index 0000000..fcd034d --- /dev/null +++ b/deploy/prepare-tauri-config.mjs @@ -0,0 +1,59 @@ +import fs from 'node:fs'; +import path from 'node:path'; + +const CONFIG_BY_MODE = { + embedBootstrapper: 'tauri.conf.embed-bootstrapper.json', + 'fixed-runtime': 'tauri.conf.fixed-runtime.json', +}; + +const TARGET_BY_NODE_ARCH = { + x64: 'x86_64-pc-windows-msvc', + arm64: 'aarch64-pc-windows-msvc', + ia32: 'i686-pc-windows-msvc', +}; + +const FIXED_RUNTIME_PATH_BY_TARGET = { + 'x86_64-pc-windows-msvc': + './webview-fixed/Microsoft.WebView2.FixedVersionRuntime.151.0.4129.78.x64/', + 'aarch64-pc-windows-msvc': + './webview-fixed/Microsoft.WebView2.FixedVersionRuntime.151.0.4129.78.arm64/', + 'i686-pc-windows-msvc': + './webview-fixed/Microsoft.WebView2.FixedVersionRuntime.151.0.4129.78.x86/', +}; + +const mode = process.argv[2]; +const sourceName = CONFIG_BY_MODE[mode]; +const target = process.argv[3] || TARGET_BY_NODE_ARCH[process.arch]; + +if (!sourceName) { + throw new Error( + `[prepare-tauri-config] Unsupported mode "${mode ?? ''}". ` + + 'Expected embedBootstrapper or fixed-runtime.' + ); +} + +if (!FIXED_RUNTIME_PATH_BY_TARGET[target]) { + throw new Error( + `[prepare-tauri-config] Unsupported Windows target "${target ?? ''}". ` + + 'Expected x86_64-pc-windows-msvc, aarch64-pc-windows-msvc or i686-pc-windows-msvc.' + ); +} + +const root = path.resolve(process.cwd()); +const sourcePath = path.join(root, 'src-tauri', sourceName); +const targetPath = path.join(root, 'src-tauri', 'tauri.conf.json'); +const config = JSON.parse(fs.readFileSync(sourcePath, 'utf8')); +const publicKey = process.env.TAURI_UPDATER_PUBLIC_KEY?.trim(); + +if (mode === 'fixed-runtime') { + config.bundle.windows.webviewInstallMode.path = FIXED_RUNTIME_PATH_BY_TARGET[target]; +} + +if (publicKey) { + config.plugins ??= {}; + config.plugins.updater ??= {}; + config.plugins.updater.pubkey = publicKey; +} + +fs.writeFileSync(targetPath, `${JSON.stringify(config, null, 2)}\n`, 'utf8'); +console.log(`[prepare-tauri-config] Prepared ${mode} for ${target} from src-tauri/${sourceName}`); diff --git a/deploy/publish-github-release.mjs b/deploy/publish-github-release.mjs deleted file mode 100644 index 55000c0..0000000 --- a/deploy/publish-github-release.mjs +++ /dev/null @@ -1,231 +0,0 @@ -import fs from 'node:fs/promises'; -import path from 'node:path'; - -const token = process.env.GH_TOKEN || process.env.GITHUB_TOKEN; -const repository = process.env.GH_REPO || process.env.GITHUB_REPOSITORY; -const tag = process.env.RELEASE_TAG; -const installerDir = path.resolve( - process.env.RELEASE_INSTALLER_DIR || 'src-tauri/target/release/bundle/nsis' -); -const latestJsonPath = path.resolve(process.env.RELEASE_LATEST_JSON_PATH || 'latest.json'); - -if (!token) throw new Error('GH_TOKEN or GITHUB_TOKEN is not set'); -if (!repository) throw new Error('GH_REPO or GITHUB_REPOSITORY is not set'); -if (!tag) throw new Error('RELEASE_TAG is not set'); - -const [owner, repo] = repository.split('/'); -if (!owner || !repo) throw new Error(`Invalid repository: ${repository}`); - -const installerPath = await findInstaller(installerDir); -const tagNotes = await readTagNotes(tag); -const release = await ensureRelease(tagNotes); - -await uploadAsset(release, installerPath, 'application/octet-stream'); -await uploadAsset(release, latestJsonPath, 'application/json'); - -console.log(`GitHub release publish finished for ${tag}`); - -async function findInstaller(dir) { - const entries = await fs.readdir(dir, { withFileTypes: true }).catch(() => []); - const exe = entries.find((entry) => entry.isFile() && entry.name.toLowerCase().endsWith('.exe')); - if (!exe) { - throw new Error(`NSIS installer not found under ${dir}`); - } - return path.join(dir, exe.name); -} - -async function readTagNotes(tagName) { - try { - const refResponse = await githubApi(`/repos/${owner}/${repo}/git/ref/tags/${encodeURIComponent(tagName)}`, { - okStatuses: [200, 404], - }); - - if (refResponse.status === 404) { - console.log(`Tag ref ${tagName} not found on GitHub, fallback to generated release notes`); - return ''; - } - - const target = refResponse.json?.object; - if (!target || target.type !== 'tag' || !target.sha) { - console.log(`Tag ${tagName} is not an annotated tag on GitHub, fallback to generated release notes`); - return ''; - } - - const tagObject = await githubApi(`/repos/${owner}/${repo}/git/tags/${target.sha}`, { - okStatuses: [200, 404], - }); - - if (tagObject.status === 404) { - console.log(`Annotated tag object for ${tagName} not found on GitHub, fallback to generated release notes`); - return ''; - } - - return (tagObject.json?.message || '').trim(); - } catch (error) { - console.warn(`Failed to read GitHub tag notes for ${tagName}: ${error.message}`); - return ''; - } -} - -async function ensureRelease(tagNotes) { - const existing = await githubApi( - `/repos/${owner}/${repo}/releases/tags/${encodeURIComponent(tag)}`, - { okStatuses: [200, 404] } - ); - - if (existing.status === 200) { - console.log(`Using existing GitHub release for ${tag}`); - return await syncExistingRelease(existing.json, tagNotes); - } - - console.log(`Creating GitHub release for ${tag}`); - const created = await githubApi(`/repos/${owner}/${repo}/releases`, { - method: 'POST', - json: buildReleasePayload(tagNotes), - okStatuses: [201, 422], - }); - - if (created.status === 201) { - return created.json; - } - - const refetched = await githubApi(`/repos/${owner}/${repo}/releases/tags/${encodeURIComponent(tag)}`); - return refetched.json; -} - -async function syncExistingRelease(release, tagNotes) { - const desiredBody = tagNotes || ''; - const currentBody = release.body || ''; - const shouldUpdateBody = Boolean(tagNotes) && currentBody !== desiredBody; - const shouldEnableGeneratedNotes = !tagNotes && !currentBody; - - if (!shouldUpdateBody && !shouldEnableGeneratedNotes) { - return release; - } - - console.log(`Updating GitHub release metadata for ${tag}`); - const updated = await githubApi(`/repos/${owner}/${repo}/releases/${release.id}`, { - method: 'PATCH', - json: buildReleasePayload(tagNotes), - okStatuses: [200], - }); - - return updated.json; -} - -function buildReleasePayload(tagNotes) { - const payload = { - tag_name: tag, - name: tag, - }; - - if (tagNotes) { - payload.body = tagNotes; - } else { - payload.generate_release_notes = true; - } - - return payload; -} - -async function uploadAsset(release, filePath, contentType) { - const fileName = path.basename(filePath); - const fileBuffer = await fs.readFile(filePath); - - await deleteExistingAsset(release, fileName); - - const uploadUrl = release.upload_url.replace('{?name,label}', `?name=${encodeURIComponent(fileName)}`); - console.log(`Uploading ${fileName}`); - - await retry(`upload ${fileName}`, async () => { - const response = await fetch(uploadUrl, { - method: 'POST', - headers: { - Authorization: `Bearer ${token}`, - Accept: 'application/vnd.github+json', - 'Content-Type': contentType, - 'Content-Length': String(fileBuffer.length), - }, - body: fileBuffer, - }); - - if (!response.ok) { - const text = await response.text(); - throw new Error(`upload failed ${response.status}: ${text}`); - } - }); -} - -async function deleteExistingAsset(release, fileName) { - const asset = (release.assets || []).find((item) => item.name === fileName); - if (!asset) return; - - console.log(`Deleting existing asset ${fileName}`); - await githubApi(`/repos/${owner}/${repo}/releases/assets/${asset.id}`, { - method: 'DELETE', - okStatuses: [204], - }); -} - -async function githubApi(apiPath, options = {}) { - const url = apiPath.startsWith('http') ? apiPath : `https://api.github.com${apiPath}`; - const headers = { - Authorization: `Bearer ${token}`, - Accept: 'application/vnd.github+json', - 'X-GitHub-Api-Version': '2022-11-28', - ...options.headers, - }; - - let body; - if (options.json !== undefined) { - body = JSON.stringify(options.json); - headers['Content-Type'] = 'application/json'; - } else if (options.body !== undefined) { - body = options.body; - } - - const response = await fetch(url, { - method: options.method || 'GET', - headers, - body, - }); - - const okStatuses = options.okStatuses || [200]; - const contentType = response.headers.get('content-type') || ''; - const payload = contentType.includes('application/json') - ? await response.json().catch(() => null) - : await response.text().catch(() => ''); - - if (!okStatuses.includes(response.status)) { - throw new Error(`${options.method || 'GET'} ${url} failed ${response.status}: ${formatPayload(payload)}`); - } - - return { status: response.status, json: payload }; -} - -async function retry(label, fn, attempts = 4) { - let lastError; - for (let attempt = 1; attempt <= attempts; attempt += 1) { - try { - await fn(); - return; - } catch (error) { - lastError = error; - if (attempt === attempts) break; - const delayMs = attempt * 2000; - console.warn(`${label} failed on attempt ${attempt}/${attempts}: ${error.message}`); - console.warn(`Retrying in ${delayMs}ms`); - await new Promise((resolve) => setTimeout(resolve, delayMs)); - } - } - throw lastError; -} - -function formatPayload(payload) { - if (typeof payload === 'string') return payload; - try { - return JSON.stringify(payload); - } catch { - return String(payload); - } -} diff --git a/deploy/publish-version.mjs b/deploy/publish-version.mjs deleted file mode 100644 index 6c918e0..0000000 --- a/deploy/publish-version.mjs +++ /dev/null @@ -1,105 +0,0 @@ -import fs from 'node:fs'; -import path from 'node:path'; -import http from 'node:http'; -import https from 'node:https'; -import { URL } from 'node:url'; -import crypto from 'node:crypto'; - -const apiUrl = process.env.VERSION_API_URL; -const apiKey = process.env.VERSION_API_KEY; -const releaseTag = process.env.RELEASE_TAG; -const releaseNotes = process.env.RELEASE_NOTES; - -if (!apiUrl) throw new Error('VERSION_API_URL is not set'); -if (!apiKey) throw new Error('VERSION_API_KEY is not set'); -if (!releaseTag) throw new Error('RELEASE_TAG is not set'); - -const version = releaseTag.replace(/^v/, ''); -const notes = (releaseNotes || '').trim() || 'Automated release'; -const exePath = path.resolve('src-tauri/target/release/simprint.exe'); - -if (!fs.existsSync(exePath)) { - throw new Error(`Executable not found at ${exePath}`); -} - -const boundary = `----simprint-${cryptoRandomString(16)}`; -const fields = [ - 'type_id', - 'resource_name', - 'version', - 'name', - 'notes', - 'platform', - 'pub_date', -]; -const values = { - type_id: '1', - resource_name: 'simprint.exe', - version, - name: `simprint-${version}.exe`, - notes, - platform: 'windows', - pub_date: new Date().toISOString(), -}; - -const startParts = fields - .map( - (field) => - `--${boundary}\r\nContent-Disposition: form-data; name="${field}"\r\n\r\n${values[field]}\r\n` - ) - .join(''); -const fileHeader = - `--${boundary}\r\nContent-Disposition: form-data; name="file"; filename="simprint.exe"\r\nContent-Type: application/octet-stream\r\n\r\n`; -const endBoundary = `\r\n--${boundary}--\r\n`; - -const preamble = Buffer.from(startParts + fileHeader, 'utf8'); -const ending = Buffer.from(endBoundary, 'utf8'); -const fileStats = fs.statSync(exePath); - -const url = new URL(apiUrl); -const isHttps = url.protocol === 'https:'; -const requestFn = isHttps ? https.request : http.request; -const port = url.port ? Number(url.port) : isHttps ? 443 : 80; -const basePath = url.pathname === '/' ? '' : url.pathname.replace(/\/$/, ''); -const requestPath = `${basePath}/api/v1/versions/create${url.search}`; -const options = { - method: 'POST', - hostname: url.hostname, - port, - path: requestPath, - headers: { - 'Content-Type': `multipart/form-data; boundary=${boundary}`, - 'X-API-KEY': apiKey, - 'Content-Length': preamble.length + fileStats.size + ending.length, - }, -}; -console.log(options); - -const { statusCode, body } = await new Promise((resolve, reject) => { - const req = requestFn(options, (res) => { - const chunks = []; - res.on('data', (chunk) => chunks.push(chunk)); - res.on('end', () => - resolve({ - statusCode: res.statusCode, - body: Buffer.concat(chunks).toString('utf8'), - }) - ); - }); - req.on('error', reject); - req.write(preamble); - const stream = fs.createReadStream(exePath); - stream.on('error', reject); - stream.pipe(req, { end: false }); - stream.on('end', () => req.end(ending)); -}); - -if (statusCode < 200 || statusCode >= 300) { - throw new Error(`versions/create returned ${statusCode}: ${body}`); -} - -console.log('version metadata published', body); - -function cryptoRandomString(length) { - return crypto.randomBytes(length).toString('hex').slice(0, length); -} diff --git a/package.json b/package.json index 05c8106..2015bba 100644 --- a/package.json +++ b/package.json @@ -10,7 +10,8 @@ "build:test": "slotkit generate-imports && vite build --mode test", "deploy:prepare-version": "node deploy/prepare-version.mjs", "deploy:build": "node deploy/build.mjs", - "deploy:latest-json": "node deploy/generate-latest-json.mjs", + "bundle:embed-bootstrapper": "node deploy/build.mjs embedBootstrapper", + "bundle:fixed-runtime": "node deploy/build.mjs fixed-runtime", "lint": "eslint .", "lint:fix": "eslint . --fix", "format": "prettier --write \"**/*.{ts,tsx,json,css,md}\"", @@ -60,6 +61,7 @@ "@tauri-apps/plugin-opener": "^2.5.3", "@tauri-apps/plugin-process": "^2.3.1", "@tauri-apps/plugin-store": "^2.4.2", + "@tauri-apps/plugin-updater": "2.9.0", "@xyflow/react": "^12.10.0", "class-variance-authority": "^0.7.1", "clsx": "^2.1.1", diff --git a/plugins/layouts/app-layout/src/components/titlebar/app-update-button.tsx b/plugins/layouts/app-layout/src/components/titlebar/app-update-button.tsx new file mode 100644 index 0000000..a57206f --- /dev/null +++ b/plugins/layouts/app-layout/src/components/titlebar/app-update-button.tsx @@ -0,0 +1,159 @@ +import { useEffect, useMemo, useState } from 'react'; +import { Download, RefreshCw } from 'lucide-react'; +import { useTranslation } from 'react-i18next'; +import type { DownloadEvent, Update } from '@tauri-apps/plugin-updater'; + +import { + AlertDialog, + AlertDialogCancel, + AlertDialogContent, + AlertDialogDescription, + AlertDialogFooter, + AlertDialogHeader, + AlertDialogTitle, +} from '@/components/ui/alert-dialog'; +import { Button } from '@/components/ui/button'; +import { Progress } from '@/components/ui/progress'; +import { Tooltip, TooltipContent, TooltipTrigger } from '@/components/ui/tooltip'; +import { + APP_UPDATE_AVAILABLE_EVENT, + APP_UPDATE_DIALOG_EVENT, + checkForAppUpdate, + getAvailableAppUpdate, + installAvailableAppUpdate, +} from '@/lib/app-updater'; + +export function AppUpdateButton() { + const { t } = useTranslation('appLayout'); + const [update, setUpdate] = useState(() => getAvailableAppUpdate()); + const [open, setOpen] = useState(false); + const [installing, setInstalling] = useState(false); + const [downloaded, setDownloaded] = useState(0); + const [total, setTotal] = useState(null); + const [error, setError] = useState(null); + + useEffect(() => { + const handleAvailable = (event: Event) => { + const availableUpdate = (event as CustomEvent).detail; + setUpdate(availableUpdate); + setError(null); + setOpen(true); + }; + const handleOpen = () => { + if (getAvailableAppUpdate()) { + setOpen(true); + } + }; + + window.addEventListener(APP_UPDATE_AVAILABLE_EVENT, handleAvailable); + window.addEventListener(APP_UPDATE_DIALOG_EVENT, handleOpen); + + // One non-blocking check is performed after the authenticated main layout mounts. + void checkForAppUpdate().catch((checkError) => { + console.warn('[Updater] Automatic update check failed:', checkError); + }); + + return () => { + window.removeEventListener(APP_UPDATE_AVAILABLE_EVENT, handleAvailable); + window.removeEventListener(APP_UPDATE_DIALOG_EVENT, handleOpen); + }; + }, []); + + const progress = useMemo(() => { + if (!total || total <= 0) return null; + return Math.min(100, Math.round((downloaded / total) * 100)); + }, [downloaded, total]); + + const handleDownloadEvent = (event: DownloadEvent) => { + if (event.event === 'Started') { + setDownloaded(0); + setTotal(event.data.contentLength ?? null); + } else if (event.event === 'Progress') { + setDownloaded((value) => value + event.data.chunkLength); + } + }; + + const handleInstall = async () => { + setInstalling(true); + setDownloaded(0); + setTotal(null); + setError(null); + + try { + await installAvailableAppUpdate(handleDownloadEvent); + } catch (installError) { + console.error('[Updater] Update installation failed:', installError); + setError(t('update.installFailed')); + setInstalling(false); + } + }; + + if (!update) { + return null; + } + + return ( + <> + + + + + {t('update.available')} + + + !installing && setOpen(nextOpen)}> + + +
+
+ +
+
+ {t('update.dialogTitle')} + + {t('update.versionChange', { + current: update.currentVersion, + next: update.version, + })} + +
+
+
+ +
+

{t('update.releaseNotes')}

+
+ {update.body?.trim() || t('update.noReleaseNotes')} +
+ + {installing ? ( +
+
+ {t('update.downloading')} + {progress == null ? t('update.preparing') : `${progress}%`} +
+ +
+ ) : null} + + {error ?

{error}

: null} +
+ + + {t('update.later')} + + +
+
+ + ); +} diff --git a/plugins/layouts/app-layout/src/components/titlebar/default-status-slot.tsx b/plugins/layouts/app-layout/src/components/titlebar/default-status-slot.tsx index 35babe1..a54ee07 100644 --- a/plugins/layouts/app-layout/src/components/titlebar/default-status-slot.tsx +++ b/plugins/layouts/app-layout/src/components/titlebar/default-status-slot.tsx @@ -1,20 +1,20 @@ import { NotificationMenu } from './notification-menu'; import { DownloadMenu } from './download-menu'; -import { PreparedUpdateButton } from './prepared-update-button'; +import { AppUpdateButton } from './app-update-button'; import { UserMenu } from './user-menu'; - -/** - * 默认状态信息区域组件 - */ -export function DefaultStatusSlot() { - return ( + +/** + * 默认状态信息区域组件 + */ +export function DefaultStatusSlot() { + return ( <>
- +
- ); -} + ); +} diff --git a/plugins/layouts/app-layout/src/components/titlebar/prepared-update-button.tsx b/plugins/layouts/app-layout/src/components/titlebar/prepared-update-button.tsx deleted file mode 100644 index 6fc6343..0000000 --- a/plugins/layouts/app-layout/src/components/titlebar/prepared-update-button.tsx +++ /dev/null @@ -1,122 +0,0 @@ -import { useEffect, useState } from 'react'; -import { RefreshCw } from 'lucide-react'; -import { useTranslation } from 'react-i18next'; - -import { - AlertDialog, - AlertDialogCancel, - AlertDialogContent, - AlertDialogDescription, - AlertDialogFooter, - AlertDialogHeader, - AlertDialogTitle, -} from '@/components/ui/alert-dialog'; -import { Button } from '@/components/ui/button'; -import { Tooltip, TooltipContent, TooltipTrigger } from '@/components/ui/tooltip'; -import { invoke } from '@/lib/tauri'; -import { listen } from '@tauri-apps/api/event'; - -interface PreparedUpdateInfo { - kind: string; - version: string; - restartRequired: boolean; -} - -export function PreparedUpdateButton() { - const { t } = useTranslation('appLayout'); - const [update, setUpdate] = useState(null); - const [open, setOpen] = useState(false); - const [installing, setInstalling] = useState(false); - const [error, setError] = useState(null); - - useEffect(() => { - let disposed = false; - let unlisten: (() => void) | undefined; - - const setup = async () => { - const preparedUpdate = await invoke('get_prepared_update'); - if (!disposed && preparedUpdate) { - setUpdate(preparedUpdate); - setError(null); - } - - unlisten = await listen('app-update-ready', (event) => { - if (disposed) { - return; - } - - setUpdate(event.payload); - setError(null); - }); - }; - - void setup(); - - return () => { - disposed = true; - unlisten?.(); - }; - }, []); - - const handleInstall = async () => { - if (!update) { - return; - } - - setInstalling(true); - setError(null); - - try { - await invoke('start_prepared_update_install', { - kind: update.kind, - }); - } catch (installError) { - const message = - installError instanceof Error ? installError.message : t('update.installStartFailed'); - setError(message); - setInstalling(false); - } - }; - - if (!update) { - return null; - } - - return ( - <> - - - - - {t('update.ready')} - - - - - - {t('update.dialogTitle')} - {t('update.dialogDescription')} - - - {error ?

{error}

: null} - - - {t('update.later')} - - -
-
- - ); -} diff --git a/plugins/layouts/app-layout/src/i18n/resources.ts b/plugins/layouts/app-layout/src/i18n/resources.ts index 924c175..5ef014c 100644 --- a/plugins/layouts/app-layout/src/i18n/resources.ts +++ b/plugins/layouts/app-layout/src/i18n/resources.ts @@ -1,173 +1,173 @@ -export const appLayoutResources = { - 'zh-CN': { - nav: { - group: { - basic: '基础功能', - automation: '自动化工具', - collaboration: '协作', - }, - item: { - proxy: '代理中心', - accounts: '账号管理', - extensions: '浏览器插件', - envList: '环境管理', - groups: '分组管理', - rpa: 'RPA 任务流', - api: 'API & AI', - team: '团队管理', - audit: '审计', - settings: '系统设置', - }, - short: { - proxy: '代理', - accounts: '账号', - extensions: '插件', - rpa: 'RPA', - api: 'API', - team: '团队', - }, - actions: { - createEnv: '创建环境', - }, - sidebar: { - expand: '展开侧边栏', - collapse: '折叠侧边栏', - }, - }, - brand: { - tagline: '释放未来', - }, - navigation: { - envList: '环境管理', - groups: '分组管理', - proxy: '代理中心', - accounts: '账号管理', - rpa: 'RPA 任务流', - api: 'API 控制台', - extensions: '浏览器插件', - team: '团队管理', - audit: '审计', - settings: '系统设置', - createWindow: '创建环境', - editTemplate: '编辑模板', - editEnvironment: '编辑环境', - }, - status: { - user: '用户', - theme: '主题', - themeLight: '浅色', - themeDark: '深色', - themeSystem: '跟随系统', +export const appLayoutResources = { + 'zh-CN': { + nav: { + group: { + basic: '基础功能', + automation: '自动化工具', + collaboration: '协作', + }, + item: { + proxy: '代理中心', + accounts: '账号管理', + extensions: '浏览器插件', + envList: '环境管理', + groups: '分组管理', + rpa: 'RPA 任务流', + api: 'API & AI', + team: '团队管理', + audit: '审计', + settings: '系统设置', + }, + short: { + proxy: '代理', + accounts: '账号', + extensions: '插件', + rpa: 'RPA', + api: 'API', + team: '团队', + }, + actions: { + createEnv: '创建环境', + }, + sidebar: { + expand: '展开侧边栏', + collapse: '折叠侧边栏', + }, + }, + brand: { + tagline: '释放未来', + }, + navigation: { + envList: '环境管理', + groups: '分组管理', + proxy: '代理中心', + accounts: '账号管理', + rpa: 'RPA 任务流', + api: 'API 控制台', + extensions: '浏览器插件', + team: '团队管理', + audit: '审计', + settings: '系统设置', + createWindow: '创建环境', + editTemplate: '编辑模板', + editEnvironment: '编辑环境', + }, + status: { + user: '用户', + theme: '主题', + themeLight: '浅色', + themeDark: '深色', + themeSystem: '跟随系统', logout: '切换用户', - settingsAccount: '账户与安全', - settingsGeneral: '通用设置', - settingsBrowser: '浏览器与自动化', - settingsNetwork: '网络与同步', - settingsStorage: '存储与更新', - workspace: '工作空间', - team: '团队', - loadingTeams: '加载中...', - noTeams: '暂无团队', - teamSwitchSuccess: '切换团队成功', - teamSwitchFailed: '切换团队失败', - }, - workspace: { - switch: '切换工作空间', - switchTitle: '切换工作空间', - switchDescription: '选择要切换的工作空间', - switchSuccess: '切换工作空间成功', - switchFailed: '切换工作空间失败', - switchConfirm: '确定要切换到工作空间 "{{name}}" 吗?', - switching: '切换中...', - manage: '管理工作空间', - manageTitle: '管理工作空间', - manageDescription: '创建、编辑和管理您的工作空间', - backToSwitch: '返回切换', - create: '创建工作空间', - createDescription: '创建一个新的工作空间', - createFirst: '创建第一个工作空间', - creating: '创建中...', - edit: '编辑工作空间', - editDescription: '修改工作空间信息', - delete: '删除工作空间', - deleteDescription: '确定要删除工作空间 "{{name}}" 吗?', - deleting: '删除中...', - name: '工作空间名称', - namePlaceholder: '请输入工作空间名称', - nameRequired: '请输入工作空间名称', - current: '当前', - loading: '加载中...', - noWorkspaces: '暂无工作空间', - noWorkspacesDescription: '您还没有任何工作空间', - noMatch: '没有找到匹配的工作空间', - noMatchDescription: '请尝试其他搜索关键词', - searchPlaceholder: '搜索工作空间...', - featureTitle: '工作空间功能', - featureDescription: - '工作空间帮助您将不同的项目和团队环境进行隔离管理。每个工作空间拥有独立的配额、团队和资源,让您的工作更加有序高效。', - selectWorkspace: '请选择一个工作空间', - createSuccess: '创建工作空间成功', - updateSuccess: '更新工作空间成功', - deleteSuccess: '删除工作空间成功', - deleteFailed: '删除工作空间失败', - deleteConfirm: '确认删除', - saveFailed: '保存工作空间失败', - saving: '保存中...', - quota: '配额使用情况', - 'quota.environments': '环境', - 'quota.proxies': '代理', - 'quota.members': '成员', - selected: '项已选择', - selectAll: '全选', - }, - common: { - save: '保存', - cancel: '取消', - edit: '编辑', - delete: '删除', - }, - notification: { - title: '通知', - empty: '暂无消息', - emptyDescription: '新消息会显示在这里', - emptyFiltered: '没有符合条件的消息', - unread: '{{count}} 条未读', - markAllRead: '全部标记为已读', - filterAll: '全部', - filterUnread: '未读', - filterRead: '已读', - filterType: '消息类型', - systemNotification: '系统通知', - teamMessage: '团队消息', - teamInvitation: '团队邀请', - teamRemoval: '团队移除', - teamAnnouncement: '团队公告', - privateChat: '私信', - markRead: '标记为已读', - loading: '加载中...', - loadMore: '加载更多', - noMore: '没有更多了', - acceptInvitation: '接受', - rejectInvitation: '拒绝', - invitationAccepted: '已接受', - invitationRejected: '已拒绝', - acceptInvitationSuccess: '接受邀请成功', - acceptInvitationFailed: '接受邀请失败', - rejectInvitationSuccess: '拒绝邀请成功', - rejectInvitationFailed: '拒绝邀请失败', - openDialog: '打开消息中心', - viewAll: '查看全部消息', - total: '条消息', - unreadCount: '未读', - refresh: '刷新', - tableType: '类型', - tableTitle: '标题', - tableTime: '时间', - tableStatus: '状态', - tableActions: '操作', - prevPage: '上一页', - nextPage: '下一页', - }, + settingsAccount: '账户与安全', + settingsGeneral: '通用设置', + settingsBrowser: '浏览器与自动化', + settingsNetwork: '网络与同步', + settingsStorage: '存储与更新', + workspace: '工作空间', + team: '团队', + loadingTeams: '加载中...', + noTeams: '暂无团队', + teamSwitchSuccess: '切换团队成功', + teamSwitchFailed: '切换团队失败', + }, + workspace: { + switch: '切换工作空间', + switchTitle: '切换工作空间', + switchDescription: '选择要切换的工作空间', + switchSuccess: '切换工作空间成功', + switchFailed: '切换工作空间失败', + switchConfirm: '确定要切换到工作空间 "{{name}}" 吗?', + switching: '切换中...', + manage: '管理工作空间', + manageTitle: '管理工作空间', + manageDescription: '创建、编辑和管理您的工作空间', + backToSwitch: '返回切换', + create: '创建工作空间', + createDescription: '创建一个新的工作空间', + createFirst: '创建第一个工作空间', + creating: '创建中...', + edit: '编辑工作空间', + editDescription: '修改工作空间信息', + delete: '删除工作空间', + deleteDescription: '确定要删除工作空间 "{{name}}" 吗?', + deleting: '删除中...', + name: '工作空间名称', + namePlaceholder: '请输入工作空间名称', + nameRequired: '请输入工作空间名称', + current: '当前', + loading: '加载中...', + noWorkspaces: '暂无工作空间', + noWorkspacesDescription: '您还没有任何工作空间', + noMatch: '没有找到匹配的工作空间', + noMatchDescription: '请尝试其他搜索关键词', + searchPlaceholder: '搜索工作空间...', + featureTitle: '工作空间功能', + featureDescription: + '工作空间帮助您将不同的项目和团队环境进行隔离管理。每个工作空间拥有独立的配额、团队和资源,让您的工作更加有序高效。', + selectWorkspace: '请选择一个工作空间', + createSuccess: '创建工作空间成功', + updateSuccess: '更新工作空间成功', + deleteSuccess: '删除工作空间成功', + deleteFailed: '删除工作空间失败', + deleteConfirm: '确认删除', + saveFailed: '保存工作空间失败', + saving: '保存中...', + quota: '配额使用情况', + 'quota.environments': '环境', + 'quota.proxies': '代理', + 'quota.members': '成员', + selected: '项已选择', + selectAll: '全选', + }, + common: { + save: '保存', + cancel: '取消', + edit: '编辑', + delete: '删除', + }, + notification: { + title: '通知', + empty: '暂无消息', + emptyDescription: '新消息会显示在这里', + emptyFiltered: '没有符合条件的消息', + unread: '{{count}} 条未读', + markAllRead: '全部标记为已读', + filterAll: '全部', + filterUnread: '未读', + filterRead: '已读', + filterType: '消息类型', + systemNotification: '系统通知', + teamMessage: '团队消息', + teamInvitation: '团队邀请', + teamRemoval: '团队移除', + teamAnnouncement: '团队公告', + privateChat: '私信', + markRead: '标记为已读', + loading: '加载中...', + loadMore: '加载更多', + noMore: '没有更多了', + acceptInvitation: '接受', + rejectInvitation: '拒绝', + invitationAccepted: '已接受', + invitationRejected: '已拒绝', + acceptInvitationSuccess: '接受邀请成功', + acceptInvitationFailed: '接受邀请失败', + rejectInvitationSuccess: '拒绝邀请成功', + rejectInvitationFailed: '拒绝邀请失败', + openDialog: '打开消息中心', + viewAll: '查看全部消息', + total: '条消息', + unreadCount: '未读', + refresh: '刷新', + tableType: '类型', + tableTitle: '标题', + tableTime: '时间', + tableStatus: '状态', + tableActions: '操作', + prevPage: '上一页', + nextPage: '下一页', + }, download: { title: '下载', empty: '暂无下载任务', @@ -175,185 +175,189 @@ export const appLayoutResources = { clearCompleted: '清除已完成', }, update: { - ready: '更新已就绪', - dialogTitle: '更新已准备完成', - dialogDescription: '检测到新的更新内容。立即重启应用以完成安装。', + available: '发现新版本', + dialogTitle: 'Simprint 有新版本', + versionChange: 'v{{current}} → v{{next}}', + releaseNotes: '更新内容', + noReleaseNotes: '本次更新没有提供说明。', later: '稍后', - restartAndInstall: '重启并安装', - restarting: '正在重启...', - installStartFailed: '启动更新安装失败', + downloadAndInstall: '下载并安装', + preparing: '准备下载', + downloading: '正在下载更新', + installing: '下载并安装中...', + installFailed: '更新下载或安装失败,请稍后重试。', }, }, - 'en-US': { - nav: { - group: { - basic: 'Basics', - automation: 'Automation', - collaboration: 'Collaboration', - }, - item: { - proxy: 'Proxy Center', - accounts: 'Accounts', - extensions: 'Extensions', - envList: 'Environments', - groups: 'Groups', - rpa: 'RPA Workflows', - api: 'API & AI', - team: 'Team', - audit: 'Audit', - settings: 'System Settings', - }, - short: { - proxy: 'Proxy', - accounts: 'Acc', - extensions: 'Ext', - rpa: 'RPA', - api: 'API', - team: 'Team', - }, - actions: { - createEnv: 'Create Environment', - }, - sidebar: { - expand: 'Expand Sidebar', - collapse: 'Collapse Sidebar', - }, - }, - brand: { - tagline: 'Unleash the Future', - }, - navigation: { - envList: 'Environments', - groups: 'Groups', - proxy: 'Proxy Center', - accounts: 'Account Center', - rpa: 'RPA Workflows', - api: 'API Console', - extensions: 'Browser Extensions', - team: 'Team Management', - audit: 'Audit', - settings: 'System Settings', - createWindow: 'Create Window', - editTemplate: 'Edit Template', - editEnvironment: 'Edit Environment', - }, - status: { - user: 'User', - theme: 'Theme', - themeLight: 'Light', - themeDark: 'Dark', - themeSystem: 'System', + 'en-US': { + nav: { + group: { + basic: 'Basics', + automation: 'Automation', + collaboration: 'Collaboration', + }, + item: { + proxy: 'Proxy Center', + accounts: 'Accounts', + extensions: 'Extensions', + envList: 'Environments', + groups: 'Groups', + rpa: 'RPA Workflows', + api: 'API & AI', + team: 'Team', + audit: 'Audit', + settings: 'System Settings', + }, + short: { + proxy: 'Proxy', + accounts: 'Acc', + extensions: 'Ext', + rpa: 'RPA', + api: 'API', + team: 'Team', + }, + actions: { + createEnv: 'Create Environment', + }, + sidebar: { + expand: 'Expand Sidebar', + collapse: 'Collapse Sidebar', + }, + }, + brand: { + tagline: 'Unleash the Future', + }, + navigation: { + envList: 'Environments', + groups: 'Groups', + proxy: 'Proxy Center', + accounts: 'Account Center', + rpa: 'RPA Workflows', + api: 'API Console', + extensions: 'Browser Extensions', + team: 'Team Management', + audit: 'Audit', + settings: 'System Settings', + createWindow: 'Create Window', + editTemplate: 'Edit Template', + editEnvironment: 'Edit Environment', + }, + status: { + user: 'User', + theme: 'Theme', + themeLight: 'Light', + themeDark: 'Dark', + themeSystem: 'System', logout: 'Switch User', - settingsAccount: 'Account & Security', - settingsGeneral: 'General', - settingsBrowser: 'Browser & Automation', - settingsNetwork: 'Network & Sync', - settingsStorage: 'Storage & Update', - workspace: 'Workspace', - team: 'Team', - loadingTeams: 'Loading...', - noTeams: 'No teams available', - teamSwitchSuccess: 'Team switched successfully', - teamSwitchFailed: 'Failed to switch team', - }, - workspace: { - switch: 'Switch Workspace', - switchTitle: 'Switch Workspace', - switchDescription: 'Select a workspace to switch to', - switchSuccess: 'Workspace switched successfully', - switchFailed: 'Failed to switch workspace', - switchConfirm: 'Are you sure you want to switch to workspace "{{name}}"?', - switching: 'Switching...', - manage: 'Manage Workspaces', - manageTitle: 'Manage Workspaces', - manageDescription: 'Create, edit, and manage your workspaces', - backToSwitch: 'Back to Switch', - create: 'Create Workspace', - createDescription: 'Create a new workspace', - createFirst: 'Create First Workspace', - creating: 'Creating...', - edit: 'Edit Workspace', - editDescription: 'Modify workspace information', - delete: 'Delete Workspace', - deleteDescription: 'Are you sure you want to delete workspace "{{name}}"?', - deleting: 'Deleting...', - name: 'Workspace Name', - namePlaceholder: 'Enter workspace name', - nameRequired: 'Please enter workspace name', - current: 'Current', - loading: 'Loading...', - noWorkspaces: 'No workspaces available', - noWorkspacesDescription: "You don't have any workspaces yet", - noMatch: 'No matching workspaces found', - noMatchDescription: 'Please try other search keywords', - searchPlaceholder: 'Search workspaces...', - featureTitle: 'Workspace Features', - featureDescription: - 'Workspaces help you isolate and manage different projects and team environments. Each workspace has independent quotas, teams, and resources, making your work more organized and efficient.', - selectWorkspace: 'Please select a workspace', - createSuccess: 'Workspace created successfully', - updateSuccess: 'Workspace updated successfully', - deleteSuccess: 'Workspace deleted successfully', - deleteFailed: 'Failed to delete workspace', - deleteConfirm: 'Confirm Delete', - saveFailed: 'Failed to save workspace', - saving: 'Saving...', - quota: 'Quota Usage', - 'quota.environments': 'Environments', - 'quota.proxies': 'Proxies', - 'quota.members': 'Members', - selected: 'items selected', - selectAll: 'Select All', - }, - common: { - save: 'Save', - cancel: 'Cancel', - edit: 'Edit', - delete: 'Delete', - close: 'Close', - }, - notification: { - title: 'Notifications', - empty: 'No messages', - emptyDescription: 'New messages will appear here', - emptyFiltered: 'No messages match the filter', - unread: '{{count}} unread', - markAllRead: 'Mark all as read', - filterAll: 'All', - filterUnread: 'Unread', - filterRead: 'Read', - filterType: 'Message Type', - systemNotification: 'System Notification', - teamMessage: 'Team', - teamInvitation: 'Team Invitation', - teamRemoval: 'Team Removal', - teamAnnouncement: 'Team Announcement', - privateChat: 'Private Chat', - markRead: 'Mark as read', - loading: 'Loading...', - loadMore: 'Load more', - noMore: 'No more', - acceptInvitation: 'Accept', - rejectInvitation: 'Reject', - invitationAccepted: 'Accepted', - invitationRejected: 'Rejected', - acceptInvitationSuccess: 'Invitation accepted successfully', - acceptInvitationFailed: 'Failed to accept invitation', - rejectInvitationSuccess: 'Invitation rejected successfully', - rejectInvitationFailed: 'Failed to reject invitation', - openDialog: 'Open Messages Center', - viewAll: 'View all messages', - total: 'messages', - unreadCount: 'Unread', - refresh: 'Refresh', - tableType: 'Type', - tableTitle: 'Title', - tableTime: 'Time', - tableStatus: 'Status', - tableActions: 'Actions', - prevPage: 'Previous', - nextPage: 'Next', - }, + settingsAccount: 'Account & Security', + settingsGeneral: 'General', + settingsBrowser: 'Browser & Automation', + settingsNetwork: 'Network & Sync', + settingsStorage: 'Storage & Update', + workspace: 'Workspace', + team: 'Team', + loadingTeams: 'Loading...', + noTeams: 'No teams available', + teamSwitchSuccess: 'Team switched successfully', + teamSwitchFailed: 'Failed to switch team', + }, + workspace: { + switch: 'Switch Workspace', + switchTitle: 'Switch Workspace', + switchDescription: 'Select a workspace to switch to', + switchSuccess: 'Workspace switched successfully', + switchFailed: 'Failed to switch workspace', + switchConfirm: 'Are you sure you want to switch to workspace "{{name}}"?', + switching: 'Switching...', + manage: 'Manage Workspaces', + manageTitle: 'Manage Workspaces', + manageDescription: 'Create, edit, and manage your workspaces', + backToSwitch: 'Back to Switch', + create: 'Create Workspace', + createDescription: 'Create a new workspace', + createFirst: 'Create First Workspace', + creating: 'Creating...', + edit: 'Edit Workspace', + editDescription: 'Modify workspace information', + delete: 'Delete Workspace', + deleteDescription: 'Are you sure you want to delete workspace "{{name}}"?', + deleting: 'Deleting...', + name: 'Workspace Name', + namePlaceholder: 'Enter workspace name', + nameRequired: 'Please enter workspace name', + current: 'Current', + loading: 'Loading...', + noWorkspaces: 'No workspaces available', + noWorkspacesDescription: "You don't have any workspaces yet", + noMatch: 'No matching workspaces found', + noMatchDescription: 'Please try other search keywords', + searchPlaceholder: 'Search workspaces...', + featureTitle: 'Workspace Features', + featureDescription: + 'Workspaces help you isolate and manage different projects and team environments. Each workspace has independent quotas, teams, and resources, making your work more organized and efficient.', + selectWorkspace: 'Please select a workspace', + createSuccess: 'Workspace created successfully', + updateSuccess: 'Workspace updated successfully', + deleteSuccess: 'Workspace deleted successfully', + deleteFailed: 'Failed to delete workspace', + deleteConfirm: 'Confirm Delete', + saveFailed: 'Failed to save workspace', + saving: 'Saving...', + quota: 'Quota Usage', + 'quota.environments': 'Environments', + 'quota.proxies': 'Proxies', + 'quota.members': 'Members', + selected: 'items selected', + selectAll: 'Select All', + }, + common: { + save: 'Save', + cancel: 'Cancel', + edit: 'Edit', + delete: 'Delete', + close: 'Close', + }, + notification: { + title: 'Notifications', + empty: 'No messages', + emptyDescription: 'New messages will appear here', + emptyFiltered: 'No messages match the filter', + unread: '{{count}} unread', + markAllRead: 'Mark all as read', + filterAll: 'All', + filterUnread: 'Unread', + filterRead: 'Read', + filterType: 'Message Type', + systemNotification: 'System Notification', + teamMessage: 'Team', + teamInvitation: 'Team Invitation', + teamRemoval: 'Team Removal', + teamAnnouncement: 'Team Announcement', + privateChat: 'Private Chat', + markRead: 'Mark as read', + loading: 'Loading...', + loadMore: 'Load more', + noMore: 'No more', + acceptInvitation: 'Accept', + rejectInvitation: 'Reject', + invitationAccepted: 'Accepted', + invitationRejected: 'Rejected', + acceptInvitationSuccess: 'Invitation accepted successfully', + acceptInvitationFailed: 'Failed to accept invitation', + rejectInvitationSuccess: 'Invitation rejected successfully', + rejectInvitationFailed: 'Failed to reject invitation', + openDialog: 'Open Messages Center', + viewAll: 'View all messages', + total: 'messages', + unreadCount: 'Unread', + refresh: 'Refresh', + tableType: 'Type', + tableTitle: 'Title', + tableTime: 'Time', + tableStatus: 'Status', + tableActions: 'Actions', + prevPage: 'Previous', + nextPage: 'Next', + }, download: { title: 'Downloads', empty: 'No download tasks', @@ -361,13 +365,17 @@ export const appLayoutResources = { clearCompleted: 'Clear completed', }, update: { - ready: 'Update ready', - dialogTitle: 'Update Ready', - dialogDescription: 'A prepared update is ready. Restart the app now to finish installation.', + available: 'Update available', + dialogTitle: 'A new Simprint version is available', + versionChange: 'v{{current}} → v{{next}}', + releaseNotes: 'What’s new', + noReleaseNotes: 'No release notes were provided for this update.', later: 'Later', - restartAndInstall: 'Restart and Install', - restarting: 'Restarting...', - installStartFailed: 'Failed to start update install.', + downloadAndInstall: 'Download and Install', + preparing: 'Preparing download', + downloading: 'Downloading update', + installing: 'Downloading and installing...', + installFailed: 'The update could not be downloaded or installed. Please try again later.', }, }, } as const; diff --git a/plugins/pages/system-settings/src/components/storage-panel.tsx b/plugins/pages/system-settings/src/components/storage-panel.tsx index 972cc76..82e31d9 100644 --- a/plugins/pages/system-settings/src/components/storage-panel.tsx +++ b/plugins/pages/system-settings/src/components/storage-panel.tsx @@ -1,58 +1,78 @@ -import { useState, useEffect, useCallback } from 'react'; -import { useTranslation } from 'react-i18next'; -import { - HardDrive, - FolderOpen, - Download, - RefreshCcw, - FlaskConical, - Database, - FileBox, - FileText, - Copy, - ExternalLink, - Settings, - RotateCcw, - Loader2, - X, -} from 'lucide-react'; -import { getVersion } from '@tauri-apps/api/app'; -import { invoke } from '@/lib/tauri'; -import { relaunch } from '@tauri-apps/plugin-process'; -import { toast } from 'sonner'; -import { Switch } from '@/components/ui/switch'; +import { useState, useEffect, useCallback } from 'react'; +import { useTranslation } from 'react-i18next'; +import { + HardDrive, + FolderOpen, + Download, + RefreshCcw, + Database, + FileBox, + FileText, + Copy, + ExternalLink, + Settings, + RotateCcw, + Loader2, + X, +} from 'lucide-react'; +import { getVersion } from '@tauri-apps/api/app'; +import { toast } from 'sonner'; import { Button } from '@/components/ui/button'; +import { checkForAppUpdate, openAppUpdateDialog } from '@/lib/app-updater'; import { FormattedDialog, FormattedDialogFooter } from '@/components/formatted-dialog'; import { SettingCard } from './setting-card'; -import { SettingRow } from './setting-row'; import { StoragePanelSkeleton } from './storage-panel-skeleton'; -import { - getStorageSettings, - setStorageSettings, -} from '../../../../services/store/src'; -import { - getStorageDefaultPaths, - getDirectorySizes, -} from '../api/storage'; +import { getStorageSettings, setStorageSettings } from '../../../../services/store/src'; +import { getStorageDefaultPaths, getDirectorySizes } from '../api/storage'; import { openPath } from '@tauri-apps/plugin-opener'; import { open as openFolderDialog } from '@tauri-apps/plugin-dialog'; import type { DirectorySizeCache, StoragePathKey } from '../../../../services/store/src'; - -interface StorageItemDef { - id: string; - key: StoragePathKey | 'profilesPath' | 'downloadsPath'; - nameKey: string; - descKey: string; - icon: React.ElementType; - color: string; - readonly?: boolean; // 只读目录,不允许用户配置 -} - + +interface StorageItemDef { + id: string; + key: StoragePathKey | 'profilesPath' | 'downloadsPath'; + nameKey: string; + descKey: string; + icon: React.ElementType; + color: string; + readonly?: boolean; // 只读目录,不允许用户配置 +} + const STORAGE_ITEM_DEFS: StorageItemDef[] = [ - { id: 'profiles', key: 'profilesPath', nameKey: 'storageProfiles', descKey: 'storageProfilesDesc', icon: Database, color: 'bg-blue-500', readonly: true }, - { id: 'cache', key: 'cachePath', nameKey: 'storageCache', descKey: 'storageCacheDesc', icon: HardDrive, color: 'bg-amber-500' }, - { id: 'logs', key: 'logsPath', nameKey: 'storageLogs', descKey: 'storageLogsDesc', icon: FileText, color: 'bg-emerald-500' }, - { id: 'downloads', key: 'downloadsPath', nameKey: 'storageDownloads', descKey: 'storageDownloadsDesc', icon: FileBox, color: 'bg-purple-500', readonly: true }, + { + id: 'profiles', + key: 'profilesPath', + nameKey: 'storageProfiles', + descKey: 'storageProfilesDesc', + icon: Database, + color: 'bg-blue-500', + readonly: true, + }, + { + id: 'cache', + key: 'cachePath', + nameKey: 'storageCache', + descKey: 'storageCacheDesc', + icon: HardDrive, + color: 'bg-amber-500', + }, + { + id: 'logs', + key: 'logsPath', + nameKey: 'storageLogs', + descKey: 'storageLogsDesc', + icon: FileText, + color: 'bg-emerald-500', + }, + { + id: 'downloads', + key: 'downloadsPath', + nameKey: 'storageDownloads', + descKey: 'storageDownloadsDesc', + icon: FileBox, + color: 'bg-purple-500', + readonly: true, + }, ]; const EMPTY_SIZES = STORAGE_ITEM_DEFS.map(() => 0); @@ -63,27 +83,25 @@ function isDirectorySizeCacheValid( ): cache is DirectorySizeCache { return Boolean( cache && - cache.paths.length === paths.length && - cache.sizes.length === paths.length && - cache.paths.every((path, index) => path === paths[index]) + cache.paths.length === paths.length && + cache.sizes.length === paths.length && + cache.paths.every((path, index) => path === paths[index]) ); } - -/** - * 存储与更新面板 - */ -export const StoragePanel: React.FC = () => { - const { t } = useTranslation('settings'); - const [betaChannel, setBetaChannel] = useState(true); - const [storageSettingsLoaded, setStorageSettingsLoaded] = useState(false); - const [appVersion, setAppVersion] = useState(null); - - const [defaultPaths, setDefaultPaths] = useState<{ - app_base: string; - profiles: string; - cache: string; - logs: string; - downloads: string; + +/** + * 存储与更新面板 + */ +export const StoragePanel: React.FC = () => { + const { t } = useTranslation('settings'); + const [appVersion, setAppVersion] = useState(null); + + const [defaultPaths, setDefaultPaths] = useState<{ + app_base: string; + profiles: string; + cache: string; + logs: string; + downloads: string; } | null>(null); const [paths, setPaths] = useState([]); const [sizes, setSizes] = useState(EMPTY_SIZES); @@ -93,49 +111,29 @@ export const StoragePanel: React.FC = () => { const [sizesUpdatedAt, setSizesUpdatedAt] = useState(null); const [restoreConfirmOpen, setRestoreConfirmOpen] = useState(false); const [restoreSubmitting, setRestoreSubmitting] = useState(false); - - const [hasUpdate, setHasUpdate] = useState(false); - const [updateChecking, setUpdateChecking] = useState(false); - const [updateChecked, setUpdateChecked] = useState(false); - - useEffect(() => { - let cancelled = false; - void getStorageSettings() - .then((s) => { - if (!cancelled) { - setBetaChannel(s.betaChannel); - setStorageSettingsLoaded(true); - } - }) - .catch(() => { - if (!cancelled) setStorageSettingsLoaded(true); - }); - return () => { - cancelled = true; - }; - }, []); - - useEffect(() => { - let cancelled = false; - void getVersion() - .then((v) => { - if (!cancelled) setAppVersion(v); - }) - .catch(() => { - if (!cancelled) setAppVersion(null); - }); - return () => { - cancelled = true; - }; - }, []); - + + const [hasUpdate, setHasUpdate] = useState(false); + const [updateChecking, setUpdateChecking] = useState(false); + const [updateChecked, setUpdateChecked] = useState(false); + + useEffect(() => { + let cancelled = false; + void getVersion() + .then((v) => { + if (!cancelled) setAppVersion(v); + }) + .catch(() => { + if (!cancelled) setAppVersion(null); + }); + return () => { + cancelled = true; + }; + }, []); + const loadPathsAndSizes = useCallback(async () => { setPathsLoading(true); try { - const [defs, storage] = await Promise.all([ - getStorageDefaultPaths(), - getStorageSettings(), - ]); + const [defs, storage] = await Promise.all([getStorageDefaultPaths(), getStorageSettings()]); setDefaultPaths(defs); const resolvedPaths = STORAGE_ITEM_DEFS.map((d) => { @@ -182,92 +180,84 @@ export const StoragePanel: React.FC = () => { setSizesRefreshing(false); } }, []); - - useEffect(() => { - void loadPathsAndSizes(); - }, [loadPathsAndSizes]); - - const handleBetaChannelChange = useCallback((checked: boolean) => { - setBetaChannel(checked); - void setStorageSettings({ betaChannel: checked }); - }, []); - - const totalSizeBytes = sizes.reduce((a, b) => a + b, 0); - - const handleCopyPath = (path: string) => { - navigator.clipboard.writeText(path); - }; - - const handleOpenFolder = useCallback((path: string) => { - void openPath(path); - }, []); - - const handleOpenAppFolder = useCallback(() => { - if (defaultPaths?.app_base) { - void openPath(defaultPaths.app_base); - } - }, [defaultPaths?.app_base]); - - const handleSetPath = useCallback( - async (def: StorageItemDef) => { - const selected = await openFolderDialog({ - directory: true, - multiple: false, - }); - if (selected && typeof selected === 'string') { - await setStorageSettings({ [def.key]: selected }); - await loadPathsAndSizes(); - } - }, - [loadPathsAndSizes] - ); - - const handleOpenRestoreConfirm = useCallback(() => { - setRestoreConfirmOpen(true); - }, []); - - const handleCheckUpdate = useCallback(async () => { - setUpdateChecking(true); - try { - const available = await invoke('check_update_available'); - setHasUpdate(available); - setUpdateChecked(true); - if (available) { - toast.success(t('updateAvailable')); - } else { - toast.success(t('alreadyLatest')); - } - } catch (e) { - toast.error(t('checkUpdateFailed') || '检查更新失败'); - setHasUpdate(false); - setUpdateChecked(true); - } finally { - setUpdateChecking(false); - } - }, [t]); - - const handleUpdateNow = useCallback(async () => { - try { - await relaunch(); - } catch (e) { - toast.error(t('updateNowFailed') || '立即更新失败'); - } - }, [t]); - - const handleConfirmRestore = useCallback(async () => { - setRestoreSubmitting(true); - setRestoreConfirmOpen(false); - try { - await setStorageSettings({ - cachePath: undefined, - logsPath: undefined, - }); - await loadPathsAndSizes(); - } finally { - setRestoreSubmitting(false); - } - }, [loadPathsAndSizes]); - + + useEffect(() => { + void loadPathsAndSizes(); + }, [loadPathsAndSizes]); + + const totalSizeBytes = sizes.reduce((a, b) => a + b, 0); + + const handleCopyPath = (path: string) => { + navigator.clipboard.writeText(path); + }; + + const handleOpenFolder = useCallback((path: string) => { + void openPath(path); + }, []); + + const handleOpenAppFolder = useCallback(() => { + if (defaultPaths?.app_base) { + void openPath(defaultPaths.app_base); + } + }, [defaultPaths?.app_base]); + + const handleSetPath = useCallback( + async (def: StorageItemDef) => { + const selected = await openFolderDialog({ + directory: true, + multiple: false, + }); + if (selected && typeof selected === 'string') { + await setStorageSettings({ [def.key]: selected }); + await loadPathsAndSizes(); + } + }, + [loadPathsAndSizes] + ); + + const handleOpenRestoreConfirm = useCallback(() => { + setRestoreConfirmOpen(true); + }, []); + + const handleCheckUpdate = useCallback(async () => { + setUpdateChecking(true); + try { + const update = await checkForAppUpdate({ force: true }); + setHasUpdate(Boolean(update)); + setUpdateChecked(true); + if (update) { + toast.success(t('updateAvailable')); + openAppUpdateDialog(); + } else { + toast.success(t('alreadyLatest')); + } + } catch { + toast.error(t('checkUpdateFailed') || '检查更新失败'); + setHasUpdate(false); + setUpdateChecked(true); + } finally { + setUpdateChecking(false); + } + }, [t]); + + const handleUpdateNow = useCallback(() => { + openAppUpdateDialog(); + }, []); + + const handleConfirmRestore = useCallback(async () => { + setRestoreSubmitting(true); + setRestoreConfirmOpen(false); + try { + await setStorageSettings({ + cachePath: undefined, + logsPath: undefined, + }); + await loadPathsAndSizes(); + } finally { + setRestoreSubmitting(false); + } + }, [loadPathsAndSizes]); + const formatSize = (bytes: number) => { const mb = bytes / (1024 * 1024); if (mb >= 1024) return `${(mb / 1024).toFixed(1)} GB`; @@ -394,118 +384,110 @@ export const StoragePanel: React.FC = () => {
)} - - {/* 快捷操作 */} + + {/* 快捷操作 */}
- - -
- - - {/* 恢复默认确认弹窗 */} - - - - - - - - {/* 版本更新 */} - - - - - - {/* 当前版本 */} -
-
-

{t('currentVersion')}

-

- {appVersion != null ? `v${appVersion}` : '—'} -

-
- {hasUpdate ? ( + + +
+
+ + {/* 恢复默认确认弹窗 */} + + + + + + + + {/* 版本更新 */} + + {/* 当前版本 */} +
+
+

{t('currentVersion')}

+

+ {appVersion != null ? `v${appVersion}` : '—'} +

+
+ {hasUpdate ? ( - ) : ( + + {t('updateNow')} + + ) : ( - )} -
-
-
- ); -}; + {updateChecking ? ( + <> + + {t('checking')} + + ) : ( + <> + + {updateChecked ? t('alreadyLatest') : t('checkUpdate')} + + )} + + )} +
+ +
+ ); +}; diff --git a/pnpm-lock.yaml b/pnpm-lock.yaml index 07a9419..4b7e652 100644 --- a/pnpm-lock.yaml +++ b/pnpm-lock.yaml @@ -116,6 +116,9 @@ importers: '@tauri-apps/plugin-store': specifier: ^2.4.2 version: 2.4.2 + '@tauri-apps/plugin-updater': + specifier: 2.9.0 + version: 2.9.0 '@xyflow/react': specifier: ^12.10.0 version: 12.10.0(@types/react@19.2.7)(react-dom@19.2.3(react@19.2.3))(react@19.2.3) @@ -1648,6 +1651,9 @@ packages: peerDependencies: vite: ^5.2.0 || ^6 || ^7 + '@tauri-apps/api@2.11.1': + resolution: {integrity: sha512-M2FPuYND2m+wh5hfW9ZpSdxMPdEJovPBWwoHJmwUpysTYNHaOkVFN419m/K0LIgjb/7KU2vBgsUepJWugQCvAA==} + '@tauri-apps/api@2.9.1': resolution: {integrity: sha512-IGlhP6EivjXHepbBic618GOmiWe4URJiIeZFlB7x3czM0yDHHYviH1Xvoiv4FefdkQtn6v7TuwWCRfOGdnVUGw==} @@ -1666,6 +1672,9 @@ packages: '@tauri-apps/plugin-store@2.4.2': resolution: {integrity: sha512-0ClHS50Oq9HEvLPhNzTNFxbWVOqoAp3dRvtewQBeqfIQ0z5m3JRnOISIn2ZVPCrQC0MyGyhTS9DWhHjpigQE7A==} + '@tauri-apps/plugin-updater@2.9.0': + resolution: {integrity: sha512-j++sgY8XpeDvzImTrzWA08OqqGqgkNyxczLD7FjNJJx/uXxMZFz5nDcfkyoI/rCjYuj2101Tci/r/HFmOmoxCg==} + '@types/babel__core@7.20.5': resolution: {integrity: sha512-qoQprZvz5wQFJwMDqeseRXWv3rqMvhgpbXFfVyWhbx9X47POIA6i/+dXefEmZKoAgOaTdaIgNSMqMIU61yRyzA==} @@ -4283,6 +4292,8 @@ snapshots: tailwindcss: 4.1.18 vite: 7.3.1(@types/node@24.10.4)(jiti@2.6.1)(lightningcss@1.30.2) + '@tauri-apps/api@2.11.1': {} + '@tauri-apps/api@2.9.1': {} '@tauri-apps/plugin-clipboard-manager@2.3.2': @@ -4305,6 +4316,10 @@ snapshots: dependencies: '@tauri-apps/api': 2.9.1 + '@tauri-apps/plugin-updater@2.9.0': + dependencies: + '@tauri-apps/api': 2.11.1 + '@types/babel__core@7.20.5': dependencies: '@babel/parser': 7.28.5 diff --git a/src-tauri/Cargo.lock b/src-tauri/Cargo.lock index efbb951..3408de0 100644 --- a/src-tauri/Cargo.lock +++ b/src-tauri/Cargo.lock @@ -8,16 +8,6 @@ version = "2.0.1" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "320119579fcad9c21884f5c4861d16174d0e06250625266f50fe6898340abefa" -[[package]] -name = "aead" -version = "0.5.2" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "d122413f284cf2d62fb1b7db97e02edb8cda96d769b16e443a4f6195e35662b0" -dependencies = [ - "crypto-common", - "generic-array", -] - [[package]] name = "aes" version = "0.8.4" @@ -29,20 +19,6 @@ dependencies = [ "cpufeatures 0.2.17", ] -[[package]] -name = "aes-gcm" -version = "0.10.3" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "831010a0f742e1209b3bcea8fab6a8e149051ba6099432c8cb2cc117dec3ead1" -dependencies = [ - "aead", - "aes", - "cipher", - "ctr", - "ghash", - "subtle", -] - [[package]] name = "ahash" version = "0.7.8" @@ -146,7 +122,7 @@ version = "1.1.5" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "40c48f72fd53cd289104fc64099abca73db4166ad86ea0b4341abe65af83dadc" dependencies = [ - "windows-sys 0.61.2", + "windows-sys 0.60.2", ] [[package]] @@ -157,7 +133,7 @@ checksum = "291e6a250ff86cd4a820112fb8898808a366d8f9f58ce16d1f538353ad55747d" dependencies = [ "anstyle", "once_cell_polyfill", - "windows-sys 0.61.2", + "windows-sys 0.60.2", ] [[package]] @@ -259,7 +235,7 @@ dependencies = [ "futures-lite", "parking", "polling", - "rustix 1.1.4", + "rustix", "slab", "windows-sys 0.61.2", ] @@ -290,7 +266,7 @@ dependencies = [ "cfg-if", "event-listener", "futures-lite", - "rustix 1.1.4", + "rustix", ] [[package]] @@ -316,7 +292,7 @@ dependencies = [ "cfg-if", "futures-core", "futures-io", - "rustix 1.1.4", + "rustix", "signal-hook-registry", "slab", "windows-sys 0.61.2", @@ -1063,7 +1039,6 @@ source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "78c8292055d1c1df0cce5d180393dc8cce0abec0a7102adb6c7b1eef6016d60a" dependencies = [ "generic-array", - "rand_core 0.6.4", "typenum", ] @@ -1104,15 +1079,6 @@ dependencies = [ "syn 2.0.114", ] -[[package]] -name = "ctr" -version = "0.9.2" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "0369ee1ad671834580515889b80f2ea915f23b8be8d0daa4bbaf2ac5c7590835" -dependencies = [ - "cipher", -] - [[package]] name = "darling" version = "0.21.3" @@ -1292,7 +1258,7 @@ dependencies = [ "libc", "option-ext", "redox_users 0.5.2", - "windows-sys 0.61.2", + "windows-sys 0.60.2", ] [[package]] @@ -1413,20 +1379,6 @@ dependencies = [ "serde", ] -[[package]] -name = "embed-resource" -version = "2.5.2" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "d506610004cfc74a6f5ee7e8c632b355de5eca1f03ee5e5e0ec11b77d4eb3d61" -dependencies = [ - "cc", - "memchr", - "rustc_version", - "toml 0.8.2", - "vswhom", - "winreg 0.52.0", -] - [[package]] name = "embed-resource" version = "3.0.6" @@ -1530,7 +1482,7 @@ source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "39cab71617ae0d63f51a36d69f866391735b51691dbda63cf6f96d042b63efeb" dependencies = [ "libc", - "windows-sys 0.61.2", + "windows-sys 0.59.0", ] [[package]] @@ -1624,6 +1576,16 @@ dependencies = [ "rustc_version", ] +[[package]] +name = "filetime" +version = "0.2.29" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "5c287a33c7f0a620c38e641e7f60827713987b3c0f26e8ddc9462cc69cf75759" +dependencies = [ + "cfg-if", + "libc", +] + [[package]] name = "find-msvc-tools" version = "0.1.6" @@ -1973,7 +1935,7 @@ version = "1.1.0" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "1bd49230192a3797a9a4d6abe9b3eed6f7fa4c8a8a4947977c6f80025f92cbd8" dependencies = [ - "rustix 1.1.4", + "rustix", "windows-link 0.2.1", ] @@ -2004,8 +1966,10 @@ source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "335ff9f135e4384c8150d6f27c6daed433577f86b4750418338c01a1a2528592" dependencies = [ "cfg-if", + "js-sys", "libc", "wasi 0.11.1+wasi-snapshot-preview1", + "wasm-bindgen", ] [[package]] @@ -2036,16 +2000,6 @@ dependencies = [ "wasip3", ] -[[package]] -name = "ghash" -version = "0.5.1" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "f0d8a4362ccb29cb0b265253fb0a2728f592895ee6854fd9bc13f2ffda266ff1" -dependencies = [ - "opaque-debug", - "polyval", -] - [[package]] name = "gio" version = "0.18.4" @@ -2410,6 +2364,7 @@ dependencies = [ "tokio", "tokio-rustls", "tower-service", + "webpki-roots", ] [[package]] @@ -2466,7 +2421,7 @@ dependencies = [ "js-sys", "log", "wasm-bindgen", - "windows-core 0.61.2", + "windows-core 0.57.0", ] [[package]] @@ -2962,12 +2917,6 @@ dependencies = [ "vcpkg", ] -[[package]] -name = "linux-raw-sys" -version = "0.4.15" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "d26c52dbd32dccf2d10cac7725f8eae5296885fb5703b261f7d0a0739ec807ab" - [[package]] name = "linux-raw-sys" version = "0.12.1" @@ -2998,6 +2947,12 @@ dependencies = [ "value-bag", ] +[[package]] +name = "lru-slab" +version = "0.1.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "112b39cec0b298b6c1999fee3e31427f74f676e4cb9879ed1a121b43661a4154" + [[package]] name = "lzma-rs" version = "0.3.0" @@ -3112,6 +3067,12 @@ dependencies = [ "unicase", ] +[[package]] +name = "minisign-verify" +version = "0.2.5" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "22f9645cb765ea72b8111f36c522475d2daa0d22c957a9826437e97534bc4e9e" + [[package]] name = "miniz_oxide" version = "0.8.9" @@ -3498,6 +3459,18 @@ dependencies = [ "objc2-core-foundation", ] +[[package]] +name = "objc2-osa-kit" +version = "0.3.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "f112d1746737b0da274ef79a23aac283376f335f4095a083a267a082f21db0c0" +dependencies = [ + "bitflags 2.11.1", + "objc2", + "objc2-app-kit", + "objc2-foundation", +] + [[package]] name = "objc2-quartz-core" version = "0.3.2" @@ -3561,12 +3534,6 @@ version = "1.70.2" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "384b8ab6d37215f3c5301a95a4accb5d64aa607f1fcb26a11b5303878451b4fe" -[[package]] -name = "opaque-debug" -version = "0.3.1" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "c08d65885ee38876c4f86fa503fb49d7b507c2b62552df7c70b2fce627e06381" - [[package]] name = "open" version = "5.3.3" @@ -3656,7 +3623,21 @@ source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "7d8fae84b431384b68627d0f9b3b1245fcf9f46f6c0e3dc902e9dce64edd1967" dependencies = [ "libc", - "windows-sys 0.61.2", + "windows-sys 0.45.0", +] + +[[package]] +name = "osakit" +version = "0.3.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "732c71caeaa72c065bb69d7ea08717bd3f4863a4f451402fc9513e29dbd5261b" +dependencies = [ + "objc2", + "objc2-foundation", + "objc2-osa-kit", + "serde", + "serde_json", + "thiserror 2.0.17", ] [[package]] @@ -4037,22 +4018,10 @@ dependencies = [ "concurrent-queue", "hermit-abi", "pin-project-lite", - "rustix 1.1.4", + "rustix", "windows-sys 0.61.2", ] -[[package]] -name = "polyval" -version = "0.6.2" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "9d1fe60d06143b2430aa532c94cfe9e29783047f06c0d7fd359a9a51b729fa25" -dependencies = [ - "cfg-if", - "cpufeatures 0.2.17", - "opaque-debug", - "universal-hash", -] - [[package]] name = "portable-atomic" version = "1.13.0" @@ -4229,6 +4198,61 @@ dependencies = [ "memchr", ] +[[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", + "thiserror 2.0.17", + "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 = [ + "bytes", + "getrandom 0.3.4", + "lru-slab", + "rand 0.9.2", + "ring", + "rustc-hash", + "rustls", + "rustls-pki-types", + "slab", + "thiserror 2.0.17", + "tinyvec", + "tracing", + "web-time", +] + +[[package]] +name = "quinn-udp" +version = "0.5.15" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "35a133f956daabe89a61a685c2649f13d82d5aa4bd5d12d1277e1072a21c0694" +dependencies = [ + "cfg_aliases", + "libc", + "once_cell", + "socket2", + "tracing", + "windows-sys 0.59.0", +] + [[package]] name = "quote" version = "1.0.47" @@ -4514,6 +4538,8 @@ dependencies = [ "native-tls", "percent-encoding", "pin-project-lite", + "quinn", + "rustls", "rustls-pki-types", "serde", "serde_json", @@ -4521,6 +4547,7 @@ dependencies = [ "sync_wrapper", "tokio", "tokio-native-tls", + "tokio-rustls", "tokio-util", "tower", "tower-http", @@ -4530,6 +4557,7 @@ dependencies = [ "wasm-bindgen-futures", "wasm-streams", "web-sys", + "webpki-roots", ] [[package]] @@ -4739,6 +4767,12 @@ dependencies = [ "serde_json", ] +[[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" @@ -4748,19 +4782,6 @@ dependencies = [ "semver", ] -[[package]] -name = "rustix" -version = "0.38.44" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "fdb5bc1ae2baa591800df16c9ca78619bf65c0488b41b96ccec5d11220d8c154" -dependencies = [ - "bitflags 2.11.1", - "errno", - "libc", - "linux-raw-sys 0.4.15", - "windows-sys 0.59.0", -] - [[package]] name = "rustix" version = "1.1.4" @@ -4770,8 +4791,8 @@ dependencies = [ "bitflags 2.11.1", "errno", "libc", - "linux-raw-sys 0.12.1", - "windows-sys 0.61.2", + "linux-raw-sys", + "windows-sys 0.59.0", ] [[package]] @@ -4781,6 +4802,7 @@ source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "c665f33d38cea657d9614f766881e4d510e0eda4239891eea56b4cadcf01801b" dependencies = [ "once_cell", + "ring", "rustls-pki-types", "rustls-webpki", "subtle", @@ -4793,6 +4815,7 @@ version = "1.13.2" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "21e6f2ab2928ca4291b86736a8bd920a277a399bba1589409d72154ff87c1282" dependencies = [ + "web-time", "zeroize", ] @@ -5233,7 +5256,6 @@ checksum = "e3a9fe34e3e7a50316060351f37187a3f546bce95496156754b601a5fa71b76e" name = "simprint" version = "0.1.0" dependencies = [ - "aes-gcm", "anyhow", "axum", "business", @@ -5241,11 +5263,9 @@ dependencies = [ "chrono", "config", "directories", - "embed-resource 2.5.2", "env_logger", "futures", "hex", - "hkdf", "include_dir", "indexmap 2.13.0", "lazy_static", @@ -5276,6 +5296,7 @@ dependencies = [ "tauri-plugin-process", "tauri-plugin-single-instance", "tauri-plugin-store", + "tauri-plugin-updater", "thiserror 2.0.17", "tokio", "tokio-socks", @@ -5283,7 +5304,7 @@ dependencies = [ "uuid", "windows 0.61.3", "winreg 0.55.0", - "zip", + "zip 2.4.2", ] [[package]] @@ -5832,6 +5853,17 @@ version = "1.0.1" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "55937e1799185b12863d447f42597ed69d9928686b8d88a1df17376a097d8369" +[[package]] +name = "tar" +version = "0.4.46" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "3f6221d9a6003c78398e3b239969f352578258df48c8eb051caadae0015bc840" +dependencies = [ + "filetime", + "libc", + "xattr", +] + [[package]] name = "target-lexicon" version = "0.12.16" @@ -6145,6 +6177,38 @@ dependencies = [ "tracing", ] +[[package]] +name = "tauri-plugin-updater" +version = "2.9.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "27cbc31740f4d507712550694749572ec0e43bdd66992db7599b89fbfd6b167b" +dependencies = [ + "base64 0.22.1", + "dirs 6.0.0", + "flate2", + "futures-util", + "http", + "infer", + "log", + "minisign-verify", + "osakit", + "percent-encoding", + "reqwest", + "semver", + "serde", + "serde_json", + "tar", + "tauri", + "tauri-plugin", + "tempfile", + "thiserror 2.0.17", + "time", + "tokio", + "url", + "windows-sys 0.60.2", + "zip 4.6.1", +] + [[package]] name = "tauri-runtime" version = "2.9.2" @@ -6242,21 +6306,20 @@ source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "1087b111fe2b005e42dbdc1990fc18593234238d47453b0c99b7de1c9ab2c1e0" dependencies = [ "dunce", - "embed-resource 3.0.6", + "embed-resource", "toml 0.9.10+spec-1.1.0", ] [[package]] name = "tempfile" -version = "3.15.0" +version = "3.27.0" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "9a8a559c81686f576e8cd0290cd2a24a2a9ad80c98b3478856500fcbd7acd704" +checksum = "32497e9a4c7b38532efcdebeef879707aa9f794296a4f0244f6f69e9bc8574bd" dependencies = [ - "cfg-if", "fastrand", - "getrandom 0.2.16", + "getrandom 0.4.2", "once_cell", - "rustix 0.38.44", + "rustix", "windows-sys 0.59.0", ] @@ -6809,16 +6872,6 @@ version = "0.2.6" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "ebc1c04c71510c7f702b52b7c350734c9ff1295c464a03335b00bb84fc54f853" -[[package]] -name = "universal-hash" -version = "0.5.1" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "fc1de2c688dc15305988b563c3854064043356019f97a4b46276fe734c4f07ea" -dependencies = [ - "crypto-common", - "subtle", -] - [[package]] name = "unsafe-libyaml" version = "0.2.11" @@ -7102,7 +7155,7 @@ checksum = "fee64194ccd96bf648f42a65a7e589547096dfa702f7cadef84347b66ad164f9" dependencies = [ "cc", "downcast-rs", - "rustix 1.1.4", + "rustix", "smallvec", "wayland-sys", ] @@ -7114,7 +7167,7 @@ source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "b8e6faa537fbb6c186cb9f1d41f2f811a4120d1b57ec61f50da451a0c5122bec" dependencies = [ "bitflags 2.11.1", - "rustix 1.1.4", + "rustix", "wayland-backend", "wayland-scanner", ] @@ -7174,6 +7227,16 @@ dependencies = [ "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 = "webkit2gtk" version = "2.0.1" @@ -7218,6 +7281,15 @@ dependencies = [ "system-deps", ] +[[package]] +name = "webpki-roots" +version = "1.0.9" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "7dcd9d09a39985f5344844e66b0c530a33843579125f23e21e9f0f220850f22a" +dependencies = [ + "rustls-pki-types", +] + [[package]] name = "webview2-com" version = "0.38.2" @@ -7292,7 +7364,7 @@ version = "0.1.11" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "c2a7b1c03c876122aa43f3020e6c3c3ee5c05081c9a00739faf7503aeba10d22" dependencies = [ - "windows-sys 0.61.2", + "windows-sys 0.48.0", ] [[package]] @@ -7859,16 +7931,6 @@ dependencies = [ "winapi", ] -[[package]] -name = "winreg" -version = "0.52.0" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "a277a57398d4bfa075df44f501a17cfdf8542d224f0d36095a2adc7aee4ef0a5" -dependencies = [ - "cfg-if", - "windows-sys 0.48.0", -] - [[package]] name = "winreg" version = "0.55.0" @@ -7982,7 +8044,7 @@ dependencies = [ "libc", "log", "os_pipe", - "rustix 1.1.4", + "rustix", "thiserror 2.0.17", "tree_magic_mini", "wayland-backend", @@ -8079,7 +8141,7 @@ source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "9993aa5be5a26815fe2c3eacfc1fde061fc1a1f094bf1ad2a18bf9c495dd7414" dependencies = [ "gethostname", - "rustix 1.1.4", + "rustix", "x11rb-protocol", ] @@ -8089,6 +8151,16 @@ version = "0.13.2" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "ea6fc2961e4ef194dcbfe56bb845534d0dc8098940c7e5c012a258bfec6701bd" +[[package]] +name = "xattr" +version = "1.6.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "32e45ad4206f6d2479085147f02bc2ef834ac85886624a23575ae137c8aa8156" +dependencies = [ + "libc", + "rustix", +] + [[package]] name = "xz2" version = "0.1.7" @@ -8317,6 +8389,18 @@ dependencies = [ "zstd", ] +[[package]] +name = "zip" +version = "4.6.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "caa8cd6af31c3b31c6631b8f483848b91589021b28fffe50adada48d4f4d2ed1" +dependencies = [ + "arbitrary", + "crc32fast", + "indexmap 2.13.0", + "memchr", +] + [[package]] name = "zmij" version = "1.0.12" diff --git a/src-tauri/Cargo.toml b/src-tauri/Cargo.toml index c0f9ea4..8a5d480 100644 --- a/src-tauri/Cargo.toml +++ b/src-tauri/Cargo.toml @@ -15,19 +15,12 @@ default-run = "simprint" name = "simprint_lib" crate-type = ["staticlib", "cdylib", "rlib"] -[[bin]] -name = "updater" -path = "src/bin/updater.rs" - [build-dependencies] -tauri-build = { version = "2", features = [] } -embed-resource = "2.0" +tauri-build = { version = "=2.5.3", features = [] } reqwest = { version = "0.12.22", features = ["blocking", "json"] } zip = "2.2.2" config = "0.15.15" serde = { version = "1", features = ["derive"] } -aes-gcm = "0.10.3" -sha2 = "0.10.9" [dependencies] tokio = { version = "1", features = [ @@ -41,7 +34,7 @@ tokio = { version = "1", features = [ "net", "fs", ] } -tauri = { version = "2", features = [ "tray-icon", "default", "devtools", "image-ico", "image-png", "unstable"] } +tauri = { version = "=2.9.5", features = [ "tray-icon", "default", "devtools", "image-ico", "image-png", "unstable"] } serde = { version = "1", features = ["derive"] } serde_json = "1" serde_yaml = "0.9" @@ -55,9 +48,9 @@ tauri-plugin-opener = "2" tauri-plugin-process = "2.2.0" tauri-plugin-deep-link = "2.2.1" tauri-plugin-dialog = "2.6.0" +tauri-plugin-updater = "=2.9.0" sysinfo = "0.34.2" rand = "0.9.1" -aes-gcm = "0.10.3" bytes = "1.10.0" include_dir = "0.7.4" lazy_static = "1.5.0" @@ -67,7 +60,6 @@ tokio-util = "0.7" futures = "0.3.31" once_cell = "1.20" sha2 = "0.10.9" -config = "0.15.15" uuid = { version = "1.18.1", features = ["v4"] } hex = "0.4.3" zip = "2.2.2" @@ -84,8 +76,6 @@ indexmap = { version = "2.12.0", features = ["serde"] } runtime = { path = "crates/runtime" } business = { path = "crates/business" } -hkdf = "0.12" - [target.'cfg(windows)'.dependencies] smbios-lib = "0.9.2" winreg = "0.55" diff --git a/src-tauri/build.rs b/src-tauri/build.rs index c85f972..93f5e8a 100644 --- a/src-tauri/build.rs +++ b/src-tauri/build.rs @@ -5,22 +5,19 @@ use std::path::{Path, PathBuf}; use config::Config; use serde::Deserialize; -// 引用配置加密模块(与运行时共享) -#[path = "src/core/config/encryption/key_derivation.rs"] -mod key_derivation; - -#[path = "src/core/config/encryption/crypto.rs"] -mod crypto; - // ============================================================================= // 入口:构建脚本执行流程 // ============================================================================= fn main() { + println!("cargo:rerun-if-env-changed=SIMPRINT_WEBVIEW_MODE"); + // 1. 仅在生产环境下下载 / 准备 webview-fixed 目录中的资源 #[cfg(feature = "production")] { - webview_assets::ensure_webview_fixed_downloaded(); + if env::var("SIMPRINT_WEBVIEW_MODE").as_deref() == Ok("fixed-runtime") { + webview_assets::ensure_webview_fixed_downloaded(); + } } // 2. 构建 Tauri 应用(处理 Windows manifest / 权限等) @@ -28,9 +25,6 @@ fn main() { // 3. 为前端构建写入环境标记文件(.build-env) frontend_env::prepare_frontend_build_env(); - - // 4. 读取明文配置并生成加密后的二进制配置文件 - config_encrypt::generate_encrypted_config(); } // ============================================================================= @@ -77,43 +71,58 @@ mod webview_assets { /// Webview 配置结构体(用于 build.rs 中解析) #[derive(Deserialize)] struct WebviewConfig { - /// 下载的 URL(注意:字段名保持与配置文件中的拼写一致:downlaod_url) - #[serde(rename = "downlaod_url")] + x86_64_download_url: String, + aarch64_download_url: String, + x86_download_url: String, + } + + struct TargetWebview { download_url: String, + runtime_directory: &'static str, } /// 确保 `webview-fixed` 目录已经从远端 ZIP 包解压完成 /// - /// - 若目录已存在,则直接跳过,不重复下载 - /// - 若目录不存在,则从指定 URL 下载 zip 并解压到 `webview-fixed/` + /// - 若当前目标架构的运行时目录已存在,则直接跳过 + /// - 否则从该架构的 URL 下载 zip,并解压到共享的 `webview-fixed/` pub fn ensure_webview_fixed_downloaded() { let target_dir = Path::new("webview-fixed"); - // 若目录已存在,则认为资源已经就绪,避免每次构建都重新下载 - if target_dir.exists() { - return; - } - // 优先尝试从当前环境的配置文件中读取下载地址 - let url = detect_webview_download_url().unwrap_or_else(|| { + let target_webview = detect_target_webview().unwrap_or_else(|| { panic!( - "[BUILD ERROR] Failed to detect webview download URL from config file '{}'.\n\ - Please ensure the config file contains a valid [webview] section with 'downlaod_url' field.", + "[BUILD ERROR] Failed to detect the target WebView runtime from config file '{}'.\n\ + Please ensure [webview] contains download URLs for x86_64, aarch64 and x86.", super::current_config_file_name() ); }); - if let Err(err) = download_and_extract_webview_fixed(&url, target_dir.to_path_buf()) { + // 三种架构的运行时可以共存;仅当当前目标架构的目录已存在时才跳过下载。 + if target_dir.join(target_webview.runtime_directory).exists() { + return; + } + + if let Err(err) = download_and_extract_webview_fixed( + &target_webview.download_url, + target_dir.to_path_buf(), + ) { // 构建脚本失败时直接 panic,阻止继续构建,以避免产生不完整的产物 panic!("failed to download and extract webview-fixed assets: {err}"); } + + if !target_dir.join(target_webview.runtime_directory).exists() { + panic!( + "downloaded WebView archive does not contain expected runtime directory '{}'", + target_webview.runtime_directory + ); + } } - /// 从当前构建环境对应的 `config..toml` 中解析 `[webview]` 段的 `downlaod_url` + /// 从当前构建目标和 `config..toml` 中选择对应的 WebView 固定运行时。 /// /// 使用 config crate 进行 TOML 解析,替代手动字符串解析,提高可靠性和可维护性。 /// 解析失败时返回 `None`,由调用方决定是否回退到默认值。 - fn detect_webview_download_url() -> Option { + fn detect_target_webview() -> Option { let config_file_name = super::current_config_file_name(); // 使用 config crate 解析 TOML 文件 @@ -141,7 +150,24 @@ mod webview_assets { }) .ok()?; - Some(webview_config.download_url) + match env::var("CARGO_CFG_TARGET_ARCH").ok()?.as_str() { + "x86_64" => Some(TargetWebview { + download_url: webview_config.x86_64_download_url, + runtime_directory: "Microsoft.WebView2.FixedVersionRuntime.151.0.4129.78.x64", + }), + "aarch64" => Some(TargetWebview { + download_url: webview_config.aarch64_download_url, + runtime_directory: "Microsoft.WebView2.FixedVersionRuntime.151.0.4129.78.arm64", + }), + "x86" => Some(TargetWebview { + download_url: webview_config.x86_download_url, + runtime_directory: "Microsoft.WebView2.FixedVersionRuntime.151.0.4129.78.x86", + }), + arch => { + eprintln!("[BUILD ERROR] Unsupported Windows target architecture: {arch}"); + None + } + } } /// 从远程下载 webview-fixed.zip 并解压到指定目录 @@ -206,71 +232,7 @@ mod webview_assets { } // ============================================================================= -// 模块三:配置加密(将 TOML 加工为加密二进制) -// ============================================================================= - -mod config_encrypt { - use super::*; - - /// 从 config.toml 生成加密后的二进制配置文件 - pub fn generate_encrypted_config() { - // 不同环境使用不同的配置文件 - // 当对应的配置文件发生变化时重新运行构建脚本 - println!("cargo:rerun-if-changed=config.development.toml"); - println!("cargo:rerun-if-changed=config.test.toml"); - println!("cargo:rerun-if-changed=config.production.toml"); - - // 根据当前构建环境选择对应的配置文件 - let config_file_name = current_config_file_name(); - - // 读取配置文件并生成加密的二进制文件,避免在可执行文件中直接出现明文配置 - let out_dir = env::var("OUT_DIR").unwrap_or_else(|e| { - panic!( - "[BUILD ERROR] OUT_DIR environment variable is not set: {}\n\ - This build script must be run by Cargo, not directly.", - e - ); - }); - - let config_path = Path::new(config_file_name); - let config_bytes = fs::read(config_path).unwrap_or_else(|e| { - panic!( - "[BUILD ERROR] Failed to read config file '{}': {}\n\ - Please ensure the config file exists and is readable.", - config_path.display(), - e - ); - }); - - std::str::from_utf8(&config_bytes).unwrap_or_else(|e| { - panic!( - "[BUILD ERROR] Config file '{}' is not valid UTF-8: {}\n\ - Please ensure workflow/local scripts write this file with UTF-8 encoding.", - config_path.display(), - e - ); - }); - - let encrypted = crypto::encrypt(&config_bytes).expect("Failed to encrypt config"); - - let out_path = Path::new(&out_dir).join("config_encrypted.bin"); - println!( - "cargo:warning=Config encrypted file path: {}", - out_path.display() - ); - fs::write(&out_path, &encrypted).unwrap_or_else(|e| { - panic!( - "[BUILD ERROR] Failed to write encrypted config to '{}': {}\n\ - Please check write permissions for OUT_DIR.", - out_path.display(), - e - ); - }); - } -} - -// ============================================================================= -// 模块四:前端构建环境标记(.build-env) +// 模块三:前端构建环境标记(.build-env) // ============================================================================= mod frontend_env { @@ -306,7 +268,7 @@ mod frontend_env { } // ============================================================================= -// 模块五:Tauri 应用构建(Windows manifest / 权限等) +// 模块四:Tauri 应用构建(Windows manifest / 权限等) // ============================================================================= mod tauri_build_pipeline { @@ -319,9 +281,6 @@ mod tauri_build_pipeline { let is_dev = true; // 暂时跳过软件管理员申请,再后续评估再决定是否需要管理员。 if !is_dev { - // 发布环境:需要管理员权限. (updater.exe manifest) - embed_resource::compile("windows/updater.rc", embed_resource::NONE); - // 发布环境:需要管理员权限. (主程序manifest) let manifest = include_str!("windows/main.manifest"); let window_attributes = @@ -335,7 +294,7 @@ mod tauri_build_pipeline { ); }); } else { - // 开发环境:不需要管理员权限. (updater.exe manifest) + // 开发环境:不需要管理员权限。 tauri_build::build(); } } diff --git a/src-tauri/capabilities/default.json b/src-tauri/capabilities/default.json index c20621a..85192ca 100644 --- a/src-tauri/capabilities/default.json +++ b/src-tauri/capabilities/default.json @@ -1,43 +1,44 @@ -{ - "$schema": "../gen/schemas/desktop-schema.json", - "identifier": "default", - "description": "Capability for the main window", - "windows": ["main"], - "permissions": [ - "core:default", - "process:default", - "opener:default", - { - "identifier": "opener:allow-open-path", - "allow": [ - { "path": "$APPDATA/**" }, - { "path": "$HOME/**" }, - { "path": "$USERPROFILE/**" }, - { "path": "**" } - ] - }, - "core:window:default", - "core:window:allow-start-dragging", - "core:window:allow-minimize", - "core:window:allow-close", - "core:window:allow-destroy", - "core:window:allow-toggle-maximize", - "core:window:allow-set-focus", - "core:window:allow-show", - "core:window:allow-hide", - "core:window:allow-unminimize", - "core:window:allow-set-always-on-top", - "core:window:allow-create", - "core:webview:allow-webview-close", - "core:webview:default", - "core:webview:allow-create-webview", - "core:webview:allow-create-webview-window", - "dialog:allow-save", - "dialog:allow-open", - "clipboard-manager:allow-read-text", - "store:default", - "autostart:allow-enable", - "autostart:allow-disable", - "autostart:allow-is-enabled" - ] -} +{ + "$schema": "../gen/schemas/desktop-schema.json", + "identifier": "default", + "description": "Capability for the main window", + "windows": ["main"], + "permissions": [ + "core:default", + "process:default", + "updater:default", + "opener:default", + { + "identifier": "opener:allow-open-path", + "allow": [ + { "path": "$APPDATA/**" }, + { "path": "$HOME/**" }, + { "path": "$USERPROFILE/**" }, + { "path": "**" } + ] + }, + "core:window:default", + "core:window:allow-start-dragging", + "core:window:allow-minimize", + "core:window:allow-close", + "core:window:allow-destroy", + "core:window:allow-toggle-maximize", + "core:window:allow-set-focus", + "core:window:allow-show", + "core:window:allow-hide", + "core:window:allow-unminimize", + "core:window:allow-set-always-on-top", + "core:window:allow-create", + "core:webview:allow-webview-close", + "core:webview:default", + "core:webview:allow-create-webview", + "core:webview:allow-create-webview-window", + "dialog:allow-save", + "dialog:allow-open", + "clipboard-manager:allow-read-text", + "store:default", + "autostart:allow-enable", + "autostart:allow-disable", + "autostart:allow-is-enabled" + ] +} diff --git a/src-tauri/config.example.toml b/src-tauri/config.example.toml index ec34c55..c943626 100644 --- a/src-tauri/config.example.toml +++ b/src-tauri/config.example.toml @@ -1,7 +1,4 @@ -[updater] -check_url = "https://update.simprint.app/api/v1/versions/check" -latest_json_url = "https://pub-39307a5e69c74324855a762027cbf9bf.r2.dev/latest.json" -updater_temp_dir = "updates" - [webview] -downlaod_url = "https://pub-39307a5e69c74324855a762027cbf9bf.r2.dev/webview-fixed.zip" +x86_64_download_url = "https://pub-39307a5e69c74324855a762027cbf9bf.r2.dev/webview-fixed-x64.zip" +aarch64_download_url = "https://pub-39307a5e69c74324855a762027cbf9bf.r2.dev/webview-fixed-arm64.zip" +x86_download_url = "https://pub-39307a5e69c74324855a762027cbf9bf.r2.dev/webview-fixed-x86.zip" diff --git a/src-tauri/src/app/context.rs b/src-tauri/src/app/context.rs index 4f58658..3cc9a2e 100644 --- a/src-tauri/src/app/context.rs +++ b/src-tauri/src/app/context.rs @@ -9,7 +9,6 @@ use std::sync::Arc; use tokio::sync::OnceCell; use crate::app::runtime::SimprintRuntimeManager; -use crate::core::config::AppConfig; use crate::local_api::LocalApiManager; use crate::mcp::McpManager; use crate::services::environment::{EnvironmentPositionManager, EnvironmentStatusManager}; @@ -19,9 +18,6 @@ use crate::services::mihomo::MihomoManager; /// /// 包含所有需要全局访问的状态和服务 pub struct AppContext { - /// 应用配置(不可变) - pub config: AppConfig, - /// 环境状态管理器 pub env_status_manager: Arc, @@ -46,7 +42,7 @@ static APP_CONTEXT: OnceCell> = OnceCell::const_new(); impl AppContext { /// 创建新的应用上下文(早期初始化,不依赖 AppHandle) - pub fn new(config: AppConfig) -> anyhow::Result { + pub fn new() -> anyhow::Result { // 初始化环境状态管理器 let env_status_manager = Arc::new(EnvironmentStatusManager::new()); @@ -66,7 +62,6 @@ impl AppContext { let simprint_runtime_manager = Arc::new(SimprintRuntimeManager::new()); Ok(Self { - config, env_status_manager, env_position_manager, local_api_manager, @@ -77,19 +72,14 @@ impl AppContext { } /// 初始化全局上下文(早期阶段) - pub fn init_early(config: AppConfig) -> anyhow::Result<&'static Arc> { - let context = Arc::new(Self::new(config)?); + pub fn init_early() -> anyhow::Result<&'static Arc> { + let context = Arc::new(Self::new()?); APP_CONTEXT .set(context) .map_err(|_| anyhow::anyhow!("AppContext already initialized"))?; Ok(APP_CONTEXT.get().unwrap()) } - /// 获取应用配置 - pub fn config(&self) -> &AppConfig { - &self.config - } - /// 获取全局上下文(如果未初始化则 panic) pub fn get() -> &'static Arc { APP_CONTEXT.get().expect("AppContext not initialized") diff --git a/src-tauri/src/app/lifecycle.rs b/src-tauri/src/app/lifecycle.rs index def2d6f..e74e512 100644 --- a/src-tauri/src/app/lifecycle.rs +++ b/src-tauri/src/app/lifecycle.rs @@ -5,17 +5,12 @@ use anyhow::Result; use tauri::AppHandle; use crate::app::context::AppContext; -use crate::core::config; /// 初始化应用核心组件(早期阶段,不依赖 Tauri) /// /// 按照正确的依赖顺序初始化所有组件 pub fn init_early() -> Result<()> { - // 1. 加载配置(最先初始化,其他组件依赖配置) - let app_config = config::get_or_err()?.clone(); - - // 2. 初始化应用上下文(包含 RSA、HTTP 客户端、runtime 管理器) - AppContext::init_early(app_config)?; + AppContext::init_early()?; Ok(()) } diff --git a/src-tauri/src/app/mod.rs b/src-tauri/src/app/mod.rs index 7be5fbe..e7750aa 100644 --- a/src-tauri/src/app/mod.rs +++ b/src-tauri/src/app/mod.rs @@ -9,7 +9,6 @@ pub mod runtime_info; pub mod session_lock; pub mod setup; pub mod startup; -pub mod startup_update; use crate::commands; use components::tray; diff --git a/src-tauri/src/app/setup.rs b/src-tauri/src/app/setup.rs index bdf9cf7..cb7d301 100644 --- a/src-tauri/src/app/setup.rs +++ b/src-tauri/src/app/setup.rs @@ -35,6 +35,11 @@ pub fn register_plugins(app_handle: &AppHandle) { // Register process plugin app_handle.plugin(tauri_plugin_process::init()).unwrap(); + // Register Tauri's signed updater. Release builds inject the public key + // into tauri.conf.json before compilation. + #[cfg(desktop)] + app_handle.plugin(tauri_plugin_updater::Builder::new().build()).unwrap(); + // deep-link 插件 app_handle.plugin(tauri_plugin_deep_link::init()).unwrap(); diff --git a/src-tauri/src/app/startup.rs b/src-tauri/src/app/startup.rs index 90adb58..2c719f4 100644 --- a/src-tauri/src/app/startup.rs +++ b/src-tauri/src/app/startup.rs @@ -65,8 +65,6 @@ fn try_complete_startup(app: &AppHandle) -> Result<(), ()> { } } - crate::app::startup_update::start_background(app.clone()); - Ok(()) } diff --git a/src-tauri/src/app/startup_update.rs b/src-tauri/src/app/startup_update.rs deleted file mode 100644 index ed20755..0000000 --- a/src-tauri/src/app/startup_update.rs +++ /dev/null @@ -1,77 +0,0 @@ -use crate::commands::updater; -use std::ffi::OsStr; -use tauri::AppHandle; - -const SKIP_UPDATE_ARG: &str = "--skip-update"; - -fn has_skip_update_arg(args: I) -> bool -where - I: IntoIterator, - S: AsRef, -{ - args.into_iter().any(|arg| arg.as_ref() == OsStr::new(SKIP_UPDATE_ARG)) -} - -fn should_skip_update() -> bool { - has_skip_update_arg(std::env::args_os()) -} - -/// Runs the automatic update flow only after the main window is fully ready. -/// Updating is intentionally not part of the startup readiness gate. -pub fn start_background(app_handle: AppHandle) { - tauri::async_runtime::spawn(async move { - if should_skip_update() { - log::info!("Skipping background update flow because {SKIP_UPDATE_ARG} was provided"); - return; - } - - log::info!("Starting background update check after main window became ready"); - let updates_available = match updater::check_updates(app_handle.clone()).await { - Ok(result) => result.has_updates, - Err(error) => { - log::error!("Background update check failed: {error}"); - return; - } - }; - - if !updates_available { - log::info!("Background update check completed; no update is available"); - return; - } - - match updater::download_updates(app_handle.clone(), None).await { - Ok(download_result) if download_result.success_count > 0 => { - if let Err(error) = updater::start_update_install(app_handle).await { - log::error!("Background update installation start failed: {error}"); - } - } - Ok(_) => { - log::warn!( - "Background update download failed; continuing with the current version" - ); - } - Err(error) => { - log::error!("Background update download failed: {error}"); - } - } - }); -} - -#[cfg(test)] -mod tests { - use super::has_skip_update_arg; - - #[test] - fn detects_skip_update_argument() { - assert!(has_skip_update_arg(["simprint.exe", "--skip-update"])); - } - - #[test] - fn does_not_treat_other_arguments_as_skip_update() { - assert!(!has_skip_update_arg([ - "simprint.exe", - "--skip-update-check", - "simprint://open" - ])); - } -} diff --git a/src-tauri/src/bin/updater.rs b/src-tauri/src/bin/updater.rs deleted file mode 100644 index 9fa3b12..0000000 --- a/src-tauri/src/bin/updater.rs +++ /dev/null @@ -1,232 +0,0 @@ -//! 更新器二进制入口点 -//! -//! 独立的更新程序,只负责执行文件替换 -//! install: 执行文件替换 - -// Prevents additional console window on Windows in release, DO NOT REMOVE!! -#![cfg_attr(not(debug_assertions), windows_subsystem = "windows")] - -use simprint_lib::core::config; -use simprint_lib::core::logger; -use simprint_lib::infrastructure::updater::types::{ArtifactInfo, InstallTasks}; -use simprint_lib::infrastructure::updater::{installer, manifest, planner}; -use std::env; -use std::fs; -use std::path::PathBuf; -use std::process; - -/// 更新器主函数 -#[tokio::main] -async fn main() -> Result<(), Box> { - // 初始化日志系统(更新器无 Tauri/store,使用兜底目录) - logger::init_logging(logger::bootstrap_log_dir()); - - // 解析命令行参数 - let args: Vec = env::args().collect(); - let mode = args.get(1).map(|s| s.as_str()).unwrap_or_else(|| { - process::exit(1); - }); - - match mode { - "install" => { - let tasks_file = args.get(2).cloned().unwrap_or_else(|| { - simprint_lib::core::paths::PathManager::get_update_tasks_file() - .unwrap_or_else(|_| PathBuf::from("update_tasks.json")) - .to_string_lossy() - .to_string() - }); - run_install(&tasks_file).await?; - } - "install-package" => { - let installer_path = args.get(2).cloned().unwrap_or_else(|| { - process::exit(1); - }); - run_install_package(&installer_path)?; - } - _ => { - process::exit(1); - } - } - - Ok(()) -} - -fn run_install_package(installer_path: &str) -> Result<(), Box> { - let installer = PathBuf::from(installer_path); - if !installer.exists() { - return Err(format!("安装包不存在: {}", installer.display()).into()); - } - - use std::process::Command; - - Command::new(&installer) - .arg("/P") - .arg("/UPDATE") - .arg("/R") - .spawn() - .map_err(|e| format!("启动安装包失败: {}", e))?; - - Ok(()) -} - -/// 安装模式 -/// -/// 从 JSON 文件读取安装任务,执行文件替换 -async fn run_install(tasks_file: &str) -> Result<(), Box> { - // 读取任务文件 - let tasks_content = fs::read_to_string(tasks_file) - .map_err(|e| format!("读取任务文件失败: {} - {}", tasks_file, e))?; - - let install_tasks: InstallTasks = serde_json::from_str(&tasks_content) - .map_err(|e| format!("解析任务文件失败: {} - {}", tasks_file, e))?; - - if install_tasks.tasks.is_empty() { - log::warn!("任务文件为空"); - return Ok(()); - } - - // 初始化配置(用于 manifest) - if let Err(e) = config::init() { - log::error!("配置初始化失败: {}", e); - return Err(format!("配置初始化失败: {}", e).into()); - } - - let mut success_count = 0; - let mut failed_count = 0; - let mut installed_artifacts: Vec = Vec::new(); - - // 遍历所有任务执行安装 - for install_task in &install_tasks.tasks { - let resource_name = install_task.resource_name.clone(); - - // 再次校验文件哈希(确保文件未被篡改) - let temp_path = PathBuf::from(&install_task.temp_path); - let actual_hash = planner::calculate_file_hash(&temp_path) - .map_err(|e| format!("计算文件哈希失败: {}", e))?; - - if actual_hash.to_lowercase() != install_task.expected_hash.to_lowercase() { - log::error!( - "文件哈希不匹配: {} (期望: {}, 实际: {})", - resource_name, - install_task.expected_hash, - actual_hash - ); - failed_count += 1; - continue; - } - - // 执行安装 - let target_path = PathBuf::from(&install_task.target_path); - let backup_path = install_task.backup_path.as_ref().map(|p| PathBuf::from(p)); - - match installer::install_file_direct( - &resource_name, - &target_path, - backup_path.as_deref(), - &temp_path, - ) { - Ok(_) => { - success_count += 1; - installed_artifacts.push(ArtifactInfo { - resource_name: resource_name.clone(), - version: install_task.version.clone(), - }); - } - Err(e) => { - log::error!("{} - 安装失败: {}", resource_name, e); - - // 尝试回滚 - if let Some(ref backup_path) = backup_path { - if backup_path.exists() { - let _ = installer::rollback_from_backup(&target_path, backup_path); - } - } - - failed_count += 1; - } - } - } - - // 更新 manifest.json - if !installed_artifacts.is_empty() { - let current_version = env!("CARGO_PKG_VERSION"); - - if let Err(e) = manifest::write_manifest(current_version, &installed_artifacts) { - log::error!("更新 manifest.json 失败: {}", e); - } - } - - // 清理任务文件 - if let Err(e) = fs::remove_file(tasks_file) { - log::warn!("删除任务文件失败: {}", e); - } - - // 设置最终状态 - if failed_count == 0 { - log::info!("安装完成,共更新 {} 个文件", success_count); - } else if success_count > 0 { - log::warn!( - "部分安装完成:成功 {}, 失败 {}", - success_count, - failed_count - ); - } else { - log::error!("所有安装失败"); - process::exit(1); - } - - // 检查是否更新了主程序,如果是则重启主程序 - if success_count > 0 { - if let Err(e) = restart_main_app() { - log::error!("重启主程序失败: {}", e); - } - } - - // 确保日志刷新 - log::logger().flush(); - - Ok(()) -} - -/// 重启主程序 -fn restart_main_app() -> Result<(), Box> { - // 获取当前可执行文件目录(updater 与主程序同目录) - let current_exe = env::current_exe()?; - let exe_dir = current_exe.parent().ok_or_else(|| "无法获取可执行文件目录")?; - - // 主程序名与 Cargo [package] name 一致,扩展名随平台(Windows: .exe) - let main_app_name = format!("{}{}", env!("CARGO_PKG_NAME"), std::env::consts::EXE_SUFFIX); - let main_app_exe = exe_dir.join(&main_app_name); - - if !main_app_exe.exists() { - return Err(format!("找不到主程序: {}", main_app_exe.display()).into()); - } - - // 使用 std::process::Command 启动主程序 - // 由于 updater.exe 已有管理员权限,可以直接启动需要管理员权限的主程序 - #[cfg(target_os = "windows")] - { - use std::process::Command; - use std::process::Stdio; - - Command::new(&main_app_exe) - .stdout(Stdio::null()) - .stderr(Stdio::null()) - .spawn() - .map_err(|e| format!("启动主程序失败: {}", e))?; - } - - #[cfg(not(target_os = "windows"))] - { - use std::process::Command; - use std::process::Stdio; - - Command::new(&main_app_exe) - .stdout(Stdio::null()) - .stderr(Stdio::null()) - .spawn() - .map_err(|e| format!("启动主程序失败: {}", e))?; - } - - Ok(()) -} diff --git a/src-tauri/src/commands.rs b/src-tauri/src/commands.rs index d8d4f3d..2b5173c 100644 --- a/src-tauri/src/commands.rs +++ b/src-tauri/src/commands.rs @@ -13,7 +13,6 @@ pub mod network; pub mod rpa; pub mod security; pub mod store; -pub mod updater; pub mod window; pub fn register_handles() -> impl Fn(Invoke) -> bool + Send + Sync + 'static { @@ -79,13 +78,6 @@ pub fn register_handles() -> impl Fn(Invoke) -> bool + Send + Sync + auth::verify_local_user_password, auth::logout, auth::is_logged_in, - // Updater commands - updater::check_update_available, - updater::check_updates, - updater::download_updates, - updater::start_update_install, - updater::start_prepared_update_install, - updater::get_prepared_update, // Core utilities crate::core::utils::process::kill_process, crate::core::utils::process::find_process, diff --git a/src-tauri/src/commands/updater.rs b/src-tauri/src/commands/updater.rs deleted file mode 100644 index af9f9c9..0000000 --- a/src-tauri/src/commands/updater.rs +++ /dev/null @@ -1,70 +0,0 @@ -/// 更新相关的 Tauri 命令 -/// -/// 提供检查更新和下载的功能,由主程序调用 -use crate::core::error::Result; -use crate::services::updater::{CheckResult, DownloadResult, PreparedUpdateInfo, UpdateService}; -use tauri::AppHandle; - -/// 简单检查是否有可用更新(仅检查,不缓存计划,不发送事件) -/// -/// 用于设置页面的「检查更新」按钮,返回布尔值。具体更新逻辑由重启时自动完成。 -#[tauri::command] -pub async fn check_update_available() -> Result { - UpdateService::check_update_available().await -} - -/// 检查更新(仅检查,不下载) -/// -/// 检查是否有可用更新,并将更新计划缓存到内存 -/// -/// # 参数 -/// - `app_handle`: Tauri 应用句柄,用于 emit 事件 -/// -/// # 返回 -/// 返回检查结果,包含是否有更新、更新数量以及计划是否可用 -#[tauri::command] -pub async fn check_updates(app_handle: AppHandle) -> Result { - UpdateService::check_updates(app_handle).await -} - -/// 下载更新(执行下载,支持进度) -/// -/// 从内存中的更新计划读取任务,执行下载和校验,实时发送进度事件 -/// -/// # 参数 -/// - `app_handle`: Tauri 应用句柄,用于 emit 事件 -/// # 返回 -/// 返回下载结果,包含任务文件路径和成功数量 -#[tauri::command] -pub async fn download_updates( - app_handle: AppHandle, - plan_file: Option, -) -> Result { - UpdateService::download_updates(app_handle, plan_file).await -} - -/// 启动更新安装并退出主程序 -/// -/// # 参数 -/// - `app_handle`: Tauri 应用句柄 -/// -/// # 说明 -/// 启动 updater.exe install 命令,然后退出主程序 -/// 任务文件路径由统一路径层提供(update_tasks.json) -#[tauri::command] -pub async fn start_update_install(app_handle: AppHandle) -> Result<()> { - UpdateService::start_update_install(app_handle).await -} - -#[tauri::command] -pub async fn start_prepared_update_install( - app_handle: AppHandle, - kind: Option, -) -> Result<()> { - UpdateService::start_prepared_update_install(app_handle, kind).await -} - -#[tauri::command] -pub async fn get_prepared_update() -> Result> { - UpdateService::get_prepared_update().await -} diff --git a/src-tauri/src/core/config/encryption/crypto.rs b/src-tauri/src/core/config/encryption/crypto.rs deleted file mode 100644 index f761aad..0000000 --- a/src-tauri/src/core/config/encryption/crypto.rs +++ /dev/null @@ -1,273 +0,0 @@ -//! 配置加密/解密模块 -//! -//! 提供配置文件的多层加密和解密功能,使用 AES-256-GCM 加密算法。 -//! 包含多层防御:密钥派生、数据变换、分块加密、认证标签。 - -use aes_gcm::{ - Aes256Gcm, Nonce, - aead::{Aead, KeyInit}, -}; -use sha2::{Digest, Sha256}; - -use super::key_derivation::derive_subkeys; - -// 使用简单的 Result 类型,兼容 build.rs 和运行时 -type Result = std::result::Result; - -/// 加密配置数据(多层防御) -/// -/// 加密流程: -/// 1. 数据预变换(XOR + 字节重排) -/// 2. 分块加密(每块使用不同的 nonce) -/// 3. 添加认证标签(防篡改) -/// 4. 数据后变换(字节混淆) -pub fn encrypt(plaintext: &[u8]) -> Result> { - let keys = derive_subkeys(); - - // 第一层:数据预变换(混淆) - let transformed = pre_transform(plaintext, &keys.transform_key); - - // 第二层:分块加密 - let encrypted = block_encrypt(&transformed, &keys.encryption_key)?; - - // 第三层:添加认证标签 - let authenticated = add_auth_tag(&encrypted, &keys.auth_key); - - // 第四层:数据后变换 - let final_data = post_transform(&authenticated, &keys.transform_key); - - Ok(final_data) -} - -/// 解密配置数据(多层防御) -/// -/// 解密流程(加密的逆过程): -/// 1. 数据逆后变换 -/// 2. 验证认证标签 -/// 3. 分块解密 -/// 4. 数据逆预变换 -pub fn decrypt(ciphertext: &[u8]) -> Result> { - let keys = derive_subkeys(); - - // 第一层:数据逆变换 - let untransformed = post_transform_reverse(ciphertext, &keys.transform_key); - - // 第二层:验证认证标签 - let (data, expected_tag) = extract_auth_tag(&untransformed)?; - verify_auth_tag(data, expected_tag, &keys.auth_key)?; - - // 第三层:分块解密 - let decrypted = block_decrypt(data, &keys.encryption_key)?; - - // 第四层:数据逆预变换 - let plaintext = pre_transform_reverse(&decrypted, &keys.transform_key); - - Ok(plaintext) -} - -/// 数据预变换:XOR + 字节重排 -fn pre_transform(data: &[u8], key: &[u8; 16]) -> Vec { - if data.is_empty() { - return Vec::new(); - } - - let mut result = Vec::with_capacity(data.len()); - - // XOR 变换 - for (i, &byte) in data.iter().enumerate() { - result.push(byte ^ key[i % 16]); - } - - result.reverse(); - let shift = transform_shift(result.len(), key); - result.rotate_left(shift); - result -} - -/// 数据预变换的逆操作 -fn pre_transform_reverse(data: &[u8], key: &[u8; 16]) -> Vec { - if data.is_empty() { - return Vec::new(); - } - - let mut unpermuted = data.to_vec(); - let shift = transform_shift(unpermuted.len(), key); - unpermuted.rotate_right(shift); - unpermuted.reverse(); - - // 逆 XOR - let mut result = Vec::with_capacity(unpermuted.len()); - for (i, &byte) in unpermuted.iter().enumerate() { - result.push(byte ^ key[i % 16]); - } - - result -} - -/// 分块加密:将数据分成多块,每块使用不同的 nonce -fn block_encrypt(data: &[u8], key: &[u8; 32]) -> Result> { - const BLOCK_SIZE: usize = 4096; // 4KB 块 - - let cipher = Aes256Gcm::new(key.into()); - let mut result = Vec::new(); - - // 写入块数量 - let block_count = (data.len() + BLOCK_SIZE - 1) / BLOCK_SIZE; - result.extend_from_slice(&(block_count as u32).to_le_bytes()); - - // 分块加密 - for (block_idx, chunk) in data.chunks(BLOCK_SIZE).enumerate() { - // 为每个块派生不同的 nonce - let nonce_bytes = derive_block_nonce(block_idx, key); - let nonce = Nonce::from_slice(&nonce_bytes); - - // 加密块 - let encrypted_block = - cipher.encrypt(nonce, chunk).map_err(|e| format!("Encryption failed: {}", e))?; - - // 写入块大小和数据 - result.extend_from_slice(&(encrypted_block.len() as u32).to_le_bytes()); - result.extend_from_slice(&encrypted_block); - } - - Ok(result) -} - -/// 分块解密 -fn block_decrypt(data: &[u8], key: &[u8; 32]) -> Result> { - let cipher = Aes256Gcm::new(key.into()); - let mut result = Vec::new(); - - // 读取块数量 - if data.len() < 4 { - return Err("Invalid data format".to_string()); - } - let block_count = u32::from_le_bytes([data[0], data[1], data[2], data[3]]) as usize; - - let mut offset = 4; - for block_idx in 0..block_count { - // 读取块大小 - if offset + 4 > data.len() { - return Err("Invalid block format".to_string()); - } - let block_size = u32::from_le_bytes([ - data[offset], - data[offset + 1], - data[offset + 2], - data[offset + 3], - ]) as usize; - offset += 4; - - // 读取块数据 - if offset + block_size > data.len() { - return Err("Invalid block data".to_string()); - } - let block_data = &data[offset..offset + block_size]; - offset += block_size; - - // 派生 nonce 并解密 - let nonce_bytes = derive_block_nonce(block_idx, key); - let nonce = Nonce::from_slice(&nonce_bytes); - - let decrypted_block = cipher - .decrypt(nonce, block_data) - .map_err(|e| format!("Decryption failed: {}", e))?; - - result.extend_from_slice(&decrypted_block); - } - - Ok(result) -} - -/// 为每个块派生不同的 nonce -fn derive_block_nonce(block_idx: usize, key: &[u8; 32]) -> [u8; 12] { - let mut hasher = Sha256::new(); - hasher.update(key); - hasher.update(b"block-nonce"); - hasher.update(&(block_idx as u64).to_le_bytes()); - let result = hasher.finalize(); - - let mut nonce = [0u8; 12]; - nonce.copy_from_slice(&result[..12]); - nonce -} - -/// 添加认证标签 -fn add_auth_tag(data: &[u8], auth_key: &[u8; 32]) -> Vec { - let mut hasher = Sha256::new(); - hasher.update(auth_key); - hasher.update(data); - let tag = hasher.finalize(); - - let mut result = Vec::with_capacity(data.len() + 32); - result.extend_from_slice(data); - result.extend_from_slice(&tag); - result -} - -/// 提取认证标签 -fn extract_auth_tag(data: &[u8]) -> Result<(&[u8], &[u8])> { - if data.len() < 32 { - return Err("Data too short for auth tag".to_string()); - } - let split_pos = data.len() - 32; - Ok((&data[..split_pos], &data[split_pos..])) -} - -/// 验证认证标签 -fn verify_auth_tag(data: &[u8], expected_tag: &[u8], auth_key: &[u8; 32]) -> Result<()> { - let mut hasher = Sha256::new(); - hasher.update(auth_key); - hasher.update(data); - let computed_tag = hasher.finalize(); - - if computed_tag.as_slice() != expected_tag { - return Err("Authentication failed".to_string()); - } - - Ok(()) -} - -/// 数据后变换:简单的字节混淆 -fn post_transform(data: &[u8], key: &[u8; 16]) -> Vec { - data.iter().enumerate().map(|(i, &b)| b.wrapping_add(key[i % 16])).collect() -} - -/// 数据后变换的逆操作 -fn post_transform_reverse(data: &[u8], key: &[u8; 16]) -> Vec { - data.iter().enumerate().map(|(i, &b)| b.wrapping_sub(key[i % 16])).collect() -} - -fn transform_shift(len: usize, key: &[u8; 16]) -> usize { - if len <= 1 { - return 0; - } - - let seed = key.iter().take(4).fold(0usize, |acc, byte| (acc << 8) | (*byte as usize)); - (seed.wrapping_add(len).wrapping_add(13)) % len -} - -#[cfg(test)] -mod tests { - use super::{decrypt, encrypt}; - - #[test] - fn roundtrip_preserves_all_lengths_up_to_1024() { - for len in 0..=1024usize { - let input = (0..len).map(|index| ((index * 31 + 17) % 256) as u8).collect::>(); - let encrypted = encrypt(&input).expect("encrypt should succeed"); - let decrypted = decrypt(&encrypted).expect("decrypt should succeed"); - assert_eq!(decrypted, input, "roundtrip mismatch for len={len}"); - } - } - - #[test] - fn roundtrip_preserves_lengths_that_are_multiples_of_seven() { - for len in [7usize, 14, 497, 504, 4095, 4096, 4097] { - let input = (0..len).map(|index| ((index * 13 + 29) % 256) as u8).collect::>(); - let encrypted = encrypt(&input).expect("encrypt should succeed"); - let decrypted = decrypt(&encrypted).expect("decrypt should succeed"); - assert_eq!(decrypted, input, "roundtrip mismatch for len={len}"); - } - } -} diff --git a/src-tauri/src/core/config/encryption/key_derivation.rs b/src-tauri/src/core/config/encryption/key_derivation.rs deleted file mode 100644 index eee9a8f..0000000 --- a/src-tauri/src/core/config/encryption/key_derivation.rs +++ /dev/null @@ -1,124 +0,0 @@ -//! 密钥派生模块 -//! -//! 通过复杂算法派生固定的加密密钥,避免密钥以明文形式出现在代码中。 -//! 此模块在编译时(build.rs)和运行时都会被使用,必须返回相同的结果。 - -use sha2::{Digest, Sha256}; - -/// 通过复杂算法派生主密钥 -/// -/// 使用多个分散的种子常量和复杂的计算过程,避免密钥以明文形式出现在二进制中。 -/// 注意:此函数在编译时和运行时都会被调用,必须返回相同的结果。 -fn derive_master_key() -> [u8; 32] { - // 第一层:分散的种子常量(伪装成配置参数) - const SEED_ALPHA: u64 = 0x1A2B3C4D5E6F7A8B; - const SEED_BETA: u64 = 0xFEDCBA9876543210; - const SEED_GAMMA: &[u8] = &[ - 0x43, 0x6f, 0x6e, 0x66, 0x69, 0x67, 0x45, 0x6e, 0x63, 0x72, 0x79, 0x70, 0x74, 0x69, 0x6f, - 0x6e, 0x53, 0x61, 0x6c, 0x74, 0x32, 0x30, 0x32, 0x34, - ]; - const SEED_DELTA: u32 = 0x89ABCDEF; - const SEED_EPSILON: u16 = 0x1234; - - // 第二层:构建初始缓冲区(使用不同的字节序和位运算) - let mut buffer = Vec::with_capacity(128); - buffer.extend_from_slice(&SEED_ALPHA.to_le_bytes()); - buffer.extend_from_slice(&SEED_BETA.to_be_bytes()); - buffer.extend_from_slice(SEED_GAMMA); - buffer.extend_from_slice(&SEED_DELTA.rotate_left(13).to_le_bytes()); - buffer.extend_from_slice(&SEED_EPSILON.wrapping_mul(7).to_be_bytes()); - - // 第三层:初始哈希 - let mut hasher = Sha256::new(); - hasher.update(&buffer); - let mut result = hasher.finalize(); - - // 第四层:多轮迭代(1000轮,增加计算复杂度) - for round in 0..1000 { - let mut hasher = Sha256::new(); - hasher.update(&result); - // 每轮混入轮数,确保每轮结果不同 - hasher.update(&(round as u32).to_le_bytes()); - result = hasher.finalize(); - } - - // 第五层:最终混淆(使用固定的应用标识) - const APP_IDENTIFIER: &[u8] = &[ - 0x53, 0x69, 0x6d, 0x50, 0x72, 0x69, 0x6e, 0x74, 0x2d, 0x43, 0x6f, 0x6e, 0x66, 0x69, 0x67, - 0x2d, 0x76, 0x31, - ]; - let mut hasher = Sha256::new(); - hasher.update(&result); - hasher.update(APP_IDENTIFIER); - result = hasher.finalize(); - - result.into() -} - -/// 密钥派生结果 -/// -/// 包含用于不同目的的子密钥 -pub struct DerivedKeys { - pub encryption_key: [u8; 32], // 用于实际加密 - pub auth_key: [u8; 32], // 用于认证/校验 - pub transform_key: [u8; 16], // 用于数据变换 -} - -/// 从主密钥派生子密钥 -/// -/// 使用不同的派生路径,生成多个独立的子密钥 -pub fn derive_subkeys() -> DerivedKeys { - let master = derive_master_key(); - - // 派生加密密钥 - let encryption_key = derive_subkey( - &master, - &[0x65, 0x6e, 0x63, 0x72, 0x79, 0x70, 0x74, 0x69, 0x6f, 0x6e], - 1000, - ); - - // 派生认证密钥 - let auth_key = derive_subkey( - &master, - &[ - 0x61, 0x75, 0x74, 0x68, 0x65, 0x6e, 0x74, 0x69, 0x63, 0x61, 0x74, 0x69, 0x6f, 0x6e, - ], - 1500, - ); - - // 派生变换密钥 - let transform_key_full = derive_subkey( - &master, - &[0x74, 0x72, 0x61, 0x6e, 0x73, 0x66, 0x6f, 0x72, 0x6d], - 2000, - ); - let mut transform_key = [0u8; 16]; - transform_key.copy_from_slice(&transform_key_full[..16]); - - DerivedKeys { - encryption_key, - auth_key, - transform_key, - } -} - -/// 子密钥派生函数 -/// -/// 使用 HKDF 类似的方法从主密钥派生子密钥 -fn derive_subkey(master: &[u8; 32], context: &[u8], rounds: usize) -> [u8; 32] { - let mut hasher = Sha256::new(); - hasher.update(master); - hasher.update(context); - let mut result = hasher.finalize(); - - // 多轮迭代 - for i in 0..rounds { - let mut hasher = Sha256::new(); - hasher.update(&result); - hasher.update(&(i as u32).to_le_bytes()); - hasher.update(context); - result = hasher.finalize(); - } - - result.into() -} diff --git a/src-tauri/src/core/config/encryption/mod.rs b/src-tauri/src/core/config/encryption/mod.rs deleted file mode 100644 index 9e2f074..0000000 --- a/src-tauri/src/core/config/encryption/mod.rs +++ /dev/null @@ -1,9 +0,0 @@ -//! 配置加密模块 -//! -//! 提供配置文件的加密和解密功能,包括密钥派生和多层加密 - -mod crypto; -mod key_derivation; - -// 导出加密/解密函数 -pub use crypto::{decrypt, encrypt}; diff --git a/src-tauri/src/core/config/loader.rs b/src-tauri/src/core/config/loader.rs deleted file mode 100644 index 7d1a77b..0000000 --- a/src-tauri/src/core/config/loader.rs +++ /dev/null @@ -1,48 +0,0 @@ -//! 配置加载和解密 -//! -//! 负责从不同来源加载配置并进行解密 - -use super::encryption; -use super::types::AppConfig; -use crate::core::error::{Error, Result}; -use config::{Config, FileFormat}; - -/// 编译期从 OUT_DIR 中引入加密后的配置二进制 -const ENCRYPTED_CONFIG: &[u8] = include_bytes!(concat!(env!("OUT_DIR"), "/config_encrypted.bin")); - -/// 解密配置内容 -fn decrypt_config() -> Result { - let decrypted = - encryption::decrypt(ENCRYPTED_CONFIG).map_err(|e| Error::ConfigDecryptFailed(e))?; - String::from_utf8(decrypted).map_err(|e| Error::ConfigDecryptFailed(e.to_string())) -} - -/// 从字符串加载配置 -pub fn load_from_str(config_str: &str) -> Result { - let config = Config::builder() - .add_source(config::File::from_str(config_str, FileFormat::Toml)) - .add_source(config::File::with_name(".").required(false)) - .add_source(config::Environment::with_prefix("APP")) - .build() - .map_err(|e| Error::ConfigLoadFailed(e.to_string()))?; - - config.try_deserialize().map_err(|e| Error::ConfigParseFailed(e.to_string())) -} - -/// 从文件路径加载配置 -pub fn load_from_path(config_path: &str) -> Result { - let config = Config::builder() - .add_source(config::File::with_name(config_path)) - .add_source(config::File::with_name(".").required(false)) - .add_source(config::Environment::with_prefix("APP")) - .build() - .map_err(|e| Error::ConfigLoadFailed(e.to_string()))?; - - config.try_deserialize().map_err(|e| Error::ConfigParseFailed(e.to_string())) -} - -/// 加载嵌入的加密配置 -pub fn load_embedded() -> Result { - let config_str = decrypt_config()?; - load_from_str(&config_str) -} diff --git a/src-tauri/src/core/config/mod.rs b/src-tauri/src/core/config/mod.rs deleted file mode 100644 index 83a2666..0000000 --- a/src-tauri/src/core/config/mod.rs +++ /dev/null @@ -1,96 +0,0 @@ -//! 配置管理模块 -//! -//! 提供配置的加载、验证和全局访问 - -mod encryption; -mod loader; -mod types; -mod validator; - -pub use types::{AppConfig, UpdaterConfig}; - -use crate::core::error::{Error, Result}; -use std::{fs, path::PathBuf, sync::OnceLock}; - -/// 全局配置实例 -static CONFIG: OnceLock = OnceLock::new(); - -/// 初始化配置(从嵌入的加密配置) -/// -/// 这是最常用的初始化方式,配置在编译时被加密并嵌入到二进制中 -pub fn init() -> Result<()> { - let config = if let Some(config_name) = local_config_file_name() { - let config_path = resolve_local_config_path(config_name); - let config_str = fs::read_to_string(&config_path) - .map_err(|e| Error::ConfigLoadFailed(format!("{}: {}", config_path.display(), e)))?; - loader::load_from_str(&config_str)? - } else { - loader::load_embedded()? - }; - validator::validate(&config)?; - - CONFIG.set(config).map_err(|_| Error::ConfigAlreadyInitialized)?; - Ok(()) -} - -/// 初始化配置(从字符串) -/// -/// 用于测试或从其他来源加载配置 -pub fn init_from_str(config_str: &str) -> Result<()> { - let config = loader::load_from_str(config_str)?; - validator::validate(&config)?; - - CONFIG.set(config).map_err(|_| Error::ConfigAlreadyInitialized)?; - Ok(()) -} - -/// 初始化配置(从文件路径) -/// -/// 用于开发环境或特殊场景 -pub fn init_from_path(config_path: &str) -> Result<()> { - let config = loader::load_from_path(config_path)?; - validator::validate(&config)?; - - CONFIG.set(config).map_err(|_| Error::ConfigAlreadyInitialized)?; - Ok(()) -} - -/// 获取配置(返回 Option) -/// -/// 如果配置未初始化,返回 None -pub fn get() -> Option<&'static AppConfig> { - CONFIG.get() -} - -/// 获取配置(必须已初始化) -/// -/// 如果配置未初始化,返回错误 -pub fn get_or_err() -> Result<&'static AppConfig> { - CONFIG.get().ok_or(Error::ConfigNotInitialized) -} - -/// 获取配置(必须已初始化,否则 panic) -/// -/// 仅在确定配置已初始化的场景使用 -pub fn get_or_panic() -> &'static AppConfig { - CONFIG.get().expect("Config not initialized. Call config::init() first.") -} - -fn local_config_file_name() -> Option<&'static str> { - if cfg!(feature = "test") { - Some("config.test.toml") - } else if cfg!(feature = "development") { - Some("config.development.toml") - } else { - None - } -} - -fn resolve_local_config_path(config_name: &str) -> PathBuf { - let cwd_path = PathBuf::from(config_name); - if cwd_path.exists() { - return cwd_path; - } - - PathBuf::from(env!("CARGO_MANIFEST_DIR")).join(config_name) -} diff --git a/src-tauri/src/core/config/types.rs b/src-tauri/src/core/config/types.rs deleted file mode 100644 index 3464fce..0000000 --- a/src-tauri/src/core/config/types.rs +++ /dev/null @@ -1,25 +0,0 @@ -//! 配置类型定义 -//! -//! 定义了应用的所有配置结构体 - -use serde::Deserialize; - -/// 更新器配置 -#[derive(Deserialize, Debug, Clone)] -pub struct UpdaterConfig { - pub check_url: String, - pub latest_json_url: String, - - /// 下载的临时目录(可选)。 - /// - 若为空:默认使用统一根目录下的 `updates` - /// - 若填写:使用该目录(相对路径基于统一根目录) - #[serde(default)] - pub updater_temp_dir: Option, -} - -/// 应用配置 -#[derive(Deserialize, Debug, Clone)] -pub struct AppConfig { - /// 更新器配置 - pub updater: UpdaterConfig, -} diff --git a/src-tauri/src/core/config/validator.rs b/src-tauri/src/core/config/validator.rs deleted file mode 100644 index d71e408..0000000 --- a/src-tauri/src/core/config/validator.rs +++ /dev/null @@ -1,45 +0,0 @@ -//! 配置验证 -//! -//! 验证配置的有效性 - -use super::types::AppConfig; -use crate::core::error::{Error, Result}; - -/// 验证配置 -pub fn validate(config: &AppConfig) -> Result<()> { - validate_updater_config(config)?; - Ok(()) -} - -/// 验证更新器配置 -fn validate_updater_config(config: &AppConfig) -> Result<()> { - if config.updater.check_url.is_empty() { - return Err(Error::ConfigValidationFailed( - "updater.check_url cannot be empty".to_string(), - )); - } - - if !config.updater.check_url.starts_with("http://") - && !config.updater.check_url.starts_with("https://") - { - return Err(Error::ConfigValidationFailed( - "updater.check_url must start with http:// or https://".to_string(), - )); - } - - if config.updater.latest_json_url.is_empty() { - return Err(Error::ConfigValidationFailed( - "updater.latest_json_url cannot be empty".to_string(), - )); - } - - if !config.updater.latest_json_url.starts_with("http://") - && !config.updater.latest_json_url.starts_with("https://") - { - return Err(Error::ConfigValidationFailed( - "updater.latest_json_url must start with http:// or https://".to_string(), - )); - } - - Ok(()) -} diff --git a/src-tauri/src/core/mod.rs b/src-tauri/src/core/mod.rs index 145b8ee..a2818cf 100644 --- a/src-tauri/src/core/mod.rs +++ b/src-tauri/src/core/mod.rs @@ -1,7 +1,6 @@ // Core 层:核心业务逻辑和领域模型 pub mod app_info; -pub mod config; pub mod error; pub mod logger; pub mod paths; diff --git a/src-tauri/src/core/paths.rs b/src-tauri/src/core/paths.rs index f751e0e..f0f5956 100644 --- a/src-tauri/src/core/paths.rs +++ b/src-tauri/src/core/paths.rs @@ -29,8 +29,6 @@ struct BootstrapConfig { cache_dir: Option, data_dir: Option, kernels_dir: Option, - updater_dir: Option, - update_tasks_file: Option, } /// 路径管理器 @@ -177,28 +175,6 @@ impl PathManager { Ok(dir) } - pub fn get_updater_dir() -> Result { - let dir = Self::resolve_named_dir( - Self::load_bootstrap().updater_dir.as_deref(), - Self::get_root_dir()?, - "updates", - )?; - Self::ensure_dir(&dir)?; - Ok(dir) - } - - pub fn get_update_tasks_file() -> Result { - let root = Self::get_root_dir()?; - let path = Self::load_bootstrap() - .update_tasks_file - .as_deref() - .map(|value| Self::resolve_override_path(value, &root)) - .transpose()? - .unwrap_or_else(|| root.join("update_tasks.json")); - Self::ensure_parent_dir(&path)?; - Ok(path) - } - pub fn get_store_file() -> Result { let path = Self::get_config_dir()?.join("store.json"); Self::ensure_parent_dir(&path)?; @@ -331,16 +307,6 @@ impl PathManager { &Self::get_kernels_dir()?.to_string_lossy().to_string(), ) .context("写入注册表 KernelsDir 失败")?; - key.set_value( - "UpdaterDir", - &Self::get_updater_dir()?.to_string_lossy().to_string(), - ) - .context("写入注册表 UpdaterDir 失败")?; - key.set_value( - "UpdateTasksFile", - &Self::get_update_tasks_file()?.to_string_lossy().to_string(), - ) - .context("写入注册表 UpdateTasksFile 失败")?; } #[cfg(not(target_os = "windows"))] @@ -391,7 +357,5 @@ impl BootstrapConfig { && self.cache_dir.is_none() && self.data_dir.is_none() && self.kernels_dir.is_none() - && self.updater_dir.is_none() - && self.update_tasks_file.is_none() } } diff --git a/src-tauri/src/core/utils/hash.rs b/src-tauri/src/core/utils/hash.rs new file mode 100644 index 0000000..0584dd6 --- /dev/null +++ b/src-tauri/src/core/utils/hash.rs @@ -0,0 +1,25 @@ +use anyhow::{Context, Result}; +use sha2::{Digest, Sha256}; +use std::fs::File; +use std::io::Read; +use std::path::Path; + +/// Calculates the complete SHA-256 digest of a file. +pub fn calculate_file_hash(file_path: &Path) -> Result { + let mut file = + File::open(file_path).with_context(|| format!("无法打开文件: {}", file_path.display()))?; + let mut hasher = Sha256::new(); + let mut buffer = [0_u8; 8192]; + + loop { + let bytes_read = file + .read(&mut buffer) + .with_context(|| format!("读取文件失败: {}", file_path.display()))?; + if bytes_read == 0 { + break; + } + hasher.update(&buffer[..bytes_read]); + } + + Ok(format!("{:x}", hasher.finalize())) +} diff --git a/src-tauri/src/core/utils/mod.rs b/src-tauri/src/core/utils/mod.rs index 80fe812..c500c46 100644 --- a/src-tauri/src/core/utils/mod.rs +++ b/src-tauri/src/core/utils/mod.rs @@ -1 +1,2 @@ +pub mod hash; pub mod process; diff --git a/src-tauri/src/infrastructure/mod.rs b/src-tauri/src/infrastructure/mod.rs index 06d54e9..635453e 100644 --- a/src-tauri/src/infrastructure/mod.rs +++ b/src-tauri/src/infrastructure/mod.rs @@ -3,4 +3,3 @@ pub mod mihomo; pub mod persistence; pub mod proxy; pub mod runtime; -pub mod updater; diff --git a/src-tauri/src/infrastructure/updater/checker.rs b/src-tauri/src/infrastructure/updater/checker.rs deleted file mode 100644 index d72da98..0000000 --- a/src-tauri/src/infrastructure/updater/checker.rs +++ /dev/null @@ -1,45 +0,0 @@ -/// 版本检查模块 -/// -/// 负责调用服务器的版本检查 API 获取更新信息 -use crate::app::context::AppContext; -use crate::infrastructure::updater::types::{CheckRequest, CheckResponse}; -use anyhow::{Context, Result}; -use reqwest::Client; -use std::time::Duration; - -const CHECK_TIMEOUT_SECS: u64 = 30; - -/// 检查更新 -/// -/// # 返回 -/// 返回服务器检查响应 -pub async fn check_updates() -> Result { - let ctx = AppContext::get(); - let url = &ctx.config.updater.check_url; - - // 创建 HTTP 客户端 - let client = Client::builder() - .timeout(Duration::from_secs(CHECK_TIMEOUT_SECS)) - .build() - .context("创建 HTTP 客户端失败")?; - - // 构建请求 - let request = CheckRequest {}; - - let response = client.post(url).json(&request).send().await.context("发送检查请求失败")?; - - // 检查 HTTP 状态码 - let status = response.status(); - if !status.is_success() { - return Err(anyhow::anyhow!("服务器返回错误状态码: {}", status)); - } - - let response_json = response.json::().await?; - // println!("检查响应: {:?}", response_json); - - // 解析响应 - let check_response: CheckResponse = - serde_json::from_value(response_json).context("解析服务器响应失败")?; - - Ok(check_response) -} diff --git a/src-tauri/src/infrastructure/updater/downloader.rs b/src-tauri/src/infrastructure/updater/downloader.rs deleted file mode 100644 index e9c5e8b..0000000 --- a/src-tauri/src/infrastructure/updater/downloader.rs +++ /dev/null @@ -1,136 +0,0 @@ -/// 下载模块 -/// -/// 负责下载更新文件到临时目录,支持重试和进度回调 -use crate::infrastructure::updater::types::UpdateTask; -use anyhow::{Context, Result}; -use futures::StreamExt; -use reqwest::{Client, header}; -use std::fs::File; -use std::io::Write; -use std::time::Duration; - -const DOWNLOAD_TIMEOUT_SECS: u64 = 300; // 5 分钟超时 -const MAX_RETRIES: u32 = 3; -const RETRY_DELAY_SECS: u64 = 2; - -/// 下载单个文件(带进度回调) -/// -/// # 参数 -/// - `task`: 更新任务 -/// - `client`: HTTP 客户端 -/// - `on_progress`: 进度回调函数,参数为已下载字节数 -/// -/// # 返回 -/// 返回下载文件的临时路径 -pub async fn download_file_with_progress( - task: &UpdateTask, - client: &Client, - mut on_progress: F, -) -> Result<()> -where - F: FnMut(u64) -> Result<()>, -{ - let mut last_error = None; - - // 重试循环 - for attempt in 1..=MAX_RETRIES { - match download_file_internal_with_progress(task, client, &mut on_progress).await { - Ok(_) => return Ok(()), - Err(e) => { - last_error = Some(e); - - // 清理临时文件 - if task.temp_path.exists() { - let _ = std::fs::remove_file(&task.temp_path); - } - - if attempt < MAX_RETRIES { - tokio::time::sleep(Duration::from_secs(RETRY_DELAY_SECS)).await; - } - } - } - } - - Err(anyhow::anyhow!( - "下载文件失败,已达到最大重试次数: {}", - last_error.unwrap_or_else(|| anyhow::anyhow!("未知错误")) - )) -} - -/// 内部下载实现(带进度回调) -async fn download_file_internal_with_progress( - task: &UpdateTask, - client: &Client, - on_progress: &mut F, -) -> Result<()> -where - F: FnMut(u64) -> Result<()>, -{ - // 确保临时目录存在 - if let Some(parent) = task.temp_path.parent() { - std::fs::create_dir_all(parent) - .with_context(|| format!("创建临时目录失败: {}", parent.display()))?; - } - - // 创建临时文件 - let mut file = File::create(&task.temp_path) - .with_context(|| format!("创建临时文件失败: {}", task.temp_path.display()))?; - - // 发送 HTTP GET 请求 - let response = client - .get(&task.artifact.url) - .timeout(Duration::from_secs(DOWNLOAD_TIMEOUT_SECS)) - .header(header::ACCEPT_ENCODING, "identity") - .send() - .await - .with_context(|| format!("发送下载请求失败"))?; - - // 检查 HTTP 状态码 - if !response.status().is_success() { - return Err(anyhow::anyhow!( - "服务器返回错误状态码: {}", - response.status() - )); - } - - // 获取内容长度(用于进度跟踪) - let content_length = response.content_length(); - if let Some(expected_size) = content_length { - if expected_size != task.artifact.file_size { - // 静默处理文件大小不匹配,不输出日志 - } - } - - // 流式下载并写入文件 - let mut stream = response.bytes_stream(); - let mut downloaded: u64 = 0; - - while let Some(chunk_result) = stream.next().await { - let chunk = chunk_result.with_context(|| "读取下载数据块失败")?; - - file.write_all(&chunk).with_context(|| "写入文件失败")?; - - downloaded += chunk.len() as u64; - - // 调用进度回调 - let _ = on_progress(downloaded); - } - - // 确保文件写入完成 - file.sync_all().with_context(|| "同步文件失败")?; - - // 验证下载的文件大小 - let actual_size = std::fs::metadata(&task.temp_path) - .with_context(|| "获取下载文件大小失败")? - .len(); - - if actual_size != task.artifact.file_size { - return Err(anyhow::anyhow!( - "下载文件大小不匹配: 期望 {}, 实际 {}", - task.artifact.file_size, - actual_size - )); - } - - Ok(()) -} diff --git a/src-tauri/src/infrastructure/updater/installer.rs b/src-tauri/src/infrastructure/updater/installer.rs deleted file mode 100644 index f1ade07..0000000 --- a/src-tauri/src/infrastructure/updater/installer.rs +++ /dev/null @@ -1,275 +0,0 @@ -/// 安装模块 -/// -/// 负责安全替换文件(备份、原子替换) -use anyhow::{Context, Result}; -use std::fs; -use std::path::Path; - -/// 检查进程是否正在运行(通过 resource_name 匹配,不检查路径) -fn is_process_running_by_name(resource_name: &str) -> Result { - use sysinfo::System; - - let resource_name_lower = resource_name.to_lowercase(); - let mut sys = System::new(); - sys.refresh_processes(sysinfo::ProcessesToUpdate::All, true); - - // 检查是否有匹配的进程(仅通过名称匹配) - let found = sys.processes().iter().any(|(_, process)| { - let process_name = process.name().to_string_lossy().to_lowercase(); - process_name == resource_name_lower - }); - - Ok(found) -} - -/// 查找并终止进程(通过 resource_name) -fn kill_process_by_name(resource_name: &str) -> Result<()> { - use sysinfo::System; - - let resource_name_lower = resource_name.to_lowercase(); - let mut sys = System::new(); - sys.refresh_processes(sysinfo::ProcessesToUpdate::All, true); - - // 查找所有匹配的进程并终止 - for (_, process) in sys.processes() { - let process_name = process.name().to_string_lossy().to_lowercase(); - - if process_name == resource_name_lower { - // 使用 sysinfo 的 kill 方法(静默执行,不输出日志) - let _ = process.kill(); - } - } - - Ok(()) -} - -/// 等待进程退出(通过 resource_name 匹配) -/// 最多等待 1.8 秒(每次 0.6 秒,总共 3 次) -/// 每次等待时都尝试终止进程 -fn wait_for_process_exit_by_name(resource_name: &str) -> Result<()> { - // 检查进程是否正在运行 - let is_running = is_process_running_by_name(resource_name).context("检查进程状态失败")?; - - if !is_running { - return Ok(()); - } - - const MAX_ATTEMPTS: u32 = 3; // 最多尝试 3 次 - const DELAY_MILLIS: u64 = 600; // 每次等待 0.6 秒 - - for _attempt in 1..=MAX_ATTEMPTS { - // 尝试终止进程 - let _ = kill_process_by_name(resource_name); - - // 等待 0.6 秒 - std::thread::sleep(std::time::Duration::from_millis(DELAY_MILLIS)); - - // 检查进程是否已退出 - let is_running = is_process_running_by_name(resource_name).context("检查进程状态失败")?; - - if !is_running { - return Ok(()); - } - } - - // 最后一次检查(静默处理,不输出日志) - let _ = is_process_running_by_name(resource_name); - - Ok(()) -} - -/// 确保文件未被锁定 -fn ensure_file_not_locked(file_path: &Path) -> Result<()> { - // Windows 下,尝试以独占模式打开文件 - // 如果成功,说明文件未被锁定 - #[cfg(target_os = "windows")] - { - use std::fs::OpenOptions; - use std::io::Read; - - // 尝试以只读模式打开文件 - if file_path.exists() { - let mut file = OpenOptions::new() - .read(true) - .open(file_path) - .with_context(|| format!("无法打开文件(可能被锁定): {}", file_path.display()))?; - - // 尝试读取一个字节来确认文件未被锁定 - let mut buffer = [0u8; 1]; - file.read_exact(&mut buffer) - .with_context(|| format!("文件可能被锁定: {}", file_path.display()))?; - } - } - - #[cfg(not(target_os = "windows"))] - { - // 非 Windows 平台暂不检查 - } - - Ok(()) -} - -/// 直接安装文件(用于 install 模式) -/// -/// # 参数 -/// - `target_path`: 目标路径 -/// - `backup_path`: 备份路径(可选) -/// - `temp_path`: 临时文件路径(已下载并校验通过的文件) -/// -/// # 返回 -/// 安装成功返回 Ok(()), 否则返回错误 -pub fn install_file_direct( - resource_name: &str, - target_path: &Path, - backup_path: Option<&Path>, - temp_path: &Path, -) -> Result<()> { - // 尝试退出同名进程(若存在) - let _ = wait_for_process_exit_by_name(resource_name); - - // 确保目标文件未被锁定 - ensure_file_not_locked(target_path)?; - - replace_file_direct(target_path, backup_path, temp_path)?; - - Ok(()) -} - -/// 备份原文件 -fn backup_file(target_path: &Path, backup_path: &Path) -> Result<()> { - // 确保备份目录存在 - if let Some(parent) = backup_path.parent() { - fs::create_dir_all(parent) - .with_context(|| format!("创建备份目录失败: {}", parent.display()))?; - } - - // 删除旧的备份文件(如果存在) - if backup_path.exists() { - fs::remove_file(backup_path) - .with_context(|| format!("删除旧备份失败: {}", backup_path.display()))?; - } - - // 复制原文件到备份位置 - fs::copy(target_path, backup_path) - .with_context(|| format!("备份文件失败: {}", backup_path.display()))?; - - Ok(()) -} - -/// 移除只读属性(Windows 平台) -#[cfg(target_os = "windows")] -fn remove_readonly_attribute(file_path: &Path) -> Result<()> { - use std::fs::metadata; - - let metadata = metadata(file_path) - .with_context(|| format!("获取文件元数据失败: {}", file_path.display()))?; - - // 如果文件是只读的,移除只读属性 - if metadata.permissions().readonly() { - let mut perms = metadata.permissions(); - perms.set_readonly(false); - fs::set_permissions(file_path, perms) - .with_context(|| format!("设置文件权限失败: {}", file_path.display()))?; - } - - Ok(()) -} - -#[cfg(not(target_os = "windows"))] -fn remove_readonly_attribute(_file_path: &Path) -> Result<()> { - // 非 Windows 平台无需处理 - Ok(()) -} - -/// 删除旧文件(如果存在) -fn remove_old_file(target_path: &Path) -> Result<()> { - if !target_path.exists() { - return Ok(()); - } - - // 移除只读属性 - remove_readonly_attribute(target_path)?; - - // 删除文件 - fs::remove_file(target_path) - .with_context(|| format!("删除旧文件失败: {}", target_path.display()))?; - - Ok(()) -} - -/// 移动临时文件到目标位置 -fn move_temp_file_to_target(temp_path: &Path, target_path: &Path) -> Result<()> { - if let Err(_) = fs::rename(temp_path, target_path) { - // 如果移动失败(可能跨文件系统),尝试复制 - fs::copy(temp_path, target_path) - .with_context(|| format!("复制文件失败: {}", target_path.display()))?; - - // 删除临时文件 - fs::remove_file(temp_path) - .with_context(|| format!("删除临时文件失败: {}", temp_path.display()))?; - } - - Ok(()) -} - -/// 删除备份文件(如果存在) -fn cleanup_backup_file(backup_path: &Path) { - if backup_path.exists() { - let _ = fs::remove_file(backup_path); - } -} - -/// 直接替换文件(用于 install 模式) -fn replace_file_direct( - target_path: &Path, - backup_path: Option<&Path>, - temp_path: &Path, -) -> Result<()> { - // 1. 备份原文件(如果存在) - let mut created_backup = false; - if target_path.exists() { - if let Some(backup_path) = backup_path { - backup_file(target_path, backup_path)?; - created_backup = true; - } - } - - // 2. 确保目标目录存在 - if let Some(parent) = target_path.parent() { - fs::create_dir_all(parent) - .with_context(|| format!("创建目标目录失败: {}", parent.display()))?; - } - - // 3. 删除旧文件 - remove_old_file(target_path)?; - - // 4. 移动临时文件到目标位置 - move_temp_file_to_target(temp_path, target_path)?; - - // 5. 替换成功后,删除备份文件(仅在替换成功时删除) - if created_backup { - if let Some(backup_path) = backup_path { - cleanup_backup_file(backup_path); - } - } - - Ok(()) -} - -/// 从备份路径回滚(用于 install 模式) -pub fn rollback_from_backup(target_path: &Path, backup_path: &Path) -> Result<()> { - if backup_path.exists() { - // 删除当前文件(如果存在) - if target_path.exists() { - fs::remove_file(target_path).context("删除失败文件失败")?; - } - - // 恢复备份 - fs::copy(backup_path, target_path) - .with_context(|| format!("恢复备份失败: {}", backup_path.display()))?; - - Ok(()) - } else { - Err(anyhow::anyhow!("备份文件不存在,无法回滚")) - } -} diff --git a/src-tauri/src/infrastructure/updater/manifest.rs b/src-tauri/src/infrastructure/updater/manifest.rs deleted file mode 100644 index a760ac2..0000000 --- a/src-tauri/src/infrastructure/updater/manifest.rs +++ /dev/null @@ -1,81 +0,0 @@ -/// Manifest 处理模块 -/// -/// 负责读写 manifest.json 文件,仅用于展示 -use crate::infrastructure::updater::types::{ArtifactInfo, ClientInfo, Manifest}; -use anyhow::{Context, Result}; -use chrono::Utc; -use std::fs; -use std::path::PathBuf; - -/// 获取 manifest.json 文件路径 -fn get_manifest_path() -> Result { - crate::core::paths::PathManager::get_manifest_file().context("无法获取 manifest.json 路径") -} - -/// 读取 manifest.json -/// -/// # 返回 -/// 返回 Manifest 结构,如果文件不存在则返回默认值 -pub fn read_manifest() -> Result { - let manifest_path = get_manifest_path()?; - - if !manifest_path.exists() { - // 文件不存在,返回默认值 - return Ok(Manifest { - client: ClientInfo { - version: env!("CARGO_PKG_VERSION").to_string(), - }, - artifacts: Vec::new(), - last_updated: Utc::now().to_rfc3339(), - }); - } - - // 读取文件内容 - let content = fs::read_to_string(&manifest_path) - .with_context(|| format!("读取 manifest.json 失败: {}", manifest_path.display()))?; - - // 解析 JSON - let manifest: Manifest = serde_json::from_str(&content) - .with_context(|| format!("解析 manifest.json 失败: {}", manifest_path.display()))?; - - Ok(manifest) -} - -/// 写入 manifest.json -/// -/// # 参数 -/// - `client_version`: 客户端版本 -/// - `artifacts`: 已安装的资源列表 -/// -/// # 说明 -/// manifest.json 仅用于展示,每次更新时都会完全重写 -pub fn write_manifest(client_version: &str, artifacts: &[ArtifactInfo]) -> Result<()> { - let manifest_path = get_manifest_path()?; - - // 构建 manifest 数据 - let artifact_infos: Vec = artifacts.to_vec(); - - let manifest = Manifest { - client: ClientInfo { - version: client_version.to_string(), - }, - artifacts: artifact_infos, - last_updated: Utc::now().to_rfc3339(), - }; - - // 序列化为 JSON - let json_content = - serde_json::to_string_pretty(&manifest).context("序列化 manifest.json 失败")?; - - // 确保目录存在 - if let Some(parent) = manifest_path.parent() { - fs::create_dir_all(parent) - .with_context(|| format!("创建目录失败: {}", parent.display()))?; - } - - // 写入文件(完全重写) - fs::write(&manifest_path, json_content) - .with_context(|| format!("写入 manifest.json 失败: {}", manifest_path.display()))?; - - Ok(()) -} diff --git a/src-tauri/src/infrastructure/updater/mod.rs b/src-tauri/src/infrastructure/updater/mod.rs deleted file mode 100644 index 3997020..0000000 --- a/src-tauri/src/infrastructure/updater/mod.rs +++ /dev/null @@ -1,21 +0,0 @@ -pub mod checker; -pub mod downloader; -pub mod installer; -pub mod manifest; -pub mod planner; -pub mod service; -pub mod state; -/// 更新器模块 -/// -/// 实现独立的更新器逻辑,包括版本检查、下载、校验、替换等功能 -pub mod types; -pub mod verifier; - -pub use checker::*; -pub use downloader::*; -pub use installer::*; -pub use manifest::*; -pub use planner::*; -pub use service::*; -pub use types::*; -pub use verifier::*; diff --git a/src-tauri/src/infrastructure/updater/planner.rs b/src-tauri/src/infrastructure/updater/planner.rs deleted file mode 100644 index 63f915c..0000000 --- a/src-tauri/src/infrastructure/updater/planner.rs +++ /dev/null @@ -1,131 +0,0 @@ -use crate::app::context::AppContext; -/// 更新计划生成模块 -/// -/// 负责对比本地文件 SHA256 与服务器版本,生成更新任务 -use crate::infrastructure::updater::types::{Artifact, CheckResponse, UpdateTask}; -use anyhow::{Context, Result}; -use sha2::{Digest, Sha256}; -use std::fs::File; -use std::io::Read; -use std::path::{Path, PathBuf}; - -/// 计算文件的完整 SHA256 哈希 -pub fn calculate_file_hash(file_path: &Path) -> Result { - let mut file = - File::open(file_path).with_context(|| format!("无法打开文件: {}", file_path.display()))?; - - let mut hasher = Sha256::new(); - let mut buffer = vec![0; 8192]; // 8KB 缓冲区 - - loop { - let bytes_read = file - .read(&mut buffer) - .with_context(|| format!("读取文件失败: {}", file_path.display()))?; - - if bytes_read == 0 { - break; - } - - hasher.update(&buffer[..bytes_read]); - } - - Ok(format!("{:x}", hasher.finalize())) -} - -/// 获取资源文件的默认路径(当前 exe 同目录) -fn get_resource_path(resource_name: &str) -> Result { - let current_exe = std::env::current_exe().context("无法获取当前可执行文件路径")?; - let exe_dir = current_exe.parent().context("无法获取可执行文件目录")?; - Ok(exe_dir.join(resource_name)) -} - -/// 获取下载临时目录 -/// 优先读取配置 `updater.updater_temp_dir`(相对路径基于统一根目录), -/// 未配置则使用统一根目录下的 `updates` -fn get_temp_dir() -> Result { - let ctx = AppContext::get(); - if let Some(ref custom) = ctx.config.updater.updater_temp_dir { - let p = PathBuf::from(custom); - let root_dir = crate::core::paths::PathManager::get_root_dir()?; - let dir = if p.is_absolute() { p } else { root_dir.join(p) }; - std::fs::create_dir_all(&dir).ok(); - return Ok(dir); - } - - crate::core::paths::PathManager::get_updater_dir() -} - -/// 根据 artifact 获取目标路径 -/// 优先使用 install_path,如果为空则使用当前目录 + resource_name -fn get_target_path(artifact: &Artifact) -> Result { - if let Some(ref install_path) = artifact.install_path { - let path = PathBuf::from(install_path); - - // 规范化路径,防止路径攻击 - let normalized_path = path.canonicalize().or_else(|_| { - if path.components().any(|c| matches!(c, std::path::Component::ParentDir)) { - return Err(anyhow::anyhow!("路径包含非法组件: {}", install_path)); - } - Ok(path) - })?; - - // 使用服务器提供的 install_path - Ok(normalized_path) - } else { - // 使用当前 exe 同级目录 + resource_name - get_resource_path(&artifact.resource_name) - } -} - -/// 检查资源文件是否存在且哈希匹配 -fn check_resource_file(artifact: &Artifact) -> Result { - let target_path = get_target_path(artifact)?; - - // 文件不存在,需要更新 - if !target_path.exists() { - return Ok(true); - } - - // 计算本地文件哈希 - let local_hash = calculate_file_hash(&target_path)?; - let remote_hash = artifact.hash.to_lowercase(); - - // 哈希不匹配,需要更新 - Ok(local_hash.to_lowercase() != remote_hash) -} - -/// 生成更新计划 -/// -/// # 参数 -/// - `check_response`: 服务器检查响应 -/// -/// # 返回 -/// 返回需要更新的任务列表 -pub fn plan_updates(check_response: &CheckResponse) -> Result> { - let mut tasks = Vec::new(); - - // 遍历所有版本类型 - for (_version_type, artifacts) in &check_response.data.versions { - for artifact in artifacts { - // 检查是否需要更新 - let needs_update = check_resource_file(artifact)?; - - if needs_update { - // 获取目标路径(优先使用 install_path,为空则使用当前目录) - let target_path = get_target_path(artifact)?; - let temp_dir = get_temp_dir()?; - let temp_path = temp_dir.join(format!("{}.tmp", artifact.resource_name)); - let backup_path = temp_dir.join(format!("{}.bak", artifact.resource_name)); - - tasks.push(UpdateTask { - artifact: artifact.clone(), - target_path: target_path.clone(), - backup_path: Some(backup_path), - temp_path, - }); - } - } - } - - Ok(tasks) -} diff --git a/src-tauri/src/infrastructure/updater/service.rs b/src-tauri/src/infrastructure/updater/service.rs deleted file mode 100644 index 662e7f1..0000000 --- a/src-tauri/src/infrastructure/updater/service.rs +++ /dev/null @@ -1,194 +0,0 @@ -/// 更新服务模块 -/// -/// 提供更新相关的公共辅助函数,消除代码重复 -use crate::core::config; -use crate::infrastructure::updater::types::{ - ErrorPayload, InstallTask, InstallTasks, UpdateEvent, UpdatePlan, UpdatePlanTask, UpdateTask, -}; -use anyhow::{Context, Result, anyhow}; -use std::fs; -use std::path::PathBuf; -use tauri::{AppHandle, Emitter}; - -/// 锁获取结果 -pub enum LockResult { - Acquired(tokio::sync::MutexGuard<'static, ()>), - Busy, -} - -/// 尝试获取更新检查和下载任务锁(非阻塞) -/// -/// # 返回 -/// - `LockResult::Acquired(guard)` - 成功获取锁 -/// - `LockResult::Busy` - 锁已被占用 -pub async fn acquire_update_lock() -> LockResult { - match crate::infrastructure::updater::state::try_acquire_update_check_and_download_lock().await - { - Some(guard) => LockResult::Acquired(guard), - None => LockResult::Busy, - } -} - -/// 初始化更新器配置 -pub fn init_updater_config() -> crate::core::error::Result<()> { - // 检查配置是否已初始化,如果已初始化则直接返回 - if config::get().is_some() { - return Ok(()); - } - // 从嵌入的加密配置中加载并初始化配置 - config::init() -} - -/// 获取可执行文件目录 -/// -/// # 返回 -/// 返回可执行文件所在目录的 PathBuf -pub fn get_exe_directory() -> Result { - let current_exe = std::env::current_exe().with_context(|| "获取当前可执行文件路径失败")?; - current_exe - .parent() - .ok_or_else(|| anyhow::anyhow!("无法获取可执行文件目录")) - .map(|p| p.to_path_buf()) -} - -/// 获取安装任务文件路径 -/// -/// # 返回 -/// 返回安装任务文件的完整路径 -pub fn get_tasks_file_path() -> Result { - crate::core::paths::PathManager::get_update_tasks_file() -} - -/// 发送更新事件 -/// -/// # 参数 -/// - `app_handle`: Tauri 应用句柄 -/// - `event`: 更新事件 -pub fn emit_event(app_handle: &AppHandle, event: UpdateEvent) { - let _ = app_handle.emit(event.event_type(), event); -} - -/// 发送错误事件 -/// -/// # 参数 -/// - `app_handle`: Tauri 应用句柄 -/// - `code`: 错误代码 -/// - `error_message`: 错误消息 -pub fn emit_error_event(app_handle: &AppHandle, code: i32, error_message: String) { - let error_event = UpdateEvent::CheckFailed { - code, - payload: ErrorPayload { error_message }, - }; - emit_event(app_handle, error_event); -} - -/// 将 UpdateTask 转换为 UpdatePlanTask -/// -/// # 参数 -/// - `tasks`: UpdateTask 列表 -/// -/// # 返回 -/// 返回 UpdatePlanTask 列表 -pub fn update_tasks_to_plan_tasks(tasks: &[UpdateTask]) -> Vec { - tasks - .iter() - .map(|task| UpdatePlanTask { - artifact: task.artifact.clone(), - target_path: task.target_path.to_string_lossy().to_string(), - backup_path: task.backup_path.as_ref().map(|p| p.to_string_lossy().to_string()), - temp_path: task.temp_path.to_string_lossy().to_string(), - }) - .collect() -} - -/// 将 UpdatePlanTask 转换为 UpdateTask -/// -/// # 参数 -/// - `plan_tasks`: UpdatePlanTask 列表 -/// -/// # 返回 -/// 返回 UpdateTask 列表 -pub fn plan_tasks_to_update_tasks(plan_tasks: &[UpdatePlanTask]) -> Vec { - plan_tasks - .iter() - .map(|plan_task| UpdateTask { - artifact: plan_task.artifact.clone(), - target_path: PathBuf::from(&plan_task.target_path), - backup_path: plan_task.backup_path.as_ref().map(|p| PathBuf::from(p)), - temp_path: PathBuf::from(&plan_task.temp_path), - }) - .collect() -} - -/// 将 UpdateTask 转换为 InstallTask -/// -/// # 参数 -/// - `task`: UpdateTask -/// -/// # 返回 -/// 返回 InstallTask -pub fn update_task_to_install_task(task: &UpdateTask) -> InstallTask { - InstallTask { - resource_name: task.artifact.resource_name.clone(), - version: task.artifact.version.clone(), - target_path: task.target_path.to_string_lossy().to_string(), - backup_path: task.backup_path.as_ref().map(|p| p.to_string_lossy().to_string()), - temp_path: task.temp_path.to_string_lossy().to_string(), - expected_hash: task.artifact.hash.clone(), - } -} - -/// 缓存更新计划 -pub async fn store_update_plan(tasks: &[UpdateTask]) -> Result<()> { - let plan_tasks = update_tasks_to_plan_tasks(tasks); - let update_plan = UpdatePlan { tasks: plan_tasks }; - crate::infrastructure::updater::state::store_update_plan(update_plan).await; - Ok(()) -} - -/// 取出更新计划(内存缓存) -pub async fn take_update_plan() -> Result { - crate::infrastructure::updater::state::take_update_plan() - .await - .ok_or_else(|| anyhow!("更新计划不存在,请重新执行检查")) -} - -pub async fn store_installer_package_path(path: String) { - crate::infrastructure::updater::state::store_installer_package_path(path).await; -} - -pub async fn take_installer_package_path() -> Option { - crate::infrastructure::updater::state::take_installer_package_path().await -} - -pub async fn clear_installer_package_path() { - crate::infrastructure::updater::state::clear_installer_package_path().await; -} - -/// 保存安装任务到文件 -/// -/// # 参数 -/// - `install_tasks`: InstallTask 列表 -/// -/// # 返回 -/// 返回保存的文件路径 -pub fn save_install_tasks(install_tasks: &[InstallTask]) -> Result { - let install_tasks_data = InstallTasks { - tasks: install_tasks.to_vec(), - }; - - let tasks_json = - serde_json::to_string_pretty(&install_tasks_data).with_context(|| "序列化安装任务失败")?; - - let file_path = get_tasks_file_path()?; - - if let Some(parent) = file_path.parent() { - fs::create_dir_all(parent) - .with_context(|| format!("创建安装任务目录失败: {}", parent.display()))?; - } - - fs::write(&file_path, tasks_json) - .with_context(|| format!("写入安装任务文件失败: {}", file_path.display()))?; - - Ok(file_path) -} diff --git a/src-tauri/src/infrastructure/updater/state.rs b/src-tauri/src/infrastructure/updater/state.rs deleted file mode 100644 index 2938d52..0000000 --- a/src-tauri/src/infrastructure/updater/state.rs +++ /dev/null @@ -1,59 +0,0 @@ -/// 状态管理模块 -/// -/// 提供更新检查和下载任务的并发控制锁 -use once_cell::sync::Lazy; -use std::sync::Arc; -use tokio::sync::Mutex as TokioMutex; - -use crate::infrastructure::updater::types::UpdatePlan; - -/// 更新检查和下载任务互斥锁:确保同一时间只有一个检查/下载任务在执行 -static UPDATE_CHECK_AND_DOWNLOAD_LOCK: Lazy>> = - Lazy::new(|| Arc::new(TokioMutex::new(()))); - -/// 内存中的更新计划缓存 -static UPDATE_PLAN_CACHE: Lazy>> = - Lazy::new(|| TokioMutex::new(None)); - -/// 安装包升级路径缓存 -static INSTALLER_PACKAGE_PATH_CACHE: Lazy>> = - Lazy::new(|| TokioMutex::new(None)); - -/// 尝试获取更新检查和下载任务锁(非阻塞) -/// -/// # 返回 -/// 如果成功获取锁,返回 `Some(guard)`,否则返回 `None` -/// -/// # 说明 -/// 使用此锁可以防止并发执行检查更新和下载任务 -pub async fn try_acquire_update_check_and_download_lock() --> Option> { - UPDATE_CHECK_AND_DOWNLOAD_LOCK.try_lock().ok() -} - -/// 将更新计划写入缓存,覆盖旧数据 -pub async fn store_update_plan(plan: UpdatePlan) { - let mut guard = UPDATE_PLAN_CACHE.lock().await; - *guard = Some(plan); -} - -/// 从缓存中取出更新计划(取出后即清空) -pub async fn take_update_plan() -> Option { - let mut guard = UPDATE_PLAN_CACHE.lock().await; - guard.take() -} - -pub async fn store_installer_package_path(path: String) { - let mut guard = INSTALLER_PACKAGE_PATH_CACHE.lock().await; - *guard = Some(path); -} - -pub async fn take_installer_package_path() -> Option { - let mut guard = INSTALLER_PACKAGE_PATH_CACHE.lock().await; - guard.take() -} - -pub async fn clear_installer_package_path() { - let mut guard = INSTALLER_PACKAGE_PATH_CACHE.lock().await; - *guard = None; -} diff --git a/src-tauri/src/infrastructure/updater/types.rs b/src-tauri/src/infrastructure/updater/types.rs deleted file mode 100644 index 131d80b..0000000 --- a/src-tauri/src/infrastructure/updater/types.rs +++ /dev/null @@ -1,346 +0,0 @@ -/// 更新器数据模型定义 -use serde::{Deserialize, Deserializer, Serialize}; -use std::collections::HashMap; -use std::path::PathBuf; - -/// 反序列化 id 字段,支持整数或字符串(字符串转换为整数) -fn deserialize_id<'de, D>(deserializer: D) -> Result, D::Error> -where - D: Deserializer<'de>, -{ - use serde::de::Error; - - let value: serde_json::Value = Deserialize::deserialize(deserializer)?; - - match value { - serde_json::Value::Null => Ok(None), - serde_json::Value::Number(n) => { - n.as_i64().ok_or_else(|| D::Error::custom("无法将数字转换为 i64")).map(Some) - } - serde_json::Value::String(s) => s - .parse::() - .map_err(|_| D::Error::custom("无法将字符串解析为整数")) - .map(Some), - _ => Err(D::Error::custom("id 必须是数字或字符串")), - } -} - -/// 反序列化 file_size 字段,支持 Option 到 u64 的转换 -fn deserialize_file_size<'de, D>(deserializer: D) -> Result -where - D: Deserializer<'de>, -{ - use serde::de::Error; - - let value: Option = Option::deserialize(deserializer)?; - match value { - None => Ok(0), - Some(size) => { - if size < 0 { - Err(D::Error::custom("file_size 不能为负数")) - } else { - Ok(size as u64) - } - } - } -} - -/// 服务器返回的 Artifact 结构 -#[derive(Debug, Clone, Serialize, Deserialize)] -pub struct Artifact { - #[serde(deserialize_with = "deserialize_id")] - pub id: Option, - #[serde(default)] - pub name: String, - pub version: String, - #[serde(default)] - pub url: String, - #[serde(default)] - pub hash: String, // SHA256 - #[serde(deserialize_with = "deserialize_file_size")] - pub file_size: u64, - #[serde(default = "default_platform")] - pub platform: String, - pub resource_name: String, - #[serde(skip_serializing_if = "Option::is_none")] - pub signature: Option, - #[serde(skip_serializing_if = "Option::is_none")] - pub notes: Option, - #[serde(skip_serializing_if = "Option::is_none")] - pub is_latest: Option, - #[serde(skip_serializing_if = "Option::is_none")] - pub install_path: Option, -} - -/// 默认平台值 -fn default_platform() -> String { - "windows".to_string() -} - -/// 服务器返回的版本集合 -/// -/// 使用 HashMap 以支持灵活的字段名(T9_CLIENT, DLL, OTHER_PROGRAM, T9_CLIENT_INSTALLER 等) -pub type Versions = HashMap>; - -/// 服务器返回的检查响应数据 -#[derive(Debug, Clone, Serialize, Deserialize)] -pub struct CheckResponseData { - pub versions: Versions, -} - -/// 服务器返回的完整检查响应 -#[derive(Debug, Clone, Serialize, Deserialize)] -pub struct CheckResponse { - pub code: i32, - pub message: String, - pub data: CheckResponseData, -} - -/// 检查请求 -#[derive(Debug, Clone, Serialize)] -pub struct CheckRequest {} - -/// 资源类型 -/// 更新任务 -#[derive(Debug, Clone)] -pub struct UpdateTask { - pub artifact: Artifact, - pub target_path: PathBuf, - pub backup_path: Option, - pub temp_path: PathBuf, // 下载后的临时路径 -} - -/// 序列化的安装任务(用于客户端和更新器之间传递) -#[derive(Debug, Clone, Serialize, Deserialize)] -pub struct InstallTask { - pub resource_name: String, - pub version: String, - pub target_path: String, - #[serde(skip_serializing_if = "Option::is_none")] - pub backup_path: Option, - pub temp_path: String, // 已下载并校验通过的文件路径 - pub expected_hash: String, // SHA256,用于再次确认 -} - -/// 安装任务列表(用于传递多个任务) -#[derive(Debug, Clone, Serialize, Deserialize)] -pub struct InstallTasks { - pub tasks: Vec, -} - -#[derive(Debug, Clone, Copy, Serialize, Deserialize, PartialEq, Eq)] -#[serde(rename_all = "snake_case")] -pub enum InstallStrategy { - DirectReplace, - InstallerPackage, -} - -/// 更新计划(用于检查后保存,供下载使用) -#[derive(Debug, Clone, Serialize, Deserialize)] -pub struct UpdatePlan { - pub tasks: Vec, -} - -/// 更新计划任务(序列化版本,用于保存到文件) -#[derive(Debug, Clone, Serialize, Deserialize)] -pub struct UpdatePlanTask { - pub artifact: Artifact, - pub target_path: String, - pub backup_path: Option, - pub temp_path: String, -} - -/// 更新状态 -#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize)] -pub enum UpdaterState { - /// 空闲,未检查或已检查但无更新 - Idle, - /// 有可用更新 - Available, - /// 检查中 - Checking, - /// 下载中 - Downloading, - /// 校验中 - Verifying, - /// 安装中 - Installing, - /// 完成 - Success, - /// 部分失败(部分资源更新成功) - PartialFailed, - /// 失败 - Failed, -} - -/// 更新状态详情 -#[derive(Debug, Clone, Serialize, Deserialize)] -pub struct UpdaterStateInfo { - pub state: UpdaterState, - pub message: String, - #[serde(skip_serializing_if = "Option::is_none")] - pub error: Option, - #[serde(skip_serializing_if = "Option::is_none")] - pub tasks: Option>, -} - -/// 任务信息(用于状态返回) -#[derive(Debug, Clone, Serialize, Deserialize)] -pub struct TaskInfo { - pub resource_name: String, - pub version: String, - pub status: String, // "pending", "downloading", "verifying", "installing", "success", "failed" - #[serde(skip_serializing_if = "Option::is_none")] - pub error: Option, -} - -/// Manifest 文件中存储的版本信息 -#[derive(Debug, Clone, Serialize, Deserialize)] -pub struct Manifest { - pub client: ClientInfo, - pub artifacts: Vec, - pub last_updated: String, // ISO 8601 时间戳 -} - -#[derive(Debug, Clone, Serialize, Deserialize)] -pub struct LatestRelease { - pub version: String, - #[serde(default)] - pub notes: String, - pub pub_date: String, - pub platforms: HashMap, -} - -#[derive(Debug, Clone, Serialize, Deserialize)] -pub struct LatestReleasePlatform { - #[serde(default)] - pub url: String, - #[serde(default)] - pub r2_url: String, -} - -/// 客户端信息 -#[derive(Debug, Clone, Serialize, Deserialize)] -pub struct ClientInfo { - pub version: String, -} - -/// Manifest 中的资源信息(仅展示用) -#[derive(Debug, Clone, Serialize, Deserialize)] -pub struct ArtifactInfo { - pub resource_name: String, - pub version: String, -} - -/// 更新事件类型 -#[derive(Debug, Clone, Serialize, Deserialize, PartialEq)] -#[serde(tag = "type", rename_all = "snake_case")] -pub enum UpdateEvent { - /// 检查中 - Checking { payload: CheckingPayload }, - /// 检查失败 - CheckFailed { code: i32, payload: ErrorPayload }, - /// 计划失败 - PlanFailed { code: i32, payload: ErrorPayload }, - /// 无需更新 - NoUpdates { payload: NoUpdatesPayload }, - /// 发现更新 - FoundUpdates { payload: FoundUpdatesPayload }, - /// 下载中 - Downloading { payload: DownloadingPayload }, - /// 下载完成 - DownloadComplete { payload: DownloadCompletePayload }, - /// 部分下载完成 - DownloadPartial { payload: DownloadPartialPayload }, - /// 下载失败 - DownloadFailed { code: i32, payload: ErrorPayload }, - /// 下载进度 - DownloadProgress { payload: DownloadProgressPayload }, -} - -impl UpdateEvent { - /// 获取事件类型字符串 - /// - /// 返回格式:`update__` - /// 例如:`update_checking`, `update_check_failed`, `update_download_complete` - pub fn event_type(&self) -> &'static str { - match self { - UpdateEvent::Checking { .. } => "update_checking", - UpdateEvent::CheckFailed { .. } => "update_check_failed", - UpdateEvent::PlanFailed { .. } => "update_plan_failed", - UpdateEvent::NoUpdates { .. } => "update_no_updates", - UpdateEvent::FoundUpdates { .. } => "update_found_updates", - UpdateEvent::Downloading { .. } => "update_downloading", - UpdateEvent::DownloadComplete { .. } => "update_download_complete", - UpdateEvent::DownloadPartial { .. } => "update_download_partial", - UpdateEvent::DownloadFailed { .. } => "update_download_failed", - UpdateEvent::DownloadProgress { .. } => "update_download_progress", - } - } -} - -/// 检查中事件负载 -#[derive(Debug, Clone, Serialize, Deserialize, PartialEq, Eq)] -pub struct CheckingPayload { - // 检查阶段无额外数据 -} - -/// 错误事件负载 -#[derive(Debug, Clone, Serialize, Deserialize, PartialEq, Eq)] -pub struct ErrorPayload { - /// 错误消息 - pub error_message: String, -} - -/// 无需更新事件负载 -#[derive(Debug, Clone, Serialize, Deserialize, PartialEq, Eq)] -pub struct NoUpdatesPayload { - // 无需更新无额外数据 -} - -/// 发现更新事件负载 -#[derive(Debug, Clone, Serialize, Deserialize, PartialEq, Eq)] -pub struct FoundUpdatesPayload { - /// 更新数量 - pub update_count: usize, -} - -/// 下载中事件负载 -#[derive(Debug, Clone, Serialize, Deserialize, PartialEq, Eq)] -pub struct DownloadingPayload { - /// 更新数量 - pub update_count: usize, -} - -/// 下载完成事件负载 -#[derive(Debug, Clone, Serialize, Deserialize, PartialEq, Eq)] -pub struct DownloadCompletePayload { - /// 更新任务文件路径 - pub tasks_file: String, - /// 成功下载的文件数量 - pub success_count: usize, -} - -/// 部分下载完成事件负载 -#[derive(Debug, Clone, Serialize, Deserialize, PartialEq, Eq)] -pub struct DownloadPartialPayload { - /// 更新任务文件路径 - pub tasks_file: String, - /// 成功下载的文件数量 - pub success_count: usize, - /// 失败的文件数量 - pub failed_count: usize, - /// 错误消息 - pub error_message: String, -} - -/// 下载进度事件负载 -#[derive(Debug, Clone, Serialize, Deserialize, PartialEq)] -pub struct DownloadProgressPayload { - /// 当前已下载总大小(字节) - pub downloaded: u64, - /// 总大小(字节) - pub total: u64, - /// 进度百分比(0-100) - pub percentage: f64, -} diff --git a/src-tauri/src/infrastructure/updater/verifier.rs b/src-tauri/src/infrastructure/updater/verifier.rs deleted file mode 100644 index 43d7623..0000000 --- a/src-tauri/src/infrastructure/updater/verifier.rs +++ /dev/null @@ -1,41 +0,0 @@ -/// 校验模块 -/// -/// 负责对下载的文件进行 SHA256 校验 -use crate::infrastructure::updater::types::UpdateTask; -use anyhow::{Context, Result}; -use std::path::Path; - -// 重新导出 calculate_file_hash 函数 -pub use crate::infrastructure::updater::planner::calculate_file_hash; - -/// 内部使用的哈希计算函数 -fn calculate_file_hash_internal(file_path: &Path) -> Result { - crate::infrastructure::updater::planner::calculate_file_hash(file_path) -} - -/// 校验下载文件的 SHA256 哈希 -/// -/// # 参数 -/// - `task`: 更新任务 -/// -/// # 返回 -/// 如果哈希匹配返回 Ok(()), 否则返回错误 -pub fn verify_file_hash(task: &UpdateTask) -> Result<()> { - // 计算临时文件的哈希 - let actual_hash = calculate_file_hash_internal(&task.temp_path) - .with_context(|| format!("计算文件哈希失败: {}", task.temp_path.display()))?; - - // 转换为小写进行比较 - let expected_hash = task.artifact.hash.to_lowercase(); - let actual_hash_lower = actual_hash.to_lowercase(); - - if actual_hash_lower != expected_hash { - return Err(anyhow::anyhow!( - "文件哈希不匹配: 期望 {}, 实际 {}", - expected_hash, - actual_hash - )); - } - - Ok(()) -} diff --git a/src-tauri/src/main.rs b/src-tauri/src/main.rs index 87a9331..25a96f9 100644 --- a/src-tauri/src/main.rs +++ b/src-tauri/src/main.rs @@ -3,7 +3,6 @@ use log::error; -use simprint_lib::core::config; use simprint_lib::core::logger; /// 屏蔽环境变量 @@ -21,13 +20,6 @@ async fn main() -> Result<(), Box> { disable_env_var(); - // 1. 首先初始化配置(从加密的 bin 中解密得到配置内容) - // 注意:必须在 security_context::init() 之前初始化,因为 anchor 存储需要读取配置 - if let Err(e) = config::init() { - eprintln!("Failed to initialize config: {}", e); - std::process::exit(-1); - } - // 初始化日志系统(兜底目录,Tauri 启动后会在 setup 中按 store 设置重新初始化) logger::init_logging(logger::bootstrap_log_dir()); diff --git a/src-tauri/src/services/environment/kernel/downloader.rs b/src-tauri/src/services/environment/kernel/downloader.rs index 37b49c0..4d7e56e 100644 --- a/src-tauri/src/services/environment/kernel/downloader.rs +++ b/src-tauri/src/services/environment/kernel/downloader.rs @@ -1,8 +1,8 @@ //! 内核下载模块 use crate::core::error::Result; +use crate::core::utils::hash::calculate_file_hash; use crate::domain::environment::{EnvironmentStatus, KernelDetail}; -use crate::infrastructure::updater::planner::calculate_file_hash; use std::fs; use std::path::{Path, PathBuf}; use uuid::Uuid; diff --git a/src-tauri/src/services/environment/kernel/extension.rs b/src-tauri/src/services/environment/kernel/extension.rs index bf479fe..0809179 100644 --- a/src-tauri/src/services/environment/kernel/extension.rs +++ b/src-tauri/src/services/environment/kernel/extension.rs @@ -276,7 +276,7 @@ fn copy_local_extension_crx(source_path: &str, crx_path: &Path) -> Result<()> { /// 校验扩展文件哈希 fn verify_extension_hash(crx_path: &Path, expected_hash: &str) -> Result<()> { - use crate::infrastructure::updater::planner::calculate_file_hash; + use crate::core::utils::hash::calculate_file_hash; let actual_hash = calculate_file_hash(crx_path)?; let expected = expected_hash.trim().to_lowercase(); diff --git a/src-tauri/src/services/local_extensions/mod.rs b/src-tauri/src/services/local_extensions/mod.rs index a2c90dd..8025b0a 100644 --- a/src-tauri/src/services/local_extensions/mod.rs +++ b/src-tauri/src/services/local_extensions/mod.rs @@ -11,7 +11,7 @@ use tauri::AppHandle; use uuid::Uuid; use crate::core::error::Result; -use crate::infrastructure::updater::planner::calculate_file_hash; +use crate::core::utils::hash::calculate_file_hash; use crate::services::environment::ExtensionInfo; static REGISTRY_LOCK: Lazy> = Lazy::new(|| Mutex::new(())); diff --git a/src-tauri/src/services/mod.rs b/src-tauri/src/services/mod.rs index e2c8d6a..f39aeab 100644 --- a/src-tauri/src/services/mod.rs +++ b/src-tauri/src/services/mod.rs @@ -12,5 +12,4 @@ pub mod environment; pub mod file_system; pub mod local_extensions; pub mod mihomo; -pub mod updater; pub mod window; diff --git a/src-tauri/src/services/updater/mod.rs b/src-tauri/src/services/updater/mod.rs deleted file mode 100644 index 3ee3438..0000000 --- a/src-tauri/src/services/updater/mod.rs +++ /dev/null @@ -1,5 +0,0 @@ -pub mod types; -pub mod update_service; - -pub use types::PreparedUpdateInfo; -pub use update_service::{CheckResult, DownloadResult, UpdateService}; diff --git a/src-tauri/src/services/updater/types.rs b/src-tauri/src/services/updater/types.rs deleted file mode 100644 index ea4de5e..0000000 --- a/src-tauri/src/services/updater/types.rs +++ /dev/null @@ -1,9 +0,0 @@ -use serde::{Deserialize, Serialize}; - -#[derive(Debug, Clone, Serialize, Deserialize)] -#[serde(rename_all = "camelCase")] -pub struct PreparedUpdateInfo { - pub kind: String, - pub version: String, - pub restart_required: bool, -} diff --git a/src-tauri/src/services/updater/update_service.rs b/src-tauri/src/services/updater/update_service.rs deleted file mode 100644 index d63b0e5..0000000 --- a/src-tauri/src/services/updater/update_service.rs +++ /dev/null @@ -1,701 +0,0 @@ -/// 更新服务 -/// -/// 提供检查更新、下载更新和启动安装的业务逻辑 -use crate::core::error::{Error, Result}; -use crate::infrastructure::updater::service::LockResult; -use crate::infrastructure::updater::types::{ - CheckingPayload, DownloadCompletePayload, DownloadPartialPayload, ErrorPayload, - FoundUpdatesPayload, InstallStrategy, LatestRelease, NoUpdatesPayload, UpdateEvent, -}; -use crate::infrastructure::updater::{checker, downloader, planner, service, verifier}; -use crate::services::updater::types::PreparedUpdateInfo; -use reqwest::Client; -use serde::{Deserialize, Serialize}; -use std::fs; -use std::path::{Path, PathBuf}; -use std::sync::Arc; -use std::sync::atomic::AtomicU64; -use std::time::Duration; -use tauri::AppHandle; -use tokio::time::Instant; - -/// 检查更新结果 -#[derive(Debug, Clone, Serialize, Deserialize)] -pub struct CheckResult { - /// 是否有可用更新 - pub has_updates: bool, - /// 更新数量(如果有更新) - #[serde(skip_serializing_if = "Option::is_none")] - pub update_count: Option, - /// 是否已有可用的更新计划缓存 - pub plan_available: bool, -} - -/// 下载更新结果 -#[derive(Debug, Clone, Serialize, Deserialize)] -pub struct DownloadResult { - /// 是否有可用更新 - pub has_updates: bool, - /// 更新任务文件路径(如果下载成功) - #[serde(skip_serializing_if = "Option::is_none")] - pub tasks_file: Option, - /// 安装策略 - pub install_strategy: InstallStrategy, - /// 成功下载的文件数量 - pub success_count: usize, -} - -pub struct UpdateService; - -fn map_update_check_error(stage: &str, err: impl std::fmt::Display) -> Error { - Error::UpdateCheckFailed(format!("{stage}: {err}")) -} - -impl UpdateService { - pub async fn get_prepared_update() -> Result> { - Ok(None) - } - - pub async fn start_prepared_update_install( - app_handle: AppHandle, - kind: Option, - ) -> Result<()> { - let resolved_kind = match kind { - Some(kind) => kind, - None => Self::get_prepared_update() - .await? - .map(|update| update.kind) - .ok_or("当前没有可安装的更新")?, - }; - - let _ = app_handle; - Err(format!( - "不支持独立更新组件: {}。环境运行时现已内嵌到 Simprint,请更新主程序。", - resolved_kind - ) - .into()) - } - - /// 简单检查是否有可用更新(仅检查,不缓存计划,不发送事件) - /// - /// 用于设置页面的「检查更新」按钮,返回布尔值。具体更新逻辑由重启时自动完成。 - pub async fn check_update_available() -> Result { - service::init_updater_config() - .map_err(|e| map_update_check_error("初始化更新配置失败", e))?; - let check_response = checker::check_updates() - .await - .map_err(|e| map_update_check_error("请求更新检查接口失败", e))?; - let tasks = planner::plan_updates(&check_response) - .map_err(|e| map_update_check_error("生成更新计划失败", e))?; - Ok(!tasks.is_empty()) - } - - /// 检查更新(仅检查,不下载) - /// - /// 检查是否有可用更新,并将更新计划缓存到内存 - /// - /// # 参数 - /// - `app_handle`: Tauri 应用句柄,用于 emit 事件 - /// - /// # 返回 - /// 返回检查结果,包含是否有更新、更新数量以及计划是否可用 - pub async fn check_updates(app_handle: AppHandle) -> Result { - // 尝试获取锁(非阻塞) - let _guard = match service::acquire_update_lock().await { - LockResult::Acquired(guard) => guard, - LockResult::Busy => { - log::debug!("更新检查正在进行中,跳过本次检查"); - return Ok(CheckResult { - has_updates: false, - update_count: None, - plan_available: false, - }); - } - }; - - // 初始化配置 - service::init_updater_config() - .map_err(|e| map_update_check_error("初始化更新配置失败", e))?; - - // 发送开始检查事件 - service::emit_event( - &app_handle, - UpdateEvent::Checking { - payload: CheckingPayload {}, - }, - ); - - // 1. 检查更新 - let check_response = checker::check_updates().await.map_err(|e| { - service::emit_error_event(&app_handle, 1, format!("检查更新失败: {}", e)); - map_update_check_error("请求更新检查接口失败", e) - })?; - - // 2. 生成更新计划 - let tasks = planner::plan_updates(&check_response).map_err(|e| { - let error_event = UpdateEvent::PlanFailed { - code: 2, - payload: ErrorPayload { - error_message: format!("生成更新计划失败: {}", e), - }, - }; - service::emit_event(&app_handle, error_event); - map_update_check_error("生成更新计划失败", e) - })?; - - if tasks.is_empty() { - log::info!("无需更新"); - service::emit_event( - &app_handle, - UpdateEvent::NoUpdates { - payload: NoUpdatesPayload {}, - }, - ); - return Ok(CheckResult { - has_updates: false, - update_count: None, - plan_available: false, - }); - } - - log::info!("发现 {} 个更新任务", tasks.len()); - - // 发送发现更新事件 - service::emit_event( - &app_handle, - UpdateEvent::FoundUpdates { - payload: FoundUpdatesPayload { - update_count: tasks.len(), - }, - }, - ); - - // 3. 缓存更新计划(供下载使用) - service::store_update_plan(&tasks).await?; - - Ok(CheckResult { - has_updates: true, - update_count: Some(tasks.len()), - plan_available: true, - }) - } - - /// 下载更新(执行下载,支持进度) - /// - /// 从内存中的更新计划读取任务,执行下载和校验,实时发送进度事件 - /// - /// # 参数 - /// - `app_handle`: Tauri 应用句柄,用于 emit 事件 - /// # 返回 - /// 返回下载结果,包含任务文件路径和成功数量 - pub async fn download_updates( - app_handle: AppHandle, - _plan_file: Option, - ) -> Result { - // 尝试获取锁(非阻塞) - let _guard = match service::acquire_update_lock().await { - LockResult::Acquired(guard) => guard, - LockResult::Busy => { - log::debug!("更新下载正在进行中,跳过本次下载"); - return Err("更新下载正在进行中".into()); - } - }; - - service::clear_installer_package_path().await; - - // 初始化配置 - service::init_updater_config()?; - - // 读取更新计划文件 - let update_plan = service::take_update_plan().await?; - - if update_plan.tasks.is_empty() { - return Err("更新计划为空".into()); - } - - // 转换为 UpdateTask - let tasks = service::plan_tasks_to_update_tasks(&update_plan.tasks); - - if should_use_installer_package(&tasks) { - return Self::download_installer_package(app_handle).await; - } - - // 计算总大小 - let total_size: u64 = tasks.iter().map(|t| t.artifact.file_size).sum(); - let downloaded_size = Arc::new(AtomicU64::new(0)); - - // 发送开始下载事件 - service::emit_event( - &app_handle, - UpdateEvent::Downloading { - payload: crate::infrastructure::updater::types::DownloadingPayload { - update_count: tasks.len(), - }, - }, - ); - - // 创建 HTTP 客户端 - let client = Client::builder().timeout(Duration::from_secs(300)).build()?; - - let mut success_count = 0; - let mut failed_count = 0; - let mut install_tasks = Vec::new(); - - // 遍历所有任务:下载和校验 - let download_start = Instant::now(); - - for task in &tasks { - let resource_name = task.artifact.resource_name.clone(); - let downloaded_size_clone = downloaded_size.clone(); - let app_handle_clone = app_handle.clone(); - let total_size_clone = total_size; - - // 1. 下载阶段(带进度回调) - // 记录该文件开始下载时的已下载总量 - let file_start_downloaded = - downloaded_size_clone.load(std::sync::atomic::Ordering::Relaxed); - - let download_result = - downloader::download_file_with_progress(task, &client, move |file_downloaded| { - // file_downloaded 是该文件的累计下载大小 - // 计算当前总下载大小 = 文件开始时的总量 + 该文件当前已下载大小 - let total_downloaded = file_start_downloaded + file_downloaded; - - // 使用 compare_and_swap 确保只更新更大的值(避免并发问题) - let current = downloaded_size_clone.load(std::sync::atomic::Ordering::Relaxed); - if total_downloaded > current { - downloaded_size_clone - .store(total_downloaded, std::sync::atomic::Ordering::Relaxed); - } - - // 重新加载确保使用最新的值 - let final_downloaded = - downloaded_size_clone.load(std::sync::atomic::Ordering::Relaxed); - - // 计算进度百分比 - let percentage = if total_size_clone > 0 { - (final_downloaded as f64 / total_size_clone as f64) * 100.0 - } else { - 0.0 - }; - - // 发送进度事件 - let progress_event = UpdateEvent::DownloadProgress { - payload: crate::infrastructure::updater::types::DownloadProgressPayload { - downloaded: final_downloaded, - total: total_size_clone, - percentage, - }, - }; - service::emit_event(&app_handle_clone, progress_event); - - Ok(()) - }) - .await; - - match download_result { - Ok(_) => { - // 下载成功,静默处理 - } - Err(e) => { - log::error!("下载失败: {} - {}", resource_name, e); - failed_count += 1; - // 清理临时文件 - let _ = fs::remove_file(&task.temp_path); - continue; - } - } - - // 2. 校验阶段 - match verifier::verify_file_hash(task) { - Ok(_) => { - // 转换为安装任务 - install_tasks.push(service::update_task_to_install_task(task)); - success_count += 1; - } - Err(e) => { - log::error!("校验失败: {} - {}", resource_name, e); - failed_count += 1; - // 清理临时文件 - let _ = fs::remove_file(&task.temp_path); - continue; - } - } - } - - let download_duration = download_start.elapsed(); - - // 3. 保存安装任务到文件 - let tasks_file_path = if !install_tasks.is_empty() { - let file_path = service::save_install_tasks(&install_tasks)?; - Some(file_path.to_string_lossy().to_string()) - } else { - None - }; - - // 4. 发送结果事件 - if failed_count == 0 { - log::info!( - "下载完成,共 {} 个文件,总耗时: {:.2} 秒", - success_count, - download_duration.as_secs_f64() - ); - - if let Some(ref tasks_file) = tasks_file_path { - service::emit_event( - &app_handle, - UpdateEvent::DownloadComplete { - payload: DownloadCompletePayload { - tasks_file: tasks_file.clone(), - success_count, - }, - }, - ); - } - - Ok(DownloadResult { - has_updates: true, - tasks_file: tasks_file_path, - install_strategy: InstallStrategy::DirectReplace, - success_count, - }) - } else if success_count > 0 { - log::warn!( - "部分下载完成:成功 {}, 失败 {},总耗时: {:.2} 秒", - success_count, - failed_count, - download_duration.as_secs_f64() - ); - - // 部分成功,也通知前端 - if let Some(ref tasks_file) = tasks_file_path { - service::emit_event( - &app_handle, - UpdateEvent::DownloadPartial { - payload: DownloadPartialPayload { - tasks_file: tasks_file.clone(), - success_count, - failed_count, - error_message: format!( - "部分下载完成:成功 {}, 失败 {}", - success_count, failed_count - ), - }, - }, - ); - } - - Ok(DownloadResult { - has_updates: true, - tasks_file: tasks_file_path, - install_strategy: InstallStrategy::DirectReplace, - success_count, - }) - } else { - log::error!( - "所有下载失败,总耗时: {:.2} 秒", - download_duration.as_secs_f64() - ); - - service::emit_error_event(&app_handle, 3, format!("{} 个下载全部失败", failed_count)); - - Err(format!("所有下载失败: {} 个任务", failed_count).into()) - } - } - - /// 启动更新安装并退出主程序 - /// - /// # 参数 - /// - `app_handle`: Tauri 应用句柄 - /// - /// # 说明 - /// 启动 updater.exe install 命令,然后退出主程序 - /// 任务文件路径由统一路径层提供(update_tasks.json) - pub async fn start_update_install(_app_handle: AppHandle) -> Result<()> { - if let Some(installer_path) = service::take_installer_package_path().await { - return launch_installer_update(PathBuf::from(installer_path)).await; - } - - let tasks_file_path = service::get_tasks_file_path()?; - Self::start_update_install_with_tasks_file(tasks_file_path).await - } - - pub async fn start_update_install_with_tasks_file(tasks_file_path: PathBuf) -> Result<()> { - // 1. 检查任务文件是否存在 - if !tasks_file_path.exists() { - return Err(format!("任务文件不存在: {}", tasks_file_path.display()).into()); - } - - // 2. 获取 updater.exe 所在目录 - let exe_dir = service::get_exe_directory()?; - - // 3. 构建 updater.exe 路径 - let updater_exe = exe_dir.join("updater.exe"); - - if !updater_exe.exists() { - log::error!("找不到 updater.exe: {}", updater_exe.display()); - return Err("UPDATER_NOT_FOUND".into()); - } - - // 4. 启动 updater.exe install - #[cfg(target_os = "windows")] - { - use std::process::Stdio; - use tokio::process::Command; - const CREATE_NO_WINDOW: u32 = 0x08000000; - - Command::new(&updater_exe) - .arg("install") - .arg(tasks_file_path.to_string_lossy().to_string()) - .creation_flags(CREATE_NO_WINDOW) - .stdout(Stdio::null()) - .stderr(Stdio::null()) - .spawn() - .map_err(|e| { - log::error!("启动 updater.exe 失败: {}", e); - if e.to_string().contains("740") { - "UPDATER_NO_PERMISSION" - } else { - "UPDATER_START_FAILED" - } - })?; - } - - #[cfg(not(target_os = "windows"))] - { - use std::process::Stdio; - use tokio::process::Command; - - Command::new(&updater_exe) - .arg("install") - .arg(tasks_file_path.to_string_lossy().to_string()) - .stdout(Stdio::null()) - .stderr(Stdio::null()) - .spawn()?; - } - - // 5. 延迟后退出主程序(给 updater 一点时间启动) - tokio::spawn(async { - tokio::time::sleep(tokio::time::Duration::from_millis(500)).await; - std::process::exit(0); - }); - - Ok(()) - } - - async fn download_installer_package(app_handle: AppHandle) -> Result { - service::emit_event( - &app_handle, - UpdateEvent::Downloading { - payload: crate::infrastructure::updater::types::DownloadingPayload { - update_count: 1, - }, - }, - ); - - let latest_release = fetch_latest_release().await?; - let platform = latest_release - .platforms - .get("x86_64-pc-windows-msvc") - .ok_or("latest.json 中缺少 windows 平台信息")?; - let installer_url = platform.r2_url.trim(); - - if installer_url.is_empty() { - return Err("latest.json 中缺少 windows.r2_url".into()); - } - - let installer_path = download_installer_file(&app_handle, installer_url).await?; - let installer_path_string = installer_path.to_string_lossy().to_string(); - - service::store_installer_package_path(installer_path_string.clone()).await; - - service::emit_event( - &app_handle, - UpdateEvent::DownloadComplete { - payload: DownloadCompletePayload { - tasks_file: installer_path_string.clone(), - success_count: 1, - }, - }, - ); - - Ok(DownloadResult { - has_updates: true, - tasks_file: Some(installer_path_string), - install_strategy: InstallStrategy::InstallerPackage, - success_count: 1, - }) - } -} - -fn should_use_installer_package( - tasks: &[crate::infrastructure::updater::types::UpdateTask], -) -> bool { - tasks.iter().any(|task| !is_target_parent_writable(&task.target_path)) -} - -fn is_target_parent_writable(target_path: &Path) -> bool { - let Some(parent) = target_path.parent() else { - return false; - }; - - if !parent.exists() { - return false; - } - - let probe_name = format!(".simprint-write-test-{}", uuid::Uuid::new_v4()); - let probe_path = parent.join(probe_name); - - match fs::write(&probe_path, b"probe") { - Ok(_) => { - let _ = fs::remove_file(&probe_path); - true - } - Err(_) => false, - } -} - -async fn fetch_latest_release() -> Result { - let ctx = crate::app::context::AppContext::get(); - let response = Client::builder() - .timeout(Duration::from_secs(30)) - .build()? - .get(&ctx.config.updater.latest_json_url) - .send() - .await?; - - let status = response.status(); - if !status.is_success() { - return Err(format!("latest.json 请求失败: {}", status).into()); - } - - Ok(response.json::().await?) -} - -async fn download_installer_file(app_handle: &AppHandle, installer_url: &str) -> Result { - let client = Client::builder().timeout(Duration::from_secs(300)).build()?; - - let response = client.get(installer_url).send().await?; - - if !response.status().is_success() { - return Err(format!("下载安装包失败: {}", response.status()).into()); - } - - let total = response.content_length().unwrap_or(0); - let file_name = installer_file_name(installer_url); - let installer_dir = crate::core::paths::PathManager::get_updater_dir()?.join("installer"); - fs::create_dir_all(&installer_dir)?; - let installer_path = installer_dir.join(file_name); - - let mut file = std::fs::File::create(&installer_path)?; - let mut stream = response.bytes_stream(); - let mut downloaded = 0u64; - - use futures::StreamExt; - use std::io::Write; - - while let Some(chunk) = stream.next().await { - let chunk = chunk?; - file.write_all(&chunk)?; - downloaded += chunk.len() as u64; - - let percentage = if total > 0 { - (downloaded as f64 / total as f64) * 100.0 - } else { - 0.0 - }; - - service::emit_event( - app_handle, - UpdateEvent::DownloadProgress { - payload: crate::infrastructure::updater::types::DownloadProgressPayload { - downloaded, - total, - percentage, - }, - }, - ); - } - - file.sync_all()?; - Ok(installer_path) -} - -fn installer_file_name(installer_url: &str) -> String { - reqwest::Url::parse(installer_url) - .ok() - .and_then(|url| { - url.path_segments().and_then(|segments| segments.last().map(|v| v.to_string())) - }) - .filter(|name| !name.trim().is_empty()) - .unwrap_or_else(|| "simprint_setup.exe".to_string()) -} - -async fn launch_installer_update(installer_path: PathBuf) -> Result<()> { - if !installer_path.exists() { - return Err(format!("安装包不存在: {}", installer_path.display()).into()); - } - - #[cfg(target_os = "windows")] - { - let exe_dir = service::get_exe_directory()?; - let updater_exe = exe_dir.join("updater.exe"); - - if !updater_exe.exists() { - return Err(format!("找不到 updater.exe: {}", updater_exe.display()).into()); - } - - launch_elevated_updater_for_installer(&updater_exe, &installer_path)?; - } - - #[cfg(not(target_os = "windows"))] - { - let _ = installer_path; - return Err("当前平台不支持安装包升级分支".into()); - } - - tokio::spawn(async { - tokio::time::sleep(tokio::time::Duration::from_millis(500)).await; - std::process::exit(0); - }); - - Ok(()) -} - -#[cfg(target_os = "windows")] -fn launch_elevated_updater_for_installer(updater_exe: &Path, installer_path: &Path) -> Result<()> { - use std::ffi::OsStr; - use std::os::windows::ffi::OsStrExt; - use windows::Win32::UI::Shell::ShellExecuteW; - use windows::Win32::UI::WindowsAndMessaging::SW_SHOWDEFAULT; - use windows::core::PCWSTR; - - fn to_wide(value: &OsStr) -> Vec { - value.encode_wide().chain(std::iter::once(0)).collect() - } - - let verb = to_wide(OsStr::new("runas")); - let file = to_wide(updater_exe.as_os_str()); - let params = to_wide(OsStr::new(&format!( - "install-package \"{}\"", - installer_path.display() - ))); - - let result = unsafe { - ShellExecuteW( - None, - PCWSTR(verb.as_ptr()), - PCWSTR(file.as_ptr()), - PCWSTR(params.as_ptr()), - PCWSTR::null(), - SW_SHOWDEFAULT, - ) - }; - - let code = result.0 as isize; - if code <= 32 { - return Err(format!("启动安装包失败: ShellExecuteW 返回 {}", code).into()); - } - - Ok(()) -} diff --git a/src-tauri/tauri.conf.window.download.json b/src-tauri/tauri.conf.embed-bootstrapper.json similarity index 61% rename from src-tauri/tauri.conf.window.download.json rename to src-tauri/tauri.conf.embed-bootstrapper.json index f3f98b0..9601432 100644 --- a/src-tauri/tauri.conf.window.download.json +++ b/src-tauri/tauri.conf.embed-bootstrapper.json @@ -1,67 +1,72 @@ -{ - "$schema": "https://schema.tauri.app/config/2", - "productName": "T9Battle", - "version": "0.5.368", - "identifier": "com.t9.csgo.lius.app", - "build": { - "beforeDevCommand": "cd ../client-frontend && pnpm run dev", - "devUrl": "http://localhost:1420", - "beforeBuildCommand": "cd ../client-frontend && node build.cjs", - "frontendDist": "../client-frontend/dist" - }, - "app": { - "windows": [], - "security": { - "csp": null - }, - "withGlobalTauri": true - }, - "bundle": { - "active": true, - "targets": "nsis", - "icon": [ - "icons/Square284x284Logo.png", - "icons/icon.icns", - "icons/icon.ico" - ], - "windows": { - "allowDowngrades": true, - "certificateThumbprint": null, - "digestAlgorithm": null, - "signCommand": null, - "timestampUrl": null, - "tsp": false, - "webviewInstallMode": { - "silent": true, - "type": "downloadBootstrapper" - }, - "wix": { - "language": "zh-CN" - }, - "nsis": { - "displayLanguageSelector": true, - "installMode": "currentUser", - "installerIcon": "icons/icon.ico", - "installerHooks": "./windows/nsis/installer-hooks.nsi", - "template": "./windows/nsis/installer.nsi", - "sidebarImage": "./windows/assets/sidebarImage.bmp", - "startMenuFolder": "Simprint", - "languages": [ - "English", - "SimpChinese" - ] - } - } - }, - "plugins": { - "deep-link": { - "desktop": [ - { - "schemes": [ - "simprint" - ] - } - ] - } - } -} +{ + "$schema": "https://schema.tauri.app/config/2", + "productName": "Simprint", + "version": "0.1.0", + "identifier": "com.lius.simprint", + "build": { + "beforeDevCommand": "pnpm dev", + "devUrl": "http://localhost:5173", + "beforeBuildCommand": "node build.cjs", + "frontendDist": "../dist" + }, + "app": { + "windows": [], + "security": { + "csp": null + }, + "withGlobalTauri": true + }, + "bundle": { + "active": true, + "createUpdaterArtifacts": true, + "targets": "nsis", + "icon": [ + "icons/32x32.png", + "icons/128x128.png", + "icons/128x128@2x.png", + "icons/icon.icns", + "icons/icon.ico" + ], + "windows": { + "allowDowngrades": true, + "certificateThumbprint": null, + "digestAlgorithm": null, + "signCommand": null, + "timestampUrl": null, + "tsp": false, + "webviewInstallMode": { + "silent": true, + "type": "embedBootstrapper" + }, + "wix": { + "language": "zh-CN" + }, + "nsis": { + "displayLanguageSelector": true, + "installMode": "currentUser", + "installerIcon": "icons/icon.ico", + "installerHooks": "./windows/nsis/installer-hooks.nsi", + "template": "./windows/nsis/installer.nsi", + "sidebarImage": "./windows/assets/sidebarImage.bmp", + "startMenuFolder": "Simprint", + "languages": ["English", "SimpChinese"] + } + } + }, + "plugins": { + "updater": { + "endpoints": ["https://github.com/Simprint/simprint/releases/latest/download/latest.json"], + "pubkey": "", + "windows": { + "installMode": "passive" + } + }, + "deep-link": { + "desktop": [ + { + "schemes": ["simprint"] + } + ] + } + } +} diff --git a/src-tauri/tauri.conf.fixed.json b/src-tauri/tauri.conf.fixed-runtime.json similarity index 82% rename from src-tauri/tauri.conf.fixed.json rename to src-tauri/tauri.conf.fixed-runtime.json index 8bd2071..302d982 100644 --- a/src-tauri/tauri.conf.fixed.json +++ b/src-tauri/tauri.conf.fixed-runtime.json @@ -1,67 +1,72 @@ -{ - "$schema": "https://schema.tauri.app/config/2", - "productName": "Simprint", - "version": "0.1.0", - "identifier": "com.lius.simprint", - "build": { - "beforeDevCommand": "pnpm dev", - "devUrl": "http://localhost:5173", - "beforeBuildCommand": "node build.cjs", - "frontendDist": "../dist" +{ + "$schema": "https://schema.tauri.app/config/2", + "productName": "Simprint", + "version": "0.1.0", + "identifier": "com.lius.simprint", + "build": { + "beforeDevCommand": "pnpm dev", + "devUrl": "http://localhost:5173", + "beforeBuildCommand": "node build.cjs", + "frontendDist": "../dist" }, "app": { "windows": [], "security": { "csp": null }, - "withGlobalTauri": true - }, + "withGlobalTauri": true + }, "bundle": { "active": true, + "createUpdaterArtifacts": false, "targets": "nsis", "icon": [ "icons/32x32.png", "icons/128x128.png", - "icons/128x128@2x.png", - "icons/icon.icns", - "icons/icon.ico" - ], - "windows": { - "allowDowngrades": true, - "certificateThumbprint": null, - "digestAlgorithm": null, - "signCommand": null, - "timestampUrl": null, - "tsp": false, - "webviewInstallMode": { - "type": "fixedRuntime", - "path": "./webview-fixed/Microsoft.WebView2.FixedVersionRuntime.144.0.3719.93.x64/" - }, - "wix": { - "language": "zh-CN" - }, + "icons/128x128@2x.png", + "icons/icon.icns", + "icons/icon.ico" + ], + "windows": { + "allowDowngrades": true, + "certificateThumbprint": null, + "digestAlgorithm": null, + "signCommand": null, + "timestampUrl": null, + "tsp": false, + "webviewInstallMode": { + "type": "fixedRuntime", + "path": "./webview-fixed/Microsoft.WebView2.FixedVersionRuntime.151.0.4129.78.x64/" + }, + "wix": { + "language": "zh-CN" + }, "nsis": { "displayLanguageSelector": true, "installMode": "currentUser", "installerIcon": "icons/icon.ico", "installerHooks": "./windows/nsis/installer-hooks.nsi", "template": "./windows/nsis/installer.nsi", - "sidebarImage": "./windows/assets/sidebarImage.bmp", - "startMenuFolder": "Simprint", - "languages": [ - "English", - "SimpChinese" - ] - } - } - }, - "plugins": { - "deep-link": { - "desktop": [ - { - "schemes": ["simprint"] - } - ] - } - } -} + "sidebarImage": "./windows/assets/sidebarImage.bmp", + "startMenuFolder": "Simprint", + "languages": ["English", "SimpChinese"] + } + } + }, + "plugins": { + "updater": { + "endpoints": ["https://github.com/Simprint/simprint/releases/latest/download/latest.json"], + "pubkey": "", + "windows": { + "installMode": "passive" + } + }, + "deep-link": { + "desktop": [ + { + "schemes": ["simprint"] + } + ] + } + } +} diff --git a/src-tauri/tauri.conf.json b/src-tauri/tauri.conf.json index 78bd8c5..cdf8cb7 100644 --- a/src-tauri/tauri.conf.json +++ b/src-tauri/tauri.conf.json @@ -1,14 +1,14 @@ -{ - "$schema": "https://schema.tauri.app/config/2", - "productName": "Simprint", - "version": "0.1.0", - "identifier": "com.lius.simprint", - "build": { - "beforeDevCommand": "pnpm dev", - "devUrl": "http://localhost:5173", - "beforeBuildCommand": "node build.cjs", - "frontendDist": "../dist" - }, +{ + "$schema": "https://schema.tauri.app/config/2", + "productName": "Simprint", + "version": "0.1.0", + "identifier": "com.lius.simprint", + "build": { + "beforeDevCommand": "pnpm dev", + "devUrl": "http://localhost:5173", + "beforeBuildCommand": "node build.cjs", + "frontendDist": "../dist" + }, "app": { "windows": [], "security": { @@ -18,52 +18,62 @@ }, "bundle": { "active": true, + "createUpdaterArtifacts": true, "targets": "nsis", "icon": [ "icons/32x32.png", "icons/128x128.png", - "icons/128x128@2x.png", - "icons/icon.icns", - "icons/icon.ico" - ], - "windows": { - "allowDowngrades": true, - "certificateThumbprint": null, - "digestAlgorithm": null, - "signCommand": null, - "timestampUrl": null, - "tsp": false, - "webviewInstallMode": { - "silent": true, - "type": "downloadBootstrapper" - }, - "wix": { - "language": "zh-CN" - }, + "icons/128x128@2x.png", + "icons/icon.icns", + "icons/icon.ico" + ], + "windows": { + "allowDowngrades": true, + "certificateThumbprint": null, + "digestAlgorithm": null, + "signCommand": null, + "timestampUrl": null, + "tsp": false, + "webviewInstallMode": { + "silent": true, + "type": "embedBootstrapper" + }, + "wix": { + "language": "zh-CN" + }, "nsis": { "displayLanguageSelector": true, "installMode": "currentUser", "installerIcon": "icons/icon.ico", "installerHooks": "./windows/nsis/installer-hooks.nsi", "template": "./windows/nsis/installer.nsi", - "sidebarImage": "./windows/assets/sidebarImage.bmp", - "startMenuFolder": "Simprint", - "languages": [ - "English", - "SimpChinese" - ] - } - } - }, - "plugins": { - "deep-link": { - "desktop": [ - { - "schemes": [ - "simprint" - ] - } - ] - } - } + "sidebarImage": "./windows/assets/sidebarImage.bmp", + "startMenuFolder": "Simprint", + "languages": [ + "English", + "SimpChinese" + ] + } + } + }, + "plugins": { + "updater": { + "endpoints": [ + "https://github.com/Simprint/simprint/releases/latest/download/latest.json" + ], + "pubkey": "", + "windows": { + "installMode": "passive" + } + }, + "deep-link": { + "desktop": [ + { + "schemes": [ + "simprint" + ] + } + ] + } + } } diff --git a/src-tauri/windows/nsis/installer-hooks.nsi b/src-tauri/windows/nsis/installer-hooks.nsi index f4f5504..658e735 100644 --- a/src-tauri/windows/nsis/installer-hooks.nsi +++ b/src-tauri/windows/nsis/installer-hooks.nsi @@ -104,10 +104,6 @@ FunctionEnd Push $RuntimePathValue Call un.RemoveRuntimePathIfSafe - ReadRegStr $RuntimePathValue HKCU "Software\${PRODUCTNAME}\RuntimePaths" "UpdaterDir" - Push $RuntimePathValue - Call un.RemoveRuntimePathIfSafe - ReadRegStr $RuntimePathValue HKCU "Software\${PRODUCTNAME}\RuntimePaths" "ConfigDir" Push $RuntimePathValue Call un.RemoveRuntimePathIfSafe diff --git a/src-tauri/windows/updater.manifest b/src-tauri/windows/updater.manifest deleted file mode 100644 index 921435e..0000000 --- a/src-tauri/windows/updater.manifest +++ /dev/null @@ -1,22 +0,0 @@ - - - - - - - - - - - - - - - - diff --git a/src-tauri/windows/updater.rc b/src-tauri/windows/updater.rc deleted file mode 100644 index d1e7c98..0000000 --- a/src-tauri/windows/updater.rc +++ /dev/null @@ -1,2 +0,0 @@ -1 RT_MANIFEST updater.manifest - diff --git a/src/lib/app-updater.ts b/src/lib/app-updater.ts new file mode 100644 index 0000000..b543a82 --- /dev/null +++ b/src/lib/app-updater.ts @@ -0,0 +1,62 @@ +import { check, type DownloadEvent, type Update } from '@tauri-apps/plugin-updater'; +import { relaunch } from '@tauri-apps/plugin-process'; + +export const APP_UPDATE_AVAILABLE_EVENT = 'simprint:app-update-available'; +export const APP_UPDATE_DIALOG_EVENT = 'simprint:open-app-update-dialog'; + +let cachedUpdate: Update | null | undefined; +let pendingCheck: Promise | null = null; + +function publishAvailableUpdate(update: Update): void { + window.dispatchEvent( + new CustomEvent(APP_UPDATE_AVAILABLE_EVENT, { + detail: update, + }) + ); +} + +export function getAvailableAppUpdate(): Update | null { + return cachedUpdate ?? null; +} + +export async function checkForAppUpdate(options?: { force?: boolean }): Promise { + if (cachedUpdate) { + return cachedUpdate; + } + if (cachedUpdate === null && !options?.force) { + return null; + } + if (pendingCheck) { + return pendingCheck; + } + + pendingCheck = check({ timeout: 30_000 }) + .then((update) => { + cachedUpdate = update; + if (update) { + publishAvailableUpdate(update); + } + return update; + }) + .finally(() => { + pendingCheck = null; + }); + + return pendingCheck; +} + +export function openAppUpdateDialog(): void { + window.dispatchEvent(new Event(APP_UPDATE_DIALOG_EVENT)); +} + +export async function installAvailableAppUpdate( + onEvent: (event: DownloadEvent) => void +): Promise { + const update = cachedUpdate; + if (!update) { + throw new Error('No application update is available'); + } + + await update.downloadAndInstall(onEvent); + await relaunch(); +} From ce1e29ecd912cd5cca4547ed57a9b09754d29961 Mon Sep 17 00:00:00 2001 From: lius-new Date: Wed, 12 Aug 2026 21:26:42 +0800 Subject: [PATCH 60/60] fix(updater): preserve fixed runtime update channel Publish signed fixed-runtime updater artifacts through latest-fixed.json while keeping latest.json dedicated to embedBootstrapper. Validate build-mode configuration and resolve manifest asset IDs against release metadata before publishing. --- .github/workflows/tauri-release-win.yml | 132 +++++++++++++----------- deploy/verify-updater-manifest.mjs | 72 +++++++++++++ src-tauri/Cargo.toml | 1 + src-tauri/build.rs | 117 ++++++++++++++++++++- src-tauri/tauri.conf.fixed-runtime.json | 6 +- 5 files changed, 261 insertions(+), 67 deletions(-) create mode 100644 deploy/verify-updater-manifest.mjs diff --git a/.github/workflows/tauri-release-win.yml b/.github/workflows/tauri-release-win.yml index dbc0f28..da09a68 100644 --- a/.github/workflows/tauri-release-win.yml +++ b/.github/workflows/tauri-release-win.yml @@ -64,20 +64,20 @@ jobs: $notes | Out-File -FilePath $env:GITHUB_OUTPUT -Append -Encoding utf8 "SIMPRINT_RELEASE_NOTES" | Out-File -FilePath $env:GITHUB_OUTPUT -Append -Encoding utf8 - - name: Prepare x64 embedBootstrapper config + - name: Prepare x64 fixed-runtime config env: TAURI_UPDATER_PUBLIC_KEY: ${{ secrets.TAURI_UPDATER_PUBLIC_KEY }} run: | - node deploy/prepare-tauri-config.mjs embedBootstrapper x86_64-pc-windows-msvc + node deploy/prepare-tauri-config.mjs fixed-runtime x86_64-pc-windows-msvc node deploy/prepare-version.mjs - - name: Publish x64 embedBootstrapper installer and create release + - name: Publish x64 fixed-runtime installer and create release id: release uses: tauri-apps/tauri-action@v1 env: GITHUB_TOKEN: ${{ secrets.GITHUB_TOKEN }} ENV_NAME: production - SIMPRINT_WEBVIEW_MODE: embedBootstrapper + SIMPRINT_WEBVIEW_MODE: fixed-runtime TAURI_SIGNING_PRIVATE_KEY: ${{ secrets.TAURI_PRIVATE_KEY }} TAURI_SIGNING_PRIVATE_KEY_PASSWORD: ${{ secrets.TAURI_KEY_PASSWORD }} with: @@ -90,22 +90,22 @@ jobs: uploadUpdaterJson: true uploadUpdaterSignatures: true updaterJsonPreferNsis: true - releaseAssetNamePattern: Simprint_[version]_[arch]_embedBootstrapper[setup].[ext] + releaseAssetNamePattern: Simprint_[version]_[arch]_fixed-runtime[setup].[ext] args: --features production --target x86_64-pc-windows-msvc - - name: Prepare ARM64 embedBootstrapper config + - name: Prepare ARM64 fixed-runtime config env: TAURI_UPDATER_PUBLIC_KEY: ${{ secrets.TAURI_UPDATER_PUBLIC_KEY }} run: | - node deploy/prepare-tauri-config.mjs embedBootstrapper aarch64-pc-windows-msvc + node deploy/prepare-tauri-config.mjs fixed-runtime aarch64-pc-windows-msvc node deploy/prepare-version.mjs - - name: Publish ARM64 embedBootstrapper installer + - name: Publish ARM64 fixed-runtime installer uses: tauri-apps/tauri-action@v1 env: GITHUB_TOKEN: ${{ secrets.GITHUB_TOKEN }} ENV_NAME: production - SIMPRINT_WEBVIEW_MODE: embedBootstrapper + SIMPRINT_WEBVIEW_MODE: fixed-runtime TAURI_SIGNING_PRIVATE_KEY: ${{ secrets.TAURI_PRIVATE_KEY }} TAURI_SIGNING_PRIVATE_KEY_PASSWORD: ${{ secrets.TAURI_KEY_PASSWORD }} with: @@ -115,22 +115,22 @@ jobs: uploadUpdaterJson: true uploadUpdaterSignatures: true updaterJsonPreferNsis: true - releaseAssetNamePattern: Simprint_[version]_[arch]_embedBootstrapper[setup].[ext] + releaseAssetNamePattern: Simprint_[version]_[arch]_fixed-runtime[setup].[ext] args: --features production --target aarch64-pc-windows-msvc - - name: Prepare x86 embedBootstrapper config + - name: Prepare x86 fixed-runtime config env: TAURI_UPDATER_PUBLIC_KEY: ${{ secrets.TAURI_UPDATER_PUBLIC_KEY }} run: | - node deploy/prepare-tauri-config.mjs embedBootstrapper i686-pc-windows-msvc + node deploy/prepare-tauri-config.mjs fixed-runtime i686-pc-windows-msvc node deploy/prepare-version.mjs - - name: Publish x86 embedBootstrapper installer + - name: Publish x86 fixed-runtime installer uses: tauri-apps/tauri-action@v1 env: GITHUB_TOKEN: ${{ secrets.GITHUB_TOKEN }} ENV_NAME: production - SIMPRINT_WEBVIEW_MODE: embedBootstrapper + SIMPRINT_WEBVIEW_MODE: fixed-runtime TAURI_SIGNING_PRIVATE_KEY: ${{ secrets.TAURI_PRIVATE_KEY }} TAURI_SIGNING_PRIVATE_KEY_PASSWORD: ${{ secrets.TAURI_KEY_PASSWORD }} with: @@ -140,108 +140,118 @@ jobs: uploadUpdaterJson: true uploadUpdaterSignatures: true updaterJsonPreferNsis: true - releaseAssetNamePattern: Simprint_[version]_[arch]_embedBootstrapper[setup].[ext] + releaseAssetNamePattern: Simprint_[version]_[arch]_fixed-runtime[setup].[ext] args: --features production --target i686-pc-windows-msvc - - name: Verify latest.json contains all embedBootstrapper architectures + - name: Preserve fixed-runtime updater manifest shell: powershell env: GH_TOKEN: ${{ secrets.GITHUB_TOKEN }} run: | - $manifestDir = Join-Path $env:RUNNER_TEMP "simprint-updater-manifest" + $manifestDir = Join-Path $env:RUNNER_TEMP "simprint-fixed-updater-manifest" New-Item -ItemType Directory -Path $manifestDir -Force | Out-Null gh release download "${{ github.ref_name }}" --pattern "latest.json" --dir $manifestDir - $manifest = Get-Content (Join-Path $manifestDir "latest.json") -Raw | ConvertFrom-Json - $requiredPlatforms = @("windows-x86_64", "windows-aarch64", "windows-i686") - - foreach ($platform in $requiredPlatforms) { - $entry = $manifest.platforms.PSObject.Properties[$platform] - if ($null -eq $entry) { - throw "latest.json is missing required updater platform '$platform'" - } - if ($entry.Value.url -notlike "*embedBootstrapper*") { - throw "latest.json platform '$platform' must point to an embedBootstrapper asset" - } - if ([string]::IsNullOrWhiteSpace($entry.Value.signature)) { - throw "latest.json platform '$platform' is missing its signature" - } - } - - $fixedRuntimeEntries = @( - $manifest.platforms.PSObject.Properties | - Where-Object { $_.Value.url -like "*fixed-runtime*" } - ) - if ($fixedRuntimeEntries.Count -ne 0) { - throw "latest.json must not contain fixed-runtime updater assets" - } + $latestPath = Join-Path $manifestDir "latest.json" + $fixedPath = Join-Path $manifestDir "latest-fixed.json" + $releaseMetadataPath = Join-Path $manifestDir "release.json" + gh api "repos/${{ github.repository }}/releases/${{ steps.release.outputs.releaseId }}" | + Out-File -FilePath $releaseMetadataPath -Encoding utf8 + node deploy/verify-updater-manifest.mjs $latestPath fixed-runtime $releaseMetadataPath + Copy-Item $latestPath $fixedPath + gh release upload "${{ github.ref_name }}" $fixedPath --clobber - - name: Prepare x64 fixed-runtime config + - name: Prepare x64 embedBootstrapper config env: TAURI_UPDATER_PUBLIC_KEY: ${{ secrets.TAURI_UPDATER_PUBLIC_KEY }} run: | - node deploy/prepare-tauri-config.mjs fixed-runtime x86_64-pc-windows-msvc + node deploy/prepare-tauri-config.mjs embedBootstrapper x86_64-pc-windows-msvc node deploy/prepare-version.mjs - - name: Publish x64 fixed-runtime installer + - name: Publish x64 embedBootstrapper installer uses: tauri-apps/tauri-action@v1 env: GITHUB_TOKEN: ${{ secrets.GITHUB_TOKEN }} ENV_NAME: production - SIMPRINT_WEBVIEW_MODE: fixed-runtime + SIMPRINT_WEBVIEW_MODE: embedBootstrapper + TAURI_SIGNING_PRIVATE_KEY: ${{ secrets.TAURI_PRIVATE_KEY }} + TAURI_SIGNING_PRIVATE_KEY_PASSWORD: ${{ secrets.TAURI_KEY_PASSWORD }} with: projectPath: . releaseId: ${{ steps.release.outputs.releaseId }} tagName: ${{ github.ref_name }} - uploadUpdaterJson: false - uploadUpdaterSignatures: false - releaseAssetNamePattern: Simprint_[version]_[arch]_fixed-runtime[setup].[ext] + uploadUpdaterJson: true + uploadUpdaterSignatures: true + updaterJsonPreferNsis: true + releaseAssetNamePattern: Simprint_[version]_[arch]_embedBootstrapper[setup].[ext] args: --features production --target x86_64-pc-windows-msvc - - name: Prepare ARM64 fixed-runtime config + - name: Prepare ARM64 embedBootstrapper config env: TAURI_UPDATER_PUBLIC_KEY: ${{ secrets.TAURI_UPDATER_PUBLIC_KEY }} run: | - node deploy/prepare-tauri-config.mjs fixed-runtime aarch64-pc-windows-msvc + node deploy/prepare-tauri-config.mjs embedBootstrapper aarch64-pc-windows-msvc node deploy/prepare-version.mjs - - name: Publish ARM64 fixed-runtime installer + - name: Publish ARM64 embedBootstrapper installer uses: tauri-apps/tauri-action@v1 env: GITHUB_TOKEN: ${{ secrets.GITHUB_TOKEN }} ENV_NAME: production - SIMPRINT_WEBVIEW_MODE: fixed-runtime + SIMPRINT_WEBVIEW_MODE: embedBootstrapper + TAURI_SIGNING_PRIVATE_KEY: ${{ secrets.TAURI_PRIVATE_KEY }} + TAURI_SIGNING_PRIVATE_KEY_PASSWORD: ${{ secrets.TAURI_KEY_PASSWORD }} with: projectPath: . releaseId: ${{ steps.release.outputs.releaseId }} tagName: ${{ github.ref_name }} - uploadUpdaterJson: false - uploadUpdaterSignatures: false - releaseAssetNamePattern: Simprint_[version]_[arch]_fixed-runtime[setup].[ext] + uploadUpdaterJson: true + uploadUpdaterSignatures: true + updaterJsonPreferNsis: true + releaseAssetNamePattern: Simprint_[version]_[arch]_embedBootstrapper[setup].[ext] args: --features production --target aarch64-pc-windows-msvc - - name: Prepare x86 fixed-runtime config + - name: Prepare x86 embedBootstrapper config env: TAURI_UPDATER_PUBLIC_KEY: ${{ secrets.TAURI_UPDATER_PUBLIC_KEY }} run: | - node deploy/prepare-tauri-config.mjs fixed-runtime i686-pc-windows-msvc + node deploy/prepare-tauri-config.mjs embedBootstrapper i686-pc-windows-msvc node deploy/prepare-version.mjs - - name: Publish x86 fixed-runtime installer + - name: Publish x86 embedBootstrapper installer uses: tauri-apps/tauri-action@v1 env: GITHUB_TOKEN: ${{ secrets.GITHUB_TOKEN }} ENV_NAME: production - SIMPRINT_WEBVIEW_MODE: fixed-runtime + SIMPRINT_WEBVIEW_MODE: embedBootstrapper + TAURI_SIGNING_PRIVATE_KEY: ${{ secrets.TAURI_PRIVATE_KEY }} + TAURI_SIGNING_PRIVATE_KEY_PASSWORD: ${{ secrets.TAURI_KEY_PASSWORD }} with: projectPath: . releaseId: ${{ steps.release.outputs.releaseId }} tagName: ${{ github.ref_name }} - uploadUpdaterJson: false - uploadUpdaterSignatures: false - releaseAssetNamePattern: Simprint_[version]_[arch]_fixed-runtime[setup].[ext] + uploadUpdaterJson: true + uploadUpdaterSignatures: true + updaterJsonPreferNsis: true + releaseAssetNamePattern: Simprint_[version]_[arch]_embedBootstrapper[setup].[ext] args: --features production --target i686-pc-windows-msvc + - name: Verify both updater manifests + shell: powershell + env: + GH_TOKEN: ${{ secrets.GITHUB_TOKEN }} + run: | + $manifestDir = Join-Path $env:RUNNER_TEMP "simprint-final-updater-manifests" + New-Item -ItemType Directory -Path $manifestDir -Force | Out-Null + gh release download "${{ github.ref_name }}" --pattern "latest.json" --dir $manifestDir + gh release download "${{ github.ref_name }}" --pattern "latest-fixed.json" --dir $manifestDir + + $releaseMetadataPath = Join-Path $manifestDir "release.json" + gh api "repos/${{ github.repository }}/releases/${{ steps.release.outputs.releaseId }}" | + Out-File -FilePath $releaseMetadataPath -Encoding utf8 + node deploy/verify-updater-manifest.mjs (Join-Path $manifestDir "latest.json") embedBootstrapper $releaseMetadataPath + node deploy/verify-updater-manifest.mjs (Join-Path $manifestDir "latest-fixed.json") fixed-runtime $releaseMetadataPath + - name: Publish completed GitHub release shell: powershell env: diff --git a/deploy/verify-updater-manifest.mjs b/deploy/verify-updater-manifest.mjs new file mode 100644 index 0000000..b84d642 --- /dev/null +++ b/deploy/verify-updater-manifest.mjs @@ -0,0 +1,72 @@ +import fs from 'node:fs'; + +const EXPECTED_ASSET_TOKEN = { + embedBootstrapper: 'embedBootstrapper', + 'fixed-runtime': 'fixed-runtime', +}; + +const REQUIRED_PLATFORMS = ['windows-x86_64', 'windows-aarch64', 'windows-i686']; +const [manifestPath, mode, releaseMetadataPath] = process.argv.slice(2); +const expectedAssetToken = EXPECTED_ASSET_TOKEN[mode]; + +if (!manifestPath || !expectedAssetToken || !releaseMetadataPath) { + throw new Error( + 'Usage: node deploy/verify-updater-manifest.mjs ' + ); +} + +function readJson(path) { + return JSON.parse(fs.readFileSync(path, 'utf8').replace(/^\uFEFF/, '')); +} + +const manifest = readJson(manifestPath); +const release = readJson(releaseMetadataPath); +const platforms = manifest.platforms ?? {}; +const assets = release.assets ?? []; + +function resolveAsset(url) { + const directMatch = assets.find( + (asset) => asset.url === url || asset.browser_download_url === url + ); + if (directMatch) { + return directMatch; + } + + const assetId = /\/releases\/assets\/(\d+)$/.exec(new URL(url).pathname)?.[1]; + return assetId ? assets.find((asset) => String(asset.id) === assetId) : undefined; +} + +function verifyEntry(platform, entry) { + if (!entry.signature?.trim()) { + throw new Error(`${manifestPath} platform "${platform}" is missing its signature`); + } + + const asset = entry.url ? resolveAsset(entry.url) : undefined; + if (!asset) { + throw new Error(`${manifestPath} platform "${platform}" does not reference a release asset`); + } + if (!asset.name.includes(expectedAssetToken)) { + throw new Error( + `${manifestPath} platform "${platform}" points to another mode's asset: "${asset.name}"` + ); + } +} + +for (const platform of REQUIRED_PLATFORMS) { + const entry = platforms[platform]; + + if (!entry) { + throw new Error(`${manifestPath} is missing required updater platform "${platform}"`); + } + verifyEntry(platform, entry); +} + +for (const [platform, entry] of Object.entries(platforms)) { + if (platform.startsWith('windows-') && !REQUIRED_PLATFORMS.includes(platform)) { + verifyEntry(platform, entry); + } +} + +console.log( + `[verify-updater-manifest] ${manifestPath} contains ${mode} updates for x64, ARM64 and x86` +); diff --git a/src-tauri/Cargo.toml b/src-tauri/Cargo.toml index 8a5d480..054a04e 100644 --- a/src-tauri/Cargo.toml +++ b/src-tauri/Cargo.toml @@ -21,6 +21,7 @@ reqwest = { version = "0.12.22", features = ["blocking", "json"] } zip = "2.2.2" config = "0.15.15" serde = { version = "1", features = ["derive"] } +serde_json = "1" [dependencies] tokio = { version = "1", features = [ diff --git a/src-tauri/build.rs b/src-tauri/build.rs index 93f5e8a..73e6cff 100644 --- a/src-tauri/build.rs +++ b/src-tauri/build.rs @@ -11,11 +11,17 @@ use serde::Deserialize; fn main() { println!("cargo:rerun-if-env-changed=SIMPRINT_WEBVIEW_MODE"); + println!("cargo:rerun-if-changed=tauri.conf.json"); + + let webview_mode = + env::var("SIMPRINT_WEBVIEW_MODE").unwrap_or_else(|_| "embedBootstrapper".to_string()); + validate_selected_tauri_config(&webview_mode); + println!("cargo:rustc-env=SIMPRINT_WEBVIEW_MODE={webview_mode}"); // 1. 仅在生产环境下下载 / 准备 webview-fixed 目录中的资源 #[cfg(feature = "production")] { - if env::var("SIMPRINT_WEBVIEW_MODE").as_deref() == Ok("fixed-runtime") { + if webview_mode == "fixed-runtime" { webview_assets::ensure_webview_fixed_downloaded(); } } @@ -27,6 +33,105 @@ fn main() { frontend_env::prepare_frontend_build_env(); } +#[derive(Deserialize)] +#[serde(rename_all = "camelCase")] +struct SelectedTauriConfig { + bundle: SelectedBundleConfig, + plugins: SelectedPluginConfig, +} + +#[derive(Deserialize)] +#[serde(rename_all = "camelCase")] +struct SelectedBundleConfig { + create_updater_artifacts: bool, + windows: SelectedWindowsConfig, +} + +#[derive(Deserialize)] +#[serde(rename_all = "camelCase")] +struct SelectedWindowsConfig { + webview_install_mode: SelectedWebviewInstallMode, +} + +#[derive(Deserialize)] +struct SelectedWebviewInstallMode { + #[serde(rename = "type")] + kind: String, + path: Option, +} + +#[derive(Deserialize)] +struct SelectedPluginConfig { + updater: SelectedUpdaterConfig, +} + +#[derive(Deserialize)] +struct SelectedUpdaterConfig { + endpoints: Vec, +} + +fn validate_selected_tauri_config(mode: &str) { + let raw = fs::read_to_string("tauri.conf.json") + .unwrap_or_else(|err| panic!("failed to read selected Tauri config: {err}")); + let config: SelectedTauriConfig = serde_json::from_str(&raw) + .unwrap_or_else(|err| panic!("failed to parse selected Tauri config: {err}")); + + let (expected_install_mode, expected_manifest) = match mode { + "embedBootstrapper" => ("embedBootstrapper", "latest.json"), + "fixed-runtime" => ("fixedRuntime", "latest-fixed.json"), + other => panic!( + "unsupported SIMPRINT_WEBVIEW_MODE '{other}'; expected embedBootstrapper or fixed-runtime" + ), + }; + + assert_eq!( + config.bundle.windows.webview_install_mode.kind, expected_install_mode, + "selected Tauri config does not match SIMPRINT_WEBVIEW_MODE '{mode}'" + ); + assert!( + config.bundle.create_updater_artifacts, + "selected Tauri config must create signed updater artifacts" + ); + + let endpoint = config + .plugins + .updater + .endpoints + .first() + .unwrap_or_else(|| panic!("selected Tauri config is missing an updater endpoint")); + assert!( + endpoint.ends_with(expected_manifest), + "updater endpoint for mode '{mode}' must end with '{expected_manifest}'" + ); + + if mode == "fixed-runtime" { + let expected_runtime_directory = fixed_runtime_directory_for_target_arch(); + let configured_path = config + .bundle + .windows + .webview_install_mode + .path + .as_deref() + .unwrap_or_else(|| panic!("fixed-runtime config is missing its WebView path")); + let normalized_path = configured_path.replace('\\', "/"); + + assert!( + normalized_path.trim_end_matches('/').ends_with(expected_runtime_directory), + "fixed-runtime path '{configured_path}' does not match target architecture directory '{expected_runtime_directory}'" + ); + } +} + +fn fixed_runtime_directory_for_target_arch() -> &'static str { + match env::var("CARGO_CFG_TARGET_ARCH").as_deref() { + Ok("x86_64") => "Microsoft.WebView2.FixedVersionRuntime.151.0.4129.78.x64", + Ok("aarch64") => "Microsoft.WebView2.FixedVersionRuntime.151.0.4129.78.arm64", + Ok("x86") => "Microsoft.WebView2.FixedVersionRuntime.151.0.4129.78.x86", + Ok(arch) => panic!("unsupported Windows target architecture '{arch}' for fixed-runtime"), + Err(err) => panic!("CARGO_CFG_TARGET_ARCH is unavailable: {err}"), + } +} + // ============================================================================= // 构建环境推导(供多个子模块复用) // ============================================================================= @@ -86,6 +191,10 @@ mod webview_assets { /// - 若当前目标架构的运行时目录已存在,则直接跳过 /// - 否则从该架构的 URL 下载 zip,并解压到共享的 `webview-fixed/` pub fn ensure_webview_fixed_downloaded() { + println!( + "cargo:rerun-if-changed={}", + super::current_config_file_name() + ); let target_dir = Path::new("webview-fixed"); // 优先尝试从当前环境的配置文件中读取下载地址 @@ -153,15 +262,15 @@ mod webview_assets { match env::var("CARGO_CFG_TARGET_ARCH").ok()?.as_str() { "x86_64" => Some(TargetWebview { download_url: webview_config.x86_64_download_url, - runtime_directory: "Microsoft.WebView2.FixedVersionRuntime.151.0.4129.78.x64", + runtime_directory: super::fixed_runtime_directory_for_target_arch(), }), "aarch64" => Some(TargetWebview { download_url: webview_config.aarch64_download_url, - runtime_directory: "Microsoft.WebView2.FixedVersionRuntime.151.0.4129.78.arm64", + runtime_directory: super::fixed_runtime_directory_for_target_arch(), }), "x86" => Some(TargetWebview { download_url: webview_config.x86_download_url, - runtime_directory: "Microsoft.WebView2.FixedVersionRuntime.151.0.4129.78.x86", + runtime_directory: super::fixed_runtime_directory_for_target_arch(), }), arch => { eprintln!("[BUILD ERROR] Unsupported Windows target architecture: {arch}"); diff --git a/src-tauri/tauri.conf.fixed-runtime.json b/src-tauri/tauri.conf.fixed-runtime.json index 302d982..f52ddce 100644 --- a/src-tauri/tauri.conf.fixed-runtime.json +++ b/src-tauri/tauri.conf.fixed-runtime.json @@ -18,7 +18,7 @@ }, "bundle": { "active": true, - "createUpdaterArtifacts": false, + "createUpdaterArtifacts": true, "targets": "nsis", "icon": [ "icons/32x32.png", @@ -55,7 +55,9 @@ }, "plugins": { "updater": { - "endpoints": ["https://github.com/Simprint/simprint/releases/latest/download/latest.json"], + "endpoints": [ + "https://github.com/Simprint/simprint/releases/latest/download/latest-fixed.json" + ], "pubkey": "", "windows": { "installMode": "passive"